feat: add posts list view and improve editor navigation flow

This commit is contained in:
Ender 2025-10-24 15:17:43 +02:00
parent 93f93e4f96
commit 4327db242d
4 changed files with 142 additions and 55 deletions

View File

@ -1,10 +1,12 @@
import { useEffect, useState } from 'react';
import AuthGate from './components/AuthGate';
import EditorShell from './components/EditorShell';
import PostsList from './components/PostsList';
import './App.css';
function App() {
const [authenticated, setAuthenticated] = useState(false);
const [selectedPostId, setSelectedPostId] = useState<string | null>(null);
useEffect(() => {
const flag = localStorage.getItem('voxblog_authed');
@ -13,18 +15,52 @@ function App() {
const handleLogout = () => {
localStorage.removeItem('voxblog_authed');
localStorage.removeItem('voxblog_draft_id');
setAuthenticated(false);
setSelectedPostId(null);
};
return (
<div className="app">
{authenticated
? <EditorShell onLogout={handleLogout} />
: <AuthGate onAuth={() => {
{authenticated ? (
selectedPostId ? (
<EditorShell
onLogout={handleLogout}
initialPostId={selectedPostId}
onBack={() => setSelectedPostId(null)}
/>
) : (
<PostsList
onSelect={(id) => {
setSelectedPostId(id);
localStorage.setItem('voxblog_draft_id', id);
}}
onNew={async () => {
try {
const res = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'Untitled', contentHtml: '<p></p>', status: 'editing' }),
});
const data = await res.json();
if (res.ok && data.id) {
setSelectedPostId(data.id);
localStorage.setItem('voxblog_draft_id', data.id);
} else {
alert('Failed to create post');
}
} catch (e: any) {
alert('Failed to create post: ' + (e?.message || 'unknown error'));
}
}}
/>
)
) : (
<AuthGate onAuth={() => {
localStorage.setItem('voxblog_authed', '1');
setAuthenticated(true);
}} />
}
)}
</div>
);
}

View File

@ -7,15 +7,15 @@ import MediaLibrary from './MediaLibrary';
import MetadataPanel, { type Metadata } from './MetadataPanel';
import { useEffect, useRef, useState } from 'react';
export default function EditorShell({ onLogout }: { onLogout?: () => 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 [drafts, setDrafts] = useState<string[]>([]);
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 }>>([]);
useEffect(() => {
const savedId = localStorage.getItem('voxblog_draft_id');
const savedId = initialPostId || localStorage.getItem('voxblog_draft_id');
const savedLocal = localStorage.getItem('voxblog_draft');
if (savedLocal) setDraft(savedLocal);
if (savedId) {
@ -26,19 +26,11 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
const data = await res.json();
setDraft(data.contentHtml || '');
setDraftId(data.id || savedId);
if (Array.isArray(data.audioClips)) setPostClips(data.audioClips);
}
} catch {}
})();
}
(async () => {
try {
const res = await fetch('/api/posts');
if (res.ok) {
const data = await res.json();
if (Array.isArray(data.items)) setDrafts(data.items.map((p: any) => p.id));
}
} catch {}
})();
}, []);
const saveDraft = async () => {
@ -68,46 +60,27 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
} catch {}
};
const loadDraft = async (id: string) => {
try {
const res = await fetch(`/api/posts/${id}`);
if (!res.ok) return;
const data = await res.json();
setDraft(data.contentHtml || '');
setDraftId(data.id || id);
localStorage.setItem('voxblog_draft_id', data.id || id);
if (data.contentHtml) localStorage.setItem('voxblog_draft', data.contentHtml);
} catch {}
};
// No inline post switching here; selection happens on Posts page
return (
<AdminLayout title="VoxBlog Admin" onLogout={onLogout}>
<Typography variant="h4" sx={{ mb: 2 }}>
Welcome to VoxBlog Editor
</Typography>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
<Typography variant="h4">VoxBlog Editor</Typography>
<Stack direction="row" spacing={1}>
{onBack && <Button variant="outlined" onClick={onBack}>Back to Posts</Button>}
</Stack>
</Stack>
<Box sx={{ display: 'grid', gap: 3 }}>
<Recorder
postId={draftId ?? undefined}
onInsertAtCursor={(html) => editorRef.current?.insertHtmlAtCursor(html)}
onInsertAtCursor={(html: string) => editorRef.current?.insertHtmlAtCursor(html)}
initialClips={postClips}
/>
<Box>
<Typography variant="subtitle1" sx={{ mb: 1 }}>Posts</Typography>
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', mb: 1 }}>
{drafts.map(id => (
<Button key={id} size="small" variant={draftId === id ? 'contained' : 'outlined'} onClick={() => loadDraft(id)}>
{id.slice(0, 8)}
</Button>
))}
{drafts.length === 0 && (
<Typography variant="body2">No drafts yet.</Typography>
)}
</Stack>
</Box>
<Box>
<Typography variant="subtitle1" sx={{ mb: 1 }}>Draft</Typography>
<Typography variant="subtitle1" sx={{ mb: 1 }}>Post</Typography>
<RichEditor ref={editorRef as any} value={draft} onChange={(html) => setDraft(html)} placeholder="Write your post..." />
<Stack direction="row" spacing={2} sx={{ mt: 1 }}>
<Button variant="contained" onClick={saveDraft}>Save Draft</Button>
<Button variant="contained" onClick={saveDraft}>Save Post</Button>
{draftId && (
<Typography variant="caption" sx={{ alignSelf: 'center' }}>ID: {draftId}</Typography>
)}
@ -136,7 +109,7 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
console.error('Ghost error', data);
alert('Ghost error: ' + (data?.detail || data?.error || res.status));
} else {
alert(`${status === 'published' ? 'Published' : 'Saved draft'} (id: ${data.id})`);
alert(`${status === 'published' ? 'Published' : 'Saved post'} (id: ${data.id})`);
}
} catch (e: any) {
alert('Ghost error: ' + (e?.message || String(e)));

View File

@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { Box, Button, Chip, Stack, Typography } from '@mui/material';
export type PostSummary = {
id: string;
title?: string | null;
status: string;
updatedAt: string;
};
export default function PostsList({ onSelect, onNew }: { onSelect: (id: string) => void; onNew?: () => void }) {
const [items, setItems] = useState<PostSummary[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>('');
useEffect(() => {
(async () => {
try {
setLoading(true);
setError('');
const res = await fetch('/api/posts');
if (!res.ok) throw new Error('Failed to load posts');
const data = await res.json();
const rows = (data.items || []) as Array<{ id: string; title?: string; status: string; updatedAt: string }>;
setItems(rows);
} catch (e: any) {
setError(e?.message || 'Failed to load posts');
} finally {
setLoading(false);
}
})();
}, []);
return (
<Box>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
<Typography variant="h5">Posts</Typography>
<Button variant="contained" onClick={onNew}>New Post</Button>
</Stack>
{loading && <Typography>Loading</Typography>}
{error && <Typography color="error">{error}</Typography>}
{!loading && items.length === 0 && (
<Typography variant="body2">No posts yet. Click New Post to create one.</Typography>
)}
<Stack spacing={1}>
{items.map((p) => (
<Stack key={p.id} direction="row" spacing={2} sx={{ border: '1px solid #eee', p: 1, borderRadius: 1, alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center' }}>
<Typography variant="subtitle1">{(p.title && p.title.trim()) || 'Untitled'}</Typography>
<Chip label={p.status} size="small" />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>{new Date(p.updatedAt).toLocaleString()}</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>ID: {p.id.slice(0, 8)}</Typography>
</Stack>
<Button size="small" variant="outlined" onClick={() => onSelect(p.id)}>Open</Button>
</Stack>
))}
</Stack>
</Box>
);
}

View File

@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { Box, Button, Stack, Typography } from '@mui/material';
export default function Recorder({ postId, onInsertAtCursor, onTranscript }: { postId?: string; onInsertAtCursor?: (html: string) => void; onTranscript?: (t: string) => void }) {
export default function Recorder({ postId, initialClips, onInsertAtCursor, onTranscript }: { postId?: string; initialClips?: Array<{ id: string; bucket: string; key: string; mime: string; transcript?: string }>; onInsertAtCursor?: (html: string) => void; onTranscript?: (t: string) => void }) {
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const mimeRef = useRef<string>('audio/webm');
@ -10,7 +10,7 @@ export default function Recorder({ postId, onInsertAtCursor, onTranscript }: { p
type Clip = {
id: string;
url: string;
blob: Blob;
blob?: Blob;
mime: string;
uploadedKey?: string;
uploadedBucket?: string | null;
@ -79,14 +79,32 @@ export default function Recorder({ postId, onInsertAtCursor, onTranscript }: { p
setRecording(false);
};
// Hydrate from initialClips when provided (e.g., persisted clips from DB)
useEffect(() => {
if (!initialClips || initialClips.length === 0) return;
setClips(initialClips.map(ic => ({
id: ic.id,
url: `/api/media/obj?bucket=${encodeURIComponent(ic.bucket)}&key=${encodeURIComponent(ic.key)}`,
mime: ic.mime,
uploadedKey: ic.key,
uploadedBucket: ic.bucket,
transcript: ic.transcript,
})));
}, [initialClips]);
const uploadClip = async (idx: number) => {
const c = clips[idx];
if (!c) return;
if (!c.blob) {
setClips((prev) => prev.map((x, i) => i === idx ? { ...x, error: 'No local audio data for upload' } : x));
return;
}
setClips((prev) => prev.map((x, i) => i === idx ? { ...x, isUploading: true, error: '' } : x));
try {
const form = new FormData();
const ext = c.mime.includes('mp4') ? 'm4a' : 'webm';
form.append('audio', c.blob, `recording.${ext}`);
const blob = c.blob as Blob;
form.append('audio', blob, `recording.${ext}`);
const url = postId ? `/api/media/audio?postId=${encodeURIComponent(postId)}` : '/api/media/audio';
const res = await fetch(url, { method: 'POST', body: form });
if (!res.ok) {
@ -94,7 +112,7 @@ export default function Recorder({ postId, onInsertAtCursor, onTranscript }: { p
throw new Error(`Upload failed: ${res.status} ${txt}`);
}
const data = await res.json();
setClips((prev) => prev.map((x, i) => i === idx ? { ...x, uploadedKey: data.key || 'uploaded', uploadedBucket: data.bucket || null } : x));
setClips((prev) => prev.map((x, i) => i === idx ? { ...x, id: (data.clipId || x.id), uploadedKey: data.key || 'uploaded', uploadedBucket: data.bucket || null } : x));
} catch (e: any) {
setClips((prev) => prev.map((x, i) => i === idx ? { ...x, error: e?.message || 'Upload failed' } : x));
} finally {
@ -190,7 +208,7 @@ export default function Recorder({ postId, onInsertAtCursor, onTranscript }: { p
</Stack>
<audio controls src={c.url} />
<Stack direction="row" spacing={1} sx={{ mt: 1, flexWrap: 'wrap' }}>
<Button size="small" variant="text" disabled={!!c.isUploading} onClick={() => uploadClip(idx)}>
<Button size="small" variant="text" disabled={!!c.isUploading || !c.blob} onClick={() => uploadClip(idx)}>
{c.isUploading ? 'Uploading…' : (c.uploadedKey ? 'Re-upload' : 'Upload')}
</Button>
<Button size="small" variant="text" disabled={!c.uploadedKey || !!c.isTranscribing} onClick={() => transcribeClip(idx)}>