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>
This commit is contained in:
chris 2026-05-11 11:47:55 -04:00
parent 8777e30d86
commit 1f51504de9
50 changed files with 9205 additions and 0 deletions

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
# Copy this to .env and fill in your values
# Not currently used — configuration is in Caddyfile and docker-compose.yml

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
node_modules/
# Docker volume data
server_data/
server_uploads/
caddy_data/
caddy_config/
# SQLite databases
*.db
*.db-shm
*.db-wal
server/data/
server/uploads/
# PocketBase leftovers
frontend/pb_data/
frontend/pocketbase
frontend/pocketbase_0.22.20_linux_amd64.zip
# Env / secrets
.env
*.log

4
Caddyfile Normal file
View File

@ -0,0 +1,4 @@
# Replace yourdomain.com with your actual domain
yourdomain.com {
reverse_proxy app:80
}

26
docker-compose.yml Normal file
View File

@ -0,0 +1,26 @@
services:
server:
build: ./server
restart: unless-stopped
volumes:
- ./server_data:/app/data
- ./server_uploads:/app/uploads
environment:
- JWT_SECRET=${JWT_SECRET:-change-me-in-production}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin}
- OLLAMA_URL=${OLLAMA_URL:-https://ai.binarygnome.com}
- OLLAMA_MODEL=${OLLAMA_MODEL:-gemma4:latest}
extra_hosts:
- "host-gateway:host-gateway"
app:
build: ./frontend
restart: unless-stopped
depends_on:
- server
ports:
- "127.0.0.1:3080:80"
volumes:
server_data:
server_uploads:

1105
frontend/CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

12
frontend/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

17
frontend/LICENSE.md Normal file
View File

@ -0,0 +1,17 @@
The MIT License (MIT)
Copyright (c) 2022 - present, Gani Georgiev
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
and associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or
substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

21
frontend/index.html Normal file
View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#1a1814" />
<meta name="description" content="Write your stories, anywhere." />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=IM+Fell+English:ital@0;1&family=Special+Elite&display=swap" rel="stylesheet" />
<link rel="manifest" href="/manifest.json" />
<title>Story Writer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

28
frontend/nginx.conf Normal file
View File

@ -0,0 +1,28 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
resolver 127.0.0.11 valid=30s;
location /api/ {
set $backend http://server:3000;
proxy_pass $backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
client_max_body_size 20M;
}
location /uploads/ {
set $backend http://server:3000;
proxy_pass $backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
}

2557
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
frontend/package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "story-writer",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@tiptap/core": "^2.4.0",
"@tiptap/extension-character-count": "^2.4.0",
"@tiptap/extension-image": "^2.4.0",
"@tiptap/extension-placeholder": "^2.4.0",
"@tiptap/extension-highlight": "^2.4.0",
"@tiptap/extension-text-align": "^2.4.0",
"@tiptap/extension-underline": "^2.4.0",
"@tiptap/react": "^2.4.0",
"@tiptap/starter-kit": "^2.4.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.24.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.0",
"vite": "^5.3.0"
}
}

4
frontend/public/icon.svg Normal file
View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" fill="#1a1814"/>
<text x="50" y="70" font-size="60" text-anchor="middle" fill="#c8941a" font-family="serif"></text>
</svg>

After

Width:  |  Height:  |  Size: 224 B

View File

