From a035d19a5ad1f2c6792cb0b254eb8082d4559160 Mon Sep 17 00:00:00 2001 From: Ender Date: Fri, 24 Oct 2025 21:25:22 +0200 Subject: [PATCH] refactor: extract post editor logic into custom hook for better separation of concerns --- apps/admin/src/components/EditorShell.tsx | 178 ++++-------------- .../admin/src/components/steps/StepAssets.tsx | 44 ++--- .../src/components/steps/StepGenerate.tsx | 66 +++---- .../src/components/steps/StepPublish.tsx | 3 - apps/admin/src/hooks/usePostEditor.ts | 138 ++++++++++++++ apps/admin/src/services/ghost.ts | 28 +++ apps/admin/src/services/posts.ts | 32 ++++ 7 files changed, 288 insertions(+), 201 deletions(-) create mode 100644 apps/admin/src/hooks/usePostEditor.ts create mode 100644 apps/admin/src/services/ghost.ts create mode 100644 apps/admin/src/services/posts.ts diff --git a/apps/admin/src/components/EditorShell.tsx b/apps/admin/src/components/EditorShell.tsx index cab3c37..c239796 100644 --- a/apps/admin/src/components/EditorShell.tsx +++ b/apps/admin/src/components/EditorShell.tsx @@ -1,7 +1,6 @@ import { Box, Button, Stack, Typography, TextField, MenuItem, Snackbar, Alert, Stepper, Step, StepLabel, StepButton } from '@mui/material'; import AdminLayout from '../layout/AdminLayout'; import type { RichEditorHandle } from './RichEditor'; -import { type Metadata } from './MetadataPanel'; import StepAssets from './steps/StepAssets'; import StepAiPrompt from './steps/StepAiPrompt'; import StepGenerate from './steps/StepGenerate'; @@ -9,141 +8,41 @@ import StepEdit from './steps/StepEdit'; import StepMetadata from './steps/StepMetadata'; import StepPublish from './steps/StepPublish'; 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 }) { - const [draft, setDraft] = useState(''); - const [draftId, setDraftId] = useState(null); const editorRef = useRef(null); - const [meta, setMeta] = useState({ title: '', tagsText: '', canonicalUrl: '', featureImage: '' }); - const [postClips, setPostClips] = useState>([]); - 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(''); - const [activeStep, setActiveStep] = useState(0); - const [previewHtml, setPreviewHtml] = useState(''); - const [previewLoading, setPreviewLoading] = useState(false); - const [previewError, setPreviewError] = useState(null); - const [genImageKeys, setGenImageKeys] = useState([]); + const { + // state + postId, + draft, + meta, + postClips, + postStatus, + promptText, + toast, + activeStep, + previewHtml, + previewLoading, + previewError, + genImageKeys, + // setters + setDraft, + setMeta, + setPostStatus, + setPromptText, + setToast, + setActiveStep, + // actions + savePost, + deletePost, + publishToGhost, + refreshPreview, + toggleGenImage, + } = usePostEditor(initialPostId); - useEffect(() => { - 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]); - }; + // All data logic moved into usePostEditor // No inline post switching here; selection happens on Posts page @@ -179,15 +78,13 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog archived - - {draftId && ( + + {postId && ( - {previewLoading && ( Generating preview… diff --git a/apps/admin/src/hooks/usePostEditor.ts b/apps/admin/src/hooks/usePostEditor.ts new file mode 100644 index 0000000..fd20182 --- /dev/null +++ b/apps/admin/src/hooks/usePostEditor.ts @@ -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(null); + const [draft, setDraft] = useState(''); + const [meta, setMeta] = useState({ title: '', tagsText: '', canonicalUrl: '', featureImage: '' }); + const [postClips, setPostClips] = useState>([]); + const [postStatus, setPostStatus] = useState<'inbox' | 'editing' | 'ready_for_publish' | 'published' | 'archived'>('editing'); + const [promptText, setPromptText] = useState(''); + const [toast, setToast] = useState<{ open: boolean; message: string; severity: 'success' | 'error' } | null>(null); + const [activeStep, setActiveStep] = useState(0); + const [previewHtml, setPreviewHtml] = useState(''); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(null); + const [genImageKeys, setGenImageKeys] = useState([]); + + 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) => { + 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; +} diff --git a/apps/admin/src/services/ghost.ts b/apps/admin/src/services/ghost.ts new file mode 100644 index 0000000..6fec93f --- /dev/null +++ b/apps/admin/src/services/ghost.ts @@ -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(); +} diff --git a/apps/admin/src/services/posts.ts b/apps/admin/src/services/posts.ts new file mode 100644 index 0000000..05423a4 --- /dev/null +++ b/apps/admin/src/services/posts.ts @@ -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; +}