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