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>
This commit is contained in:
2026-04-15 10:13:39 +02:00
parent 7efac4354e
commit e65e798021
18 changed files with 992 additions and 646 deletions

View File

@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { ChevronDown, ChevronUp } from 'lucide-react'
import clsx from 'clsx'
import { usePhotoStore } from '../store/photoStore'
import { useFilterStore } from '../store/filterStore'
@@ -109,11 +108,7 @@ export function KeyboardHints() {
</kbd>
</button>
) : (
<div
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'
)}
>
<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">
{hints.map((hint, i) => (
<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">

View File

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

View File

@@ -3,6 +3,7 @@ import { Copy, Layers, Sparkles, Trash2, Loader2, Info, Crown } from 'lucide-rea
import clsx from 'clsx'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Button } from '@/components/ui/button'
import { formatApiError } from '../../lib/apiError'
import {
useDuplicateGroupsQuery,
DUPLICATE_GROUPS_QUERY_KEY,
@@ -63,7 +64,7 @@ export function DuplicatesView() {
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
},
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
@@ -310,8 +311,8 @@ function DuplicateGroupSection({
size={180}
fill
isSelected={selectedPhotos.includes(member.id)}
onClick={() => onSelectMember(member.id)}
onDoubleClick={() => onPreviewMember(member.id)}
onClick={(p) => onSelectMember(p.id)}
onDoubleClick={(p) => onPreviewMember(p.id)}
/>
{/* BEST pill — top-right, pick-coloured. Composes the same
* THUMB_BADGE_* family used by PhotoThumbnail so the full

View File

@@ -21,6 +21,7 @@ import { heaps as heapsApi, downloads, type Heap } from '../../services/api'
import { useFilterStore } from '../../store/filterStore'
import { toast } from '../ToastContainer'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
import { formatApiError } from '../../lib/apiError'
import { HeapConvertDialog } from './HeapConvertDialog'
import { ShareDialog } from '../sharing/ShareDialog'
import { Input } from '@/components/ui/input'
@@ -79,7 +80,7 @@ export function HeapsPanel() {
setCreating(false)
},
onError: (e: any) =>
toast.error('Failed to create heap', e.message || 'Unknown error'),
toast.error('Failed to create heap', formatApiError(e)),
})
const setActiveMutation = useMutation({
@@ -90,7 +91,7 @@ export function HeapsPanel() {
toast.success('Active heap', `Now adding to "${heap.name}" with T`)
},
onError: (e: any) =>
toast.error('Failed to set active', e.message || 'Unknown error'),
toast.error('Failed to set active', formatApiError(e)),
})
const deleteMutation = useMutation({
@@ -103,7 +104,7 @@ export function HeapsPanel() {
}
},
onError: (e: any) =>
toast.error('Failed to delete heap', e.message || 'Unknown error'),
toast.error('Failed to delete heap', formatApiError(e)),
})
const renameMutation = useMutation({
@@ -111,7 +112,7 @@ export function HeapsPanel() {
heapsApi.update(heapId, { name }),
onSuccess: () => invalidate(),
onError: (e: any) =>
toast.error('Failed to rename heap', e.message || 'Unknown error'),
toast.error('Failed to rename heap', formatApiError(e)),
})
const duplicateMutation = useMutation({
@@ -121,7 +122,7 @@ export function HeapsPanel() {
toast.success('Heap duplicated', heap.name)
},
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
@@ -142,7 +143,7 @@ export function HeapsPanel() {
if (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) => {
const heap = heaps.find((h) => h.id === vars.heapId)

View File

@@ -43,6 +43,7 @@ import {
LIBRARY_STATS_QUERY_KEY,
} from '../../hooks/useLibraryStatsQuery'
import { registerUndoable } from '../../store/undoStore'
import { formatApiError } from '../../lib/apiError'
import type { Photo } from '../../types/photo'
import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'
import { ShareDialog } from '../sharing/ShareDialog'
@@ -150,7 +151,7 @@ export function LeftSidebar() {
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
},
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
@@ -223,7 +224,7 @@ export function LeftSidebar() {
queryClient.invalidateQueries({ queryKey: ['folders'] })
},
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.
@@ -245,7 +246,7 @@ export function LeftSidebar() {
queryClient.invalidateQueries({ queryKey: ['folders'] })
},
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.
@@ -314,7 +315,7 @@ export function LeftSidebar() {
queryClient.invalidateQueries({ queryKey: ['photos'] })
},
onError: (e: any) =>
toast.error('Rename failed', e?.response?.data?.detail || e.message || 'Unknown error'),
toast.error('Rename failed', formatApiError(e)),
})
const createFolderMutation = useMutation({
@@ -328,7 +329,7 @@ export function LeftSidebar() {
setCreateDraft('')
},
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
@@ -354,7 +355,7 @@ export function LeftSidebar() {
queryClient.invalidateQueries({ queryKey: ['tags'] })
},
onError: (e: any) =>
toast.error('Toggle failed', e?.response?.data?.detail || e.message || 'Unknown error'),
toast.error('Toggle failed', formatApiError(e)),
})
const deleteFolderMutation = useMutation({
@@ -383,7 +384,7 @@ export function LeftSidebar() {
setDeletingFolder(null)
},
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) => {

View File

@@ -1,8 +1,7 @@
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 { useMutation, useQueryClient } from '@tanstack/react-query'
import { format } from 'date-fns'
import { usePhotoStore } from '../../store/photoStore'
import {
photos as photosApi,
@@ -10,21 +9,19 @@ import {
tags as tagsApi,
} from '../../services/api'
import type { Photo } from '../../types/photo'
import {
guessDateFromPath,
type DateGuess,
} from '../../lib/guessDateFromPath'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
import { stripPhotosFromCache } from '../../hooks/usePhotosQuery'
import { toast } from '../ToastContainer'
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
import { BulkTakenAtEditor } from '../sidebar/BulkTakenAtEditor'
import { BulkTagsEditor } from '../sidebar/BulkTagsEditor'
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useBulkPhotoMutations } from '../../hooks/useBulkPhotoMutations'
import { formatApiError } from '../../lib/apiError'
/**
* Right-hand details panel.
@@ -36,22 +33,12 @@ export function RightSidebar() {
const { selectedPhotos, activePhotoId, clearSelection } = usePhotoStore()
const queryClient = useQueryClient()
const invalidatePhotoQueries = () => {
queryClient.invalidateQueries({ queryKey: ['photo'] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
}
const {
bulkRating: bulkRatingMutation,
bulkColor: bulkColorMutation,
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({
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
// Yank the photos from the timeline before the network round-trip
@@ -94,7 +81,7 @@ export function RightSidebar() {
photosApi.bulkSetTakenAt(ids, iso),
onSuccess: reportBulkTakenAt,
onError: (e: any) =>
toast.error('Date update failed', e?.message || 'Unknown error'),
toast.error('Date update failed', formatApiError(e)),
})
const bulkTakenAtMapMutation = useMutation({
@@ -102,7 +89,7 @@ export function RightSidebar() {
photosApi.bulkSetTakenAtMap(map),
onSuccess: reportBulkTakenAt,
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
@@ -123,7 +110,7 @@ export function RightSidebar() {
invalidateTagsAndPhotos()
},
onError: (e: any) =>
toast.error('Add tags failed', e?.message || 'Unknown error'),
toast.error('Add tags failed', formatApiError(e)),
})
const bulkRemoveTagsMutation = useMutation({
mutationFn: ({ ids, tagIds }: { ids: string[]; tagIds: string[] }) =>
@@ -137,7 +124,7 @@ export function RightSidebar() {
invalidateTagsAndPhotos()
},
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
@@ -152,7 +139,7 @@ export function RightSidebar() {
invalidateTagsAndPhotos()
},
onError: (e: any) =>
toast.error('Create tag failed', e?.message || 'Unknown error'),
toast.error('Create tag failed', formatApiError(e)),
})
const { data: allTags = [] } = useTagsQuery()
@@ -182,7 +169,7 @@ export function RightSidebar() {
if (activeHeap && 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: () => {
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
@@ -459,265 +446,3 @@ export function RightSidebar() {
</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

@@ -3,8 +3,10 @@ import { useQuery } from '@tanstack/react-query'
import { useHotkeys } from 'react-hotkeys-hook'
import { X, Info } from 'lucide-react'
import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore } from '../../store/filterStore'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { photos as photosApi } from '../../services/api'
import { findSearchMatch } from '../../lib/searchMatch'
import type { Photo } from '../../types/photo'
import { PreviewImage } from './PreviewImage'
import { PreviewFilmstrip } from './PreviewFilmstrip'
@@ -84,6 +86,15 @@ export function PreviewView() {
const currentPhoto: Photo | undefined =
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
// handlers ALWAYS read the freshest state. Without this, react-hotkeys-
// hook can fire a closure that captured an older photos array (e.g.
@@ -216,12 +227,40 @@ export function PreviewView() {
>
{/* Main column — image + filmstrip */}
<div className="relative flex min-w-0 flex-1 flex-col">
{/* Filename + counter */}
<div className="absolute left-3 top-3 z-10 rounded bg-black/60 px-3 py-1.5 text-xs text-white">
<div className="font-mono">{currentPhoto.filename}</div>
{/* Filename + counter + (optional) search match chip */}
<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="truncate font-mono">{currentPhoto.filename}</div>
<div className="text-text-muted">
{safeIndex + 1} / {photos.length}
</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>
{/* Top-right action buttons */}

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

@@ -17,17 +17,16 @@ import {
useQueryClient,
keepPreviousData,
} from '@tanstack/react-query'
import { format } from 'date-fns'
import {
photos as photosApi,
heaps as heapsApi,
tags as tagsApi,
type Tag,
} from '../../services/api'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
import { formatApiError } from '../../lib/apiError'
import { toast } from '../ToastContainer'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
@@ -40,18 +39,17 @@ import {
COLOR_LABEL_OPTIONS,
type ColorLabel,
} from '../../constants/colorLabels'
import {
guessDateFromPath,
toDatetimeLocalValue,
} from '../../lib/guessDateFromPath'
import { toDatetimeLocalValue } from '../../lib/guessDateFromPath'
import { TagsEditor } from './TagsEditor'
import { TakenAtEditor } from './TakenAtEditor'
interface PhotoTagSummary {
export interface PhotoTagSummary {
id: string
name: string
color: string | null
}
interface PhotoDetails {
export interface PhotoDetails {
id: string
filename: string
filepath: string
@@ -240,21 +238,21 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
},
onSuccess: () => invalidateTagsAndPhoto(),
onError: (e: any) =>
toast.error('Add tag failed', e?.message || 'Unknown error'),
toast.error('Add tag failed', formatApiError(e)),
})
const attachExistingTagMutation = useMutation({
mutationFn: (tagId: string) => tagsApi.addToPhoto(photoId, [tagId]),
onSuccess: () => invalidateTagsAndPhoto(),
onError: (e: any) =>
toast.error('Add tag failed', e?.message || 'Unknown error'),
toast.error('Add tag failed', formatApiError(e)),
})
const removeTagMutation = useMutation({
mutationFn: (tagId: string) => tagsApi.removeFromPhoto(photoId, tagId),
onSuccess: () => invalidateTagsAndPhoto(),
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
@@ -297,7 +295,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
onError: (e: any) => {
toast.error(
'Rename failed',
e?.response?.data?.detail || e.message || 'Unknown error'
formatApiError(e)
)
setFilenameDraft(current)
},
@@ -345,7 +343,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
onError: (e: any) => {
toast.error(
'Date update failed',
e?.response?.data?.detail || e.message || 'Unknown error'
formatApiError(e)
)
setTakenAtDraft(
photo?.taken_at
@@ -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 }) {
return (
<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 clsx from 'clsx'
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 = 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

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { memo, useState, useEffect, useCallback, useRef } from 'react'
import {
Star,
Trash2,
@@ -6,6 +6,7 @@ import {
Copy,
AlertTriangle,
Users,
ShoppingBasket,
} from 'lucide-react'
import clsx from 'clsx'
import { photos as photosApi } from '../../services/api'
@@ -74,11 +75,14 @@ interface PhotoThumbnailProps {
/** True when the photo belongs to the currently active heap — drives
* the green tint overlay. */
isInActiveHeap?: boolean
onClick: (e: React.MouseEvent) => void
onDoubleClick?: (e: React.MouseEvent) => void
/** 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,
size,
fill = false,
@@ -226,8 +230,10 @@ export function PhotoThumbnail({
? { width: '100%', height: '100%' }
: { width: size, height: displayHeight }
}
onClick={onClick}
onDoubleClick={onDoubleClick}
onClick={(e) => onClick(photo, e)}
onDoubleClick={
onDoubleClick ? (e) => onDoubleClick(photo, e) : undefined
}
draggable
onDragStart={handleDragStart}
title="Click to select • Double-click to open • Shift+Click for range • Ctrl+Click to add • Drag onto a heap to add"
@@ -249,18 +255,16 @@ export function PhotoThumbnail({
onError={handleImageError}
loading="lazy"
/>
{/* Loading indicator */}
{!imageLoaded && (
{/* Loading indicator. The outer wrapper already pulses via
* `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="text-text-muted">
{isRetrying ? (
<div className="text-center">
<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 className="text-center text-text-muted">
<RefreshCw className="mx-auto mb-1 h-4 w-4 animate-spin" />
<div className="text-[10px]">Retrying</div>
</div>
</div>
)}
@@ -381,9 +385,23 @@ export function PhotoThumbnail({
</div>
)}
{/* BR — duplicate / discard (neutral). Heap membership is shown
* via the green tint overlay above, no badge here. */}
{/* BR — duplicate / discard / date-warning (neutral info) plus a
* 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">
{isInActiveHeap && (
<div
className={clsx(
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 && (
<div
className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_SQUARE, THUMB_BADGE_NEUTRAL)}
@@ -426,4 +444,12 @@ export function PhotoThumbnail({
)}
</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

@@ -3,10 +3,12 @@ import { useVirtualizer } from '@tanstack/react-virtual'
import { format, parseISO } from 'date-fns'
import clsx from 'clsx'
import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore } from '../../store/filterStore'
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
import { PhotoThumbnail } from './PhotoThumbnail'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { Button } from '@/components/ui/button'
import { ImageOff } from 'lucide-react'
import type { Photo } from '../../types/photo'
// Layout constants for the grid + grouped headers.
@@ -203,6 +205,27 @@ export function Timeline() {
// of thumbnails each subscribing to the same query.
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
// photos. Date headers appear only in the main timeline (groupBy='date').
const items = useMemo(
@@ -498,6 +521,7 @@ export function Timeline() {
// (e.g. the global Space hotkey).
useEffect(() => {
setVisiblePhotoIds(visibleSequence)
visibleSequenceRef.current = visibleSequence
}, [visibleSequence, setVisiblePhotoIds])
// Locate the active photo in the visual grid. Returns the FIRST
@@ -660,11 +684,7 @@ export function Timeline() {
}
if (photos.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-text-muted">(°° </div>
</div>
)
return <EmptyTimelineState />
}
return (
@@ -788,16 +808,8 @@ export function Timeline() {
fill
isSelected={selectedPhotos.includes(photo.id)}
isInActiveHeap={activeHeapMembers.has(photo.id)}
onClick={(e) => {
if (e.shiftKey) {
selectRange(photo.id)
} else if (e.ctrlKey || e.metaKey) {
togglePhotoSelection(photo.id)
} else {
selectPhoto(photo.id)
}
}}
onDoubleClick={() => openPreview(photo.id, visibleSequence)}
onClick={handleCellClick}
onDoubleClick={handleCellDoubleClick}
/>
))}
</div>
@@ -809,3 +821,64 @@ export function Timeline() {
</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,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 { useMutation, useQueryClient } from '@tanstack/react-query'
import { usePhotoStore } from '../store/photoStore'
@@ -6,6 +7,8 @@ import { HEAPS_QUERY_KEY } from './useHeapsQuery'
import { toast } from '../components/ToastContainer'
import { registerUndoable, useUndoStore } from '../store/undoStore'
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
import { useBulkPhotoMutations } from './useBulkPhotoMutations'
import { formatApiError } from '../lib/apiError'
import type { Photo } from '../types/photo'
interface KeyboardShortcutsProps {
@@ -57,25 +60,10 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
},
})
const invalidatePhotoQueries = () => {
queryClient.invalidateQueries({ queryKey: ['photo'] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
}
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'),
})
const {
bulkRating: bulkRatingMutation,
bulkColor: bulkColorMutation,
} = useBulkPhotoMutations()
/** Flip the cached photos to is_discarded=value in every list query
* without removing them. Lets the grid grey them in place instead of
@@ -123,7 +111,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
markCachedDiscarded(ids, !discarded)
toast.error(
discarded ? 'Discard failed' : 'Restore failed',
e?.message || 'Unknown error'
formatApiError(e)
)
},
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.
* - Multi-selection → all selected photos
* - Single selection → that one photo
@@ -158,30 +200,15 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
// tint via the dedicated mutation (no cache strip, no timeline
// removal). They disappear on hard reload because the section
// 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) {
const discarded = data.is_discarded
discardMutation.mutate(
{ 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] })
)
}
)
},
}
{ onSuccess: () => queueDiscardUndo(ids, discarded) }
)
return
}
@@ -244,7 +271,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
if (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) => {
const heap = (queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []).find(
@@ -324,7 +351,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
await entry.undo()
} catch (e: any) {
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

View File

@@ -87,12 +87,19 @@ export function usePhotosQuery() {
queryFn: async ({ signal }) => {
// Two-phase fetch using cursor-based (keyset) pagination.
// Phase 1 returns the first page (resolves the useQuery promise
// so consumers exit loading state). Phase 2 chains cursors in
// the background — each response includes a `next_cursor` that
// so consumers exit loading state). Phase 2 chains a bounded
// background loop — each response includes a `next_cursor` that
// seeks directly to the next slice via an indexed range scan,
// 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 MAX_PAGES = 200
const MAX_PAGES = 20
const INTER_PAGE_DELAY_MS = 50
const first = await fetchCursorPage(
{ per_page: PER_PAGE, ...filterParams },
@@ -119,6 +126,10 @@ export function usePhotosQuery() {
(prev) => (prev ? [...prev, ...more] : more)
)
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 {
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
}