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)