Files
mule-image/frontend/src/components/heaps/ActiveHeapCard.tsx
dtoro 367d1277b8 fix: stronger drop shadow on heap card thumbnails
Bump the ActiveHeapCard stack thumbnails to a layered drop shadow and
a darker ring so they lift cleanly off the softened desert backdrop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 20:11:33 +02:00

170 lines
6.9 KiB
TypeScript

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="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 text-[11px] text-text-faint">
Pick photos with P to fill the heap
</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
}