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

View File

@@ -143,7 +143,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [expandedSections, setExpandedSections] = useState<Set<string>>( const [expandedSections, setExpandedSections] = useState<Set<string>>(
new Set(['edit', 'metadata', 'basic', 'camera', 'location']) new Set(['metadata', 'camera'])
) )
const toggleSection = (section: string) => { const toggleSection = (section: string) => {
const next = new Set(expandedSections) 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' : 'border-border bg-bg text-text placeholder-text-faint focus:border-primary'
) )
// Note: no h-full / flex-1 here — the parent (RightSidebar) owns the // Parent (RightSidebar) owns the scroll container; this panel is a
// scroll container so the edit fields and readonly metadata scroll // pair of stacked collapsibles. Metadata holds readonly file/photo
// together as one block beneath the pinned heap + header. // 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 ( return (
<div <div
className={cn( className={cn(
@@ -390,24 +393,66 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
isPlaceholderData && 'opacity-70' 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 <Collapsible
open={expandedSections.has('edit')} open={expandedSections.has('metadata')}
onOpenChange={() => toggleSection('edit')} onOpenChange={() => toggleSection('metadata')}
className="border-b border-border" 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"> <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> <span>Metadata</span>
{expandedSections.has('edit') ? ( {expandedSections.has('metadata') ? (
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
) : ( ) : (
<ChevronRight className="h-3 w-3" /> <ChevronRight className="h-3 w-3" />
)} )}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent className="space-y-2 px-3 py-2">
<div className="space-y-2.5 p-3"> {/* 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"
value={
photo.width && photo.height
? `${photo.width} × ${photo.height}`
: '—'
}
/>
</div>
{/* 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>
{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-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')}
</span>
</a>
)}
{/* 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> <div>
<label className="mb-1 block text-xs text-text-muted">Filename</label> <label className="mb-1 block text-xs text-text-muted">Filename</label>
<Input <Input
@@ -462,7 +507,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
onChange={(e) => setNotesDraft(e.target.value)} onChange={(e) => setNotesDraft(e.target.value)}
onBlur={commitNotes} onBlur={commitNotes}
placeholder="Add notes…" placeholder="Add notes…"
rows={3} rows={2}
className={cn(inputClass, 'resize-none')} className={cn(inputClass, 'resize-none')}
/> />
</div> </div>
@@ -483,10 +528,13 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
/> />
</div> </div>
{/* Rating */} {/* 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> <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-0.5">
{[1, 2, 3, 4, 5].map((value) => ( {[1, 2, 3, 4, 5].map((value) => (
<button <button
key={value} key={value}
@@ -498,7 +546,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
> >
<Star <Star
className={cn( className={cn(
'h-5 w-5 transition-colors', 'h-4 w-4 transition-colors',
value <= rating value <= rating
? 'fill-star text-star' ? 'fill-star text-star'
: 'text-text-muted hover:text-star' : 'text-text-muted hover:text-star'
@@ -508,10 +556,8 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
))} ))}
</div> </div>
</div> </div>
{/* Color label */}
<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>
<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 const active = colorLabel === value
@@ -522,7 +568,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
updateMutation.mutate({ color_label: active ? null : value }) updateMutation.mutate({ color_label: active ? null : value })
} }
className={cn( className={cn(
'h-5 w-5 rounded-full ring-offset-2 ring-offset-surface transition-all', 'h-4 w-4 rounded-full ring-offset-2 ring-offset-surface transition-all',
className, className,
active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100' active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'
)} )}
@@ -533,7 +579,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
{colorLabel && ( {colorLabel && (
<button <button
onClick={() => updateMutation.mutate({ color_label: null })} onClick={() => updateMutation.mutate({ color_label: null })}
className="ml-1 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text" className="ml-0.5 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" />
@@ -541,11 +587,11 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
)} )}
</div> </div>
</div> </div>
</div>
{/* Flag — Select + Discard */}
<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-1.5">
<button <button
onClick={() => { onClick={() => {
if (!activeHeap) return if (!activeHeap) return
@@ -553,7 +599,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
}} }}
disabled={!activeHeap || heapMutation.isPending} disabled={!activeHeap || heapMutation.isPending}
className={cn( className={cn(
'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-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50',
isInActiveHeap isInActiveHeap
? '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'
@@ -572,7 +618,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
<button <button
onClick={() => updateMutation.mutate({ is_discarded: !isDiscarded })} onClick={() => updateMutation.mutate({ is_discarded: !isDiscarded })}
className={cn( className={cn(
'flex items-center gap-1 rounded px-2 py-1 text-sm transition-colors', 'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors',
isDiscarded isDiscarded
? 'bg-reject/20 text-reject' ? 'bg-reject/20 text-reject'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset' : 'bg-surface-2 text-text-muted hover:bg-surface-offset'
@@ -587,58 +633,22 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
{/* Read-only metadata — collapsed/expanded as one block so the user {/* Camera — its own collapsible at the bottom so the EXIF block
* can hide everything below the editable form with a single click. * doesn't crowd the primary metadata + edit form. */}
* Sub-sections inside stay individually collapsible for finer
* control once the outer group is open. */}
<Collapsible <Collapsible
open={expandedSections.has('metadata')} open={expandedSections.has('camera')}
onOpenChange={() => toggleSection('metadata')} onOpenChange={() => toggleSection('camera')}
className="border-b border-border" 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"> <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>Metadata</span> <span>Camera</span>
{expandedSections.has('metadata') ? ( {expandedSections.has('camera') ? (
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
) : ( ) : (
<ChevronRight className="h-3 w-3" /> <ChevronRight className="h-3 w-3" />
)} )}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent className="space-y-1.5 px-3 py-2 text-xs">
<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}`
: '—'
}
/>
</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}>
{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"> <div className="flex items-center gap-2">
<Camera className="h-3 w-3 text-text-muted" /> <Camera className="h-3 w-3 text-text-muted" />
<span className="text-text"> <span className="text-text">
@@ -653,7 +663,7 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
{pickFirst(exif, 'LensModel', 'Lens')} {pickFirst(exif, 'LensModel', 'Lens')}
</span> </span>
</div> </div>
<div className="mt-2 grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-x-2 gap-y-1">
<Field label="ISO" value={formatExifValue(exif.ISO)} /> <Field label="ISO" value={formatExifValue(exif.ISO)} />
<Field <Field
label="Aperture" label="Aperture"
@@ -669,77 +679,15 @@ export function PhotoInfoPanel({ photoId, darkTheme = false }: PhotoInfoPanelPro
/> />
<Field <Field
label="Focal" label="Focal"
value={pickFirst( value={pickFirst(exif, 'FocalLength', 'FocalLengthIn35mmFormat')}
exif,
'FocalLength',
'FocalLengthIn35mmFormat'
)}
/> />
</div> </div>
</div>
</Section>
<Section
title="Location"
expanded={expandedSections.has('location')}
onToggle={() => toggleSection('location')}
>
{photo.latitude != null && photo.longitude != null ? (
<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"
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')}
</span>
</a>
) : (
<div className="text-xs text-text-muted">No GPS data</div>
)}
</Section>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</div> </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 }) { function Field({ label, value }: { label: string; value: string }) {
return ( return (
<div> <div>