4 Commits

Author SHA1 Message Date
744a7fa0c3 feat(memories): use Timeline's PhotoThumbnail grid
MemoriesView now renders PhotoThumbnail cells wired up to the shared
photoStore so selection, heap membership, preview (double-click /
Enter), badges, drag-to-heap, and search-match highlighting all work
the same way they do in Timeline. Kept the per-year section grouping,
swapped the bespoke img tiles for the shared component.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:57:29 +02:00
967cf23b82 feat(sharing): user picker in share dialog
Replace the free-text username input with a Select populated from a new
/sharing/users endpoint. Users already on the target's share list are
filtered out, and the trigger surfaces loading / empty states. Matches
the existing permission model since sharing only ever required knowing
a username.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:34:09 +02:00
a073ee7fb9 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>
2026-04-15 10:30:16 +02:00
e65e798021 perf+ux: cut grid re-renders, coalesce discard, dedup bulk mutations
Frontend cleanup pass driven by the post-shadcn review.

Performance
- Memoize PhotoThumbnail and route cell click/double-click through
  stable handlers so heap-membership invalidation no longer re-renders
  every visible thumbnail.
- Cap usePhotosQuery's eager background page-walk at 20 pages with a
  50ms inter-page yield — was unbounded (up to 100k photos cold).
- Drop the per-thumbnail loading spinner in favour of the existing
  pulse skeleton; only retry state still surfaces a spinner.

UX
- Coalesce rapid X/U presses into a single undo entry + one toast
  (1.2s window) so accidental bursts are easy to back out.
- Optimistic rating/color updates with per-id snapshot rollback on
  error, matching the existing discard pattern.
- Section-aware empty timeline state with a Clear-all-filters CTA.
- Carry the search-match chip from the grid into the preview header.
- Add a basket-icon badge for active heap membership so the green
  tint isn't the only signal (colorblind-safe).
- Standardise error toasts via formatApiError(): FastAPI detail,
  validation arrays, axios message, with a 'Network Error' filter.

Architecture
- Extract useBulkPhotoMutations and stop duplicating
  bulkRating/bulkColor across RightSidebar and useKeyboardShortcuts.
- Split RightSidebar (714 -> 448 LOC) and PhotoInfoPanel (952 -> 716)
  into co-located sub-components: BulkTakenAtEditor, BulkTagsEditor,
  TagsEditor, TakenAtEditor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:13:39 +02:00
34 changed files with 1484 additions and 861 deletions

View File

