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
119 lines
3.2 KiB
TypeScript
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>
|
|
);
|
|
}
|