diff --git a/PLAN.md b/PLAN.md index 19d80ea..b516d93 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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`). diff --git a/apps/admin/src/components/EditorShell.tsx b/apps/admin/src/components/EditorShell.tsx index 074101b..4c36955 100644 --- a/apps/admin/src/components/EditorShell.tsx +++ b/apps/admin/src/components/EditorShell.tsx @@ -5,14 +5,43 @@ import { useEffect, useState } from 'react'; export default function EditorShell({ onLogout }: { onLogout?: () => void }) { const [draft, setDraft] = useState(''); + const [draftId, setDraftId] = useState(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)} /> - + + {draftId && ( + ID: {draftId} + )} diff --git a/apps/api/src/drafts.ts b/apps/api/src/drafts.ts new file mode 100644 index 0000000..8f4aeda --- /dev/null +++ b/apps/api/src/drafts.ts @@ -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; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index b447d64..479448d 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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 }); });