Files
silo/web/src/components/AppShell.tsx
Forbes 7fec219152 feat(modules): SSE settings.changed event broadcast and UI reactions
Add useSSE hook that connects to /api/events with automatic reconnect
and exponential backoff. On settings.changed events:

- Refresh module state so sidebar nav items show/hide immediately
- Show dismissable toast when another admin updates settings

The backend already publishes settings.changed in HandleUpdateModuleSettings.

Closes #101
2026-02-15 13:11:04 -06:00

119 lines
3.2 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import { Outlet } from "react-router-dom";
import { useAuth } from "../hooks/useAuth";
import { useDensity } from "../hooks/useDensity";
import { useModules } from "../hooks/useModules";
import { useSSE } from "../hooks/useSSE";
import { Sidebar } from "./Sidebar";
export function AppShell() {
const { user, loading, logout } = useAuth();
const [density, toggleDensity] = useDensity();
const { modules, refresh: refreshModules } = useModules();
const { on } = useSSE();
const [toast, setToast] = useState<string | null>(null);
// Listen for settings.changed SSE events
useEffect(() => {
return on("settings.changed", (raw) => {
try {
const data = JSON.parse(raw) as {
module: string;
changed_keys: string[];
updated_by: string;
};
refreshModules();
if (data.updated_by !== user?.username) {
setToast(`Settings updated by ${data.updated_by}`);
}
} catch {
// ignore malformed events
}
});
}, [on, refreshModules, user?.username]);
// Auto-dismiss toast
useEffect(() => {
if (!toast) return;
const timer = setTimeout(() => setToast(null), 5000);
return () => clearTimeout(timer);
}, [toast]);
const [sidebarOpen, setSidebarOpen] = useState(() => {
return localStorage.getItem("silo-sidebar") !== "closed";
});
const toggleSidebar = useCallback(() => {
setSidebarOpen((prev) => {
const next = !prev;
localStorage.setItem("silo-sidebar", next ? "open" : "closed");
return next;
});
}, []);
// Ctrl+J to toggle sidebar
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === "j") {
e.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [toggleSidebar]);
if (loading) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
}}
>
<div className="spinner" />
</div>
);
}
return (
<div style={{ display: "flex", height: "100vh" }}>
<Sidebar
open={sidebarOpen}
onToggle={toggleSidebar}
modules={modules}
user={user}
density={density}
onToggleDensity={toggleDensity}
onLogout={logout}
/>
<main style={{ flex: 1, overflow: "auto", padding: "1rem" }}>
<Outlet />
</main>
{toast && (
<div
style={{
position: "fixed",
bottom: "1rem",
right: "1rem",
padding: "0.5rem 1rem",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-text)",
borderRadius: "0.5rem",
fontSize: "var(--font-body)",
border: "1px solid var(--ctp-surface2)",
boxShadow: "0 2px 8px rgba(0,0,0,0.3)",
zIndex: 1000,
cursor: "pointer",
}}
onClick={() => setToast(null)}
>
{toast}
</div>
)}
</div>
);
}