Compare commits

...

2 Commits

Author SHA1 Message Date
Jeff Emmett 40d26d7ffd Merge branch 'dev'
CI/CD / deploy (push) Failing after 44s Details
2026-04-16 17:04:17 -04:00
Jeff Emmett dda7760433 infra(traefik): scope rate limit per CF-Connecting-IP, raise to 600/150
Previous limits (avg 120/min, burst 30) had no sourceCriterion. Traefik
default groups by request Host, so ALL users of rspace.online shared a
single 120/min bucket — tripped almost immediately under normal load
(repeated 429s in rpast, api/mi/models, etc).

Scope per Cloudflare client IP (CF-Connecting-IP header) and raise to
600/min average with 150 burst — interactive use can spike above 120/min
from one client easily (module loads + polling + autosave).
2026-04-16 17:04:15 -04:00
15 changed files with 244 additions and 5939 deletions

View File

@ -175,10 +175,14 @@ services:
- "traefik.http.routers.rspace-rsocials.entrypoints=web"
- "traefik.http.routers.rspace-rsocials.priority=120"
- "traefik.http.routers.rspace-rsocials.service=rspace-online"
# Rate limiting middleware (coarse edge defense — token bucket per source)
- "traefik.http.middlewares.rspace-ratelimit.ratelimit.average=120"
- "traefik.http.middlewares.rspace-ratelimit.ratelimit.burst=30"
# Rate limiting middleware (coarse edge defense — token bucket per client IP)
# Without sourceCriterion Traefik groups by request Host, so one bucket is
# shared across ALL users of a domain — trips instantly under normal use.
# Scope per client IP using Cloudflare's CF-Connecting-IP header.
- "traefik.http.middlewares.rspace-ratelimit.ratelimit.average=600"
- "traefik.http.middlewares.rspace-ratelimit.ratelimit.burst=150"
- "traefik.http.middlewares.rspace-ratelimit.ratelimit.period=1m"
- "traefik.http.middlewares.rspace-ratelimit.ratelimit.sourcecriterion.requestheadername=CF-Connecting-IP"
- "traefik.http.routers.rspace-main.middlewares=rspace-ratelimit"
- "traefik.http.routers.rspace-canvas.middlewares=rspace-ratelimit"
# Service configuration

View File

