perf+style: grid subscription hygiene, a11y, shadcn-style consistency

Perf / a11y (high-impact review items)
- Timeline arrow-key handler binds once per (viewMode, currentSection)
  and reads fresh state via navStateRef instead of an 8-element dep
  array of new-each-render values.
- usePhotosQuery collapses 14 individual Zustand selectors into one
  useShallow selector returning the params object.
- PhotoThumbnail no longer subscribes to the search query directly;
  Timeline subscribes once and passes it down as a prop.
- PhotoThumbnail gains role="button", tabIndex, aria-label, aria-pressed,
  Enter/Space key handlers and a focus-visible ring. Timeline marked
  role="grid"; RightSidebar marked role="region".

Style consistency
- Swap clsx for cn (tailwind-merge aware) across 17 files so
  conflicting utility classes collapse correctly.
- New Badge primitive (ui/badge.tsx) with default/neutral/overlay/
  outline variants; adopted in ColorsView, RatedView, TagsView for
  the repeated count overlay pill.
- Fix palette drift: text-amber-400 -> text-star, text-green-*
  -> text-pick, text-red-* -> text-reject (5 files).
- Button gains an xs size (h-6 px-1.5 text-[11px]) for the repeated
  compact-button pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-15 10:30:16 +02:00
parent e65e798021
commit a073ee7fb9
21 changed files with 268 additions and 160 deletions

View File

