feat: add posts list view and improve editor navigation flow
This commit is contained in:
parent
93f93e4f96
commit
4327db242d
@ -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={() => {
|
||||
localStorage.setItem('voxblog_authed', '1');
|
||||
setAuthenticated(true);
|
||||
}} />
|
||||
}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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)));
|
||||
|
||||
60
apps/admin/src/components/PostsList.tsx
Normal file
60
apps/admin/src/components/PostsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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)}>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user