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
|
<AdminTopBar
|
||||||
userName={undefined}
|
userName={undefined}
|
||||||
onGoPosts={() => setSelectedPostId(null)}
|
onGoPosts={() => setSelectedPostId(null)}
|
||||||
onNewPost={createNewPost}
|
|
||||||
onSettings={undefined}
|
onSettings={undefined}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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' },
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
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 { 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
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,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -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(),
|
||||||
|
|||||||
@ -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' });
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user