Replace chapter setting with multi-select locations; add chapter character summary

Chapters now carry many Locations (new Tags-style entity with cross-referencing)
instead of a single free-text Setting field, with a Locations tab on the novel
for browsing them and seeing every chapter set at each one. Also surfaces the
distinct characters appearing in a chapter's beats, linked, under the beat/word
count on the outline tab.
This commit is contained in:
James Wampler
2026-08-18 11:36:13 -07:00
parent 4313c8f206
commit c620ddd626
27 changed files with 2380 additions and 44 deletions
+2
View File
@@ -5,6 +5,7 @@ import DashboardPage from './pages/DashboardPage'
import CharactersPage from './pages/CharactersPage'
import CharacterDetailPage from './pages/CharacterDetailPage'
import TagsPage from './pages/TagsPage'
import LocationsPage from './pages/LocationsPage'
import ChaptersPage from './pages/ChaptersPage'
import ChapterPage from './pages/ChapterPage'
import AgentPage from './pages/AgentPage'
@@ -42,6 +43,7 @@ export default function App() {
<Route path="chapters" element={<ChaptersPage />} />
<Route path="chapters/:chapterId" element={<ChapterPage />} />
<Route path="tags" element={<TagsPage />} />
<Route path="locations" element={<LocationsPage />} />
<Route path="agent" element={<AgentPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
+44 -4
View File
@@ -15,6 +15,8 @@ import type {
ImportJob,
ImportJobStatus,
OpenQuestion,
LocationReferences,
LocationSummary,
Novel,
NovelMember,
NovelRole,
@@ -33,6 +35,8 @@ export const keys = {
characters: (novelId: string) => ['novels', novelId, 'characters'] as const,
tags: (novelId: string) => ['novels', novelId, 'tags'] as const,
tagRefs: (tagId: string) => ['tags', tagId, 'references'] as const,
locations: (novelId: string) => ['novels', novelId, 'locations'] as const,
locationRefs: (locationId: string) => ['locations', locationId, 'references'] as const,
characterBeats: (characterId: string) => ['characters', characterId, 'beats'] as const,
chapters: (novelId: string) => ['novels', novelId, 'chapters'] as const,
questions: (novelId: string) => ['novels', novelId, 'questions'] as const,
@@ -392,6 +396,39 @@ export function useDeleteTag() {
})
}
export const useLocations = (novelId: string) =>
useQuery({
queryKey: keys.locations(novelId),
queryFn: () => api.get<LocationSummary[]>(`/api/novels/${novelId}/locations`),
})
export const useLocationReferences = (locationId: string | undefined) =>
useQuery({
queryKey: keys.locationRefs(locationId ?? ''),
queryFn: () => api.get<LocationReferences>(`/api/locations/${locationId}/references`),
enabled: Boolean(locationId),
})
export function useUpdateLocation(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...body }: { id: string; name?: string }) =>
api.patch<LocationSummary>(`/api/locations/${id}`, body),
onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: keys.locations(novelId) })
qc.invalidateQueries({ queryKey: keys.locationRefs(id) })
},
})
}
export function useDeleteLocation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/api/locations/${id}`),
onSuccess: () => qc.invalidateQueries(),
})
}
export function useCreateBeat(chapterId: string, novelId: string) {
const qc = useQueryClient()
return useMutation({
@@ -474,8 +511,9 @@ export const useChapter = (id: string | undefined) =>
export function useCreateChapter(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: Partial<Chapter> & { title: string }) =>
api.post<Chapter>(`/api/novels/${novelId}/chapters`, body),
mutationFn: (
body: Partial<Omit<Chapter, 'tags' | 'locations'>> & { title: string; tags?: string[]; locations?: string[] },
) => api.post<Chapter>(`/api/novels/${novelId}/chapters`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(novelId) }),
})
}
@@ -483,12 +521,14 @@ export function useCreateChapter(novelId: string) {
export function useUpdateChapter(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...body }: Partial<Omit<Chapter, 'tags'>> & { id: string; tags?: string[] }) =>
api.patch<Chapter>(`/api/chapters/${id}`, body),
mutationFn: (
{ id, ...body }: Partial<Omit<Chapter, 'tags' | 'locations'>> & { id: string; tags?: string[]; locations?: string[] },
) => api.patch<Chapter>(`/api/chapters/${id}`, body),
onSuccess: (updated) => {
qc.setQueryData(keys.chapter(updated.id), updated)
qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
qc.invalidateQueries({ queryKey: keys.tags(novelId) })
qc.invalidateQueries({ queryKey: keys.locations(novelId) })
},
})
}
+15 -1
View File
@@ -121,6 +121,20 @@ export interface TagReferences {
}[]
}
export interface Location {
id: string
name: string
}
export interface LocationSummary extends Location {
chapterCount: number
}
export interface LocationReferences {
location: Location
chapters: { id: string; number: number; title: string; summary: string | null }[]
}
export interface BeatCharacter {
id: string
name: string
@@ -214,7 +228,7 @@ export interface ChapterSummary {
number: number
title: string
summary: string | null
setting: string | null
locations: Location[]
status: DraftStatus
targetWordCount: number | null
beatCount: number
@@ -0,0 +1,112 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import type { Location } from '../api/types'
export function LocationChip({
location,
novelId,
onRemove,
}: {
location: Location
novelId?: string
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)' }}
>
{novelId ? (
<Link to={`/novels/${novelId}/locations/${location.id}`} className="hover:underline">
{location.name}
</Link>
) : (
location.name
)}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="opacity-60 transition hover:opacity-100"
aria-label={`Remove location ${location.name}`}
>
</button>
)}
</span>
)
}
export function LocationEditor({
id,
locations,
suggestions = [],
onChange,
label,
novelId,
readOnly = false,
}: {
id?: string
locations: Location[]
suggestions?: string[]
onChange: (names: string[]) => void
label?: string
novelId?: string
readOnly?: boolean
}) {
const [draft, setDraft] = useState('')
const listId = `location-suggestions-${label ?? 'default'}`
const add = () => {
const name = draft.trim()
if (!name) return
if (!locations.some((l) => l.name.toLowerCase() === name.toLowerCase())) {
onChange([...locations.map((l) => l.name), name])
}
setDraft('')
}
const remove = (name: string) =>
onChange(locations.filter((l) => l.name !== name).map((l) => l.name))
const unused = suggestions.filter(
(s) => !locations.some((l) => l.name.toLowerCase() === s.toLowerCase()),
)
return (
<div id={id}>
{label && <span className="label">{label}</span>}
<div className="flex flex-wrap items-center gap-1.5">
{locations.map((location) => (
<LocationChip
key={location.id}
location={location}
novelId={novelId}
onRemove={readOnly ? undefined : () => remove(location.name)}
/>
))}
{!readOnly && (
<input
className="input w-32 flex-1 px-2 py-0.5 text-xs"
value={draft}
list={listId}
placeholder="Add location…"
onChange={(e) => setDraft(e.target.value)}
onBlur={add}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
add()
}
}}
/>
)}
<datalist id={listId}>
{unused.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</div>
</div>
)
}
+30 -9
View File
@@ -9,6 +9,7 @@ import {
useCreateChapter,
useDeleteBeat,
useDeleteChapter,
useLocations,
useMoveBeats,
useNovel,
useReorderBeats,
@@ -21,6 +22,7 @@ import { useAuth } from '../auth/AuthContext'
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
import { TagChip, TagEditor } from '../components/TagEditor'
import { LocationEditor } from '../components/LocationEditor'
import { CharacterChip, CharacterMultiSelect } from '../components/CharacterMultiSelect'
import { useCharacterContextMenu } from '../components/CharacterContextMenu'
import { MarkdownEditor } from '../components/MarkdownEditor'
@@ -36,6 +38,7 @@ export default function ChapterPage() {
const { data: novel } = useNovel(novelId)
const { data: characters } = useCharacters(novelId)
const { data: allTags } = useTags(novelId)
const { data: allLocations } = useLocations(novelId)
const { data: chapters } = useChapters(novelId)
const createChapter = useCreateChapter(novelId)
const update = useUpdateChapter(novelId)
@@ -73,13 +76,15 @@ export default function ChapterPage() {
if (error) return <ErrorNote error={error} />
if (!chapter) return null
const patch = (body: Partial<Omit<Chapter, 'tags'>> & { tags?: string[] }) =>
const patch = (body: Partial<Omit<Chapter, 'tags' | 'locations'>> & { tags?: string[]; locations?: string[] }) =>
update.mutate({ id: chapter.id, ...body })
const chapterCharacters = [...new Map(chapter.beats.flatMap((b) => b.characters).map((c) => [c.id, c])).values()].sort(
(a, b) => a.name.localeCompare(b.name),
)
const suggestions = allTags?.map((t) => t.name) ?? []
const settingSuggestions = [
...new Set((chapters ?? []).map((c) => c.setting).filter((s): s is string => Boolean(s?.trim()))),
].sort()
const locationSuggestions = allLocations?.map((l) => l.name) ?? []
return (
<div>
@@ -171,11 +176,13 @@ export default function ChapterPage() {
</div>
<div className="mt-4">
<AutoField
label="Setting"
value={chapter.setting}
onCommit={(setting) => patch({ setting })}
suggestions={settingSuggestions}
<LocationEditor
id="chapter-locations"
label="Locations"
locations={chapter.locations}
suggestions={locationSuggestions}
novelId={novelId}
onChange={(locations) => canWrite && patch({ locations })}
readOnly={!canWrite}
/>
</div>
@@ -199,6 +206,20 @@ export default function ChapterPage() {
</button>
)}
</div>
{chapterCharacters.length > 0 && (
<div className="mt-2 text-sm muted" id="chapter-outline-characters">
Characters:{' '}
{chapterCharacters.map((c, i) => (
<span key={c.id}>
{i > 0 && ', '}
<Link to={`/novels/${novelId}/characters/${c.id}`} className="hover:underline">
{c.name}
</Link>
</span>
))}
</div>
)}
</section>
)}
+164
View File
@@ -0,0 +1,164 @@
import { useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { useDeleteLocation, useLocationReferences, useLocations, useNovel, useUpdateLocation } from '../api/hooks'
import { useAuth } from '../auth/AuthContext'
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
export default function LocationsPage() {
const { novelId = '' } = useParams()
const { data: locations, isPending, error } = useLocations(novelId)
const { data: novel } = useNovel(novelId)
const { can } = useAuth()
const canWrite = can('Write', novel)
const canDelete = can('DeleteContent', novel)
const [searchParams, setSearchParams] = useSearchParams()
const selectedId = searchParams.get('location') ?? undefined
if (isPending) return <Spinner label="Loading locations" />
if (error) return <ErrorNote error={error} />
const selected = locations?.find((l) => l.id === selectedId) ?? locations?.[0]
const select = (id: string) =>
setSearchParams((params) => {
params.set('location', id)
return params
})
return (
<div id="locations-page" className="grid gap-6 lg:grid-cols-[18rem_1fr]">
<aside className="grid content-start gap-2">
<div>
<h2 className="text-lg font-semibold">Locations</h2>
<p className="text-sm muted">
Applied from a chapter. Pick one to see every chapter set there.
</p>
</div>
{locations?.length === 0 && (
<p className="mt-2 text-sm muted">
No locations yet. Add one from a chapter and it will appear here.
</p>
)}
{locations?.map((location) => (
<button
key={location.id}
onClick={() => select(location.id)}
className="card flex items-center justify-between gap-2 px-3 py-2 text-left transition hover:shadow-sm"
style={
location.id === selected?.id
? { borderColor: 'var(--accent)', background: 'var(--accent-soft)' }
: undefined
}
>
<span>{location.name}</span>
<span className="text-xs muted">{location.chapterCount}</span>
</button>
))}
</aside>
<section>
{!selected ? (
<EmptyState
title="No locations yet"
hint="Locations cross-reference the book: attach one to a chapter, then trace it from here."
/>
) : (
<LocationReferencePanel
key={selected.id}
novelId={novelId}
locationId={selected.id}
canWrite={canWrite}
canDelete={canDelete}
/>
)}
</section>
</div>
)
}
function LocationReferencePanel({
novelId,
locationId,
canWrite,
canDelete,
}: {
novelId: string
locationId: string
canWrite: boolean
canDelete: boolean
}) {
const { data, isPending, error } = useLocationReferences(locationId)
const update = useUpdateLocation(novelId)
const remove = useDeleteLocation()
const [confirmingDelete, setConfirmingDelete] = useState(false)
if (isPending) return <Spinner label="Loading references" />
if (error) return <ErrorNote error={error} />
if (!data) return null
const empty = data.chapters.length === 0
return (
<div className="grid gap-4">
<div className="card flex flex-wrap items-end justify-between gap-3 p-4">
<label className="block">
<span className="label">Location name</span>
<input
className="input w-64"
defaultValue={data.location.name}
readOnly={!canWrite}
onBlur={(e) => {
const name = e.target.value.trim()
if (name && name !== data.location.name) update.mutate({ id: locationId, name })
}}
/>
</label>
{canDelete && (
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
Delete location
</button>
)}
</div>
{update.error && <ErrorNote error={update.error} />}
{confirmingDelete && (
<ConfirmModal
title="Delete location"
message={`Delete the location "${data.location.name}"? What carries it is left alone.`}
onConfirm={() => remove.mutate(locationId)}
onClose={() => setConfirmingDelete(false)}
/>
)}
{empty && (
<EmptyState
title="No chapters set here"
hint="Apply this location to a chapter and it will show up here."
/>
)}
{data.chapters.length > 0 && (
<div className="card p-4">
<h3 className="label">Chapters</h3>
<ul className="grid gap-1 text-sm">
{data.chapters.map((c) => (
<li key={c.id}>
<Link
to={`/novels/${novelId}/chapters/${c.id}`}
className="font-medium hover:underline"
>
{c.number}. {c.title}
</Link>
{c.summary && <span className="muted"> {c.summary}</span>}
</li>
))}
</ul>
</div>
)}
</div>
)
}
@@ -11,6 +11,7 @@ const sections: { to: string; label: string; end?: boolean }[] = [
{ to: 'chapters', label: 'Chapters' },
{ to: 'characters', label: 'Characters' },
{ to: 'tags', label: 'Tags' },
{ to: 'locations', label: 'Locations' },
{ to: 'agent', label: 'Agent' },
{ to: 'settings', label: 'Settings' },
]
@@ -30,6 +31,7 @@ export default function NovelLayout() {
useHotkey('g o', 'Go to outline', () => goTo('chapters'), { group: 'Navigate' })
useHotkey('g c', 'Go to characters', () => goTo('characters'), { group: 'Navigate' })
useHotkey('g t', 'Go to tags', () => goTo('tags'), { group: 'Navigate' })
useHotkey('g l', 'Go to locations', () => goTo('locations'), { group: 'Navigate' })
useHotkey('g a', 'Go to agent', () => goTo('agent'), { group: 'Navigate' })
useHotkey('g s', 'Go to settings', () => goTo('settings'), { group: 'Navigate' })