From 35aabcd3d3e90072f8e3c54e84b1681de74706bd Mon Sep 17 00:00:00 2001 From: Ender Date: Fri, 24 Oct 2025 23:31:33 +0200 Subject: [PATCH] feat: add keyboard shortcuts, pagination, and auto-upload for audio recordings --- BACKEND_UPDATES.md | 110 +++++++++ apps/admin/src/App.tsx | 1 - apps/admin/src/components/EditorShell.tsx | 14 +- apps/admin/src/components/MediaLibrary.tsx | 23 +- .../src/components/layout/AdminTopBar.tsx | 5 +- .../src/components/steps/StepAiPrompt.tsx | 8 +- .../admin/src/components/steps/StepAssets.tsx | 8 +- apps/admin/src/components/steps/StepEdit.tsx | 8 +- .../src/components/steps/StepGenerate.tsx | 9 +- .../admin/src/components/steps/StepHeader.tsx | 14 ++ .../src/components/steps/StepMetadata.tsx | 5 + .../src/components/steps/StepPublish.tsx | 9 +- apps/admin/src/features/recorder/Recorder.tsx | 27 ++- apps/admin/src/hooks/usePostEditor.ts | 10 + apps/admin/src/services/posts.ts | 1 + apps/api/drizzle/0001_jittery_revanche.sql | 3 + apps/api/drizzle/meta/0001_snapshot.json | 222 ++++++++++++++++++ apps/api/drizzle/meta/_journal.json | 7 + apps/api/src/db/schema.ts | 3 + apps/api/src/media.ts | 22 +- apps/api/src/posts.ts | 35 ++- 21 files changed, 512 insertions(+), 32 deletions(-) create mode 100644 BACKEND_UPDATES.md create mode 100644 apps/admin/src/components/steps/StepHeader.tsx create mode 100644 apps/api/drizzle/0001_jittery_revanche.sql create mode 100644 apps/api/drizzle/meta/0001_snapshot.json diff --git a/BACKEND_UPDATES.md b/BACKEND_UPDATES.md new file mode 100644 index 0000000..f1e2b09 --- /dev/null +++ b/BACKEND_UPDATES.md @@ -0,0 +1,110 @@ +# Backend Updates Summary + +## Database Schema Changes + +### Posts Table - New Fields Added + +1. **`ghost_slug`** (VARCHAR(255), nullable) + - Stores the Ghost post slug for association + - Used to link VoxBlog posts with their Ghost counterparts + +2. **`ghost_published_at`** (DATETIME(3), nullable) + - Timestamp when the post was published to Ghost + - Helps track publication history + +3. **`selected_image_keys`** (TEXT, nullable) + - JSON array of selected image keys for the post + - Persists user's image selection for the Generate step + - Stored as JSON string, parsed on retrieval + +### Migration File +- **Location**: `apps/api/drizzle/0001_jittery_revanche.sql` +- **Status**: ✅ Migration has been generated and applied + +```sql +ALTER TABLE `posts` ADD `ghost_slug` varchar(255); +ALTER TABLE `posts` ADD `ghost_published_at` datetime(3); +ALTER TABLE `posts` ADD `selected_image_keys` text; +``` + +**To apply this migration** (if needed on other environments): +```bash +cd apps/api +pnpm drizzle:migrate +``` + +## API Endpoint Changes + +### 1. GET `/api/posts/:id` +**Added Response Fields:** +- `ghostPostId`: string | null +- `ghostSlug`: string | null +- `ghostPublishedAt`: Date | null +- `ghostUrl`: string | null +- `selectedImageKeys`: string[] (parsed from JSON) + +### 2. POST `/api/posts` +**Added Request Body Fields:** +- `selectedImageKeys`: string[] (optional) +- `ghostPostId`: string (optional) +- `ghostSlug`: string (optional) +- `ghostPublishedAt`: string (optional, ISO date string) +- `ghostUrl`: string (optional) + +**Behavior:** +- `selectedImageKeys` is serialized to JSON before storage +- Ghost fields are only updated if provided in the request +- All fields are nullable and optional + +### 3. GET `/api/media/list` +**Added Query Parameters:** +- `page`: number (default: 1, min: 1) +- `pageSize`: number (default: 50, min: 1, max: 200) + +**Updated Response:** +```json +{ + "items": [...], + "total": number, + "page": number, + "pageSize": number +} +``` + +**Note:** Due to S3 limitations, pagination fetches `page * pageSize` items and slices them. For large datasets, consider implementing cursor-based pagination or caching. + +## Files Modified + +1. **`apps/api/src/db/schema.ts`** + - Added new fields to posts table definition + +2. **`apps/api/src/posts.ts`** + - Updated GET endpoint to return new fields + - Updated POST endpoint to accept and store new fields + - Added JSON serialization/deserialization for `selectedImageKeys` + +3. **`apps/api/src/media.ts`** + - Added pagination support to `/list` endpoint + - Implemented page and pageSize query parameters + +## Testing Checklist + +- [x] Run database migration ✅ +- [ ] Test POST `/api/posts` with `selectedImageKeys` array +- [ ] Test GET `/api/posts/:id` returns `selectedImageKeys` as array +- [ ] Test POST `/api/posts` with Ghost fields +- [ ] Test GET `/api/media/list?page=1&pageSize=50` +- [ ] Test GET `/api/media/list?page=2&pageSize=50` +- [ ] Verify `selectedImageKeys` persists across save/load cycles +- [ ] Verify Ghost fields update correctly on publish + +## Frontend Integration + +The frontend has been updated to: +1. Send `selectedImageKeys` when saving posts +2. Load `selectedImageKeys` when opening posts +3. Use pagination for media library +4. Update post status after Ghost publish +5. Auto-upload audio recordings + +All frontend changes are complete and ready to work with these backend updates. diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 1c18a4c..492979d 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -49,7 +49,6 @@ function App() { setSelectedPostId(null)} - onNewPost={createNewPost} onSettings={undefined} onLogout={handleLogout} /> diff --git a/apps/admin/src/components/EditorShell.tsx b/apps/admin/src/components/EditorShell.tsx index 2c63d22..0a5c766 100644 --- a/apps/admin/src/components/EditorShell.tsx +++ b/apps/admin/src/components/EditorShell.tsx @@ -8,7 +8,7 @@ import StepMetadata from './steps/StepMetadata'; import StepPublish from './steps/StepPublish'; import StepContainer from './steps/StepContainer'; import { usePostEditor } from '../hooks/usePostEditor'; -import { useRef } from 'react'; +import { useRef, useEffect } from 'react'; import PageContainer from './layout/PageContainer'; import PostSidebar from './layout/PostSidebar'; import StepNavigation from './layout/StepNavigation'; @@ -44,6 +44,18 @@ export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack toggleGenImage, } = usePostEditor(initialPostId); + // Keyboard shortcut: Ctrl/Cmd+S to save + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + void savePost(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [savePost]); + // All data logic moved into usePostEditor // No inline post switching here; selection happens on Posts page diff --git a/apps/admin/src/components/MediaLibrary.tsx b/apps/admin/src/components/MediaLibrary.tsx index 4015d39..3809378 100644 --- a/apps/admin/src/components/MediaLibrary.tsx +++ b/apps/admin/src/components/MediaLibrary.tsx @@ -29,15 +29,24 @@ export default function MediaLibrary({ const [sortBy, setSortBy] = useState<'date_desc' | 'date_asc' | 'name_asc' | 'size_desc'>('date_desc'); const [uploading, setUploading] = useState(false); const [uploadingCount, setUploadingCount] = useState(0); + const [page, setPage] = useState(1); + const [pageSize] = useState(50); + const [totalCount, setTotalCount] = useState(0); const load = async () => { try { setLoading(true); setError(''); - const res = await fetch('/api/media/list?prefix=images/'); + const params = new URLSearchParams({ + prefix: 'images/', + page: String(page), + pageSize: String(pageSize), + }); + const res = await fetch(`/api/media/list?${params.toString()}`); if (!res.ok) throw new Error(await res.text()); const data = await res.json(); setItems(Array.isArray(data.items) ? data.items : []); + setTotalCount(data.total || data.items?.length || 0); } catch (e: any) { setError(e?.message || 'Failed to load media'); } finally { @@ -47,7 +56,7 @@ export default function MediaLibrary({ useEffect(() => { load(); - }, []); + }, [page]); // Paste-to-upload clipboard images useEffect(() => { @@ -151,6 +160,16 @@ export default function MediaLibrary({ + + + {totalCount} total images + + + + Page {page} + + + {error && {error}} {filtered.map((it) => { diff --git a/apps/admin/src/components/layout/AdminTopBar.tsx b/apps/admin/src/components/layout/AdminTopBar.tsx index 03a427f..af1bc56 100644 --- a/apps/admin/src/components/layout/AdminTopBar.tsx +++ b/apps/admin/src/components/layout/AdminTopBar.tsx @@ -3,13 +3,11 @@ import { AppBar, Box, Button, Toolbar, Typography } from '@mui/material'; export default function AdminTopBar({ userName, onGoPosts, - onNewPost, onSettings, onLogout, }: { userName?: string; onGoPosts?: () => void; - onNewPost?: () => void; onSettings?: () => void; onLogout?: () => void; }) { @@ -25,8 +23,7 @@ export default function AdminTopBar({ {onGoPosts && } - {onNewPost && } - {onSettings && } + {onLogout && } diff --git a/apps/admin/src/components/steps/StepAiPrompt.tsx b/apps/admin/src/components/steps/StepAiPrompt.tsx index 7efa112..518ea58 100644 --- a/apps/admin/src/components/steps/StepAiPrompt.tsx +++ b/apps/admin/src/components/steps/StepAiPrompt.tsx @@ -1,4 +1,5 @@ -import { Box, TextField, Typography } from '@mui/material'; +import { Box, TextField } from '@mui/material'; +import StepHeader from './StepHeader'; export default function StepAiPrompt({ promptText, @@ -9,7 +10,10 @@ export default function StepAiPrompt({ }) { return ( - AI Prompt + - Assets (Audio & Images) + - Edit Content + - Generate - - Select images as generation assets, review audio transcriptions, and set the prompt to guide AI. - + {/* Audio transcriptions in order */} diff --git a/apps/admin/src/components/steps/StepHeader.tsx b/apps/admin/src/components/steps/StepHeader.tsx new file mode 100644 index 0000000..1248b84 --- /dev/null +++ b/apps/admin/src/components/steps/StepHeader.tsx @@ -0,0 +1,14 @@ +import { Box, Typography } from '@mui/material'; + +export default function StepHeader({ title, description }: { title: string; description?: string }) { + return ( + + {title} + {description && ( + + {description} + + )} + + ); +} diff --git a/apps/admin/src/components/steps/StepMetadata.tsx b/apps/admin/src/components/steps/StepMetadata.tsx index 30e6bd2..4318fef 100644 --- a/apps/admin/src/components/steps/StepMetadata.tsx +++ b/apps/admin/src/components/steps/StepMetadata.tsx @@ -1,9 +1,14 @@ import { Box } from '@mui/material'; import MetadataPanel, { type Metadata } from '../MetadataPanel'; +import StepHeader from './StepHeader'; export default function StepMetadata({ value, onChange }: { value: Metadata; onChange: (v: Metadata) => void }) { return ( + ); diff --git a/apps/admin/src/components/steps/StepPublish.tsx b/apps/admin/src/components/steps/StepPublish.tsx index 0648f04..26b6ed7 100644 --- a/apps/admin/src/components/steps/StepPublish.tsx +++ b/apps/admin/src/components/steps/StepPublish.tsx @@ -1,4 +1,5 @@ import { Alert, Box, Button, Stack, Typography } from '@mui/material'; +import StepHeader from './StepHeader'; export default function StepPublish({ previewLoading, @@ -17,10 +18,10 @@ export default function StepPublish({ }) { return ( - Publish - - Preview reflects Ghost media URL rewriting. Layout may differ from your Ghost theme. - + diff --git a/apps/admin/src/features/recorder/Recorder.tsx b/apps/admin/src/features/recorder/Recorder.tsx index c3fff60..82dd7f6 100644 --- a/apps/admin/src/features/recorder/Recorder.tsx +++ b/apps/admin/src/features/recorder/Recorder.tsx @@ -62,12 +62,30 @@ export default function Recorder({ postId, initialClips, onInsertAtCursor, onTra chunksRef.current.push(e.data); } }; - mr.onstop = () => { + mr.onstop = async () => { const blob = new Blob(chunksRef.current, { type: mimeRef.current }); const url = URL.createObjectURL(blob); const id = (globalThis.crypto && 'randomUUID' in crypto) ? crypto.randomUUID() : `${Date.now()}_${Math.random().toString(36).slice(2)}`; - setClips((prev) => [...prev, { id, url, blob, mime: mimeRef.current }]); + const newClip = { id, url, blob, mime: mimeRef.current, isUploading: true }; + setClips((prev) => [...prev, newClip]); stream.getTracks().forEach(t => t.stop()); + + // Auto-upload immediately + try { + const form = new FormData(); + const ext = mimeRef.current.includes('mp4') ? 'm4a' : 'webm'; + form.append('audio', blob, `recording.${ext}`); + const uploadUrl = postId ? `/api/media/audio?postId=${encodeURIComponent(postId)}` : '/api/media/audio'; + const res = await fetch(uploadUrl, { method: 'POST', body: form }); + if (!res.ok) { + const txt = await res.text(); + throw new Error(`Upload failed: ${res.status} ${txt}`); + } + const data = await res.json(); + setClips((prev) => prev.map(x => x.id === id ? { ...x, id: (data.clipId || x.id), uploadedKey: data.key || 'uploaded', uploadedBucket: data.bucket || null, isUploading: false } : x)); + } catch (e: any) { + setClips((prev) => prev.map(x => x.id === id ? { ...x, error: e?.message || 'Upload failed', isUploading: false } : x)); + } }; mr.start(); @@ -208,9 +226,8 @@ export default function Recorder({ postId, initialClips, onInsertAtCursor, onTra