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:
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user