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

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

View File

@@ -150,7 +150,7 @@ export function RightSidebar() {
const id = activePhotoId ?? selectedPhotos[0]
return (
<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>
<button
onClick={clearSelection}

View File

@@ -1,46 +1,17 @@
import { useState, useEffect, useRef } from 'react'
import { Search, X, ShoppingBasket } from 'lucide-react'
import { useFilterStore } from '../../store/filterStore'
import { ShoppingBasket } from 'lucide-react'
import { useHeapsQuery } from '../../hooks/useHeapsQuery'
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() {
// 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 activeHeap = heapsList.find((h) => h.is_active)
return (
<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-2">
<img src={muliLogo} alt="Mulita" className="h-7 w-7 object-contain" />
@@ -57,41 +28,6 @@ export function TopBar() {
)}
</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" />
</header>
)