feat: async 3D gen, calendar reminder widget, cross-module drag, subdomain URL fixes

- Make /api/3d-gen async with job queue + email notification on completion
- Add reminder mini-calendar widget to canvas (top-right on shape select)
- Make items draggable across 6 modules (rNotes, rTasks, rFiles, rSplat, rPhotos, rBooks)
- Upgrade rCal drop handler with time-picker popover instead of confirm()
- Show reminder indicators (dots + badges) on calendar days
- Fix subdomain routing: remove space slug from server-rendered sub-nav,
  tab bar, and module links in production (/{moduleId} not /{space}/{moduleId})
- Add buildSpaceUrl() helper for correct external URL generation
- Fix rcart payment URLs for subdomain routing
- Fix rSchedule email links to use subdomain format

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-16 22:16:03 +00:00
parent 3e4c070fea
commit 84c3c318d8
17 changed files with 924 additions and 136 deletions

View File

@ -6,6 +6,7 @@
*/ */
import { booksCatalogSchema, booksCatalogDocId, type BooksCatalogDoc } from "../schemas"; import { booksCatalogSchema, booksCatalogDocId, type BooksCatalogDoc } from "../schemas";
import { makeDraggableAll } from "../../../shared/draggable";
import type { DocumentId } from "../../../shared/local-first/document"; import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
@ -516,6 +517,14 @@ export class FolkBookShelf extends HTMLElement {
`; `;
this.bindEvents(); 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(); this._tour.renderOverlay();
} }

View File

@ -334,7 +334,7 @@ routes.get("/read/:id", async (c) => {
title: "Book not found | rSpace", title: "Book not found | rSpace",
moduleId: "rbooks", moduleId: "rbooks",
spaceSlug, spaceSlug,
body: `<div style="padding:3rem;text-align:center;color:#94a3b8;"><h2>Book not found</h2><p><a href="/${spaceSlug}/rbooks" style="color:#60a5fa;">Back to library</a></p></div>`, body: `<div style="padding:3rem;text-align:center;color:#94a3b8;"><h2>Book not found</h2><p><a href="/rbooks" style="color:#60a5fa;">Back to library</a></p></div>`,
modules: getModuleInfoList(), modules: getModuleInfoList(),
}); });
return c.html(html, 404); return c.html(html, 404);

View File

