Split character table and detail into separate pages

Characters list (/characters) is now just the filterable/sortable
table; clicking a row navigates to /characters/:characterId instead
of expanding an inline sheet below the table.
This commit is contained in:
James Wampler
2026-08-17 17:37:52 -07:00
parent f124b9b4bb
commit 92fe4317e2
4 changed files with 472 additions and 449 deletions
+2
View File
@@ -3,6 +3,7 @@ import ProjectsPage from './pages/ProjectsPage'
import ProjectLayout from './pages/ProjectLayout' import ProjectLayout from './pages/ProjectLayout'
import DashboardPage from './pages/DashboardPage' import DashboardPage from './pages/DashboardPage'
import CharactersPage from './pages/CharactersPage' import CharactersPage from './pages/CharactersPage'
import CharacterDetailPage from './pages/CharacterDetailPage'
import TagsPage from './pages/TagsPage' import TagsPage from './pages/TagsPage'
import ChaptersPage from './pages/ChaptersPage' import ChaptersPage from './pages/ChaptersPage'
import ChapterPage from './pages/ChapterPage' import ChapterPage from './pages/ChapterPage'
@@ -37,6 +38,7 @@ export default function App() {
<Route path="/projects/:projectId" element={<ProjectLayout />}> <Route path="/projects/:projectId" element={<ProjectLayout />}>
<Route index element={<DashboardPage />} /> <Route index element={<DashboardPage />} />
<Route path="characters" element={<CharactersPage />} /> <Route path="characters" element={<CharactersPage />} />
<Route path="characters/:characterId" element={<CharacterDetailPage />} />
<Route path="chapters" element={<ChaptersPage />} /> <Route path="chapters" element={<ChaptersPage />} />
<Route path="chapters/:chapterId" element={<ChapterPage />} /> <Route path="chapters/:chapterId" element={<ChapterPage />} />
<Route path="tags" element={<TagsPage />} /> <Route path="tags" element={<TagsPage />} />
@@ -19,7 +19,7 @@ export function CharacterChip({
> >
{projectId ? ( {projectId ? (
<Link <Link
to={`/projects/${projectId}/characters?character=${character.id}`} to={`/projects/${projectId}/characters/${character.id}`}
className="hover:underline" className="hover:underline"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
@@ -0,0 +1,445 @@
import { useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import {
useChapters,
useCharacters,
useDeleteCharacter,
useLinkCharacterIdentity,
useProject,
useTags,
useUnlinkCharacterIdentity,
useUpdateCharacter,
} from '../api/hooks'
import { characterImportances, characterRoles, type Character } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { AutoField, EmptyState, ErrorNote, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
import { TagEditor } from '../components/TagEditor'
import { AliasEditor } from '../components/AliasEditor'
import { CharacterArc } from '../components/CharacterArc'
import { CharacterBeats } from '../components/CharacterBeats'
import { OpenQuestions } from '../components/OpenQuestions'
export default function CharacterDetailPage() {
const { projectId = '', characterId = '' } = useParams()
const { data: characters, isPending, error } = useCharacters(projectId)
const { data: project } = useProject(projectId)
const { can } = useAuth()
const canWrite = can('Write', project)
const canCreate = can('CreateContent', project)
const canDelete = can('DeleteContent', project)
if (isPending) return <Spinner label="Loading character" />
if (error) return <ErrorNote error={error} />
const character = characters?.find((c) => c.id === characterId)
if (!character) {
return (
<div className="grid gap-4">
<Link to={`/projects/${projectId}/characters`} className="text-sm muted hover:underline">
All characters
</Link>
<EmptyState title="Character not found" hint="It may have been deleted." />
</div>
)
}
return (
<div className="grid gap-4">
<Link to={`/projects/${projectId}/characters`} className="text-sm muted hover:underline">
All characters
</Link>
<CharacterSheet
key={character.id}
projectId={projectId}
character={character}
canWrite={canWrite}
canCreate={canCreate}
canDelete={canDelete}
/>
</div>
)
}
function CharacterSheet({
projectId,
character,
canWrite,
canCreate,
canDelete,
}: {
projectId: string
character: Character
canWrite: boolean
canCreate: boolean
canDelete: boolean
}) {
const navigate = useNavigate()
const { data: allTags } = useTags(projectId)
const { data: allCharacters } = useCharacters(projectId)
const { data: chapters } = useChapters(projectId)
const update = useUpdateCharacter(projectId)
const remove = useDeleteCharacter(projectId)
const linkIdentity = useLinkCharacterIdentity(projectId)
const unlinkIdentity = useUnlinkCharacterIdentity(projectId)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const patch = (body: Partial<Omit<Character, 'tags' | 'aliases'>> & { tags?: string[]; aliases?: string[] }) =>
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_10rem_9rem]">
<AutoField
label="Name"
value={character.name}
onCommit={(name) => name.trim() && patch({ name })}
readOnly={!canWrite}
/>
<Select
label="Role"
value={character.role}
options={characterRoles}
onChange={(role) => canWrite && patch({ role })}
/>
<Select
label="Importance"
value={character.importance}
options={characterImportances}
onChange={(importance) => canWrite && patch({ importance })}
/>
</div>
{canDelete && (
<button className="btn btn-danger mt-6" onClick={() => setConfirmingDelete(true)}>
Delete
</button>
)}
</div>
<div className="grid gap-4 sm:grid-cols-3">
<AutoField label="Age" value={character.age} onCommit={(age) => patch({ age })} readOnly={!canWrite} />
<AutoField
label="Pronouns"
value={character.pronouns}
onCommit={(pronouns) => patch({ pronouns })}
readOnly={!canWrite}
/>
<AutoField
label="Occupation"
value={character.occupation}
onCommit={(occupation) => patch({ occupation })}
readOnly={!canWrite}
/>
</div>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<TagEditor
label="Tags"
tags={character.tags}
suggestions={allTags?.map((t) => t.name) ?? []}
onChange={(tags) => canWrite && patch({ tags })}
/>
<AliasEditor
label="Also known as"
aliases={character.aliases}
readOnly={!canWrite}
onChange={(aliases) => canWrite && patch({ aliases })}
/>
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-2">
<AutoField
label="Wants"
value={character.want}
multiline
rows={3}
placeholder="What they are consciously chasing."
onCommit={(want) => patch({ want })}
readOnly={!canWrite}
/>
<AutoField
label="Needs"
value={character.need}
multiline
rows={3}
placeholder="What the story will make them face instead."
onCommit={(need) => patch({ need })}
readOnly={!canWrite}
/>
<AutoField
label="Internal conflict"
value={character.internalConflict}
multiline
onCommit={(internalConflict) => patch({ internalConflict })}
readOnly={!canWrite}
/>
<AutoField
label="External conflict"
value={character.externalConflict}
multiline
onCommit={(externalConflict) => patch({ externalConflict })}
readOnly={!canWrite}
/>
<AutoField
label="Arc"
value={character.arcSummary}
multiline
onCommit={(arcSummary) => patch({ arcSummary })}
readOnly={!canWrite}
/>
<AutoField
label="Voice"
value={character.voice}
multiline
placeholder="Register, rhythm, the words they reach for."
onCommit={(voice) => patch({ voice })}
readOnly={!canWrite}
/>
<AutoField
label="Appearance"
value={character.appearance}
multiline
onCommit={(appearance) => patch({ appearance })}
readOnly={!canWrite}
/>
<AutoField
label="Personality"
value={character.personality}
multiline
onCommit={(personality) => patch({ personality })}
readOnly={!canWrite}
/>
</div>
<div className="mt-4 grid gap-4">
<AutoField
label="Backstory"
value={character.backstory}
multiline
rows={5}
serif
onCommit={(backstory) => patch({ backstory })}
readOnly={!canWrite}
/>
<AutoField
label="Notes"
value={character.notes}
multiline
onCommit={(notes) => patch({ notes })}
readOnly={!canWrite}
/>
</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}
allCharacters={allCharacters ?? []}
chapters={chapters ?? []}
canWrite={canWrite}
onLink={(sameCharacterAsId, revealedInChapterId, note) =>
linkIdentity.mutate({ id: character.id, sameCharacterAsId, revealedInChapterId, note })
}
onUnlink={() => unlinkIdentity.mutate(character.id)}
/>
</div>
{update.error && (
<div className="mt-4">
<ErrorNote error={update.error} />
</div>
)}
{linkIdentity.error && (
<div className="mt-4">
<ErrorNote error={linkIdentity.error} />
</div>
)}
</div>
{(character.importance === 'Main' || character.arcStages.length > 0) && (
<CharacterArc
projectId={projectId}
character={character}
canWrite={canWrite}
canCreate={canCreate}
canDelete={canDelete}
/>
)}
<CharacterBeats
projectId={projectId}
characterId={character.id}
characterName={character.name}
/>
<OpenQuestions
projectId={projectId}
scope={{ characterId: character.id }}
canCreate={canCreate}
canWrite={canWrite}
canDelete={canDelete}
/>
{confirmingDelete && (
<ConfirmModal
title="Delete character"
message={`Delete ${character.name}? This cannot be undone.`}
onConfirm={() =>
remove.mutate(character.id, { onSuccess: () => navigate(`/projects/${projectId}/characters`) })
}
onClose={() => setConfirmingDelete(false)}
/>
)}
</>
)
}
function IdentitySection({
character,
allCharacters,
chapters,
canWrite,
onLink,
onUnlink,
}: {
character: Character
allCharacters: Character[]
chapters: { id: string; number: number; title: string }[]
canWrite: boolean
onLink: (sameCharacterAsId: string, revealedInChapterId: string | null, note: string | null) => void
onUnlink: () => void
}) {
const [picking, setPicking] = useState(false)
const [targetId, setTargetId] = useState('')
const [chapterId, setChapterId] = useState('')
const [note, setNote] = useState('')
const candidates = allCharacters.filter(
(c) => c.id !== character.id && c.otherIdentities.length === 0,
)
const submit = (e: React.FormEvent) => {
e.preventDefault()
if (!targetId) return
onLink(targetId, chapterId || null, note.trim() || null)
setPicking(false)
setTargetId('')
setChapterId('')
setNote('')
}
if (character.sameCharacterAsId) {
return (
<div id="character-identity-linked">
<h3 className="label">Identity</h3>
<p className="text-sm">
Really <span className="font-medium">{character.sameCharacterAsName}</span>
{character.revealedInChapterNumber != null && (
<span className="muted"> revealed in Chapter {character.revealedInChapterNumber}</span>
)}
</p>
{character.identityNote && <p className="muted text-sm">{character.identityNote}</p>}
{canWrite && (
<button className="btn mt-2" id="unlink-identity-button" onClick={onUnlink}>
Unlink identity
</button>
)}
</div>
)
}
return (
<div id="character-identity-section">
{character.otherIdentities.length > 0 && (
<div className="mb-3">
<h3 className="label">Also appears as</h3>
<ul className="grid gap-1 text-sm">
{character.otherIdentities.map((identity) => (
<li key={identity.id}>{identity.name}</li>
))}
</ul>
</div>
)}
{canWrite && candidates.length > 0 && (
<>
{!picking ? (
<button className="btn" id="link-identity-button" onClick={() => setPicking(true)}>
Link as another identity
</button>
) : (
<form onSubmit={submit} className="card grid gap-2 p-3">
<label className="block">
<span className="label">This character is really</span>
<select
id="link-identity-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">Revealed in chapter (optional)</span>
<select
id="link-identity-chapter-select"
className="input"
value={chapterId}
onChange={(e) => setChapterId(e.target.value)}
>
<option value=""></option>
{chapters.map((ch) => (
<option key={ch.id} value={ch.id}>
Ch. {ch.number} {ch.title}
</option>
))}
</select>
</label>
<label className="block">
<span className="label">Note (optional)</span>
<input
id="link-identity-note-input"
className="input"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</label>
<div className="flex justify-end gap-2">
<button type="button" className="btn" onClick={() => setPicking(false)}>
Cancel
</button>
<button className="btn btn-primary" disabled={!targetId}>
Link
</button>
</div>
</form>
)}
</>
)}
</div>
)
}
+19 -443
View File
@@ -1,40 +1,22 @@
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useParams, useSearchParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import { import { useCharacters, useCreateCharacter, useProject, useTags } from '../api/hooks'
useChapters,
useCharacters,
useCreateCharacter,
useDeleteCharacter,
useLinkCharacterIdentity,
useProject,
useTags,
useUnlinkCharacterIdentity,
useUpdateCharacter,
} from '../api/hooks'
import { characterImportances, characterRoles, type Character, type CharacterImportance, type CharacterRole } from '../api/types' import { characterImportances, characterRoles, type Character, type CharacterImportance, type CharacterRole } from '../api/types'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { AutoField, EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui' import { EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal' import { TagChip } from '../components/TagEditor'
import { TagChip, TagEditor } from '../components/TagEditor'
import { AliasEditor } from '../components/AliasEditor'
import { CharacterArc } from '../components/CharacterArc'
import { CharacterBeats } from '../components/CharacterBeats'
import { OpenQuestions } from '../components/OpenQuestions'
import { useHotkey } from '../keyboard/HotkeysContext' import { useHotkey } from '../keyboard/HotkeysContext'
type SortKey = 'name' | 'updatedAt' type SortKey = 'name' | 'updatedAt'
export default function CharactersPage() { export default function CharactersPage() {
const { projectId = '' } = useParams() const { projectId = '' } = useParams()
const navigate = useNavigate()
const { data: characters, isPending, error } = useCharacters(projectId) const { data: characters, isPending, error } = useCharacters(projectId)
const { data: project } = useProject(projectId) const { data: project } = useProject(projectId)
const { data: allTags } = useTags(projectId) const { data: allTags } = useTags(projectId)
const { can } = useAuth() const { can } = useAuth()
const canCreate = can('CreateContent', project) const canCreate = can('CreateContent', project)
const canWrite = can('Write', project)
const canDelete = can('DeleteContent', project)
const [searchParams, setSearchParams] = useSearchParams()
const [selectedId, setSelectedId] = useState<string | null>(searchParams.get('character'))
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@@ -78,16 +60,6 @@ export default function CharactersPage() {
if (isPending) return <Spinner label="Loading characters" /> if (isPending) return <Spinner label="Loading characters" />
if (error) return <ErrorNote error={error} /> if (error) return <ErrorNote error={error} />
const selected = characters?.find((c) => c.id === selectedId)
const select = (id: string) => {
setSelectedId(id)
setSearchParams((params) => {
params.set('character', id)
return params
})
}
if (!characters || characters.length === 0) { if (!characters || characters.length === 0) {
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
@@ -103,7 +75,11 @@ export default function CharactersPage() {
hint="Add the protagonist first — most outline questions resolve once you know what they want." hint="Add the protagonist first — most outline questions resolve once you know what they want."
/> />
{adding && ( {adding && (
<AddCharacterModal projectId={projectId} onClose={() => setAdding(false)} onCreated={setSelectedId} /> <AddCharacterModal
projectId={projectId}
onClose={() => setAdding(false)}
onCreated={(id) => navigate(`/projects/${projectId}/characters/${id}`)}
/>
)} )}
</div> </div>
) )
@@ -139,24 +115,13 @@ export default function CharactersPage() {
onSortDir={setSortDir} onSortDir={setSortDir}
/> />
<CharacterTable characters={sorted} selectedId={selected?.id} onSelect={select} /> <CharacterTable characters={sorted} projectId={projectId} />
{selected && (
<CharacterSheet
key={selected.id}
projectId={projectId}
character={selected}
canWrite={canWrite}
canCreate={canCreate}
canDelete={canDelete}
/>
)}
{adding && ( {adding && (
<AddCharacterModal <AddCharacterModal
projectId={projectId} projectId={projectId}
onClose={() => setAdding(false)} onClose={() => setAdding(false)}
onCreated={setSelectedId} onCreated={(id) => navigate(`/projects/${projectId}/characters/${id}`)}
/> />
)} )}
</div> </div>
@@ -306,15 +271,7 @@ function CharacterFilterBar({
) )
} }
function CharacterTable({ function CharacterTable({ characters, projectId }: { characters: Character[]; projectId: string }) {
characters,
selectedId,
onSelect,
}: {
characters: Character[]
selectedId: string | undefined
onSelect: (id: string) => void
}) {
if (characters.length === 0) { if (characters.length === 0) {
return ( return (
<div className="card p-5 text-sm muted" id="character-table-empty"> <div className="card p-5 text-sm muted" id="character-table-empty">
@@ -337,20 +294,17 @@ function CharacterTable({
</thead> </thead>
<tbody> <tbody>
{characters.map((character) => ( {characters.map((character) => (
<tr <tr key={character.id} className="align-top" style={{ borderTop: '1px solid var(--line)' }}>
key={character.id} <td className="p-0">
onClick={() => onSelect(character.id)} <Link
className="cursor-pointer align-top transition" to={`/projects/${projectId}/characters/${character.id}`}
style={{ className="block px-3 py-2 transition hover:bg-[var(--surface-sunken)]"
borderTop: '1px solid var(--line)',
background: character.id === selectedId ? 'var(--accent-soft)' : undefined,
}}
> >
<td className="py-2 px-3">
<div className="font-medium">{character.name}</div> <div className="font-medium">{character.name}</div>
{character.aliases.length > 0 && ( {character.aliases.length > 0 && (
<div className="text-xs muted">aka {character.aliases.join(', ')}</div> <div className="text-xs muted">aka {character.aliases.join(', ')}</div>
)} )}
</Link>
</td> </td>
<td className="py-2 px-3">{character.role}</td> <td className="py-2 px-3">{character.role}</td>
<td className="py-2 px-3">{character.importance}</td> <td className="py-2 px-3">{character.importance}</td>
@@ -370,384 +324,6 @@ function CharacterTable({
) )
} }
function CharacterSheet({
projectId,
character,
canWrite,
canCreate,
canDelete,
}: {
projectId: string
character: Character
canWrite: boolean
canCreate: boolean
canDelete: boolean
}) {
const { data: allTags } = useTags(projectId)
const { data: allCharacters } = useCharacters(projectId)
const { data: chapters } = useChapters(projectId)
const update = useUpdateCharacter(projectId)
const remove = useDeleteCharacter(projectId)
const linkIdentity = useLinkCharacterIdentity(projectId)
const unlinkIdentity = useUnlinkCharacterIdentity(projectId)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const patch = (body: Partial<Omit<Character, 'tags' | 'aliases'>> & { tags?: string[]; aliases?: string[] }) =>
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_10rem_9rem]">
<AutoField
label="Name"
value={character.name}
onCommit={(name) => name.trim() && patch({ name })}
readOnly={!canWrite}
/>
<Select
label="Role"
value={character.role}
options={characterRoles}
onChange={(role) => canWrite && patch({ role })}
/>
<Select
label="Importance"
value={character.importance}
options={characterImportances}
onChange={(importance) => canWrite && patch({ importance })}
/>
</div>
{canDelete && (
<button className="btn btn-danger mt-6" onClick={() => setConfirmingDelete(true)}>
Delete
</button>
)}
</div>
<div className="grid gap-4 sm:grid-cols-3">
<AutoField label="Age" value={character.age} onCommit={(age) => patch({ age })} readOnly={!canWrite} />
<AutoField
label="Pronouns"
value={character.pronouns}
onCommit={(pronouns) => patch({ pronouns })}
readOnly={!canWrite}
/>
<AutoField
label="Occupation"
value={character.occupation}
onCommit={(occupation) => patch({ occupation })}
readOnly={!canWrite}
/>
</div>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<TagEditor
label="Tags"
tags={character.tags}
suggestions={allTags?.map((t) => t.name) ?? []}
onChange={(tags) => canWrite && patch({ tags })}
/>
<AliasEditor
label="Also known as"
aliases={character.aliases}
readOnly={!canWrite}
onChange={(aliases) => canWrite && patch({ aliases })}
/>
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-2">
<AutoField
label="Wants"
value={character.want}
multiline
rows={3}
placeholder="What they are consciously chasing."
onCommit={(want) => patch({ want })}
readOnly={!canWrite}
/>
<AutoField
label="Needs"
value={character.need}
multiline
rows={3}
placeholder="What the story will make them face instead."
onCommit={(need) => patch({ need })}
readOnly={!canWrite}
/>
<AutoField
label="Internal conflict"
value={character.internalConflict}
multiline
onCommit={(internalConflict) => patch({ internalConflict })}
readOnly={!canWrite}
/>
<AutoField
label="External conflict"
value={character.externalConflict}
multiline
onCommit={(externalConflict) => patch({ externalConflict })}
readOnly={!canWrite}
/>
<AutoField
label="Arc"
value={character.arcSummary}
multiline
onCommit={(arcSummary) => patch({ arcSummary })}
readOnly={!canWrite}
/>
<AutoField
label="Voice"
value={character.voice}
multiline
placeholder="Register, rhythm, the words they reach for."
onCommit={(voice) => patch({ voice })}
readOnly={!canWrite}
/>
<AutoField
label="Appearance"
value={character.appearance}
multiline
onCommit={(appearance) => patch({ appearance })}
readOnly={!canWrite}
/>
<AutoField
label="Personality"
value={character.personality}
multiline
onCommit={(personality) => patch({ personality })}
readOnly={!canWrite}
/>
</div>
<div className="mt-4 grid gap-4">
<AutoField
label="Backstory"
value={character.backstory}
multiline
rows={5}
serif
onCommit={(backstory) => patch({ backstory })}
readOnly={!canWrite}
/>
<AutoField
label="Notes"
value={character.notes}
multiline
onCommit={(notes) => patch({ notes })}
readOnly={!canWrite}
/>
</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}
allCharacters={allCharacters ?? []}
chapters={chapters ?? []}
canWrite={canWrite}
onLink={(sameCharacterAsId, revealedInChapterId, note) =>
linkIdentity.mutate({ id: character.id, sameCharacterAsId, revealedInChapterId, note })
}
onUnlink={() => unlinkIdentity.mutate(character.id)}
/>
</div>
{update.error && (
<div className="mt-4">
<ErrorNote error={update.error} />
</div>
)}
{linkIdentity.error && (
<div className="mt-4">
<ErrorNote error={linkIdentity.error} />
</div>
)}
</div>
{(character.importance === 'Main' || character.arcStages.length > 0) && (
<CharacterArc
projectId={projectId}
character={character}
canWrite={canWrite}
canCreate={canCreate}
canDelete={canDelete}
/>
)}
<CharacterBeats
projectId={projectId}
characterId={character.id}
characterName={character.name}
/>
<OpenQuestions
projectId={projectId}
scope={{ characterId: character.id }}
canCreate={canCreate}
canWrite={canWrite}
canDelete={canDelete}
/>
{confirmingDelete && (
<ConfirmModal
title="Delete character"
message={`Delete ${character.name}? This cannot be undone.`}
onConfirm={() => remove.mutate(character.id)}
onClose={() => setConfirmingDelete(false)}
/>
)}
</>
)
}
function IdentitySection({
character,
allCharacters,
chapters,
canWrite,
onLink,
onUnlink,
}: {
character: Character
allCharacters: Character[]
chapters: { id: string; number: number; title: string }[]
canWrite: boolean
onLink: (sameCharacterAsId: string, revealedInChapterId: string | null, note: string | null) => void
onUnlink: () => void
}) {
const [picking, setPicking] = useState(false)
const [targetId, setTargetId] = useState('')
const [chapterId, setChapterId] = useState('')
const [note, setNote] = useState('')
const candidates = allCharacters.filter(
(c) => c.id !== character.id && c.otherIdentities.length === 0,
)
const submit = (e: React.FormEvent) => {
e.preventDefault()
if (!targetId) return
onLink(targetId, chapterId || null, note.trim() || null)
setPicking(false)
setTargetId('')
setChapterId('')
setNote('')
}
if (character.sameCharacterAsId) {
return (
<div id="character-identity-linked">
<h3 className="label">Identity</h3>
<p className="text-sm">
Really <span className="font-medium">{character.sameCharacterAsName}</span>
{character.revealedInChapterNumber != null && (
<span className="muted"> revealed in Chapter {character.revealedInChapterNumber}</span>
)}
</p>
{character.identityNote && <p className="muted text-sm">{character.identityNote}</p>}
{canWrite && (
<button className="btn mt-2" id="unlink-identity-button" onClick={onUnlink}>
Unlink identity
</button>
)}
</div>
)
}
return (
<div id="character-identity-section">
{character.otherIdentities.length > 0 && (
<div className="mb-3">
<h3 className="label">Also appears as</h3>
<ul className="grid gap-1 text-sm">
{character.otherIdentities.map((identity) => (
<li key={identity.id}>{identity.name}</li>
))}
</ul>
</div>
)}
{canWrite && candidates.length > 0 && (
<>
{!picking ? (
<button className="btn" id="link-identity-button" onClick={() => setPicking(true)}>
Link as another identity
</button>
) : (
<form onSubmit={submit} className="card grid gap-2 p-3">
<label className="block">
<span className="label">This character is really</span>
<select
id="link-identity-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">Revealed in chapter (optional)</span>
<select
id="link-identity-chapter-select"
className="input"
value={chapterId}
onChange={(e) => setChapterId(e.target.value)}
>
<option value=""></option>
{chapters.map((ch) => (
<option key={ch.id} value={ch.id}>
Ch. {ch.number} {ch.title}
</option>
))}
</select>
</label>
<label className="block">
<span className="label">Note (optional)</span>
<input
id="link-identity-note-input"
className="input"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
</label>
<div className="flex justify-end gap-2">
<button type="button" className="btn" onClick={() => setPicking(false)}>
Cancel
</button>
<button className="btn btn-primary" disabled={!targetId}>
Link
</button>
</div>
</form>
)}
</>
)}
</div>
)
}
function AddCharacterModal({ function AddCharacterModal({
projectId, projectId,
onClose, onClose,