Field notebook: capture NPC lines, tear pages onto the board

A dialogue line gains a "✎ Note this" capture that saves it to a
per-playthrough notebook (migration 035 + notebook endpoints). The inventory
notebook lists captured pages in a handwriting font (Google "Reenie Beanie")
and "✂ Tear to board" drops a page onto the current board as a note exhibit
(reusing addNote), then removes the page. Board notes render handwritten too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 20:11:15 +02:00
co-authored by Claude Opus 4.8
parent 90ef7b25e8
commit e69558e0e7
9 changed files with 99 additions and 15 deletions
+23 -6
View File
@@ -85,13 +85,13 @@ function ToolRack({ index }: { index: number }) {
return <div ref={stageRef} className="inv-stage" />
}
export function Inventory({ onClose, session }: { onClose?: () => void; session?: PhoneSession }) {
export function Inventory({ onClose, session, onTearToBoard }: { onClose?: () => void; session?: PhoneSession; onTearToBoard?: (text: string) => void }) {
const [index, setIndex] = useState(0)
const [active, setActive] = useState<string | null>(null)
if (active) return <div className="inv-tool">
<button className="inv-back" onClick={() => setActive(null)}> TOOLS</button>
{active === 'phone' ? <PhonePreview session={session} /> : <NotebookTool />}
{active === 'phone' ? <PhonePreview session={session} /> : <NotebookTool playthroughId={session?.playthroughId} onTear={onTearToBoard} />}
</div>
return <div className="inv-backdrop">
@@ -108,13 +108,30 @@ export function Inventory({ onClose, session }: { onClose?: () => void; session?
</div>
}
// Placeholder notebook tool — a lined page you can scribble on (not yet persisted).
function NotebookTool() {
const [text, setText] = useState('')
// Field notebook: the lines captured from NPCs during play. A page can be torn off
// onto the board as a note (when a board tear handler is available).
function NotebookTool({ playthroughId, onTear }: { playthroughId?: string; onTear?: (text: string) => void }) {
const [pages, setPages] = useState<{ id: string; text: string }[]>([])
useEffect(() => {
if (!playthroughId) return
fetch(`/api/playthroughs/${playthroughId}/notebook`).then(r => r.ok ? r.json() : []).then(setPages).catch(() => {})
}, [playthroughId])
const remove = async (id: string) => {
if (playthroughId) await fetch(`/api/playthroughs/${playthroughId}/notebook/${id}`, { method: 'DELETE' })
setPages(list => list.filter(page => page.id !== id))
}
const tear = (page: { id: string; text: string }) => { onTear?.(page.text); void remove(page.id) }
return <div className="notebook">
<div className="notebook-page">
<div className="notebook-head">FIELD NOTEBOOK</div>
<textarea value={text} onChange={e => setText(e.target.value)} placeholder="Jot a lead…" spellCheck={false} />
{!pages.length && <p className="notebook-empty">Nothing noted yet. During a conversation, use Note this to jot down what someone says.</p>}
{pages.map(page => <div key={page.id} className="notebook-note">
<p>{page.text}</p>
<div className="notebook-note-actions">
{onTear && <button onClick={() => tear(page)} title="Tear off onto the board"> Tear to board</button>}
<button className="ghost" onClick={() => void remove(page.id)} title="Discard">×</button>
</div>
</div>)}
</div>
</div>
}