Add character aliases/identity links, move-beats, keyboard help overlay

- Characters can carry aliases and be linked as the same underlying
  person (canonical SameCharacterAsId, optional reveal chapter/note),
  surfaced through the API, MCP tools, agent toolset, and web UI.
- Characters page redesigned as a filterable/sortable table (name+
  aliases, role, importance, occupation, tags) instead of a sidebar
  list, to stay usable as the cast grows.
- Beats can be moved between chapters (BeatService.MoveAsync + MCP/
  agent tool + endpoint).
- Add a keyboard-shortcuts help overlay (HelpButton/HelpOverlayContext)
  wired into the project layout.
- CLAUDE.md: require every frontend component to carry a unique id
  attribute; apply it to CharacterMultiSelect and MarkdownEditor.
This commit is contained in:
James Wampler
2026-08-17 17:26:50 -07:00
parent b4f4b35e3c
commit f124b9b4bb
32 changed files with 3002 additions and 356 deletions
+19 -16
View File
@@ -13,6 +13,7 @@ import { AuthProvider, useAuth } from './auth/AuthContext'
import { Spinner } from './components/ui'
import { HotkeysProvider } from './keyboard/HotkeysContext'
import { HelpOverlay } from './keyboard/HelpOverlay'
import { HelpOverlayProvider } from './keyboard/HelpOverlayContext'
function RequireAuth() {
const { user, isPending } = useAuth()
@@ -27,23 +28,25 @@ export default function App() {
return (
<AuthProvider>
<HotkeysProvider>
<HelpOverlay />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
<Route path="/" element={<ProjectsPage />} />
<Route path="/projects/:projectId" element={<ProjectLayout />}>
<Route index element={<DashboardPage />} />
<Route path="characters" element={<CharactersPage />} />
<Route path="chapters" element={<ChaptersPage />} />
<Route path="chapters/:chapterId" element={<ChapterPage />} />
<Route path="tags" element={<TagsPage />} />
<Route path="agent" element={<AgentPage />} />
<Route path="settings" element={<SettingsPage />} />
<HelpOverlayProvider>
<HelpOverlay />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<RequireAuth />}>
<Route path="/" element={<ProjectsPage />} />
<Route path="/projects/:projectId" element={<ProjectLayout />}>
<Route index element={<DashboardPage />} />
<Route path="characters" element={<CharactersPage />} />
<Route path="chapters" element={<ChaptersPage />} />
<Route path="chapters/:chapterId" element={<ChapterPage />} />
<Route path="tags" element={<TagsPage />} />
<Route path="agent" element={<AgentPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<ProjectsPage />} />
</Route>
<Route path="*" element={<ProjectsPage />} />
</Route>
</Routes>
</Routes>
</HelpOverlayProvider>
</HotkeysProvider>
</AuthProvider>
)
+2
View File
@@ -39,5 +39,7 @@ export const api = {
request<T>(path, { method: 'POST', body: JSON.stringify(body ?? {}) }),
patch: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown) =>
request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
delete: (path: string) => request<void>(path, { method: 'DELETE' }),
}
+38
View File
@@ -178,6 +178,32 @@ export function useDeleteCharacter(projectId: string) {
})
}
export function useLinkCharacterIdentity(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({
id,
sameCharacterAsId,
revealedInChapterId,
note,
}: {
id: string
sameCharacterAsId: string
revealedInChapterId?: string | null
note?: string | null
}) => api.put<Character>(`/api/characters/${id}/identity`, { sameCharacterAsId, revealedInChapterId, note }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
})
}
export function useUnlinkCharacterIdentity(projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/api/characters/${id}/identity`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }),
})
}
export const useCharacterBeats = (characterId: string | undefined) =>
useQuery({
queryKey: keys.characterBeats(characterId ?? ''),
@@ -371,6 +397,18 @@ export function useAssignCharacterToBeats(chapterId: string) {
})
}
export function useMoveBeats(chapterId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ targetChapterId, beatIds }: { targetChapterId: string; beatIds: string[] }) =>
api.post<Beat[]>(`/api/chapters/${chapterId}/beats/move`, { targetChapterId, beatIds }),
onSuccess: (_, { targetChapterId }) => {
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
qc.invalidateQueries({ queryKey: keys.chapter(targetChapterId) })
},
})
}
export const useChapters = (projectId: string) =>
useQuery({
queryKey: keys.chapters(projectId),
+12
View File
@@ -188,12 +188,24 @@ export interface Character {
arcSummary: string | null
voice: string | null
notes: string | null
aliases: string[]
sameCharacterAsId: string | null
sameCharacterAsName: string | null
revealedInChapterId: string | null
revealedInChapterNumber: number | null
identityNote: string | null
otherIdentities: CharacterIdentity[]
relationships: Relationship[]
tags: Tag[]
arcStages: ArcStage[]
updatedAt: string
}
export interface CharacterIdentity {
id: string
name: string
}
export interface ChapterSummary {
id: string
projectId: string
@@ -0,0 +1,69 @@
import { useState } from 'react'
export function AliasEditor({
aliases,
onChange,
label,
readOnly,
}: {
aliases: string[]
onChange: (aliases: string[]) => void
label?: string
readOnly?: boolean
}) {
const [draft, setDraft] = useState('')
const add = () => {
const name = draft.trim()
if (!name) return
if (!aliases.some((a) => a.toLowerCase() === name.toLowerCase())) {
onChange([...aliases, name])
}
setDraft('')
}
const remove = (name: string) => onChange(aliases.filter((a) => a !== name))
return (
<div id="alias-editor">
{label && <span className="label">{label}</span>}
<div className="flex flex-wrap items-center gap-1.5">
{aliases.map((alias) => (
<span
key={alias}
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={{ color: 'var(--accent)', background: 'color-mix(in srgb, var(--accent) 14%, transparent)' }}
>
{alias}
{!readOnly && (
<button
type="button"
onClick={() => remove(alias)}
className="opacity-60 transition hover:opacity-100"
aria-label={`Remove alias ${alias}`}
>
</button>
)}
</span>
))}
{!readOnly && (
<input
id="alias-editor-input"
className="input w-32 flex-1 px-2 py-0.5 text-xs"
value={draft}
placeholder="Add alias…"
onChange={(e) => setDraft(e.target.value)}
onBlur={add}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
add()
}
}}
/>
)}
</div>
</div>
)
}
@@ -1,13 +1,33 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useCreateCharacter } from '../api/hooks'
import type { BeatCharacter } from '../api/types'
export function CharacterChip({ character, onRemove }: { character: BeatCharacter; onRemove?: () => void }) {
export function CharacterChip({
character,
projectId,
onRemove,
}: {
character: BeatCharacter
projectId?: string
onRemove?: () => void
}) {
return (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={{ color: 'var(--accent)', background: 'color-mix(in srgb, var(--accent) 14%, transparent)' }}
>
{character.name}
{projectId ? (
<Link
to={`/projects/${projectId}/characters?character=${character.id}`}
className="hover:underline"
onClick={(e) => e.stopPropagation()}
>
{character.name}
</Link>
) : (
character.name
)}
{onRemove && (
<button
type="button"
@@ -23,16 +43,19 @@ export function CharacterChip({ character, onRemove }: { character: BeatCharacte
}
export function CharacterMultiSelect({
projectId,
selected,
options,
onChange,
}: {
projectId: string
selected: BeatCharacter[]
options: { id: string; name: string }[]
onChange: (ids: string[]) => void
}) {
const [draft, setDraft] = useState('')
const listId = 'character-multiselect-options'
const createCharacter = useCreateCharacter(projectId)
const add = () => {
const name = draft.trim()
@@ -40,9 +63,17 @@ export function CharacterMultiSelect({
if (!name) return
const match = options.find((o) => o.name.toLowerCase() === name.toLowerCase())
if (match && !selected.some((c) => c.id === match.id)) {
onChange([...selected.map((c) => c.id), match.id])
if (match) {
if (!selected.some((c) => c.id === match.id)) {
onChange([...selected.map((c) => c.id), match.id])
}
return
}
createCharacter.mutate(
{ name },
{ onSuccess: (character) => onChange([...selected.map((c) => c.id), character.id]) },
)
}
const remove = (id: string) => onChange(selected.filter((c) => c.id !== id).map((c) => c.id))
@@ -52,7 +83,7 @@ export function CharacterMultiSelect({
return (
<div className="flex flex-wrap items-center gap-1.5">
{selected.map((character) => (
<CharacterChip key={character.id} character={character} onRemove={() => remove(character.id)} />
<CharacterChip key={character.id} character={character} projectId={projectId} onRemove={() => remove(character.id)} />
))}
<input
className="input w-28 flex-1 px-2 py-0.5 text-xs"
@@ -16,7 +16,9 @@ export function MarkdownEditor({
}) {
const [draft, setDraft] = useState(value ?? '')
const [mode, setMode] = useState<'write' | 'preview'>('write')
const [isFullscreen, setIsFullscreen] = useState(false)
const committed = useRef(value ?? '')
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const incoming = value ?? ''
@@ -26,6 +28,12 @@ export function MarkdownEditor({
}
}, [value])
useEffect(() => {
const onFullscreenChange = () => setIsFullscreen(document.fullscreenElement === containerRef.current)
document.addEventListener('fullscreenchange', onFullscreenChange)
return () => document.removeEventListener('fullscreenchange', onFullscreenChange)
}, [])
const commit = () => {
if (draft !== committed.current) {
committed.current = draft
@@ -33,8 +41,20 @@ export function MarkdownEditor({
}
}
const toggleFullscreen = () => {
if (document.fullscreenElement === containerRef.current) {
document.exitFullscreen()
return
}
containerRef.current?.requestFullscreen()
}
return (
<div>
<div
ref={containerRef}
className={isFullscreen ? 'flex h-screen flex-col p-4' : ''}
style={isFullscreen ? { background: 'var(--surface)' } : undefined}
>
<div className="mb-2 flex justify-end gap-1">
<button
type="button"
@@ -53,11 +73,14 @@ export function MarkdownEditor({
>
Preview
</button>
<button type="button" className="btn px-2 py-1 text-xs" onClick={toggleFullscreen}>
{isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
</button>
</div>
{mode === 'write' ? (
<textarea
className="input font-mono text-sm"
className={`input font-mono text-sm ${isFullscreen ? 'flex-1 resize-none' : ''}`}
rows={rows}
value={draft}
placeholder={placeholder}
@@ -66,7 +89,9 @@ export function MarkdownEditor({
readOnly={readOnly}
/>
) : (
<div className="markdown-preview card min-h-[20rem] p-4">
<div
className={`markdown-preview card min-h-[20rem] p-4 ${isFullscreen ? 'flex-1 overflow-y-auto' : ''}`}
>
{draft.trim() ? <ReactMarkdown>{draft}</ReactMarkdown> : <p className="muted">Nothing written yet.</p>}
</div>
)}
@@ -0,0 +1,17 @@
import { useHelpOverlay } from './HelpOverlayContext'
export function HelpButton() {
const { setOpen } = useHelpOverlay()
return (
<button
className="flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold shadow-sm transition hover:shadow-md"
style={{ background: 'var(--surface)', border: '1px solid var(--line)' }}
onClick={() => setOpen(true)}
aria-label="Keyboard shortcuts"
title="Keyboard shortcuts (?)"
>
?
</button>
)
}
+40 -55
View File
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { useHotkey, useHotkeysList } from './HotkeysContext'
import { useHelpOverlay } from './HelpOverlayContext'
import { useHotkeysList } from './HotkeysContext'
const formatToken = (token: string) => {
if (token === 'mod') return '⌘/Ctrl'
@@ -38,12 +38,9 @@ function KeySequence({ keys }: { keys: string }) {
}
export function HelpOverlay() {
const [open, setOpen] = useState(false)
const { open, setOpen } = useHelpOverlay()
const shortcuts = useHotkeysList()
useHotkey('?', 'Toggle this help', () => setOpen((v) => !v), { group: 'Global' })
useHotkey('Escape', 'Close help', () => setOpen(false), { group: 'Global', enabled: open })
const groups = new Map<string, typeof shortcuts>()
for (const shortcut of shortcuts) {
if (!groups.has(shortcut.group)) groups.set(shortcut.group, [])
@@ -53,57 +50,45 @@ export function HelpOverlay() {
a === 'Global' ? -1 : b === 'Global' ? 1 : a.localeCompare(b),
)
if (!open) return null
return (
<>
<button
className="fixed top-3 right-4 z-40 flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold shadow-sm transition hover:shadow-md"
style={{ background: 'var(--surface)', border: '1px solid var(--line)' }}
onClick={() => setOpen(true)}
aria-label="Keyboard shortcuts"
title="Keyboard shortcuts (?)"
<div className="fixed inset-0 z-50 flex justify-end bg-black/40" onClick={() => setOpen(false)}>
<aside
className="flex h-full w-full max-w-sm flex-col overflow-y-auto p-5 shadow-xl"
style={{ background: 'var(--surface)', borderLeft: '1px solid var(--line)' }}
onClick={(e) => e.stopPropagation()}
>
?
</button>
{open && (
<div className="fixed inset-0 z-50 flex justify-end bg-black/40" onClick={() => setOpen(false)}>
<aside
className="flex h-full w-full max-w-sm flex-col overflow-y-auto p-5 shadow-xl"
style={{ background: 'var(--surface)', borderLeft: '1px solid var(--line)' }}
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Keyboard shortcuts</h2>
<button className="btn px-2 py-1" onClick={() => setOpen(false)} aria-label="Close">
</button>
</div>
<p className="mb-4 text-sm muted">
Shown here are the shortcuts available on the screen you're on. Shortcuts don't fire
while a text field is focused, except where noted.
</p>
{orderedGroups.length === 0 && <p className="text-sm muted">No shortcuts registered.</p>}
<div className="grid gap-5">
{orderedGroups.map(([group, groupShortcuts]) => (
<div key={group}>
<h3 className="label mb-2">{group}</h3>
<ul className="grid gap-2">
{groupShortcuts.map((shortcut) => (
<li key={shortcut.id} className="flex items-center justify-between gap-3 text-sm">
<span>{shortcut.description}</span>
<KeySequence keys={shortcut.keys} />
</li>
))}
</ul>
</div>
))}
</div>
</aside>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">Keyboard shortcuts</h2>
<button className="btn px-2 py-1" onClick={() => setOpen(false)} aria-label="Close">
</button>
</div>
)}
</>
<p className="mb-4 text-sm muted">
Shown here are the shortcuts available on the screen you're on. Shortcuts don't fire
while a text field is focused, except where noted.
</p>
{orderedGroups.length === 0 && <p className="text-sm muted">No shortcuts registered.</p>}
<div className="grid gap-5">
{orderedGroups.map(([group, groupShortcuts]) => (
<div key={group}>
<h3 className="label mb-2">{group}</h3>
<ul className="grid gap-2">
{groupShortcuts.map((shortcut) => (
<li key={shortcut.id} className="flex items-center justify-between gap-3 text-sm">
<span>{shortcut.description}</span>
<KeySequence keys={shortcut.keys} />
</li>
))}
</ul>
</div>
))}
</div>
</aside>
</div>
)
}
@@ -0,0 +1,24 @@
import { createContext, useContext, useState, type ReactNode } from 'react'
import { useHotkey } from './HotkeysContext'
interface HelpOverlayState {
open: boolean
setOpen: (open: boolean) => void
}
const HelpOverlayStateContext = createContext<HelpOverlayState | null>(null)
export function HelpOverlayProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false)
useHotkey('?', 'Toggle this help', () => setOpen((v) => !v), { group: 'Global' })
useHotkey('Escape', 'Close help', () => setOpen(false), { group: 'Global', enabled: open })
return <HelpOverlayStateContext.Provider value={{ open, setOpen }}>{children}</HelpOverlayStateContext.Provider>
}
export function useHelpOverlay() {
const context = useContext(HelpOverlayStateContext)
if (!context) throw new Error('useHelpOverlay must be used within a HelpOverlayProvider')
return context
}
+320 -131
View File
@@ -3,17 +3,20 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
import {
useAssignCharacterToBeats,
useChapter,
useChapters,
useCharacters,
useCreateBeat,
useCreateChapter,
useDeleteBeat,
useDeleteChapter,
useMoveBeats,
useProject,
useReorderBeats,
useTags,
useUpdateBeat,
useUpdateChapter,
} from '../api/hooks'
import { draftStatuses, type Beat, type Chapter } from '../api/types'
import { draftStatuses, type Beat, type Chapter, type ChapterSummary } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
@@ -33,6 +36,8 @@ export default function ChapterPage() {
const { data: project } = useProject(projectId)
const { data: characters } = useCharacters(projectId)
const { data: allTags } = useTags(projectId)
const { data: chapters } = useChapters(projectId)
const createChapter = useCreateChapter(projectId)
const update = useUpdateChapter(projectId)
const remove = useDeleteChapter(projectId)
const createBeat = useCreateBeat(chapterId, projectId)
@@ -46,6 +51,24 @@ export default function ChapterPage() {
useHotkey('b', 'Add beat', () => canCreate && createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' })
const currentIndex = chapters?.findIndex((c) => c.id === chapterId) ?? -1
const prevChapter = currentIndex > 0 ? chapters?.[currentIndex - 1] : undefined
const nextChapter =
currentIndex >= 0 && chapters && currentIndex < chapters.length - 1 ? chapters[currentIndex + 1] : undefined
useHotkey(
'[',
'Previous chapter',
() => prevChapter && navigate(`/projects/${projectId}/chapters/${prevChapter.id}`),
{ group: 'Chapter', enabled: Boolean(prevChapter) },
)
useHotkey(
']',
'Next chapter',
() => nextChapter && navigate(`/projects/${projectId}/chapters/${nextChapter.id}`),
{ group: 'Chapter', enabled: Boolean(nextChapter) },
)
if (isPending) return <Spinner label="Loading chapter" />
if (error) return <ErrorNote error={error} />
if (!chapter) return null
@@ -54,87 +77,45 @@ export default function ChapterPage() {
update.mutate({ id: chapter.id, ...body })
const suggestions = allTags?.map((t) => t.name) ?? []
const settingSuggestions = [
...new Set((chapters ?? []).map((c) => c.setting).filter((s): s is string => Boolean(s?.trim()))),
].sort()
return (
<div>
<div className="mb-4">
<div className="mb-4 flex items-center justify-between gap-4">
<Link to={`/projects/${projectId}/chapters`} className="text-sm muted hover:underline">
All chapters
</Link>
</div>
<section className="card mb-6 p-5">
<div className="grid gap-4 sm:grid-cols-[4rem_1fr_10rem]">
<label className="block">
<span className="label">No.</span>
<input
className="input"
type="number"
min={1}
defaultValue={chapter.number}
readOnly={!canWrite}
onBlur={(e) => {
const number = Number(e.target.value)
if (number > 0 && number !== chapter.number) patch({ number })
}}
/>
</label>
<AutoField
label="Title"
value={chapter.title}
onCommit={(title) => title.trim() && patch({ title })}
readOnly={!canWrite}
/>
<Select
label="Status"
value={chapter.status}
options={draftStatuses}
onChange={(status) => canWrite && patch({ status })}
/>
</div>
<div className="mt-4">
<AutoField
label="Setting"
value={chapter.setting}
onCommit={(setting) => patch({ setting })}
readOnly={!canWrite}
/>
</div>
<div className="mt-4">
<TagEditor
label="Tags"
tags={chapter.tags}
suggestions={suggestions}
onChange={(tags) => canWrite && patch({ tags })}
/>
</div>
<div className="mt-4 flex items-end justify-between gap-4">
<div className="text-sm muted">
{chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words
</div>
{canDelete && (
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
Delete chapter
</button>
<div className="flex items-center gap-3 text-sm">
{prevChapter ? (
<Link
to={`/projects/${projectId}/chapters/${prevChapter.id}`}
className="muted hover:underline"
title={`Chapter ${prevChapter.number}: ${prevChapter.title}`}
>
Ch. {prevChapter.number}
</Link>
) : (
<span className="muted" style={{ opacity: 0.4 }}>
Ch.
</span>
)}
{nextChapter ? (
<Link
to={`/projects/${projectId}/chapters/${nextChapter.id}`}
className="muted hover:underline"
title={`Chapter ${nextChapter.number}: ${nextChapter.title}`}
>
Ch. {nextChapter.number}
</Link>
) : (
<span className="muted" style={{ opacity: 0.4 }}>
Ch.
</span>
)}
</div>
</section>
{confirmingDelete && (
<ConfirmModal
title="Delete chapter"
message={`Delete chapter "${chapter.title}" and everything in it? This cannot be undone.`}
onConfirm={() =>
remove.mutate(chapter.id, {
onSuccess: () => navigate(`/projects/${projectId}/chapters`),
})
}
onClose={() => setConfirmingDelete(false)}
/>
)}
</div>
<div className="mb-5 flex gap-1" style={{ borderBottom: '1px solid var(--line)' }}>
{(
@@ -157,6 +138,83 @@ export default function ChapterPage() {
))}
</div>
{tab === 'outline' && (
<section className="card mb-6 p-5">
<div className="grid gap-4 sm:grid-cols-[4rem_1fr_10rem]">
<label className="block">
<span className="label">No.</span>
<input
key={chapter.id}
className="input"
type="number"
min={1}
defaultValue={chapter.number}
readOnly={!canWrite}
onBlur={(e) => {
const number = Number(e.target.value)
if (number > 0 && number !== chapter.number) patch({ number })
}}
/>
</label>
<AutoField
label="Title"
value={chapter.title}
onCommit={(title) => title.trim() && patch({ title })}
readOnly={!canWrite}
/>
<Select
label="Status"
value={chapter.status}
options={draftStatuses}
onChange={(status) => canWrite && patch({ status })}
/>
</div>
<div className="mt-4">
<AutoField
label="Setting"
value={chapter.setting}
onCommit={(setting) => patch({ setting })}
suggestions={settingSuggestions}
readOnly={!canWrite}
/>
</div>
<div className="mt-4">
<TagEditor
label="Tags"
tags={chapter.tags}
suggestions={suggestions}
onChange={(tags) => canWrite && patch({ tags })}
/>
</div>
<div className="mt-4 flex items-end justify-between gap-4">
<div className="text-sm muted">
{chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words
</div>
{canDelete && (
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
Delete chapter
</button>
)}
</div>
</section>
)}
{confirmingDelete && (
<ConfirmModal
title="Delete chapter"
message={`Delete chapter "${chapter.title}" and everything in it? This cannot be undone.`}
onConfirm={() =>
remove.mutate(chapter.id, {
onSuccess: () => navigate(`/projects/${projectId}/chapters`),
})
}
onClose={() => setConfirmingDelete(false)}
/>
)}
{tab === 'outline' ? (
<section className="mb-8">
<p className="mb-3 text-sm muted">
@@ -180,6 +238,8 @@ export default function ChapterPage() {
chapter={chapter}
projectId={projectId}
characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []}
otherChapters={chapters?.filter((c) => c.id !== chapter.id) ?? []}
createChapter={createChapter}
suggestions={suggestions}
onCharacterContextMenu={handleContextMenu}
canWrite={canWrite}
@@ -200,6 +260,30 @@ export default function ChapterPage() {
<ErrorNote error={createBeat.error} />
</div>
)}
<div className="card mt-6 p-5">
<h3 className="mb-1 text-sm font-semibold">Notes</h3>
<p className="mb-3 text-xs muted">
Anything that does not belong in the outline itself continuity to watch, research
to do, decisions already made. Resolved questions land here too.
</p>
<AutoField
value={chapter.notes}
multiline
rows={5}
placeholder="Notes on this chapter."
onCommit={(notes) => patch({ notes })}
readOnly={!canWrite}
/>
</div>
<OpenQuestions
projectId={projectId}
scope={{ chapterId: chapter.id }}
canCreate={canCreate}
canWrite={canWrite}
canDelete={canDelete}
/>
</section>
) : (
<section className="mb-8">
@@ -207,45 +291,26 @@ export default function ChapterPage() {
<MarkdownEditor
value={chapter.prose}
placeholder="Start writing the chapter."
rows={36}
onCommit={(prose) => patch({ prose })}
readOnly={!canWrite}
/>
</section>
)}
<section className="card mt-6 p-5">
<h3 className="mb-1 text-sm font-semibold">Notes</h3>
<p className="mb-3 text-xs muted">
Anything that does not belong in the outline itself — continuity to watch, research
to do, decisions already made. Resolved questions land here too.
</p>
<AutoField
value={chapter.notes}
multiline
rows={5}
placeholder="Notes on this chapter."
onCommit={(notes) => patch({ notes })}
readOnly={!canWrite}
/>
</section>
<OpenQuestions
projectId={projectId}
scope={{ chapterId: chapter.id }}
canCreate={canCreate}
canWrite={canWrite}
canDelete={canDelete}
/>
{menuElement}
</div>
)
}
const MOVE_TO_NEW_CHAPTER = '__new__'
function BeatTable({
chapter,
projectId,
characters,
otherChapters,
createChapter,
suggestions,
onCharacterContextMenu,
canWrite,
@@ -254,6 +319,8 @@ function BeatTable({
chapter: Chapter
projectId: string
characters: { id: string; name: string }[]
otherChapters: ChapterSummary[]
createChapter: ReturnType<typeof useCreateChapter>
suggestions: string[]
onCharacterContextMenu: (
e: MouseEvent<HTMLTextAreaElement>,
@@ -266,10 +333,15 @@ function BeatTable({
const remove = useDeleteBeat(chapter.id)
const reorder = useReorderBeats(chapter.id)
const assignCharacter = useAssignCharacterToBeats(chapter.id)
const moveBeats = useMoveBeats(chapter.id)
const [editingId, setEditingId] = useState<string | null>(null)
const [deletingBeat, setDeletingBeat] = useState<Beat | null>(null)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [assignCharacterId, setAssignCharacterId] = useState('')
const [moveTargetId, setMoveTargetId] = useState('')
const [focusedBeatId, setFocusedBeatId] = useState<string | null>(null)
const [dragBeatId, setDragBeatId] = useState<string | null>(null)
const [dragOverBeatId, setDragOverBeatId] = useState<string | null>(null)
const toggleSelected = (id: string) =>
setSelectedIds((ids) => (ids.includes(id) ? ids.filter((i) => i !== id) : [...ids, id]))
@@ -285,12 +357,15 @@ function BeatTable({
)
}
if (chapter.beats.length === 0) {
return (
<div className="card px-6 py-8 text-center text-sm muted">
No beats yet. Each one is a short handle — “she burns the atlas” — plus what happened and
what it sets up.
</div>
const moveSelected = async () => {
if (!moveTargetId || selectedIds.length === 0) return
const targetChapterId =
moveTargetId === MOVE_TO_NEW_CHAPTER
? (await createChapter.mutateAsync({ title: 'New chapter' })).id
: moveTargetId
moveBeats.mutate(
{ targetChapterId, beatIds: selectedIds },
{ onSuccess: () => { setSelectedIds([]); setMoveTargetId('') } },
)
}
@@ -303,9 +378,88 @@ function BeatTable({
reorder.mutate(ids)
}
const moveFocused = (delta: number) => {
if (!canWrite || focusedBeatId === null) return
const index = chapter.beats.findIndex((b) => b.id === focusedBeatId)
if (index === -1) return
move(index, delta)
}
useHotkey('mod+ArrowUp', 'Move focused beat up', () => moveFocused(-1), {
group: 'Chapter',
enabled: canWrite && focusedBeatId !== null,
})
useHotkey('mod+ArrowDown', 'Move focused beat down', () => moveFocused(1), {
group: 'Chapter',
enabled: canWrite && focusedBeatId !== null,
})
if (chapter.beats.length === 0) {
return (
<div className="card px-6 py-8 text-center text-sm muted">
No beats yet. Each one is a short handle — “she burns the atlas” — plus what happened and
what it sets up.
</div>
)
}
const reorderByDrag = (targetBeatId: string) => {
if (!canWrite || dragBeatId === null || dragBeatId === targetBeatId) return
const ids = chapter.beats.map((b) => b.id)
const from = ids.indexOf(dragBeatId)
const to = ids.indexOf(targetBeatId)
if (from === -1 || to === -1) return
ids.splice(to, 0, ...ids.splice(from, 1))
reorder.mutate(ids)
}
const patch = (id: string, body: Partial<Omit<Beat, 'tags' | 'characters'>> & { tags?: string[]; characterIds?: string[] }) =>
update.mutate({ id, ...body })
const moveButtonClass =
'flex h-6 w-6 items-center justify-center rounded text-base leading-none transition hover:bg-[var(--accent-soft)] disabled:opacity-25 disabled:hover:bg-transparent'
const renderMoveButtons = (beat: Beat, index: number) => (
<div className="flex items-center gap-1">
<span
className={canWrite ? 'cursor-grab text-base muted' : 'text-base muted'}
title={canWrite ? 'Drag to reorder' : undefined}
aria-hidden="true"
>
</span>
<span className="w-4 text-xs muted">{index + 1}</span>
<div className="flex flex-col">
<button
id={`move-beat-up-${beat.id}`}
className={moveButtonClass}
onClick={(e) => {
e.stopPropagation()
move(index, -1)
}}
disabled={index === 0 || reorder.isPending}
aria-label="Move beat up"
title="Move beat up"
>
</button>
<button
id={`move-beat-down-${beat.id}`}
className={moveButtonClass}
onClick={(e) => {
e.stopPropagation()
move(index, 1)
}}
disabled={index === chapter.beats.length - 1 || reorder.isPending}
aria-label="Move beat down"
title="Move beat down"
>
</button>
</div>
</div>
)
return (
<div>
{selectedIds.length > 0 && canWrite && (
@@ -332,10 +486,32 @@ function BeatTable({
>
Assign
</button>
<select
className="input w-48"
value={moveTargetId}
onChange={(e) => setMoveTargetId(e.target.value)}
>
<option value="">Move to chapter…</option>
{otherChapters.map((c) => (
<option key={c.id} value={c.id}>
{c.number}. {c.title}
</option>
))}
<option value={MOVE_TO_NEW_CHAPTER}>New chapter…</option>
</select>
<button
className="btn btn-primary"
onClick={moveSelected}
disabled={!moveTargetId || moveBeats.isPending || createChapter.isPending}
>
Move
</button>
<button className="btn" onClick={() => setSelectedIds([])}>
Clear selection
</button>
{assignCharacter.error && <ErrorNote error={assignCharacter.error} />}
{moveBeats.error && <ErrorNote error={moveBeats.error} />}
{createChapter.error && <ErrorNote error={createChapter.error} />}
</div>
)}
@@ -351,7 +527,7 @@ function BeatTable({
onChange={toggleSelectAll}
/>
</th>
<th className="w-10 px-2 py-2 text-left text-xs font-semibold uppercase muted">#</th>
<th className="w-16 px-2 py-2 text-left text-xs font-semibold uppercase muted">#</th>
<th className="w-[16%] px-2 py-2 text-left text-xs font-semibold uppercase muted">Beat</th>
<th className="w-[16%] px-2 py-2 text-left text-xs font-semibold uppercase muted">
Characters
@@ -384,29 +560,7 @@ function BeatTable({
onClick={(e) => e.stopPropagation()}
/>
</td>
<td className="px-2 py-2 align-top">
<div className="flex items-center gap-1">
<span className="w-4 text-xs muted">{index + 1}</span>
<div className="flex flex-col">
<button
className="text-xs leading-none muted disabled:opacity-25"
onClick={() => move(index, -1)}
disabled={index === 0 || reorder.isPending}
aria-label="Move beat up"
>
</button>
<button
className="text-xs leading-none muted disabled:opacity-25"
onClick={() => move(index, 1)}
disabled={index === chapter.beats.length - 1 || reorder.isPending}
aria-label="Move beat down"
>
</button>
</div>
</div>
</td>
<td className="px-2 py-2 align-top">{renderMoveButtons(beat, index)}</td>
<td className="px-2 py-2 align-top">
<AutoField
@@ -425,6 +579,7 @@ function BeatTable({
<td className="px-2 py-2 align-top">
<CharacterMultiSelect
projectId={projectId}
selected={beat.characters}
options={characters}
onChange={(characterIds) => patch(beat.id, { characterIds })}
@@ -493,13 +648,19 @@ function BeatTable({
tabIndex={canWrite ? 0 : undefined}
role={canWrite ? 'button' : undefined}
aria-label={canWrite ? `Edit beat ${beat.title}` : undefined}
draggable={canWrite}
className={
canWrite
? 'cursor-pointer transition hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--accent)]'
: ''
}
style={{ borderBottom: '1px solid var(--line)' }}
style={{
borderBottom: '1px solid var(--line)',
opacity: dragBeatId === beat.id ? 0.4 : 1,
boxShadow: dragOverBeatId === beat.id && dragBeatId !== beat.id ? 'inset 0 2px 0 0 var(--accent)' : undefined,
}}
onClick={canWrite ? () => setEditingId(beat.id) : undefined}
onFocus={canWrite ? () => setFocusedBeatId(beat.id) : undefined}
onKeyDown={
canWrite
? (e) => {
@@ -510,6 +671,34 @@ function BeatTable({
}
: undefined
}
onDragStart={canWrite ? () => setDragBeatId(beat.id) : undefined}
onDragOver={
canWrite
? (e) => {
e.preventDefault()
setDragOverBeatId(beat.id)
}
: undefined
}
onDragLeave={canWrite ? () => setDragOverBeatId((id) => (id === beat.id ? null : id)) : undefined}
onDrop={
canWrite
? (e) => {
e.preventDefault()
reorderByDrag(beat.id)
setDragBeatId(null)
setDragOverBeatId(null)
}
: undefined
}
onDragEnd={
canWrite
? () => {
setDragBeatId(null)
setDragOverBeatId(null)
}
: undefined
}
>
<td className="px-2 py-2 align-top">
<input
@@ -520,7 +709,7 @@ function BeatTable({
onClick={(e) => e.stopPropagation()}
/>
</td>
<td className="px-2 py-2 align-top text-xs muted">{index + 1}</td>
<td className="px-2 py-2 align-top">{renderMoveButtons(beat, index)}</td>
<td className="px-2 py-2 align-top">
<div className="font-medium">{beat.title}</div>
@@ -537,7 +726,7 @@ function BeatTable({
{beat.characters.length > 0 ? (
<div className="flex flex-wrap gap-1">
{beat.characters.map((character) => (
<CharacterChip key={character.id} character={character} />
<CharacterChip key={character.id} character={character} projectId={projectId} />
))}
</div>
) : (
+484 -53
View File
@@ -1,93 +1,156 @@
import { useState } from 'react'
import { useParams } from 'react-router-dom'
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 { characterImportances, characterRoles, type Character } from '../api/types'
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 { TagEditor } 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'
type SortKey = 'name' | 'updatedAt'
export default function CharactersPage() {
const { projectId = '' } = useParams()
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 [selectedId, setSelectedId] = useState<string | null>(null)
const [searchParams, setSearchParams] = useSearchParams()
const [selectedId, setSelectedId] = useState<string | null>(searchParams.get('character'))
const [adding, setAdding] = useState(false)
const [search, setSearch] = useState('')
const [roleFilter, setRoleFilter] = useState<CharacterRole | ''>('')
const [importanceFilter, setImportanceFilter] = useState<CharacterImportance | ''>('')
const [occupationFilter, setOccupationFilter] = useState('')
const [tagFilter, setTagFilter] = useState('')
const [sortKey, setSortKey] = useState<SortKey>('name')
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc')
useHotkey('n', 'Add character', () => canCreate && setAdding(true), { group: 'Characters' })
const occupations = useMemo(
() => [...new Set((characters ?? []).map((c) => c.occupation).filter((o): o is string => Boolean(o)))].sort(),
[characters],
)
const filtered = useMemo(() => {
const term = search.trim().toLowerCase()
return (characters ?? []).filter((c) => {
if (term && !c.name.toLowerCase().includes(term) && !c.aliases.some((a) => a.toLowerCase().includes(term)))
return false
if (roleFilter && c.role !== roleFilter) return false
if (importanceFilter && c.importance !== importanceFilter) return false
if (occupationFilter && c.occupation !== occupationFilter) return false
if (tagFilter && !c.tags.some((t) => t.name === tagFilter)) return false
return true
})
}, [characters, search, roleFilter, importanceFilter, occupationFilter, tagFilter])
const sorted = useMemo(() => {
const list = [...filtered]
list.sort((a, b) => {
const cmp =
sortKey === 'name' ? a.name.localeCompare(b.name) : Date.parse(a.updatedAt) - Date.parse(b.updatedAt)
return sortDir === 'asc' ? cmp : -cmp
})
return list
}, [filtered, sortKey, sortDir])
if (isPending) return <Spinner label="Loading characters" />
if (error) return <ErrorNote error={error} />
const selected = characters?.find((c) => c.id === selectedId) ?? characters?.[0]
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">
{canCreate && (
<div className="flex justify-end">
<button className="btn btn-primary" onClick={() => setAdding(true)}>
Add character
</button>
</div>
)}
<EmptyState
title="No characters yet"
hint="Add the protagonist first — most outline questions resolve once you know what they want."
/>
{adding && (
<AddCharacterModal projectId={projectId} onClose={() => setAdding(false)} onCreated={setSelectedId} />
)}
</div>
)
}
return (
<div className="grid gap-6 lg:grid-cols-[16rem_1fr]">
<aside className="grid content-start gap-2">
<div className="grid gap-6">
<div className="flex items-center justify-between gap-3">
<h1 className="text-lg font-semibold">Characters</h1>
{canCreate && (
<button className="btn btn-primary w-full justify-center" onClick={() => setAdding(true)}>
<button className="btn btn-primary" onClick={() => setAdding(true)}>
Add character
</button>
)}
{(['Main', 'Supporting'] as const).map((importance) => {
const group = characters?.filter((c) => c.importance === importance) ?? []
if (group.length === 0) return null
</div>
return (
<div key={importance} className="grid gap-2">
<h2 className="label mt-2 mb-0">{importance}</h2>
{group.map((character) => (
<button
key={character.id}
onClick={() => setSelectedId(character.id)}
className="card px-3 py-2 text-left transition hover:shadow-sm"
style={
character.id === selected?.id
? { borderColor: 'var(--accent)', background: 'var(--accent-soft)' }
: undefined
}
>
<div className="truncate font-medium">{character.name}</div>
<div className="text-xs muted">{character.role}</div>
</button>
))}
</div>
)
})}
</aside>
<CharacterFilterBar
search={search}
onSearch={setSearch}
roleFilter={roleFilter}
onRoleFilter={setRoleFilter}
importanceFilter={importanceFilter}
onImportanceFilter={setImportanceFilter}
occupationFilter={occupationFilter}
onOccupationFilter={setOccupationFilter}
occupations={occupations}
tagFilter={tagFilter}
onTagFilter={setTagFilter}
tags={allTags ?? []}
sortKey={sortKey}
onSortKey={setSortKey}
sortDir={sortDir}
onSortDir={setSortDir}
/>
<section>
{!selected ? (
<EmptyState
title="No characters yet"
hint="Add the protagonist first — most outline questions resolve once you know what they want."
/>
) : (
<CharacterSheet
key={selected.id}
projectId={projectId}
character={selected}
canWrite={canWrite}
canCreate={canCreate}
canDelete={canDelete}
/>
)}
</section>
<CharacterTable characters={sorted} selectedId={selected?.id} onSelect={select} />
{selected && (
<CharacterSheet
key={selected.id}
projectId={projectId}
character={selected}
canWrite={canWrite}
canCreate={canCreate}
canDelete={canDelete}
/>
)}
{adding && (
<AddCharacterModal
@@ -100,6 +163,213 @@ export default function CharactersPage() {
)
}
function CharacterFilterBar({
search,
onSearch,
roleFilter,
onRoleFilter,
importanceFilter,
onImportanceFilter,
occupationFilter,
onOccupationFilter,
occupations,
tagFilter,
onTagFilter,
tags,
sortKey,
onSortKey,
sortDir,
onSortDir,
}: {
search: string
onSearch: (value: string) => void
roleFilter: CharacterRole | ''
onRoleFilter: (value: CharacterRole | '') => void
importanceFilter: CharacterImportance | ''
onImportanceFilter: (value: CharacterImportance | '') => void
occupationFilter: string
onOccupationFilter: (value: string) => void
occupations: string[]
tagFilter: string
onTagFilter: (value: string) => void
tags: { id: string; name: string }[]
sortKey: SortKey
onSortKey: (value: SortKey) => void
sortDir: 'asc' | 'desc'
onSortDir: (value: 'asc' | 'desc') => void
}) {
return (
<div id="character-filter-bar" className="card flex flex-wrap items-end gap-3 p-3">
<label className="block">
<span className="label">Search</span>
<input
id="character-filter-search"
className="input w-48"
value={search}
placeholder="Name or alias…"
onChange={(e) => onSearch(e.target.value)}
/>
</label>
<label className="block">
<span className="label">Role</span>
<select
id="character-filter-role"
className="input w-40"
value={roleFilter}
onChange={(e) => onRoleFilter(e.target.value as CharacterRole | '')}
>
<option value="">All roles</option>
{characterRoles.map((role) => (
<option key={role} value={role}>
{role}
</option>
))}
</select>
</label>
<label className="block">
<span className="label">Importance</span>
<select
id="character-filter-importance"
className="input w-36"
value={importanceFilter}
onChange={(e) => onImportanceFilter(e.target.value as CharacterImportance | '')}
>
<option value="">All</option>
{characterImportances.map((importance) => (
<option key={importance} value={importance}>
{importance}
</option>
))}
</select>
</label>
<label className="block">
<span className="label">Occupation</span>
<select
id="character-filter-occupation"
className="input w-40"
value={occupationFilter}
onChange={(e) => onOccupationFilter(e.target.value)}
>
<option value="">All</option>
{occupations.map((occupation) => (
<option key={occupation} value={occupation}>
{occupation}
</option>
))}
</select>
</label>
<label className="block">
<span className="label">Tag</span>
<select
id="character-filter-tag"
className="input w-36"
value={tagFilter}
onChange={(e) => onTagFilter(e.target.value)}
>
<option value="">All</option>
{tags.map((tag) => (
<option key={tag.id} value={tag.name}>
{tag.name}
</option>
))}
</select>
</label>
<div className="ml-auto flex items-end gap-2">
<label className="block">
<span className="label">Sort by</span>
<select
id="character-sort-key"
className="input w-36"
value={sortKey}
onChange={(e) => onSortKey(e.target.value as SortKey)}
>
<option value="name">Name</option>
<option value="updatedAt">Last modified</option>
</select>
</label>
<button
id="character-sort-direction"
type="button"
className="btn"
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
onClick={() => onSortDir(sortDir === 'asc' ? 'desc' : 'asc')}
>
{sortDir === 'asc' ? '↑' : '↓'}
</button>
</div>
</div>
)
}
function CharacterTable({
characters,
selectedId,
onSelect,
}: {
characters: Character[]
selectedId: string | undefined
onSelect: (id: string) => void
}) {
if (characters.length === 0) {
return (
<div className="card p-5 text-sm muted" id="character-table-empty">
No characters match these filters.
</div>
)
}
return (
<div className="card overflow-x-auto" id="character-table">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase muted">
<th className="py-2 px-3 font-semibold">Name</th>
<th className="py-2 px-3 font-semibold">Role</th>
<th className="py-2 px-3 font-semibold">Importance</th>
<th className="py-2 px-3 font-semibold">Occupation</th>
<th className="py-2 px-3 font-semibold">Tags</th>
</tr>
</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>
)}
</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.occupation ?? <span className="muted"></span>}</td>
<td className="py-2 px-3">
<div className="flex flex-wrap gap-1">
{character.tags.map((tag) => (
<TagChip key={tag.id} tag={tag} />
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function CharacterSheet({
projectId,
character,
@@ -114,10 +384,14 @@ function CharacterSheet({
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'>> & { tags?: string[] }) =>
const patch = (body: Partial<Omit<Character, 'tags' | 'aliases'>> & { tags?: string[]; aliases?: string[] }) =>
update.mutate({ id: character.id, ...body })
return (
@@ -167,13 +441,19 @@ function CharacterSheet({
/>
</div>
<div className="mt-5">
<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">
@@ -274,11 +554,29 @@ function CharacterSheet({
</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) && (
@@ -317,6 +615,139 @@ function CharacterSheet({
)
}
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,
@@ -3,6 +3,7 @@ import { useLogout, useProject, useUpdateProject } from '../api/hooks'
import { projectPhases } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { ErrorNote, Spinner } from '../components/ui'
import { HelpButton } from '../keyboard/HelpButton'
import { useHotkey } from '../keyboard/HotkeysContext'
const sections: { to: string; label: string; end?: boolean }[] = [
@@ -58,6 +59,7 @@ export default function ProjectLayout() {
))}
</select>
)}
<HelpButton />
{user && (
<>
<span className="truncate text-sm muted" title={user.email}>
+88 -76
View File
@@ -4,6 +4,7 @@ import { useCreateProject, useGenres, useLogout, useProjects } from '../api/hook
import { useAuth } from '../auth/AuthContext'
import { ImportDialog } from '../components/ImportDialog'
import { EmptyState, ErrorNote, Modal, Spinner } from '../components/ui'
import { HelpButton } from '../keyboard/HelpButton'
import { useHotkey } from '../keyboard/HotkeysContext'
export default function ProjectsPage() {
@@ -19,87 +20,94 @@ export default function ProjectsPage() {
useHotkey('i', 'Import from outline', () => canCreate && setImporting(true), { group: 'Novels' })
return (
<div className="mx-auto max-w-4xl px-6 py-12">
<header className="mb-8 flex items-end justify-between gap-4">
<div>
<h1 className="text-3xl font-semibold tracking-tight">Your novels</h1>
<p className="mt-1 text-sm muted">
Outlines, character dossiers, and a writing partner that knows the book.
</p>
</div>
<div className="flex shrink-0 items-center gap-2 whitespace-nowrap">
{canCreate && (
<>
<button className="btn" onClick={() => setImporting(true)}>
Import from outline
</button>
<button className="btn btn-primary" onClick={() => setCreating(true)}>
New novel
</button>
</>
)}
{user && (
<>
<span className="ml-2 text-sm muted" title={user.email}>
{user.displayName} · {user.globalRole}
</span>
<button
className="btn"
onClick={() => logout.mutate(undefined, { onSuccess: () => navigate('/login') })}
>
Sign out
</button>
</>
)}
<div className="min-h-full">
<header
className="sticky top-0 z-10 border-b"
style={{ borderColor: 'var(--line)', background: 'var(--surface)' }}
>
<div className="mx-auto flex max-w-4xl items-center gap-4 px-6 py-3">
<span className="truncate text-base font-semibold">Your novels</span>
<div className="ml-auto flex items-center gap-2 whitespace-nowrap">
{canCreate && (
<>
<button className="btn" onClick={() => setImporting(true)}>
Import from outline
</button>
<button className="btn btn-primary" onClick={() => setCreating(true)}>
New novel
</button>
</>
)}
<HelpButton />
{user && (
<>
<span className="truncate text-sm muted" title={user.email}>
{user.displayName} · {user.globalRole}
</span>
<button
className="btn"
onClick={() => logout.mutate(undefined, { onSuccess: () => navigate('/login') })}
>
Sign out
</button>
</>
)}
</div>
</div>
</header>
{error && <ErrorNote error={error} />}
{isPending && <Spinner label="Loading projects" />}
<main className="mx-auto max-w-4xl px-6 py-12">
<p className="mb-8 -mt-4 text-sm muted">
Outlines, character dossiers, and a writing partner that knows the book.
</p>
{projects?.length === 0 && (
<EmptyState
title="Nothing here yet"
hint="Start with a title and a one-sentence logline. Everything else can come later."
/>
)}
{error && <ErrorNote error={error} />}
{isPending && <Spinner label="Loading projects" />}
<div className="grid gap-3">
{projects?.map((project) => (
<Link
key={project.id}
to={`/projects/${project.id}`}
className="card block px-5 py-4 transition hover:shadow-md"
>
<div className="flex items-baseline justify-between gap-4">
<h2 className="text-lg font-semibold">{project.title}</h2>
<span className="text-xs muted">
{project.genre ?? 'Uncategorised'}
{project.author && ` · ${project.author}`}
</span>
</div>
{project.logline && <p className="mt-1 text-sm muted">{project.logline}</p>}
<div className="mt-3 flex gap-4 text-xs muted">
<span>{project.characterCount} characters</span>
<span>{project.chapterCount} chapters</span>
<span>
{project.wordCount.toLocaleString()}
{project.targetWordCount
? ` / ${project.targetWordCount.toLocaleString()} words`
: ' words'}
</span>
</div>
</Link>
))}
</div>
{projects?.length === 0 && (
<EmptyState
title="Nothing here yet"
hint="Start with a title and a one-sentence logline. Everything else can come later."
/>
)}
{creating && <CreateProjectModal onClose={() => setCreating(false)} />}
{importing && (
<ImportDialog
onClose={() => setImporting(false)}
onImported={(projectId) => navigate(`/projects/${projectId}`)}
/>
)}
<div className="grid gap-3">
{projects?.map((project) => (
<Link
key={project.id}
to={`/projects/${project.id}`}
className="card block px-5 py-4 transition hover:shadow-md"
>
<div className="flex items-baseline justify-between gap-4">
<h2 className="text-lg font-semibold">{project.title}</h2>
<span className="text-xs muted">
{project.genre ?? 'Uncategorised'}
{project.author && ` · ${project.author}`}
</span>
</div>
{project.logline && <p className="mt-1 text-sm muted">{project.logline}</p>}
<div className="mt-3 flex gap-4 text-xs muted">
<span>{project.characterCount} characters</span>
<span>{project.chapterCount} chapters</span>
<span>
{project.wordCount.toLocaleString()}
{project.targetWordCount
? ` / ${project.targetWordCount.toLocaleString()} words`
: ' words'}
</span>
</div>
</Link>
))}
</div>
{creating && <CreateProjectModal onClose={() => setCreating(false)} />}
{importing && (
<ImportDialog
onClose={() => setImporting(false)}
onImported={(projectId) => navigate(`/projects/${projectId}`)}
/>
)}
</main>
</div>
)
}
@@ -179,7 +187,11 @@ function CreateProjectModal({ onClose }: { onClose: () => void }) {
<button type="button" className="btn" onClick={onClose}>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={!title.trim() || create.isPending}>
<button
type="submit"
className="btn btn-primary"
disabled={!title.trim() || create.isPending}
>
{create.isPending ? 'Creating…' : 'Create'}
</button>
</div>