From 4327db242de036093a3d88ceacb7a94b7e885373 Mon Sep 17 00:00:00 2001 From: Ender Date: Fri, 24 Oct 2025 15:17:43 +0200 Subject: [PATCH] feat: add posts list view and improve editor navigation flow --- apps/admin/src/App.tsx | 50 +++++++++++++--- apps/admin/src/components/EditorShell.tsx | 59 +++++------------- apps/admin/src/components/PostsList.tsx | 60 +++++++++++++++++++ apps/admin/src/features/recorder/Recorder.tsx | 28 +++++++-- 4 files changed, 142 insertions(+), 55 deletions(-) create mode 100644 apps/admin/src/components/PostsList.tsx diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 78abf77..d1fc860 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -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(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 (
- {authenticated - ? - : { - localStorage.setItem('voxblog_authed', '1'); - setAuthenticated(true); - }} /> - } + {authenticated ? ( + selectedPostId ? ( + setSelectedPostId(null)} + /> + ) : ( + { + 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: '

', 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')); + } + }} + /> + ) + ) : ( + { + localStorage.setItem('voxblog_authed', '1'); + setAuthenticated(true); + }} /> + )}
); } diff --git a/apps/admin/src/components/EditorShell.tsx b/apps/admin/src/components/EditorShell.tsx index 2d9a7b6..ca7bc62 100644 --- a/apps/admin/src/components/EditorShell.tsx +++ b/apps/admin/src/components/EditorShell.tsx @@ -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(''); const [draftId, setDraftId] = useState(null); - const [drafts, setDrafts] = useState([]); const editorRef = useRef(null); const [meta, setMeta] = useState({ title: '', tagsText: '', canonicalUrl: '', featureImage: '' }); + const [postClips, setPostClips] = useState>([]); 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 ( - - Welcome to VoxBlog Editor - + + VoxBlog Editor + + {onBack && } + + editorRef.current?.insertHtmlAtCursor(html)} + onInsertAtCursor={(html: string) => editorRef.current?.insertHtmlAtCursor(html)} + initialClips={postClips} /> - Posts - - {drafts.map(id => ( - - ))} - {drafts.length === 0 && ( - No drafts yet. - )} - - - - Draft + Post setDraft(html)} placeholder="Write your post..." /> - + {draftId && ( ID: {draftId} )} @@ -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))); diff --git a/apps/admin/src/components/PostsList.tsx b/apps/admin/src/components/PostsList.tsx new file mode 100644 index 0000000..900182d --- /dev/null +++ b/apps/admin/src/components/PostsList.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + 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 ( + + + Posts + + + {loading && Loading…} + {error && {error}} + {!loading && items.length === 0 && ( + No posts yet. Click New Post to create one. + )} + + {items.map((p) => ( + + + {(p.title && p.title.trim()) || 'Untitled'} + + {new Date(p.updatedAt).toLocaleString()} + ID: {p.id.slice(0, 8)} + + + + ))} + + + ); +} diff --git a/apps/admin/src/features/recorder/Recorder.tsx b/apps/admin/src/features/recorder/Recorder.tsx index 121cab8..2f8ccd4 100644 --- a/apps/admin/src/features/recorder/Recorder.tsx +++ b/apps/admin/src/features/recorder/Recorder.tsx @@ -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(null); const chunksRef = useRef([]); const mimeRef = useRef('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