Add chapter kind to the web client
Chapter and its cross-referencing chips (tags, locations, questions, character arcs) now carry kind/displayNumber/label fields end to end. Chapter detail page gets a Kind selector; chapter chips across the app render "Foreword"/"Afterword" instead of a misleading number for front and back matter.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
import type { ChapterKind } from './types'
|
||||
|
||||
export function chapterLabel(kind: ChapterKind, displayNumber: number | null, title: string): string {
|
||||
if (kind === 'Body' && displayNumber !== null) return `Chapter ${displayNumber}: ${title}`
|
||||
return title
|
||||
}
|
||||
@@ -28,6 +28,10 @@ export type DraftStatus = 'Planned' | 'Outlined' | 'Drafted' | 'Revised' | 'Fina
|
||||
|
||||
export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final']
|
||||
|
||||
export type ChapterKind = 'FrontMatter' | 'Body' | 'BackMatter'
|
||||
|
||||
export const chapterKinds: ChapterKind[] = ['FrontMatter', 'Body', 'BackMatter']
|
||||
|
||||
export type NovelPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing' | 'Complete'
|
||||
|
||||
export const novelPhases: NovelPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete']
|
||||
@@ -112,12 +116,13 @@ export interface TagSummary extends Tag {
|
||||
export interface TagReferences {
|
||||
tag: Tag
|
||||
characters: { id: string; name: string; role: string }[]
|
||||
chapters: { id: string; number: number; title: string; summary: string | null }[]
|
||||
chapters: { id: string; number: number; kind: ChapterKind; displayNumber: number | null; title: string; summary: string | null }[]
|
||||
beats: {
|
||||
id: string
|
||||
chapterId: string
|
||||
chapterNumber: number
|
||||
chapterTitle: string
|
||||
chapterLabel: string
|
||||
sortOrder: number
|
||||
title: string
|
||||
characterName: string | null
|
||||
@@ -136,7 +141,7 @@ export interface LocationSummary extends Location {
|
||||
|
||||
export interface LocationReferences {
|
||||
location: Location
|
||||
chapters: { id: string; number: number; title: string; summary: string | null }[]
|
||||
chapters: { id: string; number: number; kind: ChapterKind; displayNumber: number | null; title: string; summary: string | null }[]
|
||||
}
|
||||
|
||||
export interface BeatCharacter {
|
||||
@@ -173,6 +178,7 @@ export interface ArcStage {
|
||||
chapterId: string | null
|
||||
chapterNumber: number | null
|
||||
chapterTitle: string | null
|
||||
chapterLabel: string | null
|
||||
beats: CharacterBeat[]
|
||||
updatedAt: string
|
||||
}
|
||||
@@ -182,6 +188,7 @@ export interface CharacterBeat {
|
||||
chapterId: string
|
||||
chapterNumber: number
|
||||
chapterTitle: string
|
||||
chapterLabel: string
|
||||
sortOrder: number
|
||||
title: string
|
||||
whatHappened: string | null
|
||||
@@ -210,6 +217,7 @@ export interface Character {
|
||||
sameCharacterAsName: string | null
|
||||
revealedInChapterId: string | null
|
||||
revealedInChapterNumber: number | null
|
||||
revealedInChapterLabel: string | null
|
||||
identityNote: string | null
|
||||
otherIdentities: CharacterIdentity[]
|
||||
relationships: Relationship[]
|
||||
@@ -227,6 +235,8 @@ export interface ChapterSummary {
|
||||
id: string
|
||||
novelId: string
|
||||
number: number
|
||||
kind: ChapterKind
|
||||
displayNumber: number | null
|
||||
title: string
|
||||
summary: string | null
|
||||
locations: Location[]
|
||||
@@ -254,6 +264,7 @@ export interface OpenQuestion {
|
||||
chapterId: string | null
|
||||
chapterNumber: number | null
|
||||
chapterTitle: string | null
|
||||
chapterLabel: string | null
|
||||
characterId: string | null
|
||||
characterName: string | null
|
||||
resolution: string | null
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
useSetArcStageBeats,
|
||||
useUpdateArcStage,
|
||||
} from '../api/hooks'
|
||||
import type { ArcStage, Character } from '../api/types'
|
||||
import { chapterLabel } from '../api/chapterLabel'
|
||||
import type { ArcStage, Character, ChapterKind } from '../api/types'
|
||||
import { AutoField, ErrorNote } from './ui'
|
||||
|
||||
export function CharacterArc({
|
||||
@@ -127,7 +128,7 @@ function ArcStageRow({
|
||||
}: {
|
||||
novelId: string
|
||||
stage: ArcStage
|
||||
chapters: { id: string; number: number; title: string }[]
|
||||
chapters: { id: string; number: number; kind: ChapterKind; displayNumber: number | null; title: string }[]
|
||||
unassignedBeats: { id: string; chapterNumber: number; sortOrder: number; title: string }[]
|
||||
canMoveUp: boolean
|
||||
canMoveDown: boolean
|
||||
@@ -226,7 +227,7 @@ function ArcStageRow({
|
||||
<option value="">Not pinned to a chapter</option>
|
||||
{chapters.map((chapter) => (
|
||||
<option key={chapter.id} value={chapter.id}>
|
||||
Ch. {chapter.number} — {chapter.title}
|
||||
{chapterLabel(chapter.kind, chapter.displayNumber, chapter.title)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -182,7 +182,7 @@ function QuestionRow({
|
||||
|
||||
{(showsChapter || showsCharacter) && (
|
||||
<p className="mt-1 text-xs muted">
|
||||
{showsChapter && `Ch. ${question.chapterNumber} ${question.chapterTitle}`}
|
||||
{showsChapter && question.chapterLabel}
|
||||
{showsChapter && showsCharacter && ' · '}
|
||||
{showsCharacter && question.characterName}
|
||||
</p>
|
||||
|
||||
@@ -139,11 +139,13 @@ export function AutoField({
|
||||
}
|
||||
|
||||
export function Select<T extends string>({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
id?: string
|
||||
label?: string
|
||||
value: T
|
||||
options: readonly T[]
|
||||
@@ -152,7 +154,7 @@ export function Select<T extends string>({
|
||||
return (
|
||||
<label className="block">
|
||||
{label && <span className="label">{label}</span>}
|
||||
<select className="input" value={value} onChange={(e) => onChange(e.target.value as T)}>
|
||||
<select id={id} className="input" value={value} onChange={(e) => onChange(e.target.value as T)}>
|
||||
{options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
useUpdateBeat,
|
||||
useUpdateChapter,
|
||||
} from '../api/hooks'
|
||||
import { draftStatuses, type Beat, type Chapter, type ChapterSummary } from '../api/types'
|
||||
import { chapterKinds, draftStatuses, type Beat, type Chapter, type ChapterSummary } from '../api/types'
|
||||
import { chapterLabel } from '../api/chapterLabel'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
@@ -97,9 +98,9 @@ export default function ChapterPage() {
|
||||
<Link
|
||||
to={`/novels/${novelId}/chapters/${prevChapter.id}`}
|
||||
className="muted hover:underline"
|
||||
title={`Chapter ${prevChapter.number}: ${prevChapter.title}`}
|
||||
title={chapterLabel(prevChapter.kind, prevChapter.displayNumber, prevChapter.title)}
|
||||
>
|
||||
← Ch. {prevChapter.number}
|
||||
← {prevChapter.kind === 'Body' ? `Ch. ${prevChapter.displayNumber}` : prevChapter.title}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="muted" style={{ opacity: 0.4 }}>
|
||||
@@ -110,9 +111,9 @@ export default function ChapterPage() {
|
||||
<Link
|
||||
to={`/novels/${novelId}/chapters/${nextChapter.id}`}
|
||||
className="muted hover:underline"
|
||||
title={`Chapter ${nextChapter.number}: ${nextChapter.title}`}
|
||||
title={chapterLabel(nextChapter.kind, nextChapter.displayNumber, nextChapter.title)}
|
||||
>
|
||||
Ch. {nextChapter.number} →
|
||||
{nextChapter.kind === 'Body' ? `Ch. ${nextChapter.displayNumber}` : nextChapter.title} →
|
||||
</Link>
|
||||
) : (
|
||||
<span className="muted" style={{ opacity: 0.4 }}>
|
||||
@@ -145,10 +146,11 @@ export default function ChapterPage() {
|
||||
|
||||
{tab === 'outline' && (
|
||||
<section className="card mb-6 p-5">
|
||||
<div className="grid gap-4 sm:grid-cols-[4rem_1fr_10rem]">
|
||||
<div className="grid gap-4 sm:grid-cols-[4rem_1fr_9rem_10rem]">
|
||||
<label className="block">
|
||||
<span className="label">No.</span>
|
||||
<input
|
||||
id="chapter-number-input"
|
||||
key={chapter.id}
|
||||
className="input"
|
||||
type="number"
|
||||
@@ -168,6 +170,14 @@ export default function ChapterPage() {
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
<Select
|
||||
id="chapter-kind-select"
|
||||
label="Kind"
|
||||
value={chapter.kind}
|
||||
options={chapterKinds}
|
||||
onChange={(kind) => canWrite && patch({ kind })}
|
||||
/>
|
||||
<Select
|
||||
id="chapter-status-select"
|
||||
label="Status"
|
||||
value={chapter.status}
|
||||
options={draftStatuses}
|
||||
@@ -515,7 +525,7 @@ function BeatTable({
|
||||
<option value="">Move to chapter…</option>
|
||||
{otherChapters.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.number}. {c.title}
|
||||
{chapterLabel(c.kind, c.displayNumber, c.title)}
|
||||
</option>
|
||||
))}
|
||||
<option value={MOVE_TO_NEW_CHAPTER}>New chapter…</option>
|
||||
|
||||
@@ -49,7 +49,7 @@ export default function ChaptersPage() {
|
||||
className="card flex items-center gap-4 px-5 py-3 transition hover:shadow-md"
|
||||
>
|
||||
<span className="w-8 shrink-0 text-right text-sm font-semibold muted">
|
||||
{chapter.number}
|
||||
{chapter.kind === 'Body' ? chapter.displayNumber : chapter.kind === 'FrontMatter' ? 'FM' : 'BM'}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{chapter.title}</div>
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
useUnlinkCharacterIdentity,
|
||||
useUpdateCharacter,
|
||||
} from '../api/hooks'
|
||||
import { characterImportances, characterRoles, type Character } from '../api/types'
|
||||
import { characterImportances, characterRoles, type Character, type ChapterKind } from '../api/types'
|
||||
import { chapterLabel } from '../api/chapterLabel'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { AutoField, EmptyState, ErrorNote, Select, Spinner } from '../components/ui'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
@@ -467,7 +468,7 @@ function IdentitySection({
|
||||
}: {
|
||||
character: Character
|
||||
allCharacters: Character[]
|
||||
chapters: { id: string; number: number; title: string }[]
|
||||
chapters: { id: string; number: number; kind: ChapterKind; displayNumber: number | null; title: string }[]
|
||||
canWrite: boolean
|
||||
onLink: (sameCharacterAsId: string, revealedInChapterId: string | null, note: string | null) => void
|
||||
onUnlink: () => void
|
||||
@@ -497,8 +498,8 @@ function IdentitySection({
|
||||
<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>
|
||||
{character.revealedInChapterLabel != null && (
|
||||
<span className="muted"> — revealed in {character.revealedInChapterLabel}</span>
|
||||
)}
|
||||
</p>
|
||||
{character.identityNote && <p className="muted text-sm">{character.identityNote}</p>}
|
||||
@@ -560,7 +561,7 @@ function IdentitySection({
|
||||
<option value="">—</option>
|
||||
{chapters.map((ch) => (
|
||||
<option key={ch.id} value={ch.id}>
|
||||
Ch. {ch.number} — {ch.title}
|
||||
{chapterLabel(ch.kind, ch.displayNumber, ch.title)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -97,7 +97,7 @@ function OutliningDashboard({ novelId }: { novelId: string }) {
|
||||
className="card flex items-center gap-3 px-4 py-2.5 transition hover:shadow-sm"
|
||||
>
|
||||
<span className="w-6 shrink-0 text-right text-sm font-semibold muted">
|
||||
{chapter.number}
|
||||
{chapter.kind === 'Body' ? chapter.displayNumber : chapter.kind === 'FrontMatter' ? 'FM' : 'BM'}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{chapter.title}</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { useDeleteLocation, useLocationReferences, useLocations, useNovel, useUpdateLocation } from '../api/hooks'
|
||||
import { chapterLabel } from '../api/chapterLabel'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
@@ -151,7 +152,7 @@ function LocationReferencePanel({
|
||||
to={`/novels/${novelId}/chapters/${c.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{c.number}. {c.title}
|
||||
{chapterLabel(c.kind, c.displayNumber, c.title)}
|
||||
</Link>
|
||||
{c.summary && <span className="muted"> — {c.summary}</span>}
|
||||
</li>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { useDeleteTag, useNovel, useTagReferences, useTags, useUpdateTag } from '../api/hooks'
|
||||
import { chapterLabel } from '../api/chapterLabel'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
@@ -175,7 +176,7 @@ function TagReferencePanel({
|
||||
to={`/novels/${novelId}/chapters/${c.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{c.number}. {c.title}
|
||||
{chapterLabel(c.kind, c.displayNumber, c.title)}
|
||||
</Link>
|
||||
{c.summary && <span className="muted"> — {c.summary}</span>}
|
||||
</li>
|
||||
@@ -198,7 +199,7 @@ function TagReferencePanel({
|
||||
</Link>
|
||||
<span className="muted">
|
||||
{' '}
|
||||
— ch. {b.chapterNumber} “{b.chapterTitle}”, beat {b.sortOrder}
|
||||
— {b.chapterLabel}, beat {b.sortOrder}
|
||||
{b.characterName && `, ${b.characterName}`}
|
||||
</span>
|
||||
{b.whatHappened && <div className="prose-serif muted">{b.whatHappened}</div>}
|
||||
|
||||
Reference in New Issue
Block a user