This commit is contained in:
metacryst
2026-04-28 20:05:00 -05:00
commit 0d6c7683ff
123 changed files with 20922 additions and 0 deletions

View File

@@ -0,0 +1,334 @@
import server from "/@server/server.js"
class DesktopCalendarForm extends Shadow {
static COLORS = [
"#9E1C29", "#3D6FAD", "#2A8636", "#B38A1E",
"#B85A1F", "#7A3FA3", "#B23D6B", "#2D8A87",
"#3C9A5F", "#6E9A23", "#7A8428", "#9A2F7D",
"#4F54A8", "#8A5A32", "#546B86", "#A67A1F",
]
constructor(calendars, onSaved, editCalendar = null, onDelete = null, onBack = null) {
super()
this.calendars = calendars
this.onSaved = onSaved
this.editCalendar = editCalendar
this.onDelete = onDelete
this.onBack = onBack
this.selectedColor = editCalendar?.color ?? DesktopCalendarForm.COLORS[0]
if (editCalendar) {
this.originalFormData = {
name: editCalendar.name ?? "",
description: editCalendar.description || "",
color: editCalendar.color ?? ""
}
} else {
this.originalFormData = {
name: "",
description: "",
color: DesktopCalendarForm.COLORS[0]
}
}
}
fieldStyles(el) {
return el
.border("1px solid var(--divider)")
.borderRadius(0.35, em)
.outline("none")
.background("transparent")
.color("var(--headertext)")
.fontSize(0.88, em)
.padding(0.4, em)
.boxSizing("border-box")
.onHover(function(hovering) {
this.style.border = `1px solid ${hovering ? "var(--lightDivider)" : "var(--divider)"}`
})
}
prop(label, contentFn) {
VStack(() => {
p(label)
.margin(0)
.marginBottom(1, em)
.fontSize(0.67, em)
.fontWeight("600")
.letterSpacing("0.06em")
.color("var(--headertext)")
.opacity(0.38)
VStack(() => { contentFn() })
.width(100, pct)
})
.paddingHorizontal(1.5, em)
.paddingTop(0.75, em)
.paddingBottom(0.4, em)
.boxSizing("border-box")
.width(100, pct)
}
render() {
const isEdit = !!this.editCalendar
form(() => {
VStack(() => {
this.renderHeader(isEdit)
this.renderBody(isEdit)
})
.height(100, pct)
.boxSizing("border-box")
})
.height(100, pct)
.onSubmit(e => { e.preventDefault(); this.handleSave() })
.onKeyDown(e => {
if (e.key === "Enter" && e.target.tagName !== "TEXTAREA" && e.target.tagName !== "BUTTON") e.preventDefault()
})
}
renderHeader(isEdit) {
HStack(() => {
VStack(() => {
input("", "100%")
.attr({ name: "name", type: "text", placeholder: "Enter calendar name...", value: this.editCalendar?.name ?? "" })
.border("none")
.outline("none")
.background("transparent")
.color("var(--headertext)")
.fontSize(1.45, em)
.fontWeight("700")
.padding(0)
.onHover(function(hovering) {
this.style.opacity = hovering ? 0.82 : 1;
})
})
.flex(1)
.paddingHorizontal(1.4, em)
.paddingTop(2.5, em)
.paddingBottom(0.5, em)
.justifyContent("center")
if (isEdit) {
button("Delete")
.attr({ type: "button" })
.fontSize(0.8, em)
.fontWeight("600")
.background("transparent")
.color("var(--quillred)")
.paddingVertical(0.34, em)
.paddingHorizontal(0.85, em)
.marginRight(1.4, em)
.marginBottom(1, em)
.border("1px solid var(--quillred)")
.borderRadius(0.45, em)
.flexShrink(0)
.cursor("pointer")
.boxSizing("border-box")
.onClick((done) => { if (done) this.handleDelete() })
.onHover(function(hovering) {
this.style.background = hovering ? "var(--quillred)" : "transparent";
this.style.color = hovering ? "white" : "var(--quillred)"
this.style.opacity = hovering ? 0.82 : 1;
})
}
button("Save")
.attr({ type: "submit" })
.paddingVertical(0.34, em)
.paddingHorizontal(0.85, em)
.border("none")
.borderRadius(0.45, em)
.background("var(--quillred)")
.color("white")
.cursor("pointer")
.fontSize(0.8, em)
.fontWeight("600")
.marginRight(1.4, em)
.marginTop("auto")
.marginBottom(1, em)
.flexShrink(0)
.onHover(function(hovering) {
this.style.opacity = hovering ? 0.82 : 1;
})
})
.width(100, pct)
.alignItems("flex-end")
.background("var(--darkaccent)")
.borderBottom("1px solid var(--divider)")
.boxSizing("border-box")
.flexShrink(0)
}
renderBody() {
VStack(() => {
this.prop("COLOR", () => {
const renderSwatch = (color) => {
const selected = this.selectedColor === color
p("")
.flex(1)
.height(1.6, em)
.background(color)
.borderRadius(5, px)
.border(`3px solid ${selected ? "var(--quillred)" : color}`)
.boxSizing("border-box")
.cursor("pointer")
.attr({ "data-color": color })
.onClick((done) => {
if (!done) return
const prev = this.$(`[data-color="${this.selectedColor}"]`)
if (prev) prev.style.border = `3px solid ${this.selectedColor}`
this.selectedColor = color
const next = this.$(`[data-color="${color}"]`)
if (next) next.style.border = `3px solid var(--quillred)`
})
.onHover(function(hovering) {
this.style.opacity = hovering ? 0.82 : 1;
})
}
VStack(() => {
HStack(() => {
DesktopCalendarForm.COLORS.slice(0, 8).forEach(renderSwatch)
})
.gap(0.55, em)
HStack(() => {
DesktopCalendarForm.COLORS.slice(8, 16).forEach(renderSwatch)
})
.gap(0.55, em)
})
.gap(0.55, em)
})
this.prop("DESCRIPTION", () => {
textarea(this.editCalendar?.description ?? "")
.attr({ name: "description" })
.styles(this.fieldStyles)
.lineHeight("1.65")
.width(100, pct)
.minHeight("3em")
.resize("none")
.fieldSizing("content")
.fontFamily("Arial")
.onAppear(function() {
this.value = this.placeholder;
})
})
})
.flex(1)
.overflowY("scroll")
.width(100, pct)
.boxSizing("border-box")
}
showError(msg) {
$("modal-")?.showError(msg)
}
getFormData() {
const val = name => this.$(`[name="${name}"]`).value
return {
name: val("name"),
description: val("description") || null,
color: this.selectedColor
}
}
isUnchanged(data) {
const o = this.originalFormData
return (
data.name === o.name &&
data.color === o.color &&
(data.description || "") === o.description
)
}
isNewCalendarDirty() {
const data = this.getFormData()
const o = this.originalFormData
return (
data.name !== o.name ||
(data.description || "") !== o.description ||
data.color !== o.color
)
}
async trySave() {
if (!this.editCalendar) {
// New calendar: only save if the user made edits
if (!this.isNewCalendarDirty()) return false
const data = this.getFormData()
const payload = {
name: data.name || "New calendar",
description: data.description,
color: data.color
}
const result = await server.addCalendar(payload, global.currentNetwork.id)
if (result.status === 200) return result.calendar
this.showError(result.error ?? "Failed to save calendar.")
return null
}
const data = this.getFormData()
if (this.isUnchanged(data)) return this.editCalendar
const result = await server.editCalendar(
this.editCalendar.id,
{ ...data, name: data.name || "New calendar" },
global.currentNetwork.id
)
if (result.status === 200) return result.calendar
this.showError(result.error ?? "Failed to save calendar.")
return null
}
async handleSave() {
$("modal-")?.showError("")
const data = this.getFormData()
if (this.editCalendar) {
if (this.isUnchanged(data)) {
if (this.onBack) this.onBack()
return
}
}
const payload = {
name: data.name || "New calendar",
description: data.description,
color: data.color
}
const result = this.editCalendar
? await server.editCalendar(this.editCalendar.id, payload, global.currentNetwork.id)
: await server.addCalendar(payload, global.currentNetwork.id)
if (result.status === 200) {
if (this.editCalendar) {
this.onSaved(result.calendar)
} else {
// Use forceClose so _closeOverride doesn't re-trigger trySave
$("modal-").forceClose()
this.onSaved(result.calendar)
}
} else {
this.showError(result.error ?? "Failed to save calendar.")
}
}
async handleDelete() {
const result = await server.deleteCalendar(this.editCalendar.id, global.currentNetwork.id)
if (result.status === 200) {
$("modal-").forceClose()
if (this.onDelete) this.onDelete(this.editCalendar.id)
} else {
this.showError(result.error ?? "Failed to delete calendar.")
}
}
}
register(DesktopCalendarForm)

View File

@@ -0,0 +1,320 @@
import calendarUtil from "../calendarUtil.js";
let _saved = null;
css(`
desktopmonthgrid- {
scrollbar-width: none;
-ms-overflow-style: none;
}
`)
class DesktopMonthGrid extends Shadow {
constructor(weeks, calendars, weekStartsOn = 0, onEventClick = null, onDayDoubleClick = null) {
super()
this.weeks = weeks;
this.calendars = calendars;
this.weekStartsOn = weekStartsOn;
this.onEventClick = onEventClick;
this.onDayDoubleClick = onDayDoubleClick;
this.ghostDay = null;
this.maxVisible = 4;
// Layout in em
this.dateFontSize = 0.82;
this.dateLineHeight = 1.7;
this.paddingTop = 0.35;
this.paddingBottom = 0.25;
this.pillHeight = 1.15;
this.pillGap = 0.15;
this.overflowLabelHeight = 0.7;
this.rowBottomPadding = this.overflowLabelHeight + 0.5;
this.headerHeight = this.paddingTop + (this.dateFontSize * this.dateLineHeight) + this.paddingBottom;
this.rowHeight = this.headerHeight + this.maxVisible * (this.pillHeight + this.pillGap) + this.rowBottomPadding;
}
render() {
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
const ordered = Array.from({ length: 7 }, (_, i) => DAY_NAMES[(this.weekStartsOn + i) % 7]);
VStack(() => {
// Day-name header
HStack(() => {
ordered.forEach(name => {
p(name)
.margin(0)
.fontSize(0.72, em)
.fontWeight("500")
.letterSpacing("0.015em")
.color("var(--headertext)")
.opacity(0.5)
.flex(1)
.textAlign("center")
.paddingVertical(0.55, em)
.boxSizing("border-box")
})
})
.width(100, pct)
.borderBottom("1px solid var(--divider)")
.flexShrink(0)
// Week rows
VStack(() => {
this.weeks.forEach((week, wi) => {
this.renderWeekRow(week, wi === this.weeks.length - 1)
})
})
.width(100, pct)
.height(this.rowHeight * this.weeks.length, em)
.flexShrink(0)
})
.width(100, pct)
.height(100, pct)
.overflowY("auto")
.boxSizing("border-box")
.onAppear(() => {
requestAnimationFrame(() => {
const monthKey = calendarUtil.toDateInput(this.weeks[2].days[3].day).substring(0, 7);
this.scrollTop = (_saved?.monthKey === monthKey) ? _saved.scrollTop : 0;
this.addEventListener('scroll', () => {
_saved = { monthKey, scrollTop: this.scrollTop };
}, { passive: true });
});
})
}
renderWeekRow(week, isLastWeek) {
const displayWeek = this.ghostDay ? this.injectGhostIntoWeek(week) : week;
ZStack(() => {
this.renderCellLayer(week, isLastWeek)
this.renderPillLayer(displayWeek)
})
.position("relative")
.width(100, pct)
.height(this.rowHeight, em)
.flexShrink(0)
.alignItems("stretch")
}
renderCellLayer(week, isLastWeek) {
HStack(() => {
week.days.forEach((dayData, di) => {
this.renderDayCell(dayData, di === 6, isLastWeek)
})
})
.width(100, pct)
.alignItems("stretch")
.height(100, pct)
}
renderDayCell(dayData, isLastCol, isLastWeek) {
const { day, isCurrentMonth } = dayData;
const today = calendarUtil.isToday(day);
VStack(() => {
HStack(() => {
p(day.getDate())
.margin(0)
.fontSize(this.dateFontSize, em)
.fontWeight(today ? "700" : "500")
.color(today ? "white" : "var(--headertext)")
.background(today ? "var(--quillred)" : "transparent")
.width(1.65, em)
.height(1.65, em)
.borderRadius(5, px)
.textAlign("center")
.lineHeight("1.65em")
.flexShrink(0)
.opacity(isCurrentMonth ? 1 : 0.3)
})
.paddingTop(this.paddingTop, em)
.paddingHorizontal(0.4, em)
.paddingBottom(this.paddingBottom, em)
})
.flex(1)
.width(0, px)
.minWidth(0)
.height(100, pct)
.background(isCurrentMonth ? "var(--darkaccent)" : "")
.borderBottom(isLastWeek ? "1px solid transparent" : "1px solid var(--divider)")
.borderRight(isLastCol ? "none" : "1px solid var(--divider)")
.boxSizing("border-box")
.overflow("hidden")
.alignItems("stretch")
.onClick((done, e) => {
if (e.detail !== 2) return
if (!done) {
this.ghostDay = day
this.rerender()
} else {
this.onDayDoubleClick(day, () => {
this.ghostDay = null
this.rerender()
})
}
})
}
injectGhostIntoWeek(week) {
const col = week.days.findIndex(d =>
calendarUtil.startOfDay(d.day).getTime() === calendarUtil.startOfDay(this.ghostDay).getTime()
);
if (col === -1) return week;
const ghostEvent = {
id: '__ghost__',
title: 'New event',
all_day: true,
time_start: this.ghostDay,
time_end: this.ghostDay,
calendars: ['__ghost__']
};
const slotMap = week.slotMap.map(colSlots => [...colSlots]);
let row = 0;
while (row < slotMap[col].length && slotMap[col][row] !== null) row++;
for (let c = 0; c < 7; c++) {
while (slotMap[c].length <= row) slotMap[c].push(null);
}
slotMap[col][row] = { event: ghostEvent, isStart: true, isEnd: true, isSingleDay: true };
const days = week.days.map((d, i) =>
i === col ? { ...d, events: [...d.events, ghostEvent] } : d
);
return { ...week, slotMap, days };
}
renderPillLayer(week) {
ZStack(() => {
const maxSlots = Math.max(0, ...week.slotMap.map(s => s.length));
for (let row = 0; row < Math.min(maxSlots, this.maxVisible); row++) {
this.collectSpans(week, row).forEach(span => this.renderPill(span, week, row))
}
// Overflow labels
week.days.forEach((dayData, col) => {
const overflow = Math.max(0, dayData.events.length - this.maxVisible);
if (overflow === 0) return;
p(`+${overflow} more`)
.margin(0)
.fontSize(0.63, em)
.fontWeight("600")
.color("var(--headertext)")
.opacity(0.5)
.position("absolute")
.bottom(this.overflowLabelHeight, em)
.left((col / 7) * 100, pct)
.width(100 / 7, pct)
.paddingHorizontal(0.55, em)
.zIndex(2)
})
})
.position("absolute")
.top(0).left(0).right(0).bottom(0)
.pointerEvents("none")
}
renderPill({ startCol, endCol, event }, week, row) {
const isGhost = event.id === '__ghost__';
const color = isGhost ? 'var(--quillred)' : calendarUtil.getCalendarColor(this.calendars, event.calendars.find(id => this.calendars.some(c => c.id === id)) ?? event.calendars[0]);
const leftPct = (startCol / 7) * 100;
const widthPct = ((endCol - startCol + 1) / 7) * 100;
const topEm = this.headerHeight + row * (this.pillHeight + this.pillGap);
// const isSingleDay = startCol === endCol &&
// calendarUtil.startOfDay(event.time_start).getTime() === calendarUtil.startOfDay(event.time_end).getTime();
// const isTimedSingle = isSingleDay && !event.all_day;
const isTimedSingle = !event.all_day; // wasn't rendering circle on multi-day timed events
const clippedLeft = startCol === 0 &&
calendarUtil.startOfDay(event.time_start) < calendarUtil.startOfDay(week.days[0].day);
const clippedRight = endCol === 6 &&
calendarUtil.endOfDay(event.time_end) > calendarUtil.endOfDay(week.days[6].day);
const colW = 100 / 7;
const leftInset = clippedLeft ? 0 : 0.02 * colW;
const rightInset = clippedRight ? 0 : 0.02 * colW;
const brLeft = clippedLeft ? 0 : 4;
const brRight = clippedRight ? 0 : 4;
HStack(() => {
if (isTimedSingle) {
// Dot + time + title
VStack(() => {})
.width(0.42, em)
.height(0.42, em)
.borderRadius(50, pct)
.background("white")
.flexShrink(0)
.marginRight(0.3, em)
p(calendarUtil.formatTimeShort(event.time_start) + " " + (event.title || "Untitled"))
.margin(0)
.fontSize(0.68, em)
.fontWeight("500")
.color("white")
.whiteSpace("nowrap")
.overflow("hidden")
} else {
p(event.title || "Untitled")
.margin(0)
.fontSize(0.68, em)
.fontWeight("600")
.color("white")
.whiteSpace("nowrap")
.overflow("hidden")
}
})
.position("absolute")
.top(topEm, em)
.left(leftPct + leftInset, pct)
.width(widthPct - leftInset - rightInset, pct)
.height(this.pillHeight, em)
.paddingHorizontal(0.5, em)
.background(color)
.borderTopLeftRadius(`${brLeft}px`)
.borderBottomLeftRadius(`${brLeft}px`)
.borderTopRightRadius(`${brRight}px`)
.borderBottomRightRadius(`${brRight}px`)
.alignItems("center")
.overflow("hidden")
.boxSizing("border-box")
.zIndex(1)
.pointerEvents(isGhost ? "none" : "auto")
.cursor(isGhost ? "default" : "pointer")
.opacity(isGhost ? 0.75 : 1)
.onClick((done) => {
if (!isGhost && done && this.onEventClick) this.onEventClick(event)
})
.onHover(function(hovering) {
if (!isGhost) this.opacity(hovering ? 0.82 : 1)
})
}
collectSpans(week, row) {
const spans = [];
let current = null;
for (let col = 0; col < 7; col++) {
const slot = (week.slotMap[col] || [])[row] ?? null;
if (slot && current && slot.event === current.event) {
current.endCol = col;
} else {
if (current) spans.push(current);
current = slot ? { startCol: col, endCol: col, event: slot.event } : null;
}
}
if (current) spans.push(current);
return spans;
}
}
register(DesktopMonthGrid)

