68 Commits
0.1.0 ... 0.2.4

Author SHA1 Message Date
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
3e1ec9a3ed bumped up to 0.2.0 for maintaining parity with blog api 0.2.0 version
Some checks reported errors
continuous-integration/drone/tag Build was killed
2025-11-12 05:28:39 +05:30
3cac047709 cleanup 2025-11-12 05:27:50 +05:30
1f21ab38fc cleanup 2025-11-12 05:20:01 +05:30
1f5066a661 Article to use ArticleMeta 2025-11-12 05:19:48 +05:30
6798b64431 ArticleMeta to capture Authors and Article created date 2025-11-12 05:17:50 +05:30
7fa61e6c2e abstracted styles and Author from ArticleCardSizes 2025-11-12 05:12:28 +05:30
b09900f8ec dynamic listing of top 6 or less upto 2 articles 2025-11-12 05:06:29 +05:30
fc39d832c1 cleanup 2025-11-12 04:51:46 +05:30
74cae4e4ea renamed ArticleCards.tsx to ArticleCardsGrid.tsx 2025-11-12 04:51:25 +05:30
08c20c2613 moved out ArticleCards grid 2025-11-12 04:50:47 +05:30
7fece6f8f9 cleanup 2025-11-12 04:48:23 +05:30
e75beaac48 using ArticleCards of various sizes of 6,4,2 instead of hardcoded repeated code 2025-11-12 04:43:21 +05:30
6d951b9ab5 working mvp for tag selection. fails when not enough articles for a particular tag 2025-11-12 04:19:41 +05:30
6abdd443e0 logout button 2025-11-12 03:26:50 +05:30
e9c654e138 fixes 2025-11-12 03:20:01 +05:30
eddb251e4d current user username instead of "profile" text 2025-11-12 03:19:53 +05:30
d29efe53e0 bumped up to 0.1.1
All checks were successful
continuous-integration/drone/tag Build is passing
2025-11-12 03:14:04 +05:30
089e5e1716 Merge branch 'jwt' 2025-11-12 03:13:47 +05:30
8a29261a3e profile and update view for author 2025-11-11 20:47:37 +05:30
89aa1c6ce4 cleanup code for view 2025-11-11 19:10:02 +05:30
557e8ddfc9 working login and register page 2025-11-11 18:56:48 +05:30
0267aedf52 register page 2025-11-11 18:48:06 +05:30
1c964a7fee login page 2025-11-11 18:47:59 +05:30
661f8c915b fixes for public listed articles 2025-11-11 18:47:49 +05:30
b2a7df5760 username and password instead of email and password 2025-11-11 18:47:16 +05:30
3bf0a5839c register function in Author contexts 2025-11-11 18:33:40 +05:30
90e6a85fff jwt provider and common api utils 2025-11-11 15:45:24 +05:30
26 changed files with 3097 additions and 458 deletions

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",
"version": "0.1.0",
"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"
},

View File

