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 AuthGate from './components/AuthGate';
|
||||||
import EditorShell from './components/EditorShell';
|
import EditorShell from './components/EditorShell';
|
||||||
import PostsList from './components/PostsList';
|
import PostsList from './components/PostsList';
|
||||||
|
import AdminTopBar from './components/layout/AdminTopBar';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@ -15,46 +17,60 @@ function App() {
|
|||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem('voxblog_authed');
|
localStorage.removeItem('voxblog_authed');
|
||||||
|
localStorage.removeItem('voxblog_post_id');
|
||||||
localStorage.removeItem('voxblog_draft_id');
|
localStorage.removeItem('voxblog_draft_id');
|
||||||
setAuthenticated(false);
|
setAuthenticated(false);
|
||||||
setSelectedPostId(null);
|
setSelectedPostId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createNewPost = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/posts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title: 'Untitled', contentHtml: '<p></p>', status: 'editing' }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok && data.id) {
|
||||||
|
setSelectedPostId(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 (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
{authenticated ? (
|
{authenticated ? (
|
||||||
selectedPostId ? (
|
<Box sx={{ display: 'grid', gridTemplateRows: 'auto 1fr', minHeight: '100dvh' }}>
|
||||||
<EditorShell
|
<AdminTopBar
|
||||||
|
userName={undefined}
|
||||||
|
onGoPosts={() => setSelectedPostId(null)}
|
||||||
|
onNewPost={createNewPost}
|
||||||
|
onSettings={undefined}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
initialPostId={selectedPostId}
|
|
||||||
onBack={() => setSelectedPostId(null)}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
<Box sx={{ minHeight: 0, overflow: 'hidden' }}>
|
||||||
<PostsList
|
{selectedPostId ? (
|
||||||
onSelect={(id) => {
|
<EditorShell
|
||||||
setSelectedPostId(id);
|
onLogout={handleLogout}
|
||||||
localStorage.setItem('voxblog_draft_id', id);
|
initialPostId={selectedPostId}
|
||||||
}}
|
onBack={() => setSelectedPostId(null)}
|
||||||
onNew={async () => {
|
/>
|
||||||
try {
|
) : (
|
||||||
const res = await fetch('/api/posts', {
|
<PostsList
|
||||||
method: 'POST',
|
onSelect={(id) => {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
setSelectedPostId(id);
|
||||||
body: JSON.stringify({ title: 'Untitled', contentHtml: '<p></p>', status: 'editing' }),
|
localStorage.setItem('voxblog_post_id', id);
|
||||||
});
|
}}
|
||||||
const data = await res.json();
|
onNew={createNewPost}
|
||||||
if (res.ok && data.id) {
|
/>
|
||||||
setSelectedPostId(data.id);
|
)}
|
||||||
localStorage.setItem('voxblog_draft_id', data.id);
|
</Box>
|
||||||
} else {
|
</Box>
|
||||||
alert('Failed to create post');
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
alert('Failed to create post: ' + (e?.message || 'unknown error'));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
) : (
|
||||||
<AuthGate onAuth={() => {
|
<AuthGate onAuth={() => {
|
||||||
localStorage.setItem('voxblog_authed', '1');
|
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 type { RichEditorHandle } from './RichEditor';
|
||||||
import StepAssets from './steps/StepAssets';
|
import StepAssets from './steps/StepAssets';
|
||||||
import StepAiPrompt from './steps/StepAiPrompt';
|
import StepAiPrompt from './steps/StepAiPrompt';
|
||||||
@ -9,8 +9,11 @@ import StepPublish from './steps/StepPublish';
|
|||||||
import StepContainer from './steps/StepContainer';
|
import StepContainer from './steps/StepContainer';
|
||||||
import { usePostEditor } from '../hooks/usePostEditor';
|
import { usePostEditor } from '../hooks/usePostEditor';
|
||||||
import { useRef } from 'react';
|
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 editorRef = useRef<RichEditorHandle | null>(null);
|
||||||
const {
|
const {
|
||||||
// state
|
// state
|
||||||
@ -57,44 +60,29 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog
|
|||||||
{toast?.message || ''}
|
{toast?.message || ''}
|
||||||
</Alert>
|
</Alert>
|
||||||
</Snackbar>
|
</Snackbar>
|
||||||
<Box sx={{ display: { md: 'grid' }, gridTemplateColumns: { md: '300px 1fr' }, gap: 2, height: '100%', minHeight: 0, overflow: 'hidden' }}>
|
<PageContainer
|
||||||
{/* Left sticky sidebar: Post controls */}
|
left={
|
||||||
<Box sx={{ position: 'sticky', top: 12, alignSelf: 'start', border: '1px solid', borderColor: 'divider', borderRadius: 1, p: 2, maxHeight: '100%', overflowY: 'auto' }}>
|
<PostSidebar
|
||||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>Post</Typography>
|
meta={meta}
|
||||||
<Stack spacing={1}>
|
postStatus={postStatus}
|
||||||
<TextField
|
onChangeMeta={setMeta}
|
||||||
size="small"
|
onChangeStatus={setPostStatus}
|
||||||
label="Title"
|
onSave={() => { void savePost(); }}
|
||||||
value={meta.title}
|
onDelete={async () => {
|
||||||
onChange={(e) => setMeta((m) => ({ ...m, title: e.target.value }))}
|
if (!postId) return;
|
||||||
fullWidth
|
if (!confirm('Delete this post? This will remove its audio clips too.')) return;
|
||||||
/>
|
try {
|
||||||
<TextField size="small" select label="Status" value={postStatus} onChange={(e) => setPostStatus(e.target.value as any)} fullWidth>
|
await deletePost();
|
||||||
<MenuItem value="inbox">inbox</MenuItem>
|
if (onBack) onBack();
|
||||||
<MenuItem value="editing">editing</MenuItem>
|
} catch (e: any) {
|
||||||
<MenuItem value="ready_for_publish">ready_for_publish</MenuItem>
|
alert('Delete failed: ' + (e?.message || 'unknown error'));
|
||||||
<MenuItem value="published">published</MenuItem>
|
}
|
||||||
<MenuItem value="archived">archived</MenuItem>
|
}}
|
||||||
</TextField>
|
onBack={onBack}
|
||||||
<Stack direction="row" spacing={1}>
|
showDelete={!!postId}
|
||||||
<Button variant="contained" onClick={() => { void savePost(); }} fullWidth>Save</Button>
|
/>
|
||||||
{postId && (
|
}
|
||||||
<Button color="error" variant="outlined" onClick={async () => {
|
>
|
||||||
if (!postId) return;
|
|
||||||
if (!confirm('Delete this post? This will remove its audio clips too.')) return;
|
|
||||||
try {
|
|
||||||
await deletePost();
|
|
||||||
if (onBack) onBack();
|
|
||||||
} 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>
|
|
||||||
|
|
||||||
{/* Right content: Stepper and step panels */}
|
{/* Right content: Stepper and step panels */}
|
||||||
<Box sx={{ display: 'grid', gridTemplateRows: 'auto 1fr auto', height: '100%', minHeight: 0, overflow: 'hidden' }}>
|
<Box sx={{ display: 'grid', gridTemplateRows: 'auto 1fr auto', height: '100%', minHeight: 0, overflow: 'hidden' }}>
|
||||||
<Stepper nonLinear activeStep={activeStep} sx={{ mb: 2 }}>
|
<Stepper nonLinear activeStep={activeStep} sx={{ mb: 2 }}>
|
||||||
@ -175,17 +163,14 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Bottom nav fixed to panel bottom via grid */}
|
{/* Step navigation (not page navigation) */}
|
||||||
<Box sx={{ bgcolor: 'background.paper', py: 1, borderTop: '1px solid', borderColor: 'divider' }}>
|
<StepNavigation
|
||||||
<Stack direction="row" spacing={1} justifyContent="space-between">
|
disableBack={activeStep === 0}
|
||||||
<Button disabled={activeStep === 0} onClick={() => setActiveStep((s) => Math.max(0, s - 1))}>Back</Button>
|
onBack={() => setActiveStep((s) => Math.max(0, s - 1))}
|
||||||
<Stack direction="row" spacing={1}>
|
onNext={() => setActiveStep((s) => Math.min(5, s + 1))}
|
||||||
<Button variant="outlined" onClick={() => setActiveStep((s) => Math.min(5, s + 1))}>Next</Button>
|
/>
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</PageContainer>
|
||||||
</Box>
|
</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