diff --git a/frontend/src/components/KeyboardHints.tsx b/frontend/src/components/KeyboardHints.tsx index e666f08..16f8b49 100644 --- a/frontend/src/components/KeyboardHints.tsx +++ b/frontend/src/components/KeyboardHints.tsx @@ -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() { ) : ( -
+
{hints.map((hint, i) => (
diff --git a/frontend/src/components/discard/DiscardActionBar.tsx b/frontend/src/components/discard/DiscardActionBar.tsx index 2a679a5..b5a2dc0 100644 --- a/frontend/src/components/discard/DiscardActionBar.tsx +++ b/frontend/src/components/discard/DiscardActionBar.tsx @@ -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 diff --git a/frontend/src/components/duplicates/DuplicatesView.tsx b/frontend/src/components/duplicates/DuplicatesView.tsx index dd08c45..f544f34 100644 --- a/frontend/src/components/duplicates/DuplicatesView.tsx +++ b/frontend/src/components/duplicates/DuplicatesView.tsx @@ -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 diff --git a/frontend/src/components/heaps/HeapsPanel.tsx b/frontend/src/components/heaps/HeapsPanel.tsx index 12beb47..c936a06 100644 --- a/frontend/src/components/heaps/HeapsPanel.tsx +++ b/frontend/src/components/heaps/HeapsPanel.tsx @@ -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) diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index 1dc9167..a2fa155 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -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) => { diff --git a/frontend/src/components/layout/RightSidebar.tsx b/frontend/src/components/layout/RightSidebar.tsx index 4f95b12..35dae69 100644 --- a/frontend/src/components/layout/RightSidebar.tsx +++ b/frontend/src/components/layout/RightSidebar.tsx @@ -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() {
) } - -interface BulkTakenAtEditorProps { - disabled: boolean - selectedCount: number - collectPhotos: () => Photo[] - onApplyUniform: (iso: string) => void - onApplyMap: (map: Record) => 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 = {} - 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 ( -
- {/* Apply-one row */} -
- setUniformDraft(e.target.value)} - disabled={disabled} - className="h-7 flex-1 text-xs" - /> - -
- - {/* Guess-from-path preview */} - {preview === null ? ( - - ) : ( -
-
- {preview.hits.length} will update ·{' '} - {preview.misses.length} skipped -
- {preview.hits.length > 0 && ( -
    - {preview.hits.slice(0, 5).map(({ photo, guess }) => ( -
  • - {photo.filename} - {' → '} - - {format(guess.date, 'yyyy-MM-dd')} - -
  • - ))} - {preview.hits.length > 5 && ( -
  • - …and {preview.hits.length - 5} more -
  • - )} -
- )} -
- - -
-
- )} -
- ) -} - -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 ( -
- 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 && ( - - )} - - {filtered.length > 0 ? ( -
- {filtered.map((tag) => ( - - - - - ))} -
- ) : ( -
No tags match
- )} -
- ) -} diff --git a/frontend/src/components/preview/PreviewView.tsx b/frontend/src/components/preview/PreviewView.tsx index 0b595f5..c26caf3 100644 --- a/frontend/src/components/preview/PreviewView.tsx +++ b/frontend/src/components/preview/PreviewView.tsx @@ -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 */}
- {/* Filename + counter */} -
-
{currentPhoto.filename}
+ {/* Filename + counter + (optional) search match chip */} +
+
{currentPhoto.filename}
{safeIndex + 1} / {photos.length}
+ {searchMatch && ( +
+ + {searchMatch.label} + + + {searchMatch.matchLength > 0 ? ( + <> + {searchMatch.excerpt.slice(0, searchMatch.matchStart)} + + {searchMatch.excerpt.slice( + searchMatch.matchStart, + searchMatch.matchStart + searchMatch.matchLength, + )} + + {searchMatch.excerpt.slice( + searchMatch.matchStart + searchMatch.matchLength, + )} + + ) : ( + searchMatch.excerpt + )} + +
+ )}
{/* Top-right action buttons */} diff --git a/frontend/src/components/sidebar/BulkTagsEditor.tsx b/frontend/src/components/sidebar/BulkTagsEditor.tsx new file mode 100644 index 0000000..817ad3a --- /dev/null +++ b/frontend/src/components/sidebar/BulkTagsEditor.tsx @@ -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 ( +
+ 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 && ( + + )} + + {filtered.length > 0 ? ( +
+ {filtered.map((tag) => ( + + + + + ))} +
+ ) : ( +
No tags match
+ )} +
+ ) +} diff --git a/frontend/src/components/sidebar/BulkTakenAtEditor.tsx b/frontend/src/components/sidebar/BulkTakenAtEditor.tsx new file mode 100644 index 0000000..e5b689b --- /dev/null +++ b/frontend/src/components/sidebar/BulkTakenAtEditor.tsx @@ -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) => 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 = {} + 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 ( +
+ {/* Apply-one row */} +
+ setUniformDraft(e.target.value)} + disabled={disabled} + className="h-7 flex-1 text-xs" + /> + +
+ + {/* Guess-from-path preview */} + {preview === null ? ( + + ) : ( +
+
+ {preview.hits.length} will update ·{' '} + {preview.misses.length} skipped +
+ {preview.hits.length > 0 && ( +
    + {preview.hits.slice(0, 5).map(({ photo, guess }) => ( +
  • + {photo.filename} + {' → '} + + {format(guess.date, 'yyyy-MM-dd')} + +
  • + ))} + {preview.hits.length > 5 && ( +
  • + …and {preview.hits.length - 5} more +
  • + )} +
+ )} +
+ + +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/sidebar/PhotoInfoPanel.tsx b/frontend/src/components/sidebar/PhotoInfoPanel.tsx index f9f1d0f..9afae96 100644 --- a/frontend/src/components/sidebar/PhotoInfoPanel.tsx +++ b/frontend/src/components/sidebar/PhotoInfoPanel.tsx @@ -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 ( -
- {photoTags.length > 0 ? ( -
- {photoTags.map((tag) => ( - - {tag.name} - - - ))} -
- ) : ( -
No tags
- )} - -
- 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 && ( -
- {suggestions.map((s) => ( - - ))} -
- )} - {trimmed && !exactMatch && ( - - )} -
-
- ) -} - function Field({ label, value }: { label: string; value: string }) { return (
@@ -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 ( -
- -
- 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 && ( - - {sourceLabel} - - )} -
- {showSuggestion && guess && ( - - )} -
- ) -} diff --git a/frontend/src/components/sidebar/TagsEditor.tsx b/frontend/src/components/sidebar/TagsEditor.tsx new file mode 100644 index 0000000..dc4f602 --- /dev/null +++ b/frontend/src/components/sidebar/TagsEditor.tsx @@ -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 ( +
+ {photoTags.length > 0 ? ( +
+ {photoTags.map((tag) => ( + + {tag.name} + + + ))} +
+ ) : ( +
No tags
+ )} + +
+ 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 && ( +
+ {suggestions.map((s) => ( + + ))} +
+ )} + {trimmed && !exactMatch && ( + + )} +
+
+ ) +} diff --git a/frontend/src/components/sidebar/TakenAtEditor.tsx b/frontend/src/components/sidebar/TakenAtEditor.tsx new file mode 100644 index 0000000..a25e004 --- /dev/null +++ b/frontend/src/components/sidebar/TakenAtEditor.tsx @@ -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 ( +
+ +
+ 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 && ( + + {sourceLabel} + + )} +
+ {showSuggestion && guess && ( + + )} +
+ ) +} diff --git a/frontend/src/components/timeline/PhotoThumbnail.tsx b/frontend/src/components/timeline/PhotoThumbnail.tsx index 791114b..eea4174 100644 --- a/frontend/src/components/timeline/PhotoThumbnail.tsx +++ b/frontend/src/components/timeline/PhotoThumbnail.tsx @@ -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 && (
-
- {isRetrying ? ( -
- -
Retrying...
-
- ) : ( -
- )} +
+ +
Retrying…
)} @@ -381,9 +385,23 @@ export function PhotoThumbnail({
)} - {/* 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. */}
+ {isInActiveHeap && ( +
+ +
+ )} {photo.is_duplicate && (
) -} \ No newline at end of file +} + +/** + * 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) \ No newline at end of file diff --git a/frontend/src/components/timeline/Timeline.tsx b/frontend/src/components/timeline/Timeline.tsx index 0dc6fdf..4d43b9d 100644 --- a/frontend/src/components/timeline/Timeline.tsx +++ b/frontend/src/components/timeline/Timeline.tsx @@ -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([]) + 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 ( -
-
(╯°□°)╯︵ ┻━┻
-
- ) + return } 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} /> ))}
@@ -809,3 +821,64 @@ export function Timeline() {
) } + +/** + * 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 ( +
+ +
{title}
+

{hint}

+ {filtersActive && ( + + )} +
+ ) +} + +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 1–5 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.', + } + } +} diff --git a/frontend/src/hooks/useBulkPhotoMutations.ts b/frontend/src/hooks/useBulkPhotoMutations.ts new file mode 100644 index 0000000..3091564 --- /dev/null +++ b/frontend/src/hooks/useBulkPhotoMutations.ts @@ -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> => { + const snap = new Map>() + const want = new Set(ids) + const lists = queryClient.getQueriesData({ 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', 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) => { + const want = new Set(ids) + queryClient.setQueriesData({ 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', id]) + if (cur) queryClient.setQueryData(['photo', id], { ...cur, ...patch }) + } + } + + const restoreFromSnapshot = (snap: Map>) => { + const ids = Array.from(snap.keys()) + const want = new Set(ids) + queryClient.setQueriesData({ 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', id]) + const orig = snap.get(id) + if (cur && orig) { + queryClient.setQueryData(['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 } +} diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts index fd749eb..1154474 100644 --- a/frontend/src/hooks/useKeyboardShortcuts.ts +++ b/frontend/src/hooks/useKeyboardShortcuts.ts @@ -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 + 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(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 diff --git a/frontend/src/hooks/usePhotosQuery.ts b/frontend/src/hooks/usePhotosQuery.ts index 7e98901..67f6f80 100644 --- a/frontend/src/hooks/usePhotosQuery.ts +++ b/frontend/src/hooks/usePhotosQuery.ts @@ -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 } diff --git a/frontend/src/lib/apiError.ts b/frontend/src/lib/apiError.ts new file mode 100644 index 0000000..50874da --- /dev/null +++ b/frontend/src/lib/apiError.ts @@ -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 +}