ui(sidebar): compact two-section panel, drop active-heap card

The right sidebar had three top-level blocks (ActiveHeapCard + Header
title strip + scroll region with two parallel collapsibles 'Edit' and
'Metadata'). Three nested Section sub-collapsibles inside Metadata
added another row of chevrons per group. A lot of chrome for what is
fundamentally one form per photo.

Refactor:

- RightSidebar: remove ActiveHeapCard import + both usages
  (empty-selection branch and single-photo branch). Single-photo
  branch also drops the redundant Header strip; the new Metadata
  collapsible's trigger IS the visible section title. Multi-photo
  branch keeps Header (still needs 'N Photos Selected').

- PhotoInfoPanel: collapse the Edit and Metadata-with-sub-Sections
  structure into two flat collapsibles. Metadata holds readonly facts
  (Size / Dimensions grid, Path, GPS inlined) and the editable form
  (Filename, Title, Date Taken, Notes, Tags, Rating + Color on one
  row, Flag), separated by a thin horizontal rule. Camera lives in
  its own collapsible at the bottom so a long EXIF block can't crowd
  the form. Default expanded set narrows to ['metadata', 'camera'].

- Compact density: Notes rows=3 -> rows=2, rating + color share a
  row, stars/swatches shrink h-5/w-5 -> h-4/w-4, space-y-2.5 -> 2,
  Flag buttons text-sm -> text-xs, grid gaps tightened. The empty
  'No GPS data' chip is hidden when there are no coordinates rather
  than rendered as an empty row.

- Drop the unused local Section helper and the now-orphan
  ActiveHeapCard.tsx file. Active-heap state stays in the store; the
  Select / Discard buttons inside the form still consult activeHeap
  on click.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claudio
2026-05-10 23:43:33 +02:00
parent f63daf16a8
commit c69322a89d
3 changed files with 262 additions and 498 deletions

View File

