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:
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<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.
|
||||
const dateActive = dateFrom !== null || dateTo !== null
|
||||
const dateValue = dateActive
|
||||
@@ -93,7 +116,43 @@ export function FilterBar() {
|
||||
const anyActive = hasActiveFilters(filterState)
|
||||
|
||||
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 */}
|
||||
<FilterPill
|
||||
label="Date"
|
||||
@@ -316,11 +375,13 @@ export function FilterBar() {
|
||||
</button>
|
||||
</div>
|
||||
</FilterPill>
|
||||
</div>
|
||||
|
||||
{/* Clear-all — pinned right of the pill cluster. */}
|
||||
{anyActive && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
Clear all
|
||||
|
||||
@@ -6,9 +6,10 @@ import clsx from 'clsx'
|
||||
interface FilterPillProps {
|
||||
/** Category label, always shown ("Date", "Type", etc.). */
|
||||
label: string
|
||||
/** When the filter is active, a short summary of its current value
|
||||
* ("≥ 3★", "RAW + Photo", "Mar 2024 → Apr 2026"). Renders inside the
|
||||
* pill so the user sees the state without opening the popover. */
|
||||
/** Currently unused in the rendered output — the inline value display
|
||||
* was making active pills wider than inactive ones. Kept on the
|
||||
* interface so callers don't have to change. The value is still
|
||||
* surfaced via the title attribute for hover discovery. */
|
||||
value?: string | null
|
||||
isActive?: boolean
|
||||
/** When provided + isActive, an X appears inside the pill that clears
|
||||
@@ -94,6 +95,10 @@ export function FilterPill({
|
||||
<button
|
||||
ref={buttonRef}
|
||||
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(
|
||||
'flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition-colors',
|
||||
isActive
|
||||
@@ -102,9 +107,6 @@ export function FilterPill({
|
||||
)}
|
||||
>
|
||||
<span className={clsx(isActive && 'font-medium')}>{label}</span>
|
||||
{isActive && value && (
|
||||
<span className="font-mono text-[11px] opacity-90">{value}</span>
|
||||
)}
|
||||
{isActive && onClear ? (
|
||||
<span
|
||||
role="button"
|
||||
|
||||
@@ -338,19 +338,23 @@ export function HeapsPanel() {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Right-aligned action cluster: active indicator, count,
|
||||
* target toggle, kebab menu. The flex-1 on the name span
|
||||
* pushes everything below to the right edge of the row. */}
|
||||
{/* Right-aligned cluster. Active indicator + count are
|
||||
* always visible; set-active and kebab appear on hover
|
||||
* 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 && (
|
||||
<Target
|
||||
className="h-3 w-3 flex-shrink-0 text-primary"
|
||||
aria-label="Active heap (T target)"
|
||||
/>
|
||||
)}
|
||||
{heap.photo_count > 0 && (
|
||||
<span className="flex-shrink-0 rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted">
|
||||
{heap.photo_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">
|
||||
{heap.photo_count}
|
||||
</span>
|
||||
) : (
|
||||
<span className="h-5 min-w-[24px] flex-shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
{!isActive && (
|
||||
<button
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user