Lets operators fix corrupted capture dates at scale. Adds an editable Date Taken field with a folder/filename-derived suggestion hint, a bulk Date Taken section in the multi-select sidebar that either applies one date to the whole selection or infers a per-photo date from each path, a warning badge on thumbnails whose stored date disagrees with the path, and a "Date issues" filter pill so suspicious photos can be surfaced and fixed as a group. Edits are written back to EXIF on disk so rescans don't clobber the fix. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
721 lines
26 KiB
TypeScript
721 lines
26 KiB
TypeScript
import { useState } from 'react'
|
|
import { X, Star, ShoppingBasket, Trash2, Plus, PanelRightClose } 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,
|
|
heaps as heapsApi,
|
|
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 { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
|
|
|
/**
|
|
* Right-hand details panel.
|
|
* - 1 photo selected → delegates to PhotoInfoPanel for the full editor.
|
|
* - 2+ photos selected → renders a slim bulk-action panel that fans out
|
|
* rating / color / discard / pick across the entire selection.
|
|
*/
|
|
interface RightSidebarProps {
|
|
onCollapse: () => void
|
|
}
|
|
|
|
export function RightSidebar({ onCollapse }: RightSidebarProps) {
|
|
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 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
|
|
// so the grid reflows immediately. Same pattern as the X hotkey
|
|
// path in useKeyboardShortcuts.
|
|
onMutate: (ids) => {
|
|
usePhotoStore.getState().removePhotosFromTimeline(ids)
|
|
stripPhotosFromCache(queryClient, ids)
|
|
},
|
|
onSuccess: invalidatePhotoQueries,
|
|
})
|
|
|
|
// Shared report-and-invalidate tail for both bulk taken_at mutations.
|
|
// They return a partial-apply shape (updated/skipped/errors) because
|
|
// EXIF writes can fail per-photo (unsupported format, missing file)
|
|
// without wrecking the rest of the batch.
|
|
const reportBulkTakenAt = (
|
|
data: {
|
|
status: string
|
|
updated: number
|
|
skipped: number
|
|
errors: { id: string; message: string }[]
|
|
},
|
|
) => {
|
|
const errCount = data.errors?.length ?? 0
|
|
const detail =
|
|
errCount > 0
|
|
? `${data.updated} updated · ${errCount} error${errCount === 1 ? '' : 's'}`
|
|
: `${data.updated} updated`
|
|
if (errCount > 0) {
|
|
toast.error('Date update partial', detail)
|
|
} else {
|
|
toast.success('Dates updated', detail)
|
|
}
|
|
invalidatePhotoQueries()
|
|
}
|
|
|
|
const bulkTakenAtMutation = useMutation({
|
|
mutationFn: ({ ids, iso }: { ids: string[]; iso: string }) =>
|
|
photosApi.bulkSetTakenAt(ids, iso),
|
|
onSuccess: reportBulkTakenAt,
|
|
onError: (e: any) =>
|
|
toast.error('Date update failed', e?.message || 'Unknown error'),
|
|
})
|
|
|
|
const bulkTakenAtMapMutation = useMutation({
|
|
mutationFn: (map: Record<string, string>) =>
|
|
photosApi.bulkSetTakenAtMap(map),
|
|
onSuccess: reportBulkTakenAt,
|
|
onError: (e: any) =>
|
|
toast.error('Date update failed', e?.message || 'Unknown error'),
|
|
})
|
|
|
|
// Bulk tag mutations. Tag mutations also need to invalidate the tags
|
|
// query so the FilterBar / sidebar tag counts stay fresh.
|
|
const invalidateTagsAndPhotos = () => {
|
|
invalidatePhotoQueries()
|
|
queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY })
|
|
}
|
|
const bulkAddTagsMutation = useMutation({
|
|
mutationFn: ({ ids, tagIds }: { ids: string[]; tagIds: string[] }) =>
|
|
photosApi.bulkAddTags(ids, tagIds),
|
|
onSuccess: (data) => {
|
|
const added = data?.added ?? 0
|
|
toast.success(
|
|
'Tags added',
|
|
`${added} new link${added === 1 ? '' : 's'}`
|
|
)
|
|
invalidateTagsAndPhotos()
|
|
},
|
|
onError: (e: any) =>
|
|
toast.error('Add tags failed', e?.message || 'Unknown error'),
|
|
})
|
|
const bulkRemoveTagsMutation = useMutation({
|
|
mutationFn: ({ ids, tagIds }: { ids: string[]; tagIds: string[] }) =>
|
|
photosApi.bulkRemoveTags(ids, tagIds),
|
|
onSuccess: (data) => {
|
|
const removed = data?.removed ?? 0
|
|
toast.success(
|
|
'Tags removed',
|
|
`${removed} link${removed === 1 ? '' : 's'} removed`
|
|
)
|
|
invalidateTagsAndPhotos()
|
|
},
|
|
onError: (e: any) =>
|
|
toast.error('Remove tags failed', e?.message || 'Unknown error'),
|
|
})
|
|
|
|
// Idempotent create-and-attach: lets the user type a brand-new tag
|
|
// name and apply it to the whole selection in one click.
|
|
const createAndAttachMutation = useMutation({
|
|
mutationFn: async ({ name, ids }: { name: string; ids: string[] }) => {
|
|
const created = await tagsApi.create(name)
|
|
return photosApi.bulkAddTags(ids, [created.id])
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Tag created and applied')
|
|
invalidateTagsAndPhotos()
|
|
},
|
|
onError: (e: any) =>
|
|
toast.error('Create tag failed', e?.message || 'Unknown error'),
|
|
})
|
|
|
|
const { data: allTags = [] } = useTagsQuery()
|
|
const [tagInput, setTagInput] = useState('')
|
|
|
|
// Active heap membership for the bulk Pick toggle.
|
|
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
|
|
|
|
const heapMutation = useMutation({
|
|
mutationFn: ({ ids, remove }: { ids: string[]; remove: boolean }) => {
|
|
if (!activeHeap || ids.length === 0) return Promise.resolve(null)
|
|
return remove
|
|
? heapsApi.removePhotos(activeHeap.id, ids)
|
|
: heapsApi.addPhotos(activeHeap.id, ids)
|
|
},
|
|
onMutate: ({ ids, remove }) => {
|
|
if (!activeHeap || ids.length === 0) return { previous: undefined }
|
|
const key = ['heap-photo-ids', activeHeap.id] as const
|
|
const previous = queryClient.getQueryData<string[]>(key)
|
|
const set = new Set(previous ?? [])
|
|
if (remove) ids.forEach((id) => set.delete(id))
|
|
else ids.forEach((id) => set.add(id))
|
|
queryClient.setQueryData<string[]>(key, Array.from(set))
|
|
return { previous }
|
|
},
|
|
onError: (e: any, _vars, ctx) => {
|
|
if (activeHeap && ctx?.previous) {
|
|
queryClient.setQueryData(['heap-photo-ids', activeHeap.id], ctx.previous)
|
|
}
|
|
toast.error('Heap update failed', e?.message || 'Unknown error')
|
|
},
|
|
onSettled: () => {
|
|
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
|
if (activeHeap) {
|
|
queryClient.invalidateQueries({
|
|
queryKey: ['heap-photo-ids', activeHeap.id],
|
|
})
|
|
}
|
|
},
|
|
})
|
|
|
|
// Unified header rendered in every branch so the collapse button is
|
|
// always reachable regardless of selection state. Title and the
|
|
// clear-selection X adapt to what's selected.
|
|
const headerTitle =
|
|
selectedPhotos.length === 0
|
|
? 'Metadata'
|
|
: selectedPhotos.length === 1
|
|
? 'Metadata'
|
|
: `${selectedPhotos.length} Photos Selected`
|
|
|
|
const Header = () => (
|
|
<div className="flex h-9 flex-shrink-0 items-center justify-between border-b border-border px-3">
|
|
<h2 className="text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted">
|
|
{headerTitle}
|
|
</h2>
|
|
<div className="flex items-center gap-0.5">
|
|
{selectedPhotos.length > 0 && (
|
|
<button
|
|
onClick={clearSelection}
|
|
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
|
title="Clear selection (Esc)"
|
|
aria-label="Clear selection"
|
|
>
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={onCollapse}
|
|
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
|
title="Collapse panel (I)"
|
|
aria-label="Collapse panel"
|
|
>
|
|
<PanelRightClose className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
if (selectedPhotos.length === 0) {
|
|
return (
|
|
<div className="flex h-full flex-col bg-surface">
|
|
<Header />
|
|
<div className="flex flex-1 items-center justify-center p-4 text-center">
|
|
<div className="text-text-muted">
|
|
<pre className="mx-auto mb-3 text-[6px] leading-[6px] opacity-30">{`⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⣿⡇⠀⣠⣤⠀⠀⠀⠀⠀
|
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⣿⡇⢸⣿⡟⠀⠀⠀⠀⠀
|
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⣿⣿⡿⠀⣿⡿⠁⠀⠀⠀⠀⠀
|
|
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⠃⣼⡟⠁⠀⠀⠀⠀⠀⠀
|
|
⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⣀⣤⣶⣿⣿⣿⣧⣀⠋⠀⠀⠀⠀⠀⠀⠀
|
|
⠀⠀⠀⠀⠀⠀⠠⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡄⠀⠀⠀⠀⠀⠀
|
|
⠀⠀⠀⢀⡀⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⠛⠻⣿⣿⡄⠀⠀⠀⠀⠀
|
|
⠀⠀⢠⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣿⣿⣷⡀⠀⠀⠀⠀⠀
|
|
⣠⣤⣿⣿⣿⣿⣿⣿⣿⣿⡿⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⡀⠀⠀⠀
|
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⠘⢿⣿⣿⣿⣿⣿⣿⣿⢿⡆⠀⠀
|
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠁⠀⠙⠛⠿⠿⣿⣿⠿⣾⡇⠀⠀
|
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠁⠀⠀⠀⠀⠀⠀⠹⣿⡿⣦⠈⠁⠀⠀
|
|
⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
⠛⠛⠛⠛⠛⠛⠛⠛⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀`}</pre>
|
|
<p className="text-sm">Select photos to view details</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Single-photo: full editor via PhotoInfoPanel ────────────────────
|
|
if (selectedPhotos.length === 1) {
|
|
const id = activePhotoId ?? selectedPhotos[0]
|
|
return (
|
|
<div className="flex h-full flex-col bg-surface">
|
|
<Header />
|
|
<PhotoInfoPanel photoId={id} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Multi-photo: bulk action panel ──────────────────────────────────
|
|
const allMembers = selectedPhotos.every((id) => activeHeapMembers.has(id))
|
|
|
|
/** Walk the react-query cache for every selected id and return the
|
|
* full Photo records. Checks the standalone `['photo', id]` entry
|
|
* first (populated whenever a single-photo view or preview opens),
|
|
* then falls back to scanning every cached timeline list for the
|
|
* id. Any id with no cached record is skipped — the selection UI
|
|
* can't act on a photo the user hasn't loaded yet anyway. */
|
|
const collectSelectedPhotos = (): Photo[] => {
|
|
const out: Photo[] = []
|
|
const seen = new Set<string>()
|
|
for (const id of selectedPhotos) {
|
|
if (seen.has(id)) continue
|
|
const direct = queryClient.getQueryData<Photo>(['photo', id])
|
|
if (direct) {
|
|
out.push(direct)
|
|
seen.add(id)
|
|
continue
|
|
}
|
|
const lists = queryClient.getQueriesData<Photo[]>({ queryKey: ['photos'] })
|
|
for (const [, list] of lists) {
|
|
if (!list) continue
|
|
const hit = list.find((p) => p.id === id)
|
|
if (hit) {
|
|
out.push(hit)
|
|
seen.add(id)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col bg-surface">
|
|
<Header />
|
|
|
|
<div className="space-y-2.5 border-b border-border p-3">
|
|
<p className="text-[11px] text-text-muted">
|
|
Rating, color, and flag apply to all {selectedPhotos.length} selected.
|
|
</p>
|
|
|
|
{/* Bulk rating */}
|
|
<div>
|
|
<label className="mb-1 block text-xs text-text-muted">Rating</label>
|
|
<div className="flex gap-1">
|
|
{[1, 2, 3, 4, 5].map((value) => (
|
|
<button
|
|
key={value}
|
|
onClick={() =>
|
|
bulkRatingMutation.mutate({ ids: selectedPhotos, rating: value })
|
|
}
|
|
className="p-0.5"
|
|
title={`Set rating to ${value}`}
|
|
>
|
|
<Star className="h-5 w-5 text-text-muted hover:text-star" />
|
|
</button>
|
|
))}
|
|
<button
|
|
onClick={() =>
|
|
bulkRatingMutation.mutate({ ids: selectedPhotos, rating: 0 })
|
|
}
|
|
className="ml-1 rounded px-1 text-xs text-text-muted hover:text-text"
|
|
title="Clear rating"
|
|
>
|
|
clear
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bulk color */}
|
|
<div>
|
|
<label className="mb-1 block text-xs text-text-muted">Color label</label>
|
|
<div className="flex items-center gap-1.5">
|
|
{COLOR_LABEL_OPTIONS.map(({ value, className }) => (
|
|
<button
|
|
key={value}
|
|
onClick={() =>
|
|
bulkColorMutation.mutate({ ids: selectedPhotos, color: value })
|
|
}
|
|
className={clsx(
|
|
'h-5 w-5 rounded-full opacity-80 ring-offset-2 ring-offset-surface transition-all hover:opacity-100',
|
|
className
|
|
)}
|
|
title={value}
|
|
/>
|
|
))}
|
|
<button
|
|
onClick={() =>
|
|
bulkColorMutation.mutate({ ids: selectedPhotos, color: null })
|
|
}
|
|
className="ml-1 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
|
title="Clear color label"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bulk flag */}
|
|
<div>
|
|
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => {
|
|
if (!activeHeap) return
|
|
heapMutation.mutate({ ids: selectedPhotos, remove: allMembers })
|
|
}}
|
|
disabled={!activeHeap || heapMutation.isPending}
|
|
className={clsx(
|
|
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
|
allMembers
|
|
? 'bg-pick/20 text-pick'
|
|
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
|
)}
|
|
title={
|
|
activeHeap
|
|
? allMembers
|
|
? `Remove all from "${activeHeap.name}"`
|
|
: `Add all to "${activeHeap.name}"`
|
|
: 'Set an active heap first'
|
|
}
|
|
>
|
|
<ShoppingBasket className="h-3 w-3" />
|
|
{allMembers ? 'Picked' : 'Pick'}
|
|
</button>
|
|
<button
|
|
onClick={() => bulkDiscardMutation.mutate(selectedPhotos)}
|
|
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-1 text-sm text-text-muted transition-colors hover:bg-surface-offset"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
Discard
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bulk tags. Click an existing tag chip to apply it to the
|
|
* whole selection; long-press / X icon to remove. The text
|
|
* input adds an existing tag if it matches a name, or creates
|
|
* a new tag and applies it. */}
|
|
<div>
|
|
<label className="mb-1 block text-xs text-text-muted">Tags</label>
|
|
<BulkTagsEditor
|
|
allTags={allTags}
|
|
tagInput={tagInput}
|
|
onTagInputChange={setTagInput}
|
|
disabled={
|
|
bulkAddTagsMutation.isPending ||
|
|
bulkRemoveTagsMutation.isPending ||
|
|
createAndAttachMutation.isPending
|
|
}
|
|
onApply={(tagId) =>
|
|
bulkAddTagsMutation.mutate({ ids: selectedPhotos, tagIds: [tagId] })
|
|
}
|
|
onRemove={(tagId) =>
|
|
bulkRemoveTagsMutation.mutate({
|
|
ids: selectedPhotos,
|
|
tagIds: [tagId],
|
|
})
|
|
}
|
|
onCreate={(name) => {
|
|
createAndAttachMutation.mutate({ name, ids: selectedPhotos })
|
|
setTagInput('')
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Bulk Date Taken — lets an operator repair the capture date on
|
|
* a whole selection at once, either by applying one date to
|
|
* everything or by inferring a per-photo date from each file's
|
|
* folder path and filename. Useful for cameras that lost their
|
|
* clock (1970 epoch) and for legacy libraries where the folder
|
|
* structure is the only trustworthy date signal. */}
|
|
<div>
|
|
<label className="mb-1 block text-xs text-text-muted">Date Taken</label>
|
|
<BulkTakenAtEditor
|
|
disabled={
|
|
bulkTakenAtMutation.isPending || bulkTakenAtMapMutation.isPending
|
|
}
|
|
selectedCount={selectedPhotos.length}
|
|
collectPhotos={collectSelectedPhotos}
|
|
onApplyUniform={(iso) =>
|
|
bulkTakenAtMutation.mutate({ ids: selectedPhotos, iso })
|
|
}
|
|
onApplyMap={(map) => bulkTakenAtMapMutation.mutate(map)}
|
|
/>
|
|
</div>
|
|
</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="flex-1 rounded border border-border bg-bg px-2 py-1 text-xs text-text focus:border-primary focus:outline-none disabled:opacity-50"
|
|
/>
|
|
<button
|
|
onClick={handleApplyUniform}
|
|
disabled={disabled || !uniformDraft}
|
|
className="rounded bg-primary/20 px-2 py-1 text-xs text-primary hover:bg-primary/30 disabled:cursor-not-allowed disabled:opacity-40"
|
|
title={`Apply this date to all ${selectedCount} selected`}
|
|
>
|
|
Apply
|
|
</button>
|
|
</div>
|
|
|
|
{/* Guess-from-path preview */}
|
|
{preview === null ? (
|
|
<button
|
|
onClick={handleGuess}
|
|
disabled={disabled}
|
|
className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-primary/50 px-2 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
|
|
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
|
|
onClick={handleApplyPreview}
|
|
disabled={disabled || preview.hits.length === 0}
|
|
className="flex-1 rounded bg-primary/20 px-2 py-1 text-xs text-primary hover:bg-primary/30 disabled:cursor-not-allowed disabled:opacity-40"
|
|
>
|
|
Apply {preview.hits.length}
|
|
</button>
|
|
<button
|
|
onClick={() => setPreview(null)}
|
|
disabled={disabled}
|
|
className="rounded bg-surface-2 px-2 py-1 text-xs text-text-muted hover:bg-surface-offset disabled:opacity-50"
|
|
>
|
|
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="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text placeholder-text-faint focus:border-primary focus:outline-none disabled:opacity-50"
|
|
/>
|
|
|
|
{trimmed && !exactMatch && (
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={disabled}
|
|
className="flex w-full items-center justify-center gap-1 rounded border border-dashed border-primary/50 px-2 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
|
|
>
|
|
<Plus className="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>
|
|
)
|
|
}
|