Compare commits
2 Commits
cdbc5062ca
...
a035d19a5a
| Author | SHA1 | Date | |
|---|---|---|---|
| a035d19a5a | |||
| 5efc6c690e |
@ -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<string>('');
|
||||
const [draftId, setDraftId] = useState<string | null>(null);
|
||||
const editorRef = useRef<RichEditorHandle | null>(null);
|
||||
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 [toast, setToast] = useState<{ open: boolean; message: string; severity: 'success' | 'error' } | null>(null);
|
||||
const [promptText, setPromptText] = useState<string>('');
|
||||
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[]>([]);
|
||||
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
|
||||
<MenuItem value="archived">archived</MenuItem>
|
||||
</TextField>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button variant="contained" onClick={saveDraft} fullWidth>Save</Button>
|
||||
{draftId && (
|
||||
<Button variant="contained" onClick={() => { void savePost(); }} fullWidth>Save</Button>
|
||||
{postId && (
|
||||
<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;
|
||||
try {
|
||||
const res = await fetch(`/api/posts/${draftId}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
localStorage.removeItem('voxblog_draft_id');
|
||||
await deletePost();
|
||||
if (onBack) onBack();
|
||||
} catch (e: any) {
|
||||
alert('Delete failed: ' + (e?.message || 'unknown error'));
|
||||
@ -215,7 +112,7 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog
|
||||
{activeStep === 0 && (
|
||||
<StepContainer>
|
||||
<StepAssets
|
||||
draftId={draftId}
|
||||
postId={postId}
|
||||
postClips={postClips}
|
||||
onInsertAtCursor={(html: string) => editorRef.current?.insertHtmlAtCursor(html)}
|
||||
onInsertImage={(url: string) => {
|
||||
@ -249,14 +146,13 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog
|
||||
/>
|
||||
<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>
|
||||
</StepContainer>
|
||||
)}
|
||||
|
||||
{activeStep === 3 && (
|
||||
<StepContainer>
|
||||
<StepEdit editorRef={editorRef as any} draftHtml={draft} onChangeDraft={setDraft} draftId={draftId} />
|
||||
<StepEdit editorRef={editorRef as any} draftHtml={draft} onChangeDraft={setDraft} />
|
||||
</StepContainer>
|
||||
)}
|
||||
|
||||
@ -274,8 +170,7 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog
|
||||
previewHtml={previewHtml}
|
||||
draftHtml={draft}
|
||||
onRefreshPreview={refreshPreview}
|
||||
onSaveDraft={saveDraft}
|
||||
onGhostPublish={ghostPublish}
|
||||
onGhostPublish={publishToGhost}
|
||||
/>
|
||||
</StepContainer>
|
||||
)}
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { Box, Stack, Typography } from '@mui/material';
|
||||
import Recorder from '../../features/recorder/Recorder';
|
||||
import MediaLibrary from '../MediaLibrary';
|
||||
import CollapsibleSection from './CollapsibleSection';
|
||||
|
||||
export type Clip = { id: string; bucket: string; key: string; mime: string; transcript?: string; createdAt: string };
|
||||
|
||||
export default function StepAssets({
|
||||
draftId,
|
||||
postId,
|
||||
postClips,
|
||||
onInsertAtCursor,
|
||||
onInsertImage,
|
||||
@ -13,7 +14,7 @@ export default function StepAssets({
|
||||
selectedKeys,
|
||||
onToggleSelect,
|
||||
}: {
|
||||
draftId?: string | null;
|
||||
postId?: string | null;
|
||||
postClips: Clip[];
|
||||
onInsertAtCursor: (html: string) => void;
|
||||
onInsertImage: (url: string) => void;
|
||||
@ -24,15 +25,15 @@ export default function StepAssets({
|
||||
return (
|
||||
<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 }}>
|
||||
<Stack direction="column" spacing={2} alignItems="stretch">
|
||||
<CollapsibleSection title="Audio Recorder">
|
||||
<Recorder
|
||||
postId={draftId ?? undefined}
|
||||
postId={postId ?? undefined}
|
||||
onInsertAtCursor={onInsertAtCursor}
|
||||
initialClips={postClips}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection title="Media Library">
|
||||
<MediaLibrary
|
||||
onInsert={onInsertImage}
|
||||
onSetFeature={onSetFeature}
|
||||
@ -41,7 +42,7 @@ export default function StepAssets({
|
||||
selectedKeys={selectedKeys}
|
||||
onToggleSelect={onToggleSelect}
|
||||
/>
|
||||
</Box>
|
||||
</CollapsibleSection>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@ -6,12 +6,10 @@ export default function StepEdit({
|
||||
editorRef,
|
||||
draftHtml,
|
||||
onChangeDraft,
|
||||
draftId,
|
||||
}: {
|
||||
editorRef: ForwardedRef<RichEditorHandle> | any;
|
||||
draftHtml: string;
|
||||
onChangeDraft: (html: string) => void;
|
||||
draftId?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<Box>
|
||||
@ -24,9 +22,6 @@ export default function StepEdit({
|
||||
}}>
|
||||
<RichEditor ref={editorRef} value={draftHtml} onChange={onChangeDraft} placeholder="Write your post..." />
|
||||
</Box>
|
||||
{draftId && (
|
||||
<Typography variant="caption" sx={{ mt: 1, display: 'block' }}>ID: {draftId}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@ -23,41 +23,41 @@ export default function StepGenerate({
|
||||
Select images as generation assets, review audio transcriptions, and set the prompt to guide AI.
|
||||
</Typography>
|
||||
|
||||
{/* Audio transcriptions in order */}
|
||||
<CollapsibleSection title="Audio Transcriptions">
|
||||
<Stack spacing={1}>
|
||||
{[...postClips]
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
.map((clip, idx) => (
|
||||
<Box key={clip.id} sx={{ p: 1, border: '1px solid', borderColor: 'divider', borderRadius: 1, bgcolor: 'background.paper' }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>#{idx + 1} · {new Date(clip.createdAt).toLocaleString()}</Typography>
|
||||
<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>
|
||||
)}
|
||||
</Stack>
|
||||
</CollapsibleSection>
|
||||
<Stack spacing={2}>
|
||||
{/* Audio transcriptions in order */}
|
||||
<CollapsibleSection title="Audio Transcriptions">
|
||||
<Stack spacing={1}>
|
||||
{[...postClips]
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
.map((clip, idx) => (
|
||||
<Box key={clip.id} sx={{ p: 1, border: '1px solid', borderColor: 'divider', borderRadius: 1, bgcolor: 'background.paper' }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>#{idx + 1} · {new Date(clip.createdAt).toLocaleString()}</Typography>
|
||||
<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>
|
||||
)}
|
||||
</Stack>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Selected images */}
|
||||
<CollapsibleSection title="Selected Images">
|
||||
<SelectedImages imageKeys={genImageKeys} onRemove={onToggleGenImage} />
|
||||
</CollapsibleSection>
|
||||
{/* Selected images */}
|
||||
<CollapsibleSection title="Selected Images">
|
||||
<SelectedImages imageKeys={genImageKeys} onRemove={onToggleGenImage} />
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Media library removed: selection now happens in Assets step */}
|
||||
|
||||
{/* Prompt */}
|
||||
<CollapsibleSection title="AI Prompt">
|
||||
<TextField
|
||||
label="Instructions + context for AI generation"
|
||||
value={promptText}
|
||||
onChange={(e) => onChangePrompt(e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={4}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
{/* Prompt */}
|
||||
<CollapsibleSection title="AI Prompt">
|
||||
<TextField
|
||||
label="Instructions + context for AI generation"
|
||||
value={promptText}
|
||||
onChange={(e) => onChangePrompt(e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={4}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@ -6,7 +6,6 @@ export default function StepPublish({
|
||||
previewHtml,
|
||||
draftHtml,
|
||||
onRefreshPreview,
|
||||
onSaveDraft,
|
||||
onGhostPublish,
|
||||
}: {
|
||||
previewLoading: boolean;
|
||||
@ -14,7 +13,6 @@ export default function StepPublish({
|
||||
previewHtml: string;
|
||||
draftHtml: string;
|
||||
onRefreshPreview: () => void;
|
||||
onSaveDraft: () => void;
|
||||
onGhostPublish: (status: 'draft' | 'published') => void;
|
||||
}) {
|
||||
return (
|
||||
@ -25,7 +23,6 @@ export default function StepPublish({
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button size="small" variant="outlined" onClick={onRefreshPreview} disabled={previewLoading}>Refresh Preview</Button>
|
||||
<Button size="small" variant="text" onClick={onSaveDraft}>Save Post</Button>
|
||||
</Stack>
|
||||
{previewLoading && (
|
||||
<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