feat(site): allow dismissing the active alerts banner and soften its red (#2478)

This commit is contained in:
Sven van Ginkel
2026-09-30 18:17:02 +02:00
committed by GitHub
parent 2ca5e727f9
commit 15c29fee92
4 changed files with 47 additions and 11 deletions

View File

@@ -1,10 +1,11 @@
import { alertInfo } from "@/lib/alerts"
import { $alerts, $allSystemsById } from "@/lib/stores"
import { useBrowserStorage } from "@/lib/utils"
import type { AlertRecord } from "@/types"
import { Plural, Trans } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router"
import { useMemo, useState } from "react"
import { useEffect, useMemo, useState } from "react"
import { AlertBannerSheet, AlertBannerSheetItem } from "./alert-banner-sheet"
import { $router } from "./router"
@@ -49,6 +50,9 @@ export const ActiveAlerts = ({ className }: { className?: string }) => {
const alerts = useStore($alerts)
const systems = useStore($allSystemsById)
const [open, setOpen] = useState(false)
// ids of the alerts that were active when the banner was last dismissed.
// session storage because a retrigger while the page is closed keeps the same id.
const [dismissedIds, setDismissedIds] = useBrowserStorage<string[]>("dismissedAlerts", [], sessionStorage)
const { activeAlerts, systemCount, alertsKey } = useMemo(() => {
const activeAlerts: AlertRecord[] = []
@@ -69,9 +73,24 @@ export const ActiveAlerts = ({ className }: { className?: string }) => {
return { activeAlerts, systemCount: systemIds.size, alertsKey: alertsKey.join("") }
}, [alerts])
// forget dismissed alerts once they resolve so they show again if they retrigger.
// skipped while alerts are still loading so a reload doesn't clear the dismissal,
// and re-run once loaded in case they resolved while the page was closed.
const alertsLoaded = Object.keys(alerts).length > 0
useEffect(() => {
if (!alertsLoaded) {
return
}
const activeIds = new Set(activeAlerts.map((alert) => alert.id))
if (dismissedIds.some((id) => !activeIds.has(id))) {
setDismissedIds(dismissedIds.filter((id) => activeIds.has(id)))
}
}, [alertsKey, alertsLoaded])
return useMemo(() => {
const alertCount = activeAlerts.length
if (alertCount === 0) {
// stay hidden after dismissing until an alert triggers that wasn't active at the time
if (alertCount === 0 || activeAlerts.every((alert) => dismissedIds.includes(alert.id))) {
return null
}
// name the alert directly in the banner when there is only one
@@ -80,6 +99,7 @@ export const ActiveAlerts = ({ className }: { className?: string }) => {
<AlertBannerSheet
open={open}
onOpenChange={setOpen}
onDismiss={() => setDismissedIds(activeAlerts.map((alert) => alert.id))}
className={className}
title={
alertCount === 1 ? (
@@ -117,5 +137,5 @@ export const ActiveAlerts = ({ className }: { className?: string }) => {
})}
</AlertBannerSheet>
)
}, [alertsKey, systemCount, systems, open, className])
}, [alertsKey, systemCount, systems, open, className, dismissedIds])
}

View File

@@ -1,5 +1,5 @@
import { Trans } from "@lingui/react/macro"
import { ChevronRightIcon, type LucideIcon, TriangleAlertIcon } from "lucide-react"
import { Trans, useLingui } from "@lingui/react/macro"
import { ChevronRightIcon, type LucideIcon, TriangleAlertIcon, XIcon } from "lucide-react"
import type { ReactNode } from "react"
import { cn } from "@/lib/utils"
import { Link } from "./router"
@@ -20,6 +20,7 @@ export function AlertBannerSheet({
sheetTitle,
sheetDescription,
icon: Icon = TriangleAlertIcon,
onDismiss,
className,
children,
}: {
@@ -31,9 +32,12 @@ export function AlertBannerSheet({
sheetTitle: ReactNode
sheetDescription?: ReactNode
icon?: LucideIcon
/** Shows a dismiss button when provided */
onDismiss?: () => void
className?: string
children: ReactNode
}) {
const { t } = useLingui()
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<Alert variant="destructive" className={cn("flex items-center gap-3 py-3 max-sm:flex-wrap", className)}>
@@ -42,7 +46,7 @@ export function AlertBannerSheet({
<div className="min-w-0">
<AlertTitle className="m-0">{title}</AlertTitle>
{description && (
<AlertDescription className="text-destructive/80 dark:text-red-500/80">{description}</AlertDescription>
<AlertDescription className="text-destructive/80 dark:text-red-400/80">{description}</AlertDescription>
)}
</div>
</div>
@@ -50,12 +54,24 @@ export function AlertBannerSheet({
<Button
variant="outline"
size="sm"
className="shrink-0 bg-transparent border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive dark:text-red-500 dark:hover:bg-destructive/15 dark:hover:text-red-500 max-sm:w-full"
className="shrink-0 bg-transparent border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive dark:text-red-400 dark:hover:bg-destructive/15 dark:hover:text-red-400 max-sm:w-full max-sm:order-last"
>
{buttonLabel ?? <Trans>View details</Trans>}
<ChevronRightIcon className="size-4 ms-1 -me-1" />
</Button>
</SheetTrigger>
{onDismiss && (
<Button
variant="ghost"
size="icon"
onClick={onDismiss}
aria-label={t`Dismiss`}
title={t`Dismiss`}
className="size-8 shrink-0 -me-1.5 text-destructive/70 hover:bg-destructive/10 hover:text-destructive dark:text-red-400/70 dark:hover:bg-destructive/15 dark:hover:text-red-400"
>
<XIcon className="size-4" />
</Button>
)}
</Alert>
<SheetContent className="w-140 !max-w-full gap-0">
<SheetHeader className="p-4 sm:p-6 pb-3 sm:pb-4 border-b">
@@ -88,7 +104,7 @@ export function AlertBannerSheetItem({
onClick={onClick}
className="group flex items-start gap-3 rounded-lg border p-3 transition-colors hover:bg-accent/60"
>
<div className="rounded-md bg-destructive/10 p-2 text-destructive shrink-0">
<div className="rounded-md bg-destructive/10 p-2 text-destructive dark:text-red-400 shrink-0">
<Icon className="size-4" />
</div>
<div className="min-w-0 flex-1">

View File

@@ -10,7 +10,7 @@ const alertVariants = cva(
variant: {
default: "bg-background text-foreground [&>svg]:text-foreground",
destructive:
"bg-destructive/5 border-destructive/50 text-destructive dark:bg-destructive/6 dark:text-red-500 dark:border-destructive/70 [&>svg]:text-destructive",
"bg-destructive/5 border-destructive/50 text-destructive dark:bg-destructive/6 dark:text-red-400 dark:border-destructive/50 [&>svg]:text-destructive dark:[&>svg]:text-red-400",
},
},
defaultVariants: {

View File

@@ -229,14 +229,14 @@ function getStorageValue(key: string, defaultValue: unknown, storageInterface: S
/** Hook to sync value in local or session storage */
export function useBrowserStorage<T>(key: string, defaultValue: T, storageInterface: Storage = localStorage) {
key = `besz-${key}`
const [value, setValue] = useState(() => {
const [value, setValue] = useState<T>(() => {
return getStorageValue(key, defaultValue, storageInterface)
})
useEffect(() => {
storageInterface?.setItem(key, JSON.stringify(value))
}, [key, value])
return [value, setValue]
return [value, setValue] as const
}
/** Format temperature to user's preferred unit */