+ {toasts.map((toast) => {
+ const Icon = ICONS[toast.type]
+ return (
+
+
+
+
+
{toast.title}
+ {toast.body && (
+
{toast.body}
+ )}
+
+
+
+
+ )
+ })}
+
+ )
+}
diff --git a/frontend/src/hooks/.gitkeep b/frontend/src/hooks/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/frontend/src/hooks/useSSE.ts b/frontend/src/hooks/useSSE.ts
new file mode 100644
index 0000000..051f95d
--- /dev/null
+++ b/frontend/src/hooks/useSSE.ts
@@ -0,0 +1,193 @@
+import { useEffect, useRef, useCallback } from 'react'
+import { useQueryClient } from '@tanstack/react-query'
+
+export type SSEEventType =
+ | 'printer.status'
+ | 'job.started'
+ | 'job.completed'
+ | 'filament.low'
+
+export interface SSEEvent {
+ type: SSEEventType
+ payload: unknown
+ timestamp: string
+}
+
+export interface ToastMessage {
+ id: string
+ type: 'info' | 'success' | 'warning'
+ title: string
+ body?: string
+}
+
+let toastListeners: Array<(toast: ToastMessage) => void> = []
+
+export function addToastListener(cb: (toast: ToastMessage) => void) {
+ toastListeners.push(cb)
+ return () => {
+ toastListeners = toastListeners.filter(l => l !== cb)
+ }
+}
+
+function emitToast(toast: ToastMessage) {
+ toastListeners.forEach(cb => cb(toast))
+}
+
+const RECONNECT_BASE_MS = 1000
+const RECONNECT_MAX_MS = 30000
+
+export function useSSE(enabled = true) {
+ const queryClient = useQueryClient()
+ const esRef = useRef