@@ -2,23 +2,137 @@ import * as React from 'react';
import CssBaseline from '@mui/material/CssBaseline';
import Container from '@mui/material/Container';
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';
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";
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 [selectedArticle, setSelectedArticle] = React.useState<number | null>(null);
const { currentUser } = useAuth();
const handleSelectArticle = (index: number) => {
setSelectedArticle(index);
window.scrollTo({ top: 0, behavior: 'smooth' });
const [ui, setUI] = React.useState({
selectedArticle: null as number | null,
view: "home" as View,
});
const {
goBack,
navigateToChildren,
openArticle,
} = useViewRouter(setUI);
type RouterContext = {
ui: any;
articles: ArticleModel[];
currentUser: any;
openArticle: (index: number) => void;
};
const handleBack = () => setSelectedArticle(null);
type ViewComponentEntry<P> = {
component: React.ComponentType<P>;
extraProps?: (ctx: RouterContext) => Partial<P>;
};
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>,
},
};
const renderView = () => {
const entry = VIEW_COMPONENTS[ui.view];
const ViewComponent = entry.component;
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) {
return (
@@ -64,13 +178,7 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
<AppTheme {...props}>
<CssBaseline enableColorScheme />
<Box
sx={{
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<Container
maxWidth="lg"
component="main"
@@ -80,29 +188,13 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
flexDirection: 'column',
my: 4,
gap: 4,
pb: selectedArticle === null ? 24 : 0, // space for fixed footer on home
pb: ui.view === 'home' ? 24 : 0,
}}
>
{selectedArticle === null ? (
<>
<MainContent
articles={articles}
onSelectArticle={handleSelectArticle}
/>
<Latest
articles={articles}
onSelectArticle={handleSelectArticle}
onLoadMore={async (offset, limit) => {
// Optional pagination call
}}
/>
</>
) : (
<Article article={articles[selectedArticle]} onBack={handleBack} />
)}
{renderView()}
</Container>
{selectedArticle === null && (
{ui.view === 'home' && (
<Box
component="footer"
sx={{

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

@@ -1,9 +1,12 @@
import * as React from 'react';
import { marked } from 'marked';
import { Box, Typography, Avatar, Divider, IconButton, Chip } from '@mui/material';
import { Box, Typography, Divider, IconButton, Chip } from '@mui/material';
import { styled } from '@mui/material/styles';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { ArticleProps } from '../types/props';
import EditRoundedIcon from '@mui/icons-material/EditRounded';
import { ArticleMeta } from "../ArticleMeta";
import { ArticleViewProps } from '../../types/props';
import {useAuth} from "../../providers/Author";
const ArticleContainer = styled(Box)(({ theme }) => ({
maxWidth: '800px',
@@ -22,17 +25,43 @@ 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>
{currentUser && (
<IconButton onClick={onEdit}>
<EditRoundedIcon />
</IconButton>
)}
</Box>
<Typography variant="h3" component="h1" gutterBottom fontWeight="bold">
{article.title}
</Typography>
<ArticleMeta article={article} />
<Divider sx={{ my: 3 }} />
<Chip
label={article.tag}
variant="outlined"
@@ -40,29 +69,14 @@ export default function Article({
sx={{ mb: 2, textTransform: 'uppercase', fontWeight: 500 }}
/>
<Typography variant="h3" component="h1" gutterBottom fontWeight="bold">
{article.title}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2, mb: 1 }}>
<Avatar src={article.authors[0].avatar} alt={article.authors[0].name} />
<Box>
<Typography variant="subtitle2">{article.authors[0].name}</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(article.created_at).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</Typography>
</Box>
</Box>
<Divider sx={{ my: 3 }} />
<CoverImage src={article.img} alt={article.title} />
<CoverImage
src={(
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
"/" +
(article.img?.replace(/^\/+/, "") || "")
)}
alt={article.title}
/>
<Box
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(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>
);
};

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { Typography } from '@mui/material';
import { ArticleMeta } from "../ArticleMeta";
import { ArticleCardProps } from "../../types/props";
import { StyledCard, StyledCardContent, StyledTypography } from "../../types/styles";
export default function ArticleCardSize2({
article,
index,
focusedCardIndex,
onSelectArticle,
onFocus,
onBlur,
}: ArticleCardProps) {
return (
<StyledCard
variant="outlined"
onClick={() => onSelectArticle(index)}
onFocus={() => onFocus(index)}
onBlur={onBlur}
tabIndex={0}
className={focusedCardIndex === index ? 'Mui-focused' : ''}
>
<StyledCardContent
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
height: '100%',
}}
>
<div>
<Typography gutterBottom variant="caption" component="div">
{article.tag}
</Typography>
<Typography gutterBottom variant="h6" component="div">
{article.title}
</Typography>
<StyledTypography
variant="body2"
color="text.secondary"
gutterBottom
>
{article.description}
</StyledTypography>
</div>
</StyledCardContent>
<ArticleMeta article={article} />
</StyledCard>
);
};

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { CardMedia, Typography } from '@mui/material';
import { ArticleMeta } from "../ArticleMeta";
import { ArticleCardProps } from "../../types/props";
import { StyledCard, StyledCardContent, StyledTypography } from "../../types/styles";
export default function ArticleCardSize4({
article,
index,
focusedCardIndex,
onSelectArticle,
onFocus,
onBlur,
}: ArticleCardProps) {
return (
<StyledCard
variant="outlined"
onClick={() => onSelectArticle(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={{
height: { sm: 'auto', md: '50%' },
aspectRatio: { sm: '16 / 9', md: '' },
}}
/>
<StyledCardContent>
<Typography gutterBottom variant="caption" component="div">
{article.tag}
</Typography>
<Typography gutterBottom variant="h6" component="div">
{article.title}
</Typography>
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
{article.description}
</StyledTypography>
</StyledCardContent>
<ArticleMeta article={article} />
</StyledCard>
);
};

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { CardMedia, Typography } from '@mui/material';
import { ArticleMeta } from "../ArticleMeta";
import { ArticleCardProps } from "../../types/props";
import { StyledCard, StyledCardContent, StyledTypography } from "../../types/styles";
export default function ArticleCardSize6({
article,
index,
focusedCardIndex,
onSelectArticle,
onFocus,
onBlur,
}: ArticleCardProps) {
return (
<StyledCard
variant="outlined"
onClick={() => onSelectArticle(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',
borderBottom: '1px solid',
borderColor: 'divider',
}}
/>
<StyledCardContent>
<Typography gutterBottom variant="caption" component="div">
{article.tag}
</Typography>
<Typography gutterBottom variant="h6" component="div">
{article.title}
</Typography>
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
{article.description}
</StyledTypography>
</StyledCardContent>
<ArticleMeta article={article} />
</StyledCard>
);
};

View File

@@ -0,0 +1,152 @@
import React from 'react';
import { Grid, Box } from '@mui/material';
import ArticleCardSize12 from './ArticleCardSize12';
import ArticleCardSize6 from './ArticleCardSize6';
import ArticleCardSize4 from './ArticleCardSize4';
import ArticleCardSize2 from './ArticleCardSize2';
import { ArticleModel } from "../../types/models";
import { ArticleGridProps } from "../../types/props";
export default function ArticleCardsGrid({
articles,
onSelectArticle,
xs = 12,
md12 = 12,
md6 = 6,
md4 = 4,
nested = 2,
}: ArticleGridProps ) {
const visibleArticles = articles.slice(0, 6)
const count = visibleArticles.length;
const [focusedCardIndex, setFocusedCardIndex] = React.useState<number | null>(
null,
);
const handleFocus = (index: number) => {
setFocusedCardIndex(index);
};
const handleBlur = () => {
setFocusedCardIndex(null);
};
const renderCard = (article: ArticleModel, index: number, type: '12' | '6' | '4' | '2' = '12') => {
const CardComponent =
type === '12' ? ArticleCardSize12 :
type === '6' ? ArticleCardSize6 :
type === '4' ? ArticleCardSize4 :
ArticleCardSize2;
return (
<CardComponent
key={index}
article={article}
index={index}
focusedCardIndex={focusedCardIndex}
onSelectArticle={onSelectArticle}
onFocus={handleFocus}
onBlur={handleBlur}
/>
);
};
return (
<Grid container spacing={2} columns={12}>
{/* ---- 1 article: 12 ---- */}
{count === 1 && (
<>
{visibleArticles.map((a, i) => (
<Grid key={i} size={{ xs, md: md12 }}>
{renderCard(a, i, '12')}
</Grid>
))}
</>
)}
{/* ---- 2 articles: 6 | 6 ---- */}
{count === 2 && (
<>
{visibleArticles.map((a, i) => (
<Grid key={i} size={{ xs, md: md6 }}>
{renderCard(a, i, '6')}
</Grid>
))}
</>
)}
{/* ---- 3 articles: 4 | 4 | 4 ---- */}
{count === 3 && (
<>
{visibleArticles.map((a, i) => (
<Grid key={i} size={{ xs, md: md4 }}>
{renderCard(a, i, '4')}
</Grid>
))}
</>
)}
{/* ---- 4 articles: (6|6) + (6|6) ---- */}
{count === 4 && (
<>
{visibleArticles.map((a, i) => (
<Grid key={i} size={{ xs, md: md6 }}>
{renderCard(a, i, '6')}
</Grid>
))}
</>
)}
{/* ---- 5 articles: (6|6) + (4|4|4) ---- */}
{count === 5 && (
<>
{/* Row 1: 2 x size6 */}
{visibleArticles.slice(0, 2).map((a, i) => (
<Grid key={i} size={{ xs, md: md6 }}>
{renderCard(a, i, '6')}
</Grid>
))}
{/* Row 2: 3 x size4 */}
{visibleArticles.slice(2).map((a, i) => (
<Grid key={i + 2} size={{ xs, md: md4 }}>
{renderCard(a, i + 2, '4')}
</Grid>
))}
</>
)}
{/* ---- 6 articles: (6|6) + (4|2x2|4) ---- */}
{count === 6 && (
<>
{/* Top row: 2 x size6 */}
{visibleArticles.slice(0, 2).map((a, i) => (
<Grid key={i} size={{ xs, md: md6 }}>
{renderCard(a, i, '6')}
</Grid>
))}
{/* Bottom row: 4 + 2x2 + 4 */}
<Grid size={{ xs, md: md4 }}>
{renderCard(visibleArticles[2], 2, '4')}
</Grid>
<Grid size={{ xs, md: md4 }}>
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 2, height: '100%' }}
>
{visibleArticles.slice(3, 3 + nested).map((a, i) =>
renderCard(a, i + 3, '2')
)}
</Box>
</Grid>
<Grid size={{ xs, md: md4 }}>
{renderCard(visibleArticles[5], 5, '4')}
</Grid>
</>
)}
</Grid>
);
}

View File

@@ -0,0 +1,57 @@
import Box from "@mui/material/Box";
import AvatarGroup from "@mui/material/AvatarGroup";
import Avatar from "@mui/material/Avatar";
import {Typography} from "@mui/material";
import React from "react";
import { ArticleMetaProps } from "../types/props";
export function ArticleMeta({
article,
}: ArticleMetaProps ) {
const authors = article.authors;
return (
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 2,
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px',
}}
>
<Box
sx={{ display: 'flex', flexDirection: 'row', gap: 1, alignItems: 'center' }}
>
<AvatarGroup max={3}>
{authors.map((author, index) => (
<Avatar
key={index}
alt={author.name}
src={(
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
"/" +
(author.avatar?.replace(/^\/+/, "") || "")
)}
sx={{ width: 24, height: 24 }}
/>
))}
</AvatarGroup>
<Typography variant="caption">
{authors.map((author) => author.name).join(', ')}
</Typography>
</Box>
<Typography variant="caption" color="text.secondary">
{new Date(article.created_at).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</Typography>
</Box>
);
}

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

@@ -8,7 +8,7 @@ import { styled } from '@mui/material/styles';
import NavigateNextRoundedIcon from '@mui/icons-material/NavigateNextRounded';
import CircularProgress from '@mui/material/CircularProgress';
import { LatestProps } from "../types/props";
import Fade from '@mui/material/Fade'; // ✅ for smooth appearance
import Fade from '@mui/material/Fade';
const StyledTypography = styled(Typography)({
@@ -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 }}
/>
))}

View 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 }}
>
Dont have an account?{' '}
<Link
component="button"
underline="hover"
color="primary"
onClick={onRegister}
sx={{ fontWeight: 500 }}
>
Register
</Link>
</Typography>
</Box>
);
}

