/** * Shell entry point — loaded on every page. * * Registers the three header web components: * * * */ import { RStackIdentity } from "../shared/components/rstack-identity"; import { RStackNotificationBell } from "../shared/components/rstack-notification-bell"; import { RStackAppSwitcher } from "../shared/components/rstack-app-switcher"; import { RStackSpaceSwitcher } from "../shared/components/rstack-space-switcher"; import { RStackTabBar } from "../shared/components/rstack-tab-bar"; import { RStackMi } from "../shared/components/rstack-mi"; import { RStackSpaceSettings } from "../shared/components/rstack-space-settings"; import { RStackModuleSetup } from "../shared/components/rstack-module-setup"; import { RStackHistoryPanel } from "../shared/components/rstack-history-panel"; import { RStackOfflineIndicator } from "../shared/components/rstack-offline-indicator"; import { RStackSharePanel } from "../shared/components/rstack-share-panel"; import { RStackCommentBell } from "../shared/components/rstack-comment-bell"; import { RStackCollabOverlay } from "../shared/components/rstack-collab-overlay"; import { RStackUserDashboard } from "../shared/components/rstack-user-dashboard"; import { rspaceNavUrl } from "../shared/url-helpers"; import { TabCache } from "../shared/tab-cache"; import { RSpaceOfflineRuntime } from "../shared/local-first/runtime"; import { CommunitySync } from "../lib/community-sync"; import { OfflineStore } from "../lib/offline-store"; // Expose URL helper globally (used by shell inline scripts + components) (window as any).__rspaceNavUrl = rspaceNavUrl; // Expose TabCache for inline shell script to instantiate (window as any).__RSpaceTabCache = TabCache; // Register all header components RStackIdentity.define(); RStackNotificationBell.define(); RStackAppSwitcher.define(); RStackSpaceSwitcher.define(); RStackTabBar.define(); RStackMi.define(); RStackSpaceSettings.define(); RStackModuleSetup.define(); RStackHistoryPanel.define(); RStackOfflineIndicator.define(); RStackSharePanel.define(); RStackCommentBell.define(); RStackCollabOverlay.define(); RStackUserDashboard.define(); // ── Offline Runtime ── // Instantiate the shared runtime from the space slug on the tag. // Components access it via window.__rspaceOfflineRuntime. const spaceSlug = document.body?.getAttribute("data-space-slug"); if (spaceSlug && spaceSlug !== "demo") { const runtime = new RSpaceOfflineRuntime(spaceSlug); (window as any).__rspaceOfflineRuntime = runtime; // Configure module scope resolution from server-rendered data try { const scopeJson = document.body?.getAttribute("data-scope-overrides"); const overrides: Record = scopeJson ? JSON.parse(scopeJson) : {}; // Build scope config: merge module defaults with space overrides const moduleList: Array<{ id: string; scoping?: { defaultScope: string } }> = (window as any).__rspaceModuleList || []; const scopes: Array<{ id: string; scope: 'global' | 'space' }> = moduleList.map(m => ({ id: m.id, scope: (overrides[m.id] || m.scoping?.defaultScope || 'space') as 'global' | 'space', })); runtime.setModuleScopes(scopes); } catch { /* scope config unavailable — defaults to space-scoped */ } runtime.init().catch((e: unknown) => { console.warn("[shell] Offline runtime init failed — REST fallback only:", e); }); // Flush pending writes before the page unloads window.addEventListener("beforeunload", () => { runtime.flush(); }); // ── CommunitySync (tab list Automerge sync) ── const offlineStore = new OfflineStore(); const communitySync = new CommunitySync(spaceSlug, offlineStore); (window as any).__communitySync = communitySync; (async () => { try { await offlineStore.open(); await communitySync.initFromCache(); } catch (e) { console.warn("[shell] CommunitySync cache init failed:", e); } document.dispatchEvent(new CustomEvent("community-sync-ready", { detail: { sync: communitySync, communitySlug: spaceSlug }, })); const proto = location.protocol === "https:" ? "wss:" : "ws:"; const wsUrl = `${proto}//${location.host}/ws/${spaceSlug}`; communitySync.connect(wsUrl); })(); window.addEventListener("beforeunload", () => { communitySync.saveBeforeUnload(); }); } // ── Track space visits for dashboard recency sorting ── if (spaceSlug) { try { const RECENT_KEY = "rspace_recent_spaces"; const visits = JSON.parse(localStorage.getItem(RECENT_KEY) || "{}"); visits[spaceSlug] = Date.now(); localStorage.setItem(RECENT_KEY, JSON.stringify(visits)); } catch { /* localStorage unavailable */ } } // Reload space list when user signs in/out (to show/hide private spaces) document.addEventListener("auth-change", (e) => { const reason = (e as CustomEvent).detail?.reason; // Token refreshes are invisible — no UI side-effects needed if (reason === "refresh") return; // Reload space switcher on state-changing events const spaceSwitcher = document.querySelector("rstack-space-switcher") as any; spaceSwitcher?.reload?.(); // Only redirect to homepage on genuine sign-out or server revocation if (reason === "signout" || reason === "revoked") { window.location.href = "/"; } }); // ── SW Update Banner ── // Show "new version available" when a new service worker activates. // The SW calls skipWaiting() so it activates immediately — we detect the // controller change and prompt the user to reload for the fresh content. if ("serviceWorker" in navigator && location.hostname !== "localhost") { // Only listen if there's already a controller (skip first-time install) if (navigator.serviceWorker.controller) { navigator.serviceWorker.addEventListener("controllerchange", () => { showUpdateBanner(); }); } // Also detect waiting workers (edge case: skipWaiting didn't fire yet) navigator.serviceWorker.getRegistration().then((reg) => { if (!reg) return; if (reg.waiting && navigator.serviceWorker.controller) { showUpdateBanner(); return; } reg.addEventListener("updatefound", () => { const newWorker = reg.installing; if (!newWorker) return; newWorker.addEventListener("statechange", () => { if (newWorker.state === "installed" && navigator.serviceWorker.controller) { showUpdateBanner(); } }); }); }); } function showUpdateBanner() { if (document.getElementById("sw-update-banner")) return; const banner = document.createElement("div"); banner.id = "sw-update-banner"; banner.setAttribute("role", "alert"); banner.innerHTML = ` New version available `; const style = document.createElement("style"); style.textContent = ` #sw-update-banner { position: fixed; top: 0; left: 0; right: 0; z-index: 10000; display: flex; align-items: center; justify-content: center; gap: 12px; padding: 10px 16px; background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; font-size: 14px; font-weight: 500; font-family: system-ui, -apple-system, sans-serif; box-shadow: 0 2px 12px rgba(0,0,0,0.3); animation: sw-slide-down 0.3s ease-out; } @keyframes sw-slide-down { from { transform: translateY(-100%); } to { transform: translateY(0); } } #sw-update-btn { padding: 5px 14px; border-radius: 6px; border: 1.5px solid rgba(255,255,255,0.5); background: rgba(255,255,255,0.15); color: white; font-size: 13px; font-weight: 600; cursor: pointer; transition: background 0.15s; } #sw-update-btn:hover { background: rgba(255,255,255,0.3); } #sw-update-dismiss { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); background: none; border: none; color: rgba(255,255,255,0.7); font-size: 20px; cursor: pointer; padding: 4px 8px; line-height: 1; } #sw-update-dismiss:hover { color: white; } `; document.head.appendChild(style); document.body.prepend(banner); banner.querySelector("#sw-update-btn")!.addEventListener("click", () => { window.location.reload(); }); banner.querySelector("#sw-update-dismiss")!.addEventListener("click", () => { banner.remove(); }); }