diff --git a/app/favicon.ico b/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/app/favicon.ico and /dev/null differ diff --git a/app/globals.css b/app/globals.css index 2ac014f..c5ce9d5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,44 +1,43 @@ @import "tailwindcss"; :root { - --pn-bg: #0a0e13; - --pn-surface: #10151c; - --pn-surface-raised: #161d27; - --pn-border: #232b38; - --pn-fg: #e8ecf1; - --pn-fg-muted: #7c8798; - --pn-accent: #3ee8a8; - --pn-radius: 0.75rem; - --pn-status-up: #3ee8a8; + --pn-bg: #1a1d1f; + --pn-surface: #24282b; + --pn-surface-raised: #2b3033; + --pn-fg: #f2f4f5; + --pn-fg-muted: color-mix(in srgb, var(--pn-fg) 55%, transparent); + --pn-border: color-mix(in srgb, var(--pn-fg) 14%, transparent); + --pn-accent: #5ee08c; + --pn-status-up: #5ee08c; --pn-status-down: #ff5f6d; --pn-status-degraded: #ffb84c; + --pn-shadow-sm: 0 0 0 1px var(--pn-border); + --pn-shadow-md: 0 0 0 1px var(--pn-border), 0 6px 18px rgba(0, 0, 0, 0.45); } :root[data-theme="light"] { - --pn-bg: #f6f7f5; - --pn-surface: #ffffff; - --pn-surface-raised: #eef1ef; - --pn-border: #dde1de; - --pn-fg: #12181a; - --pn-fg-muted: #5c6b66; - --pn-accent: #0e9f6e; - --pn-status-up: #0e9f6e; + --pn-bg: #e9ebec; + --pn-surface: #f6f7f7; + --pn-surface-raised: #eceded; + --pn-fg: #1e2224; + --pn-accent: #339a5a; + --pn-status-up: #339a5a; --pn-status-down: #dc2626; --pn-status-degraded: #d97706; + --pn-shadow-md: 0 0 0 1px var(--pn-border), 0 6px 18px rgba(30, 32, 45, 0.1); } @media (prefers-color-scheme: light) { :root:not([data-theme]) { - --pn-bg: #f6f7f5; - --pn-surface: #ffffff; - --pn-surface-raised: #eef1ef; - --pn-border: #dde1de; - --pn-fg: #12181a; - --pn-fg-muted: #5c6b66; - --pn-accent: #0e9f6e; - --pn-status-up: #0e9f6e; + --pn-bg: #e9ebec; + --pn-surface: #f6f7f7; + --pn-surface-raised: #eceded; + --pn-fg: #1e2224; + --pn-accent: #339a5a; + --pn-status-up: #339a5a; --pn-status-down: #dc2626; --pn-status-degraded: #d97706; + --pn-shadow-md: 0 0 0 1px var(--pn-border), 0 6px 18px rgba(30, 32, 45, 0.1); } } @@ -53,7 +52,8 @@ --color-status-up: var(--pn-status-up); --color-status-down: var(--pn-status-down); --color-status-degraded: var(--pn-status-degraded); - --radius-widget: var(--pn-radius); + --radius-md: 0.625rem; + --radius-lg: 1rem; --font-sans: var(--font-plex-sans); --font-mono: var(--font-plex-mono); --font-display: var(--font-space-grotesk); @@ -64,6 +64,62 @@ body { color: var(--pn-fg); } +/* Rules fade to transparent at both ends instead of stopping cleanly. */ +.pn-hr { + height: 1px; + flex: 1; + background: linear-gradient( + to right, + transparent, + var(--pn-border) 32px, + var(--pn-border) calc(100% - 32px), + transparent + ); +} + +.pn-card { + transition: + box-shadow 0.18s ease, + transform 0.18s ease, + background 0.3s ease, + border-color 0.3s ease; + box-shadow: var(--pn-shadow-sm); +} +.pn-card:hover { + transform: translateY(-2px); + box-shadow: + var(--pn-shadow-md), + 0 0 0 1px color-mix(in srgb, var(--pn-accent) 35%, transparent); +} + +@keyframes pn-toast-in { + from { + transform: translateY(12px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} +.pn-toast { + animation: pn-toast-in 0.28s ease; +} + +@keyframes pn-spin-in { + from { + transform: rotate(-60deg) scale(0.6); + opacity: 0; + } + to { + transform: rotate(0) scale(1); + opacity: 1; + } +} +.pn-spin-in { + animation: pn-spin-in 0.35s ease; +} + @keyframes pulse-glow { 0%, 100% { @@ -73,13 +129,32 @@ body { box-shadow: 0 0 0 4px color-mix(in srgb, var(--pn-status-up) 0%, transparent); } } - .status-pulse { animation: pulse-glow 2s ease-in-out infinite; } @media (prefers-reduced-motion: reduce) { - .status-pulse { + .status-pulse, + .pn-toast, + .pn-spin-in { animation: none; } } + +.pn-bento { + display: grid; + grid-template-columns: repeat(12, 1fr); +} + +@media (max-width: 860px) { + .pn-bento { + grid-template-columns: repeat(6, 1fr) !important; + } + .pn-bento > * { + grid-column: 1 / -1 !important; + grid-row: auto !important; + } + .pn-live-label { + display: none !important; + } +} diff --git a/app/icon.svg b/app/icon.svg new file mode 100644 index 0000000..8c5b8f2 --- /dev/null +++ b/app/icon.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/app/page.tsx b/app/page.tsx index 0b183ea..5e54a7b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -12,8 +12,8 @@ export default function Home() { const message = err instanceof ConfigError ? err.message : "Unknown configuration error"; return (
-

Configuration Error

-
+        

Configuration Error

+
           {message}
         
diff --git a/components/layout/Dashboard.tsx b/components/layout/Dashboard.tsx index 45aba14..e76a07a 100644 --- a/components/layout/Dashboard.tsx +++ b/components/layout/Dashboard.tsx @@ -1,28 +1,48 @@ "use client"; +import { Broadcast } from "@phosphor-icons/react/ssr"; import type { Config } from "@/lib/config/schema"; -import { useConfigSubscription } from "@/lib/ws/client"; +import { useConfigSubscription, useConnectionStatus } from "@/lib/ws/client"; import { GroupSection } from "./GroupSection"; import { ThemeToggle } from "./ThemeToggle"; import { PulseMark } from "./PulseMark"; +import { ToastStack } from "./Toast"; + +function LiveIndicator() { + const status = useConnectionStatus(); + const label = status === "connected" ? "live" : status === "reconnecting" ? "reconnecting…" : "connecting…"; + + return ( +
+ + {label} +
+ ); +} export function Dashboard({ config: initialConfig }: { config: Config }) { const config = useConfigSubscription(initialConfig); return ( -
-
-
- -

- {config.settings.title} -

+
+
+
+
+ + + {config.settings.title} + +
+ +
- -
- {config.groups.map((group, groupIndex) => ( - - ))} -
+ +
+ {config.groups.map((group, groupIndex) => ( + + ))} +
+ + ); } diff --git a/components/layout/GroupSection.tsx b/components/layout/GroupSection.tsx index fd8b59b..84b42a0 100644 --- a/components/layout/GroupSection.tsx +++ b/components/layout/GroupSection.tsx @@ -4,11 +4,14 @@ import { widgetRegistry } from "@/components/widgets/registry"; export function GroupSection({ group, groupIndex }: { group: Group; groupIndex: number }) { return ( -
-

- {group.name} -

-
+
+
+
+ {group.name} +
+
+
+
{group.widgets.map((widget, index) => { const Component = widgetRegistry[widget.type]; const id = widgetId(groupIndex, index); diff --git a/components/layout/PulseMark.tsx b/components/layout/PulseMark.tsx index 431589e..61b53ea 100644 --- a/components/layout/PulseMark.tsx +++ b/components/layout/PulseMark.tsx @@ -1,18 +1,21 @@ -export function PulseMark() { +export function PulseMark({ size = 30 }: { size?: number }) { return ( -
; + return
; } return ( ); } diff --git a/components/layout/Toast.tsx b/components/layout/Toast.tsx new file mode 100644 index 0000000..54c2993 --- /dev/null +++ b/components/layout/Toast.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { CheckCircle, WarningCircle } from "@phosphor-icons/react/ssr"; +import { useConfigToasts } from "@/lib/ws/client"; + +export function ToastStack() { + const toasts = useConfigToasts(); + + if (toasts.length === 0) return null; + + return ( +
+ {toasts.map((toast) => ( +
+ {toast.tone === "ok" ? ( + + ) : ( + + )} + {toast.message} +
+ ))} +
+ ); +} diff --git a/components/widgets/MetricBar.tsx b/components/widgets/MetricBar.tsx new file mode 100644 index 0000000..bbb83fc --- /dev/null +++ b/components/widgets/MetricBar.tsx @@ -0,0 +1,16 @@ +export function MetricBar({ label, percent, detail }: { label: string; percent: number; detail: string }) { + return ( +
+
+ {label} + {detail} +
+
+
+
+
+ ); +} diff --git a/components/widgets/StatusTag.tsx b/components/widgets/StatusTag.tsx new file mode 100644 index 0000000..c6499fa --- /dev/null +++ b/components/widgets/StatusTag.tsx @@ -0,0 +1,14 @@ +export function StatusTag({ children, tone = "accent" }: { children: React.ReactNode; tone?: "accent" | "down" | "degraded" | "neutral" }) { + const toneClass = { + accent: "bg-accent/15 text-accent border-accent/40", + down: "bg-status-down/15 text-status-down border-status-down/40", + degraded: "bg-status-degraded/15 text-status-degraded border-status-degraded/40", + neutral: "bg-surface-raised text-fg-muted border-border", + }[tone]; + + return ( + + {children} + + ); +} diff --git a/components/widgets/WidgetCard.tsx b/components/widgets/WidgetCard.tsx new file mode 100644 index 0000000..708695d --- /dev/null +++ b/components/widgets/WidgetCard.tsx @@ -0,0 +1,24 @@ +import type { CSSProperties, ReactNode } from "react"; + +export const PN_CARD_CLASS = "pn-card flex flex-col gap-2 rounded-md border border-border bg-surface p-4"; + +export function WidgetCard({ + span = 3, + rowSpan, + children, +}: { + span?: number; + rowSpan?: number; + children: ReactNode; +}) { + const style: CSSProperties = { + gridColumn: `span ${span}`, + ...(rowSpan ? { gridRow: `span ${rowSpan}` } : {}), + }; + + return ( +
+ {children} +
+ ); +} diff --git a/components/widgets/bookmark/Widget.tsx b/components/widgets/bookmark/Widget.tsx index 31453ff..2e700eb 100644 --- a/components/widgets/bookmark/Widget.tsx +++ b/components/widgets/bookmark/Widget.tsx @@ -1,4 +1,6 @@ +import { ArrowUpRight } from "@phosphor-icons/react/ssr"; import type { Widget } from "@/lib/config/schema"; +import { PN_CARD_CLASS } from "@/components/widgets/WidgetCard"; type BookmarkWidget = Extract; @@ -8,12 +10,14 @@ export function BookmarkWidget({ widget }: { widget: BookmarkWidget }) { href={widget.href} target="_blank" rel="noreferrer" - className="flex flex-col gap-1 rounded-[var(--radius-widget)] border border-border bg-surface p-4 transition-colors hover:border-accent" + className={`${PN_CARD_CLASS} text-inherit no-underline`} + style={{ gridColumn: "span 3" }} > - {widget.name} - {widget.description && ( - {widget.description} - )} +
+ {widget.name} + +
+ {widget.description && {widget.description}} ); } diff --git a/components/widgets/database/Widget.tsx b/components/widgets/database/Widget.tsx index 7fc6aff..b30e369 100644 --- a/components/widgets/database/Widget.tsx +++ b/components/widgets/database/Widget.tsx @@ -3,6 +3,7 @@ import type { Widget } from "@/lib/config/schema"; import { useWidgetSubscription } from "@/lib/ws/client"; import { StatusDot } from "@/components/widgets/StatusDot"; +import { WidgetCard } from "@/components/widgets/WidgetCard"; import { formatUptime } from "@/lib/format"; type DatabaseWidget = Extract; @@ -18,18 +19,18 @@ export function DatabaseWidget({ widget, widgetId }: { widget: DatabaseWidget; w const errorMessage = result?.type === "error" ? result.message : null; return ( -
+
- {widget.name} + {widget.name}
- {ENGINE_LABEL[widget.engine]} + {ENGINE_LABEL[widget.engine]} {errorMessage && {errorMessage}} {data?.uptimeSeconds != null && ( - - Uptime: {formatUptime(data.uptimeSeconds)} + + up {formatUptime(data.uptimeSeconds)} )} -
+ ); } diff --git a/components/widgets/docker/Widget.tsx b/components/widgets/docker/Widget.tsx index db2fe83..bbb5ae8 100644 --- a/components/widgets/docker/Widget.tsx +++ b/components/widgets/docker/Widget.tsx @@ -4,6 +4,8 @@ import type { Widget } from "@/lib/config/schema"; import { useWidgetSubscription } from "@/lib/ws/client"; import { StatusDot } from "@/components/widgets/StatusDot"; import { SkeletonLines } from "@/components/widgets/Skeleton"; +import { WidgetCard } from "@/components/widgets/WidgetCard"; +import { MetricBar } from "@/components/widgets/MetricBar"; import { formatBytes, formatUptime } from "@/lib/format"; type DockerWidget = Extract; @@ -12,54 +14,44 @@ export function DockerWidget({ widget, widgetId }: { widget: DockerWidget; widge const result = useWidgetSubscription(widgetId); const data = result?.type === "docker" ? result.data : null; const errorMessage = result?.type === "error" ? result.message : null; + const memPercent = data?.memUsageBytes != null && data.memLimitBytes ? (data.memUsageBytes / data.memLimitBytes) * 100 : null; return ( -
+
{widget.href ? ( {widget.name} ) : ( - {widget.name} + {widget.name} )}
{errorMessage && {errorMessage}} {!result && !errorMessage && } {data && ( -
- {data.uptimeSeconds !== null && ( -
- Uptime: {formatUptime(data.uptimeSeconds)} + <> + {widget.showStats && data.cpuPercent !== null && memPercent !== null && ( +
+ +
)} - {widget.showStats && data.cpuPercent !== null && ( -
- CPU: {data.cpuPercent.toFixed(1)}% -
- )} - {widget.showStats && data.memUsageBytes !== null && ( -
- Mem:{" "} - - {formatBytes(data.memUsageBytes)} - {data.memLimitBytes ? ` / ${formatBytes(data.memLimitBytes)}` : ""} - -
- )} - {data.restartCount > 0 && ( -
- Restarts: {data.restartCount} -
- )} -
+
+ {data.uptimeSeconds !== null && up {formatUptime(data.uptimeSeconds)}} + {widget.showStats && data.memUsageBytes !== null && memPercent === null && ( + {formatBytes(data.memUsageBytes)} + )} + {data.restartCount > 0 && {data.restartCount} restarts} +
+ )} -
+ ); } diff --git a/components/widgets/http/Widget.tsx b/components/widgets/http/Widget.tsx index edc1812..3a18964 100644 --- a/components/widgets/http/Widget.tsx +++ b/components/widgets/http/Widget.tsx @@ -1,45 +1,45 @@ "use client"; +import { Globe, Timer } from "@phosphor-icons/react/ssr"; import type { Widget } from "@/lib/config/schema"; import { useWidgetSubscription } from "@/lib/ws/client"; import { SkeletonLines } from "@/components/widgets/Skeleton"; +import { WidgetCard } from "@/components/widgets/WidgetCard"; +import { StatusTag } from "@/components/widgets/StatusTag"; type HttpWidget = Extract; -function statusColor(up: boolean | undefined, consecutiveFailures: number): string { - if (up === undefined) return "bg-fg-muted"; - if (up) return "bg-status-up"; - return consecutiveFailures > 2 ? "bg-status-down" : "bg-status-degraded"; +function statusTone(up: boolean | undefined, consecutiveFailures: number): "accent" | "down" | "degraded" | "neutral" { + if (up === undefined) return "neutral"; + if (up) return "accent"; + return consecutiveFailures > 2 ? "down" : "degraded"; } export function HttpWidget({ widget, widgetId }: { widget: HttpWidget; widgetId: string }) { const result = useWidgetSubscription(widgetId); const data = result?.type === "http" ? result.data : null; const errorMessage = result?.type === "error" ? result.message : null; - const pulse = data?.up ? "status-pulse" : ""; return ( -
-
- {widget.name} - + +
+ + {widget.name} +
+
+ + {data ? (data.up ? `${data.statusCode ?? "up"}` : "down") : "…"} +
{errorMessage && {errorMessage}} {!result && !errorMessage && } {data && ( -
-
- Status: {data.statusCode ?? "—"} -
-
- Latency: {data.latencyMs}ms -
- {!data.up && data.error &&
{data.error}
} -
+
+ + {data.latencyMs}ms latency + {!data.up && data.error && {data.error}} +
)} -
+ ); } diff --git a/components/widgets/search/Widget.tsx b/components/widgets/search/Widget.tsx index cbed0a7..52ae0e1 100644 --- a/components/widgets/search/Widget.tsx +++ b/components/widgets/search/Widget.tsx @@ -2,6 +2,7 @@ import { useState, type FormEvent } from "react"; import type { SearchEngine, Widget } from "@/lib/config/schema"; +import { WidgetCard } from "@/components/widgets/WidgetCard"; type SearchWidget = Extract; @@ -11,6 +12,12 @@ const ENGINE_URLS: Record = { bing: "https://www.bing.com/search?q=", }; +const ENGINE_LABEL: Record = { + duckduckgo: "DuckDuckGo", + google: "Google", + bing: "Bing", +}; + export function SearchWidget({ widget }: { widget: SearchWidget }) { const [engine, setEngine] = useState(widget.defaultEngine ?? widget.engines[0]); const [query, setQuery] = useState(""); @@ -23,29 +30,39 @@ export function SearchWidget({ widget }: { widget: SearchWidget }) { } return ( -
- {widget.engines.length > 1 && ( - - )} - setQuery(event.target.value)} - placeholder="Search..." - className="flex-1 bg-transparent text-sm text-fg outline-none placeholder:text-fg-muted" - /> -
+ +
+
Search
+ setQuery(event.target.value)} + placeholder="Search the web…" + autoComplete="off" + className="min-h-9 w-full rounded-md border border-border bg-bg px-2.5 py-1.5 text-sm text-fg outline-none placeholder:text-fg-muted focus-visible:border-accent" + /> + {widget.engines.length > 1 && ( +
+ {widget.engines.map((option) => ( + + ))} +
+ )} +
+
); } diff --git a/components/widgets/system/Widget.tsx b/components/widgets/system/Widget.tsx index 1f1ae99..2446096 100644 --- a/components/widgets/system/Widget.tsx +++ b/components/widgets/system/Widget.tsx @@ -3,6 +3,8 @@ import type { Widget } from "@/lib/config/schema"; import { useWidgetSubscription } from "@/lib/ws/client"; import { SkeletonLines } from "@/components/widgets/Skeleton"; +import { WidgetCard } from "@/components/widgets/WidgetCard"; +import { MetricBar } from "@/components/widgets/MetricBar"; import { formatBytes } from "@/lib/format"; type SystemWidget = Extract; @@ -11,51 +13,39 @@ function usagePercent(used: number, total: number): number { return total > 0 ? Math.min(100, (used / total) * 100) : 0; } -function UsageBar({ label, percent, detail }: { label: string; percent: number; detail: string }) { - return ( -
-
- {label} - {detail} -
-
-
-
-
- ); -} - export function SystemWidget({ widget, widgetId }: { widget: SystemWidget; widgetId: string }) { const result = useWidgetSubscription(widgetId); const data = result?.type === "system" ? result.data : null; const errorMessage = result?.type === "error" ? result.message : null; return ( -
- {widget.name} + +
System
+ {widget.name} {errorMessage && {errorMessage}} {!result && !errorMessage && } {data && ( -
- - - -
- Network +
+
+ + + +
+
↓ {formatBytes(data.netRxBytesPerSec)}/s · ↑ {formatBytes(data.netTxBytesPerSec)}/s
)} -
+ ); } diff --git a/components/widgets/traefik/Widget.tsx b/components/widgets/traefik/Widget.tsx index c9a451a..4299789 100644 --- a/components/widgets/traefik/Widget.tsx +++ b/components/widgets/traefik/Widget.tsx @@ -1,8 +1,10 @@ "use client"; +import { Lock } from "@phosphor-icons/react/ssr"; import type { Widget } from "@/lib/config/schema"; import { useWidgetSubscription } from "@/lib/ws/client"; import { SkeletonLines } from "@/components/widgets/Skeleton"; +import { WidgetCard } from "@/components/widgets/WidgetCard"; type TraefikWidget = Extract; @@ -12,11 +14,11 @@ export function TraefikWidget({ widget, widgetId }: { widget: TraefikWidget; wid const errorMessage = result?.type === "error" ? result.message : null; return ( -
+
- {widget.name} + {widget.name} {data && ( - + {data.entrypoints.map((ep) => ep.address).join(" · ") || `${data.entrypoints.length} entrypoints`} )} @@ -24,27 +26,28 @@ export function TraefikWidget({ widget, widgetId }: { widget: TraefikWidget; wid {errorMessage && {errorMessage}} {!result && !errorMessage && } {data && ( -
+
{data.routers.map((router) => ( -
+
{router.name.replace(/@.*$/, "")} - - {router.tls && 🔒} + + {router.tls && } + {router.status}
))} - + {data.middlewaresCount} middlewares
)} -
+ ); } diff --git a/lib/ws/client.ts b/lib/ws/client.ts index 9e64f82..a27a7e9 100644 --- a/lib/ws/client.ts +++ b/lib/ws/client.ts @@ -12,14 +12,23 @@ interface Envelope { } type TopicListener = (envelope: Envelope) => void; +export type ConnectionStatus = "connecting" | "connected" | "reconnecting"; +type ConnectionListener = (status: ConnectionStatus) => void; let sharedSocket: WebSocket | null = null; let refCount = 0; let reconnectAttempts = 0; let reconnectTimer: ReturnType | null = null; +let connectionStatus: ConnectionStatus = "connecting"; const topicListeners = new Map>(); +const connectionListeners = new Set(); const pendingWidgetSubscriptions = new Set(); +function setConnectionStatus(status: ConnectionStatus): void { + connectionStatus = status; + for (const listener of connectionListeners) listener(status); +} + function getSocketUrl(): string { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; return `${protocol}//${window.location.host}/ws`; @@ -27,6 +36,7 @@ function getSocketUrl(): string { function scheduleReconnect(): void { if (reconnectTimer || refCount === 0) return; + setConnectionStatus("reconnecting"); const delay = Math.min(30_000, 1_000 * 2 ** reconnectAttempts); reconnectAttempts += 1; reconnectTimer = setTimeout(() => { @@ -45,6 +55,7 @@ function ensureSocket(): WebSocket { socket.addEventListener("open", () => { reconnectAttempts = 0; + setConnectionStatus("connected"); for (const widgetId of pendingWidgetSubscriptions) { socket.send(JSON.stringify({ action: "subscribe", widgetId })); } @@ -134,3 +145,65 @@ export function useConfigSubscription(initial: Config): Config { return config; } + +export function useConnectionStatus(): ConnectionStatus { + const [status, setStatus] = useState(connectionStatus); + + useEffect(() => { + refCount += 1; + // ensureSocket() can synchronously open a connection another component + // already established, so the module-level status may have moved past + // what this hook's initial useState captured - reconcile once before + // subscribing to future changes. + ensureSocket(); + connectionListeners.add(setStatus); + // eslint-disable-next-line react-hooks/set-state-in-effect + setStatus(connectionStatus); + + return () => { + refCount -= 1; + connectionListeners.delete(setStatus); + }; + }, []); + + return status; +} + +export interface ConfigToast { + id: number; + tone: "ok" | "error"; + message: string; +} + +let toastCounter = 0; + +export function useConfigToasts(): ConfigToast[] { + const [toasts, setToasts] = useState([]); + + useEffect(() => { + refCount += 1; + ensureSocket(); + + const removeListener = addTopicListener("config", (envelope) => { + let toast: Omit | null = null; + if (envelope.type === "config:update") { + toast = { tone: "ok", message: "config.yml reloaded" }; + } else if (envelope.type === "config:error") { + toast = { tone: "error", message: "config.yml has an error — showing last valid config" }; + } + if (!toast) return; + + toastCounter += 1; + const id = toastCounter; + setToasts((prev) => [...prev, { ...toast!, id }]); + setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000); + }); + + return () => { + refCount -= 1; + removeListener(); + }; + }, []); + + return toasts; +} diff --git a/package.json b/package.json index a853d14..b98e4d0 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "lint": "eslint" }, "dependencies": { + "@phosphor-icons/react": "^2.1.10", "chokidar": "^5.0.0", "dockerode": "^5.0.1", "dotenv": "^17.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a30c11e..f7cd486 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@phosphor-icons/react': + specifier: ^2.1.10 + version: 2.1.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8) chokidar: specifier: ^5.0.0 version: 5.0.0 @@ -660,6 +663,13 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@phosphor-icons/react@2.1.10': + resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==} + engines: {node: '>=10'} + peerDependencies: + react: '>= 16.8' + react-dom: '>= 16.8' + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -2943,6 +2953,11 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@phosphor-icons/react@2.1.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {}