feat: Tags entry in the Library sidebar

Adds an expandable Tags node alongside All Photos / Rated /
Duplicates / Discarded. The children are populated dynamically from
useTagsQuery — one row per tag, showing the tag name and its photo
count badge. Click a tag row to filter the timeline to just that
tag (single-tag), with the active highlight following the filter
store.

Multi-tag filtering still lives in the FilterBar; the sidebar entry
is the quick "show me everything in this tag" affordance.

Implementation
- New 'tags' library tree node with children: allTags.map(...)
- 'tag-{id}' click handler in applyLibraryNode → clearAll() +
  setTagIds([id])
- isItemActive recognises a tag row as selected only when the
  filter store has exactly that single tag id, so combining it with
  multi-tag filter mode in the FilterBar doesn't leave a stale
  highlight.
- Tags section is collapsed by default like other library nodes; no
  effect when there are no tags yet (children list is empty).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 11:39:33 +02:00
parent 914eb58ac5
commit 2d37fba211

View File

@@ -10,6 +10,7 @@ import {
HardDrive,
RefreshCw,
Copy,
Tag as TagIcon,
} from 'lucide-react'
import clsx from 'clsx'
import { sourceFolders, library, photos as photosApi, type FolderTreeNode } from '../../services/api'
@@ -19,6 +20,7 @@ import { useFilterStore } from '../../store/filterStore'
import { HeapsPanel } from '../heaps/HeapsPanel'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
import { useTagsQuery } from '../../hooks/useTagsQuery'
interface TreeItem {
id: string
@@ -44,8 +46,11 @@ export function LeftSidebar() {
const setFlag = useFilterStore((s) => s.setFlag)
const setFolderId = useFilterStore((s) => s.setFolderId)
const setDuplicates = useFilterStore((s) => s.setDuplicates)
const setTagIds = useFilterStore((s) => s.setTagIds)
const filterFolderId = useFilterStore((s) => s.folderId)
const filterDuplicates = useFilterStore((s) => s.duplicates)
const filterTagIds = useFilterStore((s) => s.tagIds)
const { data: allTags = [] } = useTagsQuery()
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
// Bulk discard mutation for the drag-onto-Discarded interaction.
@@ -138,6 +143,14 @@ export function LeftSidebar() {
setDuplicates(true)
break
default:
if (id.startsWith('tag-')) {
// Tag rows: filter to that single tag. Multi-tag filtering is
// available via the FilterBar.
const tagId = id.slice('tag-'.length)
clearAllFilters()
setTagIds([tagId])
return
}
if (id.startsWith('folder-')) {
// Folder rows: filter to that folder, clear other filters that
// would compete (heap, discarded, etc.) so the user sees what they
@@ -219,6 +232,17 @@ export function LeftSidebar() {
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: 0 },
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
{
id: 'tags',
label: 'Tags',
icon: <TagIcon className="h-4 w-4" />,
children: allTags.map((tag) => ({
id: `tag-${tag.id}`,
label: tag.name,
icon: <TagIcon className="h-4 w-4" />,
count: tag.photo_count,
})),
},
],
},
{
@@ -237,6 +261,10 @@ export function LeftSidebar() {
if (id.startsWith('folder-')) {
return filterFolderId === id.slice('folder-'.length)
}
if (id.startsWith('tag-')) {
const tagId = id.slice('tag-'.length)
return filterTagIds.length === 1 && filterTagIds[0] === tagId
}
if (id === 'all-photos') {
return filterFolderId === null && selectedItem === 'all-photos'
}