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:
@@ -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>
|
||||
|
||||
@@ -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) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user