feat: add keyboard shortcuts, pagination, and auto-upload for audio recordings

This commit is contained in:
Ender 2025-10-24 23:31:33 +02:00
parent 706c0e4ae4
commit 35aabcd3d3
21 changed files with 512 additions and 32 deletions

110
BACKEND_UPDATES.md Normal file
View File

@ -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.

View File

@ -49,7 +49,6 @@ function App() {
<AdminTopBar <AdminTopBar
userName={undefined} userName={undefined}
onGoPosts={() => setSelectedPostId(null)} onGoPosts={() => setSelectedPostId(null)}
onNewPost={createNewPost}
onSettings={undefined} onSettings={undefined}
onLogout={handleLogout} onLogout={handleLogout}
/> />

View File

@ -8,7 +8,7 @@ import StepMetadata from './steps/StepMetadata';
import StepPublish from './steps/StepPublish'; import StepPublish from './steps/StepPublish';
import StepContainer from './steps/StepContainer'; import StepContainer from './steps/StepContainer';
import { usePostEditor } from '../hooks/usePostEditor'; import { usePostEditor } from '../hooks/usePostEditor';
import { useRef } from 'react'; import { useRef, useEffect } from 'react';
import PageContainer from './layout/PageContainer'; import PageContainer from './layout/PageContainer';
import PostSidebar from './layout/PostSidebar'; import PostSidebar from './layout/PostSidebar';
import StepNavigation from './layout/StepNavigation'; import StepNavigation from './layout/StepNavigation';
@ -44,6 +44,18 @@ export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack
toggleGenImage, toggleGenImage,
} = usePostEditor(initialPostId); } = 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 // All data logic moved into usePostEditor
// No inline post switching here; selection happens on Posts page // No inline post switching here; selection happens on Posts page

View File

