feat: tag-grouped timeline view

Reworks the Tags sidebar entry from an expandable list of tags into a
single leaf entry. Clicking it switches the timeline grouping mode to
"tag" — every tag becomes a sticky-headered group, with an "Untagged"
group at the bottom for photos with no tags. A photo with N tags
appears in N groups. Existing filters and sort still apply within each
group.

Backend
- list_photos eagerly loads Photo.tags via selectinload to avoid an
  N+1 round-trip.
- Each photo in the list response now carries a `tags: [{id, name,
  color}]` array. The route stops using PhotoListResponse strict
  validation (returns a plain dict with the same shape plus the new
  field) so we don't have to extend the pydantic schema.

Frontend
- Photo TS type gains an optional tags field plus a PhotoTagSummary
  alias.
- filterStore: new groupBy: 'date' | 'tag' field, default 'date',
  with setGroupBy + URL sync via ?group=tag. clearAll resets it.
- usePhotosQuery threads groupBy through filtersToParams (it's
  client-side only but kept in the params for cache key
  consistency).
- LeftSidebar Tags entry is now a leaf node (no children), shows the
  total tag photo count as the badge, and is highlighted when
  groupBy === 'tag'. Click → setGroupBy('tag') without touching
  other filters. Selecting "All Photos" resets groupBy back to
  'date' via clearAll.
- Timeline.buildItems gets a third "tag" branch:
  - Iterates photos × tags into per-tag buckets
  - Photos with no tags go into an "Untagged" bucket
  - Tag groups sorted alphabetically; Untagged pinned to the end
  - Headers + rows pushed in the same shape the date branch uses,
    so the existing sticky-header overlay works for free
- Selection state is by photo id, so a photo appearing in multiple
  groups stays consistently selected/highlighted across instances.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 11:51:21 +02:00
parent 2d37fba211
commit 6985026106
7 changed files with 124 additions and 44 deletions

View File

@@ -46,10 +46,10 @@ 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 setGroupBy = useFilterStore((s) => s.setGroupBy)
const filterFolderId = useFilterStore((s) => s.folderId)
const filterDuplicates = useFilterStore((s) => s.duplicates)
const filterTagIds = useFilterStore((s) => s.tagIds)
const filterGroupBy = useFilterStore((s) => s.groupBy)
const { data: allTags = [] } = useTagsQuery()
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
@@ -142,15 +142,12 @@ export function LeftSidebar() {
clearAllFilters()
setDuplicates(true)
break
case 'tags':
// Tags is a leaf entry, not expandable. Clicking switches the
// timeline to grouped-by-tag mode without touching other filters.
setGroupBy('tag')
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
@@ -222,6 +219,9 @@ export function LeftSidebar() {
: undefined,
})
// Total tag count for the badge on the Tags entry.
const tagsTotalCount = allTags.reduce((sum, t) => sum + (t.photo_count || 0), 0)
const libraryTree: TreeItem[] = [
{
id: 'library',
@@ -230,19 +230,9 @@ export function LeftSidebar() {
children: [
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
{ id: 'tags', label: 'Tags', icon: <TagIcon className="h-4 w-4" />, count: tagsTotalCount },
{ 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,
})),
},
],
},
{
@@ -261,9 +251,8 @@ 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 === 'tags') {
return filterGroupBy === 'tag'
}
if (id === 'all-photos') {
return filterFolderId === null && selectedItem === 'all-photos'