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 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user