Surface arcs, character beats and open questions in the web client

The character page gains three sections under the dossier: the arc as an
editable ordered table with each stage pinnable to a chapter, every beat the
character appears in across the book (each row linking into that chapter's
outline), and the character's open questions. The sidebar groups main
characters above supporting ones, and both the sheet and the add dialog let you
set importance.

The arc section shows for main characters, and also for supporting ones that
already have stages — demoting someone should not hide work they thought they
had lost.

The outline page gains a notes section and an open-questions section at the
bottom. Beat rows are now anchored so the character page can link straight to a
row. Raising a question from either page attaches it to what that page is
about, and the section hides the association it is already scoped to rather
than repeating "Landfall" on every row.

Also fixes an ordering wart the browser run exposed: both CharacterRole and
CharacterImportance are stored as text, so ordering them in SQL ordered the
spelling — "Deuteragonist" beat "Protagonist" and the sidebar put the second
lead above the character the book is about. Listing now sorts after
materialising, which uses the enums' declaration order. The test for it was
checked both ways: it fails on the SQL ordering and passes on the fix.

73 tests pass, the web client builds and lints clean. Driven in a browser
end to end: resolving a question with "also add to notes" drops it off the open
list and appends the decision under the chapter's existing note, "show
resolved" brings it back with a Reopen button, and a beat link on the character
page lands on the right chapter outline at that beat's anchor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
This commit is contained in:
James Wampler
2026-08-06 12:11:20 -07:00
co-authored by Claude Opus 5
parent 0358667679
commit 4f396bb5f9
10 changed files with 840 additions and 29 deletions
+18 -4
View File
@@ -8,16 +8,30 @@ namespace Novelly.Api.Characters;
public class CharacterService(INovelDbContext db, TagService tags)
{
/// <summary>
/// Main characters first, then by the part they play, then by name.
/// </summary>
/// <remarks>
/// The ordering is done in memory on purpose. Both enums are stored as text, so sorting
/// them in SQL sorts the spelling — which puts Deuteragonist above Protagonist and buries
/// the character the book is about. Sorting after materialising uses the declaration
/// order, which is the significance order these enums are written in. A project's cast is
/// small enough that this costs nothing.
/// </remarks>
public async Task<IReadOnlyList<CharacterDto>> ListAsync(Guid projectId, CancellationToken ct = default)
{
var characters = await Query()
.Where(c => c.ProjectId == projectId)
.OrderBy(c => c.Importance)
.ThenBy(c => c.Role)
.ThenBy(c => c.Name)
.ToListAsync(ct);
return [.. characters.Select(c => c.ToDto())];
return
[
.. characters
.OrderBy(c => c.Importance)
.ThenBy(c => c.Role)
.ThenBy(c => c.Name)
.Select(c => c.ToDto())
];
}
public async Task<CharacterDto> GetAsync(Guid id, CancellationToken ct = default) =>
+128
View File
@@ -2,12 +2,15 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { api } from './client'
import type {
AgentTurn,
ArcStage,
Chapter,
ChapterSummary,
Character,
CharacterBeat,
Conversation,
ConversationSummary,
Beat,
OpenQuestion,
Project,
ProjectSummary,
Scene,
@@ -21,7 +24,9 @@ export const keys = {
characters: (projectId: string) => ['projects', projectId, 'characters'] as const,
tags: (projectId: string) => ['projects', projectId, 'tags'] as const,
tagRefs: (tagId: string) => ['tags', tagId, 'references'] as const,
characterBeats: (characterId: string) => ['characters', characterId, 'beats'] as const,
chapters: (projectId: string) => ['projects', projectId, 'chapters'] as const,
questions: (projectId: string) => ['projects', projectId, 'questions'] as const,
chapter: (id: string) => ['chapters', id] as const,
conversations: (projectId: string) => ['projects', projectId, 'conversations'] as const,
conversation: (id: string) => ['conversations', id] as const,
@@ -103,6 +108,128 @@ export function useDeleteCharacter(projectId: string) {
})
}
/**
* Every beat this character appears in, across the whole book. Kept separate from the
* dossier because it is derived from the outlines — what they actually do on the page.
*/
export const useCharacterBeats = (characterId: string | undefined) =>
useQuery({
queryKey: keys.characterBeats(characterId ?? ''),
queryFn: () => api.get<CharacterBeat[]>(`/api/characters/${characterId}/beats`),
enabled: Boolean(characterId),
})
// --- Character arcs ----------------------------------------------------------
export function useCreateArcStage(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ characterId, ...body }: { characterId: string; title: string; description?: string; chapterId?: string }) =>
api.post<ArcStage>(`/api/characters/${characterId}/arc`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
})
}
export function useUpdateArcStage(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...body }: { id: string; title?: string; description?: string; chapterId?: string }) =>
api.patch<ArcStage>(`/api/arc-stages/${id}`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
})
}
export function useDeleteArcStage(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/api/arc-stages/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
})
}
export function useReorderArcStages(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ characterId, stageIds }: { characterId: string; stageIds: string[] }) =>
api.post<ArcStage[]>(`/api/characters/${characterId}/arc/reorder`, { stageIds }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
})
}
// --- Open questions ----------------------------------------------------------
/**
* The project's undecided questions. Filters narrow to one chapter outline or character;
* resolved ones are left out unless asked for, since the list is about what is still open.
*/
export const useOpenQuestions = (
projectId: string,
filter: { chapterId?: string; characterId?: string; includeResolved?: boolean } = {},
) => {
const params = new URLSearchParams()
if (filter.chapterId) params.set('chapterId', filter.chapterId)
if (filter.characterId) params.set('characterId', filter.characterId)
if (filter.includeResolved) params.set('includeResolved', 'true')
const query = params.toString()
return useQuery({
queryKey: [...keys.questions(projectId), query] as const,
queryFn: () =>
api.get<OpenQuestion[]>(`/api/projects/${projectId}/questions${query ? `?${query}` : ''}`),
})
}
export function useRaiseQuestion(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: { question: string; detail?: string; chapterId?: string; characterId?: string }) =>
api.post<OpenQuestion>(`/api/projects/${projectId}/questions`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }),
})
}
export function useUpdateQuestion(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...body }: { id: string; question?: string; detail?: string }) =>
api.patch<OpenQuestion>(`/api/questions/${id}`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }),
})
}
/**
* Resolving can append the decision to the notes of whatever the question hangs off, so
* this invalidates the chapter and character caches as well as the question list.
*/
export function useResolveQuestion(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, resolution, appendToNotes }: { id: string; resolution: string; appendToNotes: boolean }) =>
api.post<OpenQuestion>(`/api/questions/${id}/resolve`, { resolution, appendToNotes }),
onSuccess: (question) => {
qc.invalidateQueries({ queryKey: keys.questions(projectId) })
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
if (question.chapterId) qc.invalidateQueries({ queryKey: keys.chapter(question.chapterId) })
},
})
}
export function useReopenQuestion(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.post<OpenQuestion>(`/api/questions/${id}/reopen`, {}),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }),
})
}
export function useDeleteQuestion(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/api/questions/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.questions(projectId) }),
})
}
// --- Tags --------------------------------------------------------------------
export const useTags = (projectId: string) =>
@@ -280,6 +407,7 @@ export function useSendAgentMessage(projectId: string) {
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
qc.invalidateQueries({ queryKey: keys.chapters(projectId) })
qc.invalidateQueries({ queryKey: keys.questions(projectId) })
qc.invalidateQueries({ queryKey: keys.project(projectId) })
},
})
+52
View File
@@ -21,6 +21,11 @@ export const characterRoles: CharacterRole[] = [
'Foil',
]
/** How much of the book a character carries. Separate from the part they play. */
export type CharacterImportance = 'Main' | 'Supporting'
export const characterImportances: CharacterImportance[] = ['Main', 'Supporting']
export type DraftStatus = 'Planned' | 'Outlined' | 'Drafted' | 'Revised' | 'Final'
export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final']
@@ -104,11 +109,39 @@ export interface Relationship {
description: string | null
}
/** One step in a main character's arc. Flat and ordered, like a chapter's beats. */
export interface ArcStage {
id: string
characterId: string
sortOrder: number
title: string
description: string | null
chapterId: string | null
chapterNumber: number | null
chapterTitle: string | null
updatedAt: string
}
/** A beat a character appears in, carrying its chapter so the page can link into the outline. */
export interface CharacterBeat {
id: string
chapterId: string
chapterNumber: number
chapterTitle: string
sortOrder: number
title: string
whatHappened: string | null
whatsNext: string | null
sceneId: string | null
sceneTitle: string | null
}
export interface Character {
id: string
projectId: string
name: string
role: CharacterRole
importance: CharacterImportance
age: string | null
pronouns: string | null
occupation: string | null
@@ -124,6 +157,7 @@ export interface Character {
notes: string | null
relationships: Relationship[]
tags: Tag[]
arcStages: ArcStage[]
updatedAt: string
}
@@ -169,6 +203,24 @@ export interface Chapter extends Omit<ChapterSummary, 'beatCount' | 'sceneCount'
updatedAt: string
}
/** Something the writer has not decided yet, hung off a chapter outline and/or a character. */
export interface OpenQuestion {
id: string
projectId: string
question: string
detail: string | null
chapterId: string | null
chapterNumber: number | null
chapterTitle: string | null
characterId: string | null
characterName: string | null
resolution: string | null
isResolved: boolean
resolvedAt: string | null
createdAt: string
updatedAt: string
}
export interface ToolCall {
name: string
input: string
@@ -0,0 +1,194 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import {
useChapters,
useCreateArcStage,
useDeleteArcStage,
useReorderArcStages,
useUpdateArcStage,
} from '../api/hooks'
import type { ArcStage, Character } from '../api/types'
import { AutoField, ErrorNote } from './ui'
/**
* A main character's arc: a flat ordered list of the changes they go through, the same
* shape as a chapter's beat table. Each stage can be pinned to the chapter it lands in.
*/
export function CharacterArc({
projectId,
character,
}: {
projectId: string
character: Character
}) {
const { data: chapters } = useChapters(projectId)
const create = useCreateArcStage(projectId)
const reorder = useReorderArcStages(projectId)
const [title, setTitle] = useState('')
const stages = character.arcStages
const submit = (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim()) return
create.mutate(
{ characterId: character.id, title: title.trim() },
{ onSuccess: () => setTitle('') },
)
}
const move = (index: number, delta: number) => {
const next = [...stages]
const [moved] = next.splice(index, 1)
next.splice(index + delta, 0, moved)
reorder.mutate({ characterId: character.id, stageIds: next.map((s) => s.id) })
}
return (
<section className="card mt-6 p-5">
<div className="mb-1 flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold">Arc</h3>
{character.importance !== 'Main' && (
<span className="text-xs muted">Usually kept for main characters</span>
)}
</div>
<p className="mb-3 text-xs muted">
The changes {character.name} goes through, in order. Pin a stage to the chapter it
lands in and it links into that outline.
</p>
{stages.length > 0 && (
<ol className="grid gap-2">
{stages.map((stage, index) => (
<ArcStageRow
key={stage.id}
projectId={projectId}
stage={stage}
chapters={chapters ?? []}
canMoveUp={index > 0}
canMoveDown={index < stages.length - 1}
onMove={(delta) => move(index, delta)}
/>
))}
</ol>
)}
<form onSubmit={submit} className="mt-3 flex gap-2">
<input
className="input flex-1"
placeholder="Add a stage — three to five words, e.g. “she stops covering for him”"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<button className="btn btn-primary shrink-0" disabled={!title.trim() || create.isPending}>
Add
</button>
</form>
{create.error && (
<div className="mt-2">
<ErrorNote error={create.error} />
</div>
)}
</section>
)
}
function ArcStageRow({
projectId,
stage,
chapters,
canMoveUp,
canMoveDown,
onMove,
}: {
projectId: string
stage: ArcStage
chapters: { id: string; number: number; title: string }[]
canMoveUp: boolean
canMoveDown: boolean
onMove: (delta: number) => void
}) {
const update = useUpdateArcStage(projectId)
const remove = useDeleteArcStage(projectId)
return (
<li
className="rounded-md px-3 py-2"
style={{ background: 'var(--surface-2, rgba(0,0,0,0.02))' }}
>
<div className="flex items-start gap-3">
<span className="mt-2 w-5 shrink-0 text-xs tabular-nums muted">{stage.sortOrder}</span>
<div className="grid min-w-0 flex-1 gap-2">
<AutoField
value={stage.title}
onCommit={(title) => title.trim() && update.mutate({ id: stage.id, title })}
/>
<AutoField
value={stage.description}
multiline
rows={2}
placeholder="What shifts here, and what it costs them."
onCommit={(description) => update.mutate({ id: stage.id, description })}
/>
<div className="flex flex-wrap items-center gap-2">
<select
className="input max-w-[16rem] py-1 text-xs"
value={stage.chapterId ?? ''}
onChange={(e) => update.mutate({ id: stage.id, chapterId: e.target.value })}
>
<option value="">Not pinned to a chapter</option>
{chapters.map((chapter) => (
<option key={chapter.id} value={chapter.id}>
Ch. {chapter.number} {chapter.title}
</option>
))}
</select>
{stage.chapterId && (
<Link
className="text-xs underline"
style={{ color: 'var(--accent)' }}
to={`/projects/${projectId}/chapters/${stage.chapterId}`}
>
Open outline
</Link>
)}
</div>
</div>
<div className="flex shrink-0 flex-col gap-1">
<button
className="btn px-2 py-0.5 text-xs"
disabled={!canMoveUp}
onClick={() => onMove(-1)}
aria-label="Move stage earlier"
>
</button>
<button
className="btn px-2 py-0.5 text-xs"
disabled={!canMoveDown}
onClick={() => onMove(1)}
aria-label="Move stage later"
>
</button>
<button
className="btn px-2 py-0.5 text-xs"
style={{ color: 'var(--accent)' }}
onClick={() => {
if (confirm(`Delete “${stage.title}” from the arc?`)) remove.mutate(stage.id)
}}
aria-label="Delete stage"
>
</button>
</div>
</div>
</li>
)
}
@@ -0,0 +1,74 @@
import { Link } from 'react-router-dom'
import { useCharacterBeats } from '../api/hooks'
import { ErrorNote, Spinner } from './ui'
/**
* Every beat this character appears in, in manuscript order. This is the dossier's
* reality check: what they actually do on the page, as opposed to what the sheet claims
* about them. Each row links into the beat's chapter outline.
*/
export function CharacterBeats({
projectId,
characterId,
characterName,
}: {
projectId: string
characterId: string
characterName: string
}) {
const { data: beats, isPending, error } = useCharacterBeats(characterId)
return (
<section className="card mt-6 p-5">
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold">
Beats
{beats && beats.length > 0 && <span className="muted font-normal"> · {beats.length}</span>}
</h3>
</div>
{isPending ? (
<Spinner label="Loading beats" />
) : error ? (
<ErrorNote error={error} />
) : beats?.length ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase muted">
<th className="py-1 pr-3 font-semibold">Chapter</th>
<th className="py-1 pr-3 font-semibold">Beat</th>
<th className="py-1 pr-3 font-semibold">What happened</th>
<th className="py-1 font-semibold">What&rsquo;s next</th>
</tr>
</thead>
<tbody>
{beats.map((beat) => (
<tr key={beat.id} className="align-top" style={{ borderTop: '1px solid var(--border)' }}>
<td className="py-2 pr-3 whitespace-nowrap">
<Link
className="underline"
style={{ color: 'var(--accent)' }}
to={`/projects/${projectId}/chapters/${beat.chapterId}#beat-${beat.id}`}
>
{beat.chapterNumber}.{beat.sortOrder}
</Link>
<div className="text-xs muted">{beat.chapterTitle}</div>
</td>
<td className="py-2 pr-3 font-medium">{beat.title}</td>
<td className="py-2 pr-3 muted">{beat.whatHappened}</td>
<td className="py-2 muted">{beat.whatsNext}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-sm muted">
{characterName} is not on any beat yet. Assign them a beat in a chapter outline
and it shows up here.
</p>
)}
</section>
)
}
@@ -0,0 +1,241 @@
import { useState } from 'react'
import {
useDeleteQuestion,
useOpenQuestions,
useRaiseQuestion,
useReopenQuestion,
useResolveQuestion,
} from '../api/hooks'
import type { OpenQuestion } from '../api/types'
import { ErrorNote, Spinner } from './ui'
/**
* The list of decisions still outstanding. The same section serves a chapter outline and
* a character page — `scope` decides both what it shows and what a new question is
* attached to, so raising one from the outline lands on that chapter without asking.
*/
export function OpenQuestions({
projectId,
scope,
}: {
projectId: string
scope: { chapterId?: string; characterId?: string }
}) {
const [showResolved, setShowResolved] = useState(false)
const [asking, setAsking] = useState(false)
const { data: questions, isPending, error } = useOpenQuestions(projectId, {
...scope,
includeResolved: showResolved,
})
const raise = useRaiseQuestion(projectId)
const [question, setQuestion] = useState('')
const [detail, setDetail] = useState('')
const submit = (e: React.FormEvent) => {
e.preventDefault()
if (!question.trim()) return
raise.mutate(
{ question: question.trim(), detail: detail.trim() || undefined, ...scope },
{
onSuccess: () => {
setQuestion('')
setDetail('')
setAsking(false)
},
},
)
}
const openCount = questions?.filter((q) => !q.isResolved).length ?? 0
return (
<section className="card mt-6 p-5">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold">
Open questions
{openCount > 0 && <span className="muted font-normal"> · {openCount}</span>}
</h3>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs muted">
<input
type="checkbox"
checked={showResolved}
onChange={(e) => setShowResolved(e.target.checked)}
/>
Show resolved
</label>
<button className="btn" onClick={() => setAsking((open) => !open)}>
{asking ? 'Cancel' : 'Ask'}
</button>
</div>
</div>
{asking && (
<form onSubmit={submit} className="mb-4 grid gap-2">
<input
className="input"
autoFocus
placeholder="What have you not decided yet?"
value={question}
onChange={(e) => setQuestion(e.target.value)}
/>
<textarea
className="input"
rows={2}
placeholder="The thinking around it — options, and what each costs. Optional."
value={detail}
onChange={(e) => setDetail(e.target.value)}
/>
{raise.error && <ErrorNote error={raise.error} />}
<div className="flex justify-end">
<button className="btn btn-primary" disabled={!question.trim() || raise.isPending}>
Add question
</button>
</div>
</form>
)}
{isPending ? (
<Spinner label="Loading questions" />
) : error ? (
<ErrorNote error={error} />
) : questions?.length ? (
<ul className="grid gap-2">
{questions.map((q) => (
<QuestionRow key={q.id} projectId={projectId} question={q} scope={scope} />
))}
</ul>
) : (
<p className="text-sm muted">
Nothing outstanding. Raise a question when you hit something you would otherwise
decide by guessing.
</p>
)}
</section>
)
}
function QuestionRow({
projectId,
question,
scope,
}: {
projectId: string
question: OpenQuestion
scope: { chapterId?: string; characterId?: string }
}) {
const resolve = useResolveQuestion(projectId)
const reopen = useReopenQuestion(projectId)
const remove = useDeleteQuestion(projectId)
const [resolving, setResolving] = useState(false)
const [resolution, setResolution] = useState('')
const [appendToNotes, setAppendToNotes] = useState(true)
const submit = (e: React.FormEvent) => {
e.preventDefault()
if (!resolution.trim()) return
resolve.mutate(
{ id: question.id, resolution: resolution.trim(), appendToNotes },
{
onSuccess: () => {
setResolution('')
setResolving(false)
},
},
)
}
// Only show an association the page is not already scoped to — on a chapter outline,
// "Landfall" on every row is noise.
const showsChapter = question.chapterId && !scope.chapterId
const showsCharacter = question.characterName && !scope.characterId
return (
<li
className="rounded-md px-3 py-2"
style={{
background: 'var(--surface-2, rgba(0,0,0,0.02))',
opacity: question.isResolved ? 0.7 : 1,
}}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className={`text-sm ${question.isResolved ? 'line-through' : 'font-medium'}`}>
{question.question}
</p>
{question.detail && <p className="mt-0.5 text-xs muted">{question.detail}</p>}
{(showsChapter || showsCharacter) && (
<p className="mt-1 text-xs muted">
{showsChapter && `Ch. ${question.chapterNumber} ${question.chapterTitle}`}
{showsChapter && showsCharacter && ' · '}
{showsCharacter && question.characterName}
</p>
)}
{question.isResolved && question.resolution && (
<p className="mt-1 text-xs" style={{ color: 'var(--accent)' }}>
Resolved: {question.resolution}
</p>
)}
</div>
<div className="flex shrink-0 gap-1">
{question.isResolved ? (
<button className="btn px-2 py-1 text-xs" onClick={() => reopen.mutate(question.id)}>
Reopen
</button>
) : (
<button
className="btn px-2 py-1 text-xs"
onClick={() => setResolving((open) => !open)}
>
{resolving ? 'Cancel' : 'Resolve'}
</button>
)}
<button
className="btn px-2 py-1 text-xs"
style={{ color: 'var(--accent)' }}
onClick={() => {
if (confirm('Delete this question? Resolving keeps the decision; deleting does not.')) {
remove.mutate(question.id)
}
}}
>
Delete
</button>
</div>
</div>
{resolving && (
<form onSubmit={submit} className="mt-2 grid gap-2">
<textarea
className="input"
rows={2}
autoFocus
placeholder="What did you decide?"
value={resolution}
onChange={(e) => setResolution(e.target.value)}
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<label className="flex items-center gap-1.5 text-xs muted">
<input
type="checkbox"
checked={appendToNotes}
onChange={(e) => setAppendToNotes(e.target.checked)}
/>
Also add to notes
</label>
<button className="btn btn-primary px-2 py-1 text-xs" disabled={!resolution.trim() || resolve.isPending}>
Resolve
</button>
</div>
{resolve.error && <ErrorNote error={resolve.error} />}
</form>
)}
</li>
)
}
+20 -1
View File
@@ -17,6 +17,7 @@ import {
import { draftStatuses, type Beat, type Chapter, type Scene } from '../api/types'
import { AutoField, ErrorNote, Select, Spinner, StatusBadge } from '../components/ui'
import { TagEditor } from '../components/TagEditor'
import { OpenQuestions } from '../components/OpenQuestions'
export default function ChapterPage() {
const { projectId = '', chapterId = '' } = useParams()
@@ -188,6 +189,23 @@ export default function ChapterPage() {
))}
</ul>
</section>
<section className="card mt-6 p-5">
<h3 className="mb-1 text-sm font-semibold">Notes</h3>
<p className="mb-3 text-xs muted">
Anything that does not belong in the outline itself continuity to watch, research
to do, decisions already made. Resolved questions land here too.
</p>
<AutoField
value={chapter.notes}
multiline
rows={5}
placeholder="Notes on this chapter."
onCommit={(notes) => patch({ notes })}
/>
</section>
<OpenQuestions projectId={projectId} scope={{ chapterId: chapter.id }} />
</div>
)
}
@@ -247,7 +265,8 @@ function BeatTable({
</thead>
<tbody>
{chapter.beats.map((beat, index) => (
<tr key={beat.id} style={{ borderBottom: '1px solid var(--line)' }}>
// Anchored so the character page's beat list can link straight to this row.
<tr key={beat.id} id={`beat-${beat.id}`} style={{ borderBottom: '1px solid var(--line)' }}>
<td className="px-2 py-2 align-top">
<div className="flex items-center gap-1">
<span className="w-4 text-xs muted">{index + 1}</span>
+61 -18
View File
@@ -7,9 +7,12 @@ import {
useTags,
useUpdateCharacter,
} from '../api/hooks'
import { characterRoles, type Character } from '../api/types'
import { characterImportances, characterRoles, type Character } from '../api/types'
import { AutoField, EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui'
import { TagEditor } from '../components/TagEditor'
import { CharacterArc } from '../components/CharacterArc'
import { CharacterBeats } from '../components/CharacterBeats'
import { OpenQuestions } from '../components/OpenQuestions'
export default function CharactersPage() {
const { projectId = '' } = useParams()
@@ -28,21 +31,31 @@ export default function CharactersPage() {
<button className="btn btn-primary w-full justify-center" onClick={() => setAdding(true)}>
Add character
</button>
{characters?.map((character) => (
<button
key={character.id}
onClick={() => setSelectedId(character.id)}
className="card px-3 py-2 text-left transition hover:shadow-sm"
style={
character.id === selected?.id
? { borderColor: 'var(--accent)', background: 'var(--accent-soft)' }
: undefined
}
>
<div className="truncate font-medium">{character.name}</div>
<div className="text-xs muted">{character.role}</div>
</button>
))}
{(['Main', 'Supporting'] as const).map((importance) => {
const group = characters?.filter((c) => c.importance === importance) ?? []
if (group.length === 0) return null
return (
<div key={importance} className="grid gap-2">
<h2 className="label mt-2 mb-0">{importance}</h2>
{group.map((character) => (
<button
key={character.id}
onClick={() => setSelectedId(character.id)}
className="card px-3 py-2 text-left transition hover:shadow-sm"
style={
character.id === selected?.id
? { borderColor: 'var(--accent)', background: 'var(--accent-soft)' }
: undefined
}
>
<div className="truncate font-medium">{character.name}</div>
<div className="text-xs muted">{character.role}</div>
</button>
))}
</div>
)
})}
</aside>
<section>
@@ -75,9 +88,10 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
update.mutate({ id: character.id, ...body })
return (
<>
<div className="card p-5">
<div className="mb-5 flex items-start justify-between gap-4">
<div className="grid flex-1 gap-3 sm:grid-cols-[1fr_12rem]">
<div className="grid flex-1 gap-3 sm:grid-cols-[1fr_10rem_9rem]">
<AutoField
label="Name"
value={character.name}
@@ -89,6 +103,12 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
options={characterRoles}
onChange={(role) => patch({ role })}
/>
<Select
label="Importance"
value={character.importance}
options={characterImportances}
onChange={(importance) => patch({ importance })}
/>
</div>
<button
className="btn mt-6"
@@ -218,6 +238,22 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
</div>
)}
</div>
{/* The arc is what a main character is for. Supporting characters keep the section —
hidden only when there is nothing in it — so promoting someone does not surprise
them with work they thought they had lost. */}
{(character.importance === 'Main' || character.arcStages.length > 0) && (
<CharacterArc projectId={projectId} character={character} />
)}
<CharacterBeats
projectId={projectId}
characterId={character.id}
characterName={character.name}
/>
<OpenQuestions projectId={projectId} scope={{ characterId: character.id }} />
</>
)
}
@@ -233,12 +269,13 @@ function AddCharacterModal({
const create = useCreateCharacter(projectId)
const [name, setName] = useState('')
const [role, setRole] = useState<Character['role']>('Supporting')
const [importance, setImportance] = useState<Character['importance']>('Supporting')
const submit = (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return
create.mutate(
{ name: name.trim(), role },
{ name: name.trim(), role, importance },
{
onSuccess: (character) => {
onCreated(character.id)
@@ -256,6 +293,12 @@ function AddCharacterModal({
<input className="input" autoFocus value={name} onChange={(e) => setName(e.target.value)} />
</label>
<Select label="Role" value={role} options={characterRoles} onChange={setRole} />
<Select
label="Importance"
value={importance}
options={characterImportances}
onChange={setImportance}
/>
{create.error && <ErrorNote error={create.error} />}
<div className="mt-1 flex justify-end gap-2">
<button type="button" className="btn" onClick={onClose}>