feat: add keyboard shortcuts, pagination, and auto-upload for audio recordings
This commit is contained in:
parent
706c0e4ae4
commit
35aabcd3d3
110
BACKEND_UPDATES.md
Normal file
110
BACKEND_UPDATES.md
Normal 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.
|
||||
@ -49,7 +49,6 @@ function App() {
|
||||
<AdminTopBar
|
||||
userName={undefined}
|
||||
onGoPosts={() => setSelectedPostId(null)}
|
||||
onNewPost={createNewPost}
|
||||
onSettings={undefined}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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({
|
||||
</Typography>
|
||||
</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>}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 1.5 }}>
|
||||
{filtered.map((it) => {
|
||||
|
||||
@ -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({
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{onGoPosts && <Button color="inherit" onClick={onGoPosts}>Posts</Button>}
|
||||
{onNewPost && <Button color="inherit" onClick={onNewPost}>New Post</Button>}
|
||||
{onSettings && <Button color="inherit" onClick={onSettings}>Settings</Button>}
|
||||
<Button color="inherit" disabled>Settings</Button>
|
||||
{onLogout && <Button color="inherit" onClick={onLogout}>Logout</Button>}
|
||||
</Box>
|
||||
</Toolbar>
|
||||
|
||||
@ -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 (
|
||||
<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
|
||||
label="Instructions + context for AI generation"
|
||||
value={promptText}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { Box, Stack, Typography } from '@mui/material';
|
||||
import { Box, Stack } from '@mui/material';
|
||||
import Recorder from '../../features/recorder/Recorder';
|
||||
import MediaLibrary from '../MediaLibrary';
|
||||
import CollapsibleSection from './CollapsibleSection';
|
||||
import StepHeader from './StepHeader';
|
||||
|
||||
export type Clip = { id: string; bucket: string; key: string; mime: string; transcript?: string; createdAt: string };
|
||||
|
||||
@ -24,7 +25,10 @@ export default function StepAssets({
|
||||
}) {
|
||||
return (
|
||||
<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">
|
||||
<CollapsibleSection title="Audio Recorder">
|
||||
<Recorder
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import { Box } from '@mui/material';
|
||||
import RichEditor, { type RichEditorHandle } from '../RichEditor';
|
||||
import StepHeader from './StepHeader';
|
||||
import type { ForwardedRef } from 'react';
|
||||
|
||||
export default function StepEdit({
|
||||
@ -13,7 +14,10 @@ export default function StepEdit({
|
||||
}) {
|
||||
return (
|
||||
<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={{
|
||||
overflowX: 'auto',
|
||||
'& img': { maxWidth: '100%', height: 'auto' },
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Box, Stack, TextField, Typography } from '@mui/material';
|
||||
import SelectedImages from './SelectedImages';
|
||||
import CollapsibleSection from './CollapsibleSection';
|
||||
import StepHeader from './StepHeader';
|
||||
import type { Clip } from './StepAssets';
|
||||
|
||||
export default function StepGenerate({
|
||||
@ -18,10 +19,10 @@ export default function StepGenerate({
|
||||
}) {
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gap: 2 }}>
|
||||
<Typography variant="subtitle1">Generate</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
Select images as generation assets, review audio transcriptions, and set the prompt to guide AI.
|
||||
</Typography>
|
||||
<StepHeader
|
||||
title="Generate"
|
||||
description="Review audio transcriptions, verify selected images, and set the AI prompt to guide content generation."
|
||||
/>
|
||||
|
||||
<Stack spacing={2}>
|
||||
{/* Audio transcriptions in order */}
|
||||
|
||||
14
apps/admin/src/components/steps/StepHeader.tsx
Normal file
14
apps/admin/src/components/steps/StepHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<Box>
|
||||
<StepHeader
|
||||
title="Metadata"
|
||||
description="Configure post metadata including tags, canonical URL, and feature image."
|
||||
/>
|
||||
<MetadataPanel value={value} onChange={onChange} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
@ -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 (
|
||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||
<Typography variant="subtitle1">Publish</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
Preview reflects Ghost media URL rewriting. Layout may differ from your Ghost theme.
|
||||
</Typography>
|
||||
<StepHeader
|
||||
title="Publish"
|
||||
description="Preview your post with Ghost media URL rewriting applied. Publish as draft or published to Ghost. Layout may differ from your Ghost theme."
|
||||
/>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button size="small" variant="outlined" onClick={onRefreshPreview} disabled={previewLoading}>Refresh Preview</Button>
|
||||
</Stack>
|
||||
|
||||
@ -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
|
||||
</Stack>
|
||||
<audio controls src={c.url} />
|
||||
<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 ? 'Uploading…' : (c.uploadedKey ? 'Re-upload' : 'Upload')}
|
||||
</Button>
|
||||
{c.isUploading && <Typography variant="caption" sx={{ alignSelf: 'center', color: 'text.secondary' }}>Uploading…</Typography>}
|
||||
{c.uploadedKey && !c.isUploading && <Typography variant="caption" sx={{ alignSelf: 'center', color: 'success.main' }}>✓ Uploaded</Typography>}
|
||||
<Button size="small" variant="text" disabled={!c.uploadedKey || !!c.isTranscribing} onClick={() => transcribeClip(idx)}>
|
||||
{c.isTranscribing ? 'Transcribing…' : (c.transcript ? 'Retranscribe' : 'Transcribe')}
|
||||
</Button>
|
||||
|
||||
@ -39,6 +39,7 @@ export function usePostEditor(initialPostId?: string | null) {
|
||||
}));
|
||||
if (data.status) setPostStatus(data.status);
|
||||
setPromptText(data.prompt || '');
|
||||
if (Array.isArray(data.selectedImageKeys)) setGenImageKeys(data.selectedImageKeys);
|
||||
} catch {}
|
||||
})();
|
||||
}
|
||||
@ -55,6 +56,7 @@ export function usePostEditor(initialPostId?: string | null) {
|
||||
canonicalUrl: meta.canonicalUrl || undefined,
|
||||
status: postStatus,
|
||||
prompt: promptText || undefined,
|
||||
selectedImageKeys: genImageKeys.length > 0 ? genImageKeys : undefined,
|
||||
...(overrides || {}),
|
||||
};
|
||||
const data = await savePostApi(payload);
|
||||
@ -82,6 +84,14 @@ export function usePostEditor(initialPostId?: string | null) {
|
||||
tags,
|
||||
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' });
|
||||
};
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ export type SavePostPayload = {
|
||||
canonicalUrl?: string;
|
||||
status?: 'inbox' | 'editing' | 'ready_for_publish' | 'published' | 'archived';
|
||||
prompt?: string;
|
||||
selectedImageKeys?: string[];
|
||||
};
|
||||
|
||||
export async function getPost(id: string) {
|
||||
|
||||
3
apps/api/drizzle/0001_jittery_revanche.sql
Normal file
3
apps/api/drizzle/0001_jittery_revanche.sql
Normal 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;
|
||||
222
apps/api/drizzle/meta/0001_snapshot.json
Normal file
222
apps/api/drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,13 @@
|
||||
"when": 1761316101623,
|
||||
"tag": "0000_stiff_luckman",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "5",
|
||||
"when": 1761341118599,
|
||||
"tag": "0001_jittery_revanche",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -18,7 +18,10 @@ export const posts = mysqlTable('posts', {
|
||||
prompt: text('prompt'),
|
||||
status: postStatusEnum.notNull().default('editing'),
|
||||
ghostPostId: varchar('ghost_post_id', { length: 64 }),
|
||||
ghostSlug: varchar('ghost_slug', { length: 255 }),
|
||||
ghostPublishedAt: datetime('ghost_published_at', { fsp: 3 }),
|
||||
ghostUrl: text('ghost_url'),
|
||||
selectedImageKeys: text('selected_image_keys'),
|
||||
version: int('version').notNull().default(1),
|
||||
createdAt: datetime('created_at', { fsp: 3 }).notNull(),
|
||||
updatedAt: datetime('updated_at', { fsp: 3 }).notNull(),
|
||||
|
||||
@ -16,9 +16,27 @@ router.get('/list', async (
|
||||
try {
|
||||
const bucket = (req.query.bucket as string) || process.env.S3_BUCKET || '';
|
||||
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' });
|
||||
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) {
|
||||
console.error('List objects failed:', err);
|
||||
return res.status(500).json({ error: 'List failed' });
|
||||
|
||||
@ -69,6 +69,11 @@ router.get('/:id', async (req, res) => {
|
||||
canonicalUrl: posts.canonicalUrl,
|
||||
prompt: posts.prompt,
|
||||
status: posts.status,
|
||||
ghostPostId: posts.ghostPostId,
|
||||
ghostSlug: posts.ghostSlug,
|
||||
ghostPublishedAt: posts.ghostPublishedAt,
|
||||
ghostUrl: posts.ghostUrl,
|
||||
selectedImageKeys: posts.selectedImageKeys,
|
||||
createdAt: posts.createdAt,
|
||||
updatedAt: posts.updatedAt,
|
||||
version: posts.version,
|
||||
@ -94,7 +99,9 @@ router.get('/:id', async (req, res) => {
|
||||
.where(eq(audioClips.postId, id))
|
||||
.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) {
|
||||
console.error('Get post error:', err);
|
||||
return res.status(500).json({ error: 'Failed to get post' });
|
||||
@ -113,6 +120,11 @@ router.post('/', async (req, res) => {
|
||||
canonicalUrl,
|
||||
prompt,
|
||||
status,
|
||||
selectedImageKeys,
|
||||
ghostPostId,
|
||||
ghostSlug,
|
||||
ghostPublishedAt,
|
||||
ghostUrl,
|
||||
} = req.body as {
|
||||
id?: string;
|
||||
title?: string;
|
||||
@ -122,9 +134,15 @@ router.post('/', async (req, res) => {
|
||||
canonicalUrl?: string;
|
||||
prompt?: 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 selectedImageKeysJson = selectedImageKeys && Array.isArray(selectedImageKeys) ? JSON.stringify(selectedImageKeys) : null;
|
||||
|
||||
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,
|
||||
prompt: prompt ?? null as any,
|
||||
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,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
return res.json({ id: newId });
|
||||
} else {
|
||||
await db.update(posts).set({
|
||||
const updateData: any = {
|
||||
title: title ?? null as any,
|
||||
contentHtml,
|
||||
tagsText: tagsText ?? null as any,
|
||||
@ -156,7 +179,13 @@ router.post('/', async (req, res) => {
|
||||
prompt: prompt ?? null as any,
|
||||
status: (status as any) ?? 'editing',
|
||||
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 });
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user