221 lines
8.1 KiB
TypeScript
221 lines
8.1 KiB
TypeScript
/**
|
|
* Shell entry point — loaded on every page.
|
|
*
|
|
* Registers the three header web components:
|
|
* <rstack-app-switcher>
|
|
* <rstack-space-switcher>
|
|
* <rstack-identity>
|
|
*/
|
|
|
|
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 <body> 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<string, string> = 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 = `
|
|
<span>New version available</span>
|
|
<button id="sw-update-btn">Tap to update</button>
|
|
<button id="sw-update-dismiss" aria-label="Dismiss">×</button>
|
|
`;
|
|
|
|
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();
|
|
});
|
|
}
|