Add character creation

This commit is contained in:
chris 2026-06-26 18:13:18 -04:00
parent 7f6179040c
commit 2272206a2e
12 changed files with 1215 additions and 5 deletions

231
frontend/dist/assets/index-0vatXfR-.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

42
frontend/dist/icon.svg vendored Normal file
View File

@ -0,0 +1,42 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- Background -->
<rect width="512" height="512" rx="96" fill="#09090f"/>
<!-- Book depth shadow -->
<rect x="124" y="88" width="276" height="348" rx="14" fill="#060408"/>
<!-- Book back cover -->
<rect x="120" y="82" width="276" height="348" rx="14" fill="#110e24"/>
<!-- Book spine -->
<rect x="120" y="82" width="30" height="348" rx="13" fill="#090716"/>
<!-- Spine binding rings -->
<rect x="126" y="148" width="14" height="4" rx="2" fill="#c8941a" opacity="0.55"/>
<rect x="126" y="254" width="14" height="4" rx="2" fill="#c8941a" opacity="0.55"/>
<rect x="126" y="360" width="14" height="4" rx="2" fill="#c8941a" opacity="0.55"/>
<!-- Cover border inset -->
<rect x="162" y="110" width="216" height="292" rx="6" fill="none" stroke="#c8941a" stroke-width="1.5" opacity="0.22"/>
<!-- Crescent moon emblem -->
<defs>
<clipPath id="mc">
<circle cx="264" cy="240" r="84"/>
</clipPath>
</defs>
<circle cx="264" cy="240" r="84" fill="#c8941a" opacity="0.92"/>
<circle cx="295" cy="210" r="67" fill="#110e24" clip-path="url(#mc)"/>
<!-- Stars on cover -->
<circle cx="186" cy="142" r="5.5" fill="#c8941a" opacity="0.95"/>
<circle cx="356" cy="134" r="3.5" fill="#c8941a" opacity="0.85"/>
<circle cx="372" cy="156" r="2.5" fill="#c8941a" opacity="0.55"/>
<circle cx="178" cy="356" r="4" fill="#c8941a" opacity="0.75"/>
<circle cx="362" cy="346" r="4.5" fill="#c8941a" opacity="0.85"/>
<circle cx="348" cy="370" r="2.5" fill="#c8941a" opacity="0.50"/>
<!-- Title rule lines -->
<rect x="172" y="370" width="172" height="4" rx="2" fill="#c8941a" opacity="0.40"/>
<rect x="196" y="382" width="124" height="2.5" rx="1.5" fill="#c8941a" opacity="0.22"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

23
frontend/dist/index.html vendored Normal file
View File

@ -0,0 +1,23 @@
<!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="#09090f" />
<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=Lora:ital,wght@0,400;0,500;1,400;1,500&family=Special+Elite&display=swap" rel="stylesheet" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="manifest" href="/manifest.json" />
<title>Grimoire</title>
<script type="module" crossorigin src="/assets/index-0vatXfR-.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-By0z_IQf.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

17
frontend/dist/manifest.json vendored Normal file
View File

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

View File