View File

@@ -0,0 +1,118 @@
import calendarUtil from "../calendarUtil.js"
import "./DesktopMonthGrid.js"
class DesktopMonthView extends Shadow {
constructor(calendars, events, currentDate, weekStartsOn = 0, onEventClick = null, onDayDoubleClick = null) {
super()
this.calendars = calendars;
this.events = events;
this.currentDate = currentDate;
this.weekStartsOn = weekStartsOn;
this.onEventClick = onEventClick;
this.onDayDoubleClick = onDayDoubleClick;
}
render() {
const weeks = this.buildMonthWeeks();
DesktopMonthGrid(weeks, this.calendars, this.weekStartsOn, this.onEventClick, this.onDayDoubleClick)
.width(100, pct)
.height(100, pct)
}
buildMonthWeeks() {
const month = this.currentDate.getMonth();
const allDays = calendarUtil.buildMonthGrid(this.currentDate, this.weekStartsOn);
const gridStart = allDays[0];
const weeks = [];
for (let w = 0; w < 6; w++) {
weeks.push(allDays.slice(w * 7, w * 7 + 7));
}
const gridEnd = calendarUtil.endOfDay(weeks[weeks.length - 1][6]);
const monthStart = calendarUtil.startOfDay(new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), 1));
const monthEnd = calendarUtil.endOfDay(new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + 1, 0));
const expanded = calendarUtil.expandRecurringEvents(this.events, gridStart, gridEnd);
const relevantEvents = expanded.filter(event =>
calendarUtil.rangesOverlap(event.time_start, event.time_end, gridStart, gridEnd) &&
calendarUtil.rangesOverlap(event.time_start, event.time_end, monthStart, monthEnd) &&
this.calendars.some(c => event.calendars?.some(id => id === c.id))
);
return weeks.map(week => this.buildWeekData(week, month, relevantEvents));
}
buildWeekData(week, currentMonth, events) {
const weekStart = calendarUtil.startOfDay(week[0]);
const weekEnd = calendarUtil.endOfDay(week[6]);
const weekEvents = events.filter(event =>
calendarUtil.rangesOverlap(event.time_start, event.time_end, weekStart, weekEnd)
);
weekEvents.sort((a, b) => {
const aSpan = a.all_day || this.isMultiDay(a);
const bSpan = b.all_day || this.isMultiDay(b);
if (aSpan !== bSpan) return aSpan ? -1 : 1;
return a.time_start - b.time_start;
});
const slotRows = [];
weekEvents.forEach(event => {
const startCol = Math.max(0, this.dayIndex(event.time_start, week));
const endCol = Math.min(6, this.dayIndex(event.time_end, week));
let row = 0;
while (true) {
if (!slotRows[row]) slotRows[row] = new Array(7).fill(null);
if (slotRows[row].slice(startCol, endCol + 1).every(v => v === null)) break;
row++;
}
for (let c = startCol; c <= endCol; c++) {
slotRows[row][c] = {
event,
isStart: c === startCol,
isEnd: c === endCol,
isSingleDay: startCol === endCol
};
}
});
const slotMap = Array.from({ length: 7 }, (_, col) =>
slotRows.map(row => row[col] ?? null)
);
return {
days: week.map(day => ({
day,
isCurrentMonth: day.getMonth() === currentMonth,
events: weekEvents.filter(event => {
const effectiveEnd = event.all_day ? calendarUtil.endOfDay(event.time_end) : event.time_end;
return calendarUtil.rangesOverlap(
event.time_start, effectiveEnd,
calendarUtil.startOfDay(day), calendarUtil.endOfDay(day)
);
})
})),
slotMap
};
}
isMultiDay(event) {
return calendarUtil.startOfDay(event.time_start).getTime() !==
calendarUtil.startOfDay(event.time_end).getTime();
}
dayIndex(date, week) {
const dayStart = calendarUtil.startOfDay(date);
for (let i = 0; i < 7; i++) {
if (calendarUtil.startOfDay(week[i]).getTime() === dayStart.getTime()) return i;
}
return date.getTime() < week[0].getTime() ? 0 : 6;
}
}
register(DesktopMonthView)

View File

