fix: assorted UI polish from review pass

- FilterPill: drop the inline value text from the active state. Pills
  now stay the same width whether or not a filter is set; the popover
  is the canonical place to read the value, and the title attribute
  surfaces it on hover.
- TopBar: remove the search input — search lives in the filter bar now.
- FilterBar: add a search input on the left, with the pill cluster
  centered between it and a flex-shrink-0 Clear-all on the right.
- LeftSidebar / HeapsPanel: count badges use a fixed-width slot
  (h-5 min-w-[24px], tabular-nums) so counts line up in the same
  visual column across rows. Empty rows reserve the slot.
- LeftSidebar: pull section counts (All Photos, Rated, Duplicates,
  Discarded) from a new useLibraryStatsQuery hook backed by the
  expanded /library/stats endpoint. Tags count was already wired.
- backend/library: stats endpoint returns per-section counts that
  match the filter the sidebar applies on click.
- Stats invalidation hooked into the standard photo-mutation paths.
- RightSidebar header: h-12 to match TopBar height.
- Timeline sticky date overlay: only show once the natural in-grid
  header has scrolled OUT of the viewport. Avoids the duplicate-label
  flash when both labels would be visible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 20:45:30 +02:00
parent 696477eefd
commit bd904aca36
13 changed files with 207 additions and 115 deletions

View File

@@ -12,30 +12,68 @@ router = APIRouter()
@router.get("/stats") @router.get("/stats")
async def get_library_stats(db: AsyncSession = Depends(get_db)): async def get_library_stats(db: AsyncSession = Depends(get_db)):
"""Get library statistics""" """Get library statistics + per-section counts. Each section count
# Count total photos matches the filter the sidebar applies when you click it, so the
total_photos = await db.execute( sidebar badges and the timeline below them stay in sync.
select(func.count(Photo.id)).where(Photo.media_type.in_(['photo', 'heic', 'raw']))
)
photo_count = total_photos.scalar()
# Count total videos - all_photos: non-discarded photos + videos (matches the All
total_videos = await db.execute( Photos section's default filter)
select(func.count(Photo.id)).where(Photo.media_type == 'video') - rated: non-discarded with rating >= 1
) - duplicates: non-discarded with is_duplicate = true
video_count = total_videos.scalar() - discarded: is_discarded = true
- total_size: raw bytes across every row, including discarded
"""
not_discarded = Photo.is_discarded.is_(False)
# Calculate total size all_photos_count = (
total_size = await db.execute( await db.execute(select(func.count(Photo.id)).where(not_discarded))
select(func.sum(Photo.file_size)) ).scalar() or 0
)
size = total_size.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 { return {
"all_photos": all_photos_count,
"rated": rated_count,
"duplicates": duplicates_count,
"discarded": discarded_count,
"total_photos": photo_count, "total_photos": photo_count,
"total_videos": video_count, "total_videos": video_count,
"total_size": size, "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") @router.post("/scan")

View File

@@ -50,6 +50,7 @@ export function ScanProgress() {
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] }) queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
queryClient.invalidateQueries({ queryKey: ['heaps'] }) queryClient.invalidateQueries({ queryKey: ['heaps'] })
queryClient.invalidateQueries({ queryKey: ['tags'] }) queryClient.invalidateQueries({ queryKey: ['tags'] })
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
if (isVisible && (scanStatus?.processed_files ?? 0) > 0) { if (isVisible && (scanStatus?.processed_files ?? 0) > 0) {
// Keep showing for 3 seconds after scan completes // Keep showing for 3 seconds after scan completes

View File

@@ -8,6 +8,7 @@ import { discard as discardApi, photos as photosApi } from '../../services/api'
import { toast } from '../ToastContainer' import { toast } from '../ToastContainer'
import { ConfirmDialog } from '../dialogs/ConfirmDialog' import { ConfirmDialog } from '../dialogs/ConfirmDialog'
import { registerUndoable } from '../../store/undoStore' 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. * Top-of-timeline bar visible only when the discarded filter is active.
@@ -32,10 +33,12 @@ export function DiscardActionBar() {
async () => { async () => {
await photosApi.bulkDiscard(ids) await photosApi.bulkDiscard(ids)
queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
} }
) )
clearSelection() clearSelection()
queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
}, },
onError: (e: any) => toast.error('Restore failed', e.message || 'Unknown error'), onError: (e: any) => toast.error('Restore failed', e.message || 'Unknown error'),
}) })
@@ -58,6 +61,7 @@ export function DiscardActionBar() {
} }
clearSelection() clearSelection()
queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
setDeleteSelectedOpen(false) setDeleteSelectedOpen(false)
}, },
onError: (e: any) => onError: (e: any) =>
@@ -79,6 +83,7 @@ export function DiscardActionBar() {
} }
clearSelection() clearSelection()
queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
setConfirmOpen(false) setConfirmOpen(false)
}, },
onError: (e: any) => toast.error('Empty failed', e.message || 'Unknown error'), onError: (e: any) => toast.error('Empty failed', e.message || 'Unknown error'),

