diff --git a/src/Novelly.Web/src/App.tsx b/src/Novelly.Web/src/App.tsx
index 393352e..e5e6026 100644
--- a/src/Novelly.Web/src/App.tsx
+++ b/src/Novelly.Web/src/App.tsx
@@ -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() {
}>
} />
} />
+ } />
} />
} />
} />
diff --git a/src/Novelly.Web/src/components/CharacterMultiSelect.tsx b/src/Novelly.Web/src/components/CharacterMultiSelect.tsx
index 73f8296..0eb309b 100644
--- a/src/Novelly.Web/src/components/CharacterMultiSelect.tsx
+++ b/src/Novelly.Web/src/components/CharacterMultiSelect.tsx
@@ -19,7 +19,7 @@ export function CharacterChip({
>
{projectId ? (
e.stopPropagation()}
>
diff --git a/src/Novelly.Web/src/pages/CharacterDetailPage.tsx b/src/Novelly.Web/src/pages/CharacterDetailPage.tsx
new file mode 100644
index 0000000..d5d3a5d
--- /dev/null
+++ b/src/Novelly.Web/src/pages/CharacterDetailPage.tsx
@@ -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
+ if (error) return
+
+ const character = characters?.find((c) => c.id === characterId)
+
+ if (!character) {
+ return (
+
+
+ ← All characters
+
+
+
+ )
+ }
+
+ return (
+
+
+ ← All characters
+
+
+
+
+ )
+}
+
+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> & { tags?: string[]; aliases?: string[] }) =>
+ update.mutate({ id: character.id, ...body })
+
+ return (
+ <>
+
+
+
+
name.trim() && patch({ name })}
+ readOnly={!canWrite}
+ />
+
+ {canDelete && (
+
+ )}
+
+
+
+
patch({ age })} readOnly={!canWrite} />
+ patch({ pronouns })}
+ readOnly={!canWrite}
+ />
+ patch({ occupation })}
+ readOnly={!canWrite}
+ />
+
+
+
+
t.name) ?? []}
+ onChange={(tags) => canWrite && patch({ tags })}
+ />
+ canWrite && patch({ aliases })}
+ />
+
+
+
+
patch({ want })}
+ readOnly={!canWrite}
+ />
+ patch({ need })}
+ readOnly={!canWrite}
+ />
+ patch({ internalConflict })}
+ readOnly={!canWrite}
+ />
+ patch({ externalConflict })}
+ readOnly={!canWrite}
+ />
+ patch({ arcSummary })}
+ readOnly={!canWrite}
+ />
+ patch({ voice })}
+ readOnly={!canWrite}
+ />
+ patch({ appearance })}
+ readOnly={!canWrite}
+ />
+ patch({ personality })}
+ readOnly={!canWrite}
+ />
+
+
+
+
patch({ backstory })}
+ readOnly={!canWrite}
+ />
+ patch({ notes })}
+ readOnly={!canWrite}
+ />
+
+
+ {character.relationships.length > 0 && (
+
+
Relationships
+
+ {character.relationships.map((relationship) => (
+ -
+ {relationship.relatedCharacterName}
+ — {relationship.relationshipType}
+ {relationship.description && : {relationship.description}}
+
+ ))}
+
+
+ )}
+
+
+
+ linkIdentity.mutate({ id: character.id, sameCharacterAsId, revealedInChapterId, note })
+ }
+ onUnlink={() => unlinkIdentity.mutate(character.id)}
+ />
+
+
+ {update.error && (
+
+
+
+ )}
+ {linkIdentity.error && (
+
+
+
+ )}
+
+
+ {(character.importance === 'Main' || character.arcStages.length > 0) && (
+
+ )}
+
+
+
+
+
+ {confirmingDelete && (
+
+ 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 (
+
+
Identity
+
+ Really {character.sameCharacterAsName}
+ {character.revealedInChapterNumber != null && (
+ — revealed in Chapter {character.revealedInChapterNumber}
+ )}
+
+ {character.identityNote &&
{character.identityNote}
}
+ {canWrite && (
+
+ )}
+
+ )
+ }
+
+ return (
+
+ {character.otherIdentities.length > 0 && (
+
+
Also appears as
+
+ {character.otherIdentities.map((identity) => (
+ - {identity.name}
+ ))}
+
+
+ )}
+
+ {canWrite && candidates.length > 0 && (
+ <>
+ {!picking ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+
+ )
+}
diff --git a/src/Novelly.Web/src/pages/CharactersPage.tsx b/src/Novelly.Web/src/pages/CharactersPage.tsx
index 770f168..98e7456 100644
--- a/src/Novelly.Web/src/pages/CharactersPage.tsx
+++ b/src/Novelly.Web/src/pages/CharactersPage.tsx
@@ -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(searchParams.get('character'))
const [adding, setAdding] = useState(false)
const [search, setSearch] = useState('')
@@ -78,16 +60,6 @@ export default function CharactersPage() {
if (isPending) return
if (error) return
- 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 (
@@ -103,7 +75,11 @@ export default function CharactersPage() {
hint="Add the protagonist first — most outline questions resolve once you know what they want."
/>
{adding && (
-
setAdding(false)} onCreated={setSelectedId} />
+ setAdding(false)}
+ onCreated={(id) => navigate(`/projects/${projectId}/characters/${id}`)}
+ />
)}
)
@@ -139,24 +115,13 @@ export default function CharactersPage() {
onSortDir={setSortDir}
/>
-
-
- {selected && (
-
- )}
+
{adding && (
setAdding(false)}
- onCreated={setSelectedId}
+ onCreated={(id) => navigate(`/projects/${projectId}/characters/${id}`)}
/>
)}
@@ -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 (
@@ -337,20 +294,17 @@ function CharacterTable({
{characters.map((character) => (
- onSelect(character.id)}
- className="cursor-pointer align-top transition"
- style={{
- borderTop: '1px solid var(--line)',
- background: character.id === selectedId ? 'var(--accent-soft)' : undefined,
- }}
- >
- |
- {character.name}
- {character.aliases.length > 0 && (
- aka {character.aliases.join(', ')}
- )}
+ |
+ |
+
+ {character.name}
+ {character.aliases.length > 0 && (
+ aka {character.aliases.join(', ')}
+ )}
+
|
{character.role} |
{character.importance} |
@@ -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> & { tags?: string[]; aliases?: string[] }) =>
- update.mutate({ id: character.id, ...body })
-
- return (
- <>
-
-
-
-
name.trim() && patch({ name })}
- readOnly={!canWrite}
- />
- canWrite && patch({ role })}
- />
- canWrite && patch({ importance })}
- />
-
- {canDelete && (
-
- )}
-
-
-
-
patch({ age })} readOnly={!canWrite} />
- patch({ pronouns })}
- readOnly={!canWrite}
- />
- patch({ occupation })}
- readOnly={!canWrite}
- />
-
-
-
-
t.name) ?? []}
- onChange={(tags) => canWrite && patch({ tags })}
- />
- canWrite && patch({ aliases })}
- />
-
-
-
-
patch({ want })}
- readOnly={!canWrite}
- />
- patch({ need })}
- readOnly={!canWrite}
- />
- patch({ internalConflict })}
- readOnly={!canWrite}
- />
- patch({ externalConflict })}
- readOnly={!canWrite}
- />
- patch({ arcSummary })}
- readOnly={!canWrite}
- />
- patch({ voice })}
- readOnly={!canWrite}
- />
- patch({ appearance })}
- readOnly={!canWrite}
- />
- patch({ personality })}
- readOnly={!canWrite}
- />
-
-
-
-
patch({ backstory })}
- readOnly={!canWrite}
- />
- patch({ notes })}
- readOnly={!canWrite}
- />
-
-
- {character.relationships.length > 0 && (
-
-
Relationships
-
- {character.relationships.map((relationship) => (
- -
- {relationship.relatedCharacterName}
- — {relationship.relationshipType}
- {relationship.description && : {relationship.description}}
-
- ))}
-
-
- )}
-
-
-
- linkIdentity.mutate({ id: character.id, sameCharacterAsId, revealedInChapterId, note })
- }
- onUnlink={() => unlinkIdentity.mutate(character.id)}
- />
-
-
- {update.error && (
-
-
-
- )}
- {linkIdentity.error && (
-
-
-
- )}
-
-
- {(character.importance === 'Main' || character.arcStages.length > 0) && (
-
- )}
-
-
-
-
-
- {confirmingDelete && (
- 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 (
-
-
Identity
-
- Really {character.sameCharacterAsName}
- {character.revealedInChapterNumber != null && (
- — revealed in Chapter {character.revealedInChapterNumber}
- )}
-
- {character.identityNote &&
{character.identityNote}
}
- {canWrite && (
-
- )}
-
- )
- }
-
- return (
-
- {character.otherIdentities.length > 0 && (
-
-
Also appears as
-
- {character.otherIdentities.map((identity) => (
- - {identity.name}
- ))}
-
-
- )}
-
- {canWrite && candidates.length > 0 && (
- <>
- {!picking ? (
-
- ) : (
-
- )}
- >
- )}
-
- )
-}
-
function AddCharacterModal({
projectId,
onClose,