feat: redesign editor with step-by-step workflow and sticky sidebar layout
This commit is contained in:
parent
7252936657
commit
7378360104
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user