Add genre feature; extract ConfirmModal for reusable confirm dialogs
Genres seed on boot, are editable per project, and gate agent config errors more gracefully. ConfirmModal replaces ad-hoc confirm prompts across chapters, tags, and characters pages.
This commit is contained in:
@@ -10,6 +10,7 @@ import type {
|
||||
Conversation,
|
||||
ConversationSummary,
|
||||
Beat,
|
||||
Genre,
|
||||
ImportInspection,
|
||||
ImportJob,
|
||||
ImportJobStatus,
|
||||
@@ -22,6 +23,7 @@ import type {
|
||||
|
||||
export const keys = {
|
||||
projects: ['projects'] as const,
|
||||
genres: ['genres'] as const,
|
||||
project: (id: string) => ['projects', id] as const,
|
||||
characters: (projectId: string) => ['projects', projectId, 'characters'] as const,
|
||||
tags: (projectId: string) => ['projects', projectId, 'tags'] as const,
|
||||
@@ -213,6 +215,9 @@ export function useDeleteQuestion(projectId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export const useGenres = () =>
|
||||
useQuery({ queryKey: keys.genres, queryFn: () => api.get<Genre[]>('/api/genres') })
|
||||
|
||||
export const useTags = (projectId: string) =>
|
||||
useQuery({
|
||||
queryKey: keys.tags(projectId),
|
||||
|
||||
@@ -32,6 +32,11 @@ export type ProjectPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing'
|
||||
|
||||
export const projectPhases: ProjectPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete']
|
||||
|
||||
export interface Genre {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ProjectSummary {
|
||||
id: string
|
||||
title: string
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Modal } from './ui'
|
||||
|
||||
export function ConfirmModal({
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Delete',
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
title: string
|
||||
message: ReactNode
|
||||
confirmLabel?: string
|
||||
onConfirm: () => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<Modal title={title} onClose={onClose}>
|
||||
<p className="text-sm">{message}</p>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button className="btn" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => {
|
||||
onConfirm()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import { useEffect, useId, useRef, useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import type { DraftStatus } from '../api/types'
|
||||
|
||||
export function Spinner({ label = 'Loading' }: { label?: string }) {
|
||||
@@ -67,6 +67,7 @@ export function AutoField({
|
||||
rows = 3,
|
||||
placeholder,
|
||||
serif,
|
||||
suggestions,
|
||||
onContextMenu,
|
||||
}: {
|
||||
label?: string
|
||||
@@ -76,10 +77,12 @@ export function AutoField({
|
||||
rows?: number
|
||||
placeholder?: string
|
||||
serif?: boolean
|
||||
suggestions?: readonly string[]
|
||||
onContextMenu?: (e: MouseEvent<HTMLTextAreaElement>) => void
|
||||
}) {
|
||||
const [draft, setDraft] = useState(value ?? '')
|
||||
const committed = useRef(value ?? '')
|
||||
const suggestionsId = useId()
|
||||
|
||||
// Adopt changes that arrive from elsewhere (the agent, another tab) unless the user
|
||||
// is mid-edit, which would yank text out from under them.
|
||||
@@ -114,14 +117,24 @@ export function AutoField({
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className={className}
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
|
||||
/>
|
||||
<>
|
||||
<input
|
||||
className={className}
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
list={suggestions?.length ? suggestionsId : undefined}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
|
||||
/>
|
||||
{suggestions?.length ? (
|
||||
<datalist id={suggestionsId}>
|
||||
{suggestions.map((suggestion) => (
|
||||
<option key={suggestion} value={suggestion} />
|
||||
))}
|
||||
</datalist>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
)
|
||||
@@ -152,6 +165,9 @@ export function Select<T extends string>({
|
||||
)
|
||||
}
|
||||
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
|
||||
|
||||
export function Modal({
|
||||
title,
|
||||
onClose,
|
||||
@@ -161,8 +177,33 @@ export function Modal({
|
||||
onClose: () => void
|
||||
children: ReactNode
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose()
|
||||
const triggerElement = document.activeElement as HTMLElement | null
|
||||
containerRef.current?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)?.focus()
|
||||
return () => triggerElement?.focus()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab' || !containerRef.current) return
|
||||
const focusable = containerRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
|
||||
if (focusable.length === 0) return
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
@@ -173,6 +214,7 @@ export function Modal({
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="card mt-12 w-full max-w-lg p-5 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
|
||||
@@ -102,6 +102,15 @@ body {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full rounded-md px-2.5 py-1.5 text-sm outline-none transition;
|
||||
background: var(--surface);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../api/hooks'
|
||||
import { draftStatuses, type Beat, type Chapter } from '../api/types'
|
||||
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
import { TagChip, TagEditor } from '../components/TagEditor'
|
||||
import { CharacterChip, CharacterMultiSelect } from '../components/CharacterMultiSelect'
|
||||
import { useCharacterContextMenu } from '../components/CharacterContextMenu'
|
||||
@@ -32,6 +33,7 @@ export default function ChapterPage() {
|
||||
const remove = useDeleteChapter(projectId)
|
||||
const createBeat = useCreateBeat(chapterId, projectId)
|
||||
const [tab, setTab] = useState<ChapterTab>('outline')
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const { handleContextMenu, menuElement } = useCharacterContextMenu(projectId)
|
||||
|
||||
useHotkey('b', 'Add beat', () => createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' })
|
||||
@@ -86,14 +88,14 @@ export default function ChapterPage() {
|
||||
<span className="label">POV character</span>
|
||||
<select
|
||||
className="input"
|
||||
value={chapter.povCharacterName ?? '—'}
|
||||
onChange={(e) => {
|
||||
const match = characters?.find((c) => c.name === e.target.value)
|
||||
patch({ povCharacterId: match?.id ?? null })
|
||||
}}
|
||||
value={chapter.povCharacterId ?? ''}
|
||||
onChange={(e) => patch({ povCharacterId: e.target.value || null })}
|
||||
>
|
||||
{['—', ...(characters?.map((c) => c.name) ?? [])].map((name) => (
|
||||
<option key={name}>{name}</option>
|
||||
<option value="">—</option>
|
||||
{characters?.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
@@ -117,22 +119,25 @@ export default function ChapterPage() {
|
||||
<div className="text-sm muted">
|
||||
{chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words
|
||||
</div>
|
||||
<button
|
||||
className="btn"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete chapter “${chapter.title}” and everything in it?`)) {
|
||||
remove.mutate(chapter.id, {
|
||||
onSuccess: () => navigate(`/projects/${projectId}/chapters`),
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete chapter
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{confirmingDelete && (
|
||||
<ConfirmModal
|
||||
title="Delete chapter"
|
||||
message={`Delete chapter "${chapter.title}" and everything in it? This cannot be undone.`}
|
||||
onConfirm={() =>
|
||||
remove.mutate(chapter.id, {
|
||||
onSuccess: () => navigate(`/projects/${projectId}/chapters`),
|
||||
})
|
||||
}
|
||||
onClose={() => setConfirmingDelete(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mb-5 flex gap-1" style={{ borderBottom: '1px solid var(--line)' }}>
|
||||
{(
|
||||
[
|
||||
@@ -246,6 +251,7 @@ function BeatTable({
|
||||
const remove = useDeleteBeat(chapter.id)
|
||||
const reorder = useReorderBeats(chapter.id)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [deletingBeat, setDeletingBeat] = useState<Beat | null>(null)
|
||||
|
||||
if (chapter.beats.length === 0) {
|
||||
return (
|
||||
@@ -388,7 +394,7 @@ function BeatTable({
|
||||
<button
|
||||
className="text-xs muted leading-none transition hover:opacity-100"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => confirm(`Delete beat “${beat.title}”?`) && remove.mutate(beat.id)}
|
||||
onClick={() => setDeletingBeat(beat)}
|
||||
aria-label={`Delete beat ${beat.title}`}
|
||||
>
|
||||
✕
|
||||
@@ -400,9 +406,18 @@ function BeatTable({
|
||||
<tr
|
||||
key={beat.id}
|
||||
id={`beat-${beat.id}`}
|
||||
className="cursor-pointer transition hover:brightness-110"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`Edit beat ${beat.title}`}
|
||||
className="cursor-pointer transition hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[var(--accent)]"
|
||||
style={{ borderBottom: '1px solid var(--line)' }}
|
||||
onClick={() => setEditingId(beat.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setEditingId(beat.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td className="px-2 py-2 align-top text-xs muted">{index + 1}</td>
|
||||
|
||||
@@ -443,6 +458,15 @@ function BeatTable({
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{deletingBeat && (
|
||||
<ConfirmModal
|
||||
title="Delete beat"
|
||||
message={`Delete beat "${deletingBeat.title}"?`}
|
||||
onConfirm={() => remove.mutate(deletingBeat.id)}
|
||||
onClose={() => setDeletingBeat(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,12 +16,6 @@ export default function ChaptersPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<Link to={`/projects/${projectId}`} className="text-sm muted hover:underline">
|
||||
← Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 flex items-center justify-between gap-4">
|
||||
<h2 className="text-xl font-semibold">Chapters</h2>
|
||||
<button
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '../api/hooks'
|
||||
import { characterImportances, characterRoles, type Character } from '../api/types'
|
||||
import { AutoField, EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
import { TagEditor } from '../components/TagEditor'
|
||||
import { CharacterArc } from '../components/CharacterArc'
|
||||
import { CharacterBeats } from '../components/CharacterBeats'
|
||||
@@ -87,6 +88,7 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
|
||||
const { data: allTags } = useTags(projectId)
|
||||
const update = useUpdateCharacter(projectId)
|
||||
const remove = useDeleteCharacter(projectId)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const patch = (body: Partial<Omit<Character, 'tags'>> & { tags?: string[] }) =>
|
||||
update.mutate({ id: character.id, ...body })
|
||||
|
||||
@@ -113,13 +115,7 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
|
||||
onChange={(importance) => patch({ importance })}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn mt-6"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete ${character.name}?`)) remove.mutate(character.id)
|
||||
}}
|
||||
>
|
||||
<button className="btn btn-danger mt-6" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
@@ -256,6 +252,15 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
|
||||
/>
|
||||
|
||||
<OpenQuestions projectId={projectId} scope={{ characterId: character.id }} />
|
||||
|
||||
{confirmingDelete && (
|
||||
<ConfirmModal
|
||||
title="Delete character"
|
||||
message={`Delete ${character.name}? This cannot be undone.`}
|
||||
onConfirm={() => remove.mutate(character.id)}
|
||||
onClose={() => setConfirmingDelete(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,18 +56,6 @@ function OutliningDashboard({ projectId }: { projectId: string }) {
|
||||
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<nav className="flex flex-wrap gap-4 text-sm">
|
||||
<Link to="tags" className="muted hover:underline">
|
||||
Tags
|
||||
</Link>
|
||||
<Link to="agent" className="muted hover:underline">
|
||||
Agent
|
||||
</Link>
|
||||
<Link to="settings" className="muted hover:underline">
|
||||
Settings
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<section className="card p-5 lg:col-span-2">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { Outlet, useParams, Link, useNavigate } from 'react-router-dom'
|
||||
import { Outlet, useParams, Link, NavLink, useNavigate } from 'react-router-dom'
|
||||
import { useProject, useUpdateProject } from '../api/hooks'
|
||||
import { projectPhases } from '../api/types'
|
||||
import { ErrorNote, Spinner } from '../components/ui'
|
||||
import { useHotkey } from '../keyboard/HotkeysContext'
|
||||
|
||||
const sections: { to: string; label: string; end?: boolean }[] = [
|
||||
{ to: '', label: 'Dashboard', end: true },
|
||||
{ to: 'chapters', label: 'Chapters' },
|
||||
{ to: 'characters', label: 'Characters' },
|
||||
{ to: 'tags', label: 'Tags' },
|
||||
{ to: 'agent', label: 'Agent' },
|
||||
{ to: 'settings', label: 'Settings' },
|
||||
]
|
||||
|
||||
export default function ProjectLayout() {
|
||||
const { projectId = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
@@ -44,6 +53,23 @@ export default function ProjectLayout() {
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<nav className="mx-auto flex max-w-[100rem] gap-1 px-6 pb-2 text-sm">
|
||||
{sections.map(({ to, label, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
`rounded-md px-3 py-1.5 font-medium transition ${isActive ? '' : 'muted hover:opacity-100'}`
|
||||
}
|
||||
style={({ isActive }) =>
|
||||
isActive ? { background: 'var(--accent-soft)', color: 'var(--accent)' } : undefined
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-[100rem] px-6 py-8">
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useChapters, useCharacters, useDeleteProject, useProject, useUpdateProject } from '../api/hooks'
|
||||
import {
|
||||
useChapters,
|
||||
useCharacters,
|
||||
useDeleteProject,
|
||||
useGenres,
|
||||
useProject,
|
||||
useUpdateProject,
|
||||
} from '../api/hooks'
|
||||
import { ImportDialog } from '../components/ImportDialog'
|
||||
import { AutoField, ErrorNote, Spinner } from '../components/ui'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { projectId = '' } = useParams()
|
||||
@@ -10,9 +18,11 @@ export default function SettingsPage() {
|
||||
const { data: project, isPending } = useProject(projectId)
|
||||
const { data: characters } = useCharacters(projectId)
|
||||
const { data: chapters } = useChapters(projectId)
|
||||
const { data: genres } = useGenres()
|
||||
const update = useUpdateProject(projectId)
|
||||
const remove = useDeleteProject()
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
|
||||
if (isPending || !project) return <Spinner label="Loading brief" />
|
||||
|
||||
@@ -36,7 +46,13 @@ export default function SettingsPage() {
|
||||
value={project.author}
|
||||
onCommit={(author) => update.mutate({ author })}
|
||||
/>
|
||||
<AutoField label="Genre" value={project.genre} onCommit={(genre) => update.mutate({ genre })} />
|
||||
<AutoField
|
||||
label="Genre"
|
||||
value={project.genre}
|
||||
placeholder="Pick one, or name your own."
|
||||
suggestions={genres?.map((g) => g.name)}
|
||||
onCommit={(genre) => update.mutate({ genre })}
|
||||
/>
|
||||
</div>
|
||||
<AutoField
|
||||
label="Logline"
|
||||
@@ -134,15 +150,7 @@ export default function SettingsPage() {
|
||||
<p className="mb-3 text-sm muted">
|
||||
Deleting a novel removes its outline, characters, chapters and conversations.
|
||||
</p>
|
||||
<button
|
||||
className="btn w-full"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => {
|
||||
if (confirm(`Delete "${project.title}" and everything in it? This cannot be undone.`)) {
|
||||
remove.mutate(projectId, { onSuccess: () => navigate('/') })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button className="btn btn-danger w-full" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete this novel
|
||||
</button>
|
||||
</div>
|
||||
@@ -154,6 +162,15 @@ export default function SettingsPage() {
|
||||
onImported={(newProjectId) => navigate(`/projects/${newProjectId}`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmingDelete && (
|
||||
<ConfirmModal
|
||||
title="Delete novel"
|
||||
message={`Delete "${project.title}" and everything in it? This cannot be undone.`}
|
||||
onConfirm={() => remove.mutate(projectId, { onSuccess: () => navigate('/') })}
|
||||
onClose={() => setConfirmingDelete(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useDeleteTag, useTagReferences, useTags, useUpdateTag } from '../api/hooks'
|
||||
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
import { TagChip } from '../components/TagEditor'
|
||||
|
||||
export default function TagsPage() {
|
||||
@@ -65,6 +66,7 @@ function TagReferencePanel({ projectId, tagId }: { projectId: string; tagId: str
|
||||
const { data, isPending, error } = useTagReferences(tagId)
|
||||
const update = useUpdateTag(projectId)
|
||||
const remove = useDeleteTag()
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
|
||||
if (isPending) return <Spinner label="Loading references" />
|
||||
if (error) return <ErrorNote error={error} />
|
||||
@@ -96,20 +98,22 @@ function TagReferencePanel({ projectId, tagId }: { projectId: string; tagId: str
|
||||
onBlur={(e) => update.mutate({ id: tagId, color: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="btn"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() =>
|
||||
confirm(`Delete the tag “${data.tag.name}”? What carries it is left alone.`) &&
|
||||
remove.mutate(tagId)
|
||||
}
|
||||
>
|
||||
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete tag
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{update.error && <ErrorNote error={update.error} />}
|
||||
|
||||
{confirmingDelete && (
|
||||
<ConfirmModal
|
||||
title="Delete tag"
|
||||
message={`Delete the tag "${data.tag.name}"? What carries it is left alone.`}
|
||||
onConfirm={() => remove.mutate(tagId)}
|
||||
onClose={() => setConfirmingDelete(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{empty && (
|
||||
<EmptyState
|
||||
title="Nothing carries this tag"
|
||||
|
||||
Reference in New Issue
Block a user