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:
James Wampler
2026-08-17 17:52:43 -07:00
parent 92fe4317e2
commit cb66ef7343
4 changed files with 193 additions and 50 deletions
+4 -1
View File
@@ -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>
)
}
+54 -3
View File
@@ -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,6 +97,7 @@ function OutliningDashboard({ projectId }: { projectId: string }) {
)}
</section>
<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>
@@ -114,7 +116,7 @@ function OutliningDashboard({ projectId }: { projectId: string }) {
{recentCharacters.slice(0, RECENT_COUNT).map((character) => (
<li key={character.id}>
<Link
to="characters"
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">
@@ -132,7 +134,56 @@ function OutliningDashboard({ projectId }: { projectId: string }) {
</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>
)
}
+14 -11
View File
@@ -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'}
<TagColorPicker
value={data.tag.color ?? '#9a4a2f'}
readOnly={!canWrite}
onBlur={(e) => canWrite && update.mutate({ id: tagId, color: e.target.value })}
onChange={(color) => update.mutate({ id: tagId, color })}
/>
</label>
{canDelete && (
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
Delete tag