1012 lines
46 KiB
JavaScript
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)
|