From 4bc6dc1dc8abf91ed69f351b8402175325de1568 Mon Sep 17 00:00:00 2001 From: dtoro Date: Fri, 10 Apr 2026 15:48:50 +0200 Subject: [PATCH] feat: refactor grouping views into card-grid browse pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Timeline-based grouped views (tags, colors, rated) with dedicated card-grid components that drill into Timeline detail views on click/Enter. Adds shared useCardGridNav hook for arrow-key navigation across all four card grids (tags, colors, rated, people). - TagsView, ColorsView, RatedView: card grid → inline Timeline detail - PeopleView: migrated to same pattern (Timeline replaces custom grid) - Tags endpoint: fall back to first associated photo for representative - Filter store: add ratingMax for exact rating filtering in RatedView - Timeline: remove tag/rating/color grouping; skip date headers when groupBy != 'date' so detail views render flat grids - SettingsDialog: bump z-index above Leaflet map layers Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/routers/tags.py | 7 +- frontend/src/App.tsx | 9 + frontend/src/components/colors/ColorsView.tsx | 183 ++++++++++++++ .../src/components/dialogs/SettingsDialog.tsx | 2 +- .../src/components/layout/LeftSidebar.tsx | 2 +- frontend/src/components/people/PeopleView.tsx | 239 +++++++----------- frontend/src/components/rated/RatedView.tsx | 170 +++++++++++++ frontend/src/components/tags/TagsView.tsx | 138 ++++++++++ frontend/src/components/timeline/Timeline.tsx | 171 +------------ frontend/src/hooks/useCardGridNav.ts | 115 +++++++++ frontend/src/hooks/useFilterUrlSync.ts | 7 + frontend/src/hooks/usePhotosQuery.ts | 4 +- frontend/src/store/filterStore.ts | 7 + 13 files changed, 737 insertions(+), 317 deletions(-) create mode 100644 frontend/src/components/colors/ColorsView.tsx create mode 100644 frontend/src/components/rated/RatedView.tsx create mode 100644 frontend/src/components/tags/TagsView.tsx create mode 100644 frontend/src/hooks/useCardGridNav.ts diff --git a/backend/app/routers/tags.py b/backend/app/routers/tags.py index ee73037..732dda6 100644 --- a/backend/app/routers/tags.py +++ b/backend/app/routers/tags.py @@ -48,12 +48,13 @@ async def list_tags( select( photo_tags.c.tag_id, func.count(photo_tags.c.photo_id).label("photo_count"), + func.min(photo_tags.c.photo_id).label("first_photo_id"), ) .group_by(photo_tags.c.tag_id) .subquery() ) stmt = ( - select(Tag, count_subq.c.photo_count) + select(Tag, count_subq.c.photo_count, count_subq.c.first_photo_id) .outerjoin(count_subq, Tag.id == count_subq.c.tag_id) ) if kind: @@ -70,10 +71,10 @@ async def list_tags( "color": tag.color, "kind": tag.kind, "source": tag.source, - "representative_photo_id": tag.representative_photo_id, + "representative_photo_id": tag.representative_photo_id or first_photo_id, "photo_count": int(count or 0), } - for tag, count in rows + for tag, count, first_photo_id in rows ] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c27c838..887d828 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,9 @@ import { Timeline } from './components/timeline/Timeline' import { DuplicatesView } from './components/duplicates/DuplicatesView' import { MapView } from './components/map/MapView' import { PeopleView } from './components/people/PeopleView' +import { TagsView } from './components/tags/TagsView' +import { ColorsView } from './components/colors/ColorsView' +import { RatedView } from './components/rated/RatedView' import { LeftSidebar } from './components/layout/LeftSidebar' import { RightSidebar } from './components/layout/RightSidebar' import { TopBar } from './components/layout/TopBar' @@ -89,6 +92,12 @@ function App() { ) : currentSection === 'people' ? ( + ) : currentSection === 'tags' ? ( + + ) : currentSection === 'colors' ? ( + + ) : currentSection === 'rated' ? ( + ) : ( )} diff --git a/frontend/src/components/colors/ColorsView.tsx b/frontend/src/components/colors/ColorsView.tsx new file mode 100644 index 0000000..ba54492 --- /dev/null +++ b/frontend/src/components/colors/ColorsView.tsx @@ -0,0 +1,183 @@ +import { useState, useMemo, useCallback } from 'react' +import { Palette, ArrowLeft, Loader2 } from 'lucide-react' +import clsx from 'clsx' +import { photos as photosApi } from '../../services/api' +import { useFilterStore } from '../../store/filterStore' +import { usePhotosQuery } from '../../hooks/usePhotosQuery' +import { COLOR_LABEL_OPTIONS, type ColorLabel } from '../../constants/colorLabels' +import { useCardGridNav } from '../../hooks/useCardGridNav' +import { Timeline } from '../timeline/Timeline' +import type { Photo } from '../../types/photo' + +interface ColorGroup { + label: string + value: ColorLabel | null + className: string + count: number + representative: Photo | null +} + +/** + * Colors view — two states: + * 1. Grid of color label cards (default) — arrow keys + Enter to browse + * 2. Detail view showing a color's photos in the full Timeline — Esc to go back + */ +export function ColorsView() { + const { data: allPhotos = [], isLoading } = usePhotosQuery() + const setColorLabel = useFilterStore((s) => s.setColorLabel) + const [selectedGroup, setSelectedGroup] = useState(null) + + const groups = useMemo(() => { + const buckets = new Map() + const uncolored: Photo[] = [] + + for (const photo of allPhotos) { + if (photo.color_label) { + const arr = buckets.get(photo.color_label) ?? [] + arr.push(photo) + buckets.set(photo.color_label, arr) + } else { + uncolored.push(photo) + } + } + + const result: ColorGroup[] = [] + for (const { value, className } of COLOR_LABEL_OPTIONS) { + const photos = buckets.get(value) ?? [] + if (photos.length === 0) continue + result.push({ + label: value.charAt(0).toUpperCase() + value.slice(1), + value, + className, + count: photos.length, + representative: photos[0], + }) + } + if (uncolored.length > 0) { + result.push({ + label: 'Uncolored', + value: null, + className: 'bg-neutral-400', + count: uncolored.length, + representative: uncolored[0], + }) + } + return result + }, [allPhotos]) + + const enterDetail = useCallback( + (group: ColorGroup) => { + setColorLabel((group.value ?? 'none') as ColorLabel) + setSelectedGroup(group) + }, + [setColorLabel] + ) + + const exitDetail = useCallback(() => { + setColorLabel(null) + setSelectedGroup(null) + }, [setColorLabel]) + + const { activeIndex, gridRef } = useCardGridNav({ + items: groups, + inDetail: selectedGroup !== null, + onEnter: enterDetail, + onExit: exitDetail, + }) + + if (selectedGroup) { + return ( +
+
+ +
+ +

{selectedGroup.label}

+
+
+
+ +
+
+ ) + } + + if (isLoading) { + return ( +
+ + Loading colors... +
+ ) + } + + if (groups.length === 0) { + return ( +
+ +

No color labels assigned yet

+

+ Color labels will appear here once you assign them to photos. +

+
+ ) + } + + return ( +
+
+ + + {groups.length} {groups.length === 1 ? 'color' : 'colors'} + +
+ +
+ {groups.map((group, i) => ( +
enterDetail(group)} + > +
+ {group.representative ? ( + {group.label} + ) : ( +
+ +
+ )} + + + {group.count} + +
+ +
+ +

{group.label}

+
+
+ ))} +
+
+ ) +} diff --git a/frontend/src/components/dialogs/SettingsDialog.tsx b/frontend/src/components/dialogs/SettingsDialog.tsx index 41d6f34..f3312ee 100644 --- a/frontend/src/components/dialogs/SettingsDialog.tsx +++ b/frontend/src/components/dialogs/SettingsDialog.tsx @@ -195,7 +195,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) { if (!isOpen) return null return ( -
+
s.openPreview) + const setTagIds = useFilterStore((s) => s.setTagIds) const [selectedPerson, setSelectedPerson] = useState(null) const [editingId, setEditingId] = useState(null) @@ -32,7 +33,6 @@ export function PeopleView() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['tags'] }) setEditingId(null) - // Update the selected person's name if we're renaming the active one if (selectedPerson && editingId === selectedPerson.id) { setSelectedPerson({ ...selectedPerson, name: editName.trim() }) } @@ -52,20 +52,76 @@ export function PeopleView() { renameMutation.mutate({ id: editingId, name: editName.trim() }) } + const enterDetail = useCallback( + (person: Tag) => { + setTagIds([person.id]) + setSelectedPerson(person) + }, + [setTagIds] + ) + + const exitDetail = useCallback(() => { + setTagIds([]) + setSelectedPerson(null) + }, [setTagIds]) + + const { activeIndex, gridRef } = useCardGridNav({ + items: clusters, + inDetail: selectedPerson !== null, + onEnter: enterDetail, + onExit: exitDetail, + }) + // ── Detail view: a person's photos ───────────────────────────────── if (selectedPerson) { + const isEditing = editingId === selectedPerson.id return ( - setSelectedPerson(null)} - onRename={() => startEditing(selectedPerson)} - editingId={editingId} - editName={editName} - setEditName={setEditName} - submitRename={submitRename} - cancelEdit={() => setEditingId(null)} - openPreview={openPreview} - /> +
+
+ + + {isEditing ? ( +
+ setEditName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') submitRename() + if (e.key === 'Escape') setEditingId(null) + }} + className="rounded border border-border bg-bg px-2 py-1 text-sm text-text focus:border-primary focus:outline-none" + /> + + +
+ ) : ( +
+

{selectedPerson.name}

+ +
+ )} +
+
+ +
+
) } @@ -101,16 +157,23 @@ export function PeopleView() {
-
- {clusters.map((tag) => ( +
+ {clusters.map((tag, i) => (
{ - if (editingId !== tag.id) setSelectedPerson(tag) + if (editingId !== tag.id) enterDetail(tag) }} >
@@ -172,131 +235,3 @@ export function PeopleView() {
) } - - -// ── Person detail sub-view ─────────────────────────────────────────── - -interface PersonDetailProps { - person: Tag - onBack: () => void - onRename: () => void - editingId: string | null - editName: string - setEditName: (v: string) => void - submitRename: () => void - cancelEdit: () => void - openPreview: (photoId: string, photoIds: string[]) => void -} - -function PersonDetail({ - person, - onBack, - onRename, - editingId, - editName, - setEditName, - submitRename, - cancelEdit, - openPreview, -}: PersonDetailProps) { - const { data, isLoading } = useQuery({ - queryKey: ['person-photos', person.id], - queryFn: async () => { - const resp = await searchApi.query({ - filters: { tag_ids: [person.id] }, - limit: 500, - }) - return resp.results - }, - }) - - const photos = data ?? [] - const isEditing = editingId === person.id - - return ( -
- {/* Header */} -
- - - {isEditing ? ( -
- setEditName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') submitRename() - if (e.key === 'Escape') cancelEdit() - }} - className="rounded border border-border bg-bg px-2 py-1 text-sm text-text focus:border-primary focus:outline-none" - /> - - -
- ) : ( -
-

{person.name}

- -
- )} - - - {photos.length} {photos.length === 1 ? 'photo' : 'photos'} - -
- - {/* Photo grid */} -
- {isLoading ? ( -
- - Loading photos... -
- ) : photos.length === 0 ? ( -

No photos found

- ) : ( -
- {photos.map((photo) => ( -
- openPreview( - photo.id, - photos.map((p) => p.id) - ) - } - > -
- {photo.filename} -
-
- ))} -
- )} -
-
- ) -} diff --git a/frontend/src/components/rated/RatedView.tsx b/frontend/src/components/rated/RatedView.tsx new file mode 100644 index 0000000..d7e9576 --- /dev/null +++ b/frontend/src/components/rated/RatedView.tsx @@ -0,0 +1,170 @@ +import { useState, useMemo, useCallback } from 'react' +import { Star, ArrowLeft, Loader2 } from 'lucide-react' +import clsx from 'clsx' +import { photos as photosApi } from '../../services/api' +import { useFilterStore } from '../../store/filterStore' +import { usePhotosQuery } from '../../hooks/usePhotosQuery' +import { useCardGridNav } from '../../hooks/useCardGridNav' +import { Timeline } from '../timeline/Timeline' +import type { Photo } from '../../types/photo' + +interface RatingGroup { + rating: number + label: string + count: number + representative: Photo | null +} + +/** + * Rated view — two states: + * 1. Grid of rating-level cards (default) — arrow keys + Enter to browse + * 2. Detail view showing a rating level's photos in the full Timeline — Esc to go back + */ +export function RatedView() { + const { data: allPhotos = [], isLoading } = usePhotosQuery() + const setRatingMin = useFilterStore((s) => s.setRatingMin) + const setRatingMax = useFilterStore((s) => s.setRatingMax) + const [selectedGroup, setSelectedGroup] = useState(null) + + const groups = useMemo(() => { + const buckets = new Map() + + for (const photo of allPhotos) { + if (photo.rating > 0) { + const arr = buckets.get(photo.rating) ?? [] + arr.push(photo) + buckets.set(photo.rating, arr) + } + } + + // Highest rating first + const result: RatingGroup[] = [] + for (let r = 5; r >= 1; r--) { + const photos = buckets.get(r) ?? [] + if (photos.length === 0) continue + result.push({ + rating: r, + label: '★'.repeat(r), + count: photos.length, + representative: photos[0], + }) + } + return result + }, [allPhotos]) + + const enterDetail = useCallback( + (group: RatingGroup) => { + setRatingMin(group.rating) + setRatingMax(group.rating) + setSelectedGroup(group) + }, + [setRatingMin, setRatingMax] + ) + + const exitDetail = useCallback(() => { + // Restore the section preset: ratingMin=1 (all rated), no max + setRatingMin(1) + setRatingMax(0) + setSelectedGroup(null) + }, [setRatingMin, setRatingMax]) + + const { activeIndex, gridRef } = useCardGridNav({ + items: groups, + inDetail: selectedGroup !== null, + onEnter: enterDetail, + onExit: exitDetail, + }) + + if (selectedGroup) { + return ( +
+
+ +

{selectedGroup.label}

+
+
+ +
+
+ ) + } + + if (isLoading) { + return ( +
+ + Loading ratings... +
+ ) + } + + if (groups.length === 0) { + return ( +
+ +

No rated photos yet

+

+ Rate photos with 1–5 stars and they will appear here grouped by + rating. +

+
+ ) + } + + return ( +
+
+ + + {groups.length} rating {groups.length === 1 ? 'level' : 'levels'} + +
+ +
+ {groups.map((group, i) => ( +
enterDetail(group)} + > +
+ {group.representative ? ( + {group.label} + ) : ( +
+ +
+ )} + + + {group.count} + +
+ +
+

{group.label}

+
+
+ ))} +
+
+ ) +} diff --git a/frontend/src/components/tags/TagsView.tsx b/frontend/src/components/tags/TagsView.tsx new file mode 100644 index 0000000..ed06791 --- /dev/null +++ b/frontend/src/components/tags/TagsView.tsx @@ -0,0 +1,138 @@ +import { useState, useMemo, useCallback } from 'react' +import { Tag as TagIcon, ArrowLeft, Loader2 } from 'lucide-react' +import clsx from 'clsx' +import { useTagsQuery } from '../../hooks/useTagsQuery' +import { photos as photosApi, type Tag } from '../../services/api' +import { useFilterStore } from '../../store/filterStore' +import { useCardGridNav } from '../../hooks/useCardGridNav' +import { Timeline } from '../timeline/Timeline' + +/** + * Tags view — two states: + * 1. Grid of tag cards (default) — arrow keys + Enter to browse + * 2. Detail view showing a tag's photos in the full Timeline — Esc to go back + */ +export function TagsView() { + const { data: allTags = [], isLoading } = useTagsQuery() + const setTagIds = useFilterStore((s) => s.setTagIds) + const [selectedTag, setSelectedTag] = useState(null) + + // Exclude face_cluster tags (those live in PeopleView) + const tags = useMemo( + () => allTags.filter((t) => t.kind !== 'face_cluster'), + [allTags] + ) + + const enterDetail = useCallback( + (tag: Tag) => { + setTagIds([tag.id]) + setSelectedTag(tag) + }, + [setTagIds] + ) + + const exitDetail = useCallback(() => { + setTagIds([]) + setSelectedTag(null) + }, [setTagIds]) + + const { activeIndex, gridRef } = useCardGridNav({ + items: tags, + inDetail: selectedTag !== null, + onEnter: enterDetail, + onExit: exitDetail, + }) + + if (selectedTag) { + return ( +
+
+ +

{selectedTag.name}

+
+
+ +
+
+ ) + } + + if (isLoading) { + return ( +
+ + Loading tags... +
+ ) + } + + if (tags.length === 0) { + return ( +
+ +

No tags yet

+

+ Tags will appear here once photos are tagged — either manually or by + the auto-tagger. +

+
+ ) + } + + return ( +
+
+ + + {tags.length} {tags.length === 1 ? 'tag' : 'tags'} + +
+ +
+ {tags.map((tag, i) => ( +
enterDetail(tag)} + > +
+ {tag.representative_photo_id ? ( + {tag.name} + ) : ( +
+ +
+ )} + + + {tag.photo_count} + +
+ +
+

{tag.name}

+
+
+ ))} +
+
+ ) +} diff --git a/frontend/src/components/timeline/Timeline.tsx b/frontend/src/components/timeline/Timeline.tsx index 9c505a0..ccf4438 100644 --- a/frontend/src/components/timeline/Timeline.tsx +++ b/frontend/src/components/timeline/Timeline.tsx @@ -6,7 +6,6 @@ import { useFilterStore } from '../../store/filterStore' import { PhotoThumbnail } from './PhotoThumbnail' import { usePhotosQuery } from '../../hooks/usePhotosQuery' import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery' -import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels' import type { Photo } from '../../types/photo' // Layout constants for the grid + grouped headers. @@ -27,22 +26,19 @@ type TimelineItem = /** * Build the flat header|row item array the virtualizer renders. * - * Five 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='rating': one bucket per star rating 5..1 (plus "Unrated" - * for rating 0). Each photo lands in exactly one bucket. - * - groupBy='color': one bucket per color label, in canonical order - * (plus an "Uncolored" bucket for photos with no label). - * - groupBy='date' AND sortBy is a date field: month buckets (existing). + * Two modes: + * - sortBy is a date field: month buckets. * - otherwise: one un-headered stream. + * + * Tag, rating, and color grouping now live in their own dedicated views + * (TagsView, RatedView, ColorsView) instead of being handled here. */ function buildItems( photos: Photo[], columns: number, rowHeight: number, sortBy: string, - groupBy: 'date' | 'tag' | 'rating' | 'color' + groupBy: string, ): TimelineItem[] { if (photos.length === 0) return [] @@ -61,155 +57,13 @@ 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() - 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 - } - - // ── Rating grouping ─────────────────────────────────────────────────── - if (groupBy === 'rating') { - // Bucket by star rating. Each photo lands in exactly one bucket; - // rating 0 goes into "Unrated". - const ratingBuckets = new Map() - const unrated: PhotoCell[] = [] - - photos.forEach((photo, globalIndex) => { - const cell: PhotoCell = { photo, globalIndex } - if (photo.rating > 0) { - const arr = ratingBuckets.get(photo.rating) ?? [] - arr.push(cell) - ratingBuckets.set(photo.rating, arr) - } else { - unrated.push(cell) - } - }) - - // Highest rating first; Unrated goes at the end. - const sortedRatings = Array.from(ratingBuckets.keys()).sort((a, b) => b - a) - - let bucketIndex = 0 - for (const rating of sortedRatings) { - items.push({ - type: 'header', - key: `rating::${bucketIndex}::${rating}`, - label: '★'.repeat(rating), - height: HEADER_HEIGHT, - }) - pushRowsForGroup( - `rating::${bucketIndex}::${rating}`, - ratingBuckets.get(rating)! - ) - bucketIndex++ - } - if (unrated.length > 0) { - items.push({ - type: 'header', - key: `rating::${bucketIndex}::__unrated`, - label: 'Unrated', - height: HEADER_HEIGHT, - }) - pushRowsForGroup(`rating::${bucketIndex}::unrated`, unrated) - } - return items - } - - // ── Color label grouping ────────────────────────────────────────────── - if (groupBy === 'color') { - // Bucket by color_label. Each photo lands in exactly one bucket; - // photos with no label go into "Uncolored". - const colorBuckets = new Map() - const uncolored: PhotoCell[] = [] - - photos.forEach((photo, globalIndex) => { - const cell: PhotoCell = { photo, globalIndex } - const label = photo.color_label - if (label) { - const arr = colorBuckets.get(label) ?? [] - arr.push(cell) - colorBuckets.set(label, arr) - } else { - uncolored.push(cell) - } - }) - - // Walk the canonical color order so headers always read R-O-Y-G-B-P, - // matching every other color UI in the app. Skip empty buckets and - // ignore any unexpected label values that aren't in the canonical - // list (they'd be invalid backend state). - let bucketIndex = 0 - for (const { value } of COLOR_LABEL_OPTIONS) { - const cells = colorBuckets.get(value) - if (!cells || cells.length === 0) continue - const label = value.charAt(0).toUpperCase() + value.slice(1) - items.push({ - type: 'header', - key: `color::${bucketIndex}::${value}`, - label, - height: HEADER_HEIGHT, - }) - pushRowsForGroup(`color::${bucketIndex}::${value}`, cells) - bucketIndex++ - } - if (uncolored.length > 0) { - items.push({ - type: 'header', - key: `color::${bucketIndex}::__uncolored`, - label: 'Uncolored', - height: HEADER_HEIGHT, - }) - pushRowsForGroup(`color::${bucketIndex}::uncolored`, uncolored) - } - return items - } - - // ── Date grouping (existing) ────────────────────────────────────────── + // Date grouping only applies when groupBy is explicitly 'date' and + // the sort field is a date column. Other sections (tags, colors, + // rated, people) reuse Timeline for their detail views and should + // render a flat grid without month headers. const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at' - if (!isDateSort) { + if (!isDateSort || groupBy !== 'date') { // No grouping — one row stream. const cells: PhotoCell[] = photos.map((photo, globalIndex) => ({ photo, @@ -344,8 +198,7 @@ export function Timeline() { const activeHeapName = activeHeap?.name ?? null // 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). + // photos. Date headers appear only in the main timeline (groupBy='date'). const items = useMemo( () => buildItems(photos, columns, cellSize, sortBy, groupBy), [photos, columns, cellSize, sortBy, groupBy] diff --git a/frontend/src/hooks/useCardGridNav.ts b/frontend/src/hooks/useCardGridNav.ts new file mode 100644 index 0000000..638f5be --- /dev/null +++ b/frontend/src/hooks/useCardGridNav.ts @@ -0,0 +1,115 @@ +import { useState, useEffect, useCallback, useRef } from 'react' +import { usePhotoStore } from '../store/photoStore' + +/** + * Keyboard navigation for card grids (tags, colors, ratings, people). + * + * Arrow keys move the active index through the grid (wrapping at row + * boundaries based on the actual CSS column count), Enter opens the + * selected card, and Escape / Backspace exits the detail view. + * + * In detail mode, Escape only exits back to the card grid when the + * preview is closed and no photos are selected — otherwise it defers + * to Timeline's own Escape handler (clear selection / close preview). + * + * The grid container ref is used to measure the rendered column count + * so up/down navigation stays column-aligned. + */ +export function useCardGridNav(opts: { + items: T[] + /** True when the detail view is showing (disables grid nav, enables Esc) */ + inDetail: boolean + onEnter: (item: T, index: number) => void + onExit: () => void +}) { + const { items, inDetail, onEnter, onExit } = opts + const [activeIndex, setActiveIndex] = useState(0) + const gridRef = useRef(null) + const viewMode = usePhotoStore((s) => s.viewMode) + const selectedPhotos = usePhotoStore((s) => s.selectedPhotos) + + // Clamp active index when the item list shrinks + useEffect(() => { + if (items.length > 0 && activeIndex >= items.length) { + setActiveIndex(items.length - 1) + } + }, [items.length, activeIndex]) + + // Measure column count from the grid container + const getColumns = useCallback(() => { + const el = gridRef.current + if (!el) return 1 + return getComputedStyle(el).gridTemplateColumns.split(' ').length + }, []) + + // Scroll the active card into view + const scrollIntoView = useCallback((index: number) => { + const el = gridRef.current + if (!el) return + const card = el.children[index] as HTMLElement | undefined + card?.scrollIntoView({ block: 'nearest' }) + }, []) + + useEffect(() => { + if (items.length === 0) return + + const handleKeyDown = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null + if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return + + // Detail view: Escape or Backspace exits back to card grid, but + // only when the preview is closed and no photos are selected — + // otherwise defer to Timeline's own Escape handler. + if (inDetail) { + if (e.key === 'Backspace') { + e.preventDefault() + onExit() + } else if (e.key === 'Escape' && viewMode === 'grid' && selectedPhotos.length === 0) { + e.preventDefault() + onExit() + } + return + } + + // Card grid navigation + const cols = getColumns() + const count = items.length + let next = activeIndex + + switch (e.key) { + case 'ArrowRight': + e.preventDefault() + next = Math.min(activeIndex + 1, count - 1) + break + case 'ArrowLeft': + e.preventDefault() + next = Math.max(activeIndex - 1, 0) + break + case 'ArrowDown': + e.preventDefault() + next = Math.min(activeIndex + cols, count - 1) + break + case 'ArrowUp': + e.preventDefault() + next = Math.max(activeIndex - cols, 0) + break + case 'Enter': + e.preventDefault() + if (items[activeIndex]) onEnter(items[activeIndex], activeIndex) + return + default: + return + } + + if (next !== activeIndex) { + setActiveIndex(next) + scrollIntoView(next) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [items, activeIndex, inDetail, onEnter, onExit, getColumns, scrollIntoView, viewMode, selectedPhotos]) + + return { activeIndex, setActiveIndex, gridRef } +} diff --git a/frontend/src/hooks/useFilterUrlSync.ts b/frontend/src/hooks/useFilterUrlSync.ts index 51b7109..47c6a6a 100644 --- a/frontend/src/hooks/useFilterUrlSync.ts +++ b/frontend/src/hooks/useFilterUrlSync.ts @@ -60,6 +60,12 @@ function parseUrl(): HydratePayload { if (Number.isFinite(n) && n >= 0 && n <= 5) out.ratingMin = n } + const rx = sp.get('rating_max') + if (rx) { + const n = parseInt(rx, 10) + if (Number.isFinite(n) && n >= 0 && n <= 5) out.ratingMax = n + } + const cl = sp.get('color_label') if (cl && ALLOWED_COLORS.includes(cl as ColorLabel)) { out.colorLabel = cl as ColorLabel @@ -110,6 +116,7 @@ function writeUrl(f: FilterState & { currentSection?: string }) { if (f.dateTo) sp.set('date_to', f.dateTo) if (f.mediaTypes.length > 0) sp.set('media_type', f.mediaTypes.join(',')) if (f.ratingMin > 0) sp.set('rating_min', String(f.ratingMin)) + if (f.ratingMax > 0) sp.set('rating_max', String(f.ratingMax)) if (f.colorLabel) sp.set('color_label', f.colorLabel) if (f.flag !== 'any') sp.set('flag', f.flag) if (f.heapId) sp.set('heap_id', f.heapId) diff --git a/frontend/src/hooks/usePhotosQuery.ts b/frontend/src/hooks/usePhotosQuery.ts index f69f094..d8e831d 100644 --- a/frontend/src/hooks/usePhotosQuery.ts +++ b/frontend/src/hooks/usePhotosQuery.ts @@ -32,6 +32,7 @@ export function usePhotosQuery() { const dateTo = useFilterStore((s) => s.dateTo) const mediaTypes = useFilterStore((s) => s.mediaTypes) const ratingMin = useFilterStore((s) => s.ratingMin) + const ratingMax = useFilterStore((s) => s.ratingMax) const colorLabel = useFilterStore((s) => s.colorLabel) const flag = useFilterStore((s) => s.flag) const heapId = useFilterStore((s) => s.heapId) @@ -50,6 +51,7 @@ export function usePhotosQuery() { dateTo, mediaTypes, ratingMin, + ratingMax, colorLabel, flag, heapId, @@ -60,7 +62,7 @@ export function usePhotosQuery() { sortBy, sortOrder, }), - [q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, tagIds, duplicates, groupBy, sortBy, sortOrder] + [q, dateFrom, dateTo, mediaTypes, ratingMin, ratingMax, colorLabel, flag, heapId, folderId, tagIds, duplicates, groupBy, sortBy, sortOrder] ) const queryClient = useQueryClient() diff --git a/frontend/src/store/filterStore.ts b/frontend/src/store/filterStore.ts index 43ffd6b..eff39ad 100644 --- a/frontend/src/store/filterStore.ts +++ b/frontend/src/store/filterStore.ts @@ -19,6 +19,7 @@ export interface FilterState { dateTo: string | null mediaTypes: MediaType[] ratingMin: number // 0-5; 0 means no filter + ratingMax: number // 0-5; 0 means no filter colorLabel: ColorLabel | null flag: FlagFilter /** When set, restrict to photos in this heap. Independent of `activeHeapId` @@ -59,6 +60,7 @@ interface FilterStore extends FilterState { setDateTo: (date: string | null) => void toggleMediaType: (t: MediaType) => void setRatingMin: (rating: number) => void + setRatingMax: (rating: number) => void setColorLabel: (label: ColorLabel | null) => void setFlag: (flag: FlagFilter) => void setHeapId: (id: string | null) => void @@ -93,6 +95,7 @@ export const INITIAL_FILTERS: FilterState = { dateTo: null, mediaTypes: [], ratingMin: 0, + ratingMax: 0, colorLabel: null, flag: 'any', heapId: null, @@ -114,6 +117,7 @@ function snapshotFilters(s: FilterState): FilterState { dateTo: s.dateTo, mediaTypes: [...s.mediaTypes], ratingMin: s.ratingMin, + ratingMax: s.ratingMax, colorLabel: s.colorLabel, flag: s.flag, heapId: s.heapId, @@ -142,6 +146,7 @@ export const useFilterStore = create((set) => ({ : [...s.mediaTypes, t], })), setRatingMin: (ratingMin) => set({ ratingMin }), + setRatingMax: (ratingMax) => set({ ratingMax }), setColorLabel: (colorLabel) => set({ colorLabel }), setFlag: (flag) => set({ flag }), setHeapId: (heapId) => set({ heapId }), @@ -205,6 +210,7 @@ export function filtersToParams(f: FilterState): Record if (f.dateTo) params.date_to = f.dateTo if (f.mediaTypes.length > 0) params.media_type = f.mediaTypes.join(',') if (f.ratingMin > 0) params.rating_min = f.ratingMin + if (f.ratingMax > 0) params.rating_max = f.ratingMax if (f.colorLabel) params.color_label = f.colorLabel if (f.flag === 'discarded') params.is_discarded = 'true' if (f.heapId) params.heap_id = f.heapId @@ -224,6 +230,7 @@ export function hasActiveFilters(f: FilterState): boolean { f.dateTo !== null || f.mediaTypes.length > 0 || f.ratingMin > 0 || + f.ratingMax > 0 || f.colorLabel !== null || f.flag !== 'any' || f.heapId !== null ||