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.
166 lines
5.2 KiB
TypeScript
166 lines
5.2 KiB
TypeScript
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'
|
|
|
|
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"
|
|
>
|
|
{chapterLabel(c.kind, c.displayNumber, c.title)}
|
|
</Link>
|
|
{c.summary && <span className="muted"> — {c.summary}</span>}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|