26 Commits

Author SHA1 Message Date
cddc4d350f fixes 2026-04-03 20:09:36 +05:30
c73b55b737 pagination 2026-04-03 20:07:04 +05:30
f8cea025a3 pagination 2026-04-03 19:58:56 +05:30
9b87fb31a7 ui improvements for data ttable 2026-04-03 19:29:34 +05:30
7edf3e75da tooltip 2026-04-03 16:19:49 +05:30
63b31f0fc5 types of field for filtering 2026-04-03 15:28:49 +05:30
0f44a8e1b6 profile edit 2026-04-03 13:47:14 +05:30
399b2656b8 profile view 2026-04-03 13:40:06 +05:30
aa04b105d0 mobile view 2026-04-02 21:33:58 +05:30
c7095ed481 ts-ignore 2026-04-02 21:25:12 +05:30
ff3094cf09 added missing FormField 2026-04-02 21:17:15 +05:30
1f64b566cb passing token in missing headers 2026-04-02 21:16:56 +05:30
4b0d9ca425 smooth sidebar 2026-04-02 21:16:49 +05:30
08a84ea63f no scrolling on table 2026-04-02 21:14:43 +05:30
004a8a6876 smarter single item components 2026-04-02 21:10:15 +05:30
60d817fa8a navigation 2026-04-02 21:00:23 +05:30
36086e4b77 relation fixes 2026-04-02 20:38:09 +05:30
71f7ee83f1 relation fixes 2026-04-02 20:30:21 +05:30
a8581325fa fixes 2026-04-02 20:24:55 +05:30
6dc33be455 overrides for customisation 2026-04-01 19:24:09 +05:30
44567496a1 configuration for how fields look and EnhancedTable component for enhanced table display 2026-04-01 18:47:23 +05:30
344106f1a4 reading from openapi spec 2026-04-01 18:06:09 +05:30
3b472242a7 added ImageUpload and other input types. 2026-04-01 15:51:25 +05:30
14dcd19b17 generic src for react admin 2026-04-01 14:22:14 +05:30
4d06859cb0 bumped up to 0.3.2 for auth package changes
All checks were successful
continuous-integration/drone/tag Build is passing
2025-12-28 20:18:37 +05:30
226a6a651c Auth Package Extraction And Auth Flow Refactor (#2)
Reviewed-on: #2
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2025-12-28 14:47:37 +00:00
39 changed files with 2375 additions and 332 deletions

11
auth/package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "@local/auth",
"version": "0.1.0",
"private": true,
"main": "dist/index.js",
"types": "dist/index.d.ts",
"peerDependencies": {
"react": "^18",
"react-dom": "^18"
}
}

View File

