Files
apps/calendar/desktop/Events/DesktopEventForm.js
metacryst 0d6c7683ff init
2026-04-28 20:05:00 -05:00

1012 lines
46 KiB
JavaScript

import server from "/@server/server.js"
import calendarUtil from "../../calendarUtil.js"
import "../../EventFileList.js"
import "../../../components/Avatar.js"
css(`desktopeventform- { flex: 1; }`)
class DesktopEventForm extends Shadow {
startDate = calendarUtil.toDateInput(new Date())
endDate = calendarUtil.toDateInput(new Date())
startTime = "13:00"
endTime = "14:00"
recurrence = null
selectedCalendars = []
uploadedFiles = []
existingAttachments = []
filesOpen = false
attachmentsDeleted = false
pendingDeletions = []
pendingSaveEventData = null
constructor(calendars, onSaved, editEvent = null, onDelete = null, onBack = null, initialDate = null) {
super()
this.calendars = calendars
this.onSaved = onSaved
this.editEvent = editEvent
this.onDelete = onDelete
this.onBack = onBack
if (editEvent) {
const start = new Date(editEvent.time_start)
const end = new Date(editEvent.time_end)
this.startDate = calendarUtil.toDateInput(start)
this.endDate = calendarUtil.toDateInput(end)
this.startTime = editEvent.all_day ? "13:00" : calendarUtil.toTimeInput(start)
this.endTime = editEvent.all_day ? "14:00" : calendarUtil.toTimeInput(end)
this.selectedCalendars = calendars.filter(c => editEvent.calendars?.includes(c.id))
this.existingAttachments = editEvent.attachments ?? []
this.filesOpen = this.existingAttachments.length > 0
this.recurrence = editEvent.recurrence
? { frequency: editEvent.recurrence.frequency, interval: editEvent.recurrence.interval ?? 1, days_of_week: editEvent.recurrence.days_of_week ? [...editEvent.recurrence.days_of_week] : null }
: null
this.originalFormData = {
title: editEvent.title ?? "",
location: editEvent.location ?? "",
description: editEvent.description ?? "",
all_day: editEvent.all_day,
time_start: editEvent.all_day ? this.startDate : `${this.startDate}T${this.startTime}`,
time_end: editEvent.all_day ? this.endDate : `${this.endDate}T${this.endTime}`,
calendars: [...(editEvent.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)
this.endDate = calendarUtil.toDateInput(initialDate)
}
this.originalFormData = {
title: null,
location: null,
description: null,
all_day: true,
time_start: this.startDate,
time_end: this.endDate,
calendars: (calendars.length > 0 ? [calendars[0].id] : []).sort().join(","),
recurrence: null
}
}
}
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"
}
}
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)"}`
})
}
dtInput(attrs, onChange) {
return input("", "auto", "2em")
.attr(attrs)
.styles(this.fieldStyles)
.onChange(onChange)
}
dateTimeRow(label, dateName, dateVal, timeName, timeId, timeVal, showTime, onDateChange, onTimeChange) {
HStack(() => {
span(label)
.fontSize(0.8, em)
.color("var(--headertext)")
.opacity(0.5)
.width("3.5em")
.flexShrink(0)
this.dtInput({ name: dateName, type: "date", value: dateVal }, onDateChange)
this.dtInput({ name: timeName, id: timeId, type: "time", value: timeVal, step: "300" }, onTimeChange)
.display(showTime ? "" : "none")
})
.gap(0.5, em)
.alignItems("center")
}
setFilesPanel(open) {
this.filesOpen = open
const content = this.$("#files-content")
const chevron = this.$("#files-chevron")
if (content) content.style.maxHeight = open ? "600px" : "0"
if (chevron) chevron.style.transform = open ? "rotate(180deg)" : "rotate(0deg)"
}
updateFilesChevron() {
const hasFiles = this.uploadedFiles.length > 0 || this.existingAttachments.length > 0
const chevron = this.$("#files-chevron")
if (chevron) chevron.style.display = hasFiles ? "inline-block" : "none"
if (!hasFiles && this.filesOpen) this.setFilesPanel(false)
else if (hasFiles && !this.filesOpen) this.setFilesPanel(true)
}
handleDeleteNewFile(index) {
this.uploadedFiles.splice(index, 1)
this.fileListComponent.removeNew(index)
this.updateFilesChevron()
}
handleDeleteExistingFile(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 fetch(`${config.SERVER}/events/${targetId}/attachments/${fileId}`, {
method: "DELETE", credentials: "include"
})
} catch (err) {
console.error("Failed to delete attachment:", err)
}
}
this.pendingDeletions = []
}
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)
}
isCalendarSelected(calId) {
return this.selectedCalendars.some(c => c.id === calId);
}
updateCalendarOpacity() {
this.$$("[data-cal-id]").forEach(el => {
const calId = Number(el.getAttribute("data-cal-id"));
el.style.opacity = this.isCalendarSelected(calId) ? "1" : "0.35";
});
}
toggleCalendar(cal) {
const isSelected = this.isCalendarSelected(cal.id);
if (isSelected && this.selectedCalendars.length === 1) return;
this.selectedCalendars = isSelected
? this.selectedCalendars.filter(c => c.id !== cal.id)
: [...this.selectedCalendars, cal];
this.updateCalendarOpacity();
}
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
}
}
render() {
const isEdit = !!this.editEvent
const showTime = isEdit ? !this.editEvent.all_day : false
const allDay = isEdit ? this.editEvent.all_day : true
form(() => {
VStack(() => {
this.renderHeader(isEdit)
this.renderBody(isEdit, showTime, allDay)
HStack(() => {
const members = global.currentNetwork.data?.members || []
const creatorId = this.editEvent ? this.editEvent.creator_id : global.profile?.id
const creator = members.find(m => m.id === creatorId)
if (creator) {
Avatar(creator, 1.6)
VStack(() => {
const created = this.editEvent?.created
const updated = this.editEvent?.updated_at
if (created) {
p(`Created ${calendarUtil.timeAgo(created)} by ${creator.first_name}`)
.margin(0)
.fontSize(0.7, em)
.color("var(--headertext)")
.opacity(0.5)
if (updated && updated !== created) {
p(`Last updated ${calendarUtil.timeAgo(updated)}`)
.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")
})
.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: "title", type: "text", placeholder: "Enter event title...", value: this.editEvent?.title ?? "" })
.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" })
.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)
.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("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)
.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(isEdit, showTime, allDay) {
const hasFiles = this.uploadedFiles.length > 0 || this.existingAttachments.length > 0
VStack(() => {
this.prop("WHEN", () => {
VStack(() => {
HStack(() => {
span("All day")
.fontSize(0.88, em)
.color("var(--headertext)")
input("", "auto")
.attr({ name: "all_day", type: "checkbox", ...(allDay ? { checked: true } : {}) })
.accentColor("var(--quillred)")
.onChange(() => {
const isAllDay = this.$('[name="all_day"]').checked
this.$("#time_start_time").style.display = isAllDay ? "none" : ""
this.$("#time_end_time").style.display = isAllDay ? "none" : ""
})
})
.gap(0.5, em)
.alignItems("center")
this.dateTimeRow(
"Start",
"time_start_date", this.startDate,
"time_start_time", "time_start_time", this.startTime, showTime,
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(--headertext)'; oldEl.style.opacity = '0.4'; }
if (newEl) { newEl.style.background = 'var(--quillred)'; newEl.style.color = 'white'; newEl.style.opacity = '1'; }
}
}
},
e => { this.startTime = e.target.value; this.enforceEndAfterStart() }
)
this.dateTimeRow(
"End",
"time_end_date", this.endDate,
"time_end_time", "time_end_time", this.endTime, showTime,
e => { this.endDate = e.target.value; this.enforceEndAfterStart() },
e => { this.endTime = e.target.value; this.enforceEndAfterStart() }
)
})
.gap(0.5, em)
.width(100, pct)
})
this.prop("REPEAT", () => {
const currentKey = this.recurrenceOptionKey()
const isWeekly = currentKey === 'weekly' || currentKey === 'biweekly'
const selectedDays = this.recurrence?.days_of_week ?? []
// Row showing current value — click to expand
HStack(() => {
p(this.recurrenceLabel())
.attr({ id: "recur-label" })
.margin(0)
.fontSize(0.88, em)
.color("var(--headertext)")
.opacity(this.recurrence ? 1 : 0.5)
.flex(1)
span("▼")
.attr({ id: "recur-chevron" })
.fontSize(0.65, em)
.color("var(--headertext)")
.opacity(0.4)
.transition("transform 0.25s ease")
.transform(isWeekly ? "" : "")
.userSelect("none")
})
.cursor("pointer")
.alignItems("center")
.onClick((done) => {
if (!done) return
const picker = this.$("#recur-picker")
const chevron = this.$("#recur-chevron")
if (!picker) return
const open = picker.getAttribute("data-open") === "1"
if (open) {
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(180deg)"
}
})
// Expandable option list
VStack(() => {
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.88, em)
.color("var(--headertext)")
.flex(1)
span("✓")
.attr({ id: `recur-check-${opt.key}` })
.fontSize(0.82, em)
.color("var(--quillred)")
.fontWeight("700")
.display(currentKey === opt.key ? "" : "none")
})
.paddingVertical(0.5, em)
.paddingHorizontal(1, em)
.alignItems("center")
.borderTop("1px solid var(--divider)")
.cursor("pointer")
.onClick((done) => {
if (!done) return
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.opacity = newRecurrence ? "1" : "0.5"
}
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(--headertext)"
el.style.opacity = sel ? "1" : "0.4"
})
}
// Recalculate maxHeight after content change
const picker = this.$("#recur-picker")
if (picker && picker.getAttribute("data-open") === "1") {
picker.style.maxHeight = picker.scrollHeight + "px"
}
})
})
// Day-of-week selector (weekly/biweekly only)
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(1.8, em).height(1.8, em)
.lineHeight("1.8em")
.textAlign("center")
.borderRadius(50, pct)
.fontSize(0.75, em).fontWeight("600")
.cursor("pointer").flexShrink(0)
.background(sel ? "var(--quillred)" : "transparent")
.color(sel ? "white" : "var(--headertext)")
.opacity(sel ? "1" : "0.4")
.onClick((done) => {
if (!done) return
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(--headertext)"
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)
.borderTop("1px solid var(--divider)")
.display(isWeekly ? "" : "none")
})
.attr({ id: "recur-picker", "data-open": "0" })
.overflow("hidden")
.maxHeight(0)
.transition("max-height 0.3s ease")
.border("1px solid var(--divider)")
.borderRadius(0.35, em)
.marginTop(0.4, em)
})
this.prop("CALENDARS", () => {
HStack(() => {
this.calendars.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)
.cursor("pointer")
.opacity(this.isCalendarSelected(cal.id) ? 1 : 0.35)
.attr({ "data-cal-id": cal.id })
.onClick((done) => { if (done) this.toggleCalendar(cal) })
.onHover(function(hovering) {
const isSelected = $("desktopeventform-").isCalendarSelected(cal.id)
this.style.opacity = hovering ? 0.8 : isSelected ? 1 : 0.35;
})
})
})
.flexWrap("wrap")
.gap(0.45, em)
})
this.prop("LOCATION", () => {
input("", "100%")
.attr({ name: "location", type: "text", value: this.editEvent?.location ?? "" })
.styles(this.fieldStyles)
})
this.prop("DESCRIPTION", () => {
textarea(this.editEvent?.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;
})
})
this.prop("ATTACHMENTS", () => {
input("Attachments", "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()
})
HStack(() => {
span("Upload")
.color("var(--quillred)")
.cursor("pointer")
.fontSize(0.88, em)
.onClick((done) => { if (done) this.$('[name="attachments"]').click() })
.onHover(function(hovering) {
this.style.opacity = hovering ? 0.82 : 1;
})
span("▼")
.attr({ id: "files-chevron" })
.fontSize(0.7, em)
.color("var(--headertext)")
.opacity(0.5)
.display(hasFiles ? "inline-block" : "none")
.transition("transform 0.3s ease")
.transform(this.filesOpen ? "rotate(180deg)" : "rotate(0deg)")
.cursor("pointer")
.userSelect("none")
.onClick((done) => { if (done) this.setFilesPanel(!this.filesOpen) })
})
.gap(0.75, em)
.alignItems("center")
VStack(() => {
this.fileListComponent = EventFileList(
this.existingAttachments,
isEdit ? (fileId) => this.handleDeleteExistingFile(fileId) : null,
(index) => this.handleDeleteNewFile(index),
(file, url) => $("filepreview-").open(file, url)
)
this.fileListComponent
.border("1px solid var(--divider)")
.borderRadius(0.35, em)
.boxSizing("border-box")
})
.attr({ id: "files-content" })
.overflow("hidden")
.maxHeight(hasFiles && this.filesOpen ? "600px" : "0")
.transition("max-height 0.5s ease")
.marginTop(0.5, em)
})
})
.flex(1)
.overflowY("scroll")
.width(100, pct)
.boxSizing("border-box")
}
showError(msg) {
$("modal-")?.showError(msg)
}
getFormData() {
const isAllDay = this.$('[name="all_day"]')?.checked ?? true
const val = name => this.$(`[name="${name}"]`).value
return {
title: val("title") || null,
location: val("location") || null,
description: val("description") || null,
all_day: isAllDay,
time_start: isAllDay ? val("time_start_date") : `${val("time_start_date")}T${val("time_start_time")}`,
time_end: isAllDay ? val("time_end_date") : `${val("time_end_date")}T${val("time_end_time")}`,
recurrence: this.recurrence
}
}
isFormDataUnchanged(data) {
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)
)
}
isNewEventDirty() {
const data = this.getFormData()
return !this.isFormDataUnchanged(data) || this.attachmentsDeleted
}
handleBack() {
const isDirty = this.isNewEventDirty()
if (isDirty) {
$('actionsheetpopup-').show(
"Discard Changes?",
[
{
label: "Discard",
onTap: () => {
if (this.onBack) this.onBack()
else $("modal-").forceClose()
}
},
{ label: "Keep Editing", destructive: false, onTap: () => {} }
],
() => {}
)
return
}
if (this.onBack) this.onBack()
else $("modal-").forceClose()
}
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) {
$("modal-").forceClose()
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 handleUpload(eventId) {
try {
const body = new FormData()
this.uploadedFiles.forEach(file => body.append("attachments", file))
const res = await fetch(`${config.SERVER}/events/${eventId}/upload-attachments`, {
method: "POST", credentials: "include",
headers: { "Accept": "application/json" }, body
})
const { insertedFiles } = await res.json()
return { success: res.ok, insertedFiles: res.ok ? insertedFiles : [] }
} catch (err) {
console.error("Failed to upload attachments:", err)
return { success: false, insertedFiles: [] }
}
}
async _uploadAndMerge(eventId, existing = []) {
if (this.uploadedFiles.length === 0) return existing
const upload = await this.handleUpload(eventId)
if (!upload.success) return existing
const existingIds = new Set(existing.map(a => a.id))
return [...existing, ...upload.insertedFiles.filter(a => !existingIds.has(a.id))]
}
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
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)
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 {
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._uploadAndMerge(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.onSaved) this.onSaved(editResult)
} else {
this.showError(result.error ?? "Failed to save event.")
}
} catch (err) {
console.error("Failed to save event:", err)
this.showError("Failed to save event.")
}
}
async handleSave() {
$("modal-")?.showError("")
const data = this.getFormData()
if (this.editEvent) {
const unchanged = this.isFormDataUnchanged(data) && !this.attachmentsDeleted
if (unchanged) {
if (this.onBack) this.onBack()
else $("modal-").forceClose()
return
}
const isRecurring = !!(this.editEvent._isOccurrence || this.editEvent.recurrence_parent_id || this.editEvent.recurrence_id)
if (isRecurring) {
this.pendingSaveEventData = data
$('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 toISO = (str, isEnd = false) => calendarUtil.toISO(str, data.all_day, isEnd)
const payload = {
title: data.title ?? "New event",
location: data.location,
description: data.description,
time_start: toISO(data.time_start),
time_end: toISO(data.time_end, true),
all_day: data.all_day,
calendars: this.selectedCalendars.map(c => c.id),
recurrence: data.recurrence ?? null
}
const result = await server.editEvent(this.editEvent.id, payload, global.currentNetwork.id)
if (result.status === 200) {
const attachments = await this._uploadAndMerge(result.event.id, [...this.existingAttachments])
if (this.onSaved) this.onSaved({ ...result.event, attachments, recurrence: data.recurrence ?? null })
} else {
this.showError(result.error ?? "Failed to save event.")
}
} else {
const toISO = (str, isEnd = false) => calendarUtil.toISO(str, data.all_day, isEnd)
const payload = {
title: data.title ?? "New event",
location: data.location,
description: data.description,
time_start: toISO(data.time_start),
time_end: toISO(data.time_end, true),
all_day: data.all_day,
calendars: this.selectedCalendars.map(c => c.id),
recurrence: data.recurrence ?? null
}
const result = await server.addEvent(payload, global.currentNetwork.id)
if (result.status === 200) {
const attachments = await this._uploadAndMerge(result.event.id)
$("modal-").forceClose()
if (this.onSaved) this.onSaved({ ...result.event, attachments, recurrence: data.recurrence ?? null })
} else {
this.showError(result.error ?? "Failed to save event.")
}
}
}
}
register(DesktopEventForm)