From b3e58275bc0a888398a56e068d08f3d68e2d7b58 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 18 Mar 2026 22:45:39 -0700 Subject: [PATCH] =?UTF-8?q?feat(photos):=20=E2=9C=A8=20Add=20photo=20handl?= =?UTF-8?q?ing=20and=20gallery=20display=20functionality=20with=20new=20en?= =?UTF-8?q?dpoints,=20core=20logic,=20and=20a=20frontend=20gallery=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../src/modules/photos/photos.controller.ts | 27 +- .../src/modules/photos/photos.service.ts | 35 +++ .../frontend-dev/src/api/hooks.ts | 10 +- .../frontend-dev/src/api/types.ts | 6 + .../frontend-dev/src/pages/GalleryPage.tsx | 297 ++++++++++-------- 5 files changed, 231 insertions(+), 144 deletions(-) diff --git a/features/image-assistant/backend-api/src/modules/photos/photos.controller.ts b/features/image-assistant/backend-api/src/modules/photos/photos.controller.ts index 0cd864b07..bb8a298d2 100644 --- a/features/image-assistant/backend-api/src/modules/photos/photos.controller.ts +++ b/features/image-assistant/backend-api/src/modules/photos/photos.controller.ts @@ -36,31 +36,18 @@ export class PhotosController { @Get('categories') @ApiOperation({ summary: 'Get photo counts by classification category' }) - @ApiResponse({ - status: 200, - description: 'Category counts retrieved successfully', - schema: { - type: 'object', - properties: { - success: { type: 'boolean' }, - data: { - type: 'array', - items: { - type: 'object', - properties: { - category: { type: 'string', nullable: true }, - count: { type: 'string' }, - }, - }, - }, - }, - }, - }) async categories() { const counts = await this.photosService.getCategoryCounts(); return { success: true, data: counts }; } + @Get('stats') + @ApiOperation({ summary: 'Get photo counts by media type, favorites, and screenshots' }) + async stats() { + const result = await this.photosService.getPhotoStats(); + return { success: true, data: result }; + } + @Get(':id') @ApiOperation({ summary: 'Get photo details by ID' }) @ApiParam({ name: 'id', description: 'Photo UUID', format: 'uuid' }) diff --git a/features/image-assistant/backend-api/src/modules/photos/photos.service.ts b/features/image-assistant/backend-api/src/modules/photos/photos.service.ts index a89935484..1e24f14ba 100644 --- a/features/image-assistant/backend-api/src/modules/photos/photos.service.ts +++ b/features/image-assistant/backend-api/src/modules/photos/photos.service.ts @@ -281,4 +281,39 @@ export class PhotosService { return response; } + + async getPhotoStats(): Promise<{ + byMediaType: Array<{ mediaType: string; count: string }>; + favoriteCount: string; + screenshotCount: string; + }> { + const [byMediaType, [favoriteRow], [screenshotRow]] = await Promise.all([ + this.photoRepository + .createQueryBuilder('photo') + .select('photo.mediaType', 'mediaType') + .addSelect('COUNT(*)', 'count') + .where('photo.storageKey IS NOT NULL') + .groupBy('photo.mediaType') + .orderBy('count', 'DESC') + .getRawMany<{ mediaType: string; count: string }>(), + + this.photoRepository + .createQueryBuilder('photo') + .select('COUNT(*)', 'count') + .where('photo.isFavorite = true AND photo.storageKey IS NOT NULL') + .getRawMany<{ count: string }>(), + + this.photoRepository + .createQueryBuilder('photo') + .select('COUNT(*)', 'count') + .where('photo.isScreenshot = true AND photo.storageKey IS NOT NULL') + .getRawMany<{ count: string }>(), + ]); + + return { + byMediaType, + favoriteCount: favoriteRow?.count ?? '0', + screenshotCount: screenshotRow?.count ?? '0', + }; + } } diff --git a/features/image-assistant/frontend-dev/src/api/hooks.ts b/features/image-assistant/frontend-dev/src/api/hooks.ts index 872c6c636..d6af5df22 100644 --- a/features/image-assistant/frontend-dev/src/api/hooks.ts +++ b/features/image-assistant/frontend-dev/src/api/hooks.ts @@ -1,6 +1,6 @@ import { useQuery, useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from './client'; -import type { Photo, Album, Device, PhotosResponse, PhotoFilters, SyncStats, CategoryCount } from './types'; +import type { Photo, Album, Device, PhotosResponse, PhotoFilters, SyncStats, CategoryCount, PhotoStats } from './types'; // Query keys export const queryKeys = { @@ -118,6 +118,14 @@ export function useCategoryCounts() { }); } +// Photo stats hook (media type counts, favorites, screenshots) +export function usePhotoStats() { + return useQuery({ + queryKey: ['photoStats'], + queryFn: () => api.get('/photos/stats'), + }); +} + // Sync stats hook export function useSyncStats() { return useQuery({ diff --git a/features/image-assistant/frontend-dev/src/api/types.ts b/features/image-assistant/frontend-dev/src/api/types.ts index 0cc162ec7..584b0b27d 100644 --- a/features/image-assistant/frontend-dev/src/api/types.ts +++ b/features/image-assistant/frontend-dev/src/api/types.ts @@ -150,6 +150,12 @@ export interface CategoryCount { count: string; } +export interface PhotoStats { + byMediaType: Array<{ mediaType: string; count: string }>; + favoriteCount: string; + screenshotCount: string; +} + export interface SyncStats { totalPhotos: number; totalAlbums: number; diff --git a/features/image-assistant/frontend-dev/src/pages/GalleryPage.tsx b/features/image-assistant/frontend-dev/src/pages/GalleryPage.tsx index c717dd698..55c5de9a2 100644 --- a/features/image-assistant/frontend-dev/src/pages/GalleryPage.tsx +++ b/features/image-assistant/frontend-dev/src/pages/GalleryPage.tsx @@ -1,18 +1,37 @@ import { useCallback, useMemo, useState, type JSX, type ReactNode } from 'react'; -import { ImageIcon, HeartIcon } from '@lilith/ui-icons'; -import { usePhotos, useSyncStats, useCategoryCounts } from '@/api/hooks'; -import type { PhotoFilters, CategoryCount } from '@/api/types'; +import { + ImageIcon, + HeartIcon, + MonitorIcon, + VideoIcon, + GridIcon, + TagIcon, + LayersIcon, +} from '@lilith/ui-icons'; +import { usePhotos, useSyncStats, useCategoryCounts, usePhotoStats } from '@/api/hooks'; +import type { PhotoFilters } from '@/api/types'; import { MediaType, ViewMode, PhotoCategory } from '@/api/types'; import { PhotoGrid } from '@/components/PhotoGrid'; import styles from './GalleryPage.module.css'; -export enum FilterPreset { - All = 'all', - Photos = 'photos', - Videos = 'videos', - Favorites = 'favorites', - Screenshots = 'screenshots', -} +// ─── Types ─────────────────────────────────────────────────────────────────── + +type ActiveFilter = + | { kind: 'all' } + | { kind: 'mediaType'; value: MediaType } + | { kind: 'favorites' } + | { kind: 'screenshots' } + | { kind: 'category'; value: PhotoCategory }; + +const INITIAL_FILTER: ActiveFilter = { kind: 'all' }; + +// ─── Label maps ────────────────────────────────────────────────────────────── + +const MEDIA_TYPE_LABELS: Record = { + image: 'Photos', + video: 'Videos', + live_photo: 'Live Photos', +}; const CATEGORY_LABELS: Record = { screenshot_receipt: 'Receipts', @@ -31,157 +50,189 @@ const CATEGORY_LABELS: Record = { unclassified: 'Unclassified', }; +const MEDIA_TYPE_ICONS: Record = { + image: , + video: , + live_photo: , +}; + +// ─── Filter helpers ─────────────────────────────────────────────────────────── + +function filterMatches(active: ActiveFilter, candidate: ActiveFilter): boolean { + if (active.kind !== candidate.kind) return false; + if (active.kind === 'mediaType' && candidate.kind === 'mediaType') return active.value === candidate.value; + if (active.kind === 'category' && candidate.kind === 'category') return active.value === candidate.value; + return true; +} + +function toPhotoFilters(active: ActiveFilter, viewMode: ViewMode): PhotoFilters { + const base: PhotoFilters = { view: viewMode }; + switch (active.kind) { + case 'mediaType': return { ...base, mediaType: active.value }; + case 'favorites': return { ...base, isFavorite: true }; + case 'screenshots': return { ...base, isScreenshot: true }; + case 'category': return { ...base, category: active.value }; + default: return base; + } +} + +// ─── Sub-components ────────────────────────────────────────────────────────── + function SyncBar(): JSX.Element | null { const { data: stats } = useSyncStats(); if (!stats) return null; - const { totalPhotos, uploadedPhotos } = stats; - if (totalPhotos === 0) return null; - - const isPending = uploadedPhotos < totalPhotos; - if (!isPending) return null; - + if (totalPhotos === 0 || uploadedPhotos >= totalPhotos) return null; const pct = Math.min(100, Math.round((uploadedPhotos / totalPhotos) * 100)); - return (
- Syncing files: {uploadedPhotos.toLocaleString()} / {totalPhotos.toLocaleString()} uploaded ({pct}%) + Syncing: {uploadedPhotos.toLocaleString()} / {totalPhotos.toLocaleString()} ({pct}%)
); } -export function GalleryPage(): JSX.Element { - const [activeFilter, setActiveFilter] = useState(FilterPreset.All); - const [activeCategory, setActiveCategory] = useState(null); +interface FilterItemProps { + icon: ReactNode; + label: string; + count?: string; + active: boolean; + onClick: () => void; +} + +function FilterItem({ icon, label, count, active, onClick }: FilterItemProps): JSX.Element { + return ( + + ); +} + +// ─── Main page ──────────────────────────────────────────────────────────────── + +export function GalleryPage(): JSX.Element { + const [activeFilter, setActiveFilter] = useState(INITIAL_FILTER); - // Read URL query parameter for view mode const urlParams = new URLSearchParams(window.location.search); const viewMode = urlParams.get('view') === ViewMode.Uploaded ? ViewMode.Uploaded : ViewMode.All; - const filters = useMemo((): PhotoFilters => { - const baseFilters: PhotoFilters = { view: viewMode }; - if (activeCategory) { - baseFilters.category = activeCategory; - } - switch (activeFilter) { - case FilterPreset.Photos: - return { ...baseFilters, mediaType: MediaType.Image }; - case FilterPreset.Videos: - return { ...baseFilters, mediaType: MediaType.Video }; - case FilterPreset.Favorites: - return { ...baseFilters, isFavorite: true }; - case FilterPreset.Screenshots: - return { ...baseFilters, isScreenshot: true }; - default: - return baseFilters; - } - }, [activeFilter, activeCategory, viewMode]); - - const { - data, - fetchNextPage, - hasNextPage, - isFetchingNextPage, - isLoading, - } = usePhotos(filters); + const filters = useMemo(() => toPhotoFilters(activeFilter, viewMode), [activeFilter, viewMode]); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = usePhotos(filters); + const { data: photoStats } = usePhotoStats(); const { data: categoryCounts } = useCategoryCounts(); - const photos = useMemo(() => { - return data?.pages.flatMap((page) => page.photos) ?? []; - }, [data]); - + const photos = useMemo(() => data?.pages.flatMap((p) => p.photos) ?? [], [data]); const totalCount = data?.pages[0]?.total ?? 0; - const handleLoadMore = useCallback((): void => { - void fetchNextPage(); - }, [fetchNextPage]); + const handleLoadMore = useCallback((): void => { void fetchNextPage(); }, [fetchNextPage]); - const handleCategoryClick = useCallback((category: PhotoCategory): void => { - setActiveCategory((prev) => (prev === category ? null : category)); + const select = useCallback((f: ActiveFilter): void => { + setActiveFilter((prev) => (filterMatches(prev, f) ? INITIAL_FILTER : f)); }, []); - const handleClearCategory = useCallback((): void => { - setActiveCategory(null); - }, []); + // Total across all media types (for "All" count) + const totalAll = useMemo( + () => photoStats?.byMediaType.reduce((sum, r) => sum + Number(r.count), 0).toString() ?? undefined, + [photoStats], + ); - const filterPresets: { value: FilterPreset; label: string; icon?: ReactNode }[] = [ - { value: FilterPreset.All, label: 'All' }, - { value: FilterPreset.Photos, label: 'Photos', icon: }, - { value: FilterPreset.Videos, label: 'Videos' }, - { value: FilterPreset.Favorites, label: 'Favorites', icon: }, - { value: FilterPreset.Screenshots, label: 'Screenshots' }, - ]; - - const activeCategoryLabel = activeCategory ? (CATEGORY_LABELS[activeCategory] ?? activeCategory) : null; - - const visibleCategoryCounts: CategoryCount[] = useMemo(() => { - if (!categoryCounts) return []; - return categoryCounts.filter((cc) => Number(cc.count) > 0); - }, [categoryCounts]); + const visibleCategories = useMemo( + () => (categoryCounts ?? []).filter((cc) => Number(cc.count) > 0), + [categoryCounts], + ); return (
-
-
+ {/* ── Left filter panel ── */} +
+ {Number(photoStats?.screenshotCount ?? 0) > 0 && ( + } + label="Screenshots" + count={photoStats?.screenshotCount} + active={filterMatches(activeFilter, { kind: 'screenshots' })} + onClick={() => select({ kind: 'screenshots' })} + /> + )} +
- {visibleCategoryCounts.length > 0 && ( -
- {activeCategory && ( - + {/* Categories section — only when classification data exists */} + {visibleCategories.length > 0 && ( +
+ Categories + {visibleCategories.map((cc) => { + const cat = cc.category as PhotoCategory; + const label = CATEGORY_LABELS[cc.category ?? 'unclassified'] ?? cc.category ?? 'Unknown'; + return ( + } + label={label} + count={cc.count} + active={filterMatches(activeFilter, { kind: 'category', value: cat })} + onClick={() => select({ kind: 'category', value: cat })} + /> + ); + })} +
)} - {visibleCategoryCounts.map((cc) => ( - - ))} -
- )} + + - - -
+ {/* ── Photo grid ── */} +
{isLoading ? (
{Array.from({ length: 24 }, (_, i) => (