diff --git a/modules/rbooks/components/folk-book-shelf.ts b/modules/rbooks/components/folk-book-shelf.ts
index b46918f..53254be 100644
--- a/modules/rbooks/components/folk-book-shelf.ts
+++ b/modules/rbooks/components/folk-book-shelf.ts
@@ -6,6 +6,7 @@
*/
import { booksCatalogSchema, booksCatalogDocId, type BooksCatalogDoc } from "../schemas";
+import { makeDraggableAll } from "../../../shared/draggable";
import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
@@ -516,6 +517,14 @@ export class FolkBookShelf extends HTMLElement {
`;
this.bindEvents();
+
+ // Make book cards draggable for calendar reminders
+ makeDraggableAll(this.shadowRoot!, ".book-card[data-collab-id]", (el) => {
+ const title = el.querySelector(".book-title")?.textContent || "";
+ const id = el.dataset.collabId?.replace("book:", "") || "";
+ return title ? { title, module: "rbooks", entityId: id, label: "Book", color: "#f97316" } : null;
+ });
+
this._tour.renderOverlay();
}
diff --git a/modules/rbooks/mod.ts b/modules/rbooks/mod.ts
index 9623333..a5b64c4 100644
--- a/modules/rbooks/mod.ts
+++ b/modules/rbooks/mod.ts
@@ -334,7 +334,7 @@ routes.get("/read/:id", async (c) => {
title: "Book not found | rSpace",
moduleId: "rbooks",
spaceSlug,
- body: `
= {};
+ private reminders: any[] = [];
private showLunar = true;
private selectedDate = "";
private selectedEvent: any = null;
@@ -668,15 +669,18 @@ class FolkCalendarView extends HTMLElement {
const lastDay = new Date(year, month + 1, 0).getDate();
const end = `${year}-${String(month + 1).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`;
const base = this.getApiBase();
+ const schedBase = this.getScheduleApiBase();
try {
- const [eventsRes, sourcesRes, lunarRes] = await Promise.all([
+ const [eventsRes, sourcesRes, lunarRes, remindersRes] = await Promise.all([
fetch(`${base}/api/events?start=${start}&end=${end}`),
fetch(`${base}/api/sources`),
fetch(`${base}/api/lunar?start=${start}&end=${end}`),
+ fetch(`${schedBase}/api/reminders?upcoming=true`).catch(() => null),
]);
if (eventsRes.ok) { const data = await eventsRes.json(); this.events = data.results || []; }
if (sourcesRes.ok) { const data = await sourcesRes.json(); this.sources = data.results || []; }
if (lunarRes.ok) { this.lunarData = await lunarRes.json(); }
+ if (remindersRes?.ok) { const data = await remindersRes.json(); this.reminders = data.reminders || []; }
} catch { /* offline fallback */ }
this.render();
}
@@ -730,6 +734,15 @@ class FolkCalendarView extends HTMLElement {
&& !this.filteredSources.has(e.source_name));
}
+ private getRemindersForDate(dateStr: string): any[] {
+ return this.reminders.filter(r => {
+ if (!r.remindAt) return false;
+ const d = new Date(r.remindAt);
+ const rs = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
+ return rs === dateStr;
+ });
+ }
+
/** Returns visual style overrides for tentative (likelihood < 100) events. */
private getEventStyles(ev: any): { bgColor: string; borderStyle: string; opacity: number; isTentative: boolean; likelihoodLabel: string } {
const baseColor = ev.source_color || "#6366f1";
@@ -1116,14 +1129,16 @@ class FolkCalendarView extends HTMLElement {
const isToday = ds === todayStr;
const isExpanded = ds === this.expandedDay;
const dayEvents = this.getEventsForDate(ds);
+ const dayReminders = this.getRemindersForDate(ds);
const lunar = this.lunarData[ds];
html += `
${d}
${this.showLunar && lunar ? `${this.getMoonEmoji(lunar.phase)}` : ""}
+ ${dayReminders.length > 0 ? `🔔${dayReminders.length > 1 ? dayReminders.length : ""}` : ""}
- ${dayEvents.length > 0 ? `
+ ${dayEvents.length > 0 || dayReminders.length > 0 ? `
${dayEvents.slice(0, 5).map(e => {
const es = this.getEventStyles(e);
@@ -1131,6 +1146,7 @@ class FolkCalendarView extends HTMLElement {
? ``
: ``;
}).join("")}
+ ${dayReminders.slice(0, 3).map(r => ``).join("")}
${dayEvents.length > 5 ? `+${dayEvents.length - 5}` : ""}
${dayEvents.slice(0, 2).map(e => {
@@ -1737,33 +1753,105 @@ class FolkCalendarView extends HTMLElement {
if (!title.trim()) return;
- // Prompt for quick confirmation
- const confirmed = confirm(`Create reminder "${title}" on ${dropDate}?`);
- if (!confirmed) return;
+ // Show time-picker popover instead of confirm()
+ this.showReminderPopover(e, dropDate, {
+ title: title.trim(), sourceModule, sourceEntityId, sourceLabel, sourceColor,
+ });
+ }
- const remindAt = new Date(dropDate + "T09:00:00").getTime();
- const base = this.getScheduleApiBase();
+ private showReminderPopover(
+ e: DragEvent,
+ dropDate: string,
+ item: { title: string; sourceModule: string | null; sourceEntityId: string | null; sourceLabel: string | null; sourceColor: string | null },
+ ) {
+ // Remove any existing popover
+ this.shadow.querySelector(".reminder-popover")?.remove();
- try {
- await fetch(`${base}/api/reminders`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- title: title.trim(),
- remindAt,
- allDay: true,
- syncToCalendar: true,
- sourceModule,
- sourceEntityId,
- sourceLabel,
- sourceColor,
- }),
- });
- // Reload events to show the new calendar entry
- if (this.space === "demo") { this.loadDemoData(); } else { this.loadMonth(); }
- } catch (err) {
- console.error("[rCal] Failed to create reminder:", err);
+ const accentColor = item.sourceColor || "#818cf8";
+ const labelText = item.sourceLabel || "Item";
+ const friendlyDate = new Date(dropDate + "T12:00:00").toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
+
+ const popover = document.createElement("div");
+ popover.className = "reminder-popover";
+ popover.innerHTML = `
+
+
${friendlyDate}
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ this.shadow.appendChild(popover);
+
+ // Position near drop target
+ const rect = (e.target as HTMLElement)?.closest?.("[data-drop-date]")?.getBoundingClientRect();
+ if (rect) {
+ const hostRect = this.getBoundingClientRect();
+ popover.style.position = "absolute";
+ popover.style.left = `${rect.left - hostRect.left}px`;
+ popover.style.top = `${rect.bottom - hostRect.top + 4}px`;
+ popover.style.zIndex = "1000";
}
+
+ const createReminder = async (hour: number, minute = 0) => {
+ popover.remove();
+ const remindAt = new Date(`${dropDate}T${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00`).getTime();
+ const base = this.getScheduleApiBase();
+ try {
+ await fetch(`${base}/api/reminders`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ title: item.title,
+ remindAt,
+ allDay: false,
+ syncToCalendar: true,
+ sourceModule: item.sourceModule,
+ sourceEntityId: item.sourceEntityId,
+ sourceLabel: item.sourceLabel,
+ sourceColor: item.sourceColor,
+ }),
+ });
+ if (this.space === "demo") { this.loadDemoData(); } else { this.loadMonth(); }
+ } catch (err) {
+ console.error("[rCal] Failed to create reminder:", err);
+ }
+ };
+
+ // Quick-pick time buttons
+ popover.querySelectorAll
(".reminder-popover__time[data-hour]").forEach((btn) => {
+ btn.addEventListener("click", () => createReminder(parseInt(btn.dataset.hour!)));
+ });
+
+ // Custom time
+ popover.querySelector(".reminder-popover__time[data-custom]")?.addEventListener("click", () => {
+ const input = popover.querySelector(".reminder-popover__time-input") as HTMLInputElement;
+ const [h, m] = (input.value || "09:00").split(":").map(Number);
+ createReminder(h, m);
+ });
+
+ // Cancel
+ popover.querySelector(".reminder-popover__cancel")?.addEventListener("click", () => popover.remove());
+
+ // Close on outside click
+ const closeHandler = (ev: Event) => {
+ if (!popover.contains(ev.target as Node)) {
+ popover.remove();
+ document.removeEventListener("click", closeHandler);
+ }
+ };
+ setTimeout(() => document.addEventListener("click", closeHandler), 100);
}
startTour() { this._tour.start(); }
@@ -2422,6 +2510,62 @@ class FolkCalendarView extends HTMLElement {
/* ── Drop Target ── */
.day.drop-target { background: rgba(245,158,11,0.15); border: 2px dashed var(--rs-warning); }
+ /* Reminder popover */
+ /* Reminder indicators on calendar days */
+ .reminder-badge {
+ font-size: 10px; margin-left: 2px; opacity: 0.85;
+ }
+ .dot--reminder {
+ animation: pulse-reminder 2s ease-in-out infinite;
+ }
+ @keyframes pulse-reminder {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+ }
+
+ .reminder-popover {
+ background: #1e1e2e; border: 1px solid #444; border-radius: 12px;
+ padding: 14px; min-width: 220px; max-width: 280px;
+ box-shadow: 0 8px 32px rgba(0,0,0,0.5); font-size: 13px;
+ }
+ .reminder-popover__header {
+ display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
+ }
+ .reminder-popover__badge {
+ font-size: 10px; padding: 2px 8px; border-radius: 4px; color: #fff;
+ font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;
+ }
+ .reminder-popover__title {
+ font-weight: 600; color: #e0e0e0; overflow: hidden;
+ text-overflow: ellipsis; white-space: nowrap; flex: 1;
+ }
+ .reminder-popover__date {
+ color: #94a3b8; font-size: 12px; margin-bottom: 12px;
+ }
+ .reminder-popover__times {
+ display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-bottom: 8px;
+ }
+ .reminder-popover__time {
+ padding: 8px; border-radius: 6px; border: 1px solid #444;
+ background: #2a2a3e; color: #e0e0e0; cursor: pointer;
+ font-size: 12px; text-align: center; transition: all 0.15s;
+ }
+ .reminder-popover__time:hover {
+ background: #3a3a5e; border-color: #818cf8;
+ }
+ .reminder-popover__custom {
+ display: flex; gap: 6px; margin-bottom: 8px;
+ }
+ .reminder-popover__time-input {
+ flex: 1; padding: 6px 8px; border-radius: 6px; border: 1px solid #444;
+ background: #2a2a3e; color: #e0e0e0; font-size: 12px;
+ }
+ .reminder-popover__cancel {
+ width: 100%; padding: 6px; border-radius: 6px; border: 1px solid #333;
+ background: transparent; color: #64748b; cursor: pointer; font-size: 11px;
+ }
+ .reminder-popover__cancel:hover { color: #94a3b8; }
+
/* ── Day Detail Panel ── */
.day-detail { grid-column: 1 / -1; background: var(--rs-bg-surface); border: 1px solid var(--rs-bg-surface-raised); border-radius: 8px; padding: 12px; }
.dd-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts
index 98608fc..f8e4822 100644
--- a/modules/rcart/mod.ts
+++ b/modules/rcart/mod.ts
@@ -10,7 +10,7 @@
import * as Automerge from "@automerge/automerge";
import { Hono } from "hono";
-import { renderShell } from "../../server/shell";
+import { renderShell, buildSpaceUrl } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import { depositOrderRevenue } from "./flow";
import type { RSpaceModule } from "../../shared/module";
@@ -1254,10 +1254,10 @@ routes.post("/api/shopping-carts/:cartId/contribute-pay", async (c) => {
});
_syncServer!.setDoc(payDocId, payDoc);
- const host = c.req.header("host") || "rspace.online";
- const payUrl = `/${space}/rcart/pay/${paymentId}`;
+ const payUrl = `/rcart/pay/${paymentId}`;
+ const fullPayUrl = buildSpaceUrl(space, `/rcart/pay/${paymentId}`);
- return c.json({ paymentId, payUrl, fullPayUrl: `https://${host}${payUrl}` }, 201);
+ return c.json({ paymentId, payUrl, fullPayUrl }, 201);
});
// ── Extension shortcut routes ──
@@ -1489,7 +1489,7 @@ routes.post("/api/payments", async (c) => {
_syncServer!.setDoc(docId, payDoc);
const host = c.req.header("host") || "rspace.online";
- const payUrl = `https://${host}/${space}/rcart/pay/${paymentId}`;
+ const payUrl = `${buildSpaceUrl(space, "/rcart")}/pay/${paymentId}`;
return c.json({
id: paymentId,
@@ -1500,7 +1500,7 @@ routes.post("/api/payments", async (c) => {
recipientAddress,
status: 'pending',
payUrl,
- qrUrl: `https://${host}/${space}/rcart/api/payments/${paymentId}/qr`,
+ qrUrl: `${buildSpaceUrl(space, "/rcart")}/api/payments/${paymentId}/qr`,
created_at: new Date(now).toISOString(),
}, 201);
});
@@ -1733,7 +1733,7 @@ routes.get("/api/payments/:id/qr", async (c) => {
if (!doc) return c.json({ error: "Payment request not found" }, 404);
const host = c.req.header("host") || "rspace.online";
- const payUrl = `https://${host}/${space}/rcart/pay/${paymentId}`;
+ const payUrl = `${buildSpaceUrl(space, "/rcart")}/pay/${paymentId}`;
const svg = await QRCode.toString(payUrl, { type: 'svg', margin: 2 });
return c.body(svg, 200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=3600' });
@@ -1813,7 +1813,7 @@ routes.post("/api/payments/:id/share-email", async (c) => {
if (!transport) return c.json({ error: "Email not configured" }, 503);
const host = c.req.header("host") || "rspace.online";
- const payUrl = `https://${host}/${space}/rcart/pay/${paymentId}`;
+ const payUrl = `${buildSpaceUrl(space, "/rcart")}/pay/${paymentId}`;
const chainNames: Record = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
const chainName = chainNames[p.chainId] || `Chain ${p.chainId}`;
const displayAmount = (!p.amount || p.amount === '0') && p.amountEditable ? 'any amount' : `${p.amount} ${p.token}`;
@@ -2031,8 +2031,8 @@ async function sendPaymentSuccessEmail(
? `${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}`
: (p.txHash || 'N/A');
const paidDate = p.paidAt ? new Date(p.paidAt).toUTCString() : new Date().toUTCString();
- const rflowsUrl = `https://${host}/${space}/rflows`;
- const dashboardUrl = `https://${host}/${space}/rcart`;
+ const rflowsUrl = `${buildSpaceUrl(space, "/rflows")}`;
+ const dashboardUrl = `${buildSpaceUrl(space, "/rcart")}`;
const html = `
@@ -2144,7 +2144,7 @@ async function sendPaymentReceivedEmail(
? `${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}`
: (p.txHash || 'N/A');
const paidDate = p.paidAt ? new Date(p.paidAt).toUTCString() : new Date().toUTCString();
- const dashboardUrl = `https://${host}/${space}/rcart`;
+ const dashboardUrl = `${buildSpaceUrl(space, "/rcart")}`;
const payerLabel = payerEmail || p.payerIdentity?.slice(0, 10) + '...' || 'Anonymous';
const html = `
@@ -2290,7 +2290,7 @@ routes.post("/api/group-buys", async (c) => {
_syncServer!.setDoc(docId, doc);
const host = c.req.header('host') || 'rspace.online';
- const shareUrl = `https://${host}/${space}/rcart/group-buy/${buyId}`;
+ const shareUrl = `${buildSpaceUrl(space, "/rcart")}/group-buy/${buyId}`;
return c.json({ id: buyId, shareUrl }, 201);
});
diff --git a/modules/rfiles/components/folk-file-browser.ts b/modules/rfiles/components/folk-file-browser.ts
index 4d6e7cf..e11daa2 100644
--- a/modules/rfiles/components/folk-file-browser.ts
+++ b/modules/rfiles/components/folk-file-browser.ts
@@ -6,6 +6,7 @@
*/
import { filesSchema, type FilesDoc } from "../schemas";
+import { makeDraggableAll } from "../../../shared/draggable";
import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { authFetch, requireAuth } from "../../../shared/auth-fetch";
@@ -505,6 +506,18 @@ class FolkFileBrowser extends HTMLElement {
});
});
+ // Make file cards draggable for calendar reminders
+ makeDraggableAll(this.shadow, ".file-card[data-collab-id]", (el) => {
+ const title = el.querySelector(".file-name")?.textContent || "";
+ const id = el.dataset.collabId?.replace("file:", "") || "";
+ return title ? { title, module: "rfiles", entityId: id, label: "File", color: "#10b981" } : null;
+ });
+ makeDraggableAll(this.shadow, ".memory-card[data-collab-id]", (el) => {
+ const title = el.querySelector(".card-title")?.textContent || "";
+ const id = el.dataset.collabId?.replace("card:", "") || "";
+ return title ? { title, module: "rfiles", entityId: id, label: "Card", color: "#10b981" } : null;
+ });
+
this._tour.renderOverlay();
}
diff --git a/modules/rinbox/mod.ts b/modules/rinbox/mod.ts
index 9765e61..5e0a9a9 100644
--- a/modules/rinbox/mod.ts
+++ b/modules/rinbox/mod.ts
@@ -1604,7 +1604,7 @@ routes.get("/about", (c) => {
`,
diff --git a/modules/rnetwork/mod.ts b/modules/rnetwork/mod.ts
index c17a71a..1a9669c 100644
--- a/modules/rnetwork/mod.ts
+++ b/modules/rnetwork/mod.ts
@@ -698,7 +698,7 @@ function renderCrm(space: string, activeTab: string) {
styles: ``,
tabs: [...CRM_TABS],
activeTab,
- tabBasePath: `/${space}/rnetwork/crm`,
+ tabBasePath: process.env.NODE_ENV === "production" ? `/rnetwork/crm` : `/${space}/rnetwork/crm`,
});
}
diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts
index 6c18c48..c79b4b7 100644
--- a/modules/rnotes/components/folk-notes-app.ts
+++ b/modules/rnotes/components/folk-notes-app.ts
@@ -10,6 +10,7 @@
*/
import * as Automerge from '@automerge/automerge';
+import { makeDraggableAll } from '../../../shared/draggable';
import { notebookSchema } from '../schemas';
import type { DocumentId } from '../../../shared/local-first/document';
import { getAccessToken } from '../../../shared/components/rstack-identity';
@@ -2193,6 +2194,13 @@ Gear: EUR 400 (10%)Maya is tracking expenses in rF
});
});
+ // Make notes draggable for calendar reminders
+ makeDraggableAll(this.shadow, "[data-note]", (el) => {
+ const title = el.querySelector(".note-item__title")?.textContent?.replace("📌 ", "") || "";
+ const id = el.dataset.note || "";
+ return title ? { title, module: "rnotes", entityId: id, label: "Note", color: "#f59e0b" } : null;
+ });
+
// Back buttons (for notebook view)
this.navZone.querySelectorAll("[data-back]").forEach((el) => {
el.addEventListener("click", (e) => {
diff --git a/modules/rphotos/components/folk-photo-gallery.ts b/modules/rphotos/components/folk-photo-gallery.ts
index c8a212f..877d8e8 100644
--- a/modules/rphotos/components/folk-photo-gallery.ts
+++ b/modules/rphotos/components/folk-photo-gallery.ts
@@ -8,6 +8,7 @@
* space — space slug (default: "demo")
*/
+import { makeDraggableAll } from "../../../shared/draggable";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
@@ -384,6 +385,20 @@ class FolkPhotoGallery extends HTMLElement {
`;
this.attachListeners();
+
+ // Make photo cells and album cards draggable for calendar reminders
+ makeDraggableAll(this.shadow, ".photo-cell[data-asset-id]", (el) => {
+ const img = el.querySelector("img");
+ const title = img?.alt || "Photo";
+ const id = el.dataset.assetId || "";
+ return { title, module: "rphotos", entityId: id, label: "Photo", color: "#ec4899" };
+ });
+ makeDraggableAll(this.shadow, ".album-card[data-album-id]", (el) => {
+ const title = el.querySelector(".album-name")?.textContent || "Album";
+ const id = el.dataset.albumId || "";
+ return { title, module: "rphotos", entityId: id, label: "Album", color: "#ec4899" };
+ });
+
this._tour.renderOverlay();
}
diff --git a/modules/rschedule/mod.ts b/modules/rschedule/mod.ts
index 00ab567..cc8ea37 100644
--- a/modules/rschedule/mod.ts
+++ b/modules/rschedule/mod.ts
@@ -520,7 +520,7 @@ async function executeCalendarReminder(
${itemRows}
- Sent by rSchedule • View Calendar
+ Sent by rSchedule • View Calendar
`;
@@ -1136,7 +1136,7 @@ async function executeReminderEmail(
${reminder.description ? `${reminder.description}
` : ""}
${sourceInfo}
- Sent by rSchedule • Manage Reminders
+ Sent by rSchedule • Manage Reminders
`;
diff --git a/modules/rsplat/components/folk-splat-viewer.ts b/modules/rsplat/components/folk-splat-viewer.ts
index e3af9eb..677d801 100644
--- a/modules/rsplat/components/folk-splat-viewer.ts
+++ b/modules/rsplat/components/folk-splat-viewer.ts
@@ -7,6 +7,7 @@
* Three.js and GaussianSplats3D are loaded via CDN importmap (not bundled).
*/
+import { makeDraggableAll } from "../../../shared/draggable";
import { splatScenesSchema, splatScenesDocId, type SplatScenesDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document";
@@ -271,6 +272,13 @@ export class FolkSplatViewer extends HTMLElement {
this.setupGenerateHandlers();
this.setupToggle();
this.setupDemoCardHandlers();
+
+ // Make splat cards draggable for calendar reminders
+ makeDraggableAll(this, ".splat-card[data-collab-id]", (el) => {
+ const title = el.querySelector(".splat-card__title")?.textContent || "";
+ const id = el.dataset.collabId?.replace("splat:", "") || "";
+ return title ? { title, module: "rsplat", entityId: id, label: "Splat", color: "#818cf8" } : null;
+ });
}
private setupToggle() {
@@ -531,11 +539,13 @@ export class FolkSplatViewer extends HTMLElement {
try {
const imageUrl = await this.stageImage(selectedFile!);
+ const title = selectedFile.name.replace(/\.[^.]+$/, "");
+ status.textContent = "Submitting for 3D generation...";
const res = await fetch("/api/3d-gen", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ image_url: imageUrl }),
+ body: JSON.stringify({ image_url: imageUrl, title }),
});
clearInterval(ticker);
document.removeEventListener("visibilitychange", onVisChange);
@@ -565,37 +575,102 @@ export class FolkSplatViewer extends HTMLElement {
return;
}
- const data = await res.json() as { url: string; format: string };
- // Jump to 100% before hiding
- if (progressBar) progressBar.style.setProperty("--splat-progress", "100%");
- if (progressText) progressText.textContent = "Complete!";
- await new Promise(r => setTimeout(r, 400));
- progress.style.display = "none";
- const elapsed = Math.floor((Date.now() - startTime - hiddenTime) / 1000);
- status.textContent = `Generated in ${elapsed}s`;
+ const { job_id } = await res.json() as { job_id: string };
+ status.textContent = "Generating 3D model — you'll get an email when it's ready...";
- // Store generated info for save-to-gallery
- this._generatedUrl = data.url;
- this._generatedTitle = selectedFile.name.replace(/\.[^.]+$/, "");
+ // Poll for completion
+ const pollInterval = setInterval(async () => {
+ try {
+ const pollRes = await fetch(`/api/3d-gen/${job_id}`);
+ if (!pollRes.ok) return;
+ const job = await pollRes.json() as any;
- // Auto-save if authenticated
- await this.autoSave();
+ if (job.status === "complete") {
+ clearInterval(pollInterval);
+ progress.style.display = "none";
- // Open inline viewer with generated model
- this._mode = "viewer";
- this._splatUrl = data.url;
- this._splatTitle = this._generatedTitle;
- this._splatDesc = "AI-generated 3D model";
- this._inlineViewer = true;
- this.renderViewer();
- } catch (e: any) {
- clearInterval(ticker);
- document.removeEventListener("visibilitychange", onVisChange);
- if (e.name === "AbortError") {
- status.textContent = "Request timed out — try a simpler image.";
- } else {
- status.textContent = e.message || "Network error — could not reach server";
- }
+ // Show result with save option
+ const resultDiv = document.createElement("div");
+ resultDiv.className = "splat-generate__result";
+ resultDiv.innerHTML = `
+ 3D model generated successfully!
+
+
+
+
Download
+
+ ${job.email_sent ? 'Email notification sent!
' : ''}
+ `;
+ status.textContent = "";
+ status.after(resultDiv);
+
+ // View button
+ resultDiv.querySelector("#gen-view-btn")?.addEventListener("click", () => {
+ this._mode = "viewer";
+ this._splatUrl = job.url;
+ this._splatTitle = title;
+ this._splatDesc = "AI-generated 3D model";
+ this._inlineViewer = true;
+ this.renderViewer();
+ });
+
+ // Save to gallery button
+ resultDiv.querySelector("#gen-save-btn")?.addEventListener("click", async () => {
+ const saveBtn = resultDiv.querySelector("#gen-save-btn") as HTMLButtonElement;
+ saveBtn.disabled = true;
+ saveBtn.textContent = "Saving...";
+ try {
+ // Download the generated file and re-upload as a splat
+ const fileRes = await fetch(job.url);
+ const blob = await fileRes.blob();
+ const ext = job.format || "glb";
+ const file = new File([blob], `${title}.${ext}`, { type: "application/octet-stream" });
+
+ const formData = new FormData();
+ formData.append("file", file);
+ formData.append("title", title);
+ formData.append("description", "AI-generated 3D model");
+ formData.append("tags", "ai-generated");
+
+ const uploadRes = await fetch(`/${this._spaceSlug}/rsplat/api/splats`, {
+ method: "POST",
+ body: formData,
+ });
+
+ if (uploadRes.ok) {
+ saveBtn.textContent = "Saved!";
+ saveBtn.style.background = "#16a34a";
+ setTimeout(() => {
+ window.location.href = `/${this._spaceSlug}/rsplat`;
+ }, 1000);
+ } else {
+ const err = await uploadRes.json().catch(() => ({ error: "Save failed" }));
+ saveBtn.textContent = (err as any).error || "Save failed";
+ saveBtn.disabled = false;
+ }
+ } catch {
+ saveBtn.textContent = "Save failed";
+ saveBtn.disabled = false;
+ }
+ });
+
+ } else if (job.status === "failed") {
+ clearInterval(pollInterval);
+ progress.style.display = "none";
+ status.textContent = job.error || "Generation failed";
+ actions.style.display = "flex";
+ submitBtn.disabled = false;
+
+ } else if (job.status === "processing") {
+ status.textContent = "Generating 3D model — this may take a few minutes...";
+ }
+ } catch {
+ // Poll error — keep trying
+ }
+ }, 3000);
+
+ } catch (e) {
+ status.textContent = "Network error — could not reach server";
progress.style.display = "none";
actions.style.display = "flex";
submitBtn.disabled = false;
@@ -853,25 +928,49 @@ export class FolkSplatViewer extends HTMLElement {
private async initSplatViewer(container: HTMLElement, loading: HTMLElement | null) {
const GaussianSplats3D = await import("@mkkellogg/gaussian-splats-3d");
+ const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ||
+ (navigator.maxTouchPoints > 1 && window.innerWidth < 1024);
+
const viewer = new GaussianSplats3D.Viewer({
cameraUp: [0, 1, 0],
initialCameraPosition: [5, 3, 5],
initialCameraLookAt: [0, 0, 0],
rootElement: container,
sharedMemoryForWorkers: false,
+ halfPrecisionCovariancesOnGPU: isMobile,
+ dynamicScene: false,
+ ...(isMobile ? { splatSortDistanceMapPrecision: 16 } : {}),
});
this._viewer = viewer;
+ const LOAD_TIMEOUT = isMobile ? 30_000 : 60_000;
+ let loaded = false;
+
+ const timeoutId = setTimeout(() => {
+ if (loaded) return;
+ console.warn("[rSplat] Load timed out after", LOAD_TIMEOUT, "ms");
+ if (loading) {
+ const text = loading.querySelector(".splat-loading__text");
+ if (text) text.textContent = "Loading is taking longer than expected. The file may be too large for this device.";
+ const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement;
+ if (spinner) spinner.style.display = "none";
+ }
+ }, LOAD_TIMEOUT);
+
viewer.addSplatScene(this._splatUrl!, {
showLoadingUI: false,
progressiveLoad: true,
})
.then(() => {
+ loaded = true;
+ clearTimeout(timeoutId);
viewer.start();
if (loading) loading.classList.add("hidden");
})
.catch((e: Error) => {
+ loaded = true;
+ clearTimeout(timeoutId);
console.error("[rSplat] Scene load error:", e);
if (loading) {
const text = loading.querySelector(".splat-loading__text");
diff --git a/modules/rsplat/mod.ts b/modules/rsplat/mod.ts
index 0272215..83cb508 100644
--- a/modules/rsplat/mod.ts
+++ b/modules/rsplat/mod.ts
@@ -186,7 +186,7 @@ const IMPORTMAP = ``;
@@ -738,7 +738,7 @@ function renderViewerPage(spaceSlug: string, dataSpace: string, idOrSlug: string
title: "Splat not found | rSpace",
moduleId: "rsplat",
spaceSlug,
- body: ``,
+ body: ``,
modules: getModuleInfoList(),
theme: "dark",
});
diff --git a/modules/rtasks/components/folk-tasks-board.ts b/modules/rtasks/components/folk-tasks-board.ts
index 563e782..eb89cbe 100644
--- a/modules/rtasks/components/folk-tasks-board.ts
+++ b/modules/rtasks/components/folk-tasks-board.ts
@@ -6,6 +6,7 @@
*/
import { boardSchema, type BoardDoc } from "../schemas";
+import { makeDraggableAll } from "../../../shared/draggable";
import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js";
@@ -732,6 +733,22 @@ class FolkTasksBoard extends HTMLElement {
this.dragOverIndex = -1;
});
});
+
+ // Make task cards draggable for calendar reminders (cross-module)
+ this.shadow.querySelectorAll(".task-card[data-task-id]").forEach((el) => {
+ el.addEventListener("dragstart", (e) => {
+ const de = e as DragEvent;
+ if (!de.dataTransfer) return;
+ const title = el.querySelector(".task-title")?.textContent || "";
+ const id = el.dataset.taskId || "";
+ if (!title) return;
+ de.dataTransfer.setData("application/rspace-item", JSON.stringify({
+ title, module: "rtasks", entityId: id, label: "Task", color: "#3b82f6",
+ }));
+ de.dataTransfer.setData("text/plain", title);
+ de.dataTransfer.effectAllowed = "copyMove";
+ });
+ });
}
private esc(s: string): string {
diff --git a/server/index.ts b/server/index.ts
index 4dc199e..032ea27 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -128,6 +128,16 @@ const DIST_DIR = resolve(import.meta.dir, "../dist");
// ── Hono app ──
const app = new Hono();
+// Detect subdomain routing and set context flag
+app.use("*", async (c, next) => {
+ const host = c.req.header("host")?.split(":")[0] || "";
+ const parts = host.split(".");
+ const isSubdomain = parts.length >= 3 && parts.slice(-2).join(".") === "rspace.online"
+ && !["www", "rspace", "create", "new", "start", "auth"].includes(parts[0]);
+ c.set("isSubdomain", isSubdomain);
+ await next();
+});
+
// CORS for API routes
app.use("/api/*", cors());
@@ -637,6 +647,148 @@ app.post("/api/x402-test", async (c) => {
const FAL_KEY = process.env.FAL_KEY || "";
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || "";
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
+const SPLAT_NOTIFY_EMAIL = process.env.SPLAT_NOTIFY_EMAIL || "";
+const SITE_URL = process.env.SITE_URL || "https://rspace.online";
+
+// ── 3D Gen job queue ──
+
+import { createTransport } from "nodemailer";
+
+interface Gen3DJob {
+ id: string;
+ status: "pending" | "processing" | "complete" | "failed";
+ imageUrl: string;
+ resultUrl?: string;
+ resultFormat?: string;
+ error?: string;
+ createdAt: number;
+ completedAt?: number;
+ emailSent?: boolean;
+ title?: string;
+}
+
+const gen3dJobs = new Map();
+
+// Clean up old jobs every 30 minutes (keep for 24h)
+setInterval(() => {
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
+ for (const [id, job] of gen3dJobs) {
+ if (job.createdAt < cutoff) gen3dJobs.delete(id);
+ }
+}, 30 * 60 * 1000);
+
+let splatMailTransport: ReturnType | null = null;
+if (process.env.SMTP_PASS) {
+ splatMailTransport = createTransport({
+ host: process.env.SMTP_HOST || "mail.rmail.online",
+ port: Number(process.env.SMTP_PORT) || 587,
+ secure: Number(process.env.SMTP_PORT) === 465,
+ tls: { rejectUnauthorized: false },
+ auth: {
+ user: "noreply@rspace.online",
+ pass: process.env.SMTP_PASS,
+ },
+ });
+}
+
+async function sendSplatEmail(job: Gen3DJob) {
+ if (!splatMailTransport || !SPLAT_NOTIFY_EMAIL) return;
+ const downloadUrl = `${SITE_URL}${job.resultUrl}`;
+ const title = job.title || "3D Model";
+ try {
+ await splatMailTransport.sendMail({
+ from: process.env.SMTP_FROM || "rSplat ",
+ to: SPLAT_NOTIFY_EMAIL,
+ subject: `Your 3D splat "${title}" is ready — rSplat`,
+ html: `
+
+
Your 3D model is ready!
+
Your AI-generated 3D model "${title}" has finished processing.
+
+
+ View & Download
+
+
+
Or save it to your gallery at ${SITE_URL}/rsplat
+
Generated by rSplat on rSpace
+
+ `,
+ });
+ job.emailSent = true;
+ console.log(`[3d-gen] Email sent to ${SPLAT_NOTIFY_EMAIL} for job ${job.id}`);
+ } catch (e: any) {
+ console.error(`[3d-gen] Failed to send email:`, e.message);
+ }
+}
+
+async function process3DGenJob(job: Gen3DJob) {
+ job.status = "processing";
+ try {
+ const res = await fetch("https://fal.run/fal-ai/trellis", {
+ method: "POST",
+ headers: {
+ Authorization: `Key ${FAL_KEY}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ image_url: job.imageUrl }),
+ });
+
+ if (!res.ok) {
+ const errText = await res.text();
+ console.error("[3d-gen] fal.ai error:", res.status, errText);
+ let detail = "3D generation failed";
+ try {
+ const parsed = JSON.parse(errText);
+ if (parsed.detail) {
+ detail = typeof parsed.detail === "string" ? parsed.detail
+ : Array.isArray(parsed.detail) ? parsed.detail[0]?.msg || detail
+ : detail;
+ }
+ } catch {}
+ job.status = "failed";
+ job.error = detail;
+ job.completedAt = Date.now();
+ return;
+ }
+
+ const data = await res.json();
+ const modelUrl = data.glb_url || data.model_mesh?.url || data.output?.url;
+ if (!modelUrl) {
+ job.status = "failed";
+ job.error = "No 3D model returned";
+ job.completedAt = Date.now();
+ return;
+ }
+
+ const modelRes = await fetch(modelUrl);
+ if (!modelRes.ok) {
+ job.status = "failed";
+ job.error = "Failed to download model";
+ job.completedAt = Date.now();
+ return;
+ }
+
+ const modelBuf = await modelRes.arrayBuffer();
+ const ext = modelUrl.includes(".ply") ? "ply" : "glb";
+ const filename = `splat-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.${ext}`;
+ const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
+ await Bun.write(resolve(dir, filename), modelBuf);
+
+ job.status = "complete";
+ job.resultUrl = `/data/files/generated/${filename}`;
+ job.resultFormat = ext;
+ job.completedAt = Date.now();
+ console.log(`[3d-gen] Job ${job.id} complete: ${job.resultUrl}`);
+
+ // Send email notification
+ sendSplatEmail(job).catch(() => {});
+ } catch (e: any) {
+ console.error("[3d-gen] error:", e.message);
+ job.status = "failed";
+ job.error = "3D generation failed";
+ job.completedAt = Date.now();
+ }
+}
// ── Image helpers ──
@@ -1019,75 +1171,52 @@ app.post("/api/image-stage", async (c) => {
return c.json({ url: `${PUBLIC_ORIGIN}/data/files/generated/${filename}` });
});
-// Image-to-3D via fal.ai Hunyuan3D v2.1 (queue API for long-running jobs)
+// Image-to-3D via fal.ai Trellis (async job queue)
app.post("/api/3d-gen", async (c) => {
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
- const { image_url } = await c.req.json();
+ const { image_url, title, email } = await c.req.json();
if (!image_url) return c.json({ error: "image_url required" }, 400);
- const falHeaders = { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json" };
- const MODEL = "fal-ai/hunyuan3d-v21";
+ const jobId = crypto.randomUUID();
+ const job: Gen3DJob = {
+ id: jobId,
+ status: "pending",
+ imageUrl: image_url,
+ createdAt: Date.now(),
+ title: title || "Untitled",
+ };
+ gen3dJobs.set(jobId, job);
- try {
- // 1. Submit to queue
- const submitRes = await fetch(`https://queue.fal.run/${MODEL}`, {
- method: "POST",
- headers: falHeaders,
- body: JSON.stringify({ input_image_url: image_url, textured_mesh: true, octree_resolution: 256 }),
- });
- if (!submitRes.ok) {
- const errText = await submitRes.text();
- console.error("[3d-gen] fal.ai submit error:", submitRes.status, errText);
- return c.json({ error: "3D generation failed to start" }, 502);
- }
- const { request_id } = await submitRes.json() as { request_id: string };
+ // Process in background — no await, returns immediately
+ process3DGenJob(job);
- // 2. Poll for completion (up to 5 min)
- const deadline = Date.now() + 300_000;
- while (Date.now() < deadline) {
- await new Promise((r) => setTimeout(r, 3000));
- const statusRes = await fetch(
- `https://queue.fal.run/${MODEL}/requests/${request_id}/status`,
- { headers: falHeaders },
- );
- if (!statusRes.ok) continue;
- const status = await statusRes.json() as { status: string };
- if (status.status === "COMPLETED") break;
- if (status.status === "FAILED") {
- console.error("[3d-gen] fal.ai job failed:", JSON.stringify(status));
- return c.json({ error: "3D generation failed" }, 502);
- }
- }
+ return c.json({ job_id: jobId, status: "pending" });
+});
- // 3. Fetch result
- const resultRes = await fetch(
- `https://queue.fal.run/${MODEL}/requests/${request_id}`,
- { headers: falHeaders },
- );
- if (!resultRes.ok) {
- console.error("[3d-gen] fal.ai result error:", resultRes.status);
- return c.json({ error: "Failed to retrieve 3D model" }, 502);
- }
+// Poll job status
+app.get("/api/3d-gen/:jobId", async (c) => {
+ const jobId = c.req.param("jobId");
+ const job = gen3dJobs.get(jobId);
+ if (!job) return c.json({ error: "Job not found" }, 404);
- const data = await resultRes.json();
- const modelUrl = data.model_glb?.url || data.model_glb_pbr?.url;
- if (!modelUrl) return c.json({ error: "No 3D model returned" }, 502);
+ const response: Record = {
+ job_id: job.id,
+ status: job.status,
+ created_at: job.createdAt,
+ };
- // 4. Download and save
- const modelRes = await fetch(modelUrl);
- if (!modelRes.ok) return c.json({ error: "Failed to download model" }, 502);
-
- const modelBuf = await modelRes.arrayBuffer();
- const filename = `splat-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.glb`;
- const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
- await Bun.write(resolve(dir, filename), modelBuf);
-
- return c.json({ url: `/data/files/generated/${filename}`, format: "glb" });
- } catch (e: any) {
- console.error("[3d-gen] error:", e.message);
- return c.json({ error: "3D generation failed" }, 502);
+ if (job.status === "complete") {
+ response.url = job.resultUrl;
+ response.format = job.resultFormat;
+ response.completed_at = job.completedAt;
+ response.email_sent = job.emailSent || false;
+ } else if (job.status === "failed") {
+ response.error = job.error;
+ response.completed_at = job.completedAt;
}
+
+ return c.json(response);
});
// Blender 3D generation via LLM + RunPod
diff --git a/server/shell.ts b/server/shell.ts
index f92358e..fcc71ef 100644
--- a/server/shell.ts
+++ b/server/shell.ts
@@ -69,6 +69,24 @@ export interface ShellOptions {
activeTab?: string;
/** Base path for tab links (default: /{space}/{moduleId}). Set to e.g. "/{space}/rnetwork/crm" for sub-pages. */
tabBasePath?: string;
+ /** Whether this page is being served via subdomain routing (omit space from paths) */
+ isSubdomain?: boolean;
+}
+
+// In production, always use subdomain-style paths (/{moduleId}) unless explicitly overridden
+const IS_PRODUCTION = process.env.NODE_ENV === "production";
+
+/**
+ * Build a full external URL for a space + path, using subdomain routing in production.
+ * E.g. buildSpaceUrl("demo", "/rcart/pay/123", host) → "https://demo.rspace.online/rcart/pay/123"
+ */
+export function buildSpaceUrl(space: string, path: string, host?: string): string {
+ if (IS_PRODUCTION) {
+ return `https://${space}.rspace.online${path}`;
+ }
+ // Dev/localhost
+ const h = host || "localhost:3000";
+ return `http://${h}/${space}${path}`;
}
export function renderShell(opts: ShellOptions): string {
@@ -192,8 +210,8 @@ export function renderShell(opts: ShellOptions): string {
- ${renderModuleSubNav(moduleId, spaceSlug, visibleModules)}
- ${opts.tabs ? renderTabBar(opts.tabs, opts.activeTab, opts.tabBasePath || `/${escapeAttr(spaceSlug)}/${escapeAttr(moduleId)}`) : ''}
+ ${renderModuleSubNav(moduleId, spaceSlug, visibleModules, opts.isSubdomain ?? IS_PRODUCTION)}
+ ${opts.tabs ? renderTabBar(opts.tabs, opts.activeTab, opts.tabBasePath || ((opts.isSubdomain ?? IS_PRODUCTION) ? `/${escapeAttr(moduleId)}` : `/${escapeAttr(spaceSlug)}/${escapeAttr(moduleId)}`)) : ''}
${body}
@@ -1381,7 +1399,7 @@ const SUBNAV_CSS = `
`;
/** Build the module sub-nav bar from outputPaths + subPageInfos. */
-function renderModuleSubNav(moduleId: string, spaceSlug: string, modules: ModuleInfo[]): string {
+function renderModuleSubNav(moduleId: string, spaceSlug: string, modules: ModuleInfo[], isSubdomain?: boolean): string {
if (moduleId === 'rspace') return ''; // canvas page has its own chrome
const mod = modules.find(m => m.id === moduleId);
if (!mod) return '';
@@ -1406,7 +1424,7 @@ function renderModuleSubNav(moduleId: string, spaceSlug: string, modules: Module
// Don't render if no sub-paths
if (items.length === 0) return '';
- const base = `/${escapeAttr(spaceSlug)}/${escapeAttr(moduleId)}`;
+ const base = (isSubdomain ?? IS_PRODUCTION) ? `/${escapeAttr(moduleId)}` : `/${escapeAttr(spaceSlug)}/${escapeAttr(moduleId)}`;
const pills = [
`${escapeHtml(mod.name)}`,
diff --git a/shared/draggable.ts b/shared/draggable.ts
new file mode 100644
index 0000000..a9295f2
--- /dev/null
+++ b/shared/draggable.ts
@@ -0,0 +1,64 @@
+/**
+ * Shared drag-and-drop utilities for cross-module item dragging.
+ *
+ * Any module card/list-item can become draggable by calling
+ * `makeDraggable(el, payload)` — the calendar and reminders widget
+ * will accept the drop via `application/rspace-item`.
+ */
+
+export interface RSpaceItemPayload {
+ title: string;
+ module: string; // e.g. "rnotes", "rtasks", "rfiles"
+ entityId: string;
+ label?: string; // human-readable source, e.g. "Note", "Task"
+ color?: string; // module accent color
+}
+
+/** Module accent colors for drag ghost + calendar indicators */
+export const MODULE_COLORS: Record = {
+ rnotes: "#f59e0b", // amber
+ rtasks: "#3b82f6", // blue
+ rfiles: "#10b981", // emerald
+ rsplat: "#818cf8", // indigo
+ rphotos: "#ec4899", // pink
+ rbooks: "#f97316", // orange
+ rforum: "#8b5cf6", // violet
+ rinbox: "#06b6d4", // cyan
+ rvote: "#ef4444", // red
+ rtube: "#a855f7", // purple
+};
+
+/**
+ * Make an element draggable with the rspace-item protocol.
+ * Adds draggable attribute and dragstart handler.
+ */
+export function makeDraggable(el: HTMLElement, payload: RSpaceItemPayload) {
+ el.draggable = true;
+ el.style.cursor = "grab";
+ el.addEventListener("dragstart", (e) => {
+ if (!e.dataTransfer) return;
+ e.dataTransfer.setData("application/rspace-item", JSON.stringify(payload));
+ e.dataTransfer.setData("text/plain", payload.title);
+ e.dataTransfer.effectAllowed = "copyMove";
+ el.style.opacity = "0.6";
+ });
+ el.addEventListener("dragend", () => {
+ el.style.opacity = "";
+ });
+}
+
+/**
+ * Attach drag handlers to all matching elements within a root.
+ * `selector` matches the card elements.
+ * `payloadFn` extracts the payload from each matched element.
+ */
+export function makeDraggableAll(
+ root: Element | ShadowRoot,
+ selector: string,
+ payloadFn: (el: HTMLElement) => RSpaceItemPayload | null,
+) {
+ root.querySelectorAll(selector).forEach((el) => {
+ const payload = payloadFn(el);
+ if (payload) makeDraggable(el, payload);
+ });
+}
diff --git a/website/canvas.html b/website/canvas.html
index 648b611..8bf9544 100644
--- a/website/canvas.html
+++ b/website/canvas.html
@@ -2494,6 +2494,81 @@
}
.triage-drop-icon { font-size: 36px; }
+
+
+
+