Compare commits
11 Commits
3aaf328511
...
0.2.5
| Author | SHA1 | Date | |
|---|---|---|---|
| a7987ab922 | |||
| 7bdf84b6aa | |||
| 2b578fd12e | |||
| fe33dca630 | |||
| fa319e7450 | |||
| cb6125f3f9 | |||
| 0ed816e994 | |||
| 2dfbdb950a | |||
| fcc3ec16f9 | |||
| cff57f0980 | |||
| e90fab8c0b |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aetoskia-blog-app",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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%',
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
|
||||
74
src/blog/utils/articles.ts
Normal file
74
src/blog/utils/articles.ts
Normal 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;
|
||||
}
|
||||
16
src/main.jsx
16
src/main.jsx
@@ -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>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user