@@ -0,0 +1,294 @@
import calendarUtil from "../calendarUtil.js";
class DesktopSidebar extends Shadow {
static DAY_NAMES = ["S", "M", "T", "W", "T", "F", "S"];
constructor(currentDate, calendars, selectedCalendars, events, weekStartsOn, actions) {
super()
this.currentDate = currentDate
this.calendars = calendars
this.selectedCalendars = selectedCalendars
this.events = events
this.weekStartsOn = weekStartsOn
this.actions = actions
// Persist mini calendar month across parent rerenders
if (this.miniDate === undefined) {
this.miniDate = new Date(currentDate)
}
if (this.selectedDate === undefined) {
this.selectedDate = new Date(currentDate)
}
}
render() {
const { onSelectDate, onToggleCalendar, onNewCalendar, onEditCalendar } = this.actions
const ordered = Array.from({ length: 7 }, (_, i) =>
DesktopSidebar.DAY_NAMES[(this.weekStartsOn + i) % 7]
);
const weeks = this.buildMonthWeeks(this.miniDate);
const today = new Date();
VStack(() => {
// ── Mini calendar ─────────────────────────────────────────
VStack(() => {
// Month / year + nav arrows
HStack(() => {
p(this.miniDate.toLocaleDateString(undefined, { month: "long", year: "numeric" }))
.margin(0)
.fontWeight("600")
.fontSize(0.78, em)
.color("var(--headertext)")
.flex(1)
HStack(() => {
button("")
.onClick((done) => {if(!done) return; this.miniDate = calendarUtil.addMonths(this.miniDate, -1); this.rerender(); })
.padding("0.18em 0.42em")
.border("none")
.background("transparent")
.color("var(--headertext)")
.cursor("pointer")
.borderRadius(0.3, em)
.fontSize(0.85, em)
button("")
.onClick((done) => {if(!done) return; this.miniDate = calendarUtil.addMonths(this.miniDate, 1); this.rerender(); })
.padding("0.18em 0.42em")
.border("none")
.background("transparent")
.color("var(--headertext)")
.cursor("pointer")
.borderRadius(0.3, em)
.fontSize(0.85, em)
})
.gap(0)
})
.alignItems("center")
.marginBottom(0.35, em)
// Day-name header row
HStack(() => {
ordered.forEach(name => {
p(name)
.margin(0)
.fontSize(0.64, em)
.fontWeight("600")
.color("var(--headertext)")
.opacity(0.42)
.flex(1)
.textAlign("center")
.paddingVertical(0.18, em)
})
})
.width(100, pct)
// Date cells
VStack(() => {
weeks.forEach(({ days }) => {
HStack(() => {
days.forEach(({ day, isCurrentMonth, calendarColors }) => {
const isToday = calendarUtil.isSameDay(day, today);
const isSelected = calendarUtil.isSameDay(day, this.selectedDate);
VStack(() => {
p(day.getDate())
.margin(0)
.fontSize(0.72, em)
.fontWeight(isSelected ? "700" : "400")
.color(isSelected || isToday ? "white" : "var(--headertext)")
.background(
isToday ? "var(--quillred)"
: isSelected ? "var(--lightaccent)"
: "transparent"
)
.width(1.52, em)
.height(1.52, em)
.borderRadius(25, pct)
.textAlign("center")
.lineHeight("1.52em")
.opacity(isCurrentMonth ? 1 : 0.27)
.boxSizing("border-box")
if (calendarColors.length > 0) {
HStack(() => {
calendarColors.slice(0, 3).forEach(color => {
VStack(() => {})
.width(0.24, em)
.height(0.24, em)
.borderRadius(50, pct)
.background(color)
.flexShrink(0)
})
})
.gap(0.14, em)
.justifyContent("center")
.marginTop(0.1, em)
} else {
VStack(() => {}).height(0.34, em)
}
})
.flex(1)
.alignItems("center")
.paddingVertical(0.16, em)
.cursor("pointer")
.onClick((done) => {if(!done) return;
this.selectedDate = day;
this.miniDate = new Date(day);
onSelectDate(day);
this.rerender();
})
})
})
.width(100, pct)
})
})
.width(100, pct)
})
.marginTop(30, px)
.padding(1, em)
.paddingBottom(0.75, em)
// ── Divider ───────────────────────────────────────────────
VStack(() => {})
.height(1, px)
.background("var(--divider)")
.width(100, pct)
// ── Calendars list ────────────────────────────────────────
VStack(() => {
p("CALENDARS")
.margin(0)
.marginBottom(0.45, em)
.fontSize(0.63, em)
.fontWeight("700")
.letterSpacing("0.07em")
.color("var(--headertext)")
.opacity(0.38)
.paddingHorizontal(1, em)
this.calendars.forEach(cal => {
const isSelected = this.selectedCalendars.some(c => c.id === cal.id);
HStack(() => {
HStack(() => {
VStack(() => {})
.width(0.65, em)
.height(0.65, em)
.borderRadius(50, pct)
.background(isSelected ? cal.color : "transparent")
.border(`2px solid ${cal.color}`)
.boxSizing("border-box")
.flexShrink(0)
p(cal.name)
.margin(0)
.fontSize(0.78, em)
.color("var(--headertext)")
.opacity(isSelected ? 1 : 0.4)
.flex(1)
.overflow("hidden")
.whiteSpace("nowrap")
.textOverflow("ellipsis")
})
.flex(1)
.gap(0.55, em)
.alignItems("center")
.cursor("pointer")
.onClick((done) => { if (!done) return; if (onToggleCalendar) onToggleCalendar(cal); })
if (onEditCalendar && cal.owner_id === global.profile.id) {
button("···")
.border("none")
.background("transparent")
.color("var(--headertext)")
.opacity(0.4)
.fontSize(0.72, em)
.cursor("pointer")
.padding("0 0.2em")
.flexShrink(0)
.onClick((done) => { if (done) onEditCalendar(cal) })
}
})
.paddingHorizontal(1, em)
.paddingVertical(0.32, em)
.alignItems("center")
.borderRadius(0.4, em)
})
button("+ New Calendar")
.paddingVertical(0.52, em)
.paddingHorizontal(0.8, em)
.marginHorizontal(1, em)
.marginVertical(0.5, em)
.width("auto")
.background("transparent")
.border("1px solid var(--divider)")
.color("var(--headertext)")
.borderRadius(0.5, em)
.fontSize(0.83, em)
.fontWeight("600")
.cursor("pointer")
.onHover(function(hovering) {
this.style.background = hovering ? "var(--divider)" : "transparent";
})
.onClick((done) => { if (done) onNewCalendar() })
})
.paddingTop(0.7, em)
.paddingBottom(0.5, em)
})
.width(220, px)
.minWidth(220, px)
.height(100, pct)
.borderRight("1px solid var(--divider)")
.boxSizing("border-box")
.overflowY("auto")
.flexShrink(0)
}
buildMonthWeeks(date) {
const month = date.getMonth();
const allDays = calendarUtil.buildMonthGrid(date, this.weekStartsOn);
const gridStart = allDays[0];
const gridEnd = calendarUtil.endOfDay(allDays[41]);
const colorsByDay = new Map();
allDays.forEach(day => colorsByDay.set(calendarUtil.toDateInput(day), []));
const colorByCalId = new Map((this.selectedCalendars || []).map(c => [c.id, c.color]));
const expanded = calendarUtil.expandRecurringEvents(this.events || [], gridStart, gridEnd);
expanded.filter(ev => {
const end = ev.all_day ? calendarUtil.endOfDay(ev.time_end) : ev.time_end;
return calendarUtil.rangesOverlap(ev.time_start, end, gridStart, gridEnd);
}).forEach(ev => {
const colors = (ev.calendars || [])
.map(id => colorByCalId.get(id))
.filter(Boolean);
if (colors.length === 0) return;
const effectiveEnd = ev.all_day ? calendarUtil.endOfDay(ev.time_end) : ev.time_end;
let cursor = calendarUtil.startOfDay(ev.time_start > gridStart ? ev.time_start : gridStart);
const end = effectiveEnd < gridEnd ? effectiveEnd : gridEnd;
while (cursor < end) {
const key = calendarUtil.toDateInput(cursor);
const arr = colorsByDay.get(key);
if (arr) {
colors.forEach(color => {
if (!arr.includes(color) && arr.length < 3) arr.push(color);
});
}
cursor = calendarUtil.addDays(cursor, 1);
}
});
const weeks = [];
for (let w = 0; w < 6; w++) {
weeks.push({
days: allDays.slice(w * 7, w * 7 + 7).map(day => ({
day,
isCurrentMonth: day.getMonth() === month,
calendarColors: colorsByDay.get(calendarUtil.toDateInput(day)) || []
}))
});
}
return weeks;
}
}
register(DesktopSidebar)

