refactor: reorganize admin UI with new layout components and top navigation bar

This commit is contained in:
Ender 2025-10-24 21:56:19 +02:00
parent d63f450be0
commit 8686744c7e
6 changed files with 209 additions and 80 deletions

View File

@ -2,6 +2,8 @@ import { useEffect, useState } from 'react';
import AuthGate from './components/AuthGate';
import EditorShell from './components/EditorShell';
import PostsList from './components/PostsList';
import AdminTopBar from './components/layout/AdminTopBar';
import { Box } from '@mui/material';
import './App.css';
function App() {
@ -15,27 +17,13 @@ function App() {
const handleLogout = () => {
localStorage.removeItem('voxblog_authed');
localStorage.removeItem('voxblog_post_id');
localStorage.removeItem('voxblog_draft_id');
setAuthenticated(false);
setSelectedPostId(null);
};
return (
<div className="app">
{authenticated ? (
selectedPostId ? (
<EditorShell
onLogout={handleLogout}
initialPostId={selectedPostId}
onBack={() => setSelectedPostId(null)}
/>
) : (
<PostsList
onSelect={(id) => {
setSelectedPostId(id);
localStorage.setItem('voxblog_draft_id', id);
}}
onNew={async () => {
const createNewPost = async () => {
try {
const res = await fetch('/api/posts', {
method: 'POST',
@ -45,16 +33,44 @@ function App() {
const data = await res.json();
if (res.ok && data.id) {
setSelectedPostId(data.id);
localStorage.setItem('voxblog_draft_id', data.id);
localStorage.setItem('voxblog_post_id', data.id);
} else {
alert('Failed to create post');
}
} catch (e: any) {
alert('Failed to create post: ' + (e?.message || 'unknown error'));
}
}}
};
return (
<div className="app">
{authenticated ? (
<Box sx={{ display: 'grid', gridTemplateRows: 'auto 1fr', minHeight: '100dvh' }}>
<AdminTopBar
userName={undefined}
onGoPosts={() => setSelectedPostId(null)}
onNewPost={createNewPost}
onSettings={undefined}
onLogout={handleLogout}
/>
)
<Box sx={{ minHeight: 0, overflow: 'hidden' }}>
{selectedPostId ? (
<EditorShell
onLogout={handleLogout}
initialPostId={selectedPostId}
onBack={() => setSelectedPostId(null)}
/>
) : (
<PostsList
onSelect={(id) => {
setSelectedPostId(id);
localStorage.setItem('voxblog_post_id', id);
}}
onNew={createNewPost}
/>
)}
</Box>
</Box>
) : (
<AuthGate onAuth={() => {
localStorage.setItem('voxblog_authed', '1');

View File

@ -1,4 +1,4 @@
import { Box, Button, Stack, Typography, TextField, MenuItem, Snackbar, Alert, Stepper, Step, StepLabel, StepButton } from '@mui/material';
import { Box, Snackbar, Alert, Stepper, Step, StepLabel, StepButton, Stack, Button } from '@mui/material';
import type { RichEditorHandle } from './RichEditor';
import StepAssets from './steps/StepAssets';
import StepAiPrompt from './steps/StepAiPrompt';
@ -9,8 +9,11 @@ import StepPublish from './steps/StepPublish';
import StepContainer from './steps/StepContainer';
import { usePostEditor } from '../hooks/usePostEditor';
import { useRef } from 'react';
import PageContainer from './layout/PageContainer';
import PostSidebar from './layout/PostSidebar';
import StepNavigation from './layout/StepNavigation';
export default function EditorShell({ onLogout, initialPostId, onBack }: { onLogout?: () => void; initialPostId?: string | null; onBack?: () => void }) {
export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack }: { onLogout?: () => void; initialPostId?: string | null; onBack?: () => void }) {
const editorRef = useRef<RichEditorHandle | null>(null);
const {
// state
@ -57,29 +60,15 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog
{toast?.message || ''}
</Alert>
</Snackbar>
<Box sx={{ display: { md: 'grid' }, gridTemplateColumns: { md: '300px 1fr' }, gap: 2, height: '100%', minHeight: 0, overflow: 'hidden' }}>
{/* Left sticky sidebar: Post controls */}
<Box sx={{ position: 'sticky', top: 12, alignSelf: 'start', border: '1px solid', borderColor: 'divider', borderRadius: 1, p: 2, maxHeight: '100%', overflowY: 'auto' }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>Post</Typography>
<Stack spacing={1}>
<TextField
size="small"
label="Title"
value={meta.title}
onChange={(e) => setMeta((m) => ({ ...m, title: e.target.value }))}
fullWidth
/>
<TextField size="small" select label="Status" value={postStatus} onChange={(e) => setPostStatus(e.target.value as any)} fullWidth>
<MenuItem value="inbox">inbox</MenuItem>
<MenuItem value="editing">editing</MenuItem>
<MenuItem value="ready_for_publish">ready_for_publish</MenuItem>
<MenuItem value="published">published</MenuItem>
<MenuItem value="archived">archived</MenuItem>
</TextField>
<Stack direction="row" spacing={1}>
<Button variant="contained" onClick={() => { void savePost(); }} fullWidth>Save</Button>
{postId && (
<Button color="error" variant="outlined" onClick={async () => {
<PageContainer
left={
<PostSidebar
meta={meta}
postStatus={postStatus}
onChangeMeta={setMeta}
onChangeStatus={setPostStatus}
onSave={() => { void savePost(); }}
onDelete={async () => {
if (!postId) return;
if (!confirm('Delete this post? This will remove its audio clips too.')) return;
try {
@ -88,13 +77,12 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog
} catch (e: any) {
alert('Delete failed: ' + (e?.message || 'unknown error'));
}
}}>Delete</Button>
)}
</Stack>
{onBack && <Button variant="text" onClick={onBack}>Back to Posts</Button>}
</Stack>
</Box>
}}
onBack={onBack}
showDelete={!!postId}
/>
}
>
{/* Right content: Stepper and step panels */}
<Box sx={{ display: 'grid', gridTemplateRows: 'auto 1fr auto', height: '100%', minHeight: 0, overflow: 'hidden' }}>
<Stepper nonLinear activeStep={activeStep} sx={{ mb: 2 }}>
@ -175,17 +163,14 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog
)}
</Box>
{/* Bottom nav fixed to panel bottom via grid */}
<Box sx={{ bgcolor: 'background.paper', py: 1, borderTop: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" spacing={1} justifyContent="space-between">
<Button disabled={activeStep === 0} onClick={() => setActiveStep((s) => Math.max(0, s - 1))}>Back</Button>
<Stack direction="row" spacing={1}>
<Button variant="outlined" onClick={() => setActiveStep((s) => Math.min(5, s + 1))}>Next</Button>
</Stack>
</Stack>
</Box>
</Box>
{/* Step navigation (not page navigation) */}
<StepNavigation
disableBack={activeStep === 0}
onBack={() => setActiveStep((s) => Math.max(0, s - 1))}
onNext={() => setActiveStep((s) => Math.min(5, s + 1))}
/>
</Box>
</PageContainer>
</Box>
);
}

View File

@ -0,0 +1,35 @@
import { AppBar, Box, Button, Toolbar, Typography } from '@mui/material';
export default function AdminTopBar({
userName,
onGoPosts,
onNewPost,
onSettings,
onLogout,
}: {
userName?: string;
onGoPosts?: () => void;
onNewPost?: () => void;
onSettings?: () => void;
onLogout?: () => void;
}) {
return (
<AppBar position="sticky" elevation={0} sx={{
backgroundColor: 'rgba(255,255,255,0.8)',
backdropFilter: 'blur(8px)',
borderBottom: '1px solid rgba(0,0,0,0.06)'
}}>
<Toolbar>
<Typography variant="h6" sx={{ flexGrow: 1 }}>
{userName ? `Hi, ${userName}` : 'VoxBlog Admin'}
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
{onGoPosts && <Button color="inherit" onClick={onGoPosts}>Posts</Button>}
{onNewPost && <Button color="inherit" onClick={onNewPost}>New Post</Button>}
{onSettings && <Button color="inherit" onClick={onSettings}>Settings</Button>}
{onLogout && <Button color="inherit" onClick={onLogout}>Logout</Button>}
</Box>
</Toolbar>
</AppBar>
);
}

View File

@ -0,0 +1,17 @@
import { Box } from '@mui/material';
import type { ReactNode } from 'react';
export default function PageContainer({ left, children, leftWidth = 300 }: { left: ReactNode; children: ReactNode; leftWidth?: number }) {
return (
<Box sx={{ minHeight: '100dvh', display: 'flex', flexDirection: 'column', p: { xs: 2, md: 3 }, overflow: 'hidden' }}>
<Box sx={{ display: { md: 'grid' }, gridTemplateColumns: { md: `${leftWidth}px 1fr` }, gap: 2, height: '100%', minHeight: 0, overflow: 'hidden' }}>
<Box sx={{ position: 'sticky', top: 12, alignSelf: 'start', border: '1px solid', borderColor: 'divider', borderRadius: 1, p: 2, maxHeight: '100%', overflowY: 'auto' }}>
{left}
</Box>
<Box sx={{ height: '100%', minHeight: 0, overflow: 'hidden' }}>
{children}
</Box>
</Box>
</Box>
);
}

View File

@ -0,0 +1,51 @@
import { Box, Button, MenuItem, Stack, TextField, Typography } from '@mui/material';
import type { Metadata } from '../MetadataPanel';
export default function PostSidebar({
meta,
postStatus,
onChangeMeta,
onChangeStatus,
onSave,
onDelete,
onBack,
showDelete,
}: {
meta: Metadata;
postStatus: 'inbox' | 'editing' | 'ready_for_publish' | 'published' | 'archived';
onChangeMeta: (m: Metadata) => void;
onChangeStatus: (s: 'inbox' | 'editing' | 'ready_for_publish' | 'published' | 'archived') => void;
onSave: () => void | Promise<void>;
onDelete: () => void | Promise<void>;
onBack?: () => void;
showDelete?: boolean;
}) {
return (
<Box>
<Typography variant="subtitle1" sx={{ mb: 1 }}>Post</Typography>
<Stack spacing={1}>
<TextField
size="small"
label="Title"
value={meta.title}
onChange={(e) => onChangeMeta({ ...meta, title: e.target.value })}
fullWidth
/>
<TextField size="small" select label="Status" value={postStatus} onChange={(e) => onChangeStatus(e.target.value as any)} fullWidth>
<MenuItem value="inbox">inbox</MenuItem>
<MenuItem value="editing">editing</MenuItem>
<MenuItem value="ready_for_publish">ready_for_publish</MenuItem>
<MenuItem value="published">published</MenuItem>
<MenuItem value="archived">archived</MenuItem>
</TextField>
<Stack direction="row" spacing={1}>
<Button variant="contained" onClick={onSave} fullWidth>Save</Button>
{showDelete && (
<Button color="error" variant="outlined" onClick={onDelete}>Delete</Button>
)}
</Stack>
{onBack && <Button variant="text" onClick={onBack}>Back to Posts</Button>}
</Stack>
</Box>
);
}

View File

@ -0,0 +1,25 @@
import { Box, Button, Stack } from '@mui/material';
export default function StepNavigation({
disableBack,
onBack,
onNext,
right,
}: {
disableBack?: boolean;
onBack: () => void;
onNext: () => void;
right?: React.ReactNode;
}) {
return (
<Box sx={{ bgcolor: 'background.paper', py: 1, borderTop: '1px solid', borderColor: 'divider' }}>
<Stack direction="row" spacing={1} justifyContent="space-between">
<Button disabled={!!disableBack} onClick={onBack}>Back</Button>
<Stack direction="row" spacing={1} alignItems="center">
{right}
<Button variant="outlined" onClick={onNext}>Next</Button>
</Stack>
</Stack>
</Box>
);
}