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:
Generated
+1122
-2
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||
|
||||
@@ -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 ?? ''),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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'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'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>
|
||||
|
||||
Reference in New Issue
Block a user