@@ -1,180 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { ShoppingBasket } from 'lucide-react'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { useFilterStore } from '../../store/filterStore'
import { photos as photosApi, heaps as heapsApi } from '../../services/api'
import cardBg from '../../assets/card.png'
/** How many thumbnails fan out across the stack at once. The newest is
* drawn last (top), older ones fan back-left and back-right. */
const STACK_SIZE = 5
/**
* Pinned card at the bottom of the LeftSidebar showing the currently
* active heap. Renders nothing when no heap is active — the parent
* layout collapses around it cleanly.
*
* The card is a fast nav shortcut + a satisfying landing spot for the P
* pick action: every new pick optimistically updates the
* ['heap-photo-ids', heapId] cache that the existing pick mutation
* already maintains, so we just subscribe to the same query and let
* framer-motion's AnimatePresence handle the entrance/exit animation
* when ids appear or disappear.
*/
export function ActiveHeapCard() {
const { activeHeap } = useActiveHeapMembers()
const navigateToSection = useFilterStore((s) => s.navigateToSection)
// Subscribes to the same cache key the pick mutation optimistically
// updates. The data is already fetched (and kept fresh) by
// useActiveHeapMembers above; this useQuery just gives us a render-
// dependency on the array contents and stable insertion order.
const { data: orderedIds = [] } = useQuery<string[]>({
queryKey: ['heap-photo-ids', activeHeap?.id],
queryFn: () => heapsApi.photoIds(activeHeap!.id),
enabled: !!activeHeap,
staleTime: 30_000,
})
if (!activeHeap) return null
// Show the latest STACK_SIZE photos. The backend returns ids in
// insertion order so the last one in the array is the most recently
// picked — that's the one we want at the front of the stack.
const visible = orderedIds.slice(-STACK_SIZE)
// We render newest LAST so it draws on top via z-index. Reverse so
// index 0 is the back card and index N-1 is the front.
const stack = visible.map((id, i) => ({
id,
// Symmetric fan: front card has rotate=0, x=0; cards behind it
// alternate left/right as you walk back through the stack.
rotate: stackRotate(i, visible.length),
x: stackOffsetX(i, visible.length),
y: stackOffsetY(i, visible.length),
z: i,
}))
return (
<>
<div className="flex h-9 flex-shrink-0 items-center border-b border-border px-3 text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted">
Active Heap
</div>
<div className="m-1.5 rounded-md border border-border bg-surface-2 shadow-sm">
{/* Header — clickable, navigates to the heap section. */}
<button
onClick={() =>
navigateToSection(`heap-${activeHeap.id}`, { heapId: activeHeap.id })
}
className="flex w-full items-center gap-1.5 rounded-t-md px-2 py-1.5 text-left hover:bg-surface-offset"
title={`Open "${activeHeap.name}"`}
>
<ShoppingBasket className="h-3.5 w-3.5 flex-shrink-0 text-pick" />
<span className="min-w-0 flex-1 truncate text-[12px] font-semibold text-text">
{activeHeap.name}
</span>
<span className="flex h-4 min-w-[20px] items-center justify-center rounded bg-surface px-1 text-[10px] font-medium text-text-muted">
{orderedIds.length}
</span>
</button>
{/* Stack row. Re-keyed on activeHeap.id so switching heaps tears
* the animation context down cleanly instead of trying to
* crossfade unrelated photos. The desert scene sits behind the
* fanned thumbnails — `cover` + `bottom` keeps the dunes anchored
* so the cacti frame the photos rather than the (transparent) sky. */}
<div
key={activeHeap.id}
className="relative h-20 overflow-hidden rounded-b-md px-2 pb-2"
style={{
backgroundImage: `url(${cardBg})`,
backgroundSize: 'cover',
backgroundPosition: 'center bottom',
backgroundRepeat: 'no-repeat',
imageRendering: 'pixelated',
opacity: 0.7,
}}
>
{visible.length === 0 ? (
<div className="flex h-full items-center justify-center px-2 text-center">
<span className="rounded bg-black/70 px-2 py-1 text-[11px] font-medium text-white shadow-sm backdrop-blur-sm">
Select photos with{' '}
<kbd className="rounded bg-white/20 px-1 font-mono text-[10px] text-white">
S
</kbd>{' '}
to fill the heap
</span>
</div>
) : (
<div className="relative h-full">
<AnimatePresence initial={false}>
{stack.map((item) => (
<motion.img
key={item.id}
src={photosApi.getThumbnailUrl(item.id, 'small')}
alt=""
initial={{ x: 80, y: 0, scale: 0.6, rotate: 0, opacity: 0 }}
animate={{
x: item.x,
y: item.y,
scale: 1,
rotate: item.rotate,
opacity: 1,
}}
exit={{ x: -60, scale: 0.6, opacity: 0 }}
transition={{ type: 'spring', stiffness: 360, damping: 28 }}
style={{
zIndex: item.z,
position: 'absolute',
left: '50%',
top: '50%',
marginLeft: -26, // half of w-13
marginTop: -26, // half of h-13
}}
className="h-[52px] w-[52px] rounded object-cover shadow-[0_4px_10px_rgba(0,0,0,0.65),0_2px_4px_rgba(0,0,0,0.5)] ring-1 ring-black/60"
/>
))}
</AnimatePresence>
</div>
)}
</div>
</div>
</>
)
}
// ── Stack geometry ───────────────────────────────────────────────────────
//
// `i` is the position in the visible array (0 = oldest, last = newest).
// We want the newest card at center (rotate 0, x 0) and earlier cards
// fanning symmetrically outward — so we score each card by how far it
// is from the front, alternating sign.
const X_STEP = 18 // pixels per fan step
const Y_STEP = 2 // tiny vertical lift so the back cards peek above
const ROTATE_STEP = 6 // degrees per fan step
function stackRotate(i: number, len: number): number {
// Distance from the front (newest). Front card → 0, then alternating
// -1, +1, -2, +2 ... to spread cards outward.
const fromFront = len - 1 - i
if (fromFront === 0) return 0
const sign = fromFront % 2 === 1 ? -1 : 1
const magnitude = Math.ceil(fromFront / 2)
return sign * magnitude * ROTATE_STEP
}
function stackOffsetX(i: number, len: number): number {
const fromFront = len - 1 - i
if (fromFront === 0) return 0
const sign = fromFront % 2 === 1 ? -1 : 1
const magnitude = Math.ceil(fromFront / 2)
return sign * magnitude * X_STEP
}
function stackOffsetY(i: number, len: number): number {
// Back cards lift up a couple pixels so they're visible above the
// front card's top edge — gives the stack its sense of depth.
const fromFront = len - 1 - i
return fromFront * -Y_STEP
}

View File

