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

@@ -24,17 +24,22 @@ type TimelineItem =
| { type: 'row'; key: string; cells: PhotoCell[]; height: number }
/**
* Build groups by month label when sorted by a date field. For non-temporal
* sorts (filename / file_size / rating) we return a single un-headered group.
* Build the flat header|row item array the virtualizer renders.
*
* Three modes:
* - groupBy='tag': one bucket per unique tag (plus an "Untagged" bucket
* for photos with no tags). A photo with N tags appears in N buckets.
* - groupBy='date' AND sortBy is a date field: month buckets (existing).
* - otherwise: one un-headered stream.
*/
function buildItems(
photos: Photo[],
columns: number,
sortBy: string
sortBy: string,
groupBy: 'date' | 'tag'
): TimelineItem[] {
if (photos.length === 0) return []
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
const items: TimelineItem[] = []
// Helper: split a flat array of cells into rows of `columns` cells.
@@ -50,6 +55,58 @@ function buildItems(
}
}
// ── Tag grouping ──────────────────────────────────────────────────────
if (groupBy === 'tag') {
// Bucket by tag name. A photo with multiple tags lands in multiple
// buckets. Photos with no tags go into "Untagged".
const tagBuckets = new Map<string, PhotoCell[]>()
const untagged: PhotoCell[] = []
photos.forEach((photo, globalIndex) => {
const cell: PhotoCell = { photo, globalIndex }
const tags = photo.tags ?? []
if (tags.length === 0) {
untagged.push(cell)
} else {
for (const t of tags) {
const arr = tagBuckets.get(t.name) ?? []
arr.push(cell)
tagBuckets.set(t.name, arr)
}
}
})
// Sort tag groups alphabetically; Untagged goes at the end.
const sortedTagNames = Array.from(tagBuckets.keys()).sort((a, b) =>
a.localeCompare(b)
)
let bucketIndex = 0
for (const name of sortedTagNames) {
items.push({
type: 'header',
key: `tag::${bucketIndex}::${name}`,
label: name,
height: HEADER_HEIGHT,
})
pushRowsForGroup(`tag::${bucketIndex}::${name}`, tagBuckets.get(name)!)
bucketIndex++
}
if (untagged.length > 0) {
items.push({
type: 'header',
key: `tag::${bucketIndex}::__untagged`,
label: 'Untagged',
height: HEADER_HEIGHT,
})
pushRowsForGroup(`tag::${bucketIndex}::untagged`, untagged)
}
return items
}
// ── Date grouping (existing) ──────────────────────────────────────────
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
if (!isDateSort) {
// No grouping — one row stream.
const cells: PhotoCell[] = photos.map((photo, globalIndex) => ({
@@ -119,6 +176,7 @@ export function Timeline() {
} = usePhotoStore()
const sortBy = useFilterStore((s) => s.sortBy)
const groupBy = useFilterStore((s) => s.groupBy)
// Calculate number of columns based on container width.
const columns = useMemo(() => {
@@ -138,11 +196,12 @@ export function Timeline() {
// subscribing to the same query.
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
// Build the flat virtualizer items: a mix of date-group headers and rows
// of photos. Headers only appear when sorted by a date field.
// Build the flat virtualizer items: a mix of group headers and rows of
// photos. Date headers appear when sorted by a date field; tag headers
// appear when groupBy === 'tag' (overrides date grouping).
const items = useMemo(
() => buildItems(photos, columns, sortBy),
[photos, columns, sortBy]
() => buildItems(photos, columns, sortBy, groupBy),
[photos, columns, sortBy, groupBy]
)
// Pre-computed offset of every header in the virtualizer's coordinate