View File

@@ -0,0 +1,74 @@
class DesktopToolbar extends Shadow {
constructor(currentDate, actions) {
super()
this.currentDate = currentDate
this.actions = actions
}
render() {
const { goToPrevious, goToCurrent, goToNext } = this.actions
HStack(() => {
h2(this.currentDate.toLocaleDateString(undefined, { month: "long", year: "numeric" }))
.margin(0)
.fontWeight("700")
.fontSize(1.25, em)
.color("var(--headertext)")
HStack(() => {
button("+ New Event")
.paddingVertical(0.52, em)
.paddingRight(1, em)
.paddingLeft(0.8, em)
.marginHorizontal(0.4, em)
.background("transparent")
.color("var(--headertext)")
.border("1px solid var(--divider)")
.borderRadius(0.5, em)
.fontSize(0.83, em)
.fontWeight("600")
.cursor("pointer")
.onHover(function(hovering) {
this.style.background = hovering ? "var(--divider)" : "transparent";
})
.onClick((done) => { if (done) this.actions.onNewEvent() })
this.navBtn("", goToPrevious)
this.navBtn("Today", goToCurrent)
this.navBtn("", goToNext)
})
.gap(0.4, em)
.alignItems("center")
})
.paddingHorizontal(1.5, em)
.paddingVertical(0.85, em)
.alignItems("center")
.justifyContent("space-between")
.borderBottom("1px solid var(--divider)")
.width(100, pct)
.boxSizing("border-box")
.flexShrink(0)
}
navBtn(label, handler) {
return button(label)
.onClick((done) => {
if(done) {
handler()
}
})
.paddingVertical(0.38, em)
.paddingHorizontal(label === "Today" ? 0.9 : 0.72, em)
.border("1px solid var(--divider)")
.borderRadius(0.45, em)
.background("transparent")
.color("var(--headertext)")
.cursor("pointer")
.fontSize(0.83, em)
.onHover(function(hovering) {
this.style.background = hovering ? "var(--divider)" : "transparent";
})
}
}
register(DesktopToolbar)

View File

