11 Commits

Author SHA1 Message Date
3aaf328511 feat(router): migrate to declarative view-based navigation system
All checks were successful
continuous-integration/drone/tag Build is passing
- Introduce unified View hierarchy (VIEW_TREE) with parent/child relationships
- Add useViewRouter for navigate(), goBack(), openArticle(), and dynamic child navigation
- Replace legacy boolean-based view flags with single ui.view state
- Implement dynamic component rendering via VIEW_COMPONENTS map
- Add HomeView wrapper and integrate dynamic navigation props
- Update ArticleView to use open_editor and new ArticleViewProps
- Adjust ArticleEditor props type to accept null article
- Normalize navigation prop naming (open_* passed as onBack/onEdit via router)
- Enforce validation: prevent article updates without logged-in user
- Remove old conditional rendering/switch blocks and simplify Blog.tsx
- Version bump: 0.2.3 → 0.2.4
2025-11-18 17:55:01 +05:30
635e99c183 cleanup 2025-11-18 17:09:10 +05:30
b8e4decfba cleanup 2025-11-18 16:53:48 +05:30
459fa5855c abstracted navigation logic 2025-11-18 16:53:36 +05:30
f52c4a5287 added missing create 2025-11-18 16:28:53 +05:30
3a3f44c5b5 moved views logic to types 2025-11-18 16:28:41 +05:30
479ffb736c hierarchy wise view 2025-11-18 16:14:47 +05:30
87bdafb6a3 cleaner view for Blog 2025-11-18 16:05:27 +05:30
383b424bdf back and edit button spaced out properly 2025-11-18 15:23:08 +05:30
0340e17467 moved chip to between Cover Image and Article content 2025-11-18 15:20:11 +05:30
f15155d31c show edit button only if currentUser is present and don't updateArticle if currentUser is not present 2025-11-18 15:19:27 +05:30
6 changed files with 217 additions and 148 deletions

View File

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

View File

@@ -14,146 +14,124 @@ import Register from './components/Register';
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 { 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 }) {
const { articles, loading, error } = useArticles();
const { currentUser } = useAuth();
const [selectedArticle, setSelectedArticle] = React.useState<number | null>(null);
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 [ui, setUI] = React.useState({
selectedArticle: null as number | null,
view: "home" as View,
});
const handleSelectArticle = (index: number) => {
setSelectedArticle(index);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleShowLogin = () => {
setShowLogin(true);
setShowRegister(false);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleShowRegister = () => {
setShowRegister(true);
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' });
const {
goBack,
navigateToChildren,
openArticle,
} = useViewRouter(setUI);
type RouterContext = {
ui: any;
articles: ArticleModel[];
currentUser: any;
openArticle: (index: number) => void;
};
// derive a single source of truth for view
const view: View = React.useMemo(() => {
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, showEditor]);
type ViewComponentEntry<P> = {
component: React.ComponentType<P>;
extraProps?: (ctx: RouterContext) => Partial<P>;
};
const VIEW_COMPONENTS: Record<View, ViewComponentEntry<any>> = {
home: {
component: HomeView,
},
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 = () => {
switch (view) {
case 'register':
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>
const entry = VIEW_COMPONENTS[ui.view];
const ViewComponent = entry.component;
<MainContent articles={articles} onSelectArticle={handleSelectArticle} />
<Latest
articles={articles}
onSelectArticle={handleSelectArticle}
onLoadMore={async () => {}}
/>
</>
);
}
const childNav = navigateToChildren(ui.view);
const ctx: RouterContext = {
ui,
articles,
currentUser,
openArticle,
};
const extraProps = entry.extraProps ? entry.extraProps(ctx) : {};
return (
<ViewComponent
{...ctx}
{...childNav}
onBack={() => goBack(ui.view)}
{...extraProps}
/>
);
};
if (loading) {
@@ -210,13 +188,13 @@ export default function Blog(props: { disableCustomTheme?: boolean }) {
flexDirection: 'column',
my: 4,
gap: 4,
pb: view === 'home' ? 24 : 0,
pb: ui.view === 'home' ? 24 : 0,
}}
>
{renderView()}
</Container>
{view === 'home' && (
{ui.view === 'home' && (
<Box
component="footer"
sx={{

View File

@@ -5,7 +5,8 @@ import { styled } from '@mui/material/styles';
import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded';
import EditRoundedIcon from '@mui/icons-material/EditRounded';
import { ArticleMeta } from "../ArticleMeta";
import { ArticleProps } from '../../types/props';
import { ArticleViewProps } from '../../types/props';
import {useAuth} from "../../providers/Author";
const ArticleContainer = styled(Box)(({ theme }) => ({
maxWidth: '800px',
@@ -27,25 +28,31 @@ const CoverImage = styled('img')({
export default function ArticleView({
article,
onBack,
onEdit,
}: ArticleProps) {
open_editor,
}: ArticleViewProps) {
const { currentUser } = useAuth();
const onEdit = open_editor;
return (
<ArticleContainer>
<IconButton onClick={onBack} sx={{ mb: 2 }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
mb: 2,
}}
>
<IconButton onClick={onBack}>
<ArrowBackRoundedIcon />
</IconButton>
<Chip
label={article.tag}
variant="outlined"
color="primary"
sx={{ mb: 2, textTransform: 'uppercase', fontWeight: 500 }}
/>
<IconButton onClick={onEdit} sx={{ mb: 2 }}>
<EditRoundedIcon />
</IconButton>
{currentUser && (
<IconButton onClick={onEdit}>
<EditRoundedIcon />
</IconButton>
)}
</Box>
<Typography variant="h3" component="h1" gutterBottom fontWeight="bold">
{article.title}
@@ -55,6 +62,13 @@ export default function ArticleView({
<Divider sx={{ my: 3 }} />
<Chip
label={article.tag}
variant="outlined"
color="primary"
sx={{ mb: 2, textTransform: 'uppercase', fontWeight: 500 }}
/>
<CoverImage
src={(
import.meta.env.VITE_API_BASE_URL.replace(/\/+$/, "") +

View File

@@ -66,6 +66,10 @@ export const ArticleProvider: React.FC<{ children: React.ReactNode }> = ({ child
console.error('updateArticle called without _id');
return;
}
if (!currentUser) {
console.error('updateArticle called without logged in user');
return;
}
const normalizedArticleData = normalizeArticleForApi(articleData);
try {

View File

@@ -6,14 +6,15 @@ export interface LatestProps {
onLoadMore?: (offset: number, limit: number) => Promise<void>; // optional async callback
}
export interface ArticleProps {
export interface ArticleViewProps {
article: ArticleModel;
onBack: () => void;
onEdit: () => void;
open_editor: () => void;
}
export interface ArticleEditorProps {
article?: ArticleModel;
article?: ArticleModel | null;
onBack: () => void;
}

72
src/blog/types/views.ts Normal file
View 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 };
}