feat(editor): load/save draft via /api/drafts with local fallback; mount drafts router; update PLAN
This commit is contained in:
parent
258464156b
commit
7f127bf721
11
PLAN.md
11
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`).
|
||||
|
||||
@ -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
56
apps/api/src/drafts.ts
Normal 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;
|
||||
@ -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 });
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user