@ -29,15 +29,24 @@ export default function MediaLibrary({
const [sortBy, setSortBy] = useState<'date_desc' | 'date_asc' | 'name_asc' | 'size_desc'>('date_desc'); const [sortBy, setSortBy] = useState<'date_desc' | 'date_asc' | 'name_asc' | 'size_desc'>('date_desc');
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [uploadingCount, setUploadingCount] = useState(0); const [uploadingCount, setUploadingCount] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(50);
const [totalCount, setTotalCount] = useState(0);
const load = async () => { const load = async () => {
try { try {
setLoading(true); setLoading(true);
setError(''); 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()); if (!res.ok) throw new Error(await res.text());
const data = await res.json(); const data = await res.json();
setItems(Array.isArray(data.items) ? data.items : []); setItems(Array.isArray(data.items) ? data.items : []);
setTotalCount(data.total || data.items?.length || 0);
} catch (e: any) { } catch (e: any) {
setError(e?.message || 'Failed to load media'); setError(e?.message || 'Failed to load media');
} finally { } finally {
@ -47,7 +56,7 @@ export default function MediaLibrary({
useEffect(() => { useEffect(() => {
load(); load();
}, []); }, [page]);
// Paste-to-upload clipboard images // Paste-to-upload clipboard images
useEffect(() => { useEffect(() => {
@ -151,6 +160,16 @@ export default function MediaLibrary({
</Typography> </Typography>
</Stack> </Stack>
</Stack> </Stack>
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between" sx={{ mb: 1 }}>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{totalCount} total images
</Typography>
<Stack direction="row" spacing={1}>
<Button size="small" disabled={page === 1} onClick={() => setPage(p => Math.max(1, p - 1))}>Previous</Button>
<Typography variant="caption" sx={{ alignSelf: 'center' }}>Page {page}</Typography>
<Button size="small" disabled={filtered.length < pageSize} onClick={() => setPage(p => p + 1)}>Next</Button>
</Stack>
</Stack>
{error && <Typography color="error" sx={{ mb: 1 }}>{error}</Typography>} {error && <Typography color="error" sx={{ mb: 1 }}>{error}</Typography>}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 1.5 }}> <Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 1.5 }}>
{filtered.map((it) => { {filtered.map((it) => {

View File

@ -3,13 +3,11 @@ import { AppBar, Box, Button, Toolbar, Typography } from '@mui/material';
export default function AdminTopBar({ export default function AdminTopBar({
userName, userName,
onGoPosts, onGoPosts,
onNewPost,
onSettings, onSettings,
onLogout, onLogout,
}: { }: {
userName?: string; userName?: string;
onGoPosts?: () => void; onGoPosts?: () => void;
onNewPost?: () => void;
onSettings?: () => void; onSettings?: () => void;
onLogout?: () => void; onLogout?: () => void;
}) { }) {
@ -25,8 +23,7 @@ export default function AdminTopBar({
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 1 }}> <Box sx={{ display: 'flex', gap: 1 }}>
{onGoPosts && <Button color="inherit" onClick={onGoPosts}>Posts</Button>} {onGoPosts && <Button color="inherit" onClick={onGoPosts}>Posts</Button>}
{onNewPost && <Button color="inherit" onClick={onNewPost}>New Post</Button>} <Button color="inherit" disabled>Settings</Button>
{onSettings && <Button color="inherit" onClick={onSettings}>Settings</Button>}
{onLogout && <Button color="inherit" onClick={onLogout}>Logout</Button>} {onLogout && <Button color="inherit" onClick={onLogout}>Logout</Button>}
</Box> </Box>
</Toolbar> </Toolbar>

View File

@ -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({ export default function StepAiPrompt({
promptText, promptText,
@ -9,7 +10,10 @@ export default function StepAiPrompt({
}) { }) {
return ( return (
<Box> <Box>
<Typography variant="subtitle1" sx={{ mb: 1 }}>AI Prompt</Typography> <StepHeader
title="AI Prompt"
description="Provide instructions and context to guide AI content generation. Include goals, audience, tone, and reference your audio transcriptions and selected images."
/>
<TextField <TextField
label="Instructions + context for AI generation" label="Instructions + context for AI generation"
value={promptText} value={promptText}

View File

@ -1,7 +1,8 @@
import { Box, Stack, Typography } from '@mui/material'; import { Box, Stack } from '@mui/material';
import Recorder from '../../features/recorder/Recorder'; import Recorder from '../../features/recorder/Recorder';
import MediaLibrary from '../MediaLibrary'; import MediaLibrary from '../MediaLibrary';
import CollapsibleSection from './CollapsibleSection'; import CollapsibleSection from './CollapsibleSection';
import StepHeader from './StepHeader';
export type Clip = { id: string; bucket: string; key: string; mime: string; transcript?: string; createdAt: string }; export type Clip = { id: string; bucket: string; key: string; mime: string; transcript?: string; createdAt: string };
@ -24,7 +25,10 @@ export default function StepAssets({
}) { }) {
return ( return (
<Box sx={{ display: 'grid', gap: 2 }}> <Box sx={{ display: 'grid', gap: 2 }}>
<Typography variant="subtitle1">Assets (Audio & Images)</Typography> <StepHeader
title="Assets (Audio & Images)"
description="Record audio clips and select images for your post. Audio recordings auto-upload and can be transcribed. Selected images will be available in the Generate step."
/>
<Stack direction="column" spacing={2} alignItems="stretch"> <Stack direction="column" spacing={2} alignItems="stretch">
<CollapsibleSection title="Audio Recorder"> <CollapsibleSection title="Audio Recorder">
<Recorder <Recorder

View File

@ -1,5 +1,6 @@
import { Box, Typography } from '@mui/material'; import { Box } from '@mui/material';
import RichEditor, { type RichEditorHandle } from '../RichEditor'; import RichEditor, { type RichEditorHandle } from '../RichEditor';
import StepHeader from './StepHeader';
import type { ForwardedRef } from 'react'; import type { ForwardedRef } from 'react';
export default function StepEdit({ export default function StepEdit({
@ -13,7 +14,10 @@ export default function StepEdit({
}) { }) {
return ( return (
<Box> <Box>
<Typography variant="subtitle1" sx={{ mb: 1 }}>Edit Content</Typography> <StepHeader
title="Edit Content"
description="Write and format your post content. Use Ctrl/Cmd+S to save your work."
/>
<Box sx={{ <Box sx={{
overflowX: 'auto', overflowX: 'auto',
'& img': { maxWidth: '100%', height: 'auto' }, '& img': { maxWidth: '100%', height: 'auto' },

View File

@ -1,6 +1,7 @@
import { Box, Stack, TextField, Typography } from '@mui/material'; import { Box, Stack, TextField, Typography } from '@mui/material';
import SelectedImages from './SelectedImages'; import SelectedImages from './SelectedImages';
import CollapsibleSection from './CollapsibleSection'; import CollapsibleSection from './CollapsibleSection';
import StepHeader from './StepHeader';
import type { Clip } from './StepAssets'; import type { Clip } from './StepAssets';
export default function StepGenerate({ export default function StepGenerate({
@ -18,10 +19,10 @@ export default function StepGenerate({
}) { }) {
return ( return (
<Box sx={{ display: 'grid', gap: 2 }}> <Box sx={{ display: 'grid', gap: 2 }}>
<Typography variant="subtitle1">Generate</Typography> <StepHeader
<Typography variant="body2" sx={{ color: 'text.secondary' }}> title="Generate"
Select images as generation assets, review audio transcriptions, and set the prompt to guide AI. description="Review audio transcriptions, verify selected images, and set the AI prompt to guide content generation."
</Typography> />
<Stack spacing={2}> <Stack spacing={2}>
{/* Audio transcriptions in order */} {/* Audio transcriptions in order */}

View File

@ -0,0 +1,14 @@
import { Box, Typography } from '@mui/material';
export default function StepHeader({ title, description }: { title: string; description?: string }) {
return (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: description ? 0.5 : 0 }}>{title}</Typography>
{description && (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{description}
</Typography>
)}
</Box>
);
}

View File

@ -1,9 +1,14 @@
import { Box } from '@mui/material'; import { Box } from '@mui/material';
import MetadataPanel, { type Metadata } from '../MetadataPanel'; import MetadataPanel, { type Metadata } from '../MetadataPanel';
import StepHeader from './StepHeader';
export default function StepMetadata({ value, onChange }: { value: Metadata; onChange: (v: Metadata) => void }) { export default function StepMetadata({ value, onChange }: { value: Metadata; onChange: (v: Metadata) => void }) {
return ( return (
<Box> <Box>
<StepHeader
title="Metadata"
description="Configure post metadata including tags, canonical URL, and feature image."
/>
<MetadataPanel value={value} onChange={onChange} /> <MetadataPanel value={value} onChange={onChange} />
</Box> </Box>
); );

View File

@ -1,4 +1,5 @@
import { Alert, Box, Button, Stack, Typography } from '@mui/material'; import { Alert, Box, Button, Stack, Typography } from '@mui/material';
import StepHeader from './StepHeader';
export default function StepPublish({ export default function StepPublish({
previewLoading, previewLoading,
@ -17,10 +18,10 @@ export default function StepPublish({
}) { }) {
return ( return (
<Box sx={{ display: 'grid', gap: 1 }}> <Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="subtitle1">Publish</Typography> <StepHeader
<Typography variant="body2" sx={{ color: 'text.secondary' }}> title="Publish"
Preview reflects Ghost media URL rewriting. Layout may differ from your Ghost theme. description="Preview your post with Ghost media URL rewriting applied. Publish as draft or published to Ghost. Layout may differ from your Ghost theme."
</Typography> />
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={1}>
<Button size="small" variant="outlined" onClick={onRefreshPreview} disabled={previewLoading}>Refresh Preview</Button> <Button size="small" variant="outlined" onClick={onRefreshPreview} disabled={previewLoading}>Refresh Preview</Button>
</Stack> </Stack>

View File

@ -62,12 +62,30 @@ export default function Recorder({ postId, initialClips, onInsertAtCursor, onTra
chunksRef.current.push(e.data); chunksRef.current.push(e.data);
} }
}; };
mr.onstop = () => { mr.onstop = async () => {
const blob = new Blob(chunksRef.current, { type: mimeRef.current }); const blob = new Blob(chunksRef.current, { type: mimeRef.current });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const id = (globalThis.crypto && 'randomUUID' in crypto) ? crypto.randomUUID() : `${Date.now()}_${Math.random().toString(36).slice(2)}`; 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()); 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(); mr.start();
@ -208,9 +226,8 @@ export default function Recorder({ postId, initialClips, onInsertAtCursor, onTra
</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 || !c.blob} onClick={() => uploadClip(idx)}> {c.isUploading && <Typography variant="caption" sx={{ alignSelf: 'center', color: 'text.secondary' }}>Uploading</Typography>}
{c.isUploading ? 'Uploading…' : (c.uploadedKey ? 'Re-upload' : 'Upload')} {c.uploadedKey && !c.isUploading && <Typography variant="caption" sx={{ alignSelf: 'center', color: 'success.main' }}> Uploaded</Typography>}
</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)}>
{c.isTranscribing ? 'Transcribing…' : (c.transcript ? 'Retranscribe' : 'Transcribe')} {c.isTranscribing ? 'Transcribing…' : (c.transcript ? 'Retranscribe' : 'Transcribe')}
</Button> </Button>

View File

@ -39,6 +39,7 @@ export function usePostEditor(initialPostId?: string | null) {
})); }));
if (data.status) setPostStatus(data.status); if (data.status) setPostStatus(data.status);
setPromptText(data.prompt || ''); setPromptText(data.prompt || '');
if (Array.isArray(data.selectedImageKeys)) setGenImageKeys(data.selectedImageKeys);
} catch {} } catch {}
})(); })();
} }
@ -55,6 +56,7 @@ export function usePostEditor(initialPostId?: string | null) {
canonicalUrl: meta.canonicalUrl || undefined, canonicalUrl: meta.canonicalUrl || undefined,
status: postStatus, status: postStatus,
prompt: promptText || undefined, prompt: promptText || undefined,
selectedImageKeys: genImageKeys.length > 0 ? genImageKeys : undefined,
...(overrides || {}), ...(overrides || {}),
}; };
const data = await savePostApi(payload); const data = await savePostApi(payload);
@ -82,6 +84,14 @@ export function usePostEditor(initialPostId?: string | null) {
tags, tags,
status, status,
}); });
// Update post status after successful Ghost publish
const newStatus = status === 'published' ? 'published' : 'ready_for_publish';
setPostStatus(newStatus);
// Save updated status to backend
await savePost({ status: newStatus });
setToast({ open: true, message: status === 'published' ? 'Published to Ghost' : 'Draft saved to Ghost', severity: 'success' }); setToast({ open: true, message: status === 'published' ? 'Published to Ghost' : 'Draft saved to Ghost', severity: 'success' });
}; };

View File

@ -7,6 +7,7 @@ export type SavePostPayload = {
canonicalUrl?: string; canonicalUrl?: string;
status?: 'inbox' | 'editing' | 'ready_for_publish' | 'published' | 'archived'; status?: 'inbox' | 'editing' | 'ready_for_publish' | 'published' | 'archived';
prompt?: string; prompt?: string;
selectedImageKeys?: string[];
}; };
export async function getPost(id: string) { export async function getPost(id: string) {

View File

@ -0,0 +1,3 @@
ALTER TABLE `posts` ADD `ghost_slug` varchar(255);--> statement-breakpoint
ALTER TABLE `posts` ADD `ghost_published_at` datetime(3);--> statement-breakpoint
ALTER TABLE `posts` ADD `selected_image_keys` text;

View File

@ -0,0 +1,222 @@
{
"version": "5",
"dialect": "mysql",
"id": "083eb00a-492f-48d7-aad8-628b3d162082",
"prevId": "f5abcdd2-124f-4557-9356-956d9c9b25d6",
"tables": {
"audio_clips": {
"name": "audio_clips",
"columns": {
"id": {
"name": "id",
"type": "varchar(36)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"post_id": {
"name": "post_id",
"type": "varchar(36)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"bucket": {
"name": "bucket",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"object_key": {
"name": "object_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mime": {
"name": "mime",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"transcript": {
"name": "transcript",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"duration_ms": {
"name": "duration_ms",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "datetime(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"audio_clips_id": {
"name": "audio_clips_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
},
"posts": {
"name": "posts",
"columns": {
"id": {
"name": "id",
"type": "varchar(36)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content_html": {
"name": "content_html",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tags_text": {
"name": "tags_text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"feature_image": {
"name": "feature_image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"canonical_url": {
"name": "canonical_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"prompt": {
"name": "prompt",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "enum('inbox','editing','ready_for_publish','published','archived')",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'editing'"
},
"ghost_post_id": {
"name": "ghost_post_id",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ghost_slug": {
"name": "ghost_slug",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ghost_published_at": {
"name": "ghost_published_at",
"type": "datetime(3)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ghost_url": {
"name": "ghost_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"selected_image_keys": {
"name": "selected_image_keys",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"version": {
"name": "version",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"created_at": {
"name": "created_at",
"type": "datetime(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "datetime(3)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"posts_id": {
"name": "posts_id",
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
"checkConstraint": {}
}
},
"views": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"tables": {},
"indexes": {}
}
}

View File

@ -8,6 +8,13 @@
"when": 1761316101623, "when": 1761316101623,
"tag": "0000_stiff_luckman", "tag": "0000_stiff_luckman",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1761341118599,
"tag": "0001_jittery_revanche",
"breakpoints": true
} }
] ]
} }

View File

@ -18,7 +18,10 @@ export const posts = mysqlTable('posts', {
prompt: text('prompt'), prompt: text('prompt'),
status: postStatusEnum.notNull().default('editing'), status: postStatusEnum.notNull().default('editing'),
ghostPostId: varchar('ghost_post_id', { length: 64 }), ghostPostId: varchar('ghost_post_id', { length: 64 }),
ghostSlug: varchar('ghost_slug', { length: 255 }),
ghostPublishedAt: datetime('ghost_published_at', { fsp: 3 }),
ghostUrl: text('ghost_url'), ghostUrl: text('ghost_url'),
selectedImageKeys: text('selected_image_keys'),
version: int('version').notNull().default(1), version: int('version').notNull().default(1),
createdAt: datetime('created_at', { fsp: 3 }).notNull(), createdAt: datetime('created_at', { fsp: 3 }).notNull(),
updatedAt: datetime('updated_at', { fsp: 3 }).notNull(), updatedAt: datetime('updated_at', { fsp: 3 }).notNull(),

View File

@ -16,9 +16,27 @@ router.get('/list', async (
try { try {
const bucket = (req.query.bucket as string) || process.env.S3_BUCKET || ''; const bucket = (req.query.bucket as string) || process.env.S3_BUCKET || '';
const prefix = (req.query.prefix as string) || ''; const prefix = (req.query.prefix as string) || '';
const page = Math.max(parseInt((req.query.page as string) || '1', 10), 1);
const pageSize = Math.min(Math.max(parseInt((req.query.pageSize as string) || '50', 10), 1), 200);
if (!bucket) return res.status(400).json({ error: 'bucket is required' }); if (!bucket) return res.status(400).json({ error: 'bucket is required' });
const out = await listObjects({ bucket, prefix, maxKeys: 200 });
return res.json(out); // For pagination, we need to fetch more items and slice
// S3 doesn't support offset-based pagination natively, so we fetch a larger set
const maxKeys = page * pageSize;
const out = await listObjects({ bucket, prefix, maxKeys });
// Calculate pagination
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedItems = out.items.slice(startIndex, endIndex);
return res.json({
items: paginatedItems,
total: out.items.length,
page,
pageSize,
});
} catch (err) { } catch (err) {
console.error('List objects failed:', err); console.error('List objects failed:', err);
return res.status(500).json({ error: 'List failed' }); return res.status(500).json({ error: 'List failed' });

View File

@ -69,6 +69,11 @@ router.get('/:id', async (req, res) => {
canonicalUrl: posts.canonicalUrl, canonicalUrl: posts.canonicalUrl,
prompt: posts.prompt, prompt: posts.prompt,
status: posts.status, status: posts.status,
ghostPostId: posts.ghostPostId,
ghostSlug: posts.ghostSlug,
ghostPublishedAt: posts.ghostPublishedAt,
ghostUrl: posts.ghostUrl,
selectedImageKeys: posts.selectedImageKeys,
createdAt: posts.createdAt, createdAt: posts.createdAt,
updatedAt: posts.updatedAt, updatedAt: posts.updatedAt,
version: posts.version, version: posts.version,
@ -94,7 +99,9 @@ router.get('/:id', async (req, res) => {
.where(eq(audioClips.postId, id)) .where(eq(audioClips.postId, id))
.orderBy(audioClips.createdAt); .orderBy(audioClips.createdAt);
return res.json({ ...post, audioClips: clips }); // Parse selectedImageKeys from JSON string
const selectedImageKeys = post.selectedImageKeys ? JSON.parse(post.selectedImageKeys as string) : [];
return res.json({ ...post, selectedImageKeys, audioClips: clips });
} catch (err) { } catch (err) {
console.error('Get post error:', err); console.error('Get post error:', err);
return res.status(500).json({ error: 'Failed to get post' }); return res.status(500).json({ error: 'Failed to get post' });
@ -113,6 +120,11 @@ router.post('/', async (req, res) => {
canonicalUrl, canonicalUrl,
prompt, prompt,
status, status,
selectedImageKeys,
ghostPostId,
ghostSlug,
ghostPublishedAt,
ghostUrl,
} = req.body as { } = req.body as {
id?: string; id?: string;
title?: string; title?: string;
@ -122,9 +134,15 @@ router.post('/', async (req, res) => {
canonicalUrl?: string; canonicalUrl?: string;
prompt?: string; prompt?: string;
status?: string; status?: string;
selectedImageKeys?: string[];
ghostPostId?: string;
ghostSlug?: string;
ghostPublishedAt?: string;
ghostUrl?: string;
}; };
const tagsText = Array.isArray(tags) ? tags.join(',') : (typeof tags === 'string' ? tags : null); const tagsText = Array.isArray(tags) ? tags.join(',') : (typeof tags === 'string' ? tags : null);
const selectedImageKeysJson = selectedImageKeys && Array.isArray(selectedImageKeys) ? JSON.stringify(selectedImageKeys) : null;
if (!contentHtml) return res.status(400).json({ error: 'contentHtml is required' }); if (!contentHtml) return res.status(400).json({ error: 'contentHtml is required' });
@ -141,13 +159,18 @@ router.post('/', async (req, res) => {
canonicalUrl: canonicalUrl ?? null as any, canonicalUrl: canonicalUrl ?? null as any,
prompt: prompt ?? null as any, prompt: prompt ?? null as any,
status: (status as any) ?? 'editing', status: (status as any) ?? 'editing',
selectedImageKeys: selectedImageKeysJson ?? null as any,
ghostPostId: ghostPostId ?? null as any,
ghostSlug: ghostSlug ?? null as any,
ghostPublishedAt: ghostPublishedAt ? new Date(ghostPublishedAt) : null as any,
ghostUrl: ghostUrl ?? null as any,
version: 1, version: 1,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
return res.json({ id: newId }); return res.json({ id: newId });
} else { } else {
await db.update(posts).set({ const updateData: any = {
title: title ?? null as any, title: title ?? null as any,
contentHtml, contentHtml,
tagsText: tagsText ?? null as any, tagsText: tagsText ?? null as any,
@ -156,7 +179,13 @@ router.post('/', async (req, res) => {
prompt: prompt ?? null as any, prompt: prompt ?? null as any,
status: (status as any) ?? 'editing', status: (status as any) ?? 'editing',
updatedAt: now, updatedAt: now,
}).where(eq(posts.id, id)); };
if (selectedImageKeysJson !== null) updateData.selectedImageKeys = selectedImageKeysJson;
if (ghostPostId !== undefined) updateData.ghostPostId = ghostPostId ?? null as any;
if (ghostSlug !== undefined) updateData.ghostSlug = ghostSlug ?? null as any;
if (ghostPublishedAt !== undefined) updateData.ghostPublishedAt = ghostPublishedAt ? new Date(ghostPublishedAt) : null as any;
if (ghostUrl !== undefined) updateData.ghostUrl = ghostUrl ?? null as any;
await db.update(posts).set(updateData).where(eq(posts.id, id));
return res.json({ id }); return res.json({ id });
} }
} catch (err) { } catch (err) {