diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 3d92540..24d377b 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -146,17 +146,21 @@ async def list_photos( ) # Tag filter — comma-separated tag ids, AND semantics. A photo must - # have a row in photo_tags for EVERY listed tag. Implemented as one - # subquery per tag id since SQLite doesn't have an efficient - # "set-contains-all" operator. + # have a row in photo_tags for EVERY listed tag. Implemented as a + # single GROUP BY ... HAVING COUNT(DISTINCT) = N subquery so the cost + # is independent of the number of tags being filtered. if tag_ids: tag_id_list = [t.strip() for t in tag_ids.split(',') if t.strip()] - for tid in tag_id_list: - filters.append( - Photo.id.in_( - select(photo_tags.c.photo_id).where(photo_tags.c.tag_id == tid) + if tag_id_list: + matching_photos = ( + select(photo_tags.c.photo_id) + .where(photo_tags.c.tag_id.in_(tag_id_list)) + .group_by(photo_tags.c.photo_id) + .having( + func.count(func.distinct(photo_tags.c.tag_id)) == len(tag_id_list) ) ) + filters.append(Photo.id.in_(matching_photos)) # Apply all filters if filters: diff --git a/frontend/src/components/filter/FilterPill.tsx b/frontend/src/components/filter/FilterPill.tsx index ea7450c..cb170f6 100644 --- a/frontend/src/components/filter/FilterPill.tsx +++ b/frontend/src/components/filter/FilterPill.tsx @@ -106,17 +106,26 @@ export function FilterPill({ {value} )} {isActive && onClear ? ( - + ) : ( )} diff --git a/frontend/src/components/layout/RightSidebar.tsx b/frontend/src/components/layout/RightSidebar.tsx index 7107737..7273ace 100644 --- a/frontend/src/components/layout/RightSidebar.tsx +++ b/frontend/src/components/layout/RightSidebar.tsx @@ -97,6 +97,7 @@ export function RightSidebar() { onClick={clearSelection} className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text" title="Clear selection" + aria-label="Clear selection" > diff --git a/frontend/src/components/timeline/Timeline.tsx b/frontend/src/components/timeline/Timeline.tsx index 6a551d2..713a31c 100644 --- a/frontend/src/components/timeline/Timeline.tsx +++ b/frontend/src/components/timeline/Timeline.tsx @@ -422,7 +422,7 @@ export function Timeline() { * positioned children so it isn't affected by translateY transforms. * Updates as the user scrolls past month boundaries. */} {stickyLabel && ( -
+

{stickyLabel}

diff --git a/frontend/src/hooks/useFilterUrlSync.ts b/frontend/src/hooks/useFilterUrlSync.ts index fe27541..51b7109 100644 --- a/frontend/src/hooks/useFilterUrlSync.ts +++ b/frontend/src/hooks/useFilterUrlSync.ts @@ -28,9 +28,14 @@ const ALLOWED_SORT_FIELDS: SortField[] = [ ] const ALLOWED_SORT_ORDERS: SortOrder[] = ['asc', 'desc'] -function parseUrl(): Partial { +// What parseUrl returns: a partial filter state, plus the optional +// section id (which lives on the store but isn't part of FilterState +// itself). The hydrate action accepts this exact shape. +type HydratePayload = Partial & { currentSection?: string } + +function parseUrl(): HydratePayload { const sp = new URLSearchParams(window.location.search) - const out: Partial = {} + const out: HydratePayload = {} const q = sp.get('q') if (q) out.q = q @@ -83,7 +88,7 @@ function parseUrl(): Partial { if (groupBy === 'date' || groupBy === 'tag') out.groupBy = groupBy const section = sp.get('section') - if (section) (out as any).currentSection = section + if (section) out.currentSection = section const sortBy = sp.get('sort') if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) {