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:
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
|
||||||
import { usePhotoStore } from '../store/photoStore'
|
import { usePhotoStore } from '../store/photoStore'
|
||||||
import { useFilterStore } from '../store/filterStore'
|
import { useFilterStore } from '../store/filterStore'
|
||||||
|
|
||||||
@@ -109,11 +108,7 @@ export function KeyboardHints() {
|
|||||||
</kbd>
|
</kbd>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className="pointer-events-auto flex items-center gap-3 whitespace-nowrap rounded-full border border-white/15 bg-black/80 px-4 py-1.5 shadow-xl ring-1 ring-black/40 backdrop-blur-md">
|
||||||
className={clsx(
|
|
||||||
'pointer-events-auto flex items-center gap-3 whitespace-nowrap rounded-full border border-white/15 bg-black/80 px-4 py-1.5 shadow-xl ring-1 ring-black/40 backdrop-blur-md'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{hints.map((hint, i) => (
|
{hints.map((hint, i) => (
|
||||||
<div key={i} className="flex items-center gap-1.5">
|
<div key={i} className="flex items-center gap-1.5">
|
||||||
<kbd className="rounded bg-white/15 px-1.5 py-0.5 text-[11px] font-medium text-white shadow-sm">
|
<kbd className="rounded bg-white/15 px-1.5 py-0.5 text-[11px] font-medium text-white shadow-sm">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { discard as discardApi, photos as photosApi } from '../../services/api'
|
|||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
import { ConfirmDialog } from '../dialogs/ConfirmDialog'
|
import { ConfirmDialog } from '../dialogs/ConfirmDialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { formatApiError } from '../../lib/apiError'
|
||||||
import { registerUndoable } from '../../store/undoStore'
|
import { registerUndoable } from '../../store/undoStore'
|
||||||
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ export function DiscardActionBar() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||||
},
|
},
|
||||||
onError: (e: any) => toast.error('Restore failed', e.message || 'Unknown error'),
|
onError: (e: any) => toast.error('Restore failed', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const deleteSelectedMutation = useMutation({
|
const deleteSelectedMutation = useMutation({
|
||||||
@@ -78,7 +79,7 @@ export function DiscardActionBar() {
|
|||||||
setDeleteSelectedOpen(false)
|
setDeleteSelectedOpen(false)
|
||||||
},
|
},
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Delete failed', e.message || 'Unknown error'),
|
toast.error('Delete failed', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const emptyMutation = useMutation({
|
const emptyMutation = useMutation({
|
||||||
@@ -99,7 +100,7 @@ export function DiscardActionBar() {
|
|||||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||||
setConfirmOpen(false)
|
setConfirmOpen(false)
|
||||||
},
|
},
|
||||||
onError: (e: any) => toast.error('Empty failed', e.message || 'Unknown error'),
|
onError: (e: any) => toast.error('Empty failed', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (flag !== 'discarded') return null
|
if (flag !== 'discarded') return null
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Copy, Layers, Sparkles, Trash2, Loader2, Info, Crown } from 'lucide-rea
|
|||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { formatApiError } from '../../lib/apiError'
|
||||||
import {
|
import {
|
||||||
useDuplicateGroupsQuery,
|
useDuplicateGroupsQuery,
|
||||||
DUPLICATE_GROUPS_QUERY_KEY,
|
DUPLICATE_GROUPS_QUERY_KEY,
|
||||||
@@ -63,7 +64,7 @@ export function DuplicatesView() {
|
|||||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||||
},
|
},
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Discard failed', e?.message || 'Unknown error'),
|
toast.error('Discard failed', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Hooks below this point must run on every render — rules of hooks
|
// Hooks below this point must run on every render — rules of hooks
|
||||||
@@ -310,8 +311,8 @@ function DuplicateGroupSection({
|
|||||||
size={180}
|
size={180}
|
||||||
fill
|
fill
|
||||||
isSelected={selectedPhotos.includes(member.id)}
|
isSelected={selectedPhotos.includes(member.id)}
|
||||||
onClick={() => onSelectMember(member.id)}
|
onClick={(p) => onSelectMember(p.id)}
|
||||||
onDoubleClick={() => onPreviewMember(member.id)}
|
onDoubleClick={(p) => onPreviewMember(p.id)}
|
||||||
/>
|
/>
|
||||||
{/* BEST pill — top-right, pick-coloured. Composes the same
|
{/* BEST pill — top-right, pick-coloured. Composes the same
|
||||||
* THUMB_BADGE_* family used by PhotoThumbnail so the full
|
* THUMB_BADGE_* family used by PhotoThumbnail so the full
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { heaps as heapsApi, downloads, type Heap } from '../../services/api'
|
|||||||
import { useFilterStore } from '../../store/filterStore'
|
import { useFilterStore } from '../../store/filterStore'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
||||||
|
import { formatApiError } from '../../lib/apiError'
|
||||||
import { HeapConvertDialog } from './HeapConvertDialog'
|
import { HeapConvertDialog } from './HeapConvertDialog'
|
||||||
import { ShareDialog } from '../sharing/ShareDialog'
|
import { ShareDialog } from '../sharing/ShareDialog'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -79,7 +80,7 @@ export function HeapsPanel() {
|
|||||||
setCreating(false)
|
setCreating(false)
|
||||||
},
|
},
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Failed to create heap', e.message || 'Unknown error'),
|
toast.error('Failed to create heap', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const setActiveMutation = useMutation({
|
const setActiveMutation = useMutation({
|
||||||
@@ -90,7 +91,7 @@ export function HeapsPanel() {
|
|||||||
toast.success('Active heap', `Now adding to "${heap.name}" with T`)
|
toast.success('Active heap', `Now adding to "${heap.name}" with T`)
|
||||||
},
|
},
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Failed to set active', e.message || 'Unknown error'),
|
toast.error('Failed to set active', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
@@ -103,7 +104,7 @@ export function HeapsPanel() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Failed to delete heap', e.message || 'Unknown error'),
|
toast.error('Failed to delete heap', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const renameMutation = useMutation({
|
const renameMutation = useMutation({
|
||||||
@@ -111,7 +112,7 @@ export function HeapsPanel() {
|
|||||||
heapsApi.update(heapId, { name }),
|
heapsApi.update(heapId, { name }),
|
||||||
onSuccess: () => invalidate(),
|
onSuccess: () => invalidate(),
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Failed to rename heap', e.message || 'Unknown error'),
|
toast.error('Failed to rename heap', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const duplicateMutation = useMutation({
|
const duplicateMutation = useMutation({
|
||||||
@@ -121,7 +122,7 @@ export function HeapsPanel() {
|
|||||||
toast.success('Heap duplicated', heap.name)
|
toast.success('Heap duplicated', heap.name)
|
||||||
},
|
},
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Failed to duplicate heap', e.message || 'Unknown error'),
|
toast.error('Failed to duplicate heap', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Drop handler: add the dragged photos to the target heap. Optimistically
|
// Drop handler: add the dragged photos to the target heap. Optimistically
|
||||||
@@ -142,7 +143,7 @@ export function HeapsPanel() {
|
|||||||
if (ctx?.previous) {
|
if (ctx?.previous) {
|
||||||
queryClient.setQueryData(['heap-photo-ids', vars.heapId], ctx.previous)
|
queryClient.setQueryData(['heap-photo-ids', vars.heapId], ctx.previous)
|
||||||
}
|
}
|
||||||
toast.error('Failed to add to heap', e.message || 'Unknown error')
|
toast.error('Failed to add to heap', formatApiError(e))
|
||||||
},
|
},
|
||||||
onSuccess: (data, vars) => {
|
onSuccess: (data, vars) => {
|
||||||
const heap = heaps.find((h) => h.id === vars.heapId)
|
const heap = heaps.find((h) => h.id === vars.heapId)
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
LIBRARY_STATS_QUERY_KEY,
|
LIBRARY_STATS_QUERY_KEY,
|
||||||
} from '../../hooks/useLibraryStatsQuery'
|
} from '../../hooks/useLibraryStatsQuery'
|
||||||
import { registerUndoable } from '../../store/undoStore'
|
import { registerUndoable } from '../../store/undoStore'
|
||||||
|
import { formatApiError } from '../../lib/apiError'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'
|
import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog'
|
||||||
import { ShareDialog } from '../sharing/ShareDialog'
|
import { ShareDialog } from '../sharing/ShareDialog'
|
||||||
@@ -150,7 +151,7 @@ export function LeftSidebar() {
|
|||||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||||
},
|
},
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Discard failed', e?.message || 'Unknown error'),
|
toast.error('Discard failed', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Bulk move mutation for the drag-onto-folder interaction. The mutation
|
// Bulk move mutation for the drag-onto-folder interaction. The mutation
|
||||||
@@ -223,7 +224,7 @@ export function LeftSidebar() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||||
},
|
},
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Move failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
toast.error('Move failed', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Bulk copy mutation — Alt-drag uses this instead of move.
|
// Bulk copy mutation — Alt-drag uses this instead of move.
|
||||||
@@ -245,7 +246,7 @@ export function LeftSidebar() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||||
},
|
},
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Copy failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
toast.error('Copy failed', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Reads the dragged ids out of a drop event payload.
|
// Reads the dragged ids out of a drop event payload.
|
||||||
@@ -314,7 +315,7 @@ export function LeftSidebar() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
},
|
},
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Rename failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
toast.error('Rename failed', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const createFolderMutation = useMutation({
|
const createFolderMutation = useMutation({
|
||||||
@@ -328,7 +329,7 @@ export function LeftSidebar() {
|
|||||||
setCreateDraft('')
|
setCreateDraft('')
|
||||||
},
|
},
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Create failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
toast.error('Create failed', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Toggle folder hide-from-views. Invalidates every query that could
|
// Toggle folder hide-from-views. Invalidates every query that could
|
||||||
@@ -354,7 +355,7 @@ export function LeftSidebar() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['tags'] })
|
queryClient.invalidateQueries({ queryKey: ['tags'] })
|
||||||
},
|
},
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Toggle failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
toast.error('Toggle failed', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const deleteFolderMutation = useMutation({
|
const deleteFolderMutation = useMutation({
|
||||||
@@ -383,7 +384,7 @@ export function LeftSidebar() {
|
|||||||
setDeletingFolder(null)
|
setDeletingFolder(null)
|
||||||
},
|
},
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Delete failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
toast.error('Delete failed', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const toggleExpanded = (id: string) => {
|
const toggleExpanded = (id: string) => {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { X, Star, ShoppingBasket, Trash2, Plus } from 'lucide-react'
|
import { X, Star, ShoppingBasket, Trash2 } from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { format } from 'date-fns'
|
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
import {
|
import {
|
||||||
photos as photosApi,
|
photos as photosApi,
|
||||||
@@ -10,21 +9,19 @@ import {
|
|||||||
tags as tagsApi,
|
tags as tagsApi,
|
||||||
} from '../../services/api'
|
} from '../../services/api'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
import {
|
|
||||||
guessDateFromPath,
|
|
||||||
type DateGuess,
|
|
||||||
} from '../../lib/guessDateFromPath'
|
|
||||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||||
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
|
||||||
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
||||||
import { stripPhotosFromCache } from '../../hooks/usePhotosQuery'
|
import { stripPhotosFromCache } from '../../hooks/usePhotosQuery'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
|
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
|
||||||
|
import { BulkTakenAtEditor } from '../sidebar/BulkTakenAtEditor'
|
||||||
|
import { BulkTagsEditor } from '../sidebar/BulkTagsEditor'
|
||||||
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { useBulkPhotoMutations } from '../../hooks/useBulkPhotoMutations'
|
||||||
|
import { formatApiError } from '../../lib/apiError'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Right-hand details panel.
|
* Right-hand details panel.
|
||||||
@@ -36,22 +33,12 @@ export function RightSidebar() {
|
|||||||
const { selectedPhotos, activePhotoId, clearSelection } = usePhotoStore()
|
const { selectedPhotos, activePhotoId, clearSelection } = usePhotoStore()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const invalidatePhotoQueries = () => {
|
const {
|
||||||
queryClient.invalidateQueries({ queryKey: ['photo'] })
|
bulkRating: bulkRatingMutation,
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
bulkColor: bulkColorMutation,
|
||||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
invalidatePhotoQueries,
|
||||||
}
|
} = useBulkPhotoMutations()
|
||||||
|
|
||||||
const bulkRatingMutation = useMutation({
|
|
||||||
mutationFn: ({ ids, rating }: { ids: string[]; rating: number }) =>
|
|
||||||
photosApi.bulkSetRating(ids, rating),
|
|
||||||
onSuccess: invalidatePhotoQueries,
|
|
||||||
})
|
|
||||||
const bulkColorMutation = useMutation({
|
|
||||||
mutationFn: ({ ids, color }: { ids: string[]; color: string | null }) =>
|
|
||||||
photosApi.bulkSetColor(ids, color),
|
|
||||||
onSuccess: invalidatePhotoQueries,
|
|
||||||
})
|
|
||||||
const bulkDiscardMutation = useMutation({
|
const bulkDiscardMutation = useMutation({
|
||||||
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
|
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
|
||||||
// Yank the photos from the timeline before the network round-trip
|
// Yank the photos from the timeline before the network round-trip
|
||||||
@@ -94,7 +81,7 @@ export function RightSidebar() {
|
|||||||
photosApi.bulkSetTakenAt(ids, iso),
|
photosApi.bulkSetTakenAt(ids, iso),
|
||||||
onSuccess: reportBulkTakenAt,
|
onSuccess: reportBulkTakenAt,
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Date update failed', e?.message || 'Unknown error'),
|
toast.error('Date update failed', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const bulkTakenAtMapMutation = useMutation({
|
const bulkTakenAtMapMutation = useMutation({
|
||||||
@@ -102,7 +89,7 @@ export function RightSidebar() {
|
|||||||
photosApi.bulkSetTakenAtMap(map),
|
photosApi.bulkSetTakenAtMap(map),
|
||||||
onSuccess: reportBulkTakenAt,
|
onSuccess: reportBulkTakenAt,
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Date update failed', e?.message || 'Unknown error'),
|
toast.error('Date update failed', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Bulk tag mutations. Tag mutations also need to invalidate the tags
|
// Bulk tag mutations. Tag mutations also need to invalidate the tags
|
||||||
@@ -123,7 +110,7 @@ export function RightSidebar() {
|
|||||||
invalidateTagsAndPhotos()
|
invalidateTagsAndPhotos()
|
||||||
},
|
},
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Add tags failed', e?.message || 'Unknown error'),
|
toast.error('Add tags failed', formatApiError(e)),
|
||||||
})
|
})
|
||||||
const bulkRemoveTagsMutation = useMutation({
|
const bulkRemoveTagsMutation = useMutation({
|
||||||
mutationFn: ({ ids, tagIds }: { ids: string[]; tagIds: string[] }) =>
|
mutationFn: ({ ids, tagIds }: { ids: string[]; tagIds: string[] }) =>
|
||||||
@@ -137,7 +124,7 @@ export function RightSidebar() {
|
|||||||
invalidateTagsAndPhotos()
|
invalidateTagsAndPhotos()
|
||||||
},
|
},
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Remove tags failed', e?.message || 'Unknown error'),
|
toast.error('Remove tags failed', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Idempotent create-and-attach: lets the user type a brand-new tag
|
// Idempotent create-and-attach: lets the user type a brand-new tag
|
||||||
@@ -152,7 +139,7 @@ export function RightSidebar() {
|
|||||||
invalidateTagsAndPhotos()
|
invalidateTagsAndPhotos()
|
||||||
},
|
},
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Create tag failed', e?.message || 'Unknown error'),
|
toast.error('Create tag failed', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: allTags = [] } = useTagsQuery()
|
const { data: allTags = [] } = useTagsQuery()
|
||||||
@@ -182,7 +169,7 @@ export function RightSidebar() {
|
|||||||
if (activeHeap && ctx?.previous) {
|
if (activeHeap && ctx?.previous) {
|
||||||
queryClient.setQueryData(['heap-photo-ids', activeHeap.id], ctx.previous)
|
queryClient.setQueryData(['heap-photo-ids', activeHeap.id], ctx.previous)
|
||||||
}
|
}
|
||||||
toast.error('Heap update failed', e?.message || 'Unknown error')
|
toast.error('Heap update failed', formatApiError(e))
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||||||
@@ -459,265 +446,3 @@ export function RightSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BulkTakenAtEditorProps {
|
|
||||||
disabled: boolean
|
|
||||||
selectedCount: number
|
|
||||||
collectPhotos: () => Photo[]
|
|
||||||
onApplyUniform: (iso: string) => void
|
|
||||||
onApplyMap: (map: Record<string, string>) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Bulk Date Taken sub-panel used by RightSidebar in multi-select mode.
|
|
||||||
* Two modes share one UI:
|
|
||||||
* 1. Apply-one: user types a datetime, clicks Apply, every selected
|
|
||||||
* photo is rewritten to that date.
|
|
||||||
* 2. Guess-from-path: we run `guessDateFromPath` against each selected
|
|
||||||
* photo's filepath, show a preview of the hits + misses, and let
|
|
||||||
* the user commit the per-photo map in one round-trip. */
|
|
||||||
function BulkTakenAtEditor({
|
|
||||||
disabled,
|
|
||||||
selectedCount,
|
|
||||||
collectPhotos,
|
|
||||||
onApplyUniform,
|
|
||||||
onApplyMap,
|
|
||||||
}: BulkTakenAtEditorProps) {
|
|
||||||
const [uniformDraft, setUniformDraft] = useState('')
|
|
||||||
const [preview, setPreview] = useState<
|
|
||||||
| {
|
|
||||||
hits: { photo: Photo; guess: DateGuess }[]
|
|
||||||
misses: Photo[]
|
|
||||||
}
|
|
||||||
| null
|
|
||||||
>(null)
|
|
||||||
|
|
||||||
const handleGuess = () => {
|
|
||||||
const photos = collectPhotos()
|
|
||||||
const hits: { photo: Photo; guess: DateGuess }[] = []
|
|
||||||
const misses: Photo[] = []
|
|
||||||
for (const p of photos) {
|
|
||||||
const g = guessDateFromPath(p.filepath)
|
|
||||||
if (g) hits.push({ photo: p, guess: g })
|
|
||||||
else misses.push(p)
|
|
||||||
}
|
|
||||||
setPreview({ hits, misses })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleApplyPreview = () => {
|
|
||||||
if (!preview) return
|
|
||||||
const map: Record<string, string> = {}
|
|
||||||
for (const { photo, guess } of preview.hits) {
|
|
||||||
map[photo.id] = guess.date.toISOString()
|
|
||||||
}
|
|
||||||
if (Object.keys(map).length === 0) return
|
|
||||||
onApplyMap(map)
|
|
||||||
setPreview(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleApplyUniform = () => {
|
|
||||||
if (!uniformDraft) return
|
|
||||||
const parsed = new Date(uniformDraft)
|
|
||||||
if (Number.isNaN(parsed.getTime())) return
|
|
||||||
onApplyUniform(parsed.toISOString())
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/* Apply-one row */}
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<Input
|
|
||||||
type="datetime-local"
|
|
||||||
value={uniformDraft}
|
|
||||||
onChange={(e) => setUniformDraft(e.target.value)}
|
|
||||||
disabled={disabled}
|
|
||||||
className="h-7 flex-1 text-xs"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleApplyUniform}
|
|
||||||
disabled={disabled || !uniformDraft}
|
|
||||||
className="bg-primary/20 text-primary hover:bg-primary/30"
|
|
||||||
title={`Apply this date to all ${selectedCount} selected`}
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Guess-from-path preview */}
|
|
||||||
{preview === null ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleGuess}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-full border-dashed border-primary/50 bg-transparent text-primary hover:bg-primary/10"
|
|
||||||
title="Scan each photo's folder + filename for a date pattern"
|
|
||||||
>
|
|
||||||
Guess from folder paths
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<div className="rounded border border-border bg-bg p-2 text-[11px]">
|
|
||||||
<div className="mb-1.5 text-text-muted">
|
|
||||||
{preview.hits.length} will update ·{' '}
|
|
||||||
{preview.misses.length} skipped
|
|
||||||
</div>
|
|
||||||
{preview.hits.length > 0 && (
|
|
||||||
<ul className="mb-1.5 max-h-24 space-y-0.5 overflow-y-auto font-mono text-[10px] text-text">
|
|
||||||
{preview.hits.slice(0, 5).map(({ photo, guess }) => (
|
|
||||||
<li key={photo.id} className="truncate" title={photo.filepath}>
|
|
||||||
<span className="text-text-muted">{photo.filename}</span>
|
|
||||||
{' → '}
|
|
||||||
<span className="text-primary">
|
|
||||||
{format(guess.date, 'yyyy-MM-dd')}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{preview.hits.length > 5 && (
|
|
||||||
<li className="text-text-muted">
|
|
||||||
…and {preview.hits.length - 5} more
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleApplyPreview}
|
|
||||||
disabled={disabled || preview.hits.length === 0}
|
|
||||||
className="flex-1 bg-primary/20 text-primary hover:bg-primary/30"
|
|
||||||
>
|
|
||||||
Apply {preview.hits.length}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPreview(null)}
|
|
||||||
disabled={disabled}
|
|
||||||
className="text-text-muted"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BulkTagsEditorProps {
|
|
||||||
allTags: { id: string; name: string; color: string | null }[]
|
|
||||||
tagInput: string
|
|
||||||
onTagInputChange: (value: string) => void
|
|
||||||
disabled: boolean
|
|
||||||
onApply: (tagId: string) => void
|
|
||||||
onRemove: (tagId: string) => void
|
|
||||||
onCreate: (name: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compact bulk tag editor for the multi-select right sidebar. Unlike the
|
|
||||||
* single-photo TagsEditor we don't show "current tags" — there's no clean
|
|
||||||
* single-photo notion of that across an arbitrary selection. Instead the
|
|
||||||
* user picks an existing tag (apply to all) or types a new one (create
|
|
||||||
* and apply to all).
|
|
||||||
*/
|
|
||||||
function BulkTagsEditor({
|
|
||||||
allTags,
|
|
||||||
tagInput,
|
|
||||||
onTagInputChange,
|
|
||||||
disabled,
|
|
||||||
onApply,
|
|
||||||
onRemove,
|
|
||||||
onCreate,
|
|
||||||
}: BulkTagsEditorProps) {
|
|
||||||
const trimmed = tagInput.trim()
|
|
||||||
const lower = trimmed.toLowerCase()
|
|
||||||
|
|
||||||
const filtered = trimmed
|
|
||||||
? allTags.filter((t) => t.name.toLowerCase().includes(lower))
|
|
||||||
: allTags
|
|
||||||
|
|
||||||
const exactMatch = trimmed
|
|
||||||
? allTags.find((t) => t.name.toLowerCase() === lower)
|
|
||||||
: null
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (!trimmed || disabled) return
|
|
||||||
if (exactMatch) {
|
|
||||||
onApply(exactMatch.id)
|
|
||||||
onTagInputChange('')
|
|
||||||
} else {
|
|
||||||
onCreate(trimmed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={tagInput}
|
|
||||||
onChange={(e) => onTagInputChange(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
|
||||||
handleSubmit()
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
onTagInputChange('')
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Filter or create…"
|
|
||||||
disabled={disabled}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{trimmed && !exactMatch && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={disabled}
|
|
||||||
className="w-full border-dashed border-primary/50 bg-transparent text-primary hover:bg-primary/10"
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
|
||||||
Create "{trimmed}" and apply
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filtered.length > 0 ? (
|
|
||||||
<div className="flex max-h-40 flex-wrap gap-1 overflow-y-auto">
|
|
||||||
{filtered.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag.id}
|
|
||||||
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
|
|
||||||
style={
|
|
||||||
tag.color
|
|
||||||
? { backgroundColor: `${tag.color}33`, color: tag.color }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => onApply(tag.id)}
|
|
||||||
disabled={disabled}
|
|
||||||
className="hover:underline disabled:opacity-50"
|
|
||||||
title={`Apply "${tag.name}" to selection`}
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onRemove(tag.id)}
|
|
||||||
disabled={disabled}
|
|
||||||
className="rounded p-0.5 opacity-60 hover:bg-surface-offset hover:opacity-100 disabled:opacity-30"
|
|
||||||
title={`Remove "${tag.name}" from selection`}
|
|
||||||
aria-label={`Remove ${tag.name} from selection`}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-text-faint">No tags match</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
import { X, Info } from 'lucide-react'
|
import { X, Info } from 'lucide-react'
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
|
import { useFilterStore } from '../../store/filterStore'
|
||||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||||
import { photos as photosApi } from '../../services/api'
|
import { photos as photosApi } from '../../services/api'
|
||||||
|
import { findSearchMatch } from '../../lib/searchMatch'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
import { PreviewImage } from './PreviewImage'
|
import { PreviewImage } from './PreviewImage'
|
||||||
import { PreviewFilmstrip } from './PreviewFilmstrip'
|
import { PreviewFilmstrip } from './PreviewFilmstrip'
|
||||||
@@ -84,6 +86,15 @@ export function PreviewView() {
|
|||||||
const currentPhoto: Photo | undefined =
|
const currentPhoto: Photo | undefined =
|
||||||
photoInListById ?? photos[safeIndex] ?? standalonePhoto
|
photoInListById ?? photos[safeIndex] ?? standalonePhoto
|
||||||
|
|
||||||
|
// Carry the timeline's search-match chip into preview so the user
|
||||||
|
// doesn't lose the "why did this photo come back" context when they
|
||||||
|
// zoom in. Pure recompute — same helper the thumbnail uses.
|
||||||
|
const searchQuery = useFilterStore((s) => s.q)
|
||||||
|
const searchMatch =
|
||||||
|
currentPhoto && searchQuery.trim()
|
||||||
|
? findSearchMatch(currentPhoto, searchQuery)
|
||||||
|
: null
|
||||||
|
|
||||||
// Keep the latest photos array + active id in a ref so the keyboard
|
// Keep the latest photos array + active id in a ref so the keyboard
|
||||||
// handlers ALWAYS read the freshest state. Without this, react-hotkeys-
|
// handlers ALWAYS read the freshest state. Without this, react-hotkeys-
|
||||||
// hook can fire a closure that captured an older photos array (e.g.
|
// hook can fire a closure that captured an older photos array (e.g.
|
||||||
@@ -216,12 +227,40 @@ export function PreviewView() {
|
|||||||
>
|
>
|
||||||
{/* Main column — image + filmstrip */}
|
{/* Main column — image + filmstrip */}
|
||||||
<div className="relative flex min-w-0 flex-1 flex-col">
|
<div className="relative flex min-w-0 flex-1 flex-col">
|
||||||
{/* Filename + counter */}
|
{/* Filename + counter + (optional) search match chip */}
|
||||||
<div className="absolute left-3 top-3 z-10 rounded bg-black/60 px-3 py-1.5 text-xs text-white">
|
<div className="absolute left-3 top-3 z-10 max-w-[60%] rounded bg-black/60 px-3 py-1.5 text-xs text-white">
|
||||||
<div className="font-mono">{currentPhoto.filename}</div>
|
<div className="truncate font-mono">{currentPhoto.filename}</div>
|
||||||
<div className="text-text-muted">
|
<div className="text-text-muted">
|
||||||
{safeIndex + 1} / {photos.length}
|
{safeIndex + 1} / {photos.length}
|
||||||
</div>
|
</div>
|
||||||
|
{searchMatch && (
|
||||||
|
<div
|
||||||
|
className="mt-1 flex items-center gap-1.5 text-[11px]"
|
||||||
|
title={`Matched on ${searchMatch.label.toLowerCase()}: ${searchMatch.excerpt}`}
|
||||||
|
>
|
||||||
|
<span className="shrink-0 rounded-sm bg-primary/80 px-1 text-[9px] font-semibold uppercase tracking-wider">
|
||||||
|
{searchMatch.label}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">
|
||||||
|
{searchMatch.matchLength > 0 ? (
|
||||||
|
<>
|
||||||
|
{searchMatch.excerpt.slice(0, searchMatch.matchStart)}
|
||||||
|
<mark className="rounded-sm bg-amber-400/90 px-0.5 font-semibold text-black">
|
||||||
|
{searchMatch.excerpt.slice(
|
||||||
|
searchMatch.matchStart,
|
||||||
|
searchMatch.matchStart + searchMatch.matchLength,
|
||||||
|
)}
|
||||||
|
</mark>
|
||||||
|
{searchMatch.excerpt.slice(
|
||||||
|
searchMatch.matchStart + searchMatch.matchLength,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
searchMatch.excerpt
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top-right action buttons */}
|
{/* Top-right action buttons */}
|
||||||
|
|||||||
121
frontend/src/components/sidebar/BulkTagsEditor.tsx
Normal file
121
frontend/src/components/sidebar/BulkTagsEditor.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
153
frontend/src/components/sidebar/BulkTakenAtEditor.tsx
Normal file
153
frontend/src/components/sidebar/BulkTakenAtEditor.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,17 +17,16 @@ import {
|
|||||||
useQueryClient,
|
useQueryClient,
|
||||||
keepPreviousData,
|
keepPreviousData,
|
||||||
} from '@tanstack/react-query'
|
} from '@tanstack/react-query'
|
||||||
import { format } from 'date-fns'
|
|
||||||
import {
|
import {
|
||||||
photos as photosApi,
|
photos as photosApi,
|
||||||
heaps as heapsApi,
|
heaps as heapsApi,
|
||||||
tags as tagsApi,
|
tags as tagsApi,
|
||||||
type Tag,
|
|
||||||
} from '../../services/api'
|
} from '../../services/api'
|
||||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||||
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
||||||
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
||||||
|
import { formatApiError } from '../../lib/apiError'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
@@ -40,18 +39,17 @@ import {
|
|||||||
COLOR_LABEL_OPTIONS,
|
COLOR_LABEL_OPTIONS,
|
||||||
type ColorLabel,
|
type ColorLabel,
|
||||||
} from '../../constants/colorLabels'
|
} from '../../constants/colorLabels'
|
||||||
import {
|
import { toDatetimeLocalValue } from '../../lib/guessDateFromPath'
|
||||||
guessDateFromPath,
|
import { TagsEditor } from './TagsEditor'
|
||||||
toDatetimeLocalValue,
|
import { TakenAtEditor } from './TakenAtEditor'
|
||||||
} from '../../lib/guessDateFromPath'
|
|
||||||
|
|
||||||
interface PhotoTagSummary {
|
export interface PhotoTagSummary {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
color: string | null
|
color: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PhotoDetails {
|
export interface PhotoDetails {
|
||||||
id: string
|
id: string
|
||||||
filename: string
|
filename: string
|
||||||
filepath: string
|
filepath: string
|
||||||
@@ -240,21 +238,21 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
|||||||
},
|
},
|
||||||
onSuccess: () => invalidateTagsAndPhoto(),
|
onSuccess: () => invalidateTagsAndPhoto(),
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Add tag failed', e?.message || 'Unknown error'),
|
toast.error('Add tag failed', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const attachExistingTagMutation = useMutation({
|
const attachExistingTagMutation = useMutation({
|
||||||
mutationFn: (tagId: string) => tagsApi.addToPhoto(photoId, [tagId]),
|
mutationFn: (tagId: string) => tagsApi.addToPhoto(photoId, [tagId]),
|
||||||
onSuccess: () => invalidateTagsAndPhoto(),
|
onSuccess: () => invalidateTagsAndPhoto(),
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Add tag failed', e?.message || 'Unknown error'),
|
toast.error('Add tag failed', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const removeTagMutation = useMutation({
|
const removeTagMutation = useMutation({
|
||||||
mutationFn: (tagId: string) => tagsApi.removeFromPhoto(photoId, tagId),
|
mutationFn: (tagId: string) => tagsApi.removeFromPhoto(photoId, tagId),
|
||||||
onSuccess: () => invalidateTagsAndPhoto(),
|
onSuccess: () => invalidateTagsAndPhoto(),
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Remove tag failed', e?.message || 'Unknown error'),
|
toast.error('Remove tag failed', formatApiError(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Local drafts for the text fields. Mirror the server value but stay
|
// Local drafts for the text fields. Mirror the server value but stay
|
||||||
@@ -297,7 +295,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
|||||||
onError: (e: any) => {
|
onError: (e: any) => {
|
||||||
toast.error(
|
toast.error(
|
||||||
'Rename failed',
|
'Rename failed',
|
||||||
e?.response?.data?.detail || e.message || 'Unknown error'
|
formatApiError(e)
|
||||||
)
|
)
|
||||||
setFilenameDraft(current)
|
setFilenameDraft(current)
|
||||||
},
|
},
|
||||||
@@ -345,7 +343,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
|||||||
onError: (e: any) => {
|
onError: (e: any) => {
|
||||||
toast.error(
|
toast.error(
|
||||||
'Date update failed',
|
'Date update failed',
|
||||||
e?.response?.data?.detail || e.message || 'Unknown error'
|
formatApiError(e)
|
||||||
)
|
)
|
||||||
setTakenAtDraft(
|
setTakenAtDraft(
|
||||||
photo?.taken_at
|
photo?.taken_at
|
||||||
@@ -707,129 +705,6 @@ function Section({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TagsEditorProps {
|
|
||||||
photoTags: PhotoTagSummary[]
|
|
||||||
allTags: Tag[]
|
|
||||||
tagInput: string
|
|
||||||
onTagInputChange: (value: string) => void
|
|
||||||
onAttachExisting: (id: string) => void
|
|
||||||
onCreateAndAttach: (name: string) => void
|
|
||||||
onRemove: (id: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function TagsEditor({
|
|
||||||
photoTags,
|
|
||||||
allTags,
|
|
||||||
tagInput,
|
|
||||||
onTagInputChange,
|
|
||||||
onAttachExisting,
|
|
||||||
onCreateAndAttach,
|
|
||||||
onRemove,
|
|
||||||
}: TagsEditorProps) {
|
|
||||||
const trimmed = tagInput.trim()
|
|
||||||
const lowerTrimmed = trimmed.toLowerCase()
|
|
||||||
const photoTagIds = new Set(photoTags.map((t) => t.id))
|
|
||||||
|
|
||||||
const suggestions = trimmed
|
|
||||||
? allTags
|
|
||||||
.filter(
|
|
||||||
(t) =>
|
|
||||||
!photoTagIds.has(t.id) &&
|
|
||||||
t.name.toLowerCase().includes(lowerTrimmed)
|
|
||||||
)
|
|
||||||
.slice(0, 6)
|
|
||||||
: []
|
|
||||||
|
|
||||||
const exactMatch = trimmed
|
|
||||||
? allTags.find((t) => t.name.toLowerCase() === lowerTrimmed)
|
|
||||||
: null
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (!trimmed) return
|
|
||||||
if (exactMatch) {
|
|
||||||
if (!photoTagIds.has(exactMatch.id)) {
|
|
||||||
onAttachExisting(exactMatch.id)
|
|
||||||
}
|
|
||||||
onTagInputChange('')
|
|
||||||
} else {
|
|
||||||
onCreateAndAttach(trimmed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{photoTags.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{photoTags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag.id}
|
|
||||||
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-0.5 text-xs text-text"
|
|
||||||
style={
|
|
||||||
tag.color
|
|
||||||
? { backgroundColor: `${tag.color}33`, color: tag.color }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
<button
|
|
||||||
onClick={() => onRemove(tag.id)}
|
|
||||||
className="rounded p-0.5 opacity-60 hover:bg-surface-offset hover:opacity-100"
|
|
||||||
title="Remove tag"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-text-faint">No tags</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={tagInput}
|
|
||||||
onChange={(e) => onTagInputChange(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault()
|
|
||||||
handleSubmit()
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
onTagInputChange('')
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Add tag…"
|
|
||||||
className="h-7 bg-bg text-xs"
|
|
||||||
/>
|
|
||||||
{suggestions.length > 0 && (
|
|
||||||
<div className="mt-1 rounded border border-border bg-bg shadow-md">
|
|
||||||
{suggestions.map((s) => (
|
|
||||||
<button
|
|
||||||
key={s.id}
|
|
||||||
onClick={() => {
|
|
||||||
onAttachExisting(s.id)
|
|
||||||
onTagInputChange('')
|
|
||||||
}}
|
|
||||||
className="block w-full px-2 py-1 text-left text-xs text-text hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
{s.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{trimmed && !exactMatch && (
|
|
||||||
<button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
className="mt-1 w-full rounded border border-dashed border-primary/50 px-2 py-1 text-left text-xs text-primary hover:bg-primary/10"
|
|
||||||
>
|
|
||||||
+ Create "{trimmed}"
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({ label, value }: { label: string; value: string }) {
|
function Field({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -839,113 +714,3 @@ function Field({ label, value }: { label: string; value: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TakenAtEditorProps {
|
|
||||||
photo: PhotoDetails
|
|
||||||
draft: string
|
|
||||||
onDraftChange: (v: string) => void
|
|
||||||
onCommit: (raw?: string) => void
|
|
||||||
darkTheme: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Editable Date Taken field with a source badge (EXIF / filesystem / manual)
|
|
||||||
* and a folder-guess suggestion row that only shows up when the filepath
|
|
||||||
* implies a different date than what's currently stored. The suggestion
|
|
||||||
* hint is the whole point of this feature — epoch-reset phones and
|
|
||||||
* corrupted EXIF dumps end up clustered in the wrong corner of the
|
|
||||||
* timeline until someone rewrites them from the folder name. */
|
|
||||||
function TakenAtEditor({
|
|
||||||
photo,
|
|
||||||
draft,
|
|
||||||
onDraftChange,
|
|
||||||
onCommit,
|
|
||||||
darkTheme,
|
|
||||||
}: TakenAtEditorProps) {
|
|
||||||
const source = photo.taken_at_source ?? null
|
|
||||||
const sourceLabel =
|
|
||||||
source === 'exif'
|
|
||||||
? 'EXIF'
|
|
||||||
: source === 'filesystem'
|
|
||||||
? 'FILE'
|
|
||||||
: source === 'manual'
|
|
||||||
? 'MANUAL'
|
|
||||||
: null
|
|
||||||
|
|
||||||
const guess = useMemo(
|
|
||||||
() => guessDateFromPath(photo.filepath),
|
|
||||||
[photo.filepath]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Show the suggestion when:
|
|
||||||
// - there's no stored date at all, OR
|
|
||||||
// - the guess disagrees with the stored date by more than a day.
|
|
||||||
// A same-day match is treated as "already correct enough" so we don't
|
|
||||||
// nag the user on photos that happen to sit in a dated folder.
|
|
||||||
const showSuggestion = useMemo(() => {
|
|
||||||
if (!guess) return false
|
|
||||||
if (!photo.taken_at) return true
|
|
||||||
const current = new Date(photo.taken_at).getTime()
|
|
||||||
const suggested = guess.date.getTime()
|
|
||||||
return Math.abs(current - suggested) > 24 * 60 * 60 * 1000
|
|
||||||
}, [guess, photo.taken_at])
|
|
||||||
|
|
||||||
const inputClass = clsx(
|
|
||||||
'flex-1 rounded border px-2 py-1 text-xs focus:outline-none',
|
|
||||||
darkTheme
|
|
||||||
? 'border-white/15 bg-black/40 text-white focus:border-primary'
|
|
||||||
: 'border-border bg-bg text-text focus:border-primary'
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-2 text-xs">
|
|
||||||
<label className="mb-1 block text-text-muted">Date Taken</label>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={draft}
|
|
||||||
onChange={(e) => onDraftChange(e.target.value)}
|
|
||||||
onBlur={() => onCommit()}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.currentTarget.blur()
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
onDraftChange(
|
|
||||||
photo.taken_at
|
|
||||||
? toDatetimeLocalValue(new Date(photo.taken_at))
|
|
||||||
: ''
|
|
||||||
)
|
|
||||||
e.currentTarget.blur()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={inputClass}
|
|
||||||
/>
|
|
||||||
{sourceLabel && (
|
|
||||||
<span
|
|
||||||
className="rounded-sm bg-black/60 px-1.5 py-0.5 text-[9px] font-semibold tracking-wider text-white"
|
|
||||||
title={`Source: ${sourceLabel.toLowerCase()}`}
|
|
||||||
>
|
|
||||||
{sourceLabel}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{showSuggestion && guess && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const next = toDatetimeLocalValue(guess.date)
|
|
||||||
onDraftChange(next)
|
|
||||||
onCommit(next)
|
|
||||||
}}
|
|
||||||
className={clsx(
|
|
||||||
'mt-1.5 flex w-full items-center justify-between gap-2 rounded border border-dashed px-2 py-1 text-[11px] transition-colors',
|
|
||||||
'border-primary/50 text-primary hover:bg-primary/10'
|
|
||||||
)}
|
|
||||||
title={`Match "${guess.matched}" in path (${guess.source}, ${guess.confidence} confidence)`}
|
|
||||||
>
|
|
||||||
<span className="truncate">
|
|
||||||
Folder suggests {format(guess.date, 'MMM d, yyyy')}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 font-semibold">Apply</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
133
frontend/src/components/sidebar/TagsEditor.tsx
Normal file
133
frontend/src/components/sidebar/TagsEditor.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
119
frontend/src/components/sidebar/TakenAtEditor.tsx
Normal file
119
frontend/src/components/sidebar/TakenAtEditor.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { memo, useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
Star,
|
Star,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Users,
|
Users,
|
||||||
|
ShoppingBasket,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { photos as photosApi } from '../../services/api'
|
import { photos as photosApi } from '../../services/api'
|
||||||
@@ -74,11 +75,14 @@ interface PhotoThumbnailProps {
|
|||||||
/** True when the photo belongs to the currently active heap — drives
|
/** True when the photo belongs to the currently active heap — drives
|
||||||
* the green tint overlay. */
|
* the green tint overlay. */
|
||||||
isInActiveHeap?: boolean
|
isInActiveHeap?: boolean
|
||||||
onClick: (e: React.MouseEvent) => void
|
/** Called with the photo + the native event. Pass a stable handler
|
||||||
onDoubleClick?: (e: React.MouseEvent) => void
|
* (useCallback with store-action deps) so React.memo can actually
|
||||||
|
* elide re-renders on unrelated store updates. */
|
||||||
|
onClick: (photo: Photo, e: React.MouseEvent) => void
|
||||||
|
onDoubleClick?: (photo: Photo, e: React.MouseEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PhotoThumbnail({
|
function PhotoThumbnailImpl({
|
||||||
photo,
|
photo,
|
||||||
size,
|
size,
|
||||||
fill = false,
|
fill = false,
|
||||||
@@ -226,8 +230,10 @@ export function PhotoThumbnail({
|
|||||||
? { width: '100%', height: '100%' }
|
? { width: '100%', height: '100%' }
|
||||||
: { width: size, height: displayHeight }
|
: { width: size, height: displayHeight }
|
||||||
}
|
}
|
||||||
onClick={onClick}
|
onClick={(e) => onClick(photo, e)}
|
||||||
onDoubleClick={onDoubleClick}
|
onDoubleClick={
|
||||||
|
onDoubleClick ? (e) => onDoubleClick(photo, e) : undefined
|
||||||
|
}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
title="Click to select • Double-click to open • Shift+Click for range • Ctrl+Click to add • Drag onto a heap to add"
|
title="Click to select • Double-click to open • Shift+Click for range • Ctrl+Click to add • Drag onto a heap to add"
|
||||||
@@ -249,18 +255,16 @@ export function PhotoThumbnail({
|
|||||||
onError={handleImageError}
|
onError={handleImageError}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
{/* Loading indicator */}
|
{/* Loading indicator. The outer wrapper already pulses via
|
||||||
{!imageLoaded && (
|
* `bg-surface animate-pulse` while !imageLoaded, which is the
|
||||||
|
* right default — hundreds of concurrent spinners churn the GPU
|
||||||
|
* on first paint. Only surface a visible indicator for the
|
||||||
|
* relatively rare retry case, where silence would look broken. */}
|
||||||
|
{!imageLoaded && isRetrying && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-surface">
|
<div className="absolute inset-0 flex items-center justify-center bg-surface">
|
||||||
<div className="text-text-muted">
|
<div className="text-center text-text-muted">
|
||||||
{isRetrying ? (
|
<RefreshCw className="mx-auto mb-1 h-4 w-4 animate-spin" />
|
||||||
<div className="text-center">
|
<div className="text-[10px]">Retrying…</div>
|
||||||
<RefreshCw className="h-5 w-5 animate-spin mx-auto mb-1" />
|
|
||||||
<div className="text-xs">Retrying...</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="h-8 w-8 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -381,9 +385,23 @@ export function PhotoThumbnail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* BR — duplicate / discard (neutral). Heap membership is shown
|
{/* BR — duplicate / discard / date-warning (neutral info) plus a
|
||||||
* via the green tint overlay above, no badge here. */}
|
* heap-membership chip. The green tint overlay above is the
|
||||||
|
* primary signal, but the icon makes membership readable in
|
||||||
|
* colorblind-safe terms too. */}
|
||||||
<div className="absolute bottom-1 right-1 flex items-center gap-1">
|
<div className="absolute bottom-1 right-1 flex items-center gap-1">
|
||||||
|
{isInActiveHeap && (
|
||||||
|
<div
|
||||||
|
className={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 && (
|
{photo.is_duplicate && (
|
||||||
<div
|
<div
|
||||||
className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_SQUARE, THUMB_BADGE_NEUTRAL)}
|
className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_SQUARE, THUMB_BADGE_NEUTRAL)}
|
||||||
@@ -426,4 +444,12 @@ export function PhotoThumbnail({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React.memo with the default shallow comparison. Relies on the caller
|
||||||
|
* passing stable `onClick`/`onDoubleClick` handlers (via useCallback)
|
||||||
|
* so identity doesn't churn on every parent render — that's what lets
|
||||||
|
* us skip re-render on unrelated store updates like heap invalidation.
|
||||||
|
*/
|
||||||
|
export const PhotoThumbnail = memo(PhotoThumbnailImpl)
|
||||||
@@ -3,10 +3,12 @@ import { useVirtualizer } from '@tanstack/react-virtual'
|
|||||||
import { format, parseISO } from 'date-fns'
|
import { format, parseISO } from 'date-fns'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
import { useFilterStore } from '../../store/filterStore'
|
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
|
||||||
import { PhotoThumbnail } from './PhotoThumbnail'
|
import { PhotoThumbnail } from './PhotoThumbnail'
|
||||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { ImageOff } from 'lucide-react'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
|
|
||||||
// Layout constants for the grid + grouped headers.
|
// Layout constants for the grid + grouped headers.
|
||||||
@@ -203,6 +205,27 @@ export function Timeline() {
|
|||||||
// of thumbnails each subscribing to the same query.
|
// of thumbnails each subscribing to the same query.
|
||||||
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
|
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||||
|
|
||||||
|
// Stable cell handlers. PhotoThumbnail is wrapped in React.memo so
|
||||||
|
// identity-stable callbacks let it skip re-render on unrelated store
|
||||||
|
// churn (e.g. heap membership invalidation). visibleSequence is read
|
||||||
|
// through a ref at click time so scrolling doesn't rebind the
|
||||||
|
// double-click handler.
|
||||||
|
const visibleSequenceRef = useRef<string[]>([])
|
||||||
|
const handleCellClick = useCallback(
|
||||||
|
(photo: Photo, e: React.MouseEvent) => {
|
||||||
|
if (e.shiftKey) selectRange(photo.id)
|
||||||
|
else if (e.ctrlKey || e.metaKey) togglePhotoSelection(photo.id)
|
||||||
|
else selectPhoto(photo.id)
|
||||||
|
},
|
||||||
|
[selectRange, togglePhotoSelection, selectPhoto],
|
||||||
|
)
|
||||||
|
const handleCellDoubleClick = useCallback(
|
||||||
|
(photo: Photo) => {
|
||||||
|
openPreview(photo.id, visibleSequenceRef.current)
|
||||||
|
},
|
||||||
|
[openPreview],
|
||||||
|
)
|
||||||
|
|
||||||
// Build the flat virtualizer items: a mix of group headers and rows of
|
// Build the flat virtualizer items: a mix of group headers and rows of
|
||||||
// photos. Date headers appear only in the main timeline (groupBy='date').
|
// photos. Date headers appear only in the main timeline (groupBy='date').
|
||||||
const items = useMemo(
|
const items = useMemo(
|
||||||
@@ -498,6 +521,7 @@ export function Timeline() {
|
|||||||
// (e.g. the global Space hotkey).
|
// (e.g. the global Space hotkey).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVisiblePhotoIds(visibleSequence)
|
setVisiblePhotoIds(visibleSequence)
|
||||||
|
visibleSequenceRef.current = visibleSequence
|
||||||
}, [visibleSequence, setVisiblePhotoIds])
|
}, [visibleSequence, setVisiblePhotoIds])
|
||||||
|
|
||||||
// Locate the active photo in the visual grid. Returns the FIRST
|
// Locate the active photo in the visual grid. Returns the FIRST
|
||||||
@@ -660,11 +684,7 @@ export function Timeline() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (photos.length === 0) {
|
if (photos.length === 0) {
|
||||||
return (
|
return <EmptyTimelineState />
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<div className="text-text-muted">(╯°□°)╯︵ ┻━┻</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -788,16 +808,8 @@ export function Timeline() {
|
|||||||
fill
|
fill
|
||||||
isSelected={selectedPhotos.includes(photo.id)}
|
isSelected={selectedPhotos.includes(photo.id)}
|
||||||
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
||||||
onClick={(e) => {
|
onClick={handleCellClick}
|
||||||
if (e.shiftKey) {
|
onDoubleClick={handleCellDoubleClick}
|
||||||
selectRange(photo.id)
|
|
||||||
} else if (e.ctrlKey || e.metaKey) {
|
|
||||||
togglePhotoSelection(photo.id)
|
|
||||||
} else {
|
|
||||||
selectPhoto(photo.id)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDoubleClick={() => openPreview(photo.id, visibleSequence)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -809,3 +821,64 @@ export function Timeline() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty-state rendered when the current filter/section returns zero photos.
|
||||||
|
* Distinguishes "library-empty" from "filters-too-strict": the former hints
|
||||||
|
* at upload, the latter offers a one-click Clear all.
|
||||||
|
*/
|
||||||
|
function EmptyTimelineState() {
|
||||||
|
const filterState = useFilterStore()
|
||||||
|
const clearAll = useFilterStore((s) => s.clearAll)
|
||||||
|
const currentSection = useFilterStore((s) => s.currentSection)
|
||||||
|
const filtersActive = hasActiveFilters(filterState)
|
||||||
|
|
||||||
|
const { title, hint } = sectionEmptyCopy(currentSection, filtersActive)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
|
||||||
|
<ImageOff className="h-10 w-10 text-text-muted/40" />
|
||||||
|
<div className="text-sm font-medium text-text">{title}</div>
|
||||||
|
<p className="max-w-sm text-xs text-text-muted">{hint}</p>
|
||||||
|
{filtersActive && (
|
||||||
|
<Button variant="outline" size="sm" onClick={clearAll} className="mt-1">
|
||||||
|
Clear all filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionEmptyCopy(
|
||||||
|
section: string,
|
||||||
|
filtersActive: boolean,
|
||||||
|
): { title: string; hint: string } {
|
||||||
|
if (filtersActive) {
|
||||||
|
return {
|
||||||
|
title: 'No photos match',
|
||||||
|
hint: 'Your filters are excluding everything in this section. Clear them to see the full library.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (section) {
|
||||||
|
case 'discarded':
|
||||||
|
return {
|
||||||
|
title: 'Discard pile is empty',
|
||||||
|
hint: 'Photos you discard (X) land here until you empty them permanently.',
|
||||||
|
}
|
||||||
|
case 'rated':
|
||||||
|
return {
|
||||||
|
title: 'No rated photos yet',
|
||||||
|
hint: 'Rate photos 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.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
117
frontend/src/hooks/useBulkPhotoMutations.ts
Normal file
117
frontend/src/hooks/useBulkPhotoMutations.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useRef } from 'react'
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { usePhotoStore } from '../store/photoStore'
|
import { usePhotoStore } from '../store/photoStore'
|
||||||
@@ -6,6 +7,8 @@ import { HEAPS_QUERY_KEY } from './useHeapsQuery'
|
|||||||
import { toast } from '../components/ToastContainer'
|
import { toast } from '../components/ToastContainer'
|
||||||
import { registerUndoable, useUndoStore } from '../store/undoStore'
|
import { registerUndoable, useUndoStore } from '../store/undoStore'
|
||||||
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
|
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
|
||||||
|
import { useBulkPhotoMutations } from './useBulkPhotoMutations'
|
||||||
|
import { formatApiError } from '../lib/apiError'
|
||||||
import type { Photo } from '../types/photo'
|
import type { Photo } from '../types/photo'
|
||||||
|
|
||||||
interface KeyboardShortcutsProps {
|
interface KeyboardShortcutsProps {
|
||||||
@@ -57,25 +60,10 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const invalidatePhotoQueries = () => {
|
const {
|
||||||
queryClient.invalidateQueries({ queryKey: ['photo'] })
|
bulkRating: bulkRatingMutation,
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
bulkColor: bulkColorMutation,
|
||||||
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
} = useBulkPhotoMutations()
|
||||||
}
|
|
||||||
|
|
||||||
const bulkRatingMutation = useMutation({
|
|
||||||
mutationFn: ({ ids, rating }: { ids: string[]; rating: number }) =>
|
|
||||||
photosApi.bulkSetRating(ids, rating),
|
|
||||||
onSuccess: invalidatePhotoQueries,
|
|
||||||
onError: (e: any) => toast.error('Bulk rating failed', e.message || 'Unknown error'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const bulkColorMutation = useMutation({
|
|
||||||
mutationFn: ({ ids, color }: { ids: string[]; color: string | null }) =>
|
|
||||||
photosApi.bulkSetColor(ids, color),
|
|
||||||
onSuccess: invalidatePhotoQueries,
|
|
||||||
onError: (e: any) => toast.error('Bulk color failed', e.message || 'Unknown error'),
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Flip the cached photos to is_discarded=value in every list query
|
/** Flip the cached photos to is_discarded=value in every list query
|
||||||
* without removing them. Lets the grid grey them in place instead of
|
* without removing them. Lets the grid grey them in place instead of
|
||||||
@@ -123,7 +111,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
markCachedDiscarded(ids, !discarded)
|
markCachedDiscarded(ids, !discarded)
|
||||||
toast.error(
|
toast.error(
|
||||||
discarded ? 'Discard failed' : 'Restore failed',
|
discarded ? 'Discard failed' : 'Restore failed',
|
||||||
e?.message || 'Unknown error'
|
formatApiError(e)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onSuccess: (_data, { ids }) => {
|
onSuccess: (_data, { ids }) => {
|
||||||
@@ -134,6 +122,60 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Coalesced undo for rapid X (or U) presses. The mutation itself still
|
||||||
|
// fires per-press so the grayscale flip is instant; only the undo
|
||||||
|
// registration waits out COALESCE_MS so we emit one toast + one undo
|
||||||
|
// entry for a burst rather than N stacked ones.
|
||||||
|
const discardBatchRef = useRef<{
|
||||||
|
discarded: boolean
|
||||||
|
ids: Set<string>
|
||||||
|
timer: number | null
|
||||||
|
} | null>(null)
|
||||||
|
const DISCARD_COALESCE_MS = 1200
|
||||||
|
|
||||||
|
const flushDiscardBatch = () => {
|
||||||
|
const batch = discardBatchRef.current
|
||||||
|
if (!batch) return
|
||||||
|
discardBatchRef.current = null
|
||||||
|
const list = Array.from(batch.ids)
|
||||||
|
const discarded = batch.discarded
|
||||||
|
const verb = discarded ? 'Discarded' : 'Restored'
|
||||||
|
registerUndoable(
|
||||||
|
`${verb} ${list.length} photo${list.length === 1 ? '' : 's'}`,
|
||||||
|
async () => {
|
||||||
|
markCachedDiscarded(list, !discarded)
|
||||||
|
await (discarded
|
||||||
|
? photosApi.bulkRestore(list)
|
||||||
|
: photosApi.bulkDiscard(list))
|
||||||
|
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
|
||||||
|
list.forEach((id) =>
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photo', id] })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueDiscardUndo = (ids: string[], discarded: boolean) => {
|
||||||
|
const batch = discardBatchRef.current
|
||||||
|
if (batch && batch.discarded === discarded) {
|
||||||
|
if (batch.timer !== null) window.clearTimeout(batch.timer)
|
||||||
|
ids.forEach((id) => batch.ids.add(id))
|
||||||
|
batch.timer = window.setTimeout(flushDiscardBatch, DISCARD_COALESCE_MS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Flush any in-flight batch of the opposite direction first so the
|
||||||
|
// two actions stay independently undoable.
|
||||||
|
if (batch) {
|
||||||
|
if (batch.timer !== null) window.clearTimeout(batch.timer)
|
||||||
|
flushDiscardBatch()
|
||||||
|
}
|
||||||
|
discardBatchRef.current = {
|
||||||
|
discarded,
|
||||||
|
ids: new Set(ids),
|
||||||
|
timer: window.setTimeout(flushDiscardBatch, DISCARD_COALESCE_MS),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The set of photo ids the next culling action should apply to.
|
/** The set of photo ids the next culling action should apply to.
|
||||||
* - Multi-selection → all selected photos
|
* - Multi-selection → all selected photos
|
||||||
* - Single selection → that one photo
|
* - Single selection → that one photo
|
||||||
@@ -158,30 +200,15 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
// tint via the dedicated mutation (no cache strip, no timeline
|
// tint via the dedicated mutation (no cache strip, no timeline
|
||||||
// removal). They disappear on hard reload because the section
|
// removal). They disappear on hard reload because the section
|
||||||
// filter excludes them in the wrong direction.
|
// filter excludes them in the wrong direction.
|
||||||
|
//
|
||||||
|
// Rapid X presses coalesce into a single undo entry + one toast
|
||||||
|
// (see queueDiscardUndo below) so hammering the key doesn't stack
|
||||||
|
// five toasts and force five Cmd+Z to back out.
|
||||||
if (data.is_discarded === true || data.is_discarded === false) {
|
if (data.is_discarded === true || data.is_discarded === false) {
|
||||||
const discarded = data.is_discarded
|
const discarded = data.is_discarded
|
||||||
discardMutation.mutate(
|
discardMutation.mutate(
|
||||||
{ ids, discarded },
|
{ ids, discarded },
|
||||||
{
|
{ onSuccess: () => queueDiscardUndo(ids, discarded) }
|
||||||
onSuccess: () => {
|
|
||||||
const verb = discarded ? 'Discarded' : 'Restored'
|
|
||||||
registerUndoable(
|
|
||||||
`${verb} ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
|
|
||||||
async () => {
|
|
||||||
markCachedDiscarded(ids, !discarded)
|
|
||||||
await (discarded
|
|
||||||
? photosApi.bulkRestore(ids)
|
|
||||||
: photosApi.bulkDiscard(ids))
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: LIBRARY_STATS_QUERY_KEY,
|
|
||||||
})
|
|
||||||
ids.forEach((id) =>
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['photo', id] })
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -244,7 +271,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
if (ctx?.previous) {
|
if (ctx?.previous) {
|
||||||
queryClient.setQueryData(['heap-photo-ids', _vars.heapId], ctx.previous)
|
queryClient.setQueryData(['heap-photo-ids', _vars.heapId], ctx.previous)
|
||||||
}
|
}
|
||||||
toast.error('Heap update failed', e.message || 'Unknown error')
|
toast.error('Heap update failed', formatApiError(e))
|
||||||
},
|
},
|
||||||
onSuccess: (data, vars) => {
|
onSuccess: (data, vars) => {
|
||||||
const heap = (queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []).find(
|
const heap = (queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []).find(
|
||||||
@@ -324,7 +351,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
await entry.undo()
|
await entry.undo()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
useUndoStore.getState().push({ label: entry.label, undo: entry.undo })
|
useUndoStore.getState().push({ label: entry.label, undo: entry.undo })
|
||||||
toast.error('Undo failed', e?.message || 'Unknown error')
|
toast.error('Undo failed', formatApiError(e))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
HK_OPTS
|
HK_OPTS
|
||||||
|
|||||||
@@ -87,12 +87,19 @@ export function usePhotosQuery() {
|
|||||||
queryFn: async ({ signal }) => {
|
queryFn: async ({ signal }) => {
|
||||||
// Two-phase fetch using cursor-based (keyset) pagination.
|
// Two-phase fetch using cursor-based (keyset) pagination.
|
||||||
// Phase 1 returns the first page (resolves the useQuery promise
|
// Phase 1 returns the first page (resolves the useQuery promise
|
||||||
// so consumers exit loading state). Phase 2 chains cursors in
|
// so consumers exit loading state). Phase 2 chains a bounded
|
||||||
// the background — each response includes a `next_cursor` that
|
// background loop — each response includes a `next_cursor` that
|
||||||
// seeks directly to the next slice via an indexed range scan,
|
// seeks directly to the next slice via an indexed range scan,
|
||||||
// O(1) regardless of depth (no OFFSET skipping).
|
// O(1) regardless of depth (no OFFSET skipping).
|
||||||
|
//
|
||||||
|
// MAX_PAGES is intentionally modest: 20 × 500 = 10 000 photos
|
||||||
|
// covers almost every browsing session up-front without burning
|
||||||
|
// through a 100k library on cold load. If a user scrolls past
|
||||||
|
// that horizon we'll add an infinite-query trigger; for now the
|
||||||
|
// cap keeps cold-load memory / network pressure sane.
|
||||||
const PER_PAGE = 500
|
const PER_PAGE = 500
|
||||||
const MAX_PAGES = 200
|
const MAX_PAGES = 20
|
||||||
|
const INTER_PAGE_DELAY_MS = 50
|
||||||
|
|
||||||
const first = await fetchCursorPage(
|
const first = await fetchCursorPage(
|
||||||
{ per_page: PER_PAGE, ...filterParams },
|
{ per_page: PER_PAGE, ...filterParams },
|
||||||
@@ -119,6 +126,10 @@ export function usePhotosQuery() {
|
|||||||
(prev) => (prev ? [...prev, ...more] : more)
|
(prev) => (prev ? [...prev, ...more] : more)
|
||||||
)
|
)
|
||||||
if (!nextCursor || more.length < PER_PAGE) return
|
if (!nextCursor || more.length < PER_PAGE) return
|
||||||
|
// Yield a beat between pages so the main thread stays
|
||||||
|
// responsive (thumbnail decode, scroll handling) while
|
||||||
|
// we're back-filling in the background.
|
||||||
|
await new Promise((r) => setTimeout(r, INTER_PAGE_DELAY_MS))
|
||||||
} catch {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
38
frontend/src/lib/apiError.ts
Normal file
38
frontend/src/lib/apiError.ts
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user