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:
parent
8777e30d86
commit
1f51504de9
2
.env.example
Normal file
2
.env.example
Normal 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
23
.gitignore
vendored
Normal 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
4
Caddyfile
Normal file
@ -0,0 +1,4 @@
|
||||
# Replace yourdomain.com with your actual domain
|
||||
yourdomain.com {
|
||||
reverse_proxy app:80
|
||||
}
|
||||
26
docker-compose.yml
Normal file
26
docker-compose.yml
Normal 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
1105
frontend/CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
12
frontend/Dockerfile
Normal file
12
frontend/Dockerfile
Normal 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
17
frontend/LICENSE.md
Normal 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
21
frontend/index.html
Normal 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
28
frontend/nginx.conf
Normal 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
2557
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/package.json
Normal file
28
frontend/package.json
Normal 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
4
frontend/public/icon.svg
Normal 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 |
17
frontend/public/manifest.json
Normal file
17
frontend/public/manifest.json
Normal 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
37
frontend/src/App.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
frontend/src/components/ChapterPanel.jsx
Normal file
59
frontend/src/components/ChapterPanel.jsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
41
frontend/src/components/ConfirmDialog.jsx
Normal file
41
frontend/src/components/ConfirmDialog.jsx
Normal 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) }
|
||||
79
frontend/src/components/Editor.jsx
Normal file
79
frontend/src/components/Editor.jsx
Normal 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
|
||||
43
frontend/src/components/ImageView.jsx
Normal file
43
frontend/src/components/ImageView.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
frontend/src/components/StoryCard.jsx
Normal file
41
frontend/src/components/StoryCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
frontend/src/components/ThemePicker.jsx
Normal file
51
frontend/src/components/ThemePicker.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
frontend/src/components/Toast.jsx
Normal file
26
frontend/src/components/Toast.jsx
Normal 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) }
|
||||
88
frontend/src/components/Toolbar.jsx
Normal file
88
frontend/src/components/Toolbar.jsx
Normal 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
78
frontend/src/lib/api.js
Normal 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 }),
|
||||
},
|
||||
}
|
||||
89
frontend/src/lib/export.js
Normal file
89
frontend/src/lib/export.js
Normal 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()
|
||||
})
|
||||
}
|
||||
31
frontend/src/lib/milestones.js
Normal file
31
frontend/src/lib/milestones.js
Normal 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
|
||||
}
|
||||
39
frontend/src/lib/streak.js
Normal file
39
frontend/src/lib/streak.js
Normal 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
|
||||
}
|
||||
10
frontend/src/lib/wordcount.js
Normal file
10
frontend/src/lib/wordcount.js
Normal 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
11
frontend/src/main.jsx
Normal 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>
|
||||
)
|
||||
147
frontend/src/pages/Admin.jsx
Normal file
147
frontend/src/pages/Admin.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
289
frontend/src/pages/EditorPage.jsx
Normal file
289
frontend/src/pages/EditorPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
frontend/src/pages/Login.jsx
Normal file
57
frontend/src/pages/Login.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
153
frontend/src/pages/Stories.jsx
Normal file
153
frontend/src/pages/Stories.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1247
frontend/src/styles/index.css
Normal file
1247
frontend/src/styles/index.css
Normal file
File diff suppressed because it is too large
Load Diff
12
frontend/vite.config.js
Normal file
12
frontend/vite.config.js
Normal 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
12
package.json
Normal 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
3
server/.dockerignore
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
data
|
||||
uploads
|
||||
10
server/Dockerfile
Normal file
10
server/Dockerfile
Normal 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
41
server/db.js
Normal 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
22
server/index.js
Normal 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
95
server/lib/epub.js
Normal 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
138
server/lib/odt.js
Normal file
@ -0,0 +1,138 @@
|
||||
import JSZip from 'jszip'
|
||||
|
||||
function esc(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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' })
|
||||
}
|
||||
80
server/lib/tiptap-to-html.js
Normal file
80
server/lib/tiptap-to-html.js
Normal file
@ -0,0 +1,80 @@
|
||||
function esc(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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
21
server/middleware/auth.js
Normal 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
2039
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
server/package.json
Normal file
18
server/package.json
Normal 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
37
server/routes/admin.js
Normal 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
26
server/routes/auth.js
Normal 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
44
server/routes/images.js
Normal 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
56
server/routes/prompts.js
Normal 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 10–14. 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
91
server/routes/stories.js
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user