Compare commits
4 Commits
7efac4354e
...
744a7fa0c3
| Author | SHA1 | Date | |
|---|---|---|---|
| 744a7fa0c3 | |||
| 967cf23b82 | |||
| a073ee7fb9 | |||
| e65e798021 |
@@ -59,6 +59,34 @@ class SharedFolderResponse(BaseModel):
|
||||
photo_count: int
|
||||
|
||||
|
||||
class ShareableUser(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
|
||||
|
||||
# ── Shareable users ──────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/users", response_model=list[ShareableUser])
|
||||
async def list_shareable_users(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""List every active user other than the caller, for the share-picker
|
||||
dropdown. Sharing only requires knowing a username today, so surfacing
|
||||
the list is no wider an attack surface than the free-text input it
|
||||
replaces. Inactive accounts are filtered out."""
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.id != current_user.id)
|
||||
.where(User.is_active.is_(True))
|
||||
.order_by(User.username)
|
||||
)
|
||||
return [
|
||||
ShareableUser(id=str(u.id), username=u.username)
|
||||
for u in result.scalars().all()
|
||||
]
|
||||
|
||||
|
||||
# ── Heap sharing ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/heaps/shared-with-me")
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -105,11 +105,7 @@ export function UserManagement() {
|
||||
</td>
|
||||
<td className="py-1.5 pr-4">
|
||||
<span
|
||||
className={
|
||||
u.is_active
|
||||
? 'text-green-400'
|
||||
: 'text-red-400'
|
||||
}
|
||||
className={u.is_active ? 'text-pick' : 'text-reject'}
|
||||
>
|
||||
{u.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
@@ -129,7 +125,7 @@ export function UserManagement() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 hover:text-red-400"
|
||||
className="h-6 w-6 hover:text-reject"
|
||||
onClick={() => setDeactivatingUser(u)}
|
||||
title="Deactivate user"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { Palette, ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { photos as photosApi } from '../../services/api'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||
@@ -149,7 +150,7 @@ export function ColorsView() {
|
||||
{groups.map((group, i) => (
|
||||
<div
|
||||
key={group.label}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
|
||||
i === activeIndex
|
||||
? 'border-primary ring-1 ring-primary/30'
|
||||
@@ -170,9 +171,12 @@ export function ColorsView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="absolute bottom-1.5 right-1.5 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm">
|
||||
<Badge
|
||||
variant="overlay"
|
||||
className="absolute bottom-1.5 right-1.5"
|
||||
>
|
||||
{group.count}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Trash2, Archive } from 'lucide-react'
|
||||
|
||||
import {
|
||||
@@ -124,7 +124,7 @@ function ModeCard({
|
||||
return (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'flex w-full cursor-pointer gap-3 rounded-lg border p-3 text-left transition-colors',
|
||||
selected
|
||||
? destructive
|
||||
@@ -135,7 +135,7 @@ function ModeCard({
|
||||
>
|
||||
<RadioGroupItem id={id} value={value} className="mt-0.5" />
|
||||
<div
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'mt-0.5 flex-shrink-0',
|
||||
selected
|
||||
? destructive
|
||||
@@ -148,7 +148,7 @@ function ModeCard({
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'text-sm font-medium',
|
||||
selected
|
||||
? destructive
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
Brain,
|
||||
RotateCcw,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
library,
|
||||
@@ -631,7 +631,7 @@ export function SettingsPage() {
|
||||
{Object.entries(workerStatus.queues).map(([name, depth]) => (
|
||||
<div
|
||||
key={name}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded px-2 py-1',
|
||||
name === 'vision' && depth > 0
|
||||
? 'bg-surface-2'
|
||||
@@ -640,7 +640,7 @@ export function SettingsPage() {
|
||||
>
|
||||
<span className="text-text-muted">{name}</span>
|
||||
<span
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'font-mono font-semibold',
|
||||
depth > 0 ? 'text-text' : 'text-text-muted'
|
||||
)}
|
||||
@@ -924,7 +924,7 @@ function Stat({
|
||||
<div className="text-[10px] uppercase tracking-wide text-text-muted">
|
||||
{label}
|
||||
</div>
|
||||
<div className={clsx('text-base font-semibold', toneClass)}>
|
||||
<div className={cn('text-base font-semibold', toneClass)}>
|
||||
{value ?? '—'}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1016,7 +1016,7 @@ function PipelineRow({ stage }: { stage: PipelineStage }) {
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'shrink-0 font-mono text-[11px]',
|
||||
isComplete
|
||||
? 'text-pick'
|
||||
@@ -1045,7 +1045,7 @@ function ProgressBar({ done, total }: { done: number; total: number }) {
|
||||
return (
|
||||
<div className="h-1 w-full overflow-hidden rounded bg-border">
|
||||
<div
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'h-full rounded transition-[width] duration-500',
|
||||
complete ? 'bg-pick' : 'bg-text-muted'
|
||||
)}
|
||||
@@ -1072,7 +1072,7 @@ function ActionButton({
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
destructive
|
||||
? 'border-reject/40 text-reject hover:bg-reject/10'
|
||||
@@ -1190,7 +1190,7 @@ function AiFeaturesTab({ busy, runAction }: AiFeaturesTabProps) {
|
||||
return (
|
||||
<div
|
||||
key={meta.id}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'rounded border border-border bg-surface p-3 text-xs',
|
||||
dimmed && 'opacity-60',
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMemo, useState, useEffect, useCallback } from 'react'
|
||||
import { Copy, Layers, Sparkles, Trash2, Loader2, Info, Crown } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { cn } from '@/lib/utils'
|
||||
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
|
||||
@@ -322,7 +323,7 @@ function DuplicateGroupSection({
|
||||
* clearance from PhotoThumbnail's outer selection ring. */}
|
||||
{isBest && (
|
||||
<span
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'pointer-events-none absolute right-1.5 top-1.5 z-10 uppercase',
|
||||
THUMB_BADGE_BASE,
|
||||
THUMB_BADGE_PICK
|
||||
@@ -342,7 +343,7 @@ function DuplicateGroupSection({
|
||||
e.stopPropagation()
|
||||
setManualBestId(member.id)
|
||||
}}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'absolute right-1.5 top-1.5 z-10 hidden uppercase transition hover:bg-pick group-hover/dup:inline-flex',
|
||||
THUMB_BADGE_BASE,
|
||||
THUMB_BADGE_NEUTRAL
|
||||
@@ -358,7 +359,7 @@ function DuplicateGroupSection({
|
||||
* (also bottom-left) is tolerated: rated duplicates are
|
||||
* uncommon in practice. */}
|
||||
<div
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'pointer-events-none absolute bottom-1 left-1 z-10 font-mono',
|
||||
THUMB_BADGE_BASE,
|
||||
THUMB_BADGE_NEUTRAL
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
PanelRightOpen,
|
||||
PanelRightClose,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
useFilterStore,
|
||||
hasActiveFilters,
|
||||
@@ -246,7 +246,7 @@ export function FilterBar({
|
||||
title={`At least ${n} star${n > 1 ? 's' : ''}`}
|
||||
>
|
||||
<Star
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'h-5 w-5 transition-colors',
|
||||
n <= ratingMin
|
||||
? 'fill-star text-star'
|
||||
@@ -273,7 +273,7 @@ export function FilterBar({
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setColorLabel(active ? null : value)}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'h-5 w-5 rounded-full ring-offset-2 ring-offset-surface transition-all',
|
||||
className,
|
||||
active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChevronDown, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -49,7 +49,7 @@ export function FilterPill({
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
title={isActive && value ? `${label}: ${value}` : label}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
// Fixed height + py-0 so neither the X clear icon nor the
|
||||
// chevron can stretch the pill vertically when the active
|
||||
// state swaps them in.
|
||||
@@ -59,7 +59,7 @@ export function FilterPill({
|
||||
: 'border-border bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
||||
)}
|
||||
>
|
||||
<span className={clsx(isActive && 'font-medium')}>{label}</span>
|
||||
<span className={cn(isActive && 'font-medium')}>{label}</span>
|
||||
{isActive && onClear ? (
|
||||
<span
|
||||
role="button"
|
||||
@@ -90,7 +90,7 @@ export function FilterPill({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className={clsx('w-auto', contentClassName ?? 'p-2')}
|
||||
className={cn('w-auto', contentClassName ?? 'p-2')}
|
||||
>
|
||||
{children}
|
||||
</PopoverContent>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Users,
|
||||
Download as DownloadIcon,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useHeapsQuery, HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||
import { useSharedHeapsQuery } from '../../hooks/useSharingQueries'
|
||||
@@ -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)
|
||||
@@ -261,7 +262,7 @@ export function HeapsPanel() {
|
||||
return (
|
||||
<div
|
||||
key={heap.id}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'group relative flex h-[24px] cursor-pointer items-center gap-1 rounded px-2 text-[12px] leading-none',
|
||||
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
||||
// Active-heap row gets a soft primary wash so the
|
||||
@@ -308,7 +309,7 @@ export function HeapsPanel() {
|
||||
}}
|
||||
>
|
||||
<ShoppingBasket
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 flex-shrink-0',
|
||||
isActive || isFiltered ? 'text-primary' : 'text-text-muted'
|
||||
)}
|
||||
@@ -334,7 +335,7 @@ export function HeapsPanel() {
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className={clsx('truncate', isActive && 'font-semibold')}
|
||||
className={cn('truncate', isActive && 'font-semibold')}
|
||||
title={heap.name}
|
||||
>
|
||||
{heap.name}
|
||||
@@ -386,7 +387,7 @@ export function HeapsPanel() {
|
||||
onOpenChange={(o) => setOpenMenuId(o ? heap.id : null)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'relative flex-shrink-0',
|
||||
isMenuOpen ? 'block' : 'hidden group-hover:block'
|
||||
)}
|
||||
@@ -473,7 +474,7 @@ export function HeapsPanel() {
|
||||
return (
|
||||
<div
|
||||
key={sh.id}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'group flex h-[24px] cursor-pointer items-center gap-1 rounded px-2 text-[12px] leading-none',
|
||||
isFiltered ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
||||
)}
|
||||
@@ -483,7 +484,7 @@ export function HeapsPanel() {
|
||||
}
|
||||
>
|
||||
<Users
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 flex-shrink-0',
|
||||
isFiltered ? 'text-primary' : 'text-text-muted'
|
||||
)}
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
Upload as UploadIcon,
|
||||
Download as DownloadIcon,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { sourceFolders, photos as photosApi, downloads, type FolderTreeNode } from '../../services/api'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from '../ToastContainer'
|
||||
@@ -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) => {
|
||||
@@ -486,11 +487,11 @@ export function LeftSidebar() {
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<div
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'group flex cursor-pointer items-center gap-1',
|
||||
isSectionHeader
|
||||
? 'mt-2 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:text-text'
|
||||
: clsx(
|
||||
: cn(
|
||||
// Fixed h-[24px] (not min-h) locks the row height so the
|
||||
// hover-only kebab button can't grow the row vertically.
|
||||
'h-[24px] rounded px-2 text-[12px] leading-none',
|
||||
@@ -576,7 +577,7 @@ export function LeftSidebar() {
|
||||
* without hunting through the kebab menu. */}
|
||||
{item.icon && !isSectionHeader && (
|
||||
<span
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'flex-shrink-0',
|
||||
isSelected
|
||||
? 'text-primary'
|
||||
@@ -618,7 +619,7 @@ export function LeftSidebar() {
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'flex-1 truncate',
|
||||
item.isHidden && !isSelected && 'italic text-text-muted/80'
|
||||
)}
|
||||
@@ -676,7 +677,7 @@ export function LeftSidebar() {
|
||||
onOpenChange={(o) => setOpenMenuId(o ? item.id : null)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'relative flex-shrink-0',
|
||||
isMenuOpen ? 'block' : 'hidden group-hover:block'
|
||||
)}
|
||||
@@ -870,7 +871,7 @@ export function LeftSidebar() {
|
||||
return (
|
||||
<div
|
||||
key={sf.id}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'flex h-[24px] cursor-pointer items-center gap-1 rounded px-2 text-[12px] leading-none',
|
||||
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
||||
)}
|
||||
@@ -880,7 +881,7 @@ export function LeftSidebar() {
|
||||
}
|
||||
>
|
||||
<Users
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 flex-shrink-0',
|
||||
isSelected ? 'text-primary' : 'text-text-muted'
|
||||
)}
|
||||
@@ -921,7 +922,7 @@ export function LeftSidebar() {
|
||||
)}
|
||||
<button
|
||||
onClick={logout}
|
||||
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-red-400 flex-shrink-0"
|
||||
className="rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-reject flex-shrink-0"
|
||||
title="Sign out"
|
||||
>
|
||||
<LogOut className="h-3 w-3" />
|
||||
@@ -932,7 +933,7 @@ export function LeftSidebar() {
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => navigateToSection('settings', {})}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded px-2 py-1 text-[12px] hover:bg-surface-2 hover:text-text',
|
||||
currentSection === 'settings' ? 'text-primary' : 'text-text-muted',
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { X, Star, ShoppingBasket, Trash2, Plus } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { X, Star, ShoppingBasket, Trash2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
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 })
|
||||
@@ -228,7 +215,11 @@ export function RightSidebar() {
|
||||
|
||||
if (selectedPhotos.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-surface">
|
||||
<div
|
||||
className="flex h-full flex-col bg-surface"
|
||||
role="region"
|
||||
aria-label="Photo metadata"
|
||||
>
|
||||
<Header />
|
||||
<div className="flex flex-1 items-center justify-center p-4 text-center">
|
||||
<div className="text-text-muted">
|
||||
@@ -258,7 +249,11 @@ export function RightSidebar() {
|
||||
if (selectedPhotos.length === 1) {
|
||||
const id = activePhotoId ?? selectedPhotos[0]
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-surface">
|
||||
<div
|
||||
className="flex h-full flex-col bg-surface"
|
||||
role="region"
|
||||
aria-label="Photo metadata"
|
||||
>
|
||||
<Header />
|
||||
<PhotoInfoPanel photoId={id} />
|
||||
</div>
|
||||
@@ -346,7 +341,7 @@ export function RightSidebar() {
|
||||
onClick={() =>
|
||||
bulkColorMutation.mutate({ ids: selectedPhotos, color: value })
|
||||
}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'h-5 w-5 rounded-full opacity-80 ring-offset-2 ring-offset-surface transition-all hover:opacity-100',
|
||||
className
|
||||
)}
|
||||
@@ -376,7 +371,7 @@ export function RightSidebar() {
|
||||
heapMutation.mutate({ ids: selectedPhotos, remove: allMembers })
|
||||
}}
|
||||
disabled={!activeHeap || heapMutation.isPending}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
allMembers
|
||||
? 'bg-pick/20 text-pick hover:bg-pick/30'
|
||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
||||
@@ -459,265 +454,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,70 +1,162 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { photos, photos as photosApi } from '../../services/api'
|
||||
import type { MemoryGroup } from '../../services/api'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import {
|
||||
photos,
|
||||
type MemoriesResponse,
|
||||
type MemoryPhoto,
|
||||
} from '../../services/api'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { PhotoThumbnail } from '../timeline/PhotoThumbnail'
|
||||
|
||||
const CELL_SIZE = 180
|
||||
const GAP = 4
|
||||
|
||||
/**
|
||||
* "On this day" view. Same visual + interaction grid as Timeline —
|
||||
* PhotoThumbnail cells wired up to the shared photo store, so selection,
|
||||
* heap membership, preview (double-click / Enter), and drag-to-heap all
|
||||
* work identically. Just grouped by year instead of month, and sourced
|
||||
* from the /memories endpoint.
|
||||
*/
|
||||
export function MemoriesView() {
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading } = useQuery<MemoriesResponse>({
|
||||
queryKey: ['memories'],
|
||||
queryFn: () => photos.memories(),
|
||||
staleTime: 60_000 * 30, // 30 min — date doesn't change often
|
||||
queryFn: photos.memories,
|
||||
staleTime: 60_000 * 30,
|
||||
})
|
||||
|
||||
const memories = data?.memories ?? []
|
||||
|
||||
// Flat ordered sequence of ids across every year section — fed to
|
||||
// the store so preview arrow nav walks the same order the user sees.
|
||||
const visibleSequence = useMemo(
|
||||
() => memories.flatMap((g) => g.photos.map((p) => p.id)),
|
||||
[memories],
|
||||
)
|
||||
const setVisiblePhotoIds = usePhotoStore((s) => s.setVisiblePhotoIds)
|
||||
useEffect(() => {
|
||||
setVisiblePhotoIds(visibleSequence)
|
||||
}, [visibleSequence, setVisiblePhotoIds])
|
||||
|
||||
const selectedPhotos = usePhotoStore((s) => s.selectedPhotos)
|
||||
const selectPhoto = usePhotoStore((s) => s.selectPhoto)
|
||||
const togglePhotoSelection = usePhotoStore((s) => s.togglePhotoSelection)
|
||||
const selectRange = usePhotoStore((s) => s.selectRange)
|
||||
const openPreview = usePhotoStore((s) => s.openPreview)
|
||||
const searchQuery = useFilterStore((s) => s.q)
|
||||
|
||||
const visibleSequenceRef = useRef<string[]>([])
|
||||
visibleSequenceRef.current = visibleSequence
|
||||
|
||||
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],
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64 text-neutral-500">
|
||||
Loading memories...
|
||||
<div className="flex h-full items-center justify-center text-text-muted">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading memories…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const memories = data?.memories ?? []
|
||||
|
||||
if (memories.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-neutral-500 gap-2">
|
||||
<p className="text-lg font-medium">No memories for today</p>
|
||||
<p className="text-sm">Photos taken on this date in previous years will appear here.</p>
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center text-text-muted">
|
||||
<div className="text-sm font-medium text-text">
|
||||
No memories for today
|
||||
</div>
|
||||
<p className="max-w-sm text-xs">
|
||||
Photos taken on this date in previous years will appear here.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-8 max-w-5xl mx-auto">
|
||||
<h2 className="text-xl font-semibold text-neutral-200">
|
||||
On This Day — {data?.date}
|
||||
<div
|
||||
className="h-full overflow-auto bg-bg p-4"
|
||||
role="grid"
|
||||
aria-label="Memories"
|
||||
>
|
||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-text">
|
||||
On this day · {data?.date}
|
||||
</h2>
|
||||
|
||||
{memories.map((group: MemoryGroup) => (
|
||||
<section key={group.year} className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-neutral-400 uppercase tracking-wide">
|
||||
{group.year} · {group.years_ago} year{group.years_ago !== 1 ? 's' : ''} ago
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2">
|
||||
{group.photos.map((photo) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="aspect-square rounded-lg overflow-hidden bg-neutral-800 relative group"
|
||||
>
|
||||
{photo.thumb_small ? (
|
||||
<img
|
||||
src={photosApi.getThumbnailUrl(photo.id, 'small')}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-neutral-600 text-xs">
|
||||
No thumb
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent px-2 py-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<p className="text-xs text-white truncate">{photo.filename}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
<div className="space-y-6">
|
||||
{memories.map((group) => (
|
||||
<section key={group.year} className="space-y-2">
|
||||
<header className="flex items-baseline gap-2">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-text">
|
||||
{group.year}
|
||||
</h3>
|
||||
<span className="text-xs text-text-muted">
|
||||
{group.years_ago} year{group.years_ago === 1 ? '' : 's'} ago
|
||||
</span>
|
||||
</header>
|
||||
<div
|
||||
className="grid"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(${CELL_SIZE}px, 1fr))`,
|
||||
gridAutoRows: `${CELL_SIZE}px`,
|
||||
gap: `${GAP}px`,
|
||||
}}
|
||||
>
|
||||
{group.photos.map((m) => (
|
||||
<PhotoThumbnail
|
||||
key={m.id}
|
||||
photo={memoryToPhoto(m)}
|
||||
size={CELL_SIZE}
|
||||
fill
|
||||
isSelected={selectedPhotos.includes(m.id)}
|
||||
searchQuery={searchQuery}
|
||||
onClick={handleCellClick}
|
||||
onDoubleClick={handleCellDoubleClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Widen the slim MemoryPhoto payload to the full Photo shape the
|
||||
* thumbnail expects. Fields the memories endpoint doesn't return
|
||||
* (is_duplicate, file_hash, ...) get sensible defaults — memories
|
||||
* never surface discarded/duplicate photos server-side. */
|
||||
function memoryToPhoto(m: MemoryPhoto): Photo {
|
||||
return {
|
||||
id: m.id,
|
||||
filepath: m.filename,
|
||||
filename: m.filename,
|
||||
media_type: m.media_type,
|
||||
width: m.width,
|
||||
height: m.height,
|
||||
taken_at: m.taken_at,
|
||||
rating: m.rating,
|
||||
is_discarded: false,
|
||||
is_duplicate: false,
|
||||
file_hash: '',
|
||||
folder_id: null,
|
||||
added_at: null,
|
||||
thumb_small: m.thumb_small ?? undefined,
|
||||
thumb_medium: m.thumb_medium ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import { photos as photosApi } from '../../services/api'
|
||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||
@@ -34,7 +34,7 @@ export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilm
|
||||
key={photo.id}
|
||||
ref={isActive ? activeRef : null}
|
||||
onClick={() => onSelect(photo.id)}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'relative shrink-0 overflow-hidden rounded-sm will-change-transform transition-[transform,box-shadow,opacity] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
|
||||
isActive && 'scale-90 opacity-100 ring-2 ring-blue-500 ring-offset-2 ring-offset-surface',
|
||||
!isActive && isInActiveHeap && 'opacity-100',
|
||||
@@ -83,7 +83,7 @@ function FilmstripThumb({ photo }: { photo: Photo }) {
|
||||
decoding="async"
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setErrored(true)}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'h-full w-full object-cover transition-opacity duration-200',
|
||||
loaded ? 'opacity-100' : 'opacity-0',
|
||||
// Match the grid's discarded styling so the filmstrip mirrors
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { Star, ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { photos as photosApi } from '../../services/api'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||
@@ -90,7 +91,7 @@ export function RatedView() {
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h2 className="text-sm font-semibold text-amber-400">{selectedGroup.label}</h2>
|
||||
<h2 className="text-sm font-semibold text-star">{selectedGroup.label}</h2>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Timeline />
|
||||
@@ -137,7 +138,7 @@ export function RatedView() {
|
||||
{groups.map((group, i) => (
|
||||
<div
|
||||
key={group.rating}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
|
||||
i === activeIndex
|
||||
? 'border-primary ring-1 ring-primary/30'
|
||||
@@ -158,13 +159,16 @@ export function RatedView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="absolute bottom-1.5 right-1.5 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm">
|
||||
<Badge
|
||||
variant="overlay"
|
||||
className="absolute bottom-1.5 right-1.5"
|
||||
>
|
||||
{group.count}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="truncate text-xs font-medium text-amber-400">{group.label}</p>
|
||||
<p className="truncate text-xs font-medium text-star">{group.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Users, Trash2 } from 'lucide-react'
|
||||
import { sharing, type ShareInfo } from '../../services/api'
|
||||
import {
|
||||
sharing,
|
||||
type ShareInfo,
|
||||
type ShareableUser,
|
||||
} from '../../services/api'
|
||||
import { formatApiError } from '../../lib/apiError'
|
||||
import {
|
||||
SHARED_HEAPS_KEY,
|
||||
SHARED_FOLDERS_KEY,
|
||||
@@ -13,7 +18,6 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -54,6 +58,24 @@ export function ShareDialog({
|
||||
enabled: isOpen,
|
||||
})
|
||||
|
||||
// All users that can be shared with (the caller is filtered out
|
||||
// server-side). Only fetched while the dialog is open. Cached for 60s
|
||||
// because the user directory changes slowly.
|
||||
const { data: allUsers = [], isLoading: isLoadingUsers } = useQuery<
|
||||
ShareableUser[]
|
||||
>({
|
||||
queryKey: ['sharing', 'users'],
|
||||
queryFn: sharing.listUsers,
|
||||
enabled: isOpen,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
// Users who don't already have a share on this target.
|
||||
const availableUsers = useMemo(() => {
|
||||
const taken = new Set(shares.map((s) => s.shared_with_username))
|
||||
return allUsers.filter((u) => !taken.has(u.username))
|
||||
}, [allUsers, shares])
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
type === 'heap'
|
||||
@@ -68,8 +90,8 @@ export function ShareDialog({
|
||||
queryKey: type === 'heap' ? SHARED_HEAPS_KEY : SHARED_FOLDERS_KEY,
|
||||
})
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setError(err?.response?.data?.detail || 'Failed to share')
|
||||
onError: (err) => {
|
||||
setError(formatApiError(err, 'Failed to share'))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -149,17 +171,33 @@ export function ShareDialog({
|
||||
className="space-y-3"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
<Select
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value)
|
||||
onValueChange={(v) => {
|
||||
setUsername(v)
|
||||
setError(null)
|
||||
}}
|
||||
placeholder="Username"
|
||||
className="flex-1"
|
||||
autoFocus
|
||||
/>
|
||||
disabled={isLoadingUsers || availableUsers.length === 0}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoadingUsers
|
||||
? 'Loading users…'
|
||||
: availableUsers.length === 0
|
||||
? 'No users to share with'
|
||||
: 'Select a user'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableUsers.map((u) => (
|
||||
<SelectItem key={u.id} value={u.username}>
|
||||
{u.username}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={permission}
|
||||
onValueChange={(v) => setPermission(v as 'read' | 'write')}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -10,24 +10,23 @@ import {
|
||||
ShoppingBasket,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
useQuery,
|
||||
useMutation,
|
||||
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
|
||||
@@ -369,13 +367,13 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
||||
|
||||
// Single themable input class so the same component reads against either
|
||||
// the surface (grid sidebar) or a darker preview overlay.
|
||||
const inputClass = clsx(
|
||||
const inputClass = cn(
|
||||
'w-full rounded border px-2 py-1 text-sm focus:outline-none',
|
||||
darkTheme
|
||||
? 'border-white/15 bg-black/40 text-white placeholder-white/40 focus:border-primary'
|
||||
: 'border-border bg-bg text-text placeholder-text-faint focus:border-primary'
|
||||
)
|
||||
const monoInputClass = clsx(
|
||||
const monoInputClass = cn(
|
||||
'w-full rounded border px-2 py-1 font-mono text-xs focus:outline-none',
|
||||
darkTheme
|
||||
? 'border-white/15 bg-black/40 text-white placeholder-white/40 focus:border-primary'
|
||||
@@ -384,7 +382,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'flex h-full flex-col transition-opacity duration-150',
|
||||
isPlaceholderData && 'opacity-70'
|
||||
)}
|
||||
@@ -438,7 +436,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
||||
onBlur={commitNotes}
|
||||
placeholder="Add notes…"
|
||||
rows={3}
|
||||
className={clsx(inputClass, 'resize-none')}
|
||||
className={cn(inputClass, 'resize-none')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -456,7 +454,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
||||
title={`Set rating to ${value}`}
|
||||
>
|
||||
<Star
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'h-5 w-5 transition-colors',
|
||||
value <= rating
|
||||
? 'fill-star text-star'
|
||||
@@ -480,7 +478,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
||||
onClick={() =>
|
||||
updateMutation.mutate({ color_label: active ? null : value })
|
||||
}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'h-5 w-5 rounded-full ring-offset-2 ring-offset-surface transition-all',
|
||||
className,
|
||||
active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'
|
||||
@@ -511,7 +509,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
||||
heapMutation.mutate({ remove: isInActiveHeap })
|
||||
}}
|
||||
disabled={!activeHeap || heapMutation.isPending}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isInActiveHeap
|
||||
? 'bg-pick/20 text-pick'
|
||||
@@ -530,7 +528,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateMutation.mutate({ is_discarded: !isDiscarded })}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
||||
isDiscarded
|
||||
? 'bg-reject/20 text-reject'
|
||||
@@ -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 { cn } from '@/lib/utils'
|
||||
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 = cn(
|
||||
'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={cn(
|
||||
'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,7 +1,8 @@
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { Tag as TagIcon, ArrowLeft, Loader2 } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||
import { photos as photosApi, type Tag } from '../../services/api'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
@@ -106,7 +107,7 @@ export function TagsView() {
|
||||
{tags.map((tag, i) => (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'group cursor-pointer overflow-hidden rounded-lg border bg-surface transition-all hover:border-primary/50 hover:shadow-md',
|
||||
i === activeIndex
|
||||
? 'border-primary ring-1 ring-primary/30'
|
||||
@@ -127,9 +128,12 @@ export function TagsView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="absolute bottom-1.5 right-1.5 rounded-full bg-black/60 px-2 py-0.5 text-[11px] font-medium text-white backdrop-blur-sm">
|
||||
<Badge
|
||||
variant="overlay"
|
||||
className="absolute bottom-1.5 right-1.5"
|
||||
>
|
||||
{tag.photo_count}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-1.5">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { memo, useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Star,
|
||||
Trash2,
|
||||
@@ -6,12 +6,12 @@ import {
|
||||
Copy,
|
||||
AlertTriangle,
|
||||
Users,
|
||||
ShoppingBasket,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { photos as photosApi } from '../../services/api'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||
import { findSearchMatch } from '../../lib/searchMatch'
|
||||
|
||||
@@ -74,16 +74,24 @@ 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
|
||||
/** Active text-search query, passed in from Timeline rather than
|
||||
* subscribed-to here so we don't have N thumbnails each running a
|
||||
* per-keystroke selector. Empty string disables the highlight. */
|
||||
searchQuery?: string
|
||||
/** 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,
|
||||
isSelected,
|
||||
isInActiveHeap = false,
|
||||
searchQuery = '',
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
}: PhotoThumbnailProps) {
|
||||
@@ -106,12 +114,12 @@ export function PhotoThumbnail({
|
||||
// thumbnail badge all read from one source of truth.
|
||||
const dateWarning = photo.has_date_warning === true
|
||||
|
||||
// Active search query — subscribed with a focused selector so typing
|
||||
// in the search box only re-renders thumbnails, not every consumer
|
||||
// of the filter store. When a query is active we compute which field
|
||||
// on this photo matched, so the user can see *why* the photo came
|
||||
// back from the search instead of guessing.
|
||||
const searchQuery = useFilterStore((s) => s.q)
|
||||
// When a query is active we compute which field on this photo
|
||||
// matched so the user can see *why* the photo came back from the
|
||||
// search instead of guessing. The query itself is passed down as a
|
||||
// prop (subscribed once at the Timeline level) — having every
|
||||
// thumbnail subscribe individually multiplied keystroke renders by
|
||||
// the row count.
|
||||
const searchMatch = searchQuery.trim()
|
||||
? findSearchMatch(photo, searchQuery)
|
||||
: null
|
||||
@@ -197,8 +205,8 @@ export function PhotoThumbnail({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'group relative cursor-pointer overflow-hidden rounded-sm will-change-transform',
|
||||
className={cn(
|
||||
'group relative cursor-pointer overflow-hidden rounded-sm outline-none will-change-transform focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-bg',
|
||||
// Animate INTO selection (springy ease-in over 300ms); snap
|
||||
// back instantly when deselected by dropping the transition
|
||||
// entirely. Heap membership doesn't animate — it just paints
|
||||
@@ -226,8 +234,26 @@ export function PhotoThumbnail({
|
||||
? { width: '100%', height: '100%' }
|
||||
: { width: size, height: displayHeight }
|
||||
}
|
||||
onClick={onClick}
|
||||
onDoubleClick={onDoubleClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Photo: ${photo.filename}`}
|
||||
aria-pressed={isSelected}
|
||||
onClick={(e) => onClick(photo, e)}
|
||||
onKeyDown={(e) => {
|
||||
// Space toggles selection, Enter opens preview. Mirrors the
|
||||
// mouse semantics so keyboard users don't have to learn a
|
||||
// separate vocabulary.
|
||||
if (e.key === 'Enter' && onDoubleClick) {
|
||||
e.preventDefault()
|
||||
onDoubleClick(photo, e as unknown as React.MouseEvent)
|
||||
} else if (e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onClick(photo, e as unknown as React.MouseEvent)
|
||||
}
|
||||
}}
|
||||
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"
|
||||
@@ -238,7 +264,7 @@ export function PhotoThumbnail({
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={photo.filename}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'h-full w-full object-cover transition-opacity duration-200',
|
||||
imageLoaded ? 'opacity-100' : 'opacity-0',
|
||||
// Discarded photos fade out + desaturate so the trash section
|
||||
@@ -249,18 +275,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>
|
||||
)}
|
||||
@@ -341,7 +365,7 @@ export function PhotoThumbnail({
|
||||
* conveyed by the ring/outline on the wrapper, no badge needed. */}
|
||||
{photo.owner_username && (
|
||||
<div
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'absolute left-1 top-1',
|
||||
THUMB_BADGE_BASE,
|
||||
THUMB_BADGE_NEUTRAL,
|
||||
@@ -360,7 +384,7 @@ export function PhotoThumbnail({
|
||||
<div className="absolute bottom-1 left-1 flex items-center gap-1">
|
||||
{photo.color_label && COLOR_LABEL_BG[photo.color_label] && (
|
||||
<div
|
||||
className={clsx(
|
||||
className={cn(
|
||||
THUMB_BADGE_BASE,
|
||||
THUMB_BADGE_SQUARE,
|
||||
COLOR_LABEL_BG[photo.color_label],
|
||||
@@ -371,22 +395,36 @@ export function PhotoThumbnail({
|
||||
)}
|
||||
{photo.rating > 0 && (
|
||||
<div
|
||||
className={clsx('gap-0.5', THUMB_BADGE_BASE, THUMB_BADGE_PRIMARY)}
|
||||
className={cn('gap-0.5', THUMB_BADGE_BASE, THUMB_BADGE_PRIMARY)}
|
||||
>
|
||||
{Array.from({ length: photo.rating }).map((_, i) => (
|
||||
<Star key={i} className={clsx(THUMB_BADGE_ICON, 'fill-white')} />
|
||||
<Star key={i} className={cn(THUMB_BADGE_ICON, 'fill-white')} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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={cn(
|
||||
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)}
|
||||
className={cn(THUMB_BADGE_BASE, THUMB_BADGE_SQUARE, THUMB_BADGE_NEUTRAL)}
|
||||
title="Duplicate (matches another photo's hash)"
|
||||
>
|
||||
<Copy className={THUMB_BADGE_ICON} strokeWidth={2.5} />
|
||||
@@ -394,7 +432,7 @@ export function PhotoThumbnail({
|
||||
)}
|
||||
{photo.is_discarded && (
|
||||
<div
|
||||
className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_SQUARE, THUMB_BADGE_NEUTRAL)}
|
||||
className={cn(THUMB_BADGE_BASE, THUMB_BADGE_SQUARE, THUMB_BADGE_NEUTRAL)}
|
||||
title="Discarded"
|
||||
>
|
||||
<Trash2 className={THUMB_BADGE_ICON} strokeWidth={2.5} />
|
||||
@@ -402,7 +440,7 @@ export function PhotoThumbnail({
|
||||
)}
|
||||
{dateWarning && (
|
||||
<div
|
||||
className={clsx(
|
||||
className={cn(
|
||||
THUMB_BADGE_BASE,
|
||||
THUMB_BADGE_SQUARE,
|
||||
// Amber fill with the same chiseled frame as other affirmative
|
||||
@@ -420,10 +458,18 @@ export function PhotoThumbnail({
|
||||
{/* TR — file-type metadata (RAW / VIDEO) */}
|
||||
{(photo.filepath.toLowerCase().match(/\.(raw|arw|cr2|cr3|nef|orf|rw2|dng)$/i) ||
|
||||
photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i)) && (
|
||||
<div className={clsx('absolute right-1 top-1', THUMB_BADGE_BASE, THUMB_BADGE_NEUTRAL)}>
|
||||
<div className={cn('absolute right-1 top-1', THUMB_BADGE_BASE, THUMB_BADGE_NEUTRAL)}>
|
||||
{photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i) ? 'VIDEO' : 'RAW'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* React.memo with the default shallow comparison. Relies on the caller
|
||||
* passing stable `onClick`/`onDoubleClick` handlers (via useCallback)
|
||||
* so identity doesn't churn on every parent render — that's what lets
|
||||
* us skip re-render on unrelated store updates like heap invalidation.
|
||||
*/
|
||||
export const PhotoThumbnail = memo(PhotoThumbnailImpl)
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useRef, useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { format, parseISO } from 'date-fns'
|
||||
import clsx from 'clsx'
|
||||
import { cn } from '@/lib/utils'
|
||||
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.
|
||||
@@ -141,6 +143,10 @@ export function Timeline() {
|
||||
const sortBy = useFilterStore((s) => s.sortBy)
|
||||
const groupBy = useFilterStore((s) => s.groupBy)
|
||||
const currentSection = useFilterStore((s) => s.currentSection)
|
||||
// Subscribed once at this level and passed down to each
|
||||
// PhotoThumbnail as a prop. Previously every thumbnail had its own
|
||||
// subscription, multiplying keystroke renders by the row count.
|
||||
const searchQuery = useFilterStore((s) => s.q)
|
||||
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||
|
||||
// Calculate number of columns + actual cell size based on container
|
||||
@@ -203,6 +209,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,22 +525,10 @@ 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
|
||||
// (rowIndex, colIndex) where its id appears, since a tag-grouped view
|
||||
// can repeat a photo across groups. Returns null when there's no
|
||||
// active photo or it isn't currently rendered.
|
||||
const findActiveCell = (): { row: number; col: number } | null => {
|
||||
if (!activePhotoId) return null
|
||||
for (let r = 0; r < photoRows.length; r++) {
|
||||
const row = photoRows[r]
|
||||
const c = row.cells.findIndex((cell) => cell.photo.id === activePhotoId)
|
||||
if (c >= 0) return { row: r, col: c }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle keyboard shortcuts for photo navigation. Operates on the
|
||||
// grouped grid the user sees, so a half-full last row of a group
|
||||
// doesn't make ArrowDown skip into the wrong place.
|
||||
@@ -523,6 +538,29 @@ export function Timeline() {
|
||||
// against PreviewView's setActivePhoto, landing the user on the wrong
|
||||
// photo. The grid handler stays attached so it can re-engage the
|
||||
// moment the user closes preview.
|
||||
//
|
||||
// The handler reads its inputs through a ref so the listener can bind
|
||||
// once per (viewMode, currentSection) — every other dep used to be in
|
||||
// the array and triggered an unbind/rebind on every state tick (eight
|
||||
// values, several with new identity each render).
|
||||
const navStateRef = useRef({
|
||||
photoRows,
|
||||
photos,
|
||||
selectedPhotos,
|
||||
activePhotoId,
|
||||
photoRowItemIndex,
|
||||
items,
|
||||
cellSize,
|
||||
})
|
||||
navStateRef.current = {
|
||||
photoRows,
|
||||
photos,
|
||||
selectedPhotos,
|
||||
activePhotoId,
|
||||
photoRowItemIndex,
|
||||
items,
|
||||
cellSize,
|
||||
}
|
||||
useEffect(() => {
|
||||
if (viewMode !== 'grid') return
|
||||
// The duplicates section mounts its own grouped view (DuplicatesView)
|
||||
@@ -531,14 +569,31 @@ export function Timeline() {
|
||||
// doesn't match what the user actually sees on screen.
|
||||
if (currentSection === 'duplicates') return
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const {
|
||||
photoRows,
|
||||
photos,
|
||||
selectedPhotos,
|
||||
photoRowItemIndex,
|
||||
items,
|
||||
cellSize,
|
||||
} = navStateRef.current
|
||||
if (photoRows.length === 0) return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
|
||||
return
|
||||
}
|
||||
|
||||
const findActive = (): { row: number; col: number } | null => {
|
||||
const aid = navStateRef.current.activePhotoId
|
||||
if (!aid) return null
|
||||
for (let r = 0; r < photoRows.length; r++) {
|
||||
const c = photoRows[r].cells.findIndex((cell) => cell.photo.id === aid)
|
||||
if (c >= 0) return { row: r, col: c }
|
||||
}
|
||||
return null
|
||||
}
|
||||
const move = (dr: number, dc: number) => {
|
||||
const current = findActiveCell() ?? { row: 0, col: -1 }
|
||||
const current = findActive() ?? { row: 0, col: -1 }
|
||||
let nextRow = current.row
|
||||
let nextCol = current.col + dc
|
||||
|
||||
@@ -648,8 +703,14 @@ export function Timeline() {
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewMode, photoRows, photos, selectedPhotos, activePhotoId, photoRowItemIndex, items, cellSize, currentSection])
|
||||
}, [
|
||||
viewMode,
|
||||
currentSection,
|
||||
selectRange,
|
||||
selectPhoto,
|
||||
togglePhotoSelection,
|
||||
clearSelection,
|
||||
])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -660,15 +721,16 @@ 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 (
|
||||
<div className="relative h-full">
|
||||
<div
|
||||
className="relative h-full"
|
||||
role="grid"
|
||||
aria-label="Photos"
|
||||
aria-rowcount={photoRows.length}
|
||||
>
|
||||
{/* Sticky group-header overlay. Lives outside the virtualizer's
|
||||
* positioned children so it isn't affected by translateY transforms.
|
||||
* Updates as the user scrolls past month boundaries. */}
|
||||
@@ -686,7 +748,7 @@ export function Timeline() {
|
||||
* so pointer-events-none — it must never steal scrollbar clicks. */}
|
||||
{isDateSort && scrollDateLabel && (
|
||||
<div
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'pointer-events-none absolute right-4 z-30 rounded-md border border-border bg-surface/95 px-2.5 py-1 text-xs font-semibold text-text shadow-lg backdrop-blur transition-opacity duration-200',
|
||||
isScrolling ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
@@ -788,16 +850,9 @@ 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)}
|
||||
searchQuery={searchQuery}
|
||||
onClick={handleCellClick}
|
||||
onDoubleClick={handleCellDoubleClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -809,3 +864,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.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
39
frontend/src/components/ui/badge.tsx
Normal file
39
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Compact pill used for counts, tags, and overlays. Variants match the
|
||||
* surface tokens in tailwind.config.js:
|
||||
* default → primary tint (user-affirmed state)
|
||||
* neutral → surface-2 (informational metadata)
|
||||
* overlay → dark-on-photo count badge used in grid-view cards
|
||||
* outline → border-only, for subdued contexts
|
||||
*/
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium transition-colors',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary/20 text-primary',
|
||||
neutral: 'bg-surface-2 text-text-muted',
|
||||
overlay: 'bg-black/60 text-white backdrop-blur-sm',
|
||||
outline: 'border border-border text-text-muted',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default' },
|
||||
},
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
export function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<span className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { badgeVariants }
|
||||
@@ -22,6 +22,7 @@ const buttonVariants = cva(
|
||||
size: {
|
||||
default: 'h-8 px-3',
|
||||
sm: 'h-7 rounded-md px-2 text-xs',
|
||||
xs: 'h-6 rounded px-1.5 text-[11px]',
|
||||
lg: 'h-9 rounded-md px-4',
|
||||
icon: 'h-8 w-8',
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { uploads, type FolderTreeNode } from '../../services/api'
|
||||
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
|
||||
@@ -400,7 +400,7 @@ export function UploadModal({ isOpen, onClose, initialFolderId }: UploadModalPro
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center rounded border-2 border-dashed px-4 py-6 text-center transition-colors',
|
||||
dragOver
|
||||
? 'border-primary bg-primary/10'
|
||||
@@ -478,7 +478,7 @@ export function UploadModal({ isOpen, onClose, initialFolderId }: UploadModalPro
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{item.status === 'done' && <CheckCircle2 className="h-4 w-4 text-green-500" />}
|
||||
{item.status === 'done' && <CheckCircle2 className="h-4 w-4 text-pick" />}
|
||||
{item.status === 'error' && <AlertCircle className="h-4 w-4 text-reject" />}
|
||||
{item.status !== 'done' && !isUploading && (
|
||||
<button
|
||||
@@ -552,7 +552,7 @@ function FolderTreeRow({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={clsx(
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-1 rounded px-1 py-1 text-sm',
|
||||
isSelected ? 'bg-primary/20 text-text' : 'text-text-muted hover:bg-surface-2 hover:text-text'
|
||||
)}
|
||||
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useFilterStore, filtersToParams } from '../store/filterStore'
|
||||
import api from '../services/api'
|
||||
import type { Photo } from '../types/photo'
|
||||
@@ -40,44 +40,33 @@ async function fetchCursorPage(
|
||||
* Timeline's key gained the filter params.
|
||||
*/
|
||||
export function usePhotosQuery() {
|
||||
const q = useFilterStore((s) => s.q)
|
||||
const dateFrom = useFilterStore((s) => s.dateFrom)
|
||||
const dateTo = useFilterStore((s) => s.dateTo)
|
||||
const mediaTypes = useFilterStore((s) => s.mediaTypes)
|
||||
const ratingMin = useFilterStore((s) => s.ratingMin)
|
||||
const ratingMax = useFilterStore((s) => s.ratingMax)
|
||||
const colorLabel = useFilterStore((s) => s.colorLabel)
|
||||
const flag = useFilterStore((s) => s.flag)
|
||||
const heapId = useFilterStore((s) => s.heapId)
|
||||
const folderId = useFilterStore((s) => s.folderId)
|
||||
const tagIds = useFilterStore((s) => s.tagIds)
|
||||
const duplicates = useFilterStore((s) => s.duplicates)
|
||||
const needsReview = useFilterStore((s) => s.needsReview)
|
||||
const groupBy = useFilterStore((s) => s.groupBy)
|
||||
const sortBy = useFilterStore((s) => s.sortBy)
|
||||
const sortOrder = useFilterStore((s) => s.sortOrder)
|
||||
|
||||
const filterParams = useMemo(
|
||||
() =>
|
||||
// Single shallow-compared selector that returns just the filter
|
||||
// surface area. Previously this hook ran 14 individual selectors,
|
||||
// each a fresh subscription that could trigger a re-render and a
|
||||
// useMemo recompute on any unrelated filter-store update. useShallow
|
||||
// collapses them into one subscription that only fires when the
|
||||
// shape's values actually change.
|
||||
const filterParams = useFilterStore(
|
||||
useShallow((s) =>
|
||||
filtersToParams({
|
||||
q,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
mediaTypes,
|
||||
ratingMin,
|
||||
ratingMax,
|
||||
colorLabel,
|
||||
flag,
|
||||
heapId,
|
||||
folderId,
|
||||
tagIds,
|
||||
duplicates,
|
||||
needsReview,
|
||||
groupBy,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
q: s.q,
|
||||
dateFrom: s.dateFrom,
|
||||
dateTo: s.dateTo,
|
||||
mediaTypes: s.mediaTypes,
|
||||
ratingMin: s.ratingMin,
|
||||
ratingMax: s.ratingMax,
|
||||
colorLabel: s.colorLabel,
|
||||
flag: s.flag,
|
||||
heapId: s.heapId,
|
||||
folderId: s.folderId,
|
||||
tagIds: s.tagIds,
|
||||
duplicates: s.duplicates,
|
||||
needsReview: s.needsReview,
|
||||
groupBy: s.groupBy,
|
||||
sortBy: s.sortBy,
|
||||
sortOrder: s.sortOrder,
|
||||
}),
|
||||
[q, dateFrom, dateTo, mediaTypes, ratingMin, ratingMax, colorLabel, flag, heapId, folderId, tagIds, duplicates, needsReview, groupBy, sortBy, sortOrder]
|
||||
),
|
||||
)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
@@ -87,12 +76,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 +115,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
|
||||
}
|
||||
@@ -806,7 +806,18 @@ export interface ShareInfo {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ShareableUser {
|
||||
id: string
|
||||
username: string
|
||||
}
|
||||
|
||||
export const sharing = {
|
||||
// Shareable user directory for the share-dialog picker.
|
||||
listUsers: async (): Promise<ShareableUser[]> => {
|
||||
const response = await api.get('/sharing/users')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// Heap shares
|
||||
shareHeap: async (heapId: string, username: string, permission: 'read' | 'write' = 'read') => {
|
||||
const response = await api.post(`/sharing/heaps/${heapId}`, { username, permission })
|
||||
|
||||
Reference in New Issue
Block a user