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