4 Commits

Author SHA1 Message Date
07b9660e92 feat: undo for destructive photo actions
Add a global last-action stack with toast-based "Undo" buttons and a
Cmd/Ctrl+Z hotkey for the destructive photo operations.

Reversible:
- X (discard) → bulkRestore
- U (restore) → bulkDiscard
- Drag-onto-Discarded → bulkRestore
- Drag-onto-folder (move) → move back to per-photo source folders. The
  source folder ids are snapshotted from the photos cache before the
  move runs, then grouped so multi-source moves restore correctly.
- Restore button in the discard action bar → bulkDiscard

Toast gains an optional action button (label + onClick); toasts with an
action stay visible longer so the user has time to click. The undo
store caps at 20 entries; failed undo re-pushes the entry so the user
can try again.

Not reversible (call out, document later): rating, color label, copy,
permanent delete from trash, tag changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:08:58 +02:00
b870084be0 feat: per-photo permanent delete + discarded thumbnail treatment
Discarded photos now look discarded in the grid (50% opacity + grayscale)
with a red trash badge in the corner instead of a bare icon. The discard
action bar gains a "Delete N" button that permanently deletes only the
current selection, complementing the existing "Empty discard pile".

Backend: new DELETE /discard endpoint accepting {photo_ids: [...]} that
permanently removes only listed photos. Skips ids that aren't in the
discard pile so it can never bypass the soft-delete safety net.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:12:11 +02:00
3a03a56db2 feat: photo metadata panel in preview view
Extract the single-photo body of RightSidebar into a reusable PhotoInfoPanel
(rating / color / flag / filename / title / notes / tags / EXIF) and mount
it inside PreviewView as a toggleable right-side overlay so the user can
rate, tag, and read EXIF without leaving the loupe.

- New PhotoInfoPanel: self-contained, owns its own queries and mutations,
  takes a single photoId. darkTheme prop reserved for future use.
- RightSidebar: thinned down — delegates the single-select case to
  PhotoInfoPanel, keeps its own slim bulk-action panel for multi-select.
- PreviewView: I toggles the panel; new top-right Info button mirrors it.
- useKeyboardShortcuts: gate the global I (right-sidebar toggle) to grid
  mode so it doesn't double-fire alongside the preview-scoped handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 13:01:25 +02:00
1c428dda8b fix: portal FilterPill popover so it escapes overflow clipping
The FilterBar uses overflow-x-auto for horizontal scroll, which forces
overflow-y to auto as well — that was clipping the absolutely-positioned
pill popovers below the bar. Render the popover into document.body via a
portal with fixed coordinates derived from getBoundingClientRect(), and
clamp the left edge so right-most pills don't push the popover off-screen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 12:53:04 +02:00
13 changed files with 1345 additions and 817 deletions

View File

@@ -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:

View File

@@ -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"

View File

@@ -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?"

View File

@@ -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> </>
) )
} }

View File

@@ -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'] })

View File

@@ -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>
) )
} }

View File

@@ -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>
) )
} }

View 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>
)
}

View File

@@ -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>

View File

@@ -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 = () => {

View File

@@ -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

View 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))
}
},
})
}

View File

@@ -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