refactor: reorganize admin UI with new layout components and top navigation bar
This commit is contained in:
parent
d63f450be0
commit
8686744c7e
@ -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');
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
35
apps/admin/src/components/layout/AdminTopBar.tsx
Normal file
35
apps/admin/src/components/layout/AdminTopBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
apps/admin/src/components/layout/PageContainer.tsx
Normal file
17
apps/admin/src/components/layout/PageContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
apps/admin/src/components/layout/PostSidebar.tsx
Normal file
51
apps/admin/src/components/layout/PostSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
apps/admin/src/components/layout/StepNavigation.tsx
Normal file
25
apps/admin/src/components/layout/StepNavigation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user