feat: active heap card in left sidebar with subtle toasts
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 = {
|
const icons = {
|
||||||
success: <CheckCircle className="h-5 w-5 text-pick" />,
|
success: <CheckCircle className="h-3.5 w-3.5 text-pick" />,
|
||||||
error: <XCircle className="h-5 w-5 text-reject" />,
|
error: <XCircle className="h-3.5 w-3.5 text-reject" />,
|
||||||
info: <Info className="h-5 w-5 text-primary" />,
|
info: <Info className="h-3.5 w-3.5 text-primary" />,
|
||||||
warning: <AlertCircle className="h-5 w-5 text-star" />,
|
warning: <AlertCircle className="h-3.5 w-3.5 text-star" />,
|
||||||
}
|
}
|
||||||
|
|
||||||
const colors = {
|
// Single thin left accent bar per type instead of a full-border +
|
||||||
success: 'border-pick bg-pick/10',
|
// tinted fill. Keeps the toast visually quiet — the user can still
|
||||||
error: 'border-reject bg-reject/10',
|
// glance it but it doesn't compete with the rest of the UI.
|
||||||
info: 'border-primary bg-primary/10',
|
const accents = {
|
||||||
warning: 'border-star bg-star/10',
|
success: 'border-l-pick',
|
||||||
|
error: 'border-l-reject',
|
||||||
|
info: 'border-l-primary',
|
||||||
|
warning: 'border-l-star',
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-none fixed bottom-4 left-4 z-50 flex flex-col gap-2">
|
<div className="pointer-events-none fixed bottom-4 left-4 z-50 flex flex-col gap-1.5">
|
||||||
{toasts.map((toast) => (
|
{toasts.map((toast) => (
|
||||||
<div
|
<div
|
||||||
key={toast.id}
|
key={toast.id}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'pointer-events-auto flex items-start gap-3 rounded-lg border p-3 shadow-lg backdrop-blur-sm transition-all duration-300',
|
'pointer-events-auto flex items-start gap-2 rounded-md border border-border border-l-2 bg-surface/80 px-2.5 py-1.5 text-xs shadow-md backdrop-blur-md transition-all duration-300',
|
||||||
'animate-slide-up',
|
'animate-slide-up',
|
||||||
colors[toast.type]
|
accents[toast.type]
|
||||||
)}
|
)}
|
||||||
style={{ minWidth: '300px', maxWidth: '400px' }}
|
style={{ minWidth: '220px', maxWidth: '320px' }}
|
||||||
>
|
>
|
||||||
{icons[toast.type]}
|
<div className="mt-0.5 flex-shrink-0">{icons[toast.type]}</div>
|
||||||
<div className="flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-medium text-text">{toast.title}</div>
|
<div className="truncate font-medium text-text">{toast.title}</div>
|
||||||
{toast.message && (
|
{toast.message && (
|
||||||
<div className="mt-0.5 text-sm text-text-muted">{toast.message}</div>
|
<div className="mt-0.5 truncate text-[11px] text-text-muted">
|
||||||
|
{toast.message}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{toast.action && (
|
{toast.action && (
|
||||||
@@ -106,16 +114,16 @@ export function ToastContainer() {
|
|||||||
toast.action!.onClick()
|
toast.action!.onClick()
|
||||||
removeToast(toast.id)
|
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}
|
{toast.action.label}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => removeToast(toast.id)}
|
onClick={() => removeToast(toast.id)}
|
||||||
className="pointer-events-auto rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
|
className="pointer-events-auto rounded p-0.5 text-text-faint hover:bg-surface-offset hover:text-text"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
158
frontend/src/components/heaps/ActiveHeapCard.tsx
Normal file
158
frontend/src/components/heaps/ActiveHeapCard.tsx
Normal file
@@ -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<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-2 rounded-lg 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-2 rounded-t-lg px-3 py-2 text-left hover:bg-surface-offset"
|
||||||
|
title={`Open "${activeHeap.name}"`}
|
||||||
|
>
|
||||||
|
<ShoppingBasket className="h-4 w-4 flex-shrink-0 text-pick" />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-text">
|
||||||
|
{activeHeap.name}
|
||||||
|
</span>
|
||||||
|
<span className="flex h-5 min-w-[24px] items-center justify-center rounded bg-surface px-1.5 text-[11px] 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. */}
|
||||||
|
<div
|
||||||
|
key={activeHeap.id}
|
||||||
|
className="relative h-24 overflow-hidden px-3 pb-3"
|
||||||
|
>
|
||||||
|
{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: -32, // half of w-16
|
||||||
|
marginTop: -32, // half of h-16
|
||||||
|
}}
|
||||||
|
className="h-16 w-16 rounded object-cover shadow-md ring-1 ring-black/40"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
import { useFilterStore } from '../../store/filterStore'
|
import { useFilterStore } from '../../store/filterStore'
|
||||||
import { HeapsPanel } from '../heaps/HeapsPanel'
|
import { HeapsPanel } from '../heaps/HeapsPanel'
|
||||||
|
import { ActiveHeapCard } from '../heaps/ActiveHeapCard'
|
||||||
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
||||||
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
|
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
|
||||||
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||||
@@ -668,6 +669,11 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
|
|||||||
<PanelLeftClose className="h-4 w-4" />
|
<PanelLeftClose className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 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. */}
|
||||||
|
<ActiveHeapCard />
|
||||||
|
|
||||||
{/* Tree View */}
|
{/* Tree View */}
|
||||||
<div className="flex-1 overflow-y-auto py-2">
|
<div className="flex-1 overflow-y-auto py-2">
|
||||||
{libraryTree.map((item) => renderTreeItem(item))}
|
{libraryTree.map((item) => renderTreeItem(item))}
|
||||||
|
|||||||
Reference in New Issue
Block a user