56 Commits
0.2.0 ... 0.3.2

Author SHA1 Message Date
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
3aaf328511 feat(router): migrate to declarative view-based navigation system
All checks were successful
continuous-integration/drone/tag Build is passing
- Introduce unified View hierarchy (VIEW_TREE) with parent/child relationships
- Add useViewRouter for navigate(), goBack(), openArticle(), and dynamic child navigation
- Replace legacy boolean-based view flags with single ui.view state
- Implement dynamic component rendering via VIEW_COMPONENTS map
- Add HomeView wrapper and integrate dynamic navigation props
- Update ArticleView to use open_editor and new ArticleViewProps
- Adjust ArticleEditor props type to accept null article
- Normalize navigation prop naming (open_* passed as onBack/onEdit via router)
- Enforce validation: prevent article updates without logged-in user
- Remove old conditional rendering/switch blocks and simplify Blog.tsx
- Version bump: 0.2.3 → 0.2.4
2025-11-18 17:55:01 +05:30
635e99c183 cleanup 2025-11-18 17:09:10 +05:30
b8e4decfba cleanup 2025-11-18 16:53:48 +05:30
459fa5855c abstracted navigation logic 2025-11-18 16:53:36 +05:30
f52c4a5287 added missing create 2025-11-18 16:28:53 +05:30
3a3f44c5b5 moved views logic to types 2025-11-18 16:28:41 +05:30
479ffb736c hierarchy wise view 2025-11-18 16:14:47 +05:30
87bdafb6a3 cleaner view for Blog 2025-11-18 16:05:27 +05:30
383b424bdf back and edit button spaced out properly 2025-11-18 15:23:08 +05:30
0340e17467 moved chip to between Cover Image and Article content 2025-11-18 15:20:11 +05:30
f15155d31c show edit button only if currentUser is present and don't updateArticle if currentUser is not present 2025-11-18 15:19:27 +05:30
c2e6daca13 This release adds a new large article card layout, improves image URL handling across the app, and enhances article CRUD logic to correctly insert/update items in the global provider.
All checks were successful
continuous-integration/drone/tag Build is passing
2025-11-15 18:20:23 +05:30
c0bcd0e3e4 local updation of articles too after article creation or modification 2025-11-15 18:18:40 +05:30
333f931cff using full URL for Cover Image in ArticleView.tsx 2025-11-15 18:18:14 +05:30
3960de3ecb making sure currentUser is in the list of authors for article 2025-11-15 17:34:01 +05:30
763629faa1 passing description 2025-11-15 17:33:39 +05:30
a7e3ed46cb 12 size card for full width in case of single article ONLY 2025-11-15 17:33:29 +05:30
4a8c59895e cleanup 2025-11-15 17:13:39 +05:30
ec9b5c905a bumping up to 0.2.2 for Implemented article editor, cover image upload, new UploadProvider, image URL normalization, and UI integration for editing and creating articles.
All checks were successful
continuous-integration/drone/tag Build is passing
2025-11-15 05:55:06 +05:30
d7e9827819 prefixing BASE URL for images. will break existing hardcoded outside images 2025-11-15 05:49:47 +05:30
ae0bc7dd12 update and create article provider functions 2025-11-15 05:44:18 +05:30
1e6c80f1b3 Cover Image upload 2025-11-15 05:20:02 +05:30
8ff8b9236e Upload provider 2025-11-15 05:13:52 +05:30
142b169108 Upload provider 2025-11-15 05:11:53 +05:30
80bf87529e ImageUploadField 2025-11-15 04:56:02 +05:30
5582d18a01 editor TextField fixes 2025-11-15 04:48:41 +05:30
913755d971 changes for UX of opening and closing editor from both home and through article view 2025-11-15 04:28:42 +05:30
8838ff10f4 changes for UX of opening and closing editor 2025-11-15 04:12:24 +05:30
7a28dde7d5 ArticleEditor.tsx for Editing and Creating Articles 2025-11-15 03:56:47 +05:30
d6c84abdf6 refactor View.tsx as ArticleView.tsx 2025-11-15 03:38:16 +05:30
1b755968dd refactor View.tsx as ArticleView.tsx 2025-11-15 03:35:55 +05:30
33e9d70b98 use handleShowProfile instead of inline setShowProfile 2025-11-15 03:31:19 +05:30
ce91526599 added libraries for markdown editor 2025-11-15 03:23:05 +05:30
73d64ea497 refactored Article.tsx to View.tsx 2025-11-15 03:22:51 +05:30
e16804b65d refactored Article.tsx to View.tsx 2025-11-15 03:20:28 +05:30
945912f16d bumped up version to 0.2.1 for avatar upload and update profile with uploaded avatar URL
All checks were successful
continuous-integration/drone/tag Build is passing
2025-11-14 23:50:05 +05:30
4e2af82573 adding API_BASE url to avatar URL to fetch it properly 2025-11-14 23:45:10 +05:30
bd8aea46b1 upload working for avatar 2025-11-14 23:29:44 +05:30
10aa43fa27 added upload and update avatar methods for AUthor Provider 2025-11-14 23:08:43 +05:30
068a741706 cleanup 2025-11-14 23:06:43 +05:30
7faedcf2f9 cleanup 2025-11-14 22:55:59 +05:30
39 changed files with 2811 additions and 523 deletions

