39 Commits

Author SHA1 Message Date
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
21 changed files with 2336 additions and 410 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.1",
"version": "0.2.2",
"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

@@ -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';
@@ -14,7 +15,7 @@ import Profile from './components/Profile';
import { useArticles } from './providers/Article';
import { useAuth } from './providers/Author';
type View = 'home' | 'login' | 'register' | 'article' | 'profile';
type View = 'home' | 'login' | 'register' | 'article' | 'profile' | 'editor';
export default function Blog(props: { disableCustomTheme?: boolean }) {
const { articles, loading, error } = useArticles();
@@ -24,14 +25,12 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
const [showLogin, setShowLogin] = React.useState(false);
const [showRegister, setShowRegister] = React.useState(false);
const [showProfile, setShowProfile] = React.useState(false);
const [showEditor, setShowEditor] = React.useState(false);
const handleSelectArticle = (index: number) => {
setSelectedArticle(index);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleBack = () => setSelectedArticle(null);
const handleShowLogin = () => {
setShowLogin(true);
setShowRegister(false);
@@ -53,15 +52,27 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
const handleHideProfile = () => {
setShowProfile(false);
};
const handleShowEditor = () => {
setShowEditor(true);
};
const handleHideEditor = () => {
setShowEditor(false);
};
const handleArticleViewBack = () => setSelectedArticle(null);
const handleArticleEditorBack = () => {
handleHideEditor()
window.scrollTo({ top: 0, behavior: 'smooth' });
};
// derive a single source of truth for view
const view: View = React.useMemo(() => {
if (selectedArticle !== null) return 'article';
if (selectedArticle !== null && !showEditor) return 'article';
if (showRegister) return 'register';
if (showLogin) return 'login';
if (showProfile) return 'profile';
if (showEditor) return 'editor';
return 'home';
}, [selectedArticle, showLogin, showRegister, showProfile]);
}, [selectedArticle, showLogin, showRegister, showProfile, showEditor]);
// render function keeps JSX tidy
const renderView = () => {
@@ -85,7 +96,20 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
);
case 'article':
if (selectedArticle == null || !articles[selectedArticle]) return null;
return <Article article={articles[selectedArticle]} onBack={handleBack} />;
return <ArticleView
article={articles[selectedArticle]}
onBack={handleArticleViewBack}
onEdit={handleShowEditor}
/>;
case 'editor':
if (selectedArticle == null || !articles[selectedArticle])
return <ArticleEditor
onBack={handleArticleEditorBack}
/>
return <ArticleEditor
article={articles[selectedArticle] || null}
onBack={handleArticleEditorBack}
/>
case 'home':
default:
return (
@@ -106,9 +130,16 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
<Button
variant="outlined"
color="primary"
onClick={() => setShowProfile(true)}
onClick={handleShowProfile}
>
Profile
{currentUser.username}
</Button>
<Button
variant="contained"
color="primary"
onClick={handleShowEditor}
>
New Article
</Button>
</>
)}

View File

@@ -0,0 +1,191 @@
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,
content,
})
}
>
Save Changes
</Button>
</Box>
</ArticleContainer>
);
}

View File

