Group character beats into arc-stage sections; reciprocal relationship types
Arc stages now group the beats that establish or pay off that stage of a
character's arc (many-to-many via ArcStageBeats), and carry a Result field
(renamed from Description) describing what the stage results in for the
character. Assigning a beat to a stage moves it out of any other stage of
the same character. New endpoint POST /api/arc-stages/{id}/beats, MCP tool
set_arc_stage_beats, and frontend grouping UI in CharacterArc/CharacterBeats.
Also records a relationship's reciprocal type so both characters' dossiers
show the correct direction (e.g. "sister" / "brother") instead of mirroring
the same label.
This commit is contained in:
@@ -204,6 +204,40 @@ export function useUnlinkCharacterIdentity(projectId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function useAddRelationship(projectId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
relatedCharacterId,
|
||||
relationshipType,
|
||||
reciprocalRelationshipType,
|
||||
description,
|
||||
}: {
|
||||
id: string
|
||||
relatedCharacterId: string
|
||||
relationshipType: string
|
||||
reciprocalRelationshipType?: string | null
|
||||
description?: string | null
|
||||
}) =>
|
||||
api.post<Character>(`/api/characters/${id}/relationships`, {
|
||||
relatedCharacterId,
|
||||
relationshipType,
|
||||
reciprocalRelationshipType,
|
||||
description,
|
||||
}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoveRelationship(projectId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (relationshipId: string) => api.delete(`/api/characters/relationships/${relationshipId}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
|
||||
})
|
||||
}
|
||||
|
||||
export const useCharacterBeats = (characterId: string | undefined) =>
|
||||
useQuery({
|
||||
queryKey: keys.characterBeats(characterId ?? ''),
|
||||
@@ -214,7 +248,7 @@ export const useCharacterBeats = (characterId: string | undefined) =>
|
||||
export function useCreateArcStage(projectId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ characterId, ...body }: { characterId: string; title: string; description?: string; chapterId?: string }) =>
|
||||
mutationFn: ({ characterId, ...body }: { characterId: string; title: string; result?: string; chapterId?: string }) =>
|
||||
api.post<ArcStage>(`/api/characters/${characterId}/arc`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
|
||||
})
|
||||
@@ -223,12 +257,24 @@ export function useCreateArcStage(projectId: string) {
|
||||
export function useUpdateArcStage(projectId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...body }: { id: string; title?: string; description?: string; chapterId?: string }) =>
|
||||
mutationFn: ({ id, ...body }: { id: string; title?: string; result?: string; chapterId?: string }) =>
|
||||
api.patch<ArcStage>(`/api/arc-stages/${id}`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
|
||||
})
|
||||
}
|
||||
|
||||
export function useSetArcStageBeats(projectId: string, characterId: string | undefined) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, beatIds }: { id: string; beatIds: string[] }) =>
|
||||
api.post<ArcStage>(`/api/arc-stages/${id}/beats`, { beatIds }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.characterBeats(characterId ?? '') })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteArcStage(projectId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
|
||||
@@ -151,10 +151,11 @@ export interface ArcStage {
|
||||
characterId: string
|
||||
sortOrder: number
|
||||
title: string
|
||||
description: string | null
|
||||
result: string | null
|
||||
chapterId: string | null
|
||||
chapterNumber: number | null
|
||||
chapterTitle: string | null
|
||||
beats: CharacterBeat[]
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
@@ -167,6 +168,7 @@ export interface CharacterBeat {
|
||||
title: string
|
||||
whatHappened: string | null
|
||||
whatsNext: string | null
|
||||
arcStageId: string | null
|
||||
}
|
||||
|
||||
export interface Character {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
useCharacterBeats,
|
||||
useChapters,
|
||||
useCreateArcStage,
|
||||
useDeleteArcStage,
|
||||
useReorderArcStages,
|
||||
useSetArcStageBeats,
|
||||
useUpdateArcStage,
|
||||
} from '../api/hooks'
|
||||
import type { ArcStage, Character } from '../api/types'
|
||||
@@ -24,12 +26,14 @@ export function CharacterArc({
|
||||
canDelete: boolean
|
||||
}) {
|
||||
const { data: chapters } = useChapters(projectId)
|
||||
const { data: beats } = useCharacterBeats(character.id)
|
||||
const create = useCreateArcStage(projectId)
|
||||
const reorder = useReorderArcStages(projectId)
|
||||
|
||||
const [title, setTitle] = useState('')
|
||||
|
||||
const stages = character.arcStages
|
||||
const unassignedBeats = (beats ?? []).filter((b) => b.arcStageId === null)
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -56,8 +60,9 @@ export function CharacterArc({
|
||||
)}
|
||||
</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.
|
||||
The sections {character.name}’s arc breaks into, in order — each one a short span of
|
||||
beats and what it results in for them. Pin a section to the chapter it lands in and it
|
||||
links into that outline.
|
||||
</p>
|
||||
|
||||
{stages.length > 0 && (
|
||||
@@ -68,6 +73,7 @@ export function CharacterArc({
|
||||
projectId={projectId}
|
||||
stage={stage}
|
||||
chapters={chapters ?? []}
|
||||
unassignedBeats={unassignedBeats}
|
||||
canMoveUp={index > 0}
|
||||
canMoveDown={index < stages.length - 1}
|
||||
onMove={(delta) => move(index, delta)}
|
||||
@@ -78,11 +84,18 @@ export function CharacterArc({
|
||||
</ol>
|
||||
)}
|
||||
|
||||
{unassignedBeats.length > 0 && (
|
||||
<p className="mt-3 text-xs muted">
|
||||
{unassignedBeats.length} beat{unassignedBeats.length === 1 ? '' : 's'} not yet grouped
|
||||
into a section — add {character.name} to a section above, or check the Beats list below.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{canCreate && (
|
||||
<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”"
|
||||
placeholder="Add a section — a short title, e.g. “spoiled noble”"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
@@ -105,6 +118,7 @@ function ArcStageRow({
|
||||
projectId,
|
||||
stage,
|
||||
chapters,
|
||||
unassignedBeats,
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
onMove,
|
||||
@@ -114,6 +128,7 @@ function ArcStageRow({
|
||||
projectId: string
|
||||
stage: ArcStage
|
||||
chapters: { id: string; number: number; title: string }[]
|
||||
unassignedBeats: { id: string; chapterNumber: number; sortOrder: number; title: string }[]
|
||||
canMoveUp: boolean
|
||||
canMoveDown: boolean
|
||||
onMove: (delta: number) => void
|
||||
@@ -122,6 +137,16 @@ function ArcStageRow({
|
||||
}) {
|
||||
const update = useUpdateArcStage(projectId)
|
||||
const remove = useDeleteArcStage(projectId)
|
||||
const setBeats = useSetArcStageBeats(projectId, stage.characterId)
|
||||
|
||||
const addBeat = (beatId: string) => {
|
||||
if (!beatId) return
|
||||
setBeats.mutate({ id: stage.id, beatIds: [...stage.beats.map((b) => b.id), beatId] })
|
||||
}
|
||||
|
||||
const removeBeat = (beatId: string) => {
|
||||
setBeats.mutate({ id: stage.id, beatIds: stage.beats.filter((b) => b.id !== beatId).map((b) => b.id) })
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
@@ -138,14 +163,59 @@ function ArcStageRow({
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<AutoField
|
||||
value={stage.description}
|
||||
value={stage.result}
|
||||
multiline
|
||||
rows={2}
|
||||
placeholder="What shifts here, and what it costs them."
|
||||
onCommit={(description) => update.mutate({ id: stage.id, description })}
|
||||
placeholder="What this results in for them — what shifts, and what it costs."
|
||||
onCommit={(result) => update.mutate({ id: stage.id, result })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
|
||||
{stage.beats.length > 0 && (
|
||||
<ul className="grid gap-1">
|
||||
{stage.beats.map((beat) => (
|
||||
<li
|
||||
key={beat.id}
|
||||
className="flex items-center gap-2 rounded px-2 py-1 text-xs"
|
||||
style={{ background: 'var(--surface-1, rgba(0,0,0,0.015))' }}
|
||||
>
|
||||
<Link
|
||||
className="shrink-0 tabular-nums underline"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
to={`/projects/${projectId}/chapters/${beat.chapterId}#beat-${beat.id}`}
|
||||
>
|
||||
{beat.chapterNumber}.{beat.sortOrder}
|
||||
</Link>
|
||||
<span className="min-w-0 flex-1 truncate">{beat.title}</span>
|
||||
{canWrite && (
|
||||
<button
|
||||
className="btn shrink-0 px-1.5 py-0 text-xs"
|
||||
onClick={() => removeBeat(beat.id)}
|
||||
aria-label={`Remove beat ${beat.title} from this section`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{canWrite && unassignedBeats.length > 0 && (
|
||||
<select
|
||||
className="input py-1 text-xs"
|
||||
value=""
|
||||
onChange={(e) => addBeat(e.target.value)}
|
||||
>
|
||||
<option value="">Add a beat to this section…</option>
|
||||
{unassignedBeats.map((beat) => (
|
||||
<option key={beat.id} value={beat.id}>
|
||||
{beat.chapterNumber}.{beat.sortOrder} — {beat.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="input max-w-[16rem] py-1 text-xs"
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useCharacterBeats } from '../api/hooks'
|
||||
import type { ArcStage } from '../api/types'
|
||||
import { ErrorNote, Spinner } from './ui'
|
||||
|
||||
export function CharacterBeats({
|
||||
projectId,
|
||||
characterId,
|
||||
characterName,
|
||||
arcStages,
|
||||
}: {
|
||||
projectId: string
|
||||
characterId: string
|
||||
characterName: string
|
||||
arcStages: ArcStage[]
|
||||
}) {
|
||||
const { data: beats, isPending, error } = useCharacterBeats(characterId)
|
||||
const stageTitleById = new Map(arcStages.map((s) => [s.id, s.title]))
|
||||
|
||||
return (
|
||||
<section className="card mt-6 p-5">
|
||||
@@ -33,6 +37,7 @@ export function CharacterBeats({
|
||||
<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">Arc section</th>
|
||||
<th className="py-1 pr-3 font-semibold">What happened</th>
|
||||
<th className="py-1 font-semibold">What’s next</th>
|
||||
</tr>
|
||||
@@ -51,6 +56,9 @@ export function CharacterBeats({
|
||||
<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.arcStageId ? (stageTitleById.get(beat.arcStageId) ?? '—') : '—'}
|
||||
</td>
|
||||
<td className="py-2 pr-3 muted">{beat.whatHappened}</td>
|
||||
<td className="py-2 muted">{beat.whatsNext}</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
useAddRelationship,
|
||||
useChapters,
|
||||
useCharacters,
|
||||
useDeleteCharacter,
|
||||
useLinkCharacterIdentity,
|
||||
useProject,
|
||||
useRemoveRelationship,
|
||||
useTags,
|
||||
useUnlinkCharacterIdentity,
|
||||
useUpdateCharacter,
|
||||
@@ -84,6 +86,8 @@ function CharacterSheet({
|
||||
const remove = useDeleteCharacter(projectId)
|
||||
const linkIdentity = useLinkCharacterIdentity(projectId)
|
||||
const unlinkIdentity = useUnlinkCharacterIdentity(projectId)
|
||||
const addRelationship = useAddRelationship(projectId)
|
||||
const removeRelationship = useRemoveRelationship(projectId)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const patch = (body: Partial<Omit<Character, 'tags' | 'aliases'>> & { tags?: string[]; aliases?: string[] }) =>
|
||||
update.mutate({ id: character.id, ...body })
|
||||
@@ -233,21 +237,6 @@ function CharacterSheet({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{character.relationships.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h3 className="label">Relationships</h3>
|
||||
<ul className="grid gap-1 text-sm">
|
||||
{character.relationships.map((relationship) => (
|
||||
<li key={relationship.id}>
|
||||
<span className="font-medium">{relationship.relatedCharacterName}</span>
|
||||
<span className="muted"> — {relationship.relationshipType}</span>
|
||||
{relationship.description && <span className="muted">: {relationship.description}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<IdentitySection
|
||||
character={character}
|
||||
@@ -273,6 +262,23 @@ function CharacterSheet({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RelationshipsSection
|
||||
character={character}
|
||||
allCharacters={allCharacters ?? []}
|
||||
canWrite={canWrite}
|
||||
onAdd={(relatedCharacterId, relationshipType, reciprocalRelationshipType, description) =>
|
||||
addRelationship.mutate({
|
||||
id: character.id,
|
||||
relatedCharacterId,
|
||||
relationshipType,
|
||||
reciprocalRelationshipType,
|
||||
description,
|
||||
})
|
||||
}
|
||||
onRemove={(relationshipId) => removeRelationship.mutate(relationshipId)}
|
||||
error={addRelationship.error}
|
||||
/>
|
||||
|
||||
{(character.importance === 'Main' || character.arcStages.length > 0) && (
|
||||
<CharacterArc
|
||||
projectId={projectId}
|
||||
@@ -287,6 +293,7 @@ function CharacterSheet({
|
||||
projectId={projectId}
|
||||
characterId={character.id}
|
||||
characterName={character.name}
|
||||
arcStages={character.arcStages}
|
||||
/>
|
||||
|
||||
<OpenQuestions
|
||||
@@ -311,6 +318,160 @@ function CharacterSheet({
|
||||
)
|
||||
}
|
||||
|
||||
function RelationshipsSection({
|
||||
character,
|
||||
allCharacters,
|
||||
canWrite,
|
||||
onAdd,
|
||||
onRemove,
|
||||
error,
|
||||
}: {
|
||||
character: Character
|
||||
allCharacters: Character[]
|
||||
canWrite: boolean
|
||||
onAdd: (
|
||||
relatedCharacterId: string,
|
||||
relationshipType: string,
|
||||
reciprocalRelationshipType: string | null,
|
||||
description: string | null,
|
||||
) => void
|
||||
onRemove: (relationshipId: string) => void
|
||||
error: unknown
|
||||
}) {
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [targetId, setTargetId] = useState('')
|
||||
const [relationshipType, setRelationshipType] = useState('')
|
||||
const [reciprocalRelationshipType, setReciprocalRelationshipType] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
|
||||
const candidates = allCharacters.filter(
|
||||
(c) => c.id !== character.id && !character.relationships.some((r) => r.relatedCharacterId === c.id),
|
||||
)
|
||||
const targetName = candidates.find((c) => c.id === targetId)?.name ?? 'them'
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!targetId || !relationshipType.trim()) return
|
||||
onAdd(targetId, relationshipType.trim(), reciprocalRelationshipType.trim() || null, description.trim() || null)
|
||||
setAdding(false)
|
||||
setTargetId('')
|
||||
setRelationshipType('')
|
||||
setReciprocalRelationshipType('')
|
||||
setDescription('')
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="character-relationships" className="card mt-6 p-5">
|
||||
<h3 className="mb-3 text-sm font-semibold">Relationships</h3>
|
||||
|
||||
{character.relationships.length > 0 && (
|
||||
<ul className="mb-3 grid gap-1 text-sm">
|
||||
{character.relationships.map((relationship) => (
|
||||
<li key={relationship.id} className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<span className="font-medium">{character.name}</span>
|
||||
<span className="muted"> is {relationship.relatedCharacterName}’s </span>
|
||||
<span className="font-medium">{relationship.relationshipType}</span>
|
||||
{relationship.description && <span className="muted">: {relationship.description}</span>}
|
||||
</div>
|
||||
{canWrite && (
|
||||
<button
|
||||
type="button"
|
||||
id={`remove-relationship-${relationship.id}`}
|
||||
className="shrink-0 opacity-60 transition hover:opacity-100"
|
||||
aria-label={`Remove relationship with ${relationship.relatedCharacterName}`}
|
||||
onClick={() => onRemove(relationship.id)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{character.relationships.length === 0 && !adding && (
|
||||
<p className="mb-3 text-sm muted">No relationships recorded yet.</p>
|
||||
)}
|
||||
|
||||
{canWrite && candidates.length > 0 && (
|
||||
<>
|
||||
{!adding ? (
|
||||
<button className="btn" id="add-relationship-button" onClick={() => setAdding(true)}>
|
||||
Add relationship…
|
||||
</button>
|
||||
) : (
|
||||
<form onSubmit={submit} className="card grid gap-2 p-3">
|
||||
<label className="block">
|
||||
<span className="label">Related to…</span>
|
||||
<select
|
||||
id="add-relationship-character-select"
|
||||
className="input"
|
||||
value={targetId}
|
||||
onChange={(e) => setTargetId(e.target.value)}
|
||||
autoFocus
|
||||
>
|
||||
<option value="">Select a character…</option>
|
||||
{candidates.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="label">{character.name} is {targetName}’s…</span>
|
||||
<input
|
||||
id="add-relationship-type-input"
|
||||
className="input"
|
||||
placeholder="e.g. sister, rival, servant"
|
||||
value={relationshipType}
|
||||
onChange={(e) => setRelationshipType(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="label">
|
||||
{targetName} is {character.name}’s… (optional, defaults to the same)
|
||||
</span>
|
||||
<input
|
||||
id="add-relationship-reciprocal-type-input"
|
||||
className="input"
|
||||
placeholder="e.g. brother, rival, employer"
|
||||
value={reciprocalRelationshipType}
|
||||
onChange={(e) => setReciprocalRelationshipType(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="label">Note (optional)</span>
|
||||
<input
|
||||
id="add-relationship-description-input"
|
||||
className="input"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" className="btn" onClick={() => setAdding(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button className="btn btn-primary" disabled={!targetId || !relationshipType.trim()}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error !== null && error !== undefined && (
|
||||
<div className="mt-3">
|
||||
<ErrorNote error={error} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function IdentitySection({
|
||||
character,
|
||||
allCharacters,
|
||||
|
||||
Reference in New Issue
Block a user