diff --git a/frontend/src/components/ToastContainer.tsx b/frontend/src/components/ToastContainer.tsx
index 331f37b..db8cd08 100644
--- a/frontend/src/components/ToastContainer.tsx
+++ b/frontend/src/components/ToastContainer.tsx
@@ -67,37 +67,45 @@ export function ToastContainer() {
}
}, [])
+ // Subdued icons — smaller and muted so the toast reads as a
+ // background notification rather than a modal. The colored tint
+ // comes from the border-left accent, not a filled background.
const icons = {
- success: ,
- error: ,
- info: ,
- warning: ,
+ success: ,
+ error: ,
+ info: ,
+ warning: ,
}
- const colors = {
- success: 'border-pick bg-pick/10',
- error: 'border-reject bg-reject/10',
- info: 'border-primary bg-primary/10',
- warning: 'border-star bg-star/10',
+ // Single thin left accent bar per type instead of a full-border +
+ // tinted fill. Keeps the toast visually quiet — the user can still
+ // glance it but it doesn't compete with the rest of the UI.
+ const accents = {
+ success: 'border-l-pick',
+ error: 'border-l-reject',
+ info: 'border-l-primary',
+ warning: 'border-l-star',
}
return (
-
))}
diff --git a/frontend/src/components/heaps/ActiveHeapCard.tsx b/frontend/src/components/heaps/ActiveHeapCard.tsx
new file mode 100644
index 0000000..d47d2f8
--- /dev/null
+++ b/frontend/src/components/heaps/ActiveHeapCard.tsx
@@ -0,0 +1,158 @@
+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'
+
+/** 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({
+ 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 (
+
+ {/* Header — clickable, navigates to the heap section. */}
+
+
+ {/* Stack row. Re-keyed on activeHeap.id so switching heaps tears
+ * the animation context down cleanly instead of trying to
+ * crossfade unrelated photos. */}
+
+ {visible.length === 0 ? (
+
+ Pick photos with P to fill the heap
+
+ ) : (
+
+
+ {stack.map((item) => (
+
+ ))}
+
+
+ )}
+
+
+ )
+}
+
+// ── 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
+}
diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx
index a2b99b0..e719003 100644
--- a/frontend/src/components/layout/LeftSidebar.tsx
+++ b/frontend/src/components/layout/LeftSidebar.tsx
@@ -22,6 +22,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from '../ToastContainer'
import { useFilterStore } from '../../store/filterStore'
import { HeapsPanel } from '../heaps/HeapsPanel'
+import { ActiveHeapCard } from '../heaps/ActiveHeapCard'
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
import { useTagsQuery } from '../../hooks/useTagsQuery'
@@ -668,6 +669,11 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
+ {/* Active heap card — pinned just below the Library header so
+ * toasts (bottom-left fixed) can't cover it. Returns null when
+ * no heap is active, so the layout collapses cleanly. */}
+
+
{/* Tree View */}