Compare commits
10 Commits
0.2.5
...
a8581325fa
| Author | SHA1 | Date | |
|---|---|---|---|
| a8581325fa | |||
| 6dc33be455 | |||
| 44567496a1 | |||
| 344106f1a4 | |||
| 3b472242a7 | |||
| 14dcd19b17 | |||
| 4d06859cb0 | |||
| 226a6a651c | |||
| 14b43cb3c5 | |||
| 8f398c35df |
@@ -66,6 +66,8 @@ steps:
|
||||
environment:
|
||||
API_BASE_URL:
|
||||
from_secret: API_BASE_URL
|
||||
AUTH_BASE_URL:
|
||||
from_secret: AUTH_BASE_URL
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
@@ -76,6 +78,7 @@ steps:
|
||||
- |
|
||||
docker build --network=host \
|
||||
--build-arg VITE_API_BASE_URL="$API_BASE_URL" \
|
||||
--build-arg VITE_AUTH_BASE_URL="$AUTH_BASE_URL" \
|
||||
-t apps/blog:$IMAGE_TAG \
|
||||
-t apps/blog:latest \
|
||||
/drone/src
|
||||
|
||||
@@ -15,7 +15,8 @@ COPY . .
|
||||
|
||||
# Build the app
|
||||
ARG VITE_API_BASE_URL
|
||||
RUN VITE_API_BASE_URL=$VITE_API_BASE_URL npm run build
|
||||
ARG VITE_AUTH_BASE_URL
|
||||
RUN VITE_API_BASE_URL=$VITE_API_BASE_URL VITE_AUTH_BASE_URL=$VITE_AUTH_BASE_URL npm run build
|
||||
|
||||
# Stage 2: Static file server (BusyBox)
|
||||
FROM busybox:latest
|
||||
|
||||
11
auth/package.json
Normal file
11
auth/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,49 @@
|
||||
import * as React from 'react';
|
||||
import { Box, TextField, Button, Typography, IconButton, CircularProgress, Link } from '@mui/material';
|
||||
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,
|
||||
onRegister
|
||||
}: LoginProps) {
|
||||
const { login, loading, error, currentUser } = useAuth();
|
||||
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]);
|
||||
}, [currentUser, onBack]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -39,11 +62,13 @@ export default function Login({
|
||||
</IconButton>
|
||||
|
||||
<Typography variant="h4" fontWeight="bold" gutterBottom>
|
||||
Sign In
|
||||
{isLogin ? "Sign In" : "Create Account"}
|
||||
</Typography>
|
||||
|
||||
<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>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
@@ -55,6 +80,7 @@ export default function Login({
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
@@ -80,7 +106,13 @@ export default function Login({
|
||||
sx={{ mt: 3 }}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <CircularProgress size={24} color="inherit" /> : 'Login'}
|
||||
{loading ? (
|
||||
<CircularProgress size={24} color="inherit" />
|
||||
) : isLogin ? (
|
||||
"Login"
|
||||
) : (
|
||||
"Register"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -90,15 +122,15 @@ export default function Login({
|
||||
align="center"
|
||||
sx={{ mt: 3 }}
|
||||
>
|
||||
Don’t have an account?{' '}
|
||||
{isLogin ? "Don’t have an account?" : "Already have an account?"}{' '}
|
||||
<Link
|
||||
component="button"
|
||||
underline="hover"
|
||||
color="primary"
|
||||
onClick={onRegister}
|
||||
onClick={onSwitchMode}
|
||||
sx={{ fontWeight: 500 }}
|
||||
>
|
||||
Register
|
||||
{isLogin ? "Register" : "Login"}
|
||||
</Link>
|
||||
</Typography>
|
||||
</Box>
|
||||
32
auth/src/authClient.ts
Normal file
32
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();
|
||||
},
|
||||
};
|
||||
35
auth/src/axios.ts
Normal file
35
auth/src/axios.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
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) {
|
||||
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
97
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
auth/src/index.ts
Normal file
6
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
auth/src/models.ts
Normal file
11
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
auth/src/props.ts
Normal file
0
auth/src/props.ts
Normal file
15
auth/src/token.ts
Normal file
15
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);
|
||||
},
|
||||
};
|
||||
29
package-lock.json
generated
29
package-lock.json
generated
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"name": "aetoskia-blog-app",
|
||||
"version": "0.2.1",
|
||||
"version": "0.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "aetoskia-blog-app",
|
||||
"version": "0.2.1",
|
||||
"version": "0.3.2",
|
||||
"dependencies": {
|
||||
"@emotion/react": "latest",
|
||||
"@emotion/styled": "latest",
|
||||
"@mui/icons-material": "latest",
|
||||
"@mui/material": "latest",
|
||||
"@tanstack/react-query": "^5.96.1",
|
||||
"axios": "latest",
|
||||
"markdown-to-jsx": "latest",
|
||||
"marked": "latest",
|
||||
@@ -1408,6 +1409,30 @@
|
||||
"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": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
|
||||
13
package.json
13
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aetoskia-blog-app",
|
||||
"version": "0.2.5",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -10,15 +10,16 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "latest",
|
||||
"@emotion/styled": "latest",
|
||||
"@mui/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-dom": "latest",
|
||||
"react-markdown": "latest",
|
||||
"markdown-to-jsx": "latest",
|
||||
"remark-gfm": "latest",
|
||||
"marked": "latest",
|
||||
"axios": "latest"
|
||||
"remark-gfm": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "latest",
|
||||
|
||||
@@ -10,34 +10,36 @@ import ArticleView from './components/Article/ArticleView';
|
||||
import ArticleEditor from './components/Article/ArticleEditor';
|
||||
import Latest from './components/Latest';
|
||||
import Footer from './components/Footer';
|
||||
import Login from './components/Login';
|
||||
import Register from './components/Register';
|
||||
import Profile from './components/Profile';
|
||||
import { useArticles } from './providers/Article';
|
||||
import { useAuth } from './providers/Author';
|
||||
import { useAuth as useAuthor } from './providers/Author';
|
||||
import { View, useViewRouter } from "./types/views";
|
||||
import { ArticleModel, ArticlesModel } from "./types/models";
|
||||
import { ArticleViewProps, ArticleEditorProps } from "./types/props";
|
||||
|
||||
import { useAuth, AuthPage, AuthMode } from '../../auth/src';
|
||||
|
||||
function HomeView({
|
||||
currentUser,
|
||||
open_login,
|
||||
open_auth,
|
||||
open_profile,
|
||||
open_create,
|
||||
articles,
|
||||
openArticle
|
||||
openArticle,
|
||||
}: any) {
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 2, gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2, gap: 1 }}>
|
||||
{!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}
|
||||
</Button>
|
||||
<Button variant="contained" onClick={open_create}>
|
||||
<Button variant='contained' onClick={open_create}>
|
||||
New Article
|
||||
</Button>
|
||||
</>
|
||||
@@ -52,11 +54,13 @@ function HomeView({
|
||||
|
||||
export default function Blog(props: { disableCustomTheme?: boolean }) {
|
||||
const { articles, loading, error } = useArticles();
|
||||
const { currentUser } = useAuth();
|
||||
const auth = useAuth();
|
||||
const { currentUser } = useAuthor();
|
||||
|
||||
const [ui, setUI] = React.useState({
|
||||
selectedArticle: null as ArticleModel | null,
|
||||
view: "home" as View,
|
||||
view: 'home' as View,
|
||||
authMode: 'login' as AuthMode,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -73,6 +77,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
|
||||
setUI({
|
||||
selectedArticle: article,
|
||||
view: 'article',
|
||||
authMode: 'login'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -85,7 +90,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
|
||||
} = useViewRouter(setUI);
|
||||
|
||||
type RouterContext = {
|
||||
ui: any;
|
||||
ui: typeof ui;
|
||||
articles: ArticlesModel;
|
||||
currentUser: any;
|
||||
openArticle: (article: ArticleModel) => void;
|
||||
@@ -97,20 +102,27 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
|
||||
navigationMap?: Record<string, string>;
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const VIEW_COMPONENTS: Record<View, ViewComponentEntry<any>> = {
|
||||
home: {
|
||||
component: HomeView,
|
||||
},
|
||||
|
||||
login: {
|
||||
component: Login,
|
||||
navigationMap: {
|
||||
open_register: 'onRegister',
|
||||
},
|
||||
},
|
||||
|
||||
register: {
|
||||
component: Register,
|
||||
auth: {
|
||||
component: AuthPage,
|
||||
extraProps: ({ ui }) => ({
|
||||
mode: ui.authMode,
|
||||
onSwitchMode: () =>
|
||||
setUI((prev) => ({
|
||||
...prev,
|
||||
authMode: prev.authMode === 'login' ? 'register' : 'login',
|
||||
})),
|
||||
login: auth.login,
|
||||
register: auth.register,
|
||||
loading: auth.loading,
|
||||
error: auth.error,
|
||||
currentUser: currentUser,
|
||||
}),
|
||||
},
|
||||
|
||||
profile: {
|
||||
@@ -123,6 +135,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
|
||||
open_editor: 'onEdit',
|
||||
},
|
||||
extraProps: ({ ui, articles }) => ({
|
||||
// @ts-ignore
|
||||
article: articles.readById(ui.selectedArticle._id),
|
||||
}) satisfies Partial<ArticleViewProps>,
|
||||
},
|
||||
@@ -130,7 +143,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
|
||||
editor: {
|
||||
component: ArticleEditor,
|
||||
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>,
|
||||
},
|
||||
|
||||
@@ -147,10 +160,15 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
|
||||
const navigationMap= entry['navigationMap'] || {}
|
||||
const ViewComponent = entry.component;
|
||||
|
||||
const childNav = navigateToChildren(
|
||||
ui.view,
|
||||
navigationMap
|
||||
);
|
||||
const childNav = {
|
||||
...navigateToChildren(ui.view, navigationMap),
|
||||
open_auth: (mode: AuthMode = 'login') =>
|
||||
setUI((prev) => ({
|
||||
...prev,
|
||||
view: 'auth',
|
||||
authMode: mode,
|
||||
})),
|
||||
};
|
||||
|
||||
const ctx: RouterContext = {
|
||||
ui,
|
||||
@@ -234,7 +252,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
|
||||
|
||||
{ui.view === 'home' && (
|
||||
<Box
|
||||
component="footer"
|
||||
component='footer'
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
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 ImageUploadField from './ImageUploadField';
|
||||
import { ProfileProps } from '../types/props';
|
||||
@@ -17,7 +18,9 @@ import { ProfileProps } from '../types/props';
|
||||
export default function Profile({
|
||||
onBack
|
||||
}: ProfileProps) {
|
||||
const { currentUser, loading, error, logout, updateProfile } = useAuth();
|
||||
const { logout } = useAuth();
|
||||
const { currentUser, updateProfile, loading, error } = useAuthor();
|
||||
|
||||
const { uploadFile } = useUpload();
|
||||
const [formData, setFormData] = React.useState({
|
||||
username: currentUser?.username || '',
|
||||
@@ -133,6 +136,7 @@ export default function Profile({
|
||||
label="Username"
|
||||
name="username"
|
||||
margin="normal"
|
||||
disabled={true}
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { createContext, useState, useContext, useEffect } from 'react';
|
||||
import { api } from '../utils/api';
|
||||
import React, { createContext, useState, useContext, useEffect } from "react";
|
||||
import { api } from "../utils/api";
|
||||
import {
|
||||
ArticleModel,
|
||||
ArticlesModel,
|
||||
createArticlesModelObject,
|
||||
} from '../types/models';
|
||||
import { ArticleContextModel } from '../types/contexts';
|
||||
import { useAuth } from './Author';
|
||||
} from "../types/models";
|
||||
import { ArticleContextModel } from "../types/contexts";
|
||||
import { useAuth } from "./Author";
|
||||
|
||||
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 [loading, setLoading] = useState<boolean>(true);
|
||||
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 */
|
||||
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 */
|
||||
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();
|
||||
}
|
||||
}, [token]);
|
||||
}, [currentUser]); // refetch on login / logout
|
||||
|
||||
return (
|
||||
<ArticleContext.Provider value={{
|
||||
|
||||
@@ -1,63 +1,50 @@
|
||||
import React, { createContext, useState, useEffect, useContext } from 'react';
|
||||
import { api } from '../utils/api';
|
||||
import { AuthorModel } from '../types/models';
|
||||
import { AuthContextModel } from '../types/contexts';
|
||||
import React, { createContext, useState, useEffect, useContext } from "react";
|
||||
import { api } from "../utils/api";
|
||||
import { AuthorModel } from "../types/models";
|
||||
import { AuthContextModel } from "../types/contexts";
|
||||
import { useAuth as useBaseAuth } from "../../../auth/src";
|
||||
|
||||
const AuthContext = createContext<AuthContextModel | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { currentUser: authUser, logout } = useBaseAuth();
|
||||
const [currentUser, setCurrentUser] = useState<AuthorModel | null>(null);
|
||||
const [authors, setAuthors] = useState<AuthorModel[]>([]);
|
||||
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
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 {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await api.post('/auth/register', { username, password });
|
||||
return res.data;
|
||||
} catch (err: any) {
|
||||
console.error('Registration failed:', err);
|
||||
setError(err.response?.data?.detail || 'Registration failed');
|
||||
const res = await api.get<AuthorModel>("/authors/me");
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
|
||||
setCurrentUser(fullUser);
|
||||
} catch (err) {
|
||||
console.error("Failed to hydrate current user:", err);
|
||||
logout();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 🔹 Login and store JWT token */
|
||||
const login = async (username: string, password: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await api.post('/auth/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) */
|
||||
const refreshAuthors = async () => {
|
||||
try {
|
||||
@@ -95,39 +82,27 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}
|
||||
};
|
||||
|
||||
/** 🔹 Auto-load current user if token exists */
|
||||
const fetchCurrentUser = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const me = await api.get<{ _id: string; username: string; email: string }>('/auth/me');
|
||||
|
||||
const author = await api.get<AuthorModel>(`/authors/${me.data._id}`);
|
||||
|
||||
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 */
|
||||
/**
|
||||
* React strictly to auth lifecycle
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (token) fetchCurrentUser();
|
||||
}, [token]);
|
||||
if (authUser) {
|
||||
hydrateCurrentUser();
|
||||
} else {
|
||||
setCurrentUser(null);
|
||||
setAuthors([]);
|
||||
setError(null);
|
||||
}
|
||||
}, [authUser]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
currentUser,
|
||||
authors,
|
||||
token,
|
||||
loading,
|
||||
error,
|
||||
login,
|
||||
logout,
|
||||
register,
|
||||
|
||||
refreshAuthors,
|
||||
updateProfile,
|
||||
}}
|
||||
|
||||
@@ -16,12 +16,9 @@ export interface ArticleContextModel {
|
||||
export interface AuthContextModel {
|
||||
currentUser: AuthorModel | null;
|
||||
authors: AuthorModel[];
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
register: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
|
||||
refreshAuthors: () => Promise<void>;
|
||||
updateProfile: (user: AuthorModel) => Promise<AuthorModel | void>;
|
||||
}
|
||||
|
||||
@@ -2,20 +2,18 @@ import {
|
||||
createInList, readInList, updateInList, deleteInList,
|
||||
createById, readById, updateById, deleteById
|
||||
} from "../utils/articles";
|
||||
import { AuthUser } from "../../../auth/src";
|
||||
|
||||
|
||||
export interface AuthorModel {
|
||||
export interface AuthorModel extends AuthUser {
|
||||
// meta fields
|
||||
_id?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
// model fields
|
||||
username: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface ArticleModel {
|
||||
|
||||
@@ -9,11 +9,6 @@ export interface LatestProps {
|
||||
onLoadMore?: (offset: number, limit: number) => Promise<void>; // optional async callback
|
||||
}
|
||||
|
||||
export interface LoginProps {
|
||||
onBack: () => void;
|
||||
onRegister: () => void;
|
||||
}
|
||||
|
||||
export interface MainContentProps {
|
||||
articles: ArticlesModel;
|
||||
onSelectArticle: (index: ArticleModel) => void;
|
||||
@@ -23,10 +18,6 @@ export interface ProfileProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export interface RegisterProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export interface ArticleViewProps {
|
||||
article: ArticleModel;
|
||||
onBack: () => void;
|
||||
|
||||
@@ -2,8 +2,7 @@ import {ArticleModel} from "./models";
|
||||
|
||||
export type View =
|
||||
| "home"
|
||||
| "login"
|
||||
| "register"
|
||||
| "auth"
|
||||
| "article"
|
||||
| "editor"
|
||||
| "profile"
|
||||
@@ -17,25 +16,26 @@ export type ViewNode = {
|
||||
export const VIEW_TREE: Record<View, ViewNode> = {
|
||||
home: {
|
||||
parent: null,
|
||||
children: ["login", "article", "profile", "create"],
|
||||
children: ["auth", "article", "profile", "create"],
|
||||
},
|
||||
login: {
|
||||
|
||||
auth: {
|
||||
parent: "home",
|
||||
children: ["register"],
|
||||
},
|
||||
register: {
|
||||
parent: "login",
|
||||
},
|
||||
|
||||
article: {
|
||||
parent: "home",
|
||||
children: ["editor"],
|
||||
},
|
||||
|
||||
editor: {
|
||||
parent: "article",
|
||||
},
|
||||
|
||||
profile: {
|
||||
parent: "home",
|
||||
},
|
||||
|
||||
create: {
|
||||
parent: "home",
|
||||
},
|
||||
@@ -43,11 +43,15 @@ export const VIEW_TREE: Record<View, ViewNode> = {
|
||||
|
||||
export const VIEW_URL: Record<View, (ui?: any) => string> = {
|
||||
home: () => "/",
|
||||
login: () => "/login",
|
||||
register: () => "/register",
|
||||
|
||||
auth: () => "/auth",
|
||||
|
||||
profile: () => "/profile",
|
||||
|
||||
create: () => "/create",
|
||||
|
||||
article: (ui) => `/articles/${ui.selectedArticle._id ?? ""}`,
|
||||
|
||||
editor: (ui) => `/articles/${ui.selectedArticle._id ?? ""}/edit`,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,33 +1,19 @@
|
||||
// src/utils/api.ts
|
||||
import axios from 'axios';
|
||||
import { createApiClient } from "../../../auth/src";
|
||||
|
||||
const AUTH_BASE = import.meta.env.VITE_AUTH_BASE_URL;
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: API_BASE,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
/**
|
||||
* Auth service client
|
||||
* - login
|
||||
* - register
|
||||
* - me
|
||||
* - logout
|
||||
* - introspect
|
||||
*/
|
||||
export const auth = createApiClient(AUTH_BASE);
|
||||
|
||||
// 🔹 Attach token from localStorage before each request
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// 🔹 Handle expired or invalid tokens globally
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
console.warn('Token expired or invalid. Logging out...');
|
||||
localStorage.removeItem('token');
|
||||
// Optionally: trigger a redirect or event
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
/**
|
||||
* Main application API (blog, articles, etc.)
|
||||
*/
|
||||
export const api = createApiClient(API_BASE);
|
||||
|
||||
10
src/main.jsx
10
src/main.jsx
@@ -2,18 +2,22 @@ import * as React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import Blog from './blog/Blog';
|
||||
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 { AuthProvider } from "../auth/src";
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
const root = createRoot(rootElement);
|
||||
const AUTH_BASE = import.meta.env.VITE_AUTH_BASE_URL;
|
||||
|
||||
root.render(
|
||||
<UploadProvider>
|
||||
<AuthProvider>
|
||||
<AuthProvider authBaseUrl={AUTH_BASE}>
|
||||
<AuthorProvider>
|
||||
<ArticleProvider>
|
||||
<Blog />
|
||||
</ArticleProvider>
|
||||
</AuthorProvider>
|
||||
</AuthProvider>
|
||||
</UploadProvider>,
|
||||
</UploadProvider>
|
||||
);
|
||||
|
||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
@@ -2,6 +2,7 @@
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string;
|
||||
readonly VITE_AUTH_BASE_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
143
src_generic/App.tsx
Normal file
143
src_generic/App.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
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";
|
||||
|
||||
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);
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Welcome to the Admin Panel
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
Select a resource from the sidebar to manage data.
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
|
||||
gap: 3,
|
||||
mt: 4,
|
||||
}}
|
||||
>
|
||||
{config?.resources.map((res) => (
|
||||
<Paper key={res.name} sx={{ p: 3, textAlign: "center" }}>
|
||||
<Typography variant="h6">{res.pluralLabel}</Typography>
|
||||
</Paper>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminApp() {
|
||||
const { currentUser, login, logout, loading, error } = useAuth();
|
||||
const config = React.useContext(ConfigContext);
|
||||
const [selectedResourceName, setSelectedResourceName] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [selectedItemId, setSelectedItemId] = React.useState<string | null>(null);
|
||||
|
||||
const handleNavigateToResource = (resourceName: string, id: string) => {
|
||||
setSelectedResourceName(resourceName);
|
||||
setSelectedItemId(id);
|
||||
};
|
||||
|
||||
if (!currentUser) {
|
||||
return (
|
||||
<AuthPage
|
||||
mode="login"
|
||||
login={login}
|
||||
register={async () => {}} // Disable registration for Admin
|
||||
loading={loading}
|
||||
error={error}
|
||||
onSwitchMode={() => {}}
|
||||
onBack={() => {}}
|
||||
currentUser={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const selectedResource = config?.resources.find(
|
||||
(r) => r.name === selectedResourceName
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
username={currentUser.username}
|
||||
onLogout={logout}
|
||||
selectedResourceName={selectedResourceName}
|
||||
onSelectResource={(name) => {
|
||||
setSelectedResourceName(name);
|
||||
setSelectedItemId(null);
|
||||
}}
|
||||
resources={config?.resources || []}
|
||||
>
|
||||
{selectedResource ? (
|
||||
<ResourceView
|
||||
key={`${selectedResource.name}-${selectedItemId}`}
|
||||
config={selectedResource}
|
||||
onNavigateToResource={handleNavigateToResource}
|
||||
/>
|
||||
) : (
|
||||
<Dashboard />
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<AdminApp />
|
||||
</UploadProvider>
|
||||
</AuthProvider>
|
||||
</ConfigContext.Provider>
|
||||
</QueryClientProvider>
|
||||
</AppTheme>
|
||||
);
|
||||
}
|
||||
43
src_generic/api/client.ts
Normal file
43
src_generic/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);
|
||||
}
|
||||
105
src_generic/components/AdminLayout.tsx
Normal file
105
src_generic/components/AdminLayout.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Drawer,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
List,
|
||||
Typography,
|
||||
Divider,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
CssBaseline,
|
||||
IconButton
|
||||
} 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 { ResourceConfig } from '../types/config';
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode;
|
||||
onSelectResource: (resourceName: string | null) => void;
|
||||
selectedResourceName: string | null;
|
||||
onLogout: () => void;
|
||||
username?: string;
|
||||
resources: ResourceConfig[];
|
||||
}
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
onSelectResource,
|
||||
selectedResourceName,
|
||||
onLogout,
|
||||
username,
|
||||
resources,
|
||||
}: AdminLayoutProps) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<CssBaseline />
|
||||
<AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}>
|
||||
<Toolbar>
|
||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||
Admin Panel
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mr: 2 }}>
|
||||
{username}
|
||||
</Typography>
|
||||
<IconButton color="inherit" onClick={onLogout}>
|
||||
<LogoutIcon />
|
||||
</IconButton>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
[`& .MuiDrawer-paper`]: { width: drawerWidth, boxSizing: 'border-box' },
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
<Box sx={{ overflow: 'auto' }}>
|
||||
<List>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton
|
||||
selected={selectedResourceName === null}
|
||||
onClick={() => onSelectResource(null)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<DashboardIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Dashboard" />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
{resources.map((res) => (
|
||||
<ListItem key={res.name} disablePadding>
|
||||
<ListItemButton
|
||||
selected={selectedResourceName === res.name}
|
||||
onClick={() => onSelectResource(res.name)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<TableViewIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={res.pluralLabel} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
</Box>
|
||||
</Drawer>
|
||||
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
|
||||
<Toolbar />
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
158
src_generic/components/EnhancedTable.tsx
Normal file
158
src_generic/components/EnhancedTable.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
Link,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
DataGrid,
|
||||
GridColDef,
|
||||
GridActionsCellItem,
|
||||
GridRenderCellParams,
|
||||
} from '@mui/x-data-grid';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import { ResourceConfig, ResourceField } from '../types/config';
|
||||
|
||||
interface EnhancedTableProps {
|
||||
config: ResourceConfig;
|
||||
data: any[];
|
||||
onEdit: (item: any) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
onNavigateToResource?: (resourceName: string, id: string) => void;
|
||||
}
|
||||
|
||||
export default function EnhancedTable({
|
||||
config,
|
||||
data,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onCreate,
|
||||
onNavigateToResource,
|
||||
}: EnhancedTableProps) {
|
||||
|
||||
const columns: GridColDef[] = React.useMemo(() => {
|
||||
const cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => {
|
||||
const col: GridColDef = {
|
||||
field: key,
|
||||
headerName: field.label,
|
||||
flex: 1,
|
||||
minWidth: 150,
|
||||
renderCell: (params: GridRenderCellParams) => {
|
||||
const value = params.value;
|
||||
|
||||
// 1. Custom Formatter
|
||||
if (field.formatter) {
|
||||
return field.formatter(value);
|
||||
}
|
||||
|
||||
// 2. Relational Link
|
||||
if (field.relation && value) {
|
||||
const relationId = typeof value === 'object' ? value.id : value;
|
||||
if (relationId) {
|
||||
return (
|
||||
<Link
|
||||
component="button"
|
||||
variant="body2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNavigateToResource?.(field.relation!, relationId);
|
||||
}}
|
||||
>
|
||||
{relationId}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Nested Object / Array Display
|
||||
if (field.type === 'array' && Array.isArray(value)) {
|
||||
if (field.displayField) {
|
||||
return value
|
||||
.map((item) => (typeof item === 'object' ? item[field.displayField!] : item))
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
}
|
||||
return `${value.length} items`;
|
||||
}
|
||||
|
||||
if (field.type === 'object' && value) {
|
||||
if (field.displayField && value[field.displayField]) {
|
||||
return value[field.displayField];
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
// 4. Default renderings
|
||||
if (field.type === 'boolean') return value ? 'Yes' : 'No';
|
||||
if (field.type === 'datetime' || field.type === 'date') {
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
};
|
||||
return col;
|
||||
});
|
||||
|
||||
cols.push({
|
||||
field: 'actions',
|
||||
type: 'actions',
|
||||
headerName: 'Actions',
|
||||
width: 100,
|
||||
getActions: (params) => [
|
||||
<GridActionsCellItem
|
||||
icon={<EditIcon />}
|
||||
label="Edit"
|
||||
onClick={() => onEdit(params.row)}
|
||||
/>,
|
||||
<GridActionsCellItem
|
||||
icon={<DeleteIcon />}
|
||||
label="Delete"
|
||||
onClick={() => onDelete(params.id as string)}
|
||||
/>,
|
||||
],
|
||||
});
|
||||
|
||||
return cols;
|
||||
}, [config, onEdit, onDelete, onNavigateToResource]);
|
||||
|
||||
return (
|
||||
<Box sx={{ height: 600, width: '100%' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3, alignItems: 'center' }}>
|
||||
<Typography variant="h5">{config.pluralLabel}</Typography>
|
||||
<Button variant="contained" color="primary" onClick={onCreate}>
|
||||
Add {config.label}
|
||||
</Button>
|
||||
</Box>
|
||||
<DataGrid
|
||||
rows={data || []}
|
||||
columns={columns}
|
||||
getRowId={(row) => {
|
||||
const pk = config.primaryKey;
|
||||
if (row[pk] !== undefined && row[pk] !== null) return row[pk];
|
||||
// Fallback: search for common ID fields
|
||||
const fallbackKeys = ['id', 'uuid', 'pk'];
|
||||
for (const key of fallbackKeys) {
|
||||
if (row[key] !== undefined && row[key] !== null) return row[key];
|
||||
}
|
||||
debugger;
|
||||
|
||||
// Absolute fallback: index (not ideal but avoids crash)
|
||||
return `temp-id-${data.indexOf(row)}`;
|
||||
}}
|
||||
disableRowSelectionOnClick
|
||||
initialState={{
|
||||
pagination: {
|
||||
paginationModel: { page: 0, pageSize: 10 },
|
||||
},
|
||||
}}
|
||||
pageSizeOptions={[5, 10, 25]}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
203
src_generic/components/GenericForm.tsx
Normal file
203
src_generic/components/GenericForm.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Typography,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import { ResourceConfig, ResourceField } from '../types/config';
|
||||
import { useUpload } from '../providers/UploadProvider';
|
||||
import ImageUploadField from './fields/ImageUploadField';
|
||||
|
||||
interface GenericFormProps {
|
||||
config: ResourceConfig;
|
||||
initialData?: any;
|
||||
onSave: (data: any) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
import { ConfigContext } from '../App';
|
||||
|
||||
export default function GenericForm({
|
||||
config,
|
||||
initialData = {},
|
||||
onSave,
|
||||
onCancel,
|
||||
loading: saving,
|
||||
}: GenericFormProps) {
|
||||
initialData = initialData || {};
|
||||
const [formData, setFormData] = React.useState(initialData);
|
||||
const { uploadFile, uploading } = useUpload();
|
||||
const appConfig = React.useContext(ConfigContext);
|
||||
|
||||
const handleChange = (key: string, value: any) => {
|
||||
setFormData((prev: any) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Typography variant="h5">
|
||||
{initialData[config.primaryKey] ? `Edit ${config.label}` : `New ${config.label}`}
|
||||
</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={field.readOnly}
|
||||
uploadFile={uploadFile}
|
||||
uploading={uploading}
|
||||
baseUrl={appConfig?.baseUrl || ""}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 4 }}>
|
||||
<Button variant="outlined" onClick={onCancel} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="contained" type="submit" loading={saving} disabled={saving || uploading}>
|
||||
Save {config.label}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function FormField({ name, field, value, onChange, disabled, uploadFile, uploading, baseUrl }: any) {
|
||||
const label = field.label;
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'boolean') {
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={!!value}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'datetime') {
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
type="datetime-local"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={value ? new Date(value).toISOString().slice(0, 16) : ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'date') {
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
type="date"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
value={value ? new Date(value).toISOString().split('T')[0] : ''}
|
||||
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 || 0}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
disabled={disabled}
|
||||
required={field.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
fullWidth
|
||||
label={label}
|
||||
value={JSON.stringify(value)}
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
}
|
||||
81
src_generic/components/ResourceView.tsx
Normal file
81
src_generic/components/ResourceView.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
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';
|
||||
|
||||
interface ResourceViewProps {
|
||||
config: ResourceConfig;
|
||||
onNavigateToResource?: (resourceName: string, id: string) => void;
|
||||
}
|
||||
|
||||
export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) {
|
||||
const [view, setView] = React.useState<'list' | 'create' | 'edit'>('list');
|
||||
const [selectedItem, setSelectedItem] = React.useState<any>(null);
|
||||
|
||||
const { useList, useCreate, useUpdate, useDelete } = useResource(config);
|
||||
|
||||
const { data, isLoading, error } = useList();
|
||||
const createMutation = useCreate();
|
||||
const updateMutation = useUpdate();
|
||||
const deleteMutation = useDelete();
|
||||
|
||||
const handleEdit = (item: any) => {
|
||||
setSelectedItem(item);
|
||||
setView('edit');
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setSelectedItem(null);
|
||||
setView('create');
|
||||
};
|
||||
|
||||
const handleSave = async (formData: any) => {
|
||||
try {
|
||||
if (view === 'edit') {
|
||||
const id = formData[config.primaryKey];
|
||||
await updateMutation.mutateAsync({ id, data: formData });
|
||||
} else {
|
||||
await createMutation.mutateAsync(formData);
|
||||
}
|
||||
setView('list');
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (window.confirm('Are you sure you want to delete this item?')) {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <CircularProgress />;
|
||||
if (error) return <Typography color="error">Error loading {config.pluralLabel}</Typography>;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{view === 'list' ? (
|
||||
<EnhancedTable
|
||||
config={config}
|
||||
data={data || []}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onCreate={handleCreate}
|
||||
onNavigateToResource={onNavigateToResource}
|
||||
/>
|
||||
) : (
|
||||
<Paper sx={{ p: 4 }}>
|
||||
<GenericForm
|
||||
config={config}
|
||||
initialData={selectedItem}
|
||||
onSave={handleSave}
|
||||
onCancel={() => setView('list')}
|
||||
loading={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
56
src_generic/components/fields/ImageUploadField.tsx
Normal file
56
src_generic/components/fields/ImageUploadField.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export default function ImageUploadField({
|
||||
label = "Upload Image",
|
||||
value,
|
||||
uploading = false,
|
||||
onUpload,
|
||||
size = 64,
|
||||
baseUrl,
|
||||
}: 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 }}
|
||||
/>
|
||||
|
||||
<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
14
src_generic/config.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
42
src_generic/configuration.ts
Normal file
42
src_generic/configuration.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { ResourceOverride } from "./utils/overrides";
|
||||
|
||||
export const configuration: Record<string, ResourceOverride> = {
|
||||
expenses: {
|
||||
fields: {
|
||||
payee: {
|
||||
displayField: "name",
|
||||
},
|
||||
payor: {
|
||||
display: false,
|
||||
displayField: "username",
|
||||
},
|
||||
account: {
|
||||
displayField: "name",
|
||||
},
|
||||
tags: {
|
||||
displayField: "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
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
77
src_generic/hooks/useResource.ts
Normal file
77
src_generic/hooks/useResource.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
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 () => {
|
||||
const res = await api.get<T[]>(endpoint, { params });
|
||||
return res.data;
|
||||
}
|
||||
});
|
||||
|
||||
// --- READ ONE ---
|
||||
const useOne = (id: string | null) =>
|
||||
useQuery({
|
||||
queryKey: [name, "detail", id],
|
||||
queryFn: async () => {
|
||||
if (!id) return null;
|
||||
const res = await api.get<T>(`${endpoint}/${id}`);
|
||||
return res.data;
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// --- CREATE ---
|
||||
const useCreate = () =>
|
||||
useMutation({
|
||||
mutationFn: async (data: Partial<T>) => {
|
||||
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> }) => {
|
||||
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"] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
useList,
|
||||
useOne,
|
||||
useCreate,
|
||||
useUpdate,
|
||||
useDelete,
|
||||
};
|
||||
}
|
||||
18
src_generic/main.tsx
Normal file
18
src_generic/main.tsx
Normal 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>
|
||||
);
|
||||
52
src_generic/providers/UploadProvider.tsx
Normal file
52
src_generic/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;
|
||||
};
|
||||
38
src_generic/types/config.ts
Normal file
38
src_generic/types/config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
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;
|
||||
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>;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
baseUrl: string;
|
||||
authBaseUrl: string;
|
||||
resources: ResourceConfig[];
|
||||
}
|
||||
165
src_generic/utils/openapi_loader.ts
Normal file
165
src_generic/utils/openapi_loader.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import SwaggerParser from "@apidevtools/swagger-parser";
|
||||
import { AppConfig, ResourceConfig, ResourceField, FieldType } from "../types/config";
|
||||
import { configuration } 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,
|
||||
allResources: 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 any) {
|
||||
const type = mapOpenApiType(prop);
|
||||
const override = overrides[key];
|
||||
|
||||
console.log("key", key, "type", type, "prop", prop, "override", override);
|
||||
if (key !== "id" && override?.display !== false) {
|
||||
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,
|
||||
};
|
||||
} else continue;
|
||||
|
||||
// Schema-based Relation Detection
|
||||
// If it's an object/string and matches a resource name, it might be a relation
|
||||
const potentialRelation = allResources.find(
|
||||
(res) =>
|
||||
key === res ||
|
||||
key === `${res}_id` ||
|
||||
prop.title?.toLowerCase() === res ||
|
||||
prop["x-resource"] === res
|
||||
);
|
||||
|
||||
if (potentialRelation) {
|
||||
if (type === "string" || (type === "object" && prop.properties?.id)) {
|
||||
fields[key].relation = potentialRelation;
|
||||
}
|
||||
}
|
||||
|
||||
if (fields[key].type === "object" && prop.properties) {
|
||||
fields[key].schema = parseSchemaFields(prop, resourceName, allResources);
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans paths to identify resources and their basic configuration
|
||||
*/
|
||||
export async function loadConfigFromOpenApi(baseUrl: string): Promise<AppConfig> {
|
||||
// 1. Parse and dereference the spec (handles all $ref)
|
||||
const api = await SwaggerParser.dereference(
|
||||
new URL("/openapi.json", baseUrl).href
|
||||
);
|
||||
|
||||
const resources: ResourceConfig[] = [];
|
||||
const paths = api.paths || {};
|
||||
|
||||
// Group paths by base resource name (e.g., /expenses, /expenses/{id} -> expenses)
|
||||
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);
|
||||
|
||||
// We prefer the plural GET path for schema extraction
|
||||
if (!path.includes("{") && paths[path]?.get?.responses?.["200"]) {
|
||||
resourcePaths[base].listPath = path;
|
||||
}
|
||||
}
|
||||
|
||||
const allResourceNames = Object.keys(resourcePaths);
|
||||
|
||||
// Generate ResourceConfig for each identified base path
|
||||
for (const [name, info] of Object.entries(resourcePaths)) {
|
||||
const listPath = info.listPath || `/${name}`;
|
||||
const listOp = paths[listPath]?.get;
|
||||
if (!listOp) continue;
|
||||
|
||||
// Use common naming conventions or metadata from the spec
|
||||
const label = name.charAt(0).toUpperCase() + name.slice(1, -1); // naive singularization
|
||||
const pluralLabel = name.charAt(0).toUpperCase() + name.slice(1);
|
||||
|
||||
// Extract schema from the 200 response of the list endpoint
|
||||
let schema: any = null;
|
||||
const responseSchema =
|
||||
listOp.responses?.["200"]?.content?.["application/json"]?.schema;
|
||||
|
||||
if (responseSchema?.type === "array" && responseSchema.items) {
|
||||
schema = responseSchema.items;
|
||||
} else {
|
||||
schema = responseSchema;
|
||||
}
|
||||
|
||||
if (schema) {
|
||||
resources.push({
|
||||
name,
|
||||
label: schema.title || label,
|
||||
pluralLabel: pluralLabel,
|
||||
endpoint: listPath,
|
||||
primaryKey: "id", // assume 'id' as default or look for 'required' + 'unique'
|
||||
fields: parseSchemaFields(schema, name, allResourceNames),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl:
|
||||
import.meta.env.VITE_API_BASE_URL || (api.servers?.[0]?.url ?? ""),
|
||||
authBaseUrl: import.meta.env.VITE_AUTH_BASE_URL || "",
|
||||
resources,
|
||||
};
|
||||
}
|
||||
14
src_generic/utils/overrides.ts
Normal file
14
src_generic/utils/overrides.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* This file contains application-specific overrides and configuration
|
||||
* for the generic Admin Panel.
|
||||
*/
|
||||
|
||||
export interface FieldOverride {
|
||||
displayField?: string;
|
||||
display?: boolean;
|
||||
formatter?: (value: any) => string;
|
||||
}
|
||||
|
||||
export interface ResourceOverride {
|
||||
fields?: Record<string, FieldOverride>;
|
||||
}
|
||||
Reference in New Issue
Block a user