feat(rschedule,rtasks): wire Calendly-style rSchedule + rTasks canvas port
rSchedule (Calendly-style public booking module): - modules/rschedule/mod.ts — routes, docSchemas (config/bookings/invitations), standaloneDomain rschedule.online, landing, feeds, outputPaths - components/folk-schedule-booking.ts — public booking page - components/folk-schedule-admin.ts — admin availability/bookings/gcal - components/folk-schedule-cancel.ts — guest self-cancel page - booking.css, landing.ts (marketing) - Registered via registerModule(scheduleModule) in server/index.ts - Component bundles declared in vite.config.ts rTasks canvas port (uses new shared folk-app-canvas/folk-widget): - components/folk-tasks-canvas.ts, folk-tasks-activity.ts, folk-tasks-backlog.ts - modules/rtasks/mod.ts now renders <folk-app-canvas app-id=rtasks> shell Plus minor rnetwork delegation-manager / power-indices tweaks.
This commit is contained in:
parent
8042e86815
commit
f45cb753e1
|
|
@ -576,3 +576,5 @@ class FolkDelegationManager extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define("folk-delegation-manager", FolkDelegationManager);
|
customElements.define("folk-delegation-manager", FolkDelegationManager);
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
|
||||||
|
|
@ -334,3 +334,5 @@ class FolkPowerIndices extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define("folk-power-indices", FolkPowerIndices);
|
customElements.define("folk-power-indices", FolkPowerIndices);
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
/* rSchedule booking + admin components — shared host styles. */
|
||||||
|
folk-schedule-booking,
|
||||||
|
folk-schedule-cancel,
|
||||||
|
folk-schedule-admin {
|
||||||
|
display: block;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
/**
|
||||||
|
* <folk-schedule-admin> — admin dashboard for rSchedule.
|
||||||
|
*
|
||||||
|
* Passkey-gated (server enforces moderator role on every admin API call).
|
||||||
|
* Phase A: minimal shell with tab placeholders. Phase E builds full UI
|
||||||
|
* (availability rules, overrides, bookings list, settings, gcal connect).
|
||||||
|
*/
|
||||||
|
|
||||||
|
class FolkScheduleAdmin extends HTMLElement {
|
||||||
|
private shadow: ShadowRoot;
|
||||||
|
private space = "";
|
||||||
|
private tab: "overview" | "availability" | "bookings" | "calendar" | "settings" = "overview";
|
||||||
|
private err = "";
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.space = this.getAttribute("space") || "demo";
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private authHeaders(): Record<string, string> {
|
||||||
|
try {
|
||||||
|
const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}");
|
||||||
|
const token = sess?.token;
|
||||||
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
|
} catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ping() {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const m = path.match(/^(\/[^/]+)?\/rschedule/);
|
||||||
|
const base = `${m?.[0] || "/rschedule"}/api/admin`;
|
||||||
|
const res = await fetch(`${base}/settings`, { headers: this.authHeaders() });
|
||||||
|
if (res.status === 401) { this.err = "Sign in to manage this booking page."; return; }
|
||||||
|
if (res.status === 403) { this.err = "You need moderator or higher role in this space."; return; }
|
||||||
|
if (!res.ok) { this.err = `Error ${res.status}`; return; }
|
||||||
|
this.err = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private render() {
|
||||||
|
void this.ping().then(() => this.paint());
|
||||||
|
this.paint();
|
||||||
|
}
|
||||||
|
|
||||||
|
private paint() {
|
||||||
|
this.shadow.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host { display:block; color:#e2e8f0; font-family:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background:#0b1120; min-height:100vh; }
|
||||||
|
.wrap { max-width:1100px; margin:0 auto; padding:32px 24px; }
|
||||||
|
h1 { margin:0 0 6px; font-size:1.4rem; }
|
||||||
|
.sub { color:#94a3b8; margin:0 0 24px; font-size:0.9rem; }
|
||||||
|
.tabs { display:flex; gap:4px; border-bottom:1px solid rgba(148,163,184,0.12); margin-bottom:24px; overflow-x:auto; }
|
||||||
|
.tab { background:transparent; color:#94a3b8; border:0; padding:10px 16px; cursor:pointer; font-size:0.9rem; border-bottom:2px solid transparent; }
|
||||||
|
.tab.active { color:#06b6d4; border-bottom-color:#06b6d4; }
|
||||||
|
.card { background:#111827; border:1px solid rgba(148,163,184,0.12); border-radius:12px; padding:24px; }
|
||||||
|
.err { padding:16px; border:1px solid rgba(239,68,68,0.3); background:rgba(239,68,68,0.08); border-radius:8px; color:#fca5a5; margin-bottom:16px; }
|
||||||
|
.placeholder { color:#94a3b8; padding:40px; text-align:center; border:1px dashed rgba(148,163,184,0.25); border-radius:10px; }
|
||||||
|
</style>
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>rSchedule admin · ${escapeHtml(this.space)}</h1>
|
||||||
|
<p class="sub">Configure availability, bookings, and Google Calendar for this space.</p>
|
||||||
|
${this.err ? `<div class="err">${escapeHtml(this.err)}</div>` : ""}
|
||||||
|
<div class="tabs">
|
||||||
|
${this.tabBtn("overview", "Overview")}
|
||||||
|
${this.tabBtn("availability", "Availability")}
|
||||||
|
${this.tabBtn("bookings", "Bookings")}
|
||||||
|
${this.tabBtn("calendar", "Google Calendar")}
|
||||||
|
${this.tabBtn("settings", "Settings")}
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="placeholder">
|
||||||
|
<strong>${escapeHtml(this.tab)}</strong><br>
|
||||||
|
Full admin UI arrives in Phase E. For now, this confirms auth gate works.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
this.shadow.querySelectorAll<HTMLButtonElement>(".tab").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
this.tab = btn.dataset.tab as any;
|
||||||
|
this.paint();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private tabBtn(id: string, label: string): string {
|
||||||
|
return `<button class="tab ${this.tab === id ? "active" : ""}" data-tab="${id}">${label}</button>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("folk-schedule-admin", FolkScheduleAdmin);
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
/**
|
||||||
|
* <folk-schedule-booking> — public booking page.
|
||||||
|
*
|
||||||
|
* Phase A: stub that fetches public settings + availability and shows a
|
||||||
|
* placeholder date picker. Phase B ports the full UI from
|
||||||
|
* schedule-jeffemmett/src/app/page.tsx (month calendar, slot list, timezone
|
||||||
|
* toggle, world map, booking form).
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface PublicSettings {
|
||||||
|
displayName: string;
|
||||||
|
bookingMessage: string;
|
||||||
|
slotDurationMin: number;
|
||||||
|
maxAdvanceDays: number;
|
||||||
|
minNoticeHours: number;
|
||||||
|
timezone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FolkScheduleBooking extends HTMLElement {
|
||||||
|
private shadow: ShadowRoot;
|
||||||
|
private space = "";
|
||||||
|
private settings: PublicSettings | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.space = this.getAttribute("space") || "demo";
|
||||||
|
void this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load() {
|
||||||
|
try {
|
||||||
|
const base = this.apiBase();
|
||||||
|
const res = await fetch(`${base}/settings/public`);
|
||||||
|
this.settings = res.ok ? await res.json() : null;
|
||||||
|
} catch {
|
||||||
|
this.settings = null;
|
||||||
|
}
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private apiBase(): string {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const match = path.match(/^(\/[^/]+)?\/rschedule/);
|
||||||
|
return `${match?.[0] || "/rschedule"}/api`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private render() {
|
||||||
|
const s = this.settings;
|
||||||
|
const name = s?.displayName || this.space;
|
||||||
|
const msg = s?.bookingMessage || "Book a time to chat.";
|
||||||
|
const duration = s?.slotDurationMin ?? 30;
|
||||||
|
this.shadow.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host { display: block; color: #e2e8f0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; min-height: 100vh; background: #0b1120; }
|
||||||
|
.wrap { max-width: 980px; margin: 0 auto; padding: 48px 24px; }
|
||||||
|
.card { background: #111827; border: 1px solid rgba(148,163,184,0.12); border-radius: 16px; padding: 32px; }
|
||||||
|
h1 { margin: 0 0 8px; font-size: 1.6rem; background: linear-gradient(to right, #06b6d4, #8b5cf6); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
|
||||||
|
.meta { display: flex; gap: 16px; color: #94a3b8; font-size: 0.9rem; margin-bottom: 16px; }
|
||||||
|
.msg { color: #cbd5e1; margin: 0 0 24px; white-space: pre-wrap; }
|
||||||
|
.placeholder { padding: 40px; text-align: center; border: 1px dashed rgba(148,163,184,0.25); border-radius: 10px; color: #94a3b8; }
|
||||||
|
</style>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="card">
|
||||||
|
<h1>Book with ${escapeHtml(name)}</h1>
|
||||||
|
<div class="meta">
|
||||||
|
<span>⏱ ${duration} min</span>
|
||||||
|
${s?.timezone ? `<span>🌐 ${escapeHtml(s.timezone)}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
<p class="msg">${escapeHtml(msg)}</p>
|
||||||
|
<div class="placeholder">
|
||||||
|
Date picker and slot list coming in Phase B.<br>
|
||||||
|
Admin: <a href="admin" style="color:#06b6d4">configure availability</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("folk-schedule-booking", FolkScheduleBooking);
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
/**
|
||||||
|
* <folk-schedule-cancel> — guest self-cancel page.
|
||||||
|
*
|
||||||
|
* Reads booking via `/api/bookings/:id?token=...`, lets guest confirm cancel
|
||||||
|
* with optional reason. Phase F adds reschedule suggestions email.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class FolkScheduleCancel extends HTMLElement {
|
||||||
|
private shadow: ShadowRoot;
|
||||||
|
private space = "";
|
||||||
|
private bookingId = "";
|
||||||
|
private token = "";
|
||||||
|
private booking: any = null;
|
||||||
|
private err = "";
|
||||||
|
private done = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.shadow = this.attachShadow({ mode: "open" });
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.space = this.getAttribute("space") || "demo";
|
||||||
|
this.bookingId = this.getAttribute("booking-id") || "";
|
||||||
|
const qs = new URLSearchParams(window.location.search);
|
||||||
|
this.token = qs.get("token") || "";
|
||||||
|
void this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private apiBase(): string {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const match = path.match(/^(\/[^/]+)?\/rschedule/);
|
||||||
|
return `${match?.[0] || "/rschedule"}/api`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load() {
|
||||||
|
if (!this.token) {
|
||||||
|
this.err = "Missing cancel token in link.";
|
||||||
|
this.render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.apiBase()}/bookings/${this.bookingId}?token=${encodeURIComponent(this.token)}`);
|
||||||
|
if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error || "Failed to load");
|
||||||
|
this.booking = await res.json();
|
||||||
|
} catch (e: any) {
|
||||||
|
this.err = e?.message || String(e);
|
||||||
|
}
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cancel(reason: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.apiBase()}/bookings/${this.bookingId}/cancel`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token: this.token, reason }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json().catch(() => ({})))?.error || "Cancel failed");
|
||||||
|
this.done = true;
|
||||||
|
} catch (e: any) {
|
||||||
|
this.err = e?.message || String(e);
|
||||||
|
}
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private render() {
|
||||||
|
const b = this.booking;
|
||||||
|
let body = "";
|
||||||
|
if (this.err) {
|
||||||
|
body = `<div class="err">${escapeHtml(this.err)}</div>`;
|
||||||
|
} else if (this.done) {
|
||||||
|
body = `<div class="ok">Booking cancelled. A confirmation email is on the way.</div>`;
|
||||||
|
} else if (!b) {
|
||||||
|
body = `<div class="loading">Loading booking…</div>`;
|
||||||
|
} else {
|
||||||
|
const start = new Date(b.startTime).toLocaleString();
|
||||||
|
body = `
|
||||||
|
<h1>Cancel this booking?</h1>
|
||||||
|
<dl>
|
||||||
|
<dt>With</dt><dd>${escapeHtml(b.host?.label || b.host?.id || "")}</dd>
|
||||||
|
<dt>When</dt><dd>${escapeHtml(start)}</dd>
|
||||||
|
<dt>Guest</dt><dd>${escapeHtml(b.guestName || "")}</dd>
|
||||||
|
</dl>
|
||||||
|
<label>Reason (optional)<textarea id="reason" rows="3"></textarea></label>
|
||||||
|
<button id="cancel-btn">Cancel booking</button>
|
||||||
|
<a href="../" class="back">← Back</a>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
this.shadow.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host { display: block; color: #e2e8f0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0b1120; min-height: 100vh; }
|
||||||
|
.wrap { max-width: 560px; margin: 0 auto; padding: 48px 24px; }
|
||||||
|
.card { background: #111827; border: 1px solid rgba(148,163,184,0.12); border-radius: 16px; padding: 32px; }
|
||||||
|
h1 { margin: 0 0 16px; font-size: 1.4rem; }
|
||||||
|
dl { display: grid; grid-template-columns: 80px 1fr; gap: 8px 12px; margin: 0 0 20px; color: #cbd5e1; }
|
||||||
|
dt { color: #94a3b8; }
|
||||||
|
label { display: block; margin-bottom: 16px; color: #cbd5e1; font-size: 0.9rem; }
|
||||||
|
textarea { width: 100%; margin-top: 6px; padding: 8px; border-radius: 6px; border: 1px solid rgba(148,163,184,0.2); background: #0b1120; color: #e2e8f0; font-family: inherit; }
|
||||||
|
button { background: #ef4444; color: #fff; border: 0; padding: 10px 18px; border-radius: 8px; font-weight: 600; cursor: pointer; }
|
||||||
|
button:hover { background: #dc2626; }
|
||||||
|
.back { display: inline-block; margin-left: 12px; color: #94a3b8; text-decoration: none; }
|
||||||
|
.err { padding: 20px; border: 1px solid rgba(239,68,68,0.3); background: rgba(239,68,68,0.08); border-radius: 8px; color: #fca5a5; }
|
||||||
|
.ok { padding: 20px; border: 1px solid rgba(34,197,94,0.3); background: rgba(34,197,94,0.08); border-radius: 8px; color: #86efac; }
|
||||||
|
.loading { color: #94a3b8; }
|
||||||
|
</style>
|
||||||
|
<div class="wrap"><div class="card">${body}</div></div>
|
||||||
|
`;
|
||||||
|
this.shadow.getElementById("cancel-btn")?.addEventListener("click", () => {
|
||||||
|
const reason = (this.shadow.getElementById("reason") as HTMLTextAreaElement)?.value || "";
|
||||||
|
void this.cancel(reason);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("folk-schedule-cancel", FolkScheduleCancel);
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
/**
|
||||||
|
* rSchedule landing — marketing page for rspace.online/rschedule standalone.
|
||||||
|
*
|
||||||
|
* In-space `/:space/rschedule` bypasses this and renders the public booking
|
||||||
|
* UI directly via <folk-schedule-booking>.
|
||||||
|
*/
|
||||||
|
export function renderLanding(): string {
|
||||||
|
return `
|
||||||
|
<div class="rl-hero">
|
||||||
|
<span class="rl-tagline" style="color:#06b6d4;background:rgba(6,182,212,0.1);border-color:rgba(6,182,212,0.2)">
|
||||||
|
Public Booking Pages
|
||||||
|
</span>
|
||||||
|
<h1 class="rl-heading" style="background:linear-gradient(to right,#06b6d4,#8b5cf6,#ec4899);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
|
||||||
|
Book time with (you)rSpace.
|
||||||
|
</h1>
|
||||||
|
<p class="rl-subtitle">
|
||||||
|
Calendly-style public booking pages for every space and every user — backed by rCal availability, Google Calendar conflict checking, and encrypted invitations.
|
||||||
|
</p>
|
||||||
|
<p class="rl-subtext">
|
||||||
|
Each space and each user gets their own bookable schedule page. Guests pick a slot, confirm, and get a calendar invite. Hosts see bookings in their own rSchedule view — including bookings they're invited to across other spaces.
|
||||||
|
</p>
|
||||||
|
<div class="rl-cta-row">
|
||||||
|
<a href="#" class="rl-cta-primary" id="ml-primary"
|
||||||
|
style="background:linear-gradient(to right,#06b6d4,#8b5cf6);color:#fff"
|
||||||
|
onclick="var s=document.querySelector('.rl-hero').closest('[data-space]')?.getAttribute('data-space');if(s&&window.__rspaceNavUrl)window.location.href=window.__rspaceNavUrl(s,'rschedule');return false;">
|
||||||
|
Open My Booking Page
|
||||||
|
</a>
|
||||||
|
<a href="#features" class="rl-cta-secondary">How it works</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="rl-section" id="features" style="border-top:none">
|
||||||
|
<div class="rl-container">
|
||||||
|
<div class="rl-grid-4">
|
||||||
|
<div class="rl-card rl-card--center" style="padding:2rem">
|
||||||
|
<div class="rl-icon-box" style="background:rgba(6,182,212,0.12);font-size:1.5rem">
|
||||||
|
<span style="font-size:1.5rem">📅</span>
|
||||||
|
</div>
|
||||||
|
<h3>Weekly Rules + Overrides</h3>
|
||||||
|
<p>Set recurring weekly availability, then block or open specific dates with one-off overrides.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-card rl-card--center" style="padding:2rem">
|
||||||
|
<div class="rl-icon-box" style="background:rgba(139,92,246,0.12);font-size:1.5rem">
|
||||||
|
<span style="font-size:1.5rem">📧</span>
|
||||||
|
</div>
|
||||||
|
<h3>Google Calendar Sync</h3>
|
||||||
|
<p>Connect your Google Calendar — busy times block bookings, confirmed bookings create gcal events.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-card rl-card--center" style="padding:2rem">
|
||||||
|
<div class="rl-icon-box" style="background:rgba(236,72,153,0.12);font-size:1.5rem">
|
||||||
|
<span style="font-size:1.5rem">🔗</span>
|
||||||
|
</div>
|
||||||
|
<h3>Self-Cancel Links</h3>
|
||||||
|
<p>Every confirmation email includes a signed cancel token — guests reschedule without logging in.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rl-card rl-card--center" style="padding:2rem">
|
||||||
|
<div class="rl-icon-box" style="background:rgba(52,211,153,0.12);font-size:1.5rem">
|
||||||
|
<span style="font-size:1.5rem">👥</span>
|
||||||
|
</div>
|
||||||
|
<h3>Cross-Space Invites</h3>
|
||||||
|
<p>Invite other spaces or users as attendees. They see the booking in their own rSchedule view too.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rl-section" style="text-align:center;padding:4rem 0">
|
||||||
|
<h2 class="rl-heading" style="font-size:1.75rem;background:linear-gradient(to right,#06b6d4,#8b5cf6);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
|
||||||
|
Every space, bookable.
|
||||||
|
</h2>
|
||||||
|
<p style="color:rgba(148,163,184,0.8);margin-top:1rem">
|
||||||
|
<a href="/" style="color:#06b6d4;text-decoration:none">← Back to rSpace</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,465 @@
|
||||||
|
/**
|
||||||
|
* rSchedule module — Calendly-style public booking pages.
|
||||||
|
*
|
||||||
|
* Each space (and each user's personal space) gets a bookable schedule page at
|
||||||
|
* `/:space/rschedule`. Guests pick a slot; hosts manage availability rules,
|
||||||
|
* overrides, and connected Google Calendar under `/:space/rschedule/admin`.
|
||||||
|
*
|
||||||
|
* All persistence via Automerge — no PostgreSQL. Three doc collections per
|
||||||
|
* space: `config` (settings + rules + overrides + gcal state), `bookings`
|
||||||
|
* (bookings this space hosts), `invitations` (bookings this space is attendee
|
||||||
|
* on — written by server on create so cross-space visibility works without
|
||||||
|
* cross-space reads).
|
||||||
|
*
|
||||||
|
* Phase A: scaffold routes, schemas registered, stub handlers return JSON.
|
||||||
|
* Public UI + availability engine + gcal sync + admin come in later phases.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import * as Automerge from "@automerge/automerge";
|
||||||
|
import { renderShell } from "../../server/shell";
|
||||||
|
import { getModuleInfoList } from "../../shared/module";
|
||||||
|
import type { RSpaceModule } from "../../shared/module";
|
||||||
|
import { verifyToken, extractToken } from "../../server/auth";
|
||||||
|
import { resolveCallerRole, roleAtLeast } from "../../server/spaces";
|
||||||
|
import { renderLanding } from "./landing";
|
||||||
|
import type { SyncServer } from "../../server/local-first/sync-server";
|
||||||
|
import {
|
||||||
|
scheduleConfigSchema,
|
||||||
|
scheduleBookingsSchema,
|
||||||
|
scheduleInvitationsSchema,
|
||||||
|
scheduleConfigDocId,
|
||||||
|
scheduleBookingsDocId,
|
||||||
|
scheduleInvitationsDocId,
|
||||||
|
DEFAULT_SETTINGS,
|
||||||
|
DEFAULT_GOOGLE_AUTH,
|
||||||
|
type ScheduleConfigDoc,
|
||||||
|
type ScheduleBookingsDoc,
|
||||||
|
type ScheduleInvitationsDoc,
|
||||||
|
type Booking,
|
||||||
|
type Invitation,
|
||||||
|
type EntityRef,
|
||||||
|
} from "./schemas";
|
||||||
|
|
||||||
|
let _syncServer: SyncServer | null = null;
|
||||||
|
|
||||||
|
const routes = new Hono();
|
||||||
|
|
||||||
|
// ── Doc helpers ──
|
||||||
|
|
||||||
|
function ensureConfigDoc(space: string): ScheduleConfigDoc {
|
||||||
|
const docId = scheduleConfigDocId(space);
|
||||||
|
let doc = _syncServer!.getDoc<ScheduleConfigDoc>(docId);
|
||||||
|
if (!doc) {
|
||||||
|
doc = Automerge.change(Automerge.init<ScheduleConfigDoc>(), "init rschedule config", (d) => {
|
||||||
|
const init = scheduleConfigSchema.init();
|
||||||
|
d.meta = init.meta;
|
||||||
|
d.meta.spaceSlug = space;
|
||||||
|
d.settings = { ...DEFAULT_SETTINGS };
|
||||||
|
d.rules = {};
|
||||||
|
d.overrides = {};
|
||||||
|
d.googleAuth = { ...DEFAULT_GOOGLE_AUTH };
|
||||||
|
d.googleEvents = {};
|
||||||
|
});
|
||||||
|
_syncServer!.setDoc(docId, doc);
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureBookingsDoc(space: string): ScheduleBookingsDoc {
|
||||||
|
const docId = scheduleBookingsDocId(space);
|
||||||
|
let doc = _syncServer!.getDoc<ScheduleBookingsDoc>(docId);
|
||||||
|
if (!doc) {
|
||||||
|
doc = Automerge.change(Automerge.init<ScheduleBookingsDoc>(), "init rschedule bookings", (d) => {
|
||||||
|
const init = scheduleBookingsSchema.init();
|
||||||
|
d.meta = init.meta;
|
||||||
|
d.meta.spaceSlug = space;
|
||||||
|
d.bookings = {};
|
||||||
|
});
|
||||||
|
_syncServer!.setDoc(docId, doc);
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureInvitationsDoc(space: string): ScheduleInvitationsDoc {
|
||||||
|
const docId = scheduleInvitationsDocId(space);
|
||||||
|
let doc = _syncServer!.getDoc<ScheduleInvitationsDoc>(docId);
|
||||||
|
if (!doc) {
|
||||||
|
doc = Automerge.change(Automerge.init<ScheduleInvitationsDoc>(), "init rschedule invitations", (d) => {
|
||||||
|
const init = scheduleInvitationsSchema.init();
|
||||||
|
d.meta = init.meta;
|
||||||
|
d.meta.spaceSlug = space;
|
||||||
|
d.invitations = {};
|
||||||
|
});
|
||||||
|
_syncServer!.setDoc(docId, doc);
|
||||||
|
}
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auth helper (EncryptID passkey JWT) ──
|
||||||
|
|
||||||
|
async function requireAdmin(c: any): Promise<{ ok: true; did: string } | { ok: false; status: number; error: string }> {
|
||||||
|
const token = extractToken(c.req.raw.headers);
|
||||||
|
if (!token) return { ok: false, status: 401, error: "Auth required" };
|
||||||
|
let claims;
|
||||||
|
try { claims = await verifyToken(token); }
|
||||||
|
catch { return { ok: false, status: 401, error: "Invalid token" }; }
|
||||||
|
const space = c.req.param("space");
|
||||||
|
const role = await resolveCallerRole(space, claims);
|
||||||
|
if (!roleAtLeast(role, "moderator")) return { ok: false, status: 403, error: "Moderator role required" };
|
||||||
|
return { ok: true, did: claims.did };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public routes ──
|
||||||
|
|
||||||
|
routes.get("/", (c) => {
|
||||||
|
const space = c.req.param("space");
|
||||||
|
return c.html(
|
||||||
|
renderShell({
|
||||||
|
title: `${space} — Book a time | rSpace`,
|
||||||
|
moduleId: "rschedule",
|
||||||
|
space,
|
||||||
|
enabledModules: getModuleInfoList().map((m) => m.id),
|
||||||
|
scripts: `<script type="module" src="/modules/rschedule/folk-schedule-booking.js?v=1"></script>`,
|
||||||
|
styles: `<link rel="stylesheet" href="/modules/rschedule/booking.css">`,
|
||||||
|
body: `<folk-schedule-booking space="${space}"></folk-schedule-booking>`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.get("/cancel/:id", (c) => {
|
||||||
|
const space = c.req.param("space");
|
||||||
|
const id = c.req.param("id");
|
||||||
|
return c.html(
|
||||||
|
renderShell({
|
||||||
|
title: `Cancel booking | rSpace`,
|
||||||
|
moduleId: "rschedule",
|
||||||
|
space,
|
||||||
|
enabledModules: getModuleInfoList().map((m) => m.id),
|
||||||
|
scripts: `<script type="module" src="/modules/rschedule/folk-schedule-cancel.js?v=1"></script>`,
|
||||||
|
body: `<folk-schedule-cancel space="${space}" booking-id="${id}"></folk-schedule-cancel>`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.get("/admin", (c) => {
|
||||||
|
const space = c.req.param("space");
|
||||||
|
return c.html(
|
||||||
|
renderShell({
|
||||||
|
title: `${space} — Schedule admin | rSpace`,
|
||||||
|
moduleId: "rschedule",
|
||||||
|
space,
|
||||||
|
enabledModules: getModuleInfoList().map((m) => m.id),
|
||||||
|
scripts: `<script type="module" src="/modules/rschedule/folk-schedule-admin.js?v=1"></script>`,
|
||||||
|
body: `<folk-schedule-admin space="${space}"></folk-schedule-admin>`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Public API ──
|
||||||
|
|
||||||
|
routes.get("/api/settings/public", (c) => {
|
||||||
|
const space = c.req.param("space");
|
||||||
|
const doc = ensureConfigDoc(space);
|
||||||
|
const s = doc.settings;
|
||||||
|
return c.json({
|
||||||
|
displayName: s.displayName || space,
|
||||||
|
bookingMessage: s.bookingMessage,
|
||||||
|
slotDurationMin: s.slotDurationMin,
|
||||||
|
maxAdvanceDays: s.maxAdvanceDays,
|
||||||
|
minNoticeHours: s.minNoticeHours,
|
||||||
|
timezone: s.timezone,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.get("/api/availability", (_c) => {
|
||||||
|
// Phase C wires the real engine. For now return empty so UI renders.
|
||||||
|
return _c.json({ slots: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.get("/api/bookings/:id", (c) => {
|
||||||
|
const space = c.req.param("space");
|
||||||
|
const id = c.req.param("id");
|
||||||
|
const token = c.req.query("token");
|
||||||
|
const doc = ensureBookingsDoc(space);
|
||||||
|
const booking = doc.bookings[id];
|
||||||
|
if (!booking) return c.json({ error: "Not found" }, 404);
|
||||||
|
if (booking.cancelToken !== token) return c.json({ error: "Invalid token" }, 403);
|
||||||
|
return c.json({
|
||||||
|
id: booking.id,
|
||||||
|
startTime: booking.startTime,
|
||||||
|
endTime: booking.endTime,
|
||||||
|
timezone: booking.timezone,
|
||||||
|
status: booking.status,
|
||||||
|
guestName: booking.guestName,
|
||||||
|
guestEmail: booking.guestEmail,
|
||||||
|
host: booking.host,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.post("/api/bookings", async (c) => {
|
||||||
|
const space = c.req.param("space");
|
||||||
|
const body = await c.req.json().catch(() => null);
|
||||||
|
if (!body?.startTime || !body?.endTime || !body?.guestName || !body?.guestEmail) {
|
||||||
|
return c.json({ error: "Missing required fields" }, 400);
|
||||||
|
}
|
||||||
|
const id = `bk-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const cancelToken = `ct-${Math.random().toString(36).slice(2, 10)}${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const host: EntityRef = { kind: "space", id: space };
|
||||||
|
|
||||||
|
const booking: Booking = {
|
||||||
|
id,
|
||||||
|
host,
|
||||||
|
guestName: String(body.guestName).slice(0, 200),
|
||||||
|
guestEmail: String(body.guestEmail).slice(0, 200),
|
||||||
|
guestNote: String(body.guestNote || "").slice(0, 2000),
|
||||||
|
attendees: {},
|
||||||
|
startTime: Number(body.startTime),
|
||||||
|
endTime: Number(body.endTime),
|
||||||
|
timezone: String(body.timezone || "UTC"),
|
||||||
|
status: "confirmed",
|
||||||
|
meetingLink: null,
|
||||||
|
googleEventId: null,
|
||||||
|
cancelToken,
|
||||||
|
cancellationReason: null,
|
||||||
|
reminderSentAt: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
_syncServer!.changeDoc<ScheduleBookingsDoc>(scheduleBookingsDocId(space), `create booking ${id}`, (d) => {
|
||||||
|
d.bookings[id] = booking as any;
|
||||||
|
});
|
||||||
|
|
||||||
|
const inviteeRefs: EntityRef[] = Array.isArray(body.invitees) ? body.invitees : [];
|
||||||
|
for (const ref of inviteeRefs) {
|
||||||
|
if (ref.kind !== "space") continue; // user DIDs handled once user-spaces formalized
|
||||||
|
const invId = `inv-${id}-${ref.id}`;
|
||||||
|
const inv: Invitation = {
|
||||||
|
id: invId,
|
||||||
|
bookingId: id,
|
||||||
|
host,
|
||||||
|
title: `Booking with ${host.label || host.id}`,
|
||||||
|
startTime: booking.startTime,
|
||||||
|
endTime: booking.endTime,
|
||||||
|
timezone: booking.timezone,
|
||||||
|
status: booking.status,
|
||||||
|
response: "invited",
|
||||||
|
meetingLink: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
_syncServer!.changeDoc<ScheduleInvitationsDoc>(scheduleInvitationsDocId(ref.id), `receive invite ${invId}`, (d) => {
|
||||||
|
d.invitations[invId] = inv as any;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ id, cancelToken, status: "confirmed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.post("/api/bookings/:id/cancel", async (c) => {
|
||||||
|
const space = c.req.param("space");
|
||||||
|
const id = c.req.param("id");
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const token = body?.token || c.req.query("token");
|
||||||
|
const docId = scheduleBookingsDocId(space);
|
||||||
|
const doc = _syncServer!.getDoc<ScheduleBookingsDoc>(docId);
|
||||||
|
const booking = doc?.bookings[id];
|
||||||
|
if (!booking) return c.json({ error: "Not found" }, 404);
|
||||||
|
if (booking.cancelToken !== token) return c.json({ error: "Invalid token" }, 403);
|
||||||
|
if (booking.status === "cancelled") return c.json({ ok: true, alreadyCancelled: true });
|
||||||
|
|
||||||
|
_syncServer!.changeDoc<ScheduleBookingsDoc>(docId, `cancel booking ${id}`, (d) => {
|
||||||
|
d.bookings[id].status = "cancelled";
|
||||||
|
d.bookings[id].cancellationReason = String(body?.reason || "").slice(0, 500);
|
||||||
|
d.bookings[id].updatedAt = Date.now();
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Admin API (passkey-gated) ──
|
||||||
|
|
||||||
|
routes.get("/api/admin/settings", async (c) => {
|
||||||
|
const auth = await requireAdmin(c);
|
||||||
|
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
|
||||||
|
const space = c.req.param("space");
|
||||||
|
const doc = ensureConfigDoc(space);
|
||||||
|
return c.json({ settings: doc.settings, googleAuth: { connected: doc.googleAuth.connected, email: doc.googleAuth.email } });
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.put("/api/admin/settings", async (c) => {
|
||||||
|
const auth = await requireAdmin(c);
|
||||||
|
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
|
||||||
|
const space = c.req.param("space");
|
||||||
|
const patch = await c.req.json().catch(() => ({}));
|
||||||
|
_syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), "update settings", (d) => {
|
||||||
|
Object.assign(d.settings, patch);
|
||||||
|
});
|
||||||
|
const doc = ensureConfigDoc(space);
|
||||||
|
return c.json({ settings: doc.settings });
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.get("/api/admin/availability", async (c) => {
|
||||||
|
const auth = await requireAdmin(c);
|
||||||
|
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
|
||||||
|
const space = c.req.param("space");
|
||||||
|
const doc = ensureConfigDoc(space);
|
||||||
|
return c.json({ rules: Object.values(doc.rules) });
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.post("/api/admin/availability", async (c) => {
|
||||||
|
const auth = await requireAdmin(c);
|
||||||
|
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
|
||||||
|
const space = c.req.param("space");
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const id = `rule-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
_syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), `add rule ${id}`, (d) => {
|
||||||
|
d.rules[id] = {
|
||||||
|
id,
|
||||||
|
dayOfWeek: Number(body.dayOfWeek ?? 1),
|
||||||
|
startTime: String(body.startTime || "09:00"),
|
||||||
|
endTime: String(body.endTime || "17:00"),
|
||||||
|
isActive: body.isActive !== false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return c.json({ id });
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.put("/api/admin/availability/:id", async (c) => {
|
||||||
|
const auth = await requireAdmin(c);
|
||||||
|
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
|
||||||
|
const space = c.req.param("space");
|
||||||
|
const id = c.req.param("id");
|
||||||
|
const patch = await c.req.json().catch(() => ({}));
|
||||||
|
_syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), `update rule ${id}`, (d) => {
|
||||||
|
if (!d.rules[id]) return;
|
||||||
|
Object.assign(d.rules[id], patch);
|
||||||
|
});
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.delete("/api/admin/availability/:id", async (c) => {
|
||||||
|
const auth = await requireAdmin(c);
|
||||||
|
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
|
||||||
|
const space = c.req.param("space");
|
||||||
|
const id = c.req.param("id");
|
||||||
|
_syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), `delete rule ${id}`, (d) => {
|
||||||
|
delete d.rules[id];
|
||||||
|
});
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.get("/api/admin/overrides", async (c) => {
|
||||||
|
const auth = await requireAdmin(c);
|
||||||
|
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
|
||||||
|
const space = c.req.param("space");
|
||||||
|
const doc = ensureConfigDoc(space);
|
||||||
|
return c.json({ overrides: Object.values(doc.overrides) });
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.post("/api/admin/overrides", async (c) => {
|
||||||
|
const auth = await requireAdmin(c);
|
||||||
|
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
|
||||||
|
const space = c.req.param("space");
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
if (!body?.date) return c.json({ error: "date required (YYYY-MM-DD)" }, 400);
|
||||||
|
const id = `ov-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||||
|
_syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), `add override ${id}`, (d) => {
|
||||||
|
d.overrides[id] = {
|
||||||
|
id,
|
||||||
|
date: String(body.date),
|
||||||
|
isBlocked: body.isBlocked !== false,
|
||||||
|
startTime: body.startTime || null,
|
||||||
|
endTime: body.endTime || null,
|
||||||
|
reason: String(body.reason || ""),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return c.json({ id });
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.delete("/api/admin/overrides/:id", async (c) => {
|
||||||
|
const auth = await requireAdmin(c);
|
||||||
|
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
|
||||||
|
const space = c.req.param("space");
|
||||||
|
const id = c.req.param("id");
|
||||||
|
_syncServer!.changeDoc<ScheduleConfigDoc>(scheduleConfigDocId(space), `delete override ${id}`, (d) => {
|
||||||
|
delete d.overrides[id];
|
||||||
|
});
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.get("/api/admin/bookings", async (c) => {
|
||||||
|
const auth = await requireAdmin(c);
|
||||||
|
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
|
||||||
|
const space = c.req.param("space");
|
||||||
|
const doc = ensureBookingsDoc(space);
|
||||||
|
const rows = Object.values(doc.bookings)
|
||||||
|
.sort((a, b) => b.startTime - a.startTime)
|
||||||
|
.map((b) => ({
|
||||||
|
id: b.id,
|
||||||
|
guestName: b.guestName,
|
||||||
|
guestEmail: b.guestEmail,
|
||||||
|
startTime: b.startTime,
|
||||||
|
endTime: b.endTime,
|
||||||
|
timezone: b.timezone,
|
||||||
|
status: b.status,
|
||||||
|
attendeeCount: Object.keys(b.attendees).length,
|
||||||
|
}));
|
||||||
|
return c.json({ bookings: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.get("/api/invitations", (c) => {
|
||||||
|
const space = c.req.param("space");
|
||||||
|
const doc = ensureInvitationsDoc(space);
|
||||||
|
return c.json({
|
||||||
|
invitations: Object.values(doc.invitations).sort((a, b) => a.startTime - b.startTime),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.get("/api/admin/google/status", async (c) => {
|
||||||
|
const auth = await requireAdmin(c);
|
||||||
|
if (!auth.ok) return c.json({ error: auth.error }, auth.status as any);
|
||||||
|
const space = c.req.param("space");
|
||||||
|
const doc = ensureConfigDoc(space);
|
||||||
|
return c.json(doc.googleAuth);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Module export ──
|
||||||
|
|
||||||
|
export const scheduleModule: RSpaceModule = {
|
||||||
|
id: "rschedule",
|
||||||
|
name: "rSchedule",
|
||||||
|
icon: "📆",
|
||||||
|
description: "Calendly-style booking pages for spaces and users, backed by rCal availability",
|
||||||
|
scoping: { defaultScope: "global", userConfigurable: false },
|
||||||
|
docSchemas: [
|
||||||
|
{ pattern: "{space}:schedule:config", description: "Booking settings, weekly rules, overrides, gcal state", init: scheduleConfigSchema.init },
|
||||||
|
{ pattern: "{space}:schedule:bookings", description: "Bookings this space hosts", init: scheduleBookingsSchema.init },
|
||||||
|
{ pattern: "{space}:schedule:invitations", description: "Bookings this space is attendee on", init: scheduleInvitationsSchema.init },
|
||||||
|
],
|
||||||
|
routes,
|
||||||
|
standaloneDomain: "rschedule.online",
|
||||||
|
landingPage: renderLanding,
|
||||||
|
async onInit(ctx) {
|
||||||
|
_syncServer = ctx.syncServer;
|
||||||
|
ensureConfigDoc("demo");
|
||||||
|
},
|
||||||
|
feeds: [
|
||||||
|
{ id: "bookings", name: "Bookings", kind: "data", description: "Confirmed bookings with start/end times, attendees, and cancel tokens", filterable: true },
|
||||||
|
{ id: "invitations", name: "Invitations", kind: "data", description: "Bookings this space is invited to as an attendee" },
|
||||||
|
],
|
||||||
|
outputPaths: [
|
||||||
|
{ path: "admin", name: "Admin", icon: "⚙️", description: "Configure availability, bookings, and Google Calendar" },
|
||||||
|
{ path: "admin/availability", name: "Availability", icon: "📅", description: "Weekly rules and date overrides" },
|
||||||
|
{ path: "admin/bookings", name: "Bookings", icon: "📋", description: "All hosted bookings" },
|
||||||
|
],
|
||||||
|
onboardingActions: [
|
||||||
|
{ label: "Set Availability", icon: "📅", description: "Define your weekly bookable hours", type: "create", href: "/{space}/rschedule/admin/availability" },
|
||||||
|
{ label: "Connect Google Calendar", icon: "🔄", description: "Block bookings against your gcal busy times", type: "link", href: "/{space}/rschedule/admin" },
|
||||||
|
{ label: "Share Booking Link", icon: "🔗", description: "Send your public booking page to anyone", type: "link", href: "/{space}/rschedule" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
/**
|
||||||
|
* <folk-tasks-activity> — Recent task activity widget for rTasks canvas.
|
||||||
|
*
|
||||||
|
* Derives a lightweight activity feed from task updated_at timestamps until
|
||||||
|
* the server activity endpoint is populated. Each entry shows what changed
|
||||||
|
* most recently (task title + relative time).
|
||||||
|
*
|
||||||
|
* Attribute: space — space slug
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface TaskRow {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
priority: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FolkTasksActivity extends HTMLElement {
|
||||||
|
private shadow: ShadowRoot;
|
||||||
|
private space = '';
|
||||||
|
private tasks: TaskRow[] = [];
|
||||||
|
private loading = true;
|
||||||
|
private error = '';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.shadow = this.attachShadow({ mode: 'open' });
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.space = this.getAttribute('space') || 'demo';
|
||||||
|
this.render();
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
static get observedAttributes() { return ['space']; }
|
||||||
|
attributeChangedCallback(name: string, _old: string, val: string) {
|
||||||
|
if (name === 'space' && val !== this.space) {
|
||||||
|
this.space = val;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getApiBase(): string {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const match = path.match(/^(\/[^/]+)?\/rtasks/);
|
||||||
|
return match ? match[0] : '/rtasks';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.render();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.getApiBase()}/api/spaces/${encodeURIComponent(this.space)}/tasks`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
this.tasks = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
this.loading = false;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private recent(): TaskRow[] {
|
||||||
|
return [...this.tasks]
|
||||||
|
.sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at))
|
||||||
|
.slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
private relTime(iso: string): string {
|
||||||
|
const delta = Date.now() - Date.parse(iso);
|
||||||
|
const m = Math.floor(delta / 60000);
|
||||||
|
if (m < 1) return 'just now';
|
||||||
|
if (m < 60) return `${m}m ago`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return `${h}h ago`;
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
if (d < 7) return `${d}d ago`;
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
private render() {
|
||||||
|
const items = this.recent();
|
||||||
|
this.shadow.innerHTML = `
|
||||||
|
<style>${this.getStyles()}</style>
|
||||||
|
<div class="activity">
|
||||||
|
<div class="list">
|
||||||
|
${this.loading ? '<div class="state">Loading…</div>' :
|
||||||
|
this.error ? `<div class="state err">${this.esc(this.error)}</div>` :
|
||||||
|
items.length === 0 ? '<div class="state">No activity yet.</div>' :
|
||||||
|
items.map(t => this.renderEntry(t)).join('')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
this.bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderEntry(t: TaskRow): string {
|
||||||
|
const isNew = t.created_at === t.updated_at;
|
||||||
|
const verb = isNew ? 'created' : 'updated';
|
||||||
|
const rel = this.relTime(t.updated_at);
|
||||||
|
const statusCls = `status-${t.status.toLowerCase().replace(/_/g, '-')}`;
|
||||||
|
return `<div class="entry" data-task-id="${this.esc(t.id)}">
|
||||||
|
<div class="entry-main">
|
||||||
|
<span class="verb">${verb}</span>
|
||||||
|
<span class="title">${this.esc(t.title)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="entry-meta">
|
||||||
|
<span class="status ${statusCls}">${this.esc(t.status)}</span>
|
||||||
|
<span class="rel">${this.esc(rel)}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindEvents() {
|
||||||
|
this.shadow.querySelectorAll('.entry').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
const id = (el as HTMLElement).dataset.taskId;
|
||||||
|
if (!id) return;
|
||||||
|
this.dispatchEvent(new CustomEvent('task-selected', { bubbles: true, composed: true, detail: { taskId: id } }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStyles(): string {
|
||||||
|
return `
|
||||||
|
:host { display: block; height: 100%; }
|
||||||
|
.activity { display: flex; flex-direction: column; height: 100%; min-height: 0; }
|
||||||
|
.list { flex: 1; overflow-y: auto; min-height: 0; }
|
||||||
|
.state { text-align: center; padding: 1.5rem 1rem; color: var(--rs-text-secondary); font-size: 0.875rem; }
|
||||||
|
.state.err { color: #f87171; }
|
||||||
|
.entry {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--rs-border);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.entry:hover { background: var(--rs-bg-hover); }
|
||||||
|
.entry-main { display: flex; gap: 0.375rem; align-items: baseline; }
|
||||||
|
.verb { font-size: 0.75rem; color: var(--rs-text-secondary); font-weight: 500; }
|
||||||
|
.title { font-size: 0.8125rem; color: var(--rs-text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.entry-meta { display: flex; gap: 0.5rem; align-items: center; margin-top: 0.25rem; }
|
||||||
|
.status {
|
||||||
|
font-size: 0.65rem; font-weight: 600;
|
||||||
|
padding: 0.0625rem 0.375rem; border-radius: 4px;
|
||||||
|
background: var(--rs-bg-hover); color: var(--rs-text-secondary);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.status-done { background: rgba(34,197,94,0.15); color: #16a34a; }
|
||||||
|
.status-in-progress { background: rgba(59,130,246,0.15); color: #2563eb; }
|
||||||
|
.status-review { background: rgba(168,85,247,0.15); color: #9333ea; }
|
||||||
|
.rel { font-size: 0.7rem; color: var(--rs-text-muted); }
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private esc(s: string): string {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customElements.get('folk-tasks-activity')) customElements.define('folk-tasks-activity', FolkTasksActivity);
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
/**
|
||||||
|
* <folk-tasks-backlog> — Flat backlog list widget for rTasks canvas.
|
||||||
|
*
|
||||||
|
* Shows all tasks as a unified, filterable list (cross-status), sorted by
|
||||||
|
* priority then due date. Complements <folk-tasks-board> (column view) by
|
||||||
|
* offering a single sorted stream.
|
||||||
|
*
|
||||||
|
* Attribute: space — space slug
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface TaskRow {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
status: string;
|
||||||
|
priority: string | null;
|
||||||
|
labels: string[];
|
||||||
|
due_date: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FolkTasksBacklog extends HTMLElement {
|
||||||
|
private shadow: ShadowRoot;
|
||||||
|
private space = '';
|
||||||
|
private tasks: TaskRow[] = [];
|
||||||
|
private loading = true;
|
||||||
|
private error = '';
|
||||||
|
private query = '';
|
||||||
|
private priorityFilter: '' | 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT' = '';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.shadow = this.attachShadow({ mode: 'open' });
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.space = this.getAttribute('space') || 'demo';
|
||||||
|
this.render();
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
static get observedAttributes() { return ['space']; }
|
||||||
|
attributeChangedCallback(name: string, _old: string, val: string) {
|
||||||
|
if (name === 'space' && val !== this.space) {
|
||||||
|
this.space = val;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getApiBase(): string {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const match = path.match(/^(\/[^/]+)?\/rtasks/);
|
||||||
|
return match ? match[0] : '/rtasks';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.render();
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.getApiBase()}/api/spaces/${encodeURIComponent(this.space)}/tasks`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
this.tasks = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
this.loading = false;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
private filtered(): TaskRow[] {
|
||||||
|
const q = this.query.trim().toLowerCase();
|
||||||
|
const prio = this.priorityFilter;
|
||||||
|
const priOrder: Record<string, number> = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
|
||||||
|
return this.tasks
|
||||||
|
.filter(t => !q || t.title.toLowerCase().includes(q) || (t.description || '').toLowerCase().includes(q))
|
||||||
|
.filter(t => !prio || t.priority === prio)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const pa = priOrder[a.priority || 'LOW'] ?? 4;
|
||||||
|
const pb = priOrder[b.priority || 'LOW'] ?? 4;
|
||||||
|
if (pa !== pb) return pa - pb;
|
||||||
|
const da = a.due_date ? Date.parse(a.due_date) : Number.MAX_SAFE_INTEGER;
|
||||||
|
const db = b.due_date ? Date.parse(b.due_date) : Number.MAX_SAFE_INTEGER;
|
||||||
|
return da - db;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private render() {
|
||||||
|
const items = this.filtered();
|
||||||
|
this.shadow.innerHTML = `
|
||||||
|
<style>${this.getStyles()}</style>
|
||||||
|
<div class="backlog">
|
||||||
|
<div class="controls">
|
||||||
|
<input class="search" type="search" placeholder="Filter tasks…" value="${this.esc(this.query)}">
|
||||||
|
<select class="prio">
|
||||||
|
<option value="">All priorities</option>
|
||||||
|
<option value="URGENT" ${this.priorityFilter === 'URGENT' ? 'selected' : ''}>Urgent</option>
|
||||||
|
<option value="HIGH" ${this.priorityFilter === 'HIGH' ? 'selected' : ''}>High</option>
|
||||||
|
<option value="MEDIUM" ${this.priorityFilter === 'MEDIUM' ? 'selected' : ''}>Medium</option>
|
||||||
|
<option value="LOW" ${this.priorityFilter === 'LOW' ? 'selected' : ''}>Low</option>
|
||||||
|
</select>
|
||||||
|
<span class="count">${items.length} / ${this.tasks.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="list">
|
||||||
|
${this.loading ? '<div class="state">Loading…</div>' :
|
||||||
|
this.error ? `<div class="state err">${this.esc(this.error)}</div>` :
|
||||||
|
items.length === 0 ? '<div class="state">No matching tasks.</div>' :
|
||||||
|
items.map(t => this.renderRow(t)).join('')}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
this.bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderRow(t: TaskRow): string {
|
||||||
|
const due = t.due_date ? new Date(t.due_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '';
|
||||||
|
const prioClass = `prio-${(t.priority || 'LOW').toLowerCase()}`;
|
||||||
|
const statusCls = `status-${t.status.toLowerCase().replace(/_/g, '-')}`;
|
||||||
|
return `<div class="row" data-task-id="${this.esc(t.id)}">
|
||||||
|
<span class="prio-dot ${prioClass}" title="${this.esc(t.priority || 'LOW')}"></span>
|
||||||
|
<div class="row-main">
|
||||||
|
<div class="row-title">${this.esc(t.title)}</div>
|
||||||
|
<div class="row-meta">
|
||||||
|
<span class="status ${statusCls}">${this.esc(t.status)}</span>
|
||||||
|
${due ? `<span class="due">${this.esc(due)}</span>` : ''}
|
||||||
|
${t.labels.slice(0, 3).map(l => `<span class="label">${this.esc(l)}</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindEvents() {
|
||||||
|
this.shadow.querySelector('.search')?.addEventListener('input', (e) => {
|
||||||
|
this.query = (e.target as HTMLInputElement).value;
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
this.shadow.querySelector('.prio')?.addEventListener('change', (e) => {
|
||||||
|
this.priorityFilter = (e.target as HTMLSelectElement).value as any;
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
this.shadow.querySelectorAll('.row').forEach(el => {
|
||||||
|
el.addEventListener('click', () => {
|
||||||
|
const id = (el as HTMLElement).dataset.taskId;
|
||||||
|
if (!id) return;
|
||||||
|
this.dispatchEvent(new CustomEvent('task-selected', { bubbles: true, composed: true, detail: { taskId: id } }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStyles(): string {
|
||||||
|
return `
|
||||||
|
:host { display: block; height: 100%; }
|
||||||
|
.backlog { display: flex; flex-direction: column; height: 100%; min-height: 0; }
|
||||||
|
.controls {
|
||||||
|
display: flex; gap: 0.5rem; align-items: center;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--rs-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.search, .prio {
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border: 1px solid var(--rs-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--rs-input-bg, var(--rs-bg-surface));
|
||||||
|
color: var(--rs-text-primary);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.search { flex: 1; min-width: 0; }
|
||||||
|
.count { font-size: 0.75rem; color: var(--rs-text-secondary); flex-shrink: 0; }
|
||||||
|
.list { flex: 1; overflow-y: auto; padding: 0.5rem 0; min-height: 0; }
|
||||||
|
.state { text-align: center; padding: 1.5rem 1rem; color: var(--rs-text-secondary); font-size: 0.875rem; }
|
||||||
|
.state.err { color: #f87171; }
|
||||||
|
.row {
|
||||||
|
display: flex; align-items: flex-start; gap: 0.625rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--rs-border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.row:hover { background: var(--rs-bg-hover); }
|
||||||
|
.prio-dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
margin-top: 0.375rem; flex-shrink: 0;
|
||||||
|
background: var(--rs-border);
|
||||||
|
}
|
||||||
|
.prio-urgent { background: #ef4444; }
|
||||||
|
.prio-high { background: #f97316; }
|
||||||
|
.prio-medium { background: #eab308; }
|
||||||
|
.prio-low { background: #60a5fa; }
|
||||||
|
.row-main { flex: 1; min-width: 0; }
|
||||||
|
.row-title { font-size: 0.875rem; color: var(--rs-text-primary); font-weight: 500; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.row-meta { display: flex; flex-wrap: wrap; gap: 0.375rem; margin-top: 0.25rem; align-items: center; }
|
||||||
|
.status {
|
||||||
|
font-size: 0.6875rem; font-weight: 600;
|
||||||
|
padding: 0.0625rem 0.375rem; border-radius: 4px;
|
||||||
|
background: var(--rs-bg-hover); color: var(--rs-text-secondary);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.status-done { background: rgba(34,197,94,0.15); color: #16a34a; }
|
||||||
|
.status-in-progress { background: rgba(59,130,246,0.15); color: #2563eb; }
|
||||||
|
.status-review { background: rgba(168,85,247,0.15); color: #9333ea; }
|
||||||
|
.due { font-size: 0.75rem; color: var(--rs-text-muted); }
|
||||||
|
.label { font-size: 0.6875rem; color: var(--rs-text-secondary); background: var(--rs-bg-hover); padding: 0.0625rem 0.375rem; border-radius: 4px; }
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private esc(s: string): string {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customElements.get('folk-tasks-backlog')) customElements.define('folk-tasks-backlog', FolkTasksBacklog);
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
/**
|
||||||
|
* rTasks canvas bundle entry — imports all widgets + canvas shell.
|
||||||
|
*
|
||||||
|
* Loaded by the /canvas route shell as a single script.
|
||||||
|
* Each import side-effect-registers a custom element.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import '../../../shared/components/folk-widget';
|
||||||
|
import '../../../shared/components/folk-app-canvas';
|
||||||
|
import './folk-tasks-board';
|
||||||
|
import './folk-tasks-backlog';
|
||||||
|
import './folk-tasks-activity';
|
||||||
|
|
@ -869,6 +869,53 @@ routes.get("/", (c) => {
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Canvas prototype — widget-based rApp workspace ──
|
||||||
|
// Experimental replacement for the single-board view: hosts Board, Backlog,
|
||||||
|
// and Activity as togglable widgets on a shared grid canvas.
|
||||||
|
routes.get("/canvas", (c) => {
|
||||||
|
const space = c.req.param("space") || "demo";
|
||||||
|
const body = `
|
||||||
|
<folk-app-canvas app-id="rtasks" space="${escapeAttr(space)}">
|
||||||
|
<template slot="widget-registry">
|
||||||
|
<ul>
|
||||||
|
<li data-widget-id="board" data-title="Board" data-icon="📋" data-grid-area="board" data-default="1">
|
||||||
|
<folk-tasks-board space="${escapeAttr(space)}"></folk-tasks-board>
|
||||||
|
</li>
|
||||||
|
<li data-widget-id="backlog" data-title="Backlog" data-icon="📝" data-grid-area="backlog">
|
||||||
|
<folk-tasks-backlog space="${escapeAttr(space)}"></folk-tasks-backlog>
|
||||||
|
</li>
|
||||||
|
<li data-widget-id="activity" data-title="Activity" data-icon="⚡" data-grid-area="activity" data-default="1">
|
||||||
|
<folk-tasks-activity space="${escapeAttr(space)}"></folk-tasks-activity>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</folk-app-canvas>`;
|
||||||
|
return c.html(renderShell({
|
||||||
|
title: `${space} — Tasks Canvas | rSpace`,
|
||||||
|
moduleId: "rtasks",
|
||||||
|
spaceSlug: space,
|
||||||
|
modules: getModuleInfoList(),
|
||||||
|
theme: "dark",
|
||||||
|
body,
|
||||||
|
scripts: `<script type="module" src="/modules/rtasks/folk-tasks-canvas.js?v=1"></script>`,
|
||||||
|
styles: `<link rel="stylesheet" href="/modules/rtasks/tasks.css">
|
||||||
|
<style>
|
||||||
|
folk-app-canvas::part(grid) {
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
grid-template-areas: "board activity";
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
folk-app-canvas::part(grid) { grid-template-columns: 1fr; grid-template-areas: none; grid-template-rows: auto; }
|
||||||
|
}
|
||||||
|
</style>`,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
function escapeAttr(s: string): string {
|
||||||
|
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
export const tasksModule: RSpaceModule = {
|
export const tasksModule: RSpaceModule = {
|
||||||
id: "rtasks",
|
id: "rtasks",
|
||||||
name: "rTasks",
|
name: "rTasks",
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@ import { agentsModule } from "../modules/ragents/mod";
|
||||||
import { docsModule } from "../modules/rdocs/mod";
|
import { docsModule } from "../modules/rdocs/mod";
|
||||||
import { designModule } from "../modules/rdesign/mod";
|
import { designModule } from "../modules/rdesign/mod";
|
||||||
import { mindersModule } from "../modules/rminders/mod";
|
import { mindersModule } from "../modules/rminders/mod";
|
||||||
|
import { scheduleModule } from "../modules/rschedule/mod";
|
||||||
import { bnbModule } from "../modules/rbnb/mod";
|
import { bnbModule } from "../modules/rbnb/mod";
|
||||||
import { vnbModule } from "../modules/rvnb/mod";
|
import { vnbModule } from "../modules/rvnb/mod";
|
||||||
import { crowdsurfModule } from "../modules/crowdsurf/mod";
|
import { crowdsurfModule } from "../modules/crowdsurf/mod";
|
||||||
|
|
@ -168,6 +169,7 @@ registerModule(splatModule);
|
||||||
registerModule(photosModule);
|
registerModule(photosModule);
|
||||||
registerModule(socialsModule);
|
registerModule(socialsModule);
|
||||||
registerModule(mindersModule);
|
registerModule(mindersModule);
|
||||||
|
registerModule(scheduleModule);
|
||||||
registerModule(meetsModule);
|
registerModule(meetsModule);
|
||||||
registerModule(chatsModule);
|
registerModule(chatsModule);
|
||||||
registerModule(agentsModule);
|
registerModule(agentsModule);
|
||||||
|
|
|
||||||
|
|
@ -756,6 +756,26 @@ export default defineConfig({
|
||||||
resolve(__dirname, "dist/modules/rtasks/tasks.css"),
|
resolve(__dirname, "dist/modules/rtasks/tasks.css"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Build rTasks canvas bundle (canvas shell + widgets)
|
||||||
|
await wasmBuild({
|
||||||
|
configFile: false,
|
||||||
|
root: resolve(__dirname, "modules/rtasks/components"),
|
||||||
|
build: {
|
||||||
|
emptyOutDir: false,
|
||||||
|
outDir: resolve(__dirname, "dist/modules/rtasks"),
|
||||||
|
lib: {
|
||||||
|
entry: resolve(__dirname, "modules/rtasks/components/folk-tasks-canvas.ts"),
|
||||||
|
formats: ["es"],
|
||||||
|
fileName: () => "folk-tasks-canvas.js",
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: "folk-tasks-canvas.js",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Build chats module component
|
// Build chats module component
|
||||||
await wasmBuild({
|
await wasmBuild({
|
||||||
configFile: false,
|
configFile: false,
|
||||||
|
|
@ -1462,6 +1482,31 @@ export default defineConfig({
|
||||||
resolve(__dirname, "dist/modules/rminders/automation-canvas.css"),
|
resolve(__dirname, "dist/modules/rminders/automation-canvas.css"),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Build rschedule booking components
|
||||||
|
mkdirSync(resolve(__dirname, "dist/modules/rschedule"), { recursive: true });
|
||||||
|
for (const name of ["folk-schedule-booking", "folk-schedule-cancel", "folk-schedule-admin"]) {
|
||||||
|
await wasmBuild({
|
||||||
|
configFile: false,
|
||||||
|
root: resolve(__dirname, "modules/rschedule/components"),
|
||||||
|
build: {
|
||||||
|
emptyOutDir: false,
|
||||||
|
outDir: resolve(__dirname, "dist/modules/rschedule"),
|
||||||
|
lib: {
|
||||||
|
entry: resolve(__dirname, `modules/rschedule/components/${name}.ts`),
|
||||||
|
formats: ["es"],
|
||||||
|
fileName: () => `${name}.js`,
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
output: { entryFileNames: `${name}.js` },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
copyFileSync(
|
||||||
|
resolve(__dirname, "modules/rschedule/components/booking.css"),
|
||||||
|
resolve(__dirname, "dist/modules/rschedule/booking.css"),
|
||||||
|
);
|
||||||
|
|
||||||
// ── Demo infrastructure ──
|
// ── Demo infrastructure ──
|
||||||
|
|
||||||
// Build demo-sync-vanilla library
|
// Build demo-sync-vanilla library
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue