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({
|
return useMutation({
|
||||||
mutationFn: ({ id, ...body }: { id: string; name?: string; color?: string }) =>
|
mutationFn: ({ id, ...body }: { id: string; name?: string; color?: string }) =>
|
||||||
api.patch<TagSummary>(`/api/tags/${id}`, body),
|
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 { Link, useParams } from 'react-router-dom'
|
||||||
import { useChapters, useCharacters, useProject, useUpdateProject } from '../api/hooks'
|
import { useChapters, useCharacters, useProject, useTags, useUpdateProject } from '../api/hooks'
|
||||||
import type { Project } from '../api/types'
|
import type { Project, TagSummary } from '../api/types'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
import { AutoField, EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
|
import { AutoField, EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
|
||||||
|
|
||||||
@@ -47,6 +47,7 @@ function BrainstormingDashboard({ project }: { project: Project }) {
|
|||||||
function OutliningDashboard({ projectId }: { projectId: string }) {
|
function OutliningDashboard({ projectId }: { projectId: string }) {
|
||||||
const { data: characters, isPending: charactersPending, error: charactersError } = useCharacters(projectId)
|
const { data: characters, isPending: charactersPending, error: charactersError } = useCharacters(projectId)
|
||||||
const { data: chapters, isPending: chaptersPending, error: chaptersError } = useChapters(projectId)
|
const { data: chapters, isPending: chaptersPending, error: chaptersError } = useChapters(projectId)
|
||||||
|
const { data: tags, isPending: tagsPending, error: tagsError } = useTags(projectId)
|
||||||
|
|
||||||
const recentCharacters = [...(characters ?? [])].sort(
|
const recentCharacters = [...(characters ?? [])].sort(
|
||||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
|
||||||
@@ -96,43 +97,93 @@ function OutliningDashboard({ projectId }: { projectId: string }) {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card p-5">
|
<div className="grid content-start gap-6">
|
||||||
<div className="mb-4 flex items-center justify-between gap-4">
|
<section className="card p-5">
|
||||||
<h2 className="text-lg font-semibold">Characters</h2>
|
<div className="mb-4 flex items-center justify-between gap-4">
|
||||||
<Link to="characters" className="text-sm muted hover:underline">
|
<h2 className="text-lg font-semibold">Characters</h2>
|
||||||
View all →
|
<Link to="characters" className="text-sm muted hover:underline">
|
||||||
</Link>
|
View all →
|
||||||
</div>
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
{charactersError && <ErrorNote error={charactersError} />}
|
{charactersError && <ErrorNote error={charactersError} />}
|
||||||
{charactersPending ? (
|
{charactersPending ? (
|
||||||
<Spinner label="Loading characters" />
|
<Spinner label="Loading characters" />
|
||||||
) : recentCharacters.length === 0 ? (
|
) : recentCharacters.length === 0 ? (
|
||||||
<EmptyState title="No characters yet" hint="Add the protagonist first." />
|
<EmptyState title="No characters yet" hint="Add the protagonist first." />
|
||||||
) : (
|
) : (
|
||||||
<ul className="grid grid-cols-1 gap-2">
|
<ul className="grid grid-cols-1 gap-2">
|
||||||
{recentCharacters.slice(0, RECENT_COUNT).map((character) => (
|
{recentCharacters.slice(0, RECENT_COUNT).map((character) => (
|
||||||
<li key={character.id}>
|
<li key={character.id}>
|
||||||
<Link
|
<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"
|
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="min-w-0">
|
||||||
<div className="truncate font-medium">{character.name}</div>
|
<div className="truncate font-medium">{character.name}</div>
|
||||||
<div className="text-xs muted">
|
<div className="text-xs muted">
|
||||||
{character.role} · {character.importance}
|
{character.role} · {character.importance}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<span className="shrink-0 text-xs muted">
|
||||||
<span className="shrink-0 text-xs muted">
|
{new Date(character.updatedAt).toLocaleDateString()}
|
||||||
{new Date(character.updatedAt).toLocaleDateString()}
|
</span>
|
||||||
</span>
|
</Link>
|
||||||
</Link>
|
</li>
|
||||||
</li>
|
))}
|
||||||
))}
|
</ul>
|
||||||
</ul>
|
)}
|
||||||
)}
|
</section>
|
||||||
</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>
|
||||||
</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 { 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 { useDeleteTag, useProject, useTagReferences, useTags, useUpdateTag } from '../api/hooks'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
|
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
|
||||||
import { ConfirmModal } from '../components/ConfirmModal'
|
import { ConfirmModal } from '../components/ConfirmModal'
|
||||||
import { TagChip } from '../components/TagEditor'
|
import { TagChip } from '../components/TagEditor'
|
||||||
|
import { TagColorPicker } from '../components/TagColorPicker'
|
||||||
|
|
||||||
export default function TagsPage() {
|
export default function TagsPage() {
|
||||||
const { projectId = '' } = useParams()
|
const { projectId = '' } = useParams()
|
||||||
@@ -13,13 +14,20 @@ export default function TagsPage() {
|
|||||||
const { can } = useAuth()
|
const { can } = useAuth()
|
||||||
const canWrite = can('Write', project)
|
const canWrite = can('Write', project)
|
||||||
const canDelete = can('DeleteContent', 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 (isPending) return <Spinner label="Loading tags" />
|
||||||
if (error) return <ErrorNote error={error} />
|
if (error) return <ErrorNote error={error} />
|
||||||
|
|
||||||
const selected = tags?.find((t) => t.id === selectedId) ?? tags?.[0]
|
const selected = tags?.find((t) => t.id === selectedId) ?? tags?.[0]
|
||||||
|
|
||||||
|
const select = (id: string) =>
|
||||||
|
setSearchParams((params) => {
|
||||||
|
params.set('tag', id)
|
||||||
|
return params
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6 lg:grid-cols-[18rem_1fr]">
|
<div className="grid gap-6 lg:grid-cols-[18rem_1fr]">
|
||||||
<aside className="grid content-start gap-2">
|
<aside className="grid content-start gap-2">
|
||||||
@@ -39,7 +47,7 @@ export default function TagsPage() {
|
|||||||
{tags?.map((tag) => (
|
{tags?.map((tag) => (
|
||||||
<button
|
<button
|
||||||
key={tag.id}
|
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"
|
className="card flex items-center justify-between gap-2 px-3 py-2 text-left transition hover:shadow-sm"
|
||||||
style={
|
style={
|
||||||
tag.id === selected?.id
|
tag.id === selected?.id
|
||||||
@@ -111,16 +119,11 @@ function TagReferencePanel({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="block">
|
<TagColorPicker
|
||||||
<span className="label">Colour</span>
|
value={data.tag.color ?? '#9a4a2f'}
|
||||||
<input
|
readOnly={!canWrite}
|
||||||
className="input h-9 w-20 p-1"
|
onChange={(color) => update.mutate({ id: tagId, color })}
|
||||||
type="color"
|
/>
|
||||||
defaultValue={data.tag.color ?? '#9a4a2f'}
|
|
||||||
readOnly={!canWrite}
|
|
||||||
onBlur={(e) => canWrite && update.mutate({ id: tagId, color: e.target.value })}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
|
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
|
||||||
Delete tag
|
Delete tag
|
||||||
|
|||||||
Reference in New Issue
Block a user