refactor: extract post editor logic into custom hook for better separation of concerns

This commit is contained in:
Ender 2025-10-24 21:25:22 +02:00
parent 5efc6c690e
commit a035d19a5a
7 changed files with 288 additions and 201 deletions

View File

@ -1,7 +1,6 @@
import { Box, Button, Stack, Typography, TextField, MenuItem, Snackbar, Alert, Stepper, Step, StepLabel, StepButton } 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 type { RichEditorHandle } from './RichEditor'; import type { RichEditorHandle } from './RichEditor';
import { type Metadata } from './MetadataPanel';
import StepAssets from './steps/StepAssets'; import StepAssets from './steps/StepAssets';
import StepAiPrompt from './steps/StepAiPrompt'; import StepAiPrompt from './steps/StepAiPrompt';
import StepGenerate from './steps/StepGenerate'; import StepGenerate from './steps/StepGenerate';
@ -9,141 +8,41 @@ import StepEdit from './steps/StepEdit';
import StepMetadata from './steps/StepMetadata'; import StepMetadata from './steps/StepMetadata';
import StepPublish from './steps/StepPublish'; import StepPublish from './steps/StepPublish';
import StepContainer from './steps/StepContainer'; import StepContainer from './steps/StepContainer';
import { useEffect, useRef, useState } from 'react'; import { usePostEditor } from '../hooks/usePostEditor';
import { useRef } from 'react';
export default function EditorShell({ onLogout, initialPostId, onBack }: { onLogout?: () => void; initialPostId?: string | null; onBack?: () => void }) { export default function EditorShell({ onLogout, initialPostId, onBack }: { onLogout?: () => void; initialPostId?: string | null; onBack?: () => void }) {
const [draft, setDraft] = useState<string>('');
const [draftId, setDraftId] = useState<string | null>(null);
const editorRef = useRef<RichEditorHandle | null>(null); const editorRef = useRef<RichEditorHandle | null>(null);
const [meta, setMeta] = useState<Metadata>({ title: '', tagsText: '', canonicalUrl: '', featureImage: '' }); const {
const [postClips, setPostClips] = useState<Array<{ id: string; bucket: string; key: string; mime: string; transcript?: string; createdAt: string }>>([]); // state
const [postStatus, setPostStatus] = useState<'inbox' | 'editing' | 'ready_for_publish' | 'published' | 'archived'>('editing'); postId,
const [toast, setToast] = useState<{ open: boolean; message: string; severity: 'success' | 'error' } | null>(null); draft,
const [promptText, setPromptText] = useState<string>(''); meta,
const [activeStep, setActiveStep] = useState<number>(0); postClips,
const [previewHtml, setPreviewHtml] = useState<string>(''); postStatus,
const [previewLoading, setPreviewLoading] = useState<boolean>(false); promptText,
const [previewError, setPreviewError] = useState<string | null>(null); toast,
const [genImageKeys, setGenImageKeys] = useState<string[]>([]); activeStep,
previewHtml,
previewLoading,
previewError,
genImageKeys,
// setters
setDraft,
setMeta,
setPostStatus,
setPromptText,
setToast,
setActiveStep,
// actions
savePost,
deletePost,
publishToGhost,
refreshPreview,
toggleGenImage,
} = usePostEditor(initialPostId);
useEffect(() => { // All data logic moved into usePostEditor
const savedId = initialPostId || localStorage.getItem('voxblog_draft_id');
const savedLocal = localStorage.getItem('voxblog_draft');
if (savedLocal) setDraft(savedLocal);
if (savedId) {
(async () => {
try {
const res = await fetch(`/api/posts/${savedId}`);
if (res.ok) {
const data = await res.json();
setDraft(data.contentHtml || '');
setDraftId(data.id || savedId);
localStorage.setItem('voxblog_draft_id', data.id || savedId);
if (data.contentHtml) localStorage.setItem('voxblog_draft', data.contentHtml);
if (Array.isArray(data.audioClips)) setPostClips(data.audioClips);
setMeta((m) => ({
...m,
title: data.title || '',
tagsText: data.tagsText || '',
canonicalUrl: data.canonicalUrl || '',
featureImage: data.featureImage || ''
}));
if (data.status) setPostStatus(data.status);
setPromptText(data.prompt || '');
}
} catch {}
})();
}
}, []);
const saveDraft = async () => {
// Keep local fallback
localStorage.setItem('voxblog_draft', draft);
try {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: draftId ?? undefined,
title: meta.title || undefined,
contentHtml: draft,
tags: meta.tagsText ? meta.tagsText.split(',').map(t => t.trim()).filter(Boolean) : undefined,
featureImage: meta.featureImage || undefined,
canonicalUrl: meta.canonicalUrl || undefined,
status: postStatus,
prompt: promptText || undefined,
}),
});
if (res.ok) {
const data = await res.json();
if (data.id) {
setDraftId(data.id);
localStorage.setItem('voxblog_draft_id', data.id);
}
setToast({ open: true, message: 'Post saved', severity: 'success' });
} else {
const text = await res.text();
setToast({ open: true, message: `Save failed: ${text || res.status}`, severity: 'error' });
}
} catch (e: any) {
setToast({ open: true, message: `Save error: ${e?.message || 'unknown error'}` as string, severity: 'error' });
}
};
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' });
}
};
const refreshPreview = async () => {
try {
setPreviewLoading(true);
setPreviewError(null);
const res = await fetch('/api/ghost/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
html: draft,
feature_image: meta.featureImage || undefined,
}),
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
setPreviewHtml(data.html || '');
} catch (e: any) {
setPreviewError(e?.message || 'Failed to generate preview');
} finally {
setPreviewLoading(false);
}
};
useEffect(() => {
if (activeStep === 5) {
// Generate preview when entering Publish step
refreshPreview();
}
}, [activeStep]);
const toggleGenImage = (key: string) => {
setGenImageKeys((prev) => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]);
};
// No inline post switching here; selection happens on Posts page // No inline post switching here; selection happens on Posts page
@ -179,15 +78,13 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog
<MenuItem value="archived">archived</MenuItem> <MenuItem value="archived">archived</MenuItem>
</TextField> </TextField>
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
<Button variant="contained" onClick={saveDraft} fullWidth>Save</Button> <Button variant="contained" onClick={() => { void savePost(); }} fullWidth>Save</Button>
{draftId && ( {postId && (
<Button color="error" variant="outlined" onClick={async () => { <Button color="error" variant="outlined" onClick={async () => {
if (!draftId) return; if (!postId) return;
if (!confirm('Delete this post? This will remove its audio clips too.')) return; if (!confirm('Delete this post? This will remove its audio clips too.')) return;
try { try {
const res = await fetch(`/api/posts/${draftId}`, { method: 'DELETE' }); await deletePost();
if (!res.ok) throw new Error(await res.text());
localStorage.removeItem('voxblog_draft_id');
if (onBack) onBack(); if (onBack) onBack();
} catch (e: any) { } catch (e: any) {
alert('Delete failed: ' + (e?.message || 'unknown error')); alert('Delete failed: ' + (e?.message || 'unknown error'));
@ -215,7 +112,7 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog
{activeStep === 0 && ( {activeStep === 0 && (
<StepContainer> <StepContainer>
<StepAssets <StepAssets
draftId={draftId} postId={postId}
postClips={postClips} postClips={postClips}
onInsertAtCursor={(html: string) => editorRef.current?.insertHtmlAtCursor(html)} onInsertAtCursor={(html: string) => editorRef.current?.insertHtmlAtCursor(html)}
onInsertImage={(url: string) => { onInsertImage={(url: string) => {
@ -273,8 +170,7 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog
previewHtml={previewHtml} previewHtml={previewHtml}
draftHtml={draft} draftHtml={draft}
onRefreshPreview={refreshPreview} onRefreshPreview={refreshPreview}
onSaveDraft={saveDraft} onGhostPublish={publishToGhost}
onGhostPublish={ghostPublish}
/> />
</StepContainer> </StepContainer>
)} )}

View File

@ -6,7 +6,7 @@ import CollapsibleSection from './CollapsibleSection';
export type Clip = { id: string; bucket: string; key: string; mime: string; transcript?: string; createdAt: string }; export type Clip = { id: string; bucket: string; key: string; mime: string; transcript?: string; createdAt: string };
export default function StepAssets({ export default function StepAssets({
draftId, postId,
postClips, postClips,
onInsertAtCursor, onInsertAtCursor,
onInsertImage, onInsertImage,
@ -14,7 +14,7 @@ export default function StepAssets({
selectedKeys, selectedKeys,
onToggleSelect, onToggleSelect,
}: { }: {
draftId?: string | null; postId?: string | null;
postClips: Clip[]; postClips: Clip[];
onInsertAtCursor: (html: string) => void; onInsertAtCursor: (html: string) => void;
onInsertImage: (url: string) => void; onInsertImage: (url: string) => void;
@ -25,28 +25,24 @@ export default function StepAssets({
return ( return (
<Box sx={{ display: 'grid', gap: 2 }}> <Box sx={{ display: 'grid', gap: 2 }}>
<Typography variant="subtitle1">Assets (Audio & Images)</Typography> <Typography variant="subtitle1">Assets (Audio & Images)</Typography>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch"> <Stack direction="column" spacing={2} alignItems="stretch">
<Box sx={{ flex: 1 }}> <CollapsibleSection title="Audio Recorder">
<CollapsibleSection title="Audio Recorder"> <Recorder
<Recorder postId={postId ?? undefined}
postId={draftId ?? undefined} onInsertAtCursor={onInsertAtCursor}
onInsertAtCursor={onInsertAtCursor} initialClips={postClips}
initialClips={postClips} />
/> </CollapsibleSection>
</CollapsibleSection> <CollapsibleSection title="Media Library">
</Box> <MediaLibrary
<Box sx={{ flex: 1 }}> onInsert={onInsertImage}
<CollapsibleSection title="Media Library"> onSetFeature={onSetFeature}
<MediaLibrary showSetFeature
onInsert={onInsertImage} selectionMode
onSetFeature={onSetFeature} selectedKeys={selectedKeys}
showSetFeature onToggleSelect={onToggleSelect}
selectionMode />
selectedKeys={selectedKeys} </CollapsibleSection>
onToggleSelect={onToggleSelect}
/>
</CollapsibleSection>
</Box>
</Stack> </Stack>
</Box> </Box>
); );

View File

@ -23,41 +23,41 @@ export default function StepGenerate({
Select images as generation assets, review audio transcriptions, and set the prompt to guide AI. Select images as generation assets, review audio transcriptions, and set the prompt to guide AI.
</Typography> </Typography>
{/* Audio transcriptions in order */} <Stack spacing={2}>
<CollapsibleSection title="Audio Transcriptions"> {/* Audio transcriptions in order */}
<Stack spacing={1}> <CollapsibleSection title="Audio Transcriptions">
{[...postClips] <Stack spacing={1}>
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) {[...postClips]
.map((clip, idx) => ( .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
<Box key={clip.id} sx={{ p: 1, border: '1px solid', borderColor: 'divider', borderRadius: 1, bgcolor: 'background.paper' }}> .map((clip, idx) => (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>#{idx + 1} · {new Date(clip.createdAt).toLocaleString()}</Typography> <Box key={clip.id} sx={{ p: 1, border: '1px solid', borderColor: 'divider', borderRadius: 1, bgcolor: 'background.paper' }}>
<Typography variant="body2" sx={{ mt: 0.5 }}>{clip.transcript || '(no transcript yet)'}</Typography> <Typography variant="caption" sx={{ color: 'text.secondary' }}>#{idx + 1} · {new Date(clip.createdAt).toLocaleString()}</Typography>
</Box> <Typography variant="body2" sx={{ mt: 0.5 }}>{clip.transcript || '(no transcript yet)'}</Typography>
))} </Box>
{postClips.length === 0 && ( ))}
<Typography variant="body2" sx={{ color: 'text.secondary' }}>(No audio clips)</Typography> {postClips.length === 0 && (
)} <Typography variant="body2" sx={{ color: 'text.secondary' }}>(No audio clips)</Typography>
</Stack> )}
</CollapsibleSection> </Stack>
</CollapsibleSection>
{/* Selected images */} {/* Selected images */}
<CollapsibleSection title="Selected Images"> <CollapsibleSection title="Selected Images">
<SelectedImages imageKeys={genImageKeys} onRemove={onToggleGenImage} /> <SelectedImages imageKeys={genImageKeys} onRemove={onToggleGenImage} />
</CollapsibleSection> </CollapsibleSection>
{/* Media library removed: selection now happens in Assets step */} {/* Prompt */}
<CollapsibleSection title="AI Prompt">
{/* Prompt */} <TextField
<CollapsibleSection title="AI Prompt"> label="Instructions + context for AI generation"
<TextField value={promptText}
label="Instructions + context for AI generation" onChange={(e) => onChangePrompt(e.target.value)}
value={promptText} fullWidth
onChange={(e) => onChangePrompt(e.target.value)} multiline
fullWidth minRows={4}
multiline />
minRows={4} </CollapsibleSection>
/> </Stack>
</CollapsibleSection>
</Box> </Box>
); );
} }

View File

@ -6,7 +6,6 @@ export default function StepPublish({
previewHtml, previewHtml,
draftHtml, draftHtml,
onRefreshPreview, onRefreshPreview,
onSaveDraft,
onGhostPublish, onGhostPublish,
}: { }: {
previewLoading: boolean; previewLoading: boolean;
@ -14,7 +13,6 @@ export default function StepPublish({
previewHtml: string; previewHtml: string;
draftHtml: string; draftHtml: string;
onRefreshPreview: () => void; onRefreshPreview: () => void;
onSaveDraft: () => void;
onGhostPublish: (status: 'draft' | 'published') => void; onGhostPublish: (status: 'draft' | 'published') => void;
}) { }) {
return ( return (
@ -25,7 +23,6 @@ export default function StepPublish({
</Typography> </Typography>
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
<Button size="small" variant="outlined" onClick={onRefreshPreview} disabled={previewLoading}>Refresh Preview</Button> <Button size="small" variant="outlined" onClick={onRefreshPreview} disabled={previewLoading}>Refresh Preview</Button>
<Button size="small" variant="text" onClick={onSaveDraft}>Save Post</Button>
</Stack> </Stack>
{previewLoading && ( {previewLoading && (
<Box sx={{ p: 2, border: '1px dashed', borderColor: 'divider', borderRadius: 1 }}>Generating preview</Box> <Box sx={{ p: 2, border: '1px dashed', borderColor: 'divider', borderRadius: 1 }}>Generating preview</Box>

View File

@ -0,0 +1,138 @@
import { useEffect, useState } from 'react';
import { getPost, savePost as savePostApi, deletePost as deletePostApi, type SavePostPayload } from '../services/posts';
import { ghostPreview as ghostPreviewApi, ghostPublish as ghostPublishApi, type GhostPostStatus } from '../services/ghost';
import type { Metadata } from '../components/MetadataPanel';
export function usePostEditor(initialPostId?: string | null) {
const [postId, setPostId] = useState<string | null>(null);
const [draft, setDraft] = useState<string>('');
const [meta, setMeta] = useState<Metadata>({ title: '', tagsText: '', canonicalUrl: '', featureImage: '' });
const [postClips, setPostClips] = useState<Array<{ id: string; bucket: string; key: string; mime: string; transcript?: string; createdAt: string }>>([]);
const [postStatus, setPostStatus] = useState<'inbox' | 'editing' | 'ready_for_publish' | 'published' | 'archived'>('editing');
const [promptText, setPromptText] = useState<string>('');
const [toast, setToast] = useState<{ open: boolean; message: string; severity: 'success' | 'error' } | null>(null);
const [activeStep, setActiveStep] = useState<number>(0);
const [previewHtml, setPreviewHtml] = useState<string>('');
const [previewLoading, setPreviewLoading] = useState<boolean>(false);
const [previewError, setPreviewError] = useState<string | null>(null);
const [genImageKeys, setGenImageKeys] = useState<string[]>([]);
useEffect(() => {
const savedId = initialPostId || localStorage.getItem('voxblog_post_id');
const savedLocal = localStorage.getItem('voxblog_draft');
if (savedLocal) setDraft(savedLocal);
if (savedId) {
(async () => {
try {
const data = await getPost(savedId);
setDraft(data.contentHtml || '');
setPostId(data.id || savedId);
localStorage.setItem('voxblog_post_id', data.id || savedId);
if (data.contentHtml) localStorage.setItem('voxblog_draft', data.contentHtml);
if (Array.isArray(data.audioClips)) setPostClips(data.audioClips);
setMeta(m => ({
...m,
title: data.title || '',
tagsText: data.tagsText || '',
canonicalUrl: data.canonicalUrl || '',
featureImage: data.featureImage || ''
}));
if (data.status) setPostStatus(data.status);
setPromptText(data.prompt || '');
} catch {}
})();
}
}, [initialPostId]);
const savePost = async (overrides?: Partial<SavePostPayload>) => {
localStorage.setItem('voxblog_draft', draft);
const payload: SavePostPayload = {
id: postId ?? undefined,
title: meta.title || undefined,
contentHtml: draft,
tags: meta.tagsText ? meta.tagsText.split(',').map(t => t.trim()).filter(Boolean) : undefined,
featureImage: meta.featureImage || undefined,
canonicalUrl: meta.canonicalUrl || undefined,
status: postStatus,
prompt: promptText || undefined,
...(overrides || {}),
};
const data = await savePostApi(payload);
if (data?.id) {
setPostId(data.id);
localStorage.setItem('voxblog_post_id', data.id);
}
setToast({ open: true, message: 'Post saved', severity: 'success' });
return data;
};
const deletePost = async () => {
if (!postId) return;
await deletePostApi(postId);
localStorage.removeItem('voxblog_post_id');
};
const publishToGhost = async (status: GhostPostStatus) => {
const tags = meta.tagsText.split(',').map(t => t.trim()).filter(Boolean);
await ghostPublishApi({
title: meta.title || 'Untitled',
html: draft,
feature_image: meta.featureImage || null,
canonical_url: meta.canonicalUrl || null,
tags,
status,
});
setToast({ open: true, message: status === 'published' ? 'Published to Ghost' : 'Draft saved to Ghost', severity: 'success' });
};
const refreshPreview = async () => {
setPreviewLoading(true);
setPreviewError(null);
try {
const data = await ghostPreviewApi({ html: draft, feature_image: meta.featureImage || undefined });
setPreviewHtml(data.html || '');
} catch (e: any) {
setPreviewError(e?.message || 'Failed to generate preview');
} finally {
setPreviewLoading(false);
}
};
useEffect(() => {
if (activeStep === 5) refreshPreview();
}, [activeStep]);
const toggleGenImage = (key: string) => {
setGenImageKeys(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]);
};
return {
// state
postId,
draft,
meta,
postClips,
postStatus,
promptText,
toast,
activeStep,
previewHtml,
previewLoading,
previewError,
genImageKeys,
// setters
setDraft,
setMeta,
setPostClips,
setPostStatus,
setPromptText,
setToast,
setActiveStep,
// actions
savePost,
deletePost,
publishToGhost,
refreshPreview,
toggleGenImage,
} as const;
}

View File

@ -0,0 +1,28 @@
export type GhostPostStatus = 'draft' | 'published';
export async function ghostPreview(payload: { html: string; feature_image?: string }) {
const res = await fetch('/api/ghost/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await res.text());
return res.json() as Promise<{ html: string; feature_image?: string; replacements?: Array<{ from: string; to: string }> }>;
}
export async function ghostPublish(payload: {
title: string;
html: string;
feature_image?: string | null;
canonical_url?: string | null;
tags: string[];
status: GhostPostStatus;
}) {
const res = await fetch('/api/ghost/post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}

View File

@ -0,0 +1,32 @@
export type SavePostPayload = {
id?: string;
title?: string;
contentHtml?: string;
tags?: string[];
featureImage?: string;
canonicalUrl?: string;
status?: 'inbox' | 'editing' | 'ready_for_publish' | 'published' | 'archived';
prompt?: string;
};
export async function getPost(id: string) {
const res = await fetch(`/api/posts/${id}`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function savePost(payload: SavePostPayload) {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function deletePost(id: string) {
const res = await fetch(`/api/posts/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await res.text());
return true;
}