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:
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
39
frontend/src/components/ui/badge.tsx
Normal file
39
frontend/src/components/ui/badge.tsx
Normal 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 }
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user