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 Recorder from '../features/recorder/Recorder';
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 [toast, setToast] = useState<{ open: boolean; message: string; severity: 'success' | 'error' } | null>(null);
const [promptText, setPromptText] = useState<string>('');
const [activeStep, setActiveStep] = useState<number>(0);
useEffect(() => {
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
return (
@ -95,106 +118,147 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog
{toast?.message || ''}
</Alert>
</Snackbar>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2, gap: 2, flexWrap: 'wrap' }}>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flex: 1, minWidth: 300 }}>
<Typography variant="h6">Post</Typography>
<TextField
size="small"
label="Title"
value={meta.title}
onChange={(e) => setMeta((m) => ({ ...m, title: e.target.value }))}
sx={{ flex: 1, minWidth: 200 }}
/>
<TextField size="small" select label="Status" value={postStatus} onChange={(e) => setPostStatus(e.target.value as any)} sx={{ minWidth: 200 }}>
<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>
<Stack direction="row" spacing={1}>
{draftId && (
<Button color="error" variant="outlined" onClick={async () => {
if (!draftId) return;
if (!confirm('Delete this post? This will remove its audio clips too.')) return;
try {
const res = await fetch(`/api/posts/${draftId}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
localStorage.removeItem('voxblog_draft_id');
if (onBack) onBack();
} catch (e: any) {
alert('Delete failed: ' + (e?.message || 'unknown error'));
}
}}>Delete</Button>
)}
<Button variant="contained" onClick={saveDraft}>Save Post</Button>
{onBack && <Button variant="outlined" onClick={onBack}>Back to Posts</Button>}
</Stack>
</Stack>
<Box sx={{ display: 'grid', gap: 3 }}>
<Recorder
postId={draftId ?? undefined}
onInsertAtCursor={(html: string) => editorRef.current?.insertHtmlAtCursor(html)}
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 sx={{ display: { md: 'grid' }, gridTemplateColumns: { md: '300px 1fr' }, gap: 2 }}>
{/* Left sticky sidebar: Post controls */}
<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
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={saveDraft} fullWidth>Save</Button>
{draftId && (
<Button color="error" variant="outlined" onClick={async () => {
if (!draftId) return;
if (!confirm('Delete this post? This will remove its audio clips too.')) return;
try {
const res = await fetch(`/api/posts/${draftId}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
localStorage.removeItem('voxblog_draft_id');
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 */}
<Box>
<Typography variant="subtitle1" sx={{ mb: 1 }}>AI Prompt</Typography>
<TextField
label="Instructions + context for AI generation"
value={promptText}
onChange={(e) => setPromptText(e.target.value)}
fullWidth
multiline
minRows={6}
placeholder="Describe the goal, audience, tone, outline, and reference transcript/image context to guide AI content generation."
/>
<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
postId={draftId ?? undefined}
onInsertAtCursor={(html: string) => editorRef.current?.insertHtmlAtCursor(html)}
initialClips={postClips}
/>
</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>
<Typography variant="subtitle1" sx={{ mb: 1 }}>AI Prompt</Typography>
<TextField
label="Instructions + context for AI generation"
value={promptText}
onChange={(e) => setPromptText(e.target.value)}
fullWidth
multiline
minRows={6}
placeholder="Describe the goal, audience, tone, outline, and reference transcript/image context to guide AI content generation."
/>
</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
value={meta}
onChange={setMeta}
/>
)}
{activeStep === 5 && (
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="subtitle1">Publish</Typography>
<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 }} />
<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>
<MetadataPanel
value={meta}
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);
} else {
// fallback
setDraft((prev) => `${prev || ''}<p><img src="${url}" alt="" /></p>`);
}
}} showSetFeature onSetFeature={(url) => setMeta((m) => ({ ...m, featureImage: url }))} />
</Box>
</AdminLayout>
);

View File

@ -1,5 +1,4 @@
import { Box, Button, Stack, TextField, Typography, IconButton } from '@mui/material';
import { useState } from 'react';
import { Box, Stack, TextField, Typography, IconButton } from '@mui/material';
export type Metadata = {
title: string;
@ -8,12 +7,10 @@ export type Metadata = {
featureImage?: string;
};
export default function MetadataPanel({ value, onChange, onPublish }: {
export default function MetadataPanel({ value, onChange }: {
value: Metadata;
onChange: (v: Metadata) => void;
onPublish: (status: 'draft' | 'published') => void;
}) {
const [busy, setBusy] = useState(false);
const set = (patch: Partial<Metadata>) => onChange({ ...value, ...patch });
@ -42,10 +39,6 @@ export default function MetadataPanel({ value, onChange, onPublish }: {
</IconButton>
</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>
</Box>
);