@@ -0,0 +1,396 @@
import server from "/@server/server.js"
import calendarUtil from "../../calendarUtil.js"
import "../../../components/Avatar.js"
class DesktopEventDetails extends Shadow {
attachmentsOpen = false
constructor(calendars, event, onUpdated = null, onDeleted = null, onEdit = null) {
super()
this.calendars = calendars
this.event = event
this.attachmentsOpen = (event?.attachments?.length > 0)
this.onUpdated = onUpdated
this.onDeleted = onDeleted
this.onEdit = onEdit
}
render() {
if (!this.event) return
const eventCals = this.calendars.filter(c => this.event.calendars?.includes(c.id))
const isOwner = this.event.creator_id === global.profile?.id
VStack(() => {
this.renderHeader(isOwner)
this.renderBody(eventCals)
HStack(() => {
const members = global.currentNetwork.data?.members || []
const creator = members.find(m => m.id === this.event.creator_id)
if (creator) {
Avatar(creator, 1.6)
VStack(() => {
p(`Created ${calendarUtil.timeAgo(this.event.created)} by ${creator.first_name}`)
.margin(0).fontSize(0.7, em).color("var(--headertext)").opacity(0.5)
if (this.event.updated_at && this.event.updated_at !== this.event.created) {
p(`Last updated ${calendarUtil.timeAgo(this.event.updated_at)}`)
.margin(0).fontSize(0.7, em).color("var(--headertext)").opacity(0.4)
}
})
.gap(0.15, em)
}
})
.paddingHorizontal(1, em)
.paddingVertical(0.65, em)
.boxSizing("border-box")
.alignItems("center")
.gap(0.5, em)
.flexShrink(0)
})
.height(100, pct)
.boxSizing("border-box")
}
// ── Header ────────────────────────────────────────────────────────────────
renderHeader(isOwner) {
HStack(() => {
VStack(() => {
h2(this.event.title || "Untitled")
.margin(0)
.fontSize(1.45, em)
.fontWeight("700")
.color("var(--headertext)")
.lineHeight("1.2")
})
.flex(1)
.paddingHorizontal(1.4, em)
.paddingTop(2.5, em)
.paddingBottom(0.5, em)
.justifyContent("center")
if (isOwner) {
button("Delete")
.paddingVertical(0.34, em)
.paddingHorizontal(0.85, em)
.border("1px solid var(--quillred)")
.borderRadius(0.45, em)
.background("transparent")
.color("var(--quillred)")
.cursor("pointer")
.fontSize(0.8, em)
.fontWeight("600")
.marginRight(0.5, em)
.marginTop("auto")
.marginBottom(1, em)
.flexShrink(0)
.onClick((done) => { if (done) this.handleDelete() })
.onHover(function(hovering) {
this.style.background = hovering ? "var(--quillred)" : "transparent";
this.style.color = hovering ? "white" : "var(--quillred)";
})
button("Edit")
.paddingVertical(0.34, em)
.paddingHorizontal(0.85, em)
.border("1px solid var(--divider)")
.borderRadius(0.45, em)
.background("transparent")
.color("var(--headertext)")
.cursor("pointer")
.fontSize(0.8, em)
.marginRight(1.4, em)
.marginTop("auto")
.marginBottom(1, em)
.flexShrink(0)
.onClick((done) => {
if (!done) return
// Attach template dates for override events so scope='all' anchors correctly
let eventForEdit = this.event
if (this.event.recurrence_parent_id && !this.event._templateStart) {
const template = global.currentNetwork.data.events.find(e => e.id === this.event.recurrence_parent_id)
if (template) {
eventForEdit = {
...this.event,
_templateStart: new Date(template.time_start),
_templateEnd: new Date(template.time_end)
}
}
}
this.onEdit(eventForEdit)
})
.onHover(function(hovering) {
this.style.background = hovering ? "var(--divider)" : "transparent";
})
}
})
.width(100, pct)
.alignItems("stretch")
.background("var(--darkaccent)")
.borderBottom("1px solid var(--divider)")
.boxSizing("border-box")
.flexShrink(0)
}
// ── Body ─────────────────────────────────────────────────────────────────
renderBody(eventCals) {
VStack(() => {
VStack(() => {
this.prop("WHEN", () => {
p(calendarUtil.formatEventTime(this.event))
.margin(0)
.fontSize(0.88, em)
.color("var(--headertext)")
})
if (this.event.recurrence) {
this.prop("REPEATS", () => {
p(this._recurrenceLabel())
.margin(0)
.fontSize(0.88, em)
.color("var(--headertext)")
})
}
this.prop("CALENDARS", () => {
HStack(() => {
eventCals.forEach(cal => {
p(cal.name)
.margin(0)
.fontSize(0.78, em)
.fontWeight("600")
.color("white")
.paddingHorizontal(0.65, em)
.paddingVertical(0.28, em)
.background(cal.color)
.borderRadius(0.45, em)
})
})
.flexWrap("wrap")
.gap(0.45, em)
})
if (this.event.location) {
this.prop("LOCATION", () => {
p(this.event.location)
.margin(0)
.fontSize(0.88, em)
.color("var(--headertext)")
.lineHeight("1.5")
})
}
if (this.event.description) {
this.prop("DESCRIPTION", () => {
p(this.event.description)
.margin(0)
.fontSize(0.88, em)
.color("var(--headertext)")
.lineHeight("1.65")
.whiteSpace("pre-wrap")
})
}
})
if (this.event.attachments?.length > 0) {
this.renderAttachments()
}
})
.flex(1)
.overflowY("scroll")
.width(100, pct)
.boxSizing("border-box")
}
prop(label, contentFn) {
VStack(() => {
p(label)
.margin(0)
.marginBottom(1, em)
.fontSize(0.67, em)
.fontWeight("600")
.letterSpacing("0.06em")
.color("var(--headertext)")
.opacity(0.38)
VStack(() => { contentFn() })
.width(100, pct)
})
.paddingHorizontal(1.5, em)
.paddingTop(0.75, em)
.paddingBottom(0.4, em)
.boxSizing("border-box")
.width(100, pct)
}
_recurrenceLabel() {
const r = this.event.recurrence
if (!r) return ""
if (r.frequency === 'daily') return "Daily"
if (r.frequency === 'weekly' && r.interval === 2) return "Every 2 weeks"
if (r.frequency === 'weekly') return "Weekly"
if (r.frequency === 'monthly') return "Monthly"
if (r.frequency === 'yearly') return "Yearly"
return ""
}
// ── Attachments ───────────────────────────────────────────────────────────
renderAttachments() {
VStack(() => {
HStack(() => {
p("Attachments")
.margin(0)
.fontSize(0.82, em)
.fontWeight("600")
.color("var(--headertext)")
.opacity(0.4)
p("▼")
.attr({ id: "desktop-attachments-chevron" })
.margin(0)
.fontSize(0.65, em)
.color("var(--headertext)")
.opacity(0.4)
.display("inline-block")
.transition("transform 0.22s ease")
.transform(this.attachmentsOpen ? "rotate(180deg)" : "rotate(0deg)")
.userSelect("none")
})
.gap(0.5, em)
.alignItems("center")
.cursor("pointer")
.onClick((done) => { if (!done) return; this.toggleAttachments() })
VStack(() => {
const images = this.event.attachments.filter(f => f.type?.startsWith("image/"))
const files = this.event.attachments.filter(f => !f.type?.startsWith("image/"))
if (images.length > 0) {
HStack(() => {
images.forEach(file => {
const url = `${config.SERVER}/db/images/events/${file.name}`
VStack(() => {
img(url, "100%", "100%")
.objectFit("cover")
.display("block")
})
.width("6.5em")
.height("6.5em")
.flexShrink(0)
.border("1px solid var(--divider)")
.borderRadius(6, px)
.overflow("hidden")
.cursor("pointer")
.onClick((done) => { if (!done) return; $("filepreview-").open(file, url) })
})
})
.flexWrap("wrap")
.gap(0.5, em)
.boxSizing("border-box")
.width(100, pct)
}
if (files.length > 0) {
VStack(() => {
files.forEach(file => this.renderFile(file))
})
.gap(0.5, em)
.width("max-content")
.boxSizing("border-box")
}
})
.attr({ id: "desktop-attachments-content" })
.width(100, pct)
.display(this.attachmentsOpen ? "" : "none")
.gap(1, em)
})
.width(100, pct)
.boxSizing("border-box")
.paddingHorizontal(1.5, em)
.paddingVertical(0.85, em)
.gap(1, em)
}
renderFile(file) {
const url = `${config.SERVER}/db/images/events/${file.name}`
HStack(() => {
p("📎")
.margin(0)
.fontSize(0.9, em)
p(file.original_name ?? file.name)
.margin(0)
.color("var(--headertext)")
.fontSize(0.85, em)
.overflow("hidden")
.whiteSpace("nowrap")
.textOverflow("ellipsis")
})
.gap(0.5, em)
.alignItems("center")
.padding(0.55, em)
.background("var(--darkaccent)")
.border("1px solid var(--divider)")
.borderRadius(0.45, em)
.boxSizing("border-box")
.cursor("pointer")
.onClick((done) => { if (!done) return; $("filepreview-").open(file, url) })
}
toggleAttachments() {
this.attachmentsOpen = !this.attachmentsOpen
const content = this.$("#desktop-attachments-content")
const chevron = this.$("#desktop-attachments-chevron")
if (content) content.style.display = this.attachmentsOpen ? "" : "none"
if (chevron) chevron.style.transform = this.attachmentsOpen ? "rotate(180deg)" : "rotate(0deg)"
}
handleDelete() {
const event = this.event
const isRecurring = !!(event?._isOccurrence || event?.recurrence_parent_id || event?.recurrence_id)
if (isRecurring) {
$('actionsheetpopup-').show(
"Delete Recurring Event",
[
{ label: "Delete just this event", onTap: () => this.performDelete('single') },
{ label: "Delete this and future events", onTap: () => this.performDelete('future') },
{ label: "Delete all events in series", onTap: () => this.performDelete('all') },
],
() => {}
)
return
}
this.performDelete(null)
}
async performDelete(scope) {
const event = this.event
const isOverride = !!event.recurrence_parent_id
const templateId = isOverride ? event.recurrence_parent_id : event.id
const occurrenceDate = isOverride
? (event.recurrence_exception_date instanceof Date
? event.recurrence_exception_date.toISOString()
: event.recurrence_exception_date) ?? null
: event._occurrenceDate?.toISOString() ?? null
const serverEventId = (scope === 'single' && isOverride) ? event.id : templateId
try {
const result = await server.deleteEvent(serverEventId, global.currentNetwork.id, scope, occurrenceDate)
if (result.status === 200) {
$("modal-").forceClose()
const deleteResult = { scope: scope ?? 'all', templateId, occurrenceDate, overrideId: isOverride ? event.id : null }
if (this.onDeleted) this.onDeleted(deleteResult)
} else {
$("modal-")?.showError(result.error ?? "Failed to delete event.")
}
} catch (err) {
console.error("Failed to delete event:", err)
$("modal-")?.showError("Failed to delete event.")
}
}
}
register(DesktopEventDetails)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,154 @@
class FilePreview extends Window {
_visible = false
_file = null
_url = null
open(file, url) {
this._file = file
this._url = url
this._visible = true
this.rerender()
}
close() {
this._visible = false
this.rerender()
}
render() {
this.style.position = "fixed"
this.style.inset = "0"
this.style.zIndex = "300"
this.style.pointerEvents = this._visible ? "all" : "none"
if (!this._visible) return
const x = this.getX()
const y = this.getY()
const w = this.getWidth()
const h = this.getHeight()
const type = this._file?.type ?? ""
const isImage = type.startsWith("image/")
const isPDF = type === "application/pdf"
const isOffice = [
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/msword',
'application/vnd.ms-excel',
'application/vnd.ms-powerpoint',
'text/plain',
].includes(type)
const displayName = this._file?.original_name ?? this._file?.name ?? "File"
VStack(() => {
const panel = VStack(() => {
// Header
HStack(() => {
HStack(() => {
div().attr({ class: "tl tl-close" }).onClick((done) => { if (done) this.close() })
div().attr({ class: "tl tl-min" })
div().attr({ class: "tl tl-max" })
})
.attr({ class: "traffic-lights" })
.flexShrink(0)
p(displayName)
.margin(0)
.fontSize(0.88, em)
.fontWeight("600")
.color("var(--headertext)")
.overflow("hidden")
.whiteSpace("nowrap")
.textOverflow("ellipsis")
.flex(1)
.textAlign("center")
div().width("52px").flexShrink(0)
})
.paddingHorizontal(1, em)
.paddingVertical(0.75, em)
.alignItems("center")
.gap(1, em)
.background("var(--darkaccent)")
.borderBottom("1px solid var(--divider)")
.width(100, pct)
.boxSizing("border-box")
.flexShrink(0)
// Content
if (isImage) {
img(this._url, "auto", "auto")
.display("block")
.maxWidth(100, pct)
.maxHeight(h - 48, px)
.margin("0 auto")
} else if (isPDF) {
const wrap = div()
.flex(1)
.width(100, pct)
.overflow("hidden")
wrap.innerHTML = `<iframe src="${this._url}" style="width:100%;height:100%;border:none;display:block;"></iframe>`
} else {
const icon = {
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': "📄",
'application/msword': "📄",
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': "📊",
'application/vnd.ms-excel': "📊",
'application/vnd.openxmlformats-officedocument.presentationml.presentation': "📋",
'application/vnd.ms-powerpoint': "📋",
'text/plain': "📄",
}[type] ?? "📎"
VStack(() => {
p(icon).fontSize(3, em).margin(0)
p(displayName)
.margin(0)
.fontSize(0.9, em)
.color("var(--headertext)")
.textAlign("center")
a(this._url, "Download")
.attr({ download: displayName })
.fontSize(0.85, em)
.color("var(--quillred)")
.fontWeight("600")
.fontFamily("Arial")
.textDecoration("none")
.cursor("pointer")
.marginTop(0.25, em)
.onHover(function(hovering) {
this.style.textDecoration = hovering ? "underline" : "none"
})
})
.gap(0.5, em)
.alignItems("center")
.padding(2, em)
}
})
.background("var(--window)")
.backdropFilter("blur(18px)")
.border("0.5px solid var(--window-border)")
.borderRadius(12, px)
.overflow("hidden")
.width(100, pct)
.boxSizing("border-box")
if (isPDF) panel.height(h, px)
else panel.maxHeight(h, px)
panel.onClick((done, e) => { e.stopPropagation() })
})
.position("fixed")
.x(x, px)
.y(y, px)
.width(w, px)
.height(h, px)
.justifyContent(isPDF ? "flex-start" : "center")
.alignItems("center")
.onClick((done) => { if (done) this.close() })
.onEvent("resize", () => this.rerender())
}
}
register(FilePreview)