@ -0,0 +1,17 @@
{
"name": "Story Writer",
"short_name": "Stories",
"description": "Write your stories, anywhere.",
"start_url": "/",
"display": "standalone",
"background_color": "#1a1814",
"theme_color": "#1a1814",
"icons": [
{
"src": "/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

37
frontend/src/App.jsx Normal file
View File

@ -0,0 +1,37 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { useState, useEffect } from 'react'
import { getUser } from './lib/api'
import { ToastProvider } from './components/Toast'
import { ConfirmProvider } from './components/ConfirmDialog'
import Login from './pages/Login'
import Stories from './pages/Stories'
import EditorPage from './pages/EditorPage'
import Admin from './pages/Admin'
import ThemePicker from './components/ThemePicker'
const THEMES = ['grunge', 'gothic', 'forest', 'manuscript', 'noir']
export default function App() {
const [user, setUser] = useState(() => getUser())
const [theme, setTheme] = useState(() => localStorage.getItem('sw-theme') || 'grunge')
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem('sw-theme', theme)
}, [theme])
return (
<ToastProvider>
<ConfirmProvider>
<Routes>
<Route path="/login" element={user ? <Navigate to="/" /> : <Login onLogin={setUser} />} />
<Route path="/admin" element={<Admin />} />
<Route path="/" element={user ? <Stories onLogout={() => setUser(null)} /> : <Navigate to="/login" />} />
<Route path="/story/:id" element={user ? <EditorPage /> : <Navigate to="/login" />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
{user && <ThemePicker theme={theme} setTheme={setTheme} themes={THEMES} />}
</ConfirmProvider>
</ToastProvider>
)
}

View File

@ -0,0 +1,59 @@
import { useMemo, useEffect, useRef } from 'react'
function extractChapters(content) {
const chapters = []
for (const node of (content?.content || [])) {
if (node.type === 'heading' && node.attrs?.level === 1) {
chapters.push((node.content || []).map(n => n.text || '').join('') || 'Untitled Chapter')
}
}
return chapters
}
export default function ChapterPanel({ content, open, onClose }) {
const chapters = useMemo(() => extractChapters(content), [content])
const panelRef = useRef()
useEffect(() => {
if (!open) return
function handleKey(e) { if (e.key === 'Escape') onClose() }
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [open, onClose])
function scrollToChapter(index) {
const headings = document.querySelectorAll('.editor-body h1')
if (headings[index]) {
headings[index].scrollIntoView({ behavior: 'smooth', block: 'start' })
if (window.innerWidth < 768) onClose()
}
}
return (
<>
{open && <div className="panel-backdrop" onClick={onClose} />}
<aside className={`chapter-panel${open ? ' open' : ''}`} ref={panelRef} aria-label="Chapters">
<div className="chapter-panel-header">
<span className="chapter-panel-title">Chapters</span>
<button className="chapter-panel-close" onClick={onClose} aria-label="Close">×</button>
</div>
{chapters.length === 0 ? (
<p className="chapter-panel-empty">
Use the <strong>Ch.</strong> button to add chapter headings and they'll appear here.
</p>
) : (
<ol className="chapter-list">
{chapters.map((title, i) => (
<li key={i}>
<button className="chapter-btn" onClick={() => scrollToChapter(i)}>
<span className="chapter-num">Ch. {i + 1}</span>
<span className="chapter-name">{title}</span>
</button>
</li>
))}
</ol>
)}
</aside>
</>
)
}

View File

@ -0,0 +1,41 @@
import { createContext, useContext, useState, useCallback } from 'react'
const Ctx = createContext(null)
export function ConfirmProvider({ children }) {
const [state, setState] = useState(null)
const confirm = useCallback((message, opts = {}) =>
new Promise(resolve => setState({ message, opts, resolve }))
, [])
function resolve(val) { state?.resolve(val); setState(null) }
return (
<Ctx.Provider value={confirm}>
{children}
{state && (
<div className="confirm-backdrop" onClick={() => resolve(false)}>
<div className="confirm-box" onClick={e => e.stopPropagation()} role="dialog" aria-modal>
<p className="confirm-message">{state.message}</p>
<div className="confirm-actions">
<button className="btn btn-ghost" onClick={() => resolve(false)}>
{state.opts.cancelLabel || 'Cancel'}
</button>
<button
className="btn btn-primary"
style={state.opts.danger ? { background: 'var(--danger)', borderColor: 'var(--danger)' } : {}}
onClick={() => resolve(true)}
autoFocus
>
{state.opts.confirmLabel || 'OK'}
</button>
</div>
</div>
</div>
)}
</Ctx.Provider>
)
}
export function useConfirm() { return useContext(Ctx) }

View File

@ -0,0 +1,79 @@
import { forwardRef, useEffect, useRef, useImperativeHandle } from 'react'
import { useEditor, EditorContent, ReactNodeViewRenderer } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import Placeholder from '@tiptap/extension-placeholder'
import Underline from '@tiptap/extension-underline'
import CharacterCount from '@tiptap/extension-character-count'
import TextAlign from '@tiptap/extension-text-align'
import Highlight from '@tiptap/extension-highlight'
import Toolbar from './Toolbar'
import ImageView from './ImageView'
const CustomImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
width: { default: '100%' },
align: { default: 'center' },
}
},
addNodeView() {
return ReactNodeViewRenderer(ImageView)
},
}).configure({ allowBase64: false, inline: false })
const Editor = forwardRef(function Editor({ content, onChange, onImageUpload, fontSize, onFontSizeChange }, ref) {
const synced = useRef(false)
const editor = useEditor({
extensions: [
StarterKit,
Underline,
CustomImage,
Placeholder.configure({ placeholder: 'Begin your story here…' }),
CharacterCount,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Highlight.configure({ multicolor: true }),
],
content: '',
onUpdate({ editor }) {
onChange(editor.getJSON())
},
})
useImperativeHandle(ref, () => ({
insertPrompt: (text) => {
if (!editor) return
editor.chain().focus().insertContent({
type: 'blockquote',
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
}).run()
},
}))
useEffect(() => {
if (editor && !synced.current) {
const hasContent = content && Object.keys(content).length > 0
if (hasContent) editor.commands.setContent(content, false)
synced.current = true
}
}, [editor, content])
const wordCount = editor?.storage.characterCount.words() ?? 0
return (
<div className="editor-wrap" style={{ '--editor-font-size': (fontSize || 17) + 'px' }}>
<Toolbar
editor={editor}
onImageUpload={onImageUpload}
fontSize={fontSize}
onFontSizeChange={onFontSizeChange}
/>
<EditorContent editor={editor} className="editor-body" />
<div className="word-count">{wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'}</div>
</div>
)
})
export default Editor

View File

@ -0,0 +1,43 @@
import { NodeViewWrapper } from '@tiptap/react'
const SIZES = ['25%', '50%', '75%', '100%']
export default function ImageView({ node, updateAttributes, selected }) {
const { src, alt, width = '100%', align = 'center' } = node.attrs
const imgStyle = {
width,
display: 'block',
marginLeft: align === 'left' ? '0' : 'auto',
marginRight: align === 'right' ? '0' : 'auto',
}
return (
<NodeViewWrapper className="image-view-wrap">
<img src={src} alt={alt || ''} style={imgStyle} draggable="false" />
{selected && (
<div className="image-controls" contentEditable={false}>
<div className="image-ctrl-group">
{SIZES.map(w => (
<button
key={w}
className={`img-ctrl-btn${width === w ? ' active' : ''}`}
onMouseDown={e => { e.preventDefault(); updateAttributes({ width: w }) }}
>{w}</button>
))}
</div>
<span className="image-ctrl-sep" />
<div className="image-ctrl-group">
{[['left','←'],['center','↔'],['right','→']].map(([a, label]) => (
<button
key={a}
className={`img-ctrl-btn${align === a ? ' active' : ''}`}
onMouseDown={e => { e.preventDefault(); updateAttributes({ align: a }) }}
>{label}</button>
))}
</div>
</div>
)}
</NodeViewWrapper>
)
}

View File

@ -0,0 +1,41 @@
function wordCount(content) {
if (!content || typeof content !== 'object') return 0
const text = JSON.stringify(content).replace(/"[^"]*":/g, ' ').replace(/[^a-zA-Z\s]/g, ' ')
return text.trim().split(/\s+/).filter(w => w.length > 1).length
}
function timeAgo(dateStr) {
const diff = Date.now() - new Date(dateStr)
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
const days = Math.floor(hrs / 24)
if (days < 7) return `${days}d ago`
return new Date(dateStr).toLocaleDateString()
}
export default function StoryCard({ story, onClick, onDelete }) {
const coverUrl = story.cover_image || null
const words = wordCount(story.content)
return (
<div className="story-card" onClick={onClick} role="button" tabIndex={0}
onKeyDown={e => e.key === 'Enter' && onClick()}>
{coverUrl
? <img src={coverUrl} alt="" className="story-card-cover" />
: <div className="story-card-placeholder" />}
<div className="story-card-body">
<h2 className="story-card-title">{story.title}</h2>
<p className="story-card-meta">{words} words · {timeAgo(story.updated_at)}</p>
<button
className="story-card-delete"
onClick={onDelete}
title="Delete story"
aria-label="Delete story"
>×</button>
</div>
</div>
)
}

View File

@ -0,0 +1,51 @@
import { useState } from 'react'
const THEMES = {
grunge: { label: 'Midnight', bg: '#09090f', accent: '#c8901a', dot: '#ddd8f2' },
gothic: { label: 'Gothic Night', bg: '#0f0810', accent: '#c4244e', dot: '#d4b8d8' },
forest: { label: 'Enchanted Forest', bg: '#080e0a', accent: '#3d9e50', dot: '#a8c4a0' },
manuscript: { label: 'Aged Manuscript', bg: '#f4e8d0', accent: '#8b4513', dot: '#2a1f0e' },
noir: { label: 'Neon Noir', bg: '#080808', accent: '#9933ff', dot: '#e0e0e0' },
}
export default function ThemePicker({ theme, setTheme, themes }) {
const [open, setOpen] = useState(false)
return (
<div className="theme-picker">
<button
className="theme-toggle"
onClick={() => setOpen(o => !o)}
title="Change theme"
aria-label="Change theme"
aria-expanded={open}
>
🎨
</button>
{open && (
<div className="theme-menu" role="listbox" aria-label="Themes">
{themes.map(t => {
const { label, bg, accent, dot } = THEMES[t]
return (
<button
key={t}
role="option"
aria-selected={theme === t}
className={`theme-option${theme === t ? ' active' : ''}`}
onClick={() => { setTheme(t); setOpen(false) }}
>
<span
className="theme-swatch"
style={{ background: bg, border: `2px solid ${accent}` }}
>
<span style={{ color: dot }}>Aa</span>
</span>
<span className="theme-label">{label}</span>
</button>
)
})}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,26 @@
import { createContext, useContext, useState, useCallback } from 'react'
const Ctx = createContext(null)
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([])
const addToast = useCallback((message, type = 'info') => {
const id = Date.now() + Math.random()
setToasts(p => [...p, { id, message, type }])
setTimeout(() => setToasts(p => p.filter(t => t.id !== id)), 4000)
}, [])
return (
<Ctx.Provider value={addToast}>
{children}
<div className="toast-container" aria-live="polite">
{toasts.map(t => (
<div key={t.id} className={`toast toast--${t.type}`}>{t.message}</div>
))}
</div>
</Ctx.Provider>
)
}
export function useToast() { return useContext(Ctx) }

View File

@ -0,0 +1,88 @@
import { useRef } from 'react'
export default function Toolbar({ editor, onImageUpload, fontSize, onFontSizeChange }) {
const fileRef = useRef()
if (!editor) return null
async function handleImageFile(e) {
const file = e.target.files[0]
if (!file) return
e.target.value = ''
try {
const url = await onImageUpload(file)
editor.chain().focus().setImage({ src: url }).run()
} catch {
alert('Image upload failed. Try again.')
}
}
function tb(label, action, isActive, title) {
return (
<button
key={label}
className={`toolbar-btn${isActive ? ' active' : ''}`}
onMouseDown={e => { e.preventDefault(); action() }}
title={title || label}
>
{label}
</button>
)
}
return (
<div className="toolbar" role="toolbar" aria-label="Formatting">
{tb('Ch.', () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
editor.isActive('heading', { level: 1 }), 'Chapter heading')}
{tb('H2', () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
editor.isActive('heading', { level: 2 }), 'Section heading')}
{tb('H3', () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
editor.isActive('heading', { level: 3 }), 'Sub-heading')}
<span className="toolbar-sep" />
{tb('B', () => editor.chain().focus().toggleBold().run(), editor.isActive('bold'), 'Bold (Ctrl+B)')}
{tb('I', () => editor.chain().focus().toggleItalic().run(), editor.isActive('italic'), 'Italic (Ctrl+I)')}
{tb('U', () => editor.chain().focus().toggleUnderline().run(), editor.isActive('underline'), 'Underline (Ctrl+U)')}
{tb(<s>S</s>, () => editor.chain().focus().toggleStrike().run(), editor.isActive('strike'), 'Strikethrough')}
<span className="toolbar-sep" />
{tb('L', () => editor.chain().focus().setTextAlign('left').run(), editor.isActive({ textAlign: 'left' }), 'Align left')}
{tb('C', () => editor.chain().focus().setTextAlign('center').run(), editor.isActive({ textAlign: 'center' }), 'Align centre')}
{tb('R', () => editor.chain().focus().setTextAlign('right').run(), editor.isActive({ textAlign: 'right' }), 'Align right')}
<span className="toolbar-sep" />
{tb('•—', () => editor.chain().focus().toggleBulletList().run(), editor.isActive('bulletList'), 'Bullet list')}
{tb('1.', () => editor.chain().focus().toggleOrderedList().run(), editor.isActive('orderedList'), 'Numbered list')}
{tb('"…"', () => editor.chain().focus().toggleBlockquote().run(), editor.isActive('blockquote'), 'Quote block')}
<button className="toolbar-btn" onMouseDown={e => { e.preventDefault(); editor.chain().focus().setHorizontalRule().run() }} title="Scene break (✦ ✦ ✦)"></button>
<span className="toolbar-sep" />
<button className="toolbar-btn" onMouseDown={e => { e.preventDefault(); fileRef.current.click() }} title="Insert photo">
Photo
</button>
<input ref={fileRef} type="file" accept="image/*" onChange={handleImageFile} style={{ display: 'none' }} />
<span className="toolbar-sep" />
{['#fef08a', '#fda4af', '#93c5fd', '#86efac'].map(color => (
<button
key={color}
className={`toolbar-btn highlight-swatch${editor.isActive('highlight', { color }) ? ' active' : ''}`}
style={{ '--swatch': color }}
onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleHighlight({ color }).run() }}
title="Highlight"
/>
))}
<span className="toolbar-sep" />
<button className="toolbar-btn font-btn" onMouseDown={e => { e.preventDefault(); onFontSizeChange(-1) }} title="Smaller text">A</button>
<span className="toolbar-font-size">{fontSize}px</span>
<button className="toolbar-btn font-btn" onMouseDown={e => { e.preventDefault(); onFontSizeChange(+1) }} title="Larger text">A+</button>
</div>
)
}

78
frontend/src/lib/api.js Normal file
View File

@ -0,0 +1,78 @@
const TOKEN_KEY = 'sw-token'
export function getToken() { return localStorage.getItem(TOKEN_KEY) }
export function setToken(t) { localStorage.setItem(TOKEN_KEY, t) }
export function clearToken() { localStorage.removeItem(TOKEN_KEY) }
export function getUser() {
const token = getToken()
if (!token) return null
try {
const payload = JSON.parse(atob(token.split('.')[1]))
if (payload.exp * 1000 < Date.now()) { clearToken(); return null }
return payload
} catch { return null }
}
async function req(method, url, body, extraHeaders = {}) {
const token = getToken()
const isForm = body instanceof FormData
const headers = {
...(!isForm && body ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...extraHeaders,
}
const res = await fetch(url, {
method,
headers,
body: isForm ? body : body ? JSON.stringify(body) : undefined,
})
const data = await res.json().catch(() => ({}))
if (!res.ok) throw Object.assign(new Error(data.error || 'Request failed'), { status: res.status })
return data
}
async function download(url) {
const token = getToken()
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
if (!res.ok) throw new Error('Export failed')
const blob = await res.blob()
const fname = res.headers.get('content-disposition')?.match(/filename="([^"]+)"/)?.[1] || 'export'
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = fname
a.click()
URL.revokeObjectURL(a.href)
}
export const api = {
login: async (email, password) => {
const data = await req('POST', '/api/auth/login', { email, password })
setToken(data.token)
return data.user
},
logout: clearToken,
getStories: () => req('GET', '/api/stories'),
getStory: (id) => req('GET', `/api/stories/${id}`),
createStory: (data) => req('POST', '/api/stories', data),
updateStory: (id, data) => req('PUT', `/api/stories/${id}`, data),
deleteStory: (id) => req('DELETE', `/api/stories/${id}`),
uploadImage: async (file) => {
const form = new FormData()
form.append('file', file)
const data = await req('POST', '/api/images', form)
return data.url
},
getPrompt: () => req('GET', '/api/prompts'),
exportEpub: (id) => download(`/api/stories/${id}/export/epub`),
exportOdt: (id) => download(`/api/stories/${id}/export/odt`),
admin: {
getUsers: (pw) => req('GET', '/api/admin/users', null, { 'x-admin-password': pw }),
createUser: (pw, data) => req('POST', '/api/admin/users', data, { 'x-admin-password': pw }),
deleteUser: (pw, id) => req('DELETE', `/api/admin/users/${id}`, null, { 'x-admin-password': pw }),
},
}

View File

@ -0,0 +1,89 @@
import { generateHTML } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
import Image from '@tiptap/extension-image'
import TextAlign from '@tiptap/extension-text-align'
import Highlight from '@tiptap/extension-highlight'
const EXTENSIONS = [
StarterKit,
Underline,
Image,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Highlight.configure({ multicolor: true }),
]
const PRINT_CSS = `
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Georgia, "Times New Roman", serif;
font-size: 12pt;
line-height: 1.8;
color: #111;
max-width: 6.5in;
margin: 0 auto;
padding: 1in;
}
h1 {
font-size: 18pt;
margin-top: 2em;
margin-bottom: 0.5em;
page-break-before: always;
padding-bottom: 0.25em;
border-bottom: 1px solid #ccc;
}
h1.story-title { page-break-before: avoid; }
h2 { font-size: 14pt; margin-top: 1.5em; margin-bottom: 0.4em; }
h3 { font-size: 12pt; font-style: italic; margin-top: 1em; margin-bottom: 0.3em; }
p { margin-bottom: 0.4em; text-indent: 1.5em; }
h1+p, h2+p, h3+p { text-indent: 0; }
blockquote {
border-left: 3px solid #aaa;
padding-left: 1em;
margin: 1em 0;
font-style: italic;
color: #444;
}
img { max-width: 100%; }
ul, ol { padding-left: 1.5em; margin: 0.5em 0; }
hr { border: none; text-align: center; margin: 1.5em 0; }
hr::after { content: '✦ ✦ ✦'; letter-spacing: 0.5em; color: #888; font-size: 10pt; }
mark { border-radius: 2px; padding: 0.05em 0.15em; }
s { opacity: 0.6; }
@page { margin: 1in; }
@media print {
body { padding: 0; max-width: 100%; }
}
`
export function printStory(title, content) {
let html = ''
if (content && Object.keys(content).length > 0) {
try {
html = generateHTML(content, EXTENSIONS)
} catch {
html = '<p>(Could not render content)</p>'
}
}
const win = window.open('', '_blank')
if (!win) { alert('Allow pop-ups to export as PDF.'); return }
win.document.write(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>${title}</title>
<style>${PRINT_CSS}</style>
</head>
<body>
<h1 class="story-title">${title}</h1>
${html}
</body>
</html>`)
win.document.close()
win.addEventListener('load', () => {
win.focus()
win.print()
})
}

View File

@ -0,0 +1,31 @@
const KEY = 'sw-milestones'
const LEVELS = [500, 1000, 5000, 10000, 25000, 50000, 100000]
const LABELS = {
500: '500 words — you\'re off!',
1000: '1,000 words — a great start!',
5000: '5,000 words — keep going!',
10000: '10,000 words — you\'re flying!',
25000: '25,000 words — incredible!',
50000: '50,000 words — a whole novel!',
100000: '100,000 words — legendary!',
}
function load() {
try { return JSON.parse(localStorage.getItem(KEY)) || {} }
catch { return {} }
}
export function checkMilestone(storyId, wordCount) {
const data = load()
const hit = new Set(data[storyId] || [])
for (const level of LEVELS) {
if (wordCount >= level && !hit.has(level)) {
hit.add(level)
data[storyId] = [...hit]
localStorage.setItem(KEY, JSON.stringify(data))
return LABELS[level]
}
}
return null
}

View File

@ -0,0 +1,39 @@
const STREAK_KEY = 'sw-streak'
const GOAL_KEY = 'sw-goal'
function today() { return new Date().toISOString().slice(0, 10) }
function yesterday() { return new Date(Date.now() - 86400000).toISOString().slice(0, 10) }
export function getStreak() {
try { return JSON.parse(localStorage.getItem(STREAK_KEY)) || { lastDate: null, streak: 0 } }
catch { return { lastDate: null, streak: 0 } }
}
export function recordWrite() {
const t = today()
const s = getStreak()
if (s.lastDate === t) return s.streak
const next = { lastDate: t, streak: s.lastDate === yesterday() ? s.streak + 1 : 1 }
localStorage.setItem(STREAK_KEY, JSON.stringify(next))
return next.streak
}
export function getGoal() {
try {
const g = JSON.parse(localStorage.getItem(GOAL_KEY))
if (!g) return { target: 0, date: today(), count: 0 }
return g.date === today() ? g : { ...g, date: today(), count: 0 }
} catch { return { target: 0, date: today(), count: 0 } }
}
export function setGoalTarget(target) {
localStorage.setItem(GOAL_KEY, JSON.stringify({ ...getGoal(), target }))
}
export function addGoalWords(delta) {
if (delta <= 0) return 0
const g = getGoal()
const next = { ...g, date: today(), count: (g.count || 0) + delta }
localStorage.setItem(GOAL_KEY, JSON.stringify(next))
return next.count
}

View File

@ -0,0 +1,10 @@
function extractText(node) {
if (!node) return ''
if (node.type === 'text') return node.text || ''
return (node.content || []).map(extractText).join(' ')
}
export function countWords(content) {
if (!content || typeof content !== 'object') return 0
return extractText(content).trim().split(/\s+/).filter(w => w.length > 0).length
}

11
frontend/src/main.jsx Normal file
View File

@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './styles/index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<BrowserRouter>
<App />
</BrowserRouter>
)

View File

@ -0,0 +1,147 @@
import { useState } from 'react'
import { api } from '../lib/api'
export default function Admin() {
const [pw, setPw] = useState('')
const [authed, setAuthed] = useState(false)
const [users, setUsers] = useState([])
const [error, setError] = useState('')
const [form, setForm] = useState({ name: '', email: '', password: '' })
const [formError, setFormError] = useState('')
const [formSuccess, setFormSuccess] = useState('')
const [creating, setCreating] = useState(false)
async function login(e) {
e.preventDefault()
setError('')
try {
const list = await api.admin.getUsers(pw)
if (list.error) { setError('Wrong password'); return }
setUsers(list)
setAuthed(true)
} catch {
setError('Wrong password')
}
}
async function createUser(e) {
e.preventDefault()
setFormError('')
setFormSuccess('')
setCreating(true)
try {
const user = await api.admin.createUser(pw, form)
if (user.error) { setFormError(user.error); return }
setUsers(prev => [user, ...prev])
setForm({ name: '', email: '', password: '' })
setFormSuccess(`Account created for ${user.name}!`)
} catch (err) {
setFormError(err.message)
} finally {
setCreating(false)
}
}
async function deleteUser(id, name) {
if (!confirm(`Delete ${name}'s account and all their stories?`)) return
await api.admin.deleteUser(pw, id)
setUsers(prev => prev.filter(u => u.id !== id))
}
if (!authed) {
return (
<div className="login-page">
<div className="login-box">
<div className="login-quill">🔑</div>
<h1 className="login-title">Admin</h1>
<p className="login-sub">Enter admin password</p>
<form onSubmit={login} className="login-form">
<input
type="password"
placeholder="Admin password"
value={pw}
onChange={e => setPw(e.target.value)}
required
className="input"
autoComplete="current-password"
/>
{error && <p className="error-msg">{error}</p>}
<button type="submit" className="btn btn-primary">Unlock</button>
</form>
</div>
</div>
)
}
return (
<div className="stories-page">
<header className="stories-header">
<h1 className="page-title">Admin</h1>
<a href="/" className="btn btn-ghost"> Back to App</a>
</header>
<div className="admin-grid">
<section className="admin-card">
<h2 className="admin-section-title">Add Account</h2>
<form onSubmit={createUser} className="login-form">
<input
type="text"
placeholder="Name"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
required
className="input"
/>
<input
type="email"
placeholder="Email"
value={form.email}
onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
required
className="input"
/>
<input
type="password"
placeholder="Password"
value={form.password}
onChange={e => setForm(f => ({ ...f, password: e.target.value }))}
required
className="input"
/>
{formError && <p className="error-msg">{formError}</p>}
{formSuccess && <p className="success-msg">{formSuccess}</p>}
<button type="submit" className="btn btn-primary" disabled={creating}>
{creating ? 'Creating...' : 'Create Account'}
</button>
</form>
</section>
<section className="admin-card">
<h2 className="admin-section-title">Accounts ({users.length})</h2>
{users.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontStyle: 'italic' }}>No accounts yet.</p>
) : (
<ul className="user-list">
{users.map(u => (
<li key={u.id} className="user-row">
<div>
<strong>{u.name}</strong>
<span className="user-email">{u.email}</span>
</div>
<button
className="btn btn-ghost"
style={{ fontSize: '0.75rem', padding: '0.3rem 0.6rem', color: 'var(--danger)', borderColor: 'var(--danger)' }}
onClick={() => deleteUser(u.id, u.name)}
>
Delete
</button>
</li>
))}
</ul>
)}
</section>
</div>
</div>
)
}

