Compare commits
4 Commits
2f1e9033ae
...
07b9660e92
| Author | SHA1 | Date | |
|---|---|---|---|
| 07b9660e92 | |||
| b870084be0 | |||
| 3a03a56db2 | |||
| 1c428dda8b |
@@ -3,7 +3,7 @@ Discard API router
|
|||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Body
|
||||||
from sqlalchemy import select, and_
|
from sqlalchemy import select, and_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -47,7 +47,33 @@ async def empty_discard(db: AsyncSession = Depends(get_db)):
|
|||||||
select(Photo).where(Photo.is_discarded == True)
|
select(Photo).where(Photo.is_discarded == True)
|
||||||
)
|
)
|
||||||
photos = result.scalars().all()
|
photos = result.scalars().all()
|
||||||
|
return await _permanently_delete(db, photos)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("")
|
||||||
|
async def delete_discarded(
|
||||||
|
photo_ids: list[str] = Body(..., embed=True),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Permanently delete a specific subset of discarded photos. The photos
|
||||||
|
must already be in the discard pile — non-discarded ids are skipped so
|
||||||
|
this can never bypass the soft-delete safety net.
|
||||||
|
"""
|
||||||
|
if not photo_ids:
|
||||||
|
return {"status": "success", "deleted": 0, "file_errors": 0}
|
||||||
|
result = await db.execute(
|
||||||
|
select(Photo).where(
|
||||||
|
and_(Photo.id.in_(photo_ids), Photo.is_discarded == True)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
photos = result.scalars().all()
|
||||||
|
return await _permanently_delete(db, photos)
|
||||||
|
|
||||||
|
|
||||||
|
async def _permanently_delete(db: AsyncSession, photos: list[Photo]) -> dict:
|
||||||
|
"""Shared helper: unlink files for the given photos and delete their
|
||||||
|
rows. Per-file errors are counted but don't abort the batch.
|
||||||
|
"""
|
||||||
deleted = 0
|
deleted = 0
|
||||||
file_errors = 0
|
file_errors = 0
|
||||||
for photo in photos:
|
for photo in photos:
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import { useEffect, useState } from 'react'
|
|||||||
import { CheckCircle, XCircle, Info, AlertCircle, X } from 'lucide-react'
|
import { CheckCircle, XCircle, Info, AlertCircle, X } from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
|
export interface ToastAction {
|
||||||
|
label: string
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
export interface Toast {
|
export interface Toast {
|
||||||
id: string
|
id: string
|
||||||
type: 'success' | 'error' | 'info' | 'warning'
|
type: 'success' | 'error' | 'info' | 'warning'
|
||||||
title: string
|
title: string
|
||||||
message?: string
|
message?: string
|
||||||
duration?: number
|
duration?: number
|
||||||
|
action?: ToastAction
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global toast state (in production, use Zustand or Context)
|
// Global toast state (in production, use Zustand or Context)
|
||||||
@@ -15,22 +21,34 @@ let toastListeners: ((toasts: Toast[]) => void)[] = []
|
|||||||
let toastList: Toast[] = []
|
let toastList: Toast[] = []
|
||||||
|
|
||||||
export const toast = {
|
export const toast = {
|
||||||
success: (title: string, message?: string) => addToast('success', title, message),
|
success: (title: string, message?: string, action?: ToastAction) =>
|
||||||
error: (title: string, message?: string) => addToast('error', title, message),
|
addToast('success', title, message, 5000, action),
|
||||||
info: (title: string, message?: string) => addToast('info', title, message),
|
error: (title: string, message?: string, action?: ToastAction) =>
|
||||||
warning: (title: string, message?: string) => addToast('warning', title, message),
|
addToast('error', title, message, 5000, action),
|
||||||
|
info: (title: string, message?: string, action?: ToastAction) =>
|
||||||
|
addToast('info', title, message, 5000, action),
|
||||||
|
warning: (title: string, message?: string, action?: ToastAction) =>
|
||||||
|
addToast('warning', title, message, 5000, action),
|
||||||
}
|
}
|
||||||
|
|
||||||
function addToast(type: Toast['type'], title: string, message?: string, duration = 5000) {
|
function addToast(
|
||||||
const id = Date.now().toString()
|
type: Toast['type'],
|
||||||
const newToast: Toast = { id, type, title, message, duration }
|
title: string,
|
||||||
|
message?: string,
|
||||||
|
duration = 5000,
|
||||||
|
action?: ToastAction
|
||||||
|
) {
|
||||||
|
const id = Date.now().toString() + Math.random().toString(36).slice(2, 6)
|
||||||
|
const newToast: Toast = { id, type, title, message, duration, action }
|
||||||
toastList = [...toastList, newToast]
|
toastList = [...toastList, newToast]
|
||||||
toastListeners.forEach(listener => listener(toastList))
|
toastListeners.forEach(listener => listener(toastList))
|
||||||
|
|
||||||
// Auto-remove after duration
|
// Auto-remove after duration. Toasts with an action get a longer window
|
||||||
|
// so the user has time to actually click Undo.
|
||||||
|
const removeAfter = action ? Math.max(duration, 8000) : duration
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
removeToast(id)
|
removeToast(id)
|
||||||
}, duration)
|
}, removeAfter)
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeToast(id: string) {
|
function removeToast(id: string) {
|
||||||
@@ -82,6 +100,17 @@ export function ToastContainer() {
|
|||||||
<div className="mt-0.5 text-sm text-text-muted">{toast.message}</div>
|
<div className="mt-0.5 text-sm text-text-muted">{toast.message}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{toast.action && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
toast.action!.onClick()
|
||||||
|
removeToast(toast.id)
|
||||||
|
}}
|
||||||
|
className="pointer-events-auto self-center rounded border border-border bg-surface px-2 py-1 text-xs font-medium text-text hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
{toast.action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => removeToast(toast.id)}
|
onClick={() => removeToast(toast.id)}
|
||||||
className="pointer-events-auto rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
|
className="pointer-events-auto rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
import { useFilterStore } from '../../store/filterStore'
|
import { useFilterStore } from '../../store/filterStore'
|
||||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||||
import { discard as discardApi } from '../../services/api'
|
import { discard as discardApi, photos as photosApi } from '../../services/api'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
import { ConfirmDialog } from '../dialogs/ConfirmDialog'
|
import { ConfirmDialog } from '../dialogs/ConfirmDialog'
|
||||||
|
import { registerUndoable } from '../../store/undoStore'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top-of-timeline bar visible only when the discarded filter is active.
|
* Top-of-timeline bar visible only when the discarded filter is active.
|
||||||
@@ -21,17 +22,48 @@ export function DiscardActionBar() {
|
|||||||
const { data: photos = [] } = usePhotosQuery()
|
const { data: photos = [] } = usePhotosQuery()
|
||||||
|
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||||
|
const [deleteSelectedOpen, setDeleteSelectedOpen] = useState(false)
|
||||||
|
|
||||||
const restoreMutation = useMutation({
|
const restoreMutation = useMutation({
|
||||||
mutationFn: (ids: string[]) => discardApi.restore(ids),
|
mutationFn: (ids: string[]) => discardApi.restore(ids),
|
||||||
onSuccess: (_, ids) => {
|
onSuccess: (_, ids) => {
|
||||||
toast.success('Restored', `${ids.length} photo${ids.length > 1 ? 's' : ''} restored`)
|
registerUndoable(
|
||||||
|
`Restored ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
|
||||||
|
async () => {
|
||||||
|
await photosApi.bulkDiscard(ids)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
}
|
||||||
|
)
|
||||||
clearSelection()
|
clearSelection()
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
},
|
},
|
||||||
onError: (e: any) => toast.error('Restore failed', e.message || 'Unknown error'),
|
onError: (e: any) => toast.error('Restore failed', e.message || 'Unknown error'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const deleteSelectedMutation = useMutation({
|
||||||
|
mutationFn: (ids: string[]) => discardApi.deletePermanent(ids),
|
||||||
|
onSuccess: (data: any) => {
|
||||||
|
const count = data?.deleted ?? 0
|
||||||
|
const errors = data?.file_errors ?? 0
|
||||||
|
if (errors > 0) {
|
||||||
|
toast.error(
|
||||||
|
`Deleted with ${errors} error${errors > 1 ? 's' : ''}`,
|
||||||
|
`${count} record${count === 1 ? '' : 's'} deleted; some files could not be removed`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
'Permanently deleted',
|
||||||
|
`${count} photo${count === 1 ? '' : 's'} removed from disk`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
clearSelection()
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
setDeleteSelectedOpen(false)
|
||||||
|
},
|
||||||
|
onError: (e: any) =>
|
||||||
|
toast.error('Delete failed', e.message || 'Unknown error'),
|
||||||
|
})
|
||||||
|
|
||||||
const emptyMutation = useMutation({
|
const emptyMutation = useMutation({
|
||||||
mutationFn: () => discardApi.empty(),
|
mutationFn: () => discardApi.empty(),
|
||||||
onSuccess: (data: any) => {
|
onSuccess: (data: any) => {
|
||||||
@@ -70,6 +102,7 @@ export function DiscardActionBar() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{selected > 0 && (
|
{selected > 0 && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => restoreMutation.mutate(selectedPhotos)}
|
onClick={() => restoreMutation.mutate(selectedPhotos)}
|
||||||
disabled={restoreMutation.isPending}
|
disabled={restoreMutation.isPending}
|
||||||
@@ -79,6 +112,16 @@ export function DiscardActionBar() {
|
|||||||
<RotateCcw className="h-3.5 w-3.5" />
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
Restore {selected}
|
Restore {selected}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteSelectedOpen(true)}
|
||||||
|
disabled={deleteSelectedMutation.isPending}
|
||||||
|
className="flex items-center gap-1.5 rounded bg-reject/20 px-3 py-1 text-reject hover:bg-reject/30 disabled:opacity-50"
|
||||||
|
title="Permanently delete selected"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Delete {selected}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setConfirmOpen(true)}
|
onClick={() => setConfirmOpen(true)}
|
||||||
@@ -92,6 +135,22 @@ export function DiscardActionBar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={deleteSelectedOpen}
|
||||||
|
title={`Delete ${selected} photo${selected === 1 ? '' : 's'}?`}
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
This will <strong className="text-text">permanently delete</strong>{' '}
|
||||||
|
{selected} photo{selected === 1 ? '' : 's'} and remove the file
|
||||||
|
{selected === 1 ? '' : 's'} from disk. This cannot be undone.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
destructive
|
||||||
|
onConfirm={() => deleteSelectedMutation.mutate(selectedPhotos)}
|
||||||
|
onClose={() => setDeleteSelectedOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={confirmOpen}
|
isOpen={confirmOpen}
|
||||||
title="Empty discard pile?"
|
title="Empty discard pile?"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { ChevronDown, X } from 'lucide-react'
|
import { ChevronDown, X } from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|
||||||
@@ -17,9 +18,6 @@ interface FilterPillProps {
|
|||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
/** Force the popover open programmatically (rare). */
|
/** Force the popover open programmatically (rare). */
|
||||||
defaultOpen?: boolean
|
defaultOpen?: boolean
|
||||||
/** Right-align the popover instead of left (for pills near the right
|
|
||||||
* edge so they don't overflow the viewport). */
|
|
||||||
alignRight?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,20 +33,51 @@ export function FilterPill({
|
|||||||
onClear,
|
onClear,
|
||||||
children,
|
children,
|
||||||
defaultOpen = false,
|
defaultOpen = false,
|
||||||
alignRight = false,
|
|
||||||
}: FilterPillProps) {
|
}: FilterPillProps) {
|
||||||
const [open, setOpen] = useState(defaultOpen)
|
const [open, setOpen] = useState(defaultOpen)
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [popoverPos, setPopoverPos] = useState<{ top: number; left: number } | null>(null)
|
||||||
|
|
||||||
// Close on outside click + Escape.
|
// Compute the popover's screen position from the trigger button. Done
|
||||||
|
// imperatively (not via CSS absolute) so the popover can live in a portal
|
||||||
|
// and escape the FilterBar's overflow-x-auto clipping. Re-computed on
|
||||||
|
// open, scroll, and resize.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const update = () => {
|
||||||
|
const btn = buttonRef.current
|
||||||
|
if (!btn) return
|
||||||
|
const rect = btn.getBoundingClientRect()
|
||||||
|
// Default left-align under the trigger; clamp to viewport so the
|
||||||
|
// last pill on the right doesn't overflow.
|
||||||
|
const popWidth = popoverRef.current?.offsetWidth ?? 240
|
||||||
|
const margin = 8
|
||||||
|
let left = rect.left
|
||||||
|
if (left + popWidth + margin > window.innerWidth) {
|
||||||
|
left = Math.max(margin, window.innerWidth - popWidth - margin)
|
||||||
|
}
|
||||||
|
setPopoverPos({ top: rect.bottom + 4, left })
|
||||||
|
}
|
||||||
|
update()
|
||||||
|
window.addEventListener('resize', update)
|
||||||
|
window.addEventListener('scroll', update, true)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', update)
|
||||||
|
window.removeEventListener('scroll', update, true)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
// Close on outside click + Escape. Outside means neither the trigger
|
||||||
|
// button nor the (portaled) popover.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const onDocMouseDown = (e: MouseEvent) => {
|
const onDocMouseDown = (e: MouseEvent) => {
|
||||||
if (!wrapperRef.current) return
|
const target = e.target as Node
|
||||||
if (!wrapperRef.current.contains(e.target as Node)) {
|
if (buttonRef.current?.contains(target)) return
|
||||||
|
if (popoverRef.current?.contains(target)) return
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') setOpen(false)
|
if (e.key === 'Escape') setOpen(false)
|
||||||
}
|
}
|
||||||
@@ -61,8 +90,9 @@ export function FilterPill({
|
|||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} className="relative">
|
<>
|
||||||
<button
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
onClick={() => setOpen((v) => !v)}
|
onClick={() => setOpen((v) => !v)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition-colors',
|
'flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition-colors',
|
||||||
@@ -92,16 +122,22 @@ export function FilterPill({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open &&
|
||||||
|
createPortal(
|
||||||
<div
|
<div
|
||||||
className={clsx(
|
ref={popoverRef}
|
||||||
'absolute top-full z-30 mt-1 min-w-[220px] rounded-lg border border-border bg-surface p-3 shadow-xl',
|
style={{
|
||||||
alignRight ? 'right-0' : 'left-0'
|
position: 'fixed',
|
||||||
)}
|
top: popoverPos?.top ?? -9999,
|
||||||
|
left: popoverPos?.left ?? -9999,
|
||||||
|
visibility: popoverPos ? 'visible' : 'hidden',
|
||||||
|
}}
|
||||||
|
className="z-50 min-w-[220px] rounded-lg border border-border bg-surface p-3 shadow-xl"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import { HeapsPanel } from '../heaps/HeapsPanel'
|
|||||||
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
||||||
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
|
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
|
||||||
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||||
|
import { registerUndoable } from '../../store/undoStore'
|
||||||
|
import type { Photo } from '../../types/photo'
|
||||||
|
|
||||||
interface TreeItem {
|
interface TreeItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -49,9 +51,12 @@ export function LeftSidebar() {
|
|||||||
const discardDropMutation = useMutation({
|
const discardDropMutation = useMutation({
|
||||||
mutationFn: (photoIds: string[]) => photosApi.bulkDiscard(photoIds),
|
mutationFn: (photoIds: string[]) => photosApi.bulkDiscard(photoIds),
|
||||||
onSuccess: (_data, photoIds) => {
|
onSuccess: (_data, photoIds) => {
|
||||||
toast.success(
|
registerUndoable(
|
||||||
'Discarded',
|
`Discarded ${photoIds.length} photo${photoIds.length === 1 ? '' : 's'}`,
|
||||||
`${photoIds.length} photo${photoIds.length > 1 ? 's' : ''}`
|
async () => {
|
||||||
|
await photosApi.bulkRestore(photoIds)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
}
|
||||||
)
|
)
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
},
|
},
|
||||||
@@ -59,20 +64,71 @@ export function LeftSidebar() {
|
|||||||
toast.error('Discard failed', e?.message || 'Unknown error'),
|
toast.error('Discard failed', e?.message || 'Unknown error'),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Bulk move mutation for the drag-onto-folder interaction.
|
// Bulk move mutation for the drag-onto-folder interaction. The mutation
|
||||||
|
// captures each photo's source folder before issuing the move so the
|
||||||
|
// undo path can put them back exactly where they came from (different
|
||||||
|
// sources end up in different undo subgroups).
|
||||||
const moveDropMutation = useMutation({
|
const moveDropMutation = useMutation({
|
||||||
mutationFn: ({ targetId, photoIds }: { targetId: string; photoIds: string[] }) =>
|
mutationFn: async ({
|
||||||
photosApi.move(photoIds, targetId),
|
targetId,
|
||||||
onSuccess: (data) => {
|
photoIds,
|
||||||
const moved = data?.moved ?? 0
|
}: {
|
||||||
const errCount = (data?.errors?.length ?? 0)
|
targetId: string
|
||||||
|
photoIds: string[]
|
||||||
|
}) => {
|
||||||
|
// Snapshot per-photo source folder ids from the photos cache. We
|
||||||
|
// walk every cached ['photos', ...] entry because the user could
|
||||||
|
// be in any section / filter combination, and we don't know the
|
||||||
|
// exact key offhand.
|
||||||
|
const sourceMap = new Map<string, string>()
|
||||||
|
const photoCaches = queryClient.getQueriesData<Photo[]>({ queryKey: ['photos'] })
|
||||||
|
for (const [, list] of photoCaches) {
|
||||||
|
if (!list) continue
|
||||||
|
for (const p of list) {
|
||||||
|
if (photoIds.includes(p.id) && p.folder_id && !sourceMap.has(p.id)) {
|
||||||
|
sourceMap.set(p.id, p.folder_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = await photosApi.move(photoIds, targetId)
|
||||||
|
return { result, sourceMap }
|
||||||
|
},
|
||||||
|
onSuccess: ({ result, sourceMap }) => {
|
||||||
|
const moved = result?.moved ?? 0
|
||||||
|
const errCount = result?.errors?.length ?? 0
|
||||||
if (moved > 0) {
|
if (moved > 0) {
|
||||||
|
// Group photos by their source folder so we can issue one move
|
||||||
|
// call per group when undoing. Photos whose source folder we
|
||||||
|
// couldn't recover get dropped from the undo (they'll just stay
|
||||||
|
// where the move put them).
|
||||||
|
const groups = new Map<string, string[]>()
|
||||||
|
for (const [photoId, src] of sourceMap.entries()) {
|
||||||
|
const arr = groups.get(src) ?? []
|
||||||
|
arr.push(photoId)
|
||||||
|
groups.set(src, arr)
|
||||||
|
}
|
||||||
|
if (groups.size > 0) {
|
||||||
|
registerUndoable(
|
||||||
|
`Moved ${moved} photo${moved === 1 ? '' : 's'}`,
|
||||||
|
async () => {
|
||||||
|
for (const [src, ids] of groups.entries()) {
|
||||||
|
await photosApi.move(ids, src)
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
toast.success(
|
toast.success(
|
||||||
'Moved',
|
'Moved',
|
||||||
`${moved} photo${moved > 1 ? 's' : ''}${errCount ? ` (${errCount} skipped)` : ''}`
|
`${moved} photo${moved > 1 ? 's' : ''}${errCount ? ` (${errCount} skipped)` : ''}`
|
||||||
)
|
)
|
||||||
|
}
|
||||||
} else if (errCount > 0) {
|
} else if (errCount > 0) {
|
||||||
toast.error('Move failed', `${errCount} file${errCount > 1 ? 's' : ''} could not be moved`)
|
toast.error(
|
||||||
|
'Move failed',
|
||||||
|
`${errCount} file${errCount > 1 ? 's' : ''} could not be moved`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||||
|
|||||||
@@ -1,49 +1,12 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react'
|
import { X, Star, Info, ShoppingBasket, Trash2 } from 'lucide-react'
|
||||||
import {
|
|
||||||
X,
|
|
||||||
Star,
|
|
||||||
MapPin,
|
|
||||||
Camera,
|
|
||||||
Aperture,
|
|
||||||
Info,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
ShoppingBasket,
|
|
||||||
Trash2,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { format } from 'date-fns'
|
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
import { photos as photosApi, heaps as heapsApi } from '../../services/api'
|
import { photos as photosApi, heaps as heapsApi } from '../../services/api'
|
||||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||||
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
|
||||||
import { tags as tagsApi, type Tag } from '../../services/api'
|
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
|
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
|
||||||
interface PhotoTagSummary {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
color: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PhotoDetails {
|
|
||||||
id: string
|
|
||||||
filename: string
|
|
||||||
filepath: string
|
|
||||||
width: number | null
|
|
||||||
height: number | null
|
|
||||||
file_size: number | null
|
|
||||||
taken_at: string | null
|
|
||||||
rating: number
|
|
||||||
is_discarded: boolean
|
|
||||||
user_title: string | null
|
|
||||||
user_notes: string | null
|
|
||||||
color_label: string | null
|
|
||||||
exif_json: string | null
|
|
||||||
tags?: PhotoTagSummary[]
|
|
||||||
}
|
|
||||||
|
|
||||||
type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
|
type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
|
||||||
|
|
||||||
@@ -56,101 +19,21 @@ const COLOR_LABEL_OPTIONS: { value: ColorLabel; className: string }[] = [
|
|||||||
{ value: 'purple', className: 'bg-purple-500' },
|
{ value: 'purple', className: 'bg-purple-500' },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface ExifData {
|
/**
|
||||||
Make?: string
|
* Right-hand details panel.
|
||||||
Model?: string
|
* - 1 photo selected → delegates to PhotoInfoPanel for the full editor.
|
||||||
LensModel?: string
|
* - 2+ photos selected → renders a slim bulk-action panel that fans out
|
||||||
Lens?: string
|
* rating / color / discard / pick across the entire selection.
|
||||||
ISO?: number | string
|
*/
|
||||||
FNumber?: number | string
|
|
||||||
ApertureValue?: number | string
|
|
||||||
ExposureTime?: string
|
|
||||||
ShutterSpeedValue?: string
|
|
||||||
FocalLength?: string
|
|
||||||
FocalLengthIn35mmFormat?: string
|
|
||||||
GPSLatitude?: number | string
|
|
||||||
GPSLongitude?: number | string
|
|
||||||
[key: string]: unknown
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFileSize(bytes: number | null): string {
|
|
||||||
if (bytes == null) return '—'
|
|
||||||
if (bytes < 1024) return `${bytes} B`
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
||||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
||||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatExifValue(v: unknown): string {
|
|
||||||
if (v == null || v === '') return '—'
|
|
||||||
return String(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickFirst(exif: ExifData, ...keys: string[]): string {
|
|
||||||
for (const k of keys) {
|
|
||||||
const v = exif[k]
|
|
||||||
if (v != null && v !== '') return String(v)
|
|
||||||
}
|
|
||||||
return '—'
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseExif(json: string | null): ExifData {
|
|
||||||
if (!json) return {}
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(json)
|
|
||||||
return typeof parsed === 'object' && parsed !== null ? (parsed as ExifData) : {}
|
|
||||||
} catch {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RightSidebar() {
|
export function RightSidebar() {
|
||||||
const { selectedPhotos, activePhotoId, clearSelection } = usePhotoStore()
|
const { selectedPhotos, activePhotoId, clearSelection } = usePhotoStore()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
|
||||||
new Set(['basic', 'camera', 'location', 'tags'])
|
|
||||||
)
|
|
||||||
|
|
||||||
const toggleSection = (section: string) => {
|
|
||||||
const newExpanded = new Set(expandedSections)
|
|
||||||
if (newExpanded.has(section)) newExpanded.delete(section)
|
|
||||||
else newExpanded.add(section)
|
|
||||||
setExpandedSections(newExpanded)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch the active photo's full record (with EXIF) on demand.
|
|
||||||
const { data: photo } = useQuery<PhotoDetails>({
|
|
||||||
queryKey: ['photo', activePhotoId],
|
|
||||||
queryFn: () => photosApi.get(activePhotoId!),
|
|
||||||
enabled: !!activePhotoId,
|
|
||||||
staleTime: 60_000,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Mutation for any patchable field on the active photo. Invalidates both
|
|
||||||
// the photo detail cache and the timeline list so the grid reflects the
|
|
||||||
// change too.
|
|
||||||
const updateMutation = useMutation({
|
|
||||||
mutationFn: (data: {
|
|
||||||
filename?: string
|
|
||||||
rating?: number
|
|
||||||
is_discarded?: boolean
|
|
||||||
user_title?: string | null
|
|
||||||
user_notes?: string | null
|
|
||||||
color_label?: string | null
|
|
||||||
}) => photosApi.update(activePhotoId!, data),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Bulk equivalents — used when more than one photo is selected so the
|
|
||||||
// rating / color / discard buttons apply to the whole selection.
|
|
||||||
const invalidatePhotoQueries = () => {
|
const invalidatePhotoQueries = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['photo'] })
|
queryClient.invalidateQueries({ queryKey: ['photo'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
}
|
}
|
||||||
|
|
||||||
const bulkRatingMutation = useMutation({
|
const bulkRatingMutation = useMutation({
|
||||||
mutationFn: ({ ids, rating }: { ids: string[]; rating: number }) =>
|
mutationFn: ({ ids, rating }: { ids: string[]; rating: number }) =>
|
||||||
photosApi.bulkSetRating(ids, rating),
|
photosApi.bulkSetRating(ids, rating),
|
||||||
@@ -165,15 +48,9 @@ export function RightSidebar() {
|
|||||||
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
|
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
|
||||||
onSuccess: invalidatePhotoQueries,
|
onSuccess: invalidatePhotoQueries,
|
||||||
})
|
})
|
||||||
const bulkRestoreMutation = useMutation({
|
|
||||||
mutationFn: (ids: string[]) => photosApi.bulkRestore(ids),
|
|
||||||
onSuccess: invalidatePhotoQueries,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Membership in the active heap (for the Pick toggle button).
|
// Active heap membership for the bulk Pick toggle.
|
||||||
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
|
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||||
const isInActiveHeap =
|
|
||||||
!!activePhotoId && activeHeapMembers.has(activePhotoId)
|
|
||||||
|
|
||||||
const heapMutation = useMutation({
|
const heapMutation = useMutation({
|
||||||
mutationFn: ({ ids, remove }: { ids: string[]; remove: boolean }) => {
|
mutationFn: ({ ids, remove }: { ids: string[]; remove: boolean }) => {
|
||||||
@@ -182,7 +59,6 @@ export function RightSidebar() {
|
|||||||
? heapsApi.removePhotos(activeHeap.id, ids)
|
? heapsApi.removePhotos(activeHeap.id, ids)
|
||||||
: heapsApi.addPhotos(activeHeap.id, ids)
|
: heapsApi.addPhotos(activeHeap.id, ids)
|
||||||
},
|
},
|
||||||
// Optimistic flip so the badge / button label update instantly.
|
|
||||||
onMutate: ({ ids, remove }) => {
|
onMutate: ({ ids, remove }) => {
|
||||||
if (!activeHeap || ids.length === 0) return { previous: undefined }
|
if (!activeHeap || ids.length === 0) return { previous: undefined }
|
||||||
const key = ['heap-photo-ids', activeHeap.id] as const
|
const key = ['heap-photo-ids', activeHeap.id] as const
|
||||||
@@ -193,10 +69,11 @@ export function RightSidebar() {
|
|||||||
queryClient.setQueryData<string[]>(key, Array.from(set))
|
queryClient.setQueryData<string[]>(key, Array.from(set))
|
||||||
return { previous }
|
return { previous }
|
||||||
},
|
},
|
||||||
onError: (_e, _vars, ctx) => {
|
onError: (e: any, _vars, ctx) => {
|
||||||
if (activeHeap && ctx?.previous) {
|
if (activeHeap && ctx?.previous) {
|
||||||
queryClient.setQueryData(['heap-photo-ids', activeHeap.id], ctx.previous)
|
queryClient.setQueryData(['heap-photo-ids', activeHeap.id], ctx.previous)
|
||||||
}
|
}
|
||||||
|
toast.error('Heap update failed', e?.message || 'Unknown error')
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
onSettled: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||||||
@@ -208,128 +85,6 @@ export function RightSidebar() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Tags state + mutations ──────────────────────────────────────────
|
|
||||||
const { data: allTags = [] } = useTagsQuery()
|
|
||||||
const [tagInput, setTagInput] = useState('')
|
|
||||||
|
|
||||||
const invalidateTagsAndPhoto = () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['photo', activePhotoId] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
||||||
}
|
|
||||||
|
|
||||||
const addTagMutation = useMutation({
|
|
||||||
mutationFn: async (name: string) => {
|
|
||||||
// Idempotent create — backend returns existing row if name matches.
|
|
||||||
const created = await tagsApi.create(name)
|
|
||||||
if (activePhotoId) {
|
|
||||||
await tagsApi.addToPhoto(activePhotoId, [created.id])
|
|
||||||
}
|
|
||||||
return created
|
|
||||||
},
|
|
||||||
onSuccess: () => invalidateTagsAndPhoto(),
|
|
||||||
onError: (e: any) =>
|
|
||||||
toast.error('Add tag failed', e?.message || 'Unknown error'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const attachExistingTagMutation = useMutation({
|
|
||||||
mutationFn: (tagId: string) =>
|
|
||||||
tagsApi.addToPhoto(activePhotoId!, [tagId]),
|
|
||||||
onSuccess: () => invalidateTagsAndPhoto(),
|
|
||||||
onError: (e: any) =>
|
|
||||||
toast.error('Add tag failed', e?.message || 'Unknown error'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const removeTagMutation = useMutation({
|
|
||||||
mutationFn: (tagId: string) =>
|
|
||||||
tagsApi.removeFromPhoto(activePhotoId!, tagId),
|
|
||||||
onSuccess: () => invalidateTagsAndPhoto(),
|
|
||||||
onError: (e: any) =>
|
|
||||||
toast.error('Remove tag failed', e?.message || 'Unknown error'),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Local drafts for the editable text fields. These mirror the server value
|
|
||||||
// but stay independent while the user is typing, so we don't fight focus or
|
|
||||||
// clobber edits with stale refetches.
|
|
||||||
const [filenameDraft, setFilenameDraft] = useState('')
|
|
||||||
const [titleDraft, setTitleDraft] = useState('')
|
|
||||||
const [notesDraft, setNotesDraft] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setFilenameDraft(photo?.filename ?? '')
|
|
||||||
setTitleDraft(photo?.user_title ?? '')
|
|
||||||
setNotesDraft(photo?.user_notes ?? '')
|
|
||||||
}, [photo?.id, photo?.filename, photo?.user_title, photo?.user_notes])
|
|
||||||
|
|
||||||
const commitFilename = () => {
|
|
||||||
const next = filenameDraft.trim()
|
|
||||||
const current = photo?.filename ?? ''
|
|
||||||
if (!next || next === current) {
|
|
||||||
// Reset draft if user cleared it; we never send an empty filename.
|
|
||||||
setFilenameDraft(current)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (next.includes('/') || next.includes('\\') || next === '.' || next === '..') {
|
|
||||||
toast.error('Invalid filename', 'No path separators allowed')
|
|
||||||
setFilenameDraft(current)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updateMutation.mutate(
|
|
||||||
{ filename: next },
|
|
||||||
{
|
|
||||||
onError: (e: any) => {
|
|
||||||
toast.error(
|
|
||||||
'Rename failed',
|
|
||||||
e?.response?.data?.detail || e.message || 'Unknown error'
|
|
||||||
)
|
|
||||||
setFilenameDraft(current)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const commitTitle = () => {
|
|
||||||
const next = titleDraft.trim()
|
|
||||||
const current = photo?.user_title ?? ''
|
|
||||||
if (next === current) return
|
|
||||||
updateMutation.mutate({ user_title: next || null })
|
|
||||||
}
|
|
||||||
|
|
||||||
const commitNotes = () => {
|
|
||||||
const next = notesDraft
|
|
||||||
const current = photo?.user_notes ?? ''
|
|
||||||
if (next === current) return
|
|
||||||
updateMutation.mutate({ user_notes: next || null })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply a rating / color / discard to the current selection. Falls back
|
|
||||||
// to the single-photo path when only one photo is selected so the
|
|
||||||
// RightSidebar matches the keyboard shortcut behaviour exactly.
|
|
||||||
const applyRating = (value: number) => {
|
|
||||||
if (selectedPhotos.length > 1) {
|
|
||||||
bulkRatingMutation.mutate({ ids: selectedPhotos, rating: value })
|
|
||||||
} else {
|
|
||||||
updateMutation.mutate({ rating: value })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const setColor = (label: ColorLabel | null) => {
|
|
||||||
if (selectedPhotos.length > 1) {
|
|
||||||
bulkColorMutation.mutate({ ids: selectedPhotos, color: label })
|
|
||||||
} else {
|
|
||||||
updateMutation.mutate({ color_label: label })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const applyDiscard = (next: boolean) => {
|
|
||||||
if (selectedPhotos.length > 1) {
|
|
||||||
if (next) bulkDiscardMutation.mutate(selectedPhotos)
|
|
||||||
else bulkRestoreMutation.mutate(selectedPhotos)
|
|
||||||
} else {
|
|
||||||
updateMutation.mutate({ is_discarded: next })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json])
|
|
||||||
|
|
||||||
if (selectedPhotos.length === 0) {
|
if (selectedPhotos.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center p-4 text-center">
|
<div className="flex h-full items-center justify-center p-4 text-center">
|
||||||
@@ -341,19 +96,34 @@ export function RightSidebar() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const multipleSelected = selectedPhotos.length > 1
|
// ── Single-photo: full editor via PhotoInfoPanel ────────────────────
|
||||||
const rating = photo?.rating ?? 0
|
if (selectedPhotos.length === 1) {
|
||||||
const isDiscarded = photo?.is_discarded ?? false
|
const id = activePhotoId ?? selectedPhotos[0]
|
||||||
const colorLabel = (photo?.color_label ?? null) as ColorLabel | null
|
return (
|
||||||
|
<div className="flex h-full flex-col bg-surface">
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-text">Photo Details</h2>
|
||||||
|
<button
|
||||||
|
onClick={clearSelection}
|
||||||
|
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||||
|
title="Clear selection"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<PhotoInfoPanel photoId={id} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Multi-photo: bulk action panel ──────────────────────────────────
|
||||||
|
const allMembers = selectedPhotos.every((id) => activeHeapMembers.has(id))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-surface">
|
<div className="flex h-full flex-col bg-surface">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
<h2 className="text-sm font-semibold text-text">
|
<h2 className="text-sm font-semibold text-text">
|
||||||
{multipleSelected
|
{selectedPhotos.length} Photos Selected
|
||||||
? `${selectedPhotos.length} Photos Selected`
|
|
||||||
: 'Photo Details'}
|
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={clearSelection}
|
onClick={clearSelection}
|
||||||
@@ -364,168 +134,98 @@ export function RightSidebar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
|
||||||
{photo && (
|
|
||||||
<div className="space-y-3 border-b border-border p-4">
|
<div className="space-y-3 border-b border-border p-4">
|
||||||
{multipleSelected && (
|
|
||||||
<p className="text-xs text-text-muted">
|
<p className="text-xs text-text-muted">
|
||||||
Rating, color, and flag apply to all {selectedPhotos.length} selected.
|
Rating, color, and flag apply to all {selectedPhotos.length} selected.
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Per-photo fields — only meaningful for a single selection */}
|
{/* Bulk rating */}
|
||||||
{!multipleSelected && (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs text-text-muted">Filename</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={filenameDraft}
|
|
||||||
onChange={(e) => setFilenameDraft(e.target.value)}
|
|
||||||
onBlur={commitFilename}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.currentTarget.blur()
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
setFilenameDraft(photo.filename ?? '')
|
|
||||||
e.currentTarget.blur()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full rounded border border-border bg-bg px-2 py-1 font-mono text-xs text-text focus:border-primary focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs text-text-muted">Title</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={titleDraft}
|
|
||||||
onChange={(e) => setTitleDraft(e.target.value)}
|
|
||||||
onBlur={commitTitle}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.currentTarget.blur()
|
|
||||||
} else if (e.key === 'Escape') {
|
|
||||||
setTitleDraft(photo.user_title ?? '')
|
|
||||||
e.currentTarget.blur()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="No title"
|
|
||||||
className="w-full rounded border border-border bg-bg px-2 py-1 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs text-text-muted">Notes</label>
|
|
||||||
<textarea
|
|
||||||
value={notesDraft}
|
|
||||||
onChange={(e) => setNotesDraft(e.target.value)}
|
|
||||||
onBlur={commitNotes}
|
|
||||||
placeholder="Add notes…"
|
|
||||||
rows={3}
|
|
||||||
className="w-full resize-none rounded border border-border bg-bg px-2 py-1 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Rating */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-text-muted">Rating</label>
|
<label className="mb-1 block text-xs text-text-muted">Rating</label>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{[1, 2, 3, 4, 5].map((value) => (
|
{[1, 2, 3, 4, 5].map((value) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
onClick={() => applyRating(rating === value ? 0 : value)}
|
onClick={() =>
|
||||||
|
bulkRatingMutation.mutate({ ids: selectedPhotos, rating: value })
|
||||||
|
}
|
||||||
className="p-0.5"
|
className="p-0.5"
|
||||||
title={`Set rating to ${value}`}
|
title={`Set rating to ${value}`}
|
||||||
>
|
>
|
||||||
<Star
|
<Star className="h-5 w-5 text-text-muted hover:text-star" />
|
||||||
className={clsx(
|
|
||||||
'h-5 w-5 transition-colors',
|
|
||||||
value <= rating
|
|
||||||
? 'fill-star text-star'
|
|
||||||
: 'text-text-muted hover:text-star'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
bulkRatingMutation.mutate({ ids: selectedPhotos, rating: 0 })
|
||||||
|
}
|
||||||
|
className="ml-1 rounded px-1 text-xs text-text-muted hover:text-text"
|
||||||
|
title="Clear rating"
|
||||||
|
>
|
||||||
|
clear
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Color label */}
|
{/* Bulk color */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-text-muted">Color label</label>
|
<label className="mb-1 block text-xs text-text-muted">Color label</label>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{COLOR_LABEL_OPTIONS.map(({ value, className }) => {
|
{COLOR_LABEL_OPTIONS.map(({ value, className }) => (
|
||||||
const active = colorLabel === value
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
onClick={() => setColor(active ? null : value)}
|
onClick={() =>
|
||||||
|
bulkColorMutation.mutate({ ids: selectedPhotos, color: value })
|
||||||
|
}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'h-5 w-5 rounded-full ring-offset-2 ring-offset-surface transition-all',
|
'h-5 w-5 rounded-full opacity-80 ring-offset-2 ring-offset-surface transition-all hover:opacity-100',
|
||||||
className,
|
className
|
||||||
active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'
|
|
||||||
)}
|
)}
|
||||||
title={value}
|
title={value}
|
||||||
/>
|
/>
|
||||||
)
|
))}
|
||||||
})}
|
|
||||||
{colorLabel && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setColor(null)}
|
onClick={() =>
|
||||||
|
bulkColorMutation.mutate({ ids: selectedPhotos, color: null })
|
||||||
|
}
|
||||||
className="ml-1 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
className="ml-1 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||||
title="Clear color label"
|
title="Clear color label"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Flag */}
|
{/* Bulk flag */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const ids = selectedPhotos.length > 0
|
if (!activeHeap) return
|
||||||
? selectedPhotos
|
heapMutation.mutate({ ids: selectedPhotos, remove: allMembers })
|
||||||
: activePhotoId ? [activePhotoId] : []
|
|
||||||
if (!activeHeap || ids.length === 0) return
|
|
||||||
// If every selected photo is already a member, remove
|
|
||||||
// them; otherwise add the missing ones. Mirrors the
|
|
||||||
// P keyboard shortcut behaviour exactly.
|
|
||||||
const allMembers = ids.every((id) => activeHeapMembers.has(id))
|
|
||||||
heapMutation.mutate({ ids, remove: allMembers })
|
|
||||||
}}
|
}}
|
||||||
disabled={!activeHeap || heapMutation.isPending}
|
disabled={!activeHeap || heapMutation.isPending}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
isInActiveHeap
|
allMembers
|
||||||
? 'bg-pick/20 text-pick'
|
? 'bg-pick/20 text-pick'
|
||||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
||||||
)}
|
)}
|
||||||
title={
|
title={
|
||||||
activeHeap
|
activeHeap
|
||||||
? isInActiveHeap
|
? allMembers
|
||||||
? `Remove from "${activeHeap.name}"`
|
? `Remove all from "${activeHeap.name}"`
|
||||||
: `Add to "${activeHeap.name}"`
|
: `Add all to "${activeHeap.name}"`
|
||||||
: 'Set an active heap first'
|
: 'Set an active heap first'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ShoppingBasket className="h-3 w-3" />
|
<ShoppingBasket className="h-3 w-3" />
|
||||||
{isInActiveHeap ? 'Picked' : 'Pick'}
|
{allMembers ? 'Picked' : 'Pick'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => applyDiscard(!isDiscarded)}
|
onClick={() => bulkDiscardMutation.mutate(selectedPhotos)}
|
||||||
className={clsx(
|
className="flex items-center gap-1 rounded bg-surface-2 px-2 py-1 text-sm text-text-muted transition-colors hover:bg-surface-offset"
|
||||||
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
|
||||||
isDiscarded
|
|
||||||
? 'bg-reject/20 text-reject'
|
|
||||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
Discard
|
Discard
|
||||||
@@ -533,305 +233,6 @@ export function RightSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Metadata */}
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
{photo && !multipleSelected && (
|
|
||||||
<>
|
|
||||||
{/* Basic Info */}
|
|
||||||
<Section
|
|
||||||
title="Basic Info"
|
|
||||||
expanded={expandedSections.has('basic')}
|
|
||||||
onToggle={() => toggleSection('basic')}
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
||||||
<Field label="Size" value={formatFileSize(photo.file_size)} />
|
|
||||||
<Field
|
|
||||||
label="Dimensions"
|
|
||||||
value={
|
|
||||||
photo.width && photo.height
|
|
||||||
? `${photo.width} × ${photo.height}`
|
|
||||||
: '—'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Date Taken"
|
|
||||||
value={
|
|
||||||
photo.taken_at
|
|
||||||
? format(new Date(photo.taken_at), 'MMM d, yyyy HH:mm')
|
|
||||||
: '—'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Camera */}
|
|
||||||
<Section
|
|
||||||
title="Camera"
|
|
||||||
expanded={expandedSections.has('camera')}
|
|
||||||
onToggle={() => toggleSection('camera')}
|
|
||||||
>
|
|
||||||
<div className="space-y-1 text-xs">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Camera className="h-3 w-3 text-text-muted" />
|
|
||||||
<span className="text-text">
|
|
||||||
{pickFirst(exif, 'Make', 'Model') === '—'
|
|
||||||
? '—'
|
|
||||||
: `${formatExifValue(exif.Make)} ${formatExifValue(exif.Model)}`.trim()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Aperture className="h-3 w-3 text-text-muted" />
|
|
||||||
<span className="text-text">
|
|
||||||
{pickFirst(exif, 'LensModel', 'Lens')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
|
||||||
<Field label="ISO" value={formatExifValue(exif.ISO)} />
|
|
||||||
<Field
|
|
||||||
label="Aperture"
|
|
||||||
value={
|
|
||||||
exif.FNumber
|
|
||||||
? `f/${exif.FNumber}`
|
|
||||||
: pickFirst(exif, 'ApertureValue')
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Shutter"
|
|
||||||
value={pickFirst(exif, 'ExposureTime', 'ShutterSpeedValue')}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
label="Focal"
|
|
||||||
value={pickFirst(
|
|
||||||
exif,
|
|
||||||
'FocalLength',
|
|
||||||
'FocalLengthIn35mmFormat'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Location */}
|
|
||||||
<Section
|
|
||||||
title="Location"
|
|
||||||
expanded={expandedSections.has('location')}
|
|
||||||
onToggle={() => toggleSection('location')}
|
|
||||||
>
|
|
||||||
{exif.GPSLatitude && exif.GPSLongitude ? (
|
|
||||||
<div className="flex items-center gap-2 text-xs">
|
|
||||||
<MapPin className="h-3 w-3 text-text-muted" />
|
|
||||||
<span className="font-mono text-text">
|
|
||||||
{String(exif.GPSLatitude)}, {String(exif.GPSLongitude)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-text-muted">No GPS data</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<Section
|
|
||||||
title="Tags"
|
|
||||||
expanded={expandedSections.has('tags')}
|
|
||||||
onToggle={() => toggleSection('tags')}
|
|
||||||
>
|
|
||||||
<TagsEditor
|
|
||||||
photoTags={photo.tags ?? []}
|
|
||||||
allTags={allTags}
|
|
||||||
tagInput={tagInput}
|
|
||||||
onTagInputChange={setTagInput}
|
|
||||||
onAttachExisting={(id) => attachExistingTagMutation.mutate(id)}
|
|
||||||
onCreateAndAttach={(name) => {
|
|
||||||
addTagMutation.mutate(name)
|
|
||||||
setTagInput('')
|
|
||||||
}}
|
|
||||||
onRemove={(id) => removeTagMutation.mutate(id)}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!photo && !multipleSelected && (
|
|
||||||
<div className="p-4 text-xs text-text-muted">Loading…</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer Actions for multi-select */}
|
|
||||||
{multipleSelected && (
|
|
||||||
<div className="border-t border-border p-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<button className="w-full rounded bg-surface-2 px-3 py-1.5 text-sm text-text hover:bg-surface-offset">
|
|
||||||
Add to Heap
|
|
||||||
</button>
|
|
||||||
<button className="w-full rounded bg-surface-2 px-3 py-1.5 text-sm text-text hover:bg-surface-offset">
|
|
||||||
Export Selected
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Section({
|
|
||||||
title,
|
|
||||||
expanded,
|
|
||||||
onToggle,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
expanded: boolean
|
|
||||||
onToggle: () => void
|
|
||||||
children: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="border-b border-border">
|
|
||||||
<button
|
|
||||||
onClick={onToggle}
|
|
||||||
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
<span className="font-medium text-text">{title}</span>
|
|
||||||
{expanded ? (
|
|
||||||
<ChevronDown className="h-4 w-4 text-text-muted" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4 text-text-muted" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{expanded && <div className="px-4 pb-3">{children}</div>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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))
|
|
||||||
|
|
||||||
// Suggestions: tags whose name contains the input AND that aren't
|
|
||||||
// already on the photo. Capped at 6 to keep the dropdown short.
|
|
||||||
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">
|
|
||||||
{/* Existing tag chips */}
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add tag input + suggestions */}
|
|
||||||
<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="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text placeholder-text-faint focus:border-primary focus:outline-none"
|
|
||||||
/>
|
|
||||||
{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>
|
|
||||||
<span className="text-text-muted">{label}:</span>
|
|
||||||
<p className="break-words text-text">{value}</p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
import { X } from 'lucide-react'
|
import { X, Info } from 'lucide-react'
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
import { PreviewImage } from './PreviewImage'
|
import { PreviewImage } from './PreviewImage'
|
||||||
import { PreviewFilmstrip } from './PreviewFilmstrip'
|
import { PreviewFilmstrip } from './PreviewFilmstrip'
|
||||||
import { getPreviewImageSrc, isVideo } from './previewSrc'
|
import { getPreviewImageSrc, isVideo } from './previewSrc'
|
||||||
|
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
|
||||||
|
|
||||||
export function PreviewView() {
|
export function PreviewView() {
|
||||||
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
|
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
|
||||||
@@ -15,6 +16,7 @@ export function PreviewView() {
|
|||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const previouslyFocusedRef = useRef<HTMLElement | null>(null)
|
const previouslyFocusedRef = useRef<HTMLElement | null>(null)
|
||||||
|
const [infoPanelOpen, setInfoPanelOpen] = useState(false)
|
||||||
|
|
||||||
// Same hook Timeline uses, so we share one cache entry rather than looking
|
// Same hook Timeline uses, so we share one cache entry rather than looking
|
||||||
// it up by key (which broke when the key gained the filter params).
|
// it up by key (which broke when the key gained the filter params).
|
||||||
@@ -42,6 +44,7 @@ export function PreviewView() {
|
|||||||
useHotkeys('escape', closePreview, { preventDefault: true })
|
useHotkeys('escape', closePreview, { preventDefault: true })
|
||||||
useHotkeys('left', goPrev, { preventDefault: true }, [goPrev])
|
useHotkeys('left', goPrev, { preventDefault: true }, [goPrev])
|
||||||
useHotkeys('right', goNext, { preventDefault: true }, [goNext])
|
useHotkeys('right', goNext, { preventDefault: true }, [goNext])
|
||||||
|
useHotkeys('i', () => setInfoPanelOpen((v) => !v), { preventDefault: true })
|
||||||
|
|
||||||
// Preload the immediate neighbors so arrow nav feels instant. Skip videos
|
// Preload the immediate neighbors so arrow nav feels instant. Skip videos
|
||||||
// (browsers can't preload them via Image()) and skip when at the edges.
|
// (browsers can't preload them via Image()) and skip when at the edges.
|
||||||
@@ -122,18 +125,10 @@ export function PreviewView() {
|
|||||||
aria-label={`Photo preview: ${currentPhoto.filename}`}
|
aria-label={`Photo preview: ${currentPhoto.filename}`}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className="fixed inset-0 z-40 flex flex-col bg-black outline-none"
|
className="fixed inset-0 z-40 flex bg-black outline-none"
|
||||||
>
|
>
|
||||||
{/* Close button */}
|
{/* Main column — image + filmstrip */}
|
||||||
<button
|
<div className="relative flex min-w-0 flex-1 flex-col">
|
||||||
onClick={closePreview}
|
|
||||||
className="absolute right-3 top-3 z-10 flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white transition hover:bg-black/80"
|
|
||||||
title="Close (Esc)"
|
|
||||||
aria-label="Close preview"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Filename + counter */}
|
{/* 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="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>
|
<div className="font-mono">{currentPhoto.filename}</div>
|
||||||
@@ -142,6 +137,30 @@ export function PreviewView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Top-right action buttons */}
|
||||||
|
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setInfoPanelOpen((v) => !v)}
|
||||||
|
className={
|
||||||
|
'flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white transition hover:bg-black/80 ' +
|
||||||
|
(infoPanelOpen ? 'ring-2 ring-primary' : '')
|
||||||
|
}
|
||||||
|
title="Toggle info panel (I)"
|
||||||
|
aria-label="Toggle info panel"
|
||||||
|
aria-pressed={infoPanelOpen}
|
||||||
|
>
|
||||||
|
<Info className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={closePreview}
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full bg-black/60 text-white transition hover:bg-black/80"
|
||||||
|
title="Close (Esc)"
|
||||||
|
aria-label="Close preview"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<PreviewImage photo={currentPhoto} />
|
<PreviewImage photo={currentPhoto} />
|
||||||
|
|
||||||
<PreviewFilmstrip
|
<PreviewFilmstrip
|
||||||
@@ -150,5 +169,14 @@ export function PreviewView() {
|
|||||||
onSelect={setActivePhoto}
|
onSelect={setActivePhoto}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right info panel — slides in/out, mirrors the grid right sidebar
|
||||||
|
* but lives inside the preview overlay so it isn't covered by it. */}
|
||||||
|
{infoPanelOpen && (
|
||||||
|
<aside className="w-80 shrink-0 overflow-hidden border-l border-border bg-surface">
|
||||||
|
<PhotoInfoPanel photoId={currentPhoto.id} />
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
738
frontend/src/components/sidebar/PhotoInfoPanel.tsx
Normal file
738
frontend/src/components/sidebar/PhotoInfoPanel.tsx
Normal file
@@ -0,0 +1,738 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
Star,
|
||||||
|
MapPin,
|
||||||
|
Camera,
|
||||||
|
Aperture,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
ShoppingBasket,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { useQuery, useMutation, useQueryClient } 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 { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
||||||
|
import { toast } from '../ToastContainer'
|
||||||
|
|
||||||
|
interface PhotoTagSummary {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
color: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PhotoDetails {
|
||||||
|
id: string
|
||||||
|
filename: string
|
||||||
|
filepath: string
|
||||||
|
width: number | null
|
||||||
|
height: number | null
|
||||||
|
file_size: number | null
|
||||||
|
taken_at: string | null
|
||||||
|
rating: number
|
||||||
|
is_discarded: boolean
|
||||||
|
user_title: string | null
|
||||||
|
user_notes: string | null
|
||||||
|
color_label: string | null
|
||||||
|
exif_json: string | null
|
||||||
|
tags?: PhotoTagSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
|
||||||
|
|
||||||
|
const COLOR_LABEL_OPTIONS: { value: ColorLabel; className: string }[] = [
|
||||||
|
{ value: 'red', className: 'bg-red-500' },
|
||||||
|
{ value: 'orange', className: 'bg-orange-500' },
|
||||||
|
{ value: 'yellow', className: 'bg-yellow-400' },
|
||||||
|
{ value: 'green', className: 'bg-green-500' },
|
||||||
|
{ value: 'blue', className: 'bg-blue-500' },
|
||||||
|
{ value: 'purple', className: 'bg-purple-500' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface ExifData {
|
||||||
|
Make?: string
|
||||||
|
Model?: string
|
||||||
|
LensModel?: string
|
||||||
|
Lens?: string
|
||||||
|
ISO?: number | string
|
||||||
|
FNumber?: number | string
|
||||||
|
ApertureValue?: number | string
|
||||||
|
ExposureTime?: string
|
||||||
|
ShutterSpeedValue?: string
|
||||||
|
FocalLength?: string
|
||||||
|
FocalLengthIn35mmFormat?: string
|
||||||
|
GPSLatitude?: number | string
|
||||||
|
GPSLongitude?: number | string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number | null): string {
|
||||||
|
if (bytes == null) return '—'
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExifValue(v: unknown): string {
|
||||||
|
if (v == null || v === '') return '—'
|
||||||
|
return String(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFirst(exif: ExifData, ...keys: string[]): string {
|
||||||
|
for (const k of keys) {
|
||||||
|
const v = exif[k]
|
||||||
|
if (v != null && v !== '') return String(v)
|
||||||
|
}
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExif(json: string | null): ExifData {
|
||||||
|
if (!json) return {}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json)
|
||||||
|
return typeof parsed === 'object' && parsed !== null ? (parsed as ExifData) : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PhotoInfoPanelProps {
|
||||||
|
/** The photo to show metadata for. Drives an on-demand detail fetch. */
|
||||||
|
photoId: string
|
||||||
|
/** When true, the editable text fields (filename, title, notes) render
|
||||||
|
* with a darker theme to read against a black preview backdrop. */
|
||||||
|
darkTheme?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable single-photo metadata + edit panel. Used by both the grid
|
||||||
|
* RightSidebar (when one photo is selected) and the PreviewView's optional
|
||||||
|
* info overlay. Self-contained — owns its own queries and mutations.
|
||||||
|
*/
|
||||||
|
export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(
|
||||||
|
new Set(['basic', 'camera', 'location', 'tags'])
|
||||||
|
)
|
||||||
|
const toggleSection = (section: string) => {
|
||||||
|
const next = new Set(expandedSections)
|
||||||
|
if (next.has(section)) next.delete(section)
|
||||||
|
else next.add(section)
|
||||||
|
setExpandedSections(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the photo's full record (with EXIF) on demand.
|
||||||
|
const { data: photo } = useQuery<PhotoDetails>({
|
||||||
|
queryKey: ['photo', photoId],
|
||||||
|
queryFn: () => photosApi.get(photoId),
|
||||||
|
enabled: !!photoId,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mutation for any patchable field. Invalidates both the photo detail
|
||||||
|
// cache and the timeline list so the grid reflects the change too.
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: {
|
||||||
|
filename?: string
|
||||||
|
rating?: number
|
||||||
|
is_discarded?: boolean
|
||||||
|
user_title?: string | null
|
||||||
|
user_notes?: string | null
|
||||||
|
color_label?: string | null
|
||||||
|
}) => photosApi.update(photoId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photo', photoId] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Active heap membership for the Pick toggle button.
|
||||||
|
const { activeHeap, memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||||
|
const isInActiveHeap = activeHeapMembers.has(photoId)
|
||||||
|
|
||||||
|
const heapMutation = useMutation({
|
||||||
|
mutationFn: ({ remove }: { remove: boolean }) => {
|
||||||
|
if (!activeHeap) return Promise.resolve(null)
|
||||||
|
return remove
|
||||||
|
? heapsApi.removePhotos(activeHeap.id, [photoId])
|
||||||
|
: heapsApi.addPhotos(activeHeap.id, [photoId])
|
||||||
|
},
|
||||||
|
onMutate: ({ remove }) => {
|
||||||
|
if (!activeHeap) return { previous: undefined }
|
||||||
|
const key = ['heap-photo-ids', activeHeap.id] as const
|
||||||
|
const previous = queryClient.getQueryData<string[]>(key)
|
||||||
|
const set = new Set(previous ?? [])
|
||||||
|
if (remove) set.delete(photoId)
|
||||||
|
else set.add(photoId)
|
||||||
|
queryClient.setQueryData<string[]>(key, Array.from(set))
|
||||||
|
return { previous }
|
||||||
|
},
|
||||||
|
onError: (_e, _vars, ctx) => {
|
||||||
|
if (activeHeap && ctx?.previous) {
|
||||||
|
queryClient.setQueryData(['heap-photo-ids', activeHeap.id], ctx.previous)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
||||||
|
if (activeHeap) {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['heap-photo-ids', activeHeap.id],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Tags state + mutations ──────────────────────────────────────────
|
||||||
|
const { data: allTags = [] } = useTagsQuery()
|
||||||
|
const [tagInput, setTagInput] = useState('')
|
||||||
|
|
||||||
|
const invalidateTagsAndPhoto = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photo', photoId] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const addTagMutation = useMutation({
|
||||||
|
mutationFn: async (name: string) => {
|
||||||
|
const created = await tagsApi.create(name)
|
||||||
|
await tagsApi.addToPhoto(photoId, [created.id])
|
||||||
|
return created
|
||||||
|
},
|
||||||
|
onSuccess: () => invalidateTagsAndPhoto(),
|
||||||
|
onError: (e: any) =>
|
||||||
|
toast.error('Add tag failed', e?.message || 'Unknown error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const attachExistingTagMutation = useMutation({
|
||||||
|
mutationFn: (tagId: string) => tagsApi.addToPhoto(photoId, [tagId]),
|
||||||
|
onSuccess: () => invalidateTagsAndPhoto(),
|
||||||
|
onError: (e: any) =>
|
||||||
|
toast.error('Add tag failed', e?.message || 'Unknown error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const removeTagMutation = useMutation({
|
||||||
|
mutationFn: (tagId: string) => tagsApi.removeFromPhoto(photoId, tagId),
|
||||||
|
onSuccess: () => invalidateTagsAndPhoto(),
|
||||||
|
onError: (e: any) =>
|
||||||
|
toast.error('Remove tag failed', e?.message || 'Unknown error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Local drafts for the text fields. Mirror the server value but stay
|
||||||
|
// independent while typing so we don't fight focus or clobber edits.
|
||||||
|
const [filenameDraft, setFilenameDraft] = useState('')
|
||||||
|
const [titleDraft, setTitleDraft] = useState('')
|
||||||
|
const [notesDraft, setNotesDraft] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFilenameDraft(photo?.filename ?? '')
|
||||||
|
setTitleDraft(photo?.user_title ?? '')
|
||||||
|
setNotesDraft(photo?.user_notes ?? '')
|
||||||
|
}, [photo?.id, photo?.filename, photo?.user_title, photo?.user_notes])
|
||||||
|
|
||||||
|
const commitFilename = () => {
|
||||||
|
const next = filenameDraft.trim()
|
||||||
|
const current = photo?.filename ?? ''
|
||||||
|
if (!next || next === current) {
|
||||||
|
setFilenameDraft(current)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (next.includes('/') || next.includes('\\') || next === '.' || next === '..') {
|
||||||
|
toast.error('Invalid filename', 'No path separators allowed')
|
||||||
|
setFilenameDraft(current)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ filename: next },
|
||||||
|
{
|
||||||
|
onError: (e: any) => {
|
||||||
|
toast.error(
|
||||||
|
'Rename failed',
|
||||||
|
e?.response?.data?.detail || e.message || 'Unknown error'
|
||||||
|
)
|
||||||
|
setFilenameDraft(current)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitTitle = () => {
|
||||||
|
const next = titleDraft.trim()
|
||||||
|
const current = photo?.user_title ?? ''
|
||||||
|
if (next === current) return
|
||||||
|
updateMutation.mutate({ user_title: next || null })
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitNotes = () => {
|
||||||
|
const next = notesDraft
|
||||||
|
const current = photo?.user_notes ?? ''
|
||||||
|
if (next === current) return
|
||||||
|
updateMutation.mutate({ user_notes: next || null })
|
||||||
|
}
|
||||||
|
|
||||||
|
const exif = useMemo(() => parseExif(photo?.exif_json ?? null), [photo?.exif_json])
|
||||||
|
|
||||||
|
if (!photo) {
|
||||||
|
return <div className="p-4 text-xs text-text-muted">Loading…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const rating = photo.rating ?? 0
|
||||||
|
const isDiscarded = photo.is_discarded ?? false
|
||||||
|
const colorLabel = (photo.color_label ?? null) as ColorLabel | null
|
||||||
|
|
||||||
|
// Single themable input class so the same component reads against either
|
||||||
|
// the surface (grid sidebar) or a darker preview overlay.
|
||||||
|
const inputClass = clsx(
|
||||||
|
'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(
|
||||||
|
'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'
|
||||||
|
: 'border-border bg-bg text-text placeholder-text-faint focus:border-primary'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* Edit fields */}
|
||||||
|
<div className="space-y-3 border-b border-border p-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-text-muted">Filename</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={filenameDraft}
|
||||||
|
onChange={(e) => setFilenameDraft(e.target.value)}
|
||||||
|
onBlur={commitFilename}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.currentTarget.blur()
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setFilenameDraft(photo.filename ?? '')
|
||||||
|
e.currentTarget.blur()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={monoInputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-text-muted">Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={titleDraft}
|
||||||
|
onChange={(e) => setTitleDraft(e.target.value)}
|
||||||
|
onBlur={commitTitle}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.currentTarget.blur()
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setTitleDraft(photo.user_title ?? '')
|
||||||
|
e.currentTarget.blur()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="No title"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-text-muted">Notes</label>
|
||||||
|
<textarea
|
||||||
|
value={notesDraft}
|
||||||
|
onChange={(e) => setNotesDraft(e.target.value)}
|
||||||
|
onBlur={commitNotes}
|
||||||
|
placeholder="Add notes…"
|
||||||
|
rows={3}
|
||||||
|
className={clsx(inputClass, 'resize-none')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rating */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-text-muted">Rating</label>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((value) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() =>
|
||||||
|
updateMutation.mutate({ rating: rating === value ? 0 : value })
|
||||||
|
}
|
||||||
|
className="p-0.5"
|
||||||
|
title={`Set rating to ${value}`}
|
||||||
|
>
|
||||||
|
<Star
|
||||||
|
className={clsx(
|
||||||
|
'h-5 w-5 transition-colors',
|
||||||
|
value <= rating
|
||||||
|
? 'fill-star text-star'
|
||||||
|
: 'text-text-muted hover:text-star'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color label */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-text-muted">Color label</label>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{COLOR_LABEL_OPTIONS.map(({ value, className }) => {
|
||||||
|
const active = colorLabel === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() =>
|
||||||
|
updateMutation.mutate({ color_label: active ? null : value })
|
||||||
|
}
|
||||||
|
className={clsx(
|
||||||
|
'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'
|
||||||
|
)}
|
||||||
|
title={value}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{colorLabel && (
|
||||||
|
<button
|
||||||
|
onClick={() => updateMutation.mutate({ color_label: null })}
|
||||||
|
className="ml-1 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||||
|
title="Clear color label"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flag — Pick + Discard */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs text-text-muted">Flag</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (!activeHeap) return
|
||||||
|
heapMutation.mutate({ remove: isInActiveHeap })
|
||||||
|
}}
|
||||||
|
disabled={!activeHeap || heapMutation.isPending}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
isInActiveHeap
|
||||||
|
? 'bg-pick/20 text-pick'
|
||||||
|
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
activeHeap
|
||||||
|
? isInActiveHeap
|
||||||
|
? `Remove from "${activeHeap.name}"`
|
||||||
|
: `Add to "${activeHeap.name}"`
|
||||||
|
: 'Set an active heap first'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ShoppingBasket className="h-3 w-3" />
|
||||||
|
{isInActiveHeap ? 'Picked' : 'Pick'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => updateMutation.mutate({ is_discarded: !isDiscarded })}
|
||||||
|
className={clsx(
|
||||||
|
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors',
|
||||||
|
isDiscarded
|
||||||
|
? 'bg-reject/20 text-reject'
|
||||||
|
: 'bg-surface-2 text-text-muted hover:bg-surface-offset'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Read-only metadata sections */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<Section
|
||||||
|
title="Basic Info"
|
||||||
|
expanded={expandedSections.has('basic')}
|
||||||
|
onToggle={() => toggleSection('basic')}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<Field label="Size" value={formatFileSize(photo.file_size)} />
|
||||||
|
<Field
|
||||||
|
label="Dimensions"
|
||||||
|
value={
|
||||||
|
photo.width && photo.height
|
||||||
|
? `${photo.width} × ${photo.height}`
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Date Taken"
|
||||||
|
value={
|
||||||
|
photo.taken_at
|
||||||
|
? format(new Date(photo.taken_at), 'MMM d, yyyy HH:mm')
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="Camera"
|
||||||
|
expanded={expandedSections.has('camera')}
|
||||||
|
onToggle={() => toggleSection('camera')}
|
||||||
|
>
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Camera className="h-3 w-3 text-text-muted" />
|
||||||
|
<span className="text-text">
|
||||||
|
{pickFirst(exif, 'Make', 'Model') === '—'
|
||||||
|
? '—'
|
||||||
|
: `${formatExifValue(exif.Make)} ${formatExifValue(exif.Model)}`.trim()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Aperture className="h-3 w-3 text-text-muted" />
|
||||||
|
<span className="text-text">
|
||||||
|
{pickFirst(exif, 'LensModel', 'Lens')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
|
<Field label="ISO" value={formatExifValue(exif.ISO)} />
|
||||||
|
<Field
|
||||||
|
label="Aperture"
|
||||||
|
value={
|
||||||
|
exif.FNumber
|
||||||
|
? `f/${exif.FNumber}`
|
||||||
|
: pickFirst(exif, 'ApertureValue')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Shutter"
|
||||||
|
value={pickFirst(exif, 'ExposureTime', 'ShutterSpeedValue')}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Focal"
|
||||||
|
value={pickFirst(
|
||||||
|
exif,
|
||||||
|
'FocalLength',
|
||||||
|
'FocalLengthIn35mmFormat'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="Location"
|
||||||
|
expanded={expandedSections.has('location')}
|
||||||
|
onToggle={() => toggleSection('location')}
|
||||||
|
>
|
||||||
|
{exif.GPSLatitude && exif.GPSLongitude ? (
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<MapPin className="h-3 w-3 text-text-muted" />
|
||||||
|
<span className="font-mono text-text">
|
||||||
|
{String(exif.GPSLatitude)}, {String(exif.GPSLongitude)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-text-muted">No GPS data</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="Tags"
|
||||||
|
expanded={expandedSections.has('tags')}
|
||||||
|
onToggle={() => toggleSection('tags')}
|
||||||
|
>
|
||||||
|
<TagsEditor
|
||||||
|
photoTags={photo.tags ?? []}
|
||||||
|
allTags={allTags}
|
||||||
|
tagInput={tagInput}
|
||||||
|
onTagInputChange={setTagInput}
|
||||||
|
onAttachExisting={(id) => attachExistingTagMutation.mutate(id)}
|
||||||
|
onCreateAndAttach={(name) => {
|
||||||
|
addTagMutation.mutate(name)
|
||||||
|
setTagInput('')
|
||||||
|
}}
|
||||||
|
onRemove={(id) => removeTagMutation.mutate(id)}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
expanded: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border">
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="flex w-full items-center justify-between px-4 py-2 text-sm hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-text">{title}</span>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown className="h-4 w-4 text-text-muted" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4 text-text-muted" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{expanded && <div className="px-4 pb-3">{children}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text placeholder-text-faint focus:border-primary focus:outline-none"
|
||||||
|
/>
|
||||||
|
{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>
|
||||||
|
<span className="text-text-muted">{label}:</span>
|
||||||
|
<p className="break-words text-text">{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -140,7 +140,10 @@ export function PhotoThumbnail({
|
|||||||
alt={photo.filename}
|
alt={photo.filename}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'h-full w-full object-cover transition-opacity duration-200',
|
'h-full w-full object-cover transition-opacity duration-200',
|
||||||
imageLoaded ? 'opacity-100' : 'opacity-0'
|
imageLoaded ? 'opacity-100' : 'opacity-0',
|
||||||
|
// Discarded photos fade out + desaturate so the trash section
|
||||||
|
// reads as a trash section, not just another grid view.
|
||||||
|
photo.is_discarded && 'opacity-50 grayscale'
|
||||||
)}
|
)}
|
||||||
onLoad={handleImageLoad}
|
onLoad={handleImageLoad}
|
||||||
onError={handleImageError}
|
onError={handleImageError}
|
||||||
@@ -216,7 +219,12 @@ export function PhotoThumbnail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{photo.is_discarded && (
|
{photo.is_discarded && (
|
||||||
<Trash2 className="h-4 w-4 text-reject" />
|
<div
|
||||||
|
className="flex h-5 w-5 items-center justify-center rounded-full bg-reject text-white shadow-md"
|
||||||
|
title="Discarded"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { usePhotoStore } from '../store/photoStore'
|
|||||||
import { photos as photosApi, heaps as heapsApi, type Heap } from '../services/api'
|
import { photos as photosApi, heaps as heapsApi, type Heap } from '../services/api'
|
||||||
import { HEAPS_QUERY_KEY } from './useHeapsQuery'
|
import { HEAPS_QUERY_KEY } from './useHeapsQuery'
|
||||||
import { toast } from '../components/ToastContainer'
|
import { toast } from '../components/ToastContainer'
|
||||||
|
import { registerUndoable, useUndoStore } from '../store/undoStore'
|
||||||
|
|
||||||
interface KeyboardShortcutsProps {
|
interface KeyboardShortcutsProps {
|
||||||
onToggleLeftSidebar: () => void
|
onToggleLeftSidebar: () => void
|
||||||
@@ -106,7 +107,29 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
if (ids.length === 0) return
|
if (ids.length === 0) return
|
||||||
|
|
||||||
if (ids.length === 1) {
|
if (ids.length === 1) {
|
||||||
updateMutation.mutate({ id: ids[0], data })
|
const id = ids[0]
|
||||||
|
updateMutation.mutate(
|
||||||
|
{ id, data },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
// Only the discard/restore subset of single-photo updates is
|
||||||
|
// undoable today — rating and color round-trip cleanly enough
|
||||||
|
// that the manual fix is faster than maintaining per-photo
|
||||||
|
// previous-value snapshots.
|
||||||
|
if (data.is_discarded === true) {
|
||||||
|
registerUndoable('Discarded 1 photo', async () => {
|
||||||
|
await photosApi.bulkRestore([id])
|
||||||
|
invalidatePhotoQueries()
|
||||||
|
})
|
||||||
|
} else if (data.is_discarded === false) {
|
||||||
|
registerUndoable('Restored 1 photo', async () => {
|
||||||
|
await photosApi.bulkDiscard([id])
|
||||||
|
invalidatePhotoQueries()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,9 +141,29 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
bulkColorMutation.mutate({ ids, color: data.color_label })
|
bulkColorMutation.mutate({ ids, color: data.color_label })
|
||||||
}
|
}
|
||||||
if (data.is_discarded === true) {
|
if (data.is_discarded === true) {
|
||||||
bulkDiscardMutation.mutate(ids)
|
bulkDiscardMutation.mutate(ids, {
|
||||||
|
onSuccess: () => {
|
||||||
|
registerUndoable(
|
||||||
|
`Discarded ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
|
||||||
|
async () => {
|
||||||
|
await photosApi.bulkRestore(ids)
|
||||||
|
invalidatePhotoQueries()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
} else if (data.is_discarded === false) {
|
} else if (data.is_discarded === false) {
|
||||||
bulkRestoreMutation.mutate(ids)
|
bulkRestoreMutation.mutate(ids, {
|
||||||
|
onSuccess: () => {
|
||||||
|
registerUndoable(
|
||||||
|
`Restored ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
|
||||||
|
async () => {
|
||||||
|
await photosApi.bulkDiscard(ids)
|
||||||
|
invalidatePhotoQueries()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,9 +260,31 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle sidebars
|
// Toggle sidebars. The right sidebar `i` shortcut is grid-only — in
|
||||||
|
// preview mode the PreviewView mounts its own `i` handler for the
|
||||||
|
// overlay info panel, and we don't want both to fire.
|
||||||
useHotkeys('tab', onToggleLeftSidebar, HK_OPTS)
|
useHotkeys('tab', onToggleLeftSidebar, HK_OPTS)
|
||||||
useHotkeys('i', onToggleRightSidebar, HK_OPTS)
|
useHotkeys('i', onToggleRightSidebar, { ...HK_OPTS, enabled: !isPreview })
|
||||||
|
|
||||||
|
// Cmd/Ctrl+Z → pop the most recent undoable action and reverse it.
|
||||||
|
// Bound at the global level so it works in both grid and preview modes.
|
||||||
|
useHotkeys(
|
||||||
|
'mod+z',
|
||||||
|
async () => {
|
||||||
|
const entry = useUndoStore.getState().pop()
|
||||||
|
if (!entry) {
|
||||||
|
toast.info('Nothing to undo')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await entry.undo()
|
||||||
|
} catch (e: any) {
|
||||||
|
useUndoStore.getState().push({ label: entry.label, undo: entry.undo })
|
||||||
|
toast.error('Undo failed', e?.message || 'Unknown error')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
HK_OPTS
|
||||||
|
)
|
||||||
|
|
||||||
// Search focus (/ or Cmd/Ctrl+F).
|
// Search focus (/ or Cmd/Ctrl+F).
|
||||||
const focusSearch = () => {
|
const focusSearch = () => {
|
||||||
|
|||||||
@@ -305,6 +305,16 @@ export const discard = {
|
|||||||
const response = await api.delete('/discard/empty')
|
const response = await api.delete('/discard/empty')
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Permanently delete a specific subset of discarded photos. The backend
|
||||||
|
* silently skips ids that aren't in the pile, so this can never bypass
|
||||||
|
* the soft-delete safety net. */
|
||||||
|
deletePermanent: async (photoIds: string[]) => {
|
||||||
|
const response = await api.delete('/discard', {
|
||||||
|
data: { photo_ids: photoIds },
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
71
frontend/src/store/undoStore.ts
Normal file
71
frontend/src/store/undoStore.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { toast } from '../components/ToastContainer'
|
||||||
|
|
||||||
|
const MAX_STACK = 20
|
||||||
|
|
||||||
|
export interface UndoEntry {
|
||||||
|
id: string
|
||||||
|
/** Short description of what happened, e.g. "Discarded 12 photos". */
|
||||||
|
label: string
|
||||||
|
/** Function that reverses the action. May be async; errors should be
|
||||||
|
* surfaced via toast.error from inside the function. */
|
||||||
|
undo: () => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UndoStore {
|
||||||
|
stack: UndoEntry[]
|
||||||
|
/** Push a new entry. Caps the stack at MAX_STACK by dropping the oldest. */
|
||||||
|
push: (entry: Omit<UndoEntry, 'id'>) => void
|
||||||
|
/** Pop the most recent entry. Returns null when the stack is empty. */
|
||||||
|
pop: () => UndoEntry | null
|
||||||
|
clear: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUndoStore = create<UndoStore>((set, get) => ({
|
||||||
|
stack: [],
|
||||||
|
|
||||||
|
push: (entry) => {
|
||||||
|
const id = Date.now().toString() + Math.random().toString(36).slice(2, 6)
|
||||||
|
set((s) => {
|
||||||
|
const next = [...s.stack, { ...entry, id }]
|
||||||
|
if (next.length > MAX_STACK) next.shift()
|
||||||
|
return { stack: next }
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
pop: () => {
|
||||||
|
const stack = get().stack
|
||||||
|
if (stack.length === 0) return null
|
||||||
|
const last = stack[stack.length - 1]
|
||||||
|
set({ stack: stack.slice(0, -1) })
|
||||||
|
return last
|
||||||
|
},
|
||||||
|
|
||||||
|
clear: () => set({ stack: [] }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: register an undoable action AND show the user a success
|
||||||
|
* toast with an inline Undo button. The toast and Cmd+Z hotkey both pop
|
||||||
|
* from the same stack so either path works.
|
||||||
|
*/
|
||||||
|
export function registerUndoable(label: string, undo: () => void | Promise<void>) {
|
||||||
|
useUndoStore.getState().push({ label, undo })
|
||||||
|
toast.success(label, 'Press ⌘Z to undo', {
|
||||||
|
label: 'Undo',
|
||||||
|
onClick: async () => {
|
||||||
|
// Pop the entry we just pushed (or whatever is now on top, if the
|
||||||
|
// user fired multiple actions in quick succession — Undo always
|
||||||
|
// reverses the most recent thing).
|
||||||
|
const entry = useUndoStore.getState().pop()
|
||||||
|
if (!entry) return
|
||||||
|
try {
|
||||||
|
await entry.undo()
|
||||||
|
} catch (e) {
|
||||||
|
// Re-push so the user can try again, and surface the failure.
|
||||||
|
useUndoStore.getState().push({ label: entry.label, undo: entry.undo })
|
||||||
|
toast.error('Undo failed', e instanceof Error ? e.message : String(e))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export interface Photo {
|
|||||||
is_discarded: boolean
|
is_discarded: boolean
|
||||||
is_duplicate: boolean
|
is_duplicate: boolean
|
||||||
file_hash: string
|
file_hash: string
|
||||||
|
folder_id?: string | null
|
||||||
thumb_small?: string
|
thumb_small?: string
|
||||||
thumb_medium?: string
|
thumb_medium?: string
|
||||||
thumb_large?: string
|
thumb_large?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user