Compare commits
No commits in common. "a035d19a5ad1f2c6792cb0b254eb8082d4559160" and "cdbc5062ca14040cd858b94b58cbbac5a2e14c32" have entirely different histories.
a035d19a5a
...
cdbc5062ca
@ -1,6 +1,7 @@
|
|||||||
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';
|
||||||
@ -8,41 +9,141 @@ 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 { usePostEditor } from '../hooks/usePostEditor';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
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 {
|
const [meta, setMeta] = useState<Metadata>({ title: '', tagsText: '', canonicalUrl: '', featureImage: '' });
|
||||||
// state
|
const [postClips, setPostClips] = useState<Array<{ id: string; bucket: string; key: string; mime: string; transcript?: string; createdAt: string }>>([]);
|
||||||
postId,
|
const [postStatus, setPostStatus] = useState<'inbox' | 'editing' | 'ready_for_publish' | 'published' | 'archived'>('editing');
|
||||||
draft,
|
const [toast, setToast] = useState<{ open: boolean; message: string; severity: 'success' | 'error' } | null>(null);
|
||||||
meta,
|
const [promptText, setPromptText] = useState<string>('');
|
||||||
postClips,
|
const [activeStep, setActiveStep] = useState<number>(0);
|
||||||
postStatus,
|
const [previewHtml, setPreviewHtml] = useState<string>('');
|
||||||
promptText,
|
const [previewLoading, setPreviewLoading] = useState<boolean>(false);
|
||||||
toast,
|
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||||
activeStep,
|
const [genImageKeys, setGenImageKeys] = useState<string[]>([]);
|
||||||
previewHtml,
|
|
||||||
previewLoading,
|
|
||||||
previewError,
|
|
||||||
genImageKeys,
|
|
||||||
// setters
|
|
||||||
setDraft,
|
|
||||||
setMeta,
|
|
||||||
setPostStatus,
|
|
||||||
setPromptText,
|
|
||||||
setToast,
|
|
||||||
setActiveStep,
|
|
||||||
// actions
|
|
||||||
savePost,
|
|
||||||
deletePost,
|
|
||||||
publishToGhost,
|
|
||||||
refreshPreview,
|
|
||||||
toggleGenImage,
|
|
||||||
} = usePostEditor(initialPostId);
|
|
||||||
|
|
||||||
// All data logic moved into usePostEditor
|
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]);
|
||||||
|
};
|
||||||
|
|
||||||
// No inline post switching here; selection happens on Posts page
|
// No inline post switching here; selection happens on Posts page
|
||||||
|
|
||||||
@ -78,13 +179,15 @@ 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={() => { void savePost(); }} fullWidth>Save</Button>
|
<Button variant="contained" onClick={saveDraft} fullWidth>Save</Button>
|
||||||
{postId && (
|
{draftId && (
|
||||||
<Button color="error" variant="outlined" onClick={async () => {
|
<Button color="error" variant="outlined" onClick={async () => {
|
||||||
if (!postId) return;
|
if (!draftId) 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 {
|
||||||
await deletePost();
|
const res = await fetch(`/api/posts/${draftId}`, { method: 'DELETE' });
|
||||||
|
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'));
|
||||||
@ -112,7 +215,7 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog
|
|||||||
{activeStep === 0 && (
|
{activeStep === 0 && (
|
||||||
<StepContainer>
|
<StepContainer>
|
||||||
<StepAssets
|
<StepAssets
|
||||||
postId={postId}
|
draftId={draftId}
|
||||||
postClips={postClips}
|
postClips={postClips}
|
||||||
onInsertAtCursor={(html: string) => editorRef.current?.insertHtmlAtCursor(html)}
|
onInsertAtCursor={(html: string) => editorRef.current?.insertHtmlAtCursor(html)}
|
||||||
onInsertImage={(url: string) => {
|
onInsertImage={(url: string) => {
|
||||||
@ -146,13 +249,14 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog
|
|||||||
/>
|
/>
|
||||||
<Stack direction="row" spacing={1}>
|
<Stack direction="row" spacing={1}>
|
||||||
<Button variant="contained" disabled>Generate Draft (Coming Soon)</Button>
|
<Button variant="contained" disabled>Generate Draft (Coming Soon)</Button>
|
||||||
|
<Button variant="outlined" onClick={() => setActiveStep(3)}>Skip to Edit</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</StepContainer>
|
</StepContainer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeStep === 3 && (
|
{activeStep === 3 && (
|
||||||
<StepContainer>
|
<StepContainer>
|
||||||
<StepEdit editorRef={editorRef as any} draftHtml={draft} onChangeDraft={setDraft} />
|
<StepEdit editorRef={editorRef as any} draftHtml={draft} onChangeDraft={setDraft} draftId={draftId} />
|
||||||
</StepContainer>
|
</StepContainer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -170,7 +274,8 @@ export default function EditorShell({ onLogout, initialPostId, onBack }: { onLog
|
|||||||
previewHtml={previewHtml}
|
previewHtml={previewHtml}
|
||||||
draftHtml={draft}
|
draftHtml={draft}
|
||||||
onRefreshPreview={refreshPreview}
|
onRefreshPreview={refreshPreview}
|
||||||
onGhostPublish={publishToGhost}
|
onSaveDraft={saveDraft}
|
||||||
|
onGhostPublish={ghostPublish}
|
||||||
/>
|
/>
|
||||||
</StepContainer>
|
</StepContainer>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,12 +1,11 @@
|
|||||||
import { Box, Stack, Typography } from '@mui/material';
|
import { Box, Stack, Typography } from '@mui/material';
|
||||||
import Recorder from '../../features/recorder/Recorder';
|
import Recorder from '../../features/recorder/Recorder';
|
||||||
import MediaLibrary from '../MediaLibrary';
|
import MediaLibrary from '../MediaLibrary';
|
||||||
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({
|
||||||
postId,
|
draftId,
|
||||||
postClips,
|
postClips,
|
||||||
onInsertAtCursor,
|
onInsertAtCursor,
|
||||||
onInsertImage,
|
onInsertImage,
|
||||||
@ -14,7 +13,7 @@ export default function StepAssets({
|
|||||||
selectedKeys,
|
selectedKeys,
|
||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
}: {
|
}: {
|
||||||
postId?: string | null;
|
draftId?: string | null;
|
||||||
postClips: Clip[];
|
postClips: Clip[];
|
||||||
onInsertAtCursor: (html: string) => void;
|
onInsertAtCursor: (html: string) => void;
|
||||||
onInsertImage: (url: string) => void;
|
onInsertImage: (url: string) => void;
|
||||||
@ -25,15 +24,15 @@ 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="column" spacing={2} alignItems="stretch">
|
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
|
||||||
<CollapsibleSection title="Audio Recorder">
|
<Box sx={{ flex: 1 }}>
|
||||||
<Recorder
|
<Recorder
|
||||||
postId={postId ?? undefined}
|
postId={draftId ?? undefined}
|
||||||
onInsertAtCursor={onInsertAtCursor}
|
onInsertAtCursor={onInsertAtCursor}
|
||||||
initialClips={postClips}
|
initialClips={postClips}
|
||||||
/>
|
/>
|
||||||
</CollapsibleSection>
|
</Box>
|
||||||
<CollapsibleSection title="Media Library">
|
<Box sx={{ flex: 1 }}>
|
||||||
<MediaLibrary
|
<MediaLibrary
|
||||||
onInsert={onInsertImage}
|
onInsert={onInsertImage}
|
||||||
onSetFeature={onSetFeature}
|
onSetFeature={onSetFeature}
|
||||||
@ -42,7 +41,7 @@ export default function StepAssets({
|
|||||||
selectedKeys={selectedKeys}
|
selectedKeys={selectedKeys}
|
||||||
onToggleSelect={onToggleSelect}
|
onToggleSelect={onToggleSelect}
|
||||||
/>
|
/>
|
||||||
</CollapsibleSection>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,10 +6,12 @@ export default function StepEdit({
|
|||||||
editorRef,
|
editorRef,
|
||||||
draftHtml,
|
draftHtml,
|
||||||
onChangeDraft,
|
onChangeDraft,
|
||||||
|
draftId,
|
||||||
}: {
|
}: {
|
||||||
editorRef: ForwardedRef<RichEditorHandle> | any;
|
editorRef: ForwardedRef<RichEditorHandle> | any;
|
||||||
draftHtml: string;
|
draftHtml: string;
|
||||||
onChangeDraft: (html: string) => void;
|
onChangeDraft: (html: string) => void;
|
||||||
|
draftId?: string | null;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
@ -22,6 +24,9 @@ export default function StepEdit({
|
|||||||
}}>
|
}}>
|
||||||
<RichEditor ref={editorRef} value={draftHtml} onChange={onChangeDraft} placeholder="Write your post..." />
|
<RichEditor ref={editorRef} value={draftHtml} onChange={onChangeDraft} placeholder="Write your post..." />
|
||||||
</Box>
|
</Box>
|
||||||
|
{draftId && (
|
||||||
|
<Typography variant="caption" sx={{ mt: 1, display: 'block' }}>ID: {draftId}</Typography>
|
||||||
|
)}
|
||||||
</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>
|
||||||
|
|
||||||
<Stack spacing={2}>
|
{/* Audio transcriptions in order */}
|
||||||
{/* Audio transcriptions in order */}
|
<CollapsibleSection title="Audio Transcriptions">
|
||||||
<CollapsibleSection title="Audio Transcriptions">
|
<Stack spacing={1}>
|
||||||
<Stack spacing={1}>
|
{[...postClips]
|
||||||
{[...postClips]
|
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
.map((clip, idx) => (
|
||||||
.map((clip, idx) => (
|
<Box key={clip.id} sx={{ p: 1, border: '1px solid', borderColor: 'divider', borderRadius: 1, bgcolor: 'background.paper' }}>
|
||||||
<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="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>
|
||||||
<Typography variant="body2" sx={{ mt: 0.5 }}>{clip.transcript || '(no transcript yet)'}</Typography>
|
</Box>
|
||||||
</Box>
|
))}
|
||||||
))}
|
{postClips.length === 0 && (
|
||||||
{postClips.length === 0 && (
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>(No audio clips)</Typography>
|
||||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>(No audio clips)</Typography>
|
)}
|
||||||
)}
|
</Stack>
|
||||||
</Stack>
|
</CollapsibleSection>
|
||||||
</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>
|
||||||
|
|
||||||
{/* Prompt */}
|
{/* Media library removed: selection now happens in Assets step */}
|
||||||
<CollapsibleSection title="AI Prompt">
|
|
||||||
<TextField
|
{/* Prompt */}
|
||||||
label="Instructions + context for AI generation"
|
<CollapsibleSection title="AI Prompt">
|
||||||
value={promptText}
|
<TextField
|
||||||
onChange={(e) => onChangePrompt(e.target.value)}
|
label="Instructions + context for AI generation"
|
||||||
fullWidth
|
value={promptText}
|
||||||
multiline
|
onChange={(e) => onChangePrompt(e.target.value)}
|
||||||
minRows={4}
|
fullWidth
|
||||||
/>
|
multiline
|
||||||
</CollapsibleSection>
|
minRows={4}
|
||||||
</Stack>
|
/>
|
||||||
|
</CollapsibleSection>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ export default function StepPublish({
|
|||||||
previewHtml,
|
previewHtml,
|
||||||
draftHtml,
|
draftHtml,
|
||||||
onRefreshPreview,
|
onRefreshPreview,
|
||||||
|
onSaveDraft,
|
||||||
onGhostPublish,
|
onGhostPublish,
|
||||||
}: {
|
}: {
|
||||||
previewLoading: boolean;
|
previewLoading: boolean;
|
||||||
@ -13,6 +14,7 @@ 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 (
|
||||||
@ -23,6 +25,7 @@ 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>
|
||||||
|
|||||||
@ -1,138 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
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