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