Compare commits
No commits in common. "3e38c6cb1eb61346ef2ba9f0e750c18bb794a950" and "ba95df2a44d125d3f467fa0b5e0f48e1d3fb1cde" have entirely different histories.
3e38c6cb1e
...
ba95df2a44
|
|
@ -576,5 +576,3 @@ class FolkDelegationManager extends HTMLElement {
|
|||
}
|
||||
|
||||
customElements.define("folk-delegation-manager", FolkDelegationManager);
|
||||
|
||||
export {};
|
||||
|
|
|
|||
|
|
@ -334,5 +334,3 @@ class FolkPowerIndices extends HTMLElement {
|
|||
}
|
||||
|
||||
customElements.define("folk-power-indices", FolkPowerIndices);
|
||||
|
||||
export {};
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
/* rSchedule booking + admin components — shared host styles. */
|
||||
folk-schedule-booking,
|
||||
folk-schedule-cancel,
|
||||
folk-schedule-admin {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
/**
|
||||
* <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);
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
/**
|
||||
* <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);
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
/**
|
||||
* <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);
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
/**
|
||||
* 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>
|
||||
`;
|
||||
}
|
||||
|
|
@ -1,465 +0,0 @@
|
|||
/**
|
||||
* 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" },
|
||||
],
|
||||
};
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
/**
|
||||
* <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 {};
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
/**
|
||||
* <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 {};
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
/**
|
||||
* 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,53 +869,6 @@ 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 = {
|
||||
id: "rtasks",
|
||||
name: "rTasks",
|
||||
|
|
|
|||
|
|
@ -83,7 +83,6 @@ import { agentsModule } from "../modules/ragents/mod";
|
|||
import { docsModule } from "../modules/rdocs/mod";
|
||||
import { designModule } from "../modules/rdesign/mod";
|
||||
import { mindersModule } from "../modules/rminders/mod";
|
||||
import { scheduleModule } from "../modules/rschedule/mod";
|
||||
import { bnbModule } from "../modules/rbnb/mod";
|
||||
import { vnbModule } from "../modules/rvnb/mod";
|
||||
import { crowdsurfModule } from "../modules/crowdsurf/mod";
|
||||
|
|
@ -169,7 +168,6 @@ registerModule(splatModule);
|
|||
registerModule(photosModule);
|
||||
registerModule(socialsModule);
|
||||
registerModule(mindersModule);
|
||||
registerModule(scheduleModule);
|
||||
registerModule(meetsModule);
|
||||
registerModule(chatsModule);
|
||||
registerModule(agentsModule);
|
||||
|
|
|
|||
|
|
@ -756,26 +756,6 @@ export default defineConfig({
|
|||
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
|
||||
await wasmBuild({
|
||||
configFile: false,
|
||||
|
|
@ -1482,31 +1462,6 @@ export default defineConfig({
|
|||
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 ──
|
||||
|
||||
// Build demo-sync-vanilla library
|
||||
|
|
|
|||
Loading…
Reference in New Issue