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:
@@ -10,6 +10,7 @@ import {
|
|||||||
HardDrive,
|
HardDrive,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Copy,
|
Copy,
|
||||||
|
Tag as TagIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { sourceFolders, library, photos as photosApi, type FolderTreeNode } from '../../services/api'
|
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 { HeapsPanel } from '../heaps/HeapsPanel'
|
||||||
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
||||||
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
|
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
|
||||||
|
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||||
|
|
||||||
interface TreeItem {
|
interface TreeItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -44,8 +46,11 @@ export function LeftSidebar() {
|
|||||||
const setFlag = useFilterStore((s) => s.setFlag)
|
const setFlag = useFilterStore((s) => s.setFlag)
|
||||||
const setFolderId = useFilterStore((s) => s.setFolderId)
|
const setFolderId = useFilterStore((s) => s.setFolderId)
|
||||||
const setDuplicates = useFilterStore((s) => s.setDuplicates)
|
const setDuplicates = useFilterStore((s) => s.setDuplicates)
|
||||||
|
const setTagIds = useFilterStore((s) => s.setTagIds)
|
||||||
const filterFolderId = useFilterStore((s) => s.folderId)
|
const filterFolderId = useFilterStore((s) => s.folderId)
|
||||||
const filterDuplicates = useFilterStore((s) => s.duplicates)
|
const filterDuplicates = useFilterStore((s) => s.duplicates)
|
||||||
|
const filterTagIds = useFilterStore((s) => s.tagIds)
|
||||||
|
const { data: allTags = [] } = useTagsQuery()
|
||||||
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
||||||
|
|
||||||
// Bulk discard mutation for the drag-onto-Discarded interaction.
|
// Bulk discard mutation for the drag-onto-Discarded interaction.
|
||||||
@@ -138,6 +143,14 @@ export function LeftSidebar() {
|
|||||||
setDuplicates(true)
|
setDuplicates(true)
|
||||||
break
|
break
|
||||||
default:
|
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-')) {
|
if (id.startsWith('folder-')) {
|
||||||
// Folder rows: filter to that folder, clear other filters that
|
// Folder rows: filter to that folder, clear other filters that
|
||||||
// would compete (heap, discarded, etc.) so the user sees what they
|
// 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: '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: '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: '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-')) {
|
if (id.startsWith('folder-')) {
|
||||||
return filterFolderId === id.slice('folder-'.length)
|
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') {
|
if (id === 'all-photos') {
|
||||||
return filterFolderId === null && selectedItem === 'all-photos'
|
return filterFolderId === null && selectedItem === 'all-photos'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user