- 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>
92 lines
3.9 KiB
JavaScript
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
|