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)) {