Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useRef, useCallback } from 'react'; | |
| import { useNavigate } from 'react-router-dom'; | |
| import { Info, AlertTriangle, AlertOctagon, X } from 'lucide-react'; | |
| import { useSystemNoticeStore } from '../../store/systemNoticeStore.js'; | |
| import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js'; | |
| import { useTranslation } from '../../i18n/index.js'; | |
| import { isRtlLanguage } from '../../i18n/index.js'; | |
| import { runNoticeAction } from './noticeActions.js'; | |
| const SEVERITY_ICONS: Record<string, React.ElementType> = { | |
| info: Info, | |
| warn: AlertTriangle, | |
| critical: AlertOctagon, | |
| }; | |
| const SEVERITY = { | |
| info: { | |
| bg: 'bg-white dark:bg-slate-900', | |
| border: 'border-blue-500 dark:border-blue-400', | |
| text: 'text-slate-900 dark:text-slate-100', | |
| icon: 'text-blue-500 dark:text-blue-400', | |
| ariaLive: 'polite' as const, | |
| role: 'status' as const, | |
| }, | |
| warn: { | |
| bg: 'bg-amber-50 dark:bg-amber-950', | |
| border: 'border-amber-500 dark:border-amber-400', | |
| text: 'text-amber-900 dark:text-amber-100', | |
| icon: 'text-amber-500 dark:text-amber-400', | |
| ariaLive: 'polite' as const, | |
| role: 'status' as const, | |
| }, | |
| critical: { | |
| bg: 'bg-rose-50 dark:bg-rose-950', | |
| border: 'border-rose-600 dark:border-rose-400', | |
| text: 'text-rose-900 dark:text-rose-100', | |
| icon: 'text-rose-600 dark:text-rose-400', | |
| ariaLive: 'assertive' as const, | |
| role: 'alert' as const, | |
| }, | |
| } as const; | |
| interface BannerItemProps { | |
| notice: SystemNoticeDTO; | |
| onDismiss: () => void; | |
| language: string; | |
| } | |
| function CTALink({ | |
| notice, | |
| label, | |
| onDismiss, | |
| }: { | |
| notice: SystemNoticeDTO; | |
| label: string; | |
| onDismiss: () => void; | |
| }) { | |
| const navigate = useNavigate(); | |
| function handleClick() { | |
| if (!notice.cta) return; | |
| if (notice.cta.kind === 'nav') { | |
| navigate(notice.cta.href); | |
| if (notice.dismissible) onDismiss(); | |
| } else { | |
| runNoticeAction(notice.cta.actionId, { navigate }); | |
| const actionCta = notice.cta as { kind: 'action'; labelKey: string; actionId: string; dismissOnAction?: boolean }; | |
| if (actionCta.dismissOnAction !== false) onDismiss(); | |
| } | |
| } | |
| if (!notice.cta) return null; | |
| if (notice.cta.kind === 'nav') { | |
| return ( | |
| <a | |
| href={notice.cta.href} | |
| onClick={e => { e.preventDefault(); handleClick(); }} | |
| className="underline hover:no-underline font-medium ml-3 shrink-0" | |
| > | |
| {label} | |
| </a> | |
| ); | |
| } | |
| return ( | |
| <button | |
| onClick={handleClick} | |
| className="underline hover:no-underline font-medium ml-3 shrink-0" | |
| > | |
| {label} | |
| </button> | |
| ); | |
| } | |
| function BannerItem({ notice, onDismiss, language }: BannerItemProps) { | |
| const { t } = useTranslation(); | |
| const s = SEVERITY[notice.severity] ?? SEVERITY.info; | |
| const title = t(notice.titleKey); | |
| const body = t(notice.bodyKey); | |
| const ctaLabel = notice.cta ? t(notice.cta.labelKey) : null; | |
| // Tailwind 3.3+ supports border-s-4 (logical, RTL-aware) | |
| const accentBorder = 'border-s-4'; | |
| return ( | |
| <div | |
| role={s.role} | |
| aria-live={s.ariaLive} | |
| aria-atomic="true" | |
| className={`flex items-start gap-x-3 py-3 px-4 ${accentBorder} ${s.bg} ${s.border} ${s.text}`} | |
| > | |
| {React.createElement( | |
| (SEVERITY_ICONS[notice.severity] ?? Info) as React.ElementType, | |
| { size: 20, className: `shrink-0 mt-0.5 ${s.icon}` }, | |
| )} | |
| <div className="flex-1 min-w-0"> | |
| <span className="font-semibold">{title}</span> | |
| {body !== title && ( | |
| <span className="ml-2 opacity-80">{body}</span> | |
| )} | |
| {ctaLabel && notice.cta && ( | |
| <CTALink notice={notice} label={ctaLabel} onDismiss={onDismiss} /> | |
| )} | |
| </div> | |
| {notice.dismissible && ( | |
| <button | |
| onClick={onDismiss} | |
| className="shrink-0 p-2 -mr-2 rounded hover:bg-black/5 dark:hover:bg-white/10 transition" | |
| aria-label={`Dismiss: ${title}`} | |
| > | |
| <X size={20} /> | |
| </button> | |
| )} | |
| </div> | |
| ); | |
| } | |
| interface AnimatedBannerItemProps { | |
| notice: SystemNoticeDTO; | |
| onDismiss: () => void; | |
| language: string; | |
| } | |
| function AnimatedBannerItem({ notice, onDismiss, language }: AnimatedBannerItemProps) { | |
| const [mounted, setMounted] = useState(false); | |
| const prefersReducedMotion = | |
| typeof window !== 'undefined' && | |
| (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false); | |
| useEffect(() => { | |
| if (typeof requestAnimationFrame !== 'undefined') { | |
| const id = requestAnimationFrame(() => setMounted(true)); | |
| return () => cancelAnimationFrame(id); | |
| } | |
| setMounted(true); | |
| }, []); | |
| const transition = prefersReducedMotion | |
| ? 'transition-opacity duration-[120ms]' | |
| : 'transition-all duration-200 ease-out'; | |
| const state = mounted ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-2'; | |
| return ( | |
| <div className={`${transition} ${state}`}> | |
| <BannerItem notice={notice} onDismiss={onDismiss} language={language} /> | |
| </div> | |
| ); | |
| } | |
| interface BannerRendererProps { | |
| notices: SystemNoticeDTO[]; | |
| } | |
| export function BannerRenderer({ notices }: BannerRendererProps) { | |
| const { dismiss } = useSystemNoticeStore(); | |
| const { language } = useTranslation(); | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| // Show at most 2 highest-priority banners | |
| const visible = notices.slice(0, 2); | |
| // Report banner stack height for layout reflow | |
| useEffect(() => { | |
| const el = containerRef.current; | |
| if (!el) return; | |
| const observer = new ResizeObserver(() => { | |
| document.documentElement.style.setProperty('--banner-stack-h', el.offsetHeight + 'px'); | |
| }); | |
| observer.observe(el); | |
| return () => { | |
| observer.disconnect(); | |
| document.documentElement.style.setProperty('--banner-stack-h', '0px'); | |
| }; | |
| }, []); | |
| if (visible.length === 0) return null; | |
| return ( | |
| <div | |
| ref={containerRef} | |
| className="fixed left-0 right-0 z-40" | |
| style={{ top: 'var(--nav-h, 0px)' }} | |
| > | |
| {visible.map((notice, i) => ( | |
| <React.Fragment key={notice.id}> | |
| {i > 0 && <div className="border-t border-black/10 dark:border-white/10" />} | |
| <AnimatedBannerItem | |
| notice={notice} | |
| onDismiss={() => dismiss(notice.id)} | |
| language={language} | |
| /> | |
| </React.Fragment> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| interface ToastRendererProps { | |
| notices: SystemNoticeDTO[]; | |
| } | |
| export function ToastRenderer({ notices }: ToastRendererProps) { | |
| const { dismiss } = useSystemNoticeStore(); | |
| const { t } = useTranslation(); | |
| const firedRef = useRef(new Set<string>()); | |
| useEffect(() => { | |
| for (const notice of notices) { | |
| if (firedRef.current.has(notice.id)) continue; | |
| firedRef.current.add(notice.id); | |
| // Critical should not be a toast — log and skip | |
| if (notice.severity === 'critical') { | |
| console.warn( | |
| `[systemNotices] notice "${notice.id}" is critical but display=toast. ` + | |
| 'Should be banner or modal.' | |
| ); | |
| dismiss(notice.id); | |
| continue; | |
| } | |
| const variantMap: Record<string, string> = { info: 'info', warn: 'warning' }; | |
| const variant = variantMap[notice.severity] ?? 'info'; | |
| const titleStr = t(notice.titleKey); | |
| const bodyStr = t(notice.bodyKey); | |
| const message = bodyStr !== titleStr ? `${titleStr}: ${bodyStr}` : titleStr; | |
| const duration = notice.severity === 'warn' ? 9000 : 6000; | |
| // Fire the toast, retrying on the next frame if __addToast isn't registered yet | |
| // (race between ToastContainer mounting and SystemNoticeHost mounting on cold load). | |
| const fireToast = (attempt = 0) => { | |
| if (typeof window.__addToast === 'function') { | |
| window.__addToast(message, variant as 'info' | 'success' | 'error' | 'warning', duration); | |
| } else if (attempt < 10) { | |
| requestAnimationFrame(() => fireToast(attempt + 1)); | |
| return; // don't schedule dismiss until the toast actually fires | |
| } else { | |
| console.warn(`[systemNotices] toast "${notice.id}" dropped — __addToast never registered`); | |
| } | |
| setTimeout(() => dismiss(notice.id), duration + 500); | |
| }; | |
| fireToast(); | |
| } | |
| }, [notices]); // eslint-disable-line react-hooks/exhaustive-deps | |
| return null; | |
| } | |