@@ -14,7 +14,6 @@ import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
import { stripPhotosFromCache } from '../../hooks/usePhotosQuery'
import { toast } from '../ToastContainer'
import { ActiveHeapCard } from '../heaps/ActiveHeapCard'
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
import { BulkTakenAtEditor } from '../sidebar/BulkTakenAtEditor'
import { BulkTagsEditor } from '../sidebar/BulkTagsEditor'
@@ -209,7 +208,6 @@ export function RightSidebar() {
role="region"
aria-label="Photo metadata"
>
<ActiveHeapCard />
<Header />
<div className="flex flex-1 items-center justify-center p-4 text-center">
<div className="text-text-muted">
@@ -236,9 +234,9 @@ export function RightSidebar() {
}
// ── Single-photo: full editor via PhotoInfoPanel ────────────────────
// Heap card + header stay pinned at the top; the edit fields and
// readonly metadata sections scroll together in a single overflow
// region below.
// No heap card and no separate title strip: PhotoInfoPanel's own
// collapsible headers ("Metadata", "Camera") are the visible
// section titles. The whole content area is one scroll region.
if (selectedPhotos.length === 1) {
const id = activePhotoId ?? selectedPhotos[0]
return (
@@ -247,8 +245,6 @@ export function RightSidebar() {
role="region"
aria-label="Photo metadata"
>
<ActiveHeapCard />
<Header />
<div className="flex-1 overflow-y-auto">
<PhotoInfoPanel photoId={id} />
</div>

View File

@@ -143,7 +143,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
const queryClient = useQueryClient()
const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['edit', 'metadata', 'basic', 'camera', 'location'])
new Set(['metadata', 'camera'])
)
const toggleSection = (section: string) => {
const next = new Set(expandedSections)
@@ -380,9 +380,12 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
: 'border-border bg-bg text-text placeholder-text-faint focus:border-primary'
)
// Note: no h-full / flex-1 here — the parent (RightSidebar) owns the
// scroll container so the edit fields and readonly metadata scroll
// together as one block beneath the pinned heap + header.
// Parent (RightSidebar) owns the scroll container; this panel is a
// pair of stacked collapsibles. Metadata holds readonly file/photo
// facts plus the editable form (separated by a thin <hr>). Camera
// is isolated at the bottom so a long EXIF block doesn't push the
// primary form off-screen.
const hasGps = photo.latitude != null && photo.longitude != null
return (
<div
className={cn(
@@ -390,207 +393,6 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
isPlaceholderData && 'opacity-70'
)}
>
{/* Edit fields — collapsible group so the user can hide the
* editable form (filename, title, notes, rating, color, flag)
* the same way they can hide the readonly metadata block below. */}
<Collapsible
open={expandedSections.has('edit')}
onOpenChange={() => toggleSection('edit')}
className="border-b border-border"
>
<CollapsibleTrigger className="flex w-full items-center justify-between border-b border-border bg-surface-2/40 px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:bg-surface-2 hover:text-text">
<span>Edit</span>
{expandedSections.has('edit') ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</CollapsibleTrigger>
<CollapsibleContent>
<div className="space-y-2.5 p-3">
<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>
<TakenAtEditor
photo={photo}
draft={takenAtDraft}
onDraftChange={setTakenAtDraft}
onCommit={commitTakenAt}
darkTheme={darkTheme}
/>
<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={cn(inputClass, 'resize-none')}
/>
</div>
<div>
<label className="mb-1 block text-xs text-text-muted">Tags</label>
<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)}
/>
</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={cn(
'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={cn(
'h-5 w-5 rounded-full ring-offset-2 ring-offset-surface transition-all',
className,
active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'
)}
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 — Select + 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={cn(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-50',
isInActiveHeap
? 'bg-pick/20 text-pick'
: '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 ? 'Selected' : 'Select'}
</button>
<button
onClick={() => updateMutation.mutate({ is_discarded: !isDiscarded })}
className={cn(
'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>
</CollapsibleContent>
</Collapsible>
{/* Read-only metadata — collapsed/expanded as one block so the user
* can hide everything below the editable form with a single click.
* Sub-sections inside stay individually collapsible for finer
* control once the outer group is open. */}
<Collapsible
open={expandedSections.has('metadata')}
onOpenChange={() => toggleSection('metadata')}
@@ -604,13 +406,9 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
<ChevronRight className="h-3 w-3" />
)}
</CollapsibleTrigger>
<CollapsibleContent>
<Section
title="Basic Info"
expanded={expandedSections.has('basic')}
onToggle={() => toggleSection('basic')}
>
<div className="grid grid-cols-2 gap-2 text-xs">
<CollapsibleContent className="space-y-2 px-3 py-2">
{/* Readonly: size / dims / path / gps. No sub-headers. */}
<div className="grid grid-cols-2 gap-x-2 gap-y-1 text-xs">
<Field label="Size" value={formatFileSize(photo.file_size)} />
<Field
label="Dimensions"
@@ -621,125 +419,275 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
}
/>
</div>
{/* Filepath spans the full sidebar width — most paths are long
* enough that the two-column grid above wraps them painfully.
* Mono so each character lines up under the next, break-all
* so we never overflow horizontally on a long basename. */}
<div className="mt-2 text-xs">
<span className="text-text-muted">Path:</span>
<p className="mt-0.5 break-all font-mono text-[11px] text-text" title={photo.filepath}>
{/* Filepath: full-width mono so it wraps cleanly instead of
* stretching the two-column grid. */}
<div className="text-xs">
<span className="text-text-muted">Path</span>
<p
className="mt-0.5 break-all font-mono text-[11px] text-text"
title={photo.filepath}
>
{photo.filepath || '—'}
</p>
</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')}
>
{photo.latitude != null && photo.longitude != null ? (
{hasGps && (
<a
href={`https://www.openstreetmap.org/?mlat=${photo.latitude}&mlon=${photo.longitude}#map=15/${photo.latitude}/${photo.longitude}`}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 text-xs hover:underline"
className="flex items-center gap-1.5 text-xs hover:underline"
title="Open in OpenStreetMap"
>
<MapPin className="h-3 w-3 text-text-muted" />
<span className="font-mono text-text">
{formatLatLon(photo.latitude, 'lat')},{' '}
{formatLatLon(photo.longitude, 'lon')}
{formatLatLon(photo.latitude!, 'lat')},{' '}
{formatLatLon(photo.longitude!, 'lon')}
</span>
</a>
) : (
<div className="text-xs text-text-muted">No GPS data</div>
)}
</Section>
{/* Visual separator between readonly facts and the editable
* form — no second collapsible needed. */}
<hr className="my-1 border-border/60" />
{/* Editable form. Tighter space-y than before; rating + color
* share one row to save vertical space. */}
<div className="space-y-2">
<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>
<TakenAtEditor
photo={photo}
draft={takenAtDraft}
onDraftChange={setTakenAtDraft}
onCommit={commitTakenAt}
darkTheme={darkTheme}
/>
<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={2}
className={cn(inputClass, 'resize-none')}
/>
</div>
<div>
<label className="mb-1 block text-xs text-text-muted">Tags</label>
<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)}
/>
</div>
{/* Rating + Color share a row. Both are compact controls
* (5 stars / 4 swatches), so the panel doesn't feel cramped
* and we recover a row of vertical space. */}
<div className="flex items-start gap-4">
<div>
<label className="mb-1 block text-xs text-text-muted">Rating</label>
<div className="flex gap-0.5">
{[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={cn(
'h-4 w-4 transition-colors',
value <= rating
? 'fill-star text-star'
: 'text-text-muted hover:text-star'
)}
/>
</button>
))}
</div>
</div>
<div>
<label className="mb-1 block text-xs text-text-muted">Color</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={cn(
'h-4 w-4 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-0.5 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>
</div>
<div>
<label className="mb-1 block text-xs text-text-muted">Flag</label>
<div className="flex gap-1.5">
<button
onClick={() => {
if (!activeHeap) return
heapMutation.mutate({ remove: isInActiveHeap })
}}
disabled={!activeHeap || heapMutation.isPending}
className={cn(
'flex items-center gap-1 rounded px-2 py-1 text-xs 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 ? 'Selected' : 'Select'}
</button>
<button
onClick={() => updateMutation.mutate({ is_discarded: !isDiscarded })}
className={cn(
'flex items-center gap-1 rounded px-2 py-1 text-xs 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>
</CollapsibleContent>
</Collapsible>
{/* Camera — its own collapsible at the bottom so the EXIF block
* doesn't crowd the primary metadata + edit form. */}
<Collapsible
open={expandedSections.has('camera')}
onOpenChange={() => toggleSection('camera')}
className="border-b border-border"
>
<CollapsibleTrigger className="flex w-full items-center justify-between border-b border-border bg-surface-2/40 px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:bg-surface-2 hover:text-text">
<span>Camera</span>
{expandedSections.has('camera') ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</CollapsibleTrigger>
<CollapsibleContent className="space-y-1.5 px-3 py-2 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="grid grid-cols-2 gap-x-2 gap-y-1">
<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>
</CollapsibleContent>
</Collapsible>
</div>
)
}
function Section({
title,
expanded,
onToggle,
children,
}: {
title: string
expanded: boolean
onToggle: () => void
children: React.ReactNode
}) {
return (
<Collapsible
open={expanded}
onOpenChange={onToggle}
className="border-b border-border"
>
<CollapsibleTrigger className="flex w-full items-center justify-between px-3 py-1.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-text-muted hover:bg-surface-2 hover:text-text">
<span>{title}</span>
{expanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</CollapsibleTrigger>
<CollapsibleContent className="px-3 pb-2.5">
{children}
</CollapsibleContent>
</Collapsible>
)
}
function Field({ label, value }: { label: string; value: string }) {
return (
<div>