Compare commits
7 Commits
0.2.2
...
c2e6daca13
| Author | SHA1 | Date | |
|---|---|---|---|
| c2e6daca13 | |||
| c0bcd0e3e4 | |||
| 333f931cff | |||
| 3960de3ecb | |||
| 763629faa1 | |||
| a7e3ed46cb | |||
| 4a8c59895e |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aetoskia-blog-app",
|
"name": "aetoskia-blog-app",
|
||||||
"version": "0.2.2",
|
"version": "0.2.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ export default function ArticleView({
|
|||||||
title,
|
title,
|
||||||
tag,
|
tag,
|
||||||
img,
|
img,
|
||||||
|
description,
|
||||||
content,
|
content,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,14 @@ export default function ArticleView({
|
|||||||
|
|
||||||
<Divider sx={{ my: 3 }} />
|
<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
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
51
src/blog/components/ArticleCards/ArticleCardSize12.tsx
Normal file
51
src/blog/components/ArticleCards/ArticleCardSize12.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CardMedia, Typography } from '@mui/material';
|
||||||
|
import { ArticleMeta } from "../ArticleMeta";
|
||||||
|
import { ArticleCardProps } from "../../types/props";
|
||||||
|
import { StyledCard, StyledCardContent, StyledTypography } from "../../types/styles";
|
||||||
|
|
||||||
|
|
||||||
|
export default function ArticleCardSize12({
|
||||||
|
article,
|
||||||
|
index,
|
||||||
|
focusedCardIndex,
|
||||||
|
onSelectArticle,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
}: ArticleCardProps) {
|
||||||
|
return (
|
||||||
|
<StyledCard
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => onSelectArticle(index)}
|
||||||
|
onFocus={() => onFocus(index)}
|
||||||
|
onBlur={onBlur}
|
||||||
|
tabIndex={0}
|
||||||
|
className={focusedCardIndex === index ? 'Mui-focused' : ''}
|
||||||
|
>
|
||||||
|
<CardMedia
|
||||||
|
component="img"
|
||||||
|
alt={article.title}
|
||||||
|
image={(
|
||||||
|
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||||
|
"/" +
|
||||||
|
(article.img?.replace(/^\/+/, "") || "")
|
||||||
|
)}
|
||||||
|
sx={{
|
||||||
|
aspectRatio: '16 / 9',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<StyledCardContent>
|
||||||
|
<Typography gutterBottom variant="caption" component="div">
|
||||||
|
{article.tag}
|
||||||
|
</Typography>
|
||||||
|
<Typography gutterBottom variant="h6" component="div">
|
||||||
|
{article.title}
|
||||||
|
</Typography>
|
||||||
|
<StyledTypography variant="body2" color="text.secondary" gutterBottom>
|
||||||
|
{article.description}
|
||||||
|
</StyledTypography>
|
||||||
|
</StyledCardContent>
|
||||||
|
<ArticleMeta article={article} />
|
||||||
|
</StyledCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -24,7 +24,7 @@ export default function ArticleCardSize4({
|
|||||||
>
|
>
|
||||||
<CardMedia
|
<CardMedia
|
||||||
component="img"
|
component="img"
|
||||||
alt="green iguana"
|
alt={article.title}
|
||||||
image={(
|
image={(
|
||||||
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||||
"/" +
|
"/" +
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Grid, Box } from '@mui/material';
|
import { Grid, Box } from '@mui/material';
|
||||||
|
import ArticleCardSize12 from './ArticleCardSize12';
|
||||||
import ArticleCardSize6 from './ArticleCardSize6';
|
import ArticleCardSize6 from './ArticleCardSize6';
|
||||||
import ArticleCardSize4 from './ArticleCardSize4';
|
import ArticleCardSize4 from './ArticleCardSize4';
|
||||||
import ArticleCardSize2 from './ArticleCardSize2';
|
import ArticleCardSize2 from './ArticleCardSize2';
|
||||||
@@ -10,6 +11,7 @@ export default function ArticleCardsGrid({
|
|||||||
articles,
|
articles,
|
||||||
onSelectArticle,
|
onSelectArticle,
|
||||||
xs = 12,
|
xs = 12,
|
||||||
|
md12 = 12,
|
||||||
md6 = 6,
|
md6 = 6,
|
||||||
md4 = 4,
|
md4 = 4,
|
||||||
nested = 2,
|
nested = 2,
|
||||||
@@ -30,8 +32,9 @@ export default function ArticleCardsGrid({
|
|||||||
setFocusedCardIndex(null);
|
setFocusedCardIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderCard = (article: ArticleModel, index: number, type: '6' | '4' | '2' = '6') => {
|
const renderCard = (article: ArticleModel, index: number, type: '12' | '6' | '4' | '2' = '12') => {
|
||||||
const CardComponent =
|
const CardComponent =
|
||||||
|
type === '12' ? ArticleCardSize12 :
|
||||||
type === '6' ? ArticleCardSize6 :
|
type === '6' ? ArticleCardSize6 :
|
||||||
type === '4' ? ArticleCardSize4 :
|
type === '4' ? ArticleCardSize4 :
|
||||||
ArticleCardSize2;
|
ArticleCardSize2;
|
||||||
@@ -51,6 +54,17 @@ export default function ArticleCardsGrid({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid container spacing={2} columns={12}>
|
<Grid container spacing={2} columns={12}>
|
||||||
|
{/* ---- 1 article: 12 ---- */}
|
||||||
|
{count === 1 && (
|
||||||
|
<>
|
||||||
|
{visibleArticles.map((a, i) => (
|
||||||
|
<Grid key={i} size={{ xs, md: md12 }}>
|
||||||
|
{renderCard(a, i, '12')}
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ---- 2 articles: 6 | 6 ---- */}
|
{/* ---- 2 articles: 6 | 6 ---- */}
|
||||||
{count === 2 && (
|
{count === 2 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -10,15 +10,38 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|||||||
const [articles, setArticles] = useState<ArticleModel[]>([]);
|
const [articles, setArticles] = useState<ArticleModel[]>([]);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const { token } = useAuth();
|
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 */
|
/** 🔹 Author IDs must be strings for API, so we normalize here */
|
||||||
const normalizeArticleForApi = (article: Partial<ArticleModel>) => ({
|
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,
|
...article,
|
||||||
authors: (article.authors ?? []).map(a =>
|
authors: allAuthorIds,
|
||||||
a._id
|
};
|
||||||
),
|
};
|
||||||
});
|
|
||||||
|
|
||||||
/** 🔹 Fetch articles (JWT automatically attached by api.ts interceptor) */
|
/** 🔹 Fetch articles (JWT automatically attached by api.ts interceptor) */
|
||||||
const fetchArticles = async () => {
|
const fetchArticles = async () => {
|
||||||
@@ -50,6 +73,7 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const res = await api.put<ArticleModel>(`/articles/${articleData._id}`, normalizedArticleData);
|
const res = await api.put<ArticleModel>(`/articles/${articleData._id}`, normalizedArticleData);
|
||||||
|
upsertArticleInList(res.data);
|
||||||
return res.data;
|
return res.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Article update failed:', err);
|
console.error('Article update failed:', err);
|
||||||
@@ -72,6 +96,7 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const res = await api.post<ArticleModel>(`/articles`, normalizedArticleData);
|
const res = await api.post<ArticleModel>(`/articles`, normalizedArticleData);
|
||||||
|
upsertArticleInList(res.data);
|
||||||
return res.data;
|
return res.data;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Article create failed:', err);
|
console.error('Article create failed:', err);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface ArticleGridProps {
|
|||||||
articles: ArticleModel[];
|
articles: ArticleModel[];
|
||||||
onSelectArticle: (index: number) => void;
|
onSelectArticle: (index: number) => void;
|
||||||
xs?: number; // default 12 for mobile full-width
|
xs?: number; // default 12 for mobile full-width
|
||||||
|
md12?: number, // default 12 (full-width)
|
||||||
md6?: number; // default 6 (half-width)
|
md6?: number; // default 6 (half-width)
|
||||||
md4?: number; // default 4 (third-width)
|
md4?: number; // default 4 (third-width)
|
||||||
nested?: 1 | 2; // number of stacked cards in a nested column
|
nested?: 1 | 2; // number of stacked cards in a nested column
|
||||||
|
|||||||
Reference in New Issue
Block a user