Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d29efe53e0 | |||
| 089e5e1716 | |||
| 2374d9a437 | |||
| ef7ed61665 | |||
| 8a29261a3e | |||
| 89aa1c6ce4 | |||
| 557e8ddfc9 | |||
| 0267aedf52 | |||
| 1c964a7fee | |||
| 661f8c915b | |||
| b2a7df5760 | |||
| 3bf0a5839c | |||
| 90e6a85fff | |||
| 42fe31fc69 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aetoskia-blog-app",
|
"name": "aetoskia-blog-app",
|
||||||
"version": "0.0.8",
|
"version": "0.1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -2,16 +2,28 @@ import * as React 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 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 Article from './components/Article';
|
||||||
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 { useArticles } from './providers/Article';
|
import { useArticles } from './providers/Article';
|
||||||
|
import { useAuth } from './providers/Author';
|
||||||
|
|
||||||
|
type View = 'home' | 'login' | 'register' | 'article' | 'profile';
|
||||||
|
|
||||||
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 [selectedArticle, setSelectedArticle] = React.useState<number | null>(null);
|
const [selectedArticle, setSelectedArticle] = React.useState<number | null>(null);
|
||||||
|
const [showLogin, setShowLogin] = React.useState(false);
|
||||||
|
const [showRegister, setShowRegister] = React.useState(false);
|
||||||
|
const [showProfile, setShowProfile] = React.useState(false);
|
||||||
|
|
||||||
const handleSelectArticle = (index: number) => {
|
const handleSelectArticle = (index: number) => {
|
||||||
setSelectedArticle(index);
|
setSelectedArticle(index);
|
||||||
@@ -20,6 +32,99 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
|
|||||||
|
|
||||||
const handleBack = () => setSelectedArticle(null);
|
const handleBack = () => setSelectedArticle(null);
|
||||||
|
|
||||||
|
const handleShowLogin = () => {
|
||||||
|
setShowLogin(true);
|
||||||
|
setShowRegister(false);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
const handleShowRegister = () => {
|
||||||
|
setShowRegister(true);
|
||||||
|
setShowLogin(false);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
const handleHideAuth = () => {
|
||||||
|
setShowLogin(false);
|
||||||
|
setShowRegister(false);
|
||||||
|
};
|
||||||
|
const handleShowProfile = () => {
|
||||||
|
setShowProfile(true);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
const handleHideProfile = () => {
|
||||||
|
setShowProfile(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// derive a single source of truth for view
|
||||||
|
const view: View = React.useMemo(() => {
|
||||||
|
if (selectedArticle !== null) return 'article';
|
||||||
|
if (showRegister) return 'register';
|
||||||
|
if (showLogin) return 'login';
|
||||||
|
if (showProfile) return 'profile';
|
||||||
|
return 'home';
|
||||||
|
}, [selectedArticle, showLogin, showRegister, showProfile]);
|
||||||
|
|
||||||
|
// render function keeps JSX tidy
|
||||||
|
const renderView = () => {
|
||||||
|
switch (view) {
|
||||||
|
case 'register':
|
||||||
|
return <Register onBack={handleHideAuth} />;
|
||||||
|
case 'login':
|
||||||
|
return (
|
||||||
|
<Login
|
||||||
|
onBack={handleHideAuth}
|
||||||
|
onRegister={() => {
|
||||||
|
handleShowRegister();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'profile':
|
||||||
|
return (
|
||||||
|
<Profile
|
||||||
|
onBack={handleHideProfile}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'article':
|
||||||
|
if (selectedArticle == null || !articles[selectedArticle]) return null;
|
||||||
|
return <Article article={articles[selectedArticle]} onBack={handleBack} />;
|
||||||
|
case 'home':
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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)}
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<MainContent articles={articles} onSelectArticle={handleSelectArticle} />
|
||||||
|
<Latest
|
||||||
|
articles={articles}
|
||||||
|
onSelectArticle={handleSelectArticle}
|
||||||
|
onLoadMore={async () => {}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<AppTheme {...props}>
|
<AppTheme {...props}>
|
||||||
@@ -64,13 +169,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
|
|||||||
<AppTheme {...props}>
|
<AppTheme {...props}>
|
||||||
<CssBaseline enableColorScheme />
|
<CssBaseline enableColorScheme />
|
||||||
|
|
||||||
<Box
|
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||||
sx={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
minHeight: '100vh',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Container
|
<Container
|
||||||
maxWidth="lg"
|
maxWidth="lg"
|
||||||
component="main"
|
component="main"
|
||||||
@@ -80,29 +179,13 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
my: 4,
|
my: 4,
|
||||||
gap: 4,
|
gap: 4,
|
||||||
pb: selectedArticle === null ? 24 : 0, // space for fixed footer on home
|
pb: view === 'home' ? 24 : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectedArticle === null ? (
|
{renderView()}
|
||||||
<>
|
|
||||||
<MainContent
|
|
||||||
articles={articles}
|
|
||||||
onSelectArticle={handleSelectArticle}
|
|
||||||
/>
|
|
||||||
<Latest
|
|
||||||
articles={articles}
|
|
||||||
onSelectArticle={handleSelectArticle}
|
|
||||||
onLoadMore={async (offset, limit) => {
|
|
||||||
// Optional pagination call
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Article article={articles[selectedArticle]} onBack={handleBack} />
|
|
||||||
)}
|
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
{selectedArticle === null && (
|
{view === 'home' && (
|
||||||
<Box
|
<Box
|
||||||
component="footer"
|
component="footer"
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { marked } from 'marked';
|
|||||||
import { Box, Typography, Avatar, Divider, IconButton, Chip } from '@mui/material';
|
import { Box, Typography, Avatar, Divider, IconButton, Chip } from '@mui/material';
|
||||||
import { styled } from '@mui/material/styles';
|
import { styled } from '@mui/material/styles';
|
||||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||||
|
import { ArticleProps } from '../types/props';
|
||||||
|
|
||||||
const ArticleContainer = styled(Box)(({ theme }) => ({
|
const ArticleContainer = styled(Box)(({ theme }) => ({
|
||||||
maxWidth: '800px',
|
maxWidth: '800px',
|
||||||
@@ -23,11 +24,9 @@ const CoverImage = styled('img')({
|
|||||||
|
|
||||||
export default function Article({
|
export default function Article({
|
||||||
article,
|
article,
|
||||||
onBack,
|
onBack
|
||||||
}: {
|
}: ArticleProps) {
|
||||||
article: any;
|
|
||||||
onBack: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<ArticleContainer>
|
<ArticleContainer>
|
||||||
<IconButton onClick={onBack} sx={{ mb: 2 }}>
|
<IconButton onClick={onBack} sx={{ mb: 2 }}>
|
||||||
@@ -44,16 +43,19 @@ export default function Article({
|
|||||||
<Typography variant="h3" component="h1" gutterBottom fontWeight="bold">
|
<Typography variant="h3" component="h1" gutterBottom fontWeight="bold">
|
||||||
{article.title}
|
{article.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h5" color="text.secondary" gutterBottom>
|
|
||||||
{article.subtitle}
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2, mb: 1 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2, mb: 1 }}>
|
||||||
<Avatar src={article.authors[0].avatar} alt={article.authors[0].name} />
|
<Avatar src={article.authors[0].avatar} alt={article.authors[0].name} />
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2">{article.authors[0].name}</Typography>
|
<Typography variant="subtitle2">{article.authors[0].name}</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{article.authors[0].date}
|
{new Date(article.created_at).toLocaleString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Typography from '@mui/material/Typography';
|
|||||||
import { styled } from '@mui/material/styles';
|
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 type { Article } from '../providers/Article'; // ✅ import type for correctness
|
import { LatestProps } from "../types/props";
|
||||||
import Fade from '@mui/material/Fade'; // ✅ for smooth appearance
|
import Fade from '@mui/material/Fade'; // ✅ for smooth appearance
|
||||||
|
|
||||||
|
|
||||||
@@ -89,13 +89,6 @@ function Author({ authors }: { authors: { name: string; avatar: string }[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Latest component ---- //
|
|
||||||
interface LatestProps {
|
|
||||||
articles: Article[];
|
|
||||||
onSelectArticle?: (index: number) => void;
|
|
||||||
onLoadMore?: (offset: number, limit: number) => Promise<void>; // optional async callback
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Latest({ articles, onSelectArticle, onLoadMore }: LatestProps) {
|
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);
|
||||||
|
|||||||
107
src/blog/components/Login.tsx
Normal file
107
src/blog/components/Login.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
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 default function Login({ onBack, onRegister }: LoginProps) {
|
||||||
|
const { login, loading, error, currentUser } = useAuth();
|
||||||
|
const [username, setUsername] = React.useState('');
|
||||||
|
const [password, setPassword] = React.useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
await login(username, password);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ✅ Auto-return if already logged in
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (currentUser) onBack();
|
||||||
|
}, [currentUser]);
|
||||||
|
|
||||||
|
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 In
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||||
|
Please log in 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={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Typography color="error" variant="body2" sx={{ mt: 1 }}>
|
||||||
|
{error}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
sx={{ mt: 3 }}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? <CircularProgress size={24} color="inherit" /> : 'Login'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
align="center"
|
||||||
|
sx={{ mt: 3 }}
|
||||||
|
>
|
||||||
|
Don’t have an account?{' '}
|
||||||
|
<Link
|
||||||
|
component="button"
|
||||||
|
underline="hover"
|
||||||
|
color="primary"
|
||||||
|
onClick={onRegister}
|
||||||
|
sx={{ fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
src/blog/components/Profile.tsx
Normal file
166
src/blog/components/Profile.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
IconButton,
|
||||||
|
CircularProgress,
|
||||||
|
Avatar,
|
||||||
|
Alert,
|
||||||
|
} from '@mui/material';
|
||||||
|
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||||
|
import { useAuth } from '../providers/Author';
|
||||||
|
|
||||||
|
interface ProfileProps {
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Profile({ onBack }: ProfileProps) {
|
||||||
|
const { currentUser, loading, error, token, refreshAuthors, updateProfile } = useAuth();
|
||||||
|
const [formData, setFormData] = React.useState({
|
||||||
|
username: currentUser?.username || '',
|
||||||
|
name: currentUser?.name || '',
|
||||||
|
email: currentUser?.email || '',
|
||||||
|
avatar: currentUser?.avatar || '',
|
||||||
|
});
|
||||||
|
const [success, setSuccess] = React.useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (currentUser) setFormData(currentUser);
|
||||||
|
}, [currentUser]);
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!currentUser) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
setSuccess(null);
|
||||||
|
|
||||||
|
const updatedUser = { ...currentUser, ...formData };
|
||||||
|
console.log('updatedUser');
|
||||||
|
console.log(updatedUser);
|
||||||
|
const updated = await updateProfile(updatedUser);
|
||||||
|
|
||||||
|
if (updated) setSuccess('Profile updated successfully');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to update profile:', err);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
maxWidth: 400,
|
||||||
|
mx: 'auto',
|
||||||
|
mt: 8,
|
||||||
|
p: 4,
|
||||||
|
borderRadius: 3,
|
||||||
|
boxShadow: 3,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h6" align="center">
|
||||||
|
You must be logged in to view your profile.
|
||||||
|
</Typography>
|
||||||
|
<Button fullWidth variant="outlined" sx={{ mt: 2 }} onClick={onBack}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
maxWidth: 500,
|
||||||
|
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>
|
||||||
|
Profile
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||||
|
<Avatar
|
||||||
|
src={formData.avatar}
|
||||||
|
alt={formData.name || formData.username}
|
||||||
|
sx={{ width: 64, height: 64 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Avatar URL"
|
||||||
|
name="avatar"
|
||||||
|
fullWidth
|
||||||
|
value={formData.avatar}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Username"
|
||||||
|
name="username"
|
||||||
|
margin="normal"
|
||||||
|
value={formData.username}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Full Name"
|
||||||
|
name="name"
|
||||||
|
margin="normal"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
margin="normal"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mt: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<Alert severity="success" sx={{ mt: 2 }}>
|
||||||
|
{success}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
sx={{ mt: 3 }}
|
||||||
|
disabled={saving || loading}
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
{saving ? <CircularProgress size={24} color="inherit" /> : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/blog/components/Register.tsx
Normal file
114
src/blog/components/Register.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Box, TextField, Button, Typography, IconButton, CircularProgress, Alert, } from '@mui/material';
|
||||||
|
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||||
|
import { useAuth } from '../providers/Author';
|
||||||
|
|
||||||
|
interface RegisterProps {
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Register({ onBack }: RegisterProps) {
|
||||||
|
const { register, loading, error, currentUser } = useAuth();
|
||||||
|
const [username, setUsername] = React.useState('');
|
||||||
|
const [password1, setPassword1] = React.useState('');
|
||||||
|
const [password2, setPassword2] = React.useState('');
|
||||||
|
const [localError, setLocalError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLocalError(null);
|
||||||
|
|
||||||
|
// ✅ Local validation
|
||||||
|
if (password1 !== password2) {
|
||||||
|
setLocalError("Passwords don't match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password1.length < 6) {
|
||||||
|
setLocalError('Password must be at least 6 characters long');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Call backend
|
||||||
|
await register(username, password1);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (currentUser) {
|
||||||
|
// ✅ if logged in, auto-return to the article list
|
||||||
|
onBack();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
maxWidth: 400,
|
||||||
|
mx: 'auto',
|
||||||
|
mt: 8,
|
||||||
|
p: 4,
|
||||||
|
borderRadius: 3,
|
||||||
|
boxShadow: 3,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton onClick={onBack} sx={{ mb: 2 }}>
|
||||||
|
<ArrowBackRoundedIcon />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<Typography variant="h4" fontWeight="bold" gutterBottom>
|
||||||
|
Sign Up
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||||
|
Please sign up to continue
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Username"
|
||||||
|
type="username"
|
||||||
|
margin="normal"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
margin="normal"
|
||||||
|
value={password1}
|
||||||
|
onChange={(e) => setPassword1(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
margin="normal"
|
||||||
|
value={password2}
|
||||||
|
onChange={(e) => setPassword2(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(localError || error) && (
|
||||||
|
<Alert severity="error" sx={{ mt: 2 }}>
|
||||||
|
{localError || error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
sx={{ mt: 3 }}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? <CircularProgress size={24} color="inherit" /> : 'Register'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,73 +1,44 @@
|
|||||||
import React, { createContext, useState, useContext, useEffect } from 'react';
|
import React, { createContext, useState, useContext, useEffect } from 'react';
|
||||||
import axios from 'axios';
|
import { api } from '../utils/api';
|
||||||
|
import { ArticleModel } from '../types/models';
|
||||||
|
import { ArticleContextModel } from '../types/contexts';
|
||||||
|
import { useAuth } from './Author';
|
||||||
|
|
||||||
interface Author {
|
const ArticleContext = createContext<ArticleContextModel | undefined>(undefined);
|
||||||
_id?: string | null;
|
|
||||||
username: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
avatar: string;
|
|
||||||
is_active: boolean;
|
|
||||||
created_at?: string;
|
|
||||||
updated_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Article {
|
|
||||||
_id?: string | null;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
img: string;
|
|
||||||
tag: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
content: string;
|
|
||||||
authors: Author[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ArticleContextType {
|
|
||||||
articles: Article[];
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
refreshArticles: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ArticleContext = createContext<ArticleContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL;
|
|
||||||
|
|
||||||
export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const [articles, setArticles] = useState<Article[]>([]);
|
const [articles, setArticles] = useState<ArticleModel[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { token } = useAuth(); // ✅ access token if needed
|
||||||
|
|
||||||
|
/** 🔹 Fetch articles (JWT automatically attached by api.ts interceptor) */
|
||||||
const fetchArticles = async () => {
|
const fetchArticles = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// ✅ Use correct full endpoint from OpenAPI spec
|
const res = await api.get<ArticleModel[]>('/articles', { params: { skip: 0, limit: 10 } });
|
||||||
const res = await axios.get<Article[]>(`${API_BASE}/articles`, {
|
const formatted = res.data.map((a) => ({ ...a, id: a._id || undefined }));
|
||||||
params: { skip: 0, limit: 10 },
|
|
||||||
});
|
|
||||||
|
|
||||||
// ✅ Normalize if backend sends _id instead of id
|
|
||||||
const formatted = res.data.map((a) => ({
|
|
||||||
...a,
|
|
||||||
id: a._id || undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setArticles(formatted);
|
setArticles(formatted);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to fetch articles:', err);
|
console.error('Failed to fetch articles:', err);
|
||||||
setError(err.message || 'Failed to fetch articles');
|
setError(err.response?.data?.detail || 'Failed to fetch articles');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 🔹 Auto-fetch articles whenever user logs in/out */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Always load once on mount
|
||||||
|
// If endpoint requires JWT, fallback safely
|
||||||
|
if (!token) {
|
||||||
|
fetchArticles().catch(() => setLoading(false)); // try anyway (handles both public/protected)
|
||||||
|
} else {
|
||||||
fetchArticles();
|
fetchArticles();
|
||||||
}, []);
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ArticleContext.Provider value={{ articles, loading, error, refreshArticles: fetchArticles }}>
|
<ArticleContext.Provider value={{ articles, loading, error, refreshArticles: fetchArticles }}>
|
||||||
@@ -76,7 +47,7 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useArticles = (): ArticleContextType => {
|
export const useArticles = (): ArticleContextModel => {
|
||||||
const ctx = useContext(ArticleContext);
|
const ctx = useContext(ArticleContext);
|
||||||
if (!ctx) throw new Error('useArticles must be used inside ArticleProvider');
|
if (!ctx) throw new Error('useArticles must be used inside ArticleProvider');
|
||||||
return ctx;
|
return ctx;
|
||||||
|
|||||||
145
src/blog/providers/Author.tsx
Normal file
145
src/blog/providers/Author.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import React, { createContext, useState, useEffect, useContext } from 'react';
|
||||||
|
import { api } from '../utils/api';
|
||||||
|
import { AuthorModel } from '../types/models';
|
||||||
|
import { AuthContextModel } from '../types/contexts';
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextModel | undefined>(undefined);
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
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 [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
/** 🔹 Register new user */
|
||||||
|
const register = async (username: string, password: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await api.post('/auth/register', { username, password });
|
||||||
|
return res.data; // returns PublicUser from backend
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Registration failed:', err);
|
||||||
|
setError(err.response?.data?.detail || 'Registration failed');
|
||||||
|
} 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 {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await api.get<AuthorModel[]>('/authors');
|
||||||
|
setAuthors(res.data);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to fetch authors:', err);
|
||||||
|
setError(err.response?.data?.detail || 'Failed to fetch authors');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 🔹 Update current user (full model) */
|
||||||
|
const updateProfile = async (userData: AuthorModel) => {
|
||||||
|
if (!userData._id) {
|
||||||
|
console.error('updateProfile called without _id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await api.put<AuthorModel>(`/authors/${userData._id}`, userData);
|
||||||
|
setCurrentUser(res.data);
|
||||||
|
return res.data;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Profile update failed:', err);
|
||||||
|
setError(err.response?.data?.detail || 'Failed to update profile');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/** 🔹 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(); // invalid/expired token
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 🔹 On mount, try to fetch user if token exists */
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) fetchCurrentUser();
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
currentUser,
|
||||||
|
authors,
|
||||||
|
token,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
register,
|
||||||
|
refreshAuthors,
|
||||||
|
updateProfile,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = (): AuthContextModel => {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error('useAuth must be used inside AuthProvider');
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
21
src/blog/types/contexts.ts
Normal file
21
src/blog/types/contexts.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { ArticleModel, AuthorModel } from "./models";
|
||||||
|
|
||||||
|
export interface ArticleContextModel {
|
||||||
|
articles: ArticleModel[];
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refreshArticles: () => Promise<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>;
|
||||||
|
}
|
||||||
30
src/blog/types/models.ts
Normal file
30
src/blog/types/models.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export interface AuthorModel {
|
||||||
|
// 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 {
|
||||||
|
// meta fields
|
||||||
|
_id?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
|
||||||
|
// model fields
|
||||||
|
img: string;
|
||||||
|
tag: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
// ref fields
|
||||||
|
authors: AuthorModel[];
|
||||||
|
}
|
||||||
12
src/blog/types/props.ts
Normal file
12
src/blog/types/props.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { ArticleModel } from "./models";
|
||||||
|
|
||||||
|
export interface LatestProps {
|
||||||
|
articles: ArticleModel[];
|
||||||
|
onSelectArticle?: (index: number) => void;
|
||||||
|
onLoadMore?: (offset: number, limit: number) => Promise<void>; // optional async callback
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArticleProps {
|
||||||
|
article: ArticleModel;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
33
src/blog/utils/api.ts
Normal file
33
src/blog/utils/api.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// src/utils/api.ts
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_BASE_URL;
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: API_BASE,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔹 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);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -2,14 +2,17 @@ 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';
|
||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
const rootElement = document.getElementById('root');
|
||||||
const root = createRoot(rootElement);
|
const root = createRoot(rootElement);
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
<AuthProvider>
|
||||||
<ArticleProvider>
|
<ArticleProvider>
|
||||||
<Blog />
|
<Blog />
|
||||||
</ArticleProvider>
|
</ArticleProvider>
|
||||||
|
</AuthProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user