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

View File

@@ -1,7 +1,8 @@
import { useState, useMemo, useCallback } from 'react' import { useState, useMemo, useCallback } from 'react'
import { Palette, ArrowLeft, Loader2 } from 'lucide-react' import { Palette, ArrowLeft, Loader2 } from 'lucide-react'
import clsx from 'clsx' import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { photos as photosApi } from '../../services/api' import { photos as photosApi } from '../../services/api'
import { useFilterStore } from '../../store/filterStore' import { useFilterStore } from '../../store/filterStore'
import { usePhotosQuery } from '../../hooks/usePhotosQuery' import { usePhotosQuery } from '../../hooks/usePhotosQuery'
@@ -149,7 +150,7 @@ export function ColorsView() {
{groups.map((group, i) => ( {groups.map((group, i) => (
<div <div
key={group.label} 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', 'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
i === activeIndex i === activeIndex
? 'border-primary ring-1 ring-primary/30' ? 'border-primary ring-1 ring-primary/30'
@@ -170,9 +171,12 @@ export function ColorsView() {
</div> </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} {group.count}
</span> </Badge>
</div> </div>
<div className="flex items-center gap-1.5 px-2 py-1.5"> <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 { useEffect, useState } from 'react'
import clsx from 'clsx' import { cn } from '@/lib/utils'
import { Trash2, Archive } from 'lucide-react' import { Trash2, Archive } from 'lucide-react'
import { import {
@@ -124,7 +124,7 @@ function ModeCard({
return ( return (
<Label <Label
htmlFor={id} htmlFor={id}
className={clsx( className={cn(
'flex w-full cursor-pointer gap-3 rounded-lg border p-3 text-left transition-colors', 'flex w-full cursor-pointer gap-3 rounded-lg border p-3 text-left transition-colors',
selected selected
? destructive ? destructive
@@ -135,7 +135,7 @@ function ModeCard({
> >
<RadioGroupItem id={id} value={value} className="mt-0.5" /> <RadioGroupItem id={id} value={value} className="mt-0.5" />
<div <div
className={clsx( className={cn(
'mt-0.5 flex-shrink-0', 'mt-0.5 flex-shrink-0',
selected selected
? destructive ? destructive
@@ -148,7 +148,7 @@ function ModeCard({
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div <div
className={clsx( className={cn(
'text-sm font-medium', 'text-sm font-medium',
selected selected
? destructive ? destructive

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import { useState, useMemo, useCallback } from 'react' import { useState, useMemo, useCallback } from 'react'
import { Star, ArrowLeft, Loader2 } from 'lucide-react' import { Star, ArrowLeft, Loader2 } from 'lucide-react'
import clsx from 'clsx' import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { photos as photosApi } from '../../services/api' import { photos as photosApi } from '../../services/api'
import { useFilterStore } from '../../store/filterStore' import { useFilterStore } from '../../store/filterStore'
import { usePhotosQuery } from '../../hooks/usePhotosQuery' import { usePhotosQuery } from '../../hooks/usePhotosQuery'
@@ -90,7 +91,7 @@ export function RatedView() {
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </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>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<Timeline /> <Timeline />
@@ -137,7 +138,7 @@ export function RatedView() {
{groups.map((group, i) => ( {groups.map((group, i) => (
<div <div
key={group.rating} 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', 'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
i === activeIndex i === activeIndex
? 'border-primary ring-1 ring-primary/30' ? 'border-primary ring-1 ring-primary/30'
@@ -158,13 +159,16 @@ export function RatedView() {
</div> </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} {group.count}
</span> </Badge>
</div> </div>
<div className="px-2 py-1.5"> <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>
</div> </div>
))} ))}

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import { useState, useMemo, useCallback } from 'react' import { useState, useMemo, useCallback } from 'react'
import { Tag as TagIcon, ArrowLeft, Loader2 } from 'lucide-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 { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { useTagsQuery } from '../../hooks/useTagsQuery' import { useTagsQuery } from '../../hooks/useTagsQuery'
import { photos as photosApi, type Tag } from '../../services/api' import { photos as photosApi, type Tag } from '../../services/api'
import { useFilterStore } from '../../store/filterStore' import { useFilterStore } from '../../store/filterStore'
@@ -106,7 +107,7 @@ export function TagsView() {
{tags.map((tag, i) => ( {tags.map((tag, i) => (
<div <div
key={tag.id} 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', 'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
i === activeIndex i === activeIndex
? 'border-primary ring-1 ring-primary/30' ? 'border-primary ring-1 ring-primary/30'
@@ -127,9 +128,12 @@ export function TagsView() {
</div> </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} {tag.photo_count}
</span> </Badge>
</div> </div>
<div className="px-2 py-1.5"> <div className="px-2 py-1.5">

View File

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

View File

@@ -1,7 +1,7 @@
import { useRef, useEffect, useMemo, useState, useCallback } from 'react' import { useRef, useEffect, useMemo, useState, useCallback } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual' import { useVirtualizer } from '@tanstack/react-virtual'
import { format, parseISO } from 'date-fns' import { format, parseISO } from 'date-fns'
import clsx from 'clsx' import { cn } from '@/lib/utils'
import { usePhotoStore } from '../../store/photoStore' import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore, hasActiveFilters } from '../../store/filterStore' import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
import { PhotoThumbnail } from './PhotoThumbnail' import { PhotoThumbnail } from './PhotoThumbnail'
@@ -143,6 +143,10 @@ export function Timeline() {
const sortBy = useFilterStore((s) => s.sortBy) const sortBy = useFilterStore((s) => s.sortBy)
const groupBy = useFilterStore((s) => s.groupBy) const groupBy = useFilterStore((s) => s.groupBy)
const currentSection = useFilterStore((s) => s.currentSection) 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) const viewMode = usePhotoStore((s) => s.viewMode)
// Calculate number of columns + actual cell size based on container // Calculate number of columns + actual cell size based on container
@@ -525,19 +529,6 @@ export function Timeline() {
}, [visibleSequence, setVisiblePhotoIds]) }, [visibleSequence, setVisiblePhotoIds])
// Locate the active photo in the visual grid. Returns the FIRST // 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 // Handle keyboard shortcuts for photo navigation. Operates on the
// grouped grid the user sees, so a half-full last row of a group // grouped grid the user sees, so a half-full last row of a group
// doesn't make ArrowDown skip into the wrong place. // 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 // against PreviewView's setActivePhoto, landing the user on the wrong
// photo. The grid handler stays attached so it can re-engage the // photo. The grid handler stays attached so it can re-engage the
// moment the user closes preview. // 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(() => { useEffect(() => {
if (viewMode !== 'grid') return if (viewMode !== 'grid') return
// The duplicates section mounts its own grouped view (DuplicatesView) // 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. // doesn't match what the user actually sees on screen.
if (currentSection === 'duplicates') return if (currentSection === 'duplicates') return
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
const {
photoRows,
photos,
selectedPhotos,
photoRowItemIndex,
items,
cellSize,
} = navStateRef.current
if (photoRows.length === 0) return if (photoRows.length === 0) return
const target = e.target as HTMLElement | null const target = e.target as HTMLElement | null
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) { if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
return 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 move = (dr: number, dc: number) => {
const current = findActiveCell() ?? { row: 0, col: -1 } const current = findActive() ?? { row: 0, col: -1 }
let nextRow = current.row let nextRow = current.row
let nextCol = current.col + dc let nextCol = current.col + dc
@@ -672,8 +703,14 @@ export function Timeline() {
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('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) { if (isLoading) {
return ( return (
@@ -688,7 +725,12 @@ export function Timeline() {
} }
return ( 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 {/* Sticky group-header overlay. Lives outside the virtualizer's
* positioned children so it isn't affected by translateY transforms. * positioned children so it isn't affected by translateY transforms.
* Updates as the user scrolls past month boundaries. */} * 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. */} * so pointer-events-none — it must never steal scrollbar clicks. */}
{isDateSort && scrollDateLabel && ( {isDateSort && scrollDateLabel && (
<div <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', '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' isScrolling ? 'opacity-100' : 'opacity-0'
)} )}
@@ -808,6 +850,7 @@ export function Timeline() {
fill fill
isSelected={selectedPhotos.includes(photo.id)} isSelected={selectedPhotos.includes(photo.id)}
isInActiveHeap={activeHeapMembers.has(photo.id)} isInActiveHeap={activeHeapMembers.has(photo.id)}
searchQuery={searchQuery}
onClick={handleCellClick} onClick={handleCellClick}
onDoubleClick={handleCellDoubleClick} 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: { size: {
default: 'h-8 px-3', default: 'h-8 px-3',
sm: 'h-7 rounded-md px-2 text-xs', 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', lg: 'h-9 rounded-md px-4',
icon: 'h-8 w-8', icon: 'h-8 w-8',
}, },

View File

@@ -9,7 +9,7 @@ import {
CheckCircle2, CheckCircle2,
AlertCircle, AlertCircle,
} from 'lucide-react' } from 'lucide-react'
import clsx from 'clsx' import { cn } from '@/lib/utils'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { uploads, type FolderTreeNode } from '../../services/api' import { uploads, type FolderTreeNode } from '../../services/api'
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery' import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
@@ -400,7 +400,7 @@ export function UploadModal({ isOpen, onClose, initialFolderId }: UploadModalPro
}} }}
onDragLeave={() => setDragOver(false)} onDragLeave={() => setDragOver(false)}
onDrop={handleDrop} 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', 'flex flex-col items-center justify-center rounded border-2 border-dashed px-4 py-6 text-center transition-colors',
dragOver dragOver
? 'border-primary bg-primary/10' ? 'border-primary bg-primary/10'
@@ -478,7 +478,7 @@ export function UploadModal({ isOpen, onClose, initialFolderId }: UploadModalPro
)} )}
</div> </div>
<div className="shrink-0"> <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 === 'error' && <AlertCircle className="h-4 w-4 text-reject" />}
{item.status !== 'done' && !isUploading && ( {item.status !== 'done' && !isUploading && (
<button <button
@@ -552,7 +552,7 @@ function FolderTreeRow({
return ( return (
<> <>
<div <div
className={clsx( className={cn(
'flex cursor-pointer items-center gap-1 rounded px-1 py-1 text-sm', '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' 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 { useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
import { useShallow } from 'zustand/react/shallow'
import { useFilterStore, filtersToParams } from '../store/filterStore' import { useFilterStore, filtersToParams } from '../store/filterStore'
import api from '../services/api' import api from '../services/api'
import type { Photo } from '../types/photo' import type { Photo } from '../types/photo'
@@ -40,44 +40,33 @@ async function fetchCursorPage(
* Timeline's key gained the filter params. * Timeline's key gained the filter params.
*/ */
export function usePhotosQuery() { export function usePhotosQuery() {
const q = useFilterStore((s) => s.q) // Single shallow-compared selector that returns just the filter
const dateFrom = useFilterStore((s) => s.dateFrom) // surface area. Previously this hook ran 14 individual selectors,
const dateTo = useFilterStore((s) => s.dateTo) // each a fresh subscription that could trigger a re-render and a
const mediaTypes = useFilterStore((s) => s.mediaTypes) // useMemo recompute on any unrelated filter-store update. useShallow
const ratingMin = useFilterStore((s) => s.ratingMin) // collapses them into one subscription that only fires when the
const ratingMax = useFilterStore((s) => s.ratingMax) // shape's values actually change.
const colorLabel = useFilterStore((s) => s.colorLabel) const filterParams = useFilterStore(
const flag = useFilterStore((s) => s.flag) useShallow((s) =>
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(
() =>
filtersToParams({ filtersToParams({
q, q: s.q,
dateFrom, dateFrom: s.dateFrom,
dateTo, dateTo: s.dateTo,
mediaTypes, mediaTypes: s.mediaTypes,
ratingMin, ratingMin: s.ratingMin,
ratingMax, ratingMax: s.ratingMax,
colorLabel, colorLabel: s.colorLabel,
flag, flag: s.flag,
heapId, heapId: s.heapId,
folderId, folderId: s.folderId,
tagIds, tagIds: s.tagIds,
duplicates, duplicates: s.duplicates,
needsReview, needsReview: s.needsReview,
groupBy, groupBy: s.groupBy,
sortBy, sortBy: s.sortBy,
sortOrder, sortOrder: s.sortOrder,
}), }),
[q, dateFrom, dateTo, mediaTypes, ratingMin, ratingMax, colorLabel, flag, heapId, folderId, tagIds, duplicates, needsReview, groupBy, sortBy, sortOrder] ),
) )
const queryClient = useQueryClient() const queryClient = useQueryClient()