@@ -59,6 +59,34 @@ class SharedFolderResponse(BaseModel):
photo_count: int photo_count: int
class ShareableUser(BaseModel):
id: str
username: str
# ── Shareable users ──────────────────────────────────────────────────────
@router.get("/users", response_model=list[ShareableUser])
async def list_shareable_users(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List every active user other than the caller, for the share-picker
dropdown. Sharing only requires knowing a username today, so surfacing
the list is no wider an attack surface than the free-text input it
replaces. Inactive accounts are filtered out."""
result = await db.execute(
select(User)
.where(User.id != current_user.id)
.where(User.is_active.is_(True))
.order_by(User.username)
)
return [
ShareableUser(id=str(u.id), username=u.username)
for u in result.scalars().all()
]
# ── Heap sharing ───────────────────────────────────────────────────────── # ── Heap sharing ─────────────────────────────────────────────────────────
@router.get("/heaps/shared-with-me") @router.get("/heaps/shared-with-me")

View File

@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { ChevronDown, ChevronUp } from 'lucide-react' import { ChevronDown, ChevronUp } from 'lucide-react'
import clsx from 'clsx'
import { usePhotoStore } from '../store/photoStore' import { usePhotoStore } from '../store/photoStore'
import { useFilterStore } from '../store/filterStore' import { useFilterStore } from '../store/filterStore'
@@ -109,11 +108,7 @@ export function KeyboardHints() {
</kbd> </kbd>
</button> </button>
) : ( ) : (
<div <div className="pointer-events-auto flex items-center gap-3 whitespace-nowrap rounded-full border border-white/15 bg-black/80 px-4 py-1.5 shadow-xl ring-1 ring-black/40 backdrop-blur-md">
className={clsx(
'pointer-events-auto flex items-center gap-3 whitespace-nowrap rounded-full border border-white/15 bg-black/80 px-4 py-1.5 shadow-xl ring-1 ring-black/40 backdrop-blur-md'
)}
>
{hints.map((hint, i) => ( {hints.map((hint, i) => (
<div key={i} className="flex items-center gap-1.5"> <div key={i} className="flex items-center gap-1.5">
<kbd className="rounded bg-white/15 px-1.5 py-0.5 text-[11px] font-medium text-white shadow-sm"> <kbd className="rounded bg-white/15 px-1.5 py-0.5 text-[11px] font-medium text-white shadow-sm">

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

@@ -8,6 +8,7 @@ import { discard as discardApi, photos as photosApi } from '../../services/api'
import { toast } from '../ToastContainer' import { toast } from '../ToastContainer'
import { ConfirmDialog } from '../dialogs/ConfirmDialog' import { ConfirmDialog } from '../dialogs/ConfirmDialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { formatApiError } from '../../lib/apiError'
import { registerUndoable } from '../../store/undoStore' import { registerUndoable } from '../../store/undoStore'
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery' import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
@@ -49,7 +50,7 @@ export function DiscardActionBar() {
queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
}, },
onError: (e: any) => toast.error('Restore failed', e.message || 'Unknown error'), onError: (e: any) => toast.error('Restore failed', formatApiError(e)),
}) })
const deleteSelectedMutation = useMutation({ const deleteSelectedMutation = useMutation({
@@ -78,7 +79,7 @@ export function DiscardActionBar() {
setDeleteSelectedOpen(false) setDeleteSelectedOpen(false)
}, },
onError: (e: any) => onError: (e: any) =>
toast.error('Delete failed', e.message || 'Unknown error'), toast.error('Delete failed', formatApiError(e)),
}) })
const emptyMutation = useMutation({ const emptyMutation = useMutation({
@@ -99,7 +100,7 @@ export function DiscardActionBar() {
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
setConfirmOpen(false) setConfirmOpen(false)
}, },
onError: (e: any) => toast.error('Empty failed', e.message || 'Unknown error'), onError: (e: any) => toast.error('Empty failed', formatApiError(e)),
}) })
if (flag !== 'discarded') return null if (flag !== 'discarded') return null

View File

@@ -1,8 +1,9 @@
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 { import {
useDuplicateGroupsQuery, useDuplicateGroupsQuery,
DUPLICATE_GROUPS_QUERY_KEY, DUPLICATE_GROUPS_QUERY_KEY,
@@ -63,7 +64,7 @@ export function DuplicatesView() {
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
}, },
onError: (e: any) => onError: (e: any) =>
toast.error('Discard failed', e?.message || 'Unknown error'), toast.error('Discard failed', formatApiError(e)),
}) })
// Hooks below this point must run on every render — rules of hooks // Hooks below this point must run on every render — rules of hooks
@@ -310,8 +311,8 @@ function DuplicateGroupSection({
size={180} size={180}
fill fill
isSelected={selectedPhotos.includes(member.id)} isSelected={selectedPhotos.includes(member.id)}
onClick={() => onSelectMember(member.id)} onClick={(p) => onSelectMember(p.id)}
onDoubleClick={() => onPreviewMember(member.id)} onDoubleClick={(p) => onPreviewMember(p.id)}
/> />
{/* BEST pill — top-right, pick-coloured. Composes the same {/* BEST pill — top-right, pick-coloured. Composes the same
* THUMB_BADGE_* family used by PhotoThumbnail so the full * THUMB_BADGE_* family used by PhotoThumbnail so the full
@@ -322,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
@@ -342,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
@@ -358,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'
@@ -21,6 +21,7 @@ import { heaps as heapsApi, downloads, type Heap } from '../../services/api'
import { useFilterStore } from '../../store/filterStore' import { useFilterStore } from '../../store/filterStore'
import { toast } from '../ToastContainer' import { toast } from '../ToastContainer'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail' import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
import { formatApiError } from '../../lib/apiError'
import { HeapConvertDialog } from './HeapConvertDialog' import { HeapConvertDialog } from './HeapConvertDialog'
import { ShareDialog } from '../sharing/ShareDialog' import { ShareDialog } from '../sharing/ShareDialog'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -79,7 +80,7 @@ export function HeapsPanel() {
setCreating(false) setCreating(false)
}, },
onError: (e: any) => onError: (e: any) =>
toast.error('Failed to create heap', e.message || 'Unknown error'), toast.error('Failed to create heap', formatApiError(e)),
}) })
const setActiveMutation = useMutation({ const setActiveMutation = useMutation({
@@ -90,7 +91,7 @@ export function HeapsPanel() {
toast.success('Active heap', `Now adding to "${heap.name}" with T`) toast.success('Active heap', `Now adding to "${heap.name}" with T`)
}, },
onError: (e: any) => onError: (e: any) =>
toast.error('Failed to set active', e.message || 'Unknown error'), toast.error('Failed to set active', formatApiError(e)),
}) })
const deleteMutation = useMutation({ const deleteMutation = useMutation({
@@ -103,7 +104,7 @@ export function HeapsPanel() {
} }
}, },
onError: (e: any) => onError: (e: any) =>
toast.error('Failed to delete heap', e.message || 'Unknown error'), toast.error('Failed to delete heap', formatApiError(e)),
}) })
const renameMutation = useMutation({ const renameMutation = useMutation({
@@ -111,7 +112,7 @@ export function HeapsPanel() {
heapsApi.update(heapId, { name }), heapsApi.update(heapId, { name }),
onSuccess: () => invalidate(), onSuccess: () => invalidate(),
onError: (e: any) => onError: (e: any) =>
toast.error('Failed to rename heap', e.message || 'Unknown error'), toast.error('Failed to rename heap', formatApiError(e)),
}) })
const duplicateMutation = useMutation({ const duplicateMutation = useMutation({
@@ -121,7 +122,7 @@ export function HeapsPanel() {
toast.success('Heap duplicated', heap.name) toast.success('Heap duplicated', heap.name)
}, },
onError: (e: any) => onError: (e: any) =>
toast.error('Failed to duplicate heap', e.message || 'Unknown error'), toast.error('Failed to duplicate heap', formatApiError(e)),
}) })
// Drop handler: add the dragged photos to the target heap. Optimistically // Drop handler: add the dragged photos to the target heap. Optimistically
@@ -142,7 +143,7 @@ export function HeapsPanel() {
if (ctx?.previous) { if (ctx?.previous) {
queryClient.setQueryData(['heap-photo-ids', vars.heapId], ctx.previous) queryClient.setQueryData(['heap-photo-ids', vars.heapId], ctx.previous)
} }
toast.error('Failed to add to heap', e.message || 'Unknown error') toast.error('Failed to add to heap', formatApiError(e))
}, },
onSuccess: (data, vars) => { onSuccess: (data, vars) => {
const heap = heaps.find((h) => h.id === vars.heapId) const heap = heaps.find((h) => h.id === vars.heapId)
@@ -261,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
@@ -308,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'
)} )}
@@ -334,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}
@@ -386,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'
)} )}
@@ -473,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',
)} )}
@@ -483,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'
@@ -43,6 +43,7 @@ import {
LIBRARY_STATS_QUERY_KEY, LIBRARY_STATS_QUERY_KEY,
} from '../../hooks/useLibraryStatsQuery' } from '../../hooks/useLibraryStatsQuery'
import { registerUndoable } from '../../store/undoStore' import { registerUndoable } from '../../store/undoStore'
import { formatApiError } from '../../lib/apiError'
import type { Photo } from '../../types/photo' import type { Photo } from '../../types/photo'
import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog' import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'
import { ShareDialog } from '../sharing/ShareDialog' import { ShareDialog } from '../sharing/ShareDialog'
@@ -150,7 +151,7 @@ export function LeftSidebar() {
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
}, },
onError: (e: any) => onError: (e: any) =>
toast.error('Discard failed', e?.message || 'Unknown error'), toast.error('Discard failed', formatApiError(e)),
}) })
// Bulk move mutation for the drag-onto-folder interaction. The mutation // Bulk move mutation for the drag-onto-folder interaction. The mutation
@@ -223,7 +224,7 @@ export function LeftSidebar() {
queryClient.invalidateQueries({ queryKey: ['folders'] }) queryClient.invalidateQueries({ queryKey: ['folders'] })
}, },
onError: (e: any) => onError: (e: any) =>
toast.error('Move failed', e?.response?.data?.detail || e.message || 'Unknown error'), toast.error('Move failed', formatApiError(e)),
}) })
// Bulk copy mutation — Alt-drag uses this instead of move. // Bulk copy mutation — Alt-drag uses this instead of move.
@@ -245,7 +246,7 @@ export function LeftSidebar() {
queryClient.invalidateQueries({ queryKey: ['folders'] }) queryClient.invalidateQueries({ queryKey: ['folders'] })
}, },
onError: (e: any) => onError: (e: any) =>
toast.error('Copy failed', e?.response?.data?.detail || e.message || 'Unknown error'), toast.error('Copy failed', formatApiError(e)),
}) })
// Reads the dragged ids out of a drop event payload. // Reads the dragged ids out of a drop event payload.
@@ -314,7 +315,7 @@ export function LeftSidebar() {
queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: ['photos'] })
}, },
onError: (e: any) => onError: (e: any) =>
toast.error('Rename failed', e?.response?.data?.detail || e.message || 'Unknown error'), toast.error('Rename failed', formatApiError(e)),
}) })
const createFolderMutation = useMutation({ const createFolderMutation = useMutation({
@@ -328,7 +329,7 @@ export function LeftSidebar() {
setCreateDraft('') setCreateDraft('')
}, },
onError: (e: any) => onError: (e: any) =>
toast.error('Create failed', e?.response?.data?.detail || e.message || 'Unknown error'), toast.error('Create failed', formatApiError(e)),
}) })
// Toggle folder hide-from-views. Invalidates every query that could // Toggle folder hide-from-views. Invalidates every query that could
@@ -354,7 +355,7 @@ export function LeftSidebar() {
queryClient.invalidateQueries({ queryKey: ['tags'] }) queryClient.invalidateQueries({ queryKey: ['tags'] })
}, },
onError: (e: any) => onError: (e: any) =>
toast.error('Toggle failed', e?.response?.data?.detail || e.message || 'Unknown error'), toast.error('Toggle failed', formatApiError(e)),
}) })
const deleteFolderMutation = useMutation({ const deleteFolderMutation = useMutation({
@@ -383,7 +384,7 @@ export function LeftSidebar() {
setDeletingFolder(null) setDeletingFolder(null)
}, },
onError: (e: any) => onError: (e: any) =>
toast.error('Delete failed', e?.response?.data?.detail || e.message || 'Unknown error'), toast.error('Delete failed', formatApiError(e)),
}) })
const toggleExpanded = (id: string) => { const toggleExpanded = (id: string) => {
@@ -486,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',
@@ -576,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'
@@ -618,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'
)} )}
@@ -676,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'
)} )}
@@ -870,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',
)} )}
@@ -880,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'
)} )}
@@ -921,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" />
@@ -932,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,8 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { X, Star, ShoppingBasket, Trash2, Plus } 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 { format } from 'date-fns'
import { usePhotoStore } from '../../store/photoStore' import { usePhotoStore } from '../../store/photoStore'
import { import {
photos as photosApi, photos as photosApi,
@@ -10,21 +9,19 @@ import {
tags as tagsApi, tags as tagsApi,
} from '../../services/api' } from '../../services/api'
import type { Photo } from '../../types/photo' import type { Photo } from '../../types/photo'
import {
guessDateFromPath,
type DateGuess,
} from '../../lib/guessDateFromPath'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery' import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery' import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery' import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
import { stripPhotosFromCache } from '../../hooks/usePhotosQuery' import { stripPhotosFromCache } from '../../hooks/usePhotosQuery'
import { toast } from '../ToastContainer' import { toast } from '../ToastContainer'
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel' import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
import { BulkTakenAtEditor } from '../sidebar/BulkTakenAtEditor'
import { BulkTagsEditor } from '../sidebar/BulkTagsEditor'
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels' import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { useBulkPhotoMutations } from '../../hooks/useBulkPhotoMutations'
import { formatApiError } from '../../lib/apiError'
/** /**
* Right-hand details panel. * Right-hand details panel.
@@ -36,22 +33,12 @@ export function RightSidebar() {
const { selectedPhotos, activePhotoId, clearSelection } = usePhotoStore() const { selectedPhotos, activePhotoId, clearSelection } = usePhotoStore()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const invalidatePhotoQueries = () => { const {
queryClient.invalidateQueries({ queryKey: ['photo'] }) bulkRating: bulkRatingMutation,
queryClient.invalidateQueries({ queryKey: ['photos'] }) bulkColor: bulkColorMutation,
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) invalidatePhotoQueries,
} } = useBulkPhotoMutations()
const bulkRatingMutation = useMutation({
mutationFn: ({ ids, rating }: { ids: string[]; rating: number }) =>
photosApi.bulkSetRating(ids, rating),
onSuccess: invalidatePhotoQueries,
})
const bulkColorMutation = useMutation({
mutationFn: ({ ids, color }: { ids: string[]; color: string | null }) =>
photosApi.bulkSetColor(ids, color),
onSuccess: invalidatePhotoQueries,
})
const bulkDiscardMutation = useMutation({ const bulkDiscardMutation = useMutation({
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids), mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
// Yank the photos from the timeline before the network round-trip // Yank the photos from the timeline before the network round-trip
@@ -94,7 +81,7 @@ export function RightSidebar() {
photosApi.bulkSetTakenAt(ids, iso), photosApi.bulkSetTakenAt(ids, iso),
onSuccess: reportBulkTakenAt, onSuccess: reportBulkTakenAt,
onError: (e: any) => onError: (e: any) =>
toast.error('Date update failed', e?.message || 'Unknown error'), toast.error('Date update failed', formatApiError(e)),
}) })
const bulkTakenAtMapMutation = useMutation({ const bulkTakenAtMapMutation = useMutation({
@@ -102,7 +89,7 @@ export function RightSidebar() {
photosApi.bulkSetTakenAtMap(map), photosApi.bulkSetTakenAtMap(map),
onSuccess: reportBulkTakenAt, onSuccess: reportBulkTakenAt,
onError: (e: any) => onError: (e: any) =>
toast.error('Date update failed', e?.message || 'Unknown error'), toast.error('Date update failed', formatApiError(e)),
}) })
// Bulk tag mutations. Tag mutations also need to invalidate the tags // Bulk tag mutations. Tag mutations also need to invalidate the tags
@@ -123,7 +110,7 @@ export function RightSidebar() {
invalidateTagsAndPhotos() invalidateTagsAndPhotos()
}, },
onError: (e: any) => onError: (e: any) =>
toast.error('Add tags failed', e?.message || 'Unknown error'), toast.error('Add tags failed', formatApiError(e)),
}) })
const bulkRemoveTagsMutation = useMutation({ const bulkRemoveTagsMutation = useMutation({
mutationFn: ({ ids, tagIds }: { ids: string[]; tagIds: string[] }) => mutationFn: ({ ids, tagIds }: { ids: string[]; tagIds: string[] }) =>
@@ -137,7 +124,7 @@ export function RightSidebar() {
invalidateTagsAndPhotos() invalidateTagsAndPhotos()
}, },
onError: (e: any) => onError: (e: any) =>
toast.error('Remove tags failed', e?.message || 'Unknown error'), toast.error('Remove tags failed', formatApiError(e)),
}) })
// Idempotent create-and-attach: lets the user type a brand-new tag // Idempotent create-and-attach: lets the user type a brand-new tag
@@ -152,7 +139,7 @@ export function RightSidebar() {
invalidateTagsAndPhotos() invalidateTagsAndPhotos()
}, },
onError: (e: any) => onError: (e: any) =>
toast.error('Create tag failed', e?.message || 'Unknown error'), toast.error('Create tag failed', formatApiError(e)),
}) })
const { data: allTags = [] } = useTagsQuery() const { data: allTags = [] } = useTagsQuery()
@@ -182,7 +169,7 @@ export function RightSidebar() {
if (activeHeap && ctx?.previous) { if (activeHeap && ctx?.previous) {
queryClient.setQueryData(['heap-photo-ids', activeHeap.id], ctx.previous) queryClient.setQueryData(['heap-photo-ids', activeHeap.id], ctx.previous)
} }
toast.error('Heap update failed', e?.message || 'Unknown error') toast.error('Heap update failed', formatApiError(e))
}, },
onSettled: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY }) queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
@@ -228,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">
@@ -258,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>
@@ -346,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
)} )}
@@ -376,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'
@@ -459,265 +454,3 @@ export function RightSidebar() {
</div> </div>
) )
} }
interface BulkTakenAtEditorProps {
disabled: boolean
selectedCount: number
collectPhotos: () => Photo[]
onApplyUniform: (iso: string) => void
onApplyMap: (map: Record<string, string>) => void
}
/** Bulk Date Taken sub-panel used by RightSidebar in multi-select mode.
* Two modes share one UI:
* 1. Apply-one: user types a datetime, clicks Apply, every selected
* photo is rewritten to that date.
* 2. Guess-from-path: we run `guessDateFromPath` against each selected
* photo's filepath, show a preview of the hits + misses, and let
* the user commit the per-photo map in one round-trip. */
function BulkTakenAtEditor({
disabled,
selectedCount,
collectPhotos,
onApplyUniform,
onApplyMap,
}: BulkTakenAtEditorProps) {
const [uniformDraft, setUniformDraft] = useState('')
const [preview, setPreview] = useState<
| {
hits: { photo: Photo; guess: DateGuess }[]
misses: Photo[]
}
| null
>(null)
const handleGuess = () => {
const photos = collectPhotos()
const hits: { photo: Photo; guess: DateGuess }[] = []
const misses: Photo[] = []
for (const p of photos) {
const g = guessDateFromPath(p.filepath)
if (g) hits.push({ photo: p, guess: g })
else misses.push(p)
}
setPreview({ hits, misses })
}
const handleApplyPreview = () => {
if (!preview) return
const map: Record<string, string> = {}
for (const { photo, guess } of preview.hits) {
map[photo.id] = guess.date.toISOString()
}
if (Object.keys(map).length === 0) return
onApplyMap(map)
setPreview(null)
}
const handleApplyUniform = () => {
if (!uniformDraft) return
const parsed = new Date(uniformDraft)
if (Number.isNaN(parsed.getTime())) return
onApplyUniform(parsed.toISOString())
}
return (
<div className="space-y-2">
{/* Apply-one row */}
<div className="flex items-center gap-1.5">
<Input
type="datetime-local"
value={uniformDraft}
onChange={(e) => setUniformDraft(e.target.value)}
disabled={disabled}
className="h-7 flex-1 text-xs"
/>
<Button
size="sm"
onClick={handleApplyUniform}
disabled={disabled || !uniformDraft}
className="bg-primary/20 text-primary hover:bg-primary/30"
title={`Apply this date to all ${selectedCount} selected`}
>
Apply
</Button>
</div>
{/* Guess-from-path preview */}
{preview === null ? (
<Button
variant="outline"
size="sm"
onClick={handleGuess}
disabled={disabled}
className="w-full border-dashed border-primary/50 bg-transparent text-primary hover:bg-primary/10"
title="Scan each photo's folder + filename for a date pattern"
>
Guess from folder paths
</Button>
) : (
<div className="rounded border border-border bg-bg p-2 text-[11px]">
<div className="mb-1.5 text-text-muted">
{preview.hits.length} will update ·{' '}
{preview.misses.length} skipped
</div>
{preview.hits.length > 0 && (
<ul className="mb-1.5 max-h-24 space-y-0.5 overflow-y-auto font-mono text-[10px] text-text">
{preview.hits.slice(0, 5).map(({ photo, guess }) => (
<li key={photo.id} className="truncate" title={photo.filepath}>
<span className="text-text-muted">{photo.filename}</span>
{' → '}
<span className="text-primary">
{format(guess.date, 'yyyy-MM-dd')}
</span>
</li>
))}
{preview.hits.length > 5 && (
<li className="text-text-muted">
and {preview.hits.length - 5} more
</li>
)}
</ul>
)}
<div className="flex gap-1.5">
<Button
size="sm"
onClick={handleApplyPreview}
disabled={disabled || preview.hits.length === 0}
className="flex-1 bg-primary/20 text-primary hover:bg-primary/30"
>
Apply {preview.hits.length}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setPreview(null)}
disabled={disabled}
className="text-text-muted"
>
Cancel
</Button>
</div>
</div>
)}
</div>
)
}
interface BulkTagsEditorProps {
allTags: { id: string; name: string; color: string | null }[]
tagInput: string
onTagInputChange: (value: string) => void
disabled: boolean
onApply: (tagId: string) => void
onRemove: (tagId: string) => void
onCreate: (name: string) => void
}
/**
* Compact bulk tag editor for the multi-select right sidebar. Unlike the
* single-photo TagsEditor we don't show "current tags" — there's no clean
* single-photo notion of that across an arbitrary selection. Instead the
* user picks an existing tag (apply to all) or types a new one (create
* and apply to all).
*/
function BulkTagsEditor({
allTags,
tagInput,
onTagInputChange,
disabled,
onApply,
onRemove,
onCreate,
}: BulkTagsEditorProps) {
const trimmed = tagInput.trim()
const lower = trimmed.toLowerCase()
const filtered = trimmed
? allTags.filter((t) => t.name.toLowerCase().includes(lower))
: allTags
const exactMatch = trimmed
? allTags.find((t) => t.name.toLowerCase() === lower)
: null
const handleSubmit = () => {
if (!trimmed || disabled) return
if (exactMatch) {
onApply(exactMatch.id)
onTagInputChange('')
} else {
onCreate(trimmed)
}
}
return (
<div className="space-y-2">
<Input
type="text"
value={tagInput}
onChange={(e) => onTagInputChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
} else if (e.key === 'Escape') {
onTagInputChange('')
}
}}
placeholder="Filter or create…"
disabled={disabled}
className="h-7 text-xs"
/>
{trimmed && !exactMatch && (
<Button
variant="outline"
size="sm"
onClick={handleSubmit}
disabled={disabled}
className="w-full border-dashed border-primary/50 bg-transparent text-primary hover:bg-primary/10"
>
<Plus className="mr-1 h-3 w-3" />
Create "{trimmed}" and apply
</Button>
)}
{filtered.length > 0 ? (
<div className="flex max-h-40 flex-wrap gap-1 overflow-y-auto">
{filtered.map((tag) => (
<span
key={tag.id}
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
style={
tag.color
? { backgroundColor: `${tag.color}33`, color: tag.color }
: undefined
}
>
<button
onClick={() => onApply(tag.id)}
disabled={disabled}
className="hover:underline disabled:opacity-50"
title={`Apply "${tag.name}" to selection`}
>
{tag.name}
</button>
<button
onClick={() => onRemove(tag.id)}
disabled={disabled}
className="rounded p-0.5 opacity-60 hover:bg-surface-offset hover:opacity-100 disabled:opacity-30"
title={`Remove "${tag.name}" from selection`}
aria-label={`Remove ${tag.name} from selection`}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
) : (
<div className="text-xs text-text-faint">No tags match</div>
)}
</div>
)
}

View File

@@ -1,70 +1,162 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { photos, photos as photosApi } from '../../services/api' import { Loader2 } from 'lucide-react'
import type { MemoryGroup } from '../../services/api' import {
photos,
type MemoriesResponse,
type MemoryPhoto,
} from '../../services/api'
import type { Photo } from '../../types/photo'
import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore } from '../../store/filterStore'
import { PhotoThumbnail } from '../timeline/PhotoThumbnail'
const CELL_SIZE = 180
const GAP = 4
/**
* "On this day" view. Same visual + interaction grid as Timeline —
* PhotoThumbnail cells wired up to the shared photo store, so selection,
* heap membership, preview (double-click / Enter), and drag-to-heap all
* work identically. Just grouped by year instead of month, and sourced
* from the /memories endpoint.
*/
export function MemoriesView() { export function MemoriesView() {
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery<MemoriesResponse>({
queryKey: ['memories'], queryKey: ['memories'],
queryFn: () => photos.memories(), queryFn: photos.memories,
staleTime: 60_000 * 30, // 30 min — date doesn't change often staleTime: 60_000 * 30,
}) })
const memories = data?.memories ?? []
// Flat ordered sequence of ids across every year section — fed to
// the store so preview arrow nav walks the same order the user sees.
const visibleSequence = useMemo(
() => memories.flatMap((g) => g.photos.map((p) => p.id)),
[memories],
)
const setVisiblePhotoIds = usePhotoStore((s) => s.setVisiblePhotoIds)
useEffect(() => {
setVisiblePhotoIds(visibleSequence)
}, [visibleSequence, setVisiblePhotoIds])
const selectedPhotos = usePhotoStore((s) => s.selectedPhotos)
const selectPhoto = usePhotoStore((s) => s.selectPhoto)
const togglePhotoSelection = usePhotoStore((s) => s.togglePhotoSelection)
const selectRange = usePhotoStore((s) => s.selectRange)
const openPreview = usePhotoStore((s) => s.openPreview)
const searchQuery = useFilterStore((s) => s.q)
const visibleSequenceRef = useRef<string[]>([])
visibleSequenceRef.current = visibleSequence
const handleCellClick = useCallback(
(photo: Photo, e: React.MouseEvent) => {
if (e.shiftKey) selectRange(photo.id)
else if (e.ctrlKey || e.metaKey) togglePhotoSelection(photo.id)
else selectPhoto(photo.id)
},
[selectRange, togglePhotoSelection, selectPhoto],
)
const handleCellDoubleClick = useCallback(
(photo: Photo) => {
openPreview(photo.id, visibleSequenceRef.current)
},
[openPreview],
)
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center h-64 text-neutral-500"> <div className="flex h-full items-center justify-center text-text-muted">
Loading memories... <Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading memories
</div> </div>
) )
} }
const memories = data?.memories ?? []
if (memories.length === 0) { if (memories.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center h-64 text-neutral-500 gap-2"> <div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center text-text-muted">
<p className="text-lg font-medium">No memories for today</p> <div className="text-sm font-medium text-text">
<p className="text-sm">Photos taken on this date in previous years will appear here.</p> No memories for today
</div>
<p className="max-w-sm text-xs">
Photos taken on this date in previous years will appear here.
</p>
</div> </div>
) )
} }
return ( return (
<div className="p-6 space-y-8 max-w-5xl mx-auto"> <div
<h2 className="text-xl font-semibold text-neutral-200"> className="h-full overflow-auto bg-bg p-4"
On This Day &mdash; {data?.date} role="grid"
aria-label="Memories"
>
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-text">
On this day &middot; {data?.date}
</h2> </h2>
{memories.map((group: MemoryGroup) => ( <div className="space-y-6">
<section key={group.year} className="space-y-3"> {memories.map((group) => (
<h3 className="text-sm font-medium text-neutral-400 uppercase tracking-wide"> <section key={group.year} className="space-y-2">
{group.year} &middot; {group.years_ago} year{group.years_ago !== 1 ? 's' : ''} ago <header className="flex items-baseline gap-2">
</h3> <h3 className="text-sm font-semibold uppercase tracking-wide text-text">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2"> {group.year}
{group.photos.map((photo) => ( </h3>
<div <span className="text-xs text-text-muted">
key={photo.id} {group.years_ago} year{group.years_ago === 1 ? '' : 's'} ago
className="aspect-square rounded-lg overflow-hidden bg-neutral-800 relative group" </span>
> </header>
{photo.thumb_small ? ( <div
<img className="grid"
src={photosApi.getThumbnailUrl(photo.id, 'small')} style={{
alt={photo.filename} gridTemplateColumns: `repeat(auto-fill, minmax(${CELL_SIZE}px, 1fr))`,
className="w-full h-full object-cover" gridAutoRows: `${CELL_SIZE}px`,
loading="lazy" gap: `${GAP}px`,
/> }}
) : ( >
<div className="w-full h-full flex items-center justify-center text-neutral-600 text-xs"> {group.photos.map((m) => (
No thumb <PhotoThumbnail
</div> key={m.id}
)} photo={memoryToPhoto(m)}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent px-2 py-1 opacity-0 group-hover:opacity-100 transition-opacity"> size={CELL_SIZE}
<p className="text-xs text-white truncate">{photo.filename}</p> fill
</div> isSelected={selectedPhotos.includes(m.id)}
</div> searchQuery={searchQuery}
))} onClick={handleCellClick}
</div> onDoubleClick={handleCellDoubleClick}
</section> />
))} ))}
</div>
</section>
))}
</div>
</div> </div>
) )
} }
/** Widen the slim MemoryPhoto payload to the full Photo shape the
* thumbnail expects. Fields the memories endpoint doesn't return
* (is_duplicate, file_hash, ...) get sensible defaults — memories
* never surface discarded/duplicate photos server-side. */
function memoryToPhoto(m: MemoryPhoto): Photo {
return {
id: m.id,
filepath: m.filename,
filename: m.filename,
media_type: m.media_type,
width: m.width,
height: m.height,
taken_at: m.taken_at,
rating: m.rating,
is_discarded: false,
is_duplicate: false,
file_hash: '',
folder_id: null,
added_at: null,
thumb_small: m.thumb_small ?? undefined,
thumb_medium: m.thumb_medium ?? undefined,
}
}

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

@@ -3,8 +3,10 @@ import { useQuery } from '@tanstack/react-query'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { X, Info } from 'lucide-react' import { X, Info } from 'lucide-react'
import { usePhotoStore } from '../../store/photoStore' import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore } from '../../store/filterStore'
import { usePhotosQuery } from '../../hooks/usePhotosQuery' import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { photos as photosApi } from '../../services/api' import { photos as photosApi } from '../../services/api'
import { findSearchMatch } from '../../lib/searchMatch'
import type { Photo } from '../../types/photo' import type { Photo } from '../../types/photo'
import { PreviewImage } from './PreviewImage' import { PreviewImage } from './PreviewImage'
import { PreviewFilmstrip } from './PreviewFilmstrip' import { PreviewFilmstrip } from './PreviewFilmstrip'
@@ -84,6 +86,15 @@ export function PreviewView() {
const currentPhoto: Photo | undefined = const currentPhoto: Photo | undefined =
photoInListById ?? photos[safeIndex] ?? standalonePhoto photoInListById ?? photos[safeIndex] ?? standalonePhoto
// Carry the timeline's search-match chip into preview so the user
// doesn't lose the "why did this photo come back" context when they
// zoom in. Pure recompute — same helper the thumbnail uses.
const searchQuery = useFilterStore((s) => s.q)
const searchMatch =
currentPhoto && searchQuery.trim()
? findSearchMatch(currentPhoto, searchQuery)
: null
// Keep the latest photos array + active id in a ref so the keyboard // Keep the latest photos array + active id in a ref so the keyboard
// handlers ALWAYS read the freshest state. Without this, react-hotkeys- // handlers ALWAYS read the freshest state. Without this, react-hotkeys-
// hook can fire a closure that captured an older photos array (e.g. // hook can fire a closure that captured an older photos array (e.g.
@@ -216,12 +227,40 @@ export function PreviewView() {
> >
{/* Main column — image + filmstrip */} {/* Main column — image + filmstrip */}
<div className="relative flex min-w-0 flex-1 flex-col"> <div className="relative flex min-w-0 flex-1 flex-col">
{/* Filename + counter */} {/* Filename + counter + (optional) search match chip */}
<div className="absolute left-3 top-3 z-10 rounded bg-black/60 px-3 py-1.5 text-xs text-white"> <div className="absolute left-3 top-3 z-10 max-w-[60%] rounded bg-black/60 px-3 py-1.5 text-xs text-white">
<div className="font-mono">{currentPhoto.filename}</div> <div className="truncate font-mono">{currentPhoto.filename}</div>
<div className="text-text-muted"> <div className="text-text-muted">
{safeIndex + 1} / {photos.length} {safeIndex + 1} / {photos.length}
</div> </div>
{searchMatch && (
<div
className="mt-1 flex items-center gap-1.5 text-[11px]"
title={`Matched on ${searchMatch.label.toLowerCase()}: ${searchMatch.excerpt}`}
>
<span className="shrink-0 rounded-sm bg-primary/80 px-1 text-[9px] font-semibold uppercase tracking-wider">
{searchMatch.label}
</span>
<span className="truncate">
{searchMatch.matchLength > 0 ? (
<>
{searchMatch.excerpt.slice(0, searchMatch.matchStart)}
<mark className="rounded-sm bg-amber-400/90 px-0.5 font-semibold text-black">
{searchMatch.excerpt.slice(
searchMatch.matchStart,
searchMatch.matchStart + searchMatch.matchLength,
)}
</mark>
{searchMatch.excerpt.slice(
searchMatch.matchStart + searchMatch.matchLength,
)}
</>
) : (
searchMatch.excerpt
)}
</span>
</div>
)}
</div> </div>
{/* Top-right action buttons */} {/* Top-right action buttons */}

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

@@ -1,7 +1,12 @@
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Users, Trash2 } from 'lucide-react' import { Users, Trash2 } from 'lucide-react'
import { sharing, type ShareInfo } from '../../services/api' import {
sharing,
type ShareInfo,
type ShareableUser,
} from '../../services/api'
import { formatApiError } from '../../lib/apiError'
import { import {
SHARED_HEAPS_KEY, SHARED_HEAPS_KEY,
SHARED_FOLDERS_KEY, SHARED_FOLDERS_KEY,
@@ -13,7 +18,6 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -54,6 +58,24 @@ export function ShareDialog({
enabled: isOpen, enabled: isOpen,
}) })
// All users that can be shared with (the caller is filtered out
// server-side). Only fetched while the dialog is open. Cached for 60s
// because the user directory changes slowly.
const { data: allUsers = [], isLoading: isLoadingUsers } = useQuery<
ShareableUser[]
>({
queryKey: ['sharing', 'users'],
queryFn: sharing.listUsers,
enabled: isOpen,
staleTime: 60_000,
})
// Users who don't already have a share on this target.
const availableUsers = useMemo(() => {
const taken = new Set(shares.map((s) => s.shared_with_username))
return allUsers.filter((u) => !taken.has(u.username))
}, [allUsers, shares])
const addMutation = useMutation({ const addMutation = useMutation({
mutationFn: () => mutationFn: () =>
type === 'heap' type === 'heap'
@@ -68,8 +90,8 @@ export function ShareDialog({
queryKey: type === 'heap' ? SHARED_HEAPS_KEY : SHARED_FOLDERS_KEY, queryKey: type === 'heap' ? SHARED_HEAPS_KEY : SHARED_FOLDERS_KEY,
}) })
}, },
onError: (err: any) => { onError: (err) => {
setError(err?.response?.data?.detail || 'Failed to share') setError(formatApiError(err, 'Failed to share'))
}, },
}) })
@@ -149,17 +171,33 @@ export function ShareDialog({
className="space-y-3" className="space-y-3"
> >
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Select
type="text"
value={username} value={username}
onChange={(e) => { onValueChange={(v) => {
setUsername(e.target.value) setUsername(v)
setError(null) setError(null)
}} }}
placeholder="Username" disabled={isLoadingUsers || availableUsers.length === 0}
className="flex-1" >
autoFocus <SelectTrigger className="flex-1">
/> <SelectValue
placeholder={
isLoadingUsers
? 'Loading users…'
: availableUsers.length === 0
? 'No users to share with'
: 'Select a user'
}
/>
</SelectTrigger>
<SelectContent>
{availableUsers.map((u) => (
<SelectItem key={u.id} value={u.username}>
{u.username}
</SelectItem>
))}
</SelectContent>
</Select>
<Select <Select
value={permission} value={permission}
onValueChange={(v) => setPermission(v as 'read' | 'write')} onValueChange={(v) => setPermission(v as 'read' | 'write')}

View File

@@ -0,0 +1,121 @@
import { Plus, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
interface BulkTagsEditorProps {
allTags: { id: string; name: string; color: string | null }[]
tagInput: string
onTagInputChange: (value: string) => void
disabled: boolean
onApply: (tagId: string) => void
onRemove: (tagId: string) => void
onCreate: (name: string) => void
}
/**
* Compact bulk tag editor for the multi-select right sidebar. Unlike the
* single-photo TagsEditor we don't show "current tags" — there's no clean
* single-photo notion of that across an arbitrary selection. Instead the
* user picks an existing tag (apply to all) or types a new one (create
* and apply to all).
*/
export function BulkTagsEditor({
allTags,
tagInput,
onTagInputChange,
disabled,
onApply,
onRemove,
onCreate,
}: BulkTagsEditorProps) {
const trimmed = tagInput.trim()
const lower = trimmed.toLowerCase()
const filtered = trimmed
? allTags.filter((t) => t.name.toLowerCase().includes(lower))
: allTags
const exactMatch = trimmed
? allTags.find((t) => t.name.toLowerCase() === lower)
: null
const handleSubmit = () => {
if (!trimmed || disabled) return
if (exactMatch) {
onApply(exactMatch.id)
onTagInputChange('')
} else {
onCreate(trimmed)
}
}
return (
<div className="space-y-2">
<Input
type="text"
value={tagInput}
onChange={(e) => onTagInputChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
} else if (e.key === 'Escape') {
onTagInputChange('')
}
}}
placeholder="Filter or create…"
disabled={disabled}
className="h-7 text-xs"
/>
{trimmed && !exactMatch && (
<Button
variant="outline"
size="sm"
onClick={handleSubmit}
disabled={disabled}
className="w-full border-dashed border-primary/50 bg-transparent text-primary hover:bg-primary/10"
>
<Plus className="mr-1 h-3 w-3" />
Create "{trimmed}" and apply
</Button>
)}
{filtered.length > 0 ? (
<div className="flex max-h-40 flex-wrap gap-1 overflow-y-auto">
{filtered.map((tag) => (
<span
key={tag.id}
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
style={
tag.color
? { backgroundColor: `${tag.color}33`, color: tag.color }
: undefined
}
>
<button
onClick={() => onApply(tag.id)}
disabled={disabled}
className="hover:underline disabled:opacity-50"
title={`Apply "${tag.name}" to selection`}
>
{tag.name}
</button>
<button
onClick={() => onRemove(tag.id)}
disabled={disabled}
className="rounded p-0.5 opacity-60 hover:bg-surface-offset hover:opacity-100 disabled:opacity-30"
title={`Remove "${tag.name}" from selection`}
aria-label={`Remove ${tag.name} from selection`}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
) : (
<div className="text-xs text-text-faint">No tags match</div>
)}
</div>
)
}

View File

@@ -0,0 +1,153 @@
import { useState } from 'react'
import { format } from 'date-fns'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
guessDateFromPath,
type DateGuess,
} from '../../lib/guessDateFromPath'
import type { Photo } from '../../types/photo'
interface BulkTakenAtEditorProps {
disabled: boolean
selectedCount: number
collectPhotos: () => Photo[]
onApplyUniform: (iso: string) => void
onApplyMap: (map: Record<string, string>) => void
}
/** Bulk Date Taken sub-panel used by RightSidebar in multi-select mode.
* Two modes share one UI:
* 1. Apply-one: user types a datetime, clicks Apply, every selected
* photo is rewritten to that date.
* 2. Guess-from-path: we run `guessDateFromPath` against each selected
* photo's filepath, show a preview of the hits + misses, and let
* the user commit the per-photo map in one round-trip. */
export function BulkTakenAtEditor({
disabled,
selectedCount,
collectPhotos,
onApplyUniform,
onApplyMap,
}: BulkTakenAtEditorProps) {
const [uniformDraft, setUniformDraft] = useState('')
const [preview, setPreview] = useState<
| {
hits: { photo: Photo; guess: DateGuess }[]
misses: Photo[]
}
| null
>(null)
const handleGuess = () => {
const photos = collectPhotos()
const hits: { photo: Photo; guess: DateGuess }[] = []
const misses: Photo[] = []
for (const p of photos) {
const g = guessDateFromPath(p.filepath)
if (g) hits.push({ photo: p, guess: g })
else misses.push(p)
}
setPreview({ hits, misses })
}
const handleApplyPreview = () => {
if (!preview) return
const map: Record<string, string> = {}
for (const { photo, guess } of preview.hits) {
map[photo.id] = guess.date.toISOString()
}
if (Object.keys(map).length === 0) return
onApplyMap(map)
setPreview(null)
}
const handleApplyUniform = () => {
if (!uniformDraft) return
const parsed = new Date(uniformDraft)
if (Number.isNaN(parsed.getTime())) return
onApplyUniform(parsed.toISOString())
}
return (
<div className="space-y-2">
{/* Apply-one row */}
<div className="flex items-center gap-1.5">
<Input
type="datetime-local"
value={uniformDraft}
onChange={(e) => setUniformDraft(e.target.value)}
disabled={disabled}
className="h-7 flex-1 text-xs"
/>
<Button
size="sm"
onClick={handleApplyUniform}
disabled={disabled || !uniformDraft}
className="bg-primary/20 text-primary hover:bg-primary/30"
title={`Apply this date to all ${selectedCount} selected`}
>
Apply
</Button>
</div>
{/* Guess-from-path preview */}
{preview === null ? (
<Button
variant="outline"
size="sm"
onClick={handleGuess}
disabled={disabled}
className="w-full border-dashed border-primary/50 bg-transparent text-primary hover:bg-primary/10"
title="Scan each photo's folder + filename for a date pattern"
>
Guess from folder paths
</Button>
) : (
<div className="rounded border border-border bg-bg p-2 text-[11px]">
<div className="mb-1.5 text-text-muted">
{preview.hits.length} will update ·{' '}
{preview.misses.length} skipped
</div>
{preview.hits.length > 0 && (
<ul className="mb-1.5 max-h-24 space-y-0.5 overflow-y-auto font-mono text-[10px] text-text">
{preview.hits.slice(0, 5).map(({ photo, guess }) => (
<li key={photo.id} className="truncate" title={photo.filepath}>
<span className="text-text-muted">{photo.filename}</span>
{' → '}
<span className="text-primary">
{format(guess.date, 'yyyy-MM-dd')}
</span>
</li>
))}
{preview.hits.length > 5 && (
<li className="text-text-muted">
and {preview.hits.length - 5} more
</li>
)}
</ul>
)}
<div className="flex gap-1.5">
<Button
size="sm"
onClick={handleApplyPreview}
disabled={disabled || preview.hits.length === 0}
className="flex-1 bg-primary/20 text-primary hover:bg-primary/30"
>
Apply {preview.hits.length}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => setPreview(null)}
disabled={disabled}
className="text-text-muted"
>
Cancel
</Button>
</div>
</div>
)}
</div>
)
}

View File

@@ -10,24 +10,23 @@ 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,
useQueryClient, useQueryClient,
keepPreviousData, keepPreviousData,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import { format } from 'date-fns'
import { import {
photos as photosApi, photos as photosApi,
heaps as heapsApi, heaps as heapsApi,
tags as tagsApi, tags as tagsApi,
type Tag,
} from '../../services/api' } from '../../services/api'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery' import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery' import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery' import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery' import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
import { formatApiError } from '../../lib/apiError'
import { toast } from '../ToastContainer' import { toast } from '../ToastContainer'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
@@ -40,18 +39,17 @@ import {
COLOR_LABEL_OPTIONS, COLOR_LABEL_OPTIONS,
type ColorLabel, type ColorLabel,
} from '../../constants/colorLabels' } from '../../constants/colorLabels'
import { import { toDatetimeLocalValue } from '../../lib/guessDateFromPath'
guessDateFromPath, import { TagsEditor } from './TagsEditor'
toDatetimeLocalValue, import { TakenAtEditor } from './TakenAtEditor'
} from '../../lib/guessDateFromPath'
interface PhotoTagSummary { export interface PhotoTagSummary {
id: string id: string
name: string name: string
color: string | null color: string | null
} }
interface PhotoDetails { export interface PhotoDetails {
id: string id: string
filename: string filename: string
filepath: string filepath: string
@@ -240,21 +238,21 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
}, },
onSuccess: () => invalidateTagsAndPhoto(), onSuccess: () => invalidateTagsAndPhoto(),
onError: (e: any) => onError: (e: any) =>
toast.error('Add tag failed', e?.message || 'Unknown error'), toast.error('Add tag failed', formatApiError(e)),
}) })
const attachExistingTagMutation = useMutation({ const attachExistingTagMutation = useMutation({
mutationFn: (tagId: string) => tagsApi.addToPhoto(photoId, [tagId]), mutationFn: (tagId: string) => tagsApi.addToPhoto(photoId, [tagId]),
onSuccess: () => invalidateTagsAndPhoto(), onSuccess: () => invalidateTagsAndPhoto(),
onError: (e: any) => onError: (e: any) =>
toast.error('Add tag failed', e?.message || 'Unknown error'), toast.error('Add tag failed', formatApiError(e)),
}) })
const removeTagMutation = useMutation({ const removeTagMutation = useMutation({
mutationFn: (tagId: string) => tagsApi.removeFromPhoto(photoId, tagId), mutationFn: (tagId: string) => tagsApi.removeFromPhoto(photoId, tagId),
onSuccess: () => invalidateTagsAndPhoto(), onSuccess: () => invalidateTagsAndPhoto(),
onError: (e: any) => onError: (e: any) =>
toast.error('Remove tag failed', e?.message || 'Unknown error'), toast.error('Remove tag failed', formatApiError(e)),
}) })
// Local drafts for the text fields. Mirror the server value but stay // Local drafts for the text fields. Mirror the server value but stay
@@ -297,7 +295,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
onError: (e: any) => { onError: (e: any) => {
toast.error( toast.error(
'Rename failed', 'Rename failed',
e?.response?.data?.detail || e.message || 'Unknown error' formatApiError(e)
) )
setFilenameDraft(current) setFilenameDraft(current)
}, },
@@ -345,7 +343,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
onError: (e: any) => { onError: (e: any) => {
toast.error( toast.error(
'Date update failed', 'Date update failed',
e?.response?.data?.detail || e.message || 'Unknown error' formatApiError(e)
) )
setTakenAtDraft( setTakenAtDraft(
photo?.taken_at photo?.taken_at
@@ -369,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'
@@ -384,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'
)} )}
@@ -438,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>
@@ -456,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'
@@ -480,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'
@@ -511,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'
@@ -530,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'
@@ -707,129 +705,6 @@ function Section({
) )
} }
interface TagsEditorProps {
photoTags: PhotoTagSummary[]
allTags: Tag[]
tagInput: string
onTagInputChange: (value: string) => void
onAttachExisting: (id: string) => void
onCreateAndAttach: (name: string) => void
onRemove: (id: string) => void
}
function TagsEditor({
photoTags,
allTags,
tagInput,
onTagInputChange,
onAttachExisting,
onCreateAndAttach,
onRemove,
}: TagsEditorProps) {
const trimmed = tagInput.trim()
const lowerTrimmed = trimmed.toLowerCase()
const photoTagIds = new Set(photoTags.map((t) => t.id))
const suggestions = trimmed
? allTags
.filter(
(t) =>
!photoTagIds.has(t.id) &&
t.name.toLowerCase().includes(lowerTrimmed)
)
.slice(0, 6)
: []
const exactMatch = trimmed
? allTags.find((t) => t.name.toLowerCase() === lowerTrimmed)
: null
const handleSubmit = () => {
if (!trimmed) return
if (exactMatch) {
if (!photoTagIds.has(exactMatch.id)) {
onAttachExisting(exactMatch.id)
}
onTagInputChange('')
} else {
onCreateAndAttach(trimmed)
}
}
return (
<div className="space-y-2">
{photoTags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{photoTags.map((tag) => (
<span
key={tag.id}
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
style={
tag.color
? { backgroundColor: `${tag.color}33`, color: tag.color }
: undefined
}
>
{tag.name}
<button
onClick={() => onRemove(tag.id)}
className="rounded p-0.5 opacity-60 hover:bg-surface-offset hover:opacity-100"
title="Remove tag"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
) : (
<div className="text-xs text-text-faint">No tags</div>
)}
<div className="relative">
<Input
type="text"
value={tagInput}
onChange={(e) => onTagInputChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
} else if (e.key === 'Escape') {
onTagInputChange('')
}
}}
placeholder="Add tag…"
className="h-7 bg-bg text-xs"
/>
{suggestions.length > 0 && (
<div className="mt-1 rounded border border-border bg-bg shadow-md">
{suggestions.map((s) => (
<button
key={s.id}
onClick={() => {
onAttachExisting(s.id)
onTagInputChange('')
}}
className="block w-full px-2 py-1 text-left text-xs text-text hover:bg-surface-2"
>
{s.name}
</button>
))}
</div>
)}
{trimmed && !exactMatch && (
<button
onClick={handleSubmit}
className="mt-1 w-full rounded border border-dashed border-primary/50 px-2 py-1 text-left text-xs text-primary hover:bg-primary/10"
>
+ Create "{trimmed}"
</button>
)}
</div>
</div>
)
}
function Field({ label, value }: { label: string; value: string }) { function Field({ label, value }: { label: string; value: string }) {
return ( return (
<div> <div>
@@ -839,113 +714,3 @@ function Field({ label, value }: { label: string; value: string }) {
) )
} }
interface TakenAtEditorProps {
photo: PhotoDetails
draft: string
onDraftChange: (v: string) => void
onCommit: (raw?: string) => void
darkTheme: boolean
}
/** Editable Date Taken field with a source badge (EXIF / filesystem / manual)
* and a folder-guess suggestion row that only shows up when the filepath
* implies a different date than what's currently stored. The suggestion
* hint is the whole point of this feature — epoch-reset phones and
* corrupted EXIF dumps end up clustered in the wrong corner of the
* timeline until someone rewrites them from the folder name. */
function TakenAtEditor({
photo,
draft,
onDraftChange,
onCommit,
darkTheme,
}: TakenAtEditorProps) {
const source = photo.taken_at_source ?? null
const sourceLabel =
source === 'exif'
? 'EXIF'
: source === 'filesystem'
? 'FILE'
: source === 'manual'
? 'MANUAL'
: null
const guess = useMemo(
() => guessDateFromPath(photo.filepath),
[photo.filepath]
)
// Show the suggestion when:
// - there's no stored date at all, OR
// - the guess disagrees with the stored date by more than a day.
// A same-day match is treated as "already correct enough" so we don't
// nag the user on photos that happen to sit in a dated folder.
const showSuggestion = useMemo(() => {
if (!guess) return false
if (!photo.taken_at) return true
const current = new Date(photo.taken_at).getTime()
const suggested = guess.date.getTime()
return Math.abs(current - suggested) > 24 * 60 * 60 * 1000
}, [guess, photo.taken_at])
const inputClass = clsx(
'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'
: 'border-border bg-bg text-text focus:border-primary'
)
return (
<div className="mt-2 text-xs">
<label className="mb-1 block text-text-muted">Date Taken</label>
<div className="flex items-center gap-1.5">
<input
type="datetime-local"
value={draft}
onChange={(e) => onDraftChange(e.target.value)}
onBlur={() => onCommit()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
onDraftChange(
photo.taken_at
? toDatetimeLocalValue(new Date(photo.taken_at))
: ''
)
e.currentTarget.blur()
}
}}
className={inputClass}
/>
{sourceLabel && (
<span
className="rounded-sm bg-black/60 px-1.5 py-0.5 text-[9px] font-semibold tracking-wider text-white"
title={`Source: ${sourceLabel.toLowerCase()}`}
>
{sourceLabel}
</span>
)}
</div>
{showSuggestion && guess && (
<button
onClick={() => {
const next = toDatetimeLocalValue(guess.date)
onDraftChange(next)
onCommit(next)
}}
className={clsx(
'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'
)}
title={`Match "${guess.matched}" in path (${guess.source}, ${guess.confidence} confidence)`}
>
<span className="truncate">
Folder suggests {format(guess.date, 'MMM d, yyyy')}
</span>
<span className="shrink-0 font-semibold">Apply</span>
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,133 @@
import { X } from 'lucide-react'
import { Input } from '@/components/ui/input'
import type { Tag } from '../../services/api'
import type { PhotoTagSummary } from './PhotoInfoPanel'
interface TagsEditorProps {
photoTags: PhotoTagSummary[]
allTags: Tag[]
tagInput: string
onTagInputChange: (value: string) => void
onAttachExisting: (id: string) => void
onCreateAndAttach: (name: string) => void
onRemove: (id: string) => void
}
/**
* Single-photo tag editor. Shows the photo's current tags as chips,
* offers an inline search that surfaces up to 6 matching unused tags,
* and an explicit "Create" affordance when the typed name doesn't
* exist. Used by PhotoInfoPanel in the single-selection right panel.
*/
export function TagsEditor({
photoTags,
allTags,
tagInput,
onTagInputChange,
onAttachExisting,
onCreateAndAttach,
onRemove,
}: TagsEditorProps) {
const trimmed = tagInput.trim()
const lowerTrimmed = trimmed.toLowerCase()
const photoTagIds = new Set(photoTags.map((t) => t.id))
const suggestions = trimmed
? allTags
.filter(
(t) =>
!photoTagIds.has(t.id) &&
t.name.toLowerCase().includes(lowerTrimmed)
)
.slice(0, 6)
: []
const exactMatch = trimmed
? allTags.find((t) => t.name.toLowerCase() === lowerTrimmed)
: null
const handleSubmit = () => {
if (!trimmed) return
if (exactMatch) {
if (!photoTagIds.has(exactMatch.id)) {
onAttachExisting(exactMatch.id)
}
onTagInputChange('')
} else {
onCreateAndAttach(trimmed)
}
}
return (
<div className="space-y-2">
{photoTags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{photoTags.map((tag) => (
<span
key={tag.id}
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
style={
tag.color
? { backgroundColor: `${tag.color}33`, color: tag.color }
: undefined
}
>
{tag.name}
<button
onClick={() => onRemove(tag.id)}
className="rounded p-0.5 opacity-60 hover:bg-surface-offset hover:opacity-100"
title="Remove tag"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
) : (
<div className="text-xs text-text-faint">No tags</div>
)}
<div className="relative">
<Input
type="text"
value={tagInput}
onChange={(e) => onTagInputChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
} else if (e.key === 'Escape') {
onTagInputChange('')
}
}}
placeholder="Add tag…"
className="h-7 bg-bg text-xs"
/>
{suggestions.length > 0 && (
<div className="mt-1 rounded border border-border bg-bg shadow-md">
{suggestions.map((s) => (
<button
key={s.id}
onClick={() => {
onAttachExisting(s.id)
onTagInputChange('')
}}
className="block w-full px-2 py-1 text-left text-xs text-text hover:bg-surface-2"
>
{s.name}
</button>
))}
</div>
)}
{trimmed && !exactMatch && (
<button
onClick={handleSubmit}
className="mt-1 w-full rounded border border-dashed border-primary/50 px-2 py-1 text-left text-xs text-primary hover:bg-primary/10"
>
+ Create "{trimmed}"
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,119 @@
import { useMemo } from 'react'
import { format } from 'date-fns'
import { cn } from '@/lib/utils'
import {
guessDateFromPath,
toDatetimeLocalValue,
} from '../../lib/guessDateFromPath'
import type { PhotoDetails } from './PhotoInfoPanel'
interface TakenAtEditorProps {
photo: PhotoDetails
draft: string
onDraftChange: (v: string) => void
onCommit: (raw?: string) => void
darkTheme: boolean
}
/** Editable Date Taken field with a source badge (EXIF / filesystem / manual)
* and a folder-guess suggestion row that only shows up when the filepath
* implies a different date than what's currently stored. The suggestion
* hint is the whole point of this feature — epoch-reset phones and
* corrupted EXIF dumps end up clustered in the wrong corner of the
* timeline until someone rewrites them from the folder name. */
export function TakenAtEditor({
photo,
draft,
onDraftChange,
onCommit,
darkTheme,
}: TakenAtEditorProps) {
const source = photo.taken_at_source ?? null
const sourceLabel =
source === 'exif'
? 'EXIF'
: source === 'filesystem'
? 'FILE'
: source === 'manual'
? 'MANUAL'
: null
const guess = useMemo(
() => guessDateFromPath(photo.filepath),
[photo.filepath]
)
// Show the suggestion when:
// - there's no stored date at all, OR
// - the guess disagrees with the stored date by more than a day.
// A same-day match is treated as "already correct enough" so we don't
// nag the user on photos that happen to sit in a dated folder.
const showSuggestion = useMemo(() => {
if (!guess) return false
if (!photo.taken_at) return true
const current = new Date(photo.taken_at).getTime()
const suggested = guess.date.getTime()
return Math.abs(current - suggested) > 24 * 60 * 60 * 1000
}, [guess, photo.taken_at])
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'
: 'border-border bg-bg text-text focus:border-primary'
)
return (
<div className="mt-2 text-xs">
<label className="mb-1 block text-text-muted">Date Taken</label>
<div className="flex items-center gap-1.5">
<input
type="datetime-local"
value={draft}
onChange={(e) => onDraftChange(e.target.value)}
onBlur={() => onCommit()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur()
} else if (e.key === 'Escape') {
onDraftChange(
photo.taken_at
? toDatetimeLocalValue(new Date(photo.taken_at))
: ''
)
e.currentTarget.blur()
}
}}
className={inputClass}
/>
{sourceLabel && (
<span
className="rounded-sm bg-black/60 px-1.5 py-0.5 text-[9px] font-semibold tracking-wider text-white"
title={`Source: ${sourceLabel.toLowerCase()}`}
>
{sourceLabel}
</span>
)}
</div>
{showSuggestion && guess && (
<button
onClick={() => {
const next = toDatetimeLocalValue(guess.date)
onDraftChange(next)
onCommit(next)
}}
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'
)}
title={`Match "${guess.matched}" in path (${guess.source}, ${guess.confidence} confidence)`}
>
<span className="truncate">
Folder suggests {format(guess.date, 'MMM d, yyyy')}
</span>
<span className="shrink-0 font-semibold">Apply</span>
</button>
)}
</div>
)
}

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

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { memo, useState, useEffect, useCallback, useRef } from 'react'
import { import {
Star, Star,
Trash2, Trash2,
@@ -6,12 +6,12 @@ import {
Copy, Copy,
AlertTriangle, AlertTriangle,
Users, Users,
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'
@@ -74,16 +74,24 @@ 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
onClick: (e: React.MouseEvent) => void /** Active text-search query, passed in from Timeline rather than
onDoubleClick?: (e: React.MouseEvent) => void * 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. */
onClick: (photo: Photo, e: React.MouseEvent) => void
onDoubleClick?: (photo: Photo, e: React.MouseEvent) => void
} }
export function PhotoThumbnail({ function PhotoThumbnailImpl({
photo, photo,
size, size,
fill = false, fill = false,
isSelected, isSelected,
isInActiveHeap = false, isInActiveHeap = false,
searchQuery = '',
onClick, onClick,
onDoubleClick, onDoubleClick,
}: PhotoThumbnailProps) { }: PhotoThumbnailProps) {
@@ -106,12 +114,12 @@ export function PhotoThumbnail({
// 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
@@ -197,8 +205,8 @@ export function PhotoThumbnail({
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
@@ -226,8 +234,26 @@ export function PhotoThumbnail({
? { width: '100%', height: '100%' } ? { width: '100%', height: '100%' }
: { width: size, height: displayHeight } : { width: size, height: displayHeight }
} }
onClick={onClick} role="button"
onDoubleClick={onDoubleClick} 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
}
draggable draggable
onDragStart={handleDragStart} onDragStart={handleDragStart}
title="Click to select • Double-click to open • Shift+Click for range • Ctrl+Click to add • Drag onto a heap to add" title="Click to select • Double-click to open • Shift+Click for range • Ctrl+Click to add • Drag onto a heap to add"
@@ -238,7 +264,7 @@ export function PhotoThumbnail({
<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
@@ -249,18 +275,16 @@ export function PhotoThumbnail({
onError={handleImageError} onError={handleImageError}
loading="lazy" loading="lazy"
/> />
{/* Loading indicator */} {/* Loading indicator. The outer wrapper already pulses via
{!imageLoaded && ( * `bg-surface animate-pulse` while !imageLoaded, which is the
* right default — hundreds of concurrent spinners churn the GPU
* on first paint. Only surface a visible indicator for the
* relatively rare retry case, where silence would look broken. */}
{!imageLoaded && isRetrying && (
<div className="absolute inset-0 flex items-center justify-center bg-surface"> <div className="absolute inset-0 flex items-center justify-center bg-surface">
<div className="text-text-muted"> <div className="text-center text-text-muted">
{isRetrying ? ( <RefreshCw className="mx-auto mb-1 h-4 w-4 animate-spin" />
<div className="text-center"> <div className="text-[10px]">Retrying</div>
<RefreshCw className="h-5 w-5 animate-spin mx-auto mb-1" />
<div className="text-xs">Retrying...</div>
</div>
) : (
<div className="h-8 w-8 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
)}
</div> </div>
</div> </div>
)} )}
@@ -341,7 +365,7 @@ export function PhotoThumbnail({
* 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,
@@ -360,7 +384,7 @@ export function PhotoThumbnail({
<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],
@@ -371,22 +395,36 @@ export function PhotoThumbnail({
)} )}
{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>
)} )}
</div> </div>
)} )}
{/* BR — duplicate / discard (neutral). Heap membership is shown {/* BR — duplicate / discard / date-warning (neutral info) plus a
* via the green tint overlay above, no badge here. */} * heap-membership chip. The green tint overlay above is the
* primary signal, but the icon makes membership readable in
* colorblind-safe terms too. */}
<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 && (
<div
className={cn(
THUMB_BADGE_BASE,
THUMB_BADGE_SQUARE,
THUMB_BADGE_PICK
)}
title="In active heap"
>
<ShoppingBasket className={THUMB_BADGE_ICON} strokeWidth={2.5} />
</div>
)}
{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} />
@@ -394,7 +432,7 @@ export function PhotoThumbnail({
)} )}
{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} />
@@ -402,7 +440,7 @@ export function PhotoThumbnail({
)} )}
{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
@@ -420,10 +458,18 @@ export function PhotoThumbnail({
{/* 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>
)} )}
</div> </div>
) )
} }
/**
* React.memo with the default shallow comparison. Relies on the caller
* passing stable `onClick`/`onDoubleClick` handlers (via useCallback)
* so identity doesn't churn on every parent render — that's what lets
* us skip re-render on unrelated store updates like heap invalidation.
*/
export const PhotoThumbnail = memo(PhotoThumbnailImpl)

View File

@@ -1,12 +1,14 @@
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 } from '../../store/filterStore' import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
import { PhotoThumbnail } from './PhotoThumbnail' import { PhotoThumbnail } from './PhotoThumbnail'
import { usePhotosQuery } from '../../hooks/usePhotosQuery' import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery' import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { Button } from '@/components/ui/button'
import { ImageOff } from 'lucide-react'
import type { Photo } from '../../types/photo' import type { Photo } from '../../types/photo'
// Layout constants for the grid + grouped headers. // Layout constants for the grid + grouped headers.
@@ -141,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
@@ -203,6 +209,27 @@ export function Timeline() {
// of thumbnails each subscribing to the same query. // of thumbnails each subscribing to the same query.
const { memberIds: activeHeapMembers } = useActiveHeapMembers() const { memberIds: activeHeapMembers } = useActiveHeapMembers()
// Stable cell handlers. PhotoThumbnail is wrapped in React.memo so
// identity-stable callbacks let it skip re-render on unrelated store
// churn (e.g. heap membership invalidation). visibleSequence is read
// through a ref at click time so scrolling doesn't rebind the
// double-click handler.
const visibleSequenceRef = useRef<string[]>([])
const handleCellClick = useCallback(
(photo: Photo, e: React.MouseEvent) => {
if (e.shiftKey) selectRange(photo.id)
else if (e.ctrlKey || e.metaKey) togglePhotoSelection(photo.id)
else selectPhoto(photo.id)
},
[selectRange, togglePhotoSelection, selectPhoto],
)
const handleCellDoubleClick = useCallback(
(photo: Photo) => {
openPreview(photo.id, visibleSequenceRef.current)
},
[openPreview],
)
// Build the flat virtualizer items: a mix of group headers and rows of // Build the flat virtualizer items: a mix of group headers and rows of
// photos. Date headers appear only in the main timeline (groupBy='date'). // photos. Date headers appear only in the main timeline (groupBy='date').
const items = useMemo( const items = useMemo(
@@ -498,22 +525,10 @@ export function Timeline() {
// (e.g. the global Space hotkey). // (e.g. the global Space hotkey).
useEffect(() => { useEffect(() => {
setVisiblePhotoIds(visibleSequence) setVisiblePhotoIds(visibleSequence)
visibleSequenceRef.current = visibleSequence
}, [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.
@@ -523,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)
@@ -531,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
@@ -648,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 (
@@ -660,15 +721,16 @@ export function Timeline() {
} }
if (photos.length === 0) { if (photos.length === 0) {
return ( return <EmptyTimelineState />
<div className="flex items-center justify-center h-full">
<div className="text-text-muted">(°° </div>
</div>
)
} }
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. */}
@@ -686,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'
)} )}
@@ -788,16 +850,9 @@ 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)}
onClick={(e) => { searchQuery={searchQuery}
if (e.shiftKey) { onClick={handleCellClick}
selectRange(photo.id) onDoubleClick={handleCellDoubleClick}
} else if (e.ctrlKey || e.metaKey) {
togglePhotoSelection(photo.id)
} else {
selectPhoto(photo.id)
}
}}
onDoubleClick={() => openPreview(photo.id, visibleSequence)}
/> />
))} ))}
</div> </div>
@@ -809,3 +864,64 @@ export function Timeline() {
</div> </div>
) )
} }
/**
* Empty-state rendered when the current filter/section returns zero photos.
* Distinguishes "library-empty" from "filters-too-strict": the former hints
* at upload, the latter offers a one-click Clear all.
*/
function EmptyTimelineState() {
const filterState = useFilterStore()
const clearAll = useFilterStore((s) => s.clearAll)
const currentSection = useFilterStore((s) => s.currentSection)
const filtersActive = hasActiveFilters(filterState)
const { title, hint } = sectionEmptyCopy(currentSection, filtersActive)
return (
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
<ImageOff className="h-10 w-10 text-text-muted/40" />
<div className="text-sm font-medium text-text">{title}</div>
<p className="max-w-sm text-xs text-text-muted">{hint}</p>
{filtersActive && (
<Button variant="outline" size="sm" onClick={clearAll} className="mt-1">
Clear all filters
</Button>
)}
</div>
)
}
function sectionEmptyCopy(
section: string,
filtersActive: boolean,
): { title: string; hint: string } {
if (filtersActive) {
return {
title: 'No photos match',
hint: 'Your filters are excluding everything in this section. Clear them to see the full library.',
}
}
switch (section) {
case 'discarded':
return {
title: 'Discard pile is empty',
hint: 'Photos you discard (X) land here until you empty them permanently.',
}
case 'rated':
return {
title: 'No rated photos yet',
hint: 'Rate photos 15 with the number keys and they will appear here.',
}
case 'tags':
return {
title: 'No tagged photos',
hint: 'Add tags from a photo\u2019s metadata panel or via the bulk tag editor.',
}
default:
return {
title: 'Library is empty',
hint: 'Add photos via the upload button, or point the PHOTO_DIRS volume at a folder with existing images.',
}
}
}

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

@@ -0,0 +1,117 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { photos as photosApi } from '../services/api'
import { toast } from '../components/ToastContainer'
import { formatApiError } from '../lib/apiError'
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
import type { Photo } from '../types/photo'
/**
* Centralised bulk-mutation hook used by both the RightSidebar multi-
* select panel and the keyboard shortcut layer. Before this existed the
* two paths each declared their own `bulkRatingMutation` /
* `bulkColorMutation` pair — identical signatures, slightly different
* optimistic behaviour, easy to drift.
*
* Each mutation:
* - Applies an optimistic patch to every cached photo list AND the
* per-photo cache so rating stars / color swatches flip instantly.
* - Rolls back the patch on error and surfaces a toast.
* - Invalidates the photo/library queries on success so server-side
* derived fields (needs_review, date_warning, etc.) reconcile.
*
* Discard lives elsewhere — its two call sites have deliberately
* different semantics (keep-in-place for the X hotkey; strip-from-
* timeline for the sidebar bulk button) so they don't belong here.
*/
export function useBulkPhotoMutations() {
const queryClient = useQueryClient()
const invalidatePhotoQueries = () => {
queryClient.invalidateQueries({ queryKey: ['photo'] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
}
// Snapshot enough of the current cache to roll back a failed mutation.
// We only track the photos actually being patched so the snapshot
// stays O(selection), not O(library).
const snapshotPhotos = (ids: string[]): Map<string, Partial<Photo>> => {
const snap = new Map<string, Partial<Photo>>()
const want = new Set(ids)
const lists = queryClient.getQueriesData<Photo[]>({ queryKey: ['photos'] })
for (const [, list] of lists) {
if (!list) continue
for (const p of list) if (want.has(p.id)) snap.set(p.id, { ...p })
}
for (const id of ids) {
if (snap.has(id)) continue
const single = queryClient.getQueryData<Photo>(['photo', id])
if (single) snap.set(id, { ...single })
}
return snap
}
// Apply a partial patch to every matching photo in every cached list,
// plus the per-photo cache. Used as the optimistic-update primitive.
const patchPhotos = (ids: string[], patch: Partial<Photo>) => {
const want = new Set(ids)
queryClient.setQueriesData<Photo[]>({ queryKey: ['photos'] }, (prev) =>
prev ? prev.map((p) => (want.has(p.id) ? { ...p, ...patch } : p)) : prev,
)
for (const id of ids) {
const cur = queryClient.getQueryData<Photo>(['photo', id])
if (cur) queryClient.setQueryData<Photo>(['photo', id], { ...cur, ...patch })
}
}
const restoreFromSnapshot = (snap: Map<string, Partial<Photo>>) => {
const ids = Array.from(snap.keys())
const want = new Set(ids)
queryClient.setQueriesData<Photo[]>({ queryKey: ['photos'] }, (prev) =>
prev
? prev.map((p) =>
want.has(p.id) ? ({ ...p, ...snap.get(p.id) } as Photo) : p,
)
: prev,
)
for (const id of ids) {
const cur = queryClient.getQueryData<Photo>(['photo', id])
const orig = snap.get(id)
if (cur && orig) {
queryClient.setQueryData<Photo>(['photo', id], { ...cur, ...orig })
}
}
}
const bulkRating = useMutation({
mutationFn: ({ ids, rating }: { ids: string[]; rating: number }) =>
photosApi.bulkSetRating(ids, rating),
onMutate: ({ ids, rating }) => {
const snapshot = snapshotPhotos(ids)
patchPhotos(ids, { rating })
return { snapshot }
},
onError: (e, _vars, ctx) => {
if (ctx?.snapshot) restoreFromSnapshot(ctx.snapshot)
toast.error('Rating failed', formatApiError(e))
},
onSuccess: invalidatePhotoQueries,
})
const bulkColor = useMutation({
mutationFn: ({ ids, color }: { ids: string[]; color: string | null }) =>
photosApi.bulkSetColor(ids, color),
onMutate: ({ ids, color }) => {
const snapshot = snapshotPhotos(ids)
patchPhotos(ids, { color_label: color })
return { snapshot }
},
onError: (e, _vars, ctx) => {
if (ctx?.snapshot) restoreFromSnapshot(ctx.snapshot)
toast.error('Color label failed', formatApiError(e))
},
onSuccess: invalidatePhotoQueries,
})
return { bulkRating, bulkColor, invalidatePhotoQueries }
}

View File

@@ -1,3 +1,4 @@
import { useRef } from 'react'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { usePhotoStore } from '../store/photoStore' import { usePhotoStore } from '../store/photoStore'
@@ -6,6 +7,8 @@ import { HEAPS_QUERY_KEY } from './useHeapsQuery'
import { toast } from '../components/ToastContainer' import { toast } from '../components/ToastContainer'
import { registerUndoable, useUndoStore } from '../store/undoStore' import { registerUndoable, useUndoStore } from '../store/undoStore'
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery' import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
import { useBulkPhotoMutations } from './useBulkPhotoMutations'
import { formatApiError } from '../lib/apiError'
import type { Photo } from '../types/photo' import type { Photo } from '../types/photo'
interface KeyboardShortcutsProps { interface KeyboardShortcutsProps {
@@ -57,25 +60,10 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
}, },
}) })
const invalidatePhotoQueries = () => { const {
queryClient.invalidateQueries({ queryKey: ['photo'] }) bulkRating: bulkRatingMutation,
queryClient.invalidateQueries({ queryKey: ['photos'] }) bulkColor: bulkColorMutation,
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) } = useBulkPhotoMutations()
}
const bulkRatingMutation = useMutation({
mutationFn: ({ ids, rating }: { ids: string[]; rating: number }) =>
photosApi.bulkSetRating(ids, rating),
onSuccess: invalidatePhotoQueries,
onError: (e: any) => toast.error('Bulk rating failed', e.message || 'Unknown error'),
})
const bulkColorMutation = useMutation({
mutationFn: ({ ids, color }: { ids: string[]; color: string | null }) =>
photosApi.bulkSetColor(ids, color),
onSuccess: invalidatePhotoQueries,
onError: (e: any) => toast.error('Bulk color failed', e.message || 'Unknown error'),
})
/** Flip the cached photos to is_discarded=value in every list query /** Flip the cached photos to is_discarded=value in every list query
* without removing them. Lets the grid grey them in place instead of * without removing them. Lets the grid grey them in place instead of
@@ -123,7 +111,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
markCachedDiscarded(ids, !discarded) markCachedDiscarded(ids, !discarded)
toast.error( toast.error(
discarded ? 'Discard failed' : 'Restore failed', discarded ? 'Discard failed' : 'Restore failed',
e?.message || 'Unknown error' formatApiError(e)
) )
}, },
onSuccess: (_data, { ids }) => { onSuccess: (_data, { ids }) => {
@@ -134,6 +122,60 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
}, },
}) })
// Coalesced undo for rapid X (or U) presses. The mutation itself still
// fires per-press so the grayscale flip is instant; only the undo
// registration waits out COALESCE_MS so we emit one toast + one undo
// entry for a burst rather than N stacked ones.
const discardBatchRef = useRef<{
discarded: boolean
ids: Set<string>
timer: number | null
} | null>(null)
const DISCARD_COALESCE_MS = 1200
const flushDiscardBatch = () => {
const batch = discardBatchRef.current
if (!batch) return
discardBatchRef.current = null
const list = Array.from(batch.ids)
const discarded = batch.discarded
const verb = discarded ? 'Discarded' : 'Restored'
registerUndoable(
`${verb} ${list.length} photo${list.length === 1 ? '' : 's'}`,
async () => {
markCachedDiscarded(list, !discarded)
await (discarded
? photosApi.bulkRestore(list)
: photosApi.bulkDiscard(list))
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
list.forEach((id) =>
queryClient.invalidateQueries({ queryKey: ['photo', id] })
)
}
)
}
const queueDiscardUndo = (ids: string[], discarded: boolean) => {
const batch = discardBatchRef.current
if (batch && batch.discarded === discarded) {
if (batch.timer !== null) window.clearTimeout(batch.timer)
ids.forEach((id) => batch.ids.add(id))
batch.timer = window.setTimeout(flushDiscardBatch, DISCARD_COALESCE_MS)
return
}
// Flush any in-flight batch of the opposite direction first so the
// two actions stay independently undoable.
if (batch) {
if (batch.timer !== null) window.clearTimeout(batch.timer)
flushDiscardBatch()
}
discardBatchRef.current = {
discarded,
ids: new Set(ids),
timer: window.setTimeout(flushDiscardBatch, DISCARD_COALESCE_MS),
}
}
/** The set of photo ids the next culling action should apply to. /** The set of photo ids the next culling action should apply to.
* - Multi-selection → all selected photos * - Multi-selection → all selected photos
* - Single selection → that one photo * - Single selection → that one photo
@@ -158,30 +200,15 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
// tint via the dedicated mutation (no cache strip, no timeline // tint via the dedicated mutation (no cache strip, no timeline
// removal). They disappear on hard reload because the section // removal). They disappear on hard reload because the section
// filter excludes them in the wrong direction. // filter excludes them in the wrong direction.
//
// Rapid X presses coalesce into a single undo entry + one toast
// (see queueDiscardUndo below) so hammering the key doesn't stack
// five toasts and force five Cmd+Z to back out.
if (data.is_discarded === true || data.is_discarded === false) { if (data.is_discarded === true || data.is_discarded === false) {
const discarded = data.is_discarded const discarded = data.is_discarded
discardMutation.mutate( discardMutation.mutate(
{ ids, discarded }, { ids, discarded },
{ { onSuccess: () => queueDiscardUndo(ids, discarded) }
onSuccess: () => {
const verb = discarded ? 'Discarded' : 'Restored'
registerUndoable(
`${verb} ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
async () => {
markCachedDiscarded(ids, !discarded)
await (discarded
? photosApi.bulkRestore(ids)
: photosApi.bulkDiscard(ids))
queryClient.invalidateQueries({
queryKey: LIBRARY_STATS_QUERY_KEY,
})
ids.forEach((id) =>
queryClient.invalidateQueries({ queryKey: ['photo', id] })
)
}
)
},
}
) )
return return
} }
@@ -244,7 +271,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
if (ctx?.previous) { if (ctx?.previous) {
queryClient.setQueryData(['heap-photo-ids', _vars.heapId], ctx.previous) queryClient.setQueryData(['heap-photo-ids', _vars.heapId], ctx.previous)
} }
toast.error('Heap update failed', e.message || 'Unknown error') toast.error('Heap update failed', formatApiError(e))
}, },
onSuccess: (data, vars) => { onSuccess: (data, vars) => {
const heap = (queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []).find( const heap = (queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []).find(
@@ -324,7 +351,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
await entry.undo() await entry.undo()
} catch (e: any) { } catch (e: any) {
useUndoStore.getState().push({ label: entry.label, undo: entry.undo }) useUndoStore.getState().push({ label: entry.label, undo: entry.undo })
toast.error('Undo failed', e?.message || 'Unknown error') toast.error('Undo failed', formatApiError(e))
} }
}, },
HK_OPTS HK_OPTS

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()
@@ -87,12 +76,19 @@ export function usePhotosQuery() {
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
// Two-phase fetch using cursor-based (keyset) pagination. // Two-phase fetch using cursor-based (keyset) pagination.
// Phase 1 returns the first page (resolves the useQuery promise // Phase 1 returns the first page (resolves the useQuery promise
// so consumers exit loading state). Phase 2 chains cursors in // so consumers exit loading state). Phase 2 chains a bounded
// the background — each response includes a `next_cursor` that // background loop — each response includes a `next_cursor` that
// seeks directly to the next slice via an indexed range scan, // seeks directly to the next slice via an indexed range scan,
// O(1) regardless of depth (no OFFSET skipping). // O(1) regardless of depth (no OFFSET skipping).
//
// MAX_PAGES is intentionally modest: 20 × 500 = 10 000 photos
// covers almost every browsing session up-front without burning
// through a 100k library on cold load. If a user scrolls past
// that horizon we'll add an infinite-query trigger; for now the
// cap keeps cold-load memory / network pressure sane.
const PER_PAGE = 500 const PER_PAGE = 500
const MAX_PAGES = 200 const MAX_PAGES = 20
const INTER_PAGE_DELAY_MS = 50
const first = await fetchCursorPage( const first = await fetchCursorPage(
{ per_page: PER_PAGE, ...filterParams }, { per_page: PER_PAGE, ...filterParams },
@@ -119,6 +115,10 @@ export function usePhotosQuery() {
(prev) => (prev ? [...prev, ...more] : more) (prev) => (prev ? [...prev, ...more] : more)
) )
if (!nextCursor || more.length < PER_PAGE) return if (!nextCursor || more.length < PER_PAGE) return
// Yield a beat between pages so the main thread stays
// responsive (thumbnail decode, scroll handling) while
// we're back-filling in the background.
await new Promise((r) => setTimeout(r, INTER_PAGE_DELAY_MS))
} catch { } catch {
return return
} }

View File

@@ -0,0 +1,38 @@
/**
* Normalise errors coming out of the axios-backed API client into a
* user-facing string. FastAPI puts validation/permission errors on
* `response.data.detail`; network timeouts / CORS surface as the axios
* `message`. Engine messages like "SyntaxError" or bare "Network Error"
* are filtered out in favour of a stable fallback so users never see
* raw parser noise in a toast.
*/
export function formatApiError(err: unknown, fallback = 'Something went wrong'): string {
if (!err) return fallback
const anyErr = err as {
response?: { data?: { detail?: unknown; error?: unknown; message?: unknown } }
message?: string
}
const fromDetail = anyErr.response?.data?.detail
if (typeof fromDetail === 'string' && fromDetail.trim()) return fromDetail
// FastAPI validation errors come through as an array of objects.
if (Array.isArray(fromDetail) && fromDetail.length > 0) {
const first = fromDetail[0] as { msg?: string }
if (first?.msg) return first.msg
}
const fromError = anyErr.response?.data?.error
if (typeof fromError === 'string' && fromError.trim()) return fromError
const fromMessage = anyErr.response?.data?.message
if (typeof fromMessage === 'string' && fromMessage.trim()) return fromMessage
// Axios "Network Error" and native runtime errors like SyntaxError are
// worse than a stable fallback — filter them out.
const msg = anyErr.message
if (typeof msg === 'string' && msg.trim() && msg !== 'Network Error') {
return msg
}
return fallback
}

View File

@@ -806,7 +806,18 @@ export interface ShareInfo {
created_at: string created_at: string
} }
export interface ShareableUser {
id: string
username: string
}
export const sharing = { export const sharing = {
// Shareable user directory for the share-dialog picker.
listUsers: async (): Promise<ShareableUser[]> => {
const response = await api.get('/sharing/users')
return response.data
},
// Heap shares // Heap shares
shareHeap: async (heapId: string, username: string, permission: 'read' | 'write' = 'read') => { shareHeap: async (heapId: string, username: string, permission: 'read' | 'write' = 'read') => {
const response = await api.post(`/sharing/heaps/${heapId}`, { username, permission }) const response = await api.post(`/sharing/heaps/${heapId}`, { username, permission })