feat: unified module system — Phase 0 shell + Phase 1 canvas module
Implement the rSpace module architecture that enables all r-suite apps
to run as modules within a single-origin platform at rspace.online,
while each module can still deploy standalone at its own domain.
Phase 0 — Shell + Module System:
- RSpaceModule interface (shared/module.ts) with routes, metadata, hooks
- Shell HTML renderer (server/shell.ts) for wrapping module content
- Three header web components: rstack-app-switcher, rstack-space-switcher,
rstack-identity (refactored from rspace-header.ts into Shadow DOM)
- Space registry API (server/spaces.ts) — /api/spaces CRUD
- Hono-based server (server/index.ts) replacing raw Bun.serve fetch handler
while preserving all WebSocket, API, and subdomain backward compat
- Shared PostgreSQL with per-module schema isolation (rbooks, rcart, etc.)
- Vite multi-entry build: shell.js + shell.css built alongside existing entries
- Module info API: GET /api/modules returns registered module metadata
Phase 1 — Canvas Module:
- modules/canvas/mod.ts exports canvasModule as first RSpaceModule
- Canvas routes mounted at /:space/canvas with shell wrapper
- Fallback serves existing canvas.html for backward compatibility
- /:space redirects to /:space/canvas
URL structure: rspace.online/{space}/{module} (e.g. /demo/canvas)
All existing subdomain routing (*.rspace.online) preserved.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7210888aed
commit
eed7b2f151
|
|
@ -24,6 +24,8 @@ WORKDIR /app
|
|||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/server ./server
|
||||
COPY --from=build /app/lib ./lib
|
||||
COPY --from=build /app/shared ./shared
|
||||
COPY --from=build /app/modules ./modules
|
||||
COPY --from=build /app/package.json .
|
||||
COPY --from=build /encryptid-sdk /encryptid-sdk
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
-- rSpace shared PostgreSQL — per-module schema isolation
|
||||
-- Each module owns its schema. Modules that don't need a DB skip this.
|
||||
|
||||
-- Module schemas (created on init, populated by module migrations)
|
||||
CREATE SCHEMA IF NOT EXISTS rbooks;
|
||||
CREATE SCHEMA IF NOT EXISTS rcart;
|
||||
CREATE SCHEMA IF NOT EXISTS providers;
|
||||
CREATE SCHEMA IF NOT EXISTS rfiles;
|
||||
CREATE SCHEMA IF NOT EXISTS rforum;
|
||||
|
||||
-- Grant usage to the rspace user
|
||||
GRANT ALL ON SCHEMA rbooks TO rspace;
|
||||
GRANT ALL ON SCHEMA rcart TO rspace;
|
||||
GRANT ALL ON SCHEMA providers TO rspace;
|
||||
GRANT ALL ON SCHEMA rfiles TO rspace;
|
||||
GRANT ALL ON SCHEMA rforum TO rspace;
|
||||
|
|
@ -13,21 +13,51 @@ services:
|
|||
- STORAGE_DIR=/data/communities
|
||||
- PORT=3000
|
||||
- INTERNAL_API_KEY=${INTERNAL_API_KEY}
|
||||
- DATABASE_URL=postgres://rspace:${POSTGRES_PASSWORD:-rspace}@rspace-db:5432/rspace
|
||||
depends_on:
|
||||
rspace-db:
|
||||
condition: service_healthy
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# Only handle subdomains (rspace-prod handles main domain)
|
||||
- "traefik.http.routers.rspace-canvas.rule=HostRegexp(`{subdomain:[a-z0-9-]+}.rspace.online`) && !Host(`rspace.online`) && !Host(`www.rspace.online`)"
|
||||
# Main domain — serves landing + path-based routing
|
||||
- "traefik.http.routers.rspace-main.rule=Host(`rspace.online`)"
|
||||
- "traefik.http.routers.rspace-main.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-main.priority=110"
|
||||
# Subdomains — backward compat for *.rspace.online canvas
|
||||
- "traefik.http.routers.rspace-canvas.rule=HostRegexp(`{subdomain:[a-z0-9-]+}.rspace.online`) && !Host(`rspace.online`) && !Host(`www.rspace.online`) && !Host(`auth.rspace.online`)"
|
||||
- "traefik.http.routers.rspace-canvas.entrypoints=web"
|
||||
- "traefik.http.routers.rspace-canvas.priority=100"
|
||||
# Service configuration
|
||||
- "traefik.http.services.rspace-canvas.loadbalancer.server.port=3000"
|
||||
- "traefik.http.services.rspace-online.loadbalancer.server.port=3000"
|
||||
- "traefik.docker.network=traefik-public"
|
||||
networks:
|
||||
- traefik-public
|
||||
- rspace-internal
|
||||
|
||||
rspace-db:
|
||||
image: postgres:16-alpine
|
||||
container_name: rspace-db
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- rspace-pgdata:/var/lib/postgresql/data
|
||||
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||
environment:
|
||||
- POSTGRES_DB=rspace
|
||||
- POSTGRES_USER=rspace
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-rspace}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U rspace"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
networks:
|
||||
- rspace-internal
|
||||
|
||||
volumes:
|
||||
rspace-data:
|
||||
rspace-pgdata:
|
||||
|
||||
networks:
|
||||
traefik-public:
|
||||
external: true
|
||||
rspace-internal:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* Canvas module — the collaborative infinite canvas.
|
||||
*
|
||||
* This is the original rSpace canvas restructured as an rSpace module.
|
||||
* Routes are relative to the mount point (/:space/canvas in unified mode,
|
||||
* / in standalone mode).
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { resolve } from "node:path";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
|
||||
const DIST_DIR = resolve(import.meta.dir, "../../dist");
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
// GET / — serve the canvas page wrapped in shell
|
||||
routes.get("/", async (c) => {
|
||||
const spaceSlug = c.req.param("space") || c.req.query("space") || "demo";
|
||||
|
||||
// Read the canvas page template from dist
|
||||
const canvasFile = Bun.file(resolve(DIST_DIR, "canvas-module.html"));
|
||||
let canvasBody = "";
|
||||
if (await canvasFile.exists()) {
|
||||
canvasBody = await canvasFile.text();
|
||||
} else {
|
||||
// Fallback: serve full canvas.html directly if module template not built yet
|
||||
const fallbackFile = Bun.file(resolve(DIST_DIR, "canvas.html"));
|
||||
if (await fallbackFile.exists()) {
|
||||
return new Response(fallbackFile, {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
}
|
||||
canvasBody = `<div style="padding:2rem;text-align:center;color:#64748b;">Canvas loading...</div>`;
|
||||
}
|
||||
|
||||
const html = renderShell({
|
||||
title: `${spaceSlug} — Canvas | rSpace`,
|
||||
moduleId: "canvas",
|
||||
spaceSlug,
|
||||
body: canvasBody,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "light",
|
||||
scripts: `<script type="module" src="/canvas-module.js"></script>`,
|
||||
});
|
||||
|
||||
return c.html(html);
|
||||
});
|
||||
|
||||
export const canvasModule: RSpaceModule = {
|
||||
id: "canvas",
|
||||
name: "Canvas",
|
||||
icon: "🎨",
|
||||
description: "Collaborative infinite canvas",
|
||||
routes,
|
||||
};
|
||||
871
server/index.ts
871
server/index.ts
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* Shell HTML renderer.
|
||||
*
|
||||
* Wraps module content in the shared rSpace layout: header with app/space
|
||||
* switchers + identity, <main> with module content, shell script + styles.
|
||||
*
|
||||
* In standalone mode, modules call renderStandaloneShell() which omits the
|
||||
* app/space switchers and only includes identity.
|
||||
*/
|
||||
|
||||
import type { ModuleInfo } from "../shared/module";
|
||||
|
||||
export interface ShellOptions {
|
||||
/** Page <title> */
|
||||
title: string;
|
||||
/** Current module ID (highlighted in app switcher) */
|
||||
moduleId: string;
|
||||
/** Current space slug */
|
||||
spaceSlug: string;
|
||||
/** Space display name */
|
||||
spaceName?: string;
|
||||
/** Module HTML content to inject into <main> */
|
||||
body: string;
|
||||
/** Additional <script type="module"> tags for module-specific JS */
|
||||
scripts?: string;
|
||||
/** Additional <link>/<style> tags for module-specific CSS */
|
||||
styles?: string;
|
||||
/** List of available modules (for app switcher) */
|
||||
modules: ModuleInfo[];
|
||||
/** Theme for the header: 'dark' or 'light' */
|
||||
theme?: "dark" | "light";
|
||||
/** Extra <head> content (meta tags, preloads, etc.) */
|
||||
head?: string;
|
||||
}
|
||||
|
||||
export function renderShell(opts: ShellOptions): string {
|
||||
const {
|
||||
title,
|
||||
moduleId,
|
||||
spaceSlug,
|
||||
spaceName,
|
||||
body,
|
||||
scripts = "",
|
||||
styles = "",
|
||||
modules,
|
||||
theme = "light",
|
||||
head = "",
|
||||
} = opts;
|
||||
|
||||
const moduleListJSON = JSON.stringify(modules);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🌌</text></svg>">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<link rel="stylesheet" href="/shell.css">
|
||||
${styles}
|
||||
${head}
|
||||
</head>
|
||||
<body data-theme="${theme}">
|
||||
<header class="rstack-header" data-theme="${theme}">
|
||||
<div class="rstack-header__left">
|
||||
<rstack-app-switcher current="${escapeAttr(moduleId)}"></rstack-app-switcher>
|
||||
<rstack-space-switcher current="${escapeAttr(spaceSlug)}" name="${escapeAttr(spaceName || spaceSlug)}"></rstack-space-switcher>
|
||||
</div>
|
||||
<div class="rstack-header__right">
|
||||
<rstack-identity></rstack-identity>
|
||||
</div>
|
||||
</header>
|
||||
<main id="app">
|
||||
${body}
|
||||
</main>
|
||||
<script type="module">
|
||||
import '/shell.js';
|
||||
// Provide module list to app switcher
|
||||
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
|
||||
</script>
|
||||
${scripts}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** Minimal shell for standalone module deployments (no app/space switcher) */
|
||||
export function renderStandaloneShell(opts: {
|
||||
title: string;
|
||||
body: string;
|
||||
scripts?: string;
|
||||
styles?: string;
|
||||
theme?: "dark" | "light";
|
||||
head?: string;
|
||||
}): string {
|
||||
const { title, body, scripts = "", styles = "", theme = "light", head = "" } = opts;
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<link rel="stylesheet" href="/shell.css">
|
||||
${styles}
|
||||
${head}
|
||||
</head>
|
||||
<body data-theme="${theme}">
|
||||
<header class="rstack-header rstack-header--standalone" data-theme="${theme}">
|
||||
<div class="rstack-header__left">
|
||||
<a href="/" class="rstack-header__brand">
|
||||
<span class="rstack-header__brand-gradient">rSpace</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="rstack-header__right">
|
||||
<rstack-identity></rstack-identity>
|
||||
</div>
|
||||
</header>
|
||||
<main id="app">
|
||||
${body}
|
||||
</main>
|
||||
<script type="module">
|
||||
import '/shell.js';
|
||||
</script>
|
||||
${scripts}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* Space registry — CRUD for rSpace spaces.
|
||||
*
|
||||
* Spaces are stored as Automerge CRDT documents (extending the existing
|
||||
* community-store pattern). This module provides Hono routes for listing,
|
||||
* creating, and managing spaces.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
communityExists,
|
||||
createCommunity,
|
||||
loadCommunity,
|
||||
getDocumentData,
|
||||
listCommunities,
|
||||
} from "./community-store";
|
||||
import type { SpaceVisibility } from "./community-store";
|
||||
import {
|
||||
verifyEncryptIDToken,
|
||||
extractToken,
|
||||
} from "@encryptid/sdk/server";
|
||||
import type { EncryptIDClaims } from "@encryptid/sdk/server";
|
||||
import { getAllModules } from "../shared/module";
|
||||
|
||||
const spaces = new Hono();
|
||||
|
||||
// ── List all spaces (public + user's own) ──
|
||||
|
||||
spaces.get("/", async (c) => {
|
||||
const slugs = await listCommunities();
|
||||
|
||||
const spacesList = [];
|
||||
for (const slug of slugs) {
|
||||
await loadCommunity(slug);
|
||||
const data = getDocumentData(slug);
|
||||
if (data?.meta) {
|
||||
const vis = data.meta.visibility || "public_read";
|
||||
// Only include public/public_read spaces in the public listing
|
||||
if (vis === "public" || vis === "public_read") {
|
||||
spacesList.push({
|
||||
slug: data.meta.slug,
|
||||
name: data.meta.name,
|
||||
visibility: vis,
|
||||
createdAt: data.meta.createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ spaces: spacesList });
|
||||
});
|
||||
|
||||
// ── Create a new space (requires auth) ──
|
||||
|
||||
spaces.post("/", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) {
|
||||
return c.json({ error: "Authentication required" }, 401);
|
||||
}
|
||||
|
||||
let claims: EncryptIDClaims;
|
||||
try {
|
||||
claims = await verifyEncryptIDToken(token);
|
||||
} catch {
|
||||
return c.json({ error: "Invalid or expired token" }, 401);
|
||||
}
|
||||
|
||||
const body = await c.req.json<{
|
||||
name?: string;
|
||||
slug?: string;
|
||||
visibility?: SpaceVisibility;
|
||||
}>();
|
||||
|
||||
const { name, slug, visibility = "public_read" } = body;
|
||||
|
||||
if (!name || !slug) {
|
||||
return c.json({ error: "Name and slug are required" }, 400);
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(slug)) {
|
||||
return c.json({ error: "Slug must contain only lowercase letters, numbers, and hyphens" }, 400);
|
||||
}
|
||||
|
||||
const validVisibilities: SpaceVisibility[] = ["public", "public_read", "authenticated", "members_only"];
|
||||
if (!validVisibilities.includes(visibility)) {
|
||||
return c.json({ error: `Invalid visibility. Must be one of: ${validVisibilities.join(", ")}` }, 400);
|
||||
}
|
||||
|
||||
if (await communityExists(slug)) {
|
||||
return c.json({ error: "Space already exists" }, 409);
|
||||
}
|
||||
|
||||
await createCommunity(name, slug, claims.sub, visibility);
|
||||
|
||||
// Notify all modules about the new space
|
||||
for (const mod of getAllModules()) {
|
||||
if (mod.onSpaceCreate) {
|
||||
try {
|
||||
await mod.onSpaceCreate(slug);
|
||||
} catch (e) {
|
||||
console.error(`[Spaces] Module ${mod.id} onSpaceCreate failed:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
slug,
|
||||
name,
|
||||
visibility,
|
||||
ownerDID: claims.sub,
|
||||
url: `/${slug}/canvas`,
|
||||
}, 201);
|
||||
});
|
||||
|
||||
// ── Get space info ──
|
||||
|
||||
spaces.get("/:slug", async (c) => {
|
||||
const slug = c.req.param("slug");
|
||||
await loadCommunity(slug);
|
||||
const data = getDocumentData(slug);
|
||||
|
||||
if (!data) {
|
||||
return c.json({ error: "Space not found" }, 404);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
slug: data.meta.slug,
|
||||
name: data.meta.name,
|
||||
visibility: data.meta.visibility,
|
||||
createdAt: data.meta.createdAt,
|
||||
ownerDID: data.meta.ownerDID,
|
||||
memberCount: Object.keys(data.members || {}).length,
|
||||
});
|
||||
});
|
||||
|
||||
export { spaces };
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* <rstack-app-switcher> — Dropdown to switch between rSpace modules.
|
||||
*
|
||||
* Attributes:
|
||||
* current — the active module ID (highlighted)
|
||||
*
|
||||
* Methods:
|
||||
* setModules(list) — provide the list of available modules
|
||||
*/
|
||||
|
||||
export interface AppSwitcherModule {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export class RStackAppSwitcher extends HTMLElement {
|
||||
#shadow: ShadowRoot;
|
||||
#modules: AppSwitcherModule[] = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.#shadow = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return ["current"];
|
||||
}
|
||||
|
||||
get current(): string {
|
||||
return this.getAttribute("current") || "";
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.#render();
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
this.#render();
|
||||
}
|
||||
|
||||
setModules(modules: AppSwitcherModule[]) {
|
||||
this.#modules = modules;
|
||||
this.#render();
|
||||
}
|
||||
|
||||
#render() {
|
||||
const current = this.current;
|
||||
const currentMod = this.#modules.find((m) => m.id === current);
|
||||
const label = currentMod ? `${currentMod.icon} ${currentMod.name}` : "🌌 rSpace";
|
||||
|
||||
this.#shadow.innerHTML = `
|
||||
<style>${STYLES}</style>
|
||||
<div class="switcher">
|
||||
<button class="trigger" id="trigger">${label} <span class="caret">▾</span></button>
|
||||
<div class="menu" id="menu">
|
||||
${this.#modules
|
||||
.map(
|
||||
(m) => `
|
||||
<a class="item ${m.id === current ? "active" : ""}"
|
||||
href="/${this.#getSpaceSlug()}/${m.id}"
|
||||
data-id="${m.id}">
|
||||
<span class="item-icon">${m.icon}</span>
|
||||
<div class="item-text">
|
||||
<span class="item-name">${m.name}</span>
|
||||
<span class="item-desc">${m.description}</span>
|
||||
</div>
|
||||
</a>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const trigger = this.#shadow.getElementById("trigger")!;
|
||||
const menu = this.#shadow.getElementById("menu")!;
|
||||
|
||||
trigger.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
menu.classList.toggle("open");
|
||||
});
|
||||
|
||||
document.addEventListener("click", () => menu.classList.remove("open"));
|
||||
}
|
||||
|
||||
#getSpaceSlug(): string {
|
||||
// Read from the space switcher or URL
|
||||
const spaceSwitcher = document.querySelector("rstack-space-switcher");
|
||||
if (spaceSwitcher) return spaceSwitcher.getAttribute("current") || "personal";
|
||||
// Fallback: parse from URL (/:space/:module)
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
return parts[0] || "personal";
|
||||
}
|
||||
|
||||
static define(tag = "rstack-app-switcher") {
|
||||
if (!customElements.get(tag)) customElements.define(tag, RStackAppSwitcher);
|
||||
}
|
||||
}
|
||||
|
||||
const STYLES = `
|
||||
:host { display: contents; }
|
||||
|
||||
.switcher { position: relative; }
|
||||
|
||||
.trigger {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 14px; border-radius: 8px; border: none;
|
||||
font-size: 0.9rem; font-weight: 600; cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
background: rgba(255,255,255,0.08); color: inherit;
|
||||
}
|
||||
:host-context([data-theme="light"]) .trigger {
|
||||
background: rgba(0,0,0,0.05); color: #0f172a;
|
||||
}
|
||||
:host-context([data-theme="dark"]) .trigger {
|
||||
background: rgba(255,255,255,0.08); color: #e2e8f0;
|
||||
}
|
||||
.trigger:hover { background: rgba(255,255,255,0.12); }
|
||||
:host-context([data-theme="light"]) .trigger:hover { background: rgba(0,0,0,0.08); }
|
||||
|
||||
.caret { font-size: 0.7em; opacity: 0.6; }
|
||||
|
||||
.menu {
|
||||
position: absolute; top: 100%; left: 0; margin-top: 6px;
|
||||
min-width: 260px; border-radius: 12px; overflow: hidden;
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,0.25); display: none; z-index: 200;
|
||||
}
|
||||
.menu.open { display: block; }
|
||||
:host-context([data-theme="light"]) .menu {
|
||||
background: white; border: 1px solid rgba(0,0,0,0.1);
|
||||
}
|
||||
:host-context([data-theme="dark"]) .menu {
|
||||
background: #1e293b; border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 14px; text-decoration: none;
|
||||
transition: background 0.12s; cursor: pointer;
|
||||
}
|
||||
:host-context([data-theme="light"]) .item { color: #374151; }
|
||||
:host-context([data-theme="light"]) .item:hover { background: #f1f5f9; }
|
||||
:host-context([data-theme="light"]) .item.active { background: #e0f2fe; }
|
||||
:host-context([data-theme="dark"]) .item { color: #e2e8f0; }
|
||||
:host-context([data-theme="dark"]) .item:hover { background: rgba(255,255,255,0.05); }
|
||||
:host-context([data-theme="dark"]) .item.active { background: rgba(6,182,212,0.1); }
|
||||
|
||||
.item-icon { font-size: 1.3rem; width: 28px; text-align: center; flex-shrink: 0; }
|
||||
.item-text { display: flex; flex-direction: column; min-width: 0; }
|
||||
.item-name { font-size: 0.875rem; font-weight: 600; }
|
||||
.item-desc { font-size: 0.75rem; opacity: 0.6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
`;
|
||||
|
|
@ -0,0 +1,513 @@
|
|||
/**
|
||||
* <rstack-identity> — Custom element for EncryptID sign-in/sign-out.
|
||||
*
|
||||
* Renders either a "Sign In" button or the user avatar + dropdown.
|
||||
* Contains the full WebAuthn auth modal (sign-in + register).
|
||||
* Refactored from lib/rspace-header.ts into a standalone web component.
|
||||
*/
|
||||
|
||||
const SESSION_KEY = "encryptid_session";
|
||||
const ENCRYPTID_URL = "https://auth.rspace.online";
|
||||
|
||||
interface SessionState {
|
||||
accessToken: string;
|
||||
claims: {
|
||||
sub: string;
|
||||
exp: number;
|
||||
username?: string;
|
||||
did?: string;
|
||||
eid: {
|
||||
authLevel: number;
|
||||
capabilities: { encrypt: boolean; sign: boolean; wallet: boolean };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// ── Session helpers (exported for use by other code) ──
|
||||
|
||||
export function getSession(): SessionState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(SESSION_KEY);
|
||||
if (!stored) return null;
|
||||
const session = JSON.parse(stored) as SessionState;
|
||||
if (Math.floor(Date.now() / 1000) >= session.claims.exp) {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
localStorage.removeItem("rspace-username");
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSession(): void {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
localStorage.removeItem("rspace-username");
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return getSession() !== null;
|
||||
}
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return getSession()?.accessToken ?? null;
|
||||
}
|
||||
|
||||
export function getUserDID(): string | null {
|
||||
return getSession()?.claims.did ?? getSession()?.claims.sub ?? null;
|
||||
}
|
||||
|
||||
export function getUsername(): string | null {
|
||||
return getSession()?.claims.username ?? null;
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
function base64urlToBuffer(b64url: string): ArrayBuffer {
|
||||
const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const pad = "=".repeat((4 - (b64.length % 4)) % 4);
|
||||
const bin = atob(b64 + pad);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
function bufferToBase64url(buf: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let bin = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) bin += String.fromCharCode(bytes[i]);
|
||||
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||||
}
|
||||
|
||||
function parseJWT(token: string): Record<string, unknown> {
|
||||
const parts = token.split(".");
|
||||
if (parts.length < 2) return {};
|
||||
try {
|
||||
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
const pad = "=".repeat((4 - (b64.length % 4)) % 4);
|
||||
return JSON.parse(atob(b64 + pad));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function storeSession(token: string, username: string, did: string): void {
|
||||
const payload = parseJWT(token) as Record<string, any>;
|
||||
const session: SessionState = {
|
||||
accessToken: token,
|
||||
claims: {
|
||||
sub: payload.sub || "",
|
||||
exp: payload.exp || 0,
|
||||
username,
|
||||
did,
|
||||
eid: payload.eid || { authLevel: 3, capabilities: { encrypt: true, sign: true, wallet: false } },
|
||||
},
|
||||
};
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
||||
if (username) localStorage.setItem("rspace-username", username);
|
||||
}
|
||||
|
||||
// ── The custom element ──
|
||||
|
||||
export class RStackIdentity extends HTMLElement {
|
||||
#shadow: ShadowRoot;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.#shadow = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.#render();
|
||||
}
|
||||
|
||||
#render() {
|
||||
const session = getSession();
|
||||
const theme = this.closest("[data-theme]")?.getAttribute("data-theme") || "light";
|
||||
|
||||
if (session) {
|
||||
const username = session.claims.username || "";
|
||||
const did = session.claims.did || session.claims.sub;
|
||||
const displayName = username || (did.length > 24 ? did.slice(0, 16) + "..." + did.slice(-6) : did);
|
||||
const initial = username ? username[0].toUpperCase() : did.slice(8, 10).toUpperCase();
|
||||
|
||||
this.#shadow.innerHTML = `
|
||||
<style>${STYLES}</style>
|
||||
<div class="user ${theme}" id="user-toggle">
|
||||
<div class="avatar">${initial}</div>
|
||||
<span class="name">${displayName}</span>
|
||||
<div class="dropdown" id="dropdown">
|
||||
<button class="dropdown-item" data-action="profile">👤 Profile</button>
|
||||
<button class="dropdown-item" data-action="recovery">🛡️ Recovery</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item dropdown-item--danger" data-action="signout">🚪 Sign Out</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const toggle = this.#shadow.getElementById("user-toggle")!;
|
||||
const dropdown = this.#shadow.getElementById("dropdown")!;
|
||||
|
||||
toggle.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
dropdown.classList.toggle("open");
|
||||
});
|
||||
|
||||
document.addEventListener("click", () => dropdown.classList.remove("open"));
|
||||
|
||||
this.#shadow.querySelectorAll("[data-action]").forEach((el) => {
|
||||
el.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const action = (el as HTMLElement).dataset.action;
|
||||
dropdown.classList.remove("open");
|
||||
if (action === "signout") {
|
||||
clearSession();
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
|
||||
} else if (action === "profile") {
|
||||
window.open(ENCRYPTID_URL, "_blank");
|
||||
} else if (action === "recovery") {
|
||||
window.open(`${ENCRYPTID_URL}/recover`, "_blank");
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
this.#shadow.innerHTML = `
|
||||
<style>${STYLES}</style>
|
||||
<button class="signin-btn ${theme}" id="signin-btn">🔑 Sign In</button>
|
||||
`;
|
||||
|
||||
this.#shadow.getElementById("signin-btn")!.addEventListener("click", () => {
|
||||
this.showAuthModal();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Public method: show the auth modal programmatically */
|
||||
showAuthModal(callbacks?: { onSuccess?: () => void; onCancel?: () => void }): void {
|
||||
if (document.querySelector(".rstack-auth-overlay")) return;
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "rstack-auth-overlay";
|
||||
let mode: "signin" | "register" = "signin";
|
||||
|
||||
const render = () => {
|
||||
overlay.innerHTML = mode === "signin" ? signinHTML() : registerHTML();
|
||||
attachListeners();
|
||||
};
|
||||
|
||||
const signinHTML = () => `
|
||||
<style>${MODAL_STYLES}</style>
|
||||
<div class="auth-modal">
|
||||
<h2>Sign in with EncryptID</h2>
|
||||
<p>Use your passkey to sign in. No passwords needed.</p>
|
||||
<div class="actions">
|
||||
<button class="btn btn--secondary" data-action="cancel">Cancel</button>
|
||||
<button class="btn btn--primary" data-action="signin">🔑 Sign In with Passkey</button>
|
||||
</div>
|
||||
<div class="error" id="auth-error"></div>
|
||||
<div class="toggle">Don't have an account? <a data-action="switch-register">Create one</a></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const registerHTML = () => `
|
||||
<style>${MODAL_STYLES}</style>
|
||||
<div class="auth-modal">
|
||||
<h2>Create your EncryptID</h2>
|
||||
<p>Set up a secure, passwordless identity.</p>
|
||||
<input class="input" id="auth-username" type="text" placeholder="Choose a username" autocomplete="username webauthn" maxlength="32" />
|
||||
<div class="actions">
|
||||
<button class="btn btn--secondary" data-action="cancel">Cancel</button>
|
||||
<button class="btn btn--primary" data-action="register">🔐 Create Passkey</button>
|
||||
</div>
|
||||
<div class="error" id="auth-error"></div>
|
||||
<div class="toggle">Already have an account? <a data-action="switch-signin">Sign in</a></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const close = () => {
|
||||
overlay.remove();
|
||||
};
|
||||
|
||||
const handleSignIn = async () => {
|
||||
const errEl = overlay.querySelector("#auth-error") as HTMLElement;
|
||||
const btn = overlay.querySelector('[data-action="signin"]') as HTMLButtonElement;
|
||||
errEl.textContent = "";
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Authenticating...';
|
||||
|
||||
try {
|
||||
const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!startRes.ok) throw new Error("Failed to start authentication");
|
||||
const { options: serverOptions } = await startRes.json();
|
||||
|
||||
const credential = (await navigator.credentials.get({
|
||||
publicKey: {
|
||||
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
|
||||
rpId: serverOptions.rpId || "rspace.online",
|
||||
userVerification: "required",
|
||||
timeout: 60000,
|
||||
},
|
||||
})) as PublicKeyCredential;
|
||||
if (!credential) throw new Error("Authentication failed");
|
||||
|
||||
const completeRes = await fetch(`${ENCRYPTID_URL}/api/auth/complete`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
challenge: serverOptions.challenge,
|
||||
credential: { credentialId: bufferToBase64url(credential.rawId) },
|
||||
}),
|
||||
});
|
||||
const data = await completeRes.json();
|
||||
if (!completeRes.ok || !data.success) throw new Error(data.error || "Authentication failed");
|
||||
|
||||
storeSession(data.token, data.username || "", data.did || "");
|
||||
close();
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
|
||||
callbacks?.onSuccess?.();
|
||||
} catch (err: any) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = "🔑 Sign In with Passkey";
|
||||
errEl.textContent = err.name === "NotAllowedError" ? "Authentication was cancelled." : err.message || "Authentication failed.";
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async () => {
|
||||
const usernameInput = overlay.querySelector("#auth-username") as HTMLInputElement;
|
||||
const errEl = overlay.querySelector("#auth-error") as HTMLElement;
|
||||
const btn = overlay.querySelector('[data-action="register"]') as HTMLButtonElement;
|
||||
const username = usernameInput.value.trim();
|
||||
|
||||
if (!username) {
|
||||
errEl.textContent = "Please enter a username.";
|
||||
usernameInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
errEl.textContent = "";
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> Creating passkey...';
|
||||
|
||||
try {
|
||||
const startRes = await fetch(`${ENCRYPTID_URL}/api/register/start`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, displayName: username }),
|
||||
});
|
||||
if (!startRes.ok) throw new Error("Failed to start registration");
|
||||
const { options: serverOptions, userId } = await startRes.json();
|
||||
|
||||
const credential = (await navigator.credentials.create({
|
||||
publicKey: {
|
||||
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
|
||||
rp: { id: serverOptions.rp?.id || "rspace.online", name: serverOptions.rp?.name || "EncryptID" },
|
||||
user: { id: new Uint8Array(base64urlToBuffer(serverOptions.user.id)), name: username, displayName: username },
|
||||
pubKeyCredParams: serverOptions.pubKeyCredParams || [
|
||||
{ alg: -7, type: "public-key" as const },
|
||||
{ alg: -257, type: "public-key" as const },
|
||||
],
|
||||
authenticatorSelection: { residentKey: "required", requireResidentKey: true, userVerification: "required" },
|
||||
attestation: "none",
|
||||
timeout: 60000,
|
||||
},
|
||||
})) as PublicKeyCredential;
|
||||
if (!credential) throw new Error("Failed to create credential");
|
||||
|
||||
const response = credential.response as AuthenticatorAttestationResponse;
|
||||
const publicKey = response.getPublicKey?.();
|
||||
|
||||
const completeRes = await fetch(`${ENCRYPTID_URL}/api/register/complete`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
challenge: serverOptions.challenge,
|
||||
credential: {
|
||||
credentialId: bufferToBase64url(credential.rawId),
|
||||
publicKey: publicKey ? bufferToBase64url(publicKey) : "",
|
||||
transports: response.getTransports?.() || [],
|
||||
},
|
||||
userId,
|
||||
username,
|
||||
}),
|
||||
});
|
||||
const data = await completeRes.json();
|
||||
if (!completeRes.ok || !data.success) throw new Error(data.error || "Registration failed");
|
||||
|
||||
storeSession(data.token, username, data.did || "");
|
||||
close();
|
||||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
|
||||
callbacks?.onSuccess?.();
|
||||
} catch (err: any) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = "🔐 Create Passkey";
|
||||
errEl.textContent = err.name === "NotAllowedError" ? "Passkey creation was cancelled." : err.message || "Registration failed.";
|
||||
}
|
||||
};
|
||||
|
||||
const attachListeners = () => {
|
||||
overlay.querySelector('[data-action="cancel"]')?.addEventListener("click", () => {
|
||||
close();
|
||||
callbacks?.onCancel?.();
|
||||
});
|
||||
overlay.querySelector('[data-action="signin"]')?.addEventListener("click", handleSignIn);
|
||||
overlay.querySelector('[data-action="register"]')?.addEventListener("click", handleRegister);
|
||||
overlay.querySelector('[data-action="switch-register"]')?.addEventListener("click", () => {
|
||||
mode = "register";
|
||||
render();
|
||||
setTimeout(() => (overlay.querySelector("#auth-username") as HTMLInputElement)?.focus(), 50);
|
||||
});
|
||||
overlay.querySelector('[data-action="switch-signin"]')?.addEventListener("click", () => {
|
||||
mode = "signin";
|
||||
render();
|
||||
});
|
||||
overlay.querySelector("#auth-username")?.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") handleRegister();
|
||||
});
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target === overlay) {
|
||||
close();
|
||||
callbacks?.onCancel?.();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
render();
|
||||
}
|
||||
|
||||
static define(tag = "rstack-identity") {
|
||||
if (!customElements.get(tag)) customElements.define(tag, RStackIdentity);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Require auth helper (for use by module code) ──
|
||||
|
||||
export function requireAuth(onAuthenticated: () => void): boolean {
|
||||
if (isAuthenticated()) return true;
|
||||
const el = document.querySelector("rstack-identity") as RStackIdentity | null;
|
||||
if (el) {
|
||||
el.showAuthModal({ onSuccess: onAuthenticated });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Styles ──
|
||||
|
||||
const STYLES = `
|
||||
:host { display: contents; }
|
||||
|
||||
.signin-btn {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 20px; border-radius: 8px; border: none;
|
||||
font-size: 0.875rem; font-weight: 600; cursor: pointer;
|
||||
transition: all 0.2s; text-decoration: none;
|
||||
}
|
||||
.signin-btn.light {
|
||||
background: linear-gradient(135deg, #06b6d4, #0891b2); color: white;
|
||||
}
|
||||
.signin-btn.dark {
|
||||
background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white;
|
||||
}
|
||||
.signin-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6,182,212,0.3); }
|
||||
|
||||
.user {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
position: relative; cursor: pointer;
|
||||
}
|
||||
.avatar {
|
||||
width: 34px; height: 34px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, #06b6d4, #7c3aed);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: 0.8rem; color: white;
|
||||
}
|
||||
.name {
|
||||
font-size: 0.8rem; max-width: 140px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.user.light .name { color: #64748b; }
|
||||
.user.dark .name { color: #94a3b8; }
|
||||
|
||||
.dropdown {
|
||||
position: absolute; top: 100%; right: 0; margin-top: 8px;
|
||||
min-width: 200px; border-radius: 10px; overflow: hidden;
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,0.2); display: none; z-index: 100;
|
||||
}
|
||||
.dropdown.open { display: block; }
|
||||
.user.light .dropdown { background: white; border: 1px solid rgba(0,0,0,0.1); }
|
||||
.user.dark .dropdown { background: #1e293b; border: 1px solid rgba(255,255,255,0.1); }
|
||||
|
||||
.dropdown-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 12px 16px; font-size: 0.875rem; cursor: pointer;
|
||||
transition: background 0.15s; border: none; background: none;
|
||||
width: 100%; text-align: left;
|
||||
}
|
||||
.user.light .dropdown-item { color: #374151; }
|
||||
.user.light .dropdown-item:hover { background: #f1f5f9; }
|
||||
.user.dark .dropdown-item { color: #e2e8f0; }
|
||||
.user.dark .dropdown-item:hover { background: rgba(255,255,255,0.05); }
|
||||
.dropdown-item--danger { color: #ef4444 !important; }
|
||||
|
||||
.dropdown-divider { height: 1px; margin: 4px 0; }
|
||||
.user.light .dropdown-divider { background: rgba(0,0,0,0.08); }
|
||||
.user.dark .dropdown-divider { background: rgba(255,255,255,0.08); }
|
||||
`;
|
||||
|
||||
const MODAL_STYLES = `
|
||||
.rstack-auth-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||
backdrop-filter: blur(4px); display: flex; align-items: center;
|
||||
justify-content: center; z-index: 10000; animation: fadeIn 0.2s;
|
||||
}
|
||||
.auth-modal {
|
||||
background: #1e293b; border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 16px; padding: 2rem; max-width: 420px; width: 90%;
|
||||
text-align: center; color: white; box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||||
animation: slideUp 0.3s;
|
||||
}
|
||||
.auth-modal h2 {
|
||||
font-size: 1.5rem; margin-bottom: 0.5rem;
|
||||
background: linear-gradient(135deg, #06b6d4, #7c3aed);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||
}
|
||||
.auth-modal p { color: #94a3b8; font-size: 0.95rem; line-height: 1.6; margin-bottom: 1.5rem; }
|
||||
.input {
|
||||
width: 100%; padding: 12px 16px; border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05);
|
||||
color: white; font-size: 1rem; margin-bottom: 1rem; outline: none;
|
||||
transition: border-color 0.2s; box-sizing: border-box;
|
||||
}
|
||||
.input:focus { border-color: #06b6d4; }
|
||||
.input::placeholder { color: #64748b; }
|
||||
.actions { display: flex; gap: 12px; margin-top: 0.5rem; }
|
||||
.btn {
|
||||
flex: 1; padding: 12px 20px; border-radius: 8px; border: none;
|
||||
font-size: 0.95rem; font-weight: 600; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.btn--primary { background: linear-gradient(135deg, #06b6d4, #7c3aed); color: white; }
|
||||
.btn--primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(6,182,212,0.3); }
|
||||
.btn--primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
|
||||
.btn--secondary { background: rgba(255,255,255,0.08); color: #94a3b8; border: 1px solid rgba(255,255,255,0.1); }
|
||||
.btn--secondary:hover { background: rgba(255,255,255,0.12); color: white; }
|
||||
.error { color: #ef4444; font-size: 0.85rem; margin-top: 0.5rem; min-height: 1.2em; }
|
||||
.toggle { margin-top: 1rem; font-size: 0.85rem; color: #64748b; }
|
||||
.toggle a { color: #06b6d4; cursor: pointer; text-decoration: none; }
|
||||
.toggle a:hover { text-decoration: underline; }
|
||||
.spinner {
|
||||
display: inline-block; width: 18px; height: 18px;
|
||||
border: 2px solid transparent; border-top-color: currentColor;
|
||||
border-radius: 50%; animation: spin 0.7s linear infinite;
|
||||
vertical-align: middle; margin-right: 6px;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
`;
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
/**
|
||||
* <rstack-space-switcher> — Dropdown to switch between user's spaces.
|
||||
*
|
||||
* Attributes:
|
||||
* current — the active space slug
|
||||
* name — the display name of the active space
|
||||
*
|
||||
* Fetches the user's spaces from /api/spaces on click.
|
||||
*/
|
||||
|
||||
import { isAuthenticated } from "./rstack-identity";
|
||||
|
||||
interface SpaceInfo {
|
||||
slug: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export class RStackSpaceSwitcher extends HTMLElement {
|
||||
#shadow: ShadowRoot;
|
||||
#spaces: SpaceInfo[] = [];
|
||||
#loaded = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.#shadow = this.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return ["current", "name"];
|
||||
}
|
||||
|
||||
get current(): string {
|
||||
return this.getAttribute("current") || "";
|
||||
}
|
||||
|
||||
get spaceName(): string {
|
||||
return this.getAttribute("name") || this.current;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.#render();
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
this.#render();
|
||||
}
|
||||
|
||||
async #loadSpaces() {
|
||||
if (this.#loaded) return;
|
||||
try {
|
||||
const res = await fetch("/api/spaces");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
this.#spaces = data.spaces || [];
|
||||
}
|
||||
} catch {
|
||||
// Offline or API not available — just show current
|
||||
}
|
||||
this.#loaded = true;
|
||||
}
|
||||
|
||||
#render() {
|
||||
const current = this.current;
|
||||
const name = this.spaceName;
|
||||
|
||||
this.#shadow.innerHTML = `
|
||||
<style>${STYLES}</style>
|
||||
<div class="switcher">
|
||||
<button class="trigger" id="trigger">
|
||||
<span class="slash">/</span>
|
||||
<span class="space-name">${name || "Select space"}</span>
|
||||
<span class="caret">▾</span>
|
||||
</button>
|
||||
<div class="menu" id="menu">
|
||||
<div class="menu-loading">Loading spaces...</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const trigger = this.#shadow.getElementById("trigger")!;
|
||||
const menu = this.#shadow.getElementById("menu")!;
|
||||
|
||||
trigger.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
const isOpen = menu.classList.toggle("open");
|
||||
if (isOpen && !this.#loaded) {
|
||||
await this.#loadSpaces();
|
||||
this.#renderMenu(menu, current);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", () => menu.classList.remove("open"));
|
||||
}
|
||||
|
||||
#renderMenu(menu: HTMLElement, current: string) {
|
||||
if (this.#spaces.length === 0) {
|
||||
menu.innerHTML = `
|
||||
<div class="menu-empty">
|
||||
${isAuthenticated() ? "No spaces yet" : "Sign in to see your spaces"}
|
||||
</div>
|
||||
<a class="item item--create" href="/new">+ Create new space</a>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const moduleId = this.#getCurrentModule();
|
||||
|
||||
menu.innerHTML = `
|
||||
${this.#spaces
|
||||
.map(
|
||||
(s) => `
|
||||
<a class="item ${s.slug === current ? "active" : ""}"
|
||||
href="/${s.slug}/${moduleId}">
|
||||
<span class="item-icon">${s.icon || "🌐"}</span>
|
||||
<span class="item-name">${s.name}</span>
|
||||
</a>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
<div class="divider"></div>
|
||||
<a class="item item--create" href="/new">+ Create new space</a>
|
||||
`;
|
||||
}
|
||||
|
||||
#getCurrentModule(): string {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
return parts[1] || "canvas";
|
||||
}
|
||||
|
||||
static define(tag = "rstack-space-switcher") {
|
||||
if (!customElements.get(tag)) customElements.define(tag, RStackSpaceSwitcher);
|
||||
}
|
||||
}
|
||||
|
||||
const STYLES = `
|
||||
:host { display: contents; }
|
||||
|
||||
.switcher { position: relative; }
|
||||
|
||||
.trigger {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 6px 12px; border-radius: 8px; border: none;
|
||||
font-size: 0.875rem; font-weight: 500; cursor: pointer;
|
||||
transition: background 0.15s; background: transparent; color: inherit;
|
||||
}
|
||||
:host-context([data-theme="light"]) .trigger { color: #374151; }
|
||||
:host-context([data-theme="dark"]) .trigger { color: #94a3b8; }
|
||||
.trigger:hover { background: rgba(0,0,0,0.05); }
|
||||
:host-context([data-theme="dark"]) .trigger:hover { background: rgba(255,255,255,0.05); }
|
||||
|
||||
.slash { opacity: 0.4; font-weight: 300; margin-right: 2px; }
|
||||
.space-name { max-width: 160px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.caret { font-size: 0.7em; opacity: 0.5; }
|
||||
|
||||
.menu {
|
||||
position: absolute; top: 100%; left: 0; margin-top: 6px;
|
||||
min-width: 220px; max-height: 320px; overflow-y: auto;
|
||||
border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,0.25);
|
||||
display: none; z-index: 200;
|
||||
}
|
||||
.menu.open { display: block; }
|
||||
:host-context([data-theme="light"]) .menu { background: white; border: 1px solid rgba(0,0,0,0.1); }
|
||||
:host-context([data-theme="dark"]) .menu { background: #1e293b; border: 1px solid rgba(255,255,255,0.1); }
|
||||
|
||||
.item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 14px; text-decoration: none; cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
:host-context([data-theme="light"]) .item { color: #374151; }
|
||||
:host-context([data-theme="light"]) .item:hover { background: #f1f5f9; }
|
||||
:host-context([data-theme="light"]) .item.active { background: #e0f2fe; }
|
||||
:host-context([data-theme="dark"]) .item { color: #e2e8f0; }
|
||||
:host-context([data-theme="dark"]) .item:hover { background: rgba(255,255,255,0.05); }
|
||||
:host-context([data-theme="dark"]) .item.active { background: rgba(6,182,212,0.1); }
|
||||
|
||||
.item-icon { font-size: 1.1rem; flex-shrink: 0; }
|
||||
.item-name { font-size: 0.875rem; font-weight: 500; }
|
||||
|
||||
.item--create {
|
||||
font-size: 0.85rem; font-weight: 600; color: #06b6d4 !important;
|
||||
}
|
||||
.item--create:hover { background: rgba(6,182,212,0.08) !important; }
|
||||
|
||||
.divider { height: 1px; margin: 4px 0; }
|
||||
:host-context([data-theme="light"]) .divider { background: rgba(0,0,0,0.08); }
|
||||
:host-context([data-theme="dark"]) .divider { background: rgba(255,255,255,0.08); }
|
||||
|
||||
.menu-loading, .menu-empty {
|
||||
padding: 16px; text-align: center; font-size: 0.85rem; color: #94a3b8;
|
||||
}
|
||||
`;
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { Hono } from "hono";
|
||||
|
||||
/**
|
||||
* The contract every rSpace module must implement.
|
||||
*
|
||||
* A module is a self-contained feature area (books, pubs, cart, canvas, etc.)
|
||||
* that exposes Hono routes and metadata. The shell mounts these routes under
|
||||
* `/:space/{moduleId}` in unified mode. In standalone mode, the module's own
|
||||
* `standalone.ts` mounts them at the root with a minimal shell.
|
||||
*/
|
||||
export interface RSpaceModule {
|
||||
/** Short identifier used in URLs: 'books', 'pubs', 'cart', 'canvas', etc. */
|
||||
id: string;
|
||||
/** Human-readable name: 'rBooks', 'rPubs', 'rCart', etc. */
|
||||
name: string;
|
||||
/** Emoji or SVG string for the app switcher */
|
||||
icon: string;
|
||||
/** One-line description */
|
||||
description: string;
|
||||
/** Mountable Hono sub-app. Routes are relative to the mount point. */
|
||||
routes: Hono;
|
||||
/** Optional: standalone domain for this module (e.g. 'rbooks.online') */
|
||||
standaloneDomain?: string;
|
||||
/** Called when a new space is created (e.g. to initialize module-specific data) */
|
||||
onSpaceCreate?: (spaceSlug: string) => Promise<void>;
|
||||
/** Called when a space is deleted (e.g. to clean up module-specific data) */
|
||||
onSpaceDelete?: (spaceSlug: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Registry of all loaded modules */
|
||||
const modules = new Map<string, RSpaceModule>();
|
||||
|
||||
export function registerModule(mod: RSpaceModule): void {
|
||||
modules.set(mod.id, mod);
|
||||
}
|
||||
|
||||
export function getModule(id: string): RSpaceModule | undefined {
|
||||
return modules.get(id);
|
||||
}
|
||||
|
||||
export function getAllModules(): RSpaceModule[] {
|
||||
return Array.from(modules.values());
|
||||
}
|
||||
|
||||
/** Metadata exposed to the client for the app switcher */
|
||||
export interface ModuleInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function getModuleInfoList(): ModuleInfo[] {
|
||||
return getAllModules().map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
icon: m.icon,
|
||||
description: m.description,
|
||||
}));
|
||||
}
|
||||
|
|
@ -18,9 +18,11 @@
|
|||
"paths": {
|
||||
"@lib": ["lib"],
|
||||
"@lib/*": ["lib/*"],
|
||||
"@encryptid/*": ["src/encryptid/*"]
|
||||
"@encryptid/*": ["src/encryptid/*"],
|
||||
"@shared": ["shared"],
|
||||
"@shared/*": ["shared/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "vite.config.ts"],
|
||||
"exclude": ["node_modules/**/*", "dist/**/*", "src/encryptid/**/*", "website/sw.ts"]
|
||||
"exclude": ["node_modules/**/*", "dist/**/*", "src/encryptid/**/*"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,32 @@ export default defineConfig({
|
|||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Build shell.ts as a standalone JS bundle
|
||||
await build({
|
||||
configFile: false,
|
||||
root: resolve(__dirname, "website"),
|
||||
resolve: {
|
||||
alias: {
|
||||
"@lib": resolve(__dirname, "./lib"),
|
||||
"@shared": resolve(__dirname, "./shared"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
outDir: resolve(__dirname, "dist"),
|
||||
lib: {
|
||||
entry: resolve(__dirname, "website/shell.ts"),
|
||||
formats: ["es"],
|
||||
fileName: () => "shell.js",
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: "shell.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -40,6 +66,7 @@ export default defineConfig({
|
|||
alias: {
|
||||
"@lib": resolve(__dirname, "./lib"),
|
||||
"@encryptid": resolve(__dirname, "./src/encryptid"),
|
||||
"@shared": resolve(__dirname, "./shared"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
|
@ -55,6 +82,8 @@ export default defineConfig({
|
|||
},
|
||||
outDir: "../dist",
|
||||
emptyOutDir: true,
|
||||
// Copy shell.css to dist
|
||||
cssCodeSplit: false,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
/* ── rStack Shell Layout ── */
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Header bar ── */
|
||||
|
||||
.rstack-header {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
z-index: 9999;
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.rstack-header[data-theme="light"] {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.rstack-header[data-theme="dark"] {
|
||||
background: rgba(15, 23, 42, 0.85);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.rstack-header__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rstack-header__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.rstack-header__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-decoration: none;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.rstack-header__brand-gradient {
|
||||
background: linear-gradient(135deg, #14b8a6, #22d3ee);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
/* ── Main content area ── */
|
||||
|
||||
#app {
|
||||
padding-top: 56px; /* Below fixed header */
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* When canvas module is active, make it fill the viewport */
|
||||
#app.canvas-layout {
|
||||
padding-top: 56px;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Standalone mode (no app/space switcher) ── */
|
||||
|
||||
.rstack-header--standalone .rstack-header__left {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* ── Mobile adjustments ── */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.rstack-header {
|
||||
padding: 0 12px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* 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 { RStackAppSwitcher } from "../shared/components/rstack-app-switcher";
|
||||
import { RStackSpaceSwitcher } from "../shared/components/rstack-space-switcher";
|
||||
|
||||
// Register all header components
|
||||
RStackIdentity.define();
|
||||
RStackAppSwitcher.define();
|
||||
RStackSpaceSwitcher.define();
|
||||
Loading…
Reference in New Issue