View File

@@ -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

View File

@@ -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
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 * 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();
await login(username, password); if (isLogin) {
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 }}
> >
Dont have an account?{' '} {isLogin ? "Dont have an account?" : "Already have an account?"}{' '}
<Link <Link
component="button" component="button"
underline="hover" underline="hover"
color="primary" color="primary"
onClick={onRegister} onClick={onSwitchMode}
sx={{ fontWeight: 500 }} sx={{ fontWeight: 500 }}
> >
Register {isLogin ? "Register" : "Login"}
</Link> </Link>
</Typography> </Typography>
</Box> </Box>

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

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

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

1385
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "aetoskia-blog-app", "name": "aetoskia-blog-app",
"version": "0.2.0", "version": "0.3.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -14,7 +14,9 @@
"@mui/icons-material": "latest", "@mui/icons-material": "latest",
"react": "latest", "react": "latest",
"react-dom": "latest", "react-dom": "latest",
"react-markdown": "latest",
"markdown-to-jsx": "latest", "markdown-to-jsx": "latest",
"remark-gfm": "latest",
"marked": "latest", "marked": "latest",
"axios": "latest" "axios": "latest"
}, },

View File

@@ -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';
function HomeView({
currentUser,
open_auth,
open_profile,
open_create,
articles,
openArticle,
}: any) {
return (
<>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2, gap: 1 }}>
{!currentUser ? (
<Button variant='outlined' onClick={() => open_auth('login')}>
Login
</Button>
) : (
<>
<Button variant='outlined' onClick={open_profile}>
{currentUser.username}
</Button>
<Button variant='contained' onClick={open_create}>
New Article
</Button>
</>
)}
</Box>
<MainContent articles={articles} onSelectArticle={openArticle} />
<Latest articles={articles} onSelectArticle={openArticle} />
</>
);
}
export default function Blog(props: { disableCustomTheme?: boolean }) { export default function Blog(props: { disableCustomTheme?: boolean }) {
const { articles, loading, error } = useArticles(); const { articles, loading, error } = useArticles();
const { currentUser } = useAuth(); const auth = useAuth();
const { currentUser } = useAuthor();
const [selectedArticle, setSelectedArticle] = React.useState<number | null>(null); const [ui, setUI] = React.useState({
const [showLogin, setShowLogin] = React.useState(false); selectedArticle: null as ArticleModel | null,
const [showRegister, setShowRegister] = React.useState(false); view: 'home' as View,
const [showProfile, setShowProfile] = React.useState(false); authMode: 'login' as AuthMode,
});
const handleSelectArticle = (index: number) => { useEffect(() => {
setSelectedArticle(index); if (loading) return;
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleBack = () => setSelectedArticle(null); const path = window.location.pathname;
const parts = path.split('/').filter(Boolean);
const handleShowLogin = () => { if (parts[0] === 'articles' && parts[1]) {
setShowLogin(true); const id = parts[1];
setShowRegister(false); const article = articles.readById(id);
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 if (article) {
const view: View = React.useMemo(() => { setUI({
if (selectedArticle !== null) return 'article'; selectedArticle: article,
if (showRegister) return 'register'; view: 'article',
if (showLogin) return 'login'; authMode: '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 (
<>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2, gap: 1 }}>
{!currentUser ? (
<>
<Button
variant="outlined"
color="primary"
onClick={handleShowLogin}
>
Login
</Button>
</>
) : (
<>
<Button
variant="outlined"
color="primary"
onClick={() => setShowProfile(true)}
>
{currentUser.username}
</Button>
</>
)}
</Box>
<MainContent articles={articles} onSelectArticle={handleSelectArticle} />
<Latest
articles={articles}
onSelectArticle={handleSelectArticle}
onLoadMore={async () => {}}
/>
</>
);
} }
}, [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,

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

View File

@@ -3,8 +3,10 @@ import { marked } from 'marked';
import { Box, Typography, 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 { ArticleMeta } from "./ArticleMeta"; import EditRoundedIcon from '@mui/icons-material/EditRounded';
import { ArticleProps } from '../types/props'; 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',
@@ -23,23 +25,33 @@ 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>
<Chip {currentUser && (
label={article.tag} <IconButton onClick={onEdit}>
variant="outlined" <EditRoundedIcon />
color="primary" </IconButton>
sx={{ mb: 2, textTransform: 'uppercase', fontWeight: 500 }} )}
/> </Box>
<Typography variant="h3" component="h1" gutterBottom fontWeight="bold"> <Typography variant="h3" component="h1" gutterBottom fontWeight="bold">
{article.title} {article.title}
@@ -49,7 +61,21 @@ export default function Article({
<Divider sx={{ my: 3 }} /> <Divider sx={{ my: 3 }} />
<CoverImage src={article.img} alt={article.title} /> <Chip
label={article.tag}
variant="outlined"
color="primary"
sx={{ mb: 2, textTransform: 'uppercase', fontWeight: 500 }}
/>
<CoverImage
src={(
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
"/" +
(article.img?.replace(/^\/+/, "") || "")
)}
alt={article.title}
/>
<Box <Box
sx={{ sx={{

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

View File

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

View File

@@ -16,7 +16,7 @@ export default function ArticleCardSize4({
return ( return (
<StyledCard <StyledCard
variant="outlined" variant="outlined"
onClick={() => onSelectArticle(index)} onClick={() => onSelectArticle(article)}
onFocus={() => onFocus(index)} onFocus={() => onFocus(index)}
onBlur={onBlur} onBlur={onBlur}
tabIndex={0} tabIndex={0}
@@ -24,8 +24,12 @@ export default function ArticleCardSize4({
> >
<CardMedia <CardMedia
component="img" component="img"
alt="green iguana" alt={article.title}
image={article.img} image={(
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
"/" +
(article.img?.replace(/^\/+/, "") || "")
)}
sx={{ sx={{
height: { sm: 'auto', md: '50%' }, height: { sm: 'auto', md: '50%' },
aspectRatio: { sm: '16 / 9', md: '' }, aspectRatio: { sm: '16 / 9', md: '' },

View File

@@ -16,7 +16,7 @@ export default function ArticleCardSize6({
return ( return (
<StyledCard <StyledCard
variant="outlined" variant="outlined"
onClick={() => onSelectArticle(index)} onClick={() => onSelectArticle(article)}
onFocus={() => onFocus(index)} onFocus={() => onFocus(index)}
onBlur={onBlur} onBlur={onBlur}
tabIndex={0} tabIndex={0}
@@ -25,7 +25,11 @@ export default function ArticleCardSize6({
<CardMedia <CardMedia
component="img" component="img"
alt={article.title} alt={article.title}
image={article.img} image={(
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
"/" +
(article.img?.replace(/^\/+/, "") || "")
)}
sx={{ sx={{
aspectRatio: '16 / 9', aspectRatio: '16 / 9',
borderBottom: '1px solid', borderBottom: '1px solid',

View File

@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { Grid, Box } from '@mui/material'; import { Grid, Box } from '@mui/material';
import ArticleCardSize12 from './ArticleCardSize12';
import ArticleCardSize6 from './ArticleCardSize6'; import ArticleCardSize6 from './ArticleCardSize6';
import ArticleCardSize4 from './ArticleCardSize4'; import ArticleCardSize4 from './ArticleCardSize4';
import ArticleCardSize2 from './ArticleCardSize2'; import ArticleCardSize2 from './ArticleCardSize2';
@@ -10,6 +11,7 @@ export default function ArticleCardsGrid({
articles, articles,
onSelectArticle, onSelectArticle,
xs = 12, xs = 12,
md12 = 12,
md6 = 6, md6 = 6,
md4 = 4, md4 = 4,
nested = 2, nested = 2,
@@ -30,8 +32,9 @@ export default function ArticleCardsGrid({
setFocusedCardIndex(null); setFocusedCardIndex(null);
}; };
const renderCard = (article: ArticleModel, index: number, type: '6' | '4' | '2' = '6') => { const renderCard = (article: ArticleModel, index: number, type: '12' | '6' | '4' | '2' = '12') => {
const CardComponent = const CardComponent =
type === '12' ? ArticleCardSize12 :
type === '6' ? ArticleCardSize6 : type === '6' ? ArticleCardSize6 :
type === '4' ? ArticleCardSize4 : type === '4' ? ArticleCardSize4 :
ArticleCardSize2; ArticleCardSize2;
@@ -51,6 +54,17 @@ export default function ArticleCardsGrid({
return ( return (
<Grid container spacing={2} columns={12}> <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 ---- */} {/* ---- 2 articles: 6 | 6 ---- */}
{count === 2 && ( {count === 2 && (
<> <>

View File

@@ -30,7 +30,11 @@ export function ArticleMeta({
<Avatar <Avatar
key={index} key={index}
alt={author.name} alt={author.name}
src={author.avatar} src={(
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
"/" +
(author.avatar?.replace(/^\/+/, "") || "")
)}
sx={{ width: 24, height: 24 }} sx={{ width: 24, height: 24 }}
/> />
))} ))}

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

View File

@@ -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 { StyledTypography, TitleTypography } from "../types/styles";
import { ArticleMeta } from "./ArticleMeta";
import Fade from '@mui/material/Fade'; 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>

View File

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

View File

@@ -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, logout, 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');
@@ -57,8 +77,8 @@ export default function Profile({ onBack }: ProfileProps) {
}; };
const handleLogout = async () => { const handleLogout = async () => {
logout() logout();
} };
if (!currentUser) { if (!currentUser) {
return ( return (
@@ -103,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} value={formData.avatar}
alt={formData.name || formData.username} uploading={uploadingAvatar}
sx={{ width: 64, height: 64 }} onUpload={handleAvatarUpload}
/> size={64}
<TextField />
label="Avatar URL"
name="avatar"
fullWidth
value={formData.avatar}
onChange={handleChange}
/>
</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}
/> />

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,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(); 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 () => {
@@ -20,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 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 fetchArticles();
// If endpoint requires JWT, fallback safely }, [currentUser]); // refetch on login / logout
if (!token) {
fetchArticles().catch(() => setLoading(false)); // try anyway (handles both public/protected)
} else {
fetchArticles();
}
}, [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>
); );

View File

@@ -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 the 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();
}
};
/** 🔹 On mount, try to fetch user if token exists */
useEffect(() => { useEffect(() => {
if (token) fetchCurrentUser(); if (authUser) {
}, [token]); hydrateCurrentUser();
} else {
setCurrentUser(null);
setAuthors([]);
setError(null);
}
}, [authUser]);
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
currentUser, currentUser,
authors, authors,
token,
loading, loading,
error, error,
login,
logout,
register,
refreshAuthors, refreshAuthors,
updateProfile, updateProfile,
}} }}

View File

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

View File

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

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

View File

@@ -1,16 +1,32 @@
import { ArticleModel } from "./models"; import {
import {styled} from "@mui/material/styles"; ArticleModel,
import Card from "@mui/material/Card"; 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 {
articles: ArticlesModel;
onSelectArticle: (index: ArticleModel) => void;
}
export interface ProfileProps {
onBack: () => void;
}
export interface ArticleViewProps {
article: ArticleModel; article: ArticleModel;
onBack: () => void; onBack: () => void;
onEdit: () => void;
}
export interface ArticleEditorProps {
article?: ArticleModel | null;
onBack: () => void;
} }
export interface ArticleMetaProps { export interface ArticleMetaProps {
@@ -21,16 +37,25 @@ export interface ArticleCardProps {
article: ArticleModel; article: ArticleModel;
index: number; index: number;
focusedCardIndex: number | null; focusedCardIndex: number | null;
onSelectArticle: (index: number) => void; onSelectArticle: (index: ArticleModel) => void;
onFocus: (index: number) => void; onFocus: (index: number) => void;
onBlur: () => void; onBlur: () => void;
} }
export interface ArticleGridProps { export interface ArticleGridProps {
articles: ArticleModel[]; articles: ArticlesModel;
onSelectArticle: (index: number) => void; onSelectArticle: (index: ArticleModel) => void;
xs?: number; // default 12 for mobile full-width xs?: number; // default 12 for mobile full-width
md12?: number, // default 12 (full-width)
md6?: number; // default 6 (half-width) md6?: number; // default 6 (half-width)
md4?: number; // default 4 (third-width) md4?: number; // default 4 (third-width)
nested?: 1 | 2; // number of stacked cards in a nested column 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;
}

View File

@@ -37,4 +37,41 @@ export const StyledTypography = styled(Typography)({
WebkitLineClamp: 2, WebkitLineClamp: 2,
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', 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
View 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 };
}

View File

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

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,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}>
<ArticleProvider> <AuthorProvider>
<Blog /> <ArticleProvider>
</ArticleProvider> <Blog />
</ArticleProvider>
</AuthorProvider>
</AuthProvider> </AuthProvider>
</React.StrictMode>, </UploadProvider>
); );

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

@@ -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 {