Remove Scenes, group beats by multiple characters; strip comments repo-wide

Drop the Scene entity/grouping in favor of chapters carrying prose directly
and beats belonging to many characters. Add markdown editor + character
multi-select components to the web client. Remove all XML doc and inline
comments across the touched C#/TS/CSS files in favor of self-documenting
names, and record that convention in CLAUDE.md. Add .mcp.json (local MCP
server config, no secrets) and ignore .idea/.
This commit is contained in:
James Wampler
2026-08-11 21:05:13 -07:00
parent 1ce526019f
commit 23348327a9
57 changed files with 2600 additions and 1421 deletions
+1122 -2
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -13,6 +13,7 @@
"@tanstack/react-query": "^5.101.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.2"
},
"devDependencies": {
+7 -70
View File
@@ -16,7 +16,6 @@ import type {
OpenQuestion,
Project,
ProjectSummary,
Scene,
TagReferences,
TagSummary,
} from './types'
@@ -36,8 +35,6 @@ export const keys = {
importJob: (id: string) => ['imports', id] as const,
}
// --- Projects ---------------------------------------------------------------
export const useProjects = () =>
useQuery({ queryKey: keys.projects, queryFn: () => api.get<ProjectSummary[]>('/api/projects') })
@@ -72,8 +69,6 @@ export function useDeleteProject() {
})
}
// --- Characters -------------------------------------------------------------
export const useCharacters = (projectId: string) =>
useQuery({
queryKey: keys.characters(projectId),
@@ -112,10 +107,6 @@ export function useDeleteCharacter(projectId: string) {
})
}
/**
* Every beat this character appears in, across the whole book. Kept separate from the
* dossier because it is derived from the outlines — what they actually do on the page.
*/
export const useCharacterBeats = (characterId: string | undefined) =>
useQuery({
queryKey: keys.characterBeats(characterId ?? ''),
@@ -123,8 +114,6 @@ export const useCharacterBeats = (characterId: string | undefined) =>
enabled: Boolean(characterId),
})
// --- Character arcs ----------------------------------------------------------
export function useCreateArcStage(projectId: string) {
const qc = useQueryClient()
return useMutation({
@@ -160,12 +149,6 @@ export function useReorderArcStages(projectId: string) {
})
}
// --- Open questions ----------------------------------------------------------
/**
* The project's undecided questions. Filters narrow to one chapter outline or character;
* resolved ones are left out unless asked for, since the list is about what is still open.
*/
export const useOpenQuestions = (
projectId: string,
filter: { chapterId?: string; characterId?: string; includeResolved?: boolean } = {},
@@ -201,10 +184,6 @@ export function useUpdateQuestion(projectId: string) {
})
}
/**
* Resolving can append the decision to the notes of whatever the question hangs off, so
* this invalidates the chapter and character caches as well as the question list.
*/
export function useResolveQuestion(projectId: string) {
const qc = useQueryClient()
return useMutation({
@@ -234,8 +213,6 @@ export function useDeleteQuestion(projectId: string) {
})
}
// --- Tags --------------------------------------------------------------------
export const useTags = (projectId: string) =>
useQuery({
queryKey: keys.tags(projectId),
@@ -258,10 +235,6 @@ export function useUpdateTag(projectId: string) {
})
}
/**
* Deleting a tag strips it from every character, chapter and beat that carried it, so
* this invalidates the whole cache rather than trying to enumerate what moved.
*/
export function useDeleteTag() {
const qc = useQueryClient()
return useMutation({
@@ -270,13 +243,12 @@ export function useDeleteTag() {
})
}
// --- Beats (a chapter's outline) ---------------------------------------------
export function useCreateBeat(chapterId: string, projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: Partial<Beat> & { title: string }) =>
api.post<Beat>(`/api/chapters/${chapterId}/beats`, body),
mutationFn: (
body: Partial<Omit<Beat, 'tags' | 'characters'>> & { title: string; tags?: string[]; characterIds?: string[] },
) => api.post<Beat>(`/api/chapters/${chapterId}/beats`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
@@ -287,7 +259,10 @@ export function useCreateBeat(chapterId: string, projectId: string) {
export function useUpdateBeat(chapterId: string, projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...body }: Partial<Omit<Beat, 'tags'>> & { id: string; tags?: string[] }) =>
mutationFn: ({
id,
...body
}: Partial<Omit<Beat, 'tags' | 'characters'>> & { id: string; tags?: string[]; characterIds?: string[] }) =>
api.patch<Beat>(`/api/beats/${id}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
@@ -313,8 +288,6 @@ export function useReorderBeats(chapterId: string) {
})
}
// --- Chapters and scenes ----------------------------------------------------
export const useChapters = (projectId: string) =>
useQuery({
queryKey: keys.chapters(projectId),
@@ -358,34 +331,6 @@ export function useDeleteChapter(projectId: string) {
})
}
export function useCreateScene(chapterId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: Partial<Scene> & { title: string }) =>
api.post<Scene>(`/api/chapters/${chapterId}/scenes`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }),
})
}
export function useUpdateScene(chapterId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...body }: Partial<Scene> & { id: string }) =>
api.patch<Scene>(`/api/scenes/${id}`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }),
})
}
export function useDeleteScene(chapterId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/api/scenes/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }),
})
}
// --- Agent ------------------------------------------------------------------
export const useConversations = (projectId: string) =>
useQuery({
queryKey: keys.conversations(projectId),
@@ -407,7 +352,6 @@ export function useSendAgentMessage(projectId: string) {
onSuccess: (turn) => {
qc.invalidateQueries({ queryKey: keys.conversations(projectId) })
qc.invalidateQueries({ queryKey: keys.conversation(turn.conversationId) })
// The agent edits project data through its tools, so anything on screen may be stale.
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
qc.invalidateQueries({ queryKey: keys.chapters(projectId) })
@@ -417,8 +361,6 @@ export function useSendAgentMessage(projectId: string) {
})
}
// --- Outline import ----------------------------------------------------------
export function useInspectImport() {
return useMutation({
mutationFn: (sourceRoot: string) => api.post<ImportInspection>('/api/imports/inspect', { sourceRoot }),
@@ -434,11 +376,6 @@ export function useStartImport() {
const terminalImportStatuses: ImportJobStatus[] = ['Completed', 'Failed', 'Paused']
/**
* Polls a running import job. This is the app's first polling hook — there's no
* SSE/websocket infrastructure to reuse — so it stops on its own once the job reaches a
* terminal status rather than depending on the caller to unmount it in time.
*/
export function useImportJob(jobId: string | undefined) {
return useQuery({
queryKey: keys.importJob(jobId ?? ''),
+9 -37
View File
@@ -1,4 +1,3 @@
// Mirrors the DTOs in the Novelly.Api feature folders. Enums travel as their names.
export type CharacterRole =
| 'Protagonist'
@@ -21,7 +20,6 @@ export const characterRoles: CharacterRole[] = [
'Foil',
]
/** How much of the book a character carries. Separate from the part they play. */
export type CharacterImportance = 'Main' | 'Supporting'
export const characterImportances: CharacterImportance[] = ['Main', 'Supporting']
@@ -30,7 +28,6 @@ export type DraftStatus = 'Planned' | 'Outlined' | 'Drafted' | 'Revised' | 'Fina
export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final']
/** Where a novel is in its lifecycle, from first notes to a finished manuscript. */
export type ProjectPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing' | 'Complete'
export const projectPhases: ProjectPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete']
@@ -92,18 +89,19 @@ export interface TagReferences {
}[]
}
/** One row of a chapter's outline. Flat and ordered — no nesting. */
export interface BeatCharacter {
id: string
name: string
}
export interface Beat {
id: string
chapterId: string
sortOrder: number
title: string
characterId: string | null
characterName: string | null
characters: BeatCharacter[]
whatHappened: string | null
whatsNext: string | null
sceneId: string | null
sceneTitle: string | null
tags: Tag[]
updatedAt: string
}
@@ -116,7 +114,6 @@ export interface Relationship {
description: string | null
}
/** One step in a main character's arc. Flat and ordered, like a chapter's beats. */
export interface ArcStage {
id: string
characterId: string
@@ -129,7 +126,6 @@ export interface ArcStage {
updatedAt: string
}
/** A beat a character appears in, carrying its chapter so the page can link into the outline. */
export interface CharacterBeat {
id: string
chapterId: string
@@ -139,8 +135,6 @@ export interface CharacterBeat {
title: string
whatHappened: string | null
whatsNext: string | null
sceneId: string | null
sceneTitle: string | null
}
export interface Character {
@@ -168,24 +162,6 @@ export interface Character {
updatedAt: string
}
export interface Scene {
id: string
chapterId: string
sortOrder: number
title: string
summary: string | null
goal: string | null
conflict: string | null
outcome: string | null
povCharacterId: string | null
povCharacterName: string | null
location: string | null
prose: string | null
wordCount: number
status: DraftStatus
updatedAt: string
}
export interface ChapterSummary {
id: string
projectId: string
@@ -198,20 +174,19 @@ export interface ChapterSummary {
status: DraftStatus
targetWordCount: number | null
beatCount: number
sceneCount: number
wordCount: number
tags: Tag[]
updatedAt: string
}
export interface Chapter extends Omit<ChapterSummary, 'beatCount' | 'sceneCount' | 'wordCount'> {
export interface Chapter extends Omit<ChapterSummary, 'beatCount' | 'wordCount'> {
notes: string | null
beats: Beat[]
scenes: Scene[]
prose: string | null
wordCount: number
updatedAt: string
}
/** Something the writer has not decided yet, hung off a chapter outline and/or a character. */
export interface OpenQuestion {
id: string
projectId: string
@@ -260,8 +235,6 @@ export interface AgentTurn {
message: AgentMessage
}
// --- Outline import -----------------------------------------------------------
export type ImportJobStatus = 'Pending' | 'Running' | 'Completed' | 'Failed' | 'Paused'
export interface ImportJob {
@@ -276,7 +249,6 @@ export interface ImportJob {
updatedAt: string
}
/** Whether a source folder is ready for a fresh import, has one to resume, or is already done. */
export type ImportReadiness = 'Fresh' | 'Resumable' | 'Complete'
export interface ImportInspection {
@@ -0,0 +1,78 @@
import { useState } from 'react'
import type { BeatCharacter } from '../api/types'
export function CharacterChip({ character, onRemove }: { character: BeatCharacter; 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}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="opacity-60 transition hover:opacity-100"
aria-label={`Remove ${character.name}`}
>
</button>
)}
</span>
)
}
export function CharacterMultiSelect({
selected,
options,
onChange,
}: {
selected: BeatCharacter[]
options: { id: string; name: string }[]
onChange: (ids: string[]) => void
}) {
const [draft, setDraft] = useState('')
const listId = 'character-multiselect-options'
const add = () => {
const name = draft.trim()
setDraft('')
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])
}
}
const remove = (id: string) => onChange(selected.filter((c) => c.id !== id).map((c) => c.id))
const unused = options.filter((o) => !selected.some((c) => c.id === o.id))
return (
<div className="flex flex-wrap items-center gap-1.5">
{selected.map((character) => (
<CharacterChip key={character.id} character={character} onRemove={() => remove(character.id)} />
))}
<input
className="input w-28 flex-1 px-2 py-0.5 text-xs"
value={draft}
list={listId}
placeholder="Add character…"
onChange={(e) => setDraft(e.target.value)}
onBlur={add}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
add()
}
}}
/>
<datalist id={listId}>
{unused.map((o) => (
<option key={o.id} value={o.name} />
))}
</datalist>
</div>
)
}
@@ -0,0 +1,72 @@
import { useEffect, useRef, useState } from 'react'
import ReactMarkdown from 'react-markdown'
export function MarkdownEditor({
value,
onCommit,
placeholder,
rows = 24,
}: {
value: string | null
onCommit: (next: string) => void
placeholder?: string
rows?: number
}) {
const [draft, setDraft] = useState(value ?? '')
const [mode, setMode] = useState<'write' | 'preview'>('write')
const committed = useRef(value ?? '')
useEffect(() => {
const incoming = value ?? ''
if (incoming !== committed.current) {
committed.current = incoming
setDraft(incoming)
}
}, [value])
const commit = () => {
if (draft !== committed.current) {
committed.current = draft
onCommit(draft)
}
}
return (
<div>
<div className="mb-2 flex justify-end gap-1">
<button
type="button"
className={`btn px-2 py-1 text-xs ${mode === 'write' ? '' : 'opacity-60'}`}
onClick={() => setMode('write')}
>
Write
</button>
<button
type="button"
className={`btn px-2 py-1 text-xs ${mode === 'preview' ? '' : 'opacity-60'}`}
onClick={() => {
commit()
setMode('preview')
}}
>
Preview
</button>
</div>
{mode === 'write' ? (
<textarea
className="input font-mono text-sm"
rows={rows}
value={draft}
placeholder={placeholder}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
/>
) : (
<div className="markdown-preview card min-h-[20rem] p-4">
{draft.trim() ? <ReactMarkdown>{draft}</ReactMarkdown> : <p className="muted">Nothing written yet.</p>}
</div>
)}
</div>
)
}
+53 -4
View File
@@ -6,10 +6,6 @@
--font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
}
/*
* Warm paper light theme, cool ink dark theme. Colours are declared as variables so
* every surface, border and accent moves together when the scheme flips.
*/
:root {
--paper: #faf7f0;
--surface: #ffffff;
@@ -132,4 +128,57 @@ body {
font-family: var(--font-sans);
@apply text-[1.0625rem] leading-relaxed;
}
.markdown-preview {
font-family: var(--font-sans);
@apply text-[1.0625rem] leading-relaxed;
}
.markdown-preview :is(h1, h2, h3, h4) {
@apply mt-5 mb-2 font-semibold first:mt-0;
}
.markdown-preview h1 {
@apply text-2xl;
}
.markdown-preview h2 {
@apply text-xl;
}
.markdown-preview h3 {
@apply text-lg;
}
.markdown-preview p {
@apply mb-4;
}
.markdown-preview :is(ul, ol) {
@apply mb-4 ml-5;
}
.markdown-preview ul {
@apply list-disc;
}
.markdown-preview ol {
@apply list-decimal;
}
.markdown-preview blockquote {
@apply my-4 border-l-2 pl-4 italic;
border-color: var(--line);
color: var(--ink-muted);
}
.markdown-preview code {
@apply rounded px-1 py-0.5 text-sm;
background: var(--surface-sunken);
}
.markdown-preview hr {
@apply my-6;
border-color: var(--line);
}
}
+2 -2
View File
@@ -75,8 +75,8 @@ export default function AgentPage() {
<div className="card p-6">
<h2 className="text-lg font-semibold">Your writing partner</h2>
<p className="mt-1 text-sm muted">
It can read and edit the brief, the outline, character dossiers, chapters and
scenes the same data you see in the other tabs.
It can read and edit the brief, the outline, character dossiers and chapter
prose the same data you see in the other tabs.
</p>
<div className="mt-4 grid gap-2">
{starters.map((starter) => (
+94 -151
View File
@@ -4,22 +4,23 @@ import {
useChapter,
useCharacters,
useCreateBeat,
useCreateScene,
useDeleteBeat,
useDeleteChapter,
useDeleteScene,
useReorderBeats,
useTags,
useUpdateBeat,
useUpdateChapter,
useUpdateScene,
} from '../api/hooks'
import { draftStatuses, type Beat, type Chapter, type Scene } from '../api/types'
import { AutoField, ErrorNote, Select, Spinner, StatusBadge } from '../components/ui'
import { draftStatuses, type Beat, type Chapter } from '../api/types'
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
import { TagChip, TagEditor } from '../components/TagEditor'
import { CharacterChip, CharacterMultiSelect } from '../components/CharacterMultiSelect'
import { MarkdownEditor } from '../components/MarkdownEditor'
import { OpenQuestions } from '../components/OpenQuestions'
import { useHotkey } from '../keyboard/HotkeysContext'
type ChapterTab = 'outline' | 'prose'
export default function ChapterPage() {
const { projectId = '', chapterId = '' } = useParams()
const navigate = useNavigate()
@@ -29,10 +30,9 @@ export default function ChapterPage() {
const update = useUpdateChapter(projectId)
const remove = useDeleteChapter(projectId)
const createBeat = useCreateBeat(chapterId, projectId)
const createScene = useCreateScene(chapterId)
const [tab, setTab] = useState<ChapterTab>('outline')
useHotkey('b', 'Add beat', () => createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' })
useHotkey('s', 'Add scene', () => createScene.mutate({ title: 'New scene' }), { group: 'Chapter' })
if (isPending) return <Spinner label="Loading chapter" />
if (error) return <ErrorNote error={error} />
@@ -113,8 +113,7 @@ export default function ChapterPage() {
<div className="mt-4 flex items-end justify-between gap-4">
<div className="text-sm muted">
{chapter.beats.length} beats · {chapter.scenes.length} scenes ·{' '}
{chapter.scenes.reduce((sum, s) => sum + s.wordCount, 0).toLocaleString()} words
{chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words
</div>
<button
className="btn"
@@ -132,67 +131,74 @@ export default function ChapterPage() {
</div>
</section>
{/* The outline: a paragraph, then the beat table. */}
<section className="mb-8">
<h2 className="mb-1 text-lg font-semibold">Outline</h2>
<p className="mb-3 text-sm muted">
A paragraph on what the chapter does, then the beats that carry it.
</p>
<div className="card mb-4 p-4">
<AutoField
value={chapter.summary}
multiline
rows={5}
serif
placeholder="What this chapter is for: where it starts, what shifts, where it leaves the reader."
onCommit={(summary) => patch({ summary })}
/>
</div>
<BeatTable
chapter={chapter}
projectId={projectId}
characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []}
suggestions={suggestions}
/>
<button
className="btn btn-primary mt-3"
onClick={() => createBeat.mutate({ title: 'New beat' })}
disabled={createBeat.isPending}
>
Add beat
</button>
{createBeat.error && (
<div className="mt-2">
<ErrorNote error={createBeat.error} />
</div>
)}
</section>
{/* The prose layer. */}
<section>
<div className="mb-3 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Scenes</h2>
<p className="text-sm muted">Where the prose lives. Beats can be grouped under these.</p>
</div>
<div className="mb-5 flex gap-1" style={{ borderBottom: '1px solid var(--line)' }}>
{(
[
['outline', 'Outline'],
['prose', 'Text'],
] as const
).map(([value, tabLabel]) => (
<button
className="btn"
onClick={() => createScene.mutate({ title: 'New scene' })}
disabled={createScene.isPending}
key={value}
onClick={() => setTab(value)}
className="border-b-2 px-3 py-2 text-sm font-medium transition"
style={{
borderColor: tab === value ? 'var(--accent)' : 'transparent',
color: tab === value ? 'var(--accent)' : 'var(--ink-muted)',
}}
>
Add scene
{tabLabel}
</button>
</div>
))}
</div>
<ul className="grid gap-3">
{chapter.scenes.map((scene) => (
<SceneCard key={scene.id} chapterId={chapter.id} scene={scene} />
))}
</ul>
</section>
{tab === 'outline' ? (
<section className="mb-8">
<p className="mb-3 text-sm muted">
A paragraph on what the chapter does, then the beats that carry it.
</p>
<div className="card mb-4 p-4">
<AutoField
value={chapter.summary}
multiline
rows={5}
serif
placeholder="What this chapter is for: where it starts, what shifts, where it leaves the reader."
onCommit={(summary) => patch({ summary })}
/>
</div>
<BeatTable
chapter={chapter}
projectId={projectId}
characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []}
suggestions={suggestions}
/>
<button
className="btn btn-primary mt-3"
onClick={() => createBeat.mutate({ title: 'New beat' })}
disabled={createBeat.isPending}
>
Add beat
</button>
{createBeat.error && (
<div className="mt-2">
<ErrorNote error={createBeat.error} />
</div>
)}
</section>
) : (
<section className="mb-8">
<p className="mb-3 text-sm muted">The chapter's drafted text, in markdown.</p>
<MarkdownEditor
value={chapter.prose}
placeholder="Start writing the chapter."
onCommit={(prose) => patch({ prose })}
/>
</section>
)}
<section className="card mt-6 p-5">
<h3 className="mb-1 text-sm font-semibold">Notes</h3>
@@ -247,7 +253,7 @@ function BeatTable({
reorder.mutate(ids)
}
const patch = (id: string, body: Partial<Omit<Beat, 'tags'>> & { tags?: string[] }) =>
const patch = (id: string, body: Partial<Omit<Beat, 'tags' | 'characters'>> & { tags?: string[]; characterIds?: string[] }) =>
update.mutate({ id, ...body })
return (
@@ -256,15 +262,14 @@ function BeatTable({
<thead>
<tr style={{ borderBottom: '1px solid var(--line)' }}>
<th className="w-10 px-2 py-2 text-left text-xs font-semibold uppercase muted">#</th>
<th className="w-[14%] px-2 py-2 text-left text-xs font-semibold uppercase muted">Beat</th>
<th className="w-[10%] px-2 py-2 text-left text-xs font-semibold uppercase muted">
Character
<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
</th>
<th className="w-[28%] px-2 py-2 text-left text-xs font-semibold uppercase muted">
<th className="w-[32%] px-2 py-2 text-left text-xs font-semibold uppercase muted">
What happened
</th>
<th className="w-[28%] px-2 py-2 text-left text-xs font-semibold uppercase muted">What&apos;s next</th>
<th className="w-[12%] px-2 py-2 text-left text-xs font-semibold uppercase muted">Scene</th>
<th className="w-[32%] px-2 py-2 text-left text-xs font-semibold uppercase muted">What&apos;s next</th>
<th className="w-8" />
</tr>
</thead>
@@ -320,18 +325,11 @@ function BeatTable({
</td>
<td className="px-2 py-2 align-top">
<select
className="input"
value={beat.characterName ?? '—'}
onChange={(e) => {
const match = characters.find((c) => c.name === e.target.value)
patch(beat.id, { characterId: match?.id ?? null })
}}
>
{['—', ...characters.map((c) => c.name)].map((name) => (
<option key={name}>{name}</option>
))}
</select>
<CharacterMultiSelect
selected={beat.characters}
options={characters}
onChange={(characterIds) => patch(beat.id, { characterIds })}
/>
</td>
<td className="px-2 py-2 align-top">
@@ -356,21 +354,6 @@ function BeatTable({
/>
</td>
<td className="px-2 py-2 align-top">
<select
className="input"
value={beat.sceneTitle ?? '—'}
onChange={(e) => {
const match = chapter.scenes.find((s) => s.title === e.target.value)
patch(beat.id, { sceneId: match?.id ?? null })
}}
>
{['—', ...chapter.scenes.map((s) => s.title)].map((title) => (
<option key={title}>{title}</option>
))}
</select>
</td>
<td className="px-2 py-2 align-top">
<div className="flex flex-col items-center gap-2">
<button
@@ -413,7 +396,17 @@ function BeatTable({
)}
</td>
<td className="px-2 py-2 align-top">{beat.characterName ?? <span className="muted"></span>}</td>
<td className="px-2 py-2 align-top">
{beat.characters.length > 0 ? (
<div className="flex flex-wrap gap-1">
{beat.characters.map((character) => (
<CharacterChip key={character.id} character={character} />
))}
</div>
) : (
<span className="muted"></span>
)}
</td>
<td className="px-2 py-2 align-top whitespace-pre-wrap break-words">
{beat.whatHappened ?? <span className="muted"></span>}
@@ -423,8 +416,6 @@ function BeatTable({
{beat.whatsNext ?? <span className="muted"></span>}
</td>
<td className="px-2 py-2 align-top">{beat.sceneTitle ?? <span className="muted"></span>}</td>
<td className="px-2 py-2 align-top" />
</tr>
),
@@ -434,51 +425,3 @@ function BeatTable({
</div>
)
}
function SceneCard({ chapterId, scene }: { chapterId: string; scene: Scene }) {
const [showProse, setShowProse] = useState(Boolean(scene.prose))
const update = useUpdateScene(chapterId)
const remove = useDeleteScene(chapterId)
const patch = (body: Partial<Scene>) => update.mutate({ id: scene.id, ...body })
return (
<li className="card p-4">
<div className="grid gap-3 sm:grid-cols-[1fr_9rem]">
<AutoField value={scene.title} onCommit={(title) => title.trim() && patch({ title })} />
<Select value={scene.status} options={draftStatuses} onChange={(status) => patch({ status })} />
</div>
<div className="mt-3 flex items-center justify-between gap-3 text-xs muted">
<div className="flex items-center gap-3">
<StatusBadge status={scene.status} />
<span>{scene.wordCount.toLocaleString()} words</span>
</div>
<div className="flex gap-2">
<button className="btn px-2 py-1 text-xs" onClick={() => setShowProse((v) => !v)}>
{showProse ? 'Hide prose' : 'Write prose'}
</button>
<button
className="btn px-2 py-1 text-xs"
style={{ color: 'var(--accent)' }}
onClick={() => confirm(`Delete scene “${scene.title}”?`) && remove.mutate(scene.id)}
>
Delete
</button>
</div>
</div>
{showProse && (
<div className="mt-3">
<AutoField
value={scene.prose}
multiline
rows={16}
serif
placeholder="The scene itself. The beats grouped under it are the plan; this is the prose."
onCommit={(prose) => patch({ prose })}
/>
</div>
)}
</li>
)
}
@@ -59,7 +59,6 @@ export default function ChaptersPage() {
<div className="flex shrink-0 items-center gap-3 text-xs muted">
{chapter.povCharacterName && <span>POV: {chapter.povCharacterName}</span>}
<span>{chapter.beatCount} beats</span>
<span>{chapter.sceneCount} scenes</span>
<span>{chapter.wordCount.toLocaleString()} words</span>
<StatusBadge status={chapter.status} />
</div>