Files
apps/calendar/Events/EventDetails.js

615 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import server from "/calendar/@server/calendar.js"
import calendarUtil from "../calendarUtil.js"
import "./EventForm.js"
import "../../components/BottomSheet.js"
import "../../components/BackButton.js"
import "../../components/Avatar.js"
css(`
eventdetails- {
display: flex;
flex-direction: column;
height: 100%;
scrollbar-width: none;
-ms-overflow-style: none;
}
eventdetails- ::-webkit-scrollbar { display: none; width: 0; height: 0; }
eventdetails- ::-webkit-scrollbar-thumb { background: transparent; }
eventdetails- ::-webkit-scrollbar-track { background: transparent; }
#eventdetails-toast-wrap {
transition: max-height 0.25s ease, opacity 0.22s ease, padding-top 0.25s ease;
}
`)
class EventDetails extends Shadow {
attachmentsOpen = false;
selectedCalendars = [];
_pendingCalendars = null;
_prevCalendars = null;
constructor(calendars, event = null, onEventEdited = null, onEventDeleted = null) {
super()
this.calendars = calendars;
this.event = event;
this.onEventEdited = onEventEdited;
this.onEventDeleted = onEventDeleted;
this.selectedCalendars = calendars.filter(c => event?.calendars?.includes(c.id));
}
render() {
this.editSheet = BottomSheet(100) // separate sheet for the edit form, layered above this one
// Editing is currently owner-only: this flag gates the mobile Edit/Delete actions.
const canEdit = global.currentNetwork.permissions.includes("events.edit")
VStack(() => {
this.renderHeader(canEdit)
// ── Error toast ───────────────────────────────────────
VStack(() => {
p("")
.attr({ id: "eventdetails-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: "eventdetails-toast-wrap" })
.alignItems("center")
.overflow("hidden")
.maxHeight(0)
.opacity(0)
.flexShrink(0)
// ── Scrollable body ───────────────────────────────────
VStack(() => {
if (!this.event) return
// VStack(() => {}).height(0.85, em).flexShrink(0)
// ── Calendar / Location / Notes card ──────────────
VStack(() => {
// Calendar row — tappable to expand
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)
let chevron = p("")
chevron
.attr({ id: "cal-chevron" })
.margin(0).opacity(0.3).fontSize(1.1, em).flexShrink(0)
.transition("transform 0.25s ease")
.state(chevron.parentElement, "open", function (value) {
if(value === "true") {
this.style.transform = "rotate(90deg)"
} else {
console.log("no trans")
this.style.transform = ""
}
})
})
.attr({id: "calendar-row"})
.paddingHorizontal(1, em).paddingVertical(0.78, em)
.alignItems("center").gap(0.5, em)
.borderBottom("1px solid var(--divider)").cursor("pointer")
.onTap(function () {
const isOpen = this.getAttribute("open") === "true"
if (isOpen) {
this.setAttribute("open", "false")
} else {
this.setAttribute("open", "true")
}
})
// Calendar Expandable List
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 prevCalendars = [...this.selectedCalendars]
const i = this.selectedCalendars.findIndex(c => c.id === cal.id)
if (i >= 0) {
if (this.selectedCalendars.length > 1) {
this.selectedCalendars.splice(i, 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()
this.saveCalendars(prevCalendars)
})
})
})
.state(this.$("#calendar-row"), "open", function (value) {
if(value === "false") {
this.style.maxHeight = "0"
} else {
this.style.maxHeight = this.scrollHeight + "px"
}
})
.attr({ id: "cal-picker"})
.overflow("hidden").maxHeight(0)
.transition("max-height 0.3s ease")
// Location row
if (this.event.location) {
HStack(() => {
p("📍").margin(0).fontSize(0.85, em).flexShrink(0)
p(this.event.location)
.margin(0).fontSize(0.9, em).color("var(--text)").fontFamily("Arial")
})
.paddingHorizontal(1, em).paddingVertical(0.78, em)
.alignItems("center").gap(0.65, em)
.borderBottom("1px solid var(--divider)")
}
// Notes row
if (this.event.description) {
HStack(() => {
p("📝").margin(0).fontSize(0.85, em).flexShrink(0).alignSelf("flex-start").paddingTop(0.1, em)
p(this.event.description)
.margin(0).fontSize(0.9, em).color("var(--text)").fontFamily("Arial")
.whiteSpace("pre-wrap").lineHeight("1.45")
})
.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 ──────────────────────────────
if (this.event.attachments?.length > 0) {
// VStack(() => {}).height(0.85, em).flexShrink(0)
VStack(() => {
HStack(() => {
p("📎").margin(0).fontSize(0.85, em).flexShrink(0)
p("Attachments")
.margin(0).fontSize(0.92, em).color("var(--headertext)").flex(1)
p("▼")
.attr({ id: "attachments-chevron" })
.margin(0).fontSize(0.7, em).color("var(--text)").opacity(0.5)
.display("inline-block")
.transition("transform 0.3s ease")
.transform(this.attachmentsOpen ? "rotate(180deg)" : "rotate(0deg)")
})
.paddingHorizontal(1, em).paddingVertical(0.78, em)
.alignItems("center").gap(0.65, em).cursor("pointer")
.onTap(() => this.toggleAttachments())
VStack(() => {
VStack(() => {
this.event.attachments.forEach(file => this.renderFile(file))
})
.gap(0.75, em).width(100, pct)
.padding("0 1em 0.75em").boxSizing("border-box")
})
.attr({ id: "attachments-content" })
.overflow("hidden").maxHeight("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)
}
})
.overflowY("scroll").flex(1).minHeight(0).paddingTop(0.85, em).paddingBottom(1.5, em).gap(0.85, em)
// ── Footer: creator avatar + timestamps ───────────────
if (this.event) {
const members = global.currentNetwork.data?.members || []
const creator = members.find(m => m.id === this.event.creator_id)
if (creator) {
HStack(() => {
Avatar(creator, 2)
VStack(() => {
p(`Created ${calendarUtil.timeAgo(this.event.created)} by ${creator.first_name}`)
.margin(0).fontSize(0.9, em).color("var(--headertext)").opacity(0.5)
if (this.event.updated_at && this.event.updated_at !== this.event.created) {
p(`Last updated ${calendarUtil.timeAgo(this.event.updated_at)}`)
.margin(0).fontSize(0.9, em).color("var(--headertext)").opacity(0.4)
}
})
.gap(0.15, em)
})
.paddingHorizontal(1, em)
.paddingVertical(0.65, em)
.alignItems("center")
.gap(0.5, em)
.flexShrink(0)
}
}
})
.height(100, pct)
}
renderHeader(canEdit) {
VStack(() => {
HStack(() => {
BackButton(false, true, () => $("bottomsheet-").toggle())
// Non-owners never see the controls that open the edit sheet or delete flow.
if (canEdit) {
HStack(() => {
// ── Delete button ─────────────────────────────────
button("Delete")
.attr({ type: "button" })
.padding(0.4, rem)
.fontSize(1.25, em)
.boxSizing("border-box")
.outline("none")
.border("none")
.background("transparent")
.color("var(--quillred)")
.onTap(() => this.handleDelete())
button("Edit")
.padding(0.4, rem)
.fontSize(1.25, em)
.color("var(--darkaccent)")
.boxSizing("border-box")
.outline("none")
.border("none")
.zIndex(3)
.onTap((e) => {
e.preventDefault()
let formEl
const closeForm = () => {
this.editSheet._closeOverride = null
this.editSheet.setSheet(false)
}
const onSaveError = () => {
this.editSheet._closeOverride = () => this.editSheet.forceClose()
}
this.editSheet.show(() => {
// For override rows, attach template dates so scope='all' anchors correctly
let eventForForm = this.event;
if (this.event.recurrence_parent_id && !this.event._templateStart) {
const template = global.currentNetwork.data.events.find(e => e.id === this.event.recurrence_parent_id);
if (template) {
eventForForm = {
...this.event,
_templateStart: new Date(template.time_start),
_templateEnd: new Date(template.time_end)
};
}
}
formEl = EventForm(
this.calendars,
(updateResult) => {
closeForm()
const updatedEvent = updateResult?.scope ? updateResult.event : updateResult;
this.event = { ...updatedEvent, time_start: new Date(updatedEvent.time_start), time_end: new Date(updatedEvent.time_end) }
this.selectedCalendars = this.calendars.filter(c => updatedEvent.calendars?.includes(c.id))
setTimeout(() => {
this.onEventEdited(updateResult)
this.rerender()
}, 300)
},
eventForForm,
closeForm,
(deleteResult) => {
closeForm()
$("bottomsheet-").toggle()
this.onEventDeleted(deleteResult)
},
null,
onSaveError
)
})
this.editSheet._closeOverride = () => {
this.editSheet.setSheet(true)
formEl?.handleBack()
}
})
})
.fontFamily("Arial")
.cursor("pointer")
.paddingHorizontal(0.8, rem)
.gap(0.4, rem)
}
})
.width(100, pct)
.justifyContent("space-between")
.alignItems("center")
VStack(() => {
h2(this.event?.title ?? "")
.color("var(--headertext)")
.fontFamily("Arial")
.margin(0)
.fontSize(1.4, em)
p(this.event ? calendarUtil.formatEventTime(this.event) : "")
.margin(0)
.color("var(--headertext)")
.opacity(0.7)
.fontSize(0.85, em)
})
.paddingHorizontal(1, em)
.paddingBottom(1, em)
.gap(0.3, em)
.alignItems("flex-start")
})
.width(100, pct)
.background(util.darkMode() ? "var(--darkred)" : "var(--sidebottombars)")
.borderTopLeftRadius("10px").borderTopRightRadius("10px")
.border("1px solid var(--divider)")
.boxSizing("border-box").flexShrink(0)
.alignItems("flex-start")
}
updateCalendarDisplay() {
const el = this.$("#calendar-display")
if (!el) return
el.innerHTML = this.selectedCalendars.map(cal => `
<div style="display:flex;align-items:center;gap:0.3em;">
<div style="width:0.6em;height:0.6em;background:${cal.color};border-radius:50%;flex-shrink:0;"></div>
<p style="font-size:0.82em;font-weight:600;margin:0;color:var(--text);white-space:nowrap;">${cal.name}</p>
</div>
`).join('')
}
showError(msg) {
const wrap = this.$("#eventdetails-toast-wrap")
const toast = this.$("#eventdetails-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.$("#eventdetails-toast-wrap")
if (!wrap) return
clearTimeout(this._errorTimer)
wrap.style.maxHeight = "0"
wrap.style.opacity = "0"
wrap.style.paddingTop = "0"
}
saveCalendars(prevCalendars) {
const event = this.event;
const newCalendars = this.selectedCalendars.map(c => c.id);
const isRecurring = !!(event._isOccurrence || event.recurrence_parent_id || event.recurrence_id);
this._prevCalendars = prevCalendars;
this._pendingCalendars = newCalendars;
if (isRecurring) {
$('actionsheetpopup-').show(
"Edit Recurring Event",
[
{ label: "Edit just this event", onTap: () => this.performCalendarSave('single'), destructive: false },
{ label: "Edit this and future events", onTap: () => this.performCalendarSave('future'), destructive: false },
{ label: "Edit all events in series", onTap: () => this.performCalendarSave('all'), destructive: false },
],
() => {
this._pendingCalendars = null;
this._revertCalendars(prevCalendars);
}
);
return;
}
this.performCalendarSave(null);
}
_revertCalendars(prev) {
this.selectedCalendars = prev;
this.calendars.forEach(cal => {
const check = this.$(`#cal-check-${cal.id}`);
if (check) check.style.display = prev.some(c => c.id === cal.id) ? "" : "none";
});
this.updateCalendarDisplay();
}
async performCalendarSave(scope) {
const event = this.event;
const newCalendars = this._pendingCalendars ?? this.selectedCalendars.map(c => c.id);
this._pendingCalendars = null;
const prevCalendars = this._prevCalendars;
try {
if (scope) {
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;
const result = await server.editEvent(serverEventId, {
title: event.title,
description: event.description ?? null,
location: event.location ?? null,
time_start: event.time_start instanceof Date ? event.time_start.toISOString() : event.time_start,
time_end: event.time_end instanceof Date ? event.time_end.toISOString() : event.time_end,
all_day: event.all_day,
calendars: newCalendars,
scope,
exception_date: occurrenceDate
}, global.currentNetwork.id);
if (result.status === 200) {
const editResult = {
scope,
event: { ...result.event, calendars: newCalendars, attachments: event.attachments ?? [] },
templateId,
occurrenceDate
};
this.event = { ...editResult.event, time_start: new Date(result.event.time_start), time_end: new Date(result.event.time_end) };
this.selectedCalendars = this.calendars.filter(c => newCalendars.includes(c.id));
this._prevCalendars = null;
$("bottomsheet-")._closeOverride = null;
this.onEventEdited(editResult);
} else {
this._revertCalendars(prevCalendars);
this.showError(result.error ?? "Failed to update calendars.");
$("bottomsheet-")._closeOverride = () => $("bottomsheet-").forceClose();
}
} else {
const result = await server.editEvent(event.id, { ...event, calendars: newCalendars }, global.currentNetwork.id);
if (result.status === 200) {
this.event = { ...event, calendars: newCalendars };
this._prevCalendars = null;
$("bottomsheet-")._closeOverride = null;
this.onEventEdited(this.event);
} else {
this._revertCalendars(prevCalendars);
this.showError(result.error ?? "Failed to update calendars.");
$("bottomsheet-")._closeOverride = () => $("bottomsheet-").forceClose();
}
}
} catch (err) {
console.error("Failed to update calendars:", err);
this._revertCalendars(prevCalendars);
this.showError("Failed to update calendars.");
$("bottomsheet-")._closeOverride = () => $("bottomsheet-").forceClose();
}
}
handleDelete() {
const event = this.event;
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.event;
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) {
$("bottomsheet-").toggle()
const deleteResult = { scope: scope ?? 'all', templateId, occurrenceDate, overrideId: isOverride ? event.id : null };
setTimeout(() => this.onEventDeleted(deleteResult), 300)
} else {
this.showError(result.error ?? "Failed to delete event.")
$("bottomsheet-")._closeOverride = () => $("bottomsheet-").forceClose()
}
} catch (err) {
console.error("Failed to delete event:", err)
this.showError("Failed to delete event.")
$("bottomsheet-")._closeOverride = () => $("bottomsheet-").forceClose()
}
}
renderFile(file) {
const isImage = file.type?.startsWith("image/");
const url = `${config.SERVER}/db/images/events/${file.name}`;
if (isImage) {
img(url, "100%", "100%")
.borderRadius(8, px).display("block").boxSizing("border-box")
.cursor("pointer")
.onTap(() => $("filepreview-")?.open(file, url))
} else {
HStack(() => {
p("📎").margin(0).fontSize(1, em)
p(file.original_name ?? file.name)
.margin(0).color("var(--text)").fontSize(0.9, em)
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
})
.gap(0.5, em).alignItems("center")
.padding(0.5, em)
.background("var(--searchbackground)")
.borderRadius(8, px).boxSizing("border-box")
.cursor("pointer")
.onTap(() => $("filepreview-")?.open(file, url))
}
}
toggleAttachments() {
this.attachmentsOpen = !this.attachmentsOpen;
const content = this.$("#attachments-content");
const chevron = this.$("#attachments-chevron");
if (content) content.style.maxHeight = this.attachmentsOpen ? content.scrollHeight + "px" : "0";
if (chevron) chevron.style.transform = this.attachmentsOpen ? "rotate(180deg)" : "rotate(0deg)";
}
}
register(EventDetails)