diff --git a/backend/app/routers/library.py b/backend/app/routers/library.py index 887b4fe..4d74176 100644 --- a/backend/app/routers/library.py +++ b/backend/app/routers/library.py @@ -12,30 +12,68 @@ router = APIRouter() @router.get("/stats") async def get_library_stats(db: AsyncSession = Depends(get_db)): - """Get library statistics""" - # Count total photos - total_photos = await db.execute( - select(func.count(Photo.id)).where(Photo.media_type.in_(['photo', 'heic', 'raw'])) - ) - photo_count = total_photos.scalar() - - # Count total videos - total_videos = await db.execute( - select(func.count(Photo.id)).where(Photo.media_type == 'video') - ) - video_count = total_videos.scalar() - - # Calculate total size - total_size = await db.execute( - select(func.sum(Photo.file_size)) - ) - size = total_size.scalar() or 0 - + """Get library statistics + per-section counts. Each section count + matches the filter the sidebar applies when you click it, so the + sidebar badges and the timeline below them stay in sync. + + - all_photos: non-discarded photos + videos (matches the All + Photos section's default filter) + - rated: non-discarded with rating >= 1 + - duplicates: non-discarded with is_duplicate = true + - discarded: is_discarded = true + - total_size: raw bytes across every row, including discarded + """ + not_discarded = Photo.is_discarded.is_(False) + + all_photos_count = ( + await db.execute(select(func.count(Photo.id)).where(not_discarded)) + ).scalar() or 0 + + rated_count = ( + await db.execute( + select(func.count(Photo.id)).where(not_discarded, Photo.rating >= 1) + ) + ).scalar() or 0 + + duplicates_count = ( + await db.execute( + select(func.count(Photo.id)).where( + not_discarded, Photo.is_duplicate.is_(True) + ) + ) + ).scalar() or 0 + + discarded_count = ( + await db.execute( + select(func.count(Photo.id)).where(Photo.is_discarded.is_(True)) + ) + ).scalar() or 0 + + # Legacy split (kept for the existing /stats consumers). + photo_count = ( + await db.execute( + select(func.count(Photo.id)).where( + Photo.media_type.in_(['photo', 'heic', 'raw']) + ) + ) + ).scalar() or 0 + video_count = ( + await db.execute( + select(func.count(Photo.id)).where(Photo.media_type == 'video') + ) + ).scalar() or 0 + + size = (await db.execute(select(func.sum(Photo.file_size)))).scalar() or 0 + return { + "all_photos": all_photos_count, + "rated": rated_count, + "duplicates": duplicates_count, + "discarded": discarded_count, "total_photos": photo_count, "total_videos": video_count, "total_size": size, - "total_size_gb": round(size / (1024**3), 2) if size else 0 + "total_size_gb": round(size / (1024**3), 2) if size else 0, } @router.post("/scan") diff --git a/frontend/src/components/ScanProgress.tsx b/frontend/src/components/ScanProgress.tsx index 3aa8434..bb5b3ee 100644 --- a/frontend/src/components/ScanProgress.tsx +++ b/frontend/src/components/ScanProgress.tsx @@ -50,6 +50,7 @@ export function ScanProgress() { queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] }) queryClient.invalidateQueries({ queryKey: ['heaps'] }) queryClient.invalidateQueries({ queryKey: ['tags'] }) + queryClient.invalidateQueries({ queryKey: ['library', 'stats'] }) if (isVisible && (scanStatus?.processed_files ?? 0) > 0) { // Keep showing for 3 seconds after scan completes diff --git a/frontend/src/components/discard/DiscardActionBar.tsx b/frontend/src/components/discard/DiscardActionBar.tsx index c42f55b..7b6b631 100644 --- a/frontend/src/components/discard/DiscardActionBar.tsx +++ b/frontend/src/components/discard/DiscardActionBar.tsx @@ -8,6 +8,7 @@ import { discard as discardApi, photos as photosApi } from '../../services/api' import { toast } from '../ToastContainer' import { ConfirmDialog } from '../dialogs/ConfirmDialog' import { registerUndoable } from '../../store/undoStore' +import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery' /** * Top-of-timeline bar visible only when the discarded filter is active. @@ -32,10 +33,12 @@ export function DiscardActionBar() { async () => { await photosApi.bulkDiscard(ids) queryClient.invalidateQueries({ queryKey: ['photos'] }) + queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) } ) clearSelection() queryClient.invalidateQueries({ queryKey: ['photos'] }) + queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) }, onError: (e: any) => toast.error('Restore failed', e.message || 'Unknown error'), }) @@ -58,6 +61,7 @@ export function DiscardActionBar() { } clearSelection() queryClient.invalidateQueries({ queryKey: ['photos'] }) + queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) setDeleteSelectedOpen(false) }, onError: (e: any) => @@ -79,6 +83,7 @@ export function DiscardActionBar() { } clearSelection() queryClient.invalidateQueries({ queryKey: ['photos'] }) + queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) setConfirmOpen(false) }, onError: (e: any) => toast.error('Empty failed', e.message || 'Unknown error'), diff --git a/frontend/src/components/filter/FilterBar.tsx b/frontend/src/components/filter/FilterBar.tsx index d719892..43c80fa 100644 --- a/frontend/src/components/filter/FilterBar.tsx +++ b/frontend/src/components/filter/FilterBar.tsx @@ -1,4 +1,5 @@ -import { Star, X, ArrowDown, ArrowUp } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { Star, X, ArrowDown, ArrowUp, Search } from 'lucide-react' import clsx from 'clsx' import { useFilterStore, @@ -10,6 +11,8 @@ import { useTagsQuery } from '../../hooks/useTagsQuery' import { FilterPill } from './FilterPill' import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels' +const SEARCH_DEBOUNCE_MS = 300 + const MEDIA_TYPES: { value: MediaType; label: string }[] = [ { value: 'photo', label: 'Photo' }, { value: 'video', label: 'Video' }, @@ -57,6 +60,26 @@ export function FilterBar() { const { data: allTags = [] } = useTagsQuery() + // Search box. Local state mirrors the store so typing stays responsive + // while we debounce store writes (each store write triggers a re-fetch). + const storeQ = useFilterStore((s) => s.q) + const setStoreQ = useFilterStore((s) => s.setQ) + const [searchQuery, setSearchQuery] = useState(storeQ) + useEffect(() => { + setSearchQuery(storeQ) + }, [storeQ]) + const debounceRef = useRef(null) + useEffect(() => { + if (searchQuery === storeQ) return + if (debounceRef.current) window.clearTimeout(debounceRef.current) + debounceRef.current = window.setTimeout(() => { + setStoreQ(searchQuery) + }, SEARCH_DEBOUNCE_MS) + return () => { + if (debounceRef.current) window.clearTimeout(debounceRef.current) + } + }, [searchQuery, storeQ, setStoreQ]) + // Pre-compute pill values + active flags so the JSX stays terse. const dateActive = dateFrom !== null || dateTo !== null const dateValue = dateActive @@ -93,7 +116,43 @@ export function FilterBar() { const anyActive = hasActiveFilters(filterState) return ( -
+
+ {/* Search — left of the pill cluster. Same id as before so the + * global "/" focus shortcut still finds it. */} +
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setSearchQuery('') + setStoreQ('') + e.currentTarget.blur() + } + }} + placeholder="Search photos…" + className="w-full rounded-full border border-border bg-surface-2 py-1 pl-8 pr-7 text-xs text-text placeholder-text-muted focus:border-primary focus:outline-none" + /> + {searchQuery && ( + + )} +
+ + {/* Pills — centered, scroll horizontally if they overflow. */} +
{/* Date */}
+
+ {/* Clear-all — pinned right of the pill cluster. */} {anyActive && (
diff --git a/frontend/src/components/layout/RightSidebar.tsx b/frontend/src/components/layout/RightSidebar.tsx index eba7bb0..f6d115b 100644 --- a/frontend/src/components/layout/RightSidebar.tsx +++ b/frontend/src/components/layout/RightSidebar.tsx @@ -150,7 +150,7 @@ export function RightSidebar() { const id = activePhotoId ?? selectedPhotos[0] return (
-
+

Photo Details

- )} -
-
- - {/* Right — reserved for future actions */}
) diff --git a/frontend/src/components/timeline/Timeline.tsx b/frontend/src/components/timeline/Timeline.tsx index 713a31c..fee1824 100644 --- a/frontend/src/components/timeline/Timeline.tsx +++ b/frontend/src/components/timeline/Timeline.tsx @@ -257,13 +257,17 @@ export function Timeline() { return () => el.removeEventListener('scroll', onScroll) }, []) - // Find the latest header whose start <= scrollTop. That's the label of - // the group containing whatever is currently at the top of the viewport. + // Find the latest header whose BOTTOM is above the viewport top. That's + // the group whose natural in-grid header has scrolled out of view — + // exactly the case where we want to pin the label as a sticky overlay. + // If the natural header is still visible (scrolled but not yet past), + // we return null and let the in-grid label do the work, avoiding the + // duplicate-label flash. const stickyLabel = useMemo(() => { if (headerOffsets.length === 0) return null let current: string | null = null for (const h of headerOffsets) { - if (h.offset <= scrollTop) current = h.label + if (h.offset + HEADER_HEIGHT <= scrollTop) current = h.label else break } return current diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts index 4723fd3..88d46a5 100644 --- a/frontend/src/hooks/useKeyboardShortcuts.ts +++ b/frontend/src/hooks/useKeyboardShortcuts.ts @@ -5,6 +5,7 @@ import { photos as photosApi, heaps as heapsApi, type Heap } from '../services/a import { HEAPS_QUERY_KEY } from './useHeapsQuery' import { toast } from '../components/ToastContainer' import { registerUndoable, useUndoStore } from '../store/undoStore' +import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery' interface KeyboardShortcutsProps { onToggleLeftSidebar: () => void @@ -58,6 +59,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) { const invalidatePhotoQueries = () => { queryClient.invalidateQueries({ queryKey: ['photo'] }) queryClient.invalidateQueries({ queryKey: ['photos'] }) + queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) } const bulkRatingMutation = useMutation({ diff --git a/frontend/src/hooks/useLibraryStatsQuery.ts b/frontend/src/hooks/useLibraryStatsQuery.ts new file mode 100644 index 0000000..5078a03 --- /dev/null +++ b/frontend/src/hooks/useLibraryStatsQuery.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query' +import { library, type LibraryStats } from '../services/api' + +export const LIBRARY_STATS_QUERY_KEY = ['library', 'stats'] as const + +/** + * Per-section counts for the LeftSidebar badges (All Photos, Rated, + * Duplicates, Discarded). Cached briefly so navigating around doesn't + * re-fetch on every click; invalidated on photo mutations through the + * standard ['photos'] invalidation in the mutation onSuccess paths. + */ +export function useLibraryStatsQuery() { + return useQuery({ + queryKey: LIBRARY_STATS_QUERY_KEY, + queryFn: library.stats, + staleTime: 30_000, + }) +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 9024d6d..a0beaaa 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -190,12 +190,23 @@ export const library = { return response.data }, - stats: async () => { + stats: async (): Promise => { const response = await api.get('/library/stats') return response.data }, } +export interface LibraryStats { + all_photos: number + rated: number + duplicates: number + discarded: number + total_photos: number + total_videos: number + total_size: number + total_size_gb: number +} + // Heaps API export interface Heap { id: string