Compare commits
35 Commits
945912f16d
...
0.2.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 3aaf328511 | |||
| 635e99c183 | |||
| b8e4decfba | |||
| 459fa5855c | |||
| f52c4a5287 | |||
| 3a3f44c5b5 | |||
| 479ffb736c | |||
| 87bdafb6a3 | |||
| 383b424bdf | |||
| 0340e17467 | |||
| f15155d31c | |||
| c2e6daca13 | |||
| c0bcd0e3e4 | |||
| 333f931cff | |||
| 3960de3ecb | |||
| 763629faa1 | |||
| a7e3ed46cb | |||
| 4a8c59895e | |||
| ec9b5c905a | |||
| d7e9827819 | |||
| ae0bc7dd12 | |||
| 1e6c80f1b3 | |||
| 8ff8b9236e | |||
| 142b169108 | |||
| 80bf87529e | |||
| 5582d18a01 | |||
| 913755d971 | |||
| 8838ff10f4 | |||
| 7a28dde7d5 | |||
| d6c84abdf6 | |||
| 1b755968dd | |||
| 33e9d70b98 | |||
| ce91526599 | |||
| 73d64ea497 | |||
| e16804b65d |
1385
package-lock.json
generated
1385
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aetoskia-blog-app",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -14,7 +14,9 @@
|
||||
"@mui/icons-material": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"react-markdown": "latest",
|
||||
"markdown-to-jsx": "latest",
|
||||
"remark-gfm": "latest",
|
||||
"marked": "latest",
|
||||
"axios": "latest"
|
||||
},
|
||||
|
||||
@@ -5,7 +5,8 @@ import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import AppTheme from '../shared-theme/AppTheme';
|
||||
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 Footer from './components/Footer';
|
||||
import Login from './components/Login';
|
||||
@@ -13,116 +14,124 @@ import Register from './components/Register';
|
||||
import Profile from './components/Profile';
|
||||
import { useArticles } from './providers/Article';
|
||||
import { useAuth } from './providers/Author';
|
||||
import { View, useViewRouter } from "./types/views";
|
||||
import { ArticleModel } from "./types/models";
|
||||
import { ArticleViewProps, ArticleEditorProps } from "./types/props";
|
||||
|
||||
type View = 'home' | 'login' | 'register' | 'article' | 'profile';
|
||||
function HomeView({ currentUser, open_login, 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_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 }) {
|
||||
const { articles, loading, error } = useArticles();
|
||||
const { currentUser } = useAuth();
|
||||
|
||||
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 [ui, setUI] = React.useState({
|
||||
selectedArticle: null as number | null,
|
||||
view: "home" as View,
|
||||
});
|
||||
|
||||
const handleSelectArticle = (index: number) => {
|
||||
setSelectedArticle(index);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
const {
|
||||
goBack,
|
||||
navigateToChildren,
|
||||
openArticle,
|
||||
} = useViewRouter(setUI);
|
||||
|
||||
type RouterContext = {
|
||||
ui: any;
|
||||
articles: ArticleModel[];
|
||||
currentUser: any;
|
||||
openArticle: (index: number) => void;
|
||||
};
|
||||
|
||||
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);
|
||||
type ViewComponentEntry<P> = {
|
||||
component: React.ComponentType<P>;
|
||||
extraProps?: (ctx: RouterContext) => Partial<P>;
|
||||
};
|
||||
|
||||
// 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]);
|
||||
const VIEW_COMPONENTS: Record<View, ViewComponentEntry<any>> = {
|
||||
home: {
|
||||
component: HomeView,
|
||||
},
|
||||
|
||||
login: {
|
||||
component: Login,
|
||||
},
|
||||
|
||||
register: {
|
||||
component: Register,
|
||||
},
|
||||
|
||||
profile: {
|
||||
component: Profile,
|
||||
},
|
||||
|
||||
article: {
|
||||
component: ArticleView,
|
||||
extraProps: ({ ui, articles }) => ({
|
||||
article: articles[ui.selectedArticle!],
|
||||
}) satisfies Partial<ArticleViewProps>,
|
||||
},
|
||||
|
||||
editor: {
|
||||
component: ArticleEditor,
|
||||
extraProps: ({ ui, articles }) => ({
|
||||
article: ui.selectedArticle !== null ? articles[ui.selectedArticle] : null,
|
||||
}) satisfies Partial<ArticleEditorProps>,
|
||||
},
|
||||
|
||||
create: {
|
||||
component: ArticleEditor,
|
||||
extraProps: () => ({
|
||||
article: null,
|
||||
}) satisfies Partial<ArticleEditorProps>,
|
||||
},
|
||||
};
|
||||
|
||||
// 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>
|
||||
const entry = VIEW_COMPONENTS[ui.view];
|
||||
const ViewComponent = entry.component;
|
||||
|
||||
<MainContent articles={articles} onSelectArticle={handleSelectArticle} />
|
||||
<Latest
|
||||
articles={articles}
|
||||
onSelectArticle={handleSelectArticle}
|
||||
onLoadMore={async () => {}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
const childNav = navigateToChildren(ui.view);
|
||||
|
||||
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) {
|
||||
@@ -179,13 +188,13 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
|
||||
flexDirection: 'column',
|
||||
my: 4,
|
||||
gap: 4,
|
||||
pb: view === 'home' ? 24 : 0,
|
||||
pb: ui.view === 'home' ? 24 : 0,
|
||||
}}
|
||||
>
|
||||
{renderView()}
|
||||
</Container>
|
||||
|
||||
{view === 'home' && (
|
||||
{ui.view === 'home' && (
|
||||
<Box
|
||||
component="footer"
|
||||
sx={{
|
||||
|
||||
192
src/blog/components/Article/ArticleEditor.tsx
Normal file
192
src/blog/components/Article/ArticleEditor.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import * as React from 'react';
|
||||
import { Box, Typography, Divider, IconButton, TextField, Button } from '@mui/material';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||
import { ArticleEditorProps } from '../../types/props';
|
||||
import { ArticleModel } from "../../types/models";
|
||||
import { useUpload } from "../../providers/Upload";
|
||||
import { useArticles } from "../../providers/Article";
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import ImageUploadField from "../ImageUploadField";
|
||||
|
||||
const ArticleContainer = styled(Box)(({ theme }) => ({
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
padding: theme.spacing(4),
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
padding: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
|
||||
const CoverImage = styled('img')({
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
borderRadius: '12px',
|
||||
marginTop: '16px',
|
||||
marginBottom: '24px',
|
||||
});
|
||||
|
||||
export default function ArticleView({
|
||||
article,
|
||||
onBack,
|
||||
}: ArticleEditorProps) {
|
||||
|
||||
const { uploadFile } = useUpload();
|
||||
const { updateArticle, createArticle } = useArticles();
|
||||
|
||||
const [title, setTitle] = React.useState(article?.title ?? "");
|
||||
const [description, setDescription] = React.useState(article?.description ?? "");
|
||||
const [tag, setTag] = React.useState(article?.tag ?? "");
|
||||
const [img, setImg] = React.useState(article?.img ?? "");
|
||||
const [uploadingCoverImage, setUploadingCoverImage] = React.useState(false);
|
||||
const [content, setContent] = React.useState(article?.content ?? "");
|
||||
|
||||
const handleCoverImageUpload = async (file: File) => {
|
||||
setUploadingCoverImage(true);
|
||||
|
||||
try {
|
||||
const img = await uploadFile(file);
|
||||
if (img) {
|
||||
setImg(img);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Avatar upload failed:", err);
|
||||
} finally {
|
||||
setUploadingCoverImage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveArticle = async (articleData: Partial<ArticleModel>) => {
|
||||
// If _id exists → UPDATE
|
||||
if (articleData._id) {
|
||||
console.log("Updating article with ID:", articleData._id);
|
||||
return await updateArticle(articleData as ArticleModel);
|
||||
}
|
||||
|
||||
// No _id → CREATE
|
||||
console.log("Creating new article:", articleData);
|
||||
return await createArticle(articleData as ArticleModel);
|
||||
};
|
||||
|
||||
return (
|
||||
<ArticleContainer>
|
||||
{/* BACK BUTTON */}
|
||||
<IconButton onClick={onBack} sx={{ mb: 2 }}>
|
||||
<ArrowBackRoundedIcon />
|
||||
</IconButton>
|
||||
|
||||
{/* TAG */}
|
||||
<TextField
|
||||
label="Tag"
|
||||
fullWidth
|
||||
value={tag}
|
||||
onChange={(e) => setTag(e.target.value)}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
{/* TITLE */}
|
||||
<TextField
|
||||
label="Title"
|
||||
fullWidth
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
{/* DESCRIPTION */}
|
||||
<TextField
|
||||
label="Description"
|
||||
fullWidth
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
sx={{ mb: 3 }}
|
||||
/>
|
||||
|
||||
<Divider sx={{ mb: 3 }} />
|
||||
|
||||
<ImageUploadField
|
||||
label="Cover Image"
|
||||
value={img}
|
||||
uploading={uploadingCoverImage}
|
||||
onUpload={handleCoverImageUpload}
|
||||
size={128}
|
||||
/>
|
||||
|
||||
<Divider sx={{ mb: 3 }} />
|
||||
|
||||
{/* MARKDOWN EDITOR */}
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
alignItems: 'stretch'
|
||||
}}>
|
||||
<Typography variant="h6">Content</Typography>
|
||||
|
||||
<Box
|
||||
component="textarea"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '300px',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
background: 'transparent',
|
||||
color: 'inherit',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '16px',
|
||||
lineHeight: 1.6,
|
||||
resize: 'vertical',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* LIVE PREVIEW */}
|
||||
<Typography variant="h6" sx={{ mt: 4 }}>
|
||||
Preview
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 2,
|
||||
'& h3': { fontWeight: 600, mt: 4 },
|
||||
'& p': { color: 'text.primary', lineHeight: 1.8, mt: 2 },
|
||||
'& em': { fontStyle: 'italic' },
|
||||
'& ul': { pl: 3 },
|
||||
}}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* ACTIONS */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2, mt: 4 }}>
|
||||
<Button variant="outlined" color="secondary" onClick={onBack}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() =>
|
||||
handleSaveArticle({
|
||||
...article,
|
||||
title,
|
||||
tag,
|
||||
img,
|
||||
description,
|
||||
content,
|
||||
})
|
||||
}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Box>
|
||||
</ArticleContainer>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import { marked } from 'marked';
|
||||
import { Box, Typography, Divider, IconButton, Chip } from '@mui/material';
|
||||
import { styled } from '@mui/material/styles';
|
||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||
import { ArticleMeta } from "./ArticleMeta";
|
||||
import { ArticleProps } from '../types/props';
|
||||
import EditRoundedIcon from '@mui/icons-material/EditRounded';
|
||||
import { ArticleMeta } from "../ArticleMeta";
|
||||
import { ArticleViewProps } from '../../types/props';
|
||||
import {useAuth} from "../../providers/Author";
|
||||
|
||||
const ArticleContainer = styled(Box)(({ theme }) => ({
|
||||
maxWidth: '800px',
|
||||
@@ -23,23 +25,34 @@ const CoverImage = styled('img')({
|
||||
marginBottom: '24px',
|
||||
});
|
||||
|
||||
export default function Article({
|
||||
export default function ArticleView({
|
||||
article,
|
||||
onBack
|
||||
}: ArticleProps) {
|
||||
onBack,
|
||||
open_editor,
|
||||
}: ArticleViewProps) {
|
||||
|
||||
const { currentUser } = useAuth();
|
||||
const onEdit = open_editor;
|
||||
|
||||
return (
|
||||
<ArticleContainer>
|
||||
<IconButton onClick={onBack} sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<IconButton onClick={onBack}>
|
||||
<ArrowBackRoundedIcon />
|
||||
</IconButton>
|
||||
|
||||
<Chip
|
||||
label={article.tag}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
sx={{ mb: 2, textTransform: 'uppercase', fontWeight: 500 }}
|
||||
/>
|
||||
{currentUser && (
|
||||
<IconButton onClick={onEdit}>
|
||||
<EditRoundedIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="h3" component="h1" gutterBottom fontWeight="bold">
|
||||
{article.title}
|
||||
@@ -49,7 +62,21 @@ export default function Article({
|
||||
|
||||
<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
|
||||
sx={{
|
||||
51
src/blog/components/ArticleCards/ArticleCardSize12.tsx
Normal file
51
src/blog/components/ArticleCards/ArticleCardSize12.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { CardMedia, Typography } from '@mui/material';
|
||||
import { ArticleMeta } from "../ArticleMeta";
|
||||
import { ArticleCardProps } from "../../types/props";
|
||||
import { StyledCard, StyledCardContent, StyledTypography } from "../../types/styles";
|
||||
|
||||
|
||||
export default function ArticleCardSize12({
|
||||
article,
|
||||
index,
|
||||
focusedCardIndex,
|
||||
onSelectArticle,
|
||||
onFocus,
|
||||
onBlur,
|
||||
}: ArticleCardProps) {
|
||||
return (
|
||||
<StyledCard
|
||||
variant="outlined"
|
||||
onClick={() => onSelectArticle(index)}
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -24,8 +24,12 @@ export default function ArticleCardSize4({
|
||||
>
|
||||
<CardMedia
|
||||
component="img"
|
||||
alt="green iguana"
|
||||
image={article.img}
|
||||
alt={article.title}
|
||||
image={(
|
||||
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||
"/" +
|
||||
(article.img?.replace(/^\/+/, "") || "")
|
||||
)}
|
||||
sx={{
|
||||
height: { sm: 'auto', md: '50%' },
|
||||
aspectRatio: { sm: '16 / 9', md: '' },
|
||||
|
||||
@@ -25,7 +25,11 @@ export default function ArticleCardSize6({
|
||||
<CardMedia
|
||||
component="img"
|
||||
alt={article.title}
|
||||
image={article.img}
|
||||
image={(
|
||||
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||
"/" +
|
||||
(article.img?.replace(/^\/+/, "") || "")
|
||||
)}
|
||||
sx={{
|
||||
aspectRatio: '16 / 9',
|
||||
borderBottom: '1px solid',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Grid, Box } from '@mui/material';
|
||||
import ArticleCardSize12 from './ArticleCardSize12';
|
||||
import ArticleCardSize6 from './ArticleCardSize6';
|
||||
import ArticleCardSize4 from './ArticleCardSize4';
|
||||
import ArticleCardSize2 from './ArticleCardSize2';
|
||||
@@ -10,6 +11,7 @@ export default function ArticleCardsGrid({
|
||||
articles,
|
||||
onSelectArticle,
|
||||
xs = 12,
|
||||
md12 = 12,
|
||||
md6 = 6,
|
||||
md4 = 4,
|
||||
nested = 2,
|
||||
@@ -30,8 +32,9 @@ export default function ArticleCardsGrid({
|
||||
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 =
|
||||
type === '12' ? ArticleCardSize12 :
|
||||
type === '6' ? ArticleCardSize6 :
|
||||
type === '4' ? ArticleCardSize4 :
|
||||
ArticleCardSize2;
|
||||
@@ -51,6 +54,17 @@ export default function ArticleCardsGrid({
|
||||
|
||||
return (
|
||||
<Grid container spacing={2} columns={12}>
|
||||
{/* ---- 1 article: 12 ---- */}
|
||||
{count === 1 && (
|
||||
<>
|
||||
{visibleArticles.map((a, i) => (
|
||||
<Grid key={i} size={{ xs, md: md12 }}>
|
||||
{renderCard(a, i, '12')}
|
||||
</Grid>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ---- 2 articles: 6 | 6 ---- */}
|
||||
{count === 2 && (
|
||||
<>
|
||||
|
||||
@@ -30,12 +30,11 @@ export function ArticleMeta({
|
||||
<Avatar
|
||||
key={index}
|
||||
alt={author.name}
|
||||
src={
|
||||
(import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||
"/" +
|
||||
(author.avatar?.replace(/^\/+/, "") || "")
|
||||
)
|
||||
}
|
||||
src={(
|
||||
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||
"/" +
|
||||
(author.avatar?.replace(/^\/+/, "") || "")
|
||||
)}
|
||||
sx={{ width: 24, height: 24 }}
|
||||
/>
|
||||
))}
|
||||
|
||||
45
src/blog/components/ImageUploadField.tsx
Normal file
45
src/blog/components/ImageUploadField.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as React from "react";
|
||||
import { Box, Button, Avatar, CircularProgress } from "@mui/material";
|
||||
import { ImageUploadFieldProps } from "../types/props";
|
||||
|
||||
export default function ImageUploadField({
|
||||
label = "Upload Image",
|
||||
value,
|
||||
uploading = false,
|
||||
onUpload,
|
||||
size = 64,
|
||||
}: ImageUploadFieldProps) {
|
||||
|
||||
const imgSrc = value
|
||||
? import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||
"/" +
|
||||
value.replace(/^\/+/, "")
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 3 }}>
|
||||
<Avatar
|
||||
src={imgSrc}
|
||||
sx={{ width: size, height: size, borderRadius: 2 }}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
component="label"
|
||||
disabled={uploading}
|
||||
startIcon={uploading && <CircularProgress size={16} />}
|
||||
>
|
||||
{uploading ? "Uploading..." : label}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) onUpload(file);
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -75,7 +75,11 @@ function Author({ authors }: { authors: { name: string; avatar: string }[] }) {
|
||||
<Avatar
|
||||
key={index}
|
||||
alt={author.name}
|
||||
src={author.avatar}
|
||||
src={(
|
||||
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||
"/" +
|
||||
(author.avatar?.replace(/^\/+/, "") || "")
|
||||
)}
|
||||
sx={{ width: 24, height: 24 }}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -6,18 +6,20 @@ import {
|
||||
Typography,
|
||||
IconButton,
|
||||
CircularProgress,
|
||||
Avatar,
|
||||
Alert,
|
||||
} from '@mui/material';
|
||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||
import { useAuth } from '../providers/Author';
|
||||
import { useUpload } from "../providers/Upload";
|
||||
import ImageUploadField from './ImageUploadField';
|
||||
|
||||
interface ProfileProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function Profile({ onBack }: ProfileProps) {
|
||||
const { currentUser, loading, error, logout, updateProfile, updateAvatar } = useAuth();
|
||||
const { currentUser, loading, error, logout, updateProfile } = useAuth();
|
||||
const { uploadFile } = useUpload();
|
||||
const [formData, setFormData] = React.useState({
|
||||
username: currentUser?.username || '',
|
||||
name: currentUser?.name || '',
|
||||
@@ -25,7 +27,6 @@ export default function Profile({ onBack }: ProfileProps) {
|
||||
avatar: currentUser?.avatar || '',
|
||||
});
|
||||
|
||||
const [avatarFile, setAvatarFile] = React.useState<File | null>(null);
|
||||
const [uploadingAvatar, setUploadingAvatar] = React.useState(false);
|
||||
const [success, setSuccess] = React.useState<string | null>(null);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
@@ -44,9 +45,9 @@ export default function Profile({ onBack }: ProfileProps) {
|
||||
setUploadingAvatar(true);
|
||||
|
||||
try {
|
||||
const updated = await updateAvatar(file);
|
||||
if (updated) {
|
||||
setFormData((prev) => ({ ...prev, avatar: updated.avatar }));
|
||||
const avatar = await uploadFile(file);
|
||||
if (avatar) {
|
||||
setFormData((prev) => ({ ...prev, avatar: avatar }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Avatar upload failed:", err);
|
||||
@@ -120,30 +121,13 @@ export default function Profile({ onBack }: ProfileProps) {
|
||||
Profile
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Avatar
|
||||
src={
|
||||
(import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||
"/" +
|
||||
(formData.avatar?.replace(/^\/+/, "") || "")
|
||||
)
|
||||
}
|
||||
alt={formData.name || formData.username}
|
||||
sx={{ width: 64, height: 64 }}
|
||||
/>
|
||||
|
||||
<Button variant="outlined" component="label">
|
||||
{uploadingAvatar ? "Uploading..." : "Upload Avatar"}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.[0]) handleAvatarUpload(e.target.files[0]);
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</Box>
|
||||
<ImageUploadField
|
||||
label="Upload Avatar"
|
||||
value={formData.avatar}
|
||||
uploading={uploadingAvatar}
|
||||
onUpload={handleAvatarUpload}
|
||||
size={64}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
|
||||
@@ -10,7 +10,38 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
||||
const [articles, setArticles] = useState<ArticleModel[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { token } = useAuth();
|
||||
const { token, currentUser } = useAuth();
|
||||
|
||||
const upsertArticleInList = (updated: ArticleModel) => {
|
||||
setArticles(prev => {
|
||||
const exists = prev.some(a => a._id === updated._id);
|
||||
if (exists) {
|
||||
// UPDATE → replace item
|
||||
return prev.map(a => (a._id === updated._id ? updated : a));
|
||||
} else {
|
||||
// CREATE → append to top
|
||||
return [updated, ...prev];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** 🔹 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) */
|
||||
const fetchArticles = async () => {
|
||||
@@ -29,6 +60,56 @@ 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);
|
||||
upsertArticleInList(res.data);
|
||||
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);
|
||||
upsertArticleInList(res.data);
|
||||
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 */
|
||||
useEffect(() => {
|
||||
// Always load once on mount
|
||||
@@ -41,7 +122,14 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<ArticleContext.Provider value={{ articles, loading, error, refreshArticles: fetchArticles }}>
|
||||
<ArticleContext.Provider value={{
|
||||
articles,
|
||||
loading,
|
||||
error,
|
||||
refreshArticles: fetchArticles,
|
||||
updateArticle,
|
||||
createArticle,
|
||||
}}>
|
||||
{children}
|
||||
</ArticleContext.Provider>
|
||||
);
|
||||
|
||||
@@ -112,51 +112,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
}
|
||||
};
|
||||
|
||||
/** --------------------------------------------
|
||||
* 🔹 Upload avatar binary → return URL
|
||||
* -------------------------------------------- */
|
||||
const uploadAvatar = async (file: File): Promise<string | 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;
|
||||
} catch (err: any) {
|
||||
console.error("Avatar upload failed:", err);
|
||||
setError(err.response?.data?.detail || "Failed to upload avatar");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** --------------------------------------------
|
||||
* 🔹 Full flow: upload avatar → update profile
|
||||
* -------------------------------------------- */
|
||||
const updateAvatar = async (file: File) => {
|
||||
if (!currentUser) return;
|
||||
|
||||
const url = await uploadAvatar(file);
|
||||
if (!url) return;
|
||||
|
||||
// Now update the author document in DB
|
||||
const updatedUser = await updateProfile({
|
||||
...currentUser,
|
||||
avatar: url,
|
||||
});
|
||||
|
||||
return updatedUser;
|
||||
};
|
||||
|
||||
/** 🔹 On mount, try to fetch user if token exists */
|
||||
useEffect(() => {
|
||||
if (token) fetchCurrentUser();
|
||||
@@ -175,8 +130,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
register,
|
||||
refreshAuthors,
|
||||
updateProfile,
|
||||
uploadAvatar,
|
||||
updateAvatar,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
56
src/blog/providers/Upload.tsx
Normal file
56
src/blog/providers/Upload.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React, { createContext, useContext, useState } from "react";
|
||||
import { api } from "../utils/api";
|
||||
import { UploadContextModel } from "../types/contexts";
|
||||
|
||||
const UploadContext = createContext<UploadContextModel | undefined>(undefined);
|
||||
|
||||
export const UploadProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* 🔹 Upload any file → return public URL
|
||||
*/
|
||||
const uploadFile = async (file: File): Promise<string | null> => {
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const binary = new Uint8Array(arrayBuffer);
|
||||
|
||||
const res = await api.post("/uploads", binary, {
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
"Content-Disposition": `attachment; filename="${file.name}"`,
|
||||
},
|
||||
});
|
||||
|
||||
return res.data.url as string;
|
||||
} catch (err: any) {
|
||||
console.error("File upload failed:", err);
|
||||
setError(err.response?.data?.detail || "Failed to upload file");
|
||||
return null;
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<UploadContext.Provider
|
||||
value={{
|
||||
uploadFile,
|
||||
uploading,
|
||||
error,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</UploadContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useUpload = (): UploadContextModel => {
|
||||
const ctx = useContext(UploadContext);
|
||||
if (!ctx) throw new Error("useUpload must be used within UploadProvider");
|
||||
return ctx;
|
||||
};
|
||||
@@ -5,6 +5,8 @@ export interface ArticleContextModel {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refreshArticles: () => Promise<void>;
|
||||
updateArticle: (user: ArticleModel) => Promise<ArticleModel | void>;
|
||||
createArticle: (user: ArticleModel) => Promise<ArticleModel | void>;
|
||||
}
|
||||
|
||||
export interface AuthContextModel {
|
||||
@@ -18,6 +20,10 @@ export interface AuthContextModel {
|
||||
logout: () => void;
|
||||
refreshAuthors: () => Promise<void>;
|
||||
updateProfile: (user: AuthorModel) => Promise<AuthorModel | void>;
|
||||
uploadAvatar: (file: File) => Promise<string | null>;
|
||||
updateAvatar: (file: File) => Promise<AuthorModel | undefined>;
|
||||
}
|
||||
|
||||
export interface UploadContextModel {
|
||||
uploadFile: (file: File) => Promise<string | null>;
|
||||
uploading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { ArticleModel } from "./models";
|
||||
import {styled} from "@mui/material/styles";
|
||||
import Card from "@mui/material/Card";
|
||||
|
||||
export interface LatestProps {
|
||||
articles: ArticleModel[];
|
||||
@@ -8,9 +6,16 @@ export interface LatestProps {
|
||||
onLoadMore?: (offset: number, limit: number) => Promise<void>; // optional async callback
|
||||
}
|
||||
|
||||
export interface ArticleProps {
|
||||
export interface ArticleViewProps {
|
||||
article: ArticleModel;
|
||||
onBack: () => void;
|
||||
open_editor: () => void;
|
||||
}
|
||||
|
||||
|
||||
export interface ArticleEditorProps {
|
||||
article?: ArticleModel | null;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export interface ArticleMetaProps {
|
||||
@@ -30,7 +35,16 @@ export interface ArticleGridProps {
|
||||
articles: ArticleModel[];
|
||||
onSelectArticle: (index: number) => void;
|
||||
xs?: number; // default 12 for mobile full-width
|
||||
md12?: number, // default 12 (full-width)
|
||||
md6?: number; // default 6 (half-width)
|
||||
md4?: number; // default 4 (third-width)
|
||||
nested?: 1 | 2; // number of stacked cards in a nested column
|
||||
}
|
||||
|
||||
export interface ImageUploadFieldProps {
|
||||
label?: string;
|
||||
value?: string;
|
||||
uploading?: boolean;
|
||||
onUpload: (file: File) => void;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
72
src/blog/types/views.ts
Normal file
72
src/blog/types/views.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
export type View =
|
||||
| "home"
|
||||
| "login"
|
||||
| "register"
|
||||
| "article"
|
||||
| "editor"
|
||||
| "profile"
|
||||
| "create";
|
||||
|
||||
export type ViewNode = {
|
||||
parent: View | null;
|
||||
children?: View[];
|
||||
};
|
||||
|
||||
export const VIEW_TREE: Record<View, ViewNode> = {
|
||||
home: {
|
||||
parent: null,
|
||||
children: ["login", "article", "profile", "create"],
|
||||
},
|
||||
login: {
|
||||
parent: "home",
|
||||
children: ["register"],
|
||||
},
|
||||
register: {
|
||||
parent: "login",
|
||||
},
|
||||
article: {
|
||||
parent: "home",
|
||||
children: ["editor"],
|
||||
},
|
||||
editor: {
|
||||
parent: "article",
|
||||
},
|
||||
profile: {
|
||||
parent: "home",
|
||||
},
|
||||
create: {
|
||||
parent: "home",
|
||||
},
|
||||
};
|
||||
|
||||
export function useViewRouter(setUI: any) {
|
||||
const navigate = (view: View) => {
|
||||
setUI((prev: any) => ({ ...prev, view }));
|
||||
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 = (i: number) => {
|
||||
setUI({ selectedArticle: i, view: "article" });
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
// auto child navigators from children[]
|
||||
const navigateToChildren = (view: View) => {
|
||||
const node = VIEW_TREE[view];
|
||||
const funcs: Record<string, () => void> = {};
|
||||
|
||||
node.children?.forEach((child) => {
|
||||
funcs[`open_${child}`] = () => navigate(child);
|
||||
});
|
||||
|
||||
return funcs;
|
||||
};
|
||||
|
||||
return { navigate, goBack, openArticle, navigateToChildren };
|
||||
}
|
||||
13
src/main.jsx
13
src/main.jsx
@@ -3,16 +3,19 @@ import { createRoot } from 'react-dom/client';
|
||||
import Blog from './blog/Blog';
|
||||
import { ArticleProvider } from './blog/providers/Article';
|
||||
import { AuthProvider } from './blog/providers/Author';
|
||||
import { UploadProvider } from "./blog/providers/Upload";
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
const root = createRoot(rootElement);
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
<ArticleProvider>
|
||||
<Blog />
|
||||
</ArticleProvider>
|
||||
</AuthProvider>
|
||||
<UploadProvider>
|
||||
<AuthProvider>
|
||||
<ArticleProvider>
|
||||
<Blog />
|
||||
</ArticleProvider>
|
||||
</AuthProvider>
|
||||
</UploadProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user