diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 6569497..0d5033a 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -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( diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index f87f3e0..893c37c 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -46,10 +46,10 @@ export function LeftSidebar() { const setFlag = useFilterStore((s) => s.setFlag) const setFolderId = useFilterStore((s) => s.setFolderId) const setDuplicates = useFilterStore((s) => s.setDuplicates) - const setTagIds = useFilterStore((s) => s.setTagIds) + const setGroupBy = useFilterStore((s) => s.setGroupBy) const filterFolderId = useFilterStore((s) => s.folderId) const filterDuplicates = useFilterStore((s) => s.duplicates) - const filterTagIds = useFilterStore((s) => s.tagIds) + const filterGroupBy = useFilterStore((s) => s.groupBy) const { data: allTags = [] } = useTagsQuery() const [dropTargetId, setDropTargetId] = useState(null) @@ -142,15 +142,12 @@ export function LeftSidebar() { clearAllFilters() setDuplicates(true) break + case 'tags': + // Tags is a leaf entry, not expandable. Clicking switches the + // timeline to grouped-by-tag mode without touching other filters. + setGroupBy('tag') + break default: - if (id.startsWith('tag-')) { - // Tag rows: filter to that single tag. Multi-tag filtering is - // available via the FilterBar. - const tagId = id.slice('tag-'.length) - clearAllFilters() - setTagIds([tagId]) - return - } if (id.startsWith('folder-')) { // Folder rows: filter to that folder, clear other filters that // would compete (heap, discarded, etc.) so the user sees what they @@ -222,6 +219,9 @@ export function LeftSidebar() { : undefined, }) + // Total tag count for the badge on the Tags entry. + const tagsTotalCount = allTags.reduce((sum, t) => sum + (t.photo_count || 0), 0) + const libraryTree: TreeItem[] = [ { id: 'library', @@ -230,19 +230,9 @@ export function LeftSidebar() { children: [ { id: 'all-photos', label: 'All Photos', icon: , count: 0 }, { id: 'rated', label: 'Rated', icon: , count: 0 }, + { id: 'tags', label: 'Tags', icon: , count: tagsTotalCount }, { id: 'duplicates', label: 'Duplicates', icon: , count: 0 }, { id: 'discarded', label: 'Discarded', icon: , count: 0 }, - { - id: 'tags', - label: 'Tags', - icon: , - children: allTags.map((tag) => ({ - id: `tag-${tag.id}`, - label: tag.name, - icon: , - count: tag.photo_count, - })), - }, ], }, { @@ -261,9 +251,8 @@ export function LeftSidebar() { if (id.startsWith('folder-')) { return filterFolderId === id.slice('folder-'.length) } - if (id.startsWith('tag-')) { - const tagId = id.slice('tag-'.length) - return filterTagIds.length === 1 && filterTagIds[0] === tagId + if (id === 'tags') { + return filterGroupBy === 'tag' } if (id === 'all-photos') { return filterFolderId === null && selectedItem === 'all-photos' diff --git a/frontend/src/components/timeline/Timeline.tsx b/frontend/src/components/timeline/Timeline.tsx index c9dbc01..1e400e1 100644 --- a/frontend/src/components/timeline/Timeline.tsx +++ b/frontend/src/components/timeline/Timeline.tsx @@ -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() + 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 diff --git a/frontend/src/hooks/useFilterUrlSync.ts b/frontend/src/hooks/useFilterUrlSync.ts index b367664..d4766e0 100644 --- a/frontend/src/hooks/useFilterUrlSync.ts +++ b/frontend/src/hooks/useFilterUrlSync.ts @@ -79,6 +79,9 @@ function parseUrl(): Partial { if (sp.get('duplicates') === 'true') out.duplicates = true + const groupBy = sp.get('group') + if (groupBy === 'date' || groupBy === 'tag') out.groupBy = groupBy + const sortBy = sp.get('sort') if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) { out.sortBy = sortBy as SortField @@ -105,6 +108,7 @@ function writeUrl(f: FilterState) { if (f.folderId) sp.set('folder_id', f.folderId) if (f.tagIds.length > 0) sp.set('tag_ids', f.tagIds.join(',')) if (f.duplicates) sp.set('duplicates', 'true') + if (f.groupBy !== 'date') sp.set('group', f.groupBy) if (f.sortBy !== 'taken_at') sp.set('sort', f.sortBy) if (f.sortOrder !== 'desc') sp.set('order', f.sortOrder) diff --git a/frontend/src/hooks/usePhotosQuery.ts b/frontend/src/hooks/usePhotosQuery.ts index 0e70b07..d9c2ce1 100644 --- a/frontend/src/hooks/usePhotosQuery.ts +++ b/frontend/src/hooks/usePhotosQuery.ts @@ -22,6 +22,7 @@ export function usePhotosQuery() { const folderId = useFilterStore((s) => s.folderId) const tagIds = useFilterStore((s) => s.tagIds) const duplicates = useFilterStore((s) => s.duplicates) + const groupBy = useFilterStore((s) => s.groupBy) const sortBy = useFilterStore((s) => s.sortBy) const sortOrder = useFilterStore((s) => s.sortOrder) @@ -39,10 +40,11 @@ export function usePhotosQuery() { folderId, tagIds, duplicates, + groupBy, sortBy, sortOrder, }), - [q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, tagIds, duplicates, sortBy, sortOrder] + [q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, tagIds, duplicates, groupBy, sortBy, sortOrder] ) return useQuery({ diff --git a/frontend/src/store/filterStore.ts b/frontend/src/store/filterStore.ts index d4ab5e0..4a7f2a4 100644 --- a/frontend/src/store/filterStore.ts +++ b/frontend/src/store/filterStore.ts @@ -10,6 +10,7 @@ export type SortField = | 'file_size' | 'rating' export type SortOrder = 'asc' | 'desc' +export type GroupBy = 'date' | 'tag' export interface FilterState { q: string @@ -28,6 +29,9 @@ export interface FilterState { tagIds: string[] /** When true, restrict to photos flagged as duplicates by the scanner. */ duplicates: boolean + /** Visual grouping mode. 'date' groups by month when sortBy is a date + * field; 'tag' groups by photo tag membership. Independent of filters. */ + groupBy: GroupBy sortBy: SortField sortOrder: SortOrder } @@ -47,6 +51,7 @@ interface FilterStore extends FilterState { setTagIds: (ids: string[]) => void toggleTagId: (id: string) => void setDuplicates: (v: boolean) => void + setGroupBy: (mode: GroupBy) => void setSortBy: (field: SortField) => void setSortOrder: (order: SortOrder) => void toggleSortOrder: () => void @@ -70,6 +75,7 @@ export const INITIAL_FILTERS: FilterState = { folderId: null, tagIds: [], duplicates: false, + groupBy: 'date', sortBy: 'taken_at', sortOrder: 'desc', } @@ -100,6 +106,7 @@ export const useFilterStore = create((set) => ({ : [...s.tagIds, id], })), setDuplicates: (duplicates) => set({ duplicates }), + setGroupBy: (groupBy) => set({ groupBy }), setSortBy: (sortBy) => set({ sortBy }), setSortOrder: (sortOrder) => set({ sortOrder }), toggleSortOrder: () => diff --git a/frontend/src/types/photo.ts b/frontend/src/types/photo.ts index 2e1de7e..92b345c 100644 --- a/frontend/src/types/photo.ts +++ b/frontend/src/types/photo.ts @@ -1,3 +1,9 @@ +export interface PhotoTagSummary { + id: string + name: string + color: string | null +} + export interface Photo { id: string filepath: string @@ -13,4 +19,5 @@ export interface Photo { thumb_small?: string thumb_medium?: string thumb_large?: string + tags?: PhotoTagSummary[] }