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/dist ./dist
|
||||||
COPY --from=build /app/server ./server
|
COPY --from=build /app/server ./server
|
||||||
COPY --from=build /app/lib ./lib
|
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 /app/package.json .
|
||||||
COPY --from=build /encryptid-sdk /encryptid-sdk
|
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
|
- STORAGE_DIR=/data/communities
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
- INTERNAL_API_KEY=${INTERNAL_API_KEY}
|
- 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:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
# Only handle subdomains (rspace-prod handles main domain)
|
# Main domain — serves landing + path-based routing
|
||||||
- "traefik.http.routers.rspace-canvas.rule=HostRegexp(`{subdomain:[a-z0-9-]+}.rspace.online`) && !Host(`rspace.online`) && !Host(`www.rspace.online`)"
|
- "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.entrypoints=web"
|
||||||
- "traefik.http.routers.rspace-canvas.priority=100"
|
- "traefik.http.routers.rspace-canvas.priority=100"
|
||||||
# Service configuration
|
# 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"
|
- "traefik.docker.network=traefik-public"
|
||||||
networks:
|
networks:
|
||||||
- traefik-public
|
- 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:
|
volumes:
|
||||||
rspace-data:
|
rspace-data:
|
||||||
|
rspace-pgdata:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
traefik-public:
|
traefik-public:
|
||||||
external: true
|
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,
|
||||||
|
};
|
||||||
867
server/index.ts
867
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": {
|
"paths": {
|
||||||
"@lib": ["lib"],
|
"@lib": ["lib"],
|
||||||
"@lib/*": ["lib/*"],
|
"@lib/*": ["lib/*"],
|
||||||
"@encryptid/*": ["src/encryptid/*"]
|
"@encryptid/*": ["src/encryptid/*"],
|
||||||
|
"@shared": ["shared"],
|
||||||
|
"@shared/*": ["shared/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["**/*.ts", "vite.config.ts"],
|
"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: {
|
alias: {
|
||||||
"@lib": resolve(__dirname, "./lib"),
|
"@lib": resolve(__dirname, "./lib"),
|
||||||
"@encryptid": resolve(__dirname, "./src/encryptid"),
|
"@encryptid": resolve(__dirname, "./src/encryptid"),
|
||||||
|
"@shared": resolve(__dirname, "./shared"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
|
@ -55,6 +82,8 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
outDir: "../dist",
|
outDir: "../dist",
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
// Copy shell.css to dist
|
||||||
|
cssCodeSplit: false,
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
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