View File

@@ -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 clsx from 'clsx'
import { import {
useFilterStore, useFilterStore,
@@ -10,6 +11,8 @@ import { useTagsQuery } from '../../hooks/useTagsQuery'
import { FilterPill } from './FilterPill' import { FilterPill } from './FilterPill'
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels' import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
const SEARCH_DEBOUNCE_MS = 300
const MEDIA_TYPES: { value: MediaType; label: string }[] = [ const MEDIA_TYPES: { value: MediaType; label: string }[] = [
{ value: 'photo', label: 'Photo' }, { value: 'photo', label: 'Photo' },
{ value: 'video', label: 'Video' }, { value: 'video', label: 'Video' },
@@ -57,6 +60,26 @@ export function FilterBar() {
const { data: allTags = [] } = useTagsQuery() 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<number | null>(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. // Pre-compute pill values + active flags so the JSX stays terse.
const dateActive = dateFrom !== null || dateTo !== null const dateActive = dateFrom !== null || dateTo !== null
const dateValue = dateActive const dateValue = dateActive
@@ -93,7 +116,43 @@ export function FilterBar() {
const anyActive = hasActiveFilters(filterState) const anyActive = hasActiveFilters(filterState)
return ( return (
<div className="flex items-center justify-center gap-1.5 overflow-x-auto border-b border-border bg-surface px-3 py-1.5"> <div className="flex items-center gap-3 border-b border-border bg-surface px-3 py-1.5">
{/* Search — left of the pill cluster. Same id as before so the
* global "/" focus shortcut still finds it. */}
<div className="relative w-56 flex-shrink-0">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted" />
<input
id="topbar-search"
type="text"
value={searchQuery}
onChange={(e) => 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 && (
<button
onClick={() => {
setSearchQuery('')
setStoreQ('')
}}
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-full p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
title="Clear search (Esc)"
aria-label="Clear search"
>
<X className="h-3 w-3" />
</button>
)}
</div>
{/* Pills — centered, scroll horizontally if they overflow. */}
<div className="flex flex-1 items-center justify-center gap-1.5 overflow-x-auto">
{/* Date */} {/* Date */}
<FilterPill <FilterPill
label="Date" label="Date"
@@ -316,11 +375,13 @@ export function FilterBar() {
</button> </button>
</div> </div>
</FilterPill> </FilterPill>
</div>
{/* Clear-all — pinned right of the pill cluster. */}
{anyActive && ( {anyActive && (
<button <button
onClick={clearAll} onClick={clearAll}
className="ml-2 whitespace-nowrap rounded-full border border-border px-2.5 py-1 text-xs text-text-muted hover:bg-surface-2 hover:text-text" className="flex-shrink-0 whitespace-nowrap rounded-full border border-border px-2.5 py-1 text-xs text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear all filters in this section" title="Clear all filters in this section"
> >
Clear all Clear all

View File

@@ -6,9 +6,10 @@ import clsx from 'clsx'
interface FilterPillProps { interface FilterPillProps {
/** Category label, always shown ("Date", "Type", etc.). */ /** Category label, always shown ("Date", "Type", etc.). */
label: string label: string
/** When the filter is active, a short summary of its current value /** Currently unused in the rendered output — the inline value display
* ("≥ 3★", "RAW + Photo", "Mar 2024 → Apr 2026"). Renders inside the * was making active pills wider than inactive ones. Kept on the
* pill so the user sees the state without opening the popover. */ * interface so callers don't have to change. The value is still
* surfaced via the title attribute for hover discovery. */
value?: string | null value?: string | null
isActive?: boolean isActive?: boolean
/** When provided + isActive, an X appears inside the pill that clears /** When provided + isActive, an X appears inside the pill that clears
@@ -94,6 +95,10 @@ export function FilterPill({
<button <button
ref={buttonRef} ref={buttonRef}
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
// Hover to see the active value as a tooltip — keeps the pill at
// a constant width regardless of state. The popover is the
// canonical place to read/edit the filter value.
title={isActive && value ? `${label}: ${value}` : label}
className={clsx( className={clsx(
'flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition-colors', 'flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition-colors',
isActive isActive
@@ -102,9 +107,6 @@ export function FilterPill({
)} )}
> >
<span className={clsx(isActive && 'font-medium')}>{label}</span> <span className={clsx(isActive && 'font-medium')}>{label}</span>
{isActive && value && (
<span className="font-mono text-[11px] opacity-90">{value}</span>
)}
{isActive && onClear ? ( {isActive && onClear ? (
<span <span
role="button" role="button"

View File

@@ -338,19 +338,23 @@ export function HeapsPanel() {
</span> </span>
)} )}
{/* Right-aligned action cluster: active indicator, count, {/* Right-aligned cluster. Active indicator + count are
* target toggle, kebab menu. The flex-1 on the name span * always visible; set-active and kebab appear on hover
* pushes everything below to the right edge of the row. */} * to the RIGHT of the count, displacing it slightly so
* the count column lines up with the rest of the
* sidebar in the resting state. */}
{isActive && ( {isActive && (
<Target <Target
className="h-3 w-3 flex-shrink-0 text-primary" className="h-3 w-3 flex-shrink-0 text-primary"
aria-label="Active heap (T target)" aria-label="Active heap (T target)"
/> />
)} )}
{heap.photo_count > 0 && ( {heap.photo_count > 0 ? (
<span className="flex-shrink-0 rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted"> <span className="flex h-5 min-w-[24px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1.5 text-xs tabular-nums text-text-muted">
{heap.photo_count} {heap.photo_count}
</span> </span>
) : (
<span className="h-5 min-w-[24px] flex-shrink-0" aria-hidden="true" />
)} )}
{!isActive && ( {!isActive && (
<button <button

View File

@@ -21,6 +21,10 @@ import { HeapsPanel } from '../heaps/HeapsPanel'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail' import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery' import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
import { useTagsQuery } from '../../hooks/useTagsQuery' import { useTagsQuery } from '../../hooks/useTagsQuery'
import {
useLibraryStatsQuery,
LIBRARY_STATS_QUERY_KEY,
} from '../../hooks/useLibraryStatsQuery'
import { registerUndoable } from '../../store/undoStore' import { registerUndoable } from '../../store/undoStore'
import type { Photo } from '../../types/photo' import type { Photo } from '../../types/photo'
@@ -45,6 +49,7 @@ export function LeftSidebar() {
const navigateToSection = useFilterStore((s) => s.navigateToSection) const navigateToSection = useFilterStore((s) => s.navigateToSection)
const currentSection = useFilterStore((s) => s.currentSection) const currentSection = useFilterStore((s) => s.currentSection)
const { data: allTags = [] } = useTagsQuery() const { data: allTags = [] } = useTagsQuery()
const { data: stats } = useLibraryStatsQuery()
const [dropTargetId, setDropTargetId] = useState<string | null>(null) const [dropTargetId, setDropTargetId] = useState<string | null>(null)
// Bulk discard mutation for the drag-onto-Discarded interaction. // Bulk discard mutation for the drag-onto-Discarded interaction.
@@ -56,9 +61,11 @@ export function LeftSidebar() {
async () => { async () => {
await photosApi.bulkRestore(photoIds) await photosApi.bulkRestore(photoIds)
queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
} }
) )
queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
}, },
onError: (e: any) => onError: (e: any) =>
toast.error('Discard failed', e?.message || 'Unknown error'), toast.error('Discard failed', e?.message || 'Unknown error'),
@@ -270,11 +277,11 @@ export function LeftSidebar() {
label: 'Views', label: 'Views',
icon: <Layers2 className="h-4 w-4" />, icon: <Layers2 className="h-4 w-4" />,
children: [ children: [
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 }, { id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: stats?.all_photos ?? 0 },
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 }, { id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: stats?.rated ?? 0 },
{ id: 'tags', label: 'Tags', icon: <TagIcon className="h-4 w-4" />, count: tagsTotalCount }, { id: 'tags', label: 'Tags', icon: <TagIcon className="h-4 w-4" />, count: tagsTotalCount },
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: 0 }, { id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: stats?.duplicates ?? 0 },
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 }, { id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: stats?.discarded ?? 0 },
], ],
}, },
{ {
@@ -438,11 +445,14 @@ export function LeftSidebar() {
<span className="flex-1 truncate">{item.label}</span> <span className="flex-1 truncate">{item.label}</span>
)} )}
{/* Count Badge */} {/* Count Badge — fixed-width slot so counts line up in a column
{item.count !== undefined && item.count > 0 && ( * across rows regardless of digit count. */}
<span className="rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted"> {item.count !== undefined && item.count > 0 ? (
<span className="flex h-5 min-w-[24px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1.5 text-xs tabular-nums text-text-muted">
{item.count} {item.count}
</span> </span>
) : (
<span className="h-5 min-w-[24px] flex-shrink-0" aria-hidden="true" />
)} )}
</div> </div>

View File

@@ -150,7 +150,7 @@ export function RightSidebar() {
const id = activePhotoId ?? selectedPhotos[0] const id = activePhotoId ?? selectedPhotos[0]
return ( return (
<div className="flex h-full flex-col bg-surface"> <div className="flex h-full flex-col bg-surface">
<div className="flex items-center justify-between border-b border-border px-4 py-3"> <div className="flex h-12 flex-shrink-0 items-center justify-between border-b border-border px-4">
<h2 className="text-sm font-semibold text-text">Photo Details</h2> <h2 className="text-sm font-semibold text-text">Photo Details</h2>
<button <button
onClick={clearSelection} onClick={clearSelection}

View File

@@ -1,46 +1,17 @@
import { useState, useEffect, useRef } from 'react' import { ShoppingBasket } from 'lucide-react'
import { Search, X, ShoppingBasket } from 'lucide-react'
import { useFilterStore } from '../../store/filterStore'
import { useHeapsQuery } from '../../hooks/useHeapsQuery' import { useHeapsQuery } from '../../hooks/useHeapsQuery'
import muliLogo from '../../assets/muli-logo.png' import muliLogo from '../../assets/muli-logo.png'
const SEARCH_DEBOUNCE_MS = 300 /**
* Slim top bar — just the logo and the active-heap pill. The search input
* lives in the FilterBar now (next to the rest of the filter controls).
*/
export function TopBar() { export function TopBar() {
// Filter store is the source of truth for search; the input has a local
// mirror so typing stays responsive while we debounce store updates.
const storeQ = useFilterStore((s) => s.q)
const setStoreQ = useFilterStore((s) => s.setQ)
const [searchQuery, setSearchQuery] = useState(storeQ)
// Keep local input in sync if the store is changed externally (URL hydrate,
// active-chip removal, clear-all).
useEffect(() => {
setSearchQuery(storeQ)
}, [storeQ])
// Debounce local input -> store.
const debounceRef = useRef<number | null>(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])
// Currently active heap. Shown as a pill so the user always knows where
// their next P-press will land.
const { data: heapsList = [] } = useHeapsQuery() const { data: heapsList = [] } = useHeapsQuery()
const activeHeap = heapsList.find((h) => h.is_active) const activeHeap = heapsList.find((h) => h.is_active)
return ( return (
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4"> <header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
{/* Left — logo + active heap pill */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img src={muliLogo} alt="Mulita" className="h-7 w-7 object-contain" /> <img src={muliLogo} alt="Mulita" className="h-7 w-7 object-contain" />
@@ -57,41 +28,6 @@ export function TopBar() {
)} )}
</div> </div>
{/* Center — search */}
<div className="flex max-w-xl flex-1 items-center px-8">
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-muted" />
<input
id="topbar-search"
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setSearchQuery('')
setStoreQ('')
e.currentTarget.blur()
}
}}
placeholder="Search photos..."
className="w-full rounded-md border border-border bg-bg py-1.5 pl-9 pr-9 text-sm text-text placeholder-text-muted focus:border-primary focus:outline-none"
/>
{searchQuery && (
<button
onClick={() => {
setSearchQuery('')
setStoreQ('')
}}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear search"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
{/* Right — reserved for future actions */}
<div className="flex items-center gap-2" /> <div className="flex items-center gap-2" />
</header> </header>
) )

View File

@@ -257,13 +257,17 @@ export function Timeline() {
return () => el.removeEventListener('scroll', onScroll) return () => el.removeEventListener('scroll', onScroll)
}, []) }, [])
// Find the latest header whose start <= scrollTop. That's the label of // Find the latest header whose BOTTOM is above the viewport top. That's
// the group containing whatever is currently at the top of the viewport. // 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(() => { const stickyLabel = useMemo(() => {
if (headerOffsets.length === 0) return null if (headerOffsets.length === 0) return null
let current: string | null = null let current: string | null = null
for (const h of headerOffsets) { for (const h of headerOffsets) {
if (h.offset <= scrollTop) current = h.label if (h.offset + HEADER_HEIGHT <= scrollTop) current = h.label
else break else break
} }
return current return current

View File

@@ -5,6 +5,7 @@ import { photos as photosApi, heaps as heapsApi, type Heap } from '../services/a
import { HEAPS_QUERY_KEY } from './useHeapsQuery' import { HEAPS_QUERY_KEY } from './useHeapsQuery'
import { toast } from '../components/ToastContainer' import { toast } from '../components/ToastContainer'
import { registerUndoable, useUndoStore } from '../store/undoStore' import { registerUndoable, useUndoStore } from '../store/undoStore'
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
interface KeyboardShortcutsProps { interface KeyboardShortcutsProps {
onToggleLeftSidebar: () => void onToggleLeftSidebar: () => void
@@ -58,6 +59,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
const invalidatePhotoQueries = () => { const invalidatePhotoQueries = () => {
queryClient.invalidateQueries({ queryKey: ['photo'] }) queryClient.invalidateQueries({ queryKey: ['photo'] })
queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
} }
const bulkRatingMutation = useMutation({ const bulkRatingMutation = useMutation({

View File

@@ -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<LibraryStats>({
queryKey: LIBRARY_STATS_QUERY_KEY,
queryFn: library.stats,
staleTime: 30_000,
})
}

View File

@@ -190,12 +190,23 @@ export const library = {
return response.data return response.data
}, },
stats: async () => { stats: async (): Promise<LibraryStats> => {
const response = await api.get('/library/stats') const response = await api.get('/library/stats')
return response.data 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 // Heaps API
export interface Heap { export interface Heap {
id: string id: string