View File

@ -0,0 +1,289 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { api } from '../lib/api'
import { printStory } from '../lib/export'
import { countWords } from '../lib/wordcount'
import { recordWrite, addGoalWords } from '../lib/streak'
import { checkMilestone } from '../lib/milestones'
import { useToast } from '../components/Toast'
import { useConfirm } from '../components/ConfirmDialog'
import Editor from '../components/Editor'
import ChapterPanel from '../components/ChapterPanel'
const MIN_FONT = 13
const MAX_FONT = 26
export default function EditorPage() {
const { id } = useParams()
const navigate = useNavigate()
const toast = useToast()
const confirm = useConfirm()
const [story, setStory] = useState(null)
const [loadError, setLoadError] = useState(false)
const [title, setTitle] = useState('')
const [content, setContent] = useState({})
const [saveStatus, setSaveStatus] = useState('saved')
const [chapterOpen, setChapterOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
const [focusMode, setFocusMode] = useState(false)
const [promptText, setPromptText] = useState(null)
const [promptLoading, setPromptLoading] = useState(false)
const [fontSize, setFontSize] = useState(
() => Math.max(MIN_FONT, Math.min(MAX_FONT, parseInt(localStorage.getItem('sw-fontsize')) || 17))
)
const saveTimer = useRef(null)
const latestTitle = useRef('')
const latestContent = useRef({})
const prevWordCount = useRef(0)
const coverRef = useRef()
const editorRef = useRef()
const saveRef = useRef(null)
useEffect(() => {
api.getStory(id)
.then(s => {
setStory(s)
setTitle(s.title)
setContent(s.content || {})
latestTitle.current = s.title
latestContent.current = s.content || {}
prevWordCount.current = countWords(s.content)
})
.catch(() => setLoadError(true))
}, [id])
useEffect(() => {
localStorage.setItem('sw-fontsize', fontSize)
}, [fontSize])
const save = useCallback(async () => {
setSaveStatus('saving')
try {
await api.updateStory(id, { title: latestTitle.current, content: latestContent.current })
setSaveStatus('saved')
// Streak + goal tracking
const newStreak = recordWrite()
const wc = countWords(latestContent.current)
const delta = wc - prevWordCount.current
if (delta > 0) { addGoalWords(delta); prevWordCount.current = wc }
// Milestone check
const milestone = checkMilestone(id, wc)
if (milestone) toast(`🎉 ${milestone}`, 'success')
// Streak milestone
if (newStreak > 1 && newStreak % 7 === 0) toast(`🔥 ${newStreak} day writing streak!`, 'success')
} catch (err) {
console.error('Save failed:', err)
setSaveStatus('error')
toast('Could not save — check your connection', 'error')
}
}, [id, toast])
// Keep a ref so the keyboard shortcut always calls the latest save
useEffect(() => { saveRef.current = save }, [save])
function scheduleSave() {
setSaveStatus('unsaved')
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => saveRef.current?.(), 2000)
}
useEffect(() => () => clearTimeout(saveTimer.current), [])
// Keyboard shortcuts
useEffect(() => {
function onKey(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
clearTimeout(saveTimer.current)
saveRef.current?.()
}
if (e.key === 'Escape') {
setFocusMode(false)
setChapterOpen(false)
setExportOpen(false)
setPromptText(null)
}
if (e.key === 'F11' || (e.altKey && e.key === 'z')) {
e.preventDefault()
setFocusMode(f => !f)
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [])
function handleTitleChange(e) {
latestTitle.current = e.target.value
setTitle(e.target.value)
scheduleSave()
}
function handleContentChange(c) {
latestContent.current = c
setContent(c)
scheduleSave()
}
function changeFontSize(delta) {
setFontSize(f => Math.max(MIN_FONT, Math.min(MAX_FONT, f + delta)))
}
async function handleCoverUpload(e) {
const file = e.target.files[0]
if (!file) return
e.target.value = ''
try {
const url = await api.uploadImage(file)
setStory(prev => ({ ...prev, cover_image: url }))
await api.updateStory(id, { cover_image: url })
toast('Cover image updated')
} catch {
toast('Cover upload failed', 'error')
}
}
async function removeCover() {
const ok = await confirm('Remove the cover image?', { confirmLabel: 'Remove', danger: true })
if (!ok) return
setStory(prev => ({ ...prev, cover_image: null }))
await api.updateStory(id, { cover_image: null })
}
async function fetchPrompt() {
setPromptLoading(true)
setPromptText(null)
try {
const data = await api.getPrompt()
setPromptText(data.prompt)
} catch {
toast('Could not fetch a prompt', 'error')
} finally {
setPromptLoading(false)
}
}
function insertPrompt() {
if (!promptText) return
editorRef.current?.insertPrompt(promptText)
setPromptText(null)
toast('Prompt inserted as a quote block')
}
async function handleExport(format) {
setExportOpen(false)
if (format === 'pdf') { printStory(latestTitle.current || title, latestContent.current); return }
if (format === 'epub') { await api.exportEpub(id); return }
if (format === 'odt') { await api.exportOdt(id) }
}
function goBack() {
clearTimeout(saveTimer.current)
saveRef.current?.().finally(() => navigate('/'))
}
if (loadError) return (
<div className="loading">
Could not load story.{' '}
<button className="btn btn-ghost" style={{ marginLeft: '1rem' }} onClick={() => navigate('/')}>Go back</button>
</div>
)
if (!story) return <div className="loading">Opening story</div>
const statusLabel = { saved: 'Saved', saving: 'Saving…', unsaved: 'Unsaved', error: 'Error saving' }
return (
<div className={`editor-page${focusMode ? ' focus-mode' : ''}${chapterOpen ? ' panel-open' : ''}`}>
<ChapterPanel content={content} open={chapterOpen} onClose={() => setChapterOpen(false)} />
<div className="editor-main">
<div className="editor-topbar">
<div className="topbar-left">
<button onClick={goBack} className="btn btn-ghost back-btn"> Stories</button>
<button
className={`btn btn-ghost${chapterOpen ? ' active' : ''}`}
onClick={() => setChapterOpen(o => !o)}
title="Toggle chapter list"
> Chapters</button>
<button
className={`btn btn-ghost${focusMode ? ' active' : ''}`}
onClick={() => setFocusMode(f => !f)}
title="Focus mode (F11 or Alt+Z)"
>{focusMode ? '⊠ Exit Focus' : '⊡ Focus'}</button>
</div>
<div className="topbar-right">
<div className="prompt-wrap">
<button className="btn btn-ghost" onClick={fetchPrompt} disabled={promptLoading} title="Get a writing prompt">
{promptLoading ? '…' : '💡 Prompt'}
</button>
{promptText && (
<div className="prompt-popover">
<p className="prompt-text">{promptText}</p>
<div className="prompt-actions">
<button className="btn btn-ghost" onClick={fetchPrompt}> Another</button>
<button className="btn btn-primary" onClick={insertPrompt}>Insert</button>
<button className="btn btn-ghost" onClick={() => setPromptText(null)}>×</button>
</div>
</div>
)}
</div>
<span className={`save-status save-status--${saveStatus}`}>{statusLabel[saveStatus]}</span>
<div className="export-wrap">
<button className="btn btn-ghost" onClick={() => setExportOpen(o => !o)}>Export </button>
{exportOpen && (
<div className="export-menu">
<button onClick={() => handleExport('pdf')}>PDF (print)</button>
<button onClick={() => handleExport('epub')}>EPUB (.epub)</button>
<button onClick={() => handleExport('odt')}>Open Document (.odt)</button>
</div>
)}
</div>
</div>
</div>
<div className="editor-container">
<div className="cover-area">
{story.cover_image ? (
<div className="cover-preview">
<img src={story.cover_image} alt="Cover" className="cover-img" />
<div className="cover-actions">
<button className="btn btn-ghost cover-btn" onClick={() => coverRef.current.click()}>Change cover</button>
<button className="btn btn-ghost cover-btn" onClick={removeCover}>Remove</button>
</div>
</div>
) : (
<button className="cover-add" onClick={() => coverRef.current.click()}>+ Add cover image</button>
)}
<input ref={coverRef} type="file" accept="image/*" onChange={handleCoverUpload} style={{ display: 'none' }} />
</div>
<input
className="story-title-input"
value={title}
onChange={handleTitleChange}
placeholder="Story title…"
/>
<Editor
ref={editorRef}
content={story.content}
onChange={handleContentChange}
onImageUpload={api.uploadImage}
fontSize={fontSize}
onFontSizeChange={changeFontSize}
/>
</div>
</div>
{focusMode && (
<button className="focus-exit" onClick={() => setFocusMode(false)} title="Exit focus mode (Esc)">
Exit Focus
</button>
)}
</div>
)
}

View File

@ -0,0 +1,57 @@
import { useState } from 'react'
import { api } from '../lib/api'
export default function Login({ onLogin }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
setLoading(true)
setError('')
try {
const user = await api.login(email, password)
onLogin(user)
} catch (err) {
setError(err.message || 'Wrong email or password')
} finally {
setLoading(false)
}
}
return (
<div className="login-page">
<div className="login-box">
<div className="login-quill"></div>
<h1 className="login-title">Story Writer</h1>
<p className="login-sub">Your stories, your world</p>
<form onSubmit={handleSubmit} className="login-form">
<input
type="email"
placeholder="Email"
value={email}
onChange={e => setEmail(e.target.value)}
required
className="input"
autoComplete="email"
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={e => setPassword(e.target.value)}
required
className="input"
autoComplete="current-password"
/>
{error && <p className="error-msg">{error}</p>}
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Opening...' : 'Enter'}
</button>
</form>
</div>
</div>
)
}

View File

@ -0,0 +1,153 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../lib/api'
import { getStreak, getGoal, setGoalTarget } from '../lib/streak'
import { useConfirm } from '../components/ConfirmDialog'
import { useToast } from '../components/Toast'
import StoryCard from '../components/StoryCard'
export default function Stories({ onLogout }) {
const [stories, setStories] = useState([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [streak, setStreak] = useState(0)
const [goal, setGoal] = useState({ target: 0, count: 0 })
const [settingGoal, setSettingGoal] = useState(false)
const [goalInput, setGoalInput] = useState('')
const navigate = useNavigate()
const confirm = useConfirm()
const toast = useToast()
useEffect(() => {
api.getStories()
.then(setStories)
.catch(() => toast('Could not load stories', 'error'))
.finally(() => setLoading(false))
setStreak(getStreak().streak)
setGoal(getGoal())
}, [])
const visible = stories.filter(s =>
!search || s.title.toLowerCase().includes(search.toLowerCase())
)
async function newStory() {
const story = await api.createStory({ title: 'Untitled Story', content: {} })
navigate(`/story/${story.id}`)
}
async function deleteStory(e, id, title) {
e.stopPropagation()
const ok = await confirm(`Delete "${title}"? This cannot be undone.`, {
confirmLabel: 'Delete', cancelLabel: 'Keep it', danger: true
})
if (!ok) return
await api.deleteStory(id)
setStories(prev => prev.filter(s => s.id !== id))
toast('Story deleted')
}
function logout() { api.logout(); onLogout() }
function saveGoal(e) {
e.preventDefault()
const t = parseInt(goalInput)
if (!t || t < 1) return
setGoalTarget(t)
setGoal(g => ({ ...g, target: t }))
setSettingGoal(false)
toast(`Daily goal set to ${t} words`)
}
const goalPct = goal.target > 0 ? Math.min(100, Math.round((goal.count / goal.target) * 100)) : 0
return (
<div className="stories-page">
<header className="stories-header">
<h1 className="page-title">My Stories</h1>
<div className="header-actions">
<button onClick={newStory} className="btn btn-primary">+ New Story</button>
<button onClick={logout} className="btn btn-ghost">Sign Out</button>
</div>
</header>
{/* Stats bar */}
<div className="stats-bar">
{streak > 0 && (
<div className="stat-chip">
<span className="stat-icon">🔥</span>
<span>{streak} day streak</span>
</div>
)}
<div className="stat-chip goal-chip" onClick={() => { setGoalInput(goal.target || ''); setSettingGoal(true) }}>
{goal.target > 0 ? (
<>
<span className="stat-icon"></span>
<span>{goal.count} / {goal.target} words today</span>
<span className="goal-bar-wrap">
<span className="goal-bar" style={{ width: goalPct + '%' }} />
</span>
<span className="goal-pct">{goalPct}%</span>
</>
) : (
<span className="goal-set-cta">+ Set a daily writing goal</span>
)}
</div>
</div>
{settingGoal && (
<form className="goal-form" onSubmit={saveGoal}>
<label className="goal-label">Daily word goal:</label>
<input
type="number"
min="1"
className="input goal-input"
value={goalInput}
onChange={e => setGoalInput(e.target.value)}
placeholder="e.g. 500"
autoFocus
/>
<button type="submit" className="btn btn-primary">Set</button>
<button type="button" className="btn btn-ghost" onClick={() => setSettingGoal(false)}>Cancel</button>
</form>
)}
{/* Search */}
<div className="search-bar">
<input
type="search"
className="input search-input"
placeholder="Search stories…"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
{loading ? (
<div className="loading">Loading your stories</div>
) : visible.length === 0 && search ? (
<div className="empty-state">
<p>No stories match "{search}"</p>
<button className="btn btn-ghost" onClick={() => setSearch('')}>Clear search</button>
</div>
) : visible.length === 0 ? (
<div className="empty-state">
<span className="empty-quill">📖</span>
<p>No stories yet. Time to write one!</p>
<button onClick={newStory} className="btn btn-primary">Start Writing</button>
</div>
) : (
<div className="stories-grid">
{visible.map(story => (
<StoryCard
key={story.id}
story={story}
onClick={() => navigate(`/story/${story.id}`)}
onDelete={e => deleteStory(e, story.id, story.title)}
/>
))}
</div>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

12
frontend/vite.config.js Normal file
View File

@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:3000',
'/uploads': 'http://localhost:3000',
},
},
})

12
package.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "story-writer-dev",
"version": "1.0.0",
"private": true,
"scripts": {
"install:all": "npm install --prefix server && npm install --prefix frontend",
"dev": "npx concurrently -n server,frontend -c cyan,magenta \"npm run dev --prefix server\" \"npm run dev --prefix frontend\""
},
"devDependencies": {
"concurrently": "^8.2.2"
}
}

3
server/.dockerignore Normal file
View File

@ -0,0 +1,3 @@
node_modules
data
uploads

10
server/Dockerfile Normal file
View File

@ -0,0 +1,10 @@
FROM node:20-alpine
RUN apk add --no-cache python3 make g++ vips-dev
WORKDIR /app
COPY package*.json ./
ENV npm_config_build_from_source=true
RUN npm ci
COPY . .
RUN mkdir -p data uploads
EXPOSE 3000
CMD ["node", "index.js"]

41
server/db.js Normal file
View File

@ -0,0 +1,41 @@
import Database from 'better-sqlite3'
import { mkdirSync } from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const dataDir = path.join(__dirname, 'data')
mkdirSync(dataDir, { recursive: true })
const db = new Database(path.join(dataDir, 'stories.db'))
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL COLLATE NOCASE,
password TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS stories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT 'Untitled Story',
content TEXT DEFAULT '{}',
cover_image TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`)
export default db

22
server/index.js Normal file
View File

@ -0,0 +1,22 @@
import express from 'express'
import path from 'path'
import { fileURLToPath } from 'url'
import authRoutes from './routes/auth.js'
import storiesRoutes from './routes/stories.js'
import imagesRoutes from './routes/images.js'
import adminRoutes from './routes/admin.js'
import promptsRoutes from './routes/prompts.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const app = express()
app.use(express.json({ limit: '1mb' }))
app.use('/uploads', express.static(path.join(__dirname, 'uploads')))
app.use('/api/auth', authRoutes)
app.use('/api/stories', storiesRoutes)
app.use('/api/images', imagesRoutes)
app.use('/api/admin', adminRoutes)
app.use('/api/prompts', promptsRoutes)
app.listen(3000, () => console.log('Server ready on :3000'))

95
server/lib/epub.js Normal file
View File

@ -0,0 +1,95 @@
import JSZip from 'jszip'
import { splitByChapters } from './tiptap-to-html.js'
const EPUB_CSS = `
body{font-family:Georgia,"Times New Roman",serif;font-size:1em;line-height:1.8;max-width:34em;margin:0 auto;padding:1em 1.5em}
h1{font-size:1.5em;margin-top:2.5em;padding-bottom:0.3em;border-bottom:1px solid #ccc;page-break-before:always}
h1:first-of-type{page-break-before:avoid}
h2{font-size:1.2em;margin-top:1.5em}
h3{font-size:1.05em;font-style:italic}
p{margin:0.5em 0;text-indent:1.4em}
p:first-child,h1+p,h2+p,h3+p{text-indent:0}
blockquote{border-left:3px solid #aaa;padding-left:1em;margin:1em 0;font-style:italic;color:#444}
img{max-width:100%;display:block;margin:1em auto}
`
function xhtml(title, body) {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">
<head>
<meta charset="UTF-8"/>
<title>${title}</title>
<link rel="stylesheet" type="text/css" href="styles.css"/>
</head>
<body>
${body}
</body>
</html>`
}
export async function generateEpub(story) {
const zip = new JSZip()
const doc = typeof story.content === 'string' ? JSON.parse(story.content) : (story.content || {})
const title = story.title || 'Untitled'
const chapters = splitByChapters(doc, title)
const uid = `story-${story.id}`
zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' })
zip.file('META-INF/container.xml', `<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="EPUB/package.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`)
zip.file('EPUB/styles.css', EPUB_CSS.trim())
const items = []
const itemrefs = []
const navEntries = []
chapters.forEach((ch, i) => {
const n = String(i + 1).padStart(3, '0')
const fname = `ch${n}.xhtml`
zip.file(`EPUB/${fname}`, xhtml(ch.title, ch.html))
items.push(` <item id="c${n}" href="${fname}" media-type="application/xhtml+xml"/>`)
itemrefs.push(` <itemref idref="c${n}"/>`)
navEntries.push(` <li><a href="${fname}">${ch.title}</a></li>`)
})
zip.file('EPUB/nav.xhtml', `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" xml:lang="en">
<head><meta charset="UTF-8"/><title>Contents</title></head>
<body>
<nav epub:type="toc" id="toc">
<h1>Contents</h1>
<ol>
${navEntries.join('\n')}
</ol>
</nav>
</body>
</html>`)
zip.file('EPUB/package.opf', `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">${uid}</dc:identifier>
<dc:title>${title}</dc:title>
<dc:language>en</dc:language>
<meta property="dcterms:modified">${new Date().toISOString()}</meta>
</metadata>
<manifest>
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
<item id="css" href="styles.css" media-type="text/css"/>
${items.join('\n')}
</manifest>
<spine>
${itemrefs.join('\n')}
</spine>
</package>`)
return zip.generateAsync({ type: 'nodebuffer' })
}

138
server/lib/odt.js Normal file
View File

@ -0,0 +1,138 @@
import JSZip from 'jszip'
function esc(str) {
return String(str)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&apos;')
}
function nodeToOdt(node) {
if (!node) return ''
const ch = () => (node.content || []).map(nodeToOdt).join('')
switch (node.type) {
case 'doc': return ch()
case 'paragraph': return `<text:p text:style-name="Text_20_Body">${ch()}</text:p>\n`
case 'heading': {
const l = node.attrs?.level || 1
return `<text:h text:style-name="Heading_20_${l}" text:outline-level="${l}">${ch()}</text:h>\n`
}
case 'text': {
let t = esc(node.text || '')
const marks = node.marks || []
const bold = marks.some(m => m.type === 'bold')
const italic = marks.some(m => m.type === 'italic')
if (bold && italic) return `<text:span text:style-name="BoldItalic">${t}</text:span>`
if (bold) return `<text:span text:style-name="Strong_20_Emphasis">${t}</text:span>`
if (italic) return `<text:span text:style-name="Emphasis">${t}</text:span>`
return t
}
case 'bulletList': return (node.content || []).map(item =>
(item.content || []).map(p =>
`<text:p text:style-name="List_20_Bullet">${(p.content || []).map(nodeToOdt).join('')}</text:p>\n`
).join('')
).join('')
case 'orderedList': return (node.content || []).map(item =>
(item.content || []).map(p =>
`<text:p text:style-name="List_20_Number">${(p.content || []).map(nodeToOdt).join('')}</text:p>\n`
).join('')
).join('')
case 'blockquote': return (node.content || []).map(n =>
n.type === 'paragraph'
? `<text:p text:style-name="Quotations">${(n.content || []).map(nodeToOdt).join('')}</text:p>\n`
: nodeToOdt(n)
).join('')
case 'hardBreak': return '<text:line-break/>'
case 'image': return '' // ODT image embedding is complex — skipped
default: return ch()
}
}
const STYLES = `
<style:style style:name="Text_20_Body" style:display-name="Text Body" style:family="paragraph">
<style:paragraph-properties fo:margin-bottom="0.2cm" fo:line-height="150%"/>
<style:text-properties fo:font-size="12pt"/>
</style:style>
<style:style style:name="Heading_20_1" style:display-name="Heading 1" style:family="paragraph">
<style:paragraph-properties fo:margin-top="1.5cm" fo:margin-bottom="0.5cm" fo:break-before="page"/>
<style:text-properties fo:font-size="18pt" fo:font-weight="bold"/>
</style:style>
<style:style style:name="Heading_20_2" style:display-name="Heading 2" style:family="paragraph">
<style:paragraph-properties fo:margin-top="1cm" fo:margin-bottom="0.3cm"/>
<style:text-properties fo:font-size="14pt" fo:font-weight="bold"/>
</style:style>
<style:style style:name="Heading_20_3" style:display-name="Heading 3" style:family="paragraph">
<style:paragraph-properties fo:margin-top="0.8cm" fo:margin-bottom="0.2cm"/>
<style:text-properties fo:font-size="12pt" fo:font-weight="bold" fo:font-style="italic"/>
</style:style>
<style:style style:name="Quotations" style:display-name="Quotations" style:family="paragraph">
<style:paragraph-properties fo:margin-left="1cm" fo:margin-right="1cm"/>
<style:text-properties fo:font-style="italic"/>
</style:style>
<style:style style:name="List_20_Bullet" style:display-name="List Bullet" style:family="paragraph">
<style:paragraph-properties fo:margin-left="1cm"/>
</style:style>
<style:style style:name="List_20_Number" style:display-name="List Number" style:family="paragraph">
<style:paragraph-properties fo:margin-left="1cm"/>
</style:style>
<style:style style:name="Strong_20_Emphasis" style:display-name="Strong Emphasis" style:family="text">
<style:text-properties fo:font-weight="bold"/>
</style:style>
<style:style style:name="Emphasis" style:display-name="Emphasis" style:family="text">
<style:text-properties fo:font-style="italic"/>
</style:style>
<style:style style:name="BoldItalic" style:display-name="Bold Italic" style:family="text">
<style:text-properties fo:font-weight="bold" fo:font-style="italic"/>
</style:style>
`
export async function generateOdt(story) {
const zip = new JSZip()
const doc = typeof story.content === 'string' ? JSON.parse(story.content) : (story.content || {})
const title = story.title || 'Untitled'
const body = nodeToOdt(doc)
zip.file('mimetype', 'application/vnd.oasis.opendocument.text', { compression: 'STORE' })
zip.file('META-INF/manifest.xml', `<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.3">
<manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.text"/>
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>
</manifest:manifest>`)
zip.file('meta.xml', `<?xml version="1.0" encoding="UTF-8"?>
<office:document-meta xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:dc="http://purl.org/dc/elements/1.1/" office:version="1.3">
<office:meta>
<dc:title>${esc(title)}</dc:title>
</office:meta>
</office:document-meta>`)
zip.file('styles.xml', `<?xml version="1.0" encoding="UTF-8"?>
<office:document-styles
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
office:version="1.3">
<office:styles>${STYLES}</office:styles>
</office:document-styles>`)
zip.file('content.xml', `<?xml version="1.0" encoding="UTF-8"?>
<office:document-content
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
office:version="1.3">
<office:body>
<office:text>
<text:h text:style-name="Heading_20_1" text:outline-level="1">${esc(title)}</text:h>
${body} </office:text>
</office:body>
</office:document-content>`)
return zip.generateAsync({ type: 'nodebuffer' })
}

View File

@ -0,0 +1,80 @@
function esc(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
export function nodeToHtml(node) {
if (!node) return ''
const ch = () => (node.content || []).map(nodeToHtml).join('')
switch (node.type) {
case 'doc': return ch()
case 'paragraph': return `<p>${ch() || ' '}</p>\n`
case 'heading': {
const l = node.attrs?.level || 1
return `<h${l}>${ch()}</h${l}>\n`
}
case 'text': {
let t = esc(node.text || '')
for (const m of (node.marks || [])) {
if (m.type === 'bold') t = `<strong>${t}</strong>`
if (m.type === 'italic') t = `<em>${t}</em>`
if (m.type === 'underline') t = `<u>${t}</u>`
}
return t
}
case 'image': {
const { src = '', alt = '', width = '100%', align = 'center' } = node.attrs || {}
const ml = align === 'left' ? '0' : 'auto'
const mr = align === 'right' ? '0' : 'auto'
return `<img src="${esc(src)}" alt="${esc(alt)}" style="width:${esc(width)};display:block;margin-left:${ml};margin-right:${mr}"/>\n`
}
case 'bulletList': return `<ul>\n${ch()}</ul>\n`
case 'orderedList': return `<ol>\n${ch()}</ol>\n`
case 'listItem': return `<li>${ch()}</li>\n`
case 'blockquote': return `<blockquote>\n${ch()}</blockquote>\n`
case 'hardBreak': return '<br/>'
default: return ch()
}
}
export function extractChapters(doc) {
const chapters = []
for (const node of (doc?.content || [])) {
if (node.type === 'heading' && node.attrs?.level === 1) {
chapters.push((node.content || []).map(n => n.text || '').join('') || 'Chapter')
}
}
return chapters
}
// Splits the document into chapter sections at each H1 boundary
export function splitByChapters(doc, fallbackTitle = 'Story') {
const nodes = doc?.content || []
if (!nodes.length) return [{ title: fallbackTitle, html: '' }]
const groups = []
let current = { title: null, nodes: [] }
for (const node of nodes) {
if (node.type === 'heading' && node.attrs?.level === 1) {
if (current.nodes.length > 0 || current.title !== null) {
groups.push({ title: current.title || fallbackTitle, html: current.nodes.map(nodeToHtml).join('') })
}
current = {
title: (node.content || []).map(n => n.text || '').join('') || 'Chapter',
nodes: [node],
}
} else {
current.nodes.push(node)
}
}
if (current.nodes.length > 0 || current.title !== null) {
groups.push({ title: current.title || fallbackTitle, html: current.nodes.map(nodeToHtml).join('') })
}
return groups.length ? groups : [{ title: fallbackTitle, html: nodes.map(nodeToHtml).join('') }]
}

21
server/middleware/auth.js Normal file
View File

@ -0,0 +1,21 @@
import jwt from 'jsonwebtoken'
export const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production'
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin'
export function auth(req, res, next) {
const token = req.headers.authorization?.split(' ')[1]
if (!token) return res.status(401).json({ error: 'Not logged in' })
try {
req.user = jwt.verify(token, JWT_SECRET)
next()
} catch {
res.status(401).json({ error: 'Session expired, please log in again' })
}
}
export function adminAuth(req, res, next) {
const pw = req.headers['x-admin-password']
if (pw && pw === ADMIN_PASSWORD) return next()
res.status(401).json({ error: 'Wrong admin password' })
}

2039
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
server/package.json Normal file
View File

@ -0,0 +1,18 @@
{
"name": "story-writer-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^9.4.3",
"express": "^4.19.2",
"jszip": "^3.10.1",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.0",
"sharp": "^0.33.0"
}
}

37
server/routes/admin.js Normal file
View File

@ -0,0 +1,37 @@
import { Router } from 'express'
import bcrypt from 'bcryptjs'
import db from '../db.js'
import { adminAuth } from '../middleware/auth.js'
const router = Router()
router.use(adminAuth)
router.get('/users', (req, res) => {
const users = db.prepare(
'SELECT id, name, email, created_at FROM users ORDER BY created_at DESC'
).all()
res.json(users)
})
router.post('/users', (req, res) => {
const { name, email, password } = req.body || {}
if (!name || !email || !password)
return res.status(400).json({ error: 'Name, email, and password are all required' })
try {
const hash = bcrypt.hashSync(password, 10)
const { lastInsertRowid } = db.prepare(
'INSERT INTO users (name, email, password) VALUES (?, ?, ?)'
).run(name, email.toLowerCase(), hash)
res.json({ id: lastInsertRowid, name, email })
} catch {
res.status(400).json({ error: 'That email is already in use' })
}
})
router.delete('/users/:id', (req, res) => {
db.prepare('DELETE FROM users WHERE id = ?').run(req.params.id)
res.json({ ok: true })
})
export default router

26
server/routes/auth.js Normal file
View File

@ -0,0 +1,26 @@
import { Router } from 'express'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import db from '../db.js'
import { JWT_SECRET } from '../middleware/auth.js'
const router = Router()
router.post('/login', (req, res) => {
const { email, password } = req.body || {}
if (!email || !password) return res.status(400).json({ error: 'Email and password required' })
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email)
if (!user || !bcrypt.compareSync(password, user.password)) {
return res.status(401).json({ error: 'Wrong email or password' })
}
const token = jwt.sign(
{ id: user.id, email: user.email, name: user.name },
JWT_SECRET,
{ expiresIn: '30d' }
)
res.json({ token, user: { id: user.id, email: user.email, name: user.name } })
})
export default router

44
server/routes/images.js Normal file
View File

@ -0,0 +1,44 @@
import { Router } from 'express'
import multer from 'multer'
import sharp from 'sharp'
import path from 'path'
import { fileURLToPath } from 'url'
import { mkdirSync } from 'fs'
import db from '../db.js'
import { auth } from '../middleware/auth.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const uploadDir = path.join(__dirname, '..', 'uploads')
mkdirSync(uploadDir, { recursive: true })
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: (req, file, cb) =>
file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
})
const router = Router()
router.use(auth)
router.post('/', upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' })
const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.webp`
const outputPath = path.join(uploadDir, filename)
try {
await sharp(req.file.buffer)
.resize(2400, 2400, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: 88 })
.toFile(outputPath)
db.prepare('INSERT INTO images (user_id, filename) VALUES (?, ?)').run(req.user.id, filename)
res.json({ url: `/uploads/${filename}` })
} catch (err) {
console.error('Image processing error:', err)
res.status(500).json({ error: 'Image processing failed' })
}
})
export default router

56
server/routes/prompts.js Normal file
View File

@ -0,0 +1,56 @@
import { Router } from 'express'
import { auth } from '../middleware/auth.js'
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434'
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'llama3.2'
const FALLBACK = [
"A letter arrives addressed to you — dated ten years in the future. It says only: 'Don't go to the old lighthouse on Friday.'",
"Your new neighbour asks you to water one plant while they're away. On day two, it whispers your name.",
"Every mirror in the city goes dark overnight. Yours still shows a reflection — but it isn't yours.",
"You find your grandmother's diary from when she was your age. The last entry describes something happening to you. Today.",
"A bookshop appears on your street that wasn't there yesterday. Inside are books about your life — including things that haven't happened yet.",
"The last dragon in the world lands in your garden. It is very small and very scared.",
"You win a competition you never entered. The prize is one hour of flying.",
"Everyone in your town wakes up speaking a different language. Everyone except you.",
"There is a trapdoor under your bed that opens onto an upside-down forest.",
"Every story you write comes true — but always with one small, wrong detail.",
"The lighthouse has been dark for fifty years. The night you finally climb to the top, you find the lamp still warm.",
"Two kingdoms at war agree to peace. Each must send their most precious thing as a gift. Both kingdoms send the same person.",
"Your shadow starts leaving notes under your pillow.",
"The old tree at the centre of the village is cut down. Inside is a room. Inside the room is a child who has never seen the sky.",
"You are the only one who can hear the sea talking. It is furious about something.",
"A train arrives at your station that isn't on any timetable. The conductor says you have been expected.",
"You find a photograph of your house — taken fifty years before it was built.",
"The stars rearrange themselves every night. Last night you finally worked out what language they're writing in.",
"A girl discovers she can rewind the last ten seconds of any conversation — but only once per person.",
"The town clockmaker stops every clock at midnight. By morning, she is gone. So is yesterday.",
]
const router = Router()
router.use(auth)
router.get('/', async (req, res) => {
try {
const r = await fetch(`${OLLAMA_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: OLLAMA_MODEL,
prompt: 'Write one creative writing prompt for a young writer aged 1014. Make it imaginative, specific, and intriguing — a little mysterious or adventurous. Write only the prompt itself: no introduction, no explanation, no quotation marks. Maximum two sentences.',
stream: false,
}),
signal: AbortSignal.timeout(8000),
})
if (!r.ok) throw new Error('bad status')
const data = await r.json()
const prompt = data.response?.trim()
if (!prompt) throw new Error('empty')
res.json({ prompt, source: 'ollama', model: OLLAMA_MODEL })
} catch {
const prompt = FALLBACK[Math.floor(Math.random() * FALLBACK.length)]
res.json({ prompt, source: 'local' })
}
})
export default router

91
server/routes/stories.js Normal file
View File

@ -0,0 +1,91 @@
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