fix(routing): eliminate /{space} from paths on subdomain routing

On subdomain routing (e.g. demo.rspace.online), the space slug belongs
only in the subdomain — never in the URL path. This fixes all server
and client code that was generating /{space}/module paths on subdomains.

Server fixes:
- index.ts: notification actionUrls, template/disabled-module redirects,
  subdomain passthrough (now redirects HTML, rewrites API), WS notifications
- output-list.ts: subdomain-aware path prefix for hrefs and fetch URLs
- shell.ts: dashboard pushState URL

Client fixes (basePath getter pattern):
- folk-book-shelf.ts, folk-splat-viewer.ts: _basePath getter
- folk-campaign-wizard.ts: basePath subdomain check
- folk-trips-planner.ts: use __rspaceNavUrl for canvas export
- rschedule/landing.ts: onclick handlers use __rspaceNavUrl
- rsplat/mod.ts: legacy view redirect subdomain check

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 18:45:16 -07:00
parent 75ad3f8194
commit 193588443e
9 changed files with 124 additions and 22 deletions

View File

@ -31,6 +31,10 @@ export class FolkBookShelf extends HTMLElement {
private _books: BookData[] = [];
private _filtered: BookData[] = [];
private _spaceSlug = "personal";
private get _basePath(): string {
if (window.location.hostname.endsWith('.rspace.online')) return '/rbooks';
return `${this._basePath}`;
}
private _searchTerm = "";
private _selectedTag: string | null = null;
private _offlineUnsub: (() => void) | null = null;
@ -469,7 +473,7 @@ export class FolkBookShelf extends HTMLElement {
</div>`
: `<div class="grid">
${books.map((b) => `
<a class="book-card" data-collab-id="book:${b.id}" href="/${this._spaceSlug}/rbooks/read/${b.slug}">
<a class="book-card" data-collab-id="book:${b.id}" href="${this._basePath}/read/${b.slug}">
<div class="book-cover" style="background:${b.cover_color}">
<span class="book-cover-title">${this.escapeHtml(b.title)}</span>
${b.featured ? '<span class="featured-badge">Featured</span>' : ""}
@ -648,7 +652,7 @@ export class FolkBookShelf extends HTMLElement {
}
try {
const res = await fetch(`/${this._spaceSlug}/rbooks/api/books`, {
const res = await fetch(`${this._basePath}/api/books`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
@ -660,7 +664,7 @@ export class FolkBookShelf extends HTMLElement {
}
// Navigate to the new book
window.location.href = `/${this._spaceSlug}/rbooks/read/${data.slug}`;
window.location.href = `${this._basePath}/read/${data.slug}`;
} catch (e: any) {
errorEl.textContent = e.message;
errorEl.hidden = false;

View File

@ -21,12 +21,12 @@ export function renderLanding(): string {
<div class="rl-cta-row">
<a href="#" class="rl-cta-primary" id="ml-primary"
style="background:linear-gradient(to right,#f59e0b,#f97316);color:#0b1120"
onclick="document.querySelector('.rl-hero').closest('[data-space]')?.getAttribute('data-space') ? window.location.href='/' + document.querySelector('.rl-hero').closest('[data-space]').getAttribute('data-space') + '/rschedule' : void 0; return false;">
onclick="var s=document.querySelector('.rl-hero').closest('[data-space]')?.getAttribute('data-space');if(s&&window.__rspaceNavUrl)window.location.href=window.__rspaceNavUrl(s,'rschedule');return false;">
Open Scheduler
</a>
<a href="#" class="rl-cta-primary" id="ml-automations"
style="background:linear-gradient(to right,#8b5cf6,#6366f1);color:#fff"
onclick="document.querySelector('.rl-hero').closest('[data-space]')?.getAttribute('data-space') ? window.location.href='/' + document.querySelector('.rl-hero').closest('[data-space]').getAttribute('data-space') + '/rschedule/reminders' : window.location.href='https://demo.rspace.online/rschedule/reminders'; return false;">
onclick="var s=document.querySelector('.rl-hero').closest('[data-space]')?.getAttribute('data-space');if(s&&window.__rspaceNavUrl)window.location.href=window.__rspaceNavUrl(s,'rschedule')+'/reminders';return false;">
Automation Canvas
</a>
<a href="#features" class="rl-cta-secondary">Learn More</a>

View File

@ -128,6 +128,8 @@ export class FolkCampaignWizard extends HTMLElement {
}
private get basePath(): string {
const host = window.location.hostname;
if (host.endsWith('.rspace.online')) return '/rsocials';
return `/${encodeURIComponent(this._space)}/rsocials`;
}

View File

@ -41,6 +41,10 @@ export class FolkSplatViewer extends HTMLElement {
private _mode: "gallery" | "viewer" = "gallery";
private _splats: SplatItem[] = [];
private _spaceSlug = "demo";
private get _basePath(): string {
if (window.location.hostname.endsWith('.rspace.online')) return '/rsplat';
return `${this._basePath}`;
}
private _tour: LightTourEngine | null = null;
private _splatUrl = "";
private _splatTitle = "";
@ -112,7 +116,7 @@ export class FolkSplatViewer extends HTMLElement {
this._splats = Object.values(doc.items).map(s => ({
id: s.id, slug: s.slug, title: s.title, description: s.description,
file_format: s.fileFormat,
file_url: s.filePath ? `/${this._spaceSlug}/rsplat/api/splats/${s.slug}/${s.slug}.${s.fileFormat}` : undefined,
file_url: s.filePath ? `${this._basePath}/api/splats/${s.slug}/${s.slug}.${s.fileFormat}` : undefined,
file_size_bytes: s.fileSizeBytes,
view_count: s.viewCount, contributor_name: s.contributorName ?? undefined,
thumbnail_url: (s as any).thumbnailUrl ?? undefined,
@ -136,7 +140,7 @@ export class FolkSplatViewer extends HTMLElement {
if (!token || this._spaceSlug === "demo") return;
try {
const res = await fetch(`/${this._spaceSlug}/rsplat/api/splats/my-history`, {
const res = await fetch(`${this._basePath}/api/splats/my-history`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
@ -176,7 +180,7 @@ export class FolkSplatViewer extends HTMLElement {
const isReady = status === "ready";
const isDemo = !!s.demoUrl;
const tag = isReady ? (isDemo ? "div" : "a") : "div";
const href = isReady && !isDemo ? ` href="/${this._spaceSlug}/rsplat/${s.slug}"` : "";
const href = isReady && !isDemo ? ` href="${this._basePath}/${s.slug}"` : "";
const demoAttr = isDemo ? ` data-demo-url="${esc(s.demoUrl!)}" data-demo-title="${esc(s.title)}" data-demo-desc="${esc(s.description || "")}" role="button" tabindex="0"` : "";
const statusClass = !isReady ? ` splat-card--${status}` : "";
const demoClass = isDemo ? " splat-card--demo" : "";
@ -399,7 +403,7 @@ export class FolkSplatViewer extends HTMLElement {
try {
const token = this.getAuthToken();
const res = await fetch(`/${this._spaceSlug}/rsplat/api/splats`, {
const res = await fetch(`${this._basePath}/api/splats`, {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: formData,
@ -427,7 +431,7 @@ export class FolkSplatViewer extends HTMLElement {
const splat = await res.json() as SplatItem;
status.textContent = "Uploaded!";
setTimeout(() => {
window.location.href = `/${this._spaceSlug}/rsplat/${splat.slug}`;
window.location.href = `${this._basePath}/${splat.slug}`;
}, 500);
} catch (e) {
status.textContent = "Network error";
@ -690,7 +694,7 @@ export class FolkSplatViewer extends HTMLElement {
saveBtn.textContent = "Saving...";
try {
const token = this.getAuthToken();
const saveRes = await fetch(`/${this._spaceSlug}/rsplat/api/splats/save-generated`, {
const saveRes = await fetch(`${this._basePath}/api/splats/save-generated`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -709,7 +713,7 @@ export class FolkSplatViewer extends HTMLElement {
saveBtn.textContent = "Saved!";
saveBtn.style.background = "#16a34a";
setTimeout(() => {
window.location.href = `/${this._spaceSlug}/rsplat/${data.slug}`;
window.location.href = `${this._basePath}/${data.slug}`;
}, 1000);
} else {
const err = await saveRes.json().catch(() => ({ error: "Save failed" }));
@ -756,7 +760,7 @@ export class FolkSplatViewer extends HTMLElement {
if (!token || !this._generatedUrl || this._spaceSlug === "demo") return;
try {
const res = await fetch(`/${this._spaceSlug}/rsplat/api/splats/save-generated`, {
const res = await fetch(`${this._basePath}/api/splats/save-generated`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -782,12 +786,12 @@ export class FolkSplatViewer extends HTMLElement {
private renderViewer() {
const backEl = this._inlineViewer
? `<button class="splat-viewer__back" id="splat-back-btn">← Back to Gallery</button>`
: `<a class="splat-viewer__back" href="/${this._spaceSlug}/rsplat">← Gallery</a>`;
: `<a class="splat-viewer__back" href="${this._basePath}">← Gallery</a>`;
// Show "View in Gallery" if auto-saved, otherwise "Save" if generated
let actionEl = "";
if (this._savedSlug) {
actionEl = `<a class="splat-viewer__save" href="/${this._spaceSlug}/rsplat/${this._savedSlug}">View in Gallery</a>`;
actionEl = `<a class="splat-viewer__save" href="${this._basePath}/${this._savedSlug}">View in Gallery</a>`;
} else if (this._generatedUrl && this._inlineViewer && this._spaceSlug !== "demo") {
actionEl = `<button class="splat-viewer__save" id="splat-save-btn">Save to Gallery</button>`;
}
@ -925,7 +929,7 @@ export class FolkSplatViewer extends HTMLElement {
return;
}
const res = await fetch(`/${this._spaceSlug}/rsplat/api/splats/save-generated`, {
const res = await fetch(`${this._basePath}/api/splats/save-generated`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -962,7 +966,7 @@ export class FolkSplatViewer extends HTMLElement {
// Replace save button with view link
setTimeout(() => {
window.location.href = `/${this._spaceSlug}/rsplat/${data.slug}`;
window.location.href = `${this._basePath}/${data.slug}`;
}, 800);
} catch {
saveBtn.textContent = "Network error";

View File

@ -798,7 +798,8 @@ function renderViewerPage(spaceSlug: string, dataSpace: string, idOrSlug: string
routes.get("/view/:id", async (c) => {
const spaceSlug = c.req.param("space") || "demo";
const id = c.req.param("id");
return c.redirect(`/${spaceSlug}/rsplat/${id}`, 301);
const isSub = (c as any).get?.("isSubdomain");
return c.redirect(isSub ? `/rsplat/${id}` : `/${spaceSlug}/rsplat/${id}`, 301);
});
// ── Seed template data ──

View File

@ -1113,7 +1113,8 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Be specific and prac
const accepted = this._aiGeneratedItems.filter(i => i.accepted);
if (accepted.length === 0) return;
sessionStorage.setItem('rtrips-canvas-export', JSON.stringify(accepted.map(i => ({ type: i.type, props: i.props }))));
window.location.href = `/${this.space}/rspace#trip-import`;
const nav = (window as any).__rspaceNavUrl;
window.location.href = (nav ? nav(this.space, 'rspace') : `/${this.space}/rspace`) + '#trip-import';
}
private goBack() {

View File

@ -2064,6 +2064,93 @@ app.post("/api/prompt", async (c) => {
return c.json({ error: `Unknown model: ${model}` }, 400);
});
// ── Trip AI Prompt (executes trip tools server-side) ──
app.post("/api/trips/ai-prompt", async (c) => {
const { messages, model = "gemini-flash", systemPrompt } = await c.req.json();
if (!messages?.length) return c.json({ error: "messages required" }, 400);
if (!GEMINI_MODELS[model]) return c.json({ error: `Unsupported model: ${model}` }, 400);
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const { GoogleGenerativeAI } = await import("@google/generative-ai");
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
// Combine canvas tools (create_destination, etc.) + trip tools (search_flights, etc.)
const { CANVAS_TOOL_DECLARATIONS } = await import("../lib/canvas-tools");
const { TRIP_TOOL_DECLARATIONS, findTripTool } = await import("../lib/trip-ai-tools");
const { findTool } = await import("../lib/canvas-tools");
const allDeclarations = [...CANVAS_TOOL_DECLARATIONS, ...TRIP_TOOL_DECLARATIONS];
const geminiModel = genAI.getGenerativeModel({
model: GEMINI_MODELS[model],
tools: [{ functionDeclarations: allDeclarations }],
systemInstruction: systemPrompt || "You are a travel planning assistant.",
});
const contents = messages.map((m: { role: string; content: string }) => ({
role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }],
}));
const OSRM_URL = process.env.OSRM_URL || "http://osrm-backend:5000";
try {
const toolCalls: { name: string; args: Record<string, any>; label: string }[] = [];
const toolResults: Record<string, any> = {};
let loopContents = [...contents];
for (let turn = 0; turn < 5; turn++) {
const result = await geminiModel.generateContent({ contents: loopContents });
const response = result.response;
const candidate = response.candidates?.[0];
if (!candidate) break;
const parts = candidate.content?.parts || [];
const fnCalls = parts.filter((p: any) => p.functionCall);
if (fnCalls.length === 0) {
const text = response.text();
return c.json({ content: text, toolCalls, toolResults });
}
const fnResponseParts: any[] = [];
for (const part of fnCalls) {
const fc = part.functionCall!;
const tripTool = findTripTool(fc.name);
if (tripTool) {
// Execute server-side and return real data
const data = await tripTool.execute(fc.args, { osrmUrl: OSRM_URL });
const label = tripTool.actionLabel(fc.args);
toolCalls.push({ name: fc.name, args: fc.args, label });
toolResults[`${fc.name}_${toolCalls.length}`] = data;
fnResponseParts.push({
functionResponse: { name: fc.name, response: data },
});
} else {
// Canvas tool — acknowledge only
const canvasTool = findTool(fc.name);
const label = canvasTool?.actionLabel(fc.args) || fc.name;
toolCalls.push({ name: fc.name, args: fc.args, label });
fnResponseParts.push({
functionResponse: {
name: fc.name,
response: { success: true, message: `${label} — item will be created.` },
},
});
}
}
loopContents.push({ role: "model", parts });
loopContents.push({ role: "user", parts: fnResponseParts });
}
return c.json({ content: "I've set up the requested items.", toolCalls, toolResults });
} catch (e: any) {
console.error("[trips/ai-prompt] Gemini error:", e.message);
return c.json({ error: "Gemini request failed" }, 502);
}
});
// ── Gemini image generation ──
app.post("/api/gemini/image", async (c) => {
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);

View File

@ -36,6 +36,8 @@ export function renderOutputListPage(
var moduleId = ${JSON.stringify(mod.id)};
var outputPath = ${JSON.stringify(outputPath.path)};
var outputName = ${JSON.stringify(outputPath.name)};
var isSubdomain = window.location.hostname.endsWith('.rspace.online');
var pathPrefix = isSubdomain ? '/' + moduleId : '/' + space + '/' + moduleId;
function timeAgo(dateStr) {
if (!dateStr) return '';
@ -66,7 +68,7 @@ export function renderOutputListPage(
var desc = item.description || item.content_plain || '';
if (desc.length > 120) desc = desc.substring(0, 120) + '…';
var date = item.updated_at || item.created_at || '';
var href = item.url || ('/' + space + '/' + moduleId + '?item=' + (item.id || item.slug || ''));
var href = item.url || (pathPrefix + '?item=' + (item.id || item.slug || ''));
return '<a class="output-card" href="' + href + '">' +
'<div class="output-card__title">' + escapeText(title) + '</div>' +
(desc ? '<div class="output-card__desc">' + escapeText(desc) + '</div>' : '') +
@ -81,7 +83,7 @@ export function renderOutputListPage(
return d.innerHTML;
}
fetch('/' + space + '/' + moduleId + '/api/' + outputPath)
fetch(pathPrefix + '/api/' + outputPath)
.then(function(r) {
if (!r.ok) throw new Error(r.status);
return r.json();

View File

@ -1028,7 +1028,8 @@ export function renderShell(opts: ShellOptions): string {
tabBar.setAttribute('active', '');
tabBar.setLayers([]);
currentModuleId = '';
history.pushState({ dashboard: true, spaceSlug: spaceSlug }, '', '/' + spaceSlug);
var dashUrl = window.location.hostname.endsWith('.rspace.online') ? '/' : '/' + spaceSlug;
history.pushState({ dashboard: true, spaceSlug: spaceSlug }, '', dashUrl);
} else if (wasActive) {
// Closed the active tab — switch to the nearest remaining tab
const nextLayer = layers[Math.min(closedIdx, layers.length - 1)] || layers[0];