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:
parent
3e4c070fea
commit
84c3c318d8
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>`,
|
||||||
|
|
|
||||||
|
|
@ -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`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 • <a href="https://rspace.online/${space}/rcal" style="color:#f59e0b">View Calendar</a>
|
Sent by rSchedule • <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 • <a href="https://rspace.online/${space}/rschedule" style="color:#f59e0b">Manage Reminders</a>
|
Sent by rSchedule • <a href="https://${space}.rspace.online/rschedule" style="color:#f59e0b">Manage Reminders</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
245
server/index.ts
245
server/index.ts
|
|
@ -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 & 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
|
||||||
|
|
|
||||||
|
|
@ -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>`,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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">←</button>
|
||||||
|
<span class="rw-month" id="rw-month"></span>
|
||||||
|
<button class="rw-nav-btn" id="rw-next">→</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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue