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:
+19
-16
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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' }),
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user