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:
parent
90e58bf7dc
commit
b3e58275bc
5 changed files with 231 additions and 144 deletions
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue