Files
pansy/web/src/components/ui/toast.tsx
T
steveandClaude Opus 4.8 757ac7394d
Build image / build-and-push (push) Successful in 16s
Address Gadfly findings on #85
- PublicGardenPage had the same 100vh mobile bug I fixed in the editor — 100dvh
  there too, so the fix is consistent across both full-height pages.
- Cap the toast stack at 4 (drop oldest). Now that error toasts don't auto-
  dismiss, a burst of failures could otherwise grow the stack unbounded and push
  the newest — the one that just happened — off-screen.
- Hoist the From/To date-input styling to a shared dateInputClass const so the
  two don't drift.

Not taken: reusing safeRedirectPath for the 401 redirect (it validates an
incoming redirect param, it doesn't build the outgoing one — the two uses don't
overlap), and refactoring the three-branch filter spread (it's clear at three
fields; a helper earns its keep when there are more).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 23:53:18 -04:00

86 lines
2.8 KiB
TypeScript

import { useEffect } from 'react'
import { create } from 'zustand'
import { cn } from '@/lib/cn'
type ToastTone = 'info' | 'error'
interface Toast {
id: number
message: string
tone: ToastTone
}
interface ToastState {
toasts: Toast[]
push: (message: string, tone?: ToastTone) => void
dismiss: (id: number) => void
}
let nextId = 1
// Error toasts no longer auto-dismiss (#85), so a burst of failures could grow
// the stack without bound and push older ones off-screen. Cap it: keep the most
// recent MAX_TOASTS and drop the oldest, so the newest — the one that just
// happened — is always visible.
const MAX_TOASTS = 4
export const useToastStore = create<ToastState>((set) => ({
toasts: [],
push: (message, tone = 'info') =>
set((s) => ({ toasts: [...s.toasts, { id: nextId++, message, tone }].slice(-MAX_TOASTS) })),
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
}))
/** Fire a toast from anywhere (including non-component code). */
export const toast = {
info: (m: string) => useToastStore.getState().push(m, 'info'),
error: (m: string) => useToastStore.getState().push(m, 'error'),
}
// Param is `item`, not `toast`, so it doesn't shadow the module's `toast` export.
function ToastItem({ item }: { item: Toast }) {
const dismiss = useToastStore((s) => s.dismiss)
const isError = item.tone === 'error'
useEffect(() => {
// Error toasts are the primary report that a mutation failed, so they do NOT
// auto-dismiss — a user who looked away at second 4 would otherwise lose the
// only notice, with nothing to retrieve (#85). Info toasts still time out.
if (isError) return
const t = setTimeout(() => dismiss(item.id), 4000)
return () => clearTimeout(t)
}, [item.id, dismiss, isError])
return (
<div
role={isError ? 'alert' : 'status'}
className={cn(
'pointer-events-auto flex items-start gap-2 rounded-md border px-3 py-2 text-sm shadow-md',
isError
? 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300'
: 'border-border bg-surface text-fg',
)}
>
<span className="flex-1">{item.message}</span>
<button
type="button"
onClick={() => dismiss(item.id)}
aria-label="Dismiss"
className="-mr-1 shrink-0 rounded px-1 text-current opacity-60 outline-none hover:opacity-100 focus-visible:ring-2 focus-visible:ring-current/40"
>
</button>
</div>
)
}
/** Fixed stack of active toasts. Mount once near the app root. */
export function Toaster() {
const toasts = useToastStore((s) => s.toasts)
if (toasts.length === 0) return null
return (
<div className="pointer-events-none fixed bottom-4 left-1/2 z-[60] flex -translate-x-1/2 flex-col items-center gap-2">
{toasts.map((t) => (
<ToastItem key={t.id} item={t} />
))}
</div>
)
}