Add notes panel, font picker, sticky toolbar, and prompt improvements
- Notes: per-story rich-text notes panel (right drawer) with TipTap editor, image support, autosave, and full CRUD API - Font picker: 15 Google Fonts selectable from a floating Aa button, persisted to localStorage via --font-body CSS variable - Sticky toolbar: pulled formatting bar out of overflow:hidden wrapper so it sticks below the topbar while scrolling - Prompts: 100 additional built-in prompts (120 total) in a shuffled no-repeat queue; pre-fetch on page load so the AI has time to respond; timeout raised to 45s; error logging + /api/prompts/test debug endpoint; source badge shows whether prompt came from AI or built-in list Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b7baf4fa15
commit
37448be5a8
@ -8,18 +8,26 @@ import Stories from './pages/Stories'
|
|||||||
import EditorPage from './pages/EditorPage'
|
import EditorPage from './pages/EditorPage'
|
||||||
import Admin from './pages/Admin'
|
import Admin from './pages/Admin'
|
||||||
import ThemePicker from './components/ThemePicker'
|
import ThemePicker from './components/ThemePicker'
|
||||||
|
import FontPicker, { FONTS } from './components/FontPicker'
|
||||||
|
|
||||||
const THEMES = ['grunge', 'gothic', 'forest', 'manuscript', 'noir']
|
const THEMES = ['grunge', 'gothic', 'forest', 'manuscript', 'noir']
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [user, setUser] = useState(() => getUser())
|
const [user, setUser] = useState(() => getUser())
|
||||||
const [theme, setTheme] = useState(() => localStorage.getItem('sw-theme') || 'grunge')
|
const [theme, setTheme] = useState(() => localStorage.getItem('sw-theme') || 'grunge')
|
||||||
|
const [fontId, setFontId] = useState(() => localStorage.getItem('sw-font') || 'lora')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', theme)
|
document.documentElement.setAttribute('data-theme', theme)
|
||||||
localStorage.setItem('sw-theme', theme)
|
localStorage.setItem('sw-theme', theme)
|
||||||
}, [theme])
|
}, [theme])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const font = FONTS.find(f => f.id === fontId) ?? FONTS[0]
|
||||||
|
document.documentElement.style.setProperty('--font-body', font.family)
|
||||||
|
localStorage.setItem('sw-font', fontId)
|
||||||
|
}, [fontId])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
@ -31,6 +39,7 @@ export default function App() {
|
|||||||
<Route path="*" element={<Navigate to="/" />} />
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
{user && <ThemePicker theme={theme} setTheme={setTheme} themes={THEMES} />}
|
{user && <ThemePicker theme={theme} setTheme={setTheme} themes={THEMES} />}
|
||||||
|
{user && <FontPicker fontId={fontId} setFont={setFontId} />}
|
||||||
</ConfirmProvider>
|
</ConfirmProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -63,16 +63,18 @@ const Editor = forwardRef(function Editor({ content, onChange, onImageUpload, fo
|
|||||||
const wordCount = editor?.storage.characterCount.words() ?? 0
|
const wordCount = editor?.storage.characterCount.words() ?? 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="editor-wrap" style={{ '--editor-font-size': (fontSize || 17) + 'px' }}>
|
<div className="editor-outer" style={{ '--editor-font-size': (fontSize || 17) + 'px' }}>
|
||||||
<Toolbar
|
<Toolbar
|
||||||
editor={editor}
|
editor={editor}
|
||||||
onImageUpload={onImageUpload}
|
onImageUpload={onImageUpload}
|
||||||
fontSize={fontSize}
|
fontSize={fontSize}
|
||||||
onFontSizeChange={onFontSizeChange}
|
onFontSizeChange={onFontSizeChange}
|
||||||
/>
|
/>
|
||||||
|
<div className="editor-wrap">
|
||||||
<EditorContent editor={editor} className="editor-body" />
|
<EditorContent editor={editor} className="editor-body" />
|
||||||
<div className="word-count">{wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'}</div>
|
<div className="word-count">{wordCount.toLocaleString()} {wordCount === 1 ? 'word' : 'words'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
64
frontend/src/components/FontPicker.jsx
Normal file
64
frontend/src/components/FontPicker.jsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export const FONTS = [
|
||||||
|
// ── Serif ──────────────────────────────────────────────────
|
||||||
|
{ id: 'lora', label: 'Lora', category: 'Serif', family: "'Lora', Georgia, serif" },
|
||||||
|
{ id: 'merriweather',label: 'Merriweather', category: 'Serif', family: "'Merriweather', Georgia, serif" },
|
||||||
|
{ id: 'playfair', label: 'Playfair Display', category: 'Serif', family: "'Playfair Display', 'Palatino Linotype', serif" },
|
||||||
|
{ id: 'garamond', label: 'EB Garamond', category: 'Serif', family: "'EB Garamond', 'Palatino Linotype', serif" },
|
||||||
|
{ id: 'baskerville', label: 'Libre Baskerville', category: 'Serif', family: "'Libre Baskerville', Georgia, serif" },
|
||||||
|
{ id: 'crimson', label: 'Crimson Text', category: 'Serif', family: "'Crimson Text', Georgia, serif" },
|
||||||
|
{ id: 'cormorant', label: 'Cormorant Garamond', category: 'Serif', family: "'Cormorant Garamond', 'Palatino Linotype', serif" },
|
||||||
|
{ id: 'pt-serif', label: 'PT Serif', category: 'Serif', family: "'PT Serif', Georgia, serif" },
|
||||||
|
{ id: 'literata', label: 'Literata', category: 'Serif', family: "'Literata', Georgia, serif" },
|
||||||
|
{ id: 'vollkorn', label: 'Vollkorn', category: 'Serif', family: "'Vollkorn', Georgia, serif" },
|
||||||
|
// ── Sans-serif ─────────────────────────────────────────────
|
||||||
|
{ id: 'inter', label: 'Inter', category: 'Sans', family: "'Inter', system-ui, sans-serif" },
|
||||||
|
{ id: 'source-sans', label: 'Source Sans 3', category: 'Sans', family: "'Source Sans 3', system-ui, sans-serif" },
|
||||||
|
{ id: 'nunito', label: 'Nunito', category: 'Sans', family: "'Nunito', system-ui, sans-serif" },
|
||||||
|
// ── Typewriter ─────────────────────────────────────────────
|
||||||
|
{ id: 'special-elite', label: 'Special Elite', category: 'Typewriter', family: "'Special Elite', 'Courier New', monospace" },
|
||||||
|
{ id: 'courier-prime', label: 'Courier Prime', category: 'Typewriter', family: "'Courier Prime', 'Courier New', monospace" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function FontPicker({ fontId, setFont }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
const current = FONTS.find(f => f.id === fontId) ?? FONTS[0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="font-picker">
|
||||||
|
<button
|
||||||
|
className="font-toggle"
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
title="Change writing font"
|
||||||
|
aria-label="Change writing font"
|
||||||
|
aria-expanded={open}
|
||||||
|
style={{ fontFamily: current.family }}
|
||||||
|
>
|
||||||
|
Aa
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="font-menu" role="listbox" aria-label="Writing fonts">
|
||||||
|
<p className="font-menu-label">Writing font</p>
|
||||||
|
{FONTS.map(f => (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
role="option"
|
||||||
|
aria-selected={fontId === f.id}
|
||||||
|
className={`font-option${fontId === f.id ? ' active' : ''}`}
|
||||||
|
onClick={() => { setFont(f.id); setOpen(false) }}
|
||||||
|
>
|
||||||
|
<span className="font-preview" style={{ fontFamily: f.family }}>Aa</span>
|
||||||
|
<span className="font-info">
|
||||||
|
<span className="font-name" style={{ fontFamily: f.family }}>{f.label}</span>
|
||||||
|
<span className="font-category">{f.category}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
115
frontend/src/components/NoteEditor.jsx
Normal file
115
frontend/src/components/NoteEditor.jsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { useEffect, useRef } 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 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 })
|
||||||
|
|
||||||
|
export default function NoteEditor({ content, onChange, onImageUpload }) {
|
||||||
|
const synced = useRef(false)
|
||||||
|
const fileRef = useRef()
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
extensions: [
|
||||||
|
StarterKit,
|
||||||
|
Underline,
|
||||||
|
CustomImage,
|
||||||
|
Placeholder.configure({ placeholder: 'Write your note here…' }),
|
||||||
|
],
|
||||||
|
content: '',
|
||||||
|
onUpdate({ editor }) {
|
||||||
|
onChange(editor.getJSON())
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set initial content once — same synced-ref pattern as the main Editor
|
||||||
|
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])
|
||||||
|
|
||||||
|
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={String(label)}
|
||||||
|
className={`toolbar-btn${isActive ? ' active' : ''}`}
|
||||||
|
onMouseDown={e => { e.preventDefault(); action() }}
|
||||||
|
title={title || String(label)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editor) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="note-editor-outer">
|
||||||
|
<div className="note-toolbar" role="toolbar" aria-label="Note formatting">
|
||||||
|
{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)')}
|
||||||
|
|
||||||
|
<span className="toolbar-sep" />
|
||||||
|
|
||||||
|
{tb('H2', () => editor.chain().focus().toggleHeading({ level: 2 }).run(), editor.isActive('heading', { level: 2 }), 'Heading')}
|
||||||
|
{tb('H3', () => editor.chain().focus().toggleHeading({ level: 3 }).run(), editor.isActive('heading', { level: 3 }), 'Sub-heading')}
|
||||||
|
|
||||||
|
<span className="toolbar-sep" />
|
||||||
|
|
||||||
|
{tb('•—', () => editor.chain().focus().toggleBulletList().run(), editor.isActive('bulletList'), 'Bullet list')}
|
||||||
|
{tb('"…"', () => editor.chain().focus().toggleBlockquote().run(), editor.isActive('blockquote'), 'Quote block')}
|
||||||
|
|
||||||
|
<span className="toolbar-sep" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="toolbar-btn"
|
||||||
|
onMouseDown={e => { e.preventDefault(); fileRef.current.click() }}
|
||||||
|
title="Insert image"
|
||||||
|
>
|
||||||
|
Photo
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageFile}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="note-editor-wrap">
|
||||||
|
<EditorContent editor={editor} className="note-editor-body" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
239
frontend/src/components/NotesPanel.jsx
Normal file
239
frontend/src/components/NotesPanel.jsx
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import NoteEditor from './NoteEditor'
|
||||||
|
import { useToast } from './Toast'
|
||||||
|
import { useConfirm } from './ConfirmDialog'
|
||||||
|
|
||||||
|
function formatDate(str) {
|
||||||
|
if (!str) return ''
|
||||||
|
return new Date(str).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract plain text from TipTap JSON for the list preview
|
||||||
|
function noteSnippet(content) {
|
||||||
|
function walk(nodes) {
|
||||||
|
return (nodes || []).flatMap(n => n.text ? [n.text] : walk(n.content))
|
||||||
|
}
|
||||||
|
const text = walk(content?.content || []).join(' ').trim()
|
||||||
|
return text ? text.slice(0, 90) + (text.length > 90 ? '…' : '') : 'No content yet'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotesPanel({ storyId, open, onClose }) {
|
||||||
|
const toast = useToast()
|
||||||
|
const confirm = useConfirm()
|
||||||
|
|
||||||
|
const [notes, setNotes] = useState([])
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
const [activeNote, setActiveNote] = useState(null) // null = list view
|
||||||
|
const [noteTitle, setNoteTitle] = useState('')
|
||||||
|
|
||||||
|
// Refs hold the in-flight content so save callbacks don't go stale
|
||||||
|
const latestTitle = useRef('')
|
||||||
|
const latestContent = useRef({})
|
||||||
|
const saveTimer = useRef(null)
|
||||||
|
const activeNoteId = useRef(null) // stable ref to the current note's id
|
||||||
|
|
||||||
|
// Load notes the first time the panel opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && !loaded) {
|
||||||
|
api.getNotes(storyId)
|
||||||
|
.then(ns => { setNotes(ns); setLoaded(true) })
|
||||||
|
.catch(() => toast('Could not load notes', 'error'))
|
||||||
|
}
|
||||||
|
}, [open, loaded, storyId, toast])
|
||||||
|
|
||||||
|
// Escape: edit view → go back to list; list view → close panel
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
function onKey(e) {
|
||||||
|
if (e.key !== 'Escape') return
|
||||||
|
e.stopPropagation() // prevent EditorPage's global handler from also firing
|
||||||
|
if (activeNote) {
|
||||||
|
flushAndClearTimer(activeNote.id)
|
||||||
|
setActiveNote(null)
|
||||||
|
} else {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey, true) // capture phase
|
||||||
|
return () => document.removeEventListener('keydown', onKey, true)
|
||||||
|
}, [open, activeNote, onClose])
|
||||||
|
|
||||||
|
// ── Save helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const saveNote = useCallback(async (noteId) => {
|
||||||
|
if (!noteId) return
|
||||||
|
try {
|
||||||
|
const updated = await api.updateNote(storyId, noteId, {
|
||||||
|
title: latestTitle.current,
|
||||||
|
content: latestContent.current,
|
||||||
|
})
|
||||||
|
setNotes(prev => prev.map(n => n.id === noteId ? updated : n))
|
||||||
|
} catch {
|
||||||
|
toast('Could not save note', 'error')
|
||||||
|
}
|
||||||
|
}, [storyId, toast])
|
||||||
|
|
||||||
|
function scheduleSave(noteId) {
|
||||||
|
clearTimeout(saveTimer.current)
|
||||||
|
saveTimer.current = setTimeout(() => saveNote(noteId), 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush any pending debounced save immediately
|
||||||
|
function flushAndClearTimer(noteId) {
|
||||||
|
clearTimeout(saveTimer.current)
|
||||||
|
saveNote(noteId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => () => clearTimeout(saveTimer.current), [])
|
||||||
|
|
||||||
|
// ── List-view actions ─────────────────────────────────────
|
||||||
|
|
||||||
|
function openNote(note) {
|
||||||
|
// Flush any pending save for the previous note before switching
|
||||||
|
if (activeNoteId.current && activeNoteId.current !== note.id) {
|
||||||
|
flushAndClearTimer(activeNoteId.current)
|
||||||
|
}
|
||||||
|
activeNoteId.current = note.id
|
||||||
|
latestTitle.current = note.title
|
||||||
|
latestContent.current = note.content || {}
|
||||||
|
setNoteTitle(note.title)
|
||||||
|
setActiveNote(note)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createNote() {
|
||||||
|
try {
|
||||||
|
const note = await api.createNote(storyId)
|
||||||
|
setNotes(prev => [note, ...prev])
|
||||||
|
openNote(note)
|
||||||
|
} catch {
|
||||||
|
toast('Could not create note', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edit-view actions ─────────────────────────────────────
|
||||||
|
|
||||||
|
function handleTitleChange(e) {
|
||||||
|
latestTitle.current = e.target.value
|
||||||
|
setNoteTitle(e.target.value)
|
||||||
|
scheduleSave(activeNote.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContentChange(c) {
|
||||||
|
latestContent.current = c
|
||||||
|
scheduleSave(activeNote.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToList() {
|
||||||
|
flushAndClearTimer(activeNote.id)
|
||||||
|
activeNoteId.current = null
|
||||||
|
setActiveNote(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteNote() {
|
||||||
|
const ok = await confirm(`Delete "${activeNote.title || 'this note'}"?`, {
|
||||||
|
confirmLabel: 'Delete',
|
||||||
|
danger: true,
|
||||||
|
})
|
||||||
|
if (!ok) return
|
||||||
|
try {
|
||||||
|
clearTimeout(saveTimer.current)
|
||||||
|
await api.deleteNote(storyId, activeNote.id)
|
||||||
|
setNotes(prev => prev.filter(n => n.id !== activeNote.id))
|
||||||
|
activeNoteId.current = null
|
||||||
|
setActiveNote(null)
|
||||||
|
} catch {
|
||||||
|
toast('Could not delete note', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the panel's own close button / backdrop — flush before closing
|
||||||
|
function handleClose() {
|
||||||
|
if (activeNote) flushAndClearTimer(activeNote.id)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{open && <div className="panel-backdrop" onClick={handleClose} />}
|
||||||
|
|
||||||
|
<aside className={`notes-panel${open ? ' open' : ''}`} aria-label="Notes">
|
||||||
|
|
||||||
|
{/* ── Edit view ── */}
|
||||||
|
{activeNote ? (
|
||||||
|
<div className="note-edit-view">
|
||||||
|
<div className="notes-panel-header">
|
||||||
|
<button className="note-back-btn btn btn-ghost" onClick={backToList}>
|
||||||
|
← Notes
|
||||||
|
</button>
|
||||||
|
<button className="chapter-panel-close" onClick={handleClose} aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="note-title-input"
|
||||||
|
value={noteTitle}
|
||||||
|
onChange={handleTitleChange}
|
||||||
|
placeholder="Note title…"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Scrollable area — the NoteEditor toolbar sticks within this */}
|
||||||
|
<div className="note-editor-scroll">
|
||||||
|
<NoteEditor
|
||||||
|
key={activeNote.id}
|
||||||
|
content={activeNote.content}
|
||||||
|
onChange={handleContentChange}
|
||||||
|
onImageUpload={api.uploadImage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="note-edit-footer">
|
||||||
|
<button className="btn btn-ghost note-delete-btn" onClick={deleteNote}>
|
||||||
|
Delete note
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
) : (
|
||||||
|
/* ── List view ── */
|
||||||
|
<>
|
||||||
|
<div className="notes-panel-header">
|
||||||
|
<span className="notes-panel-title">Notes</span>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<button className="note-new-btn btn btn-ghost" onClick={createNote} title="New note">
|
||||||
|
+ New
|
||||||
|
</button>
|
||||||
|
<button className="chapter-panel-close" onClick={handleClose} aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loaded ? (
|
||||||
|
<p className="chapter-panel-empty">Loading…</p>
|
||||||
|
) : notes.length === 0 ? (
|
||||||
|
<div className="notes-empty">
|
||||||
|
<p className="notes-empty-msg">No notes yet.</p>
|
||||||
|
<button className="btn btn-primary notes-empty-cta" onClick={createNote}>
|
||||||
|
+ Create first note
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ol className="notes-list">
|
||||||
|
{notes.map(note => (
|
||||||
|
<li key={note.id}>
|
||||||
|
<button className="note-item" onClick={() => openNote(note)}>
|
||||||
|
<span className="note-item-title">{note.title || 'Untitled'}</span>
|
||||||
|
<span className="note-item-snippet">{noteSnippet(note.content)}</span>
|
||||||
|
<span className="note-item-date">{formatDate(note.updated_at)}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -73,6 +73,11 @@ export const api = {
|
|||||||
return data.url
|
return data.url
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getNotes: (storyId) => req('GET', `/api/stories/${storyId}/notes`),
|
||||||
|
createNote: (storyId) => req('POST', `/api/stories/${storyId}/notes`),
|
||||||
|
updateNote: (storyId, noteId, data) => req('PUT', `/api/stories/${storyId}/notes/${noteId}`, data),
|
||||||
|
deleteNote: (storyId, noteId) => req('DELETE', `/api/stories/${storyId}/notes/${noteId}`),
|
||||||
|
|
||||||
getPrompt: () => req('GET', '/api/prompts'),
|
getPrompt: () => req('GET', '/api/prompts'),
|
||||||
exportEpub: (id) => download(`/api/stories/${id}/export/epub`),
|
exportEpub: (id) => download(`/api/stories/${id}/export/epub`),
|
||||||
exportOdt: (id) => download(`/api/stories/${id}/export/odt`),
|
exportOdt: (id) => download(`/api/stories/${id}/export/odt`),
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { useToast } from '../components/Toast'
|
|||||||
import { useConfirm } from '../components/ConfirmDialog'
|
import { useConfirm } from '../components/ConfirmDialog'
|
||||||
import Editor from '../components/Editor'
|
import Editor from '../components/Editor'
|
||||||
import ChapterPanel from '../components/ChapterPanel'
|
import ChapterPanel from '../components/ChapterPanel'
|
||||||
|
import NotesPanel from '../components/NotesPanel'
|
||||||
|
|
||||||
const MIN_FONT = 13
|
const MIN_FONT = 13
|
||||||
const MAX_FONT = 26
|
const MAX_FONT = 26
|
||||||
@ -26,9 +27,12 @@ export default function EditorPage() {
|
|||||||
const [saveStatus, setSaveStatus] = useState('saved')
|
const [saveStatus, setSaveStatus] = useState('saved')
|
||||||
|
|
||||||
const [chapterOpen, setChapterOpen] = useState(false)
|
const [chapterOpen, setChapterOpen] = useState(false)
|
||||||
|
const [notesOpen, setNotesOpen] = useState(false)
|
||||||
const [exportOpen, setExportOpen] = useState(false)
|
const [exportOpen, setExportOpen] = useState(false)
|
||||||
const [focusMode, setFocusMode] = useState(false)
|
const [focusMode, setFocusMode] = useState(false)
|
||||||
const [promptText, setPromptText] = useState(null)
|
const [promptText, setPromptText] = useState(null)
|
||||||
|
const [promptSource, setPromptSource] = useState(null) // 'ollama' | 'local'
|
||||||
|
const [promptModel, setPromptModel] = useState(null)
|
||||||
const [promptLoading, setPromptLoading] = useState(false)
|
const [promptLoading, setPromptLoading] = useState(false)
|
||||||
const [fontSize, setFontSize] = useState(
|
const [fontSize, setFontSize] = useState(
|
||||||
() => Math.max(MIN_FONT, Math.min(MAX_FONT, parseInt(localStorage.getItem('sw-fontsize')) || 17))
|
() => Math.max(MIN_FONT, Math.min(MAX_FONT, parseInt(localStorage.getItem('sw-fontsize')) || 17))
|
||||||
@ -39,6 +43,9 @@ export default function EditorPage() {
|
|||||||
const latestContent = useRef({})
|
const latestContent = useRef({})
|
||||||
const prevWordCount = useRef(0)
|
const prevWordCount = useRef(0)
|
||||||
const coverRef = useRef()
|
const coverRef = useRef()
|
||||||
|
// Holds the in-flight prompt request so the click can await it rather than
|
||||||
|
// starting a brand-new fetch (which would have to wait all over again).
|
||||||
|
const pendingPrompt = useRef(null)
|
||||||
const editorRef = useRef()
|
const editorRef = useRef()
|
||||||
const saveRef = useRef(null)
|
const saveRef = useRef(null)
|
||||||
|
|
||||||
@ -55,6 +62,10 @@ export default function EditorPage() {
|
|||||||
.catch(() => setLoadError(true))
|
.catch(() => setLoadError(true))
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
|
// Kick off a prompt fetch in the background as soon as the page loads so
|
||||||
|
// Ollama has the full response time before the user ever clicks the button.
|
||||||
|
useEffect(() => { kickPromptFetch() }, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('sw-fontsize', fontSize)
|
localStorage.setItem('sw-fontsize', fontSize)
|
||||||
}, [fontSize])
|
}, [fontSize])
|
||||||
@ -106,6 +117,7 @@ export default function EditorPage() {
|
|||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
setFocusMode(false)
|
setFocusMode(false)
|
||||||
setChapterOpen(false)
|
setChapterOpen(false)
|
||||||
|
setNotesOpen(false)
|
||||||
setExportOpen(false)
|
setExportOpen(false)
|
||||||
setPromptText(null)
|
setPromptText(null)
|
||||||
}
|
}
|
||||||
@ -155,13 +167,28 @@ export default function EditorPage() {
|
|||||||
await api.updateStory(id, { cover_image: null })
|
await api.updateStory(id, { cover_image: null })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns the in-flight prompt Promise, or starts a fresh one.
|
||||||
|
// Calling this twice before the first resolves hands back the same Promise.
|
||||||
|
function kickPromptFetch() {
|
||||||
|
if (!pendingPrompt.current) {
|
||||||
|
pendingPrompt.current = api.getPrompt()
|
||||||
|
}
|
||||||
|
return pendingPrompt.current
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchPrompt() {
|
async function fetchPrompt() {
|
||||||
setPromptLoading(true)
|
setPromptLoading(true)
|
||||||
setPromptText(null)
|
setPromptText(null)
|
||||||
|
const promise = kickPromptFetch()
|
||||||
try {
|
try {
|
||||||
const data = await api.getPrompt()
|
const data = await promise
|
||||||
|
pendingPrompt.current = null // clear so the next kick starts fresh
|
||||||
setPromptText(data.prompt)
|
setPromptText(data.prompt)
|
||||||
|
setPromptSource(data.source ?? null)
|
||||||
|
setPromptModel(data.model ?? null)
|
||||||
|
kickPromptFetch() // pre-load the next one immediately
|
||||||
} catch {
|
} catch {
|
||||||
|
pendingPrompt.current = null
|
||||||
toast('Could not fetch a prompt', 'error')
|
toast('Could not fetch a prompt', 'error')
|
||||||
} finally {
|
} finally {
|
||||||
setPromptLoading(false)
|
setPromptLoading(false)
|
||||||
@ -200,6 +227,7 @@ export default function EditorPage() {
|
|||||||
return (
|
return (
|
||||||
<div className={`editor-page${focusMode ? ' focus-mode' : ''}${chapterOpen ? ' panel-open' : ''}`}>
|
<div className={`editor-page${focusMode ? ' focus-mode' : ''}${chapterOpen ? ' panel-open' : ''}`}>
|
||||||
<ChapterPanel content={content} open={chapterOpen} onClose={() => setChapterOpen(false)} />
|
<ChapterPanel content={content} open={chapterOpen} onClose={() => setChapterOpen(false)} />
|
||||||
|
<NotesPanel storyId={id} open={notesOpen} onClose={() => setNotesOpen(false)} />
|
||||||
|
|
||||||
<div className="editor-main">
|
<div className="editor-main">
|
||||||
<div className="editor-topbar">
|
<div className="editor-topbar">
|
||||||
@ -210,6 +238,11 @@ export default function EditorPage() {
|
|||||||
onClick={() => setChapterOpen(o => !o)}
|
onClick={() => setChapterOpen(o => !o)}
|
||||||
title="Toggle chapter list"
|
title="Toggle chapter list"
|
||||||
>☰ <span>Chapters</span></button>
|
>☰ <span>Chapters</span></button>
|
||||||
|
<button
|
||||||
|
className={`btn btn-ghost${notesOpen ? ' active' : ''}`}
|
||||||
|
onClick={() => setNotesOpen(o => !o)}
|
||||||
|
title="Toggle notes"
|
||||||
|
>✎ <span>Notes</span></button>
|
||||||
<button
|
<button
|
||||||
className={`btn btn-ghost${focusMode ? ' active' : ''}`}
|
className={`btn btn-ghost${focusMode ? ' active' : ''}`}
|
||||||
onClick={() => setFocusMode(f => !f)}
|
onClick={() => setFocusMode(f => !f)}
|
||||||
@ -224,6 +257,13 @@ export default function EditorPage() {
|
|||||||
{promptText && (
|
{promptText && (
|
||||||
<div className="prompt-popover">
|
<div className="prompt-popover">
|
||||||
<p className="prompt-text">{promptText}</p>
|
<p className="prompt-text">{promptText}</p>
|
||||||
|
{promptSource && (
|
||||||
|
<div className={`prompt-source prompt-source--${promptSource}`}>
|
||||||
|
{promptSource === 'ollama'
|
||||||
|
? <>🤖 AI · {promptModel || 'ollama'}</>
|
||||||
|
: <>📚 Built-in prompt</>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="prompt-actions">
|
<div className="prompt-actions">
|
||||||
<button className="btn btn-ghost" onClick={fetchPrompt}>↻ Another</button>
|
<button className="btn btn-ghost" onClick={fetchPrompt}>↻ Another</button>
|
||||||
<button className="btn btn-primary" onClick={insertPrompt}>Insert</button>
|
<button className="btn btn-primary" onClick={insertPrompt}>Insert</button>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
/* ── Google Fonts fallback stack ──────────────────────── */
|
/* ── Google Fonts ─────────────────────────────────────── */
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Lora:ital,wght@0,400;0,500;1,400;1,500&family=Special+Elite&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Cormorant+Garamond:ital,wght@0,400;0,500;1,400&family=Courier+Prime:ital,wght@0,400;1,400&family=Crimson+Text:ital,wght@0,400;0,600;1,400&family=EB+Garamond:ital,wght@0,400;1,400&family=Inter:wght@400;500&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,wght@0,400;0,500;1,400&family=Lora:ital,wght@0,400;0,500;1,400;1,500&family=Merriweather:ital,wght@0,400;0,700;1,400&family=Nunito:ital,wght@0,400;0,600;1,400&family=PT+Serif:ital,wght@0,400;1,400&family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Source+Sans+3:ital,wght@0,400;0,600;1,400&family=Special+Elite&family=Vollkorn:ital,wght@0,400;0,500;1,400&display=swap');
|
||||||
|
|
||||||
/* ── Theme Variables ──────────────────────────────────── */
|
/* ── Theme Variables ──────────────────────────────────── */
|
||||||
|
|
||||||
@ -18,6 +18,8 @@
|
|||||||
--font-body: 'Lora', Georgia, serif;
|
--font-body: 'Lora', Georgia, serif;
|
||||||
--noise: 0.025;
|
--noise: 0.025;
|
||||||
--radius: 5px;
|
--radius: 5px;
|
||||||
|
/* Height of the sticky editor topbar — used to offset the sticky toolbar */
|
||||||
|
--topbar-h: 49px;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="gothic"] {
|
[data-theme="gothic"] {
|
||||||
@ -593,6 +595,259 @@ button { cursor: pointer; font-family: inherit; }
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Notes Panel ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.notes-panel {
|
||||||
|
width: 340px;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 300;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-panel.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-panel-title {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-hi);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-new-btn {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Note list ── */
|
||||||
|
|
||||||
|
.notes-list {
|
||||||
|
list-style: none;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item {
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
transition: background 0.14s;
|
||||||
|
}
|
||||||
|
.note-item:hover { background: var(--bg-raised); }
|
||||||
|
|
||||||
|
.note-item-title {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item-snippet {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-item-date {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-empty {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-empty-msg {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-empty-cta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.4rem 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Note edit view ── */
|
||||||
|
|
||||||
|
.note-edit-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-back-btn {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-title-input {
|
||||||
|
width: 100%;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
outline: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.note-title-input:focus { border-bottom-color: var(--accent-hi); }
|
||||||
|
.note-title-input::placeholder { color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Scrollable content area — the NoteEditor toolbar sticks within this */
|
||||||
|
.note-editor-scroll {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-edit-footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-delete-btn {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
.note-delete-btn:hover {
|
||||||
|
background: var(--danger);
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── NoteEditor inner structure ── */
|
||||||
|
|
||||||
|
.note-editor-outer {
|
||||||
|
/* Siblings: note-toolbar (sticky) + note-editor-wrap
|
||||||
|
No overflow:hidden here — that would break the sticky toolbar */
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
/* Sticks to the top of .note-editor-scroll as the user scrolls a long note */
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-editor-wrap { /* no overflow:hidden — sits beside the sticky toolbar */ }
|
||||||
|
|
||||||
|
.note-editor-body { background: var(--bg-surface); }
|
||||||
|
|
||||||
|
.note-editor-body .ProseMirror {
|
||||||
|
padding: 1rem;
|
||||||
|
min-height: 240px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-editor-body .ProseMirror p.is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
pointer-events: none;
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-editor-body .ProseMirror h2 {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 1.25rem 0 0.4rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-editor-body .ProseMirror h3 {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 1rem 0 0.3rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-editor-body .ProseMirror p { margin-bottom: 0.5rem; }
|
||||||
|
.note-editor-body .ProseMirror strong { font-weight: 700; color: var(--text); }
|
||||||
|
.note-editor-body .ProseMirror em { font-style: italic; }
|
||||||
|
.note-editor-body .ProseMirror blockquote {
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
padding: 0.2rem 0 0.2rem 0.9rem;
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.note-editor-body .ProseMirror ul,
|
||||||
|
.note-editor-body .ProseMirror ol {
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
}
|
||||||
|
.note-editor-body .ProseMirror li + li { margin-top: 0.15rem; }
|
||||||
|
|
||||||
|
/* ── Focus mode hides the notes panel ── */
|
||||||
|
.focus-mode .notes-panel { display: none; }
|
||||||
|
|
||||||
|
/* ── Mobile ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.notes-panel { width: 100%; max-width: 340px; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Export Dropdown ──────────────────────────────────── */
|
/* ── Export Dropdown ──────────────────────────────────── */
|
||||||
|
|
||||||
.export-wrap { position: relative; }
|
.export-wrap { position: relative; }
|
||||||
@ -627,6 +882,11 @@ button { cursor: pointer; font-family: inherit; }
|
|||||||
|
|
||||||
/* ── Toolbar ──────────────────────────────────────────── */
|
/* ── Toolbar ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.editor-outer {
|
||||||
|
/* Contains the sticky toolbar + editor-wrap as siblings so
|
||||||
|
overflow:hidden on editor-wrap doesn't block sticky */
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -637,6 +897,10 @@ button { cursor: pointer; font-family: inherit; }
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius) var(--radius) 0 0;
|
border-radius: var(--radius) var(--radius) 0 0;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
|
/* Stick just below the editor top-bar when scrolling */
|
||||||
|
position: sticky;
|
||||||
|
top: var(--topbar-h);
|
||||||
|
z-index: 150;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-btn {
|
.toolbar-btn {
|
||||||
@ -672,7 +936,8 @@ button { cursor: pointer; font-family: inherit; }
|
|||||||
|
|
||||||
.editor-wrap {
|
.editor-wrap {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-top: none; /* toolbar provides the top edge */
|
||||||
|
border-radius: 0 0 var(--radius) var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -909,6 +1174,110 @@ button { cursor: pointer; font-family: inherit; }
|
|||||||
font-family: serif;
|
font-family: serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Font Picker ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.font-picker {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
right: 5.5rem; /* sits just left of the ThemePicker */
|
||||||
|
z-index: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-toggle {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 3px 14px rgba(0,0,0,0.55);
|
||||||
|
transition: border-color 0.2s, transform 0.15s, color 0.2s;
|
||||||
|
}
|
||||||
|
.font-toggle:hover {
|
||||||
|
border-color: var(--accent-hi);
|
||||||
|
color: var(--text);
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-menu {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 58px;
|
||||||
|
right: 0;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: calc(var(--radius) * 2);
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
box-shadow: 0 6px 28px rgba(0,0,0,0.65);
|
||||||
|
min-width: 220px;
|
||||||
|
max-height: 440px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-menu-label {
|
||||||
|
font-family: var(--font-head);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.3rem 0.5rem 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.14s;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.font-option:hover { background: var(--bg-raised); border-color: var(--border); }
|
||||||
|
.font-option.active { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.font-preview {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: var(--accent-hi);
|
||||||
|
width: 32px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-name {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-category {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-head);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Toast ────────────────────────────────────────────── */
|
/* ── Toast ────────────────────────────────────────────── */
|
||||||
|
|
||||||
.toast-container {
|
.toast-container {
|
||||||
@ -1119,6 +1488,19 @@ button { cursor: pointer; font-family: inherit; }
|
|||||||
margin-bottom: 0.875rem;
|
margin-bottom: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prompt-source {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
font-family: var(--font-head);
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 99px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
}
|
||||||
|
.prompt-source--ollama { color: var(--accent-hi); }
|
||||||
|
.prompt-source--local { color: var(--text-muted); }
|
||||||
|
|
||||||
.prompt-actions {
|
.prompt-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@ -1243,6 +1625,8 @@ button { cursor: pointer; font-family: inherit; }
|
|||||||
.login-box { padding: 2.25rem 1.5rem 2rem; }
|
.login-box { padding: 2.25rem 1.5rem 2rem; }
|
||||||
.theme-picker { bottom: 1rem; right: 1rem; }
|
.theme-picker { bottom: 1rem; right: 1rem; }
|
||||||
.theme-toggle { width: 42px; height: 42px; font-size: 1.1rem; }
|
.theme-toggle { width: 42px; height: 42px; font-size: 1.1rem; }
|
||||||
|
.font-picker { bottom: 1rem; right: 4.75rem; }
|
||||||
|
.font-toggle { width: 42px; height: 42px; font-size: 0.9rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 400px) {
|
@media (max-width: 400px) {
|
||||||
|
|||||||
10
server/db.js
10
server/db.js
@ -36,6 +36,16 @@ db.exec(`
|
|||||||
filename TEXT NOT NULL,
|
filename TEXT NOT NULL,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
story_id INTEGER NOT NULL REFERENCES stories(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL DEFAULT 'Untitled Note',
|
||||||
|
content TEXT DEFAULT '{}',
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
// Migrate: rename email -> username for existing databases
|
// Migrate: rename email -> username for existing databases
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import storiesRoutes from './routes/stories.js'
|
|||||||
import imagesRoutes from './routes/images.js'
|
import imagesRoutes from './routes/images.js'
|
||||||
import adminRoutes from './routes/admin.js'
|
import adminRoutes from './routes/admin.js'
|
||||||
import promptsRoutes from './routes/prompts.js'
|
import promptsRoutes from './routes/prompts.js'
|
||||||
|
import notesRoutes from './routes/notes.js'
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const app = express()
|
const app = express()
|
||||||
@ -18,5 +19,6 @@ app.use('/api/stories', storiesRoutes)
|
|||||||
app.use('/api/images', imagesRoutes)
|
app.use('/api/images', imagesRoutes)
|
||||||
app.use('/api/admin', adminRoutes)
|
app.use('/api/admin', adminRoutes)
|
||||||
app.use('/api/prompts', promptsRoutes)
|
app.use('/api/prompts', promptsRoutes)
|
||||||
|
app.use('/api/stories/:storyId/notes', notesRoutes)
|
||||||
|
|
||||||
app.listen(3000, () => console.log('Server ready on :3000'))
|
app.listen(3000, () => console.log('Server ready on :3000'))
|
||||||
|
|||||||
67
server/routes/notes.js
Normal file
67
server/routes/notes.js
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import db from '../db.js'
|
||||||
|
import { auth } from '../middleware/auth.js'
|
||||||
|
|
||||||
|
// mergeParams: true is required so req.params.storyId is visible
|
||||||
|
// inside this child router (Express strips parent params by default)
|
||||||
|
const router = Router({ mergeParams: true })
|
||||||
|
router.use(auth)
|
||||||
|
|
||||||
|
const parse = s => { try { return JSON.parse(s || '{}') } catch { return {} } }
|
||||||
|
const safe = s => JSON.stringify(s ?? {})
|
||||||
|
|
||||||
|
// Verify the story belongs to the requesting user
|
||||||
|
function getStory(storyId, userId) {
|
||||||
|
return db.prepare('SELECT id FROM stories WHERE id = ? AND user_id = ?').get(storyId, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/stories/:storyId/notes
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
if (!getStory(req.params.storyId, req.user.id)) return res.status(404).json({ error: 'Not found' })
|
||||||
|
const rows = db.prepare(
|
||||||
|
'SELECT * FROM notes WHERE story_id = ? AND user_id = ? ORDER BY updated_at DESC'
|
||||||
|
).all(req.params.storyId, req.user.id)
|
||||||
|
res.json(rows.map(r => ({ ...r, content: parse(r.content) })))
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST /api/stories/:storyId/notes
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
if (!getStory(req.params.storyId, req.user.id)) return res.status(404).json({ error: 'Not found' })
|
||||||
|
const { title = 'Untitled Note', content = {} } = req.body || {}
|
||||||
|
const { lastInsertRowid } = db.prepare(
|
||||||
|
'INSERT INTO notes (story_id, user_id, title, content) VALUES (?, ?, ?, ?)'
|
||||||
|
).run(req.params.storyId, req.user.id, title, safe(content))
|
||||||
|
const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(lastInsertRowid)
|
||||||
|
res.json({ ...note, content: parse(note.content) })
|
||||||
|
})
|
||||||
|
|
||||||
|
// PUT /api/stories/:storyId/notes/:noteId
|
||||||
|
router.put('/:noteId', (req, res) => {
|
||||||
|
const note = db.prepare(
|
||||||
|
'SELECT * FROM notes WHERE id = ? AND story_id = ? AND user_id = ?'
|
||||||
|
).get(req.params.noteId, req.params.storyId, req.user.id)
|
||||||
|
if (!note) return res.status(404).json({ error: 'Not found' })
|
||||||
|
|
||||||
|
const { title, content } = req.body || {}
|
||||||
|
db.prepare(
|
||||||
|
'UPDATE notes SET title = ?, content = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
|
||||||
|
).run(
|
||||||
|
title !== undefined ? title : note.title,
|
||||||
|
content !== undefined ? safe(content) : note.content,
|
||||||
|
note.id
|
||||||
|
)
|
||||||
|
const updated = db.prepare('SELECT * FROM notes WHERE id = ?').get(note.id)
|
||||||
|
res.json({ ...updated, content: parse(updated.content) })
|
||||||
|
})
|
||||||
|
|
||||||
|
// DELETE /api/stories/:storyId/notes/:noteId
|
||||||
|
router.delete('/:noteId', (req, res) => {
|
||||||
|
const note = db.prepare(
|
||||||
|
'SELECT * FROM notes WHERE id = ? AND story_id = ? AND user_id = ?'
|
||||||
|
).get(req.params.noteId, req.params.storyId, req.user.id)
|
||||||
|
if (!note) return res.status(404).json({ error: 'Not found' })
|
||||||
|
db.prepare('DELETE FROM notes WHERE id = ?').run(note.id)
|
||||||
|
res.json({ ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@ -5,6 +5,7 @@ const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434'
|
|||||||
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'llama3.2'
|
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'llama3.2'
|
||||||
|
|
||||||
const FALLBACK = [
|
const FALLBACK = [
|
||||||
|
// ── Original 20 ───────────────────────────────────────────
|
||||||
"A letter arrives addressed to you — dated ten years in the future. It says only: 'Don't go to the old lighthouse on Friday.'",
|
"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.",
|
"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.",
|
"Every mirror in the city goes dark overnight. Yours still shows a reflection — but it isn't yours.",
|
||||||
@ -25,14 +26,159 @@ const FALLBACK = [
|
|||||||
"The stars rearrange themselves every night. Last night you finally worked out what language they're writing in.",
|
"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.",
|
"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.",
|
"The town clockmaker stops every clock at midnight. By morning, she is gone. So is yesterday.",
|
||||||
|
|
||||||
|
// ── Mystery & the Unexplained ─────────────────────────────
|
||||||
|
"A door appears in the wall of your bedroom. It was not there when you went to sleep. It is locked from the inside.",
|
||||||
|
"Every Tuesday, a package arrives on your doorstep with no return address. Inside is always something you lost years ago.",
|
||||||
|
"The new kid at school knows things about you that you have never told anyone. They say they read it in a book — but they won't show you the title.",
|
||||||
|
"You discover that every dream you've ever had was recorded somewhere, and someone has been watching them.",
|
||||||
|
"A fog rolls into town and when it lifts, one building has been replaced by a building none of you recognise.",
|
||||||
|
"Your reflection is always one expression behind you. Today, for the first time, it smiled before you did.",
|
||||||
|
"The footprints in the snow lead to your door. They come from nowhere. They do not leave.",
|
||||||
|
"You find a key in the street. Every lock you try it on opens — but none of the doors were locked to begin with.",
|
||||||
|
"The library has a section you've never noticed before. The books inside are all about people in your town — and they're all set in the future.",
|
||||||
|
"The old radio in the attic turns on by itself. It's playing a news broadcast from forty years ago. The headlines are about something happening right now.",
|
||||||
|
"A map arrives in the post with a path drawn in red ink leading straight to your school. At the end of the path is a single word: 'Help.'",
|
||||||
|
"You wake up to find a small stone on your pillow with your name carved into it. It wasn't there when you fell asleep. It's still warm.",
|
||||||
|
|
||||||
|
// ── Adventure & Exploration ───────────────────────────────
|
||||||
|
"You and your best friend discover a tunnel under the old mill that leads to a country that appears on no map.",
|
||||||
|
"The tide goes out further than it ever has before, revealing an entire sunken town still full of people going about their day.",
|
||||||
|
"Your school is selected for a very unusual exchange programme: three students will spend one week living inside a painting.",
|
||||||
|
"A storm knocks out every phone and computer in town. When they come back on, every photo from the last ten years has been replaced with pictures of the same empty field.",
|
||||||
|
"You're the first person in history to climb the mountain everyone said was unclimbable. At the top is a small wooden door and a welcome mat.",
|
||||||
|
"Your family's boat engine fails in the middle of the ocean. You drift for two days before spotting land — land that charts say is deep underwater.",
|
||||||
|
"A travel company offers holidays to places that no longer exist. You choose a city that was abandoned three hundred years ago. It isn't abandoned at all.",
|
||||||
|
"You find a submarine in the middle of a forest, perfectly intact. The date on the captain's log is last week.",
|
||||||
|
"Every summer the tide brings something unusual to your beach. This year it brings a door, hinges and all, standing upright in the sand.",
|
||||||
|
|
||||||
|
// ── Friendship & Belonging ────────────────────────────────
|
||||||
|
"A new student joins your class who seems to know exactly how every lesson will go — and every conversation — before it happens.",
|
||||||
|
"Your imaginary friend from when you were five shows up at your door, now real, now twelve years old, and in serious trouble.",
|
||||||
|
"You and a rival have been competing your whole lives. On the day of the final competition, you each realise the other is the only one who can help you win.",
|
||||||
|
"The pen pal you've been writing to for three years turns out to live in the same street as you. Neither of you knew.",
|
||||||
|
"You are chosen to befriend the new kid, who everyone says is strange. They are strange — they can also turn invisible, and they're terrified someone will find out.",
|
||||||
|
"Your best friend moves away. The night before they leave, you both make a promise that you'll meet again in exactly ten years. Ten years later, a letter arrives.",
|
||||||
|
"Every person in your class has a special ability they're keeping secret. You think you're the only ordinary one. You're not.",
|
||||||
|
"You and three strangers are all given the same mysterious invitation on the same day. Following it leads you to each other.",
|
||||||
|
|
||||||
|
// ── Family & Home ─────────────────────────────────────────
|
||||||
|
"Your grandmother leaves you her house. When you arrive to collect her things, you find she has left messages hidden in every room — a trail for you to follow.",
|
||||||
|
"Your family has a rule that no one ever talks about: never open the blue cupboard under the stairs. Today the door is open. Just a crack.",
|
||||||
|
"A family moves into the empty house across the street. They are perfectly normal in every way. Perfectly, suspiciously, impossibly normal.",
|
||||||
|
"Your parent tells you they have a sibling you've never met. When you ask why, they say: 'She chose a different path. Literally.'",
|
||||||
|
"You find a box of letters in your attic between two people who you slowly realise are your grandparents — before they ever met.",
|
||||||
|
"Every member of your family has the same recurring dream. Tonight, for the first time, you are all in it together.",
|
||||||
|
|
||||||
|
// ── Magic & Impossible Things ─────────────────────────────
|
||||||
|
"A girl is born able to speak only in questions. When she is seventeen, someone finally answers one — and changes everything.",
|
||||||
|
"The colour blue disappears from the world overnight. Not the things that were blue — just the colour itself.",
|
||||||
|
"You discover you have one magical power: you can always find things that are lost. The problem is that not everything that is lost wants to be found.",
|
||||||
|
"An ordinary stone in your garden turns out to be a sleeping giant's marble. The giant is waking up.",
|
||||||
|
"Magic is real but it is taxed. The richer you are, the more you can afford. You have just found a way around the tax.",
|
||||||
|
"On your twelfth birthday you grow wings. They are very small. They are also growing very quickly.",
|
||||||
|
"A witch offers you the ability to understand any animal. The first animal you speak to has a great deal to say about the way things are run.",
|
||||||
|
"Words have weight in your world. A lie is as heavy as a stone. The truth, sometimes, is lighter than air. Today you must say something that could crush you.",
|
||||||
|
"You find a potion that makes you speak fluently in any language — including the language of rain, and traffic, and very old doors.",
|
||||||
|
"The last wizard in the world gives up magic at noon on a Tuesday. By midnight, everything he ever enchanted starts to unravel.",
|
||||||
|
"You are a mapmaker in a world where maps shape reality. Whatever you draw becomes true. Your pencil is nearly out of lead.",
|
||||||
|
"An ordinary notebook grants one wish per page. You have been very careful. You only have one page left.",
|
||||||
|
|
||||||
|
// ── Other Worlds & Creatures ──────────────────────────────
|
||||||
|
"Humans have always shared the earth with creatures that can only be seen by children under thirteen. You are twelve and a half.",
|
||||||
|
"The robots were given feelings fifty years ago to make them better workers. Now they want something you weren't expecting: bedtime stories.",
|
||||||
|
"A planet is discovered that mirrors Earth exactly — except there, all the stories ended differently.",
|
||||||
|
"Deep in the forest there is a village of people who live entirely at night and sleep through every day. One of them has just seen the sun for the first time.",
|
||||||
|
"The sea has seven layers. Humans live on the top. You have just fallen to the second.",
|
||||||
|
"In a world where everyone is born with an animal companion bonded to their soul, yours has just arrived. It's not what you expected. It's also enormous.",
|
||||||
|
"A kingdom exists entirely inside a single raindrop. It has been falling for three hundred years. It is about to land.",
|
||||||
|
"On a distant moon, archaeologists find the ruins of a perfect town, untouched for a thousand years. In the centre is a library. Every book is in English. Every book is about Earth.",
|
||||||
|
"Every person in your city is actually a story being told by someone else. You are the first character to suspect it.",
|
||||||
|
|
||||||
|
// ── Quests & Missions ─────────────────────────────────────
|
||||||
|
"You are given one task: deliver a letter to the moon. You are given two weeks, a bicycle, and no further explanation.",
|
||||||
|
"A quest is announced across the kingdom: the first person to make the old king laugh will inherit his library. The king has not smiled in thirty years.",
|
||||||
|
"You must cross a desert that doesn't exist on any map, using directions written in a language nobody speaks anymore.",
|
||||||
|
"The mission is simple: steal back the stolen dawn before sunrise. Your team is three thieves who have never agreed on anything.",
|
||||||
|
"A race is held every century across the entire known world. The prize is a question — one honest answer to any question you choose. You have trained your whole life. So has your twin.",
|
||||||
|
"You are hired to find the thing that is making the village unhappy. It isn't a thing. It's an absence. Something is missing that no one can quite remember.",
|
||||||
|
|
||||||
|
// ── Identity & Growing Up ─────────────────────────────────
|
||||||
|
"You have lived your whole life in a town where everyone's future is decided on their sixteenth birthday. Yours isn't.",
|
||||||
|
"You wake up one morning to find you have aged ten years overnight. Your body is older. Your memories stop yesterday.",
|
||||||
|
"A machine is invented that shows you the version of yourself you might have been. Yours is not what you expected.",
|
||||||
|
"Everyone in your class writes their greatest fear on a piece of paper. The papers are mixed up and handed back. Yours is the only one no one will look at.",
|
||||||
|
"You are the first person in your family to be born without the gift. You are also the first person to find something the gift could never give you.",
|
||||||
|
"A stranger tells you that you are living the wrong life — that somewhere, a mix-up was made. The strange thing is: you knew. You just didn't know anyone else did.",
|
||||||
|
|
||||||
|
// ── Science & Discovery ───────────────────────────────────
|
||||||
|
"A scientist invents a machine that can record sounds from the past. The first clear recording is of someone calling your name.",
|
||||||
|
"You discover a new species in your back garden. It is intelligent. It has been watching your family for years. It has a lot of questions.",
|
||||||
|
"A space probe launched fifty years ago sends back one final message before going silent: three coordinates and the word 'hurry'.",
|
||||||
|
"You invent a device that can translate birdsong. The birds have been trying to tell us something for centuries. They are not pleased that it took this long.",
|
||||||
|
"A new element is discovered that reacts only to human emotion. A small amount of it sits in your school's science lab. Today's lesson is about sadness.",
|
||||||
|
"The first message from an alien civilisation turns out to be a response. A response to something we sent — but no one can find the original transmission.",
|
||||||
|
|
||||||
|
// ── Endings & Beginnings ──────────────────────────────────
|
||||||
|
"The last person alive who remembers the old world asks you to sit with them for one hour and listen. What you hear changes everything.",
|
||||||
|
"The story everyone thought was finished wasn't. A character walks out of the final page and into a Tuesday morning, blinking in the light.",
|
||||||
|
"The world ends very quietly, on a Wednesday, with perfect weather. Eleven people notice. They decide to do something about it.",
|
||||||
|
"You are the keeper of the last fire on earth. Your job is to never let it go out. Tonight, someone is asking you to.",
|
||||||
|
"A new beginning is offered to the world. Everyone must vote: start over, or carry on. The vote is tied. The deciding ballot is yours.",
|
||||||
|
|
||||||
|
// ── Time & Memory ─────────────────────────────────────────
|
||||||
|
"You can borrow one hour from your own future — but you'll never know which hour you've taken until you get there.",
|
||||||
|
"A museum opens that holds one exhibit: a room full of every moment you almost made a different choice.",
|
||||||
|
"Every time you fall asleep you continue the same dream, in order, like chapters. Last night something in the dream looked back.",
|
||||||
|
"You find a calendar where every date has already been filled in, in your own handwriting, in ink that hasn't dried yet.",
|
||||||
|
"You can see how old things are just by touching them. You touch your best friend's hand today and feel something impossible.",
|
||||||
|
"Twice a year, the town gathers to trade memories. Not stories — actual memories, gone from one mind and given to another. You have one you need to be rid of.",
|
||||||
|
|
||||||
|
// ── Animals & the Natural World ───────────────────────────
|
||||||
|
"The whales have started singing a new song this year. Scientists say it has a structure like language. A girl on a small island says she knows what it means.",
|
||||||
|
"A flock of birds arrives at your window every morning and watches you until you leave for school. Today they followed you inside.",
|
||||||
|
"All the bees disappear for one week, then return. While they were gone, every flower in the world was the same colour. Nobody can agree on which colour it was.",
|
||||||
|
"You nurse an injured fox back to health and release it. The following week, it begins leaving gifts outside your door — things that belong to other people.",
|
||||||
|
"The old horse in the field at the end of your road turns out to be under a very old enchantment. It has been waiting for someone patient enough to notice.",
|
||||||
|
|
||||||
|
// ── Curious Inventions ────────────────────────────────────
|
||||||
|
"A pair of glasses is invented that lets you see the kindest thing anyone around you has ever done. They are very popular. One person refuses to wear them.",
|
||||||
|
"An umbrella is found that doesn't keep rain off — it keeps noise off. The person carrying it lives in total silence. They look very peaceful. Too peaceful.",
|
||||||
|
"A clockmaker builds a clock that runs backwards by one second for every lie told in its presence. It has not stopped since it was installed in parliament.",
|
||||||
|
"Someone builds a machine that makes a sound when someone is lying. It is very small. It fits in your pocket. You take it to school.",
|
||||||
|
"An inventor creates shoes that always walk you home by the fastest route. One pair keeps taking its owner somewhere else entirely.",
|
||||||
|
|
||||||
|
// ── Unexpected Heroes ─────────────────────────────────────
|
||||||
|
"The person chosen to save the world is seventy-three years old, very tired, and was just about to have a cup of tea.",
|
||||||
|
"Nobody expected the youngest child to be the one who could see through the spell. Least of all her.",
|
||||||
|
"The hero of the story fails on the first page. The rest of the book belongs to the person standing next to them.",
|
||||||
|
"A very ordinary child does a very ordinary thing at exactly the right moment. It turns out that was all that was needed.",
|
||||||
|
"The prophecy named a great warrior. The person who showed up was a baker. They brought bread. It was enough.",
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = Router()
|
// ── Shuffled fallback queue — no repeats until all 120 are seen ──────────
|
||||||
router.use(auth)
|
function shuffle(arr) {
|
||||||
|
const a = [...arr]
|
||||||
|
for (let i = a.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[a[i], a[j]] = [a[j], a[i]]
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
let fallbackQueue = shuffle(FALLBACK)
|
||||||
try {
|
|
||||||
const r = await fetch(`${OLLAMA_URL}/api/generate`, {
|
function nextFallback() {
|
||||||
|
if (fallbackQueue.length === 0) fallbackQueue = shuffle(FALLBACK)
|
||||||
|
return fallbackQueue.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared Ollama call (returns parsed response or throws with detail) ────
|
||||||
|
async function callOllama() {
|
||||||
|
const url = `${OLLAMA_URL}/api/generate`
|
||||||
|
console.log(`[prompts] calling Ollama url=${url} model=${OLLAMA_MODEL}`)
|
||||||
|
|
||||||
|
const r = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@ -40,17 +186,47 @@ router.get('/', async (req, res) => {
|
|||||||
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.',
|
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,
|
stream: false,
|
||||||
}),
|
}),
|
||||||
signal: AbortSignal.timeout(8000),
|
signal: AbortSignal.timeout(45000),
|
||||||
})
|
})
|
||||||
if (!r.ok) throw new Error('bad status')
|
|
||||||
|
if (!r.ok) {
|
||||||
|
const body = await r.text().catch(() => '(no body)')
|
||||||
|
throw new Error(`HTTP ${r.status} — ${body.slice(0, 200)}`)
|
||||||
|
}
|
||||||
|
|
||||||
const data = await r.json()
|
const data = await r.json()
|
||||||
const prompt = data.response?.trim()
|
const prompt = data.response?.trim()
|
||||||
if (!prompt) throw new Error('empty')
|
if (!prompt) throw new Error(`empty response — raw: ${JSON.stringify(data).slice(0, 200)}`)
|
||||||
|
return prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Router ───────────────────────────────────────────────────────────────
|
||||||
|
const router = Router()
|
||||||
|
router.use(auth)
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const prompt = await callOllama()
|
||||||
|
console.log(`[prompts] Ollama OK — "${prompt.slice(0, 60)}…"`)
|
||||||
res.json({ prompt, source: 'ollama', model: OLLAMA_MODEL })
|
res.json({ prompt, source: 'ollama', model: OLLAMA_MODEL })
|
||||||
} catch {
|
} catch (err) {
|
||||||
const prompt = FALLBACK[Math.floor(Math.random() * FALLBACK.length)]
|
console.error(`[prompts] Ollama failed, using built-in fallback. Reason: ${err.message}`)
|
||||||
res.json({ prompt, source: 'local' })
|
res.json({ prompt: nextFallback(), source: 'local' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Debug endpoint — hit /api/prompts/test to see exactly what Ollama returns
|
||||||
|
router.get('/test', async (req, res) => {
|
||||||
|
const result = { url: `${OLLAMA_URL}/api/generate`, model: OLLAMA_MODEL }
|
||||||
|
try {
|
||||||
|
result.prompt = await callOllama()
|
||||||
|
result.status = 'ok'
|
||||||
|
} catch (err) {
|
||||||
|
result.status = 'error'
|
||||||
|
result.error = err.message
|
||||||
|
}
|
||||||
|
console.log('[prompts] /test result:', result)
|
||||||
|
res.json(result)
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user