@@ -1,26 +1,49 @@
import * as React from 'react'; import * as React from 'react';
import { Box, TextField, Button, Typography, IconButton, CircularProgress, Link } from '@mui/material'; import { Box, TextField, Button, Typography, IconButton, CircularProgress, Link } from '@mui/material';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useAuth } from '../providers/Author';
import { LoginProps } from '../types/props';
export default function Login({ 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, onBack,
onRegister onSwitchMode,
}: LoginProps) { login,
const { login, loading, error, currentUser } = useAuth(); register,
loading,
error,
currentUser,
}: AuthPageProps) {
const [username, setUsername] = React.useState(''); const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState(''); const [password, setPassword] = React.useState('');
const isLogin = mode === "login";
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (isLogin) {
await login(username, password); await login(username, password);
} else {
await register(username, password);
}
}; };
// ✅ Auto-return if already logged in // ✅ Auto-return if already logged in
React.useEffect(() => { React.useEffect(() => {
if (currentUser) onBack(); if (currentUser) onBack();
}, [currentUser]); }, [currentUser, onBack]);
return ( return (
<Box <Box
@@ -39,11 +62,13 @@ export default function Login({
</IconButton> </IconButton>
<Typography variant="h4" fontWeight="bold" gutterBottom> <Typography variant="h4" fontWeight="bold" gutterBottom>
Sign In {isLogin ? "Sign In" : "Create Account"}
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" gutterBottom> <Typography variant="body2" color="text.secondary" gutterBottom>
Please log in to continue {isLogin
? "Please log in to continue"
: "Create an account to get started"}
</Typography> </Typography>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
@@ -55,6 +80,7 @@ export default function Login({
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
required required
autoFocus
/> />
<TextField <TextField
fullWidth fullWidth
@@ -80,7 +106,13 @@ export default function Login({
sx={{ mt: 3 }} sx={{ mt: 3 }}
disabled={loading} disabled={loading}
> >
{loading ? <CircularProgress size={24} color="inherit" /> : 'Login'} {loading ? (
<CircularProgress size={24} color="inherit" />
) : isLogin ? (
"Login"
) : (
"Register"
)}
</Button> </Button>
</form> </form>
@@ -90,15 +122,15 @@ export default function Login({
align="center" align="center"
sx={{ mt: 3 }} sx={{ mt: 3 }}
> >
Dont have an account?{' '} {isLogin ? "Dont have an account?" : "Already have an account?"}{' '}
<Link <Link
component="button" component="button"
underline="hover" underline="hover"
color="primary" color="primary"
onClick={onRegister} onClick={onSwitchMode}
sx={{ fontWeight: 500 }} sx={{ fontWeight: 500 }}
> >
Register {isLogin ? "Register" : "Login"}
</Link> </Link>
</Typography> </Typography>
</Box> </Box>

32
auth/src/authClient.ts Normal file
View 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
auth/src/axios.ts Normal file
View 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
auth/src/contexts.tsx Normal file
View 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
auth/src/index.ts Normal file
View 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
auth/src/models.ts Normal file
View 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
auth/src/props.ts Normal file
View File

15
auth/src/token.ts Normal file
View 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);
},
};

29
package-lock.json generated
View File

@@ -1,17 +1,18 @@
{ {
"name": "aetoskia-blog-app", "name": "aetoskia-blog-app",
"version": "0.3.1", "version": "0.3.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "aetoskia-blog-app", "name": "aetoskia-blog-app",
"version": "0.2.1", "version": "0.3.2",
"dependencies": { "dependencies": {
"@emotion/react": "latest", "@emotion/react": "latest",
"@emotion/styled": "latest", "@emotion/styled": "latest",
"@mui/icons-material": "latest", "@mui/icons-material": "latest",
"@mui/material": "latest", "@mui/material": "latest",
"@tanstack/react-query": "^5.96.1",
"axios": "latest", "axios": "latest",
"markdown-to-jsx": "latest", "markdown-to-jsx": "latest",
"marked": "latest", "marked": "latest",
@@ -1408,6 +1409,30 @@
"win32" "win32"
] ]
}, },
"node_modules/@tanstack/query-core": {
"version": "5.96.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.96.1.tgz",
"integrity": "sha512-u1yBgtavSy+N8wgtW3PiER6UpxcplMje65yXnnVgiHTqiMwLlxiw4WvQDrXyn+UD6lnn8kHaxmerJUzQcV/MMg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.96.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.96.1.tgz",
"integrity": "sha512-2X7KYK5KKWUKGeWCVcqxXAkYefJtrKB7tSKWgeG++b0H6BRHxQaLSSi8AxcgjmUnnosHuh9WsFZqvE16P1WCzA==",
"dependencies": {
"@tanstack/query-core": "5.96.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "aetoskia-blog-app", "name": "aetoskia-blog-app",
"version": "0.3.1", "version": "0.3.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -10,15 +10,16 @@
"dependencies": { "dependencies": {
"@emotion/react": "latest", "@emotion/react": "latest",
"@emotion/styled": "latest", "@emotion/styled": "latest",
"@mui/material": "latest",
"@mui/icons-material": "latest", "@mui/icons-material": "latest",
"@mui/material": "latest",
"@tanstack/react-query": "^5.96.1",
"axios": "latest",
"markdown-to-jsx": "latest",
"marked": "latest",
"react": "latest", "react": "latest",
"react-dom": "latest", "react-dom": "latest",
"react-markdown": "latest", "react-markdown": "latest",
"markdown-to-jsx": "latest", "remark-gfm": "latest"
"remark-gfm": "latest",
"marked": "latest",
"axios": "latest"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "latest", "@vitejs/plugin-react": "latest",

View File

@@ -10,34 +10,36 @@ import ArticleView from './components/Article/ArticleView';
import ArticleEditor from './components/Article/ArticleEditor'; import ArticleEditor from './components/Article/ArticleEditor';
import Latest from './components/Latest'; import Latest from './components/Latest';
import Footer from './components/Footer'; import Footer from './components/Footer';
import Login from './components/Login';
import Register from './components/Register';
import Profile from './components/Profile'; import Profile from './components/Profile';
import { useArticles } from './providers/Article'; import { useArticles } from './providers/Article';
import { useAuth } from './providers/Author'; import { useAuth as useAuthor } from './providers/Author';
import { View, useViewRouter } from "./types/views"; import { View, useViewRouter } from "./types/views";
import { ArticleModel, ArticlesModel } from "./types/models"; import { ArticleModel, ArticlesModel } from "./types/models";
import { ArticleViewProps, ArticleEditorProps } from "./types/props"; import { ArticleViewProps, ArticleEditorProps } from "./types/props";
import { useAuth, AuthPage, AuthMode } from '../../auth/src';
function HomeView({ function HomeView({
currentUser, currentUser,
open_login, open_auth,
open_profile, open_profile,
open_create, open_create,
articles, articles,
openArticle openArticle,
}: any) { }: any) {
return ( return (
<> <>
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 2, gap: 1 }}> <Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2, gap: 1 }}>
{!currentUser ? ( {!currentUser ? (
<Button variant="outlined" onClick={open_login}>Login</Button> <Button variant='outlined' onClick={() => open_auth('login')}>
Login
</Button>
) : ( ) : (
<> <>
<Button variant="outlined" onClick={open_profile}> <Button variant='outlined' onClick={open_profile}>
{currentUser.username} {currentUser.username}
</Button> </Button>
<Button variant="contained" onClick={open_create}> <Button variant='contained' onClick={open_create}>
New Article New Article
</Button> </Button>
</> </>
@@ -52,11 +54,13 @@ function HomeView({
export default function Blog(props: { disableCustomTheme?: boolean }) { export default function Blog(props: { disableCustomTheme?: boolean }) {
const { articles, loading, error } = useArticles(); const { articles, loading, error } = useArticles();
const { currentUser } = useAuth(); const auth = useAuth();
const { currentUser } = useAuthor();
const [ui, setUI] = React.useState({ const [ui, setUI] = React.useState({
selectedArticle: null as ArticleModel | null, selectedArticle: null as ArticleModel | null,
view: "home" as View, view: 'home' as View,
authMode: 'login' as AuthMode,
}); });
useEffect(() => { useEffect(() => {
@@ -73,6 +77,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
setUI({ setUI({
selectedArticle: article, selectedArticle: article,
view: 'article', view: 'article',
authMode: 'login'
}); });
} }
} }
@@ -85,7 +90,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
} = useViewRouter(setUI); } = useViewRouter(setUI);
type RouterContext = { type RouterContext = {
ui: any; ui: typeof ui;
articles: ArticlesModel; articles: ArticlesModel;
currentUser: any; currentUser: any;
openArticle: (article: ArticleModel) => void; openArticle: (article: ArticleModel) => void;
@@ -97,20 +102,27 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
navigationMap?: Record<string, string>; navigationMap?: Record<string, string>;
}; };
// @ts-ignore
const VIEW_COMPONENTS: Record<View, ViewComponentEntry<any>> = { const VIEW_COMPONENTS: Record<View, ViewComponentEntry<any>> = {
home: { home: {
component: HomeView, component: HomeView,
}, },
login: { auth: {
component: Login, component: AuthPage,
navigationMap: { extraProps: ({ ui }) => ({
open_register: 'onRegister', mode: ui.authMode,
}, onSwitchMode: () =>
}, setUI((prev) => ({
...prev,
register: { authMode: prev.authMode === 'login' ? 'register' : 'login',
component: Register, })),
login: auth.login,
register: auth.register,
loading: auth.loading,
error: auth.error,
currentUser: currentUser,
}),
}, },
profile: { profile: {
@@ -123,6 +135,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
open_editor: 'onEdit', open_editor: 'onEdit',
}, },
extraProps: ({ ui, articles }) => ({ extraProps: ({ ui, articles }) => ({
// @ts-ignore
article: articles.readById(ui.selectedArticle._id), article: articles.readById(ui.selectedArticle._id),
}) satisfies Partial<ArticleViewProps>, }) satisfies Partial<ArticleViewProps>,
}, },
@@ -130,7 +143,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
editor: { editor: {
component: ArticleEditor, component: ArticleEditor,
extraProps: ({ ui, articles }) => ({ extraProps: ({ ui, articles }) => ({
article: ui.selectedArticle !== null ? articles.readById(ui.selectedArticle._id) : null, article: ui.selectedArticle !== null ? articles.readById(ui.selectedArticle._id as string) : null,
}) satisfies Partial<ArticleEditorProps>, }) satisfies Partial<ArticleEditorProps>,
}, },
@@ -147,10 +160,15 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
const navigationMap= entry['navigationMap'] || {} const navigationMap= entry['navigationMap'] || {}
const ViewComponent = entry.component; const ViewComponent = entry.component;
const childNav = navigateToChildren( const childNav = {
ui.view, ...navigateToChildren(ui.view, navigationMap),
navigationMap open_auth: (mode: AuthMode = 'login') =>
); setUI((prev) => ({
...prev,
view: 'auth',
authMode: mode,
})),
};
const ctx: RouterContext = { const ctx: RouterContext = {
ui, ui,
@@ -234,7 +252,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
{ui.view === 'home' && ( {ui.view === 'home' && (
<Box <Box
component="footer" component='footer'
sx={{ sx={{
position: 'fixed', position: 'fixed',
bottom: 0, bottom: 0,

View File

@@ -9,7 +9,8 @@ import {
Alert, Alert,
} from '@mui/material'; } from '@mui/material';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useAuth } from '../providers/Author'; import { useAuth as useAuthor } from '../providers/Author';
import { useAuth } from '../../../auth/src';
import { useUpload } from "../providers/Upload"; import { useUpload } from "../providers/Upload";
import ImageUploadField from './ImageUploadField'; import ImageUploadField from './ImageUploadField';
import { ProfileProps } from '../types/props'; import { ProfileProps } from '../types/props';
@@ -17,7 +18,9 @@ import { ProfileProps } from '../types/props';
export default function Profile({ export default function Profile({
onBack onBack
}: ProfileProps) { }: ProfileProps) {
const { currentUser, loading, error, logout, updateProfile } = useAuth(); const { logout } = useAuth();
const { currentUser, updateProfile, loading, error } = useAuthor();
const { uploadFile } = useUpload(); const { uploadFile } = useUpload();
const [formData, setFormData] = React.useState({ const [formData, setFormData] = React.useState({
username: currentUser?.username || '', username: currentUser?.username || '',

View File

@@ -1,113 +0,0 @@
import * as React from 'react';
import { Box, TextField, Button, Typography, IconButton, CircularProgress, Alert, } from '@mui/material';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useAuth } from '../providers/Author';
import { RegisterProps } from '../types/props';
export default function Register({
onBack
}: RegisterProps) {
const { register, loading, error, currentUser } = useAuth();
const [username, setUsername] = React.useState('');
const [password1, setPassword1] = React.useState('');
const [password2, setPassword2] = React.useState('');
const [localError, setLocalError] = React.useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLocalError(null);
// ✅ Local validation
if (password1 !== password2) {
setLocalError("Passwords don't match");
return;
}
if (password1.length < 6) {
setLocalError('Password must be at least 6 characters long');
return;
}
// ✅ Call backend
await register(username, password1);
};
if (currentUser) {
// ✅ if logged in, auto-return to the article list
onBack();
return null;
}
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>
Sign Up
</Typography>
<Typography variant="body2" color="text.secondary" gutterBottom>
Please sign up to continue
</Typography>
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="Username"
type="username"
margin="normal"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
<TextField
fullWidth
label="Password"
type="password"
margin="normal"
value={password1}
onChange={(e) => setPassword1(e.target.value)}
required
/>
<TextField
fullWidth
label="Password"
type="password"
margin="normal"
value={password2}
onChange={(e) => setPassword2(e.target.value)}
required
/>
{(localError || error) && (
<Alert severity="error" sx={{ mt: 2 }}>
{localError || error}
</Alert>
)}
<Button
fullWidth
type="submit"
variant="contained"
color="primary"
sx={{ mt: 3 }}
disabled={loading}
>
{loading ? <CircularProgress size={24} color="inherit" /> : 'Register'}
</Button>
</form>
</Box>
);
}

View File

@@ -1,12 +1,12 @@
import React, { createContext, useState, useContext, useEffect } from 'react'; import React, { createContext, useState, useContext, useEffect } from "react";
import { api } from '../utils/api'; import { api } from "../utils/api";
import { import {
ArticleModel, ArticleModel,
ArticlesModel, ArticlesModel,
createArticlesModelObject, createArticlesModelObject,
} from '../types/models'; } from "../types/models";
import { ArticleContextModel } from '../types/contexts'; import { ArticleContextModel } from "../types/contexts";
import { useAuth } from './Author'; import { useAuth } from "./Author";
const ArticleContext = createContext<ArticleContextModel | undefined>(undefined); const ArticleContext = createContext<ArticleContextModel | undefined>(undefined);
@@ -14,7 +14,7 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
const [articles, setArticles] = useState<ArticlesModel>(createArticlesModelObject()); const [articles, setArticles] = useState<ArticlesModel>(createArticlesModelObject());
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { token, currentUser } = useAuth(); const { currentUser } = useAuth();
/** 🔹 Author IDs must be strings for API, so we normalize here */ /** 🔹 Author IDs must be strings for API, so we normalize here */
const normalizeArticleForApi = (article: Partial<ArticleModel>) => { const normalizeArticleForApi = (article: Partial<ArticleModel>) => {
@@ -109,14 +109,8 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
/** 🔹 Auto-fetch articles whenever user logs in/out */ /** 🔹 Auto-fetch articles whenever user logs in/out */
useEffect(() => { useEffect(() => {
// Always load once on mount
// If endpoint requires JWT, fallback safely
if (!token) {
fetchArticles().catch(() => setLoading(false)); // try anyway (handles both public/protected)
} else {
fetchArticles(); fetchArticles();
} }, [currentUser]); // refetch on login / logout
}, [token]);
return ( return (
<ArticleContext.Provider value={{ <ArticleContext.Provider value={{

View File

@@ -1,70 +1,50 @@
import React, { createContext, useState, useEffect, useContext } from 'react'; import React, { createContext, useState, useEffect, useContext } from "react";
import { api, auth } from '../utils/api'; import { api } from "../utils/api";
import { AuthorModel } from '../types/models'; import { AuthorModel } from "../types/models";
import { AuthContextModel } from '../types/contexts'; import { AuthContextModel } from "../types/contexts";
import { useAuth as useBaseAuth } from "../../../auth/src";
const AuthContext = createContext<AuthContextModel | undefined>(undefined); const AuthContext = createContext<AuthContextModel | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { currentUser: authUser, logout } = useBaseAuth();
const [currentUser, setCurrentUser] = useState<AuthorModel | null>(null); const [currentUser, setCurrentUser] = useState<AuthorModel | null>(null);
const [authors, setAuthors] = useState<AuthorModel[]>([]); const [authors, setAuthors] = useState<AuthorModel[]>([]);
const [token, setToken] = useState<string | null>(localStorage.getItem('token')); const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
/** 🔹 Register new user */ /**
const register = async (username: string, password: string) => { * Hydrate application-level currentUser
*/
const hydrateCurrentUser = async () => {
if (!authUser) return;
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const res = await auth.post('/register', { username, password }); const res = await api.get<AuthorModel>("/authors/me");
// auto-login /**
// await login(username, password); * Explicit precedence:
* Auth service is source of truth for inherited fields
*/
const fullUser: AuthorModel = {
...res.data,
username: authUser.username,
email: authUser.email,
is_active: authUser.is_active,
};
// now create author setCurrentUser(fullUser);
await api.post('/authors', { name: null, avatar: null }); } catch (err) {
console.error("Failed to hydrate current user:", err);
return res.data; logout();
} catch (err: any) {
console.error('Registration failed:', err);
setError(err.response?.data?.detail || 'Registration failed');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
/** 🔹 Login and store JWT token */
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;
if (access_token) {
localStorage.setItem('token', access_token);
setToken(access_token);
setCurrentUser(user);
}
} catch (err: any) {
console.error('Login failed:', err);
setError(err.response?.data?.detail || 'Invalid credentials');
} finally {
setLoading(false);
}
};
/** 🔹 Logout and clear everything */
const logout = () => {
localStorage.removeItem('token');
setToken(null);
setCurrentUser(null);
setAuthors([]);
};
/** 🔹 Fetch all authors (JWT handled by api interceptor) */ /** 🔹 Fetch all authors (JWT handled by api interceptor) */
const refreshAuthors = async () => { const refreshAuthors = async () => {
try { try {
@@ -102,39 +82,27 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
} }
}; };
/** 🔹 Auto-load current user if token exists */ /**
const fetchCurrentUser = async () => { * React strictly to auth lifecycle
if (!token) return; */
try {
const me = await auth.get('/me');
const author = await api.get<AuthorModel>(`/authors/me`);
const fullUser = { ...me.data, ...author.data };
setCurrentUser(fullUser);
} catch (err) {
console.error('Failed to fetch current user:', err);
logout();
}
};
/** 🔹 On mount, try to fetch user if token exists */
useEffect(() => { useEffect(() => {
if (token) fetchCurrentUser(); if (authUser) {
}, [token]); hydrateCurrentUser();
} else {
setCurrentUser(null);
setAuthors([]);
setError(null);
}
}, [authUser]);
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
currentUser, currentUser,
authors, authors,
token,
loading, loading,
error, error,
login,
logout,
register,
refreshAuthors, refreshAuthors,
updateProfile, updateProfile,
}} }}

View File

@@ -16,12 +16,9 @@ export interface ArticleContextModel {
export interface AuthContextModel { export interface AuthContextModel {
currentUser: AuthorModel | null; currentUser: AuthorModel | null;
authors: AuthorModel[]; authors: AuthorModel[];
token: string | null;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
login: (username: string, password: string) => Promise<void>;
register: (username: string, password: string) => Promise<void>;
logout: () => void;
refreshAuthors: () => Promise<void>; refreshAuthors: () => Promise<void>;
updateProfile: (user: AuthorModel) => Promise<AuthorModel | void>; updateProfile: (user: AuthorModel) => Promise<AuthorModel | void>;
} }

View File

@@ -2,20 +2,18 @@ import {
createInList, readInList, updateInList, deleteInList, createInList, readInList, updateInList, deleteInList,
createById, readById, updateById, deleteById createById, readById, updateById, deleteById
} from "../utils/articles"; } from "../utils/articles";
import { AuthUser } from "../../../auth/src";
export interface AuthorModel { export interface AuthorModel extends AuthUser {
// meta fields // meta fields
_id?: string | null; _id?: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
// model fields // model fields
username: string;
name: string; name: string;
email: string;
avatar: string; avatar: string;
is_active: boolean;
} }
export interface ArticleModel { export interface ArticleModel {

View File

@@ -9,11 +9,6 @@ export interface LatestProps {
onLoadMore?: (offset: number, limit: number) => Promise<void>; // optional async callback onLoadMore?: (offset: number, limit: number) => Promise<void>; // optional async callback
} }
export interface LoginProps {
onBack: () => void;
onRegister: () => void;
}
export interface MainContentProps { export interface MainContentProps {
articles: ArticlesModel; articles: ArticlesModel;
onSelectArticle: (index: ArticleModel) => void; onSelectArticle: (index: ArticleModel) => void;
@@ -23,10 +18,6 @@ export interface ProfileProps {
onBack: () => void; onBack: () => void;
} }
export interface RegisterProps {
onBack: () => void;
}
export interface ArticleViewProps { export interface ArticleViewProps {
article: ArticleModel; article: ArticleModel;
onBack: () => void; onBack: () => void;

View File

@@ -1,9 +1,8 @@
import {ArticleModel} from "./models"; import { ArticleModel } from "./models";
export type View = export type View =
| "home" | "home"
| "login" | "auth"
| "register"
| "article" | "article"
| "editor" | "editor"
| "profile" | "profile"
@@ -17,25 +16,26 @@ export type ViewNode = {
export const VIEW_TREE: Record<View, ViewNode> = { export const VIEW_TREE: Record<View, ViewNode> = {
home: { home: {
parent: null, parent: null,
children: ["login", "article", "profile", "create"], children: ["auth", "article", "profile", "create"],
}, },
login: {
auth: {
parent: "home", parent: "home",
children: ["register"],
},
register: {
parent: "login",
}, },
article: { article: {
parent: "home", parent: "home",
children: ["editor"], children: ["editor"],
}, },
editor: { editor: {
parent: "article", parent: "article",
}, },
profile: { profile: {
parent: "home", parent: "home",
}, },
create: { create: {
parent: "home", parent: "home",
}, },
@@ -43,11 +43,15 @@ export const VIEW_TREE: Record<View, ViewNode> = {
export const VIEW_URL: Record<View, (ui?: any) => string> = { export const VIEW_URL: Record<View, (ui?: any) => string> = {
home: () => "/", home: () => "/",
login: () => "/login",
register: () => "/register", auth: () => "/auth",
profile: () => "/profile", profile: () => "/profile",
create: () => "/create", create: () => "/create",
article: (ui) => `/articles/${ui.selectedArticle._id ?? ""}`, article: (ui) => `/articles/${ui.selectedArticle._id ?? ""}`,
editor: (ui) => `/articles/${ui.selectedArticle._id ?? ""}/edit`, editor: (ui) => `/articles/${ui.selectedArticle._id ?? ""}/edit`,
}; };

View File

@@ -1,53 +1,19 @@
// src/utils/api.ts import { createApiClient } from "../../../auth/src";
import axios from 'axios';
const AUTH_BASE = import.meta.env.VITE_AUTH_BASE_URL; const AUTH_BASE = import.meta.env.VITE_AUTH_BASE_URL;
const API_BASE = import.meta.env.VITE_API_BASE_URL; const API_BASE = import.meta.env.VITE_API_BASE_URL;
//------------------------------------------------------ /**
// COMMON TOKEN ATTACHMENT LOGIC * Auth service client
//------------------------------------------------------ * - login
const attachToken = (config: any) => { * - register
const token = localStorage.getItem('token'); * - me
if (token) { * - logout
config.headers.Authorization = `Bearer ${token}`; * - introspect
} */
return config; export const auth = createApiClient(AUTH_BASE);
};
const handleAuthError = (error: any) => { /**
if (error.response?.status === 401) { * Main application API (blog, articles, etc.)
console.warn('Token expired or invalid. Logging out...'); */
localStorage.removeItem('token'); export const api = createApiClient(API_BASE);
// Optional: eventBus, redirect, logout callback
}
return Promise.reject(error);
};
//------------------------------------------------------
// AUTH SERVICE CLIENT
//------------------------------------------------------
export const auth = axios.create({
baseURL: AUTH_BASE,
headers: {
'Content-Type': 'application/json',
},
});
//------------------------------------------------------
// BLOG SERVICE CLIENT
//------------------------------------------------------
export const api = axios.create({
baseURL: API_BASE,
headers: {
'Content-Type': 'application/json',
},
});
// Attach token + 401 handling
api.interceptors.request.use(attachToken);
api.interceptors.response.use((res) => res, handleAuthError);
// Auth service ALSO needs token for /me, /logout, /introspect
auth.interceptors.request.use(attachToken);
auth.interceptors.response.use((res) => res, handleAuthError);

View File

@@ -2,18 +2,22 @@ import * as React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import Blog from './blog/Blog'; import Blog from './blog/Blog';
import { ArticleProvider } from './blog/providers/Article'; import { ArticleProvider } from './blog/providers/Article';
import { AuthProvider } from './blog/providers/Author'; import { AuthProvider as AuthorProvider } from './blog/providers/Author';
import { UploadProvider } from "./blog/providers/Upload"; import { UploadProvider } from "./blog/providers/Upload";
import { AuthProvider } from "../auth/src";
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
const root = createRoot(rootElement); const root = createRoot(rootElement);
const AUTH_BASE = import.meta.env.VITE_AUTH_BASE_URL;
root.render( root.render(
<UploadProvider> <UploadProvider>
<AuthProvider> <AuthProvider authBaseUrl={AUTH_BASE}>
<AuthorProvider>
<ArticleProvider> <ArticleProvider>
<Blog /> <Blog />
</ArticleProvider> </ArticleProvider>
</AuthorProvider>
</AuthProvider> </AuthProvider>
</UploadProvider>, </UploadProvider>
); );

160
src_generic/App.tsx Normal file
View File

@@ -0,0 +1,160 @@
import * as React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AuthProvider, useAuth, AuthPage } from "../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 "../src/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 App() {
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
src_generic/api/client.ts Normal file
View 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);
}

View 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>
);
}

View 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;
}

View 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 '../App';
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>
);
}

View 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 '../App';
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>
);
}

View 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>
);
}

View 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
/>
);
}

View 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>
);
}

14
src_generic/config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { AppConfig } from "./types/config";
import { loadConfigFromOpenApi } from "./utils/openapi_loader";
export async function getAppConfig(): Promise<AppConfig> {
const baseUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"
const config = await loadConfigFromOpenApi(baseUrl);
// You can still apply overrides here
return {
...config,
authBaseUrl: import.meta.env.VITE_AUTH_BASE_URL || "http://localhost:8001",
baseUrl: import.meta.env.VITE_API_BASE_URL || config.baseUrl,
};
}

View 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,
};

View 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,
};
}

18
src_generic/main.tsx Normal file
View File

@@ -0,0 +1,18 @@
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import { Buffer } from 'buffer';
import process from 'process';
import App from './App';
// Polyfill Node.js globals for browser environment (needed by SwaggerParser)
window.Buffer = Buffer;
window.process = process;
const rootElement = document.getElementById('root');
const root = createRoot(rootElement!);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View 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;
};

View 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>;
};
}

View 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;
}

View 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,
};
}