@@ -105,11 +105,7 @@ export function UserManagement() {
</td>
<td className="py-1.5 pr-4">
<span
className={
u.is_active
? 'text-green-400'
: 'text-red-400'
}
className={u.is_active ? 'text-pick' : 'text-reject'}
>
{u.is_active ? 'Active' : 'Inactive'}
</span>
@@ -129,7 +125,7 @@ export function UserManagement() {
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:text-red-400"
className="h-6 w-6 hover:text-reject"
onClick={() => setDeactivatingUser(u)}
title="Deactivate user"
>

View File

@@ -1,7 +1,8 @@
import { useState, useMemo, useCallback } from 'react'
import { Palette, ArrowLeft, Loader2 } from 'lucide-react'
import clsx from 'clsx'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { photos as photosApi } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
@@ -149,7 +150,7 @@ export function ColorsView() {
{groups.map((group, i) => (
<div
key={group.label}
className={clsx(
className={cn(
'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
i === activeIndex
? 'border-primary ring-1 ring-primary/30'
@@ -170,9 +171,12 @@ export function ColorsView() {
</div>
)}
<span className="absolute bottom-1.5 right-1.5 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm">
<Badge
variant="overlay"
className="absolute bottom-1.5 right-1.5"
>
{group.count}
</span>
</Badge>
</div>
<div className="flex items-center gap-1.5 px-2 py-1.5">

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import clsx from 'clsx'
import { cn } from '@/lib/utils'
import { Trash2, Archive } from 'lucide-react'
import {
@@ -124,7 +124,7 @@ function ModeCard({
return (
<Label
htmlFor={id}
className={clsx(
className={cn(
'flex w-full cursor-pointer gap-3 rounded-lg border p-3 text-left transition-colors',
selected
? destructive
@@ -135,7 +135,7 @@ function ModeCard({
>
<RadioGroupItem id={id} value={value} className="mt-0.5" />
<div
className={clsx(
className={cn(
'mt-0.5 flex-shrink-0',
selected
? destructive
@@ -148,7 +148,7 @@ function ModeCard({
</div>
<div className="flex-1">
<div
className={clsx(
className={cn(
'text-sm font-medium',
selected
? destructive

View File

@@ -18,7 +18,7 @@ import {
Brain,
RotateCcw,
} from 'lucide-react'
import clsx from 'clsx'
import { cn } from '@/lib/utils'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
library,
@@ -631,7 +631,7 @@ export function SettingsPage() {
{Object.entries(workerStatus.queues).map(([name, depth]) => (
<div
key={name}
className={clsx(
className={cn(
'flex items-center justify-between rounded px-2 py-1',
name === 'vision' && depth > 0
? 'bg-surface-2'
@@ -640,7 +640,7 @@ export function SettingsPage() {
>
<span className="text-text-muted">{name}</span>
<span
className={clsx(
className={cn(
'font-mono font-semibold',
depth > 0 ? 'text-text' : 'text-text-muted'
)}
@@ -924,7 +924,7 @@ function Stat({
<div className="text-[10px] uppercase tracking-wide text-text-muted">
{label}
</div>
<div className={clsx('text-base font-semibold', toneClass)}>
<div className={cn('text-base font-semibold', toneClass)}>
{value ?? '—'}
</div>
</div>
@@ -1016,7 +1016,7 @@ function PipelineRow({ stage }: { stage: PipelineStage }) {
</span>
</div>
<span
className={clsx(
className={cn(
'shrink-0 font-mono text-[11px]',
isComplete
? 'text-pick'
@@ -1045,7 +1045,7 @@ function ProgressBar({ done, total }: { done: number; total: number }) {
return (
<div className="h-1 w-full overflow-hidden rounded bg-border">
<div
className={clsx(
className={cn(
'h-full rounded transition-[width] duration-500',
complete ? 'bg-pick' : 'bg-text-muted'
)}
@@ -1072,7 +1072,7 @@ function ActionButton({
<button
onClick={onClick}
disabled={disabled || loading}
className={clsx(
className={cn(
'flex items-center gap-2 rounded border px-3 py-1.5 text-xs font-medium transition-colors',
destructive
? 'border-reject/40 text-reject hover:bg-reject/10'
@@ -1190,7 +1190,7 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
return (
<div
key={meta.id}
className={clsx(
className={cn(
'rounded border border-border bg-surface p-3 text-xs',
dimmed && 'opacity-60',
)}

View File

@@ -1,6 +1,6 @@
import { useMemo, useState, useEffect, useCallback } from 'react'
import { Copy, Layers, Sparkles, Trash2, Loader2, Info, Crown } from 'lucide-react'
import clsx from 'clsx'
import { cn } from '@/lib/utils'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Button } from '@/components/ui/button'
import { formatApiError } from '../../lib/apiError'
@@ -323,7 +323,7 @@ function DuplicateGroupSection({
* clearance from PhotoThumbnail's outer selection ring. */}
{isBest && (
<span
className={clsx(
className={cn(
'pointer-events-none absolute right-1.5 top-1.5 z-10 uppercase',
THUMB_BADGE_BASE,
THUMB_BADGE_PICK
@@ -343,7 +343,7 @@ function DuplicateGroupSection({
e.stopPropagation()
setManualBestId(member.id)
}}
className={clsx(
className={cn(
'absolute right-1.5 top-1.5 z-10 hidden uppercase transition hover:bg-pick group-hover/dup:inline-flex',
THUMB_BADGE_BASE,
THUMB_BADGE_NEUTRAL
@@ -359,7 +359,7 @@ function DuplicateGroupSection({
* (also bottom-left) is tolerated: rated duplicates are
* uncommon in practice. */}
<div
className={clsx(
className={cn(
'pointer-events-none absolute bottom-1 left-1 z-10 font-mono',
THUMB_BADGE_BASE,
THUMB_BADGE_NEUTRAL

View File

@@ -10,7 +10,7 @@ import {
PanelRightOpen,
PanelRightClose,
} from 'lucide-react'
import clsx from 'clsx'
import { cn } from '@/lib/utils'
import {
useFilterStore,
hasActiveFilters,
@@ -246,7 +246,7 @@ export function FilterBar({
title={`At least ${n} star${n > 1 ? 's' : ''}`}
>
<Star
className={clsx(
className={cn(
'h-5 w-5 transition-colors',
n <= ratingMin
? 'fill-star text-star'
@@ -273,7 +273,7 @@ export function FilterBar({
<button
key={value}
onClick={() => setColorLabel(active ? null : value)}
className={clsx(
className={cn(
'h-5 w-5 rounded-full ring-offset-2 ring-offset-surface transition-all',
className,
active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'

View File

@@ -1,5 +1,5 @@
import { ChevronDown, X } from 'lucide-react'
import clsx from 'clsx'
import { cn } from '@/lib/utils'
import {
Popover,
PopoverContent,
@@ -49,7 +49,7 @@ export function FilterPill({
<PopoverTrigger asChild>
<button
title={isActive && value ? `${label}: ${value}` : label}
className={clsx(
className={cn(
// Fixed height + py-0 so neither the X clear icon nor the
// chevron can stretch the pill vertically when the active
// state swaps them in.
@@ -59,7 +59,7 @@ export function FilterPill({
: 'border-border bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
<span className={clsx(isActive && 'font-medium')}>{label}</span>
<span className={cn(isActive && 'font-medium')}>{label}</span>
{isActive && onClear ? (
<span
role="button"
@@ -90,7 +90,7 @@ export function FilterPill({
</PopoverTrigger>
<PopoverContent
align="start"
className={clsx('w-auto', contentClassName ?? 'p-2')}
className={cn('w-auto', contentClassName ?? 'p-2')}
>
{children}
</PopoverContent>

View File

@@ -13,7 +13,7 @@ import {
Users,
Download as DownloadIcon,
} from 'lucide-react'
import clsx from 'clsx'
import { cn } from '@/lib/utils'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { useSharedHeapsQuery } from '../../hooks/useSharingQueries'
@@ -262,7 +262,7 @@ export function HeapsPanel() {
return (
<div
key={heap.id}
className={clsx(
className={cn(
'group relative flex h-[24px] cursor-pointer items-center gap-1 rounded px-2 text-[12px] leading-none',
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
// Active-heap row gets a soft primary wash so the
@@ -309,7 +309,7 @@ export function HeapsPanel() {
}}
>
<ShoppingBasket
className={clsx(
className={cn(
'h-3.5 w-3.5 flex-shrink-0',
isActive || isFiltered ? 'text-primary' : 'text-text-muted'
)}
@@ -335,7 +335,7 @@ export function HeapsPanel() {
) : (
<>
<span
className={clsx('truncate', isActive && 'font-semibold')}
className={cn('truncate', isActive && 'font-semibold')}
title={heap.name}
>
{heap.name}
@@ -387,7 +387,7 @@ export function HeapsPanel() {
onOpenChange={(o) => setOpenMenuId(o ? heap.id : null)}
>
<div
className={clsx(
className={cn(
'relative flex-shrink-0',
isMenuOpen ? 'block' : 'hidden group-hover:block'
)}
@@ -474,7 +474,7 @@ export function HeapsPanel() {
return (
<div
key={sh.id}
className={clsx(
className={cn(
'group flex h-[24px] cursor-pointer items-center gap-1 rounded px-2 text-[12px] leading-none',
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
)}
@@ -484,7 +484,7 @@ export function HeapsPanel() {
}
>
<Users
className={clsx(
className={cn(
'h-3.5 w-3.5 flex-shrink-0',
isFiltered ? 'text-primary' : 'text-text-muted'
)}

View File

@@ -26,7 +26,7 @@ import {
Upload as UploadIcon,
Download as DownloadIcon,
} from 'lucide-react'
import clsx from 'clsx'
import { cn } from '@/lib/utils'
import { sourceFolders, photos as photosApi, downloads, type FolderTreeNode } from '../../services/api'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from '../ToastContainer'
@@ -487,11 +487,11 @@ export function LeftSidebar() {
return (
<div key={item.id}>
<div
className={clsx(
className={cn(
'group flex cursor-pointer items-center gap-1',
isSectionHeader
? 'mt-2 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:text-text'
: clsx(
: cn(
// Fixed h-[24px] (not min-h) locks the row height so the
// hover-only kebab button can't grow the row vertically.
'h-[24px] rounded px-2 text-[12px] leading-none',
@@ -577,7 +577,7 @@ export function LeftSidebar() {
* without hunting through the kebab menu. */}
{item.icon && !isSectionHeader && (
<span
className={clsx(
className={cn(
'flex-shrink-0',
isSelected
? 'text-primary'
@@ -619,7 +619,7 @@ export function LeftSidebar() {
/>
) : (
<span
className={clsx(
className={cn(
'flex-1 truncate',
item.isHidden && !isSelected && 'italic text-text-muted/80'
)}
@@ -677,7 +677,7 @@ export function LeftSidebar() {
onOpenChange={(o) => setOpenMenuId(o ? item.id : null)}
>
<div
className={clsx(
className={cn(
'relative flex-shrink-0',
isMenuOpen ? 'block' : 'hidden group-hover:block'
)}
@@ -871,7 +871,7 @@ export function LeftSidebar() {
return (
<div
key={sf.id}
className={clsx(
className={cn(
'flex h-[24px] cursor-pointer items-center gap-1 rounded px-2 text-[12px] leading-none',
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
)}
@@ -881,7 +881,7 @@ export function LeftSidebar() {
}
>
<Users
className={clsx(
className={cn(
'h-3.5 w-3.5 flex-shrink-0',
isSelected ? 'text-primary' : 'text-text-muted'
)}
@@ -922,7 +922,7 @@ export function LeftSidebar() {
)}
<button
onClick={logout}
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-red-400 flex-shrink-0"
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-reject flex-shrink-0"
title="Sign out"
>
<LogOut className="h-3 w-3" />
@@ -933,7 +933,7 @@ export function LeftSidebar() {
{isAdmin && (
<button
onClick={() => navigateToSection('settings', {})}
className={clsx(
className={cn(
'flex w-full items-center gap-2 rounded px-2 py-1 text-[12px] hover:bg-surface-2 hover:text-text',
currentSection === 'settings' ? 'text-primary' : 'text-text-muted',
)}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { X, Star, ShoppingBasket, Trash2 } from 'lucide-react'
import clsx from 'clsx'
import { cn } from '@/lib/utils'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { usePhotoStore } from '../../store/photoStore'
import {
@@ -215,7 +215,11 @@ export function RightSidebar() {
if (selectedPhotos.length === 0) {
return (
<div className="flex h-full flex-col bg-surface">
<div
className="flex h-full flex-col bg-surface"
role="region"
aria-label="Photo metadata"
>
<Header />
<div className="flex flex-1 items-center justify-center p-4 text-center">
<div className="text-text-muted">
@@ -245,7 +249,11 @@ export function RightSidebar() {
if (selectedPhotos.length === 1) {
const id = activePhotoId ?? selectedPhotos[0]
return (
<div className="flex h-full flex-col bg-surface">
<div
className="flex h-full flex-col bg-surface"
role="region"
aria-label="Photo metadata"
>
<Header />
<PhotoInfoPanel photoId={id} />
</div>
@@ -333,7 +341,7 @@ export function RightSidebar() {
onClick={() =>
bulkColorMutation.mutate({ ids: selectedPhotos, color: value })
}
className={clsx(
className={cn(
'h-5 w-5 rounded-full opacity-80 ring-offset-2 ring-offset-surface transition-all hover:opacity-100',
className
)}
@@ -363,7 +371,7 @@ export function RightSidebar() {
heapMutation.mutate({ ids: selectedPhotos, remove: allMembers })
}}
disabled={!activeHeap || heapMutation.isPending}
className={clsx(
className={cn(
allMembers
? 'bg-pick/20 text-pick hover:bg-pick/30'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import { cn } from '@/lib/utils'
import type { Photo } from '../../types/photo'
import { photos as photosApi } from '../../services/api'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
@@ -34,7 +34,7 @@ export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilm
key={photo.id}
ref={isActive ? activeRef : null}
onClick={() => onSelect(photo.id)}
className={clsx(
className={cn(
'relative shrink-0 overflow-hidden rounded-sm will-change-transform transition-[transform,box-shadow,opacity] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
isActive && 'scale-90 opacity-100 ring-2 ring-blue-500 ring-offset-2 ring-offset-surface',
!isActive && isInActiveHeap && 'opacity-100',
@@ -83,7 +83,7 @@ function FilmstripThumb({ photo }: { photo: Photo }) {
decoding="async"
onLoad={() => setLoaded(true)}
onError={() => setErrored(true)}
className={clsx(
className={cn(
'h-full w-full object-cover transition-opacity duration-200',
loaded ? 'opacity-100' : 'opacity-0',
// Match the grid's discarded styling so the filmstrip mirrors

View File

@@ -1,7 +1,8 @@
import { useState, useMemo, useCallback } from 'react'
import { Star, ArrowLeft, Loader2 } from 'lucide-react'
import clsx from 'clsx'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { photos as photosApi } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
@@ -90,7 +91,7 @@ export function RatedView() {
>
<ArrowLeft className="h-4 w-4" />
</Button>
<h2 className="text-sm font-semibold text-amber-400">{selectedGroup.label}</h2>
<h2 className="text-sm font-semibold text-star">{selectedGroup.label}</h2>
</div>
<div className="flex-1 overflow-hidden">
<Timeline />
@@ -137,7 +138,7 @@ export function RatedView() {
{groups.map((group, i) => (
<div
key={group.rating}
className={clsx(
className={cn(
'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
i === activeIndex
? 'border-primary ring-1 ring-primary/30'
@@ -158,13 +159,16 @@ export function RatedView() {
</div>
)}
<span className="absolute bottom-1.5 right-1.5 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm">
<Badge
variant="overlay"
className="absolute bottom-1.5 right-1.5"
>
{group.count}
</span>
</Badge>
</div>
<div className="px-2 py-1.5">
<p className="truncate text-xs font-medium text-amber-400">{group.label}</p>
<p className="truncate text-xs font-medium text-star">{group.label}</p>
</div>
</div>
))}

View File

@@ -10,7 +10,7 @@ import {
ShoppingBasket,
Trash2,
} from 'lucide-react'
import clsx from 'clsx'
import { cn } from '@/lib/utils'
import {
useQuery,
useMutation,
@@ -367,13 +367,13 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
// Single themable input class so the same component reads against either
// the surface (grid sidebar) or a darker preview overlay.
const inputClass = clsx(
const inputClass = cn(
'w-full rounded border px-2 py-1 text-sm focus:outline-none',
darkTheme
? 'border-white/15 bg-black/40 text-white placeholder-white/40 focus:border-primary'
: 'border-border bg-bg text-text placeholder-text-faint focus:border-primary'
)
const monoInputClass = clsx(
const monoInputClass = cn(
'w-full rounded border px-2 py-1 font-mono text-xs focus:outline-none',
darkTheme
? 'border-white/15 bg-black/40 text-white placeholder-white/40 focus:border-primary'
@@ -382,7 +382,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
return (
<div
className={clsx(
className={cn(
'flex h-full flex-col transition-opacity duration-150',
isPlaceholderData && 'opacity-70'
)}
@@ -436,7 +436,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
onBlur={commitNotes}
placeholder="Add notes…"
rows={3}
className={clsx(inputClass, 'resize-none')}
className={cn(inputClass, 'resize-none')}
/>
</div>
@@ -454,7 +454,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
title={`Set rating to ${value}`}
>
<Star
className={clsx(
className={cn(
'h-5 w-5 transition-colors',
value <= rating
? 'fill-star text-star'
@@ -478,7 +478,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
onClick={() =>
updateMutation.mutate({ color_label: active ? null : value })
}
className={clsx(
className={cn(
'h-5 w-5 rounded-full ring-offset-2 ring-offset-surface transition-all',
className,
active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'
@@ -509,7 +509,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
heapMutation.mutate({ remove: isInActiveHeap })
}}
disabled={!activeHeap || heapMutation.isPending}
className={clsx(
className={cn(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
isInActiveHeap
? 'bg-pick/20 text-pick'
@@ -528,7 +528,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
</button>
<button
onClick={() => updateMutation.mutate({ is_discarded: !isDiscarded })}
className={clsx(
className={cn(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
isDiscarded
? 'bg-reject/20 text-reject'

View File

@@ -1,6 +1,6 @@
import { useMemo } from 'react'
import { format } from 'date-fns'
import clsx from 'clsx'
import { cn } from '@/lib/utils'
import {
guessDateFromPath,
toDatetimeLocalValue,
@@ -56,7 +56,7 @@ export function TakenAtEditor({
return Math.abs(current - suggested) > 24 * 60 * 60 * 1000
}, [guess, photo.taken_at])
const inputClass = clsx(
const inputClass = cn(
'flex-1 rounded border px-2 py-1 text-xs focus:outline-none',
darkTheme
? 'border-white/15 bg-black/40 text-white focus:border-primary'
@@ -102,7 +102,7 @@ export function TakenAtEditor({
onDraftChange(next)
onCommit(next)
}}
className={clsx(
className={cn(
'mt-1.5 flex w-full items-center justify-between gap-2 rounded border border-dashed px-2 py-1 text-[11px] transition-colors',
'border-primary/50 text-primary hover:bg-primary/10'
)}

View File

@@ -1,7 +1,8 @@
import { useState, useMemo, useCallback } from 'react'
import { Tag as TagIcon, ArrowLeft, Loader2 } from 'lucide-react'
import clsx from 'clsx'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useTagsQuery } from '../../hooks/useTagsQuery'
import { photos as photosApi, type Tag } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
@@ -106,7 +107,7 @@ export function TagsView() {
{tags.map((tag, i) => (
<div
key={tag.id}
className={clsx(
className={cn(
'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
i === activeIndex
? 'border-primary ring-1 ring-primary/30'
@@ -127,9 +128,12 @@ export function TagsView() {
</div>
)}
<span className="absolute bottom-1.5 right-1.5 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm">
<Badge
variant="overlay"
className="absolute bottom-1.5 right-1.5"
>
{tag.photo_count}
</span>
</Badge>
</div>
<div className="px-2 py-1.5">

View File

@@ -8,11 +8,10 @@ import {
Users,
ShoppingBasket,
} from 'lucide-react'
import clsx from 'clsx'
import { cn } from '@/lib/utils'
import { photos as photosApi } from '../../services/api'
import type { Photo } from '../../types/photo'
import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore } from '../../store/filterStore'
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
import { findSearchMatch } from '../../lib/searchMatch'
@@ -75,6 +74,10 @@ interface PhotoThumbnailProps {
/** True when the photo belongs to the currently active heap — drives
* the green tint overlay. */
isInActiveHeap?: boolean
/** Active text-search query, passed in from Timeline rather than
* subscribed-to here so we don't have N thumbnails each running a
* per-keystroke selector. Empty string disables the highlight. */
searchQuery?: string
/** Called with the photo + the native event. Pass a stable handler
* (useCallback with store-action deps) so React.memo can actually
* elide re-renders on unrelated store updates. */
@@ -88,6 +91,7 @@ function PhotoThumbnailImpl({
fill = false,
isSelected,
isInActiveHeap = false,
searchQuery = '',
onClick,
onDoubleClick,
}: PhotoThumbnailProps) {
@@ -110,12 +114,12 @@ function PhotoThumbnailImpl({
// thumbnail badge all read from one source of truth.
const dateWarning = photo.has_date_warning === true
// Active search query — subscribed with a focused selector so typing
// in the search box only re-renders thumbnails, not every consumer
// of the filter store. When a query is active we compute which field
// on this photo matched, so the user can see *why* the photo came
// back from the search instead of guessing.
const searchQuery = useFilterStore((s) => s.q)
// When a query is active we compute which field on this photo
// matched so the user can see *why* the photo came back from the
// search instead of guessing. The query itself is passed down as a
// prop (subscribed once at the Timeline level) — having every
// thumbnail subscribe individually multiplied keystroke renders by
// the row count.
const searchMatch = searchQuery.trim()
? findSearchMatch(photo, searchQuery)
: null
@@ -201,8 +205,8 @@ function PhotoThumbnailImpl({
return (
<div
className={clsx(
'group relative cursor-pointer overflow-hidden rounded-sm will-change-transform',
className={cn(
'group relative cursor-pointer overflow-hidden rounded-sm outline-none will-change-transform focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-bg',
// Animate INTO selection (springy ease-in over 300ms); snap
// back instantly when deselected by dropping the transition
// entirely. Heap membership doesn't animate — it just paints
@@ -230,7 +234,23 @@ function PhotoThumbnailImpl({
? { width: '100%', height: '100%' }
: { width: size, height: displayHeight }
}
role="button"
tabIndex={0}
aria-label={`Photo: ${photo.filename}`}
aria-pressed={isSelected}
onClick={(e) => onClick(photo, e)}
onKeyDown={(e) => {
// Space toggles selection, Enter opens preview. Mirrors the
// mouse semantics so keyboard users don't have to learn a
// separate vocabulary.
if (e.key === 'Enter' && onDoubleClick) {
e.preventDefault()
onDoubleClick(photo, e as unknown as React.MouseEvent)
} else if (e.key === ' ') {
e.preventDefault()
onClick(photo, e as unknown as React.MouseEvent)
}
}}
onDoubleClick={
onDoubleClick ? (e) => onDoubleClick(photo, e) : undefined
}
@@ -244,7 +264,7 @@ function PhotoThumbnailImpl({
<img
src={thumbnailUrl}
alt={photo.filename}
className={clsx(
className={cn(
'h-full w-full object-cover transition-opacity duration-200',
imageLoaded ? 'opacity-100' : 'opacity-0',
// Discarded photos fade out + desaturate so the trash section
@@ -345,7 +365,7 @@ function PhotoThumbnailImpl({
* conveyed by the ring/outline on the wrapper, no badge needed. */}
{photo.owner_username && (
<div
className={clsx(
className={cn(
'absolute left-1 top-1',
THUMB_BADGE_BASE,
THUMB_BADGE_NEUTRAL,
@@ -364,7 +384,7 @@ function PhotoThumbnailImpl({
<div className="absolute bottom-1 left-1 flex items-center gap-1">
{photo.color_label && COLOR_LABEL_BG[photo.color_label] && (
<div
className={clsx(
className={cn(
THUMB_BADGE_BASE,
THUMB_BADGE_SQUARE,
COLOR_LABEL_BG[photo.color_label],
@@ -375,10 +395,10 @@ function PhotoThumbnailImpl({
)}
{photo.rating > 0 && (
<div
className={clsx('gap-0.5', THUMB_BADGE_BASE, THUMB_BADGE_PRIMARY)}
className={cn('gap-0.5', THUMB_BADGE_BASE, THUMB_BADGE_PRIMARY)}
>
{Array.from({ length: photo.rating }).map((_, i) => (
<Star key={i} className={clsx(THUMB_BADGE_ICON, 'fill-white')} />
<Star key={i} className={cn(THUMB_BADGE_ICON, 'fill-white')} />
))}
</div>
)}
@@ -392,7 +412,7 @@ function PhotoThumbnailImpl({
<div className="absolute bottom-1 right-1 flex items-center gap-1">
{isInActiveHeap && (
<div
className={clsx(
className={cn(
THUMB_BADGE_BASE,
THUMB_BADGE_SQUARE,
THUMB_BADGE_PICK
@@ -404,7 +424,7 @@ function PhotoThumbnailImpl({
)}
{photo.is_duplicate && (
<div
className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_SQUARE, THUMB_BADGE_NEUTRAL)}
className={cn(THUMB_BADGE_BASE, THUMB_BADGE_SQUARE, THUMB_BADGE_NEUTRAL)}
title="Duplicate (matches another photo's hash)"
>
<Copy className={THUMB_BADGE_ICON} strokeWidth={2.5} />
@@ -412,7 +432,7 @@ function PhotoThumbnailImpl({
)}
{photo.is_discarded && (
<div
className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_SQUARE, THUMB_BADGE_NEUTRAL)}
className={cn(THUMB_BADGE_BASE, THUMB_BADGE_SQUARE, THUMB_BADGE_NEUTRAL)}
title="Discarded"
>
<Trash2 className={THUMB_BADGE_ICON} strokeWidth={2.5} />
@@ -420,7 +440,7 @@ function PhotoThumbnailImpl({
)}
{dateWarning && (
<div
className={clsx(
className={cn(
THUMB_BADGE_BASE,
THUMB_BADGE_SQUARE,
// Amber fill with the same chiseled frame as other affirmative
@@ -438,7 +458,7 @@ function PhotoThumbnailImpl({
{/* TR — file-type metadata (RAW / VIDEO) */}
{(photo.filepath.toLowerCase().match(/\.(raw|arw|cr2|cr3|nef|orf|rw2|dng)$/i) ||
photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i)) && (
<div className={clsx('absolute right-1 top-1', THUMB_BADGE_BASE, THUMB_BADGE_NEUTRAL)}>
<div className={cn('absolute right-1 top-1', THUMB_BADGE_BASE, THUMB_BADGE_NEUTRAL)}>
{photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i) ? 'VIDEO' : 'RAW'}
</div>
)}

View File

@@ -1,7 +1,7 @@
import { useRef, useEffect, useMemo, useState, useCallback } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { format, parseISO } from 'date-fns'
import clsx from 'clsx'
import { cn } from '@/lib/utils'
import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
import { PhotoThumbnail } from './PhotoThumbnail'
@@ -143,6 +143,10 @@ export function Timeline() {
const sortBy = useFilterStore((s) => s.sortBy)
const groupBy = useFilterStore((s) => s.groupBy)
const currentSection = useFilterStore((s) => s.currentSection)
// Subscribed once at this level and passed down to each
// PhotoThumbnail as a prop. Previously every thumbnail had its own
// subscription, multiplying keystroke renders by the row count.
const searchQuery = useFilterStore((s) => s.q)
const viewMode = usePhotoStore((s) => s.viewMode)
// Calculate number of columns + actual cell size based on container
@@ -525,19 +529,6 @@ export function Timeline() {
}, [visibleSequence, setVisiblePhotoIds])
// Locate the active photo in the visual grid. Returns the FIRST
// (rowIndex, colIndex) where its id appears, since a tag-grouped view
// can repeat a photo across groups. Returns null when there's no
// active photo or it isn't currently rendered.
const findActiveCell = (): { row: number; col: number } | null => {
if (!activePhotoId) return null
for (let r = 0; r < photoRows.length; r++) {
const row = photoRows[r]
const c = row.cells.findIndex((cell) => cell.photo.id === activePhotoId)
if (c >= 0) return { row: r, col: c }
}
return null
}
// Handle keyboard shortcuts for photo navigation. Operates on the
// grouped grid the user sees, so a half-full last row of a group
// doesn't make ArrowDown skip into the wrong place.
@@ -547,6 +538,29 @@ export function Timeline() {
// against PreviewView's setActivePhoto, landing the user on the wrong
// photo. The grid handler stays attached so it can re-engage the
// moment the user closes preview.
//
// The handler reads its inputs through a ref so the listener can bind
// once per (viewMode, currentSection) — every other dep used to be in
// the array and triggered an unbind/rebind on every state tick (eight
// values, several with new identity each render).
const navStateRef = useRef({
photoRows,
photos,
selectedPhotos,
activePhotoId,
photoRowItemIndex,
items,
cellSize,
})
navStateRef.current = {
photoRows,
photos,
selectedPhotos,
activePhotoId,
photoRowItemIndex,
items,
cellSize,
}
useEffect(() => {
if (viewMode !== 'grid') return
// The duplicates section mounts its own grouped view (DuplicatesView)
@@ -555,14 +569,31 @@ export function Timeline() {
// doesn't match what the user actually sees on screen.
if (currentSection === 'duplicates') return
const handleKeyDown = (e: KeyboardEvent) => {
const {
photoRows,
photos,
selectedPhotos,
photoRowItemIndex,
items,
cellSize,
} = navStateRef.current
if (photoRows.length === 0) return
const target = e.target as HTMLElement | null
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
return
}
const findActive = (): { row: number; col: number } | null => {
const aid = navStateRef.current.activePhotoId
if (!aid) return null
for (let r = 0; r < photoRows.length; r++) {
const c = photoRows[r].cells.findIndex((cell) => cell.photo.id === aid)
if (c >= 0) return { row: r, col: c }
}
return null
}
const move = (dr: number, dc: number) => {
const current = findActiveCell() ?? { row: 0, col: -1 }
const current = findActive() ?? { row: 0, col: -1 }
let nextRow = current.row
let nextCol = current.col + dc
@@ -672,8 +703,14 @@ export function Timeline() {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewMode, photoRows, photos, selectedPhotos, activePhotoId, photoRowItemIndex, items, cellSize, currentSection])
}, [
viewMode,
currentSection,
selectRange,
selectPhoto,
togglePhotoSelection,
clearSelection,
])
if (isLoading) {
return (
@@ -688,7 +725,12 @@ export function Timeline() {
}
return (
<div className="relative h-full">
<div
className="relative h-full"
role="grid"
aria-label="Photos"
aria-rowcount={photoRows.length}
>
{/* Sticky group-header overlay. Lives outside the virtualizer's
* positioned children so it isn't affected by translateY transforms.
* Updates as the user scrolls past month boundaries. */}
@@ -706,7 +748,7 @@ export function Timeline() {
* so pointer-events-none — it must never steal scrollbar clicks. */}
{isDateSort && scrollDateLabel && (
<div
className={clsx(
className={cn(
'pointer-events-none absolute right-4 z-30 rounded-md border border-border bg-surface/95 px-2.5 py-1 text-xs font-semibold text-text shadow-lg backdrop-blur transition-opacity duration-200',
isScrolling ? 'opacity-100' : 'opacity-0'
)}
@@ -808,6 +850,7 @@ export function Timeline() {
fill
isSelected={selectedPhotos.includes(photo.id)}
isInActiveHeap={activeHeapMembers.has(photo.id)}
searchQuery={searchQuery}
onClick={handleCellClick}
onDoubleClick={handleCellDoubleClick}
/>

View File

@@ -0,0 +1,39 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
/**
* Compact pill used for counts, tags, and overlays. Variants match the
* surface tokens in tailwind.config.js:
* default → primary tint (user-affirmed state)
* neutral → surface-2 (informational metadata)
* overlay → dark-on-photo count badge used in grid-view cards
* outline → border-only, for subdued contexts
*/
const badgeVariants = cva(
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium transition-colors',
{
variants: {
variant: {
default: 'bg-primary/20 text-primary',
neutral: 'bg-surface-2 text-text-muted',
overlay: 'bg-black/60 text-white backdrop-blur-sm',
outline: 'border border-border text-text-muted',
},
},
defaultVariants: { variant: 'default' },
},
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLSpanElement>,
VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return (
<span className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { badgeVariants }

View File

@@ -22,6 +22,7 @@ const buttonVariants = cva(
size: {
default: 'h-8 px-3',
sm: 'h-7 rounded-md px-2 text-xs',
xs: 'h-6 rounded px-1.5 text-[11px]',
lg: 'h-9 rounded-md px-4',
icon: 'h-8 w-8',
},

View File

@@ -9,7 +9,7 @@ import {
CheckCircle2,
AlertCircle,
} from 'lucide-react'
import clsx from 'clsx'
import { cn } from '@/lib/utils'
import { useQueryClient } from '@tanstack/react-query'
import { uploads, type FolderTreeNode } from '../../services/api'
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
@@ -400,7 +400,7 @@ export function UploadModal({ isOpen, onClose, initialFolderId }: UploadModalPro
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
className={clsx(
className={cn(
'flex flex-col items-center justify-center rounded border-2 border-dashed px-4 py-6 text-center transition-colors',
dragOver
? 'border-primary bg-primary/10'
@@ -478,7 +478,7 @@ export function UploadModal({ isOpen, onClose, initialFolderId }: UploadModalPro
)}
</div>
<div className="shrink-0">
{item.status === 'done' && <CheckCircle2 className="h-4 w-4 text-green-500" />}
{item.status === 'done' && <CheckCircle2 className="h-4 w-4 text-pick" />}
{item.status === 'error' && <AlertCircle className="h-4 w-4 text-reject" />}
{item.status !== 'done' && !isUploading && (
<button
@@ -552,7 +552,7 @@ function FolderTreeRow({
return (
<>
<div
className={clsx(
className={cn(
'flex cursor-pointer items-center gap-1 rounded px-1 py-1 text-sm',
isSelected ? 'bg-primary/20 text-text' : 'text-text-muted hover:bg-surface-2 hover:text-text'
)}

View File

@@ -1,5 +1,5 @@
import { useMemo } from 'react'
import { useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
import { useShallow } from 'zustand/react/shallow'
import { useFilterStore, filtersToParams } from '../store/filterStore'
import api from '../services/api'
import type { Photo } from '../types/photo'
@@ -40,44 +40,33 @@ async function fetchCursorPage(
* Timeline's key gained the filter params.
*/
export function usePhotosQuery() {
const q = useFilterStore((s) => s.q)
const dateFrom = useFilterStore((s) => s.dateFrom)
const dateTo = useFilterStore((s) => s.dateTo)
const mediaTypes = useFilterStore((s) => s.mediaTypes)
const ratingMin = useFilterStore((s) => s.ratingMin)
const ratingMax = useFilterStore((s) => s.ratingMax)
const colorLabel = useFilterStore((s) => s.colorLabel)
const flag = useFilterStore((s) => s.flag)
const heapId = useFilterStore((s) => s.heapId)
const folderId = useFilterStore((s) => s.folderId)
const tagIds = useFilterStore((s) => s.tagIds)
const duplicates = useFilterStore((s) => s.duplicates)
const needsReview = useFilterStore((s) => s.needsReview)
const groupBy = useFilterStore((s) => s.groupBy)
const sortBy = useFilterStore((s) => s.sortBy)
const sortOrder = useFilterStore((s) => s.sortOrder)
const filterParams = useMemo(
() =>
// Single shallow-compared selector that returns just the filter
// surface area. Previously this hook ran 14 individual selectors,
// each a fresh subscription that could trigger a re-render and a
// useMemo recompute on any unrelated filter-store update. useShallow
// collapses them into one subscription that only fires when the
// shape's values actually change.
const filterParams = useFilterStore(
useShallow((s) =>
filtersToParams({
q,
dateFrom,
dateTo,
mediaTypes,
ratingMin,
ratingMax,
colorLabel,
flag,
heapId,
folderId,
tagIds,
duplicates,
needsReview,
groupBy,
sortBy,
sortOrder,
q: s.q,
dateFrom: s.dateFrom,
dateTo: s.dateTo,
mediaTypes: s.mediaTypes,
ratingMin: s.ratingMin,
ratingMax: s.ratingMax,
colorLabel: s.colorLabel,
flag: s.flag,
heapId: s.heapId,
folderId: s.folderId,
tagIds: s.tagIds,
duplicates: s.duplicates,
needsReview: s.needsReview,
groupBy: s.groupBy,
sortBy: s.sortBy,
sortOrder: s.sortOrder,
}),
[q, dateFrom, dateTo, mediaTypes, ratingMin, ratingMax, colorLabel, flag, heapId, folderId, tagIds, duplicates, needsReview, groupBy, sortBy, sortOrder]
),
)
const queryClient = useQueryClient()