refactor: extract post editor logic into custom hook for better separation of concerns
This commit is contained in:
parent
5efc6c690e
commit
a035d19a5a
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
138
apps/admin/src/hooks/usePostEditor.ts
Normal file
138
apps/admin/src/hooks/usePostEditor.ts
Normal 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;
|
||||||
|
}
|
||||||
28
apps/admin/src/services/ghost.ts
Normal file
28
apps/admin/src/services/ghost.ts
Normal 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();
|
||||||
|
}
|
||||||
32
apps/admin/src/services/posts.ts
Normal file
32
apps/admin/src/services/posts.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user