feat: redesign editor with step-by-step workflow and sticky sidebar layout

This commit is contained in:
Ender 2025-10-24 17:55:44 +02:00
parent 7252936657
commit 7378360104
2 changed files with 164 additions and 107 deletions

View File

@ -1,4 +1,4 @@
import { Box, Button, Stack, Typography, TextField, MenuItem, Snackbar, Alert } from '@mui/material'; import { Box, Button, Stack, Typography, TextField, MenuItem, Snackbar, Alert, Stepper, Step, StepLabel, StepButton } from '@mui/material';
import AdminLayout from '../layout/AdminLayout'; import AdminLayout from '../layout/AdminLayout';
import Recorder from '../features/recorder/Recorder'; import Recorder from '../features/recorder/Recorder';
import RichEditor from './RichEditor'; import RichEditor from './RichEditor';
@ -16,6 +16,7 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog
const [postStatus, setPostStatus] = useState<'inbox' | 'editing' | 'ready_for_publish' | 'published' | 'archived'>('editing'); const [postStatus, setPostStatus] = useState<'inbox' | 'editing' | 'ready_for_publish' | 'published' | 'archived'>('editing');
const [toast, setToast] = useState<{ open: boolean; message: string; severity: 'success' | 'error' } | null>(null); const [toast, setToast] = useState<{ open: boolean; message: string; severity: 'success' | 'error' } | null>(null);
const [promptText, setPromptText] = useState<string>(''); const [promptText, setPromptText] = useState<string>('');
const [activeStep, setActiveStep] = useState<number>(0);
useEffect(() => { useEffect(() => {
const savedId = initialPostId || localStorage.getItem('voxblog_draft_id'); const savedId = initialPostId || localStorage.getItem('voxblog_draft_id');
@ -81,6 +82,28 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog
} }
}; };
const ghostPublish = async (status: 'draft' | 'published') => {
try {
const tags = meta.tagsText.split(',').map(t => t.trim()).filter(Boolean);
const res = await fetch('/api/ghost/post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: meta.title || 'Untitled',
html: draft,
feature_image: meta.featureImage || null,
canonical_url: meta.canonicalUrl || null,
tags,
status,
})
});
if (!res.ok) throw new Error(await res.text());
setToast({ open: true, message: status === 'published' ? 'Published to Ghost' : 'Draft saved to Ghost', severity: 'success' });
} catch (e: any) {
setToast({ open: true, message: `Ghost ${status} failed: ${e?.message || 'unknown error'}`, severity: 'error' });
}
};
// No inline post switching here; selection happens on Posts page // No inline post switching here; selection happens on Posts page
return ( return (
@ -95,25 +118,27 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog
{toast?.message || ''} {toast?.message || ''}
</Alert> </Alert>
</Snackbar> </Snackbar>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2, gap: 2, flexWrap: 'wrap' }}> <Box sx={{ display: { md: 'grid' }, gridTemplateColumns: { md: '300px 1fr' }, gap: 2 }}>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flex: 1, minWidth: 300 }}> {/* Left sticky sidebar: Post controls */}
<Typography variant="h6">Post</Typography> <Box sx={{ position: 'sticky', top: 12, alignSelf: 'start', border: '1px solid', borderColor: 'divider', borderRadius: 1, p: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>Post</Typography>
<Stack spacing={1}>
<TextField <TextField
size="small" size="small"
label="Title" label="Title"
value={meta.title} value={meta.title}
onChange={(e) => setMeta((m) => ({ ...m, title: e.target.value }))} onChange={(e) => setMeta((m) => ({ ...m, title: e.target.value }))}
sx={{ flex: 1, minWidth: 200 }} fullWidth
/> />
<TextField size="small" select label="Status" value={postStatus} onChange={(e) => setPostStatus(e.target.value as any)} sx={{ minWidth: 200 }}> <TextField size="small" select label="Status" value={postStatus} onChange={(e) => setPostStatus(e.target.value as any)} fullWidth>
<MenuItem value="inbox">inbox</MenuItem> <MenuItem value="inbox">inbox</MenuItem>
<MenuItem value="editing">editing</MenuItem> <MenuItem value="editing">editing</MenuItem>
<MenuItem value="ready_for_publish">ready_for_publish</MenuItem> <MenuItem value="ready_for_publish">ready_for_publish</MenuItem>
<MenuItem value="published">published</MenuItem> <MenuItem value="published">published</MenuItem>
<MenuItem value="archived">archived</MenuItem> <MenuItem value="archived">archived</MenuItem>
</TextField> </TextField>
</Stack>
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
<Button variant="contained" onClick={saveDraft} fullWidth>Save</Button>
{draftId && ( {draftId && (
<Button color="error" variant="outlined" onClick={async () => { <Button color="error" variant="outlined" onClick={async () => {
if (!draftId) return; if (!draftId) return;
@ -128,23 +153,46 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog
} }
}}>Delete</Button> }}>Delete</Button>
)} )}
<Button variant="contained" onClick={saveDraft}>Save Post</Button>
{onBack && <Button variant="outlined" onClick={onBack}>Back to Posts</Button>}
</Stack> </Stack>
{onBack && <Button variant="text" onClick={onBack}>Back to Posts</Button>}
</Stack> </Stack>
<Box sx={{ display: 'grid', gap: 3 }}> </Box>
{/* Right content: Stepper and step panels */}
<Box>
<Stepper nonLinear activeStep={activeStep} sx={{ mb: 2 }}>
{[ 'Assets', 'AI Prompt', 'Generate', 'Edit', 'Metadata', 'Publish' ].map((label, idx) => (
<Step key={label} completed={false}>
<StepButton color="inherit" onClick={() => setActiveStep(idx)}>
<StepLabel>{label}</StepLabel>
</StepButton>
</Step>
))}
</Stepper>
{activeStep === 0 && (
<Box sx={{ display: 'grid', gap: 2 }}>
<Typography variant="subtitle1">Assets (Audio & Images)</Typography>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
<Box sx={{ flex: 1 }}>
<Recorder <Recorder
postId={draftId ?? undefined} postId={draftId ?? undefined}
onInsertAtCursor={(html: string) => editorRef.current?.insertHtmlAtCursor(html)} onInsertAtCursor={(html: string) => editorRef.current?.insertHtmlAtCursor(html)}
initialClips={postClips} initialClips={postClips}
/> />
<Box>
<Typography variant="subtitle1" sx={{ mb: 1 }}>Content</Typography>
<RichEditor ref={editorRef as any} value={draft} onChange={(html) => setDraft(html)} placeholder="Write your post..." />
{draftId && (
<Typography variant="caption" sx={{ mt: 1, display: 'block' }}>ID: {draftId}</Typography>
)}
</Box> </Box>
<Box sx={{ flex: 1 }}>
<MediaLibrary
onInsert={(url) => editorRef.current?.insertHtmlAtCursor(`<img src="${url}" alt="" />`)}
onSetFeature={(url) => setMeta(m => ({ ...m, featureImage: url }))}
showSetFeature
/>
</Box>
</Stack>
</Box>
)}
{activeStep === 1 && (
<Box> <Box>
<Typography variant="subtitle1" sx={{ mb: 1 }}>AI Prompt</Typography> <Typography variant="subtitle1" sx={{ mb: 1 }}>AI Prompt</Typography>
<TextField <TextField
@ -157,44 +205,60 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog
placeholder="Describe the goal, audience, tone, outline, and reference transcript/image context to guide AI content generation." placeholder="Describe the goal, audience, tone, outline, and reference transcript/image context to guide AI content generation."
/> />
</Box> </Box>
)}
{activeStep === 2 && (
<Box>
<Typography variant="subtitle1" sx={{ mb: 1 }}>Generate</Typography>
<Typography variant="body2" sx={{ mb: 1, color: 'text.secondary' }}>
AI content generation will use your prompt and assets. This step is coming soon.
</Typography>
<Stack direction="row" spacing={1}>
<Button variant="contained" disabled>Generate Draft (Coming Soon)</Button>
<Button variant="outlined" onClick={() => setActiveStep(3)}>Skip to Edit</Button>
</Stack>
</Box>
)}
{activeStep === 3 && (
<Box>
<Typography variant="subtitle1" sx={{ mb: 1 }}>Edit Content</Typography>
<RichEditor ref={editorRef as any} value={draft} onChange={(html) => setDraft(html)} placeholder="Write your post..." />
{draftId && (
<Typography variant="caption" sx={{ mt: 1, display: 'block' }}>ID: {draftId}</Typography>
)}
</Box>
)}
{activeStep === 4 && (
<MetadataPanel <MetadataPanel
value={meta} value={meta}
onChange={setMeta} onChange={setMeta}
onPublish={async (status) => {
try {
const tags = meta.tagsText.split(',').map(t => t.trim()).filter(Boolean);
const res = await fetch('/api/ghost/post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: meta.title || 'Untitled',
html: draft || '',
tags,
feature_image: meta.featureImage || undefined,
canonical_url: meta.canonicalUrl || undefined,
status,
}),
});
const data = await res.json();
if (!res.ok) {
console.error('Ghost error', data);
alert('Ghost error: ' + (data?.detail || data?.error || res.status));
} else {
alert(`${status === 'published' ? 'Published' : 'Saved post'} (id: ${data.id})`);
}
} catch (e: any) {
alert('Ghost error: ' + (e?.message || String(e)));
}
}}
/> />
<MediaLibrary onInsert={(url) => { )}
if (editorRef.current) {
editorRef.current.insertImage(url); {activeStep === 5 && (
} else { <Box sx={{ display: 'grid', gap: 1 }}>
// fallback <Typography variant="subtitle1">Publish</Typography>
setDraft((prev) => `${prev || ''}<p><img src="${url}" alt="" /></p>`); <Typography variant="body2" sx={{ color: 'text.secondary' }}>Preview below is based on current editor HTML.</Typography>
} <Box sx={{ p: 1.5, border: '1px solid #eee', borderRadius: 1, bgcolor: '#fff' }} dangerouslySetInnerHTML={{ __html: draft }} />
}} showSetFeature onSetFeature={(url) => setMeta((m) => ({ ...m, featureImage: url }))} /> <Stack direction="row" spacing={1} sx={{ mt: 1 }}>
<Button variant="outlined" onClick={() => ghostPublish('draft')}>Save Draft to Ghost</Button>
<Button variant="contained" onClick={() => ghostPublish('published')}>Publish to Ghost</Button>
</Stack>
</Box>
)}
{/* Sticky bottom nav so Back/Next don't move */}
<Box sx={{ position: 'sticky', bottom: 0, bgcolor: 'background.paper', py: 1, borderTop: '1px solid', borderColor: 'divider', zIndex: 1, mt: 2 }}>
<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>
</Box> </Box>
</AdminLayout> </AdminLayout>
); );

