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 { useEffect, useState } from 'react';
|
||||||
import AuthGate from './components/AuthGate';
|
import AuthGate from './components/AuthGate';
|
||||||
import EditorShell from './components/EditorShell';
|
import EditorShell from './components/EditorShell';
|
||||||
|
import PostsList from './components/PostsList';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [authenticated, setAuthenticated] = useState(false);
|
const [authenticated, setAuthenticated] = useState(false);
|
||||||
|
const [selectedPostId, setSelectedPostId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const flag = localStorage.getItem('voxblog_authed');
|
const flag = localStorage.getItem('voxblog_authed');
|
||||||
@ -13,18 +15,52 @@ function App() {
|
|||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
localStorage.removeItem('voxblog_authed');
|
localStorage.removeItem('voxblog_authed');
|
||||||
|
localStorage.removeItem('voxblog_draft_id');
|
||||||
setAuthenticated(false);
|
setAuthenticated(false);
|
||||||
|
setSelectedPostId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
{authenticated
|
{authenticated ? (
|
||||||
? <EditorShell onLogout={handleLogout} />
|
selectedPostId ? (
|
||||||
: <AuthGate onAuth={() => {
|
<EditorShell
|
||||||
localStorage.setItem('voxblog_authed', '1');
|
onLogout={handleLogout}
|
||||||
setAuthenticated(true);
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,15 +7,15 @@ import MediaLibrary from './MediaLibrary';
|
|||||||
import MetadataPanel, { type Metadata } from './MetadataPanel';
|
import MetadataPanel, { type Metadata } from './MetadataPanel';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
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 [draft, setDraft] = useState<string>('');
|
||||||
const [draftId, setDraftId] = useState<string | null>(null);
|
const [draftId, setDraftId] = useState<string | null>(null);
|
||||||
const [drafts, setDrafts] = useState<string[]>([]);
|
|
||||||
const editorRef = useRef<RichEditorHandle | null>(null);
|
const editorRef = useRef<RichEditorHandle | null>(null);
|
||||||
const [meta, setMeta] = useState<Metadata>({ title: '', tagsText: '', canonicalUrl: '', featureImage: '' });
|
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(() => {
|
useEffect(() => {
|
||||||
const savedId = localStorage.getItem('voxblog_draft_id');
|
const savedId = initialPostId || localStorage.getItem('voxblog_draft_id');
|
||||||
const savedLocal = localStorage.getItem('voxblog_draft');
|
const savedLocal = localStorage.getItem('voxblog_draft');
|
||||||
if (savedLocal) setDraft(savedLocal);
|
if (savedLocal) setDraft(savedLocal);
|
||||||
if (savedId) {
|
if (savedId) {
|
||||||
@ -26,19 +26,11 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setDraft(data.contentHtml || '');
|
setDraft(data.contentHtml || '');
|
||||||
setDraftId(data.id || savedId);
|
setDraftId(data.id || savedId);
|
||||||
|
if (Array.isArray(data.audioClips)) setPostClips(data.audioClips);
|
||||||
}
|
}
|
||||||
} catch {}
|
} 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 () => {
|
const saveDraft = async () => {
|
||||||
@ -68,46 +60,27 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadDraft = async (id: string) => {
|
// No inline post switching here; selection happens on Posts page
|
||||||
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 {}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminLayout title="VoxBlog Admin" onLogout={onLogout}>
|
<AdminLayout title="VoxBlog Admin" onLogout={onLogout}>
|
||||||
<Typography variant="h4" sx={{ mb: 2 }}>
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||||
Welcome to VoxBlog Editor
|
<Typography variant="h4">VoxBlog Editor</Typography>
|
||||||
</Typography>
|
<Stack direction="row" spacing={1}>
|
||||||
|
{onBack && <Button variant="outlined" onClick={onBack}>Back to Posts</Button>}
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
<Box sx={{ display: 'grid', gap: 3 }}>
|
<Box sx={{ display: 'grid', gap: 3 }}>
|
||||||
<Recorder
|
<Recorder
|
||||||
postId={draftId ?? undefined}
|
postId={draftId ?? undefined}
|
||||||
onInsertAtCursor={(html) => editorRef.current?.insertHtmlAtCursor(html)}
|
onInsertAtCursor={(html: string) => editorRef.current?.insertHtmlAtCursor(html)}
|
||||||
|
initialClips={postClips}
|
||||||
/>
|
/>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>Posts</Typography>
|
<Typography variant="subtitle1" sx={{ mb: 1 }}>Post</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>
|
|
||||||
<RichEditor ref={editorRef as any} value={draft} onChange={(html) => setDraft(html)} placeholder="Write your post..." />
|
<RichEditor ref={editorRef as any} value={draft} onChange={(html) => setDraft(html)} placeholder="Write your post..." />
|
||||||
<Stack direction="row" spacing={2} sx={{ mt: 1 }}>
|
<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 && (
|
{draftId && (
|
||||||
<Typography variant="caption" sx={{ alignSelf: 'center' }}>ID: {draftId}</Typography>
|
<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);
|
console.error('Ghost error', data);
|
||||||
alert('Ghost error: ' + (data?.detail || data?.error || res.status));
|
alert('Ghost error: ' + (data?.detail || data?.error || res.status));
|
||||||
} else {
|
} else {
|
||||||
alert(`${status === 'published' ? 'Published' : 'Saved draft'} (id: ${data.id})`);
|
alert(`${status === 'published' ? 'Published' : 'Saved post'} (id: ${data.id})`);
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert('Ghost error: ' + (e?.message || String(e)));
|
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 { useEffect, useRef, useState } from 'react';
|
||||||
import { Box, Button, Stack, Typography } from '@mui/material';
|
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 mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
const chunksRef = useRef<Blob[]>([]);
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
const mimeRef = useRef<string>('audio/webm');
|
const mimeRef = useRef<string>('audio/webm');
|
||||||
@ -10,7 +10,7 @@ export default function Recorder({ postId, onInsertAtCursor, onTranscript }: { p
|
|||||||
type Clip = {
|
type Clip = {
|
||||||
id: string;
|
id: string;
|
||||||
url: string;
|
url: string;
|
||||||
blob: Blob;
|
blob?: Blob;
|
||||||
mime: string;
|
mime: string;
|
||||||
uploadedKey?: string;
|
uploadedKey?: string;
|
||||||
uploadedBucket?: string | null;
|
uploadedBucket?: string | null;
|
||||||
@ -79,14 +79,32 @@ export default function Recorder({ postId, onInsertAtCursor, onTranscript }: { p
|
|||||||
setRecording(false);
|
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 uploadClip = async (idx: number) => {
|
||||||
const c = clips[idx];
|
const c = clips[idx];
|
||||||
if (!c) return;
|
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));
|
setClips((prev) => prev.map((x, i) => i === idx ? { ...x, isUploading: true, error: '' } : x));
|
||||||
try {
|
try {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
const ext = c.mime.includes('mp4') ? 'm4a' : 'webm';
|
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 url = postId ? `/api/media/audio?postId=${encodeURIComponent(postId)}` : '/api/media/audio';
|
||||||
const res = await fetch(url, { method: 'POST', body: form });
|
const res = await fetch(url, { method: 'POST', body: form });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -94,7 +112,7 @@ export default function Recorder({ postId, onInsertAtCursor, onTranscript }: { p
|
|||||||
throw new Error(`Upload failed: ${res.status} ${txt}`);
|
throw new Error(`Upload failed: ${res.status} ${txt}`);
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
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) {
|
} catch (e: any) {
|
||||||
setClips((prev) => prev.map((x, i) => i === idx ? { ...x, error: e?.message || 'Upload failed' } : x));
|
setClips((prev) => prev.map((x, i) => i === idx ? { ...x, error: e?.message || 'Upload failed' } : x));
|
||||||
} finally {
|
} finally {
|
||||||
@ -190,7 +208,7 @@ export default function Recorder({ postId, onInsertAtCursor, onTranscript }: { p
|
|||||||
</Stack>
|
</Stack>
|
||||||
<audio controls src={c.url} />
|
<audio controls src={c.url} />
|
||||||
<Stack direction="row" spacing={1} sx={{ mt: 1, flexWrap: 'wrap' }}>
|
<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')}
|
{c.isUploading ? 'Uploading…' : (c.uploadedKey ? 'Re-upload' : 'Upload')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="small" variant="text" disabled={!c.uploadedKey || !!c.isTranscribing} onClick={() => transcribeClip(idx)}>
|
<Button size="small" variant="text" disabled={!c.uploadedKey || !!c.isTranscribing} onClick={() => transcribeClip(idx)}>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user