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:
parent
75ad3f8194
commit
193588443e
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Reference in New Issue