feat(photos): Add photo handling and gallery display functionality with new endpoints, core logic, and a frontend gallery page

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-03-18 22:45:39 -07:00
parent 90e58bf7dc
commit b3e58275bc
5 changed files with 231 additions and 144 deletions

View file

@ -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' })

View file

@ -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',
};
}
}

View file

@ -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<PhotoStats>('/photos/stats'),
});
}
// Sync stats hook
export function useSyncStats() {
return useQuery({

View file

@ -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;

View file

@ -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<string, string> = {
image: 'Photos',
video: 'Videos',
live_photo: 'Live Photos',
};
const CATEGORY_LABELS: Record<string, string> = {
screenshot_receipt: 'Receipts',
@ -31,157 +50,189 @@ const CATEGORY_LABELS: Record<string, string> = {
unclassified: 'Unclassified',
};
const MEDIA_TYPE_ICONS: Record<string, ReactNode> = {
image: <ImageIcon size={14} />,
video: <VideoIcon size={14} />,
live_photo: <LayersIcon size={14} />,
};
// ─── 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 (
<div className={styles.syncBar}>
<div className={styles.syncBarTrack}>
<div className={styles.syncBarFill} style={{ width: `${pct}%` }} />
</div>
<span className={styles.syncBarText}>
Syncing files: {uploadedPhotos.toLocaleString()} / {totalPhotos.toLocaleString()} uploaded ({pct}%)
Syncing: {uploadedPhotos.toLocaleString()} / {totalPhotos.toLocaleString()} ({pct}%)
</span>
</div>
);
}
export function GalleryPage(): JSX.Element {
const [activeFilter, setActiveFilter] = useState<FilterPreset>(FilterPreset.All);
const [activeCategory, setActiveCategory] = useState<PhotoCategory | null>(null);
interface FilterItemProps {
icon: ReactNode;
label: string;
count?: string;
active: boolean;
onClick: () => void;
}
function FilterItem({ icon, label, count, active, onClick }: FilterItemProps): JSX.Element {
return (
<button
type="button"
className={`${styles.filterItem} ${active ? styles.active : ''}`}
onClick={onClick}
>
<span className={styles.filterItemIcon}>{icon}</span>
<span className={styles.filterItemLabel}>{label}</span>
{count !== undefined && <span className={styles.filterItemCount}>{Number(count).toLocaleString()}</span>}
</button>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
export function GalleryPage(): JSX.Element {
const [activeFilter, setActiveFilter] = useState<ActiveFilter>(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: <ImageIcon size={14} /> },
{ value: FilterPreset.Videos, label: 'Videos' },
{ value: FilterPreset.Favorites, label: 'Favorites', icon: <HeartIcon size={14} /> },
{ 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 (
<div className={styles.page}>
<header className={styles.header}>
<div className={styles.titleSection}>
{/* ── Left filter panel ── */}
<aside className={styles.filterPanel}>
<div className={styles.filterPanelHeader}>
<h1 className={styles.title}>Gallery</h1>
<span className={styles.count}>
{totalCount.toLocaleString()} items
{viewMode === ViewMode.Uploaded && <span className={styles.viewHint}>(uploaded only)</span>}
{viewMode === ViewMode.All && <span className={styles.viewHint}>(all metadata)</span>}
{activeCategoryLabel && (
<span className={styles.categoryHint}> filtered by: {activeCategoryLabel}</span>
<span className={styles.totalCount}>{totalCount.toLocaleString()} items</span>
</div>
<SyncBar />
<nav className={styles.filterNav}>
{/* Show section */}
<div className={styles.filterSection}>
<span className={styles.filterSectionTitle}>Show</span>
<FilterItem
icon={<GridIcon size={14} />}
label="All"
count={totalAll}
active={filterMatches(activeFilter, { kind: 'all' })}
onClick={() => select({ kind: 'all' })}
/>
{photoStats?.byMediaType.map(({ mediaType, count }) => (
<FilterItem
key={mediaType}
icon={MEDIA_TYPE_ICONS[mediaType] ?? <ImageIcon size={14} />}
label={MEDIA_TYPE_LABELS[mediaType] ?? mediaType}
count={count}
active={filterMatches(activeFilter, { kind: 'mediaType', value: mediaType as MediaType })}
onClick={() => select({ kind: 'mediaType', value: mediaType as MediaType })}
/>
))}
{Number(photoStats?.favoriteCount ?? 0) > 0 && (
<FilterItem
icon={<HeartIcon size={14} />}
label="Favorites"
count={photoStats?.favoriteCount}
active={filterMatches(activeFilter, { kind: 'favorites' })}
onClick={() => select({ kind: 'favorites' })}
/>
)}
</span>
</div>
<div className={styles.filters}>
{filterPresets.map((preset) => (
<button
key={preset.value}
className={`${styles.filterButton} ${activeFilter === preset.value ? styles.active : ''}`}
onClick={() => setActiveFilter(preset.value)}
type="button"
>
{preset.icon}
<span>{preset.label}</span>
</button>
))}
</div>
</header>
{Number(photoStats?.screenshotCount ?? 0) > 0 && (
<FilterItem
icon={<MonitorIcon size={14} />}
label="Screenshots"
count={photoStats?.screenshotCount}
active={filterMatches(activeFilter, { kind: 'screenshots' })}
onClick={() => select({ kind: 'screenshots' })}
/>
)}
</div>
{visibleCategoryCounts.length > 0 && (
<div className={styles.categoryBar}>
{activeCategory && (
<button
className={styles.clearCategory}
onClick={handleClearCategory}
type="button"
>
Clear
</button>
{/* Categories section — only when classification data exists */}
{visibleCategories.length > 0 && (
<div className={styles.filterSection}>
<span className={styles.filterSectionTitle}>Categories</span>
{visibleCategories.map((cc) => {
const cat = cc.category as PhotoCategory;
const label = CATEGORY_LABELS[cc.category ?? 'unclassified'] ?? cc.category ?? 'Unknown';
return (
<FilterItem
key={cc.category ?? 'null'}
icon={<TagIcon size={14} />}
label={label}
count={cc.count}
active={filterMatches(activeFilter, { kind: 'category', value: cat })}
onClick={() => select({ kind: 'category', value: cat })}
/>
);
})}
</div>
)}
{visibleCategoryCounts.map((cc) => (
<button
key={cc.category ?? 'null'}
className={`${styles.categoryChip} ${activeCategory === cc.category ? styles.active : ''}`}
onClick={() => handleCategoryClick(cc.category as PhotoCategory)}
type="button"
>
{CATEGORY_LABELS[cc.category ?? 'unclassified'] ?? (cc.category ?? 'Unknown')} ({cc.count})
</button>
))}
</div>
)}
</nav>
</aside>
<SyncBar />
<div className={styles.content}>
{/* ── Photo grid ── */}
<div className={styles.gridArea}>
{isLoading ? (
<div className={styles.loadingGrid}>
{Array.from({ length: 24 }, (_, i) => (