write/server/routes/stories.js
chris 1f51504de9 Add story writer app with editor, auth, export, and polish features
- React + TipTap editor with formatting toolbar (bold, italic, underline,
  strikethrough, alignment, highlight, scene breaks)
- Custom image node view with resize and alignment controls; server-side
  WebP conversion via sharp
- Express + SQLite backend with JWT auth and admin user management
- Export to PDF, EPUB, and ODT
- Five themes (Midnight, Gothic Night, Enchanted Forest, Aged Manuscript,
  Neon Noir); Lora body font for readability
- Writing streak, daily word goal, milestones, and Ollama writing prompts
- Docker Compose setup for self-hosted deployment behind NPMplus

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 11:47:55 -04:00

92 lines
3.9 KiB
JavaScript

import { Router } from 'express'
import db from '../db.js'
import { auth } from '../middleware/auth.js'
import { generateEpub } from '../lib/epub.js'
import { generateOdt } from '../lib/odt.js'
const router = Router()
router.use(auth)
const parse = s => { try { return JSON.parse(s || '{}') } catch { return {} } }
const safe = s => JSON.stringify(s ?? {})
router.get('/', (req, res) => {
const rows = db.prepare(
'SELECT id, title, content, cover_image, updated_at, created_at FROM stories WHERE user_id = ? ORDER BY updated_at DESC'
).all(req.user.id)
res.json(rows.map(r => ({ ...r, content: parse(r.content) })))
})
router.get('/:id', (req, res) => {
const story = db.prepare(
'SELECT id, title, content, cover_image, updated_at, created_at FROM stories WHERE id = ? AND user_id = ?'
).get(req.params.id, req.user.id)
if (!story) return res.status(404).json({ error: 'Not found' })
res.json({ ...story, content: parse(story.content) })
})
router.post('/', (req, res) => {
const { title = 'Untitled Story', content = {} } = req.body || {}
const { lastInsertRowid } = db.prepare(
'INSERT INTO stories (user_id, title, content) VALUES (?, ?, ?)'
).run(req.user.id, title, safe(content))
const story = db.prepare('SELECT * FROM stories WHERE id = ?').get(lastInsertRowid)
res.json({ ...story, content: parse(story.content) })
})
router.put('/:id', (req, res) => {
const story = db.prepare('SELECT * FROM stories WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id)
if (!story) return res.status(404).json({ error: 'Not found' })
const { title, content, cover_image } = req.body || {}
db.prepare(
'UPDATE stories SET title = ?, content = ?, cover_image = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
).run(
title ?? story.title,
content !== undefined ? safe(content) : story.content,
cover_image !== undefined ? cover_image : story.cover_image,
req.params.id
)
const updated = db.prepare('SELECT * FROM stories WHERE id = ?').get(req.params.id)
res.json({ ...updated, content: parse(updated.content) })
})
router.delete('/:id', (req, res) => {
const story = db.prepare('SELECT * FROM stories WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id)
if (!story) return res.status(404).json({ error: 'Not found' })
db.prepare('DELETE FROM stories WHERE id = ?').run(req.params.id)
res.json({ ok: true })
})
// ── Exports ──────────────────────────────────────────────
router.get('/:id/export/epub', async (req, res) => {
const story = db.prepare('SELECT * FROM stories WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id)
if (!story) return res.status(404).json({ error: 'Not found' })
try {
const buf = await generateEpub({ ...story, content: parse(story.content) })
const fname = (story.title || 'story').replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '_') + '.epub'
res.set({ 'Content-Type': 'application/epub+zip', 'Content-Disposition': `attachment; filename="${fname}"` })
res.send(buf)
} catch (err) {
console.error('EPUB error:', err)
res.status(500).json({ error: 'Export failed' })
}
})
router.get('/:id/export/odt', async (req, res) => {
const story = db.prepare('SELECT * FROM stories WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id)
if (!story) return res.status(404).json({ error: 'Not found' })
try {
const buf = await generateOdt({ ...story, content: parse(story.content) })
const fname = (story.title || 'story').replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '_') + '.odt'
res.set({ 'Content-Type': 'application/vnd.oasis.opendocument.text', 'Content-Disposition': `attachment; filename="${fname}"` })
res.send(buf)
} catch (err) {
console.error('ODT error:', err)
res.status(500).json({ error: 'Export failed' })
}
})
export default router