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:
@@ -3,6 +3,7 @@ import ProjectsPage from './pages/ProjectsPage'
|
||||
import ProjectLayout from './pages/ProjectLayout'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import CharactersPage from './pages/CharactersPage'
|
||||
import CharacterDetailPage from './pages/CharacterDetailPage'
|
||||
import TagsPage from './pages/TagsPage'
|
||||
import ChaptersPage from './pages/ChaptersPage'
|
||||
import ChapterPage from './pages/ChapterPage'
|
||||
@@ -37,6 +38,7 @@ export default function App() {
|
||||
<Route path="/projects/:projectId" element={<ProjectLayout />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="characters" element={<CharactersPage />} />
|
||||
<Route path="characters/:characterId" element={<CharacterDetailPage />} />
|
||||
<Route path="chapters" element={<ChaptersPage />} />
|
||||
<Route path="chapters/:chapterId" element={<ChapterPage />} />
|
||||
<Route path="tags" element={<TagsPage />} />
|
||||
|
||||
@@ -19,7 +19,7 @@ export function CharacterChip({
|
||||
>
|
||||
{projectId ? (
|
||||
<Link
|
||||
to={`/projects/${projectId}/characters?character=${character.id}`}
|
||||
to={`/projects/${projectId}/characters/${character.id}`}
|
||||
className="hover:underline"
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +1,22 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useParams, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
useChapters,
|
||||
useCharacters,
|
||||
useCreateCharacter,
|
||||
useDeleteCharacter,
|
||||
useLinkCharacterIdentity,
|
||||
useProject,
|
||||
useTags,
|
||||
useUnlinkCharacterIdentity,
|
||||
useUpdateCharacter,
|
||||
} from '../api/hooks'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import { useCharacters, useCreateCharacter, useProject, useTags } from '../api/hooks'
|
||||
import { characterImportances, characterRoles, type Character, type CharacterImportance, type CharacterRole } from '../api/types'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { AutoField, EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
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 { EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui'
|
||||
import { TagChip } from '../components/TagEditor'
|
||||
import { useHotkey } from '../keyboard/HotkeysContext'
|
||||
|
||||
type SortKey = 'name' | 'updatedAt'
|
||||
|
||||
export default function CharactersPage() {
|
||||
const { projectId = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { data: characters, isPending, error } = useCharacters(projectId)
|
||||
const { data: project } = useProject(projectId)
|
||||
const { data: allTags } = useTags(projectId)
|
||||
const { can } = useAuth()
|
||||
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 [search, setSearch] = useState('')
|
||||
@@ -78,16 +60,6 @@ export default function CharactersPage() {
|
||||
if (isPending) return <Spinner label="Loading characters" />
|
||||
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) {
|
||||
return (
|
||||
<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."
|
||||
/>
|
||||
{adding && (
|
||||
<AddCharacterModal projectId={projectId} onClose={() => setAdding(false)} onCreated={setSelectedId} />
|
||||
<AddCharacterModal
|
||||
projectId={projectId}
|
||||
onClose={() => setAdding(false)}
|
||||
onCreated={(id) => navigate(`/projects/${projectId}/characters/${id}`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -139,24 +115,13 @@ export default function CharactersPage() {
|
||||
onSortDir={setSortDir}
|
||||
/>
|
||||
|
||||
<CharacterTable characters={sorted} selectedId={selected?.id} onSelect={select} />
|
||||
|
||||
{selected && (
|
||||
<CharacterSheet
|
||||
key={selected.id}
|
||||
projectId={projectId}
|
||||
character={selected}
|
||||
canWrite={canWrite}
|
||||
canCreate={canCreate}
|
||||
canDelete={canDelete}
|
||||
/>
|
||||
)}
|
||||
<CharacterTable characters={sorted} projectId={projectId} />
|
||||
|
||||
{adding && (
|
||||
<AddCharacterModal
|
||||
projectId={projectId}
|
||||
onClose={() => setAdding(false)}
|
||||
onCreated={setSelectedId}
|
||||
onCreated={(id) => navigate(`/projects/${projectId}/characters/${id}`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -306,15 +271,7 @@ function CharacterFilterBar({
|
||||
)
|
||||
}
|
||||
|
||||
function CharacterTable({
|
||||
characters,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
characters: Character[]
|
||||
selectedId: string | undefined
|
||||
onSelect: (id: string) => void
|
||||
}) {
|
||||
function CharacterTable({ characters, projectId }: { characters: Character[]; projectId: string }) {
|
||||
if (characters.length === 0) {
|
||||
return (
|
||||
<div className="card p-5 text-sm muted" id="character-table-empty">
|
||||
@@ -337,20 +294,17 @@ function CharacterTable({
|
||||
</thead>
|
||||
<tbody>
|
||||
{characters.map((character) => (
|
||||
<tr
|
||||
key={character.id}
|
||||
onClick={() => onSelect(character.id)}
|
||||
className="cursor-pointer align-top transition"
|
||||
style={{
|
||||
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>
|
||||
{character.aliases.length > 0 && (
|
||||
<div className="text-xs muted">aka {character.aliases.join(', ')}</div>
|
||||
)}
|
||||
<tr key={character.id} className="align-top" style={{ borderTop: '1px solid var(--line)' }}>
|
||||
<td className="p-0">
|
||||
<Link
|
||||
to={`/projects/${projectId}/characters/${character.id}`}
|
||||
className="block px-3 py-2 transition hover:bg-[var(--surface-sunken)]"
|
||||
>
|
||||
<div className="font-medium">{character.name}</div>
|
||||
{character.aliases.length > 0 && (
|
||||
<div className="text-xs muted">aka {character.aliases.join(', ')}</div>
|
||||
)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2 px-3">{character.role}</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({
|
||||
projectId,
|
||||
onClose,
|
||||
|
||||
Reference in New Issue
Block a user