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} + /> + 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, { 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({ 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,