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

@@ -10,6 +10,7 @@ export type SortField =
| 'file_size'
| 'rating'
export type SortOrder = 'asc' | 'desc'
export type GroupBy = 'date' | 'tag'
export interface FilterState {
q: string
@@ -28,6 +29,9 @@ export interface FilterState {
tagIds: string[]
/** When true, restrict to photos flagged as duplicates by the scanner. */
duplicates: boolean
/** Visual grouping mode. 'date' groups by month when sortBy is a date
* field; 'tag' groups by photo tag membership. Independent of filters. */
groupBy: GroupBy
sortBy: SortField
sortOrder: SortOrder
}
@@ -47,6 +51,7 @@ interface FilterStore extends FilterState {
setTagIds: (ids: string[]) => void
toggleTagId: (id: string) => void
setDuplicates: (v: boolean) => void
setGroupBy: (mode: GroupBy) => void
setSortBy: (field: SortField) => void
setSortOrder: (order: SortOrder) => void
toggleSortOrder: () => void
@@ -70,6 +75,7 @@ export const INITIAL_FILTERS: FilterState = {
folderId: null,
tagIds: [],
duplicates: false,
groupBy: 'date',
sortBy: 'taken_at',
sortOrder: 'desc',
}
@@ -100,6 +106,7 @@ export const useFilterStore = create<FilterStore>((set) => ({
: [...s.tagIds, id],
})),
setDuplicates: (duplicates) => set({ duplicates }),
setGroupBy: (groupBy) => set({ groupBy }),
setSortBy: (sortBy) => set({ sortBy }),
setSortOrder: (sortOrder) => set({ sortOrder }),
toggleSortOrder: () =>