Compare commits
96 Commits
d29efe53e0
...
generic-re
| Author | SHA1 | Date | |
|---|---|---|---|
| cddc4d350f | |||
| c73b55b737 | |||
| f8cea025a3 | |||
| 9b87fb31a7 | |||
| 7edf3e75da | |||
| 63b31f0fc5 | |||
| 0f44a8e1b6 | |||
| 399b2656b8 | |||
| aa04b105d0 | |||
| c7095ed481 | |||
| ff3094cf09 | |||
| 1f64b566cb | |||
| 4b0d9ca425 | |||
| 08a84ea63f | |||
| 004a8a6876 | |||
| 60d817fa8a | |||
| 36086e4b77 | |||
| 71f7ee83f1 | |||
| a8581325fa | |||
| 6dc33be455 | |||
| 44567496a1 | |||
| 344106f1a4 | |||
| 3b472242a7 | |||
| 14dcd19b17 | |||
| 4d06859cb0 | |||
| 226a6a651c | |||
| 14b43cb3c5 | |||
| 8f398c35df | |||
| a7987ab922 | |||
| 7bdf84b6aa | |||
| 2b578fd12e | |||
| fe33dca630 | |||
| fa319e7450 | |||
| cb6125f3f9 | |||
| 0ed816e994 | |||
| 2dfbdb950a | |||
| fcc3ec16f9 | |||
| cff57f0980 | |||
| e90fab8c0b | |||
| 3aaf328511 | |||
| 635e99c183 | |||
| b8e4decfba | |||
| 459fa5855c | |||
| f52c4a5287 | |||
| 3a3f44c5b5 | |||
| 479ffb736c | |||
| 87bdafb6a3 | |||
| 383b424bdf | |||
| 0340e17467 | |||
| f15155d31c | |||
| c2e6daca13 | |||
| c0bcd0e3e4 | |||
| 333f931cff | |||
| 3960de3ecb | |||
| 763629faa1 | |||
| a7e3ed46cb | |||
| 4a8c59895e | |||
| ec9b5c905a | |||
| d7e9827819 | |||
| ae0bc7dd12 | |||
| 1e6c80f1b3 | |||
| 8ff8b9236e | |||
| 142b169108 | |||
| 80bf87529e | |||
| 5582d18a01 | |||
| 913755d971 | |||
| 8838ff10f4 | |||
| 7a28dde7d5 | |||
| d6c84abdf6 | |||
| 1b755968dd | |||
| 33e9d70b98 | |||
| ce91526599 | |||
| 73d64ea497 | |||
| e16804b65d | |||
| 945912f16d | |||
| 4e2af82573 | |||
| bd8aea46b1 | |||
| 10aa43fa27 | |||
| 068a741706 | |||
| 7faedcf2f9 | |||
| 3e1ec9a3ed | |||
| 3cac047709 | |||
| 1f21ab38fc | |||
| 1f5066a661 | |||
| 6798b64431 | |||
| 7fa61e6c2e | |||
| b09900f8ec | |||
| fc39d832c1 | |||
| 74cae4e4ea | |||
| 08c20c2613 | |||
| 7fece6f8f9 | |||
| e75beaac48 | |||
| 6d951b9ab5 | |||
| 6abdd443e0 | |||
| e9c654e138 | |||
| eddb251e4d |
@@ -66,6 +66,8 @@ steps:
|
|||||||
environment:
|
environment:
|
||||||
API_BASE_URL:
|
API_BASE_URL:
|
||||||
from_secret: API_BASE_URL
|
from_secret: API_BASE_URL
|
||||||
|
AUTH_BASE_URL:
|
||||||
|
from_secret: AUTH_BASE_URL
|
||||||
volumes:
|
volumes:
|
||||||
- name: dockersock
|
- name: dockersock
|
||||||
path: /var/run/docker.sock
|
path: /var/run/docker.sock
|
||||||
@@ -76,6 +78,7 @@ steps:
|
|||||||
- |
|
- |
|
||||||
docker build --network=host \
|
docker build --network=host \
|
||||||
--build-arg VITE_API_BASE_URL="$API_BASE_URL" \
|
--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:$IMAGE_TAG \
|
||||||
-t apps/blog:latest \
|
-t apps/blog:latest \
|
||||||
/drone/src
|
/drone/src
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ COPY . .
|
|||||||
|
|
||||||
# Build the app
|
# Build the app
|
||||||
ARG VITE_API_BASE_URL
|
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)
|
# Stage 2: Static file server (BusyBox)
|
||||||
FROM busybox:latest
|
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,27 +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';
|
|
||||||
|
|
||||||
interface LoginProps {
|
export type AuthMode = "login" | "register";
|
||||||
onBack: () => void;
|
|
||||||
onRegister: () => void;
|
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 default function Login({ onBack, onRegister }: LoginProps) {
|
export function AuthPage({
|
||||||
const { login, loading, error, currentUser } = useAuth();
|
mode,
|
||||||
|
onBack,
|
||||||
|
onSwitchMode,
|
||||||
|
login,
|
||||||
|
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
|
||||||
@@ -40,11 +62,13 @@ export default function Login({ onBack, onRegister }: LoginProps) {
|
|||||||
</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}>
|
||||||
@@ -56,6 +80,7 @@ export default function Login({ onBack, onRegister }: LoginProps) {
|
|||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
required
|
required
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -81,7 +106,13 @@ export default function Login({ onBack, onRegister }: LoginProps) {
|
|||||||
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>
|
||||||
|
|
||||||
@@ -91,15 +122,15 @@ export default function Login({ onBack, onRegister }: LoginProps) {
|
|||||||
align="center"
|
align="center"
|
||||||
sx={{ mt: 3 }}
|
sx={{ mt: 3 }}
|
||||||
>
|
>
|
||||||
Don’t have an account?{' '}
|
{isLogin ? "Don’t 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
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
38
auth/src/axios.ts
Normal file
38
auth/src/axios.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import axios, { AxiosInstance } from "axios";
|
||||||
|
import { tokenStore } from "./token";
|
||||||
|
|
||||||
|
export function attachAuthInterceptors(client: AxiosInstance) {
|
||||||
|
client.interceptors.request.use((config) => {
|
||||||
|
const token = tokenStore.get();
|
||||||
|
if (token) {
|
||||||
|
if (!config.headers) {
|
||||||
|
(config as any).headers = {};
|
||||||
|
}
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
client.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
tokenStore.clear();
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory for app APIs that need auth
|
||||||
|
*/
|
||||||
|
export function createApiClient(baseURL: string): AxiosInstance {
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
attachAuthInterceptors(client);
|
||||||
|
return client;
|
||||||
|
}
|
||||||
97
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
1410
package-lock.json
generated
1410
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aetoskia-blog-app",
|
"name": "aetoskia-blog-app",
|
||||||
"version": "0.1.1",
|
"version": "0.3.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -10,13 +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",
|
||||||
"react": "latest",
|
"@mui/material": "latest",
|
||||||
"react-dom": "latest",
|
"@tanstack/react-query": "^5.96.1",
|
||||||
|
"axios": "latest",
|
||||||
"markdown-to-jsx": "latest",
|
"markdown-to-jsx": "latest",
|
||||||
"marked": "latest",
|
"marked": "latest",
|
||||||
"axios": "latest"
|
"react": "latest",
|
||||||
|
"react-dom": "latest",
|
||||||
|
"react-markdown": "latest",
|
||||||
|
"remark-gfm": "latest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "latest",
|
"@vitejs/plugin-react": "latest",
|
||||||
|
|||||||
@@ -1,128 +1,193 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
import Container from '@mui/material/Container';
|
import Container from '@mui/material/Container';
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
import AppTheme from '../shared-theme/AppTheme';
|
import AppTheme from '../shared-theme/AppTheme';
|
||||||
import MainContent from './components/MainContent';
|
import MainContent from './components/MainContent';
|
||||||
import Article from './components/Article';
|
import ArticleView from './components/Article/ArticleView';
|
||||||
|
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 { ArticleModel, ArticlesModel } from "./types/models";
|
||||||
|
import { ArticleViewProps, ArticleEditorProps } from "./types/props";
|
||||||
|
|
||||||
type View = 'home' | 'login' | 'register' | 'article' | 'profile';
|
import { useAuth, AuthPage, AuthMode } from '../../auth/src';
|
||||||
|
|
||||||
export default function Blog(props: { disableCustomTheme?: boolean }) {
|
function HomeView({
|
||||||
const { articles, loading, error } = useArticles();
|
currentUser,
|
||||||
const { currentUser } = useAuth();
|
open_auth,
|
||||||
|
open_profile,
|
||||||
const [selectedArticle, setSelectedArticle] = React.useState<number | null>(null);
|
open_create,
|
||||||
const [showLogin, setShowLogin] = React.useState(false);
|
articles,
|
||||||
const [showRegister, setShowRegister] = React.useState(false);
|
openArticle,
|
||||||
const [showProfile, setShowProfile] = React.useState(false);
|
}: any) {
|
||||||
|
|
||||||
const handleSelectArticle = (index: number) => {
|
|
||||||
setSelectedArticle(index);
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBack = () => setSelectedArticle(null);
|
|
||||||
|
|
||||||
const handleShowLogin = () => {
|
|
||||||
setShowLogin(true);
|
|
||||||
setShowRegister(false);
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
const handleShowRegister = () => {
|
|
||||||
setShowRegister(true);
|
|
||||||
setShowLogin(false);
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
const handleHideAuth = () => {
|
|
||||||
setShowLogin(false);
|
|
||||||
setShowRegister(false);
|
|
||||||
};
|
|
||||||
const handleShowProfile = () => {
|
|
||||||
setShowProfile(true);
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
const handleHideProfile = () => {
|
|
||||||
setShowProfile(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// derive a single source of truth for view
|
|
||||||
const view: View = React.useMemo(() => {
|
|
||||||
if (selectedArticle !== null) return 'article';
|
|
||||||
if (showRegister) return 'register';
|
|
||||||
if (showLogin) return 'login';
|
|
||||||
if (showProfile) return 'profile';
|
|
||||||
return 'home';
|
|
||||||
}, [selectedArticle, showLogin, showRegister, showProfile]);
|
|
||||||
|
|
||||||
// render function keeps JSX tidy
|
|
||||||
const renderView = () => {
|
|
||||||
switch (view) {
|
|
||||||
case 'register':
|
|
||||||
return <Register onBack={handleHideAuth} />;
|
|
||||||
case 'login':
|
|
||||||
return (
|
|
||||||
<Login
|
|
||||||
onBack={handleHideAuth}
|
|
||||||
onRegister={() => {
|
|
||||||
handleShowRegister();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'profile':
|
|
||||||
return (
|
|
||||||
<Profile
|
|
||||||
onBack={handleHideProfile}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'article':
|
|
||||||
if (selectedArticle == null || !articles[selectedArticle]) return null;
|
|
||||||
return <Article article={articles[selectedArticle]} onBack={handleBack} />;
|
|
||||||
case 'home':
|
|
||||||
default:
|
|
||||||
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_auth('login')}>
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
onClick={handleShowLogin}
|
|
||||||
>
|
|
||||||
Login
|
Login
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button variant='outlined' onClick={open_profile}>
|
||||||
variant="outlined"
|
{currentUser.username}
|
||||||
color="primary"
|
</Button>
|
||||||
onClick={() => setShowProfile(true)}
|
<Button variant='contained' onClick={open_create}>
|
||||||
>
|
New Article
|
||||||
Profile
|
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<MainContent articles={articles} onSelectArticle={handleSelectArticle} />
|
<MainContent articles={articles} onSelectArticle={openArticle} />
|
||||||
<Latest
|
<Latest articles={articles} onSelectArticle={openArticle} />
|
||||||
articles={articles}
|
|
||||||
onSelectArticle={handleSelectArticle}
|
|
||||||
onLoadMore={async () => {}}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Blog(props: { disableCustomTheme?: boolean }) {
|
||||||
|
const { articles, loading, error } = useArticles();
|
||||||
|
const auth = useAuth();
|
||||||
|
const { currentUser } = useAuthor();
|
||||||
|
|
||||||
|
const [ui, setUI] = React.useState({
|
||||||
|
selectedArticle: null as ArticleModel | null,
|
||||||
|
view: 'home' as View,
|
||||||
|
authMode: 'login' as AuthMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const parts = path.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
if (parts[0] === 'articles' && parts[1]) {
|
||||||
|
const id = parts[1];
|
||||||
|
const article = articles.readById(id);
|
||||||
|
|
||||||
|
if (article) {
|
||||||
|
setUI({
|
||||||
|
selectedArticle: article,
|
||||||
|
view: 'article',
|
||||||
|
authMode: 'login'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}, [loading]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
goBack,
|
||||||
|
navigateToChildren,
|
||||||
|
openArticle,
|
||||||
|
} = useViewRouter(setUI);
|
||||||
|
|
||||||
|
type RouterContext = {
|
||||||
|
ui: typeof ui;
|
||||||
|
articles: ArticlesModel;
|
||||||
|
currentUser: any;
|
||||||
|
openArticle: (article: ArticleModel) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ViewComponentEntry<P> = {
|
||||||
|
component: React.ComponentType<P>;
|
||||||
|
extraProps?: (ctx: RouterContext) => Partial<P>;
|
||||||
|
navigationMap?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const VIEW_COMPONENTS: Record<View, ViewComponentEntry<any>> = {
|
||||||
|
home: {
|
||||||
|
component: HomeView,
|
||||||
|
},
|
||||||
|
|
||||||
|
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: {
|
||||||
|
component: Profile,
|
||||||
|
},
|
||||||
|
|
||||||
|
article: {
|
||||||
|
component: ArticleView,
|
||||||
|
navigationMap: {
|
||||||
|
open_editor: 'onEdit',
|
||||||
|
},
|
||||||
|
extraProps: ({ ui, articles }) => ({
|
||||||
|
// @ts-ignore
|
||||||
|
article: articles.readById(ui.selectedArticle._id),
|
||||||
|
}) satisfies Partial<ArticleViewProps>,
|
||||||
|
},
|
||||||
|
|
||||||
|
editor: {
|
||||||
|
component: ArticleEditor,
|
||||||
|
extraProps: ({ ui, articles }) => ({
|
||||||
|
article: ui.selectedArticle !== null ? articles.readById(ui.selectedArticle._id as string) : null,
|
||||||
|
}) satisfies Partial<ArticleEditorProps>,
|
||||||
|
},
|
||||||
|
|
||||||
|
create: {
|
||||||
|
component: ArticleEditor,
|
||||||
|
extraProps: () => ({
|
||||||
|
article: null,
|
||||||
|
}) satisfies Partial<ArticleEditorProps>,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderView = () => {
|
||||||
|
const entry = VIEW_COMPONENTS[ui.view];
|
||||||
|
const navigationMap= entry['navigationMap'] || {}
|
||||||
|
const ViewComponent = entry.component;
|
||||||
|
|
||||||
|
const childNav = {
|
||||||
|
...navigateToChildren(ui.view, navigationMap),
|
||||||
|
open_auth: (mode: AuthMode = 'login') =>
|
||||||
|
setUI((prev) => ({
|
||||||
|
...prev,
|
||||||
|
view: 'auth',
|
||||||
|
authMode: mode,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx: RouterContext = {
|
||||||
|
ui,
|
||||||
|
articles,
|
||||||
|
currentUser,
|
||||||
|
|
||||||
|
openArticle,
|
||||||
|
};
|
||||||
|
|
||||||
|
const extraProps = entry.extraProps ? entry.extraProps(ctx) : {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ViewComponent
|
||||||
|
{...ctx}
|
||||||
|
{...childNav}
|
||||||
|
onBack={() => goBack(ui.view)}
|
||||||
|
{...extraProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -179,15 +244,15 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
my: 4,
|
my: 4,
|
||||||
gap: 4,
|
gap: 4,
|
||||||
pb: view === 'home' ? 24 : 0,
|
pb: ui.view === 'home' ? 24 : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderView()}
|
{renderView()}
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
{view === 'home' && (
|
{ui.view === 'home' && (
|
||||||
<Box
|
<Box
|
||||||
component="footer"
|
component='footer'
|
||||||
sx={{
|
sx={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
|
|||||||
192
src/blog/components/Article/ArticleEditor.tsx
Normal file
192
src/blog/components/Article/ArticleEditor.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Box, Typography, Divider, IconButton, TextField, Button } from '@mui/material';
|
||||||
|
import { styled } from '@mui/material/styles';
|
||||||
|
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||||
|
import { ArticleEditorProps } from '../../types/props';
|
||||||
|
import { ArticleModel } from "../../types/models";
|
||||||
|
import { useUpload } from "../../providers/Upload";
|
||||||
|
import { useArticles } from "../../providers/Article";
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import ImageUploadField from "../ImageUploadField";
|
||||||
|
|
||||||
|
const ArticleContainer = styled(Box)(({ theme }) => ({
|
||||||
|
maxWidth: '800px',
|
||||||
|
margin: '0 auto',
|
||||||
|
padding: theme.spacing(4),
|
||||||
|
[theme.breakpoints.down('sm')]: {
|
||||||
|
padding: theme.spacing(2),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const CoverImage = styled('img')({
|
||||||
|
width: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
borderRadius: '12px',
|
||||||
|
marginTop: '16px',
|
||||||
|
marginBottom: '24px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function ArticleView({
|
||||||
|
article,
|
||||||
|
onBack,
|
||||||
|
}: ArticleEditorProps) {
|
||||||
|
|
||||||
|
const { uploadFile } = useUpload();
|
||||||
|
const { updateArticle, createArticle } = useArticles();
|
||||||
|
|
||||||
|
const [title, setTitle] = React.useState(article?.title ?? "");
|
||||||
|
const [description, setDescription] = React.useState(article?.description ?? "");
|
||||||
|
const [tag, setTag] = React.useState(article?.tag ?? "");
|
||||||
|
const [img, setImg] = React.useState(article?.img ?? "");
|
||||||
|
const [uploadingCoverImage, setUploadingCoverImage] = React.useState(false);
|
||||||
|
const [content, setContent] = React.useState(article?.content ?? "");
|
||||||
|
|
||||||
|
const handleCoverImageUpload = async (file: File) => {
|
||||||
|
setUploadingCoverImage(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const img = await uploadFile(file);
|
||||||
|
if (img) {
|
||||||
|
setImg(img);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Avatar upload failed:", err);
|
||||||
|
} finally {
|
||||||
|
setUploadingCoverImage(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveArticle = async (articleData: Partial<ArticleModel>) => {
|
||||||
|
// If _id exists → UPDATE
|
||||||
|
if (articleData._id) {
|
||||||
|
console.log("Updating article with ID:", articleData._id);
|
||||||
|
return await updateArticle(articleData as ArticleModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No _id → CREATE
|
||||||
|
console.log("Creating new article:", articleData);
|
||||||
|
return await createArticle(articleData as ArticleModel);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ArticleContainer>
|
||||||
|
{/* BACK BUTTON */}
|
||||||
|
<IconButton onClick={onBack} sx={{ mb: 2 }}>
|
||||||
|
<ArrowBackRoundedIcon />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
{/* TAG */}
|
||||||
|
<TextField
|
||||||
|
label="Tag"
|
||||||
|
fullWidth
|
||||||
|
value={tag}
|
||||||
|
onChange={(e) => setTag(e.target.value)}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* TITLE */}
|
||||||
|
<TextField
|
||||||
|
label="Title"
|
||||||
|
fullWidth
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* DESCRIPTION */}
|
||||||
|
<TextField
|
||||||
|
label="Description"
|
||||||
|
fullWidth
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
sx={{ mb: 3 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 3 }} />
|
||||||
|
|
||||||
|
<ImageUploadField
|
||||||
|
label="Cover Image"
|
||||||
|
value={img}
|
||||||
|
uploading={uploadingCoverImage}
|
||||||
|
onUpload={handleCoverImageUpload}
|
||||||
|
size={128}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider sx={{ mb: 3 }} />
|
||||||
|
|
||||||
|
{/* MARKDOWN EDITOR */}
|
||||||
|
<Box sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
alignItems: 'stretch'
|
||||||
|
}}>
|
||||||
|
<Typography variant="h6">Content</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component="textarea"
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
minHeight: '300px',
|
||||||
|
padding: '16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid rgba(255,255,255,0.2)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'inherit',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '16px',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
resize: 'vertical',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* LIVE PREVIEW */}
|
||||||
|
<Typography variant="h6" sx={{ mt: 4 }}>
|
||||||
|
Preview
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
borderRadius: 2,
|
||||||
|
'& h3': { fontWeight: 600, mt: 4 },
|
||||||
|
'& p': { color: 'text.primary', lineHeight: 1.8, mt: 2 },
|
||||||
|
'& em': { fontStyle: 'italic' },
|
||||||
|
'& ul': { pl: 3 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ACTIONS */}
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 4 }}>
|
||||||
|
<Button variant="outlined" color="secondary" onClick={onBack}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
onClick={() =>
|
||||||
|
handleSaveArticle({
|
||||||
|
...article,
|
||||||
|
title,
|
||||||
|
tag,
|
||||||
|
img,
|
||||||
|
description,
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</ArticleContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import { Box, Typography, Avatar, Divider, IconButton, Chip } from '@mui/material';
|
import { Box, Typography, Divider, IconButton, Chip } from '@mui/material';
|
||||||
import { styled } from '@mui/material/styles';
|
import { styled } from '@mui/material/styles';
|
||||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||||
import { ArticleProps } from '../types/props';
|
import EditRoundedIcon from '@mui/icons-material/EditRounded';
|
||||||
|
import { ArticleMeta } from "../ArticleMeta";
|
||||||
|
import { ArticleViewProps } from '../../types/props';
|
||||||
|
import {useAuth} from "../../providers/Author";
|
||||||
|
|
||||||
const ArticleContainer = styled(Box)(({ theme }) => ({
|
const ArticleContainer = styled(Box)(({ theme }) => ({
|
||||||
maxWidth: '800px',
|
maxWidth: '800px',
|
||||||
@@ -22,17 +25,42 @@ const CoverImage = styled('img')({
|
|||||||
marginBottom: '24px',
|
marginBottom: '24px',
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function Article({
|
export default function ArticleView({
|
||||||
article,
|
article,
|
||||||
onBack
|
onBack,
|
||||||
}: ArticleProps) {
|
onEdit,
|
||||||
|
}: ArticleViewProps) {
|
||||||
|
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ArticleContainer>
|
<ArticleContainer>
|
||||||
<IconButton onClick={onBack} sx={{ mb: 2 }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton onClick={onBack}>
|
||||||
<ArrowBackRoundedIcon />
|
<ArrowBackRoundedIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
|
{currentUser && (
|
||||||
|
<IconButton onClick={onEdit}>
|
||||||
|
<EditRoundedIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="h3" component="h1" gutterBottom fontWeight="bold">
|
||||||
|
{article.title}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<ArticleMeta article={article} />
|
||||||
|
|
||||||
|
<Divider sx={{ my: 3 }} />
|
||||||
|
|
||||||
<Chip
|
<Chip
|
||||||
label={article.tag}
|
label={article.tag}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@@ -40,29 +68,14 @@ export default function Article({
|
|||||||
sx={{ mb: 2, textTransform: 'uppercase', fontWeight: 500 }}
|
sx={{ mb: 2, textTransform: 'uppercase', fontWeight: 500 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Typography variant="h3" component="h1" gutterBottom fontWeight="bold">
|
<CoverImage
|
||||||
{article.title}
|
src={(
|
||||||
</Typography>
|
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||||
|
"/" +
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2, mb: 1 }}>
|
(article.img?.replace(/^\/+/, "") || "")
|
||||||
<Avatar src={article.authors[0].avatar} alt={article.authors[0].name} />
|
)}
|
||||||
<Box>
|
alt={article.title}
|
||||||
<Typography variant="subtitle2">{article.authors[0].name}</Typography>
|
/>
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{new Date(article.created_at).toLocaleString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider sx={{ my: 3 }} />
|
|
||||||
|
|
||||||
<CoverImage src={article.img} alt={article.title} />
|
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
51
src/blog/components/ArticleCards/ArticleCardSize12.tsx
Normal file
51
src/blog/components/ArticleCards/ArticleCardSize12.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CardMedia, Typography } from '@mui/material';
|
||||||
|
import { ArticleMeta } from "../ArticleMeta";
|
||||||
|
import { ArticleCardProps } from "../../types/props";
|
||||||
|
import { StyledCard, StyledCardContent, StyledTypography } from "../../types/styles";
|
||||||
|
|
||||||
|
|
||||||
|
export default function ArticleCardSize12({
|
||||||
|
article,
|
||||||
|
index,
|
||||||
|
focusedCardIndex,
|
||||||
|
onSelectArticle,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
}: ArticleCardProps) {
|
||||||
|
return (
|
||||||
|
<StyledCard
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => onSelectArticle(article)}
|
||||||
|
onFocus={() => onFocus(index)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
tabIndex={0}
|
||||||
|
className={focusedCardIndex === index ? 'Mui-focused' : ''}
|
||||||
|
>
|
||||||
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
alt={article.title}
|
||||||
|
image={(
|
||||||
|
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||||
|
"/" +
|
||||||
|
(article.img?.replace(/^\/+/, "") || "")
|
||||||
|
)}
|
||||||
|
sx={{
|
||||||
|
aspectRatio: '16 / 9',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<StyledCardContent>
|
||||||
|
<Typography gutterBottom variant="caption" component="div">
|
||||||
|
{article.tag}
|
||||||
|
</Typography>
|
||||||
|
<Typography gutterBottom variant="h6" component="div">
|
||||||
|
{article.title}
|
||||||
|
</Typography>
|
||||||
|
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
|
||||||
|
{article.description}
|
||||||
|
</StyledTypography>
|
||||||
|
</StyledCardContent>
|
||||||
|
<ArticleMeta article={article} />
|
||||||
|
</StyledCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
52
src/blog/components/ArticleCards/ArticleCardSize2.tsx
Normal file
52
src/blog/components/ArticleCards/ArticleCardSize2.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Typography } from '@mui/material';
|
||||||
|
import { ArticleMeta } from "../ArticleMeta";
|
||||||
|
import { ArticleCardProps } from "../../types/props";
|
||||||
|
import { StyledCard, StyledCardContent, StyledTypography } from "../../types/styles";
|
||||||
|
|
||||||
|
|
||||||
|
export default function ArticleCardSize2({
|
||||||
|
article,
|
||||||
|
index,
|
||||||
|
focusedCardIndex,
|
||||||
|
onSelectArticle,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
}: ArticleCardProps) {
|
||||||
|
return (
|
||||||
|
<StyledCard
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => onSelectArticle(article)}
|
||||||
|
onFocus={() => onFocus(index)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
tabIndex={0}
|
||||||
|
className={focusedCardIndex === index ? 'Mui-focused' : ''}
|
||||||
|
>
|
||||||
|
<StyledCardContent
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Typography gutterBottom variant="caption" component="div">
|
||||||
|
{article.tag}
|
||||||
|
</Typography>
|
||||||
|
<Typography gutterBottom variant="h6" component="div">
|
||||||
|
{article.title}
|
||||||
|
</Typography>
|
||||||
|
<StyledTypography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
gutterBottom
|
||||||
|
>
|
||||||
|
{article.description}
|
||||||
|
</StyledTypography>
|
||||||
|
</div>
|
||||||
|
</StyledCardContent>
|
||||||
|
<ArticleMeta article={article} />
|
||||||
|
</StyledCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
52
src/blog/components/ArticleCards/ArticleCardSize4.tsx
Normal file
52
src/blog/components/ArticleCards/ArticleCardSize4.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CardMedia, Typography } from '@mui/material';
|
||||||
|
import { ArticleMeta } from "../ArticleMeta";
|
||||||
|
import { ArticleCardProps } from "../../types/props";
|
||||||
|
import { StyledCard, StyledCardContent, StyledTypography } from "../../types/styles";
|
||||||
|
|
||||||
|
|
||||||
|
export default function ArticleCardSize4({
|
||||||
|
article,
|
||||||
|
index,
|
||||||
|
focusedCardIndex,
|
||||||
|
onSelectArticle,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
}: ArticleCardProps) {
|
||||||
|
return (
|
||||||
|
<StyledCard
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => onSelectArticle(article)}
|
||||||
|
onFocus={() => onFocus(index)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
tabIndex={0}
|
||||||
|
className={focusedCardIndex === index ? 'Mui-focused' : ''}
|
||||||
|
>
|
||||||
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
alt={article.title}
|
||||||
|
image={(
|
||||||
|
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||||
|
"/" +
|
||||||
|
(article.img?.replace(/^\/+/, "") || "")
|
||||||
|
)}
|
||||||
|
sx={{
|
||||||
|
height: { sm: 'auto', md: '50%' },
|
||||||
|
aspectRatio: { sm: '16 / 9', md: '' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<StyledCardContent>
|
||||||
|
<Typography gutterBottom variant="caption" component="div">
|
||||||
|
{article.tag}
|
||||||
|
</Typography>
|
||||||
|
<Typography gutterBottom variant="h6" component="div">
|
||||||
|
{article.title}
|
||||||
|
</Typography>
|
||||||
|
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
|
||||||
|
{article.description}
|
||||||
|
</StyledTypography>
|
||||||
|
</StyledCardContent>
|
||||||
|
<ArticleMeta article={article} />
|
||||||
|
</StyledCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
53
src/blog/components/ArticleCards/ArticleCardSize6.tsx
Normal file
53
src/blog/components/ArticleCards/ArticleCardSize6.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CardMedia, Typography } from '@mui/material';
|
||||||
|
import { ArticleMeta } from "../ArticleMeta";
|
||||||
|
import { ArticleCardProps } from "../../types/props";
|
||||||
|
import { StyledCard, StyledCardContent, StyledTypography } from "../../types/styles";
|
||||||
|
|
||||||
|
|
||||||
|
export default function ArticleCardSize6({
|
||||||
|
article,
|
||||||
|
index,
|
||||||
|
focusedCardIndex,
|
||||||
|
onSelectArticle,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
}: ArticleCardProps) {
|
||||||
|
return (
|
||||||
|
<StyledCard
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => onSelectArticle(article)}
|
||||||
|
onFocus={() => onFocus(index)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
tabIndex={0}
|
||||||
|
className={focusedCardIndex === index ? 'Mui-focused' : ''}
|
||||||
|
>
|
||||||
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
alt={article.title}
|
||||||
|
image={(
|
||||||
|
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||||
|
"/" +
|
||||||
|
(article.img?.replace(/^\/+/, "") || "")
|
||||||
|
)}
|
||||||
|
sx={{
|
||||||
|
aspectRatio: '16 / 9',
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<StyledCardContent>
|
||||||
|
<Typography gutterBottom variant="caption" component="div">
|
||||||
|
{article.tag}
|
||||||
|
</Typography>
|
||||||
|
<Typography gutterBottom variant="h6" component="div">
|
||||||
|
{article.title}
|
||||||
|
</Typography>
|
||||||
|
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
|
||||||
|
{article.description}
|
||||||
|
</StyledTypography>
|
||||||
|
</StyledCardContent>
|
||||||
|
<ArticleMeta article={article} />
|
||||||
|
</StyledCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
152
src/blog/components/ArticleCards/ArticleCardsGrid.tsx
Normal file
152
src/blog/components/ArticleCards/ArticleCardsGrid.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Grid, Box } from '@mui/material';
|
||||||
|
import ArticleCardSize12 from './ArticleCardSize12';
|
||||||
|
import ArticleCardSize6 from './ArticleCardSize6';
|
||||||
|
import ArticleCardSize4 from './ArticleCardSize4';
|
||||||
|
import ArticleCardSize2 from './ArticleCardSize2';
|
||||||
|
import { ArticleModel } from "../../types/models";
|
||||||
|
import { ArticleGridProps } from "../../types/props";
|
||||||
|
|
||||||
|
export default function ArticleCardsGrid({
|
||||||
|
articles,
|
||||||
|
onSelectArticle,
|
||||||
|
xs = 12,
|
||||||
|
md12 = 12,
|
||||||
|
md6 = 6,
|
||||||
|
md4 = 4,
|
||||||
|
nested = 2,
|
||||||
|
}: ArticleGridProps ) {
|
||||||
|
|
||||||
|
const visibleArticles = articles.slice(0, 6)
|
||||||
|
const count = visibleArticles.length;
|
||||||
|
|
||||||
|
const [focusedCardIndex, setFocusedCardIndex] = React.useState<number | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFocus = (index: number) => {
|
||||||
|
setFocusedCardIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
setFocusedCardIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCard = (article: ArticleModel, index: number, type: '12' | '6' | '4' | '2' = '12') => {
|
||||||
|
const CardComponent =
|
||||||
|
type === '12' ? ArticleCardSize12 :
|
||||||
|
type === '6' ? ArticleCardSize6 :
|
||||||
|
type === '4' ? ArticleCardSize4 :
|
||||||
|
ArticleCardSize2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardComponent
|
||||||
|
key={index}
|
||||||
|
article={article}
|
||||||
|
index={index}
|
||||||
|
focusedCardIndex={focusedCardIndex}
|
||||||
|
onSelectArticle={onSelectArticle}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid container spacing={2} columns={12}>
|
||||||
|
{/* ---- 1 article: 12 ---- */}
|
||||||
|
{count === 1 && (
|
||||||
|
<>
|
||||||
|
{visibleArticles.map((a, i) => (
|
||||||
|
<Grid key={i} size={{ xs, md: md12 }}>
|
||||||
|
{renderCard(a, i, '12')}
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- 2 articles: 6 | 6 ---- */}
|
||||||
|
{count === 2 && (
|
||||||
|
<>
|
||||||
|
{visibleArticles.map((a, i) => (
|
||||||
|
<Grid key={i} size={{ xs, md: md6 }}>
|
||||||
|
{renderCard(a, i, '6')}
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- 3 articles: 4 | 4 | 4 ---- */}
|
||||||
|
{count === 3 && (
|
||||||
|
<>
|
||||||
|
{visibleArticles.map((a, i) => (
|
||||||
|
<Grid key={i} size={{ xs, md: md4 }}>
|
||||||
|
{renderCard(a, i, '4')}
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- 4 articles: (6|6) + (6|6) ---- */}
|
||||||
|
{count === 4 && (
|
||||||
|
<>
|
||||||
|
{visibleArticles.map((a, i) => (
|
||||||
|
<Grid key={i} size={{ xs, md: md6 }}>
|
||||||
|
{renderCard(a, i, '6')}
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- 5 articles: (6|6) + (4|4|4) ---- */}
|
||||||
|
{count === 5 && (
|
||||||
|
<>
|
||||||
|
{/* Row 1: 2 x size6 */}
|
||||||
|
{visibleArticles.slice(0, 2).map((a, i) => (
|
||||||
|
<Grid key={i} size={{ xs, md: md6 }}>
|
||||||
|
{renderCard(a, i, '6')}
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Row 2: 3 x size4 */}
|
||||||
|
{visibleArticles.slice(2).map((a, i) => (
|
||||||
|
<Grid key={i + 2} size={{ xs, md: md4 }}>
|
||||||
|
{renderCard(a, i + 2, '4')}
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- 6 articles: (6|6) + (4|2x2|4) ---- */}
|
||||||
|
{count === 6 && (
|
||||||
|
<>
|
||||||
|
{/* Top row: 2 x size6 */}
|
||||||
|
{visibleArticles.slice(0, 2).map((a, i) => (
|
||||||
|
<Grid key={i} size={{ xs, md: md6 }}>
|
||||||
|
{renderCard(a, i, '6')}
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Bottom row: 4 + 2x2 + 4 */}
|
||||||
|
<Grid size={{ xs, md: md4 }}>
|
||||||
|
{renderCard(visibleArticles[2], 2, '4')}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs, md: md4 }}>
|
||||||
|
<Box
|
||||||
|
sx={{ display: 'flex', flexDirection: 'column', gap: 2, height: '100%' }}
|
||||||
|
>
|
||||||
|
{visibleArticles.slice(3, 3 + nested).map((a, i) =>
|
||||||
|
renderCard(a, i + 3, '2')
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid size={{ xs, md: md4 }}>
|
||||||
|
{renderCard(visibleArticles[5], 5, '4')}
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
src/blog/components/ArticleMeta.tsx
Normal file
57
src/blog/components/ArticleMeta.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import AvatarGroup from "@mui/material/AvatarGroup";
|
||||||
|
import Avatar from "@mui/material/Avatar";
|
||||||
|
import {Typography} from "@mui/material";
|
||||||
|
import React from "react";
|
||||||
|
import { ArticleMetaProps } from "../types/props";
|
||||||
|
|
||||||
|
export function ArticleMeta({
|
||||||
|
article,
|
||||||
|
}: ArticleMetaProps ) {
|
||||||
|
|
||||||
|
const authors = article.authors;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 2,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{ display: 'flex', flexDirection: 'row', gap: 1, alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
<AvatarGroup max={3}>
|
||||||
|
{authors.map((author, index) => (
|
||||||
|
<Avatar
|
||||||
|
key={index}
|
||||||
|
alt={author.name}
|
||||||
|
src={(
|
||||||
|
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||||
|
"/" +
|
||||||
|
(author.avatar?.replace(/^\/+/, "") || "")
|
||||||
|
)}
|
||||||
|
sx={{ width: 24, height: 24 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AvatarGroup>
|
||||||
|
<Typography variant="caption">
|
||||||
|
{authors.map((author) => author.name).join(', ')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{new Date(article.created_at).toLocaleString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/blog/components/ImageUploadField.tsx
Normal file
45
src/blog/components/ImageUploadField.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Box, Button, Avatar, CircularProgress } from "@mui/material";
|
||||||
|
import { ImageUploadFieldProps } from "../types/props";
|
||||||
|
|
||||||
|
export default function ImageUploadField({
|
||||||
|
label = "Upload Image",
|
||||||
|
value,
|
||||||
|
uploading = false,
|
||||||
|
onUpload,
|
||||||
|
size = 64,
|
||||||
|
}: ImageUploadFieldProps) {
|
||||||
|
|
||||||
|
const imgSrc = value
|
||||||
|
? import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||||
|
"/" +
|
||||||
|
value.replace(/^\/+/, "")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 3 }}>
|
||||||
|
<Avatar
|
||||||
|
src={imgSrc}
|
||||||
|
sx={{ width: size, height: size, borderRadius: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
component="label"
|
||||||
|
disabled={uploading}
|
||||||
|
startIcon={uploading && <CircularProgress size={16} />}
|
||||||
|
>
|
||||||
|
{uploading ? "Uploading..." : label}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) onUpload(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,95 +1,19 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import Avatar from '@mui/material/Avatar';
|
|
||||||
import AvatarGroup from '@mui/material/AvatarGroup';
|
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Grid from '@mui/material/Grid';
|
import Grid from '@mui/material/Grid';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import { styled } from '@mui/material/styles';
|
|
||||||
import NavigateNextRoundedIcon from '@mui/icons-material/NavigateNextRounded';
|
import NavigateNextRoundedIcon from '@mui/icons-material/NavigateNextRounded';
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import { LatestProps } from "../types/props";
|
import { LatestProps } from "../types/props";
|
||||||
import Fade from '@mui/material/Fade'; // ✅ for smooth appearance
|
import { StyledTypography, TitleTypography } from "../types/styles";
|
||||||
|
import { ArticleMeta } from "./ArticleMeta";
|
||||||
|
import Fade from '@mui/material/Fade';
|
||||||
|
|
||||||
|
export default function Latest({
|
||||||
const StyledTypography = styled(Typography)({
|
articles,
|
||||||
display: '-webkit-box',
|
onSelectArticle,
|
||||||
WebkitBoxOrient: 'vertical',
|
onLoadMore
|
||||||
WebkitLineClamp: 2,
|
}: LatestProps) {
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
});
|
|
||||||
|
|
||||||
const TitleTypography = styled(Typography)(({ theme }) => ({
|
|
||||||
position: 'relative',
|
|
||||||
textDecoration: 'none',
|
|
||||||
'&:hover': { cursor: 'pointer' },
|
|
||||||
'& .arrow': {
|
|
||||||
visibility: 'hidden',
|
|
||||||
position: 'absolute',
|
|
||||||
right: 0,
|
|
||||||
top: '50%',
|
|
||||||
transform: 'translateY(-50%)',
|
|
||||||
},
|
|
||||||
'&:hover .arrow': {
|
|
||||||
visibility: 'visible',
|
|
||||||
opacity: 0.7,
|
|
||||||
},
|
|
||||||
'&:focus-visible': {
|
|
||||||
outline: '3px solid',
|
|
||||||
outlineColor: 'hsla(210, 98%, 48%, 0.5)',
|
|
||||||
outlineOffset: '3px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
},
|
|
||||||
'&::before': {
|
|
||||||
content: '""',
|
|
||||||
position: 'absolute',
|
|
||||||
width: 0,
|
|
||||||
height: '1px',
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
backgroundColor: (theme.vars || theme).palette.text.primary,
|
|
||||||
opacity: 0.3,
|
|
||||||
transition: 'width 0.3s ease, opacity 0.3s ease',
|
|
||||||
},
|
|
||||||
'&:hover::before': {
|
|
||||||
width: '100%',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
function Author({ authors }: { authors: { name: string; avatar: string }[] }) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 2,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{ display: 'flex', flexDirection: 'row', gap: 1, alignItems: 'center' }}
|
|
||||||
>
|
|
||||||
<AvatarGroup max={3}>
|
|
||||||
{authors.map((author, index) => (
|
|
||||||
<Avatar
|
|
||||||
key={index}
|
|
||||||
alt={author.name}
|
|
||||||
src={author.avatar}
|
|
||||||
sx={{ width: 24, height: 24 }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</AvatarGroup>
|
|
||||||
<Typography variant="caption">
|
|
||||||
{authors.map((a) => a.name).join(', ')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="caption">Recently Updated</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Latest({ articles, onSelectArticle, onLoadMore }: LatestProps) {
|
|
||||||
const [visibleCount, setVisibleCount] = React.useState(2);
|
const [visibleCount, setVisibleCount] = React.useState(2);
|
||||||
const [loadingMore, setLoadingMore] = React.useState(false);
|
const [loadingMore, setLoadingMore] = React.useState(false);
|
||||||
const [animating, setAnimating] = React.useState(false);
|
const [animating, setAnimating] = React.useState(false);
|
||||||
@@ -178,7 +102,7 @@ export default function Latest({ articles, onSelectArticle, onLoadMore }: Latest
|
|||||||
{article.description}
|
{article.description}
|
||||||
</StyledTypography>
|
</StyledTypography>
|
||||||
|
|
||||||
<Author authors={article.authors} />
|
<ArticleMeta article={article} />
|
||||||
</Box>
|
</Box>
|
||||||
</Fade>
|
</Fade>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -1,91 +1,17 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import Avatar from '@mui/material/Avatar';
|
|
||||||
import AvatarGroup from '@mui/material/AvatarGroup';
|
|
||||||
import Box from '@mui/material/Box';
|
import Box from '@mui/material/Box';
|
||||||
import Card from '@mui/material/Card';
|
|
||||||
import CardContent from '@mui/material/CardContent';
|
|
||||||
import CardMedia from '@mui/material/CardMedia';
|
|
||||||
import Chip from '@mui/material/Chip';
|
import Chip from '@mui/material/Chip';
|
||||||
import Grid from '@mui/material/Grid';
|
|
||||||
import IconButton from '@mui/material/IconButton';
|
import IconButton from '@mui/material/IconButton';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import FormControl from '@mui/material/FormControl';
|
import FormControl from '@mui/material/FormControl';
|
||||||
import InputAdornment from '@mui/material/InputAdornment';
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
import OutlinedInput from '@mui/material/OutlinedInput';
|
import OutlinedInput from '@mui/material/OutlinedInput';
|
||||||
import { styled } from '@mui/material/styles';
|
|
||||||
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
|
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
|
||||||
import RssFeedRoundedIcon from '@mui/icons-material/RssFeedRounded';
|
import RssFeedRoundedIcon from '@mui/icons-material/RssFeedRounded';
|
||||||
|
|
||||||
|
import {ArticlesModel, createArticlesModelObject} from "../types/models";
|
||||||
const StyledCard = styled(Card)(({ theme }) => ({
|
import { MainContentProps } from "../types/props";
|
||||||
display: 'flex',
|
import ArticleCardsGrid from "./ArticleCards/ArticleCardsGrid";
|
||||||
flexDirection: 'column',
|
|
||||||
padding: 0,
|
|
||||||
height: '100%',
|
|
||||||
backgroundColor: (theme.vars || theme).palette.background.paper,
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
cursor: 'pointer',
|
|
||||||
},
|
|
||||||
'&:focus-visible': {
|
|
||||||
outline: '3px solid',
|
|
||||||
outlineColor: 'hsla(210, 98%, 48%, 0.5)',
|
|
||||||
outlineOffset: '2px',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const StyledCardContent = styled(CardContent)({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 4,
|
|
||||||
padding: 16,
|
|
||||||
flexGrow: 1,
|
|
||||||
'&:last-child': {
|
|
||||||
paddingBottom: 16,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const StyledTypography = styled(Typography)({
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitBoxOrient: 'vertical',
|
|
||||||
WebkitLineClamp: 2,
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
});
|
|
||||||
|
|
||||||
function Author({ authors }: { authors: { name: string; avatar: string }[] }) {
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 2,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '16px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
sx={{ display: 'flex', flexDirection: 'row', gap: 1, alignItems: 'center' }}
|
|
||||||
>
|
|
||||||
<AvatarGroup max={3}>
|
|
||||||
{authors.map((author, index) => (
|
|
||||||
<Avatar
|
|
||||||
key={index}
|
|
||||||
alt={author.name}
|
|
||||||
src={author.avatar}
|
|
||||||
sx={{ width: 24, height: 24 }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</AvatarGroup>
|
|
||||||
<Typography variant="caption">
|
|
||||||
{authors.map((author) => author.name).join(', ')}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<Typography variant="caption">July 14, 2021</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Search() {
|
export function Search() {
|
||||||
return (
|
return (
|
||||||
@@ -111,33 +37,45 @@ export function Search() {
|
|||||||
export default function MainContent({
|
export default function MainContent({
|
||||||
articles,
|
articles,
|
||||||
onSelectArticle,
|
onSelectArticle,
|
||||||
}: {
|
}: MainContentProps) {
|
||||||
articles: any[];
|
|
||||||
onSelectArticle: (index: number) => void;
|
|
||||||
}) {
|
|
||||||
const [focusedCardIndex, setFocusedCardIndex] = React.useState<number | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFocus = (index: number) => {
|
const [visibleArticles, setVisibleArticles] = React.useState<ArticlesModel>(articles);
|
||||||
setFocusedCardIndex(index);
|
const [activeTag, setActiveTag] = React.useState<string>('all');
|
||||||
|
|
||||||
|
const filterArticlesByTag = (tag: string) => {
|
||||||
|
if (tag === 'all') {
|
||||||
|
// 🟢 Show all articles
|
||||||
|
setVisibleArticles(articles);
|
||||||
|
setActiveTag('all');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTag === tag) {
|
||||||
|
// 🟡 Toggle off the current tag → reset to all
|
||||||
|
setVisibleArticles(articles);
|
||||||
|
setActiveTag('all');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔵 Filter by selected tag
|
||||||
|
const filtered = articles.articlesList.filter((article) => article.tag === tag);
|
||||||
|
console.log('👀 All Articles:', articles);
|
||||||
|
console.log(`👀 Filtered (${tag}):`, filtered);
|
||||||
|
|
||||||
|
setVisibleArticles(createArticlesModelObject(filtered));
|
||||||
|
setActiveTag(tag);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBlur = () => {
|
const handleTagClick = (tag: string) => {
|
||||||
setFocusedCardIndex(null);
|
setActiveTag((prev) => (prev === tag ? 'all' : tag));
|
||||||
};
|
filterArticlesByTag(tag)
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
console.info('You clicked the filter chip.');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
<div>
|
|
||||||
<Typography variant="h1" gutterBottom>
|
<Typography variant="h1" gutterBottom>
|
||||||
Blog
|
Blog
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: { xs: 'flex', sm: 'none' },
|
display: { xs: 'flex', sm: 'none' },
|
||||||
@@ -171,43 +109,21 @@ export default function MainContent({
|
|||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Chip onClick={handleClick} size="medium" label="All categories" />
|
{['all', 'infra', 'code', 'media', 'monitoring'].map((tag) => (
|
||||||
<Chip
|
<Chip
|
||||||
onClick={handleClick}
|
key={tag}
|
||||||
|
onClick={() => handleTagClick(tag)}
|
||||||
size="medium"
|
size="medium"
|
||||||
label="Company"
|
label={tag === 'all' ? 'All categories' : tag.charAt(0).toUpperCase() + tag.slice(1)}
|
||||||
|
color={activeTag === tag ? 'primary' : 'default'}
|
||||||
|
variant={activeTag === tag ? 'filled' : 'outlined'}
|
||||||
sx={{
|
sx={{
|
||||||
backgroundColor: 'transparent',
|
borderRadius: '8px',
|
||||||
border: 'none',
|
fontWeight: activeTag === tag ? 600 : 400,
|
||||||
}}
|
textTransform: 'capitalize',
|
||||||
/>
|
|
||||||
<Chip
|
|
||||||
onClick={handleClick}
|
|
||||||
size="medium"
|
|
||||||
label="Product"
|
|
||||||
sx={{
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Chip
|
|
||||||
onClick={handleClick}
|
|
||||||
size="medium"
|
|
||||||
label="Design"
|
|
||||||
sx={{
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Chip
|
|
||||||
onClick={handleClick}
|
|
||||||
size="medium"
|
|
||||||
label="Engineering"
|
|
||||||
sx={{
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@@ -224,216 +140,10 @@ export default function MainContent({
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Grid container spacing={2} columns={12}>
|
<ArticleCardsGrid
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
articles={visibleArticles}
|
||||||
<StyledCard
|
onSelectArticle={onSelectArticle}
|
||||||
variant="outlined"
|
|
||||||
onClick={() => onSelectArticle(0)}
|
|
||||||
onFocus={() => handleFocus(0)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
tabIndex={0}
|
|
||||||
className={focusedCardIndex === 0 ? 'Mui-focused' : ''}
|
|
||||||
>
|
|
||||||
<CardMedia
|
|
||||||
component="img"
|
|
||||||
alt="green iguana"
|
|
||||||
image={articles[0].img}
|
|
||||||
sx={{
|
|
||||||
aspectRatio: '16 / 9',
|
|
||||||
borderBottom: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<StyledCardContent>
|
|
||||||
<Typography gutterBottom variant="caption" component="div">
|
|
||||||
{articles[0].tag}
|
|
||||||
</Typography>
|
|
||||||
<Typography gutterBottom variant="h6" component="div">
|
|
||||||
{articles[0].title}
|
|
||||||
</Typography>
|
|
||||||
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
|
|
||||||
{articles[0].description}
|
|
||||||
</StyledTypography>
|
|
||||||
</StyledCardContent>
|
|
||||||
<Author authors={articles[0].authors} />
|
|
||||||
</StyledCard>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 12, md: 6 }}>
|
|
||||||
<StyledCard
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => onSelectArticle(1)}
|
|
||||||
onFocus={() => handleFocus(1)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
tabIndex={0}
|
|
||||||
className={focusedCardIndex === 1 ? 'Mui-focused' : ''}
|
|
||||||
>
|
|
||||||
<CardMedia
|
|
||||||
component="img"
|
|
||||||
alt="green iguana"
|
|
||||||
image={articles[1].img}
|
|
||||||
aspect-ratio="16 / 9"
|
|
||||||
sx={{
|
|
||||||
borderBottom: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<StyledCardContent>
|
|
||||||
<Typography gutterBottom variant="caption" component="div">
|
|
||||||
{articles[1].tag}
|
|
||||||
</Typography>
|
|
||||||
<Typography gutterBottom variant="h6" component="div">
|
|
||||||
{articles[1].title}
|
|
||||||
</Typography>
|
|
||||||
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
|
|
||||||
{articles[1].description}
|
|
||||||
</StyledTypography>
|
|
||||||
</StyledCardContent>
|
|
||||||
<Author authors={articles[1].authors} />
|
|
||||||
</StyledCard>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 12, md: 4 }}>
|
|
||||||
<StyledCard
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => onSelectArticle(2)}
|
|
||||||
onFocus={() => handleFocus(2)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
tabIndex={0}
|
|
||||||
className={focusedCardIndex === 2 ? 'Mui-focused' : ''}
|
|
||||||
sx={{ height: '100%' }}
|
|
||||||
>
|
|
||||||
<CardMedia
|
|
||||||
component="img"
|
|
||||||
alt="green iguana"
|
|
||||||
image={articles[2].img}
|
|
||||||
sx={{
|
|
||||||
height: { sm: 'auto', md: '50%' },
|
|
||||||
aspectRatio: { sm: '16 / 9', md: '' },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<StyledCardContent>
|
|
||||||
<Typography gutterBottom variant="caption" component="div">
|
|
||||||
{articles[2].tag}
|
|
||||||
</Typography>
|
|
||||||
<Typography gutterBottom variant="h6" component="div">
|
|
||||||
{articles[2].title}
|
|
||||||
</Typography>
|
|
||||||
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
|
|
||||||
{articles[2].description}
|
|
||||||
</StyledTypography>
|
|
||||||
</StyledCardContent>
|
|
||||||
<Author authors={articles[2].authors} />
|
|
||||||
</StyledCard>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 12, md: 4 }}>
|
|
||||||
<Box
|
|
||||||
sx={{ display: 'flex', flexDirection: 'column', gap: 2, height: '100%' }}
|
|
||||||
>
|
|
||||||
<StyledCard
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => onSelectArticle(3)}
|
|
||||||
onFocus={() => handleFocus(3)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
tabIndex={0}
|
|
||||||
className={focusedCardIndex === 3 ? 'Mui-focused' : ''}
|
|
||||||
sx={{ height: '100%' }}
|
|
||||||
>
|
|
||||||
<StyledCardContent
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
height: '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Typography gutterBottom variant="caption" component="div">
|
|
||||||
{articles[3].tag}
|
|
||||||
</Typography>
|
|
||||||
<Typography gutterBottom variant="h6" component="div">
|
|
||||||
{articles[3].title}
|
|
||||||
</Typography>
|
|
||||||
<StyledTypography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
gutterBottom
|
|
||||||
>
|
|
||||||
{articles[3].description}
|
|
||||||
</StyledTypography>
|
|
||||||
</div>
|
|
||||||
</StyledCardContent>
|
|
||||||
<Author authors={articles[3].authors} />
|
|
||||||
</StyledCard>
|
|
||||||
<StyledCard
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => onSelectArticle(4)}
|
|
||||||
onFocus={() => handleFocus(4)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
tabIndex={0}
|
|
||||||
className={focusedCardIndex === 4 ? 'Mui-focused' : ''}
|
|
||||||
sx={{ height: '100%' }}
|
|
||||||
>
|
|
||||||
<StyledCardContent
|
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
height: '100%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<Typography gutterBottom variant="caption" component="div">
|
|
||||||
{articles[4].tag}
|
|
||||||
</Typography>
|
|
||||||
<Typography gutterBottom variant="h6" component="div">
|
|
||||||
{articles[4].title}
|
|
||||||
</Typography>
|
|
||||||
<StyledTypography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
gutterBottom
|
|
||||||
>
|
|
||||||
{articles[4].description}
|
|
||||||
</StyledTypography>
|
|
||||||
</div>
|
|
||||||
</StyledCardContent>
|
|
||||||
<Author authors={articles[4].authors} />
|
|
||||||
</StyledCard>
|
|
||||||
</Box>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={{ xs: 12, md: 4 }}>
|
|
||||||
<StyledCard
|
|
||||||
variant="outlined"
|
|
||||||
onClick={() => onSelectArticle(5)}
|
|
||||||
onFocus={() => handleFocus(5)}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
tabIndex={0}
|
|
||||||
className={focusedCardIndex === 5 ? 'Mui-focused' : ''}
|
|
||||||
sx={{ height: '100%' }}
|
|
||||||
>
|
|
||||||
<CardMedia
|
|
||||||
component="img"
|
|
||||||
alt="green iguana"
|
|
||||||
image={articles[5].img}
|
|
||||||
sx={{
|
|
||||||
height: { sm: 'auto', md: '50%' },
|
|
||||||
aspectRatio: { sm: '16 / 9', md: '' },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<StyledCardContent>
|
|
||||||
<Typography gutterBottom variant="caption" component="div">
|
|
||||||
{articles[5].tag}
|
|
||||||
</Typography>
|
|
||||||
<Typography gutterBottom variant="h6" component="div">
|
|
||||||
{articles[5].title}
|
|
||||||
</Typography>
|
|
||||||
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
|
|
||||||
{articles[5].description}
|
|
||||||
</StyledTypography>
|
|
||||||
</StyledCardContent>
|
|
||||||
<Author authors={articles[5].authors} />
|
|
||||||
</StyledCard>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,29 +6,36 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
IconButton,
|
IconButton,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Avatar,
|
|
||||||
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 ImageUploadField from './ImageUploadField';
|
||||||
|
import { ProfileProps } from '../types/props';
|
||||||
|
|
||||||
interface ProfileProps {
|
export default function Profile({
|
||||||
onBack: () => void;
|
onBack
|
||||||
}
|
}: ProfileProps) {
|
||||||
|
const { logout } = useAuth();
|
||||||
|
const { currentUser, updateProfile, loading, error } = useAuthor();
|
||||||
|
|
||||||
export default function Profile({ onBack }: ProfileProps) {
|
const { uploadFile } = useUpload();
|
||||||
const { currentUser, loading, error, token, refreshAuthors, updateProfile } = useAuth();
|
|
||||||
const [formData, setFormData] = React.useState({
|
const [formData, setFormData] = React.useState({
|
||||||
username: currentUser?.username || '',
|
username: currentUser?.username || '',
|
||||||
name: currentUser?.name || '',
|
name: currentUser?.name || '',
|
||||||
email: currentUser?.email || '',
|
email: currentUser?.email || '',
|
||||||
avatar: currentUser?.avatar || '',
|
avatar: currentUser?.avatar || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [uploadingAvatar, setUploadingAvatar] = React.useState(false);
|
||||||
const [success, setSuccess] = React.useState<string | null>(null);
|
const [success, setSuccess] = React.useState<string | null>(null);
|
||||||
const [saving, setSaving] = React.useState(false);
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (currentUser) setFormData(currentUser);
|
if (currentUser) setFormData(currentUser);
|
||||||
|
console.log("Current User:", currentUser);
|
||||||
}, [currentUser]);
|
}, [currentUser]);
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -36,6 +43,21 @@ export default function Profile({ onBack }: ProfileProps) {
|
|||||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAvatarUpload = async (file: File) => {
|
||||||
|
setUploadingAvatar(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const avatar = await uploadFile(file);
|
||||||
|
if (avatar) {
|
||||||
|
setFormData((prev) => ({ ...prev, avatar: avatar }));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Avatar upload failed:", err);
|
||||||
|
} finally {
|
||||||
|
setUploadingAvatar(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
|
|
||||||
@@ -44,8 +66,6 @@ export default function Profile({ onBack }: ProfileProps) {
|
|||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
|
|
||||||
const updatedUser = { ...currentUser, ...formData };
|
const updatedUser = { ...currentUser, ...formData };
|
||||||
console.log('updatedUser');
|
|
||||||
console.log(updatedUser);
|
|
||||||
const updated = await updateProfile(updatedUser);
|
const updated = await updateProfile(updatedUser);
|
||||||
|
|
||||||
if (updated) setSuccess('Profile updated successfully');
|
if (updated) setSuccess('Profile updated successfully');
|
||||||
@@ -56,6 +76,10 @@ export default function Profile({ onBack }: ProfileProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
logout();
|
||||||
|
};
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -99,26 +123,20 @@ export default function Profile({ onBack }: ProfileProps) {
|
|||||||
Profile
|
Profile
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
<ImageUploadField
|
||||||
<Avatar
|
label="Upload Avatar"
|
||||||
src={formData.avatar}
|
|
||||||
alt={formData.name || formData.username}
|
|
||||||
sx={{ width: 64, height: 64 }}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Avatar URL"
|
|
||||||
name="avatar"
|
|
||||||
fullWidth
|
|
||||||
value={formData.avatar}
|
value={formData.avatar}
|
||||||
onChange={handleChange}
|
uploading={uploadingAvatar}
|
||||||
|
onUpload={handleAvatarUpload}
|
||||||
|
size={64}
|
||||||
/>
|
/>
|
||||||
</Box>
|
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label="Username"
|
label="Username"
|
||||||
name="username"
|
name="username"
|
||||||
margin="normal"
|
margin="normal"
|
||||||
|
disabled={true}
|
||||||
value={formData.username}
|
value={formData.username}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
@@ -161,6 +179,15 @@ export default function Profile({ onBack }: ProfileProps) {
|
|||||||
>
|
>
|
||||||
{saving ? <CircularProgress size={24} color="inherit" /> : 'Save Changes'}
|
{saving ? <CircularProgress size={24} color="inherit" /> : 'Save Changes'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
sx={{ mt: 3 }}
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,114 +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';
|
|
||||||
|
|
||||||
interface RegisterProps {
|
|
||||||
onBack: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,16 +1,38 @@
|
|||||||
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 { ArticleModel } from '../types/models';
|
import {
|
||||||
import { ArticleContextModel } from '../types/contexts';
|
ArticleModel,
|
||||||
import { useAuth } from './Author';
|
ArticlesModel,
|
||||||
|
createArticlesModelObject,
|
||||||
|
} from "../types/models";
|
||||||
|
import { ArticleContextModel } from "../types/contexts";
|
||||||
|
import { useAuth } from "./Author";
|
||||||
|
|
||||||
const ArticleContext = createContext<ArticleContextModel | undefined>(undefined);
|
const ArticleContext = createContext<ArticleContextModel | undefined>(undefined);
|
||||||
|
|
||||||
export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const [articles, setArticles] = useState<ArticleModel[]>([]);
|
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 } = useAuth(); // ✅ access token if needed
|
const { currentUser } = useAuth();
|
||||||
|
|
||||||
|
/** 🔹 Author IDs must be strings for API, so we normalize here */
|
||||||
|
const normalizeArticleForApi = (article: Partial<ArticleModel>) => {
|
||||||
|
// Extract existing authors as a list of IDs (string[])
|
||||||
|
const existingIds = (article.authors ?? []).map(a =>
|
||||||
|
typeof a === "string" ? a : a._id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Inject currentUser if missing
|
||||||
|
const allAuthorIds = currentUser?._id
|
||||||
|
? Array.from(new Set([...existingIds, currentUser._id])) // dedupe
|
||||||
|
: existingIds;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...article,
|
||||||
|
authors: allAuthorIds,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/** 🔹 Fetch articles (JWT automatically attached by api.ts interceptor) */
|
/** 🔹 Fetch articles (JWT automatically attached by api.ts interceptor) */
|
||||||
const fetchArticles = async () => {
|
const fetchArticles = async () => {
|
||||||
@@ -18,9 +40,9 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const res = await api.get<ArticleModel[]>('/articles', { params: { skip: 0, limit: 10 } });
|
const res = await api.get<ArticleModel[]>('/articles', { params: { skip: 0, limit: 100 } });
|
||||||
const formatted = res.data.map((a) => ({ ...a, id: a._id || undefined }));
|
const formatted = res.data.map((a) => ({ ...a, id: a._id || undefined }));
|
||||||
setArticles(formatted);
|
setArticles(prev => prev.refresh(formatted));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to fetch articles:', err);
|
console.error('Failed to fetch articles:', err);
|
||||||
setError(err.response?.data?.detail || 'Failed to fetch articles');
|
setError(err.response?.data?.detail || 'Failed to fetch articles');
|
||||||
@@ -29,19 +51,76 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 🔹 Update article */
|
||||||
|
const updateArticle = async (articleData: ArticleModel) => {
|
||||||
|
if (!articleData._id) {
|
||||||
|
console.error('updateArticle called without _id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!currentUser) {
|
||||||
|
console.error('updateArticle called without logged in user');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedArticleData = normalizeArticleForApi(articleData);
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await api.put<ArticleModel>(`/articles/${articleData._id}`, normalizedArticleData);
|
||||||
|
setArticles(prev => {
|
||||||
|
prev.update(res.data);
|
||||||
|
return { ...prev };
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Article update failed:', err);
|
||||||
|
setError(err.response?.data?.detail || 'Failed to update article');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 🔹 Create article */
|
||||||
|
const createArticle = async (articleData: ArticleModel) => {
|
||||||
|
if (articleData._id) {
|
||||||
|
console.error('createArticle called with _id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedArticleData = normalizeArticleForApi(articleData);
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await api.post<ArticleModel>(`/articles`, normalizedArticleData);
|
||||||
|
setArticles(prev => {
|
||||||
|
prev.create(res.data);
|
||||||
|
return { ...prev };
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Article create failed:', err);
|
||||||
|
setError(err.response?.data?.detail || 'Failed to create article');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/** 🔹 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={{ articles, loading, error, refreshArticles: fetchArticles }}>
|
<ArticleContext.Provider value={{
|
||||||
|
articles,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refreshArticles: fetchArticles,
|
||||||
|
updateArticle,
|
||||||
|
createArticle,
|
||||||
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</ArticleContext.Provider>
|
</ArticleContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,63 +1,50 @@
|
|||||||
import React, { createContext, useState, useEffect, useContext } from 'react';
|
import React, { createContext, useState, useEffect, useContext } from "react";
|
||||||
import { api } 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 api.post('/auth/register', { username, password });
|
const res = await api.get<AuthorModel>("/authors/me");
|
||||||
return res.data; // returns PublicUser from backend
|
|
||||||
} catch (err: any) {
|
/**
|
||||||
console.error('Registration failed:', err);
|
* Explicit precedence:
|
||||||
setError(err.response?.data?.detail || 'Registration failed');
|
* 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 {
|
} 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 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) */
|
/** 🔹 Fetch all authors (JWT handled by api interceptor) */
|
||||||
const refreshAuthors = async () => {
|
const refreshAuthors = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -95,40 +82,27 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
/** 🔹 Auto-load current user if token exists */
|
* React strictly to auth lifecycle
|
||||||
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(); // invalid/expired token
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 🔹 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,
|
||||||
}}
|
}}
|
||||||
|
|||||||
56
src/blog/providers/Upload.tsx
Normal file
56
src/blog/providers/Upload.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import React, { createContext, useContext, useState } from "react";
|
||||||
|
import { api } from "../utils/api";
|
||||||
|
import { UploadContextModel } from "../types/contexts";
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔹 Upload any file → return public URL
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -1,21 +1,30 @@
|
|||||||
import { ArticleModel, AuthorModel } from "./models";
|
import {
|
||||||
|
ArticleModel,
|
||||||
|
ArticlesModel,
|
||||||
|
AuthorModel
|
||||||
|
} from "./models";
|
||||||
|
|
||||||
export interface ArticleContextModel {
|
export interface ArticleContextModel {
|
||||||
articles: ArticleModel[];
|
articles: ArticlesModel;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
refreshArticles: () => Promise<void>;
|
refreshArticles: () => Promise<void>;
|
||||||
|
updateArticle: (article: ArticleModel) => Promise<ArticleModel | void>;
|
||||||
|
createArticle: (article: ArticleModel) => Promise<ArticleModel | void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UploadContextModel {
|
||||||
|
uploadFile: (file: File) => Promise<string | null>;
|
||||||
|
uploading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
export interface AuthorModel {
|
import {
|
||||||
|
createInList, readInList, updateInList, deleteInList,
|
||||||
|
createById, readById, updateById, deleteById
|
||||||
|
} from "../utils/articles";
|
||||||
|
import { AuthUser } from "../../../auth/src";
|
||||||
|
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -28,3 +32,82 @@ export interface ArticleModel {
|
|||||||
// ref fields
|
// ref fields
|
||||||
authors: AuthorModel[];
|
authors: AuthorModel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ArticlesModel {
|
||||||
|
articlesList: ArticleModel[];
|
||||||
|
articlesById: Record<string, ArticleModel>;
|
||||||
|
// articlesByTag: Record<string, ArticleModel[]>;
|
||||||
|
// articlesByAuthor: Record<string, ArticleModel[]>;
|
||||||
|
|
||||||
|
length: number;
|
||||||
|
slice(start: number, end?: number): ArticleModel[];
|
||||||
|
|
||||||
|
refresh(list: ArticleModel[]): ArticlesModel;
|
||||||
|
create(a: ArticleModel): ArticlesModel;
|
||||||
|
readByIndex(index: number): ArticleModel | undefined;
|
||||||
|
readById(id: string): ArticleModel | undefined;
|
||||||
|
update(a: ArticleModel): ArticlesModel;
|
||||||
|
delete(id: string): ArticlesModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- FACTORY ----------
|
||||||
|
export function createArticlesModelObject(
|
||||||
|
articles: ArticleModel[] = []
|
||||||
|
): ArticlesModel {
|
||||||
|
const initialMap: Record<string, ArticleModel> = {};
|
||||||
|
for (const a of articles) {
|
||||||
|
if (a._id) initialMap[a._id] = a;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
articlesList: articles,
|
||||||
|
articlesById: initialMap,
|
||||||
|
|
||||||
|
// --- computed property ---
|
||||||
|
get length() {
|
||||||
|
return this.articlesList.length;
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- array-like slice ---
|
||||||
|
slice(start: number, end?: number) {
|
||||||
|
return this.articlesList.slice(start, end);
|
||||||
|
},
|
||||||
|
|
||||||
|
refresh(list) {
|
||||||
|
this.articlesList = list;
|
||||||
|
|
||||||
|
const map: Record<string, ArticleModel> = {};
|
||||||
|
for (const a of list) {
|
||||||
|
if (a._id) map[a._id] = a;
|
||||||
|
}
|
||||||
|
this.articlesById = map;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
create(a) {
|
||||||
|
this.articlesList = createInList(this.articlesList, a);
|
||||||
|
this.articlesById = createById(this.articlesById, a);
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
readByIndex(index) {
|
||||||
|
return readInList(this.articlesList, index);
|
||||||
|
},
|
||||||
|
|
||||||
|
readById(id) {
|
||||||
|
return readById(this.articlesById, id);
|
||||||
|
},
|
||||||
|
|
||||||
|
update(a) {
|
||||||
|
this.articlesList = updateInList(this.articlesList, a);
|
||||||
|
this.articlesById = updateById(this.articlesById, a);
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete(id) {
|
||||||
|
this.articlesList = deleteInList(this.articlesList, id);
|
||||||
|
this.articlesById = deleteById(this.articlesById, id);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,61 @@
|
|||||||
import { ArticleModel } from "./models";
|
import {
|
||||||
|
ArticleModel,
|
||||||
|
ArticlesModel,
|
||||||
|
} from "./models";
|
||||||
|
|
||||||
export interface LatestProps {
|
export interface LatestProps {
|
||||||
articles: ArticleModel[];
|
articles: ArticlesModel;
|
||||||
onSelectArticle?: (index: number) => void;
|
onSelectArticle?: (index: number) => void;
|
||||||
onLoadMore?: (offset: number, limit: number) => Promise<void>; // optional async callback
|
onLoadMore?: (offset: number, limit: number) => Promise<void>; // optional async callback
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ArticleProps {
|
export interface MainContentProps {
|
||||||
article: ArticleModel;
|
articles: ArticlesModel;
|
||||||
|
onSelectArticle: (index: ArticleModel) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileProps {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ArticleViewProps {
|
||||||
|
article: ArticleModel;
|
||||||
|
onBack: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArticleEditorProps {
|
||||||
|
article?: ArticleModel | null;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArticleMetaProps {
|
||||||
|
article: ArticleModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArticleCardProps {
|
||||||
|
article: ArticleModel;
|
||||||
|
index: number;
|
||||||
|
focusedCardIndex: number | null;
|
||||||
|
onSelectArticle: (index: ArticleModel) => void;
|
||||||
|
onFocus: (index: number) => void;
|
||||||
|
onBlur: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArticleGridProps {
|
||||||
|
articles: ArticlesModel;
|
||||||
|
onSelectArticle: (index: ArticleModel) => void;
|
||||||
|
xs?: number; // default 12 for mobile full-width
|
||||||
|
md12?: number, // default 12 (full-width)
|
||||||
|
md6?: number; // default 6 (half-width)
|
||||||
|
md4?: number; // default 4 (third-width)
|
||||||
|
nested?: 1 | 2; // number of stacked cards in a nested column
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageUploadFieldProps {
|
||||||
|
label?: string;
|
||||||
|
value?: string;
|
||||||
|
uploading?: boolean;
|
||||||
|
onUpload: (file: File) => void;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|||||||
77
src/blog/types/styles.ts
Normal file
77
src/blog/types/styles.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import {styled} from "@mui/material/styles";
|
||||||
|
import Card from "@mui/material/Card";
|
||||||
|
import CardContent from "@mui/material/CardContent";
|
||||||
|
import {Typography} from "@mui/material";
|
||||||
|
|
||||||
|
export const StyledCard = styled(Card)(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
padding: 0,
|
||||||
|
height: '100%',
|
||||||
|
backgroundColor: (theme.vars || theme).palette.background.paper,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
'&:focus-visible': {
|
||||||
|
outline: '3px solid',
|
||||||
|
outlineColor: 'hsla(210, 98%, 48%, 0.5)',
|
||||||
|
outlineOffset: '2px',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const StyledCardContent = styled(CardContent)({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
padding: 16,
|
||||||
|
flexGrow: 1,
|
||||||
|
'&:last-child': {
|
||||||
|
paddingBottom: 16,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const StyledTypography = styled(Typography)({
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitBoxOrient: 'vertical',
|
||||||
|
WebkitLineClamp: 2,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TitleTypography = styled(Typography)(({ theme }) => ({
|
||||||
|
position: 'relative',
|
||||||
|
textDecoration: 'none',
|
||||||
|
'&:hover': { cursor: 'pointer' },
|
||||||
|
'& .arrow': {
|
||||||
|
visibility: 'hidden',
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
},
|
||||||
|
'&:hover .arrow': {
|
||||||
|
visibility: 'visible',
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
'&:focus-visible': {
|
||||||
|
outline: '3px solid',
|
||||||
|
outlineColor: 'hsla(210, 98%, 48%, 0.5)',
|
||||||
|
outlineOffset: '3px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
},
|
||||||
|
'&::before': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
width: 0,
|
||||||
|
height: '1px',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
backgroundColor: (theme.vars || theme).palette.text.primary,
|
||||||
|
opacity: 0.3,
|
||||||
|
transition: 'width 0.3s ease, opacity 0.3s ease',
|
||||||
|
},
|
||||||
|
'&:hover::before': {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
}));
|
||||||
117
src/blog/types/views.ts
Normal file
117
src/blog/types/views.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { ArticleModel } from "./models";
|
||||||
|
|
||||||
|
export type View =
|
||||||
|
| "home"
|
||||||
|
| "auth"
|
||||||
|
| "article"
|
||||||
|
| "editor"
|
||||||
|
| "profile"
|
||||||
|
| "create";
|
||||||
|
|
||||||
|
export type ViewNode = {
|
||||||
|
parent: View | null;
|
||||||
|
children?: View[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VIEW_TREE: Record<View, ViewNode> = {
|
||||||
|
home: {
|
||||||
|
parent: null,
|
||||||
|
children: ["auth", "article", "profile", "create"],
|
||||||
|
},
|
||||||
|
|
||||||
|
auth: {
|
||||||
|
parent: "home",
|
||||||
|
},
|
||||||
|
|
||||||
|
article: {
|
||||||
|
parent: "home",
|
||||||
|
children: ["editor"],
|
||||||
|
},
|
||||||
|
|
||||||
|
editor: {
|
||||||
|
parent: "article",
|
||||||
|
},
|
||||||
|
|
||||||
|
profile: {
|
||||||
|
parent: "home",
|
||||||
|
},
|
||||||
|
|
||||||
|
create: {
|
||||||
|
parent: "home",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VIEW_URL: Record<View, (ui?: any) => string> = {
|
||||||
|
home: () => "/",
|
||||||
|
|
||||||
|
auth: () => "/auth",
|
||||||
|
|
||||||
|
profile: () => "/profile",
|
||||||
|
|
||||||
|
create: () => "/create",
|
||||||
|
|
||||||
|
article: (ui) => `/articles/${ui.selectedArticle._id ?? ""}`,
|
||||||
|
|
||||||
|
editor: (ui) => `/articles/${ui.selectedArticle._id ?? ""}/edit`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useViewRouter(setUI: any) {
|
||||||
|
const navigate = (
|
||||||
|
view: View,
|
||||||
|
nextState?: any
|
||||||
|
) => {
|
||||||
|
setUI((prev: any) => {
|
||||||
|
const newState = { ...prev, ...nextState, view };
|
||||||
|
|
||||||
|
// update URL
|
||||||
|
const url = VIEW_URL[view](newState);
|
||||||
|
window.history.pushState(newState, "", url);
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
// auto back logic from parent
|
||||||
|
const goBack = (view: View) => {
|
||||||
|
const parent = VIEW_TREE[view].parent;
|
||||||
|
if (parent) navigate(parent);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openArticle = (article: ArticleModel) => {
|
||||||
|
setUI((prev: any) => {
|
||||||
|
const newState = {
|
||||||
|
...prev,
|
||||||
|
selectedArticle: article,
|
||||||
|
view: "article",
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = `/articles/${article._id}`;
|
||||||
|
window.history.pushState(newState, "", url);
|
||||||
|
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
// auto child navigators from children[]
|
||||||
|
const navigateToChildren = (
|
||||||
|
view: View,
|
||||||
|
navigationMap?: Record<string, string>,
|
||||||
|
) => {
|
||||||
|
const node = VIEW_TREE[view];
|
||||||
|
const funcs: Record<string, () => void> = {};
|
||||||
|
|
||||||
|
node.children?.forEach((child) => {
|
||||||
|
const funcName = `open_${child}`;
|
||||||
|
const customFuncName = navigationMap?.[funcName];
|
||||||
|
funcs[funcName] = () => navigate(child);
|
||||||
|
if (customFuncName) funcs[customFuncName] = () => navigate(child);
|
||||||
|
});
|
||||||
|
|
||||||
|
return funcs;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { navigate, goBack, openArticle, navigateToChildren };
|
||||||
|
}
|
||||||
@@ -1,33 +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 API_BASE = import.meta.env.VITE_API_BASE_URL;
|
const API_BASE = import.meta.env.VITE_API_BASE_URL;
|
||||||
|
|
||||||
export const api = axios.create({
|
/**
|
||||||
baseURL: API_BASE,
|
* Auth service client
|
||||||
headers: {
|
* - login
|
||||||
'Content-Type': 'application/json',
|
* - register
|
||||||
},
|
* - me
|
||||||
});
|
* - logout
|
||||||
|
* - introspect
|
||||||
|
*/
|
||||||
|
export const auth = createApiClient(AUTH_BASE);
|
||||||
|
|
||||||
// 🔹 Attach token from localStorage before each request
|
/**
|
||||||
api.interceptors.request.use((config) => {
|
* Main application API (blog, articles, etc.)
|
||||||
const token = localStorage.getItem('token');
|
*/
|
||||||
if (token) {
|
export const api = createApiClient(API_BASE);
|
||||||
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);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|||||||
74
src/blog/utils/articles.ts
Normal file
74
src/blog/utils/articles.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
ArticleModel,
|
||||||
|
} from "../types/models";
|
||||||
|
|
||||||
|
export function createInList(list: ArticleModel[], a: ArticleModel) {
|
||||||
|
return [...list, a];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readInList(list: ArticleModel[], index: number) {
|
||||||
|
if (index < 0 || index >= list.length) {
|
||||||
|
// Soft fallback
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return list[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateInList(list: ArticleModel[], a: ArticleModel) {
|
||||||
|
return list.map(x => (x._id === a._id ? a : x));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteInList(list: ArticleModel[], id: string) {
|
||||||
|
return list.filter(x => x._id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map helpers
|
||||||
|
export function createById(
|
||||||
|
map: Record<string, ArticleModel>,
|
||||||
|
a: ArticleModel
|
||||||
|
) {
|
||||||
|
if (!a._id) {
|
||||||
|
// Soft mode: ignore create, return unchanged
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (map[a._id]) {
|
||||||
|
// Soft mode: do not replace existing
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...map, [a._id]: a };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readById(
|
||||||
|
map: Record<string, ArticleModel>,
|
||||||
|
id: string
|
||||||
|
) {
|
||||||
|
if (!id) return undefined;
|
||||||
|
return map[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateById(
|
||||||
|
map: Record<string, ArticleModel>,
|
||||||
|
a: ArticleModel
|
||||||
|
) {
|
||||||
|
if (!a._id) {
|
||||||
|
// Cannot update without ID
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map[a._id]) {
|
||||||
|
// ID does not exist → soft mode: do nothing
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...map, [a._id]: a };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteById(
|
||||||
|
map: Record<string, ArticleModel>,
|
||||||
|
id: string
|
||||||
|
) {
|
||||||
|
const { [id]: _, ...rest } = map;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
13
src/main.jsx
13
src/main.jsx
@@ -2,17 +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 { 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(
|
||||||
<React.StrictMode>
|
<UploadProvider>
|
||||||
<AuthProvider>
|
<AuthProvider authBaseUrl={AUTH_BASE}>
|
||||||
|
<AuthorProvider>
|
||||||
<ArticleProvider>
|
<ArticleProvider>
|
||||||
<Blog />
|
<Blog />
|
||||||
</ArticleProvider>
|
</ArticleProvider>
|
||||||
|
</AuthorProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</React.StrictMode>,
|
</UploadProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_API_BASE_URL: string;
|
readonly VITE_API_BASE_URL: string;
|
||||||
|
readonly VITE_AUTH_BASE_URL: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
160
src_generic/App.tsx
Normal file
160
src_generic/App.tsx
Normal 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
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);
|
||||||
|
}
|
||||||
261
src_generic/components/AdminLayout.tsx
Normal file
261
src_generic/components/AdminLayout.tsx
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Drawer,
|
||||||
|
AppBar,
|
||||||
|
Toolbar,
|
||||||
|
List,
|
||||||
|
Typography,
|
||||||
|
Divider,
|
||||||
|
ListItem,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
CssBaseline,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
} from '@mui/material';
|
||||||
|
import TableViewIcon from '@mui/icons-material/TableView';
|
||||||
|
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||||
|
import LogoutIcon from '@mui/icons-material/Logout';
|
||||||
|
import MenuIcon from '@mui/icons-material/Menu';
|
||||||
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||||
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
|
import { ResourceConfig } from '../types/config';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
const drawerWidth = 240;
|
||||||
|
const collapsedWidth = 64;
|
||||||
|
|
||||||
|
interface AdminLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onSelectResource: (resourceName: string | null) => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
username?: string;
|
||||||
|
resources: ResourceConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminLayout({
|
||||||
|
children,
|
||||||
|
onSelectResource,
|
||||||
|
onLogout,
|
||||||
|
username,
|
||||||
|
resources,
|
||||||
|
}: AdminLayoutProps) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [isCollapsed, setIsCollapsed] = React.useState(false);
|
||||||
|
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const activeResourceName = location.pathname.split('/')[1] || null;
|
||||||
|
|
||||||
|
// AUTO-TOGGLE LOGIC
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isMobile) {
|
||||||
|
setIsCollapsed(false); // Mobile drawer is never "mini"
|
||||||
|
setMobileOpen(false); // Close on navigation
|
||||||
|
} else {
|
||||||
|
if (location.pathname === '/' || location.pathname === '') {
|
||||||
|
setIsCollapsed(false);
|
||||||
|
} else {
|
||||||
|
setIsCollapsed(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [location.pathname, isMobile]);
|
||||||
|
|
||||||
|
const currentWidth = isMobile ? drawerWidth : (isCollapsed ? collapsedWidth : drawerWidth);
|
||||||
|
|
||||||
|
const handleDrawerToggle = () => {
|
||||||
|
setMobileOpen(!mobileOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSidebarToggle = () => {
|
||||||
|
setIsCollapsed(!isCollapsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawerContent = (
|
||||||
|
<Box sx={{ overflow: 'hidden', display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
{!isMobile && (
|
||||||
|
<>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: isCollapsed ? 'center' : 'flex-end', p: 1 }}>
|
||||||
|
<IconButton onClick={handleSidebarToggle}>
|
||||||
|
{isCollapsed ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isMobile && <Toolbar />}
|
||||||
|
|
||||||
|
<List>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<Tooltip title={(isCollapsed && !isMobile) ? "Dashboard" : ""} placement="right">
|
||||||
|
<ListItemButton
|
||||||
|
selected={location.pathname === '/'}
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
sx={{
|
||||||
|
minHeight: 48,
|
||||||
|
justifyContent: (isCollapsed && !isMobile) ? 'center' : 'initial',
|
||||||
|
px: 2.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon sx={{
|
||||||
|
minWidth: 0,
|
||||||
|
mr: (isCollapsed && !isMobile) ? 0 : 3,
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<DashboardIcon color={location.pathname === '/' ? 'primary' : 'inherit'} />
|
||||||
|
</ListItemIcon>
|
||||||
|
{(!isCollapsed || isMobile) && <ListItemText primary="Dashboard" />}
|
||||||
|
</ListItemButton>
|
||||||
|
</Tooltip>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
<Divider />
|
||||||
|
<List sx={{ flexGrow: 1 }}>
|
||||||
|
{resources.map((res) => (
|
||||||
|
<ListItem key={res.name} disablePadding>
|
||||||
|
<Tooltip title={(isCollapsed && !isMobile) ? res.pluralLabel : ""} placement="right">
|
||||||
|
<ListItemButton
|
||||||
|
selected={activeResourceName === res.name}
|
||||||
|
onClick={() => onSelectResource(res.name)}
|
||||||
|
sx={{
|
||||||
|
minHeight: 48,
|
||||||
|
justifyContent: (isCollapsed && !isMobile) ? 'center' : 'initial',
|
||||||
|
px: 2.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon sx={{
|
||||||
|
minWidth: 0,
|
||||||
|
mr: (isCollapsed && !isMobile) ? 0 : 3,
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<TableViewIcon color={activeResourceName === res.name ? 'primary' : 'inherit'} />
|
||||||
|
</ListItemIcon>
|
||||||
|
{(!isCollapsed || isMobile) && <ListItemText primary={res.pluralLabel} />}
|
||||||
|
</ListItemButton>
|
||||||
|
</Tooltip>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex' }}>
|
||||||
|
<CssBaseline />
|
||||||
|
<AppBar
|
||||||
|
position="fixed"
|
||||||
|
sx={{
|
||||||
|
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||||
|
color: 'text.primary',
|
||||||
|
boxShadow: 'none',
|
||||||
|
borderBottom: '1px solid',
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar>
|
||||||
|
{isMobile && (
|
||||||
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
aria-label="open drawer"
|
||||||
|
edge="start"
|
||||||
|
onClick={handleDrawerToggle}
|
||||||
|
sx={{ mr: 2 }}
|
||||||
|
>
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1, fontWeight: 'bold' }}>
|
||||||
|
Admin Panel
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: { xs: 'none', sm: 'flex' }, alignItems: 'center', mr: 2 }}>
|
||||||
|
<Button
|
||||||
|
color="inherit"
|
||||||
|
onClick={() => navigate('/profile')}
|
||||||
|
sx={{ textTransform: 'none', fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
{username}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<Tooltip title="Logout">
|
||||||
|
<IconButton color="inherit" onClick={onLogout}>
|
||||||
|
<LogoutIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component="nav"
|
||||||
|
sx={{ width: { md: currentWidth }, flexShrink: { md: 0 } }}
|
||||||
|
>
|
||||||
|
{isMobile ? (
|
||||||
|
<Drawer
|
||||||
|
variant="temporary"
|
||||||
|
open={mobileOpen}
|
||||||
|
onClose={handleDrawerToggle}
|
||||||
|
ModalProps={{ keepMounted: true }}
|
||||||
|
sx={{
|
||||||
|
display: { xs: 'block', md: 'none' },
|
||||||
|
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{drawerContent}
|
||||||
|
</Drawer>
|
||||||
|
) : (
|
||||||
|
<Drawer
|
||||||
|
variant="permanent"
|
||||||
|
sx={{
|
||||||
|
display: { xs: 'none', md: 'block' },
|
||||||
|
width: currentWidth,
|
||||||
|
flexShrink: 0,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
transition: (theme) => theme.transitions.create('width', {
|
||||||
|
easing: theme.transitions.easing.sharp,
|
||||||
|
duration: theme.transitions.duration.enteringScreen,
|
||||||
|
}),
|
||||||
|
[`& .MuiDrawer-paper`]: {
|
||||||
|
width: currentWidth,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
transition: (theme) => theme.transitions.create('width', {
|
||||||
|
easing: theme.transitions.easing.sharp,
|
||||||
|
duration: theme.transitions.duration.enteringScreen,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
open
|
||||||
|
>
|
||||||
|
{drawerContent}
|
||||||
|
</Drawer>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component="main"
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
p: { xs: 2, md: 3 },
|
||||||
|
width: { xs: '100%', md: `calc(100% - ${currentWidth}px)` },
|
||||||
|
transition: (theme) => theme.transitions.create(['margin', 'width'], {
|
||||||
|
easing: theme.transitions.easing.sharp,
|
||||||
|
duration: theme.transitions.duration.enteringScreen,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar />
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
369
src_generic/components/EnhancedTable.tsx
Normal file
369
src_generic/components/EnhancedTable.tsx
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { alpha } from '@mui/material/styles';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardActions,
|
||||||
|
Grid,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
useMediaQuery,
|
||||||
|
useTheme,
|
||||||
|
Divider,
|
||||||
|
Chip,
|
||||||
|
Stack,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
DataGrid,
|
||||||
|
GridColDef,
|
||||||
|
GridActionsCellItem,
|
||||||
|
GridRenderCellParams,
|
||||||
|
GridPaginationModel,
|
||||||
|
} from '@mui/x-data-grid';
|
||||||
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||||
|
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { ResourceConfig } from '../types/config';
|
||||||
|
|
||||||
|
interface EnhancedTableProps {
|
||||||
|
config: ResourceConfig;
|
||||||
|
data: any[];
|
||||||
|
total?: number;
|
||||||
|
paginationModel?: GridPaginationModel;
|
||||||
|
onPaginationModelChange?: (model: GridPaginationModel) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
onEdit: (item: any) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
onNavigateToResource?: (resourceName: string, id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EnhancedTable({
|
||||||
|
config,
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
paginationModel,
|
||||||
|
onPaginationModelChange,
|
||||||
|
loading = false,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onCreate,
|
||||||
|
onNavigateToResource,
|
||||||
|
}: EnhancedTableProps) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const columns: GridColDef[] = React.useMemo(() => {
|
||||||
|
const cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => {
|
||||||
|
let muiType: 'string' | 'number' | 'boolean' | 'date' | 'dateTime' | 'singleSelect' = 'string';
|
||||||
|
if (field.type === 'number') muiType = 'number';
|
||||||
|
if (field.type === 'boolean') muiType = 'boolean';
|
||||||
|
if (field.type === 'date') muiType = 'date';
|
||||||
|
if (field.type === 'datetime') muiType = 'dateTime';
|
||||||
|
if (field.type === 'enum') muiType = 'singleSelect';
|
||||||
|
|
||||||
|
const col: GridColDef = {
|
||||||
|
field: key,
|
||||||
|
headerName: field.label,
|
||||||
|
type: muiType,
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 150,
|
||||||
|
renderCell: (params: GridRenderCellParams) => <FieldRenderer params={params} field={field} fieldKey={key} config={config} onNavigate={onNavigateToResource} navigate={navigate} />
|
||||||
|
};
|
||||||
|
|
||||||
|
if (muiType === 'date' || muiType === 'dateTime') {
|
||||||
|
col.valueGetter = (value: any) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = new Date(value);
|
||||||
|
return isNaN(date.getTime()) ? null : date;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (muiType === 'singleSelect' && field.options) {
|
||||||
|
// @ts-ignore
|
||||||
|
col.valueOptions = field.options;
|
||||||
|
}
|
||||||
|
|
||||||
|
return col;
|
||||||
|
});
|
||||||
|
|
||||||
|
cols.push({
|
||||||
|
field: 'actions',
|
||||||
|
type: 'actions',
|
||||||
|
headerName: 'Actions',
|
||||||
|
width: 120,
|
||||||
|
getActions: (params) => [
|
||||||
|
<GridActionsCellItem
|
||||||
|
icon={<VisibilityIcon />}
|
||||||
|
label="View"
|
||||||
|
onClick={() => navigate(`/${config.name}/${params.id}`)}
|
||||||
|
/>,
|
||||||
|
<GridActionsCellItem
|
||||||
|
icon={<EditIcon />}
|
||||||
|
label="Edit"
|
||||||
|
onClick={() => navigate(`/${config.name}/edit/${params.id}`)}
|
||||||
|
/>,
|
||||||
|
<GridActionsCellItem
|
||||||
|
icon={<DeleteIcon />}
|
||||||
|
label="Delete"
|
||||||
|
onClick={() => onDelete(params.id as string)}
|
||||||
|
/>,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return cols;
|
||||||
|
}, [config, onDelete, navigate, onNavigateToResource]);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2, alignItems: 'center' }}>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>{config.pluralLabel}</Typography>
|
||||||
|
<Button variant="contained" color="primary" onClick={onCreate} size="small">
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{data.map((row) => (
|
||||||
|
<Box key={row[config.primaryKey] || Math.random()}>
|
||||||
|
<MobileCardRow
|
||||||
|
row={row}
|
||||||
|
config={config}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onNavigate={onNavigateToResource}
|
||||||
|
navigate={navigate}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ width: '100%' }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3, alignItems: 'center' }}>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 'bold' }}>{config.pluralLabel}</Typography>
|
||||||
|
<Button variant="contained" color="primary" onClick={onCreate}>
|
||||||
|
Add {config.label}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<DataGrid
|
||||||
|
rows={data || []}
|
||||||
|
columns={columns}
|
||||||
|
autoHeight
|
||||||
|
paginationMode={config.pagination ? 'server' : 'client'}
|
||||||
|
rowCount={(() => {
|
||||||
|
if (!config.pagination) return data.length;
|
||||||
|
if (total !== undefined) return total;
|
||||||
|
|
||||||
|
// Graceful fallback for missing total count
|
||||||
|
const page = paginationModel?.page || 0;
|
||||||
|
const pageSize = paginationModel?.pageSize || 10;
|
||||||
|
if (data.length < pageSize) {
|
||||||
|
return page * pageSize + data.length;
|
||||||
|
}
|
||||||
|
// Enable 'Next' button by pretending there's at least one more page
|
||||||
|
return (page + 2) * pageSize;
|
||||||
|
})()}
|
||||||
|
loading={loading}
|
||||||
|
paginationModel={paginationModel || { page: 0, pageSize: 10 }}
|
||||||
|
onPaginationModelChange={onPaginationModelChange}
|
||||||
|
getRowId={(row) => {
|
||||||
|
const pk = config.primaryKey;
|
||||||
|
if (row[pk] !== undefined && row[pk] !== null) return row[pk];
|
||||||
|
const fallbackKeys = ['id', '_id', 'uuid', 'pk'];
|
||||||
|
for (const key of fallbackKeys) {
|
||||||
|
if (row[key] !== undefined && row[key] !== null) return row[key];
|
||||||
|
}
|
||||||
|
return `temp-id-${data.indexOf(row)}`;
|
||||||
|
}}
|
||||||
|
disableRowSelectionOnClick
|
||||||
|
pageSizeOptions={[10, 25, 50]}
|
||||||
|
sx={{
|
||||||
|
border: 'none',
|
||||||
|
'& .MuiDataGrid-cell:focus': { outline: 'none' },
|
||||||
|
'& .MuiDataGrid-columnHeader:focus': { outline: 'none' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) {
|
||||||
|
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||||
|
const open = Boolean(anchorEl);
|
||||||
|
const id = row[config.primaryKey];
|
||||||
|
|
||||||
|
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||||
|
setAnchorEl(event.currentTarget);
|
||||||
|
};
|
||||||
|
const handleClose = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card variant="outlined" sx={{ borderRadius: 2 }}>
|
||||||
|
<CardContent sx={{ pb: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
|
||||||
|
#{id}
|
||||||
|
</Typography>
|
||||||
|
<IconButton size="small" onClick={handleClick}>
|
||||||
|
<MoreVertIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
<Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
|
||||||
|
<MenuItem onClick={() => { handleClose(); navigate(`/${config.name}/${id}`); }}>View</MenuItem>
|
||||||
|
<MenuItem onClick={() => { handleClose(); navigate(`/${config.name}/edit/${id}`); }}>Edit</MenuItem>
|
||||||
|
<MenuItem onClick={() => { handleClose(); onDelete(id); }} sx={{ color: 'error.main' }}>Delete</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</Box>
|
||||||
|
<Divider sx={{ mb: 2 }} />
|
||||||
|
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 2 }}>
|
||||||
|
{Object.entries(config.fields).slice(0, 5).map(([key, field]: [string, any]) => (
|
||||||
|
<Box key={key}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||||
|
{field.label}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 500, wordBreak: 'break-all' }}>
|
||||||
|
<FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile />
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
<CardActions sx={{ justifyContent: 'flex-end', px: 2, pb: 2 }}>
|
||||||
|
<Button size="small" onClick={() => navigate(`/${config.name}/${id}`)}>View Details</Button>
|
||||||
|
</CardActions>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFormattedDisplayValue(item: any, displayField?: string | string[]) {
|
||||||
|
if (!item) return "";
|
||||||
|
if (!displayField) return item.name || item.title || item.label || item.id || JSON.stringify(item);
|
||||||
|
|
||||||
|
if (Array.isArray(displayField)) {
|
||||||
|
return displayField
|
||||||
|
.map(key => item[key])
|
||||||
|
.filter(val => val !== undefined && val !== null)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return item[displayField] || item.id || JSON.stringify(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate, isMobile }: any) {
|
||||||
|
const value = params.value;
|
||||||
|
const isPk = fieldKey === config.primaryKey;
|
||||||
|
|
||||||
|
if (field.formatter) return field.formatter(value);
|
||||||
|
|
||||||
|
// 1. Single Relation
|
||||||
|
if (field.relation && value && !Array.isArray(value)) {
|
||||||
|
const relationId = typeof value === 'object' ? (value.id || value._id || value.pk) : value;
|
||||||
|
const displayValue = getFormattedDisplayValue(value, field.displayField);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
label={displayValue}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (relationId) onNavigate?.(field.relation!, String(relationId));
|
||||||
|
}}
|
||||||
|
sx={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Multi-Select (Array of relations or simple strings)
|
||||||
|
if (field.type === 'array' && Array.isArray(value)) {
|
||||||
|
const tooltipTitle = value.map((item) => getFormattedDisplayValue(item, field.displayField)).join(', ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={tooltipTitle} arrow placement="top">
|
||||||
|
<Stack direction="row" spacing={0.5} sx={{ overflow: 'hidden', flexWrap: 'nowrap' }}>
|
||||||
|
{value.map((item, idx) => (
|
||||||
|
<Chip
|
||||||
|
key={idx}
|
||||||
|
label={getFormattedDisplayValue(item, field.displayField)}
|
||||||
|
size="small"
|
||||||
|
variant="filled"
|
||||||
|
sx={{ maxWidth: 120 }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (field.relation) {
|
||||||
|
const id = typeof item === 'object' ? (item.id || item._id) : item;
|
||||||
|
if (id) onNavigate?.(field.relation!, String(id));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Simple Objects
|
||||||
|
if (field.type === 'object' && value) {
|
||||||
|
return getFormattedDisplayValue(value, field.displayField) || (isMobile ? 'Object' : JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'number' && typeof value === 'number') {
|
||||||
|
const isNegative = value < 0;
|
||||||
|
const color = isNegative ? 'error' : 'success';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
label={value.toLocaleString()}
|
||||||
|
size="small"
|
||||||
|
color={color}
|
||||||
|
variant="filled"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 'bold',
|
||||||
|
minWidth: 60,
|
||||||
|
// Soft background with bold text for a premium feel
|
||||||
|
bgcolor: (theme) => alpha(theme.palette[color].main, 0.15),
|
||||||
|
color: (theme) => theme.palette[color].dark,
|
||||||
|
'& .MuiChip-label': { px: 1.5 }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'boolean') {
|
||||||
|
return value ? (
|
||||||
|
<Chip label="Yes" size="small" color="success" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
|
||||||
|
) : (
|
||||||
|
<Chip label="No" size="small" color="default" variant="outlined" sx={{ height: 20, fontSize: '0.7rem' }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'datetime' || field.type === 'date') return value ? new Date(value).toLocaleString() : '';
|
||||||
|
|
||||||
|
if (isPk && !isMobile) {
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
label={value}
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
onClick={(e) => { e.stopPropagation(); navigate(`/${config.name}/${params.row[config.primaryKey]}`); }}
|
||||||
|
sx={{ cursor: 'pointer', fontWeight: 'bold' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
138
src_generic/components/GenericForm.tsx
Normal file
138
src_generic/components/GenericForm.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Divider,
|
||||||
|
CircularProgress,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { ResourceConfig } from '../types/config';
|
||||||
|
import { useUpload } from '../providers/UploadProvider';
|
||||||
|
import { useQueries } from '@tanstack/react-query';
|
||||||
|
import { useResource } from '../hooks/useResource';
|
||||||
|
import FormField from './fields/FormField';
|
||||||
|
import { ConfigContext } from '../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>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src_generic/components/ProfileView.tsx
Normal file
83
src_generic/components/ProfileView.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Box, Typography, Paper, CircularProgress, Alert } from '@mui/material';
|
||||||
|
import { useResource } from '../hooks/useResource';
|
||||||
|
import GenericForm from './GenericForm';
|
||||||
|
import { ConfigContext } from '../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>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
src_generic/components/ResourceView.tsx
Normal file
110
src_generic/components/ResourceView.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Box, Typography, Paper, CircularProgress } from '@mui/material';
|
||||||
|
import { ResourceConfig } from '../types/config';
|
||||||
|
import { useResource } from '../hooks/useResource';
|
||||||
|
import GenericForm from './GenericForm';
|
||||||
|
import EnhancedTable from './EnhancedTable';
|
||||||
|
import { useParams, useLocation, useNavigate, Routes, Route } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface ResourceViewProps {
|
||||||
|
config: ResourceConfig;
|
||||||
|
onNavigateToResource?: (resourceName: string, id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
import { GridPaginationModel } from '@mui/x-data-grid';
|
||||||
|
|
||||||
|
export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) {
|
||||||
|
const { id } = useParams();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const isCreate = location.pathname.endsWith('/create');
|
||||||
|
const isEdit = location.pathname.includes('/edit/');
|
||||||
|
const isView = !!id && !isEdit;
|
||||||
|
const isList = !id && !isCreate;
|
||||||
|
|
||||||
|
const [paginationModel, setPaginationModel] = React.useState<GridPaginationModel>({
|
||||||
|
page: 0,
|
||||||
|
pageSize: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { useList, useRead, useCreate, useUpdate, useDelete } = useResource(config);
|
||||||
|
|
||||||
|
// Determine query parameters based on pagination config
|
||||||
|
const queryParams = React.useMemo(() => {
|
||||||
|
if (!config.pagination) return {};
|
||||||
|
return {
|
||||||
|
skip: paginationModel.page * paginationModel.pageSize,
|
||||||
|
limit: paginationModel.pageSize,
|
||||||
|
};
|
||||||
|
}, [config.pagination, paginationModel]);
|
||||||
|
|
||||||
|
const listQuery = useList(queryParams);
|
||||||
|
const itemQuery = useRead(id || "");
|
||||||
|
|
||||||
|
const paginatedData = listQuery.data || { data: [], total: undefined };
|
||||||
|
const createMutation = useCreate();
|
||||||
|
const updateMutation = useUpdate();
|
||||||
|
const deleteMutation = useDelete();
|
||||||
|
|
||||||
|
const handleEdit = (item: any) => {
|
||||||
|
navigate(`/${config.name}/edit/${item[config.primaryKey]}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
navigate(`/${config.name}/create`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (formData: any) => {
|
||||||
|
try {
|
||||||
|
if (isEdit) {
|
||||||
|
await updateMutation.mutateAsync({ id: id!, data: formData });
|
||||||
|
} else {
|
||||||
|
await createMutation.mutateAsync(formData);
|
||||||
|
}
|
||||||
|
navigate(`/${config.name}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Save failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (itemId: string) => {
|
||||||
|
if (window.confirm('Are you sure you want to delete this item?')) {
|
||||||
|
await deleteMutation.mutateAsync(itemId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isList && listQuery.isLoading) return <CircularProgress />;
|
||||||
|
if ((isEdit || isView) && itemQuery.isLoading) return <CircularProgress />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{isList ? (
|
||||||
|
<EnhancedTable
|
||||||
|
config={config}
|
||||||
|
data={paginatedData.data || []}
|
||||||
|
total={paginatedData.total}
|
||||||
|
paginationModel={paginationModel}
|
||||||
|
onPaginationModelChange={setPaginationModel}
|
||||||
|
loading={listQuery.isFetching}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onCreate={handleCreate}
|
||||||
|
onNavigateToResource={(res, id) => navigate(`/${res}/${id}`)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Paper sx={{ p: 4 }}>
|
||||||
|
<GenericForm
|
||||||
|
config={config}
|
||||||
|
initialData={isCreate ? null : itemQuery.data}
|
||||||
|
onSave={handleSave}
|
||||||
|
onCancel={() => navigate(`/${config.name}`)}
|
||||||
|
loading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
readOnly={isView}
|
||||||
|
onEditClick={() => navigate(`/${config.name}/edit/${id}`)}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
224
src_generic/components/fields/FormField.tsx
Normal file
224
src_generic/components/fields/FormField.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import {
|
||||||
|
TextField,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
FormControlLabel,
|
||||||
|
Checkbox,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Divider,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { ResourceField } from '../../types/config';
|
||||||
|
import ImageUploadField from './ImageUploadField';
|
||||||
|
|
||||||
|
interface FormFieldProps {
|
||||||
|
name: string;
|
||||||
|
field: ResourceField;
|
||||||
|
value: any;
|
||||||
|
onChange: (val: any) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
uploadFile: (file: File) => Promise<string | null>;
|
||||||
|
uploading: boolean;
|
||||||
|
baseUrl: string;
|
||||||
|
relationDataMap?: Record<string, any[]>; // Map of relation name to data array
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FormField({
|
||||||
|
name,
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled,
|
||||||
|
uploadFile,
|
||||||
|
uploading,
|
||||||
|
baseUrl,
|
||||||
|
relationDataMap = {},
|
||||||
|
}: FormFieldProps) {
|
||||||
|
const label = field.label;
|
||||||
|
|
||||||
|
// 1. Recursive Rendering for Objects (Not Relations)
|
||||||
|
if (field.type === 'object' && field.schema && !field.relation) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ ml: 2, mt: 2, p: 2, borderLeft: '2px solid #e0e0e0' }}>
|
||||||
|
<Typography variant="subtitle2" color="primary" gutterBottom>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{Object.entries(field.schema).map(([subKey, subField]) => (
|
||||||
|
<FormField
|
||||||
|
key={subKey}
|
||||||
|
name={`${name}.${subKey}`}
|
||||||
|
field={subField}
|
||||||
|
value={value?.[subKey]}
|
||||||
|
onChange={(newVal) => {
|
||||||
|
const updated = { ...(value || {}), [subKey]: newVal };
|
||||||
|
onChange(updated);
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
uploadFile={uploadFile}
|
||||||
|
uploading={uploading}
|
||||||
|
baseUrl={baseUrl}
|
||||||
|
relationDataMap={relationDataMap}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Relation Handling (Select / Multi-Select)
|
||||||
|
if (field.relation && relationDataMap[field.relation]) {
|
||||||
|
const relationData = relationDataMap[field.relation];
|
||||||
|
const isArrayRelation = field.type === 'array';
|
||||||
|
|
||||||
|
// Determine how to display the related item
|
||||||
|
const getOptionLabel = (option: any) => {
|
||||||
|
if (!option) return "";
|
||||||
|
if (field.displayField && option[field.displayField]) return option[field.displayField];
|
||||||
|
// Standard naming fields
|
||||||
|
return option.name || option.title || option.label || option.id || JSON.stringify(option);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOptionValue = (option: any) => {
|
||||||
|
// Return the whole object to maintain identity
|
||||||
|
return option;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel shrink>{label}</InputLabel>
|
||||||
|
<Select
|
||||||
|
multiple={isArrayRelation}
|
||||||
|
value={value || (isArrayRelation ? [] : "")}
|
||||||
|
label={label}
|
||||||
|
displayEmpty
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
renderValue={(selected: any) => {
|
||||||
|
if (isArrayRelation) {
|
||||||
|
return (selected as any[]).map(getOptionLabel).join(', ');
|
||||||
|
}
|
||||||
|
return getOptionLabel(selected);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{relationData.map((option) => (
|
||||||
|
<MenuItem key={option.id || JSON.stringify(option)} value={getOptionValue(option)}>
|
||||||
|
{getOptionLabel(option)}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Image Handling
|
||||||
|
if (field.type === 'image') {
|
||||||
|
return (
|
||||||
|
<ImageUploadField
|
||||||
|
label={label}
|
||||||
|
value={value}
|
||||||
|
onUpload={async (file: any) => {
|
||||||
|
const url = await uploadFile(file);
|
||||||
|
if (url) onChange(url);
|
||||||
|
}}
|
||||||
|
uploading={uploading}
|
||||||
|
baseUrl={baseUrl}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Boolean Handling
|
||||||
|
if (field.type === 'boolean') {
|
||||||
|
return (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
checked={!!value}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Enum Handling
|
||||||
|
if (field.type === 'enum' && field.options) {
|
||||||
|
return (
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>{label}</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={value || ''}
|
||||||
|
label={label}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{field.options.map((opt: string) => (
|
||||||
|
<MenuItem key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Common Text Fields
|
||||||
|
if (field.type === 'datetime' || field.type === 'date') {
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={label}
|
||||||
|
type={field.type === 'datetime' ? "datetime-local" : "date"}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
value={value ? new Date(value).toISOString().slice(0, field.type === 'datetime' ? 16 : 10) : ''}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
required={field.required}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'markdown' || field.type === 'string') {
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={label}
|
||||||
|
value={value || ''}
|
||||||
|
multiline={field.type === 'markdown'}
|
||||||
|
rows={field.type === 'markdown' ? 4 : 1}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
required={field.required}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'number') {
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={label}
|
||||||
|
type="number"
|
||||||
|
value={value === undefined || value === null ? '' : value}
|
||||||
|
onChange={(e) => onChange(e.target.value === '' ? '' : Number(e.target.value))}
|
||||||
|
disabled={disabled}
|
||||||
|
required={field.required}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={label}
|
||||||
|
value={typeof value === 'object' ? JSON.stringify(value) : value || ''}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src_generic/components/fields/ImageUploadField.tsx
Normal file
60
src_generic/components/fields/ImageUploadField.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Box, Button, Avatar, CircularProgress, Typography } from "@mui/material";
|
||||||
|
|
||||||
|
interface ImageUploadFieldProps {
|
||||||
|
label?: string;
|
||||||
|
value: string;
|
||||||
|
uploading?: boolean;
|
||||||
|
onUpload: (file: File) => void;
|
||||||
|
size?: number;
|
||||||
|
baseUrl: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImageUploadField({
|
||||||
|
label = "Upload Image",
|
||||||
|
value,
|
||||||
|
uploading = false,
|
||||||
|
onUpload,
|
||||||
|
size = 64,
|
||||||
|
baseUrl,
|
||||||
|
disabled = false,
|
||||||
|
}: ImageUploadFieldProps) {
|
||||||
|
|
||||||
|
const imgSrc = value
|
||||||
|
? baseUrl.replace(/\/+$/, "") +
|
||||||
|
"/" +
|
||||||
|
value.replace(/^\/+/, "")
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1, mb: 3 }}>
|
||||||
|
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
|
||||||
|
<Avatar
|
||||||
|
src={imgSrc}
|
||||||
|
sx={{ width: size, height: size, borderRadius: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!disabled && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
component="label"
|
||||||
|
disabled={uploading}
|
||||||
|
startIcon={uploading && <CircularProgress size={16} />}
|
||||||
|
>
|
||||||
|
{uploading ? "Uploading..." : "Choose File"}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) onUpload(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
50
src_generic/configuration.ts
Normal file
50
src_generic/configuration.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { ResourceOverride } from "./types/overrides";
|
||||||
|
|
||||||
|
export const configuration: Record<string, ResourceOverride> = {
|
||||||
|
expenses: {
|
||||||
|
fields: {
|
||||||
|
payee: {
|
||||||
|
displayField: "name",
|
||||||
|
},
|
||||||
|
payor: {
|
||||||
|
display: false,
|
||||||
|
displayField: "username",
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
displayField: "name",
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
displayField: ["name", "icon"],
|
||||||
|
},
|
||||||
|
occurred_at: {
|
||||||
|
formatter: (val: string) => {
|
||||||
|
const date = new Date(val);
|
||||||
|
const day = date.getDate();
|
||||||
|
const month = date.toLocaleString('default', { month: 'long' });
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const suffix = (day: number) => {
|
||||||
|
if (day > 3 && day < 21) return 'th';
|
||||||
|
switch (day % 10) {
|
||||||
|
case 1: return "st";
|
||||||
|
case 2: return "nd";
|
||||||
|
case 3: return "rd";
|
||||||
|
default: return "th";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return `${day}${suffix(day)} ${month} ${year}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created_at: {
|
||||||
|
display: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pagination: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const profileConfiguration = {
|
||||||
|
"extraFields": ['name'],
|
||||||
|
"resource": "payors",
|
||||||
|
// not in use
|
||||||
|
"hidden": true,
|
||||||
|
};
|
||||||
127
src_generic/hooks/useResource.ts
Normal file
127
src_generic/hooks/useResource.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import { ResourceConfig } from "../types/config";
|
||||||
|
|
||||||
|
export function useResource<T = any>(config: ResourceConfig) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { name, endpoint, primaryKey } = config;
|
||||||
|
|
||||||
|
// --- READ ALL ---
|
||||||
|
const useList = (params?: any) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: [name, "list", params],
|
||||||
|
queryFn: async () => {
|
||||||
|
// @ts-ignore
|
||||||
|
const res = await api.get<T[]>(endpoint, { params });
|
||||||
|
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
|
||||||
|
return {
|
||||||
|
data: res.data,
|
||||||
|
total: isNaN(total as any) ? undefined : total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- READ ONE ---
|
||||||
|
const useRead = (id: string | null) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: [name, "detail", id],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!id) return null;
|
||||||
|
// @ts-ignore
|
||||||
|
const res = await api.get<T>(`${endpoint}/${id}`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- CREATE ---
|
||||||
|
const useCreate = () =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: async (data: Partial<T>) => {
|
||||||
|
// @ts-ignore
|
||||||
|
const res = await api.post<T>(endpoint, data);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [name, "list"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- UPDATE ---
|
||||||
|
const useUpdate = () =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
|
||||||
|
// @ts-ignore
|
||||||
|
const res = await api.put<T>(`${endpoint}/${id}`, data);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: (updatedItem) => {
|
||||||
|
// @ts-ignore
|
||||||
|
const id = updatedItem[primaryKey];
|
||||||
|
queryClient.invalidateQueries({ queryKey: [name, "list"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: [name, "detail", id] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- DELETE ---
|
||||||
|
const useDelete = () =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
await api.delete(`${endpoint}/${id}`);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [name, "list"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- HELPERS FOR useQueries ---
|
||||||
|
const getListQueryOptions = (params?: any) => ({
|
||||||
|
queryKey: [name, "list", params],
|
||||||
|
queryFn: async () => {
|
||||||
|
// @ts-ignore
|
||||||
|
const res = await api.get<T[]>(endpoint, { params });
|
||||||
|
const total = res.headers ? parseInt(res.headers['x-total-count'] || res.headers['X-Total-Count']) : undefined;
|
||||||
|
return {
|
||||||
|
data: res.data,
|
||||||
|
total: isNaN(total as any) ? undefined : total
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- READ ME ---
|
||||||
|
const useMe = () =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: [name, "me"],
|
||||||
|
queryFn: async () => {
|
||||||
|
// @ts-ignore
|
||||||
|
const res = await api.get<T>(`${endpoint}/me`);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- UPDATE ME ---
|
||||||
|
const useUpdateMe = () =>
|
||||||
|
useMutation({
|
||||||
|
mutationFn: async (data: Partial<T>) => {
|
||||||
|
// @ts-ignore
|
||||||
|
const res = await api.put<T>(`${endpoint}/me`, data);
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [name, "me"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: [name, "list"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
useList,
|
||||||
|
useRead,
|
||||||
|
useMe,
|
||||||
|
useCreate,
|
||||||
|
useUpdate,
|
||||||
|
useUpdateMe,
|
||||||
|
useDelete,
|
||||||
|
getListQueryOptions,
|
||||||
|
};
|
||||||
|
}
|
||||||
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;
|
||||||
|
};
|
||||||
43
src_generic/types/config.ts
Normal file
43
src_generic/types/config.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export type FieldType =
|
||||||
|
| 'string'
|
||||||
|
| 'number'
|
||||||
|
| 'boolean'
|
||||||
|
| 'date'
|
||||||
|
| 'datetime'
|
||||||
|
| 'markdown'
|
||||||
|
| 'enum'
|
||||||
|
| 'image'
|
||||||
|
| 'object'
|
||||||
|
| 'array';
|
||||||
|
|
||||||
|
export interface ResourceField {
|
||||||
|
type: FieldType;
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
options?: string[];
|
||||||
|
readOnly?: boolean;
|
||||||
|
schema?: Record<string, ResourceField>;
|
||||||
|
displayField?: string | string[];
|
||||||
|
formatter?: (value: any) => string;
|
||||||
|
relation?: string; // Name of the target resource
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceConfig {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
pluralLabel: string;
|
||||||
|
endpoint: string;
|
||||||
|
primaryKey: string;
|
||||||
|
fields: Record<string, ResourceField>;
|
||||||
|
pagination?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
baseUrl: string;
|
||||||
|
authBaseUrl: string;
|
||||||
|
resources: ResourceConfig[];
|
||||||
|
profile?: {
|
||||||
|
resource: string;
|
||||||
|
extraFields?: Record<string, any>;
|
||||||
|
};
|
||||||
|
}
|
||||||
15
src_generic/types/overrides.ts
Normal file
15
src_generic/types/overrides.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* This file contains application-specific overrides and configuration
|
||||||
|
* for the generic Admin Panel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FieldOverride {
|
||||||
|
displayField?: string | string[];
|
||||||
|
display?: boolean;
|
||||||
|
formatter?: (value: any) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceOverride {
|
||||||
|
fields?: Record<string, FieldOverride>;
|
||||||
|
pagination?: boolean;
|
||||||
|
}
|
||||||
178
src_generic/utils/openapi_loader.ts
Normal file
178
src_generic/utils/openapi_loader.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import SwaggerParser from "@apidevtools/swagger-parser";
|
||||||
|
import { AppConfig, ResourceConfig, ResourceField, FieldType } from "../types/config";
|
||||||
|
import { configuration, profileConfiguration } from "../configuration";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps OpenAPI property types to our internal FieldType
|
||||||
|
*/
|
||||||
|
function mapOpenApiType(prop: any): FieldType {
|
||||||
|
const type = prop.type;
|
||||||
|
const format = prop.format;
|
||||||
|
|
||||||
|
if (format === "date-time") return "datetime";
|
||||||
|
if (format === "date") return "date";
|
||||||
|
if (prop.enum) return "enum";
|
||||||
|
if (
|
||||||
|
type === "string" &&
|
||||||
|
(prop.description?.toLowerCase().includes("image") ||
|
||||||
|
prop.name?.toLowerCase().includes("icon"))
|
||||||
|
)
|
||||||
|
return "image";
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "integer":
|
||||||
|
case "number":
|
||||||
|
return "number";
|
||||||
|
case "boolean":
|
||||||
|
return "boolean";
|
||||||
|
case "object":
|
||||||
|
return "object";
|
||||||
|
case "array":
|
||||||
|
return "array";
|
||||||
|
default:
|
||||||
|
return "string";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively converts OpenAPI schemas to ResourceField map
|
||||||
|
*/
|
||||||
|
function parseSchemaFields(
|
||||||
|
schema: any,
|
||||||
|
resourceName: string,
|
||||||
|
schemaToResourceMap: Map<any, string>
|
||||||
|
): Record<string, ResourceField> {
|
||||||
|
const fields: Record<string, ResourceField> = {};
|
||||||
|
const properties = schema.properties || {};
|
||||||
|
const required = schema.required || [];
|
||||||
|
const overrides = configuration[resourceName]?.fields || {};
|
||||||
|
|
||||||
|
for (const [key, prop] of Object.entries(properties) as [string, any]) {
|
||||||
|
const type = mapOpenApiType(prop);
|
||||||
|
const override = overrides[key];
|
||||||
|
|
||||||
|
// Explicitly skip 'id' as it's the primary key and handled elsewhere
|
||||||
|
if (key === "id" || override?.display === false) continue;
|
||||||
|
|
||||||
|
fields[key] = {
|
||||||
|
type,
|
||||||
|
label:
|
||||||
|
prop.title ||
|
||||||
|
key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "),
|
||||||
|
required: required.includes(key),
|
||||||
|
options: prop.enum,
|
||||||
|
readOnly:
|
||||||
|
prop.readOnly ||
|
||||||
|
key === "created_at" ||
|
||||||
|
key === "updated_at",
|
||||||
|
...override,
|
||||||
|
};
|
||||||
|
|
||||||
|
// STRICT RELATION DETECTION
|
||||||
|
// A field is a relation ONLY if its schema object (or items schema)
|
||||||
|
// exactly matches a schema that is defined as a resource.
|
||||||
|
let targetSchema = prop;
|
||||||
|
if (type === "array" && prop.items) {
|
||||||
|
targetSchema = prop.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this schema object is registered as a resource
|
||||||
|
const relation = schemaToResourceMap.get(targetSchema);
|
||||||
|
if (relation) {
|
||||||
|
fields[key].relation = relation;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively parse nested objects (only if not a relation)
|
||||||
|
if (fields[key].type === "object" && prop.properties && !relation) {
|
||||||
|
fields[key].schema = parseSchemaFields(prop, resourceName, schemaToResourceMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans paths to identify resources and their basic configuration
|
||||||
|
*/
|
||||||
|
export async function loadConfigFromOpenApi(baseUrl: string): Promise<AppConfig> {
|
||||||
|
// Use SwaggerParser to dereference the spec.
|
||||||
|
// Dereferencing preserves object identity for $ref targets.
|
||||||
|
const api = await SwaggerParser.dereference(
|
||||||
|
new URL("/openapi.json", baseUrl).href
|
||||||
|
);
|
||||||
|
|
||||||
|
const resources: ResourceConfig[] = [];
|
||||||
|
const paths = api.paths || {};
|
||||||
|
|
||||||
|
// Group paths by base resource name
|
||||||
|
const resourcePaths: Record<string, any> = {};
|
||||||
|
for (const path of Object.keys(paths)) {
|
||||||
|
const base = path.split("/")[1];
|
||||||
|
if (!base) continue;
|
||||||
|
|
||||||
|
if (!resourcePaths[base]) resourcePaths[base] = { path, methods: [] };
|
||||||
|
const methods = Object.keys(paths[path] || {});
|
||||||
|
resourcePaths[base].methods.push(...methods);
|
||||||
|
|
||||||
|
// Identify the list endpoint for this resource
|
||||||
|
if (!resourcePaths[base].listPath && !path.includes("{") && paths[path]?.get?.responses?.["200"]) {
|
||||||
|
resourcePaths[base].listPath = path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Identify which schema objects correspond to which resources
|
||||||
|
const schemaToResourceMap = new Map<any, string>();
|
||||||
|
for (const [name, info] of Object.entries(resourcePaths)) {
|
||||||
|
const listPath = info.listPath || `/${name}`;
|
||||||
|
const listOp = paths[listPath]?.get;
|
||||||
|
if (!listOp) continue;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const responseSchema = listOp.responses?.["200"]?.content?.["application/json"]?.schema;
|
||||||
|
let schemaObj = responseSchema;
|
||||||
|
if (responseSchema?.type === "array" && responseSchema.items) {
|
||||||
|
schemaObj = responseSchema.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (schemaObj) {
|
||||||
|
schemaToResourceMap.set(schemaObj, name);
|
||||||
|
resourcePaths[name].schemaObj = schemaObj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Generate ResourceConfig for each identified resource
|
||||||
|
for (const [name, info] of Object.entries(resourcePaths)) {
|
||||||
|
const listPath = info.listPath || `/${name}`;
|
||||||
|
const listOp = paths[listPath]?.get;
|
||||||
|
if (!listOp || !info.schemaObj) continue;
|
||||||
|
|
||||||
|
const schema = info.schemaObj;
|
||||||
|
const label = name.charAt(0).toUpperCase() + name.slice(1, -1);
|
||||||
|
const pluralLabel = name.charAt(0).toUpperCase() + name.slice(1);
|
||||||
|
|
||||||
|
const fields = parseSchemaFields(schema, name, schemaToResourceMap);
|
||||||
|
|
||||||
|
const resourceOverride = configuration[name] || {};
|
||||||
|
|
||||||
|
resources.push({
|
||||||
|
name,
|
||||||
|
label: schema.title || label,
|
||||||
|
pluralLabel: pluralLabel,
|
||||||
|
endpoint: listPath,
|
||||||
|
primaryKey: "id", // Strict default, no heuristics
|
||||||
|
fields,
|
||||||
|
pagination: resourceOverride.pagination,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const serverBaseUrl = import.meta.env.VITE_API_BASE_URL || (api.servers?.[0]?.url ?? "")
|
||||||
|
// @ts-ignore
|
||||||
|
const authBaseUrl = import.meta.env.VITE_AUTH_BASE_URL || ""
|
||||||
|
return {
|
||||||
|
baseUrl: serverBaseUrl,
|
||||||
|
authBaseUrl: authBaseUrl,
|
||||||
|
resources,
|
||||||
|
profile: profileConfiguration,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user