// App.jsx — Root application component // Wires together: hash router, login guard, shell layout, and lazy screen container. // // Architecture: // 1. Router bootstrap — registers all valid routes, sets default, inits hash listener. // Must complete before ReactDOM.createRoot so router.current() is correct when // App's useState initialiser fires. // 2. Login guard — if authState.status !== "authed", renders + TweaksPanel // demo shortcut only. While status === "loading" a spinner/skeleton is shown. // 3. Shell layout — once authed, renders + + screen container. // 4. Lazy screen container — renders only the active screen component based on // `route` state. Each screen is conditionally included so React never mounts // a screen that isn't the current view (no hidden renders). // ── Constants ───────────────────────────────────────────────────────────────── const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "showStaleBanner": false, "density": "cozy", "collapseSidebar": false, "showHelpHints": false }/*EDITMODE-END*/; const LS_SIDEBAR_KEY = "keeper.sidebarCollapsed"; function findGreenhouseInData(greenhouseId) { if (!greenhouseId) return null; const greenhouseMap = (DATA && DATA.greenhouses) || {}; const siteIds = Object.keys(greenhouseMap); for (let i = 0; i < siteIds.length; i += 1) { const siteId = siteIds[i]; const rows = Array.isArray(greenhouseMap[siteId]) ? greenhouseMap[siteId] : []; const greenhouse = rows.find((item) => String(item.id) === String(greenhouseId)); if (greenhouse) return { siteId, greenhouse }; } return null; } function firstGreenhouseIdForSite(siteId) { if (siteId === undefined || siteId === null) return null; const rows = ((DATA && DATA.greenhouses) || {})[siteId]; if (!Array.isArray(rows) || rows.length === 0) return null; return rows[0]?.id ?? null; } function normalizeMapRouteState(state) { if (!state || typeof state !== "object") return null; const hasGreenhouseKey = Object.prototype.hasOwnProperty.call(state, "greenhouseId") || Object.prototype.hasOwnProperty.call(state, "greenhouse_id"); const hasZoneKey = Object.prototype.hasOwnProperty.call(state, "zoneId") || Object.prototype.hasOwnProperty.call(state, "zone_id"); const hasHiveKey = Object.prototype.hasOwnProperty.call(state, "hiveId") || Object.prototype.hasOwnProperty.call(state, "hive_id"); const hasDeviceKey = Object.prototype.hasOwnProperty.call(state, "deviceId") || Object.prototype.hasOwnProperty.call(state, "device_id"); if (!hasGreenhouseKey && !hasZoneKey && !hasHiveKey && !hasDeviceKey) return null; return { hasGreenhouseKey, hasZoneKey, hasHiveKey, hasDeviceKey, greenhouseId: hasGreenhouseKey ? (state.greenhouseId ?? state.greenhouse_id ?? null) : null, zoneId: hasZoneKey ? (state.zoneId ?? state.zone_id ?? null) : null, hiveId: hasHiveKey ? (state.hiveId ?? state.hive_id ?? null) : null, deviceId: hasDeviceKey ? (state.deviceId ?? state.device_id ?? null) : null, }; } // ── App root component ──────────────────────────────────────────────────────── function App() { // ── Auth & tenant state ────────────────────────────────────────────────── // authState shape: { status: "loading" | "authed" | "anon", user: object|null, tenant: object|null } // • "loading" — session check in-flight; render a neutral loading screen. // • "authed" — user is authenticated; render the full shell. // • "anon" — no active session; render the login screen. const [authState, setAuthState] = React.useState({ status: "loading", user: null, tenant: null, restoreMessage: "", }); // ── Router state ───────────────────────────────────────────────────────── // Initialise from router.current() so the correct screen is shown // immediately without waiting for a hashchange event. const [route, setRoute] = React.useState(() => Keeper.router.current()); const [routeState, setRouteState] = React.useState(() => { return Keeper.router.currentState ? Keeper.router.currentState() : null; }); const [selectedSiteId, setSelectedSiteId] = React.useState(() => { return DATA?.sites?.[0]?.id ?? null; }); const [selectedGreenhouseId, setSelectedGreenhouseId] = React.useState(null); const [selectedZoneId, setSelectedZoneId] = React.useState(null); const [selectedDeviceId, setSelectedDeviceId] = React.useState(null); // ── Hive overlay / full-page state ─────────────────────────────────────── const [hive, setHive] = React.useState(null); // selected hive id (detail panel) const [showHive, setShowHive] = React.useState(false); // full-page hive view toggle // ── Design tweaks & sidebar collapse ──────────────────────────────────── const [tweaks, setTweak] = useTweaks(TWEAK_DEFAULTS); const [sidebarCollapsed, setSidebarCollapsed] = React.useState( () => localStorage.getItem(LS_SIDEBAR_KEY) === "true" ); // ── Sidebar collapse helpers ───────────────────────────────────────────── function toggleSidebar() { setSidebarCollapsed(prev => { const next = !prev; localStorage.setItem(LS_SIDEBAR_KEY, String(next)); return next; }); } // ── Session revalidation on mount ─────────────────────────────────────── // When a keeper.token exists in localStorage, attempt to silently resume // the session by calling revalidateSession(). On success the session data // lands in window.DATA (via revalidateSession's own merge) so we read // activeTenant from there. On any failure (401, network error, etc.) we // wipe the stale token and drop the user to the login screen. // If there is no token at all we skip the round-trip and go straight to // "anon" so the user sees the login form with zero flicker. // [TASK_START:T012] React.useEffect(() => { const token = localStorage.getItem("keeper.token"); if (!token) { setAuthState({ status: "anon", user: null, tenant: null, restoreMessage: "" }); return; } Keeper.revalidateSession().then(ok => { if (ok) { const user = (window.DATA && window.DATA.user) || null; const tenant = (window.DATA && window.DATA.activeTenant) || null; setAuthState({ status: "authed", user, tenant, restoreMessage: "" }); } else { // Session invalid or backend unreachable — clear stale credentials // and send the user back to the login screen. localStorage.removeItem("keeper.token"); localStorage.removeItem("keeper.activeTenant"); setAuthState({ status: "anon", user: null, tenant: null, restoreMessage: "Не удалось восстановить сессию. Войдите заново.", }); } }); }, []); // run once on mount async function handleLogout() { // [TASK_START:T009] await Keeper.api.logout().catch(() => null); localStorage.removeItem("keeper.token"); localStorage.removeItem("keeper.activeTenant"); window.DATA = Object.assign({}, window.DATA || {}, { user: null, activeTenant: null }); setAuthState({ status: "anon", user: null, tenant: null, restoreMessage: "" }); Keeper.router.go("overview"); // [TASK_COMPLETE:T009] } function mapContextRouteState() { const resolvedGreenhouseId = selectedGreenhouseId ?? firstGreenhouseIdForSite(selectedSiteId) ?? null; return { greenhouseId: resolvedGreenhouseId, zoneId: selectedZoneId ?? null, deviceId: selectedDeviceId ?? null, }; } function navigate(routeId, state) { if (state === undefined) { Keeper.router.go(routeId); return; } Keeper.router.go(routeId, state); } function navigateFromScreen(routeId, state) { if (state !== undefined) { navigate(routeId, state); return; } if (routeId === "hives") { navigate("hives", mapContextRouteState()); return; } navigate(routeId); } // ── Route change listener ──────────────────────────────────────────────── // Sync React route state with the hash router's keeper:route custom event. // Also clears the hive overlay whenever the user navigates to a new screen. // // On mount we also re-read router.current() as a belt-and-suspenders guard: // router.init() runs synchronously before React mounts and updates _current // from window.location.hash, so this call ensures the component state always // reflects the correct initial route even if the useState lazy initialiser // captured a stale value for any reason (e.g. race with Babel transpilation). React.useEffect(() => { // Sync once on mount so a direct URL load (e.g. /#alerts) always shows // the correct screen on the very first render. setRoute(Keeper.router.current()); setRouteState(Keeper.router.currentState ? Keeper.router.currentState() : null); function onRouteChange(e) { setRoute(e.detail.route); setRouteState(e.detail.state ?? null); setHive(null); setShowHive(false); } window.addEventListener("keeper:route", onRouteChange); return () => window.removeEventListener("keeper:route", onRouteChange); }, []); // [TASK_START:T004] React.useEffect(() => { if (!["hives", "zones", "hiveslist", "hivepage", "devices"].includes(route)) return; const context = normalizeMapRouteState(routeState); if (!context) return; if (context.hasGreenhouseKey) { setSelectedGreenhouseId(context.greenhouseId); const greenhouseRef = findGreenhouseInData(context.greenhouseId); if (greenhouseRef) { const parsedSiteId = Number(greenhouseRef.siteId); setSelectedSiteId(Number.isNaN(parsedSiteId) ? greenhouseRef.siteId : parsedSiteId); } } if (context.hasZoneKey) setSelectedZoneId(context.zoneId); if (context.hasHiveKey) setHive(context.hiveId); if (context.hasDeviceKey) setSelectedDeviceId(context.deviceId); }, [route, routeState]); // [TASK_COMPLETE:T004] React.useEffect(() => { const selectedGreenhouseRef = findGreenhouseInData(selectedGreenhouseId); const resolvedSiteId = selectedGreenhouseRef ? (Number.isNaN(Number(selectedGreenhouseRef.siteId)) ? selectedGreenhouseRef.siteId : Number(selectedGreenhouseRef.siteId)) : selectedSiteId; const site = (DATA?.sites || []).find((item) => item.id === resolvedSiteId); const greenhouse = selectedGreenhouseRef?.greenhouse || null; const zone = (selectedGreenhouseId && DATA?.zones?.[selectedGreenhouseId] || []).find( (item) => item.id === selectedZoneId ); Keeper.breadcrumbs.setContext({ tenantName: authState?.tenant?.name || "Ahal Agro Park", siteName: site?.name || "Теплица", greenhouseName: greenhouse?.name || "Теплица", zoneName: zone?.name || zone?.id || "Зона", }); }, [authState?.tenant?.name, selectedSiteId, selectedGreenhouseId, selectedZoneId]); // [TASK_COMPLETE:T012] // ── Derived values ─────────────────────────────────────────────────────── const densCls = tweaks.density === "cozy" ? "dens-cozy" : tweaks.density === "comfy" ? "dens-comfy" : ""; // sidebar collapsed: persisted user preference OR tweaks-panel override const isCollapsed = sidebarCollapsed || tweaks.collapseSidebar; // ── Login guard ────────────────────────────────────────────────────────── // Show a loading indicator while the session is being resolved. // Show the login screen when the user is anonymous. // The TweaksPanel provides a quick "skip to app" shortcut for demos. if (authState.status === "loading") { // Full-screen splash rendered while the session revalidation round-trip is // in-flight. Keeps the user on a neutral dark screen so they never see a // flash of the login form when they already have a valid token. return (
Keeper ); } if (authState.status === "anon") { return ( <> setAuthState({ status: "authed", user, tenant, restoreMessage: "" })} /> setAuthState({ status: "authed", user: DATA.user || null, tenant: DATA.tenants[0], restoreMessage: "" })} /> ); } // ── Authenticated shell layout ─────────────────────────────────────────── // Destructure for convenience — authState.status === "authed" at this point. const { user, tenant } = authState; return (
{/* Sidebar — wrapped so .nav-collapsed class can be applied without adding an extra DOM element to the CSS Grid layout */}
navigateFromScreen(r)} onLogout={handleLogout} alertCount={DATA.alerts.filter(a => a.kind === "crit" || a.kind === "warn").length} taskCount={DATA.tasks.filter(t => t.col !== "done").length} collapsed={isCollapsed} onToggleCollapse={toggleSidebar} />
{/* Main content area: top bar + optional stale-data banner + screen */}
{tweaks.showStaleBanner && (
Связь с gateway GW-01 потеряна 2 мин назад · показаны последние известные значения
)} {/* ── Lazy screen container ────────────────────────────────────── Each screen is rendered only when its route is active. React unmounts inactive screens, keeping the DOM lean. Full-page HivePage is a special case: it overlays the entire main area regardless of route when showHive is true. */} {showHive && ( { setShowHive(false); navigate("hives", mapContextRouteState()); }}/> )} {!showHive && route === "overview" && ( navigateFromScreen(r)} onOpenHive={(hiveId) => { setHive(hiveId); navigate("hivepage"); }} /> )} {!showHive && route === "sites" && ( navigateFromScreen(r)} onSelectSite={(siteId) => { setSelectedSiteId(siteId); setSelectedGreenhouseId(null); setSelectedZoneId(null); setSelectedDeviceId(null); }} /> )} {!showHive && route === "site" && ( navigateFromScreen(r)} onOpenGreenhouses={(siteId) => { if (siteId !== undefined && siteId !== null) setSelectedSiteId(siteId); setSelectedGreenhouseId(null); setSelectedZoneId(null); setSelectedDeviceId(null); navigate("greenhouses"); }} /> )} {/* [TASK_START:T007] */} {!showHive && route === "greenhouses" && ( navigateFromScreen(r)} onSelectGreenhouse={(greenhouseId) => { setSelectedGreenhouseId(greenhouseId); setSelectedZoneId(null); setSelectedDeviceId(null); }} /> )} {!showHive && route === "zones" && ( navigateFromScreen(r)} onSelectZone={(zoneId) => { setSelectedZoneId(zoneId); setSelectedDeviceId(null); }} /> )} {!showHive && route === "hiveslist" && ( navigateFromScreen(r)} onSelectHive={(hiveId) => { setHive(hiveId); }} /> )} {/* [TASK_COMPLETE:T007] */} {!showHive && route === "hives" && ( { setHive(h); }} onDrillZone={(payload) => { setSelectedGreenhouseId(payload?.greenhouseId ?? selectedGreenhouseId); setSelectedZoneId(payload?.zoneId ?? null); setSelectedDeviceId(null); navigate("hiveslist", { greenhouseId: payload?.greenhouseId ?? selectedGreenhouseId ?? null, zoneId: payload?.zoneId ?? null, }); }} onDrillHive={(payload) => { setSelectedGreenhouseId(payload?.greenhouseId ?? selectedGreenhouseId); setSelectedZoneId(payload?.zoneId ?? null); setSelectedDeviceId(null); setHive(payload?.hiveId ?? null); navigate("hivepage", { greenhouseId: payload?.greenhouseId ?? selectedGreenhouseId ?? null, zoneId: payload?.zoneId ?? null, hiveId: payload?.hiveId ?? null, }); }} onDrillDevice={(payload) => { setSelectedGreenhouseId(payload?.greenhouseId ?? selectedGreenhouseId); setSelectedZoneId(payload?.zoneId ?? null); setSelectedDeviceId(payload?.deviceId ?? null); navigate("devices", { greenhouseId: payload?.greenhouseId ?? selectedGreenhouseId ?? null, zoneId: payload?.zoneId ?? null, deviceId: payload?.deviceId ?? null, }); }} /> )} {!showHive && route === "devices" && ( )} {!showHive && route === "telemetry" && } {!showHive && route === "microclimate" && } {!showHive && route === "alerts" && } {!showHive && route === "recommendations" && navigateFromScreen(r)}/>} {!showHive && route === "tasks" && } {!showHive && route === "rules" && } {!showHive && route === "commands" && } {!showHive && route === "ota" && } {!showHive && route === "reports" && } {!showHive && route === "settings" && } {!showHive && route === "roi" && } {!showHive && route === "hivepage" && navigate("hives", mapContextRouteState())}/>} {!showHive && route === "simulator" && }
{/* Hive detail slide-over panel + "open full page" button */} {hive && !showHive && ( <> setHive(null)}/> )} {/* Design tweaks panel — fixed bottom-right, available in all authed views */} setTweak("showStaleBanner", v)} /> setTweak("collapseSidebar", v)} /> setTweak("density", v)} options={[ { value: "cozy", label: "Cozy" }, { value: "default", label: "Default" }, { value: "comfy", label: "Comfy" }, ]} /> Keeper.router.go("overview")}/> navigate("hives", mapContextRouteState())}/> { setShowHive(true); setHive(null); }}/> Keeper.router.go("commands")}/> Keeper.router.go("tasks")}/> Keeper.router.go("ota")}/> Keeper.router.go("roi")}/> Keeper.router.go("simulator")}/>
); } // ── Router bootstrap ─────────────────────────────────────────────────────────── // Register every valid route, set the default fallback, then initialise the // hash listener. Must run before ReactDOM.createRoot so that router.current() // returns the correct value when App's useState initialiser fires. Keeper.router .registerAll([ "overview", "sites", "site", "hives", "greenhouses", "zones", "hiveslist", "devices", "telemetry", "microclimate", "alerts", "recommendations", "tasks", "rules", "commands", "ota", "reports", "settings", "roi", "hivepage", "simulator", ]) .setDefault("overview") .init(); // ── Mount ────────────────────────────────────────────────────────────────────── // Hydrate demo data (no-op if backend is unavailable) then render the React tree. window.Keeper.hydrate().finally(() => { ReactDOM.createRoot(document.getElementById("root")).render(); });