21 Commits

Author SHA1 Message Date
a8581325fa fixes 2026-04-02 20:24:55 +05:30
6dc33be455 overrides for customisation 2026-04-01 19:24:09 +05:30
44567496a1 configuration for how fields look and EnhancedTable component for enhanced table display 2026-04-01 18:47:23 +05:30
344106f1a4 reading from openapi spec 2026-04-01 18:06:09 +05:30
3b472242a7 added ImageUpload and other input types. 2026-04-01 15:51:25 +05:30
14dcd19b17 generic src for react admin 2026-04-01 14:22:14 +05:30
4d06859cb0 bumped up to 0.3.2 for auth package changes
All checks were successful
continuous-integration/drone/tag Build is passing
2025-12-28 20:18:37 +05:30
226a6a651c Auth Package Extraction And Auth Flow Refactor (#2)
Reviewed-on: #2
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2025-12-28 14:47:37 +00:00
14b43cb3c5 hotfix for build args and bumped up version
All checks were successful
continuous-integration/drone/tag Build is passing
2025-12-13 19:31:39 +05:30
8f398c35df Auth / Author Flow Hardening and Client Separation (#1)
All checks were successful
continuous-integration/drone/tag Build is passing
# Merge Request: Auth / Author Flow Hardening and Client Separation

## Summary
This change set improves the authentication–author lifecycle by clearly separating **Auth** and **Blog API** clients, ensuring an **Author is created at registration time**, and preventing user-controlled mutation of immutable identity fields in the UI.

The result is a cleaner contract between services, fewer edge cases around missing authors, and more predictable client behavior.

---

## Key Changes

### 1. Username Made Read-Only in Profile UI
- Disabled the `username` field in `Profile.tsx`
- Prevents accidental or malicious mutation of identity-bound fields
- Aligns UI behavior with backend ownership rules

---

### 2. Dedicated Auth vs Blog API Clients
- Introduced a separate Axios client for the Auth service (`auth`)
- Blog service continues to use `api`
- Both clients:
  - Automatically attach JWT tokens
  - Share centralized `401` handling and token invalidation logic

**Why:**
Auth and Blog are separate concerns and potentially separate services. Explicit clients reduce coupling and eliminate ambiguous routing.

---

### 3. Registration Flow Now Creates Author Automatically
- `register()` now:
  1. Registers the user via Auth service
  2. Creates a corresponding Author via Blog API

This guarantees:
- Every authenticated user has an Author record
- No race condition or implicit author creation later

---

### 4. Correct Endpoint Usage for “Current User”
- `/auth/me` is now correctly called via the Auth client
- `/authors/me` replaces ID-based lookup for the current author
- Eliminates dependency on user ID leaking across service boundaries

---

### 5. Centralized Token & Auth Error Handling
- Shared request interceptor to attach JWT tokens
- Shared response interceptor to handle `401` consistently
- Token invalidation is now uniform across services

---

### 6. Environment Configuration Updated
- Added `VITE_AUTH_BASE_URL` to support separate Auth service routing
- Explicit environment contract avoids accidental misconfiguration

---

## Impact
- Cleaner service boundaries
- Deterministic user → author lifecycle
- Reduced client-side complexity and edge cases
- More secure handling of identity fields

---

## Notes / Follow-ups
- Optional auto-login after registration is scaffolded but commented
- Logout or redirect handling on `401` can be wired later via an event bus or global handler

---

**Risk Level:** Low
**Behavioral Change:** Yes (author auto-created on registration)
**Backward Compatibility:** Requires Auth + Blog services to be reachable separately

Reviewed-on: #1
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2025-12-13 13:15:20 +00:00
a7987ab922 feat(core): migrate articles to ArticlesModel, add URL-synced view routing, and unify component props
All checks were successful
continuous-integration/drone/tag Build is passing
Summary

Introduced ArticlesModel abstraction with list + map store for fast lookup and clean CRUD operations.

Replaced all array-based article access with model methods (readById, create, update, refresh).

Added utils/articles.ts with pure functions for list/map operations.

Updated ArticleProvider to use the new model API and avoid mutation issues.

Added URL-synced navigation (pushState) + VIEW_URL mapping for deep-linkable routes.

Implemented route parsing on load (/articles/:id) to auto-open article view.

Standardized openArticle to pass full ArticleModel instead of index.

Updated all ArticleCard components to use article object instead of index.

Added navigationMap to view router for custom child navigation names.

Extracted shared styles to types/styles.ts and replaced old inline styled components.

Updated props definitions for Login, Register, Profile, ArticleView, MainContent, ArticleGrid.

Removed React.StrictMode wrapper to avoid double-effects during development.

Release: 0.2.5
Type: Feature + Refactor + Routing upgrade
2025-11-20 17:00:26 +05:30
7bdf84b6aa URL handling both on navigation and when directly calling URL.
directly calling only works for Article ID
2025-11-20 16:58:32 +05:30
2b578fd12e full article instead of index for article and using article._id open select article using readByIndex 2025-11-20 16:33:24 +05:30
fe33dca630 cleanup 2025-11-20 16:08:33 +05:30
fa319e7450 move from ArticleModel[] to ArticlesModel 2025-11-20 15:56:35 +05:30
cb6125f3f9 articles to articlesList and slice and length functions for ArticlesModel to act like an array 2025-11-20 15:56:13 +05:30
0ed816e994 ArticlesModel as single point for storing articles and operations on them 2025-11-20 15:38:10 +05:30
2dfbdb950a utils for articles 2025-11-20 00:09:23 +05:30
fcc3ec16f9 correct name article instead of user for ArticleModel 2025-11-19 23:45:25 +05:30
cff57f0980 option to customize navigation names as per the component props 2025-11-19 23:40:36 +05:30
e90fab8c0b cleanup 2025-11-19 23:16:41 +05:30
49 changed files with 1983 additions and 438 deletions

View File

@@ -66,6 +66,8 @@ steps:
environment:
API_BASE_URL:
from_secret: API_BASE_URL
AUTH_BASE_URL:
from_secret: AUTH_BASE_URL
volumes:
- name: dockersock
path: /var/run/docker.sock
@@ -76,6 +78,7 @@ steps:
- |
docker build --network=host \
--build-arg VITE_API_BASE_URL="$API_BASE_URL" \
--build-arg VITE_AUTH_BASE_URL="$AUTH_BASE_URL" \
-t apps/blog:$IMAGE_TAG \
-t apps/blog:latest \
/drone/src

View File

@@ -15,7 +15,8 @@ COPY . .
# Build the app
ARG VITE_API_BASE_URL
RUN VITE_API_BASE_URL=$VITE_API_BASE_URL npm run build
ARG VITE_AUTH_BASE_URL
RUN VITE_API_BASE_URL=$VITE_API_BASE_URL VITE_AUTH_BASE_URL=$VITE_AUTH_BASE_URL npm run build
# Stage 2: Static file server (BusyBox)
FROM busybox:latest

11
auth/package.json Normal file
View File

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

View File

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

32
auth/src/authClient.ts Normal file
View File

@@ -0,0 +1,32 @@
import { createApiClient } from "./axios";
import { tokenStore } from "./token";
// @ts-ignore
const authApi = createApiClient(import.meta.env.VITE_AUTH_BASE_URL);
export const authClient = {
async login(username: string, password: string) {
const res = await authApi.post("/login", { username, password });
const { access_token } = res.data;
if (!access_token) {
throw new Error("No access token returned");
}
tokenStore.set(access_token);
return this.getIdentity();
},
logout() {
tokenStore.clear();
},
async getIdentity() {
const res = await authApi.get("/me");
return res.data;
},
isAuthenticated() {
return !!tokenStore.get();
},
};

35
auth/src/axios.ts Normal file
View File

@@ -0,0 +1,35 @@
import axios, { AxiosInstance } from "axios";
import { tokenStore } from "./token";
export function attachAuthInterceptors(client: AxiosInstance) {
client.interceptors.request.use((config) => {
const token = tokenStore.get();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
client.interceptors.response.use(
(res) => res,
(error) => {
if (error.response?.status === 401) {
tokenStore.clear();
}
return Promise.reject(error);
}
);
}
/**
* Factory for app APIs that need auth
*/
export function createApiClient(baseURL: string): AxiosInstance {
const client = axios.create({
baseURL,
headers: { "Content-Type": "application/json" },
});
attachAuthInterceptors(client);
return client;
}

97
auth/src/contexts.tsx Normal file
View File

@@ -0,0 +1,97 @@
import React, { createContext, useContext, useEffect, useState } from "react";
import { tokenStore } from "./token";
import { createApiClient } from "./axios";
import { AuthUser } from "./models";
interface AuthContextModel {
currentUser: AuthUser | null;
token: string | null;
loading: boolean;
error: string | null;
login(username: string, password: string): Promise<void>;
register(username: string, password: string): Promise<void>;
logout(): void;
}
const AuthContext = createContext<AuthContextModel | undefined>(undefined);
export function AuthProvider({
children,
authBaseUrl,
}: {
children: React.ReactNode;
authBaseUrl: string;
}) {
const [currentUser, setCurrentUser] = useState<AuthUser | null>(null);
const [token, setToken] = useState<string | null>(tokenStore.get());
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const auth = createApiClient(authBaseUrl);
const login = async (username: string, password: string) => {
try {
setLoading(true);
setError(null);
const res = await auth.post("/login", { username, password });
const { access_token, user } = res.data;
tokenStore.set(access_token);
setToken(access_token);
setCurrentUser(user);
} catch (e: any) {
setError(e.response?.data?.detail ?? "Login failed");
} finally {
setLoading(false);
}
};
const register = async (username: string, password: string) => {
try {
setLoading(true);
setError(null);
await auth.post("/register", { username, password });
await login(username, password);
} catch (e: any) {
setError(e.response?.data?.detail ?? "Registration failed");
} finally {
setLoading(false);
}
};
const logout = () => {
tokenStore.clear();
setToken(null);
setCurrentUser(null);
};
const fetchCurrentUser = async () => {
if (!token) return;
try {
const me = await auth.get("/me");
setCurrentUser({ ...me.data });
} catch {
logout();
}
};
useEffect(() => {
fetchCurrentUser();
}, [token]);
return (
<AuthContext.Provider
value={{ currentUser, token, loading, error, login, logout, register }}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextModel {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used inside AuthProvider");
return ctx;
}

6
auth/src/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export { AuthProvider, useAuth } from "./contexts";
export { createApiClient } from "./axios";
export { AuthPage } from "./AuthPage";
export type { AuthUser } from "./models";
export type { AuthMode } from "./AuthPage";
export { tokenStore } from "./token"

11
auth/src/models.ts Normal file
View File

@@ -0,0 +1,11 @@
export interface AuthUser {
// meta fields
_id?: string | null;
created_at: string;
updated_at: string;
// model fields
username: string;
email: string;
is_active: boolean;
}

0
auth/src/props.ts Normal file
View File

15
auth/src/token.ts Normal file
View File

@@ -0,0 +1,15 @@
const TOKEN_KEY = "token";
export const tokenStore = {
get(): string | null {
return localStorage.getItem(TOKEN_KEY);
},
set(token: string) {
localStorage.setItem(TOKEN_KEY, token);
},
clear() {
localStorage.removeItem(TOKEN_KEY);
},
};

29
package-lock.json generated
View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import * as React from 'react';
import { useEffect } from 'react';
import CssBaseline from '@mui/material/CssBaseline';
import Container from '@mui/material/Container';
import Box from '@mui/material/Box';
@@ -9,27 +10,36 @@ import ArticleView from './components/Article/ArticleView';
import ArticleEditor from './components/Article/ArticleEditor';
import Latest from './components/Latest';
import Footer from './components/Footer';
import Login from './components/Login';
import Register from './components/Register';
import Profile from './components/Profile';
import { useArticles } from './providers/Article';
import { useAuth } from './providers/Author';
import { useAuth as useAuthor } from './providers/Author';
import { View, useViewRouter } from "./types/views";
import { ArticleModel } from "./types/models";
import { ArticleModel, ArticlesModel } from "./types/models";
import { ArticleViewProps, ArticleEditorProps } from "./types/props";
function HomeView({ currentUser, open_login, open_profile, open_create, articles, openArticle }: any) {
import { useAuth, AuthPage, AuthMode } from '../../auth/src';
function HomeView({
currentUser,
open_auth,
open_profile,
open_create,
articles,
openArticle,
}: any) {
return (
<>
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 2, gap: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2, gap: 1 }}>
{!currentUser ? (
<Button variant="outlined" onClick={open_login}>Login</Button>
<Button variant='outlined' onClick={() => open_auth('login')}>
Login
</Button>
) : (
<>
<Button variant="outlined" onClick={open_profile}>
<Button variant='outlined' onClick={open_profile}>
{currentUser.username}
</Button>
<Button variant="contained" onClick={open_create}>
<Button variant='contained' onClick={open_create}>
New Article
</Button>
</>
@@ -44,13 +54,35 @@ function HomeView({ currentUser, open_login, open_profile, open_create, articles
export default function Blog(props: { disableCustomTheme?: boolean }) {
const { articles, loading, error } = useArticles();
const { currentUser } = useAuth();
const auth = useAuth();
const { currentUser } = useAuthor();
const [ui, setUI] = React.useState({
selectedArticle: null as number | null,
view: "home" as View,
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,
@@ -58,28 +90,39 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
} = useViewRouter(setUI);
type RouterContext = {
ui: any;
articles: ArticleModel[];
ui: typeof ui;
articles: ArticlesModel;
currentUser: any;
openArticle: (index: number) => void;
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,
},
login: {
component: Login,
},
register: {
component: Register,
auth: {
component: AuthPage,
extraProps: ({ ui }) => ({
mode: ui.authMode,
onSwitchMode: () =>
setUI((prev) => ({
...prev,
authMode: prev.authMode === 'login' ? 'register' : 'login',
})),
login: auth.login,
register: auth.register,
loading: auth.loading,
error: auth.error,
currentUser: currentUser,
}),
},
profile: {
@@ -88,15 +131,19 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
article: {
component: ArticleView,
navigationMap: {
open_editor: 'onEdit',
},
extraProps: ({ ui, articles }) => ({
article: articles[ui.selectedArticle!],
// @ts-ignore
article: articles.readById(ui.selectedArticle._id),
}) satisfies Partial<ArticleViewProps>,
},
editor: {
component: ArticleEditor,
extraProps: ({ ui, articles }) => ({
article: ui.selectedArticle !== null ? articles[ui.selectedArticle] : null,
article: ui.selectedArticle !== null ? articles.readById(ui.selectedArticle._id as string) : null,
}) satisfies Partial<ArticleEditorProps>,
},
@@ -110,9 +157,18 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
const renderView = () => {
const entry = VIEW_COMPONENTS[ui.view];
const navigationMap= entry['navigationMap'] || {}
const ViewComponent = entry.component;
const childNav = navigateToChildren(ui.view);
const childNav = {
...navigateToChildren(ui.view, navigationMap),
open_auth: (mode: AuthMode = 'login') =>
setUI((prev) => ({
...prev,
view: 'auth',
authMode: mode,
})),
};
const ctx: RouterContext = {
ui,
@@ -196,7 +252,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
{ui.view === 'home' && (
<Box
component="footer"
component='footer'
sx={{
position: 'fixed',
bottom: 0,

View File

@@ -28,11 +28,10 @@ const CoverImage = styled('img')({
export default function ArticleView({
article,
onBack,
open_editor,
onEdit,
}: ArticleViewProps) {
const { currentUser } = useAuth();
const onEdit = open_editor;
return (
<ArticleContainer>

View File

@@ -16,7 +16,7 @@ export default function ArticleCardSize12({
return (
<StyledCard
variant="outlined"
onClick={() => onSelectArticle(index)}
onClick={() => onSelectArticle(article)}
onFocus={() => onFocus(index)}
onBlur={onBlur}
tabIndex={0}

View File

@@ -16,7 +16,7 @@ export default function ArticleCardSize2({
return (
<StyledCard
variant="outlined"
onClick={() => onSelectArticle(index)}
onClick={() => onSelectArticle(article)}
onFocus={() => onFocus(index)}
onBlur={onBlur}
tabIndex={0}

View File

@@ -16,7 +16,7 @@ export default function ArticleCardSize4({
return (
<StyledCard
variant="outlined"
onClick={() => onSelectArticle(index)}
onClick={() => onSelectArticle(article)}
onFocus={() => onFocus(index)}
onBlur={onBlur}
tabIndex={0}

View File

@@ -16,7 +16,7 @@ export default function ArticleCardSize6({
return (
<StyledCard
variant="outlined"
onClick={() => onSelectArticle(index)}
onClick={() => onSelectArticle(article)}
onFocus={() => onFocus(index)}
onBlur={onBlur}
tabIndex={0}

View File

@@ -1,99 +1,19 @@
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import AvatarGroup from '@mui/material/AvatarGroup';
import Box from '@mui/material/Box';
import Grid from '@mui/material/Grid';
import Typography from '@mui/material/Typography';
import { styled } from '@mui/material/styles';
import NavigateNextRoundedIcon from '@mui/icons-material/NavigateNextRounded';
import CircularProgress from '@mui/material/CircularProgress';
import { LatestProps } from "../types/props";
import { StyledTypography, TitleTypography } from "../types/styles";
import { ArticleMeta } from "./ArticleMeta";
import Fade from '@mui/material/Fade';
const StyledTypography = styled(Typography)({
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
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={(
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
"/" +
(author.avatar?.replace(/^\/+/, "") || "")
)}
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) {
export default function Latest({
articles,
onSelectArticle,
onLoadMore
}: LatestProps) {
const [visibleCount, setVisibleCount] = React.useState(2);
const [loadingMore, setLoadingMore] = React.useState(false);
const [animating, setAnimating] = React.useState(false);
@@ -182,7 +102,7 @@ export default function Latest({ articles, onSelectArticle, onLoadMore }: Latest
{article.description}
</StyledTypography>
<Author authors={article.authors} />
<ArticleMeta article={article} />
</Box>
</Fade>
</Grid>

View File

@@ -9,7 +9,8 @@ import OutlinedInput from '@mui/material/OutlinedInput';
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
import RssFeedRoundedIcon from '@mui/icons-material/RssFeedRounded';
import { ArticleModel } from "../types/models";
import {ArticlesModel, createArticlesModelObject} from "../types/models";
import { MainContentProps } from "../types/props";
import ArticleCardsGrid from "./ArticleCards/ArticleCardsGrid";
export function Search() {
@@ -36,12 +37,9 @@ export function Search() {
export default function MainContent({
articles,
onSelectArticle,
}: {
articles: ArticleModel[];
onSelectArticle: (index: number) => void;
}) {
}: MainContentProps) {
const [visibleArticles, setVisibleArticles] = React.useState<ArticleModel[]>(articles);
const [visibleArticles, setVisibleArticles] = React.useState<ArticlesModel>(articles);
const [activeTag, setActiveTag] = React.useState<string>('all');
const filterArticlesByTag = (tag: string) => {
@@ -60,11 +58,11 @@ export default function MainContent({
}
// 🔵 Filter by selected tag
const filtered = articles.filter((article) => article.tag === tag);
const filtered = articles.articlesList.filter((article) => article.tag === tag);
console.log('👀 All Articles:', articles);
console.log(`👀 Filtered (${tag}):`, filtered);
setVisibleArticles(filtered);
setVisibleArticles(createArticlesModelObject(filtered));
setActiveTag(tag);
};

View File

@@ -9,16 +9,18 @@ import {
Alert,
} from '@mui/material';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useAuth } from '../providers/Author';
import { useAuth as useAuthor } from '../providers/Author';
import { useAuth } from '../../../auth/src';
import { useUpload } from "../providers/Upload";
import ImageUploadField from './ImageUploadField';
import { ProfileProps } from '../types/props';
interface ProfileProps {
onBack: () => void;
}
export default function Profile({
onBack
}: ProfileProps) {
const { logout } = useAuth();
const { currentUser, updateProfile, loading, error } = useAuthor();
export default function Profile({ onBack }: ProfileProps) {
const { currentUser, loading, error, logout, updateProfile } = useAuth();
const { uploadFile } = useUpload();
const [formData, setFormData] = React.useState({
username: currentUser?.username || '',
@@ -134,6 +136,7 @@ export default function Profile({ onBack }: ProfileProps) {
label="Username"
name="username"
margin="normal"
disabled={true}
value={formData.username}
onChange={handleChange}
/>

View File

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

View File

@@ -1,29 +1,20 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
import { api } from '../utils/api';
import { ArticleModel } from '../types/models';
import { ArticleContextModel } from '../types/contexts';
import { useAuth } from './Author';
import React, { createContext, useState, useContext, useEffect } from "react";
import { api } from "../utils/api";
import {
ArticleModel,
ArticlesModel,
createArticlesModelObject,
} from "../types/models";
import { ArticleContextModel } from "../types/contexts";
import { useAuth } from "./Author";
const ArticleContext = createContext<ArticleContextModel | undefined>(undefined);
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 [error, setError] = useState<string | null>(null);
const { token, currentUser } = useAuth();
const upsertArticleInList = (updated: ArticleModel) => {
setArticles(prev => {
const exists = prev.some(a => a._id === updated._id);
if (exists) {
// UPDATE → replace item
return prev.map(a => (a._id === updated._id ? updated : a));
} else {
// CREATE → append to top
return [updated, ...prev];
}
});
};
const { currentUser } = useAuth();
/** 🔹 Author IDs must be strings for API, so we normalize here */
const normalizeArticleForApi = (article: Partial<ArticleModel>) => {
@@ -51,7 +42,7 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
const res = await api.get<ArticleModel[]>('/articles', { params: { skip: 0, limit: 100 } });
const formatted = res.data.map((a) => ({ ...a, id: a._id || undefined }));
setArticles(formatted);
setArticles(prev => prev.refresh(formatted));
} catch (err: any) {
console.error('Failed to fetch articles:', err);
setError(err.response?.data?.detail || 'Failed to fetch articles');
@@ -77,7 +68,10 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
setError(null);
const res = await api.put<ArticleModel>(`/articles/${articleData._id}`, normalizedArticleData);
upsertArticleInList(res.data);
setArticles(prev => {
prev.update(res.data);
return { ...prev };
});
return res.data;
} catch (err: any) {
console.error('Article update failed:', err);
@@ -100,7 +94,10 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
setError(null);
const res = await api.post<ArticleModel>(`/articles`, normalizedArticleData);
upsertArticleInList(res.data);
setArticles(prev => {
prev.create(res.data);
return { ...prev };
});
return res.data;
} catch (err: any) {
console.error('Article create failed:', err);
@@ -112,14 +109,8 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
/** 🔹 Auto-fetch articles whenever user logs in/out */
useEffect(() => {
// Always load once on mount
// If endpoint requires JWT, fallback safely
if (!token) {
fetchArticles().catch(() => setLoading(false)); // try anyway (handles both public/protected)
} else {
fetchArticles();
}
}, [token]);
}, [currentUser]); // refetch on login / logout
return (
<ArticleContext.Provider value={{

View File

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

View File

@@ -1,23 +1,24 @@
import { ArticleModel, AuthorModel } from "./models";
import {
ArticleModel,
ArticlesModel,
AuthorModel
} from "./models";
export interface ArticleContextModel {
articles: ArticleModel[];
articles: ArticlesModel;
loading: boolean;
error: string | null;
refreshArticles: () => Promise<void>;
updateArticle: (user: ArticleModel) => Promise<ArticleModel | void>;
createArticle: (user: ArticleModel) => Promise<ArticleModel | void>;
updateArticle: (article: ArticleModel) => Promise<ArticleModel | void>;
createArticle: (article: ArticleModel) => Promise<ArticleModel | void>;
}
export interface AuthContextModel {
currentUser: AuthorModel | null;
authors: AuthorModel[];
token: string | null;
loading: boolean;
error: string | null;
login: (username: string, password: string) => Promise<void>;
register: (username: string, password: string) => Promise<void>;
logout: () => void;
refreshAuthors: () => Promise<void>;
updateProfile: (user: AuthorModel) => Promise<AuthorModel | void>;
}

View File

@@ -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
_id?: string | null;
created_at: string;
updated_at: string;
// model fields
username: string;
name: string;
email: string;
avatar: string;
is_active: boolean;
}
export interface ArticleModel {
@@ -28,3 +32,82 @@ export interface ArticleModel {
// ref fields
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;
}
};
}

View File

@@ -1,18 +1,29 @@
import { ArticleModel } from "./models";
import {
ArticleModel,
ArticlesModel,
} from "./models";
export interface LatestProps {
articles: ArticleModel[];
articles: ArticlesModel;
onSelectArticle?: (index: number) => void;
onLoadMore?: (offset: number, limit: number) => Promise<void>; // optional async callback
}
export interface MainContentProps {
articles: ArticlesModel;
onSelectArticle: (index: ArticleModel) => void;
}
export interface ProfileProps {
onBack: () => void;
}
export interface ArticleViewProps {
article: ArticleModel;
onBack: () => void;
open_editor: () => void;
onEdit: () => void;
}
export interface ArticleEditorProps {
article?: ArticleModel | null;
onBack: () => void;
@@ -26,14 +37,14 @@ export interface ArticleCardProps {
article: ArticleModel;
index: number;
focusedCardIndex: number | null;
onSelectArticle: (index: number) => void;
onSelectArticle: (index: ArticleModel) => void;
onFocus: (index: number) => void;
onBlur: () => void;
}
export interface ArticleGridProps {
articles: ArticleModel[];
onSelectArticle: (index: number) => void;
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)

View File

@@ -38,3 +38,40 @@ export const StyledTypography = styled(Typography)({
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%',
},
}));

View File

@@ -1,7 +1,8 @@
import { ArticleModel } from "./models";
export type View =
| "home"
| "login"
| "register"
| "auth"
| "article"
| "editor"
| "profile"
@@ -15,33 +16,60 @@ export type ViewNode = {
export const VIEW_TREE: Record<View, ViewNode> = {
home: {
parent: null,
children: ["login", "article", "profile", "create"],
children: ["auth", "article", "profile", "create"],
},
login: {
auth: {
parent: "home",
children: ["register"],
},
register: {
parent: "login",
},
article: {
parent: "home",
children: ["editor"],
},
editor: {
parent: "article",
},
profile: {
parent: "home",
},
create: {
parent: "home",
},
};
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) => {
setUI((prev: any) => ({ ...prev, view }));
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" });
};
@@ -51,18 +79,35 @@ export function useViewRouter(setUI: any) {
if (parent) navigate(parent);
};
const openArticle = (i: number) => {
setUI({ selectedArticle: i, view: "article" });
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) => {
const navigateToChildren = (
view: View,
navigationMap?: Record<string, string>,
) => {
const node = VIEW_TREE[view];
const funcs: Record<string, () => void> = {};
node.children?.forEach((child) => {
funcs[`open_${child}`] = () => navigate(child);
const funcName = `open_${child}`;
const customFuncName = navigationMap?.[funcName];
funcs[funcName] = () => navigate(child);
if (customFuncName) funcs[customFuncName] = () => navigate(child);
});
return funcs;

View File

@@ -1,33 +1,19 @@
// src/utils/api.ts
import axios from 'axios';
import { createApiClient } from "../../../auth/src";
const AUTH_BASE = import.meta.env.VITE_AUTH_BASE_URL;
const API_BASE = import.meta.env.VITE_API_BASE_URL;
export const api = axios.create({
baseURL: API_BASE,
headers: {
'Content-Type': 'application/json',
},
});
/**
* Auth service client
* - login
* - register
* - me
* - logout
* - introspect
*/
export const auth = createApiClient(AUTH_BASE);
// 🔹 Attach token from localStorage before each request
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 🔹 Handle expired or invalid tokens globally
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
console.warn('Token expired or invalid. Logging out...');
localStorage.removeItem('token');
// Optionally: trigger a redirect or event
}
return Promise.reject(error);
}
);
/**
* Main application API (blog, articles, etc.)
*/
export const api = createApiClient(API_BASE);

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

View File

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

1
src/vite-env.d.ts vendored
View File

@@ -2,6 +2,7 @@
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
readonly VITE_AUTH_BASE_URL: string;
}
interface ImportMeta {

143
src_generic/App.tsx Normal file
View File

@@ -0,0 +1,143 @@
import * as React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AuthProvider, useAuth, AuthPage } from "../auth/src";
import { UploadProvider } from "./providers/UploadProvider";
import AdminLayout from "./components/AdminLayout";
import ResourceView from "./components/ResourceView";
import { getAppConfig } from "./config";
import { initializeApiClients } from "./api/client";
import { AppConfig } from "./types/config";
import { Box, Typography, Paper, CircularProgress } from "@mui/material";
import AppTheme from "../src/shared-theme/AppTheme";
const queryClient = new QueryClient();
// Create a context for the app config
export const ConfigContext = React.createContext<AppConfig | null>(null);
function Dashboard() {
const config = React.useContext(ConfigContext);
return (
<Box>
<Typography variant="h4" gutterBottom>
Welcome to the Admin Panel
</Typography>
<Typography variant="body1">
Select a resource from the sidebar to manage data.
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
gap: 3,
mt: 4,
}}
>
{config?.resources.map((res) => (
<Paper key={res.name} sx={{ p: 3, textAlign: "center" }}>
<Typography variant="h6">{res.pluralLabel}</Typography>
</Paper>
))}
</Box>
</Box>
);
}
function AdminApp() {
const { currentUser, login, logout, loading, error } = useAuth();
const config = React.useContext(ConfigContext);
const [selectedResourceName, setSelectedResourceName] = React.useState<
string | null
>(null);
const [selectedItemId, setSelectedItemId] = React.useState<string | null>(null);
const handleNavigateToResource = (resourceName: string, id: string) => {
setSelectedResourceName(resourceName);
setSelectedItemId(id);
};
if (!currentUser) {
return (
<AuthPage
mode="login"
login={login}
register={async () => {}} // Disable registration for Admin
loading={loading}
error={error}
onSwitchMode={() => {}}
onBack={() => {}}
currentUser={null}
/>
);
}
const selectedResource = config?.resources.find(
(r) => r.name === selectedResourceName
);
return (
<AdminLayout
username={currentUser.username}
onLogout={logout}
selectedResourceName={selectedResourceName}
onSelectResource={(name) => {
setSelectedResourceName(name);
setSelectedItemId(null);
}}
resources={config?.resources || []}
>
{selectedResource ? (
<ResourceView
key={`${selectedResource.name}-${selectedItemId}`}
config={selectedResource}
onNavigateToResource={handleNavigateToResource}
/>
) : (
<Dashboard />
)}
</AdminLayout>
);
}
export default function App() {
const [config, setConfig] = React.useState<AppConfig | null>(null);
React.useEffect(() => {
getAppConfig().then((cfg) => {
initializeApiClients(cfg.baseUrl, cfg.authBaseUrl);
setConfig(cfg);
});
}, []);
if (!config) {
return (
<AppTheme>
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
}}
>
<CircularProgress />
</Box>
</AppTheme>
);
}
return (
<AppTheme>
<QueryClientProvider client={queryClient}>
<ConfigContext.Provider value={config}>
<AuthProvider authBaseUrl={config.authBaseUrl}>
<UploadProvider>
<AdminApp />
</UploadProvider>
</AuthProvider>
</ConfigContext.Provider>
</QueryClientProvider>
</AppTheme>
);
}

43
src_generic/api/client.ts Normal file
View File

@@ -0,0 +1,43 @@
import axios, { AxiosInstance } from "axios";
import { createApiClient } from "../../auth/src";
/**
* We expose a singleton-like getter/setter for the API clients
*/
let _api: AxiosInstance | null = null;
let _auth: AxiosInstance | null = null;
export const api = {
get: (...args: Parameters<AxiosInstance["get"]>) => {
if (!_api) throw new Error("API client not initialized");
return _api.get(...args);
},
post: (...args: Parameters<AxiosInstance["post"]>) => {
if (!_api) throw new Error("API client not initialized");
return _api.post(...args);
},
put: (...args: Parameters<AxiosInstance["put"]>) => {
if (!_api) throw new Error("API client not initialized");
return _api.put(...args);
},
delete: (...args: Parameters<AxiosInstance["delete"]>) => {
if (!_api) throw new Error("API client not initialized");
return _api.delete(...args);
},
};
export const auth = {
post: (...args: Parameters<AxiosInstance["post"]>) => {
if (!_auth) throw new Error("Auth client not initialized");
return _auth.post(...args);
},
get: (...args: Parameters<AxiosInstance["get"]>) => {
if (!_auth) throw new Error("Auth client not initialized");
return _auth.get(...args);
},
};
export function initializeApiClients(baseUrl: string, authBaseUrl: string) {
_api = createApiClient(baseUrl);
_auth = createApiClient(authBaseUrl);
}

View File

@@ -0,0 +1,105 @@
import * as React from 'react';
import {
Box,
Drawer,
AppBar,
Toolbar,
List,
Typography,
Divider,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
CssBaseline,
IconButton
} from '@mui/material';
import TableViewIcon from '@mui/icons-material/TableView';
import DashboardIcon from '@mui/icons-material/Dashboard';
import LogoutIcon from '@mui/icons-material/Logout';
import { ResourceConfig } from '../types/config';
const drawerWidth = 240;
interface AdminLayoutProps {
children: React.ReactNode;
onSelectResource: (resourceName: string | null) => void;
selectedResourceName: string | null;
onLogout: () => void;
username?: string;
resources: ResourceConfig[];
}
export default function AdminLayout({
children,
onSelectResource,
selectedResourceName,
onLogout,
username,
resources,
}: AdminLayoutProps) {
return (
<Box sx={{ display: 'flex' }}>
<CssBaseline />
<AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}>
<Toolbar>
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
Admin Panel
</Typography>
<Typography variant="body1" sx={{ mr: 2 }}>
{username}
</Typography>
<IconButton color="inherit" onClick={onLogout}>
<LogoutIcon />
</IconButton>
</Toolbar>
</AppBar>
<Drawer
variant="permanent"
sx={{
width: drawerWidth,
flexShrink: 0,
[`& .MuiDrawer-paper`]: { width: drawerWidth, boxSizing: 'border-box' },
}}
>
<Toolbar />
<Box sx={{ overflow: 'auto' }}>
<List>
<ListItem disablePadding>
<ListItemButton
selected={selectedResourceName === null}
onClick={() => onSelectResource(null)}
>
<ListItemIcon>
<DashboardIcon />
</ListItemIcon>
<ListItemText primary="Dashboard" />
</ListItemButton>
</ListItem>
</List>
<Divider />
<List>
{resources.map((res) => (
<ListItem key={res.name} disablePadding>
<ListItemButton
selected={selectedResourceName === res.name}
onClick={() => onSelectResource(res.name)}
>
<ListItemIcon>
<TableViewIcon />
</ListItemIcon>
<ListItemText primary={res.pluralLabel} />
</ListItemButton>
</ListItem>
))}
</List>
<Divider />
</Box>
</Drawer>
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
<Toolbar />
{children}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,158 @@
import * as React from 'react';
import {
Box,
Typography,
Button,
IconButton,
Link,
Tooltip,
} from '@mui/material';
import {
DataGrid,
GridColDef,
GridActionsCellItem,
GridRenderCellParams,
} from '@mui/x-data-grid';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import { ResourceConfig, ResourceField } from '../types/config';
interface EnhancedTableProps {
config: ResourceConfig;
data: any[];
onEdit: (item: any) => void;
onDelete: (id: string) => void;
onCreate: () => void;
onNavigateToResource?: (resourceName: string, id: string) => void;
}
export default function EnhancedTable({
config,
data,
onEdit,
onDelete,
onCreate,
onNavigateToResource,
}: EnhancedTableProps) {
const columns: GridColDef[] = React.useMemo(() => {
const cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => {
const col: GridColDef = {
field: key,
headerName: field.label,
flex: 1,
minWidth: 150,
renderCell: (params: GridRenderCellParams) => {
const value = params.value;
// 1. Custom Formatter
if (field.formatter) {
return field.formatter(value);
}
// 2. Relational Link
if (field.relation && value) {
const relationId = typeof value === 'object' ? value.id : value;
if (relationId) {
return (
<Link
component="button"
variant="body2"
onClick={(e) => {
e.stopPropagation();
onNavigateToResource?.(field.relation!, relationId);
}}
>
{relationId}
</Link>
);
}
}
// 3. Nested Object / Array Display
if (field.type === 'array' && Array.isArray(value)) {
if (field.displayField) {
return value
.map((item) => (typeof item === 'object' ? item[field.displayField!] : item))
.filter(Boolean)
.join(', ');
}
return `${value.length} items`;
}
if (field.type === 'object' && value) {
if (field.displayField && value[field.displayField]) {
return value[field.displayField];
}
return JSON.stringify(value);
}
// 4. Default renderings
if (field.type === 'boolean') return value ? 'Yes' : 'No';
if (field.type === 'datetime' || field.type === 'date') {
return new Date(value).toLocaleString();
}
return value;
}
};
return col;
});
cols.push({
field: 'actions',
type: 'actions',
headerName: 'Actions',
width: 100,
getActions: (params) => [
<GridActionsCellItem
icon={<EditIcon />}
label="Edit"
onClick={() => onEdit(params.row)}
/>,
<GridActionsCellItem
icon={<DeleteIcon />}
label="Delete"
onClick={() => onDelete(params.id as string)}
/>,
],
});
return cols;
}, [config, onEdit, onDelete, onNavigateToResource]);
return (
<Box sx={{ height: 600, width: '100%' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3, alignItems: 'center' }}>
<Typography variant="h5">{config.pluralLabel}</Typography>
<Button variant="contained" color="primary" onClick={onCreate}>
Add {config.label}
</Button>
</Box>
<DataGrid
rows={data || []}
columns={columns}
getRowId={(row) => {
const pk = config.primaryKey;
if (row[pk] !== undefined && row[pk] !== null) return row[pk];
// Fallback: search for common ID fields
const fallbackKeys = ['id', 'uuid', 'pk'];
for (const key of fallbackKeys) {
if (row[key] !== undefined && row[key] !== null) return row[key];
}
debugger;
// Absolute fallback: index (not ideal but avoids crash)
return `temp-id-${data.indexOf(row)}`;
}}
disableRowSelectionOnClick
initialState={{
pagination: {
paginationModel: { page: 0, pageSize: 10 },
},
}}
pageSizeOptions={[5, 10, 25]}
/>
</Box>
);
}

View File

@@ -0,0 +1,203 @@
import * as React from 'react';
import {
Box,
TextField,
Button,
FormControl,
InputLabel,
Select,
MenuItem,
FormControlLabel,
Checkbox,
Typography,
Divider,
} from '@mui/material';
import { ResourceConfig, ResourceField } from '../types/config';
import { useUpload } from '../providers/UploadProvider';
import ImageUploadField from './fields/ImageUploadField';
interface GenericFormProps {
config: ResourceConfig;
initialData?: any;
onSave: (data: any) => Promise<void>;
onCancel: () => void;
loading?: boolean;
}
import { ConfigContext } from '../App';
export default function GenericForm({
config,
initialData = {},
onSave,
onCancel,
loading: saving,
}: GenericFormProps) {
initialData = initialData || {};
const [formData, setFormData] = React.useState(initialData);
const { uploadFile, uploading } = useUpload();
const appConfig = React.useContext(ConfigContext);
const handleChange = (key: string, value: any) => {
setFormData((prev: any) => ({ ...prev, [key]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(formData);
};
return (
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Typography variant="h5">
{initialData[config.primaryKey] ? `Edit ${config.label}` : `New ${config.label}`}
</Typography>
<Divider />
{Object.entries(config.fields).map(([key, field]) => (
<FormField
key={key}
name={key}
field={field}
value={formData[key]}
onChange={(val: any) => handleChange(key, val)}
disabled={field.readOnly}
uploadFile={uploadFile}
uploading={uploading}
baseUrl={appConfig?.baseUrl || ""}
/>
))}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 4 }}>
<Button variant="outlined" onClick={onCancel} disabled={saving}>
Cancel
</Button>
<Button variant="contained" type="submit" loading={saving} disabled={saving || uploading}>
Save {config.label}
</Button>
</Box>
</Box>
);
}
function FormField({ name, field, value, onChange, disabled, uploadFile, uploading, baseUrl }: any) {
const label = field.label;
if (field.type === 'image') {
return (
<ImageUploadField
label={label}
value={value}
onUpload={async (file: any) => {
const url = await uploadFile(file);
if (url) onChange(url);
}}
uploading={uploading}
baseUrl={baseUrl}
/>
);
}
if (field.type === 'boolean') {
return (
<FormControlLabel
control={
<Checkbox
checked={!!value}
onChange={(e) => onChange(e.target.checked)}
disabled={disabled}
/>
}
label={label}
/>
);
}
if (field.type === 'enum' && field.options) {
return (
<FormControl fullWidth>
<InputLabel>{label}</InputLabel>
<Select
value={value || ''}
label={label}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
>
{field.options.map((opt: string) => (
<MenuItem key={opt} value={opt}>
{opt}
</MenuItem>
))}
</Select>
</FormControl>
);
}
if (field.type === 'datetime') {
return (
<TextField
fullWidth
label={label}
type="datetime-local"
InputLabelProps={{ shrink: true }}
value={value ? new Date(value).toISOString().slice(0, 16) : ''}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={field.required}
/>
);
}
if (field.type === 'date') {
return (
<TextField
fullWidth
label={label}
type="date"
InputLabelProps={{ shrink: true }}
value={value ? new Date(value).toISOString().split('T')[0] : ''}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={field.required}
/>
);
}
if (field.type === 'markdown' || field.type === 'string') {
return (
<TextField
fullWidth
label={label}
value={value || ''}
multiline={field.type === 'markdown'}
rows={field.type === 'markdown' ? 4 : 1}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required={field.required}
/>
);
}
if (field.type === 'number') {
return (
<TextField
fullWidth
label={label}
type="number"
value={value || 0}
onChange={(e) => onChange(Number(e.target.value))}
disabled={disabled}
required={field.required}
/>
);
}
return (
<TextField
fullWidth
label={label}
value={JSON.stringify(value)}
disabled
/>
);
}

View File

@@ -0,0 +1,81 @@
import * as React from 'react';
import { Box, Typography, Paper, CircularProgress } from '@mui/material';
import { ResourceConfig } from '../types/config';
import { useResource } from '../hooks/useResource';
import GenericForm from './GenericForm';
import EnhancedTable from './EnhancedTable';
interface ResourceViewProps {
config: ResourceConfig;
onNavigateToResource?: (resourceName: string, id: string) => void;
}
export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) {
const [view, setView] = React.useState<'list' | 'create' | 'edit'>('list');
const [selectedItem, setSelectedItem] = React.useState<any>(null);
const { useList, useCreate, useUpdate, useDelete } = useResource(config);
const { data, isLoading, error } = useList();
const createMutation = useCreate();
const updateMutation = useUpdate();
const deleteMutation = useDelete();
const handleEdit = (item: any) => {
setSelectedItem(item);
setView('edit');
};
const handleCreate = () => {
setSelectedItem(null);
setView('create');
};
const handleSave = async (formData: any) => {
try {
if (view === 'edit') {
const id = formData[config.primaryKey];
await updateMutation.mutateAsync({ id, data: formData });
} else {
await createMutation.mutateAsync(formData);
}
setView('list');
} catch (err) {
console.error('Save failed:', err);
}
};
const handleDelete = async (id: string) => {
if (window.confirm('Are you sure you want to delete this item?')) {
await deleteMutation.mutateAsync(id);
}
};
if (isLoading) return <CircularProgress />;
if (error) return <Typography color="error">Error loading {config.pluralLabel}</Typography>;
return (
<Box>
{view === 'list' ? (
<EnhancedTable
config={config}
data={data || []}
onEdit={handleEdit}
onDelete={handleDelete}
onCreate={handleCreate}
onNavigateToResource={onNavigateToResource}
/>
) : (
<Paper sx={{ p: 4 }}>
<GenericForm
config={config}
initialData={selectedItem}
onSave={handleSave}
onCancel={() => setView('list')}
loading={createMutation.isPending || updateMutation.isPending}
/>
</Paper>
)}
</Box>
);
}

View File

@@ -0,0 +1,56 @@
import { Box, Button, Avatar, CircularProgress, Typography } from "@mui/material";
interface ImageUploadFieldProps {
label?: string;
value: string;
uploading?: boolean;
onUpload: (file: File) => void;
size?: number;
baseUrl: string;
}
export default function ImageUploadField({
label = "Upload Image",
value,
uploading = false,
onUpload,
size = 64,
baseUrl,
}: ImageUploadFieldProps) {
const imgSrc = value
? baseUrl.replace(/\/+$/, "") +
"/" +
value.replace(/^\/+/, "")
: "";
return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1, mb: 3 }}>
<Typography variant="caption" color="text.secondary">{label}</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Avatar
src={imgSrc}
sx={{ width: size, height: size, borderRadius: 2 }}
/>
<Button
variant="outlined"
component="label"
disabled={uploading}
startIcon={uploading && <CircularProgress size={16} />}
>
{uploading ? "Uploading..." : "Choose File"}
<input
type="file"
accept="image/*"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onUpload(file);
}}
/>
</Button>
</Box>
</Box>
);
}

14
src_generic/config.ts Normal file
View File

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

View File

@@ -0,0 +1,42 @@
import { ResourceOverride } from "./utils/overrides";
export const configuration: Record<string, ResourceOverride> = {
expenses: {
fields: {
payee: {
displayField: "name",
},
payor: {
display: false,
displayField: "username",
},
account: {
displayField: "name",
},
tags: {
displayField: "icon",
},
occurred_at: {
formatter: (val: string) => {
const date = new Date(val);
const day = date.getDate();
const month = date.toLocaleString('default', { month: 'long' });
const year = date.getFullYear();
const suffix = (day: number) => {
if (day > 3 && day < 21) return 'th';
switch (day % 10) {
case 1: return "st";
case 2: return "nd";
case 3: return "rd";
default: return "th";
}
};
return `${day}${suffix(day)} ${month} ${year}`;
}
},
created_at: {
display: false
}
},
},
};

View File

@@ -0,0 +1,77 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api/client";
import { ResourceConfig } from "../types/config";
export function useResource<T = any>(config: ResourceConfig) {
const queryClient = useQueryClient();
const { name, endpoint, primaryKey } = config;
// --- READ ALL ---
const useList = (params?: any) =>
useQuery({
queryKey: [name, "list", params],
queryFn: async () => {
const res = await api.get<T[]>(endpoint, { params });
return res.data;
}
});
// --- READ ONE ---
const useOne = (id: string | null) =>
useQuery({
queryKey: [name, "detail", id],
queryFn: async () => {
if (!id) return null;
const res = await api.get<T>(`${endpoint}/${id}`);
return res.data;
},
enabled: !!id,
});
// --- CREATE ---
const useCreate = () =>
useMutation({
mutationFn: async (data: Partial<T>) => {
const res = await api.post<T>(endpoint, data);
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [name, "list"] });
},
});
// --- UPDATE ---
const useUpdate = () =>
useMutation({
mutationFn: async ({ id, data }: { id: string; data: Partial<T> }) => {
const res = await api.put<T>(`${endpoint}/${id}`, data);
return res.data;
},
onSuccess: (updatedItem) => {
// @ts-ignore
const id = updatedItem[primaryKey];
queryClient.invalidateQueries({ queryKey: [name, "list"] });
queryClient.invalidateQueries({ queryKey: [name, "detail", id] });
},
});
// --- DELETE ---
const useDelete = () =>
useMutation({
mutationFn: async (id: string) => {
await api.delete(`${endpoint}/${id}`);
return id;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [name, "list"] });
},
});
return {
useList,
useOne,
useCreate,
useUpdate,
useDelete,
};
}

18
src_generic/main.tsx Normal file
View File

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

View File

@@ -0,0 +1,52 @@
import React, { createContext, useContext, useState } from "react";
import { api } from "../api/client";
export interface UploadContextModel {
uploadFile: (file: File) => Promise<string | null>;
uploading: boolean;
error: string | null;
}
const UploadContext = createContext<UploadContextModel | undefined>(undefined);
export const UploadProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const uploadFile = async (file: File): Promise<string | null> => {
setUploading(true);
setError(null);
try {
const arrayBuffer = await file.arrayBuffer();
const binary = new Uint8Array(arrayBuffer);
const res = await api.post("/uploads", binary, {
headers: {
"Content-Type": file.type,
"Content-Disposition": `attachment; filename="${file.name}"`,
},
});
return res.data.url as string;
} catch (err: any) {
console.error("File upload failed:", err);
setError(err.response?.data?.detail || "Failed to upload file");
return null;
} finally {
setUploading(false);
}
};
return (
<UploadContext.Provider value={{ uploadFile, uploading, error }}>
{children}
</UploadContext.Provider>
);
};
export const useUpload = (): UploadContextModel => {
const ctx = useContext(UploadContext);
if (!ctx) throw new Error("useUpload must be used within UploadProvider");
return ctx;
};

View File

@@ -0,0 +1,38 @@
export type FieldType =
| 'string'
| 'number'
| 'boolean'
| 'date'
| 'datetime'
| 'markdown'
| 'enum'
| 'image'
| 'object'
| 'array';
export interface ResourceField {
type: FieldType;
label: string;
required?: boolean;
options?: string[];
readOnly?: boolean;
schema?: Record<string, ResourceField>;
displayField?: string;
formatter?: (value: any) => string;
relation?: string; // Name of the target resource
}
export interface ResourceConfig {
name: string;
label: string;
pluralLabel: string;
endpoint: string;
primaryKey: string;
fields: Record<string, ResourceField>;
}
export interface AppConfig {
baseUrl: string;
authBaseUrl: string;
resources: ResourceConfig[];
}

View File

@@ -0,0 +1,165 @@
import SwaggerParser from "@apidevtools/swagger-parser";
import { AppConfig, ResourceConfig, ResourceField, FieldType } from "../types/config";
import { configuration } from "../configuration";
/**
* Maps OpenAPI property types to our internal FieldType
*/
function mapOpenApiType(prop: any): FieldType {
const type = prop.type;
const format = prop.format;
if (format === "date-time") return "datetime";
if (format === "date") return "date";
if (prop.enum) return "enum";
if (
type === "string" &&
(prop.description?.toLowerCase().includes("image") ||
prop.name?.toLowerCase().includes("icon"))
)
return "image";
switch (type) {
case "integer":
case "number":
return "number";
case "boolean":
return "boolean";
case "object":
return "object";
case "array":
return "array";
default:
return "string";
}
}
/**
* Recursively converts OpenAPI schemas to ResourceField map
*/
function parseSchemaFields(
schema: any,
resourceName: string,
allResources: string[]
): Record<string, ResourceField> {
const fields: Record<string, ResourceField> = {};
const properties = schema.properties || {};
const required = schema.required || [];
const overrides = configuration[resourceName]?.fields || {};
for (const [key, prop] of Object.entries(properties) as any) {
const type = mapOpenApiType(prop);
const override = overrides[key];
console.log("key", key, "type", type, "prop", prop, "override", override);
if (key !== "id" && override?.display !== false) {
fields[key] = {
type,
label:
prop.title ||
key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, " "),
required: required.includes(key),
options: prop.enum,
readOnly:
prop.readOnly ||
key === "created_at" ||
key === "updated_at",
...override,
};
} else continue;
// Schema-based Relation Detection
// If it's an object/string and matches a resource name, it might be a relation
const potentialRelation = allResources.find(
(res) =>
key === res ||
key === `${res}_id` ||
prop.title?.toLowerCase() === res ||
prop["x-resource"] === res
);
if (potentialRelation) {
if (type === "string" || (type === "object" && prop.properties?.id)) {
fields[key].relation = potentialRelation;
}
}
if (fields[key].type === "object" && prop.properties) {
fields[key].schema = parseSchemaFields(prop, resourceName, allResources);
}
}
return fields;
}
/**
* Scans paths to identify resources and their basic configuration
*/
export async function loadConfigFromOpenApi(baseUrl: string): Promise<AppConfig> {
// 1. Parse and dereference the spec (handles all $ref)
const api = await SwaggerParser.dereference(
new URL("/openapi.json", baseUrl).href
);
const resources: ResourceConfig[] = [];
const paths = api.paths || {};
// Group paths by base resource name (e.g., /expenses, /expenses/{id} -> expenses)
const resourcePaths: Record<string, any> = {};
for (const path of Object.keys(paths)) {
const base = path.split("/")[1];
if (!base) continue;
if (!resourcePaths[base]) resourcePaths[base] = { path, methods: [] };
const methods = Object.keys(paths[path] || {});
resourcePaths[base].methods.push(...methods);
// We prefer the plural GET path for schema extraction
if (!path.includes("{") && paths[path]?.get?.responses?.["200"]) {
resourcePaths[base].listPath = path;
}
}
const allResourceNames = Object.keys(resourcePaths);
// Generate ResourceConfig for each identified base path
for (const [name, info] of Object.entries(resourcePaths)) {
const listPath = info.listPath || `/${name}`;
const listOp = paths[listPath]?.get;
if (!listOp) continue;
// Use common naming conventions or metadata from the spec
const label = name.charAt(0).toUpperCase() + name.slice(1, -1); // naive singularization
const pluralLabel = name.charAt(0).toUpperCase() + name.slice(1);
// Extract schema from the 200 response of the list endpoint
let schema: any = null;
const responseSchema =
listOp.responses?.["200"]?.content?.["application/json"]?.schema;
if (responseSchema?.type === "array" && responseSchema.items) {
schema = responseSchema.items;
} else {
schema = responseSchema;
}
if (schema) {
resources.push({
name,
label: schema.title || label,
pluralLabel: pluralLabel,
endpoint: listPath,
primaryKey: "id", // assume 'id' as default or look for 'required' + 'unique'
fields: parseSchemaFields(schema, name, allResourceNames),
});
}
}
return {
baseUrl:
import.meta.env.VITE_API_BASE_URL || (api.servers?.[0]?.url ?? ""),
authBaseUrl: import.meta.env.VITE_AUTH_BASE_URL || "",
resources,
};
}

View File

@@ -0,0 +1,14 @@
/**
* This file contains application-specific overrides and configuration
* for the generic Admin Panel.
*/
export interface FieldOverride {
displayField?: string;
display?: boolean;
formatter?: (value: any) => string;
}
export interface ResourceOverride {
fields?: Record<string, FieldOverride>;
}