View File

@@ -1,91 +1,16 @@
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import AvatarGroup from '@mui/material/AvatarGroup';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardMedia from '@mui/material/CardMedia';
import Chip from '@mui/material/Chip';
import Grid from '@mui/material/Grid';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import FormControl from '@mui/material/FormControl';
import InputAdornment from '@mui/material/InputAdornment';
import OutlinedInput from '@mui/material/OutlinedInput';
import { styled } from '@mui/material/styles';
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
import RssFeedRoundedIcon from '@mui/icons-material/RssFeedRounded';
const StyledCard = styled(Card)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
padding: 0,
height: '100%',
backgroundColor: (theme.vars || theme).palette.background.paper,
'&:hover': {
backgroundColor: 'transparent',
cursor: 'pointer',
},
'&:focus-visible': {
outline: '3px solid',
outlineColor: 'hsla(210, 98%, 48%, 0.5)',
outlineOffset: '2px',
},
}));
const StyledCardContent = styled(CardContent)({
display: 'flex',
flexDirection: 'column',
gap: 4,
padding: 16,
flexGrow: 1,
'&:last-child': {
paddingBottom: 16,
},
});
const StyledTypography = styled(Typography)({
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
});
function Author({ authors }: { authors: { name: string; avatar: string }[] }) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 2,
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px',
}}
>
<Box
sx={{ display: 'flex', flexDirection: 'row', gap: 1, alignItems: 'center' }}
>
<AvatarGroup max={3}>
{authors.map((author, index) => (
<Avatar
key={index}
alt={author.name}
src={author.avatar}
sx={{ width: 24, height: 24 }}
/>
))}
</AvatarGroup>
<Typography variant="caption">
{authors.map((author) => author.name).join(', ')}
</Typography>
</Box>
<Typography variant="caption">July 14, 2021</Typography>
</Box>
);
}
import { ArticleModel } from "../types/models";
import ArticleCardsGrid from "./ArticleCards/ArticleCardsGrid";
export function Search() {
return (
@@ -112,32 +37,47 @@ export default function MainContent({
articles,
onSelectArticle,
}: {
articles: any[];
articles: ArticleModel[];
onSelectArticle: (index: number) => void;
}) {
const [focusedCardIndex, setFocusedCardIndex] = React.useState<number | null>(
null,
);
const handleFocus = (index: number) => {
setFocusedCardIndex(index);
const [visibleArticles, setVisibleArticles] = React.useState<ArticleModel[]>(articles);
const [activeTag, setActiveTag] = React.useState<string>('all');
const filterArticlesByTag = (tag: string) => {
if (tag === 'all') {
// 🟢 Show all articles
setVisibleArticles(articles);
setActiveTag('all');
return;
}
if (activeTag === tag) {
// 🟡 Toggle off the current tag → reset to all
setVisibleArticles(articles);
setActiveTag('all');
return;
}
// 🔵 Filter by selected tag
const filtered = articles.filter((article) => article.tag === tag);
console.log('👀 All Articles:', articles);
console.log(`👀 Filtered (${tag}):`, filtered);
setVisibleArticles(filtered);
setActiveTag(tag);
};
const handleBlur = () => {
setFocusedCardIndex(null);
};
const handleClick = () => {
console.info('You clicked the filter chip.');
const handleTagClick = (tag: string) => {
setActiveTag((prev) => (prev === tag ? 'all' : tag));
filterArticlesByTag(tag)
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div>
<Typography variant="h1" gutterBottom>
Blog
</Typography>
</div>
<Typography variant="h1" gutterBottom>
Blog
</Typography>
<Box
sx={{
display: { xs: 'flex', sm: 'none' },
@@ -171,43 +111,21 @@ export default function MainContent({
overflow: 'auto',
}}
>
<Chip onClick={handleClick} size="medium" label="All categories" />
<Chip
onClick={handleClick}
size="medium"
label="Company"
sx={{
backgroundColor: 'transparent',
border: 'none',
}}
/>
<Chip
onClick={handleClick}
size="medium"
label="Product"
sx={{
backgroundColor: 'transparent',
border: 'none',
}}
/>
<Chip
onClick={handleClick}
size="medium"
label="Design"
sx={{
backgroundColor: 'transparent',
border: 'none',
}}
/>
<Chip
onClick={handleClick}
size="medium"
label="Engineering"
sx={{
backgroundColor: 'transparent',
border: 'none',
}}
/>
{['all', 'infra', 'code', 'media', 'monitoring'].map((tag) => (
<Chip
key={tag}
onClick={() => handleTagClick(tag)}
size="medium"
label={tag === 'all' ? 'All categories' : tag.charAt(0).toUpperCase() + tag.slice(1)}
color={activeTag === tag ? 'primary' : 'default'}
variant={activeTag === tag ? 'filled' : 'outlined'}
sx={{
borderRadius: '8px',
fontWeight: activeTag === tag ? 600 : 400,
textTransform: 'capitalize',
}}
/>
))}
</Box>
<Box
sx={{
@@ -224,216 +142,10 @@ export default function MainContent({
</IconButton>
</Box>
</Box>
<Grid container spacing={2} columns={12}>
<Grid size={{ xs: 12, md: 6 }}>
<StyledCard
variant="outlined"
onClick={() => onSelectArticle(0)}
onFocus={() => handleFocus(0)}
onBlur={handleBlur}
tabIndex={0}
className={focusedCardIndex === 0 ? 'Mui-focused' : ''}
>
<CardMedia
component="img"
alt="green iguana"
image={articles[0].img}
sx={{
aspectRatio: '16 / 9',
borderBottom: '1px solid',
borderColor: 'divider',
}}
/>
<StyledCardContent>
<Typography gutterBottom variant="caption" component="div">
{articles[0].tag}
</Typography>
<Typography gutterBottom variant="h6" component="div">
{articles[0].title}
</Typography>
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
{articles[0].description}
</StyledTypography>
</StyledCardContent>
<Author authors={articles[0].authors} />
</StyledCard>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<StyledCard
variant="outlined"
onClick={() => onSelectArticle(1)}
onFocus={() => handleFocus(1)}
onBlur={handleBlur}
tabIndex={0}
className={focusedCardIndex === 1 ? 'Mui-focused' : ''}
>
<CardMedia
component="img"
alt="green iguana"
image={articles[1].img}
aspect-ratio="16 / 9"
sx={{
borderBottom: '1px solid',
borderColor: 'divider',
}}
/>
<StyledCardContent>
<Typography gutterBottom variant="caption" component="div">
{articles[1].tag}
</Typography>
<Typography gutterBottom variant="h6" component="div">
{articles[1].title}
</Typography>
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
{articles[1].description}
</StyledTypography>
</StyledCardContent>
<Author authors={articles[1].authors} />
</StyledCard>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<StyledCard
variant="outlined"
onClick={() => onSelectArticle(2)}
onFocus={() => handleFocus(2)}
onBlur={handleBlur}
tabIndex={0}
className={focusedCardIndex === 2 ? 'Mui-focused' : ''}
sx={{ height: '100%' }}
>
<CardMedia
component="img"
alt="green iguana"
image={articles[2].img}
sx={{
height: { sm: 'auto', md: '50%' },
aspectRatio: { sm: '16 / 9', md: '' },
}}
/>
<StyledCardContent>
<Typography gutterBottom variant="caption" component="div">
{articles[2].tag}
</Typography>
<Typography gutterBottom variant="h6" component="div">
{articles[2].title}
</Typography>
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
{articles[2].description}
</StyledTypography>
</StyledCardContent>
<Author authors={articles[2].authors} />
</StyledCard>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 2, height: '100%' }}
>
<StyledCard
variant="outlined"
onClick={() => onSelectArticle(3)}
onFocus={() => handleFocus(3)}
onBlur={handleBlur}
tabIndex={0}
className={focusedCardIndex === 3 ? 'Mui-focused' : ''}
sx={{ height: '100%' }}
>
<StyledCardContent
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
height: '100%',
}}
>
<div>
<Typography gutterBottom variant="caption" component="div">
{articles[3].tag}
</Typography>
<Typography gutterBottom variant="h6" component="div">
{articles[3].title}
</Typography>
<StyledTypography
variant="body2"
color="text.secondary"
gutterBottom
>
{articles[3].description}
</StyledTypography>
</div>
</StyledCardContent>
<Author authors={articles[3].authors} />
</StyledCard>
<StyledCard
variant="outlined"
onClick={() => onSelectArticle(4)}
onFocus={() => handleFocus(4)}
onBlur={handleBlur}
tabIndex={0}
className={focusedCardIndex === 4 ? 'Mui-focused' : ''}
sx={{ height: '100%' }}
>
<StyledCardContent
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
height: '100%',
}}
>
<div>
<Typography gutterBottom variant="caption" component="div">
{articles[4].tag}
</Typography>
<Typography gutterBottom variant="h6" component="div">
{articles[4].title}
</Typography>
<StyledTypography
variant="body2"
color="text.secondary"
gutterBottom
>
{articles[4].description}
</StyledTypography>
</div>
</StyledCardContent>
<Author authors={articles[4].authors} />
</StyledCard>
</Box>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<StyledCard
variant="outlined"
onClick={() => onSelectArticle(5)}
onFocus={() => handleFocus(5)}
onBlur={handleBlur}
tabIndex={0}
className={focusedCardIndex === 5 ? 'Mui-focused' : ''}
sx={{ height: '100%' }}
>
<CardMedia
component="img"
alt="green iguana"
image={articles[5].img}
sx={{
height: { sm: 'auto', md: '50%' },
aspectRatio: { sm: '16 / 9', md: '' },
}}
/>
<StyledCardContent>
<Typography gutterBottom variant="caption" component="div">
{articles[5].tag}
</Typography>
<Typography gutterBottom variant="h6" component="div">
{articles[5].title}
</Typography>
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
{articles[5].description}
</StyledTypography>
</StyledCardContent>
<Author authors={articles[5].authors} />
</StyledCard>
</Grid>
</Grid>
<ArticleCardsGrid
articles={visibleArticles}
onSelectArticle={onSelectArticle}
/>
</Box>
);
}

View File

@@ -0,0 +1,190 @@
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';
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 } = useAuth();
const { uploadFile } = useUpload();
const [formData, setFormData] = React.useState({
username: currentUser?.username || '',
name: currentUser?.name || '',
email: currentUser?.email || '',
avatar: currentUser?.avatar || '',
});
const [uploadingAvatar, setUploadingAvatar] = React.useState(false);
const [success, setSuccess] = React.useState<string | null>(null);
const [saving, setSaving] = React.useState(false);
React.useEffect(() => {
if (currentUser) setFormData(currentUser);
console.log("Current User:", currentUser);
}, [currentUser]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
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 () => {
if (!currentUser) return;
try {
setSaving(true);
setSuccess(null);
const updatedUser = { ...currentUser, ...formData };
const updated = await updateProfile(updatedUser);
if (updated) setSuccess('Profile updated successfully');
} catch (err: any) {
console.error('Failed to update profile:', err);
} finally {
setSaving(false);
}
};
const handleLogout = async () => {
logout();
};
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>
<ImageUploadField
label="Upload Avatar"
value={formData.avatar}
uploading={uploadingAvatar}
onUpload={handleAvatarUpload}
size={64}
/>
<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>
<Button
fullWidth
variant="contained"
color="error"
sx={{ mt: 3 }}
onClick={handleLogout}
>
Logout
</Button>
</Box>
);
}

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

View File

@@ -1,48 +1,135 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
import axios from 'axios';
import { ArticleModel } from "../types/models";
import { ArticleContextModel } from "../types/contexts";
import { api } from '../utils/api';
import { ArticleModel } from '../types/models';
import { ArticleContextModel } from '../types/contexts';
import { useAuth } from './Author';
const ArticleContext = createContext<ArticleContextModel | undefined>(undefined);
const API_BASE = import.meta.env.VITE_API_BASE_URL;
export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [articles, setArticles] = useState<ArticleModel[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const { token, currentUser } = useAuth();
const upsertArticleInList = (updated: ArticleModel) => {
setArticles(prev => {
const exists = prev.some(a => a._id === updated._id);
if (exists) {
// UPDATE → replace item
return prev.map(a => (a._id === updated._id ? updated : a));
} else {
// CREATE → append to top
return [updated, ...prev];
}
});
};
/** 🔹 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 () => {
try {
setLoading(true);
setError(null);
// ✅ Use correct full endpoint from OpenAPI spec
const res = await axios.get<ArticleModel[]>(`${API_BASE}/articles`, {
params: { skip: 0, limit: 10 },
});
// ✅ Normalize if backend sends _id instead of id
const formatted = res.data.map((a) => ({
...a,
id: a._id || undefined,
}));
const res = await api.get<ArticleModel[]>('/articles', { params: { skip: 0, limit: 100 } });
const formatted = res.data.map((a) => ({ ...a, id: a._id || undefined }));
setArticles(formatted);
} catch (err: any) {
console.error('Failed to fetch articles:', err);
setError(err.message || 'Failed to fetch articles');
setError(err.response?.data?.detail || 'Failed to fetch articles');
} finally {
setLoading(false);
}
};
/** 🔹 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(() => {
fetchArticles();
}, []);
// Always load once on mount
// If endpoint requires JWT, fallback safely
if (!token) {
fetchArticles().catch(() => setLoading(false)); // try anyway (handles both public/protected)
} else {
fetchArticles();
}
}, [token]);
return (
<ArticleContext.Provider value={{ articles, loading, error, refreshArticles: fetchArticles }}>
<ArticleContext.Provider value={{
articles,
loading,
error,
refreshArticles: fetchArticles,
updateArticle,
createArticle,
}}>
{children}
</ArticleContext.Provider>
);

View File

@@ -1,12 +1,10 @@
import React, { createContext, useState, useEffect, useContext } from 'react';
import axios from 'axios';
import { AuthorModel } from "../types/models";
import { AuthContextModel } from "../types/contexts";
import { api } from '../utils/api';
import { AuthorModel } from '../types/models';
import { AuthContextModel } from '../types/contexts';
const AuthContext = createContext<AuthContextModel | undefined>(undefined);
const API_BASE = import.meta.env.VITE_API_BASE_URL;
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [currentUser, setCurrentUser] = useState<AuthorModel | null>(null);
const [authors, setAuthors] = useState<AuthorModel[]>([]);
@@ -14,13 +12,29 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
/** 🔹 Login and store JWT token */
const login = async (email: string, password: string) => {
/** 🔹 Register new user */
const register = async (username: string, password: string) => {
try {
setLoading(true);
setError(null);
const res = await axios.post(`${API_BASE}/auth/login`, { email, password });
const res = await api.post('/auth/register', { username, password });
return res.data;
} catch (err: any) {
console.error('Registration failed:', err);
setError(err.response?.data?.detail || 'Registration failed');
} 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) {
@@ -44,21 +58,38 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
setAuthors([]);
};
/** 🔹 Fetch all authors (requires valid JWT) */
/** 🔹 Fetch all authors (JWT handled by api interceptor) */
const refreshAuthors = async () => {
if (!token) return;
try {
setLoading(true);
setError(null);
const res = await axios.get<AuthorModel[]>(`${API_BASE}/authors`, {
headers: { Authorization: `Bearer ${token}` },
});
const res = await api.get<AuthorModel[]>('/authors');
setAuthors(res.data);
} catch (err: any) {
console.error('Failed to fetch authors:', err);
setError(err.message || 'Failed to fetch authors');
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);
}
@@ -68,16 +99,20 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const fetchCurrentUser = async () => {
if (!token) return;
try {
const res = await axios.get<AuthorModel>(`${API_BASE}/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
});
setCurrentUser(res.data);
} catch (err: any) {
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
logout();
}
};
/** 🔹 On mount, try to fetch user if token exists */
useEffect(() => {
if (token) fetchCurrentUser();
}, [token]);
@@ -92,7 +127,9 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
error,
login,
logout,
register,
refreshAuthors,
updateProfile,
}}
>
{children}

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

@@ -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 {
@@ -13,7 +15,15 @@ export interface AuthContextModel {
token: string | null;
loading: boolean;
error: string | null;
login: (email: string, password: string) => Promise<void>;
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>;
}
export interface UploadContextModel {
uploadFile: (file: File) => Promise<string | null>;
uploading: boolean;
error: string | null;
}

View File

@@ -6,7 +6,45 @@ 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 {
article: ArticleModel;
}
export interface ArticleCardProps {
article: ArticleModel;
index: number;
focusedCardIndex: number | null;
onSelectArticle: (index: number) => void;
onFocus: (index: number) => void;
onBlur: () => void;
}
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;
}

40
src/blog/types/styles.ts Normal file
View File

@@ -0,0 +1,40 @@
import {styled} from "@mui/material/styles";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import {Typography} from "@mui/material";
export const StyledCard = styled(Card)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
padding: 0,
height: '100%',
backgroundColor: (theme.vars || theme).palette.background.paper,
'&:hover': {
backgroundColor: 'transparent',
cursor: 'pointer',
},
'&:focus-visible': {
outline: '3px solid',
outlineColor: 'hsla(210, 98%, 48%, 0.5)',
outlineOffset: '2px',
},
}));
export const StyledCardContent = styled(CardContent)({
display: 'flex',
flexDirection: 'column',
gap: 4,
padding: 16,
flexGrow: 1,
'&:last-child': {
paddingBottom: 16,
},
});
export const StyledTypography = styled(Typography)({
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
});

72
src/blog/types/views.ts Normal file
View 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 };
}

33
src/blog/utils/api.ts Normal file
View 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);
}
);

View File

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