284 lines
9.9 KiB
TypeScript
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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
}
|
|
|
|
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">✓</button>
|
|
<button class="rw-btn-sm rw-btn-ghost" data-snooze="${r.id}" title="Snooze 24h">◴</button>
|
|
<button class="rw-btn-sm rw-btn-ghost" data-delete="${r.id}" title="Delete">✕</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">🔔 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);
|