Add genre feature; extract ConfirmModal for reusable confirm dialogs

Genres seed on boot, are editable per project, and gate agent config
errors more gracefully. ConfirmModal replaces ad-hoc confirm prompts
across chapters, tags, and characters pages.
This commit is contained in:
James Wampler
2026-08-15 11:25:13 -07:00
parent ffb476a81a
commit 27c287b9c8
27 changed files with 1428 additions and 80 deletions
+5
View File
@@ -10,6 +10,7 @@ import type {
Conversation,
ConversationSummary,
Beat,
Genre,
ImportInspection,
ImportJob,
ImportJobStatus,
@@ -22,6 +23,7 @@ import type {
export const keys = {
projects: ['projects'] as const,
genres: ['genres'] as const,
project: (id: string) => ['projects', id] as const,
characters: (projectId: string) => ['projects', projectId, 'characters'] as const,
tags: (projectId: string) => ['projects', projectId, 'tags'] as const,
@@ -213,6 +215,9 @@ export function useDeleteQuestion(projectId: string) {
})
}
export const useGenres = () =>
useQuery({ queryKey: keys.genres, queryFn: () => api.get<Genre[]>('/api/genres') })
export const useTags = (projectId: string) =>
useQuery({
queryKey: keys.tags(projectId),
+5
View File
@@ -32,6 +32,11 @@ export type ProjectPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing'
export const projectPhases: ProjectPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete']
export interface Genre {
id: string
name: string
}
export interface ProjectSummary {
id: string
title: string
@@ -0,0 +1,36 @@
import type { ReactNode } from 'react'
import { Modal } from './ui'
export function ConfirmModal({
title,
message,
confirmLabel = 'Delete',
onConfirm,
onClose,
}: {
title: string
message: ReactNode
confirmLabel?: string
onConfirm: () => void
onClose: () => void
}) {
return (
<Modal title={title} onClose={onClose}>
<p className="text-sm">{message}</p>
<div className="mt-4 flex justify-end gap-2">
<button className="btn" onClick={onClose}>
Cancel
</button>
<button
className="btn btn-danger"
onClick={() => {
onConfirm()
onClose()
}}
>
{confirmLabel}
</button>
</div>
</Modal>
)
}
+52 -10
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
import { useEffect, useId, useRef, useState, type MouseEvent, type ReactNode } from 'react'
import type { DraftStatus } from '../api/types'
export function Spinner({ label = 'Loading' }: { label?: string }) {
@@ -67,6 +67,7 @@ export function AutoField({
rows = 3,
placeholder,
serif,
suggestions,
onContextMenu,
}: {
label?: string
@@ -76,10 +77,12 @@ export function AutoField({
rows?: number
placeholder?: string
serif?: boolean
suggestions?: readonly string[]
onContextMenu?: (e: MouseEvent<HTMLTextAreaElement>) => void
}) {
const [draft, setDraft] = useState(value ?? '')
const committed = useRef(value ?? '')
const suggestionsId = useId()
// Adopt changes that arrive from elsewhere (the agent, another tab) unless the user
// is mid-edit, which would yank text out from under them.
@@ -114,14 +117,24 @@ export function AutoField({
onContextMenu={onContextMenu}
/>
) : (
<input
className={className}
value={draft}
placeholder={placeholder}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
/>
<>
<input
className={className}
value={draft}
placeholder={placeholder}
list={suggestions?.length ? suggestionsId : undefined}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
/>
{suggestions?.length ? (
<datalist id={suggestionsId}>
{suggestions.map((suggestion) => (
<option key={suggestion} value={suggestion} />
))}
</datalist>
) : null}
</>
)}
</label>
)
@@ -152,6 +165,9 @@ export function Select<T extends string>({
)
}
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
export function Modal({
title,
onClose,
@@ -161,8 +177,33 @@ export function Modal({
onClose: () => void
children: ReactNode
}) {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose()
const triggerElement = document.activeElement as HTMLElement | null
containerRef.current?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)?.focus()
return () => triggerElement?.focus()
}, [])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
return
}
if (e.key !== 'Tab' || !containerRef.current) return
const focusable = containerRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey && document.activeElement === first) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
@@ -173,6 +214,7 @@ export function Modal({
onClick={onClose}
>
<div
ref={containerRef}
className="card mt-12 w-full max-w-lg p-5 shadow-xl"
onClick={(e) => e.stopPropagation()}
role="dialog"
+9
View File
@@ -102,6 +102,15 @@ body {
background: var(--accent);
}
.btn-danger {
border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
color: var(--accent);
}
.btn-danger:hover:not(:disabled) {
background: var(--accent-soft);
}
.input {
@apply w-full rounded-md px-2.5 py-1.5 text-sm outline-none transition;
background: var(--surface);
+44 -20
View File
@@ -13,6 +13,7 @@ import {
} from '../api/hooks'
import { draftStatuses, type Beat, type Chapter } from '../api/types'
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
import { TagChip, TagEditor } from '../components/TagEditor'
import { CharacterChip, CharacterMultiSelect } from '../components/CharacterMultiSelect'
import { useCharacterContextMenu } from '../components/CharacterContextMenu'
@@ -32,6 +33,7 @@ export default function ChapterPage() {
const remove = useDeleteChapter(projectId)
const createBeat = useCreateBeat(chapterId, projectId)
const [tab, setTab] = useState<ChapterTab>('outline')
const [confirmingDelete, setConfirmingDelete] = useState(false)
const { handleContextMenu, menuElement } = useCharacterContextMenu(projectId)
useHotkey('b', 'Add beat', () => createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' })
@@ -86,14 +88,14 @@ export default function ChapterPage() {
<span className="label">POV character</span>
<select
className="input"
value={chapter.povCharacterName ?? ''}
onChange={(e) => {
const match = characters?.find((c) => c.name === e.target.value)
patch({ povCharacterId: match?.id ?? null })
}}
value={chapter.povCharacterId ?? ''}
onChange={(e) => patch({ povCharacterId: e.target.value || null })}
>
{['—', ...(characters?.map((c) => c.name) ?? [])].map((name) => (
<option key={name}>{name}</option>
<option value=""></option>
{characters?.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</label>
@@ -117,22 +119,25 @@ export default function ChapterPage() {
<div className="text-sm muted">
{chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words
</div>
<button
className="btn"
style={{ color: 'var(--accent)' }}
onClick={() => {
if (confirm(`Delete chapter “${chapter.title}” and everything in it?`)) {
remove.mutate(chapter.id, {
onSuccess: () => navigate(`/projects/${projectId}/chapters`),
})
}
}}
>
<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)}
/>
)}
<div className="mb-5 flex gap-1" style={{ borderBottom: '1px solid var(--line)' }}>
{(
[
@@ -246,6 +251,7 @@ function BeatTable({
const remove = useDeleteBeat(chapter.id)
const reorder = useReorderBeats(chapter.id)
const [editingId, setEditingId] = useState<string | null>(null)
const [deletingBeat, setDeletingBeat] = useState<Beat | null>(null)
if (chapter.beats.length === 0) {
return (
@@ -388,7 +394,7 @@ function BeatTable({
<button
className="text-xs muted leading-none transition hover:opacity-100"
style={{ color: 'var(--accent)' }}
onClick={() => confirm(`Delete beat “${beat.title}”?`) && remove.mutate(beat.id)}
onClick={() => setDeletingBeat(beat)}
aria-label={`Delete beat ${beat.title}`}
>
@@ -400,9 +406,18 @@ function BeatTable({
<tr
key={beat.id}
id={`beat-${beat.id}`}
className="cursor-pointer transition hover:brightness-110"
tabIndex={0}
role="button"
aria-label={`Edit beat ${beat.title}`}
className="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)' }}
onClick={() => setEditingId(beat.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
setEditingId(beat.id)
}
}}
>
<td className="px-2 py-2 align-top text-xs muted">{index + 1}</td>
@@ -443,6 +458,15 @@ function BeatTable({
)}
</tbody>
</table>
{deletingBeat && (
<ConfirmModal
title="Delete beat"
message={`Delete beat "${deletingBeat.title}"?`}
onConfirm={() => remove.mutate(deletingBeat.id)}
onClose={() => setDeletingBeat(null)}
/>
)}
</div>
)
}
@@ -16,12 +16,6 @@ export default function ChaptersPage() {
return (
<div>
<div className="mb-4">
<Link to={`/projects/${projectId}`} className="text-sm muted hover:underline">
Dashboard
</Link>
</div>
<div className="mb-5 flex items-center justify-between gap-4">
<h2 className="text-xl font-semibold">Chapters</h2>
<button
+12 -7
View File
@@ -9,6 +9,7 @@ import {
} from '../api/hooks'
import { characterImportances, characterRoles, type Character } from '../api/types'
import { AutoField, EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
import { TagEditor } from '../components/TagEditor'
import { CharacterArc } from '../components/CharacterArc'
import { CharacterBeats } from '../components/CharacterBeats'
@@ -87,6 +88,7 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
const { data: allTags } = useTags(projectId)
const update = useUpdateCharacter(projectId)
const remove = useDeleteCharacter(projectId)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const patch = (body: Partial<Omit<Character, 'tags'>> & { tags?: string[] }) =>
update.mutate({ id: character.id, ...body })
@@ -113,13 +115,7 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
onChange={(importance) => patch({ importance })}
/>
</div>
<button
className="btn mt-6"
style={{ color: 'var(--accent)' }}
onClick={() => {
if (confirm(`Delete ${character.name}?`)) remove.mutate(character.id)
}}
>
<button className="btn btn-danger mt-6" onClick={() => setConfirmingDelete(true)}>
Delete
</button>
</div>
@@ -256,6 +252,15 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
/>
<OpenQuestions projectId={projectId} scope={{ characterId: character.id }} />
{confirmingDelete && (
<ConfirmModal
title="Delete character"
message={`Delete ${character.name}? This cannot be undone.`}
onConfirm={() => remove.mutate(character.id)}
onClose={() => setConfirmingDelete(false)}
/>
)}
</>
)
}
@@ -56,18 +56,6 @@ function OutliningDashboard({ projectId }: { projectId: string }) {
return (
<div className="grid gap-6">
<nav className="flex flex-wrap gap-4 text-sm">
<Link to="tags" className="muted hover:underline">
Tags
</Link>
<Link to="agent" className="muted hover:underline">
Agent
</Link>
<Link to="settings" className="muted hover:underline">
Settings
</Link>
</nav>
<div className="grid gap-6 lg:grid-cols-3">
<section className="card p-5 lg:col-span-2">
<div className="mb-4 flex items-center justify-between gap-4">
+27 -1
View File
@@ -1,9 +1,18 @@
import { Outlet, useParams, Link, useNavigate } from 'react-router-dom'
import { Outlet, useParams, Link, NavLink, useNavigate } from 'react-router-dom'
import { useProject, useUpdateProject } from '../api/hooks'
import { projectPhases } from '../api/types'
import { ErrorNote, Spinner } from '../components/ui'
import { useHotkey } from '../keyboard/HotkeysContext'
const sections: { to: string; label: string; end?: boolean }[] = [
{ to: '', label: 'Dashboard', end: true },
{ to: 'chapters', label: 'Chapters' },
{ to: 'characters', label: 'Characters' },
{ to: 'tags', label: 'Tags' },
{ to: 'agent', label: 'Agent' },
{ to: 'settings', label: 'Settings' },
]
export default function ProjectLayout() {
const { projectId = '' } = useParams()
const navigate = useNavigate()
@@ -44,6 +53,23 @@ export default function ProjectLayout() {
</select>
)}
</div>
<nav className="mx-auto flex max-w-[100rem] gap-1 px-6 pb-2 text-sm">
{sections.map(({ to, label, end }) => (
<NavLink
key={to}
to={to}
end={end}
className={({ isActive }) =>
`rounded-md px-3 py-1.5 font-medium transition ${isActive ? '' : 'muted hover:opacity-100'}`
}
style={({ isActive }) =>
isActive ? { background: 'var(--accent-soft)', color: 'var(--accent)' } : undefined
}
>
{label}
</NavLink>
))}
</nav>
</header>
<main className="mx-auto max-w-[100rem] px-6 py-8">
+28 -11
View File
@@ -1,8 +1,16 @@
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useChapters, useCharacters, useDeleteProject, useProject, useUpdateProject } from '../api/hooks'
import {
useChapters,
useCharacters,
useDeleteProject,
useGenres,
useProject,
useUpdateProject,
} from '../api/hooks'
import { ImportDialog } from '../components/ImportDialog'
import { AutoField, ErrorNote, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
export default function SettingsPage() {
const { projectId = '' } = useParams()
@@ -10,9 +18,11 @@ export default function SettingsPage() {
const { data: project, isPending } = useProject(projectId)
const { data: characters } = useCharacters(projectId)
const { data: chapters } = useChapters(projectId)
const { data: genres } = useGenres()
const update = useUpdateProject(projectId)
const remove = useDeleteProject()
const [importing, setImporting] = useState(false)
const [confirmingDelete, setConfirmingDelete] = useState(false)
if (isPending || !project) return <Spinner label="Loading brief" />
@@ -36,7 +46,13 @@ export default function SettingsPage() {
value={project.author}
onCommit={(author) => update.mutate({ author })}
/>
<AutoField label="Genre" value={project.genre} onCommit={(genre) => update.mutate({ genre })} />
<AutoField
label="Genre"
value={project.genre}
placeholder="Pick one, or name your own."
suggestions={genres?.map((g) => g.name)}
onCommit={(genre) => update.mutate({ genre })}
/>
</div>
<AutoField
label="Logline"
@@ -134,15 +150,7 @@ export default function SettingsPage() {
<p className="mb-3 text-sm muted">
Deleting a novel removes its outline, characters, chapters and conversations.
</p>
<button
className="btn w-full"
style={{ color: 'var(--accent)' }}
onClick={() => {
if (confirm(`Delete "${project.title}" and everything in it? This cannot be undone.`)) {
remove.mutate(projectId, { onSuccess: () => navigate('/') })
}
}}
>
<button className="btn btn-danger w-full" onClick={() => setConfirmingDelete(true)}>
Delete this novel
</button>
</div>
@@ -154,6 +162,15 @@ export default function SettingsPage() {
onImported={(newProjectId) => navigate(`/projects/${newProjectId}`)}
/>
)}
{confirmingDelete && (
<ConfirmModal
title="Delete novel"
message={`Delete "${project.title}" and everything in it? This cannot be undone.`}
onConfirm={() => remove.mutate(projectId, { onSuccess: () => navigate('/') })}
onClose={() => setConfirmingDelete(false)}
/>
)}
</div>
)
}
+12 -8
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useDeleteTag, useTagReferences, useTags, useUpdateTag } from '../api/hooks'
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
import { TagChip } from '../components/TagEditor'
export default function TagsPage() {
@@ -65,6 +66,7 @@ function TagReferencePanel({ projectId, tagId }: { projectId: string; tagId: str
const { data, isPending, error } = useTagReferences(tagId)
const update = useUpdateTag(projectId)
const remove = useDeleteTag()
const [confirmingDelete, setConfirmingDelete] = useState(false)
if (isPending) return <Spinner label="Loading references" />
if (error) return <ErrorNote error={error} />
@@ -96,20 +98,22 @@ function TagReferencePanel({ projectId, tagId }: { projectId: string; tagId: str
onBlur={(e) => update.mutate({ id: tagId, color: e.target.value })}
/>
</label>
<button
className="btn"
style={{ color: 'var(--accent)' }}
onClick={() =>
confirm(`Delete the tag “${data.tag.name}”? What carries it is left alone.`) &&
remove.mutate(tagId)
}
>
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
Delete tag
</button>
</div>
{update.error && <ErrorNote error={update.error} />}
{confirmingDelete && (
<ConfirmModal
title="Delete tag"
message={`Delete the tag "${data.tag.name}"? What carries it is left alone.`}
onConfirm={() => remove.mutate(tagId)}
onClose={() => setConfirmingDelete(false)}
/>
)}
{empty && (
<EmptyState
title="Nothing carries this tag"