Files
novelly/src/Novelly.Web/src/pages/LocationsPage.tsx
T
James Wampler 3795ddd541
CI / build-and-push (push) Successful in 54s
CI / deploy (push) Successful in 9s
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.
2026-08-19 18:07:21 -07:00

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>
)
}