From 64b84e206953e67c007cc7d91d693301aa3fa445 Mon Sep 17 00:00:00 2001 From: dtoro Date: Thu, 9 Apr 2026 17:35:06 +0200 Subject: [PATCH] feat: active heap card in left sidebar with subtle toasts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New ActiveHeapCard pinned just below the Library header in the left sidebar. Renders null when no heap is active. When one is, shows the heap name + member count + a fan of the last 5 member thumbnails (newest front-and-center, older members rotated ±6°/±18px outward). Clicking the header navigates to the heap via the same navigateToSection pattern HeapsPanel uses. - Stack animates with framer-motion (previously pinned in package.json but unused). New picks spring-slide into the front of the stack by subscribing to the ['heap-photo-ids', heapId] cache that the existing pick mutation already updates optimistically — no new event wiring. Unpicks run the exit transition and the remaining cards re-fan. - Toasts are now subtle: glassy bg-surface/80 + backdrop-blur, thin 2px left accent bar in the type color instead of a full tinted fill, smaller icons and text, tighter padding, truncation on overflow so they stay a single compact row. The colored alert block that was competing with the rest of the UI is gone. - Card lives at the top of the sidebar specifically so the bottom-left toast stack can never occlude it. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/ToastContainer.tsx | 48 +++--- .../src/components/heaps/ActiveHeapCard.tsx | 158 ++++++++++++++++++ .../src/components/layout/LeftSidebar.tsx | 6 + 3 files changed, 192 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/heaps/ActiveHeapCard.tsx 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 ( -
+
{toasts.map((toast) => (
- {icons[toast.type]} -
-
{toast.title}
+
{icons[toast.type]}
+
+
{toast.title}
{toast.message && ( -
{toast.message}
+
+ {toast.message} +
)}
{toast.action && ( @@ -106,16 +114,16 @@ export function ToastContainer() { toast.action!.onClick() removeToast(toast.id) }} - className="pointer-events-auto self-center rounded border border-border bg-surface px-2 py-1 text-xs font-medium text-text hover:bg-surface-2" + className="pointer-events-auto self-center rounded border border-border bg-surface px-1.5 py-0.5 text-[11px] font-medium text-text hover:bg-surface-2" > {toast.action.label} )}
))} 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 */}
{libraryTree.map((item) => renderTreeItem(item))}