@@ -1,9 +1,11 @@
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 { ArticleProps } from '../../types/props';
const ArticleContainer = styled(Box)(({ theme }) => ({
maxWidth: '800px',
@@ -22,9 +24,10 @@ const CoverImage = styled('img')({
marginBottom: '24px',
});
export default function Article({
export default function ArticleView({
article,
onBack
onBack,
onEdit,
}: ArticleProps) {
return (
@@ -40,25 +43,15 @@ export default function Article({
sx={{ mb: 2, textTransform: 'uppercase', fontWeight: 500 }}
/>
<IconButton onClick={onEdit} sx={{ mb: 2 }}>
<EditRoundedIcon />
</IconButton>
<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>
<ArticleMeta article={article} />
<Divider sx={{ my: 3 }} />

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="green iguana"
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,138 @@
import React from 'react';
import { Grid, Box } from '@mui/material';
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,
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: '6' | '4' | '2' = '6') => {
const CardComponent =
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}>
{/* ---- 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

@@ -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>
<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" />
{['all', 'infra', 'code', 'media', 'monitoring'].map((tag) => (
<Chip
onClick={handleClick}
key={tag}
onClick={() => handleTagClick(tag)}
size="medium"
label="Company"
label={tag === 'all' ? 'All categories' : tag.charAt(0).toUpperCase() + tag.slice(1)}
color={activeTag === tag ? 'primary' : 'default'}
variant={activeTag === tag ? 'filled' : 'outlined'}
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',
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',
}}
<ArticleCardsGrid
articles={visibleArticles}
onSelectArticle={onSelectArticle}
/>
<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>
</Box>
);
}

View File

@@ -6,29 +6,34 @@ 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, token, refreshAuthors, updateProfile } = useAuth();
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>) => {
@@ -36,6 +41,21 @@ export default function Profile({ onBack }: ProfileProps) {
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;
@@ -44,8 +64,6 @@ export default function Profile({ onBack }: ProfileProps) {
setSuccess(null);
const updatedUser = { ...currentUser, ...formData };
console.log('updatedUser');
console.log(updatedUser);
const updated = await updateProfile(updatedUser);
if (updated) setSuccess('Profile updated successfully');
@@ -56,6 +74,10 @@ export default function Profile({ onBack }: ProfileProps) {
}
};
const handleLogout = async () => {
logout();
};
if (!currentUser) {
return (
<Box
@@ -99,20 +121,13 @@ export default function Profile({ onBack }: ProfileProps) {
Profile
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Avatar
src={formData.avatar}
alt={formData.name || formData.username}
sx={{ width: 64, height: 64 }}
/>
<TextField
label="Avatar URL"
name="avatar"
fullWidth
<ImageUploadField
label="Upload Avatar"
value={formData.avatar}
onChange={handleChange}
uploading={uploadingAvatar}
onUpload={handleAvatarUpload}
size={64}
/>
</Box>
<TextField
fullWidth
@@ -161,6 +176,15 @@ export default function Profile({ onBack }: ProfileProps) {
>
{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

@@ -10,7 +10,15 @@ 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(); // ✅ access token if needed
const { token } = useAuth();
/** 🔹 Author IDs must be strings for API, so we normalize here */
const normalizeArticleForApi = (article: Partial<ArticleModel>) => ({
...article,
authors: (article.authors ?? []).map(a =>
a._id
),
});
/** 🔹 Fetch articles (JWT automatically attached by api.ts interceptor) */
const fetchArticles = async () => {
@@ -18,7 +26,7 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
setLoading(true);
setError(null);
const res = await api.get<ArticleModel[]>('/articles', { params: { skip: 0, limit: 10 } });
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) {
@@ -29,6 +37,50 @@ 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;
}
const normalizedArticleData = normalizeArticleForApi(articleData);
try {
setLoading(true);
setError(null);
const res = await api.put<ArticleModel>(`/articles/${articleData._id}`, normalizedArticleData);
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);
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 +93,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>
);

View File

@@ -19,7 +19,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
setError(null);
const res = await api.post('/auth/register', { username, password });
return res.data; // returns PublicUser from backend
return res.data;
} catch (err: any) {
console.error('Registration failed:', err);
setError(err.response?.data?.detail || 'Registration failed');
@@ -95,7 +95,6 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
};
/** 🔹 Auto-load current user if token exists */
const fetchCurrentUser = async () => {
if (!token) return;
@@ -109,7 +108,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
setCurrentUser(fullUser);
} catch (err) {
console.error('Failed to fetch current user:', err);
logout(); // invalid/expired token
logout();
}
};

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 {
@@ -19,3 +21,9 @@ export interface AuthContextModel {
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

@@ -9,4 +9,40 @@ export interface LatestProps {
export interface ArticleProps {
article: ArticleModel;
onBack: () => void;
onEdit: () => void;
}
export interface ArticleEditorProps {
article?: ArticleModel;
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
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',
});

View File

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