rspace-online/modules/rschedule/components/folk-reminders-widget.ts

284 lines
9.9 KiB
TypeScript

/**
* <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 = "";
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.loadReminders();
}
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: #e2e8f0; }
.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: #64748b; }
.rw-btn-ghost:hover { color: #e2e8f0; }
.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: #64748b; margin-top: 2px; }
.rw-card-source { font-size: 10px; color: #94a3b8; }
.rw-actions { display: flex; gap: 2px; flex-shrink: 0; }
.rw-empty { text-align: center; padding: 20px; color: #64748b; font-size: 13px; }
.rw-loading { text-align: center; padding: 20px; color: #94a3b8; font-size: 13px; }
.rw-form { background: rgba(15,23,42,0.6); 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 #334155; background: rgba(30,41,59,0.8); color: #e2e8f0; 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);