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

@@ -9,6 +9,7 @@ from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import select, and_, or_, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
import json
import os
import logging
@@ -25,7 +26,7 @@ from app.config import settings
router = APIRouter()
@router.get("", response_model=PhotoListResponse)
@router.get("")
async def list_photos(
q: Optional[str] = None,
date_from: Optional[datetime] = None,
@@ -47,8 +48,9 @@ async def list_photos(
):
"""List photos with filters and pagination"""
# Build query
query = select(Photo)
# Build query — eager-load tags so the response can include them
# without an N+1 round-trip per photo.
query = select(Photo).options(selectinload(Photo.tags))
# Apply filters
filters = []
@@ -180,14 +182,24 @@ async def list_photos(
result = await db.execute(query)
photos = result.scalars().all()
# Convert to response
return PhotoListResponse(
photos=[PhotoResponse.from_orm(photo) for photo in photos],
total=total,
page=page,
per_page=per_page,
pages=(total + per_page - 1) // per_page
)
# Convert to response, attaching tags inline so the frontend can group
# client-side without a second round-trip.
photo_dicts = []
for photo in photos:
d = PhotoResponse.from_orm(photo).dict()
d["tags"] = [
{"id": t.id, "name": t.name, "color": t.color}
for t in (photo.tags or [])
]
photo_dicts.append(d)
return {
"photos": photo_dicts,
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page if total else 0,
}
@router.get("/{photo_id}")
async def get_photo(