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 => `

${cal.name}

`).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)