@ -0,0 +1,436 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { api } from '../lib/api'
import { useToast } from './Toast'
import { useConfirm } from './ConfirmDialog'
const ROLES = ['protagonist', 'antagonist', 'supporting', 'minor', 'other']
const ROLE_LABELS = {
protagonist: 'Protagonist',
antagonist: 'Antagonist',
supporting: 'Supporting',
minor: 'Minor',
other: 'Other',
}
const EMPTY_FORM = { name: '', role: 'supporting', age: '', description: '', notes: '' }
export default function CharactersPanel({ storyId, open, onClose }) {
const toast = useToast()
const confirm = useConfirm()
const [characters, setCharacters] = useState([])
const [loaded, setLoaded] = useState(false)
const [activeChar, setActiveChar] = useState(null)
const [form, setForm] = useState(EMPTY_FORM)
const [charPhoto, setCharPhoto] = useState(null)
// Import state
const [importOpen, setImportOpen] = useState(false)
const [importStories, setImportStories] = useState([])
const [importSourceId, setImportSourceId] = useState(null)
const [importChars, setImportChars] = useState([])
const [importSelected, setImportSelected] = useState(new Set())
const [importLoadingChars,setImportLoadingChars]= useState(false)
const saveTimer = useRef(null)
const latestForm = useRef(EMPTY_FORM)
const latestPhoto = useRef(null)
const photoRef = useRef()
useEffect(() => {
if (open && !loaded) {
api.getCharacters(storyId)
.then(cs => { setCharacters(cs); setLoaded(true) })
.catch(() => toast('Could not load characters', 'error'))
}
}, [open, loaded, storyId, toast])
useEffect(() => {
if (!open) return
function onKey(e) {
if (e.key !== 'Escape') return
e.stopPropagation()
if (importOpen) {
setImportOpen(false)
} else if (activeChar) {
flushAndClearTimer(activeChar.id)
setActiveChar(null)
} else {
onClose()
}
}
document.addEventListener('keydown', onKey, true)
return () => document.removeEventListener('keydown', onKey, true)
}, [open, activeChar, importOpen, onClose])
const saveChar = useCallback(async (charId) => {
if (!charId) return
try {
const updated = await api.updateCharacter(storyId, charId, {
...latestForm.current,
photo: latestPhoto.current,
})
setCharacters(prev => prev.map(c => c.id === charId ? updated : c))
} catch {
toast('Could not save character', 'error')
}
}, [storyId, toast])
function scheduleSave(charId) {
clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => saveChar(charId), 1500)
}
function flushAndClearTimer(charId) {
clearTimeout(saveTimer.current)
saveChar(charId)
}
useEffect(() => () => clearTimeout(saveTimer.current), [])
function openChar(char) {
if (activeChar && activeChar.id !== char.id) flushAndClearTimer(activeChar.id)
const f = {
name: char.name || '',
role: char.role || 'supporting',
age: char.age || '',
description: char.description || '',
notes: char.notes || '',
}
latestForm.current = f
latestPhoto.current = char.photo ?? null
setForm(f)
setCharPhoto(char.photo ?? null)
setActiveChar(char)
}
async function createCharacter() {
try {
const char = await api.createCharacter(storyId, { name: 'New Character', role: 'supporting' })
setCharacters(prev => [...prev, char].sort((a, b) => a.name.localeCompare(b.name)))
openChar(char)
} catch {
toast('Could not create character', 'error')
}
}
function handleFormChange(field, value) {
const updated = { ...latestForm.current, [field]: value }
latestForm.current = updated
setForm(updated)
scheduleSave(activeChar.id)
}
async function handlePhotoUpload(e) {
const file = e.target.files[0]
if (!file) return
e.target.value = ''
try {
const url = await api.uploadImage(file)
latestPhoto.current = url
setCharPhoto(url)
scheduleSave(activeChar.id)
} catch {
toast('Photo upload failed', 'error')
}
}
async function removePhoto() {
latestPhoto.current = null
setCharPhoto(null)
scheduleSave(activeChar.id)
}
function backToList() {
flushAndClearTimer(activeChar.id)
setActiveChar(null)
}
async function deleteChar() {
const ok = await confirm(`Delete "${activeChar.name || 'this character'}"?`, {
confirmLabel: 'Delete',
danger: true,
})
if (!ok) return
try {
clearTimeout(saveTimer.current)
await api.deleteCharacter(storyId, activeChar.id)
setCharacters(prev => prev.filter(c => c.id !== activeChar.id))
setActiveChar(null)
} catch {
toast('Could not delete character', 'error')
}
}
function handleClose() {
if (activeChar) flushAndClearTimer(activeChar.id)
setImportOpen(false)
onClose()
}
// Import
async function openImport() {
try {
const stories = await api.getStories()
setImportStories(stories.filter(s => s.id !== parseInt(storyId)))
setImportSourceId(null)
setImportChars([])
setImportSelected(new Set())
setImportOpen(true)
} catch {
toast('Could not load stories', 'error')
}
}
async function selectImportStory(id) {
setImportSourceId(id)
setImportSelected(new Set())
setImportLoadingChars(true)
try {
setImportChars(await api.getCharacters(id))
} catch {
toast('Could not load characters from that story', 'error')
} finally {
setImportLoadingChars(false)
}
}
function toggleImportChar(id) {
setImportSelected(prev => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
return next
})
}
async function doImport() {
if (!importSourceId || importSelected.size === 0) return
try {
const imported = await api.importCharacters(storyId, importSourceId, [...importSelected])
setCharacters(prev => [...prev, ...imported].sort((a, b) => a.name.localeCompare(b.name)))
setImportOpen(false)
toast(`Imported ${imported.length} character${imported.length !== 1 ? 's' : ''}`)
} catch {
toast('Could not import characters', 'error')
}
}
// Render
return (
<>
{open && <div className="panel-backdrop" onClick={handleClose} />}
<aside className={`characters-panel${open ? ' open' : ''}`} aria-label="Characters">
{/* ── Import overlay ── */}
{importOpen && (
<div className="chars-import-overlay">
<div className="chars-panel-header">
<span className="chars-panel-title">Import Characters</span>
<button className="chapter-panel-close" onClick={() => setImportOpen(false)} aria-label="Close">×</button>
</div>
<div className="chars-import-body">
{importStories.length === 0 ? (
<p className="chars-empty-msg">No other stories found.</p>
) : (
<>
<p className="chars-import-label">Pick a story:</p>
<ul className="chars-import-story-list">
{importStories.map(s => (
<li key={s.id}>
<button
className={`chars-import-story-btn${importSourceId === s.id ? ' active' : ''}`}
onClick={() => selectImportStory(s.id)}
>
{s.title || 'Untitled Story'}
</button>
</li>
))}
</ul>
{importSourceId && (
<>
<p className="chars-import-label">Select characters:</p>
{importLoadingChars ? (
<p className="chars-empty-msg">Loading</p>
) : importChars.length === 0 ? (
<p className="chars-empty-msg">No characters in this story.</p>
) : (
<ul className="chars-import-char-list">
{importChars.map(c => (
<li key={c.id}>
<label className="chars-import-char-item">
<input
type="checkbox"
checked={importSelected.has(c.id)}
onChange={() => toggleImportChar(c.id)}
/>
{c.photo && <img src={c.photo} alt="" className="chars-import-char-photo" />}
<span className="chars-import-char-name">{c.name}</span>
<span className={`char-role-badge char-role--${c.role}`}>
{ROLE_LABELS[c.role] || c.role}
</span>
</label>
</li>
))}
</ul>
)}
</>
)}
</>
)}
</div>
{importSelected.size > 0 && (
<div className="chars-import-footer">
<button className="btn btn-primary chars-import-btn" onClick={doImport}>
Import {importSelected.size} character{importSelected.size !== 1 ? 's' : ''}
</button>
</div>
)}
</div>
)}
{/* ── Edit view ── */}
{activeChar ? (
<div className="char-edit-view">
<div className="chars-panel-header">
<button className="note-back-btn btn btn-ghost" onClick={backToList}> Characters</button>
<button className="chapter-panel-close" onClick={handleClose} aria-label="Close">×</button>
</div>
<div className="char-edit-scroll">
{/* Photo */}
<div className="char-photo-area">
{charPhoto ? (
<div className="char-photo-preview">
<img src={charPhoto} alt="Character photo" className="char-photo-img" />
<div className="char-photo-actions">
<button className="btn btn-ghost char-photo-btn" onClick={() => photoRef.current.click()}>Change</button>
<button className="btn btn-ghost char-photo-btn" onClick={removePhoto}>Remove</button>
</div>
</div>
) : (
<button className="char-photo-add" onClick={() => photoRef.current.click()}>
+ Add photo
</button>
)}
<input ref={photoRef} type="file" accept="image/*" onChange={handlePhotoUpload} style={{ display: 'none' }} />
</div>
{/* Form fields */}
<div className="char-form">
<label className="char-field">
<span className="char-field-label">Name</span>
<input
className="input"
value={form.name}
onChange={e => handleFormChange('name', e.target.value)}
placeholder="Character name…"
/>
</label>
<label className="char-field">
<span className="char-field-label">Role</span>
<select
className="input"
value={form.role}
onChange={e => handleFormChange('role', e.target.value)}
>
{ROLES.map(r => <option key={r} value={r}>{ROLE_LABELS[r]}</option>)}
</select>
</label>
<label className="char-field">
<span className="char-field-label">Age</span>
<input
className="input"
value={form.age}
onChange={e => handleFormChange('age', e.target.value)}
placeholder="e.g. 30s, ancient, unknown…"
/>
</label>
<label className="char-field">
<span className="char-field-label">Description</span>
<textarea
className="input char-textarea"
value={form.description}
onChange={e => handleFormChange('description', e.target.value)}
placeholder="Appearance, personality…"
rows={5}
/>
</label>
<label className="char-field">
<span className="char-field-label">Notes</span>
<textarea
className="input char-textarea"
value={form.notes}
onChange={e => handleFormChange('notes', e.target.value)}
placeholder="Backstory, plot notes…"
rows={5}
/>
</label>
</div>
</div>
<div className="note-edit-footer">
<button className="btn btn-ghost note-delete-btn" onClick={deleteChar}>Delete character</button>
</div>
</div>
) : (
/* ── List view ── */
<>
<div className="chars-panel-header">
<span className="chars-panel-title">Characters</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<button className="note-new-btn btn btn-ghost" onClick={openImport} title="Import from another story">
Import
</button>
<button className="note-new-btn btn btn-ghost" onClick={createCharacter} title="New character">
+ New
</button>
<button className="chapter-panel-close" onClick={handleClose} aria-label="Close">×</button>
</div>
</div>
{!loaded ? (
<p className="chapter-panel-empty">Loading</p>
) : characters.length === 0 ? (
<div className="notes-empty">
<p className="notes-empty-msg">No characters yet.</p>
<button className="btn btn-primary notes-empty-cta" onClick={createCharacter}>
+ Create first character
</button>
</div>
) : (
<ol className="chars-list">
{characters.map(char => (
<li key={char.id}>
<button className="char-item" onClick={() => openChar(char)}>
{char.photo && <img src={char.photo} alt="" className="char-item-photo" />}
<div className="char-item-info">
<span className="char-item-name">{char.name || 'Unnamed'}</span>
<span className={`char-role-badge char-role--${char.role}`}>
{ROLE_LABELS[char.role] || char.role}
</span>
{char.description && (
<span className="char-item-snippet">
{char.description.slice(0, 80)}{char.description.length > 80 ? '…' : ''}
</span>
)}
</div>
</button>
</li>
))}
</ol>
)}
</>
)}
</aside>
</>
)
}

