Add dashboard tag cloud and spectrum tag color picker
- Dashboard's Characters column gains a tag cloud below it, sized by usage count, linking into the tags page. - Tags page color editor replaced with 16 preset swatches across the spectrum plus a "Custom..." link to the native hex picker. - Fix: tag color/name updates weren't invalidating the tag-references query, so the reference panel showed a stale color after editing. - Tag cloud/tag list selection now round-trips through a ?tag= query param so clicking a cloud tag selects it on the tags page.
This commit is contained in:
@@ -331,7 +331,10 @@ export function useUpdateTag(projectId: string) {
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...body }: { id: string; name?: string; color?: string }) =>
|
||||
api.patch<TagSummary>(`/api/tags/${id}`, body),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: keys.tags(projectId) }),
|
||||
onSuccess: (_, { id }) => {
|
||||
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
|
||||
qc.invalidateQueries({ queryKey: keys.tagRefs(id) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useRef } from 'react'
|
||||
|
||||
export const TAG_COLOR_SWATCHES = [
|
||||
'#d74242',
|
||||
'#d77a42',
|
||||
'#d7b242',
|
||||
'#c4d742',
|
||||
'#8cd742',
|
||||
'#54d742',
|
||||
'#42d767',
|
||||
'#42d79f',
|
||||
'#42d7d7',
|
||||
'#429fd7',
|
||||
'#4267d7',
|
||||
'#5442d7',
|
||||
'#8c42d7',
|
||||
'#c442d7',
|
||||
'#d742b2',
|
||||
'#d7427a',
|
||||
]
|
||||
|
||||
export function TagColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
readOnly,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (color: string) => void
|
||||
readOnly?: boolean
|
||||
}) {
|
||||
const customInputRef = useRef<HTMLInputElement>(null)
|
||||
const isCustom = !TAG_COLOR_SWATCHES.some((swatch) => swatch.toLowerCase() === value.toLowerCase())
|
||||
|
||||
return (
|
||||
<div id="tag-color-picker" className="grid gap-1.5">
|
||||
<span className="label">Colour</span>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{TAG_COLOR_SWATCHES.map((swatch) => (
|
||||
<button
|
||||
key={swatch}
|
||||
type="button"
|
||||
id={`tag-color-swatch-${swatch.slice(1)}`}
|
||||
aria-label={`Use colour ${swatch}`}
|
||||
aria-pressed={value.toLowerCase() === swatch.toLowerCase()}
|
||||
disabled={readOnly}
|
||||
onClick={() => onChange(swatch)}
|
||||
className="h-6 w-6 rounded-full transition disabled:cursor-not-allowed"
|
||||
style={{
|
||||
background: swatch,
|
||||
outline: value.toLowerCase() === swatch.toLowerCase() ? '2px solid var(--ink)' : '2px solid transparent',
|
||||
outlineOffset: '2px',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
id="tag-color-custom-button"
|
||||
disabled={readOnly}
|
||||
onClick={() => customInputRef.current?.click()}
|
||||
className="flex h-6 items-center gap-1.5 rounded-full px-2 text-xs font-medium transition hover:underline disabled:cursor-not-allowed"
|
||||
style={{ color: 'var(--ink-muted)' }}
|
||||
>
|
||||
<span
|
||||
className="h-4 w-4 shrink-0 rounded-full"
|
||||
style={{
|
||||
background: isCustom ? value : 'conic-gradient(from 0deg, red, yellow, lime, cyan, blue, magenta, red)',
|
||||
outline: isCustom ? '2px solid var(--ink)' : '2px solid transparent',
|
||||
outlineOffset: '2px',
|
||||
}}
|
||||
/>
|
||||
Custom…
|
||||
</button>
|
||||
<input
|
||||
ref={customInputRef}
|
||||
id="tag-color-custom-input"
|
||||
className="sr-only"
|
||||
type="color"
|
||||
value={isCustom ? value : '#9a4a2f'}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { useChapters, useCharacters, useProject, useUpdateProject } from '../api/hooks'
|
||||
import type { Project } from '../api/types'
|
||||
import { useChapters, useCharacters, useProject, useTags, useUpdateProject } from '../api/hooks'
|
||||
import type { Project, TagSummary } from '../api/types'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { AutoField, EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
|
||||
|
||||
@@ -47,6 +47,7 @@ function BrainstormingDashboard({ project }: { project: Project }) {
|
||||
function OutliningDashboard({ projectId }: { projectId: string }) {
|
||||
const { data: characters, isPending: charactersPending, error: charactersError } = useCharacters(projectId)
|
||||
const { data: chapters, isPending: chaptersPending, error: chaptersError } = useChapters(projectId)
|
||||
const { data: tags, isPending: tagsPending, error: tagsError } = useTags(projectId)
|
||||
|
||||
const recentCharacters = [...(characters ?? [])].sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||
@@ -96,43 +97,93 @@ function OutliningDashboard({ projectId }: { projectId: string }) {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="card p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold">Characters</h2>
|
||||
<Link to="characters" className="text-sm muted hover:underline">
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid content-start gap-6">
|
||||
<section className="card p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold">Characters</h2>
|
||||
<Link to="characters" className="text-sm muted hover:underline">
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{charactersError && <ErrorNote error={charactersError} />}
|
||||
{charactersPending ? (
|
||||
<Spinner label="Loading characters" />
|
||||
) : recentCharacters.length === 0 ? (
|
||||
<EmptyState title="No characters yet" hint="Add the protagonist first." />
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 gap-2">
|
||||
{recentCharacters.slice(0, RECENT_COUNT).map((character) => (
|
||||
<li key={character.id}>
|
||||
<Link
|
||||
to="characters"
|
||||
className="card flex items-center justify-between gap-3 px-4 py-2.5 transition hover:shadow-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{character.name}</div>
|
||||
<div className="text-xs muted">
|
||||
{character.role} · {character.importance}
|
||||
{charactersError && <ErrorNote error={charactersError} />}
|
||||
{charactersPending ? (
|
||||
<Spinner label="Loading characters" />
|
||||
) : recentCharacters.length === 0 ? (
|
||||
<EmptyState title="No characters yet" hint="Add the protagonist first." />
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 gap-2">
|
||||
{recentCharacters.slice(0, RECENT_COUNT).map((character) => (
|
||||
<li key={character.id}>
|
||||
<Link
|
||||
to={`characters/${character.id}`}
|
||||
className="card flex items-center justify-between gap-3 px-4 py-2.5 transition hover:shadow-sm"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{character.name}</div>
|
||||
<div className="text-xs muted">
|
||||
{character.role} · {character.importance}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs muted">
|
||||
{new Date(character.updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
<span className="shrink-0 text-xs muted">
|
||||
{new Date(character.updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section id="dashboard-tag-cloud" className="card p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<h2 className="text-lg font-semibold">Tags</h2>
|
||||
<Link to="tags" className="text-sm muted hover:underline">
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{tagsError && <ErrorNote error={tagsError} />}
|
||||
{tagsPending ? (
|
||||
<Spinner label="Loading tags" />
|
||||
) : !tags || tags.length === 0 ? (
|
||||
<EmptyState title="No tags yet" hint="Tag a character, chapter or beat and it shows up here." />
|
||||
) : (
|
||||
<TagCloud projectId={projectId} tags={tags} />
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TagCloud({ projectId, tags }: { projectId: string; tags: TagSummary[] }) {
|
||||
const maxCount = Math.max(...tags.map((t) => t.totalCount), 1)
|
||||
|
||||
const sizeFor = (count: number) => {
|
||||
const scale = Math.sqrt(count / maxCount)
|
||||
return 0.75 + scale * 0.85
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="tag-cloud" className="flex flex-wrap items-baseline gap-x-3 gap-y-2">
|
||||
{[...tags]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((tag) => (
|
||||
<Link
|
||||
key={tag.id}
|
||||
to={`/projects/${projectId}/tags?tag=${tag.id}`}
|
||||
className="leading-none font-medium transition hover:underline"
|
||||
style={{
|
||||
fontSize: `${sizeFor(tag.totalCount)}rem`,
|
||||
color: tag.color ?? 'var(--accent)',
|
||||
}}
|
||||
title={`${tag.name} · ${tag.totalCount}`}
|
||||
>
|
||||
{tag.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||
import { useDeleteTag, useProject, useTagReferences, useTags, useUpdateTag } from '../api/hooks'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
|
||||
import { ConfirmModal } from '../components/ConfirmModal'
|
||||
import { TagChip } from '../components/TagEditor'
|
||||
import { TagColorPicker } from '../components/TagColorPicker'
|
||||
|
||||
export default function TagsPage() {
|
||||
const { projectId = '' } = useParams()
|
||||
@@ -13,13 +14,20 @@ export default function TagsPage() {
|
||||
const { can } = useAuth()
|
||||
const canWrite = can('Write', project)
|
||||
const canDelete = can('DeleteContent', project)
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const selectedId = searchParams.get('tag') ?? undefined
|
||||
|
||||
if (isPending) return <Spinner label="Loading tags" />
|
||||
if (error) return <ErrorNote error={error} />
|
||||
|
||||
const selected = tags?.find((t) => t.id === selectedId) ?? tags?.[0]
|
||||
|
||||
const select = (id: string) =>
|
||||
setSearchParams((params) => {
|
||||
params.set('tag', id)
|
||||
return params
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[18rem_1fr]">
|
||||
<aside className="grid content-start gap-2">
|
||||
@@ -39,7 +47,7 @@ export default function TagsPage() {
|
||||
{tags?.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => setSelectedId(tag.id)}
|
||||
onClick={() => select(tag.id)}
|
||||
className="card flex items-center justify-between gap-2 px-3 py-2 text-left transition hover:shadow-sm"
|
||||
style={
|
||||
tag.id === selected?.id
|
||||
@@ -111,16 +119,11 @@ function TagReferencePanel({
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="label">Colour</span>
|
||||
<input
|
||||
className="input h-9 w-20 p-1"
|
||||
type="color"
|
||||
defaultValue={data.tag.color ?? '#9a4a2f'}
|
||||
readOnly={!canWrite}
|
||||
onBlur={(e) => canWrite && update.mutate({ id: tagId, color: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<TagColorPicker
|
||||
value={data.tag.color ?? '#9a4a2f'}
|
||||
readOnly={!canWrite}
|
||||
onChange={(color) => update.mutate({ id: tagId, color })}
|
||||
/>
|
||||
{canDelete && (
|
||||
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
|
||||
Delete tag
|
||||
|
||||
Reference in New Issue
Block a user