Add batch character assignment for chapter outline beats

Lets a writer select several beats and add one character to all of them
at once, without disturbing each beat's existing characters. Surfaced
through the REST API, the embedded agent, and the MCP server per the
project's rule that all three share the same service methods.
This commit is contained in:
James Wampler
2026-08-15 20:36:25 -07:00
parent 6774a38684
commit df10b1f99b
9 changed files with 277 additions and 33 deletions
+9
View File
@@ -293,6 +293,15 @@ export function useReorderBeats(chapterId: string) {
})
}
export function useAssignCharacterToBeats(chapterId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ characterId, beatIds }: { characterId: string; beatIds: string[] }) =>
api.post<Beat[]>(`/api/chapters/${chapterId}/beats/assign-character`, { characterId, beatIds }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }),
})
}
export const useChapters = (projectId: string) =>
useQuery({
queryKey: keys.chapters(projectId),
+78 -1
View File
@@ -1,6 +1,7 @@
import { useState, type MouseEvent } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import {
useAssignCharacterToBeats,
useChapter,
useCharacters,
useCreateBeat,
@@ -250,8 +251,25 @@ function BeatTable({
const update = useUpdateBeat(chapter.id, projectId)
const remove = useDeleteBeat(chapter.id)
const reorder = useReorderBeats(chapter.id)
const assignCharacter = useAssignCharacterToBeats(chapter.id)
const [editingId, setEditingId] = useState<string | null>(null)
const [deletingBeat, setDeletingBeat] = useState<Beat | null>(null)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [assignCharacterId, setAssignCharacterId] = useState('')
const toggleSelected = (id: string) =>
setSelectedIds((ids) => (ids.includes(id) ? ids.filter((i) => i !== id) : [...ids, id]))
const toggleSelectAll = () =>
setSelectedIds((ids) => (ids.length === chapter.beats.length ? [] : chapter.beats.map((b) => b.id)))
const assignToSelected = () => {
if (!assignCharacterId || selectedIds.length === 0) return
assignCharacter.mutate(
{ characterId: assignCharacterId, beatIds: selectedIds },
{ onSuccess: () => setSelectedIds([]) },
)
}
if (chapter.beats.length === 0) {
return (
@@ -274,10 +292,50 @@ function BeatTable({
update.mutate({ id, ...body })
return (
<div className="card overflow-x-auto">
<div>
{selectedIds.length > 0 && (
<div className="card mb-3 flex flex-wrap items-center gap-3 p-3">
<span className="text-sm font-medium">
{selectedIds.length} beat{selectedIds.length === 1 ? '' : 's'} selected
</span>
<select
className="input w-48"
value={assignCharacterId}
onChange={(e) => setAssignCharacterId(e.target.value)}
>
<option value="">Assign character…</option>
{characters.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<button
className="btn btn-primary"
onClick={assignToSelected}
disabled={!assignCharacterId || assignCharacter.isPending}
>
Assign
</button>
<button className="btn" onClick={() => setSelectedIds([])}>
Clear selection
</button>
{assignCharacter.error && <ErrorNote error={assignCharacter.error} />}
</div>
)}
<div className="card overflow-x-auto">
<table className="w-full table-fixed border-collapse text-sm">
<thead>
<tr style={{ borderBottom: '1px solid var(--line)' }}>
<th className="w-8 px-2 py-2 text-left">
<input
type="checkbox"
aria-label="Select all beats"
checked={selectedIds.length === chapter.beats.length}
onChange={toggleSelectAll}
/>
</th>
<th className="w-10 px-2 py-2 text-left text-xs font-semibold uppercase muted">#</th>
<th className="w-[16%] px-2 py-2 text-left text-xs font-semibold uppercase muted">Beat</th>
<th className="w-[16%] px-2 py-2 text-left text-xs font-semibold uppercase muted">
@@ -302,6 +360,15 @@ function BeatTable({
}}
onKeyDown={(e) => e.key === 'Escape' && setEditingId(null)}
>
<td className="px-2 py-2 align-top">
<input
type="checkbox"
aria-label={`Select beat ${beat.title}`}
checked={selectedIds.includes(beat.id)}
onChange={() => toggleSelected(beat.id)}
onClick={(e) => e.stopPropagation()}
/>
</td>
<td className="px-2 py-2 align-top">
<div className="flex items-center gap-1">
<span className="w-4 text-xs muted">{index + 1}</span>
@@ -419,6 +486,15 @@ function BeatTable({
}
}}
>
<td className="px-2 py-2 align-top">
<input
type="checkbox"
aria-label={`Select beat ${beat.title}`}
checked={selectedIds.includes(beat.id)}
onChange={() => toggleSelected(beat.id)}
onClick={(e) => e.stopPropagation()}
/>
</td>
<td className="px-2 py-2 align-top text-xs muted">{index + 1}</td>
<td className="px-2 py-2 align-top">
@@ -467,6 +543,7 @@ function BeatTable({
onClose={() => setDeletingBeat(null)}
/>
)}
</div>
</div>
)
}