@ -116,6 +116,7 @@ class FolkCalendarView extends HTMLElement {
private events: any[] = []; private events: any[] = [];
private sources: any[] = []; private sources: any[] = [];
private lunarData: Record<string, { phase: string; illumination: number }> = {}; private lunarData: Record<string, { phase: string; illumination: number }> = {};
private reminders: any[] = [];
private showLunar = true; private showLunar = true;
private selectedDate = ""; private selectedDate = "";
private selectedEvent: any = null; private selectedEvent: any = null;
@ -668,15 +669,18 @@ class FolkCalendarView extends HTMLElement {
const lastDay = new Date(year, month + 1, 0).getDate(); const lastDay = new Date(year, month + 1, 0).getDate();
const end = `${year}-${String(month + 1).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`; const end = `${year}-${String(month + 1).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`;
const base = this.getApiBase(); const base = this.getApiBase();
const schedBase = this.getScheduleApiBase();
try { 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/events?start=${start}&end=${end}`),
fetch(`${base}/api/sources`), fetch(`${base}/api/sources`),
fetch(`${base}/api/lunar?start=${start}&end=${end}`), 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 (eventsRes.ok) { const data = await eventsRes.json(); this.events = data.results || []; }
if (sourcesRes.ok) { const data = await sourcesRes.json(); this.sources = data.results || []; } if (sourcesRes.ok) { const data = await sourcesRes.json(); this.sources = data.results || []; }
if (lunarRes.ok) { this.lunarData = await lunarRes.json(); } if (lunarRes.ok) { this.lunarData = await lunarRes.json(); }
if (remindersRes?.ok) { const data = await remindersRes.json(); this.reminders = data.reminders || []; }
} catch { /* offline fallback */ } } catch { /* offline fallback */ }
this.render(); this.render();
} }
@ -730,6 +734,15 @@ class FolkCalendarView extends HTMLElement {
&& !this.filteredSources.has(e.source_name)); && !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. */ /** Returns visual style overrides for tentative (likelihood < 100) events. */
private getEventStyles(ev: any): { bgColor: string; borderStyle: string; opacity: number; isTentative: boolean; likelihoodLabel: string } { private getEventStyles(ev: any): { bgColor: string; borderStyle: string; opacity: number; isTentative: boolean; likelihoodLabel: string } {
const baseColor = ev.source_color || "#6366f1"; const baseColor = ev.source_color || "#6366f1";
@ -1116,14 +1129,16 @@ class FolkCalendarView extends HTMLElement {
const isToday = ds === todayStr; const isToday = ds === todayStr;
const isExpanded = ds === this.expandedDay; const isExpanded = ds === this.expandedDay;
const dayEvents = this.getEventsForDate(ds); const dayEvents = this.getEventsForDate(ds);
const dayReminders = this.getRemindersForDate(ds);
const lunar = this.lunarData[ds]; const lunar = this.lunarData[ds];
html += `<div class="day ${isToday ? "today" : ""} ${isExpanded ? "expanded" : ""}" data-date="${ds}" data-drop-date="${ds}"> html += `<div class="day ${isToday ? "today" : ""} ${isExpanded ? "expanded" : ""}" data-date="${ds}" data-drop-date="${ds}">
<div class="day-num"> <div class="day-num">
<span>${d}</span> <span>${d}</span>
${this.showLunar && lunar ? `<span class="moon">${this.getMoonEmoji(lunar.phase)}</span>` : ""} ${this.showLunar && lunar ? `<span class="moon">${this.getMoonEmoji(lunar.phase)}</span>` : ""}
${dayReminders.length > 0 ? `<span class="reminder-badge" title="${dayReminders.length} reminder${dayReminders.length > 1 ? "s" : ""}">🔔${dayReminders.length > 1 ? dayReminders.length : ""}</span>` : ""}
</div> </div>
${dayEvents.length > 0 ? ` ${dayEvents.length > 0 || dayReminders.length > 0 ? `
<div class="dots"> <div class="dots">
${dayEvents.slice(0, 5).map(e => { ${dayEvents.slice(0, 5).map(e => {
const es = this.getEventStyles(e); const es = this.getEventStyles(e);
@ -1131,6 +1146,7 @@ class FolkCalendarView extends HTMLElement {
? `<span class="dot dot--tentative" style="border-color:${e.source_color || "#6366f1"}"></span>` ? `<span class="dot dot--tentative" style="border-color:${e.source_color || "#6366f1"}"></span>`
: `<span class="dot" style="background:${e.source_color || "#6366f1"}"></span>`; : `<span class="dot" style="background:${e.source_color || "#6366f1"}"></span>`;
}).join("")} }).join("")}
${dayReminders.slice(0, 3).map(r => `<span class="dot dot--reminder" style="background:${r.sourceColor || "#f59e0b"}" title="${this.esc(r.title)}"></span>`).join("")}
${dayEvents.length > 5 ? `<span style="font-size:8px;color:var(--rs-text-muted)">+${dayEvents.length - 5}</span>` : ""} ${dayEvents.length > 5 ? `<span style="font-size:8px;color:var(--rs-text-muted)">+${dayEvents.length - 5}</span>` : ""}
</div> </div>
${dayEvents.slice(0, 2).map(e => { ${dayEvents.slice(0, 2).map(e => {
@ -1737,33 +1753,105 @@ class FolkCalendarView extends HTMLElement {
if (!title.trim()) return; if (!title.trim()) return;
// Prompt for quick confirmation // Show time-picker popover instead of confirm()
const confirmed = confirm(`Create reminder "${title}" on ${dropDate}?`); this.showReminderPopover(e, dropDate, {
if (!confirmed) return; title: title.trim(), sourceModule, sourceEntityId, sourceLabel, sourceColor,
});
}
const remindAt = new Date(dropDate + "T09:00:00").getTime(); private showReminderPopover(
const base = this.getScheduleApiBase(); 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 { const accentColor = item.sourceColor || "#818cf8";
await fetch(`${base}/api/reminders`, { const labelText = item.sourceLabel || "Item";
method: "POST", const friendlyDate = new Date(dropDate + "T12:00:00").toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ const popover = document.createElement("div");
title: title.trim(), popover.className = "reminder-popover";
remindAt, popover.innerHTML = `
allDay: true, <div class="reminder-popover__header">
syncToCalendar: true, <span class="reminder-popover__badge" style="background:${accentColor}">${labelText}</span>
sourceModule, <span class="reminder-popover__title">${item.title}</span>
sourceEntityId, </div>
sourceLabel, <div class="reminder-popover__date">${friendlyDate}</div>
sourceColor, <div class="reminder-popover__times">
}), <button class="reminder-popover__time" data-hour="9">🌅 9:00 AM</button>
}); <button class="reminder-popover__time" data-hour="12"> 12:00 PM</button>
// Reload events to show the new calendar entry <button class="reminder-popover__time" data-hour="17">🌇 5:00 PM</button>
if (this.space === "demo") { this.loadDemoData(); } else { this.loadMonth(); } <button class="reminder-popover__time" data-hour="21">🌙 9:00 PM</button>
} catch (err) { </div>
console.error("[rCal] Failed to create reminder:", err); <div class="reminder-popover__custom">
<input type="time" class="reminder-popover__time-input" value="09:00">
<button class="reminder-popover__time" data-custom="true">Set Custom</button>
</div>
<button class="reminder-popover__cancel">Cancel</button>
`;
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<HTMLButtonElement>(".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(); } startTour() { this._tour.start(); }
@ -2422,6 +2510,62 @@ class FolkCalendarView extends HTMLElement {
/* ── Drop Target ── */ /* ── Drop Target ── */
.day.drop-target { background: rgba(245,158,11,0.15); border: 2px dashed var(--rs-warning); } .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 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; } .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; } .dd-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }

View File

@ -10,7 +10,7 @@
import * as Automerge from "@automerge/automerge"; import * as Automerge from "@automerge/automerge";
import { Hono } from "hono"; import { Hono } from "hono";
import { renderShell } from "../../server/shell"; import { renderShell, buildSpaceUrl } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module"; import { getModuleInfoList } from "../../shared/module";
import { depositOrderRevenue } from "./flow"; import { depositOrderRevenue } from "./flow";
import type { RSpaceModule } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module";
@ -1254,10 +1254,10 @@ routes.post("/api/shopping-carts/:cartId/contribute-pay", async (c) => {
}); });
_syncServer!.setDoc(payDocId, payDoc); _syncServer!.setDoc(payDocId, payDoc);
const host = c.req.header("host") || "rspace.online"; const payUrl = `/rcart/pay/${paymentId}`;
const payUrl = `/${space}/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 ── // ── Extension shortcut routes ──
@ -1489,7 +1489,7 @@ routes.post("/api/payments", async (c) => {
_syncServer!.setDoc(docId, payDoc); _syncServer!.setDoc(docId, payDoc);
const host = c.req.header("host") || "rspace.online"; 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({ return c.json({
id: paymentId, id: paymentId,
@ -1500,7 +1500,7 @@ routes.post("/api/payments", async (c) => {
recipientAddress, recipientAddress,
status: 'pending', status: 'pending',
payUrl, payUrl,
qrUrl: `https://${host}/${space}/rcart/api/payments/${paymentId}/qr`, qrUrl: `${buildSpaceUrl(space, "/rcart")}/api/payments/${paymentId}/qr`,
created_at: new Date(now).toISOString(), created_at: new Date(now).toISOString(),
}, 201); }, 201);
}); });
@ -1733,7 +1733,7 @@ routes.get("/api/payments/:id/qr", async (c) => {
if (!doc) return c.json({ error: "Payment request not found" }, 404); if (!doc) return c.json({ error: "Payment request not found" }, 404);
const host = c.req.header("host") || "rspace.online"; 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 }); 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' }); 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); if (!transport) return c.json({ error: "Email not configured" }, 503);
const host = c.req.header("host") || "rspace.online"; 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<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' }; const chainNames: Record<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
const chainName = chainNames[p.chainId] || `Chain ${p.chainId}`; const chainName = chainNames[p.chainId] || `Chain ${p.chainId}`;
const displayAmount = (!p.amount || p.amount === '0') && p.amountEditable ? 'any amount' : `${p.amount} ${p.token}`; const displayAmount = (!p.amount || p.amount === '0') && p.amountEditable ? 'any amount' : `${p.amount} ${p.token}`;
@ -2031,8 +2031,8 @@ async function sendPaymentSuccessEmail(
? `<a href="${explorer}${p.txHash}" style="color:#67e8f9;text-decoration:none">${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}</a>` ? `<a href="${explorer}${p.txHash}" style="color:#67e8f9;text-decoration:none">${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}</a>`
: (p.txHash || 'N/A'); : (p.txHash || 'N/A');
const paidDate = p.paidAt ? new Date(p.paidAt).toUTCString() : new Date().toUTCString(); const paidDate = p.paidAt ? new Date(p.paidAt).toUTCString() : new Date().toUTCString();
const rflowsUrl = `https://${host}/${space}/rflows`; const rflowsUrl = `${buildSpaceUrl(space, "/rflows")}`;
const dashboardUrl = `https://${host}/${space}/rcart`; const dashboardUrl = `${buildSpaceUrl(space, "/rcart")}`;
const html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head> <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
@ -2144,7 +2144,7 @@ async function sendPaymentReceivedEmail(
? `<a href="${explorer}${p.txHash}" style="color:#67e8f9;text-decoration:none">${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}</a>` ? `<a href="${explorer}${p.txHash}" style="color:#67e8f9;text-decoration:none">${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}</a>`
: (p.txHash || 'N/A'); : (p.txHash || 'N/A');
const paidDate = p.paidAt ? new Date(p.paidAt).toUTCString() : new Date().toUTCString(); 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 payerLabel = payerEmail || p.payerIdentity?.slice(0, 10) + '...' || 'Anonymous';
const html = `<!DOCTYPE html> const html = `<!DOCTYPE html>
@ -2290,7 +2290,7 @@ routes.post("/api/group-buys", async (c) => {
_syncServer!.setDoc(docId, doc); _syncServer!.setDoc(docId, doc);
const host = c.req.header('host') || 'rspace.online'; 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); return c.json({ id: buyId, shareUrl }, 201);
}); });

