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

@@ -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()