rspace-online/modules/cal/components/folk-calendar-view.ts

239 lines
10 KiB
TypeScript

/**
* <folk-calendar-view> — temporal coordination calendar.
*
* Month grid view with event dots, lunar phase overlay,
* event creation, and source filtering.
*/
class FolkCalendarView extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private currentDate = new Date();
private events: any[] = [];
private sources: any[] = [];
private lunarData: Record<string, { phase: string; illumination: number }> = {};
private showLunar = true;
private selectedDate = "";
private selectedEvent: any = null;
private error = "";
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.loadMonth();
this.render();
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^\/([^/]+)\/cal/);
return match ? `/${match[1]}/cal` : "";
}
private async loadMonth() {
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
const start = `${year}-${String(month + 1).padStart(2, "0")}-01`;
const lastDay = new Date(year, month + 1, 0).getDate();
const end = `${year}-${String(month + 1).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`;
const base = this.getApiBase();
try {
const [eventsRes, sourcesRes, lunarRes] = await Promise.all([
fetch(`${base}/api/events?start=${start}&end=${end}`),
fetch(`${base}/api/sources`),
fetch(`${base}/api/lunar?start=${start}&end=${end}`),
]);
if (eventsRes.ok) { const data = await eventsRes.json(); this.events = data.results || []; }
if (sourcesRes.ok) { const data = await sourcesRes.json(); this.sources = data.results || []; }
if (lunarRes.ok) { this.lunarData = await lunarRes.json(); }
} catch { /* offline fallback */ }
this.render();
}
private navigate(delta: number) {
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + delta, 1);
this.loadMonth();
}
private getMoonEmoji(phase: string): string {
const map: Record<string, string> = {
new_moon: "\u{1F311}", waxing_crescent: "\u{1F312}", first_quarter: "\u{1F313}",
waxing_gibbous: "\u{1F314}", full_moon: "\u{1F315}", waning_gibbous: "\u{1F316}",
last_quarter: "\u{1F317}", waning_crescent: "\u{1F318}",
};
return map[phase] || "";
}
private render() {
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
const monthName = this.currentDate.toLocaleString("default", { month: "long" });
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
* { box-sizing: border-box; }
.header { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; }
.nav-btn { padding: 6px 12px; border-radius: 6px; border: 1px solid #444; background: #1e1e2e; color: #ccc; cursor: pointer; font-size: 16px; }
.nav-btn:hover { border-color: #666; }
.header-title { font-size: 18px; font-weight: 600; flex: 1; text-align: center; }
.toggle-btn { padding: 6px 12px; border-radius: 6px; border: 1px solid #444; background: #1e1e2e; color: #ccc; cursor: pointer; font-size: 12px; }
.toggle-btn.active { border-color: #6366f1; color: #6366f1; }
.create-btn { padding: 6px 14px; border-radius: 6px; border: none; background: #6366f1; color: #fff; font-weight: 600; cursor: pointer; font-size: 12px; }
.weekdays { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; margin-bottom: 4px; }
.weekday { text-align: center; font-size: 11px; color: #666; padding: 4px; font-weight: 600; }
.grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.day {
background: #16161e; border: 1px solid #222; border-radius: 6px;
min-height: 80px; padding: 6px; cursor: pointer; position: relative;
}
.day:hover { border-color: #444; }
.day.today { border-color: #6366f1; }
.day.other-month { opacity: 0.3; }
.day-num { font-size: 12px; font-weight: 600; margin-bottom: 2px; display: flex; justify-content: space-between; }
.moon { font-size: 10px; opacity: 0.7; }
.event-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; margin: 1px; }
.event-dots { display: flex; flex-wrap: wrap; gap: 1px; }
.event-label { font-size: 9px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #aaa; line-height: 1.4; }
.event-modal {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.modal-content { background: #1e1e2e; border: 1px solid #333; border-radius: 12px; padding: 20px; max-width: 400px; width: 90%; }
.modal-title { font-size: 16px; font-weight: 600; margin-bottom: 12px; }
.modal-field { font-size: 13px; color: #aaa; margin-bottom: 6px; }
.modal-close { float: right; background: none; border: none; color: #888; font-size: 18px; cursor: pointer; }
.sources { display: flex; gap: 6px; margin-bottom: 12px; flex-wrap: wrap; }
.source-badge { font-size: 10px; padding: 3px 8px; border-radius: 10px; border: 1px solid #333; }
</style>
${this.error ? `<div style="color:#ef5350;text-align:center;padding:8px">${this.esc(this.error)}</div>` : ""}
<div class="header">
<button class="nav-btn" id="prev">\u2190</button>
<span class="header-title">${monthName} ${year}</span>
<button class="toggle-btn ${this.showLunar ? "active" : ""}" id="toggle-lunar">\u{1F319} Lunar</button>
<button class="nav-btn" id="next">\u2192</button>
</div>
${this.sources.length > 0 ? `<div class="sources">
${this.sources.map(s => `<span class="source-badge" style="border-color:${s.color || "#666"};color:${s.color || "#aaa"}">${this.esc(s.name)}</span>`).join("")}
</div>` : ""}
<div class="weekdays">
${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map(d => `<div class="weekday">${d}</div>`).join("")}
</div>
<div class="grid">
${this.renderDays(year, month)}
</div>
${this.selectedEvent ? this.renderEventModal() : ""}
`;
this.attachListeners();
}
private renderDays(year: number, month: number): string {
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
let html = "";
// Previous month padding
const prevDays = new Date(year, month, 0).getDate();
for (let i = firstDay - 1; i >= 0; i--) {
html += `<div class="day other-month"><div class="day-num">${prevDays - i}</div></div>`;
}
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const isToday = dateStr === todayStr;
const dayEvents = this.events.filter(e => e.start_time && e.start_time.startsWith(dateStr));
const lunar = this.lunarData[dateStr];
html += `<div class="day ${isToday ? "today" : ""}" data-date="${dateStr}">
<div class="day-num">
<span>${d}</span>
${this.showLunar && lunar ? `<span class="moon">${this.getMoonEmoji(lunar.phase)}</span>` : ""}
</div>
${dayEvents.length > 0 ? `
<div class="event-dots">
${dayEvents.slice(0, 3).map(e => `<span class="event-dot" style="background:${e.source_color || "#6366f1"}"></span>`).join("")}
${dayEvents.length > 3 ? `<span style="font-size:9px;color:#888">+${dayEvents.length - 3}</span>` : ""}
</div>
${dayEvents.slice(0, 2).map(e => `<div class="event-label" data-event='${JSON.stringify({ id: e.id })}'>${this.esc(e.title)}</div>`).join("")}
` : ""}
</div>`;
}
// Next month padding
const totalCells = firstDay + daysInMonth;
const remaining = (7 - (totalCells % 7)) % 7;
for (let i = 1; i <= remaining; i++) {
html += `<div class="day other-month"><div class="day-num">${i}</div></div>`;
}
return html;
}
private renderEventModal(): string {
const e = this.selectedEvent;
return `
<div class="event-modal" id="modal-overlay">
<div class="modal-content">
<button class="modal-close" id="modal-close">\u2715</button>
<div class="modal-title">${this.esc(e.title)}</div>
${e.description ? `<div class="modal-field">${this.esc(e.description)}</div>` : ""}
<div class="modal-field">When: ${new Date(e.start_time).toLocaleString()}${e.end_time ? ` \u2014 ${new Date(e.end_time).toLocaleString()}` : ""}</div>
${e.location_name ? `<div class="modal-field">Where: ${this.esc(e.location_name)}</div>` : ""}
${e.source_name ? `<div class="modal-field">Source: ${this.esc(e.source_name)}</div>` : ""}
${e.is_virtual ? `<div class="modal-field">Virtual: ${this.esc(e.virtual_platform || "")} ${e.virtual_url ? `<a href="${e.virtual_url}" target="_blank" style="color:#6366f1">Join</a>` : ""}</div>` : ""}
</div>
</div>
`;
}
private attachListeners() {
this.shadow.getElementById("prev")?.addEventListener("click", () => this.navigate(-1));
this.shadow.getElementById("next")?.addEventListener("click", () => this.navigate(1));
this.shadow.getElementById("toggle-lunar")?.addEventListener("click", () => {
this.showLunar = !this.showLunar;
this.render();
});
this.shadow.querySelectorAll("[data-event]").forEach(el => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const data = JSON.parse((el as HTMLElement).dataset.event!);
this.selectedEvent = this.events.find(ev => ev.id === data.id);
this.render();
});
});
this.shadow.getElementById("modal-overlay")?.addEventListener("click", (e) => {
if ((e.target as HTMLElement).id === "modal-overlay") { this.selectedEvent = null; this.render(); }
});
this.shadow.getElementById("modal-close")?.addEventListener("click", () => {
this.selectedEvent = null; this.render();
});
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
}
customElements.define("folk-calendar-view", FolkCalendarView);