diff --git a/src/Novelly.Web/src/api/hooks.ts b/src/Novelly.Web/src/api/hooks.ts index 3377d78..8251ed2 100644 --- a/src/Novelly.Web/src/api/hooks.ts +++ b/src/Novelly.Web/src/api/hooks.ts @@ -331,7 +331,10 @@ export function useUpdateTag(projectId: string) { return useMutation({ mutationFn: ({ id, ...body }: { id: string; name?: string; color?: string }) => api.patch(`/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) }) + }, }) } diff --git a/src/Novelly.Web/src/components/TagColorPicker.tsx b/src/Novelly.Web/src/components/TagColorPicker.tsx new file mode 100644 index 0000000..4ea922c --- /dev/null +++ b/src/Novelly.Web/src/components/TagColorPicker.tsx @@ -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(null) + const isCustom = !TAG_COLOR_SWATCHES.some((swatch) => swatch.toLowerCase() === value.toLowerCase()) + + return ( +
+ Colour +
+ {TAG_COLOR_SWATCHES.map((swatch) => ( + + onChange(e.target.value)} + /> +
+
+ ) +} diff --git a/src/Novelly.Web/src/pages/DashboardPage.tsx b/src/Novelly.Web/src/pages/DashboardPage.tsx index 053d75b..8819a3d 100644 --- a/src/Novelly.Web/src/pages/DashboardPage.tsx +++ b/src/Novelly.Web/src/pages/DashboardPage.tsx @@ -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 }) { )} -
-
-

Characters

- - View all → - -
+
+
+
+

Characters

+ + View all → + +
- {charactersError && } - {charactersPending ? ( - - ) : recentCharacters.length === 0 ? ( - - ) : ( -
    - {recentCharacters.slice(0, RECENT_COUNT).map((character) => ( -
  • - -
    -
    {character.name}
    -
    - {character.role} · {character.importance} + {charactersError && } + {charactersPending ? ( + + ) : recentCharacters.length === 0 ? ( + + ) : ( +
      + {recentCharacters.slice(0, RECENT_COUNT).map((character) => ( +
    • + +
      +
      {character.name}
      +
      + {character.role} · {character.importance} +
      -
    - - {new Date(character.updatedAt).toLocaleDateString()} - - -
  • - ))} -
- )} -
+ + {new Date(character.updatedAt).toLocaleDateString()} + + + + ))} + + )} +
+ +
+
+

Tags

+ + View all → + +
+ + {tagsError && } + {tagsPending ? ( + + ) : !tags || tags.length === 0 ? ( + + ) : ( + + )} +
+ ) } + +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 ( +
+ {[...tags] + .sort((a, b) => a.name.localeCompare(b.name)) + .map((tag) => ( + + {tag.name} + + ))} +
+ ) +} diff --git a/src/Novelly.Web/src/pages/TagsPage.tsx b/src/Novelly.Web/src/pages/TagsPage.tsx index 19396f1..d728383 100644 --- a/src/Novelly.Web/src/pages/TagsPage.tsx +++ b/src/Novelly.Web/src/pages/TagsPage.tsx @@ -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() + const [searchParams, setSearchParams] = useSearchParams() + const selectedId = searchParams.get('tag') ?? undefined if (isPending) return if (error) return const selected = tags?.find((t) => t.id === selectedId) ?? tags?.[0] + const select = (id: string) => + setSearchParams((params) => { + params.set('tag', id) + return params + }) + return (