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 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 type NovelPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing' | 'Complete'
|
||||||
|
|
||||||
export const novelPhases: 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 {
|
export interface TagReferences {
|
||||||
tag: Tag
|
tag: Tag
|
||||||
characters: { id: string; name: string; role: string }[]
|
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: {
|
beats: {
|
||||||
id: string
|
id: string
|
||||||
chapterId: string
|
chapterId: string
|
||||||
chapterNumber: number
|
chapterNumber: number
|
||||||
chapterTitle: string
|
chapterTitle: string
|
||||||
|
chapterLabel: string
|
||||||
sortOrder: number
|
sortOrder: number
|
||||||
title: string
|
title: string
|
||||||
characterName: string | null
|
characterName: string | null
|
||||||
@@ -136,7 +141,7 @@ export interface LocationSummary extends Location {
|
|||||||
|
|
||||||
export interface LocationReferences {
|
export interface LocationReferences {
|
||||||
location: Location
|
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 {
|
export interface BeatCharacter {
|
||||||
@@ -173,6 +178,7 @@ export interface ArcStage {
|
|||||||
chapterId: string | null
|
chapterId: string | null
|
||||||
chapterNumber: number | null
|
chapterNumber: number | null
|
||||||
chapterTitle: string | null
|
chapterTitle: string | null
|
||||||
|
chapterLabel: string | null
|
||||||
beats: CharacterBeat[]
|
beats: CharacterBeat[]
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
@@ -182,6 +188,7 @@ export interface CharacterBeat {
|
|||||||
chapterId: string
|
chapterId: string
|
||||||
chapterNumber: number
|
chapterNumber: number
|
||||||
chapterTitle: string
|
chapterTitle: string
|
||||||
|
chapterLabel: string
|
||||||
sortOrder: number
|
sortOrder: number
|
||||||
title: string
|
title: string
|
||||||
whatHappened: string | null
|
whatHappened: string | null
|
||||||
@@ -210,6 +217,7 @@ export interface Character {
|
|||||||
sameCharacterAsName: string | null
|
sameCharacterAsName: string | null
|
||||||
revealedInChapterId: string | null
|
revealedInChapterId: string | null
|
||||||
revealedInChapterNumber: number | null
|
revealedInChapterNumber: number | null
|
||||||
|
revealedInChapterLabel: string | null
|
||||||
identityNote: string | null
|
identityNote: string | null
|
||||||
otherIdentities: CharacterIdentity[]
|
otherIdentities: CharacterIdentity[]
|
||||||
relationships: Relationship[]
|
relationships: Relationship[]
|
||||||
@@ -227,6 +235,8 @@ export interface ChapterSummary {
|
|||||||
id: string
|
id: string
|
||||||
novelId: string
|
novelId: string
|
||||||
number: number
|
number: number
|
||||||
|
kind: ChapterKind
|
||||||
|
displayNumber: number | null
|
||||||
title: string
|
title: string
|
||||||
summary: string | null
|
summary: string | null
|
||||||
locations: Location[]
|
locations: Location[]
|
||||||
@@ -254,6 +264,7 @@ export interface OpenQuestion {
|
|||||||
chapterId: string | null
|
chapterId: string | null
|
||||||
chapterNumber: number | null
|
chapterNumber: number | null
|
||||||
chapterTitle: string | null
|
chapterTitle: string | null
|
||||||
|
chapterLabel: string | null
|
||||||
characterId: string | null
|
characterId: string | null
|
||||||
characterName: string | null
|
characterName: string | null
|
||||||
resolution: string | null
|
resolution: string | null
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
useSetArcStageBeats,
|
useSetArcStageBeats,
|
||||||
useUpdateArcStage,
|
useUpdateArcStage,
|
||||||
} from '../api/hooks'
|
} 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'
|
import { AutoField, ErrorNote } from './ui'
|
||||||
|
|
||||||
export function CharacterArc({
|
export function CharacterArc({
|
||||||
@@ -127,7 +128,7 @@ function ArcStageRow({
|
|||||||
}: {
|
}: {
|
||||||
novelId: string
|
novelId: string
|
||||||
stage: ArcStage
|
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 }[]
|
unassignedBeats: { id: string; chapterNumber: number; sortOrder: number; title: string }[]
|
||||||
canMoveUp: boolean
|
canMoveUp: boolean
|
||||||
canMoveDown: boolean
|
canMoveDown: boolean
|
||||||
@@ -226,7 +227,7 @@ function ArcStageRow({
|
|||||||
<option value="">Not pinned to a chapter</option>
|
<option value="">Not pinned to a chapter</option>
|
||||||
{chapters.map((chapter) => (
|
{chapters.map((chapter) => (
|
||||||
<option key={chapter.id} value={chapter.id}>
|
<option key={chapter.id} value={chapter.id}>
|
||||||
Ch. {chapter.number} — {chapter.title}
|
{chapterLabel(chapter.kind, chapter.displayNumber, chapter.title)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ function QuestionRow({
|
|||||||
|
|
||||||
{(showsChapter || showsCharacter) && (
|
{(showsChapter || showsCharacter) && (
|
||||||
<p className="mt-1 text-xs muted">
|
<p className="mt-1 text-xs muted">
|
||||||
{showsChapter && `Ch. ${question.chapterNumber} ${question.chapterTitle}`}
|
{showsChapter && question.chapterLabel}
|
||||||
{showsChapter && showsCharacter && ' · '}
|
{showsChapter && showsCharacter && ' · '}
|
||||||
{showsCharacter && question.characterName}
|
{showsCharacter && question.characterName}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -139,11 +139,13 @@ export function AutoField({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Select<T extends string>({
|
export function Select<T extends string>({
|
||||||
|
id,
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
options,
|
options,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
|
id?: string
|
||||||
label?: string
|
label?: string
|
||||||
value: T
|
value: T
|
||||||
options: readonly T[]
|
options: readonly T[]
|
||||||
@@ -152,7 +154,7 @@ export function Select<T extends string>({
|
|||||||
return (
|
return (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
{label && <span className="label">{label}</span>}
|
{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) => (
|
{options.map((option) => (
|
||||||
<option key={option} value={option}>
|
<option key={option} value={option}>
|
||||||
{option}
|
{option}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import {
|
|||||||
useUpdateBeat,
|
useUpdateBeat,
|
||||||
useUpdateChapter,
|
useUpdateChapter,
|
||||||
} from '../api/hooks'
|
} 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 { useAuth } from '../auth/AuthContext'
|
||||||
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
|
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
|
||||||
import { ConfirmModal } from '../components/ConfirmModal'
|
import { ConfirmModal } from '../components/ConfirmModal'
|
||||||
@@ -97,9 +98,9 @@ export default function ChapterPage() {
|
|||||||
<Link
|
<Link
|
||||||
to={`/novels/${novelId}/chapters/${prevChapter.id}`}
|
to={`/novels/${novelId}/chapters/${prevChapter.id}`}
|
||||||
className="muted hover:underline"
|
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>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span className="muted" style={{ opacity: 0.4 }}>
|
<span className="muted" style={{ opacity: 0.4 }}>
|
||||||
@@ -110,9 +111,9 @@ export default function ChapterPage() {
|
|||||||
<Link
|
<Link
|
||||||
to={`/novels/${novelId}/chapters/${nextChapter.id}`}
|
to={`/novels/${novelId}/chapters/${nextChapter.id}`}
|
||||||
className="muted hover:underline"
|
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>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span className="muted" style={{ opacity: 0.4 }}>
|
<span className="muted" style={{ opacity: 0.4 }}>
|
||||||
@@ -145,10 +146,11 @@ export default function ChapterPage() {
|
|||||||
|
|
||||||
{tab === 'outline' && (
|
{tab === 'outline' && (
|
||||||
<section className="card mb-6 p-5">
|
<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">
|
<label className="block">
|
||||||
<span className="label">No.</span>
|
<span className="label">No.</span>
|
||||||
<input
|
<input
|
||||||
|
id="chapter-number-input"
|
||||||
key={chapter.id}
|
key={chapter.id}
|
||||||
className="input"
|
className="input"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -168,6 +170,14 @@ export default function ChapterPage() {
|
|||||||
readOnly={!canWrite}
|
readOnly={!canWrite}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
|
id="chapter-kind-select"
|
||||||
|
label="Kind"
|
||||||
|
value={chapter.kind}
|
||||||
|
options={chapterKinds}
|
||||||
|
onChange={(kind) => canWrite && patch({ kind })}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
id="chapter-status-select"
|
||||||
label="Status"
|
label="Status"
|
||||||
value={chapter.status}
|
value={chapter.status}
|
||||||
options={draftStatuses}
|
options={draftStatuses}
|
||||||
@@ -515,7 +525,7 @@ function BeatTable({
|
|||||||
<option value="">Move to chapter…</option>
|
<option value="">Move to chapter…</option>
|
||||||
{otherChapters.map((c) => (
|
{otherChapters.map((c) => (
|
||||||
<option key={c.id} value={c.id}>
|
<option key={c.id} value={c.id}>
|
||||||
{c.number}. {c.title}
|
{chapterLabel(c.kind, c.displayNumber, c.title)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
<option value={MOVE_TO_NEW_CHAPTER}>New chapter…</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"
|
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">
|
<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>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate font-medium">{chapter.title}</div>
|
<div className="truncate font-medium">{chapter.title}</div>
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
useUnlinkCharacterIdentity,
|
useUnlinkCharacterIdentity,
|
||||||
useUpdateCharacter,
|
useUpdateCharacter,
|
||||||
} from '../api/hooks'
|
} 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 { useAuth } from '../auth/AuthContext'
|
||||||
import { AutoField, EmptyState, ErrorNote, Select, Spinner } from '../components/ui'
|
import { AutoField, EmptyState, ErrorNote, Select, Spinner } from '../components/ui'
|
||||||
import { ConfirmModal } from '../components/ConfirmModal'
|
import { ConfirmModal } from '../components/ConfirmModal'
|
||||||
@@ -467,7 +468,7 @@ function IdentitySection({
|
|||||||
}: {
|
}: {
|
||||||
character: Character
|
character: Character
|
||||||
allCharacters: Character[]
|
allCharacters: Character[]
|
||||||
chapters: { id: string; number: number; title: string }[]
|
chapters: { id: string; number: number; kind: ChapterKind; displayNumber: number | null; title: string }[]
|
||||||
canWrite: boolean
|
canWrite: boolean
|
||||||
onLink: (sameCharacterAsId: string, revealedInChapterId: string | null, note: string | null) => void
|
onLink: (sameCharacterAsId: string, revealedInChapterId: string | null, note: string | null) => void
|
||||||
onUnlink: () => void
|
onUnlink: () => void
|
||||||
@@ -497,8 +498,8 @@ function IdentitySection({
|
|||||||
<h3 className="label">Identity</h3>
|
<h3 className="label">Identity</h3>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
Really <span className="font-medium">{character.sameCharacterAsName}</span>
|
Really <span className="font-medium">{character.sameCharacterAsName}</span>
|
||||||
{character.revealedInChapterNumber != null && (
|
{character.revealedInChapterLabel != null && (
|
||||||
<span className="muted"> — revealed in Chapter {character.revealedInChapterNumber}</span>
|
<span className="muted"> — revealed in {character.revealedInChapterLabel}</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{character.identityNote && <p className="muted text-sm">{character.identityNote}</p>}
|
{character.identityNote && <p className="muted text-sm">{character.identityNote}</p>}
|
||||||
@@ -560,7 +561,7 @@ function IdentitySection({
|
|||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
{chapters.map((ch) => (
|
{chapters.map((ch) => (
|
||||||
<option key={ch.id} value={ch.id}>
|
<option key={ch.id} value={ch.id}>
|
||||||
Ch. {ch.number} — {ch.title}
|
{chapterLabel(ch.kind, ch.displayNumber, ch.title)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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"
|
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">
|
<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>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate font-medium">{chapter.title}</div>
|
<div className="truncate font-medium">{chapter.title}</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import { useDeleteLocation, useLocationReferences, useLocations, useNovel, useUpdateLocation } from '../api/hooks'
|
import { useDeleteLocation, useLocationReferences, useLocations, useNovel, useUpdateLocation } from '../api/hooks'
|
||||||
|
import { chapterLabel } from '../api/chapterLabel'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
|
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
|
||||||
import { ConfirmModal } from '../components/ConfirmModal'
|
import { ConfirmModal } from '../components/ConfirmModal'
|
||||||
@@ -151,7 +152,7 @@ function LocationReferencePanel({
|
|||||||
to={`/novels/${novelId}/chapters/${c.id}`}
|
to={`/novels/${novelId}/chapters/${c.id}`}
|
||||||
className="font-medium hover:underline"
|
className="font-medium hover:underline"
|
||||||
>
|
>
|
||||||
{c.number}. {c.title}
|
{chapterLabel(c.kind, c.displayNumber, c.title)}
|
||||||
</Link>
|
</Link>
|
||||||
{c.summary && <span className="muted"> — {c.summary}</span>}
|
{c.summary && <span className="muted"> — {c.summary}</span>}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import { useDeleteTag, useNovel, useTagReferences, useTags, useUpdateTag } from '../api/hooks'
|
import { useDeleteTag, useNovel, useTagReferences, useTags, useUpdateTag } from '../api/hooks'
|
||||||
|
import { chapterLabel } from '../api/chapterLabel'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
|
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
|
||||||
import { ConfirmModal } from '../components/ConfirmModal'
|
import { ConfirmModal } from '../components/ConfirmModal'
|
||||||
@@ -175,7 +176,7 @@ function TagReferencePanel({
|
|||||||
to={`/novels/${novelId}/chapters/${c.id}`}
|
to={`/novels/${novelId}/chapters/${c.id}`}
|
||||||
className="font-medium hover:underline"
|
className="font-medium hover:underline"
|
||||||
>
|
>
|
||||||
{c.number}. {c.title}
|
{chapterLabel(c.kind, c.displayNumber, c.title)}
|
||||||
</Link>
|
</Link>
|
||||||
{c.summary && <span className="muted"> — {c.summary}</span>}
|
{c.summary && <span className="muted"> — {c.summary}</span>}
|
||||||
</li>
|
</li>
|
||||||
@@ -198,7 +199,7 @@ function TagReferencePanel({
|
|||||||
</Link>
|
</Link>
|
||||||
<span className="muted">
|
<span className="muted">
|
||||||
{' '}
|
{' '}
|
||||||
— ch. {b.chapterNumber} “{b.chapterTitle}”, beat {b.sortOrder}
|
— {b.chapterLabel}, beat {b.sortOrder}
|
||||||
{b.characterName && `, ${b.characterName}`}
|
{b.characterName && `, ${b.characterName}`}
|
||||||
</span>
|
</span>
|
||||||
{b.whatHappened && <div className="prose-serif muted">{b.whatHappened}</div>}
|
{b.whatHappened && <div className="prose-serif muted">{b.whatHappened}</div>}
|
||||||
|
|||||||
Reference in New Issue
Block a user