11 Commits

Author SHA1 Message Date
a7987ab922 feat(core): migrate articles to ArticlesModel, add URL-synced view routing, and unify component props
All checks were successful
continuous-integration/drone/tag Build is passing
Summary

Introduced ArticlesModel abstraction with list + map store for fast lookup and clean CRUD operations.

Replaced all array-based article access with model methods (readById, create, update, refresh).

Added utils/articles.ts with pure functions for list/map operations.

Updated ArticleProvider to use the new model API and avoid mutation issues.

Added URL-synced navigation (pushState) + VIEW_URL mapping for deep-linkable routes.

Implemented route parsing on load (/articles/:id) to auto-open article view.

Standardized openArticle to pass full ArticleModel instead of index.

Updated all ArticleCard components to use article object instead of index.

Added navigationMap to view router for custom child navigation names.

Extracted shared styles to types/styles.ts and replaced old inline styled components.

Updated props definitions for Login, Register, Profile, ArticleView, MainContent, ArticleGrid.

Removed React.StrictMode wrapper to avoid double-effects during development.

Release: 0.2.5
Type: Feature + Refactor + Routing upgrade
2025-11-20 17:00:26 +05:30
7bdf84b6aa URL handling both on navigation and when directly calling URL.
directly calling only works for Article ID
2025-11-20 16:58:32 +05:30
2b578fd12e full article instead of index for article and using article._id open select article using readByIndex 2025-11-20 16:33:24 +05:30
fe33dca630 cleanup 2025-11-20 16:08:33 +05:30
fa319e7450 move from ArticleModel[] to ArticlesModel 2025-11-20 15:56:35 +05:30
cb6125f3f9 articles to articlesList and slice and length functions for ArticlesModel to act like an array 2025-11-20 15:56:13 +05:30
0ed816e994 ArticlesModel as single point for storing articles and operations on them 2025-11-20 15:38:10 +05:30
2dfbdb950a utils for articles 2025-11-20 00:09:23 +05:30
fcc3ec16f9 correct name article instead of user for ArticleModel 2025-11-19 23:45:25 +05:30
cff57f0980 option to customize navigation names as per the component props 2025-11-19 23:40:36 +05:30
e90fab8c0b cleanup 2025-11-19 23:16:41 +05:30
20 changed files with 381 additions and 173 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "aetoskia-blog-app",
"version": "0.2.4",
"version": "0.2.5",
"private": true,
"scripts": {
"dev": "vite",

View File

@@ -1,4 +1,5 @@
import * as React from 'react';
import { useEffect } from 'react';
import CssBaseline from '@mui/material/CssBaseline';
import Container from '@mui/material/Container';
import Box from '@mui/material/Box';
@@ -15,10 +16,17 @@ 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 { ArticleModel, ArticlesModel } from "./types/models";
import { ArticleViewProps, ArticleEditorProps } from "./types/props";
function HomeView({ currentUser, open_login, open_profile, open_create, articles, openArticle }: any) {
function HomeView({
currentUser,
open_login,
open_profile,
open_create,
articles,
openArticle
}: any) {
return (
<>
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 2, gap: 1 }}>
@@ -47,10 +55,29 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
const { currentUser } = useAuth();
const [ui, setUI] = React.useState({
selectedArticle: null as number | null,
selectedArticle: null as ArticleModel | null,
view: "home" as View,
});
useEffect(() => {
if (loading) return;
const path = window.location.pathname;
const parts = path.split('/').filter(Boolean);
if (parts[0] === 'articles' && parts[1]) {
const id = parts[1];
const article = articles.readById(id);
if (article) {
setUI({
selectedArticle: article,
view: 'article',
});
}
}
}, [loading]);
const {
goBack,
navigateToChildren,
@@ -59,14 +86,15 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
type RouterContext = {
ui: any;
articles: ArticleModel[];
articles: ArticlesModel;
currentUser: any;
openArticle: (index: number) => void;
openArticle: (article: ArticleModel) => void;
};
type ViewComponentEntry<P> = {
component: React.ComponentType<P>;
extraProps?: (ctx: RouterContext) => Partial<P>;
navigationMap?: Record<string, string>;
};
const VIEW_COMPONENTS: Record<View, ViewComponentEntry<any>> = {
@@ -76,6 +104,9 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
login: {
component: Login,
navigationMap: {
open_register: 'onRegister',
},
},
register: {
@@ -88,15 +119,18 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
article: {
component: ArticleView,
navigationMap: {
open_editor: 'onEdit',
},
extraProps: ({ ui, articles }) => ({
article: articles[ui.selectedArticle!],
article: articles.readById(ui.selectedArticle._id),
}) satisfies Partial<ArticleViewProps>,
},
editor: {
component: ArticleEditor,
extraProps: ({ ui, articles }) => ({
article: ui.selectedArticle !== null ? articles[ui.selectedArticle] : null,
article: ui.selectedArticle !== null ? articles.readById(ui.selectedArticle._id) : null,
}) satisfies Partial<ArticleEditorProps>,
},
@@ -110,9 +144,13 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
const renderView = () => {
const entry = VIEW_COMPONENTS[ui.view];
const navigationMap= entry['navigationMap'] || {}
const ViewComponent = entry.component;
const childNav = navigateToChildren(ui.view);
const childNav = navigateToChildren(
ui.view,
navigationMap
);
const ctx: RouterContext = {
ui,

View File

@@ -28,11 +28,10 @@ const CoverImage = styled('img')({
export default function ArticleView({
article,
onBack,
open_editor,
onEdit,
}: ArticleViewProps) {
const { currentUser } = useAuth();
const onEdit = open_editor;
return (
<ArticleContainer>

View File

@@ -16,7 +16,7 @@ export default function ArticleCardSize12({
return (
<StyledCard
variant="outlined"
onClick={() => onSelectArticle(index)}
onClick={() => onSelectArticle(article)}
onFocus={() => onFocus(index)}
onBlur={onBlur}
tabIndex={0}

View File

@@ -16,7 +16,7 @@ export default function ArticleCardSize2({
return (
<StyledCard
variant="outlined"
onClick={() => onSelectArticle(index)}
onClick={() => onSelectArticle(article)}
onFocus={() => onFocus(index)}
onBlur={onBlur}
tabIndex={0}

View File

@@ -16,7 +16,7 @@ export default function ArticleCardSize4({
return (
<StyledCard
variant="outlined"
onClick={() => onSelectArticle(index)}
onClick={() => onSelectArticle(article)}
onFocus={() => onFocus(index)}
onBlur={onBlur}
tabIndex={0}

View File

@@ -16,7 +16,7 @@ export default function ArticleCardSize6({
return (
<StyledCard
variant="outlined"
onClick={() => onSelectArticle(index)}
onClick={() => onSelectArticle(article)}
onFocus={() => onFocus(index)}
onBlur={onBlur}
tabIndex={0}

View File

@@ -1,99 +1,19 @@
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import AvatarGroup from '@mui/material/AvatarGroup';
import Box from '@mui/material/Box';
import Grid from '@mui/material/Grid';
import Typography from '@mui/material/Typography';
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 { StyledTypography, TitleTypography } from "../types/styles";
import { ArticleMeta } from "./ArticleMeta";
import Fade from '@mui/material/Fade';
const StyledTypography = styled(Typography)({
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
});
const TitleTypography = styled(Typography)(({ theme }) => ({
position: 'relative',
textDecoration: 'none',
'&:hover': { cursor: 'pointer' },
'& .arrow': {
visibility: 'hidden',
position: 'absolute',
right: 0,
top: '50%',
transform: 'translateY(-50%)',
},
'&:hover .arrow': {
visibility: 'visible',
opacity: 0.7,
},
'&:focus-visible': {
outline: '3px solid',
outlineColor: 'hsla(210, 98%, 48%, 0.5)',
outlineOffset: '3px',
borderRadius: '8px',
},
'&::before': {
content: '""',
position: 'absolute',
width: 0,
height: '1px',
bottom: 0,
left: 0,
backgroundColor: (theme.vars || theme).palette.text.primary,
opacity: 0.3,
transition: 'width 0.3s ease, opacity 0.3s ease',
},
'&:hover::before': {
width: '100%',
},
}));
function Author({ authors }: { authors: { name: string; avatar: string }[] }) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 2,
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<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((a) => a.name).join(', ')}
</Typography>
</Box>
<Typography variant="caption">Recently Updated</Typography>
</Box>
);
}
export default function Latest({ articles, onSelectArticle, onLoadMore }: LatestProps) {
export default function Latest({
articles,
onSelectArticle,
onLoadMore
}: LatestProps) {
const [visibleCount, setVisibleCount] = React.useState(2);
const [loadingMore, setLoadingMore] = React.useState(false);
const [animating, setAnimating] = React.useState(false);
@@ -182,7 +102,7 @@ export default function Latest({ articles, onSelectArticle, onLoadMore }: Latest
{article.description}
</StyledTypography>
<Author authors={article.authors} />
<ArticleMeta article={article} />
</Box>
</Fade>
</Grid>

View File

@@ -2,13 +2,12 @@ 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';
import { LoginProps } from '../types/props';
interface LoginProps {
onBack: () => void;
onRegister: () => void;
}
export default function Login({ onBack, onRegister }: LoginProps) {
export default function Login({
onBack,
onRegister
}: LoginProps) {
const { login, loading, error, currentUser } = useAuth();
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');

View File

@@ -9,7 +9,8 @@ import OutlinedInput from '@mui/material/OutlinedInput';
import SearchRoundedIcon from '@mui/icons-material/SearchRounded';
import RssFeedRoundedIcon from '@mui/icons-material/RssFeedRounded';
import { ArticleModel } from "../types/models";
import {ArticlesModel, createArticlesModelObject} from "../types/models";
import { MainContentProps } from "../types/props";
import ArticleCardsGrid from "./ArticleCards/ArticleCardsGrid";
export function Search() {
@@ -36,12 +37,9 @@ export function Search() {
export default function MainContent({
articles,
onSelectArticle,
}: {
articles: ArticleModel[];
onSelectArticle: (index: number) => void;
}) {
}: MainContentProps) {
const [visibleArticles, setVisibleArticles] = React.useState<ArticleModel[]>(articles);
const [visibleArticles, setVisibleArticles] = React.useState<ArticlesModel>(articles);
const [activeTag, setActiveTag] = React.useState<string>('all');
const filterArticlesByTag = (tag: string) => {
@@ -60,11 +58,11 @@ export default function MainContent({
}
// 🔵 Filter by selected tag
const filtered = articles.filter((article) => article.tag === tag);
const filtered = articles.articlesList.filter((article) => article.tag === tag);
console.log('👀 All Articles:', articles);
console.log(`👀 Filtered (${tag}):`, filtered);
setVisibleArticles(filtered);
setVisibleArticles(createArticlesModelObject(filtered));
setActiveTag(tag);
};

View File

@@ -12,12 +12,11 @@ import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import { useAuth } from '../providers/Author';
import { useUpload } from "../providers/Upload";
import ImageUploadField from './ImageUploadField';
import { ProfileProps } from '../types/props';
interface ProfileProps {
onBack: () => void;
}
export default function Profile({ onBack }: ProfileProps) {
export default function Profile({
onBack
}: ProfileProps) {
const { currentUser, loading, error, logout, updateProfile } = useAuth();
const { uploadFile } = useUpload();
const [formData, setFormData] = React.useState({

View File

@@ -2,19 +2,18 @@ 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 { RegisterProps } from '../types/props';
interface RegisterProps {
onBack: () => void;
}
export default function Register({ onBack }: RegisterProps) {
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) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLocalError(null);

View File

@@ -1,30 +1,21 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
import { api } from '../utils/api';
import { ArticleModel } from '../types/models';
import {
ArticleModel,
ArticlesModel,
createArticlesModelObject,
} from '../types/models';
import { ArticleContextModel } from '../types/contexts';
import { useAuth } from './Author';
const ArticleContext = createContext<ArticleContextModel | undefined>(undefined);
export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [articles, setArticles] = useState<ArticleModel[]>([]);
const [articles, setArticles] = useState<ArticlesModel>(createArticlesModelObject());
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[])
@@ -51,7 +42,7 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
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);
setArticles(prev => prev.refresh(formatted));
} catch (err: any) {
console.error('Failed to fetch articles:', err);
setError(err.response?.data?.detail || 'Failed to fetch articles');
@@ -77,7 +68,10 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
setError(null);
const res = await api.put<ArticleModel>(`/articles/${articleData._id}`, normalizedArticleData);
upsertArticleInList(res.data);
setArticles(prev => {
prev.update(res.data);
return { ...prev };
});
return res.data;
} catch (err: any) {
console.error('Article update failed:', err);
@@ -100,7 +94,10 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
setError(null);
const res = await api.post<ArticleModel>(`/articles`, normalizedArticleData);
upsertArticleInList(res.data);
setArticles(prev => {
prev.create(res.data);
return { ...prev };
});
return res.data;
} catch (err: any) {
console.error('Article create failed:', err);

View File

@@ -1,12 +1,16 @@
import { ArticleModel, AuthorModel } from "./models";
import {
ArticleModel,
ArticlesModel,
AuthorModel
} from "./models";
export interface ArticleContextModel {
articles: ArticleModel[];
articles: ArticlesModel;
loading: boolean;
error: string | null;
refreshArticles: () => Promise<void>;
updateArticle: (user: ArticleModel) => Promise<ArticleModel | void>;
createArticle: (user: ArticleModel) => Promise<ArticleModel | void>;
updateArticle: (article: ArticleModel) => Promise<ArticleModel | void>;
createArticle: (article: ArticleModel) => Promise<ArticleModel | void>;
}
export interface AuthContextModel {

View File

@@ -1,3 +1,9 @@
import {
createInList, readInList, updateInList, deleteInList,
createById, readById, updateById, deleteById
} from "../utils/articles";
export interface AuthorModel {
// meta fields
_id?: string | null;
@@ -28,3 +34,82 @@ export interface ArticleModel {
// ref fields
authors: AuthorModel[];
}
export interface ArticlesModel {
articlesList: ArticleModel[];
articlesById: Record<string, ArticleModel>;
// articlesByTag: Record<string, ArticleModel[]>;
// articlesByAuthor: Record<string, ArticleModel[]>;
length: number;
slice(start: number, end?: number): ArticleModel[];
refresh(list: ArticleModel[]): ArticlesModel;
create(a: ArticleModel): ArticlesModel;
readByIndex(index: number): ArticleModel | undefined;
readById(id: string): ArticleModel | undefined;
update(a: ArticleModel): ArticlesModel;
delete(id: string): ArticlesModel;
}
// ---------- FACTORY ----------
export function createArticlesModelObject(
articles: ArticleModel[] = []
): ArticlesModel {
const initialMap: Record<string, ArticleModel> = {};
for (const a of articles) {
if (a._id) initialMap[a._id] = a;
}
return {
articlesList: articles,
articlesById: initialMap,
// --- computed property ---
get length() {
return this.articlesList.length;
},
// --- array-like slice ---
slice(start: number, end?: number) {
return this.articlesList.slice(start, end);
},
refresh(list) {
this.articlesList = list;
const map: Record<string, ArticleModel> = {};
for (const a of list) {
if (a._id) map[a._id] = a;
}
this.articlesById = map;
return this;
},
create(a) {
this.articlesList = createInList(this.articlesList, a);
this.articlesById = createById(this.articlesById, a);
return this;
},
readByIndex(index) {
return readInList(this.articlesList, index);
},
readById(id) {
return readById(this.articlesById, id);
},
update(a) {
this.articlesList = updateInList(this.articlesList, a);
this.articlesById = updateById(this.articlesById, a);
return this;
},
delete(id) {
this.articlesList = deleteInList(this.articlesList, id);
this.articlesById = deleteById(this.articlesById, id);
return this;
}
};
}

View File

@@ -1,18 +1,38 @@
import { ArticleModel } from "./models";
import {
ArticleModel,
ArticlesModel,
} from "./models";
export interface LatestProps {
articles: ArticleModel[];
articles: ArticlesModel;
onSelectArticle?: (index: number) => void;
onLoadMore?: (offset: number, limit: number) => Promise<void>; // optional async callback
}
export interface LoginProps {
onBack: () => void;
onRegister: () => void;
}
export interface MainContentProps {
articles: ArticlesModel;
onSelectArticle: (index: ArticleModel) => void;
}
export interface ProfileProps {
onBack: () => void;
}
export interface RegisterProps {
onBack: () => void;
}
export interface ArticleViewProps {
article: ArticleModel;
onBack: () => void;
open_editor: () => void;
onEdit: () => void;
}
export interface ArticleEditorProps {
article?: ArticleModel | null;
onBack: () => void;
@@ -26,14 +46,14 @@ export interface ArticleCardProps {
article: ArticleModel;
index: number;
focusedCardIndex: number | null;
onSelectArticle: (index: number) => void;
onSelectArticle: (index: ArticleModel) => void;
onFocus: (index: number) => void;
onBlur: () => void;
}
export interface ArticleGridProps {
articles: ArticleModel[];
onSelectArticle: (index: number) => void;
articles: ArticlesModel;
onSelectArticle: (index: ArticleModel) => void;
xs?: number; // default 12 for mobile full-width
md12?: number, // default 12 (full-width)
md6?: number; // default 6 (half-width)

View File

@@ -37,4 +37,41 @@ export const StyledTypography = styled(Typography)({
WebkitLineClamp: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
});
});
export const TitleTypography = styled(Typography)(({ theme }) => ({
position: 'relative',
textDecoration: 'none',
'&:hover': { cursor: 'pointer' },
'& .arrow': {
visibility: 'hidden',
position: 'absolute',
right: 0,
top: '50%',
transform: 'translateY(-50%)',
},
'&:hover .arrow': {
visibility: 'visible',
opacity: 0.7,
},
'&:focus-visible': {
outline: '3px solid',
outlineColor: 'hsla(210, 98%, 48%, 0.5)',
outlineOffset: '3px',
borderRadius: '8px',
},
'&::before': {
content: '""',
position: 'absolute',
width: 0,
height: '1px',
bottom: 0,
left: 0,
backgroundColor: (theme.vars || theme).palette.text.primary,
opacity: 0.3,
transition: 'width 0.3s ease, opacity 0.3s ease',
},
'&:hover::before': {
width: '100%',
},
}));

View File

@@ -1,3 +1,5 @@
import {ArticleModel} from "./models";
export type View =
| "home"
| "login"
@@ -39,9 +41,31 @@ export const VIEW_TREE: Record<View, ViewNode> = {
},
};
export const VIEW_URL: Record<View, (ui?: any) => string> = {
home: () => "/",
login: () => "/login",
register: () => "/register",
profile: () => "/profile",
create: () => "/create",
article: (ui) => `/articles/${ui.selectedArticle._id ?? ""}`,
editor: (ui) => `/articles/${ui.selectedArticle._id ?? ""}/edit`,
};
export function useViewRouter(setUI: any) {
const navigate = (view: View) => {
setUI((prev: any) => ({ ...prev, view }));
const navigate = (
view: View,
nextState?: any
) => {
setUI((prev: any) => {
const newState = { ...prev, ...nextState, view };
// update URL
const url = VIEW_URL[view](newState);
window.history.pushState(newState, "", url);
return newState;
});
window.scrollTo({ top: 0, behavior: "smooth" });
};
@@ -51,18 +75,35 @@ export function useViewRouter(setUI: any) {
if (parent) navigate(parent);
};
const openArticle = (i: number) => {
setUI({ selectedArticle: i, view: "article" });
const openArticle = (article: ArticleModel) => {
setUI((prev: any) => {
const newState = {
...prev,
selectedArticle: article,
view: "article",
};
const url = `/articles/${article._id}`;
window.history.pushState(newState, "", url);
return newState;
});
window.scrollTo({ top: 0, behavior: "smooth" });
};
// auto child navigators from children[]
const navigateToChildren = (view: View) => {
const navigateToChildren = (
view: View,
navigationMap?: Record<string, string>,
) => {
const node = VIEW_TREE[view];
const funcs: Record<string, () => void> = {};
node.children?.forEach((child) => {
funcs[`open_${child}`] = () => navigate(child);
const funcName = `open_${child}`;
const customFuncName = navigationMap?.[funcName];
funcs[funcName] = () => navigate(child);
if (customFuncName) funcs[customFuncName] = () => navigate(child);
});
return funcs;

View File

@@ -0,0 +1,74 @@
import {
ArticleModel,
} from "../types/models";
export function createInList(list: ArticleModel[], a: ArticleModel) {
return [...list, a];
}
export function readInList(list: ArticleModel[], index: number) {
if (index < 0 || index >= list.length) {
// Soft fallback
return undefined;
}
return list[index];
}
export function updateInList(list: ArticleModel[], a: ArticleModel) {
return list.map(x => (x._id === a._id ? a : x));
}
export function deleteInList(list: ArticleModel[], id: string) {
return list.filter(x => x._id !== id);
}
// Map helpers
export function createById(
map: Record<string, ArticleModel>,
a: ArticleModel
) {
if (!a._id) {
// Soft mode: ignore create, return unchanged
return map;
}
if (map[a._id]) {
// Soft mode: do not replace existing
return map;
}
return { ...map, [a._id]: a };
}
export function readById(
map: Record<string, ArticleModel>,
id: string
) {
if (!id) return undefined;
return map[id];
}
export function updateById(
map: Record<string, ArticleModel>,
a: ArticleModel
) {
if (!a._id) {
// Cannot update without ID
return map;
}
if (!map[a._id]) {
// ID does not exist → soft mode: do nothing
return map;
}
return { ...map, [a._id]: a };
}
export function deleteById(
map: Record<string, ArticleModel>,
id: string
) {
const { [id]: _, ...rest } = map;
return rest;
}

View File

@@ -9,13 +9,11 @@ const rootElement = document.getElementById('root');
const root = createRoot(rootElement);
root.render(
<React.StrictMode>
<UploadProvider>
<AuthProvider>
<ArticleProvider>
<Blog />
</ArticleProvider>
</AuthProvider>
</UploadProvider>
</React.StrictMode>,
<UploadProvider>
<AuthProvider>
<ArticleProvider>
<Blog />
</ArticleProvider>
</AuthProvider>
</UploadProvider>,
);