View File

@ -78,6 +78,12 @@ export const api = {
updateNote: (storyId, noteId, data) => req('PUT', `/api/stories/${storyId}/notes/${noteId}`, data),
deleteNote: (storyId, noteId) => req('DELETE', `/api/stories/${storyId}/notes/${noteId}`),
getCharacters: (storyId) => req('GET', `/api/stories/${storyId}/characters`),
createCharacter: (storyId, data) => req('POST', `/api/stories/${storyId}/characters`, data),
updateCharacter: (storyId, charId, data) => req('PUT', `/api/stories/${storyId}/characters/${charId}`, data),
deleteCharacter: (storyId, charId) => req('DELETE', `/api/stories/${storyId}/characters/${charId}`),
importCharacters: (storyId, fromId, ids) => req('POST', `/api/stories/${storyId}/characters/import-from`, { from_story_id: fromId, character_ids: ids }),
lintCheck: (text, language = 'en-US') => req('POST', '/api/lint/check', { text, language }),
getPrompt: () => req('GET', '/api/prompts'),

View File

@ -10,6 +10,7 @@ import { useConfirm } from '../components/ConfirmDialog'
import Editor from '../components/Editor'
import ChapterPanel from '../components/ChapterPanel'
import NotesPanel from '../components/NotesPanel'
import CharactersPanel from '../components/CharactersPanel'
const MIN_FONT = 13
const MAX_FONT = 26
@ -53,8 +54,9 @@ export default function EditorPage() {
const [content, setContent] = useState({})
const [saveStatus, setSaveStatus] = useState('saved')
const [chapterOpen, setChapterOpen] = useState(false)
const [notesOpen, setNotesOpen] = useState(false)
const [chapterOpen, setChapterOpen] = useState(false)
const [notesOpen, setNotesOpen] = useState(false)
const [charactersOpen, setCharactersOpen] = useState(false)
const [exportOpen, setExportOpen] = useState(false)
const [focusMode, setFocusMode] = useState(false)
const [promptText, setPromptText] = useState(null)
@ -144,6 +146,7 @@ export default function EditorPage() {
setFocusMode(false)
setChapterOpen(false)
setNotesOpen(false)
setCharactersOpen(false)
setExportOpen(false)
setPromptText(null)
}
@ -250,6 +253,7 @@ export default function EditorPage() {
<div className={`editor-page${focusMode ? ' focus-mode' : ''}${chapterOpen ? ' panel-open' : ''}`}>
<ChapterPanel content={content} open={chapterOpen} onClose={() => setChapterOpen(false)} />
<NotesPanel storyId={id} open={notesOpen} onClose={() => setNotesOpen(false)} />
<CharactersPanel storyId={id} open={charactersOpen} onClose={() => setCharactersOpen(false)} />
<div className="editor-main">
<div className="editor-topbar">
@ -265,6 +269,11 @@ export default function EditorPage() {
onClick={() => setNotesOpen(o => !o)}
title="Toggle notes"
> <span>Notes</span></button>
<button
className={`btn btn-ghost${charactersOpen ? ' active' : ''}`}
onClick={() => setCharactersOpen(o => !o)}
title="Toggle characters"
>👤 <span>Characters</span></button>
<button
className={`btn btn-ghost${focusMode ? ' active' : ''}`}
onClick={() => setFocusMode(f => !f)}

View File

@ -848,6 +848,338 @@ button { cursor: pointer; font-family: inherit; }
.notes-panel { width: 100%; max-width: 340px; }
}
/* ── Characters Panel ─────────────────────────────────── */
.characters-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: 299;
transform: translateX(100%);
transition: transform 0.25s ease;
overflow: hidden;
}
.characters-panel.open {
transform: translateX(0);
}
.focus-mode .characters-panel { display: none; }
@media (max-width: 768px) {
.characters-panel { width: 100%; max-width: 340px; }
}
.chars-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;
}
.chars-panel-title {
font-family: var(--font-head);
font-size: 0.8rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--accent-hi);
flex-shrink: 0;
}
/* ── Characters list ── */
.chars-list {
list-style: none;
flex: 1;
overflow-y: auto;
}
.char-item {
width: 100%;
background: transparent;
border: none;
padding: 0.7rem 1rem;
text-align: left;
cursor: pointer;
display: flex;
align-items: flex-start;
gap: 0.7rem;
border-bottom: 1px solid var(--border);
transition: background 0.14s;
}
.char-item:hover { background: var(--bg-raised); }
.char-item-photo {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
border: 1px solid var(--border);
}
.char-item-info {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
flex: 1;
}
.char-item-name {
font-size: 0.88rem;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: var(--font-head);
letter-spacing: 0.02em;
}
.char-item-snippet {
font-size: 0.75rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-style: italic;
}
/* ── Role badge ── */
.char-role-badge {
display: inline-block;
font-size: 0.65rem;
font-family: var(--font-head);
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 0.1rem 0.4rem;
border-radius: 3px;
border: 1px solid transparent;
flex-shrink: 0;
}
.char-role--protagonist { color: var(--accent-hi); border-color: var(--accent); background: rgba(0,0,0,0.2); }
.char-role--antagonist { color: #c45; border-color: #833; background: rgba(0,0,0,0.2); }
.char-role--supporting { color: var(--text-muted); border-color: var(--border); background: transparent; }
.char-role--minor { color: var(--text-muted); border-color: var(--border); background: transparent; opacity: 0.7; }
.char-role--other { color: var(--text-muted); border-color: var(--border); background: transparent; }
/* ── Character edit view ── */
.char-edit-view {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
.char-edit-scroll {
flex: 1;
overflow-y: auto;
padding-bottom: 1rem;
}
.char-photo-area {
padding: 1rem 1rem 0;
}
.char-photo-preview {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.char-photo-img {
width: 72px;
height: 72px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--border);
flex-shrink: 0;
}
.char-photo-actions {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.char-photo-btn {
font-size: 0.68rem;
padding: 0.2rem 0.55rem;
}
.char-photo-add {
display: inline-block;
font-size: 0.75rem;
font-family: var(--font-head);
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
background: var(--bg-raised);
border: 1px dashed var(--border);
border-radius: var(--radius);
padding: 0.5rem 0.875rem;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
margin-bottom: 0.5rem;
}
.char-photo-add:hover { color: var(--text); border-color: var(--text-muted); }
.char-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
}
.char-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.char-field-label {
font-family: var(--font-head);
font-size: 0.65rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.char-textarea {
resize: vertical;
min-height: 80px;
line-height: 1.55;
}
/* ── Import overlay ── */
.chars-import-overlay {
position: absolute;
inset: 0;
background: var(--bg-surface);
z-index: 10;
display: flex;
flex-direction: column;
overflow: hidden;
}
.chars-import-body {
flex: 1;
overflow-y: auto;
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.chars-empty-msg {
font-size: 0.82rem;
color: var(--text-muted);
font-style: italic;
}
.chars-import-label {
font-family: var(--font-head);
font-size: 0.65rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
margin-top: 0.5rem;
}
.chars-import-story-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.chars-import-story-btn {
width: 100%;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 0.84rem;
font-family: var(--font-body);
padding: 0.45rem 0.75rem;
text-align: left;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chars-import-story-btn:hover { border-color: var(--text-muted); }
.chars-import-story-btn.active { border-color: var(--accent-hi); background: var(--bg); }
.chars-import-char-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.chars-import-char-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.6rem;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: border-color 0.15s;
}
.chars-import-char-item:hover { border-color: var(--text-muted); }
.chars-import-char-item input[type="checkbox"] {
accent-color: var(--accent-hi);
flex-shrink: 0;
}
.chars-import-char-photo {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
border: 1px solid var(--border);
}
.chars-import-char-name {
flex: 1;
font-size: 0.84rem;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chars-import-footer {
flex-shrink: 0;
padding: 0.75rem 1rem;
border-top: 1px solid var(--border);
}
.chars-import-btn {
width: 100%;
font-size: 0.78rem;
}
/* ── Export Dropdown ──────────────────────────────────── */
.export-wrap { position: relative; }

View File

@ -82,6 +82,20 @@ db.exec(`
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS characters (
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,
name TEXT NOT NULL DEFAULT 'New Character',
role TEXT NOT NULL DEFAULT 'supporting',
age TEXT DEFAULT '',
description TEXT DEFAULT '',
notes TEXT DEFAULT '',
photo TEXT DEFAULT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`)
// Migrate: rename email -> username for existing databases
@ -90,4 +104,10 @@ if (userCols.includes('email') && !userCols.includes('username')) {
db.exec('ALTER TABLE users RENAME COLUMN email TO username')
}
// Migrate: add photo column to characters if missing (pre-existing table)
const charCols = db.pragma('table_info(characters)').map(c => c.name)
if (charCols.length > 0 && !charCols.includes('photo')) {
db.exec('ALTER TABLE characters ADD COLUMN photo TEXT DEFAULT NULL')
}
export default db

View File

@ -6,8 +6,9 @@ import storiesRoutes from './routes/stories.js'
import imagesRoutes from './routes/images.js'
import adminRoutes from './routes/admin.js'
import promptsRoutes from './routes/prompts.js'
import notesRoutes from './routes/notes.js'
import lintRoutes from './routes/lint.js'
import notesRoutes from './routes/notes.js'
import charactersRoutes from './routes/characters.js'
import lintRoutes from './routes/lint.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const app = express()
@ -20,7 +21,8 @@ app.use('/api/stories', storiesRoutes)
app.use('/api/images', imagesRoutes)
app.use('/api/admin', adminRoutes)
app.use('/api/prompts', promptsRoutes)
app.use('/api/stories/:storyId/notes', notesRoutes)
app.use('/api/stories/:storyId/notes', notesRoutes)
app.use('/api/stories/:storyId/characters', charactersRoutes)
app.use('/api/lint', lintRoutes)
app.listen(3000, () => console.log('Server ready on :3000'))

View File

@ -0,0 +1,91 @@
import { Router } from 'express'
import db from '../db.js'
import { auth } from '../middleware/auth.js'
const router = Router({ mergeParams: true })
router.use(auth)
function getStory(storyId, userId) {
return db.prepare('SELECT id FROM stories WHERE id = ? AND user_id = ?').get(storyId, userId)
}
// GET /api/stories/:storyId/characters
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 characters WHERE story_id = ? AND user_id = ? ORDER BY name ASC'
).all(req.params.storyId, req.user.id)
res.json(rows)
})
// POST /api/stories/:storyId/characters
router.post('/', (req, res) => {
if (!getStory(req.params.storyId, req.user.id)) return res.status(404).json({ error: 'Not found' })
const { name = 'New Character', role = 'supporting', age = '', description = '', notes = '', photo = null } = req.body || {}
const { lastInsertRowid } = db.prepare(
'INSERT INTO characters (story_id, user_id, name, role, age, description, notes, photo) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(req.params.storyId, req.user.id, name, role, age, description, notes, photo)
res.json(db.prepare('SELECT * FROM characters WHERE id = ?').get(lastInsertRowid))
})
// POST /api/stories/:storyId/characters/import-from
router.post('/import-from', (req, res) => {
if (!getStory(req.params.storyId, req.user.id)) return res.status(404).json({ error: 'Not found' })
const { from_story_id, character_ids } = req.body || {}
if (!from_story_id || !Array.isArray(character_ids) || character_ids.length === 0)
return res.status(400).json({ error: 'Missing from_story_id or character_ids' })
if (!getStory(from_story_id, req.user.id)) return res.status(404).json({ error: 'Source story not found' })
const placeholders = character_ids.map(() => '?').join(',')
const sourceChars = db.prepare(
`SELECT * FROM characters WHERE id IN (${placeholders}) AND story_id = ? AND user_id = ?`
).all(...character_ids, from_story_id, req.user.id)
const insert = db.prepare(
'INSERT INTO characters (story_id, user_id, name, role, age, description, notes, photo) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
)
const inserted = db.transaction(() =>
sourceChars.map(c => {
const { lastInsertRowid } = insert.run(
req.params.storyId, req.user.id, c.name, c.role, c.age, c.description, c.notes, c.photo ?? null
)
return db.prepare('SELECT * FROM characters WHERE id = ?').get(lastInsertRowid)
})
)()
res.json(inserted)
})
// PUT /api/stories/:storyId/characters/:charId
router.put('/:charId', (req, res) => {
const char = db.prepare(
'SELECT * FROM characters WHERE id = ? AND story_id = ? AND user_id = ?'
).get(req.params.charId, req.params.storyId, req.user.id)
if (!char) return res.status(404).json({ error: 'Not found' })
const { name, role, age, description, notes, photo } = req.body || {}
db.prepare(
'UPDATE characters SET name = ?, role = ?, age = ?, description = ?, notes = ?, photo = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
).run(
name !== undefined ? name : char.name,
role !== undefined ? role : char.role,
age !== undefined ? age : char.age,
description !== undefined ? description : char.description,
notes !== undefined ? notes : char.notes,
photo !== undefined ? photo : char.photo,
char.id
)
res.json(db.prepare('SELECT * FROM characters WHERE id = ?').get(char.id))
})
// DELETE /api/stories/:storyId/characters/:charId
router.delete('/:charId', (req, res) => {
const char = db.prepare(
'SELECT * FROM characters WHERE id = ? AND story_id = ? AND user_id = ?'
).get(req.params.charId, req.params.storyId, req.user.id)
if (!char) return res.status(404).json({ error: 'Not found' })
db.prepare('DELETE FROM characters WHERE id = ?').run(char.id)
res.json({ ok: true })
})
export default router