@ -167,6 +167,23 @@ const styles = css`
width: 16px;
}
/* Touch devices: enlarge handles to 22px so they're reachable,
and make rotation handles visible (tinted dot). Hover-only reveal
doesn't work on touch. */
@media (pointer: coarse) {
[part^="resize"] {
width: 22px;
border-width: 2px;
border-radius: 4px;
}
[part^="rotation"] {
opacity: 0.6;
width: 26px;
background: hsl(214, 84%, 56%);
border-radius: 50%;
}
}
[part$="top-left"] {
top: 0;
left: 0;

View File

@ -1,9 +1,5 @@
/**
* <folk-payments-dashboard> Payments dashboard showing requests in/out.
*
* Tabs:
* - Payments In: payment requests the user created (money coming to them)
* - Payments Out: placeholder for future on-chain tx tracking
* <folk-payments-dashboard> Payments dashboard showing payment requests.
*/
interface PaymentRow {
@ -27,7 +23,6 @@ class FolkPaymentsDashboard extends HTMLElement {
private authenticated = false;
private loading = true;
private error = '';
private activeTab: 'in' | 'out' = 'in';
private payments: PaymentRow[] = [];
constructor() {
@ -119,16 +114,8 @@ class FolkPaymentsDashboard extends HTMLElement {
<a class="btn btn-primary" href="${this.getSpacePrefix()}/rcart/request">+ Create Payment Request</a>
</div>
<div class="tabs">
<button class="tab ${this.activeTab === 'in' ? 'active' : ''}" data-tab="in">
Payments In
${this.payments.length > 0 ? `<span class="tab-count">${this.payments.length}</span>` : ''}
</button>
<button class="tab ${this.activeTab === 'out' ? 'active' : ''}" data-tab="out">Payments Out</button>
</div>
<div class="tab-content">
${this.activeTab === 'in' ? this.renderPaymentsIn() : this.renderPaymentsOut()}
${this.renderPaymentsIn()}
</div>
</div>`;
this.bindEvents();
@ -196,14 +183,6 @@ class FolkPaymentsDashboard extends HTMLElement {
</a>`;
}
private renderPaymentsOut(): string {
return `<div class="empty-state">
<div class="empty-icon">🚀</div>
<p class="empty-title">Coming soon</p>
<p class="empty-desc">Outbound payment tracking will show on-chain transactions from your wallet.</p>
</div>`;
}
private getStatusClass(status: string): string {
switch (status) {
case 'pending': return 'status-pending';
@ -216,13 +195,6 @@ class FolkPaymentsDashboard extends HTMLElement {
}
private bindEvents() {
this.shadow.querySelectorAll('[data-tab]').forEach(el => {
el.addEventListener('click', () => {
this.activeTab = (el as HTMLElement).dataset.tab as 'in' | 'out';
this.render();
});
});
this.shadow.querySelector('[data-action="retry"]')?.addEventListener('click', () => this.fetchPayments());
}
@ -240,13 +212,6 @@ class FolkPaymentsDashboard extends HTMLElement {
.btn-primary:hover { background: #4338ca; }
.btn-sm { padding: 0.25rem 0.625rem; font-size: 0.75rem; }
.tabs { display: flex; border-bottom: 1px solid var(--rs-border); margin-bottom: 1rem; gap: 0; }
.tab { padding: 0.625rem 1rem; border: none; background: none; color: var(--rs-text-secondary); cursor: pointer; font-size: 0.875rem; font-weight: 500; border-bottom: 2px solid transparent; transition: all 0.15s; display: flex; align-items: center; gap: 0.375rem; }
.tab:hover { color: var(--rs-text-primary); }
.tab.active { color: var(--rs-text-primary); border-bottom-color: var(--rs-primary-hover); }
.tab-count { background: var(--rs-bg-hover); color: var(--rs-text-secondary); padding: 0.0625rem 0.375rem; border-radius: 999px; font-size: 0.6875rem; font-weight: 600; }
.tab.active .tab-count { background: rgba(99,102,241,0.15); color: var(--rs-primary-hover); }
.loading { text-align: center; color: var(--rs-text-secondary); padding: 3rem 1rem; font-size: 0.9375rem; }
.error-msg { color: #f87171; text-align: center; padding: 2rem 1rem; font-size: 0.875rem; display: flex; flex-direction: column; align-items: center; gap: 0.75rem; }
@ -283,7 +248,6 @@ class FolkPaymentsDashboard extends HTMLElement {
:host { padding: 1rem 0.75rem; }
.title { font-size: 1.25rem; }
.header { flex-direction: column; align-items: stretch; }
.tab { font-size: 0.8125rem; padding: 0.5rem 0.75rem; }
}
@media (max-width: 480px) {
:host { padding: 0.75rem 0.5rem; }

View File

@ -1,551 +0,0 @@
/* rSchedule Automation Canvas — n8n-style workflow builder */
folk-automation-canvas {
display: block;
height: 100%;
}
.ac-root {
display: flex;
flex-direction: column;
height: 100%;
font-family: system-ui, -apple-system, sans-serif;
color: var(--rs-text-primary, #e2e8f0);
}
/* ── Toolbar ── */
.ac-toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
min-height: 46px;
border-bottom: 1px solid var(--rs-border, #2d2d44);
background: var(--rs-bg-surface, #1a1a2e);
z-index: 10;
}
.ac-toolbar__title {
font-size: 15px;
font-weight: 600;
flex: 1;
display: flex;
align-items: center;
gap: 8px;
}
.ac-toolbar__title input {
background: transparent;
border: 1px solid transparent;
color: var(--rs-text-primary, #e2e8f0);
font-size: 15px;
font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
width: 200px;
}
.ac-toolbar__title input:hover,
.ac-toolbar__title input:focus {
border-color: var(--rs-border-strong, #3d3d5c);
outline: none;
}
.ac-toolbar__actions {
display: flex;
gap: 6px;
align-items: center;
}
.ac-btn {
padding: 6px 12px;
border-radius: 8px;
border: 1px solid var(--rs-input-border, #3d3d5c);
background: var(--rs-input-bg, #16162a);
color: var(--rs-text-primary, #e2e8f0);
font-size: 12px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
white-space: nowrap;
}
.ac-btn:hover {
border-color: var(--rs-border-strong, #4d4d6c);
}
.ac-btn--run {
background: #3b82f622;
border-color: #3b82f655;
color: #60a5fa;
}
.ac-btn--run:hover {
background: #3b82f633;
border-color: #3b82f6;
}
.ac-btn--save {
background: #10b98122;
border-color: #10b98155;
color: #34d399;
}
.ac-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--rs-text-muted, #94a3b8);
}
.ac-toggle input[type="checkbox"] {
accent-color: #10b981;
}
.ac-save-indicator {
font-size: 11px;
color: var(--rs-text-muted, #64748b);
}
/* ── Canvas area ── */
.ac-canvas-area {
flex: 1;
display: flex;
overflow: hidden;
position: relative;
}
/* ── Left sidebar — node palette ── */
.ac-palette {
width: 200px;
min-width: 200px;
border-right: 1px solid var(--rs-border, #2d2d44);
background: var(--rs-bg-surface, #1a1a2e);
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 16px;
}
.ac-palette__group-title {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--rs-text-muted, #94a3b8);
margin-bottom: 4px;
}
.ac-palette__card {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--rs-border, #2d2d44);
background: var(--rs-input-bg, #16162a);
cursor: grab;
font-size: 12px;
transition: border-color 0.15s, background 0.15s;
margin-bottom: 4px;
}
.ac-palette__card:hover {
border-color: #6366f1;
background: #6366f111;
}
.ac-palette__card:active {
cursor: grabbing;
}
.ac-palette__card-icon {
font-size: 16px;
width: 24px;
text-align: center;
flex-shrink: 0;
}
.ac-palette__card-label {
font-weight: 500;
color: var(--rs-text-primary, #e2e8f0);
}
/* ── SVG canvas ── */
.ac-canvas {
flex: 1;
position: relative;
overflow: hidden;
cursor: grab;
background: var(--rs-canvas-bg, #0f0f23);
}
.ac-canvas.grabbing {
cursor: grabbing;
}
.ac-canvas.wiring {
cursor: crosshair;
}
.ac-canvas svg {
display: block;
width: 100%;
height: 100%;
}
/* ── Right sidebar — config ── */
.ac-config {
width: 0;
overflow: hidden;
border-left: 1px solid var(--rs-border, #2d2d44);
background: var(--rs-bg-surface, #1a1a2e);
display: flex;
flex-direction: column;
transition: width 0.2s ease;
}
.ac-config.open {
width: 280px;
min-width: 280px;
}
.ac-config__header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-bottom: 1px solid var(--rs-border, #2d2d44);
font-weight: 600;
font-size: 13px;
}
.ac-config__header-close {
background: none;
border: none;
color: var(--rs-text-muted, #94a3b8);
font-size: 16px;
cursor: pointer;
margin-left: auto;
padding: 2px;
}
.ac-config__body {
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
flex: 1;
}
.ac-config__field {
display: flex;
flex-direction: column;
gap: 4px;
}
.ac-config__field label {
font-size: 11px;
font-weight: 500;
color: var(--rs-text-muted, #94a3b8);
}
.ac-config__field input,
.ac-config__field select,
.ac-config__field textarea {
width: 100%;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--rs-input-border, #3d3d5c);
background: var(--rs-input-bg, #16162a);
color: var(--rs-text-primary, #e2e8f0);
font-size: 12px;
font-family: inherit;
box-sizing: border-box;
}
.ac-config__field textarea {
resize: vertical;
min-height: 60px;
}
.ac-config__field input:focus,
.ac-config__field select:focus,
.ac-config__field textarea:focus {
border-color: #3b82f6;
outline: none;
}
.ac-config__delete {
margin-top: 12px;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid #ef444455;
background: #ef444422;
color: #f87171;
font-size: 12px;
cursor: pointer;
}
.ac-config__delete:hover {
background: #ef444433;
}
/* ── Execution log in config panel ── */
.ac-exec-log {
margin-top: 12px;
border-top: 1px solid var(--rs-border, #2d2d44);
padding-top: 12px;
}
.ac-exec-log__title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--rs-text-muted, #94a3b8);
margin-bottom: 8px;
}
.ac-exec-log__entry {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0;
font-size: 11px;
color: var(--rs-text-muted, #94a3b8);
}
.ac-exec-log__dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.ac-exec-log__dot.success { background: #22c55e; }
.ac-exec-log__dot.error { background: #ef4444; }
.ac-exec-log__dot.running { background: #3b82f6; animation: ac-pulse 1s infinite; }
@keyframes ac-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* ── Zoom controls ── */
.ac-zoom-controls {
position: absolute;
bottom: 12px;
right: 12px;
display: flex;
align-items: center;
gap: 4px;
background: var(--rs-bg-surface, #1a1a2e);
border: 1px solid var(--rs-border-strong, #3d3d5c);
border-radius: 8px;
padding: 4px 6px;
z-index: 5;
}
.ac-zoom-btn {
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--rs-text-primary, #e2e8f0);
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.ac-zoom-btn:hover {
background: var(--rs-bg-surface-raised, #252545);
}
.ac-zoom-level {
font-size: 11px;
color: var(--rs-text-muted, #94a3b8);
min-width: 36px;
text-align: center;
}
/* ── Node styles in SVG ── */
.ac-node { cursor: pointer; }
.ac-node.selected > foreignObject > div {
outline: 2px solid #6366f1;
outline-offset: 2px;
}
.ac-node foreignObject > div {
border-radius: 10px;
border: 1px solid var(--rs-border, #2d2d44);
background: var(--rs-bg-surface, #1a1a2e);
overflow: hidden;
font-size: 12px;
transition: border-color 0.15s;
}
.ac-node:hover foreignObject > div {
border-color: #4f46e5 !important;
}
.ac-node-header {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-bottom: 1px solid var(--rs-border, #2d2d44);
cursor: move;
}
.ac-node-icon {
font-size: 14px;
}
.ac-node-label {
font-weight: 600;
font-size: 12px;
color: var(--rs-text-primary, #e2e8f0);
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ac-node-status {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.ac-node-status.idle { background: #4b5563; }
.ac-node-status.running { background: #3b82f6; animation: ac-pulse 1s infinite; }
.ac-node-status.success { background: #22c55e; }
.ac-node-status.error { background: #ef4444; }
.ac-node-ports {
padding: 6px 10px;
display: flex;
justify-content: space-between;
min-height: 28px;
}
.ac-node-inputs,
.ac-node-outputs {
display: flex;
flex-direction: column;
gap: 4px;
}
.ac-node-outputs {
align-items: flex-end;
}
.ac-port-label {
font-size: 10px;
color: var(--rs-text-muted, #94a3b8);
}
/* ── Port handles in SVG ── */
.ac-port-group { cursor: crosshair; }
.ac-port-dot {
transition: r 0.15s, filter 0.15s;
}
.ac-port-group:hover .ac-port-dot {
r: 7;
filter: drop-shadow(0 0 4px currentColor);
}
.ac-port-group--wiring-source .ac-port-dot {
r: 7;
filter: drop-shadow(0 0 6px currentColor);
}
.ac-port-group--wiring-target .ac-port-dot {
r: 7;
animation: port-pulse 0.8s ease-in-out infinite;
}
.ac-port-group--wiring-dimmed .ac-port-dot {
opacity: 0.2;
}
@keyframes port-pulse {
0%, 100% { filter: drop-shadow(0 0 3px currentColor); }
50% { filter: drop-shadow(0 0 8px currentColor); }
}
/* ── Edge styles ── */
.ac-edge-group { pointer-events: stroke; }
.ac-edge-path {
fill: none;
stroke-width: 2;
}
.ac-edge-hit {
fill: none;
stroke: transparent;
stroke-width: 16;
cursor: pointer;
}
.ac-edge-path.running {
animation: ac-edge-flow 1s linear infinite;
stroke-dasharray: 8 4;
}
@keyframes ac-edge-flow {
to { stroke-dashoffset: -24; }
}
/* ── Wiring temp line ── */
.ac-wiring-temp {
fill: none;
stroke: #6366f1;
stroke-width: 2;
stroke-dasharray: 6 4;
opacity: 0.7;
pointer-events: none;
}
/* ── Workflow selector ── */
.ac-workflow-select {
padding: 4px 8px;
border-radius: 6px;
border: 1px solid var(--rs-input-border, #3d3d5c);
background: var(--rs-input-bg, #16162a);
color: var(--rs-text-primary, #e2e8f0);
font-size: 12px;
}
/* ── Mobile ── */
@media (max-width: 768px) {
.ac-palette {
width: 160px;
min-width: 160px;
padding: 8px;
}
.ac-config.open {
position: absolute;
top: 0;
right: 0;
width: 100%;
height: 100%;
z-index: 20;
min-width: unset;
}
.ac-toolbar {
flex-wrap: wrap;
padding: 8px 12px;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,293 +0,0 @@
/**
* <folk-reminders-widget> lightweight sidebar widget for upcoming reminders.
*
* Fetches upcoming reminders from rSchedule API, renders compact card list,
* supports quick actions (complete, snooze, delete), and accepts drops
* of cross-module items to create new reminders.
*/
interface ReminderData {
id: string;
title: string;
description: string;
remindAt: number;
allDay: boolean;
completed: boolean;
notified: boolean;
sourceModule: string | null;
sourceLabel: string | null;
sourceColor: string | null;
}
class FolkRemindersWidget extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private reminders: ReminderData[] = [];
private loading = false;
private showAddForm = false;
private formTitle = "";
private formDate = "";
private refreshTimer: ReturnType<typeof setInterval> | null = null;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.loadReminders();
// Auto-refresh every 5 minutes to pick up newly-due reminders
this.refreshTimer = setInterval(() => this.loadReminders(), 5 * 60_000);
}
disconnectedCallback() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^(\/[^/]+)/);
return match ? `${match[1]}/rschedule` : "/rschedule";
}
private async loadReminders() {
this.loading = true;
this.render();
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/reminders?upcoming=7&completed=false`);
if (res.ok) {
const data = await res.json();
this.reminders = data.results || [];
}
} catch { this.reminders = []; }
this.loading = false;
this.render();
}
private async completeReminder(id: string) {
const base = this.getApiBase();
await fetch(`${base}/api/reminders/${id}/complete`, { method: "POST" });
await this.loadReminders();
}
private async snoozeReminder(id: string) {
const base = this.getApiBase();
await fetch(`${base}/api/reminders/${id}/snooze`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hours: 24 }),
});
await this.loadReminders();
}
private async deleteReminder(id: string) {
if (!confirm("Delete this reminder?")) return;
const base = this.getApiBase();
await fetch(`${base}/api/reminders/${id}`, { method: "DELETE" });
await this.loadReminders();
}
private async createReminder(title: string, date: string) {
if (!title.trim() || !date) return;
const base = this.getApiBase();
await fetch(`${base}/api/reminders`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title.trim(),
remindAt: new Date(date + "T09:00:00").getTime(),
allDay: true,
syncToCalendar: true,
}),
});
this.showAddForm = false;
this.formTitle = "";
this.formDate = "";
await this.loadReminders();
}
private async handleDrop(e: DragEvent) {
e.preventDefault();
(e.currentTarget as HTMLElement)?.classList.remove("widget-drop-active");
let title = "";
let sourceModule: string | null = null;
let sourceEntityId: string | null = null;
let sourceLabel: string | null = null;
let sourceColor: string | null = null;
const rspaceData = e.dataTransfer?.getData("application/rspace-item");
if (rspaceData) {
try {
const parsed = JSON.parse(rspaceData);
title = parsed.title || "";
sourceModule = parsed.module || null;
sourceEntityId = parsed.entityId || null;
sourceLabel = parsed.label || null;
sourceColor = parsed.color || null;
} catch { /* fall through */ }
}
if (!title) {
title = e.dataTransfer?.getData("text/plain") || "";
}
if (!title.trim()) return;
// Show add form pre-filled
this.formTitle = title.trim();
this.formDate = new Date().toISOString().slice(0, 10);
this.showAddForm = true;
this.render();
// Store source info for when form is submitted
(this as any)._pendingSource = { sourceModule, sourceEntityId, sourceLabel, sourceColor };
}
private formatRelativeTime(ts: number): string {
const diff = ts - Date.now();
if (diff < 0) return "overdue";
if (diff < 3600000) return `in ${Math.floor(diff / 60000)}m`;
if (diff < 86400000) return `in ${Math.floor(diff / 3600000)}h`;
return `in ${Math.floor(diff / 86400000)}d`;
}
private esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
private render() {
const styles = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); }
.rw-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.rw-title { font-size: 14px; font-weight: 600; color: #f59e0b; }
.rw-btn { padding: 4px 10px; border-radius: 6px; border: none; cursor: pointer; font-size: 12px; font-weight: 600; transition: all 0.15s; }
.rw-btn-primary { background: linear-gradient(135deg, #f59e0b, #f97316); color: #0f172a; }
.rw-btn-primary:hover { opacity: 0.9; }
.rw-btn-sm { padding: 2px 6px; font-size: 10px; border-radius: 4px; border: none; cursor: pointer; }
.rw-btn-ghost { background: transparent; color: var(--rs-text-muted); }
.rw-btn-ghost:hover { color: var(--rs-text-primary); }
.rw-card { display: flex; align-items: flex-start; gap: 8px; padding: 8px; border-radius: 6px; margin-bottom: 4px; transition: background 0.15s; }
.rw-card:hover { background: rgba(255,255,255,0.05); }
.rw-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-top: 4px; }
.rw-info { flex: 1; min-width: 0; }
.rw-card-title { font-size: 13px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rw-card-meta { font-size: 11px; color: var(--rs-text-muted); margin-top: 2px; }
.rw-card-source { font-size: 10px; color: var(--rs-text-secondary); }
.rw-actions { display: flex; gap: 2px; flex-shrink: 0; }
.rw-empty { text-align: center; padding: 20px; color: var(--rs-text-muted); font-size: 13px; }
.rw-loading { text-align: center; padding: 20px; color: var(--rs-text-secondary); font-size: 13px; }
.rw-form { background: var(--rs-bg-surface-sunken); border: 1px solid rgba(30,41,59,0.8); border-radius: 8px; padding: 10px; margin-bottom: 8px; }
.rw-input { width: 100%; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--rs-border-strong); background: rgba(30,41,59,0.8); color: var(--rs-text-primary); font-size: 13px; margin-bottom: 6px; box-sizing: border-box; outline: none; font-family: inherit; }
.rw-input:focus { border-color: #f59e0b; }
.rw-form-actions { display: flex; gap: 6px; }
.widget-drop-active { background: rgba(245,158,11,0.1); border: 2px dashed #f59e0b; border-radius: 8px; }
</style>
`;
if (this.loading) {
this.shadow.innerHTML = `${styles}<div class="rw-loading">Loading reminders...</div>`;
return;
}
const cards = this.reminders.map(r => `
<div class="rw-card">
<div class="rw-dot" style="background:${r.sourceColor || "#f59e0b"}"></div>
<div class="rw-info">
<div class="rw-card-title">${this.esc(r.title)}</div>
<div class="rw-card-meta">${this.formatRelativeTime(r.remindAt)}</div>
${r.sourceLabel ? `<div class="rw-card-source">${this.esc(r.sourceLabel)}</div>` : ""}
</div>
<div class="rw-actions">
<button class="rw-btn-sm rw-btn-ghost" data-complete="${r.id}" title="Complete">&#10003;</button>
<button class="rw-btn-sm rw-btn-ghost" data-snooze="${r.id}" title="Snooze 24h">&#9716;</button>
<button class="rw-btn-sm rw-btn-ghost" data-delete="${r.id}" title="Delete">&#10005;</button>
</div>
</div>
`).join("");
const addForm = this.showAddForm ? `
<div class="rw-form">
<input type="text" class="rw-input" id="rw-title" placeholder="Reminder title..." value="${this.esc(this.formTitle)}">
<input type="date" class="rw-input" id="rw-date" value="${this.formDate}">
<div class="rw-form-actions">
<button class="rw-btn rw-btn-primary" data-action="submit">Add</button>
<button class="rw-btn rw-btn-ghost" data-action="cancel">Cancel</button>
</div>
</div>
` : "";
this.shadow.innerHTML = `
${styles}
<div id="widget-root">
<div class="rw-header">
<span class="rw-title">&#128276; Reminders</span>
<button class="rw-btn rw-btn-primary" data-action="add">+ Add</button>
</div>
${addForm}
${this.reminders.length > 0 ? cards : '<div class="rw-empty">No upcoming reminders</div>'}
</div>
`;
this.attachListeners();
}
private attachListeners() {
// Add button
this.shadow.querySelector("[data-action='add']")?.addEventListener("click", () => {
this.showAddForm = !this.showAddForm;
this.formTitle = "";
this.formDate = new Date().toISOString().slice(0, 10);
this.render();
});
// Form submit
this.shadow.querySelector("[data-action='submit']")?.addEventListener("click", () => {
const title = (this.shadow.getElementById("rw-title") as HTMLInputElement)?.value || "";
const date = (this.shadow.getElementById("rw-date") as HTMLInputElement)?.value || "";
this.createReminder(title, date);
});
// Form cancel
this.shadow.querySelector("[data-action='cancel']")?.addEventListener("click", () => {
this.showAddForm = false;
this.render();
});
// Complete
this.shadow.querySelectorAll<HTMLButtonElement>("[data-complete]").forEach(btn => {
btn.addEventListener("click", () => this.completeReminder(btn.dataset.complete!));
});
// Snooze
this.shadow.querySelectorAll<HTMLButtonElement>("[data-snooze]").forEach(btn => {
btn.addEventListener("click", () => this.snoozeReminder(btn.dataset.snooze!));
});
// Delete
this.shadow.querySelectorAll<HTMLButtonElement>("[data-delete]").forEach(btn => {
btn.addEventListener("click", () => this.deleteReminder(btn.dataset.delete!));
});
// Drop target on the whole widget
const root = this.shadow.getElementById("widget-root");
if (root) {
root.addEventListener("dragover", (e) => {
e.preventDefault();
root.classList.add("widget-drop-active");
});
root.addEventListener("dragleave", () => {
root.classList.remove("widget-drop-active");
});
root.addEventListener("drop", (e) => this.handleDrop(e as DragEvent));
}
}
}
customElements.define("folk-reminders-widget", FolkRemindersWidget);

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +0,0 @@
/* rSchedule module — dark theme */
folk-schedule-app {
display: block;
min-height: 400px;
padding: 20px;
}

View File

@ -1,261 +0,0 @@
/**
* rSchedule landing page persistent job scheduling for rSpace.
*/
export function renderLanding(): string {
return `
<!-- Hero -->
<div class="rl-hero">
<span class="rl-tagline" style="color:#f59e0b;background:rgba(245,158,11,0.1);border-color:rgba(245,158,11,0.2)">
Persistent Scheduling
</span>
<h1 class="rl-heading" style="background:linear-gradient(to right,#f59e0b,#f97316,#ef4444);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Automate (you)rSpace,<br>on (you)rSchedule.
</h1>
<p class="rl-subtitle">
Cron-powered job scheduling with email, webhooks, calendar events, and backlog briefings &mdash; all managed from within rSpace.
</p>
<p class="rl-subtext">
rSchedule replaces system-level crontabs with an <span style="color:#f59e0b;font-weight:600">in-process, persistent scheduler</span>.
Jobs survive restarts, fire on a 60-second tick loop, and are fully configurable through the UI.
</p>
<div class="rl-cta-row">
<a href="#" class="rl-cta-primary" id="ml-primary"
style="background:linear-gradient(to right,#f59e0b,#f97316);color:#0b1120"
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 Scheduler
</a>
<a href="#" class="rl-cta-primary" id="ml-automations"
style="background:linear-gradient(to right,#8b5cf6,#6366f1);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')+'/reminders';return false;">
Automation Canvas
</a>
<a href="#features" class="rl-cta-secondary">Learn More</a>
</div>
</div>
<!-- Features (4-card grid) -->
<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(245,158,11,0.12);font-size:1.5rem">
<span style="font-size:1.5rem">&#9200;</span>
</div>
<h3>Cron Expressions</h3>
<p>Standard cron syntax with timezone support. Schedule anything from every minute to once a year.</p>
</div>
<div class="rl-card rl-card--center" style="padding:2rem">
<div class="rl-icon-box" style="background:rgba(249,115,22,0.12);font-size:1.5rem">
<span style="font-size:1.5rem">&#128231;</span>
</div>
<h3>Email Actions</h3>
<p>Send scheduled emails via SMTP &mdash; morning briefings, weekly digests, monthly audits.</p>
</div>
<div class="rl-card rl-card--center" style="padding:2rem">
<div class="rl-icon-box" style="background:rgba(239,68,68,0.12);font-size:1.5rem">
<span style="font-size:1.5rem">&#128279;</span>
</div>
<h3>Webhook Actions</h3>
<p>Fire HTTP requests on schedule &mdash; trigger builds, sync data, or ping external services.</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">&#128203;</span>
</div>
<h3>Backlog Briefings</h3>
<p>Automated task digests from your Backlog &mdash; morning, weekly, and monthly summaries delivered by email.</p>
</div>
</div>
</div>
</section>
<!-- Your Automations -->
<section class="rl-section" id="automations">
<div class="rl-container">
<h2 style="text-align:center;font-size:1.5rem;margin-bottom:0.5rem;color:#e2e8f0">Your Automations</h2>
<p style="text-align:center;color:rgba(148,163,184,0.7);margin-bottom:2rem;font-size:0.9rem">
Visual workflows built on the automation canvas
</p>
<div id="rs-automations-list" style="min-height:120px">
<p style="text-align:center;color:rgba(148,163,184,0.5);padding:2rem 0">Loading automations&hellip;</p>
</div>
</div>
</section>
<script>
(function() {
var space = 'demo';
var el = document.querySelector('.rl-hero');
if (el) {
var ds = el.closest('[data-space]');
if (ds) space = ds.getAttribute('data-space') || 'demo';
}
var basePath = '/' + space + '/rschedule/';
var container = document.getElementById('rs-automations-list');
fetch(basePath + 'api/workflows')
.then(function(r) { return r.ok ? r.json() : { results: [] }; })
.then(function(data) {
var wfs = data.results || [];
if (wfs.length === 0) {
container.innerHTML =
'<div style="text-align:center;padding:2.5rem 1rem">' +
'<p style="color:rgba(148,163,184,0.6);margin-bottom:1.5rem">No automations yet.</p>' +
'<a href="' + basePath + 'reminders" ' +
'style="display:inline-block;padding:0.6rem 1.5rem;border-radius:8px;' +
'background:linear-gradient(to right,#8b5cf6,#6366f1);color:#fff;text-decoration:none;font-weight:600;font-size:0.9rem">' +
'+ Create your first automation</a>' +
'</div>';
return;
}
var viewMode = wfs.length > 6 ? 'list' : 'grid';
var html = '';
if (viewMode === 'grid') {
html += '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:1rem">';
for (var i = 0; i < wfs.length; i++) {
var w = wfs[i];
var nodeCount = (w.nodes || []).length;
var edgeCount = (w.edges || []).length;
var statusColor = w.enabled ? '#34d399' : '#64748b';
var statusLabel = w.enabled ? 'Active' : 'Disabled';
var lastRun = w.lastRunAt ? new Date(w.lastRunAt).toLocaleDateString() : 'Never';
var runStatusIcon = w.lastRunStatus === 'success' ? '&#10003;' : w.lastRunStatus === 'error' ? '&#10007;' : '&mdash;';
var runStatusColor = w.lastRunStatus === 'success' ? '#34d399' : w.lastRunStatus === 'error' ? '#ef4444' : '#64748b';
// Build a mini node-count summary
var triggers = 0, conditions = 0, actions = 0;
for (var n = 0; n < (w.nodes || []).length; n++) {
var t = (w.nodes[n].type || '');
if (t.indexOf('trigger') === 0) triggers++;
else if (t.indexOf('condition') === 0) conditions++;
else if (t.indexOf('action') === 0) actions++;
}
html += '<a href="' + basePath + 'reminders?wf=' + w.id + '" ' +
'style="display:block;text-decoration:none;border-radius:12px;' +
'background:rgba(30,41,59,0.6);border:1px solid rgba(148,163,184,0.12);' +
'padding:1.25rem;transition:border-color 0.2s,transform 0.15s;cursor:pointer" ' +
'onmouseover="this.style.borderColor=\'rgba(139,92,246,0.4)\';this.style.transform=\'translateY(-2px)\'" ' +
'onmouseout="this.style.borderColor=\'rgba(148,163,184,0.12)\';this.style.transform=\'none\'">' +
// Header row
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem">' +
'<span style="font-weight:600;color:#e2e8f0;font-size:0.95rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + (w.name || 'Untitled') + '</span>' +
'<span style="font-size:0.7rem;padding:2px 8px;border-radius:9999px;background:' + statusColor + '20;color:' + statusColor + ';font-weight:500;white-space:nowrap">' + statusLabel + '</span>' +
'</div>' +
// Node summary
'<div style="display:flex;gap:0.75rem;margin-bottom:0.6rem;font-size:0.8rem;color:rgba(148,163,184,0.7)">' +
(triggers ? '<span style="color:#60a5fa">' + triggers + ' trigger' + (triggers > 1 ? 's' : '') + '</span>' : '') +
(conditions ? '<span style="color:#fbbf24">' + conditions + ' condition' + (conditions > 1 ? 's' : '') + '</span>' : '') +
(actions ? '<span style="color:#34d399">' + actions + ' action' + (actions > 1 ? 's' : '') + '</span>' : '') +
(!nodeCount ? '<span>Empty workflow</span>' : '') +
'</div>' +
// Footer
'<div style="display:flex;align-items:center;justify-content:space-between;font-size:0.75rem;color:rgba(148,163,184,0.5)">' +
'<span>Runs: ' + (w.runCount || 0) + '</span>' +
'<span>Last: <span style="color:' + runStatusColor + '">' + runStatusIcon + '</span> ' + lastRun + '</span>' +
'</div>' +
'</a>';
}
html += '</div>';
} else {
// List view for many workflows
html += '<div style="display:flex;flex-direction:column;gap:0.5rem">';
for (var i = 0; i < wfs.length; i++) {
var w = wfs[i];
var nodeCount = (w.nodes || []).length;
var statusColor = w.enabled ? '#34d399' : '#64748b';
var statusLabel = w.enabled ? 'Active' : 'Disabled';
var lastRun = w.lastRunAt ? new Date(w.lastRunAt).toLocaleDateString() : 'Never';
var runStatusIcon = w.lastRunStatus === 'success' ? '&#10003;' : w.lastRunStatus === 'error' ? '&#10007;' : '&mdash;';
var runStatusColor = w.lastRunStatus === 'success' ? '#34d399' : w.lastRunStatus === 'error' ? '#ef4444' : '#64748b';
html += '<a href="' + basePath + 'reminders?wf=' + w.id + '" ' +
'style="display:flex;align-items:center;gap:1rem;text-decoration:none;border-radius:8px;' +
'background:rgba(30,41,59,0.4);border:1px solid rgba(148,163,184,0.08);' +
'padding:0.75rem 1rem;transition:border-color 0.2s" ' +
'onmouseover="this.style.borderColor=\'rgba(139,92,246,0.4)\'" ' +
'onmouseout="this.style.borderColor=\'rgba(148,163,184,0.08)\'">' +
'<span style="width:8px;height:8px;border-radius:50%;background:' + statusColor + ';flex-shrink:0"></span>' +
'<span style="flex:1;font-weight:500;color:#e2e8f0;font-size:0.9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + (w.name || 'Untitled') + '</span>' +
'<span style="font-size:0.75rem;color:rgba(148,163,184,0.5)">' + nodeCount + ' nodes</span>' +
'<span style="font-size:0.75rem;color:rgba(148,163,184,0.5)">' + (w.runCount || 0) + ' runs</span>' +
'<span style="font-size:0.75rem;color:' + runStatusColor + '">' + runStatusIcon + ' ' + lastRun + '</span>' +
'</a>';
}
html += '</div>';
}
// Add "Open Canvas" link at the bottom
html += '<div style="text-align:center;margin-top:1.5rem">' +
'<a href="' + basePath + 'reminders" ' +
'style="color:#8b5cf6;text-decoration:none;font-size:0.9rem;font-weight:500">' +
'Open Automation Canvas &rarr;</a>' +
'</div>';
container.innerHTML = html;
})
.catch(function() {
container.innerHTML =
'<div style="text-align:center;padding:2rem">' +
'<p style="color:rgba(148,163,184,0.5)">Could not load automations.</p>' +
'<a href="' + basePath + 'reminders" ' +
'style="color:#8b5cf6;text-decoration:none;font-size:0.9rem">Open Automation Canvas &rarr;</a>' +
'</div>';
});
})();
</script>
<!-- How it works -->
<section class="rl-section">
<div class="rl-container">
<h2 style="text-align:center;font-size:1.5rem;margin-bottom:2rem;color:#e2e8f0">How it works</h2>
<div class="rl-grid-2">
<div class="rl-card" style="padding:2rem">
<h3 style="color:#f59e0b">Persistent Jobs</h3>
<p>Jobs are stored in Automerge documents &mdash; they survive container restarts and server reboots. No more lost crontabs.</p>
</div>
<div class="rl-card" style="padding:2rem">
<h3 style="color:#f97316">60-Second Tick Loop</h3>
<p>A lightweight in-process loop checks every 60 seconds for due jobs. No external scheduler process needed.</p>
</div>
</div>
</div>
</section>
<!-- Ecosystem integration -->
<section class="rl-section">
<div class="rl-container">
<h2 style="text-align:center;font-size:1.5rem;margin-bottom:2rem;color:#e2e8f0">Ecosystem Integration</h2>
<div class="rl-grid-3">
<div class="rl-card rl-card--center" style="padding:1.5rem">
<h3>rCal</h3>
<p>Create recurring calendar events automatically via the calendar-event action type.</p>
</div>
<div class="rl-card rl-card--center" style="padding:1.5rem">
<h3>rInbox</h3>
<p>Schedule email delivery through shared SMTP infrastructure.</p>
</div>
<div class="rl-card rl-card--center" style="padding:1.5rem">
<h3>Backlog</h3>
<p>Scan backlog tasks and generate automated priority briefings on any cadence.</p>
</div>
</div>
</div>
</section>
<!-- CTA -->
<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,#f59e0b,#f97316);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
Stop managing crontabs. Start scheduling from rSpace.
</h2>
<p style="color:rgba(148,163,184,0.8);margin-top:1rem">
<a href="/" style="color:#f59e0b;text-decoration:none">&larr; Back to rSpace</a>
</p>
</section>
`;
}

View File

@ -1,158 +0,0 @@
/**
* rSchedule Local-First Client
*
* Wraps the shared local-first stack for collaborative schedule management.
* Jobs, reminders, workflows, and execution logs sync in real-time.
*/
import { DocumentManager } from '../../shared/local-first/document';
import type { DocumentId } from '../../shared/local-first/document';
import { EncryptedDocStore } from '../../shared/local-first/storage';
import { DocSyncManager } from '../../shared/local-first/sync';
import { DocCrypto } from '../../shared/local-first/crypto';
import { scheduleSchema, scheduleDocId, MAX_LOG_ENTRIES } from './schemas';
import type { ScheduleDoc, ScheduleJob, Reminder, Workflow, ExecutionLogEntry } from './schemas';
export class ScheduleLocalFirstClient {
#space: string;
#documents: DocumentManager;
#store: EncryptedDocStore;
#sync: DocSyncManager;
#initialized = false;
constructor(space: string, docCrypto?: DocCrypto) {
this.#space = space;
this.#documents = new DocumentManager();
this.#store = new EncryptedDocStore(space, docCrypto);
this.#sync = new DocSyncManager({
documents: this.#documents,
store: this.#store,
});
this.#documents.registerSchema(scheduleSchema);
}
get isConnected(): boolean { return this.#sync.isConnected; }
async init(): Promise<void> {
if (this.#initialized) return;
await this.#store.open();
const cachedIds = await this.#store.listByModule('schedule', 'jobs');
const cached = await this.#store.loadMany(cachedIds);
for (const [docId, binary] of cached) {
this.#documents.open<ScheduleDoc>(docId, scheduleSchema, binary);
}
await this.#sync.preloadSyncStates(cachedIds);
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[ScheduleClient] Working offline'); }
this.#initialized = true;
}
async subscribe(): Promise<ScheduleDoc | null> {
const docId = scheduleDocId(this.#space) as DocumentId;
let doc = this.#documents.get<ScheduleDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<ScheduleDoc>(docId, scheduleSchema, binary)
: this.#documents.open<ScheduleDoc>(docId, scheduleSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
getDoc(): ScheduleDoc | undefined {
return this.#documents.get<ScheduleDoc>(scheduleDocId(this.#space) as DocumentId);
}
onChange(cb: (doc: ScheduleDoc) => void): () => void {
return this.#sync.onChange(scheduleDocId(this.#space) as DocumentId, cb as (doc: any) => void);
}
onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); }
// ── Job CRUD ──
saveJob(job: ScheduleJob): void {
const docId = scheduleDocId(this.#space) as DocumentId;
this.#sync.change<ScheduleDoc>(docId, `Save job ${job.name}`, (d) => {
d.jobs[job.id] = job;
});
}
deleteJob(jobId: string): void {
const docId = scheduleDocId(this.#space) as DocumentId;
this.#sync.change<ScheduleDoc>(docId, `Delete job`, (d) => {
delete d.jobs[jobId];
});
}
toggleJob(jobId: string, enabled: boolean): void {
const docId = scheduleDocId(this.#space) as DocumentId;
this.#sync.change<ScheduleDoc>(docId, `Toggle job`, (d) => {
if (d.jobs[jobId]) {
d.jobs[jobId].enabled = enabled;
d.jobs[jobId].updatedAt = Date.now();
}
});
}
// ── Reminder CRUD ──
saveReminder(reminder: Reminder): void {
const docId = scheduleDocId(this.#space) as DocumentId;
this.#sync.change<ScheduleDoc>(docId, `Save reminder ${reminder.title}`, (d) => {
d.reminders[reminder.id] = reminder;
});
}
deleteReminder(reminderId: string): void {
const docId = scheduleDocId(this.#space) as DocumentId;
this.#sync.change<ScheduleDoc>(docId, `Delete reminder`, (d) => {
delete d.reminders[reminderId];
});
}
completeReminder(reminderId: string): void {
const docId = scheduleDocId(this.#space) as DocumentId;
this.#sync.change<ScheduleDoc>(docId, `Complete reminder`, (d) => {
if (d.reminders[reminderId]) {
d.reminders[reminderId].completed = true;
d.reminders[reminderId].updatedAt = Date.now();
}
});
}
// ── Workflow CRUD ──
saveWorkflow(workflow: Workflow): void {
const docId = scheduleDocId(this.#space) as DocumentId;
this.#sync.change<ScheduleDoc>(docId, `Save workflow ${workflow.name}`, (d) => {
d.workflows[workflow.id] = workflow;
});
}
deleteWorkflow(workflowId: string): void {
const docId = scheduleDocId(this.#space) as DocumentId;
this.#sync.change<ScheduleDoc>(docId, `Delete workflow`, (d) => {
delete d.workflows[workflowId];
});
}
// ── Execution Log ──
appendLogEntry(entry: ExecutionLogEntry): void {
const docId = scheduleDocId(this.#space) as DocumentId;
this.#sync.change<ScheduleDoc>(docId, `Log execution`, (d) => {
if (!d.log) d.log = [] as any;
d.log.push(entry);
// Trim to keep doc size manageable
while (d.log.length > MAX_LOG_ENTRIES) d.log.splice(0, 1);
});
}
async disconnect(): Promise<void> {
await this.#sync.flush();
this.#sync.disconnect();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,415 +0,0 @@
/**
* rSchedule Automerge document schemas.
*
* Granularity: one Automerge document per space (all jobs + execution log).
* DocId format: {space}:schedule:jobs
*/
import type { DocSchema } from '../../shared/local-first/document';
// ── Document types ──
export type ActionType = 'email' | 'webhook' | 'calendar-event' | 'broadcast' | 'backlog-briefing' | 'calendar-reminder';
export interface ScheduleJob {
id: string;
name: string;
description: string;
enabled: boolean;
// Timing
cronExpression: string;
timezone: string;
// Action
actionType: ActionType;
actionConfig: Record<string, unknown>;
// Execution state
lastRunAt: number | null;
lastRunStatus: 'success' | 'error' | null;
lastRunMessage: string;
nextRunAt: number | null;
runCount: number;
// Metadata
createdBy: string;
createdAt: number;
updatedAt: number;
}
export interface ExecutionLogEntry {
id: string;
jobId: string;
status: 'success' | 'error';
message: string;
durationMs: number;
timestamp: number;
}
export interface Reminder {
id: string;
title: string;
description: string;
remindAt: number; // epoch ms — when to fire
allDay: boolean;
timezone: string;
notifyEmail: string | null;
notified: boolean; // has email been sent?
completed: boolean; // dismissed by user?
// Cross-module reference (null for free-form reminders)
sourceModule: string | null; // "rtasks", "rnotes", etc.
sourceEntityId: string | null;
sourceLabel: string | null; // "rTasks Task"
sourceColor: string | null; // "#f97316"
// Optional recurrence
cronExpression: string | null;
// Link to rCal event (bidirectional)
calendarEventId: string | null;
createdBy: string;
createdAt: number;
updatedAt: number;
}
// ── Workflow / Automation types ──
export type AutomationNodeType =
// Triggers
| 'trigger-cron'
| 'trigger-data-change'
| 'trigger-webhook'
| 'trigger-manual'
| 'trigger-proximity'
// Conditions
| 'condition-compare'
| 'condition-geofence'
| 'condition-time-window'
| 'condition-data-filter'
// Actions
| 'action-send-email'
| 'action-post-webhook'
| 'action-create-event'
| 'action-create-task'
| 'action-send-notification'
| 'action-update-data';
export type AutomationNodeCategory = 'trigger' | 'condition' | 'action';
export interface WorkflowNodePort {
name: string;
type: 'trigger' | 'data' | 'boolean';
}
export interface WorkflowNode {
id: string;
type: AutomationNodeType;
label: string;
position: { x: number; y: number };
config: Record<string, unknown>;
// Runtime state (not persisted to Automerge — set during execution)
runtimeStatus?: 'idle' | 'running' | 'success' | 'error';
runtimeMessage?: string;
runtimeDurationMs?: number;
}
export interface WorkflowEdge {
id: string;
fromNode: string;
fromPort: string;
toNode: string;
toPort: string;
}
export interface Workflow {
id: string;
name: string;
enabled: boolean;
nodes: WorkflowNode[];
edges: WorkflowEdge[];
lastRunAt: number | null;
lastRunStatus: 'success' | 'error' | null;
runCount: number;
createdAt: number;
updatedAt: number;
}
export interface AutomationNodeDef {
type: AutomationNodeType;
category: AutomationNodeCategory;
label: string;
icon: string;
description: string;
inputs: WorkflowNodePort[];
outputs: WorkflowNodePort[];
configSchema: { key: string; label: string; type: 'text' | 'number' | 'select' | 'textarea' | 'cron'; options?: string[]; placeholder?: string }[];
}
export const NODE_CATALOG: AutomationNodeDef[] = [
// ── Triggers ──
{
type: 'trigger-cron',
category: 'trigger',
label: 'Cron Schedule',
icon: '⏰',
description: 'Fire on a cron schedule',
inputs: [],
outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'timestamp', type: 'data' }],
configSchema: [
{ key: 'cronExpression', label: 'Cron Expression', type: 'cron', placeholder: '0 9 * * *' },
{ key: 'timezone', label: 'Timezone', type: 'text', placeholder: 'America/Vancouver' },
],
},
{
type: 'trigger-data-change',
category: 'trigger',
label: 'Data Change',
icon: '📊',
description: 'Fire when data changes in any rApp',
inputs: [],
outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
configSchema: [
{ key: 'module', label: 'Module', type: 'select', options: ['rnotes', 'rtasks', 'rcal', 'rnetwork', 'rfiles', 'rvote', 'rflows'] },
{ key: 'field', label: 'Field to Watch', type: 'text', placeholder: 'status' },
],
},
{
type: 'trigger-webhook',
category: 'trigger',
label: 'Webhook Incoming',
icon: '🔗',
description: 'Fire when an external webhook is received',
inputs: [],
outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'payload', type: 'data' }],
configSchema: [
{ key: 'hookId', label: 'Hook ID', type: 'text', placeholder: 'auto-generated' },
],
},
{
type: 'trigger-manual',
category: 'trigger',
label: 'Manual Trigger',
icon: '👆',
description: 'Fire manually via button click',
inputs: [],
outputs: [{ name: 'trigger', type: 'trigger' }],
configSchema: [],
},
{
type: 'trigger-proximity',
category: 'trigger',
label: 'Location Proximity',
icon: '📍',
description: 'Fire when a location approaches a point',
inputs: [],
outputs: [{ name: 'trigger', type: 'trigger' }, { name: 'distance', type: 'data' }],
configSchema: [
{ key: 'lat', label: 'Latitude', type: 'number', placeholder: '49.2827' },
{ key: 'lng', label: 'Longitude', type: 'number', placeholder: '-123.1207' },
{ key: 'radiusKm', label: 'Radius (km)', type: 'number', placeholder: '1' },
],
},
// ── Conditions ──
{
type: 'condition-compare',
category: 'condition',
label: 'Compare Values',
icon: '⚖️',
description: 'Compare two values',
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'value', type: 'data' }],
outputs: [{ name: 'true', type: 'trigger' }, { name: 'false', type: 'trigger' }],
configSchema: [
{ key: 'operator', label: 'Operator', type: 'select', options: ['equals', 'not-equals', 'greater-than', 'less-than', 'contains'] },
{ key: 'compareValue', label: 'Compare To', type: 'text', placeholder: 'value' },
],
},
{
type: 'condition-geofence',
category: 'condition',
label: 'Geofence Check',
icon: '🗺️',
description: 'Check if coordinates are within a radius',
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'coords', type: 'data' }],
outputs: [{ name: 'inside', type: 'trigger' }, { name: 'outside', type: 'trigger' }],
configSchema: [
{ key: 'centerLat', label: 'Center Lat', type: 'number', placeholder: '49.2827' },
{ key: 'centerLng', label: 'Center Lng', type: 'number', placeholder: '-123.1207' },
{ key: 'radiusKm', label: 'Radius (km)', type: 'number', placeholder: '5' },
],
},
{
type: 'condition-time-window',
category: 'condition',
label: 'Time Window',
icon: '🕐',
description: 'Check if current time is within a window',
inputs: [{ name: 'trigger', type: 'trigger' }],
outputs: [{ name: 'in-window', type: 'trigger' }, { name: 'outside', type: 'trigger' }],
configSchema: [
{ key: 'startHour', label: 'Start Hour (0-23)', type: 'number', placeholder: '9' },
{ key: 'endHour', label: 'End Hour (0-23)', type: 'number', placeholder: '17' },
{ key: 'days', label: 'Days', type: 'text', placeholder: '1,2,3,4,5' },
],
},
{
type: 'condition-data-filter',
category: 'condition',
label: 'Data Filter',
icon: '🔍',
description: 'Filter data by field value',
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
outputs: [{ name: 'match', type: 'trigger' }, { name: 'no-match', type: 'trigger' }, { name: 'filtered', type: 'data' }],
configSchema: [
{ key: 'field', label: 'Field Path', type: 'text', placeholder: 'status' },
{ key: 'operator', label: 'Operator', type: 'select', options: ['equals', 'not-equals', 'contains', 'exists'] },
{ key: 'value', label: 'Value', type: 'text', placeholder: 'completed' },
],
},
// ── Actions ──
{
type: 'action-send-email',
category: 'action',
label: 'Send Email',
icon: '📧',
description: 'Send an email via SMTP',
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
outputs: [{ name: 'done', type: 'trigger' }, { name: 'result', type: 'data' }],
configSchema: [
{ key: 'to', label: 'To', type: 'text', placeholder: 'user@example.com' },
{ key: 'subject', label: 'Subject', type: 'text', placeholder: 'Notification from rSpace' },
{ key: 'bodyTemplate', label: 'Body (HTML)', type: 'textarea', placeholder: '<p>Hello {{name}}</p>' },
],
},
{
type: 'action-post-webhook',
category: 'action',
label: 'POST Webhook',
icon: '🌐',
description: 'Send an HTTP POST to an external URL',
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
outputs: [{ name: 'done', type: 'trigger' }, { name: 'response', type: 'data' }],
configSchema: [
{ key: 'url', label: 'URL', type: 'text', placeholder: 'https://api.example.com/hook' },
{ key: 'method', label: 'Method', type: 'select', options: ['POST', 'PUT', 'PATCH'] },
{ key: 'bodyTemplate', label: 'Body Template', type: 'textarea', placeholder: '{"event": "{{event}}"}' },
],
},
{
type: 'action-create-event',
category: 'action',
label: 'Create Calendar Event',
icon: '📅',
description: 'Create an event in rCal',
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
outputs: [{ name: 'done', type: 'trigger' }, { name: 'eventId', type: 'data' }],
configSchema: [
{ key: 'title', label: 'Event Title', type: 'text', placeholder: 'Meeting' },
{ key: 'durationMinutes', label: 'Duration (min)', type: 'number', placeholder: '60' },
],
},
{
type: 'action-create-task',
category: 'action',
label: 'Create Task',
icon: '✅',
description: 'Create a task in rTasks',
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
outputs: [{ name: 'done', type: 'trigger' }, { name: 'taskId', type: 'data' }],
configSchema: [
{ key: 'title', label: 'Task Title', type: 'text', placeholder: 'New task' },
{ key: 'description', label: 'Description', type: 'textarea', placeholder: 'Task details...' },
{ key: 'priority', label: 'Priority', type: 'select', options: ['low', 'medium', 'high', 'urgent'] },
],
},
{
type: 'action-send-notification',
category: 'action',
label: 'Send Notification',
icon: '🔔',
description: 'Send an in-app notification',
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
outputs: [{ name: 'done', type: 'trigger' }],
configSchema: [
{ key: 'title', label: 'Title', type: 'text', placeholder: 'Notification' },
{ key: 'message', label: 'Message', type: 'textarea', placeholder: 'Something happened...' },
{ key: 'level', label: 'Level', type: 'select', options: ['info', 'warning', 'error', 'success'] },
],
},
{
type: 'action-update-data',
category: 'action',
label: 'Update Data',
icon: '💾',
description: 'Update data in an rApp document',
inputs: [{ name: 'trigger', type: 'trigger' }, { name: 'data', type: 'data' }],
outputs: [{ name: 'done', type: 'trigger' }, { name: 'result', type: 'data' }],
configSchema: [
{ key: 'module', label: 'Target Module', type: 'select', options: ['rnotes', 'rtasks', 'rcal', 'rnetwork'] },
{ key: 'operation', label: 'Operation', type: 'select', options: ['create', 'update', 'delete'] },
{ key: 'template', label: 'Data Template (JSON)', type: 'textarea', placeholder: '{"field": "{{value}}"}' },
],
},
];
export interface WorkflowLogEntry {
id: string;
workflowId: string;
nodeResults: { nodeId: string; status: string; message: string; durationMs: number }[];
overallStatus: 'success' | 'error';
timestamp: number;
triggerType: string;
}
export interface ScheduleDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
jobs: Record<string, ScheduleJob>;
reminders: Record<string, Reminder>;
workflows: Record<string, Workflow>;
log: ExecutionLogEntry[];
workflowLog?: WorkflowLogEntry[];
}
// ── Schema registration ──
export const scheduleSchema: DocSchema<ScheduleDoc> = {
module: 'schedule',
collection: 'jobs',
version: 1,
init: (): ScheduleDoc => ({
meta: {
module: 'schedule',
collection: 'jobs',
version: 1,
spaceSlug: '',
createdAt: Date.now(),
},
jobs: {},
reminders: {},
workflows: {},
log: [],
}),
};
// ── Helpers ──
export function scheduleDocId(space: string) {
return `${space}:schedule:jobs` as const;
}
/** Maximum execution log entries to keep per doc */
export const MAX_LOG_ENTRIES = 200;
/** Maximum workflow log entries to keep per doc */
export const MAX_WORKFLOW_LOG = 100;
/** Maximum reminders per space */
export const MAX_REMINDERS = 500;

View File

@ -82,7 +82,7 @@ import { chatsModule } from "../modules/rchats/mod";
import { agentsModule } from "../modules/ragents/mod";
import { docsModule } from "../modules/rdocs/mod";
import { designModule } from "../modules/rdesign/mod";
import { scheduleModule } from "../modules/rschedule/mod";
import { mindersModule } from "../modules/rminders/mod";
import { bnbModule } from "../modules/rbnb/mod";
import { vnbModule } from "../modules/rvnb/mod";
import { crowdsurfModule } from "../modules/crowdsurf/mod";
@ -167,7 +167,7 @@ registerModule(dataModule);
registerModule(splatModule);
registerModule(photosModule);
registerModule(socialsModule);
registerModule(scheduleModule);
registerModule(mindersModule);
registerModule(meetsModule);
registerModule(chatsModule);
registerModule(agentsModule);

View File

@ -1,20 +1,20 @@
/**
* MCP tools for rSchedule (cron jobs, reminders, workflows).
* MCP tools for rMinders (cron jobs, reminders, workflows).
*
* Tools: rschedule_list_jobs, rschedule_list_reminders,
* rschedule_list_workflows, rschedule_create_reminder
* Tools: rminders_list_jobs, rminders_list_reminders,
* rminders_list_workflows, rminders_create_reminder
*/
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import type { SyncServer } from "../local-first/sync-server";
import { scheduleDocId } from "../../modules/rschedule/schemas";
import type { ScheduleDoc } from "../../modules/rschedule/schemas";
import { mindersDocId } from "../../modules/rminders/schemas";
import type { MindersDoc } from "../../modules/rminders/schemas";
import { resolveAccess, accessDeniedResponse } from "./_auth";
export function registerScheduleTools(server: McpServer, syncServer: SyncServer) {
export function registerMindersTools(server: McpServer, syncServer: SyncServer) {
server.tool(
"rschedule_list_jobs",
"rminders_list_jobs",
"List cron/scheduled jobs in a space",
{
space: z.string().describe("Space slug"),
@ -25,7 +25,7 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer)
const access = await resolveAccess(token, space, false);
if (!access.allowed) return accessDeniedResponse(access.reason!);
const doc = syncServer.getDoc<ScheduleDoc>(scheduleDocId(space));
const doc = syncServer.getDoc<MindersDoc>(mindersDocId(space));
if (!doc) {
return { content: [{ type: "text", text: JSON.stringify({ error: "No schedule data found" }) }] };
}
@ -52,7 +52,7 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer)
);
server.tool(
"rschedule_list_reminders",
"rminders_list_reminders",
"List reminders in a space",
{
space: z.string().describe("Space slug"),
@ -65,7 +65,7 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer)
const access = await resolveAccess(token, space, false);
if (!access.allowed) return accessDeniedResponse(access.reason!);
const doc = syncServer.getDoc<ScheduleDoc>(scheduleDocId(space));
const doc = syncServer.getDoc<MindersDoc>(mindersDocId(space));
if (!doc) {
return { content: [{ type: "text", text: JSON.stringify({ error: "No schedule data found" }) }] };
}
@ -102,7 +102,7 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer)
);
server.tool(
"rschedule_list_workflows",
"rminders_list_workflows",
"List automation workflows in a space (summaries only, omits node/edge graph)",
{
space: z.string().describe("Space slug"),
@ -112,7 +112,7 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer)
const access = await resolveAccess(token, space, false);
if (!access.allowed) return accessDeniedResponse(access.reason!);
const doc = syncServer.getDoc<ScheduleDoc>(scheduleDocId(space));
const doc = syncServer.getDoc<MindersDoc>(mindersDocId(space));
if (!doc) {
return { content: [{ type: "text", text: JSON.stringify({ error: "No schedule data found" }) }] };
}
@ -135,7 +135,7 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer)
);
server.tool(
"rschedule_create_reminder",
"rminders_create_reminder",
"Create a new reminder (requires auth token + space membership)",
{
space: z.string().describe("Space slug"),
@ -151,8 +151,8 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer)
const access = await resolveAccess(token, space, true);
if (!access.allowed) return accessDeniedResponse(access.reason!);
const docId = scheduleDocId(space);
const doc = syncServer.getDoc<ScheduleDoc>(docId);
const docId = mindersDocId(space);
const doc = syncServer.getDoc<MindersDoc>(docId);
if (!doc) {
return { content: [{ type: "text", text: JSON.stringify({ error: "No schedule data found" }) }], isError: true };
}
@ -160,7 +160,7 @@ export function registerScheduleTools(server: McpServer, syncServer: SyncServer)
const reminderId = `rem-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const now = Date.now();
syncServer.changeDoc<ScheduleDoc>(docId, `Create reminder ${title}`, (d) => {
syncServer.changeDoc<MindersDoc>(docId, `Create reminder ${title}`, (d) => {
if (!d.reminders) (d as any).reminders = {};
d.reminders[reminderId] = {
id: reminderId,

View File

@ -4,6 +4,10 @@
* Any module card/list-item can become draggable by calling
* `makeDraggable(el, payload)` the calendar and reminders widget
* will accept the drop via `application/rspace-item`.
*
* Uses native HTML5 drag for mouse, and a PointerEvent-based fallback
* for touch/pen that synthesizes real DragEvents with a constructed
* DataTransfer drop receivers don't need to change.
*/
export interface RSpaceItemPayload {
@ -28,13 +32,19 @@ export const MODULE_COLORS: Record<string, string> = {
rtube: "#a855f7", // purple
};
const TOUCH_DRAG_THRESHOLD = 8; // px before a touch becomes a drag
const TOUCH_DRAG_LONGPRESS_MS = 350;
/**
* Make an element draggable with the rspace-item protocol.
* Adds draggable attribute and dragstart handler.
* Adds draggable attribute and dragstart handler for mouse, plus a
* pointer-based touch/pen fallback that fires synthetic DragEvents.
*/
export function makeDraggable(el: HTMLElement, payload: RSpaceItemPayload) {
el.draggable = true;
el.style.cursor = "grab";
// Native HTML5 drag (mouse / desktop trackpad)
el.addEventListener("dragstart", (e) => {
if (!e.dataTransfer) return;
e.dataTransfer.setData("application/rspace-item", JSON.stringify(payload));
@ -45,6 +55,9 @@ export function makeDraggable(el: HTMLElement, payload: RSpaceItemPayload) {
el.addEventListener("dragend", () => {
el.style.opacity = "";
});
// Pointer-based fallback for touch + pen (iOS/iPadOS/Android)
attachPointerDragFallback(el, payload);
}
/**
@ -62,3 +75,189 @@ export function makeDraggableAll(
if (payload) makeDraggable(el, payload);
});
}
// ── Pointer-based drag fallback ──────────────────────────────────────
interface TouchDragState {
el: HTMLElement;
payload: RSpaceItemPayload;
startX: number;
startY: number;
started: boolean;
pointerId: number;
dataTransfer: DataTransfer | null;
ghost: HTMLElement | null;
lastTarget: Element | null;
longPressTimer: ReturnType<typeof setTimeout> | null;
}
function attachPointerDragFallback(el: HTMLElement, payload: RSpaceItemPayload) {
let state: TouchDragState | null = null;
el.addEventListener("pointerdown", (e) => {
// Mouse goes through native HTML5 drag path — skip.
if (e.pointerType === "mouse") return;
// Only primary button
if (e.button !== 0) return;
// If inside an editable surface, don't hijack
const target = e.target as HTMLElement | null;
if (target?.closest("input, textarea, [contenteditable='true']")) return;
state = {
el,
payload,
startX: e.clientX,
startY: e.clientY,
started: false,
pointerId: e.pointerId,
dataTransfer: null,
ghost: null,
lastTarget: null,
longPressTimer: null,
};
// Long-press alternative: start drag after 350ms even without movement
state.longPressTimer = setTimeout(() => {
if (state && !state.started) beginPointerDrag(state, e.clientX, e.clientY);
}, TOUCH_DRAG_LONGPRESS_MS);
});
window.addEventListener("pointermove", (e) => {
if (!state || e.pointerId !== state.pointerId) return;
if (!state.started) {
const dx = e.clientX - state.startX;
const dy = e.clientY - state.startY;
if (Math.hypot(dx, dy) < TOUCH_DRAG_THRESHOLD) return;
beginPointerDrag(state, e.clientX, e.clientY);
}
if (!state.started) return;
// Prevent scrolling while dragging
e.preventDefault();
// Move ghost
if (state.ghost) {
state.ghost.style.transform = `translate(${e.clientX}px, ${e.clientY}px)`;
}
// Find current drop target (hide ghost briefly so it doesn't block elementFromPoint)
const ghost = state.ghost;
if (ghost) ghost.style.display = "none";
const under = document.elementFromPoint(e.clientX, e.clientY);
if (ghost) ghost.style.display = "";
if (under !== state.lastTarget) {
// dragleave on previous
if (state.lastTarget && state.dataTransfer) {
dispatchDragEvent(state.lastTarget, "dragleave", state.dataTransfer, e.clientX, e.clientY);
}
state.lastTarget = under;
if (under && state.dataTransfer) {
dispatchDragEvent(under, "dragenter", state.dataTransfer, e.clientX, e.clientY);
}
}
if (state.lastTarget && state.dataTransfer) {
dispatchDragEvent(state.lastTarget, "dragover", state.dataTransfer, e.clientX, e.clientY);
}
}, { passive: false });
const finish = (e: PointerEvent) => {
if (!state || e.pointerId !== state.pointerId) return;
if (state.longPressTimer) clearTimeout(state.longPressTimer);
if (state.started) {
// Dispatch drop on current target
if (state.lastTarget && state.dataTransfer) {
dispatchDragEvent(state.lastTarget, "drop", state.dataTransfer, e.clientX, e.clientY);
}
// Dispatch dragend on source
if (state.dataTransfer) {
dispatchDragEvent(state.el, "dragend", state.dataTransfer, e.clientX, e.clientY);
}
state.el.style.opacity = "";
if (state.ghost) state.ghost.remove();
}
state = null;
};
window.addEventListener("pointerup", finish);
window.addEventListener("pointercancel", finish);
}
function beginPointerDrag(state: TouchDragState, x: number, y: number) {
state.started = true;
state.el.style.opacity = "0.4";
// Build a real DataTransfer so existing dragover/drop listeners that
// call `e.dataTransfer.getData('application/rspace-item')` work unchanged.
let dt: DataTransfer;
try {
dt = new DataTransfer();
dt.setData("application/rspace-item", JSON.stringify(state.payload));
dt.setData("text/plain", state.payload.title);
dt.effectAllowed = "copyMove";
} catch {
// Older Safari fallback — we'll still dispatch events, but dataTransfer.getData
// may not work. Consumers can read from (event as any).rspaceItemPayload.
dt = new DataTransfer();
}
state.dataTransfer = dt;
// Let source's existing `dragstart` listeners set extra payloads
// (e.g. rdocs `application/x-rdocs-move`).
dispatchDragEvent(state.el, "dragstart", dt, x, y);
// Build drag ghost (floating preview of the source element)
const ghost = document.createElement("div");
ghost.textContent = state.payload.title;
ghost.style.cssText = `
position: fixed;
top: 0;
left: 0;
transform: translate(${x}px, ${y}px);
z-index: 100000;
pointer-events: none;
padding: 8px 12px;
background: ${state.payload.color || "#3b82f6"};
color: #fff;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
opacity: 0.92;
max-width: 260px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`;
document.body.appendChild(ghost);
state.ghost = ghost;
}
function dispatchDragEvent(
target: Element | EventTarget,
type: string,
dataTransfer: DataTransfer,
clientX: number,
clientY: number,
): void {
let ev: DragEvent;
try {
ev = new DragEvent(type, {
bubbles: true,
cancelable: true,
clientX,
clientY,
dataTransfer,
});
} catch {
// Older browsers: fall back to CustomEvent with dataTransfer attached
const fallback = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperty(fallback, "dataTransfer", { value: dataTransfer, enumerable: true });
Object.defineProperty(fallback, "clientX", { value: clientX, enumerable: true });
Object.defineProperty(fallback, "clientY", { value: clientY, enumerable: true });
target.dispatchEvent(fallback);
return;
}
target.dispatchEvent(ev);
}