Compare commits

..

2 Commits

8 changed files with 278 additions and 192 deletions

View File

@ -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>
)}

View File

@ -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>
);

View File

@ -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>
);
}

View File

@ -23,41 +23,41 @@ export default function StepGenerate({
Select images as generation assets, review audio transcriptions, and set the prompt to guide AI.
</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>
);
}

View File

@ -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>

View File

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

View File

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

View File

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