init
This commit is contained in:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
.env
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
# React Router
|
||||
.react-router
|
||||
build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.idea
|
||||
9
.run/Install Deps.run.xml
Normal file
9
.run/Install Deps.run.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="Install Deps" type="js.build_tools.npm">
|
||||
<package-json value="$PROJECT_DIR$/package.json" />
|
||||
<command value="install" />
|
||||
<node-interpreter value="$APPLICATION_CONFIG_DIR$/node/versions/20.19.5/node" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
18
index.html
Normal file
18
index.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
|
||||
/>
|
||||
<title>khata - Aetoskia</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4355
package-lock.json
generated
Normal file
4355
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "aetoskia-khata-app",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apidevtools/swagger-parser": "^12.1.0",
|
||||
"@emotion/react": "latest",
|
||||
"@emotion/styled": "latest",
|
||||
"@mui/icons-material": "latest",
|
||||
"@mui/material": "latest",
|
||||
"@mui/x-data-grid": "^8.28.2",
|
||||
"@tanstack/react-query": "^5.96.1",
|
||||
"axios": "latest",
|
||||
"buffer": "^6.0.3",
|
||||
"markdown-to-jsx": "latest",
|
||||
"marked": "latest",
|
||||
"process": "^0.11.10",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"react-markdown": "latest",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"remark-gfm": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"vite": "latest"
|
||||
}
|
||||
}
|
||||
138
react-auth/src/AuthPage.tsx
Normal file
138
react-auth/src/AuthPage.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import * as React from 'react';
|
||||
import { Box, TextField, Button, Typography, IconButton, CircularProgress, Link } from '@mui/material';
|
||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||
|
||||
export type AuthMode = "login" | "register";
|
||||
|
||||
export interface AuthPageProps {
|
||||
mode: AuthMode;
|
||||
onBack(): void;
|
||||
onSwitchMode(): void;
|
||||
login(username: string, password: string): Promise<void>;
|
||||
register(username: string, password: string): Promise<void>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
currentUser: any;
|
||||
}
|
||||
|
||||
export function AuthPage({
|
||||
mode,
|
||||
onBack,
|
||||
onSwitchMode,
|
||||
login,
|
||||
register,
|
||||
loading,
|
||||
error,
|
||||
currentUser,
|
||||
}: AuthPageProps) {
|
||||
|
||||
const [username, setUsername] = React.useState('');
|
||||
const [password, setPassword] = React.useState('');
|
||||
|
||||
const isLogin = mode === "login";
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (isLogin) {
|
||||
await login(username, password);
|
||||
} else {
|
||||
await register(username, password);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ Auto-return if already logged in
|
||||
React.useEffect(() => {
|
||||
if (currentUser) onBack();
|
||||
}, [currentUser, onBack]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
maxWidth: 400,
|
||||
mx: 'auto',
|
||||
mt: 8,
|
||||
p: 4,
|
||||
borderRadius: 3,
|
||||
boxShadow: 3,
|
||||
bgcolor: 'background.paper',
|
||||
}}
|
||||
>
|
||||
<IconButton onClick={onBack} sx={{ mb: 2 }}>
|
||||
<ArrowBackRoundedIcon />
|
||||
</IconButton>
|
||||
|
||||
<Typography variant="h4" fontWeight="bold" gutterBottom>
|
||||
{isLogin ? "Sign In" : "Create Account"}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
{isLogin
|
||||
? "Please log in to continue"
|
||||
: "Create an account to get started"}
|
||||
</Typography>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
type="username"
|
||||
margin="normal"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
type="password"
|
||||
margin="normal"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Typography color="error" variant="body2" sx={{ mt: 1 }}>
|
||||
{error}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ mt: 3 }}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<CircularProgress size={24} color="inherit" />
|
||||
) : isLogin ? (
|
||||
"Login"
|
||||
) : (
|
||||
"Register"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
align="center"
|
||||
sx={{ mt: 3 }}
|
||||
>
|
||||
{isLogin ? "Don’t have an account?" : "Already have an account?"}{' '}
|
||||
<Link
|
||||
component="button"
|
||||
underline="hover"
|
||||
color="primary"
|
||||
onClick={onSwitchMode}
|
||||
sx={{ fontWeight: 500 }}
|
||||
>
|
||||
{isLogin ? "Register" : "Login"}
|
||||
</Link>
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
32
react-auth/src/authClient.ts
Normal file
32
react-auth/src/authClient.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createApiClient } from "./axios";
|
||||
import { tokenStore } from "./token";
|
||||
|
||||
// @ts-ignore
|
||||
const authApi = createApiClient(import.meta.env.VITE_AUTH_BASE_URL);
|
||||
|
||||
export const authClient = {
|
||||
async login(username: string, password: string) {
|
||||
const res = await authApi.post("/login", { username, password });
|
||||
const { access_token } = res.data;
|
||||
|
||||
if (!access_token) {
|
||||
throw new Error("No access token returned");
|
||||
}
|
||||
|
||||
tokenStore.set(access_token);
|
||||
return this.getIdentity();
|
||||
},
|
||||
|
||||
logout() {
|
||||
tokenStore.clear();
|
||||
},
|
||||
|
||||
async getIdentity() {
|
||||
const res = await authApi.get("/me");
|
||||
return res.data;
|
||||
},
|
||||
|
||||
isAuthenticated() {
|
||||
return !!tokenStore.get();
|
||||
},
|
||||
};
|
||||
38
react-auth/src/axios.ts
Normal file
38
react-auth/src/axios.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import { tokenStore } from "./token";
|
||||
|
||||
export function attachAuthInterceptors(client: AxiosInstance) {
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = tokenStore.get();
|
||||
if (token) {
|
||||
if (!config.headers) {
|
||||
(config as any).headers = {};
|
||||
}
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
client.interceptors.response.use(
|
||||
(res) => res,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
tokenStore.clear();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for app APIs that need auth
|
||||
*/
|
||||
export function createApiClient(baseURL: string): AxiosInstance {
|
||||
const client = axios.create({
|
||||
baseURL,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
attachAuthInterceptors(client);
|
||||
return client;
|
||||
}
|
||||
97
react-auth/src/contexts.tsx
Normal file
97
react-auth/src/contexts.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
import { tokenStore } from "./token";
|
||||
import { createApiClient } from "./axios";
|
||||
import { AuthUser } from "./models";
|
||||
|
||||
interface AuthContextModel {
|
||||
currentUser: AuthUser | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
login(username: string, password: string): Promise<void>;
|
||||
register(username: string, password: string): Promise<void>;
|
||||
logout(): void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextModel | undefined>(undefined);
|
||||
|
||||
export function AuthProvider({
|
||||
children,
|
||||
authBaseUrl,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
authBaseUrl: string;
|
||||
}) {
|
||||
const [currentUser, setCurrentUser] = useState<AuthUser | null>(null);
|
||||
const [token, setToken] = useState<string | null>(tokenStore.get());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const auth = createApiClient(authBaseUrl);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await auth.post("/login", { username, password });
|
||||
const { access_token, user } = res.data;
|
||||
|
||||
tokenStore.set(access_token);
|
||||
setToken(access_token);
|
||||
setCurrentUser(user);
|
||||
} catch (e: any) {
|
||||
setError(e.response?.data?.detail ?? "Login failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (username: string, password: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
await auth.post("/register", { username, password });
|
||||
await login(username, password);
|
||||
} catch (e: any) {
|
||||
setError(e.response?.data?.detail ?? "Registration failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
tokenStore.clear();
|
||||
setToken(null);
|
||||
setCurrentUser(null);
|
||||
};
|
||||
|
||||
const fetchCurrentUser = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const me = await auth.get("/me");
|
||||
setCurrentUser({ ...me.data });
|
||||
} catch {
|
||||
logout();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCurrentUser();
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{ currentUser, token, loading, error, login, logout, register }}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextModel {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
|
||||
return ctx;
|
||||
}
|
||||
6
react-auth/src/index.ts
Normal file
6
react-auth/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { AuthProvider, useAuth } from "./contexts";
|
||||
export { createApiClient } from "./axios";
|
||||
export { AuthPage } from "./AuthPage";
|
||||
export type { AuthUser } from "./models";
|
||||
export type { AuthMode } from "./AuthPage";
|
||||
export { tokenStore } from "./token"
|
||||
11
react-auth/src/models.ts
Normal file
11
react-auth/src/models.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface AuthUser {
|
||||
// meta fields
|
||||
_id?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
// model fields
|
||||
username: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
0
react-auth/src/props.ts
Normal file
0
react-auth/src/props.ts
Normal file
15
react-auth/src/token.ts
Normal file
15
react-auth/src/token.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
const TOKEN_KEY = "token";
|
||||
|
||||
export const tokenStore = {
|
||||
get(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
},
|
||||
|
||||
set(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
},
|
||||
|
||||
clear() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
},
|
||||
};
|
||||
160
react-openapi/Admin.tsx
Normal file
160
react-openapi/Admin.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import * as React from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { AuthProvider, useAuth, AuthPage } from "../react-auth/src";
|
||||
import { UploadProvider } from "./providers/UploadProvider";
|
||||
import AdminLayout from "./components/AdminLayout";
|
||||
import ResourceView from "./components/ResourceView";
|
||||
import { getAppConfig } from "./config";
|
||||
import { initializeApiClients } from "./api/client";
|
||||
import { AppConfig } from "./types/config";
|
||||
import { Box, Typography, Paper, CircularProgress } from "@mui/material";
|
||||
import AppTheme from "./shared-theme/AppTheme";
|
||||
import {
|
||||
BrowserRouter,
|
||||
Routes,
|
||||
Route,
|
||||
useNavigate,
|
||||
useParams,
|
||||
Navigate,
|
||||
} from "react-router-dom";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
// Create a context for the app config
|
||||
export const ConfigContext = React.createContext<AppConfig | null>(null);
|
||||
|
||||
function Dashboard() {
|
||||
const config = React.useContext(ConfigContext);
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Welcome to the Admin Panel
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ color: 'text.secondary' }}>
|
||||
Select a resource from the sidebar to manage data.
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))",
|
||||
gap: 3,
|
||||
mt: 4,
|
||||
}}
|
||||
>
|
||||
{config?.resources.map((res) => (
|
||||
<Paper
|
||||
key={res.name}
|
||||
sx={{
|
||||
p: 3,
|
||||
textAlign: "center",
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.2s',
|
||||
'&:hover': { transform: 'translateY(-4px)', boxShadow: 4 }
|
||||
}}
|
||||
onClick={() => navigate(`/${res.name}`)}
|
||||
>
|
||||
<Typography variant="h6" color="primary">{res.pluralLabel}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">Manage {res.pluralLabel.toLowerCase()}</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
import ProfileView from "./components/ProfileView";
|
||||
|
||||
function AdminApp() {
|
||||
const { currentUser, login, logout, loading, error } = useAuth();
|
||||
const config = React.useContext(ConfigContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!currentUser) {
|
||||
return (
|
||||
<AuthPage
|
||||
mode="login"
|
||||
login={login}
|
||||
register={async () => {}} // Disable registration for Admin
|
||||
loading={loading}
|
||||
error={error}
|
||||
onSwitchMode={() => {}}
|
||||
onBack={() => {}}
|
||||
currentUser={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
username={currentUser.username}
|
||||
onLogout={logout}
|
||||
onSelectResource={(name) => navigate(`/${name}`)}
|
||||
resources={config?.resources || []}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/profile" element={<ProfileView />} />
|
||||
<Route path="/:resourceName" element={<ResourceRouteWrapper />} />
|
||||
<Route path="/:resourceName/:id" element={<ResourceRouteWrapper />} />
|
||||
<Route path="/:resourceName/create" element={<ResourceRouteWrapper />} />
|
||||
<Route path="/:resourceName/edit/:id" element={<ResourceRouteWrapper />} />
|
||||
</Routes>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceRouteWrapper() {
|
||||
const { resourceName } = useParams();
|
||||
const config = React.useContext(ConfigContext);
|
||||
const selectedResource = config?.resources.find((r) => r.name === resourceName);
|
||||
|
||||
if (!selectedResource) return <Typography>Resource not found</Typography>;
|
||||
|
||||
return <ResourceView config={selectedResource} />;
|
||||
}
|
||||
|
||||
export default function Admin() {
|
||||
const [config, setConfig] = React.useState<AppConfig | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
getAppConfig().then((cfg) => {
|
||||
initializeApiClients(cfg.baseUrl, cfg.authBaseUrl);
|
||||
setConfig(cfg);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<AppTheme>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: "100vh",
|
||||
}}
|
||||
>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</AppTheme>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppTheme>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ConfigContext.Provider value={config}>
|
||||
<AuthProvider authBaseUrl={config.authBaseUrl}>
|
||||
<UploadProvider>
|
||||
<BrowserRouter>
|
||||
<AdminApp />
|
||||
</BrowserRouter>
|
||||
</UploadProvider>
|
||||
</AuthProvider>
|
||||
</ConfigContext.Provider>
|
||||
</QueryClientProvider>
|
||||
</AppTheme>
|
||||
);
|
||||
}
|
||||
43
react-openapi/api/client.ts
Normal file
43
react-openapi/api/client.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import { createApiClient } from "../../auth/src";
|
||||
|
||||
/**
|
||||
* We expose a singleton-like getter/setter for the API clients
|
||||
*/
|
||||
let _api: AxiosInstance | null = null;
|
||||
let _auth: AxiosInstance | null = null;
|
||||
|
||||
export const api = {
|
||||
get: (...args: Parameters<AxiosInstance["get"]>) => {
|
||||
if (!_api) throw new Error("API client not initialized");
|
||||
return _api.get(...args);
|
||||
},
|
||||
post: (...args: Parameters<AxiosInstance["post"]>) => {
|
||||
if (!_api) throw new Error("API client not initialized");
|
||||
return _api.post(...args);
|
||||
},
|
||||
put: (...args: Parameters<AxiosInstance["put"]>) => {
|
||||
if (!_api) throw new Error("API client not initialized");
|
||||
return _api.put(...args);
|
||||
},
|
||||
delete: (...args: Parameters<AxiosInstance["delete"]>) => {
|
||||
if (!_api) throw new Error("API client not initialized");
|
||||
return _api.delete(...args);
|
||||
},
|
||||
};
|
||||
|
||||
export const auth = {
|
||||
post: (...args: Parameters<AxiosInstance["post"]>) => {
|
||||
if (!_auth) throw new Error("Auth client not initialized");
|
||||
return _auth.post(...args);
|
||||
},
|
||||
get: (...args: Parameters<AxiosInstance["get"]>) => {
|
||||
if (!_auth) throw new Error("Auth client not initialized");
|
||||
return _auth.get(...args);
|
||||
},
|
||||
};
|
||||
|
||||
export function initializeApiClients(baseUrl: string, authBaseUrl: string) {
|
||||
_api = createApiClient(baseUrl);
|
||||
_auth = createApiClient(authBaseUrl);
|
||||
}
|
||||
261
react-openapi/components/AdminLayout.tsx
Normal file
261
react-openapi/components/AdminLayout.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Drawer,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
List,
|
||||
Typography,
|
||||
Divider,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
CssBaseline,
|
||||
Button,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import TableViewIcon from '@mui/icons-material/TableView';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import LogoutIcon from '@mui/icons-material/Logout';
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import { ResourceConfig } from '../types/config';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
const drawerWidth = 240;
|
||||
const collapsedWidth = 64;
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode;
|
||||
onSelectResource: (resourceName: string | null) => void;
|
||||
onLogout: () => void;
|
||||
username?: string;
|
||||
resources: ResourceConfig[];
|
||||
}
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
onSelectResource,
|
||||
onLogout,
|
||||
username,
|
||||
resources,
|
||||
}: AdminLayoutProps) {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = React.useState(false);
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||
|
||||
const activeResourceName = location.pathname.split('/')[1] || null;
|
||||
|
||||
// AUTO-TOGGLE LOGIC
|
||||
React.useEffect(() => {
|
||||
if (isMobile) {
|
||||
setIsCollapsed(false); // Mobile drawer is never "mini"
|
||||
setMobileOpen(false); // Close on navigation
|
||||
} else {
|
||||
if (location.pathname === '/' || location.pathname === '') {
|
||||
setIsCollapsed(false);
|
||||
} else {
|
||||
setIsCollapsed(true);
|
||||
}
|
||||
}
|
||||
}, [location.pathname, isMobile]);
|
||||
|
||||
const currentWidth = isMobile ? drawerWidth : (isCollapsed ? collapsedWidth : drawerWidth);
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
setMobileOpen(!mobileOpen);
|
||||
};
|
||||
|
||||
const handleSidebarToggle = () => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
};
|
||||
|
||||
const drawerContent = (
|
||||
<Box sx={{ overflow: 'hidden', display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{!isMobile && (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', justifyContent: isCollapsed ? 'center' : 'flex-end', p: 1 }}>
|
||||
<IconButton onClick={handleSidebarToggle}>
|
||||
{isCollapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
{isMobile && <Toolbar />}
|
||||
|
||||
<List>
|
||||
<ListItem disablePadding>
|
||||
<Tooltip title={(isCollapsed && !isMobile) ? "Dashboard" : ""} placement="right">
|
||||
<ListItemButton
|
||||
selected={location.pathname === '/'}
|
||||
onClick={() => navigate('/')}
|
||||
sx={{
|
||||
minHeight: 48,
|
||||
justifyContent: (isCollapsed && !isMobile) ? 'center' : 'initial',
|
||||
px: 2.5,
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{
|
||||
minWidth: 0,
|
||||
mr: (isCollapsed && !isMobile) ? 0 : 3,
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<DashboardIcon color={location.pathname === '/' ? 'primary' : 'inherit'} />
|
||||
</ListItemIcon>
|
||||
{(!isCollapsed || isMobile) && <ListItemText primary="Dashboard" />}
|
||||
</ListItemButton>
|
||||
</Tooltip>
|
||||
</ListItem>
|
||||
</List>
|
||||
<Divider />
|
||||
<List sx={{ flexGrow: 1 }}>
|
||||
{resources.map((res) => (
|
||||
<ListItem key={res.name} disablePadding>
|
||||
<Tooltip title={(isCollapsed && !isMobile) ? res.pluralLabel : ""} placement="right">
|
||||
<ListItemButton
|
||||
selected={activeResourceName === res.name}
|
||||
onClick={() => onSelectResource(res.name)}
|
||||
sx={{
|
||||
minHeight: 48,
|
||||
justifyContent: (isCollapsed && !isMobile) ? 'center' : 'initial',
|
||||
px: 2.5,
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{
|
||||
minWidth: 0,
|
||||
mr: (isCollapsed && !isMobile) ? 0 : 3,
|
||||
justifyContent: 'center',
|
||||
}}>
|
||||
<TableViewIcon color={activeResourceName === res.name ? 'primary' : 'inherit'} />
|
||||
</ListItemIcon>
|
||||
{(!isCollapsed || isMobile) && <ListItemText primary={res.pluralLabel} />}
|
||||
</ListItemButton>
|
||||
</Tooltip>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<CssBaseline />
|
||||
<AppBar
|
||||
position="fixed"
|
||||
sx={{
|
||||
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||
backdropFilter: 'blur(8px)',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
color: 'text.primary',
|
||||
boxShadow: 'none',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
{isMobile && (
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
edge="start"
|
||||
onClick={handleDrawerToggle}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1, fontWeight: 'bold' }}>
|
||||
Admin Panel
|
||||
</Typography>
|
||||
<Box sx={{ display: { xs: 'none', sm: 'flex' }, alignItems: 'center', mr: 2 }}>
|
||||
<Button
|
||||
color="inherit"
|
||||
onClick={() => navigate('/profile')}
|
||||
sx={{ textTransform: 'none', fontWeight: 500 }}
|
||||
>
|
||||
{username}
|
||||
</Button>
|
||||
</Box>
|
||||
<Tooltip title="Logout">
|
||||
<IconButton color="inherit" onClick={onLogout}>
|
||||
<LogoutIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Box
|
||||
component="nav"
|
||||
sx={{ width: { md: currentWidth }, flexShrink: { md: 0 } }}
|
||||
>
|
||||
{isMobile ? (
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onClose={handleDrawerToggle}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={{
|
||||
display: { xs: 'block', md: 'none' },
|
||||
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
|
||||
}}
|
||||
>
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
) : (
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
display: { xs: 'none', md: 'block' },
|
||||
width: currentWidth,
|
||||
flexShrink: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
boxSizing: 'border-box',
|
||||
transition: (theme) => theme.transitions.create('width', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
[`& .MuiDrawer-paper`]: {
|
||||
width: currentWidth,
|
||||
boxSizing: 'border-box',
|
||||
overflowX: 'hidden',
|
||||
transition: (theme) => theme.transitions.create('width', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
},
|
||||
}}
|
||||
open
|
||||
>
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
p: { xs: 2, md: 3 },
|
||||
width: { xs: '100%', md: `calc(100% - ${currentWidth}px)` },
|
||||
transition: (theme) => theme.transitions.create(['margin', 'width'], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
369
react-openapi/components/EnhancedTable.tsx
Normal file
369
react-openapi/components/EnhancedTable.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
import * as React from 'react';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Card,
|
||||
CardContent,
|
||||
CardActions,
|
||||
Grid,
|
||||
Menu,
|
||||
MenuItem,
|
||||
useMediaQuery,
|
||||
useTheme,
|
||||
Divider,
|
||||
Chip,
|
||||
Stack,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
DataGrid,
|
||||
GridColDef,
|
||||
GridActionsCellItem,
|
||||
GridRenderCellParams,
|
||||
GridPaginationModel,
|
||||
} from '@mui/x-data-grid';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ResourceConfig } from '../types/config';
|
||||
|
||||
interface EnhancedTableProps {
|
||||
config: ResourceConfig;
|
||||
data: any[];
|
||||
total?: number;
|
||||
paginationModel?: GridPaginationModel;
|
||||
onPaginationModelChange?: (model: GridPaginationModel) => void;
|
||||
loading?: boolean;
|
||||
onEdit: (item: any) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
onNavigateToResource?: (resourceName: string, id: string) => void;
|
||||
}
|
||||
|
||||
export default function EnhancedTable({
|
||||
config,
|
||||
data,
|
||||
total,
|
||||
paginationModel,
|
||||
onPaginationModelChange,
|
||||
loading = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCreate,
|
||||
onNavigateToResource,
|
||||
}: EnhancedTableProps) {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const navigate = useNavigate();
|
||||
|
||||
const columns: GridColDef[] = React.useMemo(() => {
|
||||
const cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => {
|
||||
let muiType: 'string' | 'number' | 'boolean' | 'date' | 'dateTime' | 'singleSelect' = 'string';
|
||||
if (field.type === 'number') muiType = 'number';
|
||||
if (field.type === 'boolean') muiType = 'boolean';
|
||||
if (field.type === 'date') muiType = 'date';
|
||||
if (field.type === 'datetime') muiType = 'dateTime';
|
||||
if (field.type === 'enum') muiType = 'singleSelect';
|
||||
|
||||
const col: GridColDef = {
|
||||
field: key,
|
||||
headerName: field.label,
|
||||
type: muiType,
|
||||
flex: 1,
|
||||
minWidth: 150,
|
||||
renderCell: (params: GridRenderCellParams) => <FieldRenderer params={params} field={field} fieldKey={key} config={config} onNavigate={onNavigateToResource} navigate={navigate} />
|
||||
};
|
||||
|
||||
if (muiType === 'date' || muiType === 'dateTime') {
|
||||
col.valueGetter = (value: any) => {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
};
|
||||
}
|
||||
|
||||
if (muiType === 'singleSelect' && field.options) {
|
||||
// @ts-ignore
|
||||
col.valueOptions = field.options;
|
||||
}
|
||||
|
||||
return col;
|
||||
});
|
||||
|
||||
cols.push({
|
||||
field: 'actions',
|
||||
type: 'actions',
|
||||
headerName: 'Actions',
|
||||
width: 120,
|
||||
getActions: (params) => [
|
||||
<GridActionsCellItem
|
||||
icon={<VisibilityIcon />}
|
||||
label="View"
|
||||
onClick={() => navigate(`/${config.name}/${params.id}`)}
|
||||
/>,
|
||||
<GridActionsCellItem
|
||||
icon={<EditIcon />}
|
||||
label="Edit"
|
||||
onClick={() => navigate(`/${config.name}/edit/${params.id}`)}
|
||||
/>,
|
||||
<GridActionsCellItem
|
||||
icon={<DeleteIcon />}
|
||||
label="Delete"
|
||||
onClick={() => onDelete(params.id as string)}
|
||||
/>,
|
||||
],
|
||||
});
|
||||
|
||||
return cols;
|
||||
}, [config, onDelete, navigate, onNavigateToResource]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2, alignItems: 'center' }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>{config.pluralLabel}</Typography>
|
||||
<Button variant="contained" color="primary" onClick={onCreate} size="small">
|
||||
Add
|
||||
</Button>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{data.map((row) => (
|
||||
<Box key={row[config.primaryKey] || Math.random()}>
|
||||
<MobileCardRow
|
||||
row={row}
|
||||
config={config}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onNavigate={onNavigateToResource}
|
||||
navigate={navigate}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3, alignItems: 'center' }}>
|
||||
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>{config.pluralLabel}</Typography>
|
||||
<Button variant="contained" color="primary" onClick={onCreate}>
|
||||
Add {config.label}
|
||||
</Button>
|
||||
</Box>
|
||||
<DataGrid
|
||||
rows={data || []}
|
||||
columns={columns}
|
||||
autoHeight
|
||||
paginationMode={config.pagination ? 'server' : 'client'}
|
||||
rowCount={(() => {
|
||||
if (!config.pagination) return data.length;
|
||||
if (total !== undefined) return total;
|
||||
|
||||
// Graceful fallback for missing total count
|
||||
const page = paginationModel?.page || 0;
|
||||
const pageSize = paginationModel?.pageSize || 10;
|
||||
if (data.length < pageSize) {
|
||||
return page * pageSize + data.length;
|
||||
}
|
||||
// Enable 'Next' button by pretending there's at least one more page
|
||||
return (page + 2) * pageSize;
|
||||
})()}
|
||||
loading={loading}
|
||||
paginationModel={paginationModel || { page: 0, pageSize: 10 }}
|
||||
onPaginationModelChange={onPaginationModelChange}
|
||||
getRowId={(row) => {
|
||||
const pk = config.primaryKey;
|
||||
if (row[pk] !== undefined && row[pk] !== null) return row[pk];
|
||||
const fallbackKeys = ['id', '_id', 'uuid', 'pk'];
|
||||
for (const key of fallbackKeys) {
|
||||
if (row[key] !== undefined && row[key] !== null) return row[key];
|
||||
}
|
||||
return `temp-id-${data.indexOf(row)}`;
|
||||
}}
|
||||
disableRowSelectionOnClick
|
||||
pageSizeOptions={[10, 25, 50]}
|
||||
sx={{
|
||||
border: 'none',
|
||||
'& .MuiDataGrid-cell:focus': { outline: 'none' },
|
||||
'& .MuiDataGrid-columnHeader:focus': { outline: 'none' },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) {
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const id = row[config.primaryKey];
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card variant="outlined" sx={{ borderRadius: 2 }}>
|
||||
<CardContent sx={{ pb: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
|
||||
#{id}
|
||||
</Typography>
|
||||
<IconButton size="small" onClick={handleClick}>
|
||||
<MoreVertIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
|
||||
<MenuItem onClick={() => { handleClose(); navigate(`/${config.name}/${id}`); }}>View</MenuItem>
|
||||
<MenuItem onClick={() => { handleClose(); navigate(`/${config.name}/edit/${id}`); }}>Edit</MenuItem>
|
||||
<MenuItem onClick={() => { handleClose(); onDelete(id); }} sx={{ color: 'error.main' }}>Delete</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 2 }}>
|
||||
{Object.entries(config.fields).slice(0, 5).map(([key, field]: [string, any]) => (
|
||||
<Box key={key}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||
{field.label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, wordBreak: 'break-all' }}>
|
||||
<FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile />
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
<CardActions sx={{ justifyContent: 'flex-end', px: 2, pb: 2 }}>
|
||||
<Button size="small" onClick={() => navigate(`/${config.name}/${id}`)}>View Details</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function getFormattedDisplayValue(item: any, displayField?: string | string[]) {
|
||||
if (!item) return "";
|
||||
if (!displayField) return item.name || item.title || item.label || item.id || JSON.stringify(item);
|
||||
|
||||
if (Array.isArray(displayField)) {
|
||||
return displayField
|
||||
.map(key => item[key])
|
||||
.filter(val => val !== undefined && val !== null)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
return item[displayField] || item.id || JSON.stringify(item);
|
||||
}
|
||||
|
||||
function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate, isMobile }: any) {
|
||||
const value = params.value;
|
||||
const isPk = fieldKey === config.primaryKey;
|
||||
|
||||
if (field.formatter) return field.formatter(value);
|
||||
|
||||
// 1. Single Relation
|
||||
if (field.relation && value && !Array.isArray(value)) {
|
||||
const relationId = typeof value === 'object' ? (value.id || value._id || value.pk) : value;
|
||||
const displayValue = getFormattedDisplayValue(value, field.displayField);
|
||||
|
||||
return (
|
||||
<Chip
|
||||
label={displayValue}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (relationId) onNavigate?.(field.relation!, String(relationId));
|
||||
}}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Multi-Select (Array of relations or simple strings)
|
||||
if (field.type === 'array' && Array.isArray(value)) {
|
||||
const tooltipTitle = value.map((item) => getFormattedDisplayValue(item, field.displayField)).join(', ');
|
||||
|
||||
return (
|
||||
<Tooltip title={tooltipTitle} arrow placement="top">
|
||||
<Stack direction="row" spacing={0.5} sx={{ overflow: 'hidden', flexWrap: 'nowrap' }}>
|
||||
{value.map((item, idx) => (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={getFormattedDisplayValue(item, field.displayField)}
|
||||
size="small"
|
||||
variant="filled"
|
||||
sx={{ maxWidth: 120 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (field.relation) {
|
||||
const id = typeof item === 'object' ? (item.id || item._id) : item;
|
||||
if (id) onNavigate?.(field.relation!, String(id));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Simple Objects
|
||||
if (field.type === 'object' && value) {
|
||||
return getFormattedDisplayValue(value, field.displayField) || (isMobile ? 'Object' : JSON.stringify(value));
|
||||
}
|
||||
|
||||
if (field.type === 'number' && typeof value === 'number') {
|
||||
const isNegative = value < 0;
|
||||
const color = isNegative ? 'error' : 'success';
|
||||
|
||||
return (
|
||||
<Chip
|
||||
label={value.toLocaleString()}
|
||||
size="small"
|
||||
color={color}
|
||||
variant="filled"
|
||||
sx={{
|
||||
fontWeight: 'bold',
|
||||
minWidth: 60,
|
||||
// Soft background with bold text for a premium feel
|
||||
bgcolor: (theme) => alpha(theme.palette[color].main, 0.15),
|
||||
color: (theme) => theme.palette[color].dark,
|
||||
'& .MuiChip-label': { px: 1.5 }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'boolean') {
|
||||
return value ? (
|
||||
<Chip label="Yes" size="small" color="success" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
|
||||
) : (
|
||||
<Chip label="No" size="small" color="default" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'datetime' || field.type === 'date') return value ? new Date(value).toLocaleString() : '';
|
||||
|
||||
if (isPk && !isMobile) {
|
||||
return (
|
||||
<Chip
|
||||
label={value}
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/${config.name}/${params.row[config.primaryKey]}`); }}
|
||||
sx={{ cursor: 'pointer', fontWeight: 'bold' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
138
react-openapi/components/GenericForm.tsx
Normal file
138
react-openapi/components/GenericForm.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import { ResourceConfig } from '../types/config';
|
||||
import { useUpload } from '../providers/UploadProvider';
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
import { useResource } from '../hooks/useResource';
|
||||
import FormField from './fields/FormField';
|
||||
import { ConfigContext } from '../Admin';
|
||||
|
||||
interface GenericFormProps {
|
||||
config: ResourceConfig;
|
||||
initialData?: any;
|
||||
onSave: (data: any) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
readOnly?: boolean;
|
||||
onEditClick?: () => void;
|
||||
}
|
||||
|
||||
export default function GenericForm({
|
||||
config,
|
||||
initialData = {},
|
||||
onSave,
|
||||
onCancel,
|
||||
loading: saving,
|
||||
readOnly = false,
|
||||
onEditClick,
|
||||
}: GenericFormProps) {
|
||||
initialData = initialData || {};
|
||||
const [formData, setFormData] = React.useState(initialData);
|
||||
const { uploadFile, uploading } = useUpload();
|
||||
const appConfig = React.useContext(ConfigContext);
|
||||
|
||||
// 1. Identify all unique relations in the schema (including nested ones)
|
||||
const getRelationFields = (fields: Record<string, any>): string[] => {
|
||||
let relations: string[] = [];
|
||||
Object.values(fields).forEach(field => {
|
||||
if (field.relation) relations.push(field.relation);
|
||||
if (field.schema) relations = [...relations, ...getRelationFields(field.schema)];
|
||||
});
|
||||
return Array.from(new Set(relations));
|
||||
};
|
||||
|
||||
const allRelations = React.useMemo(() => getRelationFields(config.fields), [config.fields]);
|
||||
|
||||
// 2. Parallel fetch for all related resource lists
|
||||
const queries = useQueries({
|
||||
queries: allRelations.map(relName => {
|
||||
const relatedRes = appConfig?.resources.find(r => r.name === relName);
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { getListQueryOptions } = useResource(relatedRes!);
|
||||
return {
|
||||
...getListQueryOptions(),
|
||||
enabled: !!relatedRes,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
const isLoadingRelations = queries.some(q => q.isLoading);
|
||||
|
||||
const relationDataMap = React.useMemo(() => {
|
||||
const map: Record<string, any[]> = {};
|
||||
allRelations.forEach((relName, index) => {
|
||||
map[relName] = queries[index].data || [];
|
||||
});
|
||||
return map;
|
||||
}, [allRelations, queries]);
|
||||
|
||||
const handleChange = (key: string, value: any) => {
|
||||
if (readOnly) return;
|
||||
setFormData((prev: any) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (readOnly) return;
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
if (readOnly) return `View ${config.label}`;
|
||||
return initialData[config.primaryKey] ? `Edit ${config.label}` : `New ${config.label}`;
|
||||
};
|
||||
|
||||
if (isLoadingRelations) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', p: 8, gap: 2 }}>
|
||||
<CircularProgress />
|
||||
<Typography variant="body2" color="text.secondary">Loading relationships...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Typography variant="h5">
|
||||
{getTitle()}
|
||||
</Typography>
|
||||
<Divider />
|
||||
|
||||
{Object.entries(config.fields).map(([key, field]) => (
|
||||
<FormField
|
||||
key={key}
|
||||
name={key}
|
||||
field={field}
|
||||
value={formData[key]}
|
||||
onChange={(val: any) => handleChange(key, val)}
|
||||
disabled={readOnly || field.readOnly}
|
||||
uploadFile={uploadFile}
|
||||
uploading={uploading}
|
||||
baseUrl={appConfig?.baseUrl || ""}
|
||||
relationDataMap={relationDataMap}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 4 }}>
|
||||
<Button variant="outlined" onClick={onCancel} disabled={saving}>
|
||||
{readOnly ? 'Back to List' : 'Cancel'}
|
||||
</Button>
|
||||
{readOnly ? (
|
||||
<Button variant="contained" color="primary" onClick={onEditClick}>
|
||||
Edit {config.label}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="contained" type="submit" loading={saving} disabled={saving || uploading}>
|
||||
Save {config.label}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
83
react-openapi/components/ProfileView.tsx
Normal file
83
react-openapi/components/ProfileView.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Typography, Paper, CircularProgress, Alert } from '@mui/material';
|
||||
import { useResource } from '../hooks/useResource';
|
||||
import GenericForm from './GenericForm';
|
||||
import { ConfigContext } from '../Admin';
|
||||
|
||||
export default function ProfileView() {
|
||||
const appConfig = React.useContext(ConfigContext);
|
||||
const profileConfig = appConfig?.profile;
|
||||
const resourceConfig = appConfig?.resources.find(r => r.name === profileConfig?.resource);
|
||||
|
||||
if (!profileConfig || !resourceConfig) {
|
||||
return <Alert severity="error">Profile configuration not found.</Alert>;
|
||||
}
|
||||
|
||||
// Create a modified config where only extraFields are editable
|
||||
const editableConfig = React.useMemo(() => {
|
||||
const newFields = { ...resourceConfig.fields };
|
||||
const extraFields = profileConfig.extraFields || [];
|
||||
|
||||
Object.keys(newFields).forEach(key => {
|
||||
newFields[key] = {
|
||||
...newFields[key],
|
||||
readOnly: !extraFields.includes(key),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...resourceConfig,
|
||||
fields: newFields,
|
||||
};
|
||||
}, [resourceConfig, profileConfig.extraFields]);
|
||||
|
||||
const { useMe, useUpdateMe } = useResource(resourceConfig);
|
||||
const { data: profile, isLoading, error } = useMe();
|
||||
const updateMutation = useUpdateMe();
|
||||
|
||||
const handleSave = async (formData: any) => {
|
||||
try {
|
||||
// Only send editable fields to prevent accidental overwrites of read-only data
|
||||
const extraFields = profileConfig.extraFields || [];
|
||||
const dataToSave = Object.keys(formData)
|
||||
.filter(key => extraFields.includes(key))
|
||||
.reduce((obj: any, key) => {
|
||||
obj[key] = formData[key];
|
||||
return obj;
|
||||
}, {});
|
||||
|
||||
await updateMutation.mutateAsync(dataToSave);
|
||||
} catch (err) {
|
||||
console.error('Profile update failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Alert severity="error">Failed to load profile data.</Alert>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 800, mx: 'auto', mt: 4 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
My Profile
|
||||
</Typography>
|
||||
<Paper sx={{ p: 4, mt: 2 }}>
|
||||
<GenericForm
|
||||
config={editableConfig}
|
||||
initialData={profile}
|
||||
onSave={handleSave}
|
||||
onCancel={() => window.history.back()}
|
||||
loading={updateMutation.isPending}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
110
react-openapi/components/ResourceView.tsx
Normal file
110
react-openapi/components/ResourceView.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Typography, Paper, CircularProgress } from '@mui/material';
|
||||
import { ResourceConfig } from '../types/config';
|
||||
import { useResource } from '../hooks/useResource';
|
||||
import GenericForm from './GenericForm';
|
||||
import EnhancedTable from './EnhancedTable';
|
||||
import { useParams, useLocation, useNavigate, Routes, Route } from 'react-router-dom';
|
||||
|
||||
interface ResourceViewProps {
|
||||
config: ResourceConfig;
|
||||
onNavigateToResource?: (resourceName: string, id: string) => void;
|
||||
}
|
||||
|
||||
import { GridPaginationModel } from '@mui/x-data-grid';
|
||||
|
||||
export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) {
|
||||
const { id } = useParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isCreate = location.pathname.endsWith('/create');
|
||||
const isEdit = location.pathname.includes('/edit/');
|
||||
const isView = !!id && !isEdit;
|
||||
const isList = !id && !isCreate;
|
||||
|
||||
const [paginationModel, setPaginationModel] = React.useState<GridPaginationModel>({
|
||||
page: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const { useList, useRead, useCreate, useUpdate, useDelete } = useResource(config);
|
||||
|
||||
// Determine query parameters based on pagination config
|
||||
const queryParams = React.useMemo(() => {
|
||||
if (!config.pagination) return {};
|
||||
return {
|
||||
skip: paginationModel.page * paginationModel.pageSize,
|
||||
limit: paginationModel.pageSize,
|
||||
};
|
||||
}, [config.pagination, paginationModel]);
|
||||
|
||||
const listQuery = useList(queryParams);
|
||||
const itemQuery = useRead(id || "");
|
||||
|
||||
const paginatedData = listQuery.data || { data: [], total: undefined };
|
||||
const createMutation = useCreate();
|
||||
const updateMutation = useUpdate();
|
||||
const deleteMutation = useDelete();
|
||||
|
||||
const handleEdit = (item: any) => {
|
||||
navigate(`/${config.name}/edit/${item[config.primaryKey]}`);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
navigate(`/${config.name}/create`);
|
||||
};
|
||||
|
||||
const handleSave = async (formData: any) => {
|
||||
try {
|
||||
if (isEdit) {
|
||||
await updateMutation.mutateAsync({ id: id!, data: formData });
|
||||
} else {
|
||||
await createMutation.mutateAsync(formData);
|
||||
}
|
||||
navigate(`/${config.name}`);
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (itemId: string) => {
|
||||
if (window.confirm('Are you sure you want to delete this item?')) {
|
||||
await deleteMutation.mutateAsync(itemId);
|
||||
}
|
||||
};
|
||||
|
||||
if (isList && listQuery.isLoading) return <CircularProgress />;
|
||||
if ((isEdit || isView) && itemQuery.isLoading) return <CircularProgress />;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{isList ? (
|
||||
<EnhancedTable
|
||||
config={config}
|
||||
data={paginatedData.data || []}
|
||||
total={paginatedData.total}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
loading={listQuery.isFetching}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onCreate={handleCreate}
|
||||
onNavigateToResource={(res, id) => navigate(`/${res}/${id}`)}
|
||||
/>
|
||||
) : (
|
||||
<Paper sx={{ p: 4 }}>
|
||||
<GenericForm
|
||||
config={config}
|
||||
initialData={isCreate ? null : itemQuery.data}
|
||||
onSave={handleSave}
|
||||
onCancel={() => navigate(`/${config.name}`)}
|
||||
loading={createMutation.isPending || updateMutation.isPending}
|
||||
readOnly={isView}
|
||||
onEditClick={() => navigate(`/${config.name}/edit/${id}`)}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
224
react-openapi/components/fields/FormField.tsx
Normal file
224
react-openapi/components/fields/FormField.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Typography,
|
||||
Box,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import { ResourceField } from '../../types/config';
|
||||
import ImageUploadField from './ImageUploadField';
|
||||
|
||||
interface FormFieldProps {
|
||||
name: string;
|
||||
field: ResourceField;
|
||||
value: any;
|
||||
onChange: (val: any) => void;
|
||||
disabled?: boolean;
|
||||
uploadFile: (file: File) => Promise<string | null>;
|
||||
uploading: boolean;
|
||||
baseUrl: string;
|
||||
relationDataMap?: Record<string, any[]>; // Map of relation name to data array
|
||||
}
|
||||
|
||||
export default function FormField({
|
||||
name,
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
uploadFile,
|
||||
uploading,
|
||||
baseUrl,
|
||||
relationDataMap = {},
|
||||
}: FormFieldProps) {
|
||||
const label = field.label;
|
||||
|
||||
// 1. Recursive Rendering for Objects (Not Relations)
|
||||
if (field.type === 'object' && field.schema && !field.relation) {
|
||||
return (
|
||||
<Box sx={{ ml: 2, mt: 2, p: 2, borderLeft: '2px solid #e0e0e0' }}>
|
||||
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||
{label}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{Object.entries(field.schema).map(([subKey, subField]) => (
|
||||
<FormField
|
||||
key={subKey}
|
||||
name={`${name}.${subKey}`}
|
||||
field={subField}
|
||||
value={value?.[subKey]}
|
||||
onChange={(newVal) => {
|
||||
const updated = { ...(value || {}), [subKey]: newVal };
|
||||
onChange(updated);
|
||||
}}
|
||||
disabled={disabled}
|
||||
uploadFile={uploadFile}
|
||||
uploading={uploading}
|
||||
baseUrl={baseUrl}
|
||||
relationDataMap={relationDataMap}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Relation Handling (Select / Multi-Select)
|
||||
if (field.relation && relationDataMap[field.relation]) {
|
||||
const relationData = relationDataMap[field.relation];
|
||||
const isArrayRelation = field.type === 'array';
|
||||
|
||||
// Determine how to display the related item
|
||||
const getOptionLabel = (option: any) => {
|
||||
if (!option) return "";
|
||||
if (field.displayField && option[field.displayField]) return option[field.displayField];
|
||||
// Standard naming fields
|
||||
return option.name || option.title || option.label || option.id || JSON.stringify(option);
|
||||
};
|
||||
|
||||
const getOptionValue = (option: any) => {
|
||||
// Return the whole object to maintain identity
|
||||
return option;
|
||||
};
|
||||
|
||||
return (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel shrink>{label}</InputLabel>
|
||||
<Select
|
||||
multiple={isArrayRelation}
|
||||
value={value || (isArrayRelation ? [] : "")}
|
||||
label={label}
|
||||
displayEmpty
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
renderValue={(selected: any) => {
|
||||
if (isArrayRelation) {
|
||||
return (selected as any[]).map(getOptionLabel).join(', ');
|
||||
}
|
||||
return getOptionLabel(selected);
|
||||
}}
|
||||
>
|
||||
{relationData.map((option) => (
|
||||
<MenuItem key={option.id || JSON.stringify(option)} value={getOptionValue(option)}>
|
||||
{getOptionLabel(option)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Image Handling
|
||||
if (field.type === 'image') {
|
||||
return (
|
||||
<ImageUploadField
|
||||
label={label}
|
||||
value={value}
|
||||
onUpload={async (file: any) => {
|
||||
const url = await uploadFile(file);
|
||||
if (url) onChange(url);
|
||||
}}
|
||||
uploading={uploading}
|
||||
baseUrl={baseUrl}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Boolean Handling
|
||||
if (field.type === 'boolean') {
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={!!value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Enum Handling
|
||||
if (field.type === 'enum' && field.options) {
|
||||
return (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{label}</InputLabel>
|
||||
<Select
|
||||
value={value || ''}
|
||||
label={label}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{field.options.map((opt: string) => (
|
||||
<MenuItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Common Text Fields
|
||||
if (field.type === 'datetime' || field.type === 'date') {
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
type={field.type === 'datetime' ? "datetime-local" : "date"}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={value ? new Date(value).toISOString().slice(0, field.type === 'datetime' ? 16 : 10) : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'markdown' || field.type === 'string') {
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
value={value || ''}
|
||||
multiline={field.type === 'markdown'}
|
||||
rows={field.type === 'markdown' ? 4 : 1}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
type="number"
|
||||
value={value === undefined || value === null ? '' : value}
|
||||
onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))}
|
||||
disabled={disabled}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
value={typeof value === 'object' ? JSON.stringify(value) : value || ''}
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
}
|
||||
60
react-openapi/components/fields/ImageUploadField.tsx
Normal file
60
react-openapi/components/fields/ImageUploadField.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Box, Button, Avatar, CircularProgress, Typography } from "@mui/material";
|
||||
|
||||
interface ImageUploadFieldProps {
|
||||
label?: string;
|
||||
value: string;
|
||||
uploading?: boolean;
|
||||
onUpload: (file: File) => void;
|
||||
size?: number;
|
||||
baseUrl: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ImageUploadField({
|
||||
label = "Upload Image",
|
||||
value,
|
||||
uploading = false,
|
||||
onUpload,
|
||||
size = 64,
|
||||
baseUrl,
|
||||
disabled = false,
|
||||
}: ImageUploadFieldProps) {
|
||||
|
||||
const imgSrc = value
|
||||
? baseUrl.replace(/\/+$/, "") +
|
||||
"/" +
|
||||
value.replace(/^\/+/, "")
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1, mb: 3 }}>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||
<Avatar
|
||||
src={imgSrc}
|
||||
sx={{ width: size, height: size, borderRadius: 2 }}
|
||||
/>
|
||||
|
||||
{!disabled && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
component="label"
|
||||
disabled={uploading}
|
||||
startIcon={uploading && <CircularProgress size={16} />}
|
||||
>
|
||||
{uploading ? "Uploading..." : "Choose File"}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) onUpload(file);
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
18
react-openapi/config.ts
Normal file
18
react-openapi/config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { AppConfig } from "./types/config";
|
||||
import { loadConfigFromOpenApi } from "./utils/openapi_loader";
|
||||
|
||||
export async function getAppConfig(): Promise<AppConfig> {
|
||||
// @ts-ignore
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL
|
||||
|
||||
// @ts-ignore
|
||||
const authBaseUrl = import.meta.env.VITE_AUTH_BASE_URL
|
||||
const config = await loadConfigFromOpenApi(baseUrl);
|
||||
|
||||
// You can still apply overrides here
|
||||
return {
|
||||
...config,
|
||||
authBaseUrl: authBaseUrl,
|
||||
baseUrl: baseUrl,
|
||||
};
|
||||
}
|
||||
50
react-openapi/configuration.ts
Normal file
50
react-openapi/configuration.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { ResourceOverride } from "./types/overrides";
|
||||
|
||||
export const configuration: Record<string, ResourceOverride> = {
|
||||
expenses: {
|
||||
fields: {
|
||||
payee: {
|
||||
displayField: "name",
|
||||
},
|
||||
payor: {
|
||||
display: false,
|
||||
displayField: "username",
|
||||
},
|
||||
account: {
|
||||
displayField: "name",
|
||||
},
|
||||
tags: {
|
||||
displayField: ["name", "icon"],
|
||||
},
|
||||
occurred_at: {
|
||||
formatter: (val: string) => {
|
||||
const date = new Date(val);
|
||||
const day = date.getDate();
|
||||
const month = date.toLocaleString('default', { month: 'long' });
|
||||
const year = date.getFullYear();
|
||||
const suffix = (day: number) => {
|
||||
if (day > 3 && day < 21) return 'th';
|
||||
switch (day % 10) {
|
||||
case 1: return "st";
|
||||
case 2: return "nd";
|
||||
case 3: return "rd";
|
||||
default: return "th";
|
||||
}
|
||||
};
|
||||
return `${day}${suffix(day)} ${month} ${year}`;
|
||||
}
|
||||
},
|
||||
created_at: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
pagination: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const profileConfiguration = {
|
||||
"extraFields": ['name'],
|
||||
"resource": "payors",
|
||||
// not in use
|
||||
"hidden": true,
|
||||
};
|
||||
127
react-openapi/hooks/useResource.ts
Normal file
127
react-openapi/hooks/useResource.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api/client";
|
||||
import { ResourceConfig } from "../types/config";
|
||||
|
||||
export function useResource<T = any>(config: ResourceConfig) {
|
||||
const queryClient = useQueryClient();
|
||||
const { name, endpoint, primaryKey } = config;
|
||||
|
||||
// --- READ ALL ---
|
||||
const useList = (params?: any) =>
|
||||
useQuery({
|
||||
queryKey: [name, "list", params],
|
||||
queryFn: async () => {
|
||||
// @ts-ignore
|
||||
const res = await api.get<T[]>(endpoint, { params });
|
||||
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
|
||||
return {
|
||||
data: res.data,
|
||||
total: isNaN(total as any) ? undefined : total
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// --- READ ONE ---
|
||||
const useRead = (id: string | null) =>
|
||||
useQuery({
|
||||
queryKey: [name, "detail", id],
|
||||
queryFn: async () => {
|
||||
if (!id) return null;
|
||||
// @ts-ignore
|
||||
const res = await api.get<T>(`${endpoint}/${id}`);
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// --- CREATE ---
|
||||
const useCreate = () =>
|
||||
useMutation({
|
||||
mutationFn: async (data: Partial<T>) => {
|
||||
// @ts-ignore
|
||||
const res = await api.post<T>(endpoint, data);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [name, "list"] });
|
||||
},
|
||||
});
|
||||
|
||||
// --- UPDATE ---
|
||||
const useUpdate = () =>
|
||||
useMutation({
|
||||
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
|
||||
// @ts-ignore
|
||||
const res = await api.put<T>(`${endpoint}/${id}`, data);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: (updatedItem) => {
|
||||
// @ts-ignore
|
||||
const id = updatedItem[primaryKey];
|
||||
queryClient.invalidateQueries({ queryKey: [name, "list"] });
|
||||
queryClient.invalidateQueries({ queryKey: [name, "detail", id] });
|
||||
},
|
||||
});
|
||||
|
||||
// --- DELETE ---
|
||||
const useDelete = () =>
|
||||
useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await api.delete(`${endpoint}/${id}`);
|
||||
return id;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [name, "list"] });
|
||||
},
|
||||
});
|
||||
|
||||
// --- HELPERS FOR useQueries ---
|
||||
const getListQueryOptions = (params?: any) => ({
|
||||
queryKey: [name, "list", params],
|
||||
queryFn: async () => {
|
||||
// @ts-ignore
|
||||
const res = await api.get<T[]>(endpoint, { params });
|
||||
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
|
||||
return {
|
||||
data: res.data,
|
||||
total: isNaN(total as any) ? undefined : total
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// --- READ ME ---
|
||||
const useMe = () =>
|
||||
useQuery({
|
||||
queryKey: [name, "me"],
|
||||
queryFn: async () => {
|
||||
// @ts-ignore
|
||||
const res = await api.get<T>(`${endpoint}/me`);
|
||||
return res.data;
|
||||
},
|
||||
});
|
||||
|
||||
// --- UPDATE ME ---
|
||||
const useUpdateMe = () =>
|
||||
useMutation({
|
||||
mutationFn: async (data: Partial<T>) => {
|
||||
// @ts-ignore
|
||||
const res = await api.put<T>(`${endpoint}/me`, data);
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: [name, "me"] });
|
||||
queryClient.invalidateQueries({ queryKey: [name, "list"] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
useList,
|
||||
useRead,
|
||||
useMe,
|
||||
useCreate,
|
||||
useUpdate,
|
||||
useUpdateMe,
|
||||
useDelete,
|
||||
getListQueryOptions,
|
||||
};
|
||||
}
|
||||
52
react-openapi/providers/UploadProvider.tsx
Normal file
52
react-openapi/providers/UploadProvider.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { createContext, useContext, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
|
||||
export interface UploadContextModel {
|
||||
uploadFile: (file: File) => Promise<string | null>;
|
||||
uploading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const UploadContext = createContext<UploadContextModel | undefined>(undefined);
|
||||
|
||||
export const UploadProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const uploadFile = async (file: File): Promise<string | null> => {
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const binary = new Uint8Array(arrayBuffer);
|
||||
|
||||
const res = await api.post("/uploads", binary, {
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
"Content-Disposition": `attachment; filename="${file.name}"`,
|
||||
},
|
||||
});
|
||||
|
||||
return res.data.url as string;
|
||||
} catch (err: any) {
|
||||
console.error("File upload failed:", err);
|
||||
setError(err.response?.data?.detail || "Failed to upload file");
|
||||
return null;
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<UploadContext.Provider value={{ uploadFile, uploading, error }}>
|
||||
{children}
|
||||
</UploadContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useUpload = (): UploadContextModel => {
|
||||
const ctx = useContext(UploadContext);
|
||||
if (!ctx) throw new Error("useUpload must be used within UploadProvider");
|
||||
return ctx;
|
||||
};
|
||||
53
react-openapi/shared-theme/AppTheme.tsx
Normal file
53
react-openapi/shared-theme/AppTheme.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from 'react';
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
import type { ThemeOptions } from '@mui/material/styles';
|
||||
import { inputsCustomizations } from './customizations/inputs';
|
||||
import { dataDisplayCustomizations } from './customizations/dataDisplay';
|
||||
import { feedbackCustomizations } from './customizations/feedback';
|
||||
import { navigationCustomizations } from './customizations/navigation';
|
||||
import { surfacesCustomizations } from './customizations/surfaces';
|
||||
import { colorSchemes, typography, shadows, shape } from './themePrimitives';
|
||||
|
||||
interface AppThemeProps {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* This is for the docs site. You can ignore it or remove it.
|
||||
*/
|
||||
disableCustomTheme?: boolean;
|
||||
themeComponents?: ThemeOptions['components'];
|
||||
}
|
||||
|
||||
export default function AppTheme(props: AppThemeProps) {
|
||||
const { children, disableCustomTheme, themeComponents } = props;
|
||||
const theme = React.useMemo(() => {
|
||||
return disableCustomTheme
|
||||
? {}
|
||||
: createTheme({
|
||||
// For more details about CSS variables configuration, see https://mui.com/material-ui/customization/css-theme-variables/configuration/
|
||||
cssVariables: {
|
||||
colorSchemeSelector: 'data-mui-color-scheme',
|
||||
cssVarPrefix: 'template',
|
||||
},
|
||||
colorSchemes, // Recently added in v6 for building light & dark mode app, see https://mui.com/material-ui/customization/palette/#color-schemes
|
||||
typography,
|
||||
shadows,
|
||||
shape,
|
||||
components: {
|
||||
...inputsCustomizations,
|
||||
...dataDisplayCustomizations,
|
||||
...feedbackCustomizations,
|
||||
...navigationCustomizations,
|
||||
...surfacesCustomizations,
|
||||
...themeComponents,
|
||||
},
|
||||
});
|
||||
}, [disableCustomTheme, themeComponents]);
|
||||
if (disableCustomTheme) {
|
||||
return <React.Fragment>{children}</React.Fragment>;
|
||||
}
|
||||
return (
|
||||
<ThemeProvider theme={theme} disableTransitionOnChange>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
89
react-openapi/shared-theme/ColorModeIconDropdown.tsx
Normal file
89
react-openapi/shared-theme/ColorModeIconDropdown.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import * as React from 'react';
|
||||
import DarkModeIcon from '@mui/icons-material/DarkModeRounded';
|
||||
import LightModeIcon from '@mui/icons-material/LightModeRounded';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton, { IconButtonOwnProps } from '@mui/material/IconButton';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { useColorScheme } from '@mui/material/styles';
|
||||
|
||||
export default function ColorModeIconDropdown(props: IconButtonOwnProps) {
|
||||
const { mode, systemMode, setMode } = useColorScheme();
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
const handleMode = (targetMode: 'system' | 'light' | 'dark') => () => {
|
||||
setMode(targetMode);
|
||||
handleClose();
|
||||
};
|
||||
if (!mode) {
|
||||
return (
|
||||
<Box
|
||||
data-screenshot="toggle-mode"
|
||||
sx={(theme) => ({
|
||||
verticalAlign: 'bottom',
|
||||
display: 'inline-flex',
|
||||
width: '2.25rem',
|
||||
height: '2.25rem',
|
||||
borderRadius: (theme.vars || theme).shape.borderRadius,
|
||||
border: '1px solid',
|
||||
borderColor: (theme.vars || theme).palette.divider,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const resolvedMode = (systemMode || mode) as 'light' | 'dark';
|
||||
const icon = {
|
||||
light: <LightModeIcon />,
|
||||
dark: <DarkModeIcon />,
|
||||
}[resolvedMode];
|
||||
return (
|
||||
<React.Fragment>
|
||||
<IconButton
|
||||
data-screenshot="toggle-mode"
|
||||
onClick={handleClick}
|
||||
disableRipple
|
||||
size="small"
|
||||
aria-controls={open ? 'color-scheme-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
id="account-menu"
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
onClick={handleClose}
|
||||
slotProps={{
|
||||
paper: {
|
||||
variant: 'outlined',
|
||||
elevation: 0,
|
||||
sx: {
|
||||
my: '4px',
|
||||
},
|
||||
},
|
||||
}}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
>
|
||||
<MenuItem selected={mode === 'system'} onClick={handleMode('system')}>
|
||||
System
|
||||
</MenuItem>
|
||||
<MenuItem selected={mode === 'light'} onClick={handleMode('light')}>
|
||||
Light
|
||||
</MenuItem>
|
||||
<MenuItem selected={mode === 'dark'} onClick={handleMode('dark')}>
|
||||
Dark
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
28
react-openapi/shared-theme/ColorModeSelect.tsx
Normal file
28
react-openapi/shared-theme/ColorModeSelect.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from 'react';
|
||||
import { useColorScheme } from '@mui/material/styles';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Select, { SelectProps } from '@mui/material/Select';
|
||||
|
||||
export default function ColorModeSelect(props: SelectProps) {
|
||||
const { mode, setMode } = useColorScheme();
|
||||
if (!mode) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Select
|
||||
value={mode}
|
||||
onChange={(event) =>
|
||||
setMode(event.target.value as 'system' | 'light' | 'dark')
|
||||
}
|
||||
SelectDisplayProps={{
|
||||
// @ts-ignore
|
||||
'data-screenshot': 'toggle-mode',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<MenuItem value="system">System</MenuItem>
|
||||
<MenuItem value="light">Light</MenuItem>
|
||||
<MenuItem value="dark">Dark</MenuItem>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
233
react-openapi/shared-theme/customizations/dataDisplay.tsx
Normal file
233
react-openapi/shared-theme/customizations/dataDisplay.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { Theme, alpha, Components } from '@mui/material/styles';
|
||||
import { svgIconClasses } from '@mui/material/SvgIcon';
|
||||
import { typographyClasses } from '@mui/material/Typography';
|
||||
import { buttonBaseClasses } from '@mui/material/ButtonBase';
|
||||
import { chipClasses } from '@mui/material/Chip';
|
||||
import { iconButtonClasses } from '@mui/material/IconButton';
|
||||
import { gray, red, green } from '../themePrimitives';
|
||||
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export const dataDisplayCustomizations: Components<Theme> = {
|
||||
MuiList: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
padding: '8px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiListItem: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
[`& .${svgIconClasses.root}`]: {
|
||||
width: '1rem',
|
||||
height: '1rem',
|
||||
color: (theme.vars || theme).palette.text.secondary,
|
||||
},
|
||||
[`& .${typographyClasses.root}`]: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
[`& .${buttonBaseClasses.root}`]: {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
padding: '2px 8px',
|
||||
borderRadius: (theme.vars || theme).shape.borderRadius,
|
||||
opacity: 0.7,
|
||||
'&.Mui-selected': {
|
||||
opacity: 1,
|
||||
backgroundColor: alpha(theme.palette.action.selected, 0.3),
|
||||
[`& .${svgIconClasses.root}`]: {
|
||||
color: (theme.vars || theme).palette.text.primary,
|
||||
},
|
||||
'&:focus-visible': {
|
||||
backgroundColor: alpha(theme.palette.action.selected, 0.3),
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(theme.palette.action.selected, 0.5),
|
||||
},
|
||||
},
|
||||
'&:focus-visible': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiListItemText: {
|
||||
styleOverrides: {
|
||||
primary: ({ theme }) => ({
|
||||
fontSize: theme.typography.body2.fontSize,
|
||||
fontWeight: 500,
|
||||
lineHeight: theme.typography.body2.lineHeight,
|
||||
}),
|
||||
secondary: ({ theme }) => ({
|
||||
fontSize: theme.typography.caption.fontSize,
|
||||
lineHeight: theme.typography.caption.lineHeight,
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiListSubheader: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
backgroundColor: 'transparent',
|
||||
padding: '4px 8px',
|
||||
fontSize: theme.typography.caption.fontSize,
|
||||
fontWeight: 500,
|
||||
lineHeight: theme.typography.caption.lineHeight,
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiListItemIcon: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
minWidth: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
defaultProps: {
|
||||
size: 'small',
|
||||
},
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
border: '1px solid',
|
||||
borderRadius: '999px',
|
||||
[`& .${chipClasses.label}`]: {
|
||||
fontWeight: 600,
|
||||
},
|
||||
variants: [
|
||||
{
|
||||
props: {
|
||||
color: 'default',
|
||||
},
|
||||
style: {
|
||||
borderColor: gray[200],
|
||||
backgroundColor: gray[100],
|
||||
[`& .${chipClasses.label}`]: {
|
||||
color: gray[500],
|
||||
},
|
||||
[`& .${chipClasses.icon}`]: {
|
||||
color: gray[500],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
borderColor: gray[700],
|
||||
backgroundColor: gray[800],
|
||||
[`& .${chipClasses.label}`]: {
|
||||
color: gray[300],
|
||||
},
|
||||
[`& .${chipClasses.icon}`]: {
|
||||
color: gray[300],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
props: {
|
||||
color: 'success',
|
||||
},
|
||||
style: {
|
||||
borderColor: green[200],
|
||||
backgroundColor: green[50],
|
||||
[`& .${chipClasses.label}`]: {
|
||||
color: green[500],
|
||||
},
|
||||
[`& .${chipClasses.icon}`]: {
|
||||
color: green[500],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
borderColor: green[800],
|
||||
backgroundColor: green[900],
|
||||
[`& .${chipClasses.label}`]: {
|
||||
color: green[300],
|
||||
},
|
||||
[`& .${chipClasses.icon}`]: {
|
||||
color: green[300],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
props: {
|
||||
color: 'error',
|
||||
},
|
||||
style: {
|
||||
borderColor: red[100],
|
||||
backgroundColor: red[50],
|
||||
[`& .${chipClasses.label}`]: {
|
||||
color: red[500],
|
||||
},
|
||||
[`& .${chipClasses.icon}`]: {
|
||||
color: red[500],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
borderColor: red[800],
|
||||
backgroundColor: red[900],
|
||||
[`& .${chipClasses.label}`]: {
|
||||
color: red[200],
|
||||
},
|
||||
[`& .${chipClasses.icon}`]: {
|
||||
color: red[300],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
props: { size: 'small' },
|
||||
style: {
|
||||
maxHeight: 20,
|
||||
[`& .${chipClasses.label}`]: {
|
||||
fontSize: theme.typography.caption.fontSize,
|
||||
},
|
||||
[`& .${svgIconClasses.root}`]: {
|
||||
fontSize: theme.typography.caption.fontSize,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
props: { size: 'medium' },
|
||||
style: {
|
||||
[`& .${chipClasses.label}`]: {
|
||||
fontSize: theme.typography.caption.fontSize,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiTablePagination: {
|
||||
styleOverrides: {
|
||||
actions: {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
marginRight: 6,
|
||||
[`& .${iconButtonClasses.root}`]: {
|
||||
minWidth: 0,
|
||||
width: 36,
|
||||
height: 36,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiIcon: {
|
||||
defaultProps: {
|
||||
fontSize: 'small',
|
||||
},
|
||||
styleOverrides: {
|
||||
root: {
|
||||
variants: [
|
||||
{
|
||||
props: {
|
||||
fontSize: 'small',
|
||||
},
|
||||
style: {
|
||||
fontSize: '1rem',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
46
react-openapi/shared-theme/customizations/feedback.tsx
Normal file
46
react-openapi/shared-theme/customizations/feedback.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Theme, alpha, Components } from '@mui/material/styles';
|
||||
import { gray, orange } from '../themePrimitives';
|
||||
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export const feedbackCustomizations: Components<Theme> = {
|
||||
MuiAlert: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
borderRadius: 10,
|
||||
backgroundColor: orange[100],
|
||||
color: (theme.vars || theme).palette.text.primary,
|
||||
border: `1px solid ${alpha(orange[300], 0.5)}`,
|
||||
'& .MuiAlert-icon': {
|
||||
color: orange[500],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: `${alpha(orange[900], 0.5)}`,
|
||||
border: `1px solid ${alpha(orange[800], 0.5)}`,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiDialog: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
'& .MuiDialog-paper': {
|
||||
borderRadius: '10px',
|
||||
border: '1px solid',
|
||||
borderColor: (theme.vars || theme).palette.divider,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiLinearProgress: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
height: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: gray[200],
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: gray[800],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
445
react-openapi/shared-theme/customizations/inputs.tsx
Normal file
445
react-openapi/shared-theme/customizations/inputs.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
import * as React from 'react';
|
||||
import { alpha, Theme, Components } from '@mui/material/styles';
|
||||
import { outlinedInputClasses } from '@mui/material/OutlinedInput';
|
||||
import { svgIconClasses } from '@mui/material/SvgIcon';
|
||||
import { toggleButtonGroupClasses } from '@mui/material/ToggleButtonGroup';
|
||||
import { toggleButtonClasses } from '@mui/material/ToggleButton';
|
||||
import CheckBoxOutlineBlankRoundedIcon from '@mui/icons-material/CheckBoxOutlineBlankRounded';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
import RemoveRoundedIcon from '@mui/icons-material/RemoveRounded';
|
||||
import { gray, brand } from '../themePrimitives';
|
||||
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export const inputsCustomizations: Components<Theme> = {
|
||||
MuiButtonBase: {
|
||||
defaultProps: {
|
||||
disableTouchRipple: true,
|
||||
disableRipple: true,
|
||||
},
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
boxSizing: 'border-box',
|
||||
transition: 'all 100ms ease-in',
|
||||
'&:focus-visible': {
|
||||
outline: `3px solid ${alpha(theme.palette.primary.main, 0.5)}`,
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
boxShadow: 'none',
|
||||
borderRadius: (theme.vars || theme).shape.borderRadius,
|
||||
textTransform: 'none',
|
||||
variants: [
|
||||
{
|
||||
props: {
|
||||
size: 'small',
|
||||
},
|
||||
style: {
|
||||
height: '2.25rem',
|
||||
padding: '8px 12px',
|
||||
},
|
||||
},
|
||||
{
|
||||
props: {
|
||||
size: 'medium',
|
||||
},
|
||||
style: {
|
||||
height: '2.5rem', // 40px
|
||||
},
|
||||
},
|
||||
{
|
||||
props: {
|
||||
color: 'primary',
|
||||
variant: 'contained',
|
||||
},
|
||||
style: {
|
||||
color: 'white',
|
||||
backgroundColor: gray[900],
|
||||
backgroundImage: `linear-gradient(to bottom, ${gray[700]}, ${gray[800]})`,
|
||||
boxShadow: `inset 0 1px 0 ${gray[600]}, inset 0 -1px 0 1px hsl(220, 0%, 0%)`,
|
||||
border: `1px solid ${gray[700]}`,
|
||||
'&:hover': {
|
||||
backgroundImage: 'none',
|
||||
backgroundColor: gray[700],
|
||||
boxShadow: 'none',
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: gray[800],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
color: 'black',
|
||||
backgroundColor: gray[50],
|
||||
backgroundImage: `linear-gradient(to bottom, ${gray[100]}, ${gray[50]})`,
|
||||
boxShadow: 'inset 0 -1px 0 hsl(220, 30%, 80%)',
|
||||
border: `1px solid ${gray[50]}`,
|
||||
'&:hover': {
|
||||
backgroundImage: 'none',
|
||||
backgroundColor: gray[300],
|
||||
boxShadow: 'none',
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: gray[400],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
props: {
|
||||
color: 'secondary',
|
||||
variant: 'contained',
|
||||
},
|
||||
style: {
|
||||
color: 'white',
|
||||
backgroundColor: brand[300],
|
||||
backgroundImage: `linear-gradient(to bottom, ${alpha(brand[400], 0.8)}, ${brand[500]})`,
|
||||
boxShadow: `inset 0 2px 0 ${alpha(brand[200], 0.2)}, inset 0 -2px 0 ${alpha(brand[700], 0.4)}`,
|
||||
border: `1px solid ${brand[500]}`,
|
||||
'&:hover': {
|
||||
backgroundColor: brand[700],
|
||||
boxShadow: 'none',
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: brand[700],
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
props: {
|
||||
variant: 'outlined',
|
||||
},
|
||||
style: {
|
||||
color: (theme.vars || theme).palette.text.primary,
|
||||
border: '1px solid',
|
||||
borderColor: gray[200],
|
||||
backgroundColor: alpha(gray[50], 0.3),
|
||||
'&:hover': {
|
||||
backgroundColor: gray[100],
|
||||
borderColor: gray[300],
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: gray[200],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: gray[800],
|
||||
borderColor: gray[700],
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: gray[900],
|
||||
borderColor: gray[600],
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: gray[900],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
props: {
|
||||
color: 'secondary',
|
||||
variant: 'outlined',
|
||||
},
|
||||
style: {
|
||||
color: brand[700],
|
||||
border: '1px solid',
|
||||
borderColor: brand[200],
|
||||
backgroundColor: brand[50],
|
||||
'&:hover': {
|
||||
backgroundColor: brand[100],
|
||||
borderColor: brand[400],
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: alpha(brand[200], 0.7),
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
color: brand[50],
|
||||
border: '1px solid',
|
||||
borderColor: brand[900],
|
||||
backgroundColor: alpha(brand[900], 0.3),
|
||||
'&:hover': {
|
||||
borderColor: brand[700],
|
||||
backgroundColor: alpha(brand[900], 0.6),
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: alpha(brand[900], 0.5),
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
props: {
|
||||
variant: 'text',
|
||||
},
|
||||
style: {
|
||||
color: gray[600],
|
||||
'&:hover': {
|
||||
backgroundColor: gray[100],
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: gray[200],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
color: gray[50],
|
||||
'&:hover': {
|
||||
backgroundColor: gray[700],
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: alpha(gray[700], 0.7),
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
props: {
|
||||
color: 'secondary',
|
||||
variant: 'text',
|
||||
},
|
||||
style: {
|
||||
color: brand[700],
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(brand[100], 0.5),
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: alpha(brand[200], 0.7),
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
color: brand[100],
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(brand[900], 0.5),
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: alpha(brand[900], 0.3),
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiIconButton: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
boxShadow: 'none',
|
||||
borderRadius: (theme.vars || theme).shape.borderRadius,
|
||||
textTransform: 'none',
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
letterSpacing: 0,
|
||||
color: (theme.vars || theme).palette.text.primary,
|
||||
border: '1px solid ',
|
||||
borderColor: gray[200],
|
||||
backgroundColor: alpha(gray[50], 0.3),
|
||||
'&:hover': {
|
||||
backgroundColor: gray[100],
|
||||
borderColor: gray[300],
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: gray[200],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: gray[800],
|
||||
borderColor: gray[700],
|
||||
'&:hover': {
|
||||
backgroundColor: gray[900],
|
||||
borderColor: gray[600],
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: gray[900],
|
||||
},
|
||||
}),
|
||||
variants: [
|
||||
{
|
||||
props: {
|
||||
size: 'small',
|
||||
},
|
||||
style: {
|
||||
width: '2.25rem',
|
||||
height: '2.25rem',
|
||||
padding: '0.25rem',
|
||||
[`& .${svgIconClasses.root}`]: { fontSize: '1rem' },
|
||||
},
|
||||
},
|
||||
{
|
||||
props: {
|
||||
size: 'medium',
|
||||
},
|
||||
style: {
|
||||
width: '2.5rem',
|
||||
height: '2.5rem',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiToggleButtonGroup: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
borderRadius: '10px',
|
||||
boxShadow: `0 4px 16px ${alpha(gray[400], 0.2)}`,
|
||||
[`& .${toggleButtonGroupClasses.selected}`]: {
|
||||
color: brand[500],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
[`& .${toggleButtonGroupClasses.selected}`]: {
|
||||
color: '#fff',
|
||||
},
|
||||
boxShadow: `0 4px 16px ${alpha(brand[700], 0.5)}`,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiToggleButton: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
padding: '12px 16px',
|
||||
textTransform: 'none',
|
||||
borderRadius: '10px',
|
||||
fontWeight: 500,
|
||||
...theme.applyStyles('dark', {
|
||||
color: gray[400],
|
||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
|
||||
[`&.${toggleButtonClasses.selected}`]: {
|
||||
color: brand[300],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiCheckbox: {
|
||||
defaultProps: {
|
||||
disableRipple: true,
|
||||
icon: (
|
||||
<CheckBoxOutlineBlankRoundedIcon sx={{ color: 'hsla(210, 0%, 0%, 0.0)' }} />
|
||||
),
|
||||
checkedIcon: <CheckRoundedIcon sx={{ height: 14, width: 14 }} />,
|
||||
indeterminateIcon: <RemoveRoundedIcon sx={{ height: 14, width: 14 }} />,
|
||||
},
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
margin: 10,
|
||||
height: 16,
|
||||
width: 16,
|
||||
borderRadius: 5,
|
||||
border: '1px solid ',
|
||||
borderColor: alpha(gray[300], 0.8),
|
||||
boxShadow: '0 0 0 1.5px hsla(210, 0%, 0%, 0.04) inset',
|
||||
backgroundColor: alpha(gray[100], 0.4),
|
||||
transition: 'border-color, background-color, 120ms ease-in',
|
||||
'&:hover': {
|
||||
borderColor: brand[300],
|
||||
},
|
||||
'&.Mui-focusVisible': {
|
||||
outline: `3px solid ${alpha(brand[500], 0.5)}`,
|
||||
outlineOffset: '2px',
|
||||
borderColor: brand[400],
|
||||
},
|
||||
'&.Mui-checked': {
|
||||
color: 'white',
|
||||
backgroundColor: brand[500],
|
||||
borderColor: brand[500],
|
||||
boxShadow: `none`,
|
||||
'&:hover': {
|
||||
backgroundColor: brand[600],
|
||||
},
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
borderColor: alpha(gray[700], 0.8),
|
||||
boxShadow: '0 0 0 1.5px hsl(210, 0%, 0%) inset',
|
||||
backgroundColor: alpha(gray[900], 0.8),
|
||||
'&:hover': {
|
||||
borderColor: brand[300],
|
||||
},
|
||||
'&.Mui-focusVisible': {
|
||||
borderColor: brand[400],
|
||||
outline: `3px solid ${alpha(brand[500], 0.5)}`,
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiInputBase: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
border: 'none',
|
||||
},
|
||||
input: {
|
||||
'&::placeholder': {
|
||||
opacity: 0.7,
|
||||
color: gray[500],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiOutlinedInput: {
|
||||
styleOverrides: {
|
||||
input: {
|
||||
padding: 0,
|
||||
},
|
||||
root: ({ theme }) => ({
|
||||
padding: '8px 12px',
|
||||
color: (theme.vars || theme).palette.text.primary,
|
||||
borderRadius: (theme.vars || theme).shape.borderRadius,
|
||||
border: `1px solid ${(theme.vars || theme).palette.divider}`,
|
||||
backgroundColor: (theme.vars || theme).palette.background.default,
|
||||
transition: 'border 120ms ease-in',
|
||||
'&:hover': {
|
||||
borderColor: gray[400],
|
||||
},
|
||||
[`&.${outlinedInputClasses.focused}`]: {
|
||||
outline: `3px solid ${alpha(brand[500], 0.5)}`,
|
||||
borderColor: brand[400],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
'&:hover': {
|
||||
borderColor: gray[500],
|
||||
},
|
||||
}),
|
||||
variants: [
|
||||
{
|
||||
props: {
|
||||
size: 'small',
|
||||
},
|
||||
style: {
|
||||
height: '2.25rem',
|
||||
},
|
||||
},
|
||||
{
|
||||
props: {
|
||||
size: 'medium',
|
||||
},
|
||||
style: {
|
||||
height: '2.5rem',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
notchedOutline: {
|
||||
border: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiInputAdornment: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
color: (theme.vars || theme).palette.grey[500],
|
||||
...theme.applyStyles('dark', {
|
||||
color: (theme.vars || theme).palette.grey[400],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiFormLabel: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
typography: theme.typography.caption,
|
||||
marginBottom: 8,
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
279
react-openapi/shared-theme/customizations/navigation.tsx
Normal file
279
react-openapi/shared-theme/customizations/navigation.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import * as React from 'react';
|
||||
import { Theme, alpha, Components } from '@mui/material/styles';
|
||||
import { SvgIconProps } from '@mui/material/SvgIcon';
|
||||
import { buttonBaseClasses } from '@mui/material/ButtonBase';
|
||||
import { dividerClasses } from '@mui/material/Divider';
|
||||
import { menuItemClasses } from '@mui/material/MenuItem';
|
||||
import { selectClasses } from '@mui/material/Select';
|
||||
import { tabClasses } from '@mui/material/Tab';
|
||||
import UnfoldMoreRoundedIcon from '@mui/icons-material/UnfoldMoreRounded';
|
||||
import { gray, brand } from '../themePrimitives';
|
||||
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export const navigationCustomizations: Components<Theme> = {
|
||||
MuiMenuItem: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
borderRadius: (theme.vars || theme).shape.borderRadius,
|
||||
padding: '6px 8px',
|
||||
[`&.${menuItemClasses.focusVisible}`]: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
[`&.${menuItemClasses.selected}`]: {
|
||||
[`&.${menuItemClasses.focusVisible}`]: {
|
||||
backgroundColor: alpha(theme.palette.action.selected, 0.3),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiMenu: {
|
||||
styleOverrides: {
|
||||
list: {
|
||||
gap: '0px',
|
||||
[`&.${dividerClasses.root}`]: {
|
||||
margin: '0 -8px',
|
||||
},
|
||||
},
|
||||
paper: ({ theme }) => ({
|
||||
marginTop: '4px',
|
||||
borderRadius: (theme.vars || theme).shape.borderRadius,
|
||||
border: `1px solid ${(theme.vars || theme).palette.divider}`,
|
||||
backgroundImage: 'none',
|
||||
background: 'hsl(0, 0%, 100%)',
|
||||
boxShadow:
|
||||
'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px',
|
||||
[`& .${buttonBaseClasses.root}`]: {
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: alpha(theme.palette.action.selected, 0.3),
|
||||
},
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
background: gray[900],
|
||||
boxShadow:
|
||||
'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px',
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiSelect: {
|
||||
defaultProps: {
|
||||
IconComponent: React.forwardRef<SVGSVGElement, SvgIconProps>((props, ref) => (
|
||||
<UnfoldMoreRoundedIcon fontSize="small" {...props} ref={ref} />
|
||||
)),
|
||||
},
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
borderRadius: (theme.vars || theme).shape.borderRadius,
|
||||
border: '1px solid',
|
||||
borderColor: gray[200],
|
||||
backgroundColor: (theme.vars || theme).palette.background.paper,
|
||||
boxShadow: `inset 0 1px 0 1px hsla(220, 0%, 100%, 0.6), inset 0 -1px 0 1px hsla(220, 35%, 90%, 0.5)`,
|
||||
'&:hover': {
|
||||
borderColor: gray[300],
|
||||
backgroundColor: (theme.vars || theme).palette.background.paper,
|
||||
boxShadow: 'none',
|
||||
},
|
||||
[`&.${selectClasses.focused}`]: {
|
||||
outlineOffset: 0,
|
||||
borderColor: gray[400],
|
||||
},
|
||||
'&:before, &:after': {
|
||||
display: 'none',
|
||||
},
|
||||
|
||||
...theme.applyStyles('dark', {
|
||||
borderRadius: (theme.vars || theme).shape.borderRadius,
|
||||
borderColor: gray[700],
|
||||
backgroundColor: (theme.vars || theme).palette.background.paper,
|
||||
boxShadow: `inset 0 1px 0 1px ${alpha(gray[700], 0.15)}, inset 0 -1px 0 1px hsla(220, 0%, 0%, 0.7)`,
|
||||
'&:hover': {
|
||||
borderColor: alpha(gray[700], 0.7),
|
||||
backgroundColor: (theme.vars || theme).palette.background.paper,
|
||||
boxShadow: 'none',
|
||||
},
|
||||
[`&.${selectClasses.focused}`]: {
|
||||
outlineOffset: 0,
|
||||
borderColor: gray[900],
|
||||
},
|
||||
'&:before, &:after': {
|
||||
display: 'none',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
select: ({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
...theme.applyStyles('dark', {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
'&:focus-visible': {
|
||||
backgroundColor: gray[900],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiLink: {
|
||||
defaultProps: {
|
||||
underline: 'none',
|
||||
},
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
color: (theme.vars || theme).palette.text.primary,
|
||||
fontWeight: 500,
|
||||
position: 'relative',
|
||||
textDecoration: 'none',
|
||||
width: 'fit-content',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '1px',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
backgroundColor: (theme.vars || theme).palette.text.secondary,
|
||||
opacity: 0.3,
|
||||
transition: 'width 0.3s ease, opacity 0.3s ease',
|
||||
},
|
||||
'&:hover::before': {
|
||||
width: 0,
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: `3px solid ${alpha(brand[500], 0.5)}`,
|
||||
outlineOffset: '4px',
|
||||
borderRadius: '2px',
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiDrawer: {
|
||||
styleOverrides: {
|
||||
paper: ({ theme }) => ({
|
||||
backgroundColor: (theme.vars || theme).palette.background.default,
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiPaginationItem: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
'&.Mui-selected': {
|
||||
color: 'white',
|
||||
backgroundColor: (theme.vars || theme).palette.grey[900],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
'&.Mui-selected': {
|
||||
color: 'black',
|
||||
backgroundColor: (theme.vars || theme).palette.grey[50],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiTabs: {
|
||||
styleOverrides: {
|
||||
root: { minHeight: 'fit-content' },
|
||||
indicator: ({ theme }) => ({
|
||||
backgroundColor: (theme.vars || theme).palette.grey[800],
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: (theme.vars || theme).palette.grey[200],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiTab: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
padding: '6px 8px',
|
||||
marginBottom: '8px',
|
||||
textTransform: 'none',
|
||||
minWidth: 'fit-content',
|
||||
minHeight: 'fit-content',
|
||||
color: (theme.vars || theme).palette.text.secondary,
|
||||
borderRadius: (theme.vars || theme).shape.borderRadius,
|
||||
border: '1px solid',
|
||||
borderColor: 'transparent',
|
||||
':hover': {
|
||||
color: (theme.vars || theme).palette.text.primary,
|
||||
backgroundColor: gray[100],
|
||||
borderColor: gray[200],
|
||||
},
|
||||
[`&.${tabClasses.selected}`]: {
|
||||
color: gray[900],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
':hover': {
|
||||
color: (theme.vars || theme).palette.text.primary,
|
||||
backgroundColor: gray[800],
|
||||
borderColor: gray[700],
|
||||
},
|
||||
[`&.${tabClasses.selected}`]: {
|
||||
color: '#fff',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiStepConnector: {
|
||||
styleOverrides: {
|
||||
line: ({ theme }) => ({
|
||||
borderTop: '1px solid',
|
||||
borderColor: (theme.vars || theme).palette.divider,
|
||||
flex: 1,
|
||||
borderRadius: '99px',
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiStepIcon: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
color: 'transparent',
|
||||
border: `1px solid ${gray[400]}`,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
'& text': {
|
||||
display: 'none',
|
||||
},
|
||||
'&.Mui-active': {
|
||||
border: 'none',
|
||||
color: (theme.vars || theme).palette.primary.main,
|
||||
},
|
||||
'&.Mui-completed': {
|
||||
border: 'none',
|
||||
color: (theme.vars || theme).palette.success.main,
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
border: `1px solid ${gray[700]}`,
|
||||
'&.Mui-active': {
|
||||
border: 'none',
|
||||
color: (theme.vars || theme).palette.primary.light,
|
||||
},
|
||||
'&.Mui-completed': {
|
||||
border: 'none',
|
||||
color: (theme.vars || theme).palette.success.light,
|
||||
},
|
||||
}),
|
||||
variants: [
|
||||
{
|
||||
props: { completed: true },
|
||||
style: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiStepLabel: {
|
||||
styleOverrides: {
|
||||
label: ({ theme }) => ({
|
||||
'&.Mui-completed': {
|
||||
opacity: 0.6,
|
||||
...theme.applyStyles('dark', { opacity: 0.5 }),
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
113
react-openapi/shared-theme/customizations/surfaces.ts
Normal file
113
react-openapi/shared-theme/customizations/surfaces.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { alpha, Theme, Components } from '@mui/material/styles';
|
||||
import { gray } from '../themePrimitives';
|
||||
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export const surfacesCustomizations: Components<Theme> = {
|
||||
MuiAccordion: {
|
||||
defaultProps: {
|
||||
elevation: 0,
|
||||
disableGutters: true,
|
||||
},
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
padding: 4,
|
||||
overflow: 'clip',
|
||||
backgroundColor: (theme.vars || theme).palette.background.default,
|
||||
border: '1px solid',
|
||||
borderColor: (theme.vars || theme).palette.divider,
|
||||
':before': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
'&:not(:last-of-type)': {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
'&:first-of-type': {
|
||||
borderTopLeftRadius: (theme.vars || theme).shape.borderRadius,
|
||||
borderTopRightRadius: (theme.vars || theme).shape.borderRadius,
|
||||
},
|
||||
'&:last-of-type': {
|
||||
borderBottomLeftRadius: (theme.vars || theme).shape.borderRadius,
|
||||
borderBottomRightRadius: (theme.vars || theme).shape.borderRadius,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiAccordionSummary: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
'&:hover': { backgroundColor: gray[50] },
|
||||
'&:focus-visible': { backgroundColor: 'transparent' },
|
||||
...theme.applyStyles('dark', {
|
||||
'&:hover': { backgroundColor: gray[800] },
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiAccordionDetails: {
|
||||
styleOverrides: {
|
||||
root: { mb: 20, border: 'none' },
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
defaultProps: {
|
||||
elevation: 0,
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => {
|
||||
return {
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
transition: 'all 100ms ease',
|
||||
backgroundColor: gray[50],
|
||||
borderRadius: (theme.vars || theme).shape.borderRadius,
|
||||
border: `1px solid ${(theme.vars || theme).palette.divider}`,
|
||||
boxShadow: 'none',
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: gray[800],
|
||||
}),
|
||||
variants: [
|
||||
{
|
||||
props: {
|
||||
variant: 'outlined',
|
||||
},
|
||||
style: {
|
||||
border: `1px solid ${(theme.vars || theme).palette.divider}`,
|
||||
boxShadow: 'none',
|
||||
background: 'hsl(0, 0%, 100%)',
|
||||
...theme.applyStyles('dark', {
|
||||
background: alpha(gray[900], 0.4),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCardContent: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
padding: 0,
|
||||
'&:last-child': { paddingBottom: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCardHeader: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
padding: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCardActions: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
padding: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
403
react-openapi/shared-theme/themePrimitives.ts
Normal file
403
react-openapi/shared-theme/themePrimitives.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import { createTheme, alpha, PaletteMode, Shadows } from '@mui/material/styles';
|
||||
|
||||
declare module '@mui/material/Paper' {
|
||||
interface PaperPropsVariantOverrides {
|
||||
highlighted: true;
|
||||
}
|
||||
}
|
||||
declare module '@mui/material/styles' {
|
||||
interface ColorRange {
|
||||
50: string;
|
||||
100: string;
|
||||
200: string;
|
||||
300: string;
|
||||
400: string;
|
||||
500: string;
|
||||
600: string;
|
||||
700: string;
|
||||
800: string;
|
||||
900: string;
|
||||
}
|
||||
|
||||
interface PaletteColor extends ColorRange {}
|
||||
|
||||
interface Palette {
|
||||
baseShadow: string;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultTheme = createTheme();
|
||||
|
||||
const customShadows: Shadows = [...defaultTheme.shadows];
|
||||
|
||||
export const brand = {
|
||||
50: 'hsl(210, 100%, 95%)',
|
||||
100: 'hsl(210, 100%, 92%)',
|
||||
200: 'hsl(210, 100%, 80%)',
|
||||
300: 'hsl(210, 100%, 65%)',
|
||||
400: 'hsl(210, 98%, 48%)',
|
||||
500: 'hsl(210, 98%, 42%)',
|
||||
600: 'hsl(210, 98%, 55%)',
|
||||
700: 'hsl(210, 100%, 35%)',
|
||||
800: 'hsl(210, 100%, 16%)',
|
||||
900: 'hsl(210, 100%, 21%)',
|
||||
};
|
||||
|
||||
export const gray = {
|
||||
50: 'hsl(220, 35%, 97%)',
|
||||
100: 'hsl(220, 30%, 94%)',
|
||||
200: 'hsl(220, 20%, 88%)',
|
||||
300: 'hsl(220, 20%, 80%)',
|
||||
400: 'hsl(220, 20%, 65%)',
|
||||
500: 'hsl(220, 20%, 42%)',
|
||||
600: 'hsl(220, 20%, 35%)',
|
||||
700: 'hsl(220, 20%, 25%)',
|
||||
800: 'hsl(220, 30%, 6%)',
|
||||
900: 'hsl(220, 35%, 3%)',
|
||||
};
|
||||
|
||||
export const green = {
|
||||
50: 'hsl(120, 80%, 98%)',
|
||||
100: 'hsl(120, 75%, 94%)',
|
||||
200: 'hsl(120, 75%, 87%)',
|
||||
300: 'hsl(120, 61%, 77%)',
|
||||
400: 'hsl(120, 44%, 53%)',
|
||||
500: 'hsl(120, 59%, 30%)',
|
||||
600: 'hsl(120, 70%, 25%)',
|
||||
700: 'hsl(120, 75%, 16%)',
|
||||
800: 'hsl(120, 84%, 10%)',
|
||||
900: 'hsl(120, 87%, 6%)',
|
||||
};
|
||||
|
||||
export const orange = {
|
||||
50: 'hsl(45, 100%, 97%)',
|
||||
100: 'hsl(45, 92%, 90%)',
|
||||
200: 'hsl(45, 94%, 80%)',
|
||||
300: 'hsl(45, 90%, 65%)',
|
||||
400: 'hsl(45, 90%, 40%)',
|
||||
500: 'hsl(45, 90%, 35%)',
|
||||
600: 'hsl(45, 91%, 25%)',
|
||||
700: 'hsl(45, 94%, 20%)',
|
||||
800: 'hsl(45, 95%, 16%)',
|
||||
900: 'hsl(45, 93%, 12%)',
|
||||
};
|
||||
|
||||
export const red = {
|
||||
50: 'hsl(0, 100%, 97%)',
|
||||
100: 'hsl(0, 92%, 90%)',
|
||||
200: 'hsl(0, 94%, 80%)',
|
||||
300: 'hsl(0, 90%, 65%)',
|
||||
400: 'hsl(0, 90%, 40%)',
|
||||
500: 'hsl(0, 90%, 30%)',
|
||||
600: 'hsl(0, 91%, 25%)',
|
||||
700: 'hsl(0, 94%, 18%)',
|
||||
800: 'hsl(0, 95%, 12%)',
|
||||
900: 'hsl(0, 93%, 6%)',
|
||||
};
|
||||
|
||||
export const getDesignTokens = (mode: PaletteMode) => {
|
||||
customShadows[1] =
|
||||
mode === 'dark'
|
||||
? 'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px'
|
||||
: 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px';
|
||||
|
||||
return {
|
||||
palette: {
|
||||
mode,
|
||||
primary: {
|
||||
light: brand[200],
|
||||
main: brand[400],
|
||||
dark: brand[700],
|
||||
contrastText: brand[50],
|
||||
...(mode === 'dark' && {
|
||||
contrastText: brand[50],
|
||||
light: brand[300],
|
||||
main: brand[400],
|
||||
dark: brand[700],
|
||||
}),
|
||||
},
|
||||
info: {
|
||||
light: brand[100],
|
||||
main: brand[300],
|
||||
dark: brand[600],
|
||||
contrastText: gray[50],
|
||||
...(mode === 'dark' && {
|
||||
contrastText: brand[300],
|
||||
light: brand[500],
|
||||
main: brand[700],
|
||||
dark: brand[900],
|
||||
}),
|
||||
},
|
||||
warning: {
|
||||
light: orange[300],
|
||||
main: orange[400],
|
||||
dark: orange[800],
|
||||
...(mode === 'dark' && {
|
||||
light: orange[400],
|
||||
main: orange[500],
|
||||
dark: orange[700],
|
||||
}),
|
||||
},
|
||||
error: {
|
||||
light: red[300],
|
||||
main: red[400],
|
||||
dark: red[800],
|
||||
...(mode === 'dark' && {
|
||||
light: red[400],
|
||||
main: red[500],
|
||||
dark: red[700],
|
||||
}),
|
||||
},
|
||||
success: {
|
||||
light: green[300],
|
||||
main: green[400],
|
||||
dark: green[800],
|
||||
...(mode === 'dark' && {
|
||||
light: green[400],
|
||||
main: green[500],
|
||||
dark: green[700],
|
||||
}),
|
||||
},
|
||||
grey: {
|
||||
...gray,
|
||||
},
|
||||
divider: mode === 'dark' ? alpha(gray[700], 0.6) : alpha(gray[300], 0.4),
|
||||
background: {
|
||||
default: 'hsl(0, 0%, 99%)',
|
||||
paper: 'hsl(220, 35%, 97%)',
|
||||
...(mode === 'dark' && { default: gray[900], paper: 'hsl(220, 30%, 7%)' }),
|
||||
},
|
||||
text: {
|
||||
primary: gray[800],
|
||||
secondary: gray[600],
|
||||
warning: orange[400],
|
||||
...(mode === 'dark' && { primary: 'hsl(0, 0%, 100%)', secondary: gray[400] }),
|
||||
},
|
||||
action: {
|
||||
hover: alpha(gray[200], 0.2),
|
||||
selected: `${alpha(gray[200], 0.3)}`,
|
||||
...(mode === 'dark' && {
|
||||
hover: alpha(gray[600], 0.2),
|
||||
selected: alpha(gray[600], 0.3),
|
||||
}),
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
h1: {
|
||||
fontSize: defaultTheme.typography.pxToRem(48),
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
h2: {
|
||||
fontSize: defaultTheme.typography.pxToRem(36),
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
h3: {
|
||||
fontSize: defaultTheme.typography.pxToRem(30),
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
h4: {
|
||||
fontSize: defaultTheme.typography.pxToRem(24),
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
h5: {
|
||||
fontSize: defaultTheme.typography.pxToRem(20),
|
||||
fontWeight: 600,
|
||||
},
|
||||
h6: {
|
||||
fontSize: defaultTheme.typography.pxToRem(18),
|
||||
fontWeight: 600,
|
||||
},
|
||||
subtitle1: {
|
||||
fontSize: defaultTheme.typography.pxToRem(18),
|
||||
},
|
||||
subtitle2: {
|
||||
fontSize: defaultTheme.typography.pxToRem(14),
|
||||
fontWeight: 500,
|
||||
},
|
||||
body1: {
|
||||
fontSize: defaultTheme.typography.pxToRem(14),
|
||||
},
|
||||
body2: {
|
||||
fontSize: defaultTheme.typography.pxToRem(14),
|
||||
fontWeight: 400,
|
||||
},
|
||||
caption: {
|
||||
fontSize: defaultTheme.typography.pxToRem(12),
|
||||
fontWeight: 400,
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
shadows: customShadows,
|
||||
};
|
||||
};
|
||||
|
||||
export const colorSchemes = {
|
||||
light: {
|
||||
palette: {
|
||||
primary: {
|
||||
light: brand[200],
|
||||
main: brand[400],
|
||||
dark: brand[700],
|
||||
contrastText: brand[50],
|
||||
},
|
||||
info: {
|
||||
light: brand[100],
|
||||
main: brand[300],
|
||||
dark: brand[600],
|
||||
contrastText: gray[50],
|
||||
},
|
||||
warning: {
|
||||
light: orange[300],
|
||||
main: orange[400],
|
||||
dark: orange[800],
|
||||
},
|
||||
error: {
|
||||
light: red[300],
|
||||
main: red[400],
|
||||
dark: red[800],
|
||||
},
|
||||
success: {
|
||||
light: green[300],
|
||||
main: green[400],
|
||||
dark: green[800],
|
||||
},
|
||||
grey: {
|
||||
...gray,
|
||||
},
|
||||
divider: alpha(gray[300], 0.4),
|
||||
background: {
|
||||
default: 'hsl(0, 0%, 99%)',
|
||||
paper: 'hsl(220, 35%, 97%)',
|
||||
},
|
||||
text: {
|
||||
primary: gray[800],
|
||||
secondary: gray[600],
|
||||
warning: orange[400],
|
||||
},
|
||||
action: {
|
||||
hover: alpha(gray[200], 0.2),
|
||||
selected: `${alpha(gray[200], 0.3)}`,
|
||||
},
|
||||
baseShadow:
|
||||
'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px',
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
primary: {
|
||||
contrastText: brand[50],
|
||||
light: brand[300],
|
||||
main: brand[400],
|
||||
dark: brand[700],
|
||||
},
|
||||
info: {
|
||||
contrastText: brand[300],
|
||||
light: brand[500],
|
||||
main: brand[700],
|
||||
dark: brand[900],
|
||||
},
|
||||
warning: {
|
||||
light: orange[400],
|
||||
main: orange[500],
|
||||
dark: orange[700],
|
||||
},
|
||||
error: {
|
||||
light: red[400],
|
||||
main: red[500],
|
||||
dark: red[700],
|
||||
},
|
||||
success: {
|
||||
light: green[400],
|
||||
main: green[500],
|
||||
dark: green[700],
|
||||
},
|
||||
grey: {
|
||||
...gray,
|
||||
},
|
||||
divider: alpha(gray[700], 0.6),
|
||||
background: {
|
||||
default: gray[900],
|
||||
paper: 'hsl(220, 30%, 7%)',
|
||||
},
|
||||
text: {
|
||||
primary: 'hsl(0, 0%, 100%)',
|
||||
secondary: gray[400],
|
||||
},
|
||||
action: {
|
||||
hover: alpha(gray[600], 0.2),
|
||||
selected: alpha(gray[600], 0.3),
|
||||
},
|
||||
baseShadow:
|
||||
'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const typography = {
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
h1: {
|
||||
fontSize: defaultTheme.typography.pxToRem(48),
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
h2: {
|
||||
fontSize: defaultTheme.typography.pxToRem(36),
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
h3: {
|
||||
fontSize: defaultTheme.typography.pxToRem(30),
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
h4: {
|
||||
fontSize: defaultTheme.typography.pxToRem(24),
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
h5: {
|
||||
fontSize: defaultTheme.typography.pxToRem(20),
|
||||
fontWeight: 600,
|
||||
},
|
||||
h6: {
|
||||
fontSize: defaultTheme.typography.pxToRem(18),
|
||||
fontWeight: 600,
|
||||
},
|
||||
subtitle1: {
|
||||
fontSize: defaultTheme.typography.pxToRem(18),
|
||||
},
|
||||
subtitle2: {
|
||||
fontSize: defaultTheme.typography.pxToRem(14),
|
||||
fontWeight: 500,
|
||||
},
|
||||
body1: {
|
||||
fontSize: defaultTheme.typography.pxToRem(14),
|
||||
},
|
||||
body2: {
|
||||
fontSize: defaultTheme.typography.pxToRem(14),
|
||||
fontWeight: 400,
|
||||
},
|
||||
caption: {
|
||||
fontSize: defaultTheme.typography.pxToRem(12),
|
||||
fontWeight: 400,
|
||||
},
|
||||
};
|
||||
|
||||
export const shape = {
|
||||
borderRadius: 8,
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const defaultShadows: Shadows = [
|
||||
'none',
|
||||
'var(--template-palette-baseShadow)',
|
||||
...defaultTheme.shadows.slice(2),
|
||||
];
|
||||
export const shadows = defaultShadows;
|
||||
43
react-openapi/types/config.ts
Normal file
43
react-openapi/types/config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export type FieldType =
|
||||
| 'string'
|
||||
| 'number'
|
||||
| 'boolean'
|
||||
| 'date'
|
||||
| 'datetime'
|
||||
| 'markdown'
|
||||
| 'enum'
|
||||
| 'image'
|
||||
| 'object'
|
||||
| 'array';
|
||||
|
||||
export interface ResourceField {
|
||||
type: FieldType;
|
||||
label: string;
|
||||
required?: boolean;
|
||||
options?: string[];
|
||||
readOnly?: boolean;
|
||||
schema?: Record<string, ResourceField>;
|
||||
displayField?: string | string[];
|
||||
formatter?: (value: any) => string;
|
||||
relation?: string; // Name of the target resource
|
||||
}
|
||||
|
||||
export interface ResourceConfig {
|
||||
name: string;
|
||||
label: string;
|
||||
pluralLabel: string;
|
||||
endpoint: string;
|
||||
primaryKey: string;
|
||||
fields: Record<string, ResourceField>;
|
||||
pagination?: boolean;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
baseUrl: string;
|
||||
authBaseUrl: string;
|
||||
resources: ResourceConfig[];
|
||||
profile?: {
|
||||
resource: string;
|
||||
extraFields?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
15
react-openapi/types/overrides.ts
Normal file
15
react-openapi/types/overrides.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* This file contains application-specific overrides and configuration
|
||||
* for the generic Admin Panel.
|
||||
*/
|
||||
|
||||
export interface FieldOverride {
|
||||
displayField?: string | string[];
|
||||
display?: boolean;
|
||||
formatter?: (value: any) => string;
|
||||
}
|
||||
|
||||
export interface ResourceOverride {
|
||||
fields?: Record<string, FieldOverride>;
|
||||
pagination?: boolean;
|
||||
}
|
||||
178
react-openapi/utils/openapi_loader.ts
Normal file
178
react-openapi/utils/openapi_loader.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import SwaggerParser from "@apidevtools/swagger-parser";
|
||||
import { AppConfig, ResourceConfig, ResourceField, FieldType } from "../types/config";
|
||||
import { configuration, profileConfiguration } from "../configuration";
|
||||
|
||||
/**
|
||||
* Maps OpenAPI property types to our internal FieldType
|
||||
*/
|
||||
function mapOpenApiType(prop: any): FieldType {
|
||||
const type = prop.type;
|
||||
const format = prop.format;
|
||||
|
||||
if (format === "date-time") return "datetime";
|
||||
if (format === "date") return "date";
|
||||
if (prop.enum) return "enum";
|
||||
if (
|
||||
type === "string" &&
|
||||
(prop.description?.toLowerCase().includes("image") ||
|
||||
prop.name?.toLowerCase().includes("icon"))
|
||||
)
|
||||
return "image";
|
||||
|
||||
switch (type) {
|
||||
case "integer":
|
||||
case "number":
|
||||
return "number";
|
||||
case "boolean":
|
||||
return "boolean";
|
||||
case "object":
|
||||
return "object";
|
||||
case "array":
|
||||
return "array";
|
||||
default:
|
||||
return "string";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively converts OpenAPI schemas to ResourceField map
|
||||
*/
|
||||
function parseSchemaFields(
|
||||
schema: any,
|
||||
resourceName: string,
|
||||
schemaToResourceMap: Map<any, string>
|
||||
): Record<string, ResourceField> {
|
||||
const fields: Record<string, ResourceField> = {};
|
||||
const properties = schema.properties || {};
|
||||
const required = schema.required || [];
|
||||
const overrides = configuration[resourceName]?.fields || {};
|
||||
|
||||
for (const [key, prop] of Object.entries(properties) as [string, any]) {
|
||||
const type = mapOpenApiType(prop);
|
||||
const override = overrides[key];
|
||||
|
||||
// Explicitly skip 'id' as it's the primary key and handled elsewhere
|
||||
if (key === "id" || override?.display === false) continue;
|
||||
|
||||
fields[key] = {
|
||||
type,
|
||||
label:
|
||||
prop.title ||
|
||||
key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "),
|
||||
required: required.includes(key),
|
||||
options: prop.enum,
|
||||
readOnly:
|
||||
prop.readOnly ||
|
||||
key === "created_at" ||
|
||||
key === "updated_at",
|
||||
...override,
|
||||
};
|
||||
|
||||
// STRICT RELATION DETECTION
|
||||
// A field is a relation ONLY if its schema object (or items schema)
|
||||
// exactly matches a schema that is defined as a resource.
|
||||
let targetSchema = prop;
|
||||
if (type === "array" && prop.items) {
|
||||
targetSchema = prop.items;
|
||||
}
|
||||
|
||||
// Check if this schema object is registered as a resource
|
||||
const relation = schemaToResourceMap.get(targetSchema);
|
||||
if (relation) {
|
||||
fields[key].relation = relation;
|
||||
}
|
||||
|
||||
// Recursively parse nested objects (only if not a relation)
|
||||
if (fields[key].type === "object" && prop.properties && !relation) {
|
||||
fields[key].schema = parseSchemaFields(prop, resourceName, schemaToResourceMap);
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans paths to identify resources and their basic configuration
|
||||
*/
|
||||
export async function loadConfigFromOpenApi(baseUrl: string): Promise<AppConfig> {
|
||||
// Use SwaggerParser to dereference the spec.
|
||||
// Dereferencing preserves object identity for $ref targets.
|
||||
const api = await SwaggerParser.dereference(
|
||||
new URL("/openapi.json", baseUrl).href
|
||||
);
|
||||
|
||||
const resources: ResourceConfig[] = [];
|
||||
const paths = api.paths || {};
|
||||
|
||||
// Group paths by base resource name
|
||||
const resourcePaths: Record<string, any> = {};
|
||||
for (const path of Object.keys(paths)) {
|
||||
const base = path.split("/")[1];
|
||||
if (!base) continue;
|
||||
|
||||
if (!resourcePaths[base]) resourcePaths[base] = { path, methods: [] };
|
||||
const methods = Object.keys(paths[path] || {});
|
||||
resourcePaths[base].methods.push(...methods);
|
||||
|
||||
// Identify the list endpoint for this resource
|
||||
if (!resourcePaths[base].listPath && !path.includes("{") && paths[path]?.get?.responses?.["200"]) {
|
||||
resourcePaths[base].listPath = path;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Identify which schema objects correspond to which resources
|
||||
const schemaToResourceMap = new Map<any, string>();
|
||||
for (const [name, info] of Object.entries(resourcePaths)) {
|
||||
const listPath = info.listPath || `/${name}`;
|
||||
const listOp = paths[listPath]?.get;
|
||||
if (!listOp) continue;
|
||||
|
||||
// @ts-ignore
|
||||
const responseSchema = listOp.responses?.["200"]?.content?.["application/json"]?.schema;
|
||||
let schemaObj = responseSchema;
|
||||
if (responseSchema?.type === "array" && responseSchema.items) {
|
||||
schemaObj = responseSchema.items;
|
||||
}
|
||||
|
||||
if (schemaObj) {
|
||||
schemaToResourceMap.set(schemaObj, name);
|
||||
resourcePaths[name].schemaObj = schemaObj;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Generate ResourceConfig for each identified resource
|
||||
for (const [name, info] of Object.entries(resourcePaths)) {
|
||||
const listPath = info.listPath || `/${name}`;
|
||||
const listOp = paths[listPath]?.get;
|
||||
if (!listOp || !info.schemaObj) continue;
|
||||
|
||||
const schema = info.schemaObj;
|
||||
const label = name.charAt(0).toUpperCase() + name.slice(1, -1);
|
||||
const pluralLabel = name.charAt(0).toUpperCase() + name.slice(1);
|
||||
|
||||
const fields = parseSchemaFields(schema, name, schemaToResourceMap);
|
||||
|
||||
const resourceOverride = configuration[name] || {};
|
||||
|
||||
resources.push({
|
||||
name,
|
||||
label: schema.title || label,
|
||||
pluralLabel: pluralLabel,
|
||||
endpoint: listPath,
|
||||
primaryKey: "id", // Strict default, no heuristics
|
||||
fields,
|
||||
pagination: resourceOverride.pagination,
|
||||
});
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const serverBaseUrl = import.meta.env.VITE_API_BASE_URL || (api.servers?.[0]?.url ?? "")
|
||||
// @ts-ignore
|
||||
const authBaseUrl = import.meta.env.VITE_AUTH_BASE_URL || ""
|
||||
return {
|
||||
baseUrl: serverBaseUrl,
|
||||
authBaseUrl: authBaseUrl,
|
||||
resources,
|
||||
profile: profileConfiguration,
|
||||
};
|
||||
}
|
||||
26
src/Home.tsx
Normal file
26
src/Home.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
|
||||
const Home: React.FC = () => {
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<h1 style={styles.title}>Welcome to Khata</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const styles: { [key: string]: React.CSSProperties } = {
|
||||
container: {
|
||||
height: "100vh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#0f172a",
|
||||
},
|
||||
title: {
|
||||
color: "#ffffff",
|
||||
fontSize: "2rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
};
|
||||
|
||||
export default Home;
|
||||
17
src/main.jsx
Normal file
17
src/main.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import Home from './Home';
|
||||
import { AuthProvider } from "../react-auth/src";
|
||||
import { OpenapiProvider } from "../react-openapi/src";
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
const root = createRoot(rootElement);
|
||||
const AUTH_BASE = import.meta.env.VITE_AUTH_BASE_URL;
|
||||
|
||||
root.render(
|
||||
<AuthProvider authBaseUrl={AUTH_BASE}>
|
||||
<OpenapiProvider authBaseUrl={AUTH_BASE}>
|
||||
<Home />
|
||||
</OpenapiProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
53
src/shared-theme/AppTheme.tsx
Normal file
53
src/shared-theme/AppTheme.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from 'react';
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
import type { ThemeOptions } from '@mui/material/styles';
|
||||
import { inputsCustomizations } from './customizations/inputs';
|
||||
import { dataDisplayCustomizations } from './customizations/dataDisplay';
|
||||
import { feedbackCustomizations } from './customizations/feedback';
|
||||
import { navigationCustomizations } from './customizations/navigation';
|
||||
import { surfacesCustomizations } from './customizations/surfaces';
|
||||
import { colorSchemes, typography, shadows, shape } from './themePrimitives';
|
||||
|
||||
interface AppThemeProps {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* This is for the docs site. You can ignore it or remove it.
|
||||
*/
|
||||
disableCustomTheme?: boolean;
|
||||
themeComponents?: ThemeOptions['components'];
|
||||
}
|
||||
|
||||
export default function AppTheme(props: AppThemeProps) {
|
||||
const { children, disableCustomTheme, themeComponents } = props;
|
||||
const theme = React.useMemo(() => {
|
||||
return disableCustomTheme
|
||||
? {}
|
||||
: createTheme({
|
||||
// For more details about CSS variables configuration, see https://mui.com/material-ui/customization/css-theme-variables/configuration/
|
||||
cssVariables: {
|
||||
colorSchemeSelector: 'data-mui-color-scheme',
|
||||
cssVarPrefix: 'template',
|
||||
},
|
||||
colorSchemes, // Recently added in v6 for building light & dark mode app, see https://mui.com/material-ui/customization/palette/#color-schemes
|
||||
typography,
|
||||
shadows,
|
||||
shape,
|
||||
components: {
|
||||
...inputsCustomizations,
|
||||
...dataDisplayCustomizations,
|
||||
...feedbackCustomizations,
|
||||
...navigationCustomizations,
|
||||
...surfacesCustomizations,
|
||||
...themeComponents,
|
||||
},
|
||||
});
|
||||
}, [disableCustomTheme, themeComponents]);
|
||||
if (disableCustomTheme) {
|
||||
return <React.Fragment>{children}</React.Fragment>;
|
||||
}
|
||||
return (
|
||||
<ThemeProvider theme={theme} disableTransitionOnChange>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
89
src/shared-theme/ColorModeIconDropdown.tsx
Normal file
89
src/shared-theme/ColorModeIconDropdown.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import * as React from 'react';
|
||||
import DarkModeIcon from '@mui/icons-material/DarkModeRounded';
|
||||
import LightModeIcon from '@mui/icons-material/LightModeRounded';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton, { IconButtonOwnProps } from '@mui/material/IconButton';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import { useColorScheme } from '@mui/material/styles';
|
||||
|
||||
export default function ColorModeIconDropdown(props: IconButtonOwnProps) {
|
||||
const { mode, systemMode, setMode } = useColorScheme();
|
||||
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||
const open = Boolean(anchorEl);
|
||||
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
const handleMode = (targetMode: 'system' | 'light' | 'dark') => () => {
|
||||
setMode(targetMode);
|
||||
handleClose();
|
||||
};
|
||||
if (!mode) {
|
||||
return (
|
||||
<Box
|
||||
data-screenshot="toggle-mode"
|
||||
sx={(theme) => ({
|
||||
verticalAlign: 'bottom',
|
||||
display: 'inline-flex',
|
||||
width: '2.25rem',
|
||||
height: '2.25rem',
|
||||
borderRadius: (theme.vars || theme).shape.borderRadius,
|
||||
border: '1px solid',
|
||||
borderColor: (theme.vars || theme).palette.divider,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const resolvedMode = (systemMode || mode) as 'light' | 'dark';
|
||||
const icon = {
|
||||
light: <LightModeIcon />,
|
||||
dark: <DarkModeIcon />,
|
||||
}[resolvedMode];
|
||||
return (
|
||||
<React.Fragment>
|
||||
<IconButton
|
||||
data-screenshot="toggle-mode"
|
||||
onClick={handleClick}
|
||||
disableRipple
|
||||
size="small"
|
||||
aria-controls={open ? 'color-scheme-menu' : undefined}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
</IconButton>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
id="account-menu"
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
onClick={handleClose}
|
||||
slotProps={{
|
||||
paper: {
|
||||
variant: 'outlined',
|
||||
elevation: 0,
|
||||
sx: {
|
||||
my: '4px',
|
||||
},
|
||||
},
|
||||
}}
|
||||
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
||||
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
||||
>
|
||||
<MenuItem selected={mode === 'system'} onClick={handleMode('system')}>
|
||||
System
|
||||
</MenuItem>
|
||||
<MenuItem selected={mode === 'light'} onClick={handleMode('light')}>
|
||||
Light
|
||||
</MenuItem>
|
||||
<MenuItem selected={mode === 'dark'} onClick={handleMode('dark')}>
|
||||
Dark
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
28
src/shared-theme/ColorModeSelect.tsx
Normal file
28
src/shared-theme/ColorModeSelect.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from 'react';
|
||||
import { useColorScheme } from '@mui/material/styles';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Select, { SelectProps } from '@mui/material/Select';
|
||||
|
||||
export default function ColorModeSelect(props: SelectProps) {
|
||||
const { mode, setMode } = useColorScheme();
|
||||
if (!mode) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Select
|
||||
value={mode}
|
||||
onChange={(event) =>
|
||||
setMode(event.target.value as 'system' | 'light' | 'dark')
|
||||
}
|
||||
SelectDisplayProps={{
|
||||
// @ts-ignore
|
||||
'data-screenshot': 'toggle-mode',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<MenuItem value="system">System</MenuItem>
|
||||
<MenuItem value="light">Light</MenuItem>
|
||||
<MenuItem value="dark">Dark</MenuItem>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
233
src/shared-theme/customizations/dataDisplay.tsx
Normal file
233
src/shared-theme/customizations/dataDisplay.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { Theme, alpha, Components } from '@mui/material/styles';
|
||||
import { svgIconClasses } from '@mui/material/SvgIcon';
|
||||
import { typographyClasses } from '@mui/material/Typography';
|
||||
import { buttonBaseClasses } from '@mui/material/ButtonBase';
|
||||
import { chipClasses } from '@mui/material/Chip';
|
||||
import { iconButtonClasses } from '@mui/material/IconButton';
|
||||
import { gray, red, green } from '../themePrimitives';
|
||||
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export const dataDisplayCustomizations: Components<Theme> = {
|
||||
MuiList: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
padding: '8px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiListItem: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
[`& .${svgIconClasses.root}`]: {
|
||||
width: '1rem',
|
||||
height: '1rem',
|
||||
color: (theme.vars || theme).palette.text.secondary,
|
||||
},
|
||||
[`& .${typographyClasses.root}`]: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
[`& .${buttonBaseClasses.root}`]: {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
padding: '2px 8px',
|
||||
borderRadius: (theme.vars || theme).shape.borderRadius,
|
||||
opacity: 0.7,
|
||||
'&.Mui-selected': {
|
||||
opacity: 1,
|
||||
backgroundColor: alpha(theme.palette.action.selected, 0.3),
|
||||
[`& .${svgIconClasses.root}`]: {
|
||||
color: (theme.vars || theme).palette.text.primary,
|
||||
},
|
||||
'&:focus-visible': {
|
||||
backgroundColor: alpha(theme.palette.action.selected, 0.3),
|
||||
},
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(theme.palette.action.selected, 0.5),
|
||||
},
|
||||
},
|
||||
'&:focus-visible': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiListItemText: {
|
||||
styleOverrides: {
|
||||
primary: ({ theme }) => ({
|
||||
fontSize: theme.typography.body2.fontSize,
|
||||
fontWeight: 500,
|
||||
lineHeight: theme.typography.body2.lineHeight,
|
||||
}),
|
||||
secondary: ({ theme }) => ({
|
||||
fontSize: theme.typography.caption.fontSize,
|
||||
lineHeight: theme.typography.caption.lineHeight,
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiListSubheader: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
backgroundColor: 'transparent',
|
||||
padding: '4px 8px',
|
||||
fontSize: theme.typography.caption.fontSize,
|
||||
fontWeight: 500,
|
||||
lineHeight: theme.typography.caption.lineHeight,
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiListItemIcon: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
minWidth: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
defaultProps: {
|
||||
size: 'small',
|
||||
},
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
border: '1px solid',
|
||||
borderRadius: '999px',
|
||||
[`& .${chipClasses.label}`]: {
|
||||
fontWeight: 600,
|
||||
},
|
||||
variants: [
|
||||
{
|
||||
props: {
|
||||
color: 'default',
|
||||
},
|
||||
style: {
|
||||
borderColor: gray[200],
|
||||
backgroundColor: gray[100],
|
||||
[`& .${chipClasses.label}`]: {
|
||||
color: gray[500],
|
||||
},
|
||||
[`& .${chipClasses.icon}`]: {
|
||||
color: gray[500],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
borderColor: gray[700],
|
||||
backgroundColor: gray[800],
|
||||
[`& .${chipClasses.label}`]: {
|
||||
color: gray[300],
|
||||
},
|
||||
[`& .${chipClasses.icon}`]: {
|
||||
color: gray[300],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
props: {
|
||||
color: 'success',
|
||||
},
|
||||
style: {
|
||||
borderColor: green[200],
|
||||
backgroundColor: green[50],
|
||||
[`& .${chipClasses.label}`]: {
|
||||
color: green[500],
|
||||
},
|
||||
[`& .${chipClasses.icon}`]: {
|
||||
color: green[500],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
borderColor: green[800],
|
||||
backgroundColor: green[900],
|
||||
[`& .${chipClasses.label}`]: {
|
||||
color: green[300],
|
||||
},
|
||||
[`& .${chipClasses.icon}`]: {
|
||||
color: green[300],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
props: {
|
||||
color: 'error',
|
||||
},
|
||||
style: {
|
||||
borderColor: red[100],
|
||||
backgroundColor: red[50],
|
||||
[`& .${chipClasses.label}`]: {
|
||||
color: red[500],
|
||||
},
|
||||
[`& .${chipClasses.icon}`]: {
|
||||
color: red[500],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
borderColor: red[800],
|
||||
backgroundColor: red[900],
|
||||
[`& .${chipClasses.label}`]: {
|
||||
color: red[200],
|
||||
},
|
||||
[`& .${chipClasses.icon}`]: {
|
||||
color: red[300],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
props: { size: 'small' },
|
||||
style: {
|
||||
maxHeight: 20,
|
||||
[`& .${chipClasses.label}`]: {
|
||||
fontSize: theme.typography.caption.fontSize,
|
||||
},
|
||||
[`& .${svgIconClasses.root}`]: {
|
||||
fontSize: theme.typography.caption.fontSize,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
props: { size: 'medium' },
|
||||
style: {
|
||||
[`& .${chipClasses.label}`]: {
|
||||
fontSize: theme.typography.caption.fontSize,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiTablePagination: {
|
||||
styleOverrides: {
|
||||
actions: {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
marginRight: 6,
|
||||
[`& .${iconButtonClasses.root}`]: {
|
||||
minWidth: 0,
|
||||
width: 36,
|
||||
height: 36,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiIcon: {
|
||||
defaultProps: {
|
||||
fontSize: 'small',
|
||||
},
|
||||
styleOverrides: {
|
||||
root: {
|
||||
variants: [
|
||||
{
|
||||
props: {
|
||||
fontSize: 'small',
|
||||
},
|
||||
style: {
|
||||
fontSize: '1rem',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
46
src/shared-theme/customizations/feedback.tsx
Normal file
46
src/shared-theme/customizations/feedback.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Theme, alpha, Components } from '@mui/material/styles';
|
||||
import { gray, orange } from '../themePrimitives';
|
||||
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export const feedbackCustomizations: Components<Theme> = {
|
||||
MuiAlert: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
borderRadius: 10,
|
||||
backgroundColor: orange[100],
|
||||
color: (theme.vars || theme).palette.text.primary,
|
||||
border: `1px solid ${alpha(orange[300], 0.5)}`,
|
||||
'& .MuiAlert-icon': {
|
||||
color: orange[500],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: `${alpha(orange[900], 0.5)}`,
|
||||
border: `1px solid ${alpha(orange[800], 0.5)}`,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiDialog: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
'& .MuiDialog-paper': {
|
||||
borderRadius: '10px',
|
||||
border: '1px solid',
|
||||
borderColor: (theme.vars || theme).palette.divider,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiLinearProgress: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
height: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: gray[200],
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: gray[800],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
445
src/shared-theme/customizations/inputs.tsx
Normal file
445
src/shared-theme/customizations/inputs.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
import * as React from 'react';
|
||||
import { alpha, Theme, Components } from '@mui/material/styles';
|
||||
import { outlinedInputClasses } from '@mui/material/OutlinedInput';
|
||||
import { svgIconClasses } from '@mui/material/SvgIcon';
|
||||
import { toggleButtonGroupClasses } from '@mui/material/ToggleButtonGroup';
|
||||
import { toggleButtonClasses } from '@mui/material/ToggleButton';
|
||||
import CheckBoxOutlineBlankRoundedIcon from '@mui/icons-material/CheckBoxOutlineBlankRounded';
|
||||
import CheckRoundedIcon from '@mui/icons-material/CheckRounded';
|
||||
import RemoveRoundedIcon from '@mui/icons-material/RemoveRounded';
|
||||
import { gray, brand } from '../themePrimitives';
|
||||
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export const inputsCustomizations: Components<Theme> = {
|
||||
MuiButtonBase: {
|
||||
defaultProps: {
|
||||
disableTouchRipple: true,
|
||||
disableRipple: true,
|
||||
},
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
boxSizing: 'border-box',
|
||||
transition: 'all 100ms ease-in',
|
||||
'&:focus-visible': {
|
||||
outline: `3px solid ${alpha(theme.palette.primary.main, 0.5)}`,
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
boxShadow: 'none',
|
||||
borderRadius: (theme.vars || theme).shape.borderRadius,
|
||||
textTransform: 'none',
|
||||
variants: [
|
||||
{
|
||||
props: {
|
||||
size: 'small',
|
||||
},
|
||||
style: {
|
||||
height: '2.25rem',
|
||||
padding: '8px 12px',
|
||||
},
|
||||
},
|
||||
{
|
||||
props: {
|
||||
size: 'medium',
|
||||
},
|
||||
style: {
|
||||
height: '2.5rem', // 40px
|
||||
},
|
||||
},
|
||||
{
|
||||
props: {
|
||||
color: 'primary',
|
||||
variant: 'contained',
|
||||
},
|
||||
style: {
|
||||
color: 'white',
|
||||
backgroundColor: gray[900],
|
||||
backgroundImage: `linear-gradient(to bottom, ${gray[700]}, ${gray[800]})`,
|
||||
boxShadow: `inset 0 1px 0 ${gray[600]}, inset 0 -1px 0 1px hsl(220, 0%, 0%)`,
|
||||
border: `1px solid ${gray[700]}`,
|
||||
'&:hover': {
|
||||
backgroundImage: 'none',
|
||||
backgroundColor: gray[700],
|
||||
boxShadow: 'none',
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: gray[800],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
color: 'black',
|
||||
backgroundColor: gray[50],
|
||||
backgroundImage: `linear-gradient(to bottom, ${gray[100]}, ${gray[50]})`,
|
||||
boxShadow: 'inset 0 -1px 0 hsl(220, 30%, 80%)',
|
||||
border: `1px solid ${gray[50]}`,
|
||||
'&:hover': {
|
||||
backgroundImage: 'none',
|
||||
backgroundColor: gray[300],
|
||||
boxShadow: 'none',
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: gray[400],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
props: {
|
||||
color: 'secondary',
|
||||
variant: 'contained',
|
||||
},
|
||||
style: {
|
||||
color: 'white',
|
||||
backgroundColor: brand[300],
|
||||
backgroundImage: `linear-gradient(to bottom, ${alpha(brand[400], 0.8)}, ${brand[500]})`,
|
||||
boxShadow: `inset 0 2px 0 ${alpha(brand[200], 0.2)}, inset 0 -2px 0 ${alpha(brand[700], 0.4)}`,
|
||||
border: `1px solid ${brand[500]}`,
|
||||
'&:hover': {
|
||||
backgroundColor: brand[700],
|
||||
boxShadow: 'none',
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: brand[700],
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
props: {
|
||||
variant: 'outlined',
|
||||
},
|
||||
style: {
|
||||
color: (theme.vars || theme).palette.text.primary,
|
||||
border: '1px solid',
|
||||
borderColor: gray[200],
|
||||
backgroundColor: alpha(gray[50], 0.3),
|
||||
'&:hover': {
|
||||
backgroundColor: gray[100],
|
||||
borderColor: gray[300],
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: gray[200],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: gray[800],
|
||||
borderColor: gray[700],
|
||||
|
||||
'&:hover': {
|
||||
backgroundColor: gray[900],
|
||||
borderColor: gray[600],
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: gray[900],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
props: {
|
||||
color: 'secondary',
|
||||
variant: 'outlined',
|
||||
},
|
||||
style: {
|
||||
color: brand[700],
|
||||
border: '1px solid',
|
||||
borderColor: brand[200],
|
||||
backgroundColor: brand[50],
|
||||
'&:hover': {
|
||||
backgroundColor: brand[100],
|
||||
borderColor: brand[400],
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: alpha(brand[200], 0.7),
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
color: brand[50],
|
||||
border: '1px solid',
|
||||
borderColor: brand[900],
|
||||
backgroundColor: alpha(brand[900], 0.3),
|
||||
'&:hover': {
|
||||
borderColor: brand[700],
|
||||
backgroundColor: alpha(brand[900], 0.6),
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: alpha(brand[900], 0.5),
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
props: {
|
||||
variant: 'text',
|
||||
},
|
||||
style: {
|
||||
color: gray[600],
|
||||
'&:hover': {
|
||||
backgroundColor: gray[100],
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: gray[200],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
color: gray[50],
|
||||
'&:hover': {
|
||||
backgroundColor: gray[700],
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: alpha(gray[700], 0.7),
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
props: {
|
||||
color: 'secondary',
|
||||
variant: 'text',
|
||||
},
|
||||
style: {
|
||||
color: brand[700],
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(brand[100], 0.5),
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: alpha(brand[200], 0.7),
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
color: brand[100],
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(brand[900], 0.5),
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: alpha(brand[900], 0.3),
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiIconButton: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
boxShadow: 'none',
|
||||
borderRadius: (theme.vars || theme).shape.borderRadius,
|
||||
textTransform: 'none',
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
letterSpacing: 0,
|
||||
color: (theme.vars || theme).palette.text.primary,
|
||||
border: '1px solid ',
|
||||
borderColor: gray[200],
|
||||
backgroundColor: alpha(gray[50], 0.3),
|
||||
'&:hover': {
|
||||
backgroundColor: gray[100],
|
||||
borderColor: gray[300],
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: gray[200],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: gray[800],
|
||||
borderColor: gray[700],
|
||||
'&:hover': {
|
||||
backgroundColor: gray[900],
|
||||
borderColor: gray[600],
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: gray[900],
|
||||
},
|
||||
}),
|
||||
variants: [
|
||||
{
|
||||
props: {
|
||||
size: 'small',
|
||||
},
|
||||
style: {
|
||||
width: '2.25rem',
|
||||
height: '2.25rem',
|
||||
padding: '0.25rem',
|
||||
[`& .${svgIconClasses.root}`]: { fontSize: '1rem' },
|
||||
},
|
||||
},
|
||||
{
|
||||
props: {
|
||||
size: 'medium',
|
||||
},
|
||||
style: {
|
||||
width: '2.5rem',
|
||||
height: '2.5rem',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiToggleButtonGroup: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
borderRadius: '10px',
|
||||
boxShadow: `0 4px 16px ${alpha(gray[400], 0.2)}`,
|
||||
[`& .${toggleButtonGroupClasses.selected}`]: {
|
||||
color: brand[500],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
[`& .${toggleButtonGroupClasses.selected}`]: {
|
||||
color: '#fff',
|
||||
},
|
||||
boxShadow: `0 4px 16px ${alpha(brand[700], 0.5)}`,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiToggleButton: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
padding: '12px 16px',
|
||||
textTransform: 'none',
|
||||
borderRadius: '10px',
|
||||
fontWeight: 500,
|
||||
...theme.applyStyles('dark', {
|
||||
color: gray[400],
|
||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
|
||||
[`&.${toggleButtonClasses.selected}`]: {
|
||||
color: brand[300],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiCheckbox: {
|
||||
defaultProps: {
|
||||
disableRipple: true,
|
||||
icon: (
|
||||
<CheckBoxOutlineBlankRoundedIcon sx={{ color: 'hsla(210, 0%, 0%, 0.0)' }} />
|
||||
),
|
||||
checkedIcon: <CheckRoundedIcon sx={{ height: 14, width: 14 }} />,
|
||||
indeterminateIcon: <RemoveRoundedIcon sx={{ height: 14, width: 14 }} />,
|
||||
},
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
margin: 10,
|
||||
height: 16,
|
||||
width: 16,
|
||||
borderRadius: 5,
|
||||
border: '1px solid ',
|
||||
borderColor: alpha(gray[300], 0.8),
|
||||
boxShadow: '0 0 0 1.5px hsla(210, 0%, 0%, 0.04) inset',
|
||||
backgroundColor: alpha(gray[100], 0.4),
|
||||
transition: 'border-color, background-color, 120ms ease-in',
|
||||
'&:hover': {
|
||||
borderColor: brand[300],
|
||||
},
|
||||
'&.Mui-focusVisible': {
|
||||
outline: `3px solid ${alpha(brand[500], 0.5)}`,
|
||||
outlineOffset: '2px',
|
||||
borderColor: brand[400],
|
||||
},
|
||||
'&.Mui-checked': {
|
||||
color: 'white',
|
||||
backgroundColor: brand[500],
|
||||
borderColor: brand[500],
|
||||
boxShadow: `none`,
|
||||
'&:hover': {
|
||||
backgroundColor: brand[600],
|
||||
},
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
borderColor: alpha(gray[700], 0.8),
|
||||
boxShadow: '0 0 0 1.5px hsl(210, 0%, 0%) inset',
|
||||
backgroundColor: alpha(gray[900], 0.8),
|
||||
'&:hover': {
|
||||
borderColor: brand[300],
|
||||
},
|
||||
'&.Mui-focusVisible': {
|
||||
borderColor: brand[400],
|
||||
outline: `3px solid ${alpha(brand[500], 0.5)}`,
|
||||
outlineOffset: '2px',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiInputBase: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
border: 'none',
|
||||
},
|
||||
input: {
|
||||
'&::placeholder': {
|
||||
opacity: 0.7,
|
||||
color: gray[500],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiOutlinedInput: {
|
||||
styleOverrides: {
|
||||
input: {
|
||||
padding: 0,
|
||||
},
|
||||
root: ({ theme }) => ({
|
||||
padding: '8px 12px',
|
||||
color: (theme.vars || theme).palette.text.primary,
|
||||
borderRadius: (theme.vars || theme).shape.borderRadius,
|
||||
border: `1px solid ${(theme.vars || theme).palette.divider}`,
|
||||
backgroundColor: (theme.vars || theme).palette.background.default,
|
||||
transition: 'border 120ms ease-in',
|
||||
'&:hover': {
|
||||
borderColor: gray[400],
|
||||
},
|
||||
[`&.${outlinedInputClasses.focused}`]: {
|
||||
outline: `3px solid ${alpha(brand[500], 0.5)}`,
|
||||
borderColor: brand[400],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
'&:hover': {
|
||||
borderColor: gray[500],
|
||||
},
|
||||
}),
|
||||
variants: [
|
||||
{
|
||||
props: {
|
||||
size: 'small',
|
||||
},
|
||||
style: {
|
||||
height: '2.25rem',
|
||||
},
|
||||
},
|
||||
{
|
||||
props: {
|
||||
size: 'medium',
|
||||
},
|
||||
style: {
|
||||
height: '2.5rem',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
notchedOutline: {
|
||||
border: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiInputAdornment: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
color: (theme.vars || theme).palette.grey[500],
|
||||
...theme.applyStyles('dark', {
|
||||
color: (theme.vars || theme).palette.grey[400],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiFormLabel: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
typography: theme.typography.caption,
|
||||
marginBottom: 8,
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
279
src/shared-theme/customizations/navigation.tsx
Normal file
279
src/shared-theme/customizations/navigation.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import * as React from 'react';
|
||||
import { Theme, alpha, Components } from '@mui/material/styles';
|
||||
import { SvgIconProps } from '@mui/material/SvgIcon';
|
||||
import { buttonBaseClasses } from '@mui/material/ButtonBase';
|
||||
import { dividerClasses } from '@mui/material/Divider';
|
||||
import { menuItemClasses } from '@mui/material/MenuItem';
|
||||
import { selectClasses } from '@mui/material/Select';
|
||||
import { tabClasses } from '@mui/material/Tab';
|
||||
import UnfoldMoreRoundedIcon from '@mui/icons-material/UnfoldMoreRounded';
|
||||
import { gray, brand } from '../themePrimitives';
|
||||
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export const navigationCustomizations: Components<Theme> = {
|
||||
MuiMenuItem: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
borderRadius: (theme.vars || theme).shape.borderRadius,
|
||||
padding: '6px 8px',
|
||||
[`&.${menuItemClasses.focusVisible}`]: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
[`&.${menuItemClasses.selected}`]: {
|
||||
[`&.${menuItemClasses.focusVisible}`]: {
|
||||
backgroundColor: alpha(theme.palette.action.selected, 0.3),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiMenu: {
|
||||
styleOverrides: {
|
||||
list: {
|
||||
gap: '0px',
|
||||
[`&.${dividerClasses.root}`]: {
|
||||
margin: '0 -8px',
|
||||
},
|
||||
},
|
||||
paper: ({ theme }) => ({
|
||||
marginTop: '4px',
|
||||
borderRadius: (theme.vars || theme).shape.borderRadius,
|
||||
border: `1px solid ${(theme.vars || theme).palette.divider}`,
|
||||
backgroundImage: 'none',
|
||||
background: 'hsl(0, 0%, 100%)',
|
||||
boxShadow:
|
||||
'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px',
|
||||
[`& .${buttonBaseClasses.root}`]: {
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: alpha(theme.palette.action.selected, 0.3),
|
||||
},
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
background: gray[900],
|
||||
boxShadow:
|
||||
'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px',
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiSelect: {
|
||||
defaultProps: {
|
||||
IconComponent: React.forwardRef<SVGSVGElement, SvgIconProps>((props, ref) => (
|
||||
<UnfoldMoreRoundedIcon fontSize="small" {...props} ref={ref} />
|
||||
)),
|
||||
},
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
borderRadius: (theme.vars || theme).shape.borderRadius,
|
||||
border: '1px solid',
|
||||
borderColor: gray[200],
|
||||
backgroundColor: (theme.vars || theme).palette.background.paper,
|
||||
boxShadow: `inset 0 1px 0 1px hsla(220, 0%, 100%, 0.6), inset 0 -1px 0 1px hsla(220, 35%, 90%, 0.5)`,
|
||||
'&:hover': {
|
||||
borderColor: gray[300],
|
||||
backgroundColor: (theme.vars || theme).palette.background.paper,
|
||||
boxShadow: 'none',
|
||||
},
|
||||
[`&.${selectClasses.focused}`]: {
|
||||
outlineOffset: 0,
|
||||
borderColor: gray[400],
|
||||
},
|
||||
'&:before, &:after': {
|
||||
display: 'none',
|
||||
},
|
||||
|
||||
...theme.applyStyles('dark', {
|
||||
borderRadius: (theme.vars || theme).shape.borderRadius,
|
||||
borderColor: gray[700],
|
||||
backgroundColor: (theme.vars || theme).palette.background.paper,
|
||||
boxShadow: `inset 0 1px 0 1px ${alpha(gray[700], 0.15)}, inset 0 -1px 0 1px hsla(220, 0%, 0%, 0.7)`,
|
||||
'&:hover': {
|
||||
borderColor: alpha(gray[700], 0.7),
|
||||
backgroundColor: (theme.vars || theme).palette.background.paper,
|
||||
boxShadow: 'none',
|
||||
},
|
||||
[`&.${selectClasses.focused}`]: {
|
||||
outlineOffset: 0,
|
||||
borderColor: gray[900],
|
||||
},
|
||||
'&:before, &:after': {
|
||||
display: 'none',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
select: ({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
...theme.applyStyles('dark', {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
'&:focus-visible': {
|
||||
backgroundColor: gray[900],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiLink: {
|
||||
defaultProps: {
|
||||
underline: 'none',
|
||||
},
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
color: (theme.vars || theme).palette.text.primary,
|
||||
fontWeight: 500,
|
||||
position: 'relative',
|
||||
textDecoration: 'none',
|
||||
width: 'fit-content',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '1px',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
backgroundColor: (theme.vars || theme).palette.text.secondary,
|
||||
opacity: 0.3,
|
||||
transition: 'width 0.3s ease, opacity 0.3s ease',
|
||||
},
|
||||
'&:hover::before': {
|
||||
width: 0,
|
||||
},
|
||||
'&:focus-visible': {
|
||||
outline: `3px solid ${alpha(brand[500], 0.5)}`,
|
||||
outlineOffset: '4px',
|
||||
borderRadius: '2px',
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiDrawer: {
|
||||
styleOverrides: {
|
||||
paper: ({ theme }) => ({
|
||||
backgroundColor: (theme.vars || theme).palette.background.default,
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiPaginationItem: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
'&.Mui-selected': {
|
||||
color: 'white',
|
||||
backgroundColor: (theme.vars || theme).palette.grey[900],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
'&.Mui-selected': {
|
||||
color: 'black',
|
||||
backgroundColor: (theme.vars || theme).palette.grey[50],
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiTabs: {
|
||||
styleOverrides: {
|
||||
root: { minHeight: 'fit-content' },
|
||||
indicator: ({ theme }) => ({
|
||||
backgroundColor: (theme.vars || theme).palette.grey[800],
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: (theme.vars || theme).palette.grey[200],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiTab: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
padding: '6px 8px',
|
||||
marginBottom: '8px',
|
||||
textTransform: 'none',
|
||||
minWidth: 'fit-content',
|
||||
minHeight: 'fit-content',
|
||||
color: (theme.vars || theme).palette.text.secondary,
|
||||
borderRadius: (theme.vars || theme).shape.borderRadius,
|
||||
border: '1px solid',
|
||||
borderColor: 'transparent',
|
||||
':hover': {
|
||||
color: (theme.vars || theme).palette.text.primary,
|
||||
backgroundColor: gray[100],
|
||||
borderColor: gray[200],
|
||||
},
|
||||
[`&.${tabClasses.selected}`]: {
|
||||
color: gray[900],
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
':hover': {
|
||||
color: (theme.vars || theme).palette.text.primary,
|
||||
backgroundColor: gray[800],
|
||||
borderColor: gray[700],
|
||||
},
|
||||
[`&.${tabClasses.selected}`]: {
|
||||
color: '#fff',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiStepConnector: {
|
||||
styleOverrides: {
|
||||
line: ({ theme }) => ({
|
||||
borderTop: '1px solid',
|
||||
borderColor: (theme.vars || theme).palette.divider,
|
||||
flex: 1,
|
||||
borderRadius: '99px',
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiStepIcon: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
color: 'transparent',
|
||||
border: `1px solid ${gray[400]}`,
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
'& text': {
|
||||
display: 'none',
|
||||
},
|
||||
'&.Mui-active': {
|
||||
border: 'none',
|
||||
color: (theme.vars || theme).palette.primary.main,
|
||||
},
|
||||
'&.Mui-completed': {
|
||||
border: 'none',
|
||||
color: (theme.vars || theme).palette.success.main,
|
||||
},
|
||||
...theme.applyStyles('dark', {
|
||||
border: `1px solid ${gray[700]}`,
|
||||
'&.Mui-active': {
|
||||
border: 'none',
|
||||
color: (theme.vars || theme).palette.primary.light,
|
||||
},
|
||||
'&.Mui-completed': {
|
||||
border: 'none',
|
||||
color: (theme.vars || theme).palette.success.light,
|
||||
},
|
||||
}),
|
||||
variants: [
|
||||
{
|
||||
props: { completed: true },
|
||||
style: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiStepLabel: {
|
||||
styleOverrides: {
|
||||
label: ({ theme }) => ({
|
||||
'&.Mui-completed': {
|
||||
opacity: 0.6,
|
||||
...theme.applyStyles('dark', { opacity: 0.5 }),
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
113
src/shared-theme/customizations/surfaces.ts
Normal file
113
src/shared-theme/customizations/surfaces.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { alpha, Theme, Components } from '@mui/material/styles';
|
||||
import { gray } from '../themePrimitives';
|
||||
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export const surfacesCustomizations: Components<Theme> = {
|
||||
MuiAccordion: {
|
||||
defaultProps: {
|
||||
elevation: 0,
|
||||
disableGutters: true,
|
||||
},
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
padding: 4,
|
||||
overflow: 'clip',
|
||||
backgroundColor: (theme.vars || theme).palette.background.default,
|
||||
border: '1px solid',
|
||||
borderColor: (theme.vars || theme).palette.divider,
|
||||
':before': {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
'&:not(:last-of-type)': {
|
||||
borderBottom: 'none',
|
||||
},
|
||||
'&:first-of-type': {
|
||||
borderTopLeftRadius: (theme.vars || theme).shape.borderRadius,
|
||||
borderTopRightRadius: (theme.vars || theme).shape.borderRadius,
|
||||
},
|
||||
'&:last-of-type': {
|
||||
borderBottomLeftRadius: (theme.vars || theme).shape.borderRadius,
|
||||
borderBottomRightRadius: (theme.vars || theme).shape.borderRadius,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiAccordionSummary: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => ({
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
'&:hover': { backgroundColor: gray[50] },
|
||||
'&:focus-visible': { backgroundColor: 'transparent' },
|
||||
...theme.applyStyles('dark', {
|
||||
'&:hover': { backgroundColor: gray[800] },
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
MuiAccordionDetails: {
|
||||
styleOverrides: {
|
||||
root: { mb: 20, border: 'none' },
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
defaultProps: {
|
||||
elevation: 0,
|
||||
},
|
||||
},
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: ({ theme }) => {
|
||||
return {
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
transition: 'all 100ms ease',
|
||||
backgroundColor: gray[50],
|
||||
borderRadius: (theme.vars || theme).shape.borderRadius,
|
||||
border: `1px solid ${(theme.vars || theme).palette.divider}`,
|
||||
boxShadow: 'none',
|
||||
...theme.applyStyles('dark', {
|
||||
backgroundColor: gray[800],
|
||||
}),
|
||||
variants: [
|
||||
{
|
||||
props: {
|
||||
variant: 'outlined',
|
||||
},
|
||||
style: {
|
||||
border: `1px solid ${(theme.vars || theme).palette.divider}`,
|
||||
boxShadow: 'none',
|
||||
background: 'hsl(0, 0%, 100%)',
|
||||
...theme.applyStyles('dark', {
|
||||
background: alpha(gray[900], 0.4),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCardContent: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
padding: 0,
|
||||
'&:last-child': { paddingBottom: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCardHeader: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
padding: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiCardActions: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
padding: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
403
src/shared-theme/themePrimitives.ts
Normal file
403
src/shared-theme/themePrimitives.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import { createTheme, alpha, PaletteMode, Shadows } from '@mui/material/styles';
|
||||
|
||||
declare module '@mui/material/Paper' {
|
||||
interface PaperPropsVariantOverrides {
|
||||
highlighted: true;
|
||||
}
|
||||
}
|
||||
declare module '@mui/material/styles' {
|
||||
interface ColorRange {
|
||||
50: string;
|
||||
100: string;
|
||||
200: string;
|
||||
300: string;
|
||||
400: string;
|
||||
500: string;
|
||||
600: string;
|
||||
700: string;
|
||||
800: string;
|
||||
900: string;
|
||||
}
|
||||
|
||||
interface PaletteColor extends ColorRange {}
|
||||
|
||||
interface Palette {
|
||||
baseShadow: string;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultTheme = createTheme();
|
||||
|
||||
const customShadows: Shadows = [...defaultTheme.shadows];
|
||||
|
||||
export const brand = {
|
||||
50: 'hsl(210, 100%, 95%)',
|
||||
100: 'hsl(210, 100%, 92%)',
|
||||
200: 'hsl(210, 100%, 80%)',
|
||||
300: 'hsl(210, 100%, 65%)',
|
||||
400: 'hsl(210, 98%, 48%)',
|
||||
500: 'hsl(210, 98%, 42%)',
|
||||
600: 'hsl(210, 98%, 55%)',
|
||||
700: 'hsl(210, 100%, 35%)',
|
||||
800: 'hsl(210, 100%, 16%)',
|
||||
900: 'hsl(210, 100%, 21%)',
|
||||
};
|
||||
|
||||
export const gray = {
|
||||
50: 'hsl(220, 35%, 97%)',
|
||||
100: 'hsl(220, 30%, 94%)',
|
||||
200: 'hsl(220, 20%, 88%)',
|
||||
300: 'hsl(220, 20%, 80%)',
|
||||
400: 'hsl(220, 20%, 65%)',
|
||||
500: 'hsl(220, 20%, 42%)',
|
||||
600: 'hsl(220, 20%, 35%)',
|
||||
700: 'hsl(220, 20%, 25%)',
|
||||
800: 'hsl(220, 30%, 6%)',
|
||||
900: 'hsl(220, 35%, 3%)',
|
||||
};
|
||||
|
||||
export const green = {
|
||||
50: 'hsl(120, 80%, 98%)',
|
||||
100: 'hsl(120, 75%, 94%)',
|
||||
200: 'hsl(120, 75%, 87%)',
|
||||
300: 'hsl(120, 61%, 77%)',
|
||||
400: 'hsl(120, 44%, 53%)',
|
||||
500: 'hsl(120, 59%, 30%)',
|
||||
600: 'hsl(120, 70%, 25%)',
|
||||
700: 'hsl(120, 75%, 16%)',
|
||||
800: 'hsl(120, 84%, 10%)',
|
||||
900: 'hsl(120, 87%, 6%)',
|
||||
};
|
||||
|
||||
export const orange = {
|
||||
50: 'hsl(45, 100%, 97%)',
|
||||
100: 'hsl(45, 92%, 90%)',
|
||||
200: 'hsl(45, 94%, 80%)',
|
||||
300: 'hsl(45, 90%, 65%)',
|
||||
400: 'hsl(45, 90%, 40%)',
|
||||
500: 'hsl(45, 90%, 35%)',
|
||||
600: 'hsl(45, 91%, 25%)',
|
||||
700: 'hsl(45, 94%, 20%)',
|
||||
800: 'hsl(45, 95%, 16%)',
|
||||
900: 'hsl(45, 93%, 12%)',
|
||||
};
|
||||
|
||||
export const red = {
|
||||
50: 'hsl(0, 100%, 97%)',
|
||||
100: 'hsl(0, 92%, 90%)',
|
||||
200: 'hsl(0, 94%, 80%)',
|
||||
300: 'hsl(0, 90%, 65%)',
|
||||
400: 'hsl(0, 90%, 40%)',
|
||||
500: 'hsl(0, 90%, 30%)',
|
||||
600: 'hsl(0, 91%, 25%)',
|
||||
700: 'hsl(0, 94%, 18%)',
|
||||
800: 'hsl(0, 95%, 12%)',
|
||||
900: 'hsl(0, 93%, 6%)',
|
||||
};
|
||||
|
||||
export const getDesignTokens = (mode: PaletteMode) => {
|
||||
customShadows[1] =
|
||||
mode === 'dark'
|
||||
? 'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px'
|
||||
: 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px';
|
||||
|
||||
return {
|
||||
palette: {
|
||||
mode,
|
||||
primary: {
|
||||
light: brand[200],
|
||||
main: brand[400],
|
||||
dark: brand[700],
|
||||
contrastText: brand[50],
|
||||
...(mode === 'dark' && {
|
||||
contrastText: brand[50],
|
||||
light: brand[300],
|
||||
main: brand[400],
|
||||
dark: brand[700],
|
||||
}),
|
||||
},
|
||||
info: {
|
||||
light: brand[100],
|
||||
main: brand[300],
|
||||
dark: brand[600],
|
||||
contrastText: gray[50],
|
||||
...(mode === 'dark' && {
|
||||
contrastText: brand[300],
|
||||
light: brand[500],
|
||||
main: brand[700],
|
||||
dark: brand[900],
|
||||
}),
|
||||
},
|
||||
warning: {
|
||||
light: orange[300],
|
||||
main: orange[400],
|
||||
dark: orange[800],
|
||||
...(mode === 'dark' && {
|
||||
light: orange[400],
|
||||
main: orange[500],
|
||||
dark: orange[700],
|
||||
}),
|
||||
},
|
||||
error: {
|
||||
light: red[300],
|
||||
main: red[400],
|
||||
dark: red[800],
|
||||
...(mode === 'dark' && {
|
||||
light: red[400],
|
||||
main: red[500],
|
||||
dark: red[700],
|
||||
}),
|
||||
},
|
||||
success: {
|
||||
light: green[300],
|
||||
main: green[400],
|
||||
dark: green[800],
|
||||
...(mode === 'dark' && {
|
||||
light: green[400],
|
||||
main: green[500],
|
||||
dark: green[700],
|
||||
}),
|
||||
},
|
||||
grey: {
|
||||
...gray,
|
||||
},
|
||||
divider: mode === 'dark' ? alpha(gray[700], 0.6) : alpha(gray[300], 0.4),
|
||||
background: {
|
||||
default: 'hsl(0, 0%, 99%)',
|
||||
paper: 'hsl(220, 35%, 97%)',
|
||||
...(mode === 'dark' && { default: gray[900], paper: 'hsl(220, 30%, 7%)' }),
|
||||
},
|
||||
text: {
|
||||
primary: gray[800],
|
||||
secondary: gray[600],
|
||||
warning: orange[400],
|
||||
...(mode === 'dark' && { primary: 'hsl(0, 0%, 100%)', secondary: gray[400] }),
|
||||
},
|
||||
action: {
|
||||
hover: alpha(gray[200], 0.2),
|
||||
selected: `${alpha(gray[200], 0.3)}`,
|
||||
...(mode === 'dark' && {
|
||||
hover: alpha(gray[600], 0.2),
|
||||
selected: alpha(gray[600], 0.3),
|
||||
}),
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
h1: {
|
||||
fontSize: defaultTheme.typography.pxToRem(48),
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
h2: {
|
||||
fontSize: defaultTheme.typography.pxToRem(36),
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
h3: {
|
||||
fontSize: defaultTheme.typography.pxToRem(30),
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
h4: {
|
||||
fontSize: defaultTheme.typography.pxToRem(24),
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
h5: {
|
||||
fontSize: defaultTheme.typography.pxToRem(20),
|
||||
fontWeight: 600,
|
||||
},
|
||||
h6: {
|
||||
fontSize: defaultTheme.typography.pxToRem(18),
|
||||
fontWeight: 600,
|
||||
},
|
||||
subtitle1: {
|
||||
fontSize: defaultTheme.typography.pxToRem(18),
|
||||
},
|
||||
subtitle2: {
|
||||
fontSize: defaultTheme.typography.pxToRem(14),
|
||||
fontWeight: 500,
|
||||
},
|
||||
body1: {
|
||||
fontSize: defaultTheme.typography.pxToRem(14),
|
||||
},
|
||||
body2: {
|
||||
fontSize: defaultTheme.typography.pxToRem(14),
|
||||
fontWeight: 400,
|
||||
},
|
||||
caption: {
|
||||
fontSize: defaultTheme.typography.pxToRem(12),
|
||||
fontWeight: 400,
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 8,
|
||||
},
|
||||
shadows: customShadows,
|
||||
};
|
||||
};
|
||||
|
||||
export const colorSchemes = {
|
||||
light: {
|
||||
palette: {
|
||||
primary: {
|
||||
light: brand[200],
|
||||
main: brand[400],
|
||||
dark: brand[700],
|
||||
contrastText: brand[50],
|
||||
},
|
||||
info: {
|
||||
light: brand[100],
|
||||
main: brand[300],
|
||||
dark: brand[600],
|
||||
contrastText: gray[50],
|
||||
},
|
||||
warning: {
|
||||
light: orange[300],
|
||||
main: orange[400],
|
||||
dark: orange[800],
|
||||
},
|
||||
error: {
|
||||
light: red[300],
|
||||
main: red[400],
|
||||
dark: red[800],
|
||||
},
|
||||
success: {
|
||||
light: green[300],
|
||||
main: green[400],
|
||||
dark: green[800],
|
||||
},
|
||||
grey: {
|
||||
...gray,
|
||||
},
|
||||
divider: alpha(gray[300], 0.4),
|
||||
background: {
|
||||
default: 'hsl(0, 0%, 99%)',
|
||||
paper: 'hsl(220, 35%, 97%)',
|
||||
},
|
||||
text: {
|
||||
primary: gray[800],
|
||||
secondary: gray[600],
|
||||
warning: orange[400],
|
||||
},
|
||||
action: {
|
||||
hover: alpha(gray[200], 0.2),
|
||||
selected: `${alpha(gray[200], 0.3)}`,
|
||||
},
|
||||
baseShadow:
|
||||
'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px',
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
primary: {
|
||||
contrastText: brand[50],
|
||||
light: brand[300],
|
||||
main: brand[400],
|
||||
dark: brand[700],
|
||||
},
|
||||
info: {
|
||||
contrastText: brand[300],
|
||||
light: brand[500],
|
||||
main: brand[700],
|
||||
dark: brand[900],
|
||||
},
|
||||
warning: {
|
||||
light: orange[400],
|
||||
main: orange[500],
|
||||
dark: orange[700],
|
||||
},
|
||||
error: {
|
||||
light: red[400],
|
||||
main: red[500],
|
||||
dark: red[700],
|
||||
},
|
||||
success: {
|
||||
light: green[400],
|
||||
main: green[500],
|
||||
dark: green[700],
|
||||
},
|
||||
grey: {
|
||||
...gray,
|
||||
},
|
||||
divider: alpha(gray[700], 0.6),
|
||||
background: {
|
||||
default: gray[900],
|
||||
paper: 'hsl(220, 30%, 7%)',
|
||||
},
|
||||
text: {
|
||||
primary: 'hsl(0, 0%, 100%)',
|
||||
secondary: gray[400],
|
||||
},
|
||||
action: {
|
||||
hover: alpha(gray[600], 0.2),
|
||||
selected: alpha(gray[600], 0.3),
|
||||
},
|
||||
baseShadow:
|
||||
'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const typography = {
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
h1: {
|
||||
fontSize: defaultTheme.typography.pxToRem(48),
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.2,
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
h2: {
|
||||
fontSize: defaultTheme.typography.pxToRem(36),
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
h3: {
|
||||
fontSize: defaultTheme.typography.pxToRem(30),
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
h4: {
|
||||
fontSize: defaultTheme.typography.pxToRem(24),
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
h5: {
|
||||
fontSize: defaultTheme.typography.pxToRem(20),
|
||||
fontWeight: 600,
|
||||
},
|
||||
h6: {
|
||||
fontSize: defaultTheme.typography.pxToRem(18),
|
||||
fontWeight: 600,
|
||||
},
|
||||
subtitle1: {
|
||||
fontSize: defaultTheme.typography.pxToRem(18),
|
||||
},
|
||||
subtitle2: {
|
||||
fontSize: defaultTheme.typography.pxToRem(14),
|
||||
fontWeight: 500,
|
||||
},
|
||||
body1: {
|
||||
fontSize: defaultTheme.typography.pxToRem(14),
|
||||
},
|
||||
body2: {
|
||||
fontSize: defaultTheme.typography.pxToRem(14),
|
||||
fontWeight: 400,
|
||||
},
|
||||
caption: {
|
||||
fontSize: defaultTheme.typography.pxToRem(12),
|
||||
fontWeight: 400,
|
||||
},
|
||||
};
|
||||
|
||||
export const shape = {
|
||||
borderRadius: 8,
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const defaultShadows: Shadows = [
|
||||
'none',
|
||||
'var(--template-palette-baseShadow)',
|
||||
...defaultTheme.shadows.slice(2),
|
||||
];
|
||||
export const shadows = defaultShadows;
|
||||
10
src/vite-env.d.ts
vendored
Normal file
10
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string;
|
||||
readonly VITE_AUTH_BASE_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
22
tsconfig.json
Normal file
22
tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
|
||||
// 👇 This line tells TypeScript how to handle JSX
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
vite.config.js
Normal file
7
vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
||||
Reference in New Issue
Block a user