View File

@ -1,5 +1,4 @@
import { Box, Button, Stack, TextField, Typography, IconButton } from '@mui/material'; import { Box, Stack, TextField, Typography, IconButton } from '@mui/material';
import { useState } from 'react';
export type Metadata = { export type Metadata = {
title: string; title: string;
@ -8,12 +7,10 @@ export type Metadata = {
featureImage?: string; featureImage?: string;
}; };
export default function MetadataPanel({ value, onChange, onPublish }: { export default function MetadataPanel({ value, onChange }: {
value: Metadata; value: Metadata;
onChange: (v: Metadata) => void; onChange: (v: Metadata) => void;
onPublish: (status: 'draft' | 'published') => void;
}) { }) {
const [busy, setBusy] = useState(false);
const set = (patch: Partial<Metadata>) => onChange({ ...value, ...patch }); const set = (patch: Partial<Metadata>) => onChange({ ...value, ...patch });
@ -42,10 +39,6 @@ export default function MetadataPanel({ value, onChange, onPublish }: {
</IconButton> </IconButton>
</Box> </Box>
)} )}
<Stack direction="row" spacing={1}>
<Button variant="outlined" disabled={busy} onClick={() => onPublish('draft')}>Save Draft to Ghost</Button>
<Button variant="contained" disabled={busy} onClick={() => onPublish('published')}>Publish to Ghost</Button>
</Stack>
</Stack> </Stack>
</Box> </Box>
); );