Compare commits
11 Commits
0.2.3
...
3aaf328511
| Author | SHA1 | Date | |
|---|---|---|---|
| 3aaf328511 | |||
| 635e99c183 | |||
| b8e4decfba | |||
| 459fa5855c | |||
| f52c4a5287 | |||
| 3a3f44c5b5 | |||
| 479ffb736c | |||
| 87bdafb6a3 | |||
| 383b424bdf | |||
| 0340e17467 | |||
| f15155d31c |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aetoskia-blog-app",
|
"name": "aetoskia-blog-app",
|
||||||
"version": "0.2.3",
|
"version": "0.2.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -14,146 +14,124 @@ import Register from './components/Register';
|
|||||||
import Profile from './components/Profile';
|
import Profile from './components/Profile';
|
||||||
import { useArticles } from './providers/Article';
|
import { useArticles } from './providers/Article';
|
||||||
import { useAuth } from './providers/Author';
|
import { useAuth } from './providers/Author';
|
||||||
|
import { View, useViewRouter } from "./types/views";
|
||||||
|
import { ArticleModel } from "./types/models";
|
||||||
|
import { ArticleViewProps, ArticleEditorProps } from "./types/props";
|
||||||
|
|
||||||
type View = 'home' | 'login' | 'register' | 'article' | 'profile' | 'editor';
|
function HomeView({ currentUser, open_login, open_profile, open_create, articles, openArticle }: any) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "flex-end", mb: 2, gap: 1 }}>
|
||||||
|
{!currentUser ? (
|
||||||
|
<Button variant="outlined" onClick={open_login}>Login</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="outlined" onClick={open_profile}>
|
||||||
|
{currentUser.username}
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" onClick={open_create}>
|
||||||
|
New Article
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<MainContent articles={articles} onSelectArticle={openArticle} />
|
||||||
|
<Latest articles={articles} onSelectArticle={openArticle} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Blog(props: { disableCustomTheme?: boolean }) {
|
export default function Blog(props: { disableCustomTheme?: boolean }) {
|
||||||
const { articles, loading, error } = useArticles();
|
const { articles, loading, error } = useArticles();
|
||||||
const { currentUser } = useAuth();
|
const { currentUser } = useAuth();
|
||||||
|
|
||||||
const [selectedArticle, setSelectedArticle] = React.useState<number | null>(null);
|
const [ui, setUI] = React.useState({
|
||||||
const [showLogin, setShowLogin] = React.useState(false);
|
selectedArticle: null as number | null,
|
||||||
const [showRegister, setShowRegister] = React.useState(false);
|
view: "home" as View,
|
||||||
const [showProfile, setShowProfile] = React.useState(false);
|
});
|
||||||
const [showEditor, setShowEditor] = React.useState(false);
|
|
||||||
|
|
||||||
const handleSelectArticle = (index: number) => {
|
const {
|
||||||
setSelectedArticle(index);
|
goBack,
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
navigateToChildren,
|
||||||
};
|
openArticle,
|
||||||
const handleShowLogin = () => {
|
} = useViewRouter(setUI);
|
||||||
setShowLogin(true);
|
|
||||||
setShowRegister(false);
|
type RouterContext = {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
ui: any;
|
||||||
};
|
articles: ArticleModel[];
|
||||||
const handleShowRegister = () => {
|
currentUser: any;
|
||||||
setShowRegister(true);
|
openArticle: (index: number) => void;
|
||||||
setShowLogin(false);
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
const handleHideAuth = () => {
|
|
||||||
setShowLogin(false);
|
|
||||||
setShowRegister(false);
|
|
||||||
};
|
|
||||||
const handleShowProfile = () => {
|
|
||||||
setShowProfile(true);
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
};
|
|
||||||
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
|
type ViewComponentEntry<P> = {
|
||||||
const view: View = React.useMemo(() => {
|
component: React.ComponentType<P>;
|
||||||
if (selectedArticle !== null && !showEditor) return 'article';
|
extraProps?: (ctx: RouterContext) => Partial<P>;
|
||||||
if (showRegister) return 'register';
|
};
|
||||||
if (showLogin) return 'login';
|
|
||||||
if (showProfile) return 'profile';
|
const VIEW_COMPONENTS: Record<View, ViewComponentEntry<any>> = {
|
||||||
if (showEditor) return 'editor';
|
home: {
|
||||||
return 'home';
|
component: HomeView,
|
||||||
}, [selectedArticle, showLogin, showRegister, showProfile, showEditor]);
|
},
|
||||||
|
|
||||||
|
login: {
|
||||||
|
component: Login,
|
||||||
|
},
|
||||||
|
|
||||||
|
register: {
|
||||||
|
component: Register,
|
||||||
|
},
|
||||||
|
|
||||||
|
profile: {
|
||||||
|
component: Profile,
|
||||||
|
},
|
||||||
|
|
||||||
|
article: {
|
||||||
|
component: ArticleView,
|
||||||
|
extraProps: ({ ui, articles }) => ({
|
||||||
|
article: articles[ui.selectedArticle!],
|
||||||
|
}) satisfies Partial<ArticleViewProps>,
|
||||||
|
},
|
||||||
|
|
||||||
|
editor: {
|
||||||
|
component: ArticleEditor,
|
||||||
|
extraProps: ({ ui, articles }) => ({
|
||||||
|
article: ui.selectedArticle !== null ? articles[ui.selectedArticle] : null,
|
||||||
|
}) satisfies Partial<ArticleEditorProps>,
|
||||||
|
},
|
||||||
|
|
||||||
|
create: {
|
||||||
|
component: ArticleEditor,
|
||||||
|
extraProps: () => ({
|
||||||
|
article: null,
|
||||||
|
}) satisfies Partial<ArticleEditorProps>,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// render function keeps JSX tidy
|
|
||||||
const renderView = () => {
|
const renderView = () => {
|
||||||
switch (view) {
|
const entry = VIEW_COMPONENTS[ui.view];
|
||||||
case 'register':
|
const ViewComponent = entry.component;
|
||||||
return <Register onBack={handleHideAuth} />;
|
|
||||||
case 'login':
|
|
||||||
return (
|
|
||||||
<Login
|
|
||||||
onBack={handleHideAuth}
|
|
||||||
onRegister={() => {
|
|
||||||
handleShowRegister();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'profile':
|
|
||||||
return (
|
|
||||||
<Profile
|
|
||||||
onBack={handleHideProfile}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'article':
|
|
||||||
if (selectedArticle == null || !articles[selectedArticle]) return null;
|
|
||||||
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 (
|
|
||||||
<>
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2, gap: 1 }}>
|
|
||||||
{!currentUser ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
onClick={handleShowLogin}
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
onClick={handleShowProfile}
|
|
||||||
>
|
|
||||||
{currentUser.username}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
onClick={handleShowEditor}
|
|
||||||
>
|
|
||||||
New Article
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<MainContent articles={articles} onSelectArticle={handleSelectArticle} />
|
const childNav = navigateToChildren(ui.view);
|
||||||
<Latest
|
|
||||||
articles={articles}
|
const ctx: RouterContext = {
|
||||||
onSelectArticle={handleSelectArticle}
|
ui,
|
||||||
onLoadMore={async () => {}}
|
articles,
|
||||||
/>
|
currentUser,
|
||||||
</>
|
|
||||||
);
|
openArticle,
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const extraProps = entry.extraProps ? entry.extraProps(ctx) : {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ViewComponent
|
||||||
|
{...ctx}
|
||||||
|
{...childNav}
|
||||||
|
onBack={() => goBack(ui.view)}
|
||||||
|
{...extraProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -210,13 +188,13 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
my: 4,
|
my: 4,
|
||||||
gap: 4,
|
gap: 4,
|
||||||
pb: view === 'home' ? 24 : 0,
|
pb: ui.view === 'home' ? 24 : 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderView()}
|
{renderView()}
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
{view === 'home' && (
|
{ui.view === 'home' && (
|
||||||
<Box
|
<Box
|
||||||
component="footer"
|
component="footer"
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { styled } from '@mui/material/styles';
|
|||||||
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
|
||||||
import EditRoundedIcon from '@mui/icons-material/EditRounded';
|
import EditRoundedIcon from '@mui/icons-material/EditRounded';
|
||||||
import { ArticleMeta } from "../ArticleMeta";
|
import { ArticleMeta } from "../ArticleMeta";
|
||||||
import { ArticleProps } from '../../types/props';
|
import { ArticleViewProps } from '../../types/props';
|
||||||
|
import {useAuth} from "../../providers/Author";
|
||||||
|
|
||||||
const ArticleContainer = styled(Box)(({ theme }) => ({
|
const ArticleContainer = styled(Box)(({ theme }) => ({
|
||||||
maxWidth: '800px',
|
maxWidth: '800px',
|
||||||
@@ -27,25 +28,31 @@ const CoverImage = styled('img')({
|
|||||||
export default function ArticleView({
|
export default function ArticleView({
|
||||||
article,
|
article,
|
||||||
onBack,
|
onBack,
|
||||||
onEdit,
|
open_editor,
|
||||||
}: ArticleProps) {
|
}: ArticleViewProps) {
|
||||||
|
|
||||||
|
const { currentUser } = useAuth();
|
||||||
|
const onEdit = open_editor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ArticleContainer>
|
<ArticleContainer>
|
||||||
<IconButton onClick={onBack} sx={{ mb: 2 }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
mb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton onClick={onBack}>
|
||||||
<ArrowBackRoundedIcon />
|
<ArrowBackRoundedIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
<Chip
|
{currentUser && (
|
||||||
label={article.tag}
|
<IconButton onClick={onEdit}>
|
||||||
variant="outlined"
|
<EditRoundedIcon />
|
||||||
color="primary"
|
</IconButton>
|
||||||
sx={{ mb: 2, textTransform: 'uppercase', fontWeight: 500 }}
|
)}
|
||||||
/>
|
</Box>
|
||||||
|
|
||||||
<IconButton onClick={onEdit} sx={{ mb: 2 }}>
|
|
||||||
<EditRoundedIcon />
|
|
||||||
</IconButton>
|
|
||||||
|
|
||||||
<Typography variant="h3" component="h1" gutterBottom fontWeight="bold">
|
<Typography variant="h3" component="h1" gutterBottom fontWeight="bold">
|
||||||
{article.title}
|
{article.title}
|
||||||
@@ -55,6 +62,13 @@ export default function ArticleView({
|
|||||||
|
|
||||||
<Divider sx={{ my: 3 }} />
|
<Divider sx={{ my: 3 }} />
|
||||||
|
|
||||||
|
<Chip
|
||||||
|
label={article.tag}
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
sx={{ mb: 2, textTransform: 'uppercase', fontWeight: 500 }}
|
||||||
|
/>
|
||||||
|
|
||||||
<CoverImage
|
<CoverImage
|
||||||
src={(
|
src={(
|
||||||
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
|||||||
console.error('updateArticle called without _id');
|
console.error('updateArticle called without _id');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!currentUser) {
|
||||||
|
console.error('updateArticle called without logged in user');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedArticleData = normalizeArticleForApi(articleData);
|
const normalizedArticleData = normalizeArticleForApi(articleData);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,14 +6,15 @@ export interface LatestProps {
|
|||||||
onLoadMore?: (offset: number, limit: number) => Promise<void>; // optional async callback
|
onLoadMore?: (offset: number, limit: number) => Promise<void>; // optional async callback
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ArticleProps {
|
export interface ArticleViewProps {
|
||||||
article: ArticleModel;
|
article: ArticleModel;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onEdit: () => void;
|
open_editor: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface ArticleEditorProps {
|
export interface ArticleEditorProps {
|
||||||
article?: ArticleModel;
|
article?: ArticleModel | null;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
72
src/blog/types/views.ts
Normal file
72
src/blog/types/views.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
export type View =
|
||||||
|
| "home"
|
||||||
|
| "login"
|
||||||
|
| "register"
|
||||||
|
| "article"
|
||||||
|
| "editor"
|
||||||
|
| "profile"
|
||||||
|
| "create";
|
||||||
|
|
||||||
|
export type ViewNode = {
|
||||||
|
parent: View | null;
|
||||||
|
children?: View[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VIEW_TREE: Record<View, ViewNode> = {
|
||||||
|
home: {
|
||||||
|
parent: null,
|
||||||
|
children: ["login", "article", "profile", "create"],
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
parent: "home",
|
||||||
|
children: ["register"],
|
||||||
|
},
|
||||||
|
register: {
|
||||||
|
parent: "login",
|
||||||
|
},
|
||||||
|
article: {
|
||||||
|
parent: "home",
|
||||||
|
children: ["editor"],
|
||||||
|
},
|
||||||
|
editor: {
|
||||||
|
parent: "article",
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
parent: "home",
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
parent: "home",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useViewRouter(setUI: any) {
|
||||||
|
const navigate = (view: View) => {
|
||||||
|
setUI((prev: any) => ({ ...prev, view }));
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
// auto back logic from parent
|
||||||
|
const goBack = (view: View) => {
|
||||||
|
const parent = VIEW_TREE[view].parent;
|
||||||
|
if (parent) navigate(parent);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openArticle = (i: number) => {
|
||||||
|
setUI({ selectedArticle: i, view: "article" });
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
// auto child navigators from children[]
|
||||||
|
const navigateToChildren = (view: View) => {
|
||||||
|
const node = VIEW_TREE[view];
|
||||||
|
const funcs: Record<string, () => void> = {};
|
||||||
|
|
||||||
|
node.children?.forEach((child) => {
|
||||||
|
funcs[`open_${child}`] = () => navigate(child);
|
||||||
|
});
|
||||||
|
|
||||||
|
return funcs;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { navigate, goBack, openArticle, navigateToChildren };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user