feat: auto-route users to personal/demo space + landing overlay
- Anon users visiting any rApp (standalone or unified) land on demo space - Logged-in users auto-redirect to personal space (auto-provisioned) - POST /api/spaces/auto-provision creates personal space on first visit - Standalone domains support /<space> path prefix (rpubs.online/jeff) - rspace.online/ redirects to /demo/canvas (app-first experience) - Quarter-screen welcome overlay on demo space for first-time visitors - Full landing page moved to /about - Auth flow triggers auto-space-resolution on sign-in/register - Demo space seeded with shapes for all 22 rApps Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
38a5d84f72
commit
e7ce57ce0b
|
|
@ -423,6 +423,48 @@ app.get("/api/modules", (c) => {
|
|||
return c.json({ modules: getModuleInfoList() });
|
||||
});
|
||||
|
||||
// ── Auto-provision personal space ──
|
||||
app.post("/api/spaces/auto-provision", 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 username = claims.username?.toLowerCase();
|
||||
if (!username || !/^[a-z0-9][a-z0-9-]*$/.test(username)) {
|
||||
return c.json({ error: "Username not suitable for space slug" }, 400);
|
||||
}
|
||||
|
||||
if (await communityExists(username)) {
|
||||
return c.json({ status: "exists", slug: username });
|
||||
}
|
||||
|
||||
await createCommunity(
|
||||
`${claims.username}'s Space`,
|
||||
username,
|
||||
claims.sub,
|
||||
"authenticated",
|
||||
);
|
||||
|
||||
for (const mod of getAllModules()) {
|
||||
if (mod.onSpaceCreate) {
|
||||
try {
|
||||
await mod.onSpaceCreate(username);
|
||||
} catch (e) {
|
||||
console.error(`[AutoProvision] Module ${mod.id} onSpaceCreate:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[AutoProvision] Created personal space: ${username}`);
|
||||
return c.json({ status: "created", slug: username }, 201);
|
||||
});
|
||||
|
||||
// ── Mount module routes under /:space/:moduleId ──
|
||||
for (const mod of getAllModules()) {
|
||||
app.route(`/:space/${mod.id}`, mod.routes);
|
||||
|
|
@ -430,8 +472,11 @@ for (const mod of getAllModules()) {
|
|||
|
||||
// ── Page routes ──
|
||||
|
||||
// Landing page: rspace.online/
|
||||
app.get("/", async (c) => {
|
||||
// Landing page: rspace.online/ → redirect to demo canvas (overlay shows there)
|
||||
app.get("/", (c) => c.redirect("/demo/canvas", 302));
|
||||
|
||||
// About/info page (full landing content)
|
||||
app.get("/about", async (c) => {
|
||||
const file = Bun.file(resolve(DIST_DIR, "index.html"));
|
||||
if (await file.exists()) {
|
||||
return new Response(file, { headers: { "Content-Type": "text/html" } });
|
||||
|
|
@ -633,9 +678,27 @@ const server = Bun.serve<WSData>({
|
|||
if (staticResponse) return staticResponse;
|
||||
}
|
||||
|
||||
// Rewrite: / → /demo/{moduleId}, /foo → /demo/{moduleId}/foo
|
||||
const suffix = url.pathname === "/" ? "" : url.pathname;
|
||||
const rewrittenPath = `/demo/${standaloneModuleId}${suffix}`;
|
||||
// Determine space from URL path: /{space} prefix on standalone domains
|
||||
// / → /demo/{moduleId} (anon default)
|
||||
// /jeff → /jeff/{moduleId} (personal space)
|
||||
// /api/... → /demo/{moduleId}/api/... (module API)
|
||||
const pathParts = url.pathname.split("/").filter(Boolean);
|
||||
let space = "demo";
|
||||
let suffix = "";
|
||||
|
||||
if (
|
||||
pathParts.length > 0 &&
|
||||
!pathParts[0].includes(".") &&
|
||||
pathParts[0] !== "api" &&
|
||||
pathParts[0] !== "ws"
|
||||
) {
|
||||
space = pathParts[0];
|
||||
suffix = pathParts.length > 1 ? "/" + pathParts.slice(1).join("/") : "";
|
||||
} else if (url.pathname !== "/") {
|
||||
suffix = url.pathname;
|
||||
}
|
||||
|
||||
const rewrittenPath = `/${space}/${standaloneModuleId}${suffix}`;
|
||||
const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
|
||||
const rewrittenReq = new Request(rewrittenUrl, req);
|
||||
return app.fetch(rewrittenReq);
|
||||
|
|
|
|||
|
|
@ -482,6 +482,287 @@ const DEMO_SHAPES: Record<string, unknown>[] = [
|
|||
targetId: "demo-ledger-cred",
|
||||
color: "#059669",
|
||||
},
|
||||
|
||||
// ─── rFiles: Shared Documents ──────────────────────────────
|
||||
{
|
||||
id: "demo-file-permits",
|
||||
type: "folk-file",
|
||||
x: 1550, y: 900, width: 280, height: 80, rotation: 0,
|
||||
fileName: "Swiss Hiking Permits.pdf",
|
||||
fileSize: "2.4 MB",
|
||||
mimeType: "application/pdf",
|
||||
uploadedBy: "Priya",
|
||||
uploadedAt: "2026-06-28",
|
||||
tags: ["permits", "switzerland", "logistics"],
|
||||
},
|
||||
{
|
||||
id: "demo-file-insurance",
|
||||
type: "folk-file",
|
||||
x: 1550, y: 1000, width: 280, height: 80, rotation: 0,
|
||||
fileName: "Travel Insurance Policy.pdf",
|
||||
fileSize: "1.1 MB",
|
||||
mimeType: "application/pdf",
|
||||
uploadedBy: "Omar",
|
||||
uploadedAt: "2026-06-25",
|
||||
tags: ["insurance", "emergency", "documents"],
|
||||
},
|
||||
{
|
||||
id: "demo-file-topo",
|
||||
type: "folk-file",
|
||||
x: 1550, y: 1100, width: 280, height: 80, rotation: 0,
|
||||
fileName: "Tre Cime Topo Map.gpx",
|
||||
fileSize: "340 KB",
|
||||
mimeType: "application/gpx+xml",
|
||||
uploadedBy: "Liam",
|
||||
uploadedAt: "2026-07-01",
|
||||
tags: ["maps", "hiking", "dolomites"],
|
||||
},
|
||||
|
||||
// ─── rForum: Discussion Threads ────────────────────────────
|
||||
{
|
||||
id: "demo-forum-weather",
|
||||
type: "folk-forum-thread",
|
||||
x: 1870, y: 900, width: 320, height: 160, rotation: 0,
|
||||
threadTitle: "Weather Contingency Plan",
|
||||
author: "Omar",
|
||||
createdAt: "2026-06-30",
|
||||
replyCount: 8,
|
||||
lastReply: "Maya — 2 hours ago",
|
||||
preview: "What's our plan if the Matterhorn trek gets rained out? I found a cheese museum in Zermatt as backup...",
|
||||
tags: ["planning", "weather"],
|
||||
},
|
||||
{
|
||||
id: "demo-forum-transport",
|
||||
type: "folk-forum-thread",
|
||||
x: 1870, y: 1080, width: 320, height: 140, rotation: 0,
|
||||
threadTitle: "Train vs Rental Car — Dolomites Transfer",
|
||||
author: "Liam",
|
||||
createdAt: "2026-06-28",
|
||||
replyCount: 5,
|
||||
lastReply: "Priya — yesterday",
|
||||
preview: "The Bernina Express is scenic but 6 hours. Rental car is 3.5 hours. Thoughts?",
|
||||
tags: ["transport", "logistics"],
|
||||
},
|
||||
|
||||
// ─── rBooks: Shared Reading ────────────────────────────────
|
||||
{
|
||||
id: "demo-book-alpine",
|
||||
type: "folk-book",
|
||||
x: 2350, y: 50, width: 280, height: 200, rotation: 0,
|
||||
bookTitle: "The Alps: A Human History",
|
||||
author: "Stephen O'Shea",
|
||||
coverColor: "#7c3aed",
|
||||
pageCount: 320,
|
||||
currentPage: 145,
|
||||
readers: ["Maya", "Liam"],
|
||||
status: "reading",
|
||||
},
|
||||
{
|
||||
id: "demo-book-wild",
|
||||
type: "folk-book",
|
||||
x: 2350, y: 280, width: 280, height: 200, rotation: 0,
|
||||
bookTitle: "Wild: A Journey from Lost to Found",
|
||||
author: "Cheryl Strayed",
|
||||
coverColor: "#059669",
|
||||
pageCount: 315,
|
||||
currentPage: 315,
|
||||
readers: ["Priya"],
|
||||
status: "finished",
|
||||
},
|
||||
|
||||
// ─── rPubs: Published Artifacts ────────────────────────────
|
||||
{
|
||||
id: "demo-pub-zine",
|
||||
type: "folk-pub",
|
||||
x: 2350, y: 520, width: 300, height: 180, rotation: 0,
|
||||
pubTitle: "Alpine Explorer Zine",
|
||||
pubType: "zine",
|
||||
creator: "Maya",
|
||||
format: "A5 risograph",
|
||||
status: "in-production",
|
||||
copies: 50,
|
||||
price: 12.00,
|
||||
currency: "EUR",
|
||||
description: "Photo zine documenting the Alpine Explorer 2026 trip. Risograph printed on recycled paper.",
|
||||
},
|
||||
|
||||
// ─── rSwag: Merchandise ────────────────────────────────────
|
||||
{
|
||||
id: "demo-swag-tee",
|
||||
type: "folk-swag",
|
||||
x: 2350, y: 730, width: 280, height: 120, rotation: 0,
|
||||
swagTitle: "Alpine Explorer 2026 Tee",
|
||||
swagType: "t-shirt",
|
||||
designer: "Liam",
|
||||
sizes: ["S", "M", "L", "XL"],
|
||||
price: 28.00,
|
||||
currency: "EUR",
|
||||
status: "available",
|
||||
orderCount: 12,
|
||||
},
|
||||
|
||||
// ─── rProviders: Local Production ──────────────────────────
|
||||
{
|
||||
id: "demo-provider-risograph",
|
||||
type: "folk-provider",
|
||||
x: 2350, y: 880, width: 300, height: 160, rotation: 0,
|
||||
providerName: "Chamonix Print Collective",
|
||||
location: "Chamonix, France",
|
||||
capabilities: ["risograph", "screen-print", "letterpress"],
|
||||
substrates: ["recycled paper", "card stock", "cotton"],
|
||||
turnaround: "5-7 days",
|
||||
rating: 4.8,
|
||||
ordersFulfilled: 127,
|
||||
},
|
||||
|
||||
// ─── rWork: Task Board ─────────────────────────────────────
|
||||
{
|
||||
id: "demo-work-board",
|
||||
type: "folk-work-board",
|
||||
x: 750, y: 1350, width: 500, height: 280, rotation: 0,
|
||||
boardTitle: "Trip Preparation Tasks",
|
||||
columns: [
|
||||
{
|
||||
name: "To Do",
|
||||
tasks: [
|
||||
{ title: "Book paragliding (2 remaining spots)", assignee: "Liam", priority: "high" },
|
||||
{ title: "Buy trekking poles", assignee: "Maya", priority: "medium" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "In Progress",
|
||||
tasks: [
|
||||
{ title: "Research Italian drone regulations", assignee: "Liam", priority: "high" },
|
||||
{ title: "First aid training refresher", assignee: "Omar", priority: "medium" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Done",
|
||||
tasks: [
|
||||
{ title: "Book hut reservations", assignee: "Priya", priority: "high" },
|
||||
{ title: "Get travel insurance", assignee: "Omar", priority: "high" },
|
||||
{ title: "Break in hiking boots", assignee: "Maya", priority: "medium" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─── rCal: Shared Calendar ─────────────────────────────────
|
||||
{
|
||||
id: "demo-cal-events",
|
||||
type: "folk-calendar",
|
||||
x: 50, y: 1350, width: 350, height: 250, rotation: 0,
|
||||
calTitle: "Alpine Explorer 2026",
|
||||
month: "July 2026",
|
||||
events: [
|
||||
{ date: "Jul 6", title: "Fly to Geneva", color: "#3b82f6" },
|
||||
{ date: "Jul 7", title: "Lac Blanc Hike", color: "#22c55e" },
|
||||
{ date: "Jul 8", title: "Via Ferrata", color: "#ef4444" },
|
||||
{ date: "Jul 10", title: "Train to Zermatt", color: "#3b82f6" },
|
||||
{ date: "Jul 13", title: "Paragliding", color: "#ef4444" },
|
||||
{ date: "Jul 14", title: "Transfer to Dolomites", color: "#3b82f6" },
|
||||
{ date: "Jul 15", title: "Tre Cime Loop", color: "#22c55e" },
|
||||
{ date: "Jul 18", title: "Cooking Class", color: "#f59e0b" },
|
||||
{ date: "Jul 20", title: "Fly Home", color: "#3b82f6" },
|
||||
],
|
||||
},
|
||||
|
||||
// ─── rNetwork: Contact Graph ───────────────────────────────
|
||||
{
|
||||
id: "demo-network-graph",
|
||||
type: "folk-network",
|
||||
x: 1300, y: 1350, width: 400, height: 280, rotation: 0,
|
||||
networkTitle: "Trip Contacts",
|
||||
nodes: [
|
||||
{ id: "maya", label: "Maya", role: "organizer" },
|
||||
{ id: "liam", label: "Liam", role: "photographer" },
|
||||
{ id: "priya", label: "Priya", role: "logistics" },
|
||||
{ id: "omar", label: "Omar", role: "safety" },
|
||||
{ id: "vrony", label: "Chez Vrony", role: "restaurant" },
|
||||
{ id: "rega", label: "REGA Rescue", role: "emergency" },
|
||||
{ id: "locatelli", label: "Rif. Locatelli", role: "hut" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "maya", to: "liam" },
|
||||
{ from: "maya", to: "priya" },
|
||||
{ from: "maya", to: "omar" },
|
||||
{ from: "priya", to: "locatelli" },
|
||||
{ from: "omar", to: "rega" },
|
||||
{ from: "liam", to: "vrony" },
|
||||
],
|
||||
},
|
||||
|
||||
// ─── rTube: Shared Videos ──────────────────────────────────
|
||||
{
|
||||
id: "demo-tube-vlog",
|
||||
type: "folk-video",
|
||||
x: 2680, y: 50, width: 300, height: 180, rotation: 0,
|
||||
videoTitle: "Lac Blanc Sunrise — Test Footage",
|
||||
duration: "3:42",
|
||||
creator: "Liam",
|
||||
uploadedAt: "2026-07-08",
|
||||
views: 24,
|
||||
thumbnail: "sunrise-lacblanc",
|
||||
},
|
||||
|
||||
// ─── rInbox: Group Messages ────────────────────────────────
|
||||
{
|
||||
id: "demo-inbox-msg",
|
||||
type: "folk-inbox",
|
||||
x: 2680, y: 260, width: 300, height: 160, rotation: 0,
|
||||
inboxTitle: "Trip Group Chat",
|
||||
messages: [
|
||||
{ from: "Maya", text: "Don't forget passports tomorrow!", time: "10:32 AM" },
|
||||
{ from: "Liam", text: "Drone batteries charging. All 3 ready.", time: "10:45 AM" },
|
||||
{ from: "Omar", text: "First aid kit packed. Added altitude sickness meds.", time: "11:02 AM" },
|
||||
{ from: "Priya", text: "Locatelli confirmed our reservation!", time: "11:15 AM" },
|
||||
],
|
||||
},
|
||||
|
||||
// ─── rData: Trip Analytics ─────────────────────────────────
|
||||
{
|
||||
id: "demo-data-dashboard",
|
||||
type: "folk-dashboard",
|
||||
x: 2680, y: 450, width: 320, height: 220, rotation: 0,
|
||||
dashTitle: "Trip Statistics",
|
||||
metrics: [
|
||||
{ label: "Total Budget", value: "€4,000", trend: "neutral" },
|
||||
{ label: "Spent", value: "€1,203", trend: "up" },
|
||||
{ label: "Remaining", value: "€2,797", trend: "down" },
|
||||
{ label: "Tasks Done", value: "3/7", trend: "up" },
|
||||
{ label: "Hike Distance", value: "~85 km", trend: "neutral" },
|
||||
{ label: "Peak Altitude", value: "3,842m", trend: "neutral" },
|
||||
],
|
||||
},
|
||||
|
||||
// ─── rChoices: Decision Matrix ─────────────────────────────
|
||||
{
|
||||
id: "demo-choices-camera",
|
||||
type: "folk-choice-matrix",
|
||||
x: 2680, y: 700, width: 320, height: 200, rotation: 0,
|
||||
choiceTitle: "Camera Gear Decision",
|
||||
options: [
|
||||
{ name: "DJI Mini 4 Pro", score: 8.5, criteria: { weight: 9, quality: 8, price: 7, battery: 10 } },
|
||||
{ name: "GoPro Hero 12", score: 7.2, criteria: { weight: 10, quality: 6, price: 8, battery: 5 } },
|
||||
{ name: "Sony A7C II", score: 7.8, criteria: { weight: 4, quality: 10, price: 5, battery: 8 } },
|
||||
],
|
||||
decidedBy: "Liam",
|
||||
status: "decided",
|
||||
winner: "DJI Mini 4 Pro",
|
||||
},
|
||||
|
||||
// ─── rSplat: 3D Captures ───────────────────────────────────
|
||||
{
|
||||
id: "demo-splat-matterhorn",
|
||||
type: "folk-splat",
|
||||
x: 2680, y: 930, width: 300, height: 160, rotation: 0,
|
||||
splatTitle: "Matterhorn Base Camp — 3D Scan",
|
||||
pointCount: "2.4M",
|
||||
capturedBy: "Liam",
|
||||
capturedAt: "2026-07-12",
|
||||
fileSize: "48 MB",
|
||||
status: "processing",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
152
server/shell.ts
152
server/shell.ts
|
|
@ -59,6 +59,7 @@ export function renderShell(opts: ShellOptions): string {
|
|||
<link rel="stylesheet" href="/shell.css">
|
||||
${styles}
|
||||
${head}
|
||||
<style>${WELCOME_CSS}</style>
|
||||
</head>
|
||||
<body data-theme="${theme}">
|
||||
<header class="rstack-header" data-theme="${theme}">
|
||||
|
|
@ -79,11 +80,60 @@ export function renderShell(opts: ShellOptions): string {
|
|||
<main id="app">
|
||||
${body}
|
||||
</main>
|
||||
|
||||
${renderWelcomeOverlay()}
|
||||
|
||||
<script type="module">
|
||||
import '/shell.js';
|
||||
// Provide module list to app switcher
|
||||
document.querySelector('rstack-app-switcher')?.setModules(${moduleListJSON});
|
||||
|
||||
// ── Auto-space resolution ──
|
||||
// Logged-in users on demo space → redirect to personal space
|
||||
(function() {
|
||||
try {
|
||||
var raw = localStorage.getItem('encryptid_session');
|
||||
if (!raw) return;
|
||||
var session = JSON.parse(raw);
|
||||
if (!session || !session.claims || !session.claims.username) return;
|
||||
var currentSpace = '${escapeAttr(spaceSlug)}';
|
||||
if (currentSpace !== 'demo') return;
|
||||
fetch('/api/spaces/auto-provision', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + session.accessToken,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}).then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.slug) {
|
||||
var host = window.location.host.split(':')[0];
|
||||
var isStandalone = host !== 'rspace.online' && host !== 'localhost' && host !== '127.0.0.1';
|
||||
var moduleId = '${escapeAttr(moduleId)}';
|
||||
if (isStandalone) {
|
||||
window.location.replace('/' + data.slug);
|
||||
} else {
|
||||
window.location.replace('/' + data.slug + '/' + moduleId);
|
||||
}
|
||||
}
|
||||
}).catch(function() {});
|
||||
} catch(e) {}
|
||||
})();
|
||||
|
||||
// ── Welcome overlay (first visit to demo) ──
|
||||
(function() {
|
||||
var currentSpace = '${escapeAttr(spaceSlug)}';
|
||||
if (currentSpace !== 'demo') return;
|
||||
if (localStorage.getItem('rspace_welcomed')) return;
|
||||
var el = document.getElementById('rspace-welcome');
|
||||
if (el) el.style.display = 'flex';
|
||||
})();
|
||||
window.__rspaceDismissWelcome = function() {
|
||||
localStorage.setItem('rspace_welcomed', '1');
|
||||
var el = document.getElementById('rspace-welcome');
|
||||
if (el) el.style.display = 'none';
|
||||
};
|
||||
|
||||
// ── Tab bar / Layer system initialization ──
|
||||
const tabBar = document.querySelector('rstack-tab-bar');
|
||||
const spaceSlug = '${escapeAttr(spaceSlug)}';
|
||||
|
|
@ -266,6 +316,108 @@ export function renderStandaloneShell(opts: {
|
|||
</html>`;
|
||||
}
|
||||
|
||||
// ── Welcome overlay (quarter-screen popup for first-time visitors on demo) ──
|
||||
|
||||
function renderWelcomeOverlay(): string {
|
||||
return `
|
||||
<div id="rspace-welcome" class="rspace-welcome" style="display:none">
|
||||
<div class="rspace-welcome__popup">
|
||||
<button class="rspace-welcome__close" onclick="window.__rspaceDismissWelcome()">×</button>
|
||||
<h2 class="rspace-welcome__title">Welcome to rSpace</h2>
|
||||
<p class="rspace-welcome__text">
|
||||
A collaborative, local-first community platform with 22+ interoperable tools.
|
||||
You're viewing the <strong>demo space</strong> — sign in to access your own.
|
||||
</p>
|
||||
<div class="rspace-welcome__grid">
|
||||
<span>🎨 Canvas</span><span>📝 Notes</span>
|
||||
<span>🗳 Voting</span><span>💸 Funds</span>
|
||||
<span>🗺 Maps</span><span>📁 Files</span>
|
||||
<span>🔐 Passkeys</span><span>📡 Offline-First</span>
|
||||
</div>
|
||||
<div class="rspace-welcome__actions">
|
||||
<a href="/create-space" class="rspace-welcome__btn rspace-welcome__btn--primary">Create a Space</a>
|
||||
<button onclick="window.__rspaceDismissWelcome()" class="rspace-welcome__btn rspace-welcome__btn--secondary">Explore Demo</button>
|
||||
</div>
|
||||
<div class="rspace-welcome__footer">
|
||||
<a href="/about" class="rspace-welcome__link">Learn more about rSpace</a>
|
||||
<span class="rspace-welcome__dot">·</span>
|
||||
<a href="https://ridentity.online" class="rspace-welcome__link">EncryptID</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const WELCOME_CSS = `
|
||||
.rspace-welcome {
|
||||
position: fixed; bottom: 20px; right: 20px; z-index: 10000;
|
||||
display: none; align-items: flex-end; justify-content: flex-end;
|
||||
}
|
||||
.rspace-welcome__popup {
|
||||
position: relative;
|
||||
width: min(380px, 44vw); max-height: 50vh;
|
||||
background: #1e293b; border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 16px; padding: 24px 24px 18px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5); color: #e2e8f0;
|
||||
overflow-y: auto; animation: rspace-welcome-in 0.3s ease-out;
|
||||
}
|
||||
@keyframes rspace-welcome-in {
|
||||
from { opacity: 0; transform: translateY(20px) scale(0.95); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
.rspace-welcome__close {
|
||||
position: absolute; top: 10px; right: 12px;
|
||||
background: none; border: none; color: #64748b;
|
||||
font-size: 1.4rem; cursor: pointer; line-height: 1;
|
||||
padding: 4px; border-radius: 4px;
|
||||
}
|
||||
.rspace-welcome__close:hover { color: #e2e8f0; background: rgba(255,255,255,0.08); }
|
||||
.rspace-welcome__title {
|
||||
font-size: 1.35rem; margin: 0 0 8px;
|
||||
background: linear-gradient(135deg, #14b8a6, #22d3ee);
|
||||
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.rspace-welcome__text {
|
||||
font-size: 0.85rem; color: #94a3b8; margin: 0 0 14px; line-height: 1.55;
|
||||
}
|
||||
.rspace-welcome__text strong { color: #e2e8f0; }
|
||||
.rspace-welcome__grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr;
|
||||
gap: 5px; margin-bottom: 14px; font-size: 0.8rem; color: #cbd5e1;
|
||||
}
|
||||
.rspace-welcome__grid span { padding: 3px 0; }
|
||||
.rspace-welcome__actions {
|
||||
display: flex; gap: 8px; margin-bottom: 12px;
|
||||
}
|
||||
.rspace-welcome__btn {
|
||||
padding: 8px 16px; border-radius: 8px; font-size: 0.82rem;
|
||||
font-weight: 600; text-decoration: none; cursor: pointer; border: none;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.rspace-welcome__btn:hover { transform: translateY(-1px); }
|
||||
.rspace-welcome__btn--primary {
|
||||
background: linear-gradient(135deg, #14b8a6, #0d9488); color: white;
|
||||
box-shadow: 0 2px 8px rgba(20,184,166,0.3);
|
||||
}
|
||||
.rspace-welcome__btn--secondary {
|
||||
background: rgba(255,255,255,0.08); color: #94a3b8;
|
||||
}
|
||||
.rspace-welcome__btn--secondary:hover { color: #e2e8f0; }
|
||||
.rspace-welcome__footer {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.rspace-welcome__link {
|
||||
font-size: 0.72rem; color: #64748b; text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.rspace-welcome__link:hover { color: #c4b5fd; }
|
||||
.rspace-welcome__dot { color: #475569; font-size: 0.6rem; }
|
||||
@media (max-width: 600px) {
|
||||
.rspace-welcome { bottom: 12px; right: 12px; left: 12px; }
|
||||
.rspace-welcome__popup { width: 100%; max-width: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,43 @@ function storeSession(token: string, username: string, did: string): void {
|
|||
if (username) localStorage.setItem("rspace-username", username);
|
||||
}
|
||||
|
||||
// ── Auto-space resolution after auth ──
|
||||
|
||||
function autoResolveSpace(token: string, username: string): void {
|
||||
if (!username) return;
|
||||
const slug = username.toLowerCase();
|
||||
|
||||
// Detect current space from URL
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
const currentSpace = parts[0] || "demo";
|
||||
if (currentSpace !== "demo") return; // Already on a non-demo space
|
||||
|
||||
// Provision personal space and redirect
|
||||
fetch("/api/spaces/auto-provision", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (!data.slug) return;
|
||||
const host = window.location.host.split(":")[0];
|
||||
const isStandalone =
|
||||
host !== "rspace.online" &&
|
||||
host !== "localhost" &&
|
||||
host !== "127.0.0.1";
|
||||
const moduleId = parts[1] || "canvas";
|
||||
if (isStandalone) {
|
||||
window.location.replace("/" + data.slug);
|
||||
} else {
|
||||
window.location.replace("/" + data.slug + "/" + moduleId);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// ── The custom element ──
|
||||
|
||||
export class RStackIdentity extends HTMLElement {
|
||||
|
|
@ -279,6 +316,8 @@ export class RStackIdentity extends HTMLElement {
|
|||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
|
||||
callbacks?.onSuccess?.();
|
||||
// Auto-redirect to personal space
|
||||
autoResolveSpace(data.token, data.username || "");
|
||||
} catch (err: any) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = "🔑 Sign In with Passkey";
|
||||
|
|
@ -352,6 +391,8 @@ export class RStackIdentity extends HTMLElement {
|
|||
this.#render();
|
||||
this.dispatchEvent(new CustomEvent("auth-change", { bubbles: true, composed: true }));
|
||||
callbacks?.onSuccess?.();
|
||||
// Auto-redirect to personal space
|
||||
autoResolveSpace(data.token, username);
|
||||
} catch (err: any) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = "🔐 Create Passkey";
|
||||
|
|
|
|||
|
|
@ -378,8 +378,8 @@
|
|||
<p class="tagline">Collaborative community spaces powered by FolkJS</p>
|
||||
|
||||
<div class="cta-buttons">
|
||||
<a href="/create-space" class="cta-primary">Create a Space</a>
|
||||
<a href="/demo/canvas" class="cta-secondary">Try the Demo</a>
|
||||
<a href="/create-space" class="cta-primary" id="cta-primary">Create a Space</a>
|
||||
<a href="/demo/canvas" class="cta-secondary" id="cta-demo">Try the Demo</a>
|
||||
</div>
|
||||
|
||||
<div class="features">
|
||||
|
|
@ -603,7 +603,7 @@
|
|||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { RStackIdentity } from "@shared/components/rstack-identity";
|
||||
import { RStackIdentity, isAuthenticated, getAccessToken } from "@shared/components/rstack-identity";
|
||||
import { RStackAppSwitcher } from "@shared/components/rstack-app-switcher";
|
||||
import { RStackSpaceSwitcher } from "@shared/components/rstack-space-switcher";
|
||||
|
||||
|
|
@ -615,6 +615,34 @@
|
|||
document.querySelector("rstack-app-switcher")?.setModules(data.modules || []);
|
||||
}).catch(() => {});
|
||||
|
||||
// If logged in, update CTA to go to personal space
|
||||
try {
|
||||
const raw = localStorage.getItem('encryptid_session');
|
||||
if (raw) {
|
||||
const session = JSON.parse(raw);
|
||||
if (session?.claims?.username) {
|
||||
const username = session.claims.username.toLowerCase();
|
||||
const primary = document.getElementById('cta-primary');
|
||||
const demo = document.getElementById('cta-demo');
|
||||
if (primary) {
|
||||
primary.textContent = 'Go to My Space';
|
||||
primary.href = '/' + username + '/canvas';
|
||||
}
|
||||
if (demo) {
|
||||
demo.textContent = 'View Demo Space';
|
||||
}
|
||||
// Auto-provision personal space
|
||||
const token = session.accessToken;
|
||||
if (token) {
|
||||
fetch('/api/spaces/auto-provision', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// Newsletter signup
|
||||
const newsletterForm = document.getElementById("newsletter-form");
|
||||
const newsletterEmail = document.getElementById("newsletter-email");
|
||||
|
|
|
|||
Loading…
Reference in New Issue