feat(editor): load/save draft via /api/drafts with local fallback; mount drafts router; update PLAN

This commit is contained in:
Ender 2025-10-23 23:02:09 +02:00
parent 258464156b
commit 7f127bf721
4 changed files with 103 additions and 6 deletions

11
PLAN.md
View File

@ -72,9 +72,16 @@ Voice-first authoring tool for single-user Ghost blog. Capture audio, refine wit
- [x] Add STT trigger in UI: call `/api/stt` with `{ bucket, key }` and render transcript
## Next Priorities
- [ ] Save transcript into an editor document (draft state) and display in editor.
- [x] Save transcript into an editor document (draft state) and display in editor.
- [x] Add simple document persistence API (filesystem) at `/api/drafts` (list/get/save).
- [ ] Wire editor to use `/api/drafts` (load/save) instead of only localStorage.
- [ ] List uploaded media items and allow re-use/deletion.
- [ ] Add simple document persistence API (CRUD) and local storage autosave.
## Verification Steps
- [ ] Start API: `pnpm run dev -C apps/api`
- [ ] Start Admin: `pnpm run dev -C apps/admin`
- [ ] Record → Stop → Upload → Transcribe; see transcript populate Draft.
- [ ] Save Draft (local) and verify persistence on reload.
## MinIO Integration Checklist
- [ ] Deploy MinIO on VPS (console `:9001`, API `:9000`).

View File

@ -5,14 +5,43 @@ import { useEffect, useState } from 'react';
export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
const [draft, setDraft] = useState<string>('');
const [draftId, setDraftId] = useState<string | null>(null);
useEffect(() => {
const saved = localStorage.getItem('voxblog_draft');
if (saved) setDraft(saved);
const savedId = localStorage.getItem('voxblog_draft_id');
const savedLocal = localStorage.getItem('voxblog_draft');
if (savedLocal) setDraft(savedLocal);
if (savedId) {
(async () => {
try {
const res = await fetch(`/api/drafts/${savedId}`);
if (res.ok) {
const data = await res.json();
setDraft(data.content || '');
setDraftId(data.id || savedId);
}
} catch {}
})();
}
}, []);
const saveDraft = () => {
const saveDraft = async () => {
// Keep local fallback
localStorage.setItem('voxblog_draft', draft);
try {
const res = await fetch('/api/drafts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: draftId ?? undefined, content: draft }),
});
if (res.ok) {
const data = await res.json();
if (data.id) {
setDraftId(data.id);
localStorage.setItem('voxblog_draft_id', data.id);
}
}
} catch {}
};
return (
@ -32,7 +61,10 @@ export default function EditorShell({ onLogout }: { onLogout?: () => void }) {
onChange={(e) => setDraft(e.target.value)}
/>
<Stack direction="row" spacing={2} sx={{ mt: 1 }}>
<Button variant="contained" onClick={saveDraft}>Save Draft (local)</Button>
<Button variant="contained" onClick={saveDraft}>Save Draft</Button>
{draftId && (
<Typography variant="caption" sx={{ alignSelf: 'center' }}>ID: {draftId}</Typography>
)}
</Stack>
</Box>
</Box>

56
apps/api/src/drafts.ts Normal file
View File

@ -0,0 +1,56 @@
import express from 'express';
import path from 'path';
import { promises as fs } from 'fs';
import crypto from 'crypto';
const router = express.Router();
const draftsDir = path.resolve(__dirname, '../../../data/drafts');
async function ensureDir() {
await fs.mkdir(draftsDir, { recursive: true });
}
router.get('/', async (_req, res) => {
try {
await ensureDir();
const files = await fs.readdir(draftsDir);
const items = files.filter(f => f.endsWith('.json')).map(f => f.replace(/\.json$/, ''));
res.json({ items });
} catch (err) {
console.error('List drafts error:', err);
res.status(500).json({ error: 'Failed to list drafts' });
}
});
router.get('/:id', async (req, res) => {
try {
await ensureDir();
const id = req.params.id;
const file = path.join(draftsDir, `${id}.json`);
const content = await fs.readFile(file, 'utf8');
res.json(JSON.parse(content));
} catch (err) {
console.error('Read draft error:', err);
res.status(404).json({ error: 'Draft not found' });
}
});
router.post('/', async (req, res) => {
try {
await ensureDir();
const { id, content } = req.body as { id?: string; content?: string };
if (typeof content !== 'string') {
return res.status(400).json({ error: 'content is required' });
}
const draftId = id || crypto.randomUUID();
const file = path.join(draftsDir, `${draftId}.json`);
const payload = { id: draftId, content, updatedAt: new Date().toISOString() };
await fs.writeFile(file, JSON.stringify(payload, null, 2), 'utf8');
res.json({ success: true, id: draftId });
} catch (err) {
console.error('Save draft error:', err);
res.status(500).json({ error: 'Failed to save draft' });
}
});
export default router;

View File

@ -6,6 +6,7 @@ import cors from 'cors';
import authRouter from './auth';
import mediaRouter from './media';
import sttRouter from './stt';
import draftsRouter from './drafts';
const app = express();
console.log('ENV ADMIN_PASSWORD loaded:', Boolean(process.env.ADMIN_PASSWORD));
@ -21,6 +22,7 @@ app.use(express.json());
app.use('/api/auth', authRouter);
app.use('/api/media', mediaRouter);
app.use('/api/stt', sttRouter);
app.use('/api/drafts', draftsRouter);
app.get('/api/health', (_req, res) => {
res.json({ ok: true });
});