Files
apps/calendar/Events/EventForm.js
2026-04-28 21:25:54 -05:00

1157 lines
59 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import server from "/calendar/@server/calendar.js"
import calendarUtil from "../calendarUtil.js"
import "../EventFileList.js"
import "../../components/Avatar.js"
css(`
eventform- {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
scrollbar-width: none;
-ms-overflow-style: none;
}
eventform- > form {
display: flex;
flex-direction: column;
flex: 1 1 auto;
height: 100%;
min-height: 0;
}
eventform- ::-webkit-scrollbar { display: none; width: 0; height: 0; }
eventform- ::-webkit-scrollbar-thumb { background: transparent; }
eventform- ::-webkit-scrollbar-track { background: transparent; }
eventform- input::placeholder,
eventform- textarea::placeholder {
color: var(--headertext);
opacity: 0.35;
}
eventform- input[type="date"],
eventform- input[type="time"] {
min-width: 0;
}
eventform- input[type="checkbox"] {
flex-shrink: 0;
}
#eventform-toast-wrap {
transition: max-height 0.25s ease, opacity 0.22s ease, padding-top 0.25s ease;
}
`)
class EventForm extends Shadow {
cardInputStyles(el) {
return el
.border("none").outline("none").background("transparent")
.color("var(--text)").fontSize(0.9, em).fontFamily("Arial")
.padding(0).boxSizing("border-box")
}
selectedCalendars = [];
pendingSaveEventData = null; // stashed before the recurring scope prompt, consumed by performSave
uploadedFiles = [];
existingAttachments = [];
filesOpen = false;
attachmentsDeleted = false;
pendingDeletions = [];
initialAllDay = true;
startDate = calendarUtil.toDateInput(new Date());
endDate = calendarUtil.toDateInput(new Date());
startTime = "13:00";
endTime = "14:00";
recurrence = null;
constructor(calendars, updateEvents, event = null, onBack = null, onDelete = null, initialDate = null, onSaveError = null) {
super()
this.calendars = calendars;
this.editEvent = event;
this.updateEvents = updateEvents;
this.onBack = onBack;
this.onDelete = onDelete;
this.onSaveError = onSaveError;
if (event) {
const start = new Date(event.time_start);
const end = new Date(event.time_end);
this.startDate = calendarUtil.toDateInput(start);
this.endDate = calendarUtil.toDateInput(end);
this.startTime = event.all_day ? "13:00" : calendarUtil.toTimeInput(start);
this.endTime = event.all_day ? "14:00" : calendarUtil.toTimeInput(end);
this.selectedCalendars = calendars.filter(c => event.calendars?.includes(c.id));
this.existingAttachments = event.attachments ?? [];
this.recurrence = event.recurrence
? { frequency: event.recurrence.frequency, interval: event.recurrence.interval ?? 1, days_of_week: event.recurrence.days_of_week ? [...event.recurrence.days_of_week] : null }
: null;
// Snapshot original state in getFormData() shape for dirty checking
this.originalFormData = {
title: event.title ?? "",
location: event.location ?? "",
description: event.description ?? "",
all_day: event.all_day,
time_start: event.all_day ? this.startDate : `${this.startDate}T${this.startTime}`,
time_end: event.all_day ? this.endDate : `${this.endDate}T${this.endTime}`,
calendars: [...(event.calendars ?? [])].sort().join(","),
recurrence: this.recurrence ? { ...this.recurrence, days_of_week: this.recurrence.days_of_week ? [...this.recurrence.days_of_week] : null } : null
};
} else {
if (calendars.length > 0) {
this.selectedCalendars = [calendars[0]];
}
if (initialDate) {
this.startDate = calendarUtil.toDateInput(initialDate);
const h = initialDate.getHours(), m = initialDate.getMinutes();
if (h !== 0 || m !== 0) {
this.initialAllDay = false;
this.startTime = calendarUtil.toTimeInput(initialDate);
const endDate = new Date(initialDate.getTime() + 30 * 60 * 1000);
this.endTime = calendarUtil.toTimeInput(endDate);
this.endDate = calendarUtil.toDateInput(endDate);
} else {
this.endDate = calendarUtil.toDateInput(initialDate);
}
}
this.originalFormData = {
title: "",
location: "",
description: "",
all_day: this.initialAllDay,
time_start: this.initialAllDay ? this.startDate : `${this.startDate}T${this.startTime}`,
time_end: this.initialAllDay ? this.endDate : `${this.endDate}T${this.endTime}`,
calendars: (calendars.length > 0 ? [calendars[0].id] : []).sort().join(","),
recurrence: null
};
}
}
enforceEndAfterStart() {
const isAllDay = this.$('[name="all_day"]')?.checked ?? true;
const start = new Date(`${this.startDate}T${isAllDay ? "00:00" : this.startTime}`);
const end = new Date(`${this.endDate}T${isAllDay ? "00:00" : this.endTime}`);
if (end < start) {
const newEnd = new Date(start.getTime() + (isAllDay ? 0 : 60 * 60 * 1000));
this.endDate = calendarUtil.toDateInput(newEnd);
this.endTime = calendarUtil.toTimeInput(newEnd);
const endDateEl = this.$('[name="time_end_date"]');
const endTimeEl = this.$('[name="time_end_time"]');
if (endDateEl) endDateEl.value = this.endDate;
if (endTimeEl) endTimeEl.value = this.endTime;
}
}
recurrenceOptionKey() {
const r = this.recurrence;
if (!r) return "never";
if (r.frequency === 'daily') return "daily";
if (r.frequency === 'weekly' && r.interval === 2) return "biweekly";
if (r.frequency === 'weekly') return "weekly";
if (r.frequency === 'monthly') return "monthly";
if (r.frequency === 'yearly') return "yearly";
return "never";
}
recurrenceLabel() {
switch (this.recurrenceOptionKey()) {
case "daily": return "Daily";
case "weekly": return "Weekly";
case "biweekly": return "Every 2 weeks";
case "monthly": return "Monthly";
case "yearly": return "Yearly";
default: return "Never";
}
}
render() {
const isEditMode = !!this.editEvent;
const showTime = isEditMode ? !this.editEvent.all_day : !this.initialAllDay;
const initialAllDay = isEditMode ? this.editEvent.all_day : this.initialAllDay;
form(() => {
VStack(() => {
// ── Header ────────────────────────────────────────────
HStack(() => {
button("Cancel")
.attr({ type: "button" })
.background("none").border("none").padding(0)
.color("var(--quillred)").fontSize(0.95, em)
.fontFamily("Arial").cursor("pointer").flexShrink(0)
.onTap(() => this.handleBack())
p(isEditMode ? "Edit Event" : "New Event")
.margin(0).fontSize(1, em).fontWeight("700")
.color("var(--headertext)").fontFamily("Arial")
.flex(1).textAlign("center")
button("Save")
.attr({ type: "submit" })
.background("none").border("none").padding(0)
.color("var(--quillred)").fontSize(0.95, em)
.fontWeight("600").fontFamily("Arial").cursor("pointer").flexShrink(0)
})
.paddingHorizontal(1.25, em).paddingVertical(0.9, em)
.alignItems("center").borderBottom("1px solid var(--divider)")
.flexShrink(0).background(util.darkMode() ? "transparent" : "var(--sidebottombars)")
// ── Error toast ───────────────────────────────────────
VStack(() => {
p("")
.attr({ id: "eventform-toast" })
.margin(0)
.padding("0.55em 1.1em")
.background("var(--quillred)")
.color("white")
.fontSize(0.85, em)
.fontWeight("500")
.fontFamily("Arial")
.borderRadius("0.5em")
.boxShadow("0 2px 10px rgba(0,0,0,0.15)")
.whiteSpace("nowrap")
})
.attr({ id: "eventform-toast-wrap" })
.alignItems("center")
.overflow("hidden")
.maxHeight(0)
.opacity(0)
.flexShrink(0)
// ── Scrollable body ───────────────────────────────────
VStack(() => {
// ── Title card ────────────────────────────────────
VStack(() => {
input("Title", "100%")
.attr({ name: "title", type: "text", value: this.editEvent?.title ?? "" })
.border("none").outline("none").background("transparent")
.color("var(--text)").fontSize(1.1, em).fontFamily("Arial")
.fontWeight("500").padding("0.9em 1em").boxSizing("border-box")
})
.background("var(--darkaccent)").border("1px solid var(--divider)")
.borderRadius(12, px).marginHorizontal(1, em).overflow("hidden")
.flexShrink(0)
// ── Date / time card ──────────────────────────────
VStack(() => {
// All day row
HStack(() => {
p("All day")
.margin(0).fontSize(0.92, em).color("var(--headertext)")
input("", "auto")
.attr({ name: "all_day", type: "checkbox", ...(initialAllDay ? { checked: true } : {}) })
.accentColor("var(--quillred)").transform("scale(1.25)")
.onChange(() => {
const isAllDay = this.$('[name="all_day"]').checked
const display = isAllDay ? "none" : ""
this.$("#time_start_time").style.display = display
this.$("#time_end_time").style.display = display
})
})
.paddingHorizontal(1, em).paddingVertical(0.78, em)
.justifyContent("space-between").alignItems("center")
.borderBottom("1px solid var(--divider)")
// Starts row
HStack(() => {
p("Starts")
.margin(0).fontSize(0.92, em).color("var(--headertext)").flexShrink(0)
HStack(() => {
input("", "auto", "1em")
.attr({ name: "time_start_date", type: "date", id: "time_start_date", value: this.startDate })
.styles(this.cardInputStyles).fontSize(0.88, em)
.onChange((e) => {
const oldDay = new Date(this.startDate + 'T00:00:00').getDay();
this.startDate = e.target.value;
this.enforceEndAfterStart();
if (this.recurrence?.frequency === 'weekly' && this.recurrence.days_of_week?.length > 0) {
const newDay = new Date(this.startDate + 'T00:00:00').getDay();
if (newDay !== oldDay && this.recurrence.days_of_week.includes(oldDay)) {
const updated = [...new Set(this.recurrence.days_of_week.map(d => d === oldDay ? newDay : d))].sort((a, b) => a - b);
this.recurrence.days_of_week = updated;
const oldEl = this.$(`#recur-day-${oldDay}`);
const newEl = this.$(`#recur-day-${newDay}`);
if (oldEl) { oldEl.style.background = 'transparent'; oldEl.style.color = 'var(--text)'; oldEl.style.opacity = '0.4'; }
if (newEl) { newEl.style.background = 'var(--quillred)'; newEl.style.color = 'white'; newEl.style.opacity = '1'; }
}
}
})
input("", "auto", "1em")
.attr({ name: "time_start_time", type: "time", id: "time_start_time", value: this.startTime, step: "300" })
.styles(this.cardInputStyles).fontSize(0.88, em)
.display(showTime ? "" : "none")
.onChange((e) => { this.startTime = e.target.value; this.enforceEndAfterStart() })
})
.gap(0.35, em).flex(1).justifyContent("flex-end")
})
.paddingHorizontal(1, em).paddingVertical(0.78, em)
.alignItems("center").borderBottom("1px solid var(--divider)")
// Ends row
HStack(() => {
p("Ends")
.margin(0).fontSize(0.92, em).color("var(--headertext)").flexShrink(0)
HStack(() => {
input("", "auto", "1em")
.attr({ name: "time_end_date", type: "date", id: "time_end_date", value: this.endDate })
.styles(this.cardInputStyles).fontSize(0.88, em)
.onChange((e) => { this.endDate = e.target.value; this.enforceEndAfterStart() })
input("", "auto", "1em")
.attr({ name: "time_end_time", type: "time", id: "time_end_time", value: this.endTime, step: "300" })
.styles(this.cardInputStyles).fontSize(0.88, em)
.display(showTime ? "" : "none")
.onChange((e) => { this.endTime = e.target.value; this.enforceEndAfterStart() })
})
.gap(0.35, em).flex(1).justifyContent("flex-end")
})
.paddingHorizontal(1, em).paddingVertical(0.78, em)
.alignItems("center").borderBottom("1px solid var(--divider)")
// Repeat row
HStack(() => {
p("Repeat")
.margin(0).fontSize(0.92, em).color("var(--headertext)").flexShrink(0)
p(this.recurrenceLabel())
.attr({ id: "recur-label" })
.margin(0).fontSize(0.88, em).fontFamily("Arial")
.color(this.recurrence ? "var(--text)" : "var(--headertext)")
.opacity(this.recurrence ? 1 : 0.55)
.flex(1).textAlign("right")
p("")
.attr({ id: "recur-chevron" })
.margin(0).opacity(0.3).fontSize(1.1, em).flexShrink(0)
.transition("transform 0.25s ease")
})
.paddingHorizontal(1, em).paddingVertical(0.78, em)
.alignItems("center").gap(0.5, em)
.cursor("pointer")
.onTap(() => {
const picker = this.$("#recur-picker")
const chevron = this.$("#recur-chevron")
if (!picker) return
const isOpen = picker.getAttribute("data-open") === "1"
if (isOpen) {
picker.setAttribute("data-open", "0")
picker.style.maxHeight = "0"
if (chevron) chevron.style.transform = ""
} else {
picker.setAttribute("data-open", "1")
picker.style.maxHeight = picker.scrollHeight + "px"
if (chevron) chevron.style.transform = "rotate(90deg)"
}
})
// Inline recurrence picker
VStack(() => {
const currentKey = this.recurrenceOptionKey()
const isWeekly = currentKey === 'weekly' || currentKey === 'biweekly'
const selectedDays = this.recurrence?.days_of_week ?? []
const recurrenceOptions = [
{ key: "never", label: "Never" },
{ key: "daily", label: "Daily" },
{ key: "weekly", label: "Weekly" },
{ key: "biweekly", label: "Every 2 weeks" },
{ key: "monthly", label: "Monthly" },
{ key: "yearly", label: "Yearly" },
]
recurrenceOptions.forEach(opt => {
HStack(() => {
p(opt.label)
.margin(0).fontSize(0.9, em).color("var(--text)").fontFamily("Arial").flex(1)
p("✓")
.attr({ id: `recur-check-${opt.key}` })
.margin(0).fontSize(0.88, em)
.color("var(--quillred)").fontWeight("700")
.display(currentKey === opt.key ? "" : "none")
})
.paddingHorizontal(1.25, em).paddingVertical(0.72, em)
.alignItems("center")
.borderBottom("1px solid var(--divider)")
.cursor("pointer")
.onTap(() => {
let newRecurrence = null
if (opt.key !== 'never') {
if (opt.key === 'weekly' || opt.key === 'biweekly') {
const sd = new Date(this.startDate + 'T00:00:00').getDay()
const curDays = this.recurrence?.frequency === 'weekly'
? (this.recurrence.days_of_week ?? [sd])
: [sd]
newRecurrence = {
frequency: 'weekly',
interval: opt.key === 'biweekly' ? 2 : 1,
days_of_week: [...curDays]
}
} else {
const freqMap = { daily: 'daily', monthly: 'monthly', yearly: 'yearly' }
newRecurrence = { frequency: freqMap[opt.key], interval: 1 }
}
}
this.recurrence = newRecurrence
const allKeys = ['never', 'daily', 'weekly', 'biweekly', 'monthly', 'yearly']
allKeys.forEach(k => {
const el = this.$(`#recur-check-${k}`)
if (el) el.style.display = k === opt.key ? "" : "none"
})
const labelEl = this.$("#recur-label")
if (labelEl) {
labelEl.textContent = this.recurrenceLabel()
labelEl.style.color = newRecurrence ? "var(--text)" : "var(--headertext)"
labelEl.style.opacity = newRecurrence ? "1" : "0.55"
}
const isNowWeekly = opt.key === 'weekly' || opt.key === 'biweekly'
const daysRow = this.$("#recur-days")
if (daysRow) daysRow.style.display = isNowWeekly ? "" : "none"
if (isNowWeekly && newRecurrence?.days_of_week) {
const days = newRecurrence.days_of_week;
[0, 1, 2, 3, 4, 5, 6].forEach(d => {
const el = this.$(`#recur-day-${d}`)
if (!el) return
const sel = days.includes(d)
el.style.background = sel ? "var(--quillred)" : "transparent"
el.style.color = sel ? "white" : "var(--text)"
el.style.opacity = sel ? "1" : "0.4"
})
}
const picker = this.$("#recur-picker")
if (picker && picker.getAttribute("data-open") === "1") {
picker.style.maxHeight = picker.scrollHeight + "px"
}
})
})
// Day-of-week selector (visible only for weekly/biweekly)
const DAY_LABELS = ['S', 'M', 'T', 'W', 'T', 'F', 'S']
HStack(() => {
DAY_LABELS.forEach((label, i) => {
const sel = selectedDays.includes(i)
span(label)
.attr({ id: `recur-day-${i}` })
.width(2, em).height(2, em)
.lineHeight("2em")
.textAlign("center")
.borderRadius(50, pct)
.fontSize(0.78, em).fontWeight("600")
.cursor("pointer").flexShrink(0)
.background(sel ? "var(--quillred)" : "transparent")
.color(sel ? "white" : "var(--text)")
.opacity(sel ? "1" : "0.4")
.onTap(() => {
if (!this.recurrence?.days_of_week) return
const days = this.recurrence.days_of_week
const idx = days.indexOf(i)
if (idx >= 0) {
if (days.length > 1) days.splice(idx, 1)
} else {
days.push(i)
}
const nowSel = days.includes(i)
const el = this.$(`#recur-day-${i}`)
if (el) {
el.style.background = nowSel ? "var(--quillred)" : "transparent"
el.style.color = nowSel ? "white" : "var(--text)"
el.style.opacity = nowSel ? "1" : "0.4"
}
const picker = this.$("#recur-picker")
if (picker && picker.getAttribute("data-open") === "1") {
picker.style.maxHeight = picker.scrollHeight + "px"
}
})
})
})
.attr({ id: "recur-days" })
.justifyContent("space-around")
.paddingHorizontal(0.5, em).paddingVertical(0.65, em)
.display(isWeekly ? "" : "none")
})
.attr({ id: "recur-picker", "data-open": "0" })
.overflow("hidden").maxHeight(0)
.transition("max-height 0.3s ease")
})
.background("var(--darkaccent)").border("1px solid var(--divider)")
.borderRadius(12, px).marginHorizontal(1, em).overflow("hidden")
.flexShrink(0)
// ── Calendar / Location / Notes card ──────────────
VStack(() => {
// Calendar row — tapping expands inline picker
HStack(() => {
p("Calendar")
.margin(0).fontSize(0.92, em).color("var(--headertext)").flexShrink(0)
HStack(() => {
this.selectedCalendars.forEach(cal => {
HStack(() => {
p("").width(0.6, em).height(0.6, em)
.background(cal.color).borderRadius(50, pct).flexShrink(0)
p(cal.name)
.margin(0).fontSize(0.82, em).fontWeight("600")
.color("var(--text)").whiteSpace("nowrap")
})
.gap(0.3, em).alignItems("center")
})
})
.attr({ id: "calendar-display" })
.flex(1).justifyContent("flex-end").flexWrap("wrap").gap(0.5, em)
p("")
.attr({ id: "cal-chevron" })
.margin(0).opacity(0.3).fontSize(1.1, em).flexShrink(0)
.transition("transform 0.25s ease")
})
.paddingHorizontal(1, em).paddingVertical(0.78, em)
.alignItems("center").gap(0.5, em)
.borderBottom("1px solid var(--divider)").cursor("pointer")
.onTap(() => {
const picker = this.$("#cal-picker")
const chevron = this.$("#cal-chevron")
if (!picker) return
const isOpen = picker.getAttribute("data-open") === "1"
if (isOpen) {
picker.setAttribute("data-open", "0")
picker.style.maxHeight = "0"
if (chevron) chevron.style.transform = ""
} else {
picker.setAttribute("data-open", "1")
picker.style.maxHeight = picker.scrollHeight + "px"
if (chevron) chevron.style.transform = "rotate(90deg)"
}
})
// Inline calendar picker (collapsed by default)
VStack(() => {
this.calendars.forEach(cal => {
const isSelected = this.selectedCalendars.some(c => c.id === cal.id)
HStack(() => {
HStack(() => {
p("").width(0.65, em).height(0.65, em)
.background(cal.color).borderRadius(50, pct).flexShrink(0)
p(cal.name)
.margin(0).fontSize(0.9, em).color("var(--text)").fontFamily("Arial")
})
.gap(0.45, em).alignItems("center").flex(1)
p("✓")
.attr({ id: `cal-check-${cal.id}` })
.margin(0).fontSize(0.88, em)
.color("var(--quillred)").fontWeight("700")
.display(isSelected ? "" : "none")
})
.paddingHorizontal(1.25, em).paddingVertical(0.72, em)
.alignItems("center")
.borderBottom("1px solid var(--divider)")
.cursor("pointer")
.onTap(() => {
const idx = this.selectedCalendars.findIndex(c => c.id === cal.id)
if (idx >= 0) {
if (this.selectedCalendars.length > 1) {
this.selectedCalendars.splice(idx, 1)
const check = this.$(`#cal-check-${cal.id}`)
if (check) check.style.display = "none"
}
} else {
this.selectedCalendars.push(cal)
const check = this.$(`#cal-check-${cal.id}`)
if (check) check.style.display = ""
}
this.updateCalendarDisplay()
})
})
})
.attr({ id: "cal-picker", "data-open": "0" })
.overflow("hidden").maxHeight(0)
.transition("max-height 0.3s ease")
// Location row
HStack(() => {
p("📍").margin(0).fontSize(0.85, em).flexShrink(0)
input("Location", "100%")
.attr({ name: "location", type: "text", value: this.editEvent?.location ?? "" })
.styles(this.cardInputStyles).flex(1)
})
.paddingHorizontal(1, em).paddingVertical(0.78, em)
.alignItems("center").gap(0.65, em)
.borderBottom("1px solid var(--divider)")
// Noes row
HStack(() => {
p("📝")
.margin(0).fontSize(0.85, em).flexShrink(0).alignSelf("flex-start").paddingTop(0.08, em)
textarea(this.editEvent?.description ?? "")
.attr({ name: "description" })
.styles(this.cardInputStyles).flex(1)
.minHeight(3, em).resize("none")
.fieldSizing("content").lineHeight("1.45")
.onAppear(function() {
this.value = this.placeholder
})
})
.paddingHorizontal(1, em).paddingVertical(0.78, em)
.alignItems("flex-start").gap(0.65, em)
})
.background("var(--darkaccent)").border("1px solid var(--divider)")
.borderRadius(12, px).marginHorizontal(1, em).overflow("hidden")
.flexShrink(0)
// ── Attachments card ──────────────────────────────
input("", "100%")
.attr({ name: "attachments", type: "file", multiple: true })
.display("none")
.onChange(async (e) => {
const files = Array.from(e.target.files)
e.target.value = ""
this.uploadedFiles.push(...files)
this.fileListComponent.update(files)
this.updateFilesChevron()
})
VStack(() => {
HStack(() => {
p("📎").margin(0).fontSize(0.85, em).flexShrink(0)
p("Attachments")
.margin(0).fontSize(0.92, em).color("var(--headertext)").flex(1)
HStack(() => {
span("Upload")
.color("var(--quillred)").cursor("pointer")
.fontFamily("Arial").fontSize(0.85, em)
.padding(0.5, em).margin(-0.5, em)
.onTap((e) => { e.stopPropagation(); this.$('[name="attachments"]').click() })
span("▼")
.attr({ id: "files-chevron" })
.margin(0).fontSize(0.7, em).color("var(--text)").opacity(0.5)
.display(this.hasAnyFiles() ? "inline-block" : "none")
.transition("transform 0.3s ease")
.transform(this.filesOpen ? "rotate(180deg)" : "rotate(0deg)")
.padding(0.5, em).margin(-0.5, em)
})
.gap(0.65, em).alignItems("center")
})
.paddingHorizontal(1, em).paddingVertical(0.78, em).alignItems("center").gap(0.65, em)
.cursor(this.hasAnyFiles() ? "pointer" : "")
.onTap(() => { if (this.hasAnyFiles()) this.toggleFiles() })
VStack(() => {
this.fileListComponent = EventFileList(
this.existingAttachments,
isEditMode ? (fileId) => this.handleDeleteAttachment(fileId) : null,
(index) => this.handleDeleteNewFile(index),
(file, url) => $("filepreview-")?.open(file, url)
)
this.fileListComponent
.padding("0 1em 0.75em").flexDirection("column")
.gap(0.5, em).border("none")
})
.attr({ id: "files-content" })
.overflow("hidden")
.maxHeight(this.hasAnyFiles() && this.filesOpen ? "600px" : "0")
.transition("max-height 0.5s ease")
})
.background("var(--darkaccent)").border("1px solid var(--divider)")
.borderRadius(12, px).marginHorizontal(1, em).overflow("hidden")
.flexShrink(0)
if (isEditMode) {
button("Delete Event")
.attr({ type: "button" })
.width("calc(100% - 2em)").marginHorizontal(1, em)
.padding(0.85, em).boxSizing("border-box")
.background("transparent").color("var(--quillred)")
.border("1.5px solid var(--quillred)")
.borderRadius(12, px).fontSize(0.95, em).fontFamily("Arial")
.fontWeight("600").cursor("pointer")
.flexShrink(0)
.onTap(() => this.handleDelete())
}
})
.overflowY("scroll").flex(1).minHeight(0).paddingTop(0.85, em).paddingBottom(1.5, em).gap(0.85, em)
// ── Footer: creator avatar + timestamps ───────────────
if (this.editEvent) {
const members = global.currentNetwork.data?.members || []
const creator = members.find(m => m.id === this.editEvent.creator_id)
if (creator) {
HStack(() => {
Avatar(creator, 2)
VStack(() => {
p(`Created ${calendarUtil.timeAgo(this.editEvent.created)} by ${creator.first_name}`)
.margin(0).fontSize(0.9, em).color("var(--headertext)").opacity(0.5)
if (this.editEvent.updated_at && this.editEvent.updated_at !== this.editEvent.created) {
p(`Last updated ${calendarUtil.timeAgo(this.editEvent.updated_at)}`)
.margin(0).fontSize(0.9, em).color("var(--headertext)").opacity(0.4)
}
})
.gap(0.15, em)
})
// .position("absolute")
// .bottom(0)
// .left(0)
// .right(0)
.paddingHorizontal(1, em)
.paddingVertical(0.65, em)
.alignItems("center")
.gap(0.5, em)
.flexShrink(0)
}
}
})
.height(100, pct)
.position("relative")
.onSubmit((e) => {
e.preventDefault()
this.handleSend(this.getFormData())
})
.onKeyDown((e) => {
if (e.key === "Enter" && e.target.tagName !== "TEXTAREA" && e.target.tagName !== "BUTTON") e.preventDefault()
})
})
.height(100, pct)
}
hasAnyFiles() {
return this.existingAttachments.length > 0 || this.uploadedFiles.length > 0;
}
toggleFiles() {
this.filesOpen = !this.filesOpen;
const content = this.$("#files-content");
const chevron = this.$("#files-chevron");
if (content) content.style.maxHeight = this.filesOpen ? "600px" : "0";
if (chevron) chevron.style.transform = this.filesOpen ? "rotate(180deg)" : "rotate(0deg)";
}
updateFilesChevron() {
const chevron = this.$("#files-chevron");
const content = this.$("#files-content");
const hasFiles = this.hasAnyFiles();
if (chevron) chevron.style.display = hasFiles ? "inline-block" : "none";
if (!hasFiles && this.filesOpen) {
this.filesOpen = false;
if (content) content.style.maxHeight = "0";
} else if (hasFiles && !this.filesOpen) {
this.filesOpen = true;
if (content) content.style.maxHeight = "600px";
if (chevron) chevron.style.transform = "rotate(180deg)";
}
}
updateCalendarDisplay() {
const el = this.$("#calendar-display");
if (!el) return;
el.innerHTML = this.selectedCalendars.map(cal => `
<div style="display:flex;align-items:center;gap:0.3em;">
<div style="width:0.6em;height:0.6em;background:${cal.color};border-radius:50%;flex-shrink:0;"></div>
<p style="font-size:0.82em;font-weight:600;margin:0;color:var(--text);white-space:nowrap;">${cal.name}</p>
</div>
`).join('');
}
showError(msg) {
const wrap = this.$("#eventform-toast-wrap")
const toast = this.$("#eventform-toast")
if (!wrap || !toast) return
clearTimeout(this._errorTimer)
if (msg) {
toast.innerText = msg
wrap.style.maxHeight = "3em"
wrap.style.opacity = "1"
wrap.style.paddingTop = "0.85em"
this._errorTimer = setTimeout(() => this.hideError(), 3500)
} else {
this.hideError()
}
}
hideError() {
const wrap = this.$("#eventform-toast-wrap")
if (!wrap) return
clearTimeout(this._errorTimer)
wrap.style.maxHeight = "0"
wrap.style.opacity = "0"
wrap.style.paddingTop = "0"
}
getFormData() {
const isAllDay = this.$('[name="all_day"]')?.checked ?? true;
return {
title: this.$('[name="title"]').value,
location: this.$('[name="location"]').value,
time_start: isAllDay
? this.$('[name="time_start_date"]').value
: `${this.$('[name="time_start_date"]').value}T${this.$('[name="time_start_time"]').value}`,
time_end: isAllDay
? this.$('[name="time_end_date"]').value
: `${this.$('[name="time_end_date"]').value}T${this.$('[name="time_end_time"]').value}`,
all_day: isAllDay,
description: this.$('[name="description"]').value,
recurrence: this.recurrence
};
}
isNewEventDirty() {
const data = this.getFormData()
const o = this.originalFormData
const newCalIds = this.selectedCalendars.map(c => c.id).sort().join(",")
return (
data.title !== o.title ||
(data.location || "") !== o.location ||
(data.description || "") !== o.description ||
data.all_day !== o.all_day ||
data.time_start !== o.time_start ||
data.time_end !== o.time_end ||
newCalIds !== o.calendars ||
this.uploadedFiles.length > 0 ||
JSON.stringify(data.recurrence) !== JSON.stringify(o.recurrence)
)
}
handleBack() {
const isDirty = this.isNewEventDirty() || (!!this.editEvent && this.attachmentsDeleted);
if (isDirty) {
$('actionsheetpopup-').show(
"Discard Changes?",
[
{
label: "Discard",
onTap: () => {
if (this.onBack) {
this.onBack()
} else {
$("bottomsheet-")._closeOverride = null
$("bottomsheet-").setSheet(false)
}
}
},
{
label: "Keep Editing",
destructive: false,
onTap: () => {}
},
],
() => {}
)
return;
}
if (this.onBack) {
this.onBack()
} else {
$("bottomsheet-")._closeOverride = null
$("bottomsheet-").setSheet(false)
}
}
handleDelete() {
const event = this.editEvent;
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.editEvent;
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) {
const deleteResult = { scope: scope ?? 'all', templateId, occurrenceDate, overrideId: isOverride ? event.id : null };
if (this.onDelete) this.onDelete(deleteResult);
} else {
this.showError(result.error ?? "Failed to delete event.");
}
} catch (err) {
console.error("Failed to delete event:", err);
this.showError("Failed to delete event.");
}
}
async performSave(scope) {
const eventData = this.pendingSaveEventData;
this.pendingSaveEventData = null;
const event = this.editEvent;
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 toISO = (str, isEnd = false) => calendarUtil.toISO(str, eventData.all_day, isEnd)
// scope='all': anchor dates to the template's start, not the tapped occurrence, to preserve earlier occurrences
let time_start = toISO(eventData.time_start);
let time_end = toISO(eventData.time_end, true);
if (scope === 'all' && event._templateStart) {
const tDateStr = calendarUtil.toDateInput(event._templateStart);
const tEndDateStr = calendarUtil.toDateInput(event._templateEnd ?? event._templateStart);
// Compute day offset between the tapped occurrence's date and the user's new date,
// then apply that same offset to the template's start/end dates.
// Zero offset (date unchanged) = previous behavior; non-zero = intentional date shift.
const shiftDate = (base, offset) =>
calendarUtil.toDateInput(calendarUtil.addDays(new Date(base + 'T00:00:00'), offset));
const daysBetween = (a, b) =>
Math.round((new Date(a + 'T00:00:00') - new Date(b + 'T00:00:00')) / 86400000);
const occStartDate = calendarUtil.toDateInput(event.time_start instanceof Date ? event.time_start : new Date(event.time_start));
if (eventData.all_day) {
const startShift = daysBetween(eventData.time_start, occStartDate);
const span = daysBetween(eventData.time_end, eventData.time_start);
const newTStart = shiftDate(tDateStr, startShift);
const newTEnd = shiftDate(newTStart, span);
time_start = toISO(newTStart);
time_end = toISO(newTEnd, true);
} else {
const occEndDate = calendarUtil.toDateInput(event.time_end instanceof Date ? event.time_end : new Date(event.time_end));
const formStart = eventData.time_start.includes('T') ? eventData.time_start.split('T')[0] : eventData.time_start;
const formEnd = eventData.time_end.includes('T') ? eventData.time_end.split('T')[0] : eventData.time_end;
const startT = eventData.time_start.includes('T') ? eventData.time_start.split('T')[1] : '00:00';
const endT = eventData.time_end.includes('T') ? eventData.time_end.split('T')[1] : '00:00';
const newTStart = shiftDate(tDateStr, daysBetween(formStart, occStartDate));
const newTEnd = shiftDate(tEndDateStr, daysBetween(formEnd, occEndDate));
time_start = toISO(`${newTStart}T${startT}`);
time_end = toISO(`${newTEnd}T${endT}`, true);
}
}
// For weekly recurrence, if the user shifted the start to a different day of week,
// replace the old anchor day in days_of_week so the first occurrence isn't skipped.
let recurrence = eventData.recurrence ? { ...eventData.recurrence } : null;
if (scope !== 'single' && recurrence?.frequency === 'weekly' && recurrence.days_of_week?.length > 0) {
const newDay = new Date(time_start).getDay();
const oldDayRef = scope === 'all' && event._templateStart
? new Date(event._templateStart)
: occurrenceDate ? new Date(occurrenceDate) : null;
const originalDay = oldDayRef?.getDay() ?? null;
if (newDay !== originalDay && !recurrence.days_of_week.includes(newDay)) {
const days = [...recurrence.days_of_week];
recurrence = {
...recurrence,
days_of_week: (originalDay !== null && days.includes(originalDay))
? days.map(d => d === originalDay ? newDay : d)
: [...days, newDay].sort((a, b) => a - b)
};
}
}
const payload = {
title: eventData.title || "New event",
location: eventData.location || null,
description: eventData.description || null,
time_start,
time_end,
all_day: eventData.all_day,
calendars: this.selectedCalendars.map(c => c.id),
scope,
exception_date: occurrenceDate,
recurrence
}
try {
// Override rows: pass event.id (direct UPDATE); virtual occurrences: pass templateId (INSERT override)
const serverEventId = (scope === 'single' && isOverride) ? event.id : templateId;
const result = await server.editEvent(serverEventId, payload, global.currentNetwork.id)
if (result.status === 200) {
// Run deletions against the final event ID so that for scope='single'/'future' (new event row)
// we remove from the new event (which inherited parent files) not from the original template
await this.performPendingDeletions(result.event.id)
const attachments = await this.uploadAndMergeFiles(result.event.id, [...this.existingAttachments])
const editResult = {
scope,
event: { ...result.event, calendars: this.selectedCalendars.map(c => c.id), attachments, recurrence: eventData.recurrence ?? null },
templateId,
occurrenceDate
};
if (this.updateEvents) {
this.updateEvents(editResult);
} else {
$("bottomsheet-").toggle();
}
} else {
this.rerender();
this.showError(result.error ?? "Failed to save event.")
this.onSaveError?.()
}
} catch (err) {
console.error("Failed to save event:", err);
this.rerender();
this.showError("Failed to save event.")
this.onSaveError?.()
}
}
handleDeleteNewFile(index) {
this.uploadedFiles.splice(index, 1);
this.fileListComponent.removeNew(index);
this.updateFilesChevron();
}
handleDeleteAttachment(fileId) {
this.pendingDeletions.push(fileId)
this.existingAttachments = this.existingAttachments.filter(f => f.id !== fileId)
this.fileListComponent.removeExisting(fileId)
this.updateFilesChevron()
this.attachmentsDeleted = true
}
async performPendingDeletions(eventId = null) {
const targetId = eventId ?? this.editEvent.id;
for (const fileId of this.pendingDeletions) {
try {
await mobileUtil.authFetch(`${config.SERVER}/events/${targetId}/attachments/${fileId}`, {
method: "DELETE",
credentials: "include"
})
} catch (err) {
console.error("Failed to delete attachment:", err)
this.showError("Failed to delete attachment.")
}
}
this.pendingDeletions = []
}
async uploadAndMergeFiles(eventId, existing = []) {
if (this.uploadedFiles.length === 0) return existing;
const uploadResult = await this.handleUpload(eventId);
if (!uploadResult.success) {
this.uploadedFiles = [];
this.showError("Failed to upload attachment(s).")
return existing;
}
const existingIds = new Set(existing.map(a => a.id));
const uniqueNew = uploadResult.insertedFiles.filter(a => !existingIds.has(a.id));
return [...existing, ...uniqueNew];
}
async handleSend(eventData) {
this.hideError()
const toISO = (str, isEnd = false) => calendarUtil.toISO(str, eventData.all_day, isEnd)
const startTimestamp = toISO(eventData.time_start);
const endTimestamp = toISO(eventData.time_end, true);
const eventPayload = {
title: eventData.title || "New event",
location: eventData.location || null,
time_start: startTimestamp,
time_end: endTimestamp,
all_day: eventData.all_day,
calendars: this.selectedCalendars.map(({ id }) => id),
description: eventData.description || null,
recurrence: eventData.recurrence ?? null
}
if (this.editEvent) {
const newCalIds = this.selectedCalendars.map(c => c.id).sort().join(",");
const unchanged =
eventData.title === this.originalFormData.title &&
(eventData.location || "") === this.originalFormData.location &&
(eventData.description || "") === this.originalFormData.description &&
eventData.all_day === this.originalFormData.all_day &&
eventData.time_start === this.originalFormData.time_start &&
eventData.time_end === this.originalFormData.time_end &&
newCalIds === this.originalFormData.calendars &&
this.uploadedFiles.length === 0 &&
!this.attachmentsDeleted &&
JSON.stringify(eventData.recurrence) === JSON.stringify(this.originalFormData.recurrence);
if (unchanged) {
this.onBack ? this.onBack() : $("bottomsheet-").toggle();
return;
}
const isRecurring = !!(this.editEvent._isOccurrence || this.editEvent.recurrence_parent_id || this.editEvent.recurrence_id);
if (isRecurring) {
this.pendingSaveEventData = eventData;
$('actionsheetpopup-').show(
"Edit Recurring Event",
[
{ label: "Edit just this event", onTap: () => this.performSave('single'), destructive: false },
{ label: "Edit this and future events", onTap: () => this.performSave('future'), destructive: false },
{ label: "Edit all events in series", onTap: () => this.performSave('all'), destructive: false },
],
() => { this.pendingSaveEventData = null; }
)
return;
}
await this.performPendingDeletions()
const result = await server.editEvent(this.editEvent.id, eventPayload, global.currentNetwork.id)
if (result.status === 200) {
const attachments = await this.uploadAndMergeFiles(result.event.id, [...this.existingAttachments]);
if (this.updateEvents) {
this.updateEvents({ ...result.event, calendars: this.selectedCalendars.map(c => c.id), attachments, recurrence: eventData.recurrence ?? null });
} else {
$("bottomsheet-").toggle();
}
} else {
this.showError(result.error ?? "Failed to save event.")
this.onSaveError?.()
}
} else {
const result = await server.addEvent(eventPayload, global.currentNetwork.id)
if (result.status === 200) {
const attachments = await this.uploadAndMergeFiles(result.event.id);
// Clear override so save button doesn't re-trigger trySave
$("bottomsheet-")._closeOverride = null
$("bottomsheet-").toggle();
setTimeout(() => {
this.updateEvents({ ...result.event, calendars: this.selectedCalendars.map(c => c.id), attachments });
}, 300);
} else {
this.showError(result.error ?? "Failed to save event.")
this.onSaveError?.()
}
}
}
async handleUpload(eventId) {
try {
const body = new FormData();
Array.from(this.uploadedFiles).forEach(file => {
body.append('attachments', file);
})
const res = await mobileUtil.authFetch(`${config.SERVER}/events/${eventId}/upload-attachments`, {
method: "POST",
credentials: "include",
headers: {
"Accept": "application/json"
},
body: body
});
const { insertedFiles } = await res.json();
return { success: res.ok, insertedFiles: res.ok ? insertedFiles : [] };
} catch (err) {
console.log("Failed to add attachment to event: ", eventId)
return { success: false, error: "Failed to add attachment(s)" };
}
}
}
register(EventForm)