View File

@ -6,6 +6,7 @@
*/ */
import { filesSchema, type FilesDoc } from "../schemas"; import { filesSchema, type FilesDoc } from "../schemas";
import { makeDraggableAll } from "../../../shared/draggable";
import type { DocumentId } from "../../../shared/local-first/document"; import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { authFetch, requireAuth } from "../../../shared/auth-fetch"; 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(); this._tour.renderOverlay();
} }

View File

@ -1604,7 +1604,7 @@ routes.get("/about", (c) => {
</div> </div>
</div> </div>
<div style="text-align:center;margin-top:2.5rem;"> <div style="text-align:center;margin-top:2.5rem;">
<a href="/${space}/rinbox" style="display:inline-block;padding:0.75rem 2rem;background:#0891b2;color:white;border-radius:0.75rem;text-decoration:none;font-weight:600;">Open Inbox</a> <a href="/rinbox" style="display:inline-block;padding:0.75rem 2rem;background:#0891b2;color:white;border-radius:0.75rem;text-decoration:none;font-weight:600;">Open Inbox</a>
<a href="https://rinbox.online" style="display:inline-block;padding:0.75rem 2rem;margin-left:1rem;border:1px solid rgba(51,65,85,.5);color:#94a3b8;border-radius:0.75rem;text-decoration:none;font-weight:600;">rinbox.online</a> <a href="https://rinbox.online" style="display:inline-block;padding:0.75rem 2rem;margin-left:1rem;border:1px solid rgba(51,65,85,.5);color:#94a3b8;border-radius:0.75rem;text-decoration:none;font-weight:600;">rinbox.online</a>
</div> </div>
</div>`, </div>`,

View File

@ -698,7 +698,7 @@ function renderCrm(space: string, activeTab: string) {
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`, styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
tabs: [...CRM_TABS], tabs: [...CRM_TABS],
activeTab, activeTab,
tabBasePath: `/${space}/rnetwork/crm`, tabBasePath: process.env.NODE_ENV === "production" ? `/rnetwork/crm` : `/${space}/rnetwork/crm`,
}); });
} }

View File

@ -10,6 +10,7 @@
*/ */
import * as Automerge from '@automerge/automerge'; import * as Automerge from '@automerge/automerge';
import { makeDraggableAll } from '../../../shared/draggable';
import { notebookSchema } from '../schemas'; import { notebookSchema } from '../schemas';
import type { DocumentId } from '../../../shared/local-first/document'; import type { DocumentId } from '../../../shared/local-first/document';
import { getAccessToken } from '../../../shared/components/rstack-identity'; import { getAccessToken } from '../../../shared/components/rstack-identity';
@ -2193,6 +2194,13 @@ Gear: EUR 400 (10%)</code></pre><p><em>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) // Back buttons (for notebook view)
this.navZone.querySelectorAll("[data-back]").forEach((el) => { this.navZone.querySelectorAll("[data-back]").forEach((el) => {
el.addEventListener("click", (e) => { el.addEventListener("click", (e) => {

View File

@ -8,6 +8,7 @@
* space space slug (default: "demo") * space space slug (default: "demo")
*/ */
import { makeDraggableAll } from "../../../shared/draggable";
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js"; import { ViewHistory } from "../../../shared/view-history.js";
@ -384,6 +385,20 @@ class FolkPhotoGallery extends HTMLElement {
`; `;
this.attachListeners(); 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(); this._tour.renderOverlay();
} }

View File

@ -520,7 +520,7 @@ async function executeCalendarReminder(
<tbody>${itemRows}</tbody> <tbody>${itemRows}</tbody>
</table> </table>
<p style="color:#475569;font-size:11px;margin:24px 0 0;text-align:center"> <p style="color:#475569;font-size:11px;margin:24px 0 0;text-align:center">
Sent by rSchedule &bull; <a href="https://rspace.online/${space}/rcal" style="color:#f59e0b">View Calendar</a> Sent by rSchedule &bull; <a href="https://${space}.rspace.online/rcal" style="color:#f59e0b">View Calendar</a>
</p> </p>
</div> </div>
`; `;
@ -1136,7 +1136,7 @@ async function executeReminderEmail(
${reminder.description ? `<p style="color:#cbd5e1;font-size:14px;line-height:1.6;margin:0 0 16px">${reminder.description}</p>` : ""} ${reminder.description ? `<p style="color:#cbd5e1;font-size:14px;line-height:1.6;margin:0 0 16px">${reminder.description}</p>` : ""}
${sourceInfo} ${sourceInfo}
<p style="color:#475569;font-size:11px;margin:24px 0 0;text-align:center"> <p style="color:#475569;font-size:11px;margin:24px 0 0;text-align:center">
Sent by rSchedule &bull; <a href="https://rspace.online/${space}/rschedule" style="color:#f59e0b">Manage Reminders</a> Sent by rSchedule &bull; <a href="https://${space}.rspace.online/rschedule" style="color:#f59e0b">Manage Reminders</a>
</p> </p>
</div> </div>
`; `;

View File

@ -7,6 +7,7 @@
* Three.js and GaussianSplats3D are loaded via CDN importmap (not bundled). * Three.js and GaussianSplats3D are loaded via CDN importmap (not bundled).
*/ */
import { makeDraggableAll } from "../../../shared/draggable";
import { splatScenesSchema, splatScenesDocId, type SplatScenesDoc } from "../schemas"; import { splatScenesSchema, splatScenesDocId, type SplatScenesDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document"; import type { DocumentId } from "../../../shared/local-first/document";
@ -271,6 +272,13 @@ export class FolkSplatViewer extends HTMLElement {
this.setupGenerateHandlers(); this.setupGenerateHandlers();
this.setupToggle(); this.setupToggle();
this.setupDemoCardHandlers(); 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() { private setupToggle() {
@ -531,11 +539,13 @@ export class FolkSplatViewer extends HTMLElement {
try { try {
const imageUrl = await this.stageImage(selectedFile!); const imageUrl = await this.stageImage(selectedFile!);
const title = selectedFile.name.replace(/\.[^.]+$/, "");
status.textContent = "Submitting for 3D generation...";
const res = await fetch("/api/3d-gen", { const res = await fetch("/api/3d-gen", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_url: imageUrl }), body: JSON.stringify({ image_url: imageUrl, title }),
}); });
clearInterval(ticker); clearInterval(ticker);
document.removeEventListener("visibilitychange", onVisChange); document.removeEventListener("visibilitychange", onVisChange);
@ -565,37 +575,102 @@ export class FolkSplatViewer extends HTMLElement {
return; return;
} }
const data = await res.json() as { url: string; format: string }; const { job_id } = await res.json() as { job_id: string };
// Jump to 100% before hiding status.textContent = "Generating 3D model — you'll get an email when it's ready...";
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`;
// Store generated info for save-to-gallery // Poll for completion
this._generatedUrl = data.url; const pollInterval = setInterval(async () => {
this._generatedTitle = selectedFile.name.replace(/\.[^.]+$/, ""); 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 if (job.status === "complete") {
await this.autoSave(); clearInterval(pollInterval);
progress.style.display = "none";
// Open inline viewer with generated model // Show result with save option
this._mode = "viewer"; const resultDiv = document.createElement("div");
this._splatUrl = data.url; resultDiv.className = "splat-generate__result";
this._splatTitle = this._generatedTitle; resultDiv.innerHTML = `
this._splatDesc = "AI-generated 3D model"; <p style="color:#22c55e;font-weight:600;margin-bottom:12px;">3D model generated successfully!</p>
this._inlineViewer = true; <div style="display:flex;gap:8px;flex-wrap:wrap;">
this.renderViewer(); <button class="splat-upload__btn" id="gen-view-btn">View 3D Model</button>
} catch (e: any) { <button class="splat-upload__btn" id="gen-save-btn" style="background:#22c55e;">Save to Gallery</button>
clearInterval(ticker); <a href="${job.url}" download class="splat-upload__btn" style="background:#64748b;text-decoration:none;text-align:center;">Download</a>
document.removeEventListener("visibilitychange", onVisChange); </div>
if (e.name === "AbortError") { ${job.email_sent ? '<p style="color:#94a3b8;font-size:13px;margin-top:8px;">Email notification sent!</p>' : ''}
status.textContent = "Request timed out — try a simpler image."; `;
} else { status.textContent = "";
status.textContent = e.message || "Network error — could not reach server"; 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"; progress.style.display = "none";
actions.style.display = "flex"; actions.style.display = "flex";
submitBtn.disabled = false; submitBtn.disabled = false;
@ -853,25 +928,49 @@ export class FolkSplatViewer extends HTMLElement {
private async initSplatViewer(container: HTMLElement, loading: HTMLElement | null) { private async initSplatViewer(container: HTMLElement, loading: HTMLElement | null) {
const GaussianSplats3D = await import("@mkkellogg/gaussian-splats-3d"); 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({ const viewer = new GaussianSplats3D.Viewer({
cameraUp: [0, 1, 0], cameraUp: [0, 1, 0],
initialCameraPosition: [5, 3, 5], initialCameraPosition: [5, 3, 5],
initialCameraLookAt: [0, 0, 0], initialCameraLookAt: [0, 0, 0],
rootElement: container, rootElement: container,
sharedMemoryForWorkers: false, sharedMemoryForWorkers: false,
halfPrecisionCovariancesOnGPU: isMobile,
dynamicScene: false,
...(isMobile ? { splatSortDistanceMapPrecision: 16 } : {}),
}); });
this._viewer = viewer; 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!, { viewer.addSplatScene(this._splatUrl!, {
showLoadingUI: false, showLoadingUI: false,
progressiveLoad: true, progressiveLoad: true,
}) })
.then(() => { .then(() => {
loaded = true;
clearTimeout(timeoutId);
viewer.start(); viewer.start();
if (loading) loading.classList.add("hidden"); if (loading) loading.classList.add("hidden");
}) })
.catch((e: Error) => { .catch((e: Error) => {
loaded = true;
clearTimeout(timeoutId);
console.error("[rSplat] Scene load error:", e); console.error("[rSplat] Scene load error:", e);
if (loading) { if (loading) {
const text = loading.querySelector(".splat-loading__text"); const text = loading.querySelector(".splat-loading__text");

View File

@ -186,7 +186,7 @@ const IMPORTMAP = `<script type="importmap">
"imports": { "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js", "three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/", "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/",
"@mkkellogg/gaussian-splats-3d": "https://cdn.jsdelivr.net/npm/@mkkellogg/gaussian-splats-3d@0.4.6/build/gaussian-splats-3d.module.js" "@mkkellogg/gaussian-splats-3d": "https://cdn.jsdelivr.net/npm/@mkkellogg/gaussian-splats-3d@0.4.7/build/gaussian-splats-3d.module.js"
} }
} }
</script>`; </script>`;
@ -738,7 +738,7 @@ function renderViewerPage(spaceSlug: string, dataSpace: string, idOrSlug: string
title: "Splat not found | rSpace", title: "Splat not found | rSpace",
moduleId: "rsplat", moduleId: "rsplat",
spaceSlug, spaceSlug,
body: `<div style="padding:3rem;text-align:center;color:#94a3b8;"><h2>Splat not found</h2><p><a href="/${spaceSlug}/rsplat" style="color:#818cf8;">Back to gallery</a></p></div>`, body: `<div style="padding:3rem;text-align:center;color:#94a3b8;"><h2>Splat not found</h2><p><a href="/rsplat" style="color:#818cf8;">Back to gallery</a></p></div>`,
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
}); });

View File

@ -6,6 +6,7 @@
*/ */
import { boardSchema, type BoardDoc } from "../schemas"; import { boardSchema, type BoardDoc } from "../schemas";
import { makeDraggableAll } from "../../../shared/draggable";
import type { DocumentId } from "../../../shared/local-first/document"; import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { ViewHistory } from "../../../shared/view-history.js"; import { ViewHistory } from "../../../shared/view-history.js";
@ -732,6 +733,22 @@ class FolkTasksBoard extends HTMLElement {
this.dragOverIndex = -1; this.dragOverIndex = -1;
}); });
}); });
// Make task cards draggable for calendar reminders (cross-module)
this.shadow.querySelectorAll<HTMLElement>(".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 { private esc(s: string): string {

View File

@ -128,6 +128,16 @@ const DIST_DIR = resolve(import.meta.dir, "../dist");
// ── Hono app ── // ── Hono app ──
const app = new Hono(); 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 // CORS for API routes
app.use("/api/*", cors()); app.use("/api/*", cors());
@ -637,6 +647,148 @@ app.post("/api/x402-test", async (c) => {
const FAL_KEY = process.env.FAL_KEY || ""; const FAL_KEY = process.env.FAL_KEY || "";
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || ""; const GEMINI_API_KEY = process.env.GEMINI_API_KEY || "";
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434"; 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<string, Gen3DJob>();
// 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<typeof createTransport> | 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 <noreply@rspace.online>",
to: SPLAT_NOTIFY_EMAIL,
subject: `Your 3D splat "${title}" is ready — rSplat`,
html: `
<div style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto;padding:24px;">
<h2 style="color:#818cf8;">Your 3D model is ready!</h2>
<p>Your AI-generated 3D model <strong>"${title}"</strong> has finished processing.</p>
<p style="margin:24px 0;">
<a href="${downloadUrl}" style="display:inline-block;padding:12px 24px;background:#818cf8;color:#fff;text-decoration:none;border-radius:8px;font-weight:600;">
View &amp; Download
</a>
</p>
<p>Or save it to your gallery at <a href="${SITE_URL}/rsplat">${SITE_URL}/rsplat</a></p>
<p style="color:#64748b;font-size:13px;margin-top:32px;">Generated by rSplat on rSpace</p>
</div>
`,
});
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 ── // ── Image helpers ──
@ -1019,75 +1171,52 @@ app.post("/api/image-stage", async (c) => {
return c.json({ url: `${PUBLIC_ORIGIN}/data/files/generated/${filename}` }); 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) => { app.post("/api/3d-gen", async (c) => {
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); 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); if (!image_url) return c.json({ error: "image_url required" }, 400);
const falHeaders = { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json" }; const jobId = crypto.randomUUID();
const MODEL = "fal-ai/hunyuan3d-v21"; const job: Gen3DJob = {
id: jobId,
status: "pending",
imageUrl: image_url,
createdAt: Date.now(),
title: title || "Untitled",
};
gen3dJobs.set(jobId, job);
try { // Process in background — no await, returns immediately
// 1. Submit to queue process3DGenJob(job);
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 };
// 2. Poll for completion (up to 5 min) return c.json({ job_id: jobId, status: "pending" });
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);
}
}
// 3. Fetch result // Poll job status
const resultRes = await fetch( app.get("/api/3d-gen/:jobId", async (c) => {
`https://queue.fal.run/${MODEL}/requests/${request_id}`, const jobId = c.req.param("jobId");
{ headers: falHeaders }, const job = gen3dJobs.get(jobId);
); if (!job) return c.json({ error: "Job not found" }, 404);
if (!resultRes.ok) {
console.error("[3d-gen] fal.ai result error:", resultRes.status);
return c.json({ error: "Failed to retrieve 3D model" }, 502);
}
const data = await resultRes.json(); const response: Record<string, any> = {
const modelUrl = data.model_glb?.url || data.model_glb_pbr?.url; job_id: job.id,
if (!modelUrl) return c.json({ error: "No 3D model returned" }, 502); status: job.status,
created_at: job.createdAt,
};
// 4. Download and save if (job.status === "complete") {
const modelRes = await fetch(modelUrl); response.url = job.resultUrl;
if (!modelRes.ok) return c.json({ error: "Failed to download model" }, 502); response.format = job.resultFormat;
response.completed_at = job.completedAt;
const modelBuf = await modelRes.arrayBuffer(); response.email_sent = job.emailSent || false;
const filename = `splat-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.glb`; } else if (job.status === "failed") {
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated"); response.error = job.error;
await Bun.write(resolve(dir, filename), modelBuf); response.completed_at = job.completedAt;
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);
} }
return c.json(response);
}); });
// Blender 3D generation via LLM + RunPod // Blender 3D generation via LLM + RunPod

View File

@ -69,6 +69,24 @@ export interface ShellOptions {
activeTab?: string; activeTab?: string;
/** Base path for tab links (default: /{space}/{moduleId}). Set to e.g. "/{space}/rnetwork/crm" for sub-pages. */ /** Base path for tab links (default: /{space}/{moduleId}). Set to e.g. "/{space}/rnetwork/crm" for sub-pages. */
tabBasePath?: string; 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 { export function renderShell(opts: ShellOptions): string {
@ -192,8 +210,8 @@ export function renderShell(opts: ShellOptions): string {
</div> </div>
<rstack-user-dashboard space="${escapeAttr(spaceSlug)}" style="display:none"></rstack-user-dashboard> <rstack-user-dashboard space="${escapeAttr(spaceSlug)}" style="display:none"></rstack-user-dashboard>
<main id="app"${moduleId === "rspace" ? ' class="canvas-layout"' : ''}> <main id="app"${moduleId === "rspace" ? ' class="canvas-layout"' : ''}>
${renderModuleSubNav(moduleId, spaceSlug, visibleModules)} ${renderModuleSubNav(moduleId, spaceSlug, visibleModules, opts.isSubdomain ?? IS_PRODUCTION)}
${opts.tabs ? renderTabBar(opts.tabs, opts.activeTab, opts.tabBasePath || `/${escapeAttr(spaceSlug)}/${escapeAttr(moduleId)}`) : ''} ${opts.tabs ? renderTabBar(opts.tabs, opts.activeTab, opts.tabBasePath || ((opts.isSubdomain ?? IS_PRODUCTION) ? `/${escapeAttr(moduleId)}` : `/${escapeAttr(spaceSlug)}/${escapeAttr(moduleId)}`)) : ''}
${body} ${body}
</main> </main>
<rstack-collab-overlay module-id="${escapeAttr(moduleId)}" space="${escapeAttr(spaceSlug)}"></rstack-collab-overlay> <rstack-collab-overlay module-id="${escapeAttr(moduleId)}" space="${escapeAttr(spaceSlug)}"></rstack-collab-overlay>
@ -1381,7 +1399,7 @@ const SUBNAV_CSS = `
`; `;
/** Build the module sub-nav bar from outputPaths + subPageInfos. */ /** 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 if (moduleId === 'rspace') return ''; // canvas page has its own chrome
const mod = modules.find(m => m.id === moduleId); const mod = modules.find(m => m.id === moduleId);
if (!mod) return ''; if (!mod) return '';
@ -1406,7 +1424,7 @@ function renderModuleSubNav(moduleId: string, spaceSlug: string, modules: Module
// Don't render if no sub-paths // Don't render if no sub-paths
if (items.length === 0) return ''; if (items.length === 0) return '';
const base = `/${escapeAttr(spaceSlug)}/${escapeAttr(moduleId)}`; const base = (isSubdomain ?? IS_PRODUCTION) ? `/${escapeAttr(moduleId)}` : `/${escapeAttr(spaceSlug)}/${escapeAttr(moduleId)}`;
const pills = [ const pills = [
`<a class="rapp-nav-pill" href="${base}" data-subnav-root>${escapeHtml(mod.name)}</a>`, `<a class="rapp-nav-pill" href="${base}" data-subnav-root>${escapeHtml(mod.name)}</a>`,

64
shared/draggable.ts Normal file
View File

@ -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<string, string> = {
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<HTMLElement>(selector).forEach((el) => {
const payload = payloadFn(el);
if (payload) makeDraggable(el, payload);
});
}

View File

@ -2494,6 +2494,81 @@
} }
.triage-drop-icon { font-size: 36px; } .triage-drop-icon { font-size: 36px; }
</style> </style>
<!-- Reminder mini-calendar widget -->
<style>
#reminder-widget {
position: fixed; top: 108px; right: 16px; z-index: 1001;
background: var(--rs-toolbar-bg, #1a1a2e); border: 1px solid #333;
border-radius: 14px; padding: 14px; width: 260px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
opacity: 0; transform: translateY(-8px) scale(0.96);
pointer-events: none; transition: opacity 0.2s, transform 0.2s;
font-family: system-ui, -apple-system, sans-serif;
color: var(--rs-text, #e0e0e0);
}
#reminder-widget.visible {
opacity: 1; transform: translateY(0) scale(1); pointer-events: auto;
}
.rw-header { font-size: 12px; color: #94a3b8; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.rw-header-icon { font-size: 16px; }
.rw-shape-label {
font-size: 13px; font-weight: 600; color: #e0e0e0;
margin-bottom: 10px; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; display: flex; align-items: center; gap: 6px;
}
.rw-shape-badge {
font-size: 10px; padding: 2px 6px; border-radius: 4px;
color: #fff; font-weight: 600; text-transform: uppercase; flex-shrink: 0;
}
.rw-nav { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.rw-nav-btn {
background: none; border: none; color: #94a3b8; cursor: pointer;
font-size: 14px; padding: 2px 6px; border-radius: 4px;
}
.rw-nav-btn:hover { background: #2a2a3e; color: #e0e0e0; }
.rw-month { font-size: 12px; font-weight: 600; color: #e0e0e0; }
.rw-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.rw-dow { font-size: 9px; text-align: center; color: #64748b; padding: 2px 0; }
.rw-day {
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
font-size: 11px; border-radius: 6px; cursor: pointer;
color: #94a3b8; transition: all 0.15s; border: 2px solid transparent;
}
.rw-day:hover { background: rgba(129,140,248,0.15); color: #e0e0e0; }
.rw-day.today { color: #818cf8; font-weight: 700; }
.rw-day.other { color: #333; cursor: default; }
.rw-day.other:hover { background: none; }
.rw-day.drop-highlight {
background: rgba(245,158,11,0.25) !important;
border-color: #f59e0b !important;
color: #fff !important; font-weight: 700;
transform: scale(1.15); box-shadow: 0 0 8px rgba(245,158,11,0.4);
}
.rw-day.has-reminder {
position: relative;
}
.rw-day.has-reminder::after {
content: ''; position: absolute; bottom: 1px; left: 50%;
transform: translateX(-50%); width: 4px; height: 4px;
border-radius: 50%; background: #f59e0b;
}
.rw-feedback {
text-align: center; font-size: 11px; color: #22c55e; font-weight: 600;
margin-top: 6px; min-height: 16px;
}
</style>
<div id="reminder-widget">
<div class="rw-header"><span class="rw-header-icon">🔔</span> Remind me of this on:</div>
<div class="rw-shape-label" id="rw-shape-label"></div>
<div class="rw-nav">
<button class="rw-nav-btn" id="rw-prev">&larr;</button>
<span class="rw-month" id="rw-month"></span>
<button class="rw-nav-btn" id="rw-next">&rarr;</button>
</div>
<div class="rw-grid" id="rw-grid"></div>
<div class="rw-feedback" id="rw-feedback"></div>
</div>
<div id="select-rect"></div> <div id="select-rect"></div>
<script type="module"> <script type="module">
@ -3145,6 +3220,7 @@
} }
} }
__miCanvasBridge.setSelection([...selectedShapeIds]); __miCanvasBridge.setSelection([...selectedShapeIds]);
updateReminderWidget();
} }
function rectsOverlapScreen(sel, r) { function rectsOverlapScreen(sel, r) {
@ -6727,6 +6803,202 @@
// Debug: expose sync for console inspection // Debug: expose sync for console inspection
window.sync = sync; window.sync = sync;
// ── Reminder Mini-Calendar Widget ──
const rwWidget = document.getElementById("reminder-widget");
const rwLabel = document.getElementById("rw-shape-label");
const rwMonth = document.getElementById("rw-month");
const rwGrid = document.getElementById("rw-grid");
const rwFeedback = document.getElementById("rw-feedback");
const rwPrev = document.getElementById("rw-prev");
const rwNext = document.getElementById("rw-next");
let rwDate = new Date();
let rwSelectedShape = null;
let rwDragShape = null;
const TYPE_COLORS = {
"folk-markdown": "#6366f1", "folk-rapp": "#818cf8", "folk-embed": "#06b6d4",
"folk-image": "#ec4899", "folk-image-gen": "#a855f7", "folk-video-gen": "#8b5cf6",
"folk-prompt": "#10b981", "folk-bookmark": "#f97316", "folk-calendar": "#3b82f6",
"folk-chat": "#ef4444", "folk-obs-note": "#f59e0b", "folk-slide": "#14b8a6",
};
function getShapeInfo(el) {
const data = sync.doc?.shapes?.[el.id] || {};
const tag = el.tagName.toLowerCase();
const label = data.title || data.content?.slice(0, 50)?.replace(/<[^>]+>/g, "") || data.tokenName || data.label || feedTypeName(tag);
const moduleId = data.moduleId || null;
const color = TYPE_COLORS[tag] || "#818cf8";
const typeName = feedTypeName(tag);
return { id: el.id, label, moduleId, color, typeName, tag };
}
function updateReminderWidget() {
if (selectedShapeIds.size === 1) {
const id = [...selectedShapeIds][0];
const el = document.getElementById(id);
if (el) {
rwSelectedShape = el;
const info = getShapeInfo(el);
rwLabel.innerHTML = `<span class="rw-shape-badge" style="background:${info.color}">${info.typeName}</span> ${info.label}`;
rwWidget.classList.add("visible");
rwFeedback.textContent = "";
renderRwCalendar();
setupRwDrag();
return;
}
}
rwSelectedShape = null;
rwWidget.classList.remove("visible");
}
function renderRwCalendar() {
const year = rwDate.getFullYear();
const month = rwDate.getMonth();
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,"0")}-${String(today.getDate()).padStart(2,"0")}`;
rwMonth.textContent = rwDate.toLocaleDateString("en-US", { month: "long", year: "numeric" });
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const prevDays = new Date(year, month, 0).getDate();
let html = ["S","M","T","W","T","F","S"].map(d => `<div class="rw-dow">${d}</div>`).join("");
// Previous month padding
for (let i = firstDay - 1; i >= 0; i--) {
html += `<div class="rw-day other">${prevDays - i}</div>`;
}
// Current month days
for (let d = 1; d <= daysInMonth; d++) {
const ds = `${year}-${String(month+1).padStart(2,"0")}-${String(d).padStart(2,"0")}`;
const isToday = ds === todayStr;
html += `<div class="rw-day${isToday ? " today" : ""}" data-rw-date="${ds}">${d}</div>`;
}
// Next month padding
const totalCells = firstDay + daysInMonth;
const remaining = totalCells % 7 === 0 ? 0 : 7 - (totalCells % 7);
for (let i = 1; i <= remaining; i++) {
html += `<div class="rw-day other">${i}</div>`;
}
rwGrid.innerHTML = html;
// Click handler for days
rwGrid.querySelectorAll(".rw-day[data-rw-date]").forEach(dayEl => {
dayEl.addEventListener("click", () => {
if (rwSelectedShape) createReminderForShape(dayEl.dataset.rwDate);
});
// Drop handlers for drag-from-canvas
dayEl.addEventListener("dragover", (e) => {
e.preventDefault();
dayEl.classList.add("drop-highlight");
});
dayEl.addEventListener("dragleave", () => {
dayEl.classList.remove("drop-highlight");
});
dayEl.addEventListener("drop", (e) => {
e.preventDefault();
dayEl.classList.remove("drop-highlight");
if (rwDragShape) {
rwSelectedShape = rwDragShape;
const info = getShapeInfo(rwDragShape);
rwLabel.innerHTML = `<span class="rw-shape-badge" style="background:${info.color}">${info.typeName}</span> ${info.label}`;
createReminderForShape(dayEl.dataset.rwDate);
rwDragShape = null;
} else if (rwSelectedShape) {
createReminderForShape(dayEl.dataset.rwDate);
}
});
});
}
// Make canvas shapes natively draggable TO the reminder widget
function setupRwDrag() {
for (const el of canvasContent.children) {
if (el._rwDragBound) continue;
el._rwDragBound = true;
el.addEventListener("dragstart", (e) => {
if (!e.dataTransfer) return;
rwDragShape = el;
const info = getShapeInfo(el);
e.dataTransfer.setData("text/plain", info.label);
e.dataTransfer.effectAllowed = "copy";
// Show the widget when dragging starts
rwSelectedShape = el;
rwLabel.innerHTML = `<span class="rw-shape-badge" style="background:${info.color}">${info.typeName}</span> ${info.label}`;
rwWidget.classList.add("visible");
rwFeedback.textContent = "";
renderRwCalendar();
});
el.addEventListener("dragend", () => {
rwDragShape = null;
});
}
}
// Periodically re-attach drag handlers as new shapes are added
setInterval(setupRwDrag, 2000);
async function createReminderForShape(dateStr) {
if (!rwSelectedShape) return;
const info = getShapeInfo(rwSelectedShape);
const remindAt = new Date(dateStr + "T09:00:00").getTime();
const schedBase = `/${communitySlug}/rschedule`;
try {
const res = await fetch(`${schedBase}/api/reminders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: info.label,
remindAt,
allDay: true,
syncToCalendar: true,
sourceModule: info.moduleId || info.tag,
sourceEntityId: info.id,
sourceLabel: info.typeName,
sourceColor: info.color,
}),
});
if (res.ok) {
const friendlyDate = new Date(dateStr + "T12:00:00").toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
rwFeedback.textContent = `✓ Reminder set for ${friendlyDate}`;
// Flash the day cell
const dayEl = rwGrid.querySelector(`[data-rw-date="${dateStr}"]`);
if (dayEl) {
dayEl.classList.add("drop-highlight");
setTimeout(() => dayEl.classList.remove("drop-highlight"), 800);
}
setTimeout(() => { rwFeedback.textContent = ""; }, 3000);
} else {
const err = await res.json().catch(() => ({}));
rwFeedback.textContent = err.error || "Failed to create reminder";
rwFeedback.style.color = "#ef4444";
setTimeout(() => { rwFeedback.style.color = ""; rwFeedback.textContent = ""; }, 3000);
}
} catch (e) {
rwFeedback.textContent = "Network error";
rwFeedback.style.color = "#ef4444";
setTimeout(() => { rwFeedback.style.color = ""; rwFeedback.textContent = ""; }, 3000);
}
}
rwPrev.addEventListener("click", () => {
rwDate = new Date(rwDate.getFullYear(), rwDate.getMonth() - 1, 1);
renderRwCalendar();
});
rwNext.addEventListener("click", () => {
rwDate = new Date(rwDate.getFullYear(), rwDate.getMonth() + 1, 1);
renderRwCalendar();
});
</script> </script>
</body> </body>
</html> </html>