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: `

Book not found

Back to library

`, + body: `

Book not found

Back to library

`, modules: getModuleInfoList(), }); return c.html(html, 404); diff --git a/modules/rcal/components/folk-calendar-view.ts b/modules/rcal/components/folk-calendar-view.ts index 1ffe4e0..272c1b7 100644 --- a/modules/rcal/components/folk-calendar-view.ts +++ b/modules/rcal/components/folk-calendar-view.ts @@ -116,6 +116,7 @@ class FolkCalendarView extends HTMLElement { private events: any[] = []; private sources: any[] = []; private lunarData: Record = {}; + 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 = ` +
+ ${labelText} + ${item.title} +
+
${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) => {
- Open Inbox + Open Inbox rinbox.online
`, 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: `

Splat not found

Back to gallery

`, + body: `

Splat not found

Back to gallery

`, 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; } + + +
+
🔔 Remind me of this on:
+
+
+ + + +
+
+
+
+