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 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,106 +118,147 @@ 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 }}>
|
||||||
<TextField
|
<Typography variant="subtitle1" sx={{ mb: 1 }}>Post</Typography>
|
||||||
size="small"
|
<Stack spacing={1}>
|
||||||
label="Title"
|
<TextField
|
||||||
value={meta.title}
|
size="small"
|
||||||
onChange={(e) => setMeta((m) => ({ ...m, title: e.target.value }))}
|
label="Title"
|
||||||
sx={{ flex: 1, minWidth: 200 }}
|
value={meta.title}
|
||||||
/>
|
onChange={(e) => setMeta((m) => ({ ...m, title: e.target.value }))}
|
||||||
<TextField size="small" select label="Status" value={postStatus} onChange={(e) => setPostStatus(e.target.value as any)} sx={{ minWidth: 200 }}>
|
fullWidth
|
||||||
<MenuItem value="inbox">inbox</MenuItem>
|
/>
|
||||||
<MenuItem value="editing">editing</MenuItem>
|
<TextField size="small" select label="Status" value={postStatus} onChange={(e) => setPostStatus(e.target.value as any)} fullWidth>
|
||||||
<MenuItem value="ready_for_publish">ready_for_publish</MenuItem>
|
<MenuItem value="inbox">inbox</MenuItem>
|
||||||
<MenuItem value="published">published</MenuItem>
|
<MenuItem value="editing">editing</MenuItem>
|
||||||
<MenuItem value="archived">archived</MenuItem>
|
<MenuItem value="ready_for_publish">ready_for_publish</MenuItem>
|
||||||
</TextField>
|
<MenuItem value="published">published</MenuItem>
|
||||||
</Stack>
|
<MenuItem value="archived">archived</MenuItem>
|
||||||
<Stack direction="row" spacing={1}>
|
</TextField>
|
||||||
{draftId && (
|
<Stack direction="row" spacing={1}>
|
||||||
<Button color="error" variant="outlined" onClick={async () => {
|
<Button variant="contained" onClick={saveDraft} fullWidth>Save</Button>
|
||||||
if (!draftId) return;
|
{draftId && (
|
||||||
if (!confirm('Delete this post? This will remove its audio clips too.')) return;
|
<Button color="error" variant="outlined" onClick={async () => {
|
||||||
try {
|
if (!draftId) return;
|
||||||
const res = await fetch(`/api/posts/${draftId}`, { method: 'DELETE' });
|
if (!confirm('Delete this post? This will remove its audio clips too.')) return;
|
||||||
if (!res.ok) throw new Error(await res.text());
|
try {
|
||||||
localStorage.removeItem('voxblog_draft_id');
|
const res = await fetch(`/api/posts/${draftId}`, { method: 'DELETE' });
|
||||||
if (onBack) onBack();
|
if (!res.ok) throw new Error(await res.text());
|
||||||
} catch (e: any) {
|
localStorage.removeItem('voxblog_draft_id');
|
||||||
alert('Delete failed: ' + (e?.message || 'unknown error'));
|
if (onBack) onBack();
|
||||||
}
|
} catch (e: any) {
|
||||||
}}>Delete</Button>
|
alert('Delete failed: ' + (e?.message || 'unknown error'));
|
||||||
)}
|
}
|
||||||
<Button variant="contained" onClick={saveDraft}>Save Post</Button>
|
}}>Delete</Button>
|
||||||
{onBack && <Button variant="outlined" onClick={onBack}>Back to Posts</Button>}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
{onBack && <Button variant="text" onClick={onBack}>Back to Posts</Button>}
|
||||||
<Box sx={{ display: 'grid', gap: 3 }}>
|
</Stack>
|
||||||
<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>
|
</Box>
|
||||||
|
|
||||||
|
{/* Right content: Stepper and step panels */}
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>AI Prompt</Typography>
|
<Stepper nonLinear activeStep={activeStep} sx={{ mb: 2 }}>
|
||||||
<TextField
|
{[ 'Assets', 'AI Prompt', 'Generate', 'Edit', 'Metadata', 'Publish' ].map((label, idx) => (
|
||||||
label="Instructions + context for AI generation"
|
<Step key={label} completed={false}>
|
||||||
value={promptText}
|
<StepButton color="inherit" onClick={() => setActiveStep(idx)}>
|
||||||
onChange={(e) => setPromptText(e.target.value)}
|
<StepLabel>{label}</StepLabel>
|
||||||
fullWidth
|
</StepButton>
|
||||||
multiline
|
</Step>
|
||||||
minRows={6}
|
))}
|
||||||
placeholder="Describe the goal, audience, tone, outline, and reference transcript/image context to guide AI content generation."
|
</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>
|
</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>
|
</Box>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user