View File

@@ -0,0 +1,415 @@
import calendarUtil from "../calendarUtil.js"
import "./DesktopToolbar.js"
import "./DesktopSidebar.js"
import "./DesktopMonthView.js"
import "./Events/DesktopEventDetails.js"
import "./Events/DesktopEventForm.js"
import "./DesktopCalendarForm.js"
css(`
calendar- {
font-family: 'Arial';
scrollbar-width: none;
-ms-overflow-style: none;
}
`)
class Calendar extends Shadow {
constructor() {
super()
this.currentDate = new Date();
this.weekStartsOn = 0;
this.calendars = (global.currentNetwork.data?.calendars ?? []).map(c => ({ ...c }))
const storedCalIds = JSON.parse(localStorage.getItem(`calendarSelection_${global.profile.id}_${global.currentNetwork.id}`) || 'null')
if (storedCalIds) {
const restored = this.calendars.filter(c => storedCalIds.includes(c.id))
this.selectedCalendars = restored.length > 0 ? restored : [...this.calendars]
} else {
this.selectedCalendars = [...this.calendars]
}
this.events = (global.currentNetwork.data?.events ?? []).map(event => ({
...event,
time_start: new Date(event.time_start),
time_end: new Date(event.time_end)
}))
}
toggleCalendar(calendar) {
const isSelected = this.selectedCalendars.some(c => c.id === calendar.id);
if (isSelected && this.selectedCalendars.length === 1) return;
if (isSelected) {
this.selectedCalendars = this.selectedCalendars.filter(c => c.id !== calendar.id);
} else {
this.selectedCalendars = [...this.selectedCalendars, calendar];
}
localStorage.setItem(`calendarSelection_${global.profile.id}_${global.currentNetwork.id}`, JSON.stringify(this.selectedCalendars.map(c => c.id)))
this.rerender();
}
render() {
HStack(() => {
DesktopSidebar(
this.currentDate,
this.calendars,
this.selectedCalendars,
this.events,
this.weekStartsOn,
{
onSelectDate: (date) => this.goToDate(date),
onToggleCalendar: (cal) => this.toggleCalendar(cal),
onNewCalendar: () => {
let formEl
$("modal-").open(() => {
formEl = DesktopCalendarForm(this.calendars, (calendar) => this.addCalendar(calendar))
})
$("modal-")._closeOverride = async () => {
const saved = formEl ? await formEl.trySave() : false
if (saved === null) return
if (saved) this.addCalendar(saved)
$("modal-").forceClose()
}
},
onEditCalendar: (cal) => this.openEditCalendarForm(cal)
}
)
VStack(() => {
DesktopToolbar(this.currentDate, {
goToPrevious: () => this.goToPrevious(),
goToCurrent: () => this.goToCurrent(),
goToNext: () => this.goToNext(),
onNewEvent: () => {
let formEl
$("modal-").open(() => {
formEl = DesktopEventForm(this.calendars, (event) => this.addEvent(event))
})
$("modal-")._closeOverride = () => formEl?.handleBack()
}
})
DesktopMonthView(this.selectedCalendars, this.events, this.currentDate, this.weekStartsOn,
(event) => this.openEventDetails(event),
(day, removeGhost) => {
let formEl
const onBack = () => {
if (removeGhost) removeGhost()
$("modal-").forceClose()
}
$("modal-").open(() => {
formEl = DesktopEventForm(this.calendars, (event) => this.addEvent(event), null, null, onBack, day)
})
$("modal-")._closeOverride = () => formEl?.handleBack()
}
)
.flex(1)
.minHeight(0)
.width(100, pct)
.overflow("hidden")
})
.flex(1)
.height(100, pct)
.overflow("hidden")
})
.height(100, pct)
.width(100, pct)
.overflow("hidden")
}
openEventDetails(event) {
$("modal-").open(() => DesktopEventDetails(
this.calendars,
event,
(editResult) => {
if (editResult?.scope) {
this.handleEditResult(editResult)
} else {
this.updateEvent(editResult)
}
this.rerender()
},
(deleteResult) => {
this.handleDeleteResult(deleteResult)
this.rerender()
},
(evt) => this.openEditForm(evt)
))
}
openEditForm(event) {
let formEl
const goBack = () => {
$("modal-").forceClose()
const currentEvent = this.events.find(e => e.id === event.id) ?? event
this.openEventDetails({ ...currentEvent })
}
$("modal-").open(() => {
formEl = DesktopEventForm(
this.calendars,
(editResult) => {
if (editResult?.scope) {
this.handleEditResult(editResult)
this.rerender()
const findId = editResult.scope === 'all' ? editResult.templateId : editResult.event.id
const updatedEvt = this.events.find(e => e.id === findId)
if (updatedEvt) setTimeout(() => this.openEventDetails(updatedEvt), 50)
} else {
this.updateEvent(editResult)
this.openEventDetails(editResult)
}
},
event,
(deleteResult) => {
this.handleDeleteResult(deleteResult)
this.rerender()
},
goBack
)
})
$("modal-")._closeOverride = () => formEl?.handleBack()
}
addEvent(event) {
this.events = [...this.events, this.parseEvent(event)]
global.currentNetwork.data.events = [...global.currentNetwork.data.events, event]
this.rerender()
}
openEditCalendarForm(calendar) {
let formEl
$("modal-").open(() => {
formEl = DesktopCalendarForm(
this.calendars,
(updated) => { this.updateCalendar(updated); $("modal-").forceClose() },
calendar,
(deletedId) => this.deleteCalendar(deletedId),
() => $("modal-").forceClose()
)
})
$("modal-")._closeOverride = async () => {
const saved = formEl ? await formEl.trySave() : null
if (saved === null) return
this.updateCalendar(saved)
$("modal-").forceClose()
}
}
addCalendar(calendar) {
this.calendars = [...this.calendars, calendar]
this.selectedCalendars = [...this.selectedCalendars, calendar]
global.currentNetwork.data.calendars = [...global.currentNetwork.data.calendars, calendar]
this.rerender()
}
updateCalendar(calendar) {
this.calendars = this.calendars.map(c => c.id === calendar.id ? calendar : c)
this.selectedCalendars = this.selectedCalendars.map(c => c.id === calendar.id ? calendar : c)
global.currentNetwork.data.calendars = global.currentNetwork.data.calendars.map(c => c.id === calendar.id ? calendar : c)
this.rerender()
}
deleteCalendar(id) {
this.calendars = this.calendars.filter(c => c.id !== id)
this.selectedCalendars = this.selectedCalendars.filter(c => c.id !== id)
global.currentNetwork.data.calendars = global.currentNetwork.data.calendars.filter(c => c.id !== id)
this.rerender()
}
updateEvent(event) {
const parsed = this.parseEvent(event)
this.events = this.events.map(e => e.id === parsed.id ? parsed : e)
global.currentNetwork.data.events = global.currentNetwork.data.events.map(e => e.id === event.id ? event : e)
this.rerender()
}
deleteEvent(id) {
this.events = this.events.filter(e => e.id !== id)
global.currentNetwork.data.events = global.currentNetwork.data.events.filter(e => e.id !== id)
this.rerender()
}
handleEditResult({ scope, event: resultEvent, templateId, occurrenceDate }) {
const event = { ...resultEvent, time_start: new Date(resultEvent.time_start), time_end: new Date(resultEvent.time_end) };
if (scope === 'all') {
// Preserve end_date from old template — it may have been set by a 'this and future' split.
const oldTemplate = this.events.find(e => e.id === templateId);
const oldEndDate = oldTemplate?.recurrence?.end_date ?? null;
const recurrence = event.recurrence
? { ...event.recurrence, end_date: event.recurrence.end_date ?? oldEndDate }
: null;
const mergedEvent = { ...event, recurrence };
this.events = this.events.map(e => e.id === templateId ? mergedEvent : e);
global.currentNetwork.data.events = global.currentNetwork.data.events.map(e =>
e.id === templateId ? { ...resultEvent, recurrence } : e
);
} else if (scope === 'single') {
const alreadyExists = this.events.some(e => e.id === resultEvent.id);
if (alreadyExists) {
this.events = this.events.map(e => e.id === resultEvent.id ? event : e);
global.currentNetwork.data.events = global.currentNetwork.data.events.map(e => e.id === resultEvent.id ? resultEvent : e);
} else {
this.events = [...this.events, event];
global.currentNetwork.data.events = [...global.currentNetwork.data.events, resultEvent];
}
} else if (scope === 'future') {
const capDate = occurrenceDate ? new Date(occurrenceDate) : null;
if (capDate) {
const oldTemplate = this.events.find(e => e.id === templateId);
const oldEndDate = oldTemplate?.recurrence?.end_date ? new Date(oldTemplate.recurrence.end_date) : null;
const baseRecurrence = event.recurrence ?? oldTemplate?.recurrence;
const inheritedRecurrence = baseRecurrence
? { ...baseRecurrence, end_date: oldEndDate ? oldEndDate.toISOString() : null }
: null;
const newTemplateEvent = { ...event, recurrence: inheritedRecurrence };
const newTemplateRaw = { ...resultEvent, recurrence: inheritedRecurrence };
const descendantIds = new Set(
this.events
.filter(e => {
if (!(e.parent_template_id === templateId && e.recurrence_id)) return false;
const t = new Date(e.time_start);
return t >= capDate && (!oldEndDate || t < oldEndDate);
})
.map(e => e.id)
);
const newId = resultEvent.id;
const updateAndFilter = (arr) => arr.map(e => {
if (e.id === templateId && e.recurrence) {
return { ...e, recurrence: { ...e.recurrence, end_date: capDate.toISOString() } };
}
if (e.recurrence_parent_id === templateId && e.recurrence_exception_date) {
const exDate = new Date(e.recurrence_exception_date);
if (exDate >= capDate && (!oldEndDate || exDate < oldEndDate)) {
return { ...e, recurrence_parent_id: newId };
}
}
return e;
}).filter(e => {
if (e.recurrence_parent_id === templateId && e.recurrence_exception_date) {
return new Date(e.recurrence_exception_date) < capDate;
}
if (descendantIds.has(e.id) || descendantIds.has(e.recurrence_parent_id)) {
return false;
}
return true;
});
this.events = [...updateAndFilter(this.events), newTemplateEvent];
global.currentNetwork.data.events = [...updateAndFilter(global.currentNetwork.data.events), newTemplateRaw];
}
}
}
handleDeleteResult({ scope, templateId, occurrenceDate, overrideId }) {
if (scope === 'all') {
// Promote non-cancelled overrides (single-event edits) to standalone; remove cancelled placeholders and template
const promoteOverrides = (arr) => arr
.filter(e => e.id !== templateId && !(e.recurrence_parent_id === templateId && e.is_cancelled))
.map(e => e.recurrence_parent_id === templateId
? { ...e, recurrence_parent_id: null, recurrence_exception_date: null }
: e
);
this.events = promoteOverrides(this.events);
global.currentNetwork.data.events = promoteOverrides(global.currentNetwork.data.events);
} else if (scope === 'single') {
if (overrideId) {
this.events = this.events.map(e => e.id === overrideId ? { ...e, is_cancelled: true } : e);
global.currentNetwork.data.events = global.currentNetwork.data.events.map(e => e.id === overrideId ? { ...e, is_cancelled: true } : e);
} else if (occurrenceDate) {
const occDate = new Date(occurrenceDate);
const syntheticOverride = {
id: `cancelled_${templateId}_${occurrenceDate}`,
recurrence_parent_id: templateId,
recurrence_exception_date: occDate,
is_cancelled: true,
time_start: occDate,
time_end: occDate,
calendars: [],
all_day: false,
};
this.events = [...this.events, syntheticOverride];
global.currentNetwork.data.events = [...global.currentNetwork.data.events, syntheticOverride];
}
} else if (scope === 'future') {
const capDate = occurrenceDate ? new Date(occurrenceDate) : null;
if (capDate) {
const oldTemplate = this.events.find(e => e.id === templateId);
// Server does a full delete when capDate <= time_start (no occurrences would remain)
if (oldTemplate && capDate <= new Date(oldTemplate.time_start)) {
const promoteOverrides = (arr) => arr
.filter(e => e.id !== templateId && !(e.recurrence_parent_id === templateId && e.is_cancelled))
.map(e => e.recurrence_parent_id === templateId
? { ...e, recurrence_parent_id: null, recurrence_exception_date: null }
: e
);
this.events = promoteOverrides(this.events);
global.currentNetwork.data.events = promoteOverrides(global.currentNetwork.data.events);
return;
}
const oldEndDate = oldTemplate?.recurrence?.end_date ? new Date(oldTemplate.recurrence.end_date) : null;
const descendantIds = new Set(
this.events
.filter(e => {
if (!(e.parent_template_id === templateId && e.recurrence_id)) return false;
const t = new Date(e.time_start);
return t >= capDate && (!oldEndDate || t < oldEndDate);
})
.map(e => e.id)
);
const updateAndFilter = (arr) => arr.map(e => {
if (e.id === templateId && e.recurrence) {
return { ...e, recurrence: { ...e.recurrence, end_date: capDate.toISOString() } };
}
// Promote future non-cancelled overrides to standalone events
if (e.recurrence_parent_id === templateId && e.recurrence_exception_date
&& new Date(e.recurrence_exception_date) >= capDate && !e.is_cancelled) {
return { ...e, recurrence_parent_id: null, recurrence_exception_date: null };
}
return e;
}).filter(e => {
// Remove future cancelled placeholders (meaningless without the series)
if (e.recurrence_parent_id === templateId && e.recurrence_exception_date) {
return new Date(e.recurrence_exception_date) < capDate;
}
if (descendantIds.has(e.id) || descendantIds.has(e.recurrence_parent_id)) {
return false;
}
return true;
});
this.events = updateAndFilter(this.events);
global.currentNetwork.data.events = updateAndFilter(global.currentNetwork.data.events);
}
}
}
parseEvent(event) {
return { ...event, time_start: new Date(event.time_start), time_end: new Date(event.time_end) }
}
goToPrevious() {
this.currentDate = calendarUtil.addMonths(this.currentDate, -1);
this.rerender();
}
goToCurrent() {
this.currentDate = new Date();
this.rerender();
}
goToNext() {
this.currentDate = calendarUtil.addMonths(this.currentDate, 1);
this.rerender();
}
goToDate(date) {
if (calendarUtil.isSameMonth(this.currentDate, date)) return;
this.currentDate = date;
this.rerender();
}
}
register(Calendar)