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:
co-authored by
Claude Opus 5
parent
0358667679
commit
4f396bb5f9
@@ -65,7 +65,7 @@ and everything else keeps working.
|
|||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet test # 44 tests
|
dotnet test # 73 tests
|
||||||
cd src/Novelly.Web && npm run build # typecheck + bundle
|
cd src/Novelly.Web && npm run build # typecheck + bundle
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -98,10 +98,12 @@ out of `appsettings.json` and use user-secrets or the environment.
|
|||||||
## The data model
|
## The data model
|
||||||
|
|
||||||
```
|
```
|
||||||
Project ──┬── Character ── CharacterRelationship
|
Project ──┬── Character ──┬── CharacterRelationship
|
||||||
|
│ └── CharacterArcStage (the arc: flat, ordered)
|
||||||
├── Chapter ──┬── Beat (the outline: flat, ordered)
|
├── Chapter ──┬── Beat (the outline: flat, ordered)
|
||||||
│ └── Scene (the prose)
|
│ └── Scene (the prose)
|
||||||
├── Tag (applied to characters, chapters and beats)
|
├── Tag (applied to characters, chapters and beats)
|
||||||
|
├── OpenQuestion (attached to a chapter and/or a character)
|
||||||
└── AgentConversation ── AgentMessage
|
└── AgentConversation ── AgentMessage
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -124,6 +126,29 @@ is for working out what happens, and a scene is where you write it. A beat's `Sc
|
|||||||
the optional link between them, and it is nullable in both directions — deleting a scene
|
the optional link between them, and it is nullable in both directions — deleting a scene
|
||||||
ungroups its beats rather than deleting the plan.
|
ungroups its beats rather than deleting the plan.
|
||||||
|
|
||||||
|
**Main characters carry the book; supporting characters hold it up.** A character's
|
||||||
|
`Importance` (`Main` or `Supporting`) is separate from their `Role` — role is the part they
|
||||||
|
play in the story, importance is how much of it they take, and a mentor can be either.
|
||||||
|
Characters start supporting and get promoted. The two together drive the ordering, so the
|
||||||
|
protagonist is always the first name on the list.
|
||||||
|
|
||||||
|
**A main character's arc is a table, not a paragraph.** `ArcSummary` still holds the
|
||||||
|
sentence version; `CharacterArcStage` breaks the same change into ordered steps, each
|
||||||
|
optionally pinned to the chapter it lands in. It is the same flat-and-ordered shape as the
|
||||||
|
beat table, for the same reason. Nothing refuses an arc on a supporting character —
|
||||||
|
demoting someone should not delete their work.
|
||||||
|
|
||||||
|
**The character page reads the outlines back.** `GET /api/characters/{id}/beats` returns
|
||||||
|
every beat a character appears in, in manuscript order, each carrying its chapter so the
|
||||||
|
page links straight into that chapter's outline. It is the dossier's reality check: what
|
||||||
|
they actually do on the page, as against what the sheet claims about them.
|
||||||
|
|
||||||
|
**Open questions are what you have not decided.** A question hangs off a chapter outline, a
|
||||||
|
character, both, or neither. It can be resolved, reopened or deleted, and resolving can
|
||||||
|
append the decision to the notes of whatever it was attached to — so a settled question ends
|
||||||
|
up where you re-read it rather than in a list you have stopped looking at. Resolved
|
||||||
|
questions drop off the list unless you ask for them.
|
||||||
|
|
||||||
**Tags cross-reference the book.** A tag is scoped to one project, unique by name
|
**Tags cross-reference the book.** A tag is scoped to one project, unique by name
|
||||||
(case-insensitively), and can be attached to any character, chapter or beat. Applying an
|
(case-insensitively), and can be attached to any character, chapter or beat. Applying an
|
||||||
unknown tag by name creates it, so tagging is one action rather than two. `GET
|
unknown tag by name creates it, so tagging is one action rather than two. `GET
|
||||||
@@ -134,8 +159,9 @@ motif or a thread across all three kinds at once.
|
|||||||
|
|
||||||
`NovelAgentService` runs the tool-use loop: it calls the Messages API, executes any tools
|
`NovelAgentService` runs the tool-use loop: it calls the Messages API, executes any tools
|
||||||
Claude asks for, feeds every result back in a single user turn, and repeats until Claude
|
Claude asks for, feeds every result back in a single user turn, and repeats until Claude
|
||||||
stops asking. It has 18 tools covering the brief, characters, chapter outlines (beats), scenes and
|
stops asking. It has 29 tools covering the brief, characters and their arcs, chapter outlines
|
||||||
tags — all of them going through the same application services the REST API uses.
|
(beats), scenes, tags and open questions — all of them going through the same application
|
||||||
|
services the REST API uses.
|
||||||
|
|
||||||
A few deliberate choices worth knowing about:
|
A few deliberate choices worth knowing about:
|
||||||
|
|
||||||
@@ -153,7 +179,7 @@ A few deliberate choices worth knowing about:
|
|||||||
|
|
||||||
## The MCP server
|
## The MCP server
|
||||||
|
|
||||||
A stdio MCP server exposing 26 tools over the same REST API. It holds no domain logic of
|
A stdio MCP server exposing 38 tools over the same REST API. It holds no domain logic of
|
||||||
its own — it is a second front end, not a second implementation.
|
its own — it is a second front end, not a second implementation.
|
||||||
|
|
||||||
Build it, then point your MCP client at the produced binary:
|
Build it, then point your MCP client at the produced binary:
|
||||||
@@ -190,6 +216,8 @@ rather than failing opaquely.
|
|||||||
| Beats | `GET\|POST /api/chapters/{id}/beats`, `POST /api/chapters/{id}/beats/reorder`, `GET\|PATCH\|DELETE /api/beats/{id}` |
|
| Beats | `GET\|POST /api/chapters/{id}/beats`, `POST /api/chapters/{id}/beats/reorder`, `GET\|PATCH\|DELETE /api/beats/{id}` |
|
||||||
| Scenes | `GET\|POST /api/chapters/{id}/scenes`, `GET\|PATCH\|DELETE /api/scenes/{id}` |
|
| Scenes | `GET\|POST /api/chapters/{id}/scenes`, `GET\|PATCH\|DELETE /api/scenes/{id}` |
|
||||||
| Tags | `GET\|POST /api/projects/{id}/tags`, `GET /api/tags/{id}/references`, `PATCH\|DELETE /api/tags/{id}` |
|
| Tags | `GET\|POST /api/projects/{id}/tags`, `GET /api/tags/{id}/references`, `PATCH\|DELETE /api/tags/{id}` |
|
||||||
|
| Arcs | `GET\|POST /api/characters/{id}/arc`, `POST /api/characters/{id}/arc/reorder`, `GET\|PATCH\|DELETE /api/arc-stages/{id}` |
|
||||||
|
| Questions | `GET\|POST /api/projects/{id}/questions`, `GET\|PATCH\|DELETE /api/questions/{id}`, `POST /api/questions/{id}/resolve`, `POST /api/questions/{id}/reopen` |
|
||||||
| Agent | `GET /api/projects/{id}/agent/conversations`, `POST /api/projects/{id}/agent/messages`, `GET\|DELETE /api/conversations/{id}` |
|
| Agent | `GET /api/projects/{id}/agent/conversations`, `POST /api/projects/{id}/agent/messages`, `GET\|DELETE /api/conversations/{id}` |
|
||||||
|
|
||||||
`PATCH` bodies are partial: an omitted field is left alone, an empty string clears it. A
|
`PATCH` bodies are partial: an omitted field is left alone, an empty string clears it. A
|
||||||
|
|||||||
@@ -8,16 +8,30 @@ namespace Novelly.Api.Characters;
|
|||||||
|
|
||||||
public class CharacterService(INovelDbContext db, TagService tags)
|
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)
|
public async Task<IReadOnlyList<CharacterDto>> ListAsync(Guid projectId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var characters = await Query()
|
var characters = await Query()
|
||||||
.Where(c => c.ProjectId == projectId)
|
.Where(c => c.ProjectId == projectId)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
return
|
||||||
|
[
|
||||||
|
.. characters
|
||||||
.OrderBy(c => c.Importance)
|
.OrderBy(c => c.Importance)
|
||||||
.ThenBy(c => c.Role)
|
.ThenBy(c => c.Role)
|
||||||
.ThenBy(c => c.Name)
|
.ThenBy(c => c.Name)
|
||||||
.ToListAsync(ct);
|
.Select(c => c.ToDto())
|
||||||
|
];
|
||||||
return [.. characters.Select(c => c.ToDto())];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CharacterDto> GetAsync(Guid id, CancellationToken ct = default) =>
|
public async Task<CharacterDto> GetAsync(Guid id, CancellationToken ct = default) =>
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { api } from './client'
|
import { api } from './client'
|
||||||
import type {
|
import type {
|
||||||
AgentTurn,
|
AgentTurn,
|
||||||
|
ArcStage,
|
||||||
Chapter,
|
Chapter,
|
||||||
ChapterSummary,
|
ChapterSummary,
|
||||||
Character,
|
Character,
|
||||||
|
CharacterBeat,
|
||||||
Conversation,
|
Conversation,
|
||||||
ConversationSummary,
|
ConversationSummary,
|
||||||
Beat,
|
Beat,
|
||||||
|
OpenQuestion,
|
||||||
Project,
|
Project,
|
||||||
ProjectSummary,
|
ProjectSummary,
|
||||||
Scene,
|
Scene,
|
||||||
@@ -21,7 +24,9 @@ export const keys = {
|
|||||||
characters: (projectId: string) => ['projects', projectId, 'characters'] as const,
|
characters: (projectId: string) => ['projects', projectId, 'characters'] as const,
|
||||||
tags: (projectId: string) => ['projects', projectId, 'tags'] as const,
|
tags: (projectId: string) => ['projects', projectId, 'tags'] as const,
|
||||||
tagRefs: (tagId: string) => ['tags', tagId, 'references'] 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,
|
chapters: (projectId: string) => ['projects', projectId, 'chapters'] as const,
|
||||||
|
questions: (projectId: string) => ['projects', projectId, 'questions'] as const,
|
||||||
chapter: (id: string) => ['chapters', id] as const,
|
chapter: (id: string) => ['chapters', id] as const,
|
||||||
conversations: (projectId: string) => ['projects', projectId, 'conversations'] as const,
|
conversations: (projectId: string) => ['projects', projectId, 'conversations'] as const,
|
||||||
conversation: (id: string) => ['conversations', id] 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 --------------------------------------------------------------------
|
// --- Tags --------------------------------------------------------------------
|
||||||
|
|
||||||
export const useTags = (projectId: string) =>
|
export const useTags = (projectId: string) =>
|
||||||
@@ -280,6 +407,7 @@ export function useSendAgentMessage(projectId: string) {
|
|||||||
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
|
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
|
||||||
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
|
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
|
||||||
qc.invalidateQueries({ queryKey: keys.chapters(projectId) })
|
qc.invalidateQueries({ queryKey: keys.chapters(projectId) })
|
||||||
|
qc.invalidateQueries({ queryKey: keys.questions(projectId) })
|
||||||
qc.invalidateQueries({ queryKey: keys.project(projectId) })
|
qc.invalidateQueries({ queryKey: keys.project(projectId) })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export const characterRoles: CharacterRole[] = [
|
|||||||
'Foil',
|
'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 type DraftStatus = 'Planned' | 'Outlined' | 'Drafted' | 'Revised' | 'Final'
|
||||||
|
|
||||||
export const draftStatuses: 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
|
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 {
|
export interface Character {
|
||||||
id: string
|
id: string
|
||||||
projectId: string
|
projectId: string
|
||||||
name: string
|
name: string
|
||||||
role: CharacterRole
|
role: CharacterRole
|
||||||
|
importance: CharacterImportance
|
||||||
age: string | null
|
age: string | null
|
||||||
pronouns: string | null
|
pronouns: string | null
|
||||||
occupation: string | null
|
occupation: string | null
|
||||||
@@ -124,6 +157,7 @@ export interface Character {
|
|||||||
notes: string | null
|
notes: string | null
|
||||||
relationships: Relationship[]
|
relationships: Relationship[]
|
||||||
tags: Tag[]
|
tags: Tag[]
|
||||||
|
arcStages: ArcStage[]
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +203,24 @@ export interface Chapter extends Omit<ChapterSummary, 'beatCount' | 'sceneCount'
|
|||||||
updatedAt: string
|
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 {
|
export interface ToolCall {
|
||||||
name: string
|
name: string
|
||||||
input: 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’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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { draftStatuses, type Beat, type Chapter, type Scene } from '../api/types'
|
import { draftStatuses, type Beat, type Chapter, type Scene } from '../api/types'
|
||||||
import { AutoField, ErrorNote, Select, Spinner, StatusBadge } from '../components/ui'
|
import { AutoField, ErrorNote, Select, Spinner, StatusBadge } from '../components/ui'
|
||||||
import { TagEditor } from '../components/TagEditor'
|
import { TagEditor } from '../components/TagEditor'
|
||||||
|
import { OpenQuestions } from '../components/OpenQuestions'
|
||||||
|
|
||||||
export default function ChapterPage() {
|
export default function ChapterPage() {
|
||||||
const { projectId = '', chapterId = '' } = useParams()
|
const { projectId = '', chapterId = '' } = useParams()
|
||||||
@@ -188,6 +189,23 @@ export default function ChapterPage() {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -247,7 +265,8 @@ function BeatTable({
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{chapter.beats.map((beat, index) => (
|
{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">
|
<td className="px-2 py-2 align-top">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="w-4 text-xs muted">{index + 1}</span>
|
<span className="w-4 text-xs muted">{index + 1}</span>
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ import {
|
|||||||
useTags,
|
useTags,
|
||||||
useUpdateCharacter,
|
useUpdateCharacter,
|
||||||
} from '../api/hooks'
|
} 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 { AutoField, EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui'
|
||||||
import { TagEditor } from '../components/TagEditor'
|
import { TagEditor } from '../components/TagEditor'
|
||||||
|
import { CharacterArc } from '../components/CharacterArc'
|
||||||
|
import { CharacterBeats } from '../components/CharacterBeats'
|
||||||
|
import { OpenQuestions } from '../components/OpenQuestions'
|
||||||
|
|
||||||
export default function CharactersPage() {
|
export default function CharactersPage() {
|
||||||
const { projectId = '' } = useParams()
|
const { projectId = '' } = useParams()
|
||||||
@@ -28,7 +31,14 @@ export default function CharactersPage() {
|
|||||||
<button className="btn btn-primary w-full justify-center" onClick={() => setAdding(true)}>
|
<button className="btn btn-primary w-full justify-center" onClick={() => setAdding(true)}>
|
||||||
Add character
|
Add character
|
||||||
</button>
|
</button>
|
||||||
{characters?.map((character) => (
|
{(['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
|
<button
|
||||||
key={character.id}
|
key={character.id}
|
||||||
onClick={() => setSelectedId(character.id)}
|
onClick={() => setSelectedId(character.id)}
|
||||||
@@ -43,6 +53,9 @@ export default function CharactersPage() {
|
|||||||
<div className="text-xs muted">{character.role}</div>
|
<div className="text-xs muted">{character.role}</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -75,9 +88,10 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
|
|||||||
update.mutate({ id: character.id, ...body })
|
update.mutate({ id: character.id, ...body })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="card p-5">
|
<div className="card p-5">
|
||||||
<div className="mb-5 flex items-start justify-between gap-4">
|
<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
|
<AutoField
|
||||||
label="Name"
|
label="Name"
|
||||||
value={character.name}
|
value={character.name}
|
||||||
@@ -89,6 +103,12 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
|
|||||||
options={characterRoles}
|
options={characterRoles}
|
||||||
onChange={(role) => patch({ role })}
|
onChange={(role) => patch({ role })}
|
||||||
/>
|
/>
|
||||||
|
<Select
|
||||||
|
label="Importance"
|
||||||
|
value={character.importance}
|
||||||
|
options={characterImportances}
|
||||||
|
onChange={(importance) => patch({ importance })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="btn mt-6"
|
className="btn mt-6"
|
||||||
@@ -218,6 +238,22 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 create = useCreateCharacter(projectId)
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [role, setRole] = useState<Character['role']>('Supporting')
|
const [role, setRole] = useState<Character['role']>('Supporting')
|
||||||
|
const [importance, setImportance] = useState<Character['importance']>('Supporting')
|
||||||
|
|
||||||
const submit = (e: React.FormEvent) => {
|
const submit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!name.trim()) return
|
if (!name.trim()) return
|
||||||
create.mutate(
|
create.mutate(
|
||||||
{ name: name.trim(), role },
|
{ name: name.trim(), role, importance },
|
||||||
{
|
{
|
||||||
onSuccess: (character) => {
|
onSuccess: (character) => {
|
||||||
onCreated(character.id)
|
onCreated(character.id)
|
||||||
@@ -256,6 +293,12 @@ function AddCharacterModal({
|
|||||||
<input className="input" autoFocus value={name} onChange={(e) => setName(e.target.value)} />
|
<input className="input" autoFocus value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
<Select label="Role" value={role} options={characterRoles} onChange={setRole} />
|
<Select label="Role" value={role} options={characterRoles} onChange={setRole} />
|
||||||
|
<Select
|
||||||
|
label="Importance"
|
||||||
|
value={importance}
|
||||||
|
options={characterImportances}
|
||||||
|
onChange={setImportance}
|
||||||
|
/>
|
||||||
{create.error && <ErrorNote error={create.error} />}
|
{create.error && <ErrorNote error={create.error} />}
|
||||||
<div className="mt-1 flex justify-end gap-2">
|
<div className="mt-1 flex justify-end gap-2">
|
||||||
<button type="button" className="btn" onClick={onClose}>
|
<button type="button" className="btn" onClick={onClose}>
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ public class CharacterArcTests : ServiceTestFixture
|
|||||||
_projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
|
_projectId = Projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
|
||||||
_characterId = Characters.CreateAsync(
|
_characterId = Characters.CreateAsync(
|
||||||
_projectId,
|
_projectId,
|
||||||
new CreateCharacterRequest("Ines", Importance: CharacterImportance.Main)).Result.Id;
|
new CreateCharacterRequest("Ines", CharacterRole.Protagonist, CharacterImportance.Main))
|
||||||
|
.Result.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
@@ -60,6 +61,23 @@ public class CharacterArcTests : ServiceTestFixture
|
|||||||
Is.EqualTo(new[] { "Ines", "Mara", "Zeno" }));
|
Is.EqualTo(new[] { "Ines", "Mara", "Zeno" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Within_a_group_the_lead_comes_before_the_second_lead()
|
||||||
|
{
|
||||||
|
// Both enums are stored as text, so ordering them in SQL orders the spelling and
|
||||||
|
// "Deuteragonist" beats "Protagonist" — burying the character the book is about.
|
||||||
|
await Characters.CreateAsync(_projectId, new CreateCharacterRequest(
|
||||||
|
"Mara", CharacterRole.Deuteragonist, CharacterImportance.Main));
|
||||||
|
await Characters.CreateAsync(_projectId, new CreateCharacterRequest(
|
||||||
|
"Anders", CharacterRole.Antagonist, CharacterImportance.Main));
|
||||||
|
|
||||||
|
var listed = await Characters.ListAsync(_projectId);
|
||||||
|
|
||||||
|
Assert.That(
|
||||||
|
listed.Select(c => c.Name),
|
||||||
|
Is.EqualTo(new[] { "Ines", "Anders", "Mara" }));
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task Arc_stages_are_appended_in_order_and_read_back_that_way()
|
public async Task Arc_stages_are_appended_in_order_and_read_back_that_way()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user