init
This commit is contained in:
334
calendar/desktop/DesktopCalendarForm.js
Normal file
334
calendar/desktop/DesktopCalendarForm.js
Normal file
@@ -0,0 +1,334 @@
|
||||
import server from "/@server/server.js"
|
||||
|
||||
class DesktopCalendarForm extends Shadow {
|
||||
|
||||
static COLORS = [
|
||||
"#9E1C29", "#3D6FAD", "#2A8636", "#B38A1E",
|
||||
"#B85A1F", "#7A3FA3", "#B23D6B", "#2D8A87",
|
||||
"#3C9A5F", "#6E9A23", "#7A8428", "#9A2F7D",
|
||||
"#4F54A8", "#8A5A32", "#546B86", "#A67A1F",
|
||||
]
|
||||
|
||||
constructor(calendars, onSaved, editCalendar = null, onDelete = null, onBack = null) {
|
||||
super()
|
||||
this.calendars = calendars
|
||||
this.onSaved = onSaved
|
||||
this.editCalendar = editCalendar
|
||||
this.onDelete = onDelete
|
||||
this.onBack = onBack
|
||||
this.selectedColor = editCalendar?.color ?? DesktopCalendarForm.COLORS[0]
|
||||
|
||||
if (editCalendar) {
|
||||
this.originalFormData = {
|
||||
name: editCalendar.name ?? "",
|
||||
description: editCalendar.description || "",
|
||||
color: editCalendar.color ?? ""
|
||||
}
|
||||
} else {
|
||||
this.originalFormData = {
|
||||
name: "",
|
||||
description: "",
|
||||
color: DesktopCalendarForm.COLORS[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)"}`
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
render() {
|
||||
const isEdit = !!this.editCalendar
|
||||
|
||||
form(() => {
|
||||
VStack(() => {
|
||||
this.renderHeader(isEdit)
|
||||
this.renderBody(isEdit)
|
||||
})
|
||||
.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: "name", type: "text", placeholder: "Enter calendar name...", value: this.editCalendar?.name ?? "" })
|
||||
.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" })
|
||||
.fontSize(0.8, em)
|
||||
.fontWeight("600")
|
||||
.background("transparent")
|
||||
.color("var(--quillred)")
|
||||
.paddingVertical(0.34, em)
|
||||
.paddingHorizontal(0.85, em)
|
||||
.marginRight(1.4, em)
|
||||
.marginBottom(1, em)
|
||||
.border("1px solid var(--quillred)")
|
||||
.borderRadius(0.45, em)
|
||||
.flexShrink(0)
|
||||
.cursor("pointer")
|
||||
.boxSizing("border-box")
|
||||
.onClick((done) => { if (done) this.handleDelete() })
|
||||
.onHover(function(hovering) {
|
||||
this.style.background = hovering ? "var(--quillred)" : "transparent";
|
||||
this.style.color = hovering ? "white" : "var(--quillred)"
|
||||
this.style.opacity = hovering ? 0.82 : 1;
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
.marginTop("auto")
|
||||
.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() {
|
||||
VStack(() => {
|
||||
|
||||
this.prop("COLOR", () => {
|
||||
const renderSwatch = (color) => {
|
||||
const selected = this.selectedColor === color
|
||||
p("")
|
||||
.flex(1)
|
||||
.height(1.6, em)
|
||||
.background(color)
|
||||
.borderRadius(5, px)
|
||||
.border(`3px solid ${selected ? "var(--quillred)" : color}`)
|
||||
.boxSizing("border-box")
|
||||
.cursor("pointer")
|
||||
.attr({ "data-color": color })
|
||||
.onClick((done) => {
|
||||
if (!done) return
|
||||
const prev = this.$(`[data-color="${this.selectedColor}"]`)
|
||||
if (prev) prev.style.border = `3px solid ${this.selectedColor}`
|
||||
this.selectedColor = color
|
||||
const next = this.$(`[data-color="${color}"]`)
|
||||
if (next) next.style.border = `3px solid var(--quillred)`
|
||||
})
|
||||
.onHover(function(hovering) {
|
||||
this.style.opacity = hovering ? 0.82 : 1;
|
||||
})
|
||||
}
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
DesktopCalendarForm.COLORS.slice(0, 8).forEach(renderSwatch)
|
||||
})
|
||||
.gap(0.55, em)
|
||||
HStack(() => {
|
||||
DesktopCalendarForm.COLORS.slice(8, 16).forEach(renderSwatch)
|
||||
})
|
||||
.gap(0.55, em)
|
||||
})
|
||||
.gap(0.55, em)
|
||||
})
|
||||
|
||||
this.prop("DESCRIPTION", () => {
|
||||
textarea(this.editCalendar?.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;
|
||||
})
|
||||
})
|
||||
})
|
||||
.flex(1)
|
||||
.overflowY("scroll")
|
||||
.width(100, pct)
|
||||
.boxSizing("border-box")
|
||||
}
|
||||
|
||||
showError(msg) {
|
||||
$("modal-")?.showError(msg)
|
||||
}
|
||||
|
||||
getFormData() {
|
||||
const val = name => this.$(`[name="${name}"]`).value
|
||||
return {
|
||||
name: val("name"),
|
||||
description: val("description") || null,
|
||||
color: this.selectedColor
|
||||
}
|
||||
}
|
||||
|
||||
isUnchanged(data) {
|
||||
const o = this.originalFormData
|
||||
return (
|
||||
data.name === o.name &&
|
||||
data.color === o.color &&
|
||||
(data.description || "") === o.description
|
||||
)
|
||||
}
|
||||
|
||||
isNewCalendarDirty() {
|
||||
const data = this.getFormData()
|
||||
const o = this.originalFormData
|
||||
return (
|
||||
data.name !== o.name ||
|
||||
(data.description || "") !== o.description ||
|
||||
data.color !== o.color
|
||||
)
|
||||
}
|
||||
|
||||
async trySave() {
|
||||
if (!this.editCalendar) {
|
||||
// New calendar: only save if the user made edits
|
||||
if (!this.isNewCalendarDirty()) return false
|
||||
const data = this.getFormData()
|
||||
const payload = {
|
||||
name: data.name || "New calendar",
|
||||
description: data.description,
|
||||
color: data.color
|
||||
}
|
||||
|
||||
const result = await server.addCalendar(payload, global.currentNetwork.id)
|
||||
if (result.status === 200) return result.calendar
|
||||
|
||||
this.showError(result.error ?? "Failed to save calendar.")
|
||||
return null
|
||||
}
|
||||
|
||||
const data = this.getFormData()
|
||||
if (this.isUnchanged(data)) return this.editCalendar
|
||||
|
||||
const result = await server.editCalendar(
|
||||
this.editCalendar.id,
|
||||
{ ...data, name: data.name || "New calendar" },
|
||||
global.currentNetwork.id
|
||||
)
|
||||
|
||||
if (result.status === 200) return result.calendar
|
||||
|
||||
this.showError(result.error ?? "Failed to save calendar.")
|
||||
return null
|
||||
}
|
||||
|
||||
async handleSave() {
|
||||
$("modal-")?.showError("")
|
||||
|
||||
const data = this.getFormData()
|
||||
|
||||
if (this.editCalendar) {
|
||||
if (this.isUnchanged(data)) {
|
||||
if (this.onBack) this.onBack()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: data.name || "New calendar",
|
||||
description: data.description,
|
||||
color: data.color
|
||||
}
|
||||
|
||||
const result = this.editCalendar
|
||||
? await server.editCalendar(this.editCalendar.id, payload, global.currentNetwork.id)
|
||||
: await server.addCalendar(payload, global.currentNetwork.id)
|
||||
|
||||
if (result.status === 200) {
|
||||
if (this.editCalendar) {
|
||||
this.onSaved(result.calendar)
|
||||
} else {
|
||||
// Use forceClose so _closeOverride doesn't re-trigger trySave
|
||||
$("modal-").forceClose()
|
||||
this.onSaved(result.calendar)
|
||||
}
|
||||
} else {
|
||||
this.showError(result.error ?? "Failed to save calendar.")
|
||||
}
|
||||
}
|
||||
|
||||
async handleDelete() {
|
||||
const result = await server.deleteCalendar(this.editCalendar.id, global.currentNetwork.id)
|
||||
if (result.status === 200) {
|
||||
$("modal-").forceClose()
|
||||
if (this.onDelete) this.onDelete(this.editCalendar.id)
|
||||
} else {
|
||||
this.showError(result.error ?? "Failed to delete calendar.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
register(DesktopCalendarForm)
|
||||
320
calendar/desktop/DesktopMonthGrid.js
Normal file
320
calendar/desktop/DesktopMonthGrid.js
Normal file
@@ -0,0 +1,320 @@
|
||||
import calendarUtil from "../calendarUtil.js";
|
||||
|
||||
let _saved = null;
|
||||
|
||||
css(`
|
||||
desktopmonthgrid- {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
`)
|
||||
|
||||
class DesktopMonthGrid extends Shadow {
|
||||
constructor(weeks, calendars, weekStartsOn = 0, onEventClick = null, onDayDoubleClick = null) {
|
||||
super()
|
||||
this.weeks = weeks;
|
||||
this.calendars = calendars;
|
||||
this.weekStartsOn = weekStartsOn;
|
||||
this.onEventClick = onEventClick;
|
||||
this.onDayDoubleClick = onDayDoubleClick;
|
||||
this.ghostDay = null;
|
||||
this.maxVisible = 4;
|
||||
|
||||
// Layout in em
|
||||
this.dateFontSize = 0.82;
|
||||
this.dateLineHeight = 1.7;
|
||||
this.paddingTop = 0.35;
|
||||
this.paddingBottom = 0.25;
|
||||
|
||||
this.pillHeight = 1.15;
|
||||
this.pillGap = 0.15;
|
||||
this.overflowLabelHeight = 0.7;
|
||||
this.rowBottomPadding = this.overflowLabelHeight + 0.5;
|
||||
|
||||
this.headerHeight = this.paddingTop + (this.dateFontSize * this.dateLineHeight) + this.paddingBottom;
|
||||
this.rowHeight = this.headerHeight + this.maxVisible * (this.pillHeight + this.pillGap) + this.rowBottomPadding;
|
||||
}
|
||||
|
||||
render() {
|
||||
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
const ordered = Array.from({ length: 7 }, (_, i) => DAY_NAMES[(this.weekStartsOn + i) % 7]);
|
||||
|
||||
VStack(() => {
|
||||
// Day-name header
|
||||
HStack(() => {
|
||||
ordered.forEach(name => {
|
||||
p(name)
|
||||
.margin(0)
|
||||
.fontSize(0.72, em)
|
||||
.fontWeight("500")
|
||||
.letterSpacing("0.015em")
|
||||
.color("var(--headertext)")
|
||||
.opacity(0.5)
|
||||
.flex(1)
|
||||
.textAlign("center")
|
||||
.paddingVertical(0.55, em)
|
||||
.boxSizing("border-box")
|
||||
})
|
||||
})
|
||||
.width(100, pct)
|
||||
.borderBottom("1px solid var(--divider)")
|
||||
.flexShrink(0)
|
||||
|
||||
// Week rows
|
||||
VStack(() => {
|
||||
this.weeks.forEach((week, wi) => {
|
||||
this.renderWeekRow(week, wi === this.weeks.length - 1)
|
||||
})
|
||||
})
|
||||
.width(100, pct)
|
||||
.height(this.rowHeight * this.weeks.length, em)
|
||||
.flexShrink(0)
|
||||
})
|
||||
.width(100, pct)
|
||||
.height(100, pct)
|
||||
.overflowY("auto")
|
||||
.boxSizing("border-box")
|
||||
.onAppear(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const monthKey = calendarUtil.toDateInput(this.weeks[2].days[3].day).substring(0, 7);
|
||||
this.scrollTop = (_saved?.monthKey === monthKey) ? _saved.scrollTop : 0;
|
||||
this.addEventListener('scroll', () => {
|
||||
_saved = { monthKey, scrollTop: this.scrollTop };
|
||||
}, { passive: true });
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
renderWeekRow(week, isLastWeek) {
|
||||
const displayWeek = this.ghostDay ? this.injectGhostIntoWeek(week) : week;
|
||||
ZStack(() => {
|
||||
this.renderCellLayer(week, isLastWeek)
|
||||
this.renderPillLayer(displayWeek)
|
||||
})
|
||||
.position("relative")
|
||||
.width(100, pct)
|
||||
.height(this.rowHeight, em)
|
||||
.flexShrink(0)
|
||||
.alignItems("stretch")
|
||||
}
|
||||
|
||||
renderCellLayer(week, isLastWeek) {
|
||||
HStack(() => {
|
||||
week.days.forEach((dayData, di) => {
|
||||
this.renderDayCell(dayData, di === 6, isLastWeek)
|
||||
})
|
||||
})
|
||||
.width(100, pct)
|
||||
.alignItems("stretch")
|
||||
.height(100, pct)
|
||||
}
|
||||
|
||||
renderDayCell(dayData, isLastCol, isLastWeek) {
|
||||
const { day, isCurrentMonth } = dayData;
|
||||
const today = calendarUtil.isToday(day);
|
||||
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
p(day.getDate())
|
||||
.margin(0)
|
||||
.fontSize(this.dateFontSize, em)
|
||||
.fontWeight(today ? "700" : "500")
|
||||
.color(today ? "white" : "var(--headertext)")
|
||||
.background(today ? "var(--quillred)" : "transparent")
|
||||
.width(1.65, em)
|
||||
.height(1.65, em)
|
||||
.borderRadius(5, px)
|
||||
.textAlign("center")
|
||||
.lineHeight("1.65em")
|
||||
.flexShrink(0)
|
||||
.opacity(isCurrentMonth ? 1 : 0.3)
|
||||
})
|
||||
.paddingTop(this.paddingTop, em)
|
||||
.paddingHorizontal(0.4, em)
|
||||
.paddingBottom(this.paddingBottom, em)
|
||||
})
|
||||
.flex(1)
|
||||
.width(0, px)
|
||||
.minWidth(0)
|
||||
.height(100, pct)
|
||||
.background(isCurrentMonth ? "var(--darkaccent)" : "")
|
||||
.borderBottom(isLastWeek ? "1px solid transparent" : "1px solid var(--divider)")
|
||||
.borderRight(isLastCol ? "none" : "1px solid var(--divider)")
|
||||
.boxSizing("border-box")
|
||||
.overflow("hidden")
|
||||
.alignItems("stretch")
|
||||
.onClick((done, e) => {
|
||||
if (e.detail !== 2) return
|
||||
if (!done) {
|
||||
this.ghostDay = day
|
||||
this.rerender()
|
||||
} else {
|
||||
this.onDayDoubleClick(day, () => {
|
||||
this.ghostDay = null
|
||||
this.rerender()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
injectGhostIntoWeek(week) {
|
||||
const col = week.days.findIndex(d =>
|
||||
calendarUtil.startOfDay(d.day).getTime() === calendarUtil.startOfDay(this.ghostDay).getTime()
|
||||
);
|
||||
if (col === -1) return week;
|
||||
|
||||
const ghostEvent = {
|
||||
id: '__ghost__',
|
||||
title: 'New event',
|
||||
all_day: true,
|
||||
time_start: this.ghostDay,
|
||||
time_end: this.ghostDay,
|
||||
calendars: ['__ghost__']
|
||||
};
|
||||
|
||||
const slotMap = week.slotMap.map(colSlots => [...colSlots]);
|
||||
|
||||
let row = 0;
|
||||
while (row < slotMap[col].length && slotMap[col][row] !== null) row++;
|
||||
|
||||
for (let c = 0; c < 7; c++) {
|
||||
while (slotMap[c].length <= row) slotMap[c].push(null);
|
||||
}
|
||||
|
||||
slotMap[col][row] = { event: ghostEvent, isStart: true, isEnd: true, isSingleDay: true };
|
||||
|
||||
const days = week.days.map((d, i) =>
|
||||
i === col ? { ...d, events: [...d.events, ghostEvent] } : d
|
||||
);
|
||||
|
||||
return { ...week, slotMap, days };
|
||||
}
|
||||
|
||||
renderPillLayer(week) {
|
||||
ZStack(() => {
|
||||
const maxSlots = Math.max(0, ...week.slotMap.map(s => s.length));
|
||||
|
||||
for (let row = 0; row < Math.min(maxSlots, this.maxVisible); row++) {
|
||||
this.collectSpans(week, row).forEach(span => this.renderPill(span, week, row))
|
||||
}
|
||||
|
||||
// Overflow labels
|
||||
week.days.forEach((dayData, col) => {
|
||||
const overflow = Math.max(0, dayData.events.length - this.maxVisible);
|
||||
if (overflow === 0) return;
|
||||
|
||||
p(`+${overflow} more`)
|
||||
.margin(0)
|
||||
.fontSize(0.63, em)
|
||||
.fontWeight("600")
|
||||
.color("var(--headertext)")
|
||||
.opacity(0.5)
|
||||
.position("absolute")
|
||||
.bottom(this.overflowLabelHeight, em)
|
||||
.left((col / 7) * 100, pct)
|
||||
.width(100 / 7, pct)
|
||||
.paddingHorizontal(0.55, em)
|
||||
.zIndex(2)
|
||||
})
|
||||
})
|
||||
.position("absolute")
|
||||
.top(0).left(0).right(0).bottom(0)
|
||||
.pointerEvents("none")
|
||||
}
|
||||
|
||||
renderPill({ startCol, endCol, event }, week, row) {
|
||||
const isGhost = event.id === '__ghost__';
|
||||
const color = isGhost ? 'var(--quillred)' : calendarUtil.getCalendarColor(this.calendars, event.calendars.find(id => this.calendars.some(c => c.id === id)) ?? event.calendars[0]);
|
||||
const leftPct = (startCol / 7) * 100;
|
||||
const widthPct = ((endCol - startCol + 1) / 7) * 100;
|
||||
const topEm = this.headerHeight + row * (this.pillHeight + this.pillGap);
|
||||
|
||||
// const isSingleDay = startCol === endCol &&
|
||||
// calendarUtil.startOfDay(event.time_start).getTime() === calendarUtil.startOfDay(event.time_end).getTime();
|
||||
// const isTimedSingle = isSingleDay && !event.all_day;
|
||||
const isTimedSingle = !event.all_day; // wasn't rendering circle on multi-day timed events
|
||||
|
||||
const clippedLeft = startCol === 0 &&
|
||||
calendarUtil.startOfDay(event.time_start) < calendarUtil.startOfDay(week.days[0].day);
|
||||
const clippedRight = endCol === 6 &&
|
||||
calendarUtil.endOfDay(event.time_end) > calendarUtil.endOfDay(week.days[6].day);
|
||||
|
||||
const colW = 100 / 7;
|
||||
const leftInset = clippedLeft ? 0 : 0.02 * colW;
|
||||
const rightInset = clippedRight ? 0 : 0.02 * colW;
|
||||
const brLeft = clippedLeft ? 0 : 4;
|
||||
const brRight = clippedRight ? 0 : 4;
|
||||
|
||||
HStack(() => {
|
||||
if (isTimedSingle) {
|
||||
// Dot + time + title
|
||||
VStack(() => {})
|
||||
.width(0.42, em)
|
||||
.height(0.42, em)
|
||||
.borderRadius(50, pct)
|
||||
.background("white")
|
||||
.flexShrink(0)
|
||||
.marginRight(0.3, em)
|
||||
p(calendarUtil.formatTimeShort(event.time_start) + " " + (event.title || "Untitled"))
|
||||
.margin(0)
|
||||
.fontSize(0.68, em)
|
||||
.fontWeight("500")
|
||||
.color("white")
|
||||
.whiteSpace("nowrap")
|
||||
.overflow("hidden")
|
||||
} else {
|
||||
p(event.title || "Untitled")
|
||||
.margin(0)
|
||||
.fontSize(0.68, em)
|
||||
.fontWeight("600")
|
||||
.color("white")
|
||||
.whiteSpace("nowrap")
|
||||
.overflow("hidden")
|
||||
}
|
||||
})
|
||||
.position("absolute")
|
||||
.top(topEm, em)
|
||||
.left(leftPct + leftInset, pct)
|
||||
.width(widthPct - leftInset - rightInset, pct)
|
||||
.height(this.pillHeight, em)
|
||||
.paddingHorizontal(0.5, em)
|
||||
.background(color)
|
||||
.borderTopLeftRadius(`${brLeft}px`)
|
||||
.borderBottomLeftRadius(`${brLeft}px`)
|
||||
.borderTopRightRadius(`${brRight}px`)
|
||||
.borderBottomRightRadius(`${brRight}px`)
|
||||
.alignItems("center")
|
||||
.overflow("hidden")
|
||||
.boxSizing("border-box")
|
||||
.zIndex(1)
|
||||
.pointerEvents(isGhost ? "none" : "auto")
|
||||
.cursor(isGhost ? "default" : "pointer")
|
||||
.opacity(isGhost ? 0.75 : 1)
|
||||
.onClick((done) => {
|
||||
if (!isGhost && done && this.onEventClick) this.onEventClick(event)
|
||||
})
|
||||
.onHover(function(hovering) {
|
||||
if (!isGhost) this.opacity(hovering ? 0.82 : 1)
|
||||
})
|
||||
}
|
||||
|
||||
collectSpans(week, row) {
|
||||
const spans = [];
|
||||
let current = null;
|
||||
|
||||
for (let col = 0; col < 7; col++) {
|
||||
const slot = (week.slotMap[col] || [])[row] ?? null;
|
||||
|
||||
if (slot && current && slot.event === current.event) {
|
||||
current.endCol = col;
|
||||
} else {
|
||||
if (current) spans.push(current);
|
||||
current = slot ? { startCol: col, endCol: col, event: slot.event } : null;
|
||||
}
|
||||
}
|
||||
if (current) spans.push(current);
|
||||
return spans;
|
||||
}
|
||||
}
|
||||
|
||||
register(DesktopMonthGrid)
|
||||
118
calendar/desktop/DesktopMonthView.js
Normal file
118
calendar/desktop/DesktopMonthView.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import calendarUtil from "../calendarUtil.js"
|
||||
import "./DesktopMonthGrid.js"
|
||||
|
||||
class DesktopMonthView extends Shadow {
|
||||
constructor(calendars, events, currentDate, weekStartsOn = 0, onEventClick = null, onDayDoubleClick = null) {
|
||||
super()
|
||||
this.calendars = calendars;
|
||||
this.events = events;
|
||||
this.currentDate = currentDate;
|
||||
this.weekStartsOn = weekStartsOn;
|
||||
this.onEventClick = onEventClick;
|
||||
this.onDayDoubleClick = onDayDoubleClick;
|
||||
}
|
||||
|
||||
render() {
|
||||
const weeks = this.buildMonthWeeks();
|
||||
|
||||
DesktopMonthGrid(weeks, this.calendars, this.weekStartsOn, this.onEventClick, this.onDayDoubleClick)
|
||||
.width(100, pct)
|
||||
.height(100, pct)
|
||||
}
|
||||
|
||||
buildMonthWeeks() {
|
||||
const month = this.currentDate.getMonth();
|
||||
|
||||
const allDays = calendarUtil.buildMonthGrid(this.currentDate, this.weekStartsOn);
|
||||
const gridStart = allDays[0];
|
||||
const weeks = [];
|
||||
for (let w = 0; w < 6; w++) {
|
||||
weeks.push(allDays.slice(w * 7, w * 7 + 7));
|
||||
}
|
||||
|
||||
const gridEnd = calendarUtil.endOfDay(weeks[weeks.length - 1][6]);
|
||||
const monthStart = calendarUtil.startOfDay(new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), 1));
|
||||
const monthEnd = calendarUtil.endOfDay(new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + 1, 0));
|
||||
|
||||
const expanded = calendarUtil.expandRecurringEvents(this.events, gridStart, gridEnd);
|
||||
const relevantEvents = expanded.filter(event =>
|
||||
calendarUtil.rangesOverlap(event.time_start, event.time_end, gridStart, gridEnd) &&
|
||||
calendarUtil.rangesOverlap(event.time_start, event.time_end, monthStart, monthEnd) &&
|
||||
this.calendars.some(c => event.calendars?.some(id => id === c.id))
|
||||
);
|
||||
|
||||
return weeks.map(week => this.buildWeekData(week, month, relevantEvents));
|
||||
}
|
||||
|
||||
buildWeekData(week, currentMonth, events) {
|
||||
const weekStart = calendarUtil.startOfDay(week[0]);
|
||||
const weekEnd = calendarUtil.endOfDay(week[6]);
|
||||
|
||||
const weekEvents = events.filter(event =>
|
||||
calendarUtil.rangesOverlap(event.time_start, event.time_end, weekStart, weekEnd)
|
||||
);
|
||||
|
||||
weekEvents.sort((a, b) => {
|
||||
const aSpan = a.all_day || this.isMultiDay(a);
|
||||
const bSpan = b.all_day || this.isMultiDay(b);
|
||||
if (aSpan !== bSpan) return aSpan ? -1 : 1;
|
||||
return a.time_start - b.time_start;
|
||||
});
|
||||
|
||||
const slotRows = [];
|
||||
weekEvents.forEach(event => {
|
||||
const startCol = Math.max(0, this.dayIndex(event.time_start, week));
|
||||
const endCol = Math.min(6, this.dayIndex(event.time_end, week));
|
||||
|
||||
let row = 0;
|
||||
while (true) {
|
||||
if (!slotRows[row]) slotRows[row] = new Array(7).fill(null);
|
||||
if (slotRows[row].slice(startCol, endCol + 1).every(v => v === null)) break;
|
||||
row++;
|
||||
}
|
||||
|
||||
for (let c = startCol; c <= endCol; c++) {
|
||||
slotRows[row][c] = {
|
||||
event,
|
||||
isStart: c === startCol,
|
||||
isEnd: c === endCol,
|
||||
isSingleDay: startCol === endCol
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const slotMap = Array.from({ length: 7 }, (_, col) =>
|
||||
slotRows.map(row => row[col] ?? null)
|
||||
);
|
||||
|
||||
return {
|
||||
days: week.map(day => ({
|
||||
day,
|
||||
isCurrentMonth: day.getMonth() === currentMonth,
|
||||
events: weekEvents.filter(event => {
|
||||
const effectiveEnd = event.all_day ? calendarUtil.endOfDay(event.time_end) : event.time_end;
|
||||
return calendarUtil.rangesOverlap(
|
||||
event.time_start, effectiveEnd,
|
||||
calendarUtil.startOfDay(day), calendarUtil.endOfDay(day)
|
||||
);
|
||||
})
|
||||
})),
|
||||
slotMap
|
||||
};
|
||||
}
|
||||
|
||||
isMultiDay(event) {
|
||||
return calendarUtil.startOfDay(event.time_start).getTime() !==
|
||||
calendarUtil.startOfDay(event.time_end).getTime();
|
||||
}
|
||||
|
||||
dayIndex(date, week) {
|
||||
const dayStart = calendarUtil.startOfDay(date);
|
||||
for (let i = 0; i < 7; i++) {
|
||||
if (calendarUtil.startOfDay(week[i]).getTime() === dayStart.getTime()) return i;
|
||||
}
|
||||
return date.getTime() < week[0].getTime() ? 0 : 6;
|
||||
}
|
||||
}
|
||||
|
||||
register(DesktopMonthView)
|
||||
294
calendar/desktop/DesktopSidebar.js
Normal file
294
calendar/desktop/DesktopSidebar.js
Normal file
@@ -0,0 +1,294 @@
|
||||
import calendarUtil from "../calendarUtil.js";
|
||||
|
||||
class DesktopSidebar extends Shadow {
|
||||
static DAY_NAMES = ["S", "M", "T", "W", "T", "F", "S"];
|
||||
|
||||
constructor(currentDate, calendars, selectedCalendars, events, weekStartsOn, actions) {
|
||||
super()
|
||||
this.currentDate = currentDate
|
||||
this.calendars = calendars
|
||||
this.selectedCalendars = selectedCalendars
|
||||
this.events = events
|
||||
this.weekStartsOn = weekStartsOn
|
||||
this.actions = actions
|
||||
|
||||
// Persist mini calendar month across parent rerenders
|
||||
if (this.miniDate === undefined) {
|
||||
this.miniDate = new Date(currentDate)
|
||||
}
|
||||
if (this.selectedDate === undefined) {
|
||||
this.selectedDate = new Date(currentDate)
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { onSelectDate, onToggleCalendar, onNewCalendar, onEditCalendar } = this.actions
|
||||
const ordered = Array.from({ length: 7 }, (_, i) =>
|
||||
DesktopSidebar.DAY_NAMES[(this.weekStartsOn + i) % 7]
|
||||
);
|
||||
const weeks = this.buildMonthWeeks(this.miniDate);
|
||||
const today = new Date();
|
||||
|
||||
VStack(() => {
|
||||
// ── Mini calendar ─────────────────────────────────────────
|
||||
VStack(() => {
|
||||
// Month / year + nav arrows
|
||||
HStack(() => {
|
||||
p(this.miniDate.toLocaleDateString(undefined, { month: "long", year: "numeric" }))
|
||||
.margin(0)
|
||||
.fontWeight("600")
|
||||
.fontSize(0.78, em)
|
||||
.color("var(--headertext)")
|
||||
.flex(1)
|
||||
|
||||
HStack(() => {
|
||||
button("‹")
|
||||
.onClick((done) => {if(!done) return; this.miniDate = calendarUtil.addMonths(this.miniDate, -1); this.rerender(); })
|
||||
.padding("0.18em 0.42em")
|
||||
.border("none")
|
||||
.background("transparent")
|
||||
.color("var(--headertext)")
|
||||
.cursor("pointer")
|
||||
.borderRadius(0.3, em)
|
||||
.fontSize(0.85, em)
|
||||
button("›")
|
||||
.onClick((done) => {if(!done) return; this.miniDate = calendarUtil.addMonths(this.miniDate, 1); this.rerender(); })
|
||||
.padding("0.18em 0.42em")
|
||||
.border("none")
|
||||
.background("transparent")
|
||||
.color("var(--headertext)")
|
||||
.cursor("pointer")
|
||||
.borderRadius(0.3, em)
|
||||
.fontSize(0.85, em)
|
||||
})
|
||||
.gap(0)
|
||||
})
|
||||
.alignItems("center")
|
||||
.marginBottom(0.35, em)
|
||||
|
||||
// Day-name header row
|
||||
HStack(() => {
|
||||
ordered.forEach(name => {
|
||||
p(name)
|
||||
.margin(0)
|
||||
.fontSize(0.64, em)
|
||||
.fontWeight("600")
|
||||
.color("var(--headertext)")
|
||||
.opacity(0.42)
|
||||
.flex(1)
|
||||
.textAlign("center")
|
||||
.paddingVertical(0.18, em)
|
||||
})
|
||||
})
|
||||
.width(100, pct)
|
||||
|
||||
// Date cells
|
||||
VStack(() => {
|
||||
weeks.forEach(({ days }) => {
|
||||
HStack(() => {
|
||||
days.forEach(({ day, isCurrentMonth, calendarColors }) => {
|
||||
const isToday = calendarUtil.isSameDay(day, today);
|
||||
const isSelected = calendarUtil.isSameDay(day, this.selectedDate);
|
||||
|
||||
VStack(() => {
|
||||
p(day.getDate())
|
||||
.margin(0)
|
||||
.fontSize(0.72, em)
|
||||
.fontWeight(isSelected ? "700" : "400")
|
||||
.color(isSelected || isToday ? "white" : "var(--headertext)")
|
||||
.background(
|
||||
isToday ? "var(--quillred)"
|
||||
: isSelected ? "var(--lightaccent)"
|
||||
: "transparent"
|
||||
)
|
||||
.width(1.52, em)
|
||||
.height(1.52, em)
|
||||
.borderRadius(25, pct)
|
||||
.textAlign("center")
|
||||
.lineHeight("1.52em")
|
||||
.opacity(isCurrentMonth ? 1 : 0.27)
|
||||
.boxSizing("border-box")
|
||||
|
||||
if (calendarColors.length > 0) {
|
||||
HStack(() => {
|
||||
calendarColors.slice(0, 3).forEach(color => {
|
||||
VStack(() => {})
|
||||
.width(0.24, em)
|
||||
.height(0.24, em)
|
||||
.borderRadius(50, pct)
|
||||
.background(color)
|
||||
.flexShrink(0)
|
||||
})
|
||||
})
|
||||
.gap(0.14, em)
|
||||
.justifyContent("center")
|
||||
.marginTop(0.1, em)
|
||||
} else {
|
||||
VStack(() => {}).height(0.34, em)
|
||||
}
|
||||
})
|
||||
.flex(1)
|
||||
.alignItems("center")
|
||||
.paddingVertical(0.16, em)
|
||||
.cursor("pointer")
|
||||
.onClick((done) => {if(!done) return;
|
||||
this.selectedDate = day;
|
||||
this.miniDate = new Date(day);
|
||||
onSelectDate(day);
|
||||
this.rerender();
|
||||
})
|
||||
})
|
||||
})
|
||||
.width(100, pct)
|
||||
})
|
||||
})
|
||||
.width(100, pct)
|
||||
})
|
||||
.marginTop(30, px)
|
||||
.padding(1, em)
|
||||
.paddingBottom(0.75, em)
|
||||
|
||||
// ── Divider ───────────────────────────────────────────────
|
||||
VStack(() => {})
|
||||
.height(1, px)
|
||||
.background("var(--divider)")
|
||||
.width(100, pct)
|
||||
|
||||
// ── Calendars list ────────────────────────────────────────
|
||||
VStack(() => {
|
||||
p("CALENDARS")
|
||||
.margin(0)
|
||||
.marginBottom(0.45, em)
|
||||
.fontSize(0.63, em)
|
||||
.fontWeight("700")
|
||||
.letterSpacing("0.07em")
|
||||
.color("var(--headertext)")
|
||||
.opacity(0.38)
|
||||
.paddingHorizontal(1, em)
|
||||
|
||||
this.calendars.forEach(cal => {
|
||||
const isSelected = this.selectedCalendars.some(c => c.id === cal.id);
|
||||
HStack(() => {
|
||||
HStack(() => {
|
||||
VStack(() => {})
|
||||
.width(0.65, em)
|
||||
.height(0.65, em)
|
||||
.borderRadius(50, pct)
|
||||
.background(isSelected ? cal.color : "transparent")
|
||||
.border(`2px solid ${cal.color}`)
|
||||
.boxSizing("border-box")
|
||||
.flexShrink(0)
|
||||
p(cal.name)
|
||||
.margin(0)
|
||||
.fontSize(0.78, em)
|
||||
.color("var(--headertext)")
|
||||
.opacity(isSelected ? 1 : 0.4)
|
||||
.flex(1)
|
||||
.overflow("hidden")
|
||||
.whiteSpace("nowrap")
|
||||
.textOverflow("ellipsis")
|
||||
})
|
||||
.flex(1)
|
||||
.gap(0.55, em)
|
||||
.alignItems("center")
|
||||
.cursor("pointer")
|
||||
.onClick((done) => { if (!done) return; if (onToggleCalendar) onToggleCalendar(cal); })
|
||||
if (onEditCalendar && cal.owner_id === global.profile.id) {
|
||||
button("···")
|
||||
.border("none")
|
||||
.background("transparent")
|
||||
.color("var(--headertext)")
|
||||
.opacity(0.4)
|
||||
.fontSize(0.72, em)
|
||||
.cursor("pointer")
|
||||
.padding("0 0.2em")
|
||||
.flexShrink(0)
|
||||
.onClick((done) => { if (done) onEditCalendar(cal) })
|
||||
}
|
||||
})
|
||||
.paddingHorizontal(1, em)
|
||||
.paddingVertical(0.32, em)
|
||||
.alignItems("center")
|
||||
.borderRadius(0.4, em)
|
||||
})
|
||||
|
||||
button("+ New Calendar")
|
||||
.paddingVertical(0.52, em)
|
||||
.paddingHorizontal(0.8, em)
|
||||
.marginHorizontal(1, em)
|
||||
.marginVertical(0.5, em)
|
||||
.width("auto")
|
||||
.background("transparent")
|
||||
.border("1px solid var(--divider)")
|
||||
.color("var(--headertext)")
|
||||
.borderRadius(0.5, em)
|
||||
.fontSize(0.83, em)
|
||||
.fontWeight("600")
|
||||
.cursor("pointer")
|
||||
.onHover(function(hovering) {
|
||||
this.style.background = hovering ? "var(--divider)" : "transparent";
|
||||
})
|
||||
.onClick((done) => { if (done) onNewCalendar() })
|
||||
})
|
||||
.paddingTop(0.7, em)
|
||||
.paddingBottom(0.5, em)
|
||||
})
|
||||
.width(220, px)
|
||||
.minWidth(220, px)
|
||||
.height(100, pct)
|
||||
.borderRight("1px solid var(--divider)")
|
||||
.boxSizing("border-box")
|
||||
.overflowY("auto")
|
||||
.flexShrink(0)
|
||||
}
|
||||
|
||||
buildMonthWeeks(date) {
|
||||
const month = date.getMonth();
|
||||
const allDays = calendarUtil.buildMonthGrid(date, this.weekStartsOn);
|
||||
const gridStart = allDays[0];
|
||||
const gridEnd = calendarUtil.endOfDay(allDays[41]);
|
||||
|
||||
const colorsByDay = new Map();
|
||||
allDays.forEach(day => colorsByDay.set(calendarUtil.toDateInput(day), []));
|
||||
|
||||
const colorByCalId = new Map((this.selectedCalendars || []).map(c => [c.id, c.color]));
|
||||
const expanded = calendarUtil.expandRecurringEvents(this.events || [], gridStart, gridEnd);
|
||||
expanded.filter(ev => {
|
||||
const end = ev.all_day ? calendarUtil.endOfDay(ev.time_end) : ev.time_end;
|
||||
return calendarUtil.rangesOverlap(ev.time_start, end, gridStart, gridEnd);
|
||||
}).forEach(ev => {
|
||||
const colors = (ev.calendars || [])
|
||||
.map(id => colorByCalId.get(id))
|
||||
.filter(Boolean);
|
||||
if (colors.length === 0) return;
|
||||
const effectiveEnd = ev.all_day ? calendarUtil.endOfDay(ev.time_end) : ev.time_end;
|
||||
let cursor = calendarUtil.startOfDay(ev.time_start > gridStart ? ev.time_start : gridStart);
|
||||
const end = effectiveEnd < gridEnd ? effectiveEnd : gridEnd;
|
||||
while (cursor < end) {
|
||||
const key = calendarUtil.toDateInput(cursor);
|
||||
const arr = colorsByDay.get(key);
|
||||
if (arr) {
|
||||
colors.forEach(color => {
|
||||
if (!arr.includes(color) && arr.length < 3) arr.push(color);
|
||||
});
|
||||
}
|
||||
cursor = calendarUtil.addDays(cursor, 1);
|
||||
}
|
||||
});
|
||||
|
||||
const weeks = [];
|
||||
for (let w = 0; w < 6; w++) {
|
||||
weeks.push({
|
||||
days: allDays.slice(w * 7, w * 7 + 7).map(day => ({
|
||||
day,
|
||||
isCurrentMonth: day.getMonth() === month,
|
||||
calendarColors: colorsByDay.get(calendarUtil.toDateInput(day)) || []
|
||||
}))
|
||||
});
|
||||
}
|
||||
return weeks;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
register(DesktopSidebar)
|
||||
74
calendar/desktop/DesktopToolbar.js
Normal file
74
calendar/desktop/DesktopToolbar.js
Normal file
@@ -0,0 +1,74 @@
|
||||
class DesktopToolbar extends Shadow {
|
||||
constructor(currentDate, actions) {
|
||||
super()
|
||||
this.currentDate = currentDate
|
||||
this.actions = actions
|
||||
}
|
||||
|
||||
render() {
|
||||
const { goToPrevious, goToCurrent, goToNext } = this.actions
|
||||
|
||||
HStack(() => {
|
||||
h2(this.currentDate.toLocaleDateString(undefined, { month: "long", year: "numeric" }))
|
||||
.margin(0)
|
||||
.fontWeight("700")
|
||||
.fontSize(1.25, em)
|
||||
.color("var(--headertext)")
|
||||
|
||||
HStack(() => {
|
||||
button("+ New Event")
|
||||
.paddingVertical(0.52, em)
|
||||
.paddingRight(1, em)
|
||||
.paddingLeft(0.8, em)
|
||||
.marginHorizontal(0.4, em)
|
||||
.background("transparent")
|
||||
.color("var(--headertext)")
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(0.5, em)
|
||||
.fontSize(0.83, em)
|
||||
.fontWeight("600")
|
||||
.cursor("pointer")
|
||||
.onHover(function(hovering) {
|
||||
this.style.background = hovering ? "var(--divider)" : "transparent";
|
||||
})
|
||||
.onClick((done) => { if (done) this.actions.onNewEvent() })
|
||||
|
||||
this.navBtn("‹", goToPrevious)
|
||||
this.navBtn("Today", goToCurrent)
|
||||
this.navBtn("›", goToNext)
|
||||
})
|
||||
.gap(0.4, em)
|
||||
.alignItems("center")
|
||||
})
|
||||
.paddingHorizontal(1.5, em)
|
||||
.paddingVertical(0.85, em)
|
||||
.alignItems("center")
|
||||
.justifyContent("space-between")
|
||||
.borderBottom("1px solid var(--divider)")
|
||||
.width(100, pct)
|
||||
.boxSizing("border-box")
|
||||
.flexShrink(0)
|
||||
}
|
||||
|
||||
navBtn(label, handler) {
|
||||
return button(label)
|
||||
.onClick((done) => {
|
||||
if(done) {
|
||||
handler()
|
||||
}
|
||||
})
|
||||
.paddingVertical(0.38, em)
|
||||
.paddingHorizontal(label === "Today" ? 0.9 : 0.72, em)
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(0.45, em)
|
||||
.background("transparent")
|
||||
.color("var(--headertext)")
|
||||
.cursor("pointer")
|
||||
.fontSize(0.83, em)
|
||||
.onHover(function(hovering) {
|
||||
this.style.background = hovering ? "var(--divider)" : "transparent";
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
register(DesktopToolbar)
|
||||
396
calendar/desktop/Events/DesktopEventDetails.js
Normal file
396
calendar/desktop/Events/DesktopEventDetails.js
Normal file
@@ -0,0 +1,396 @@
|
||||
import server from "/@server/server.js"
|
||||
import calendarUtil from "../../calendarUtil.js"
|
||||
import "../../../components/Avatar.js"
|
||||
|
||||
class DesktopEventDetails extends Shadow {
|
||||
attachmentsOpen = false
|
||||
|
||||
constructor(calendars, event, onUpdated = null, onDeleted = null, onEdit = null) {
|
||||
super()
|
||||
this.calendars = calendars
|
||||
this.event = event
|
||||
this.attachmentsOpen = (event?.attachments?.length > 0)
|
||||
this.onUpdated = onUpdated
|
||||
this.onDeleted = onDeleted
|
||||
this.onEdit = onEdit
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.event) return
|
||||
|
||||
const eventCals = this.calendars.filter(c => this.event.calendars?.includes(c.id))
|
||||
const isOwner = this.event.creator_id === global.profile?.id
|
||||
|
||||
VStack(() => {
|
||||
this.renderHeader(isOwner)
|
||||
this.renderBody(eventCals)
|
||||
HStack(() => {
|
||||
const members = global.currentNetwork.data?.members || []
|
||||
const creator = members.find(m => m.id === this.event.creator_id)
|
||||
if (creator) {
|
||||
Avatar(creator, 1.6)
|
||||
VStack(() => {
|
||||
p(`Created ${calendarUtil.timeAgo(this.event.created)} by ${creator.first_name}`)
|
||||
.margin(0).fontSize(0.7, 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.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")
|
||||
}
|
||||
|
||||
// ── Header ────────────────────────────────────────────────────────────────
|
||||
|
||||
renderHeader(isOwner) {
|
||||
HStack(() => {
|
||||
VStack(() => {
|
||||
h2(this.event.title || "Untitled")
|
||||
.margin(0)
|
||||
.fontSize(1.45, em)
|
||||
.fontWeight("700")
|
||||
.color("var(--headertext)")
|
||||
.lineHeight("1.2")
|
||||
})
|
||||
.flex(1)
|
||||
.paddingHorizontal(1.4, em)
|
||||
.paddingTop(2.5, em)
|
||||
.paddingBottom(0.5, em)
|
||||
.justifyContent("center")
|
||||
|
||||
if (isOwner) {
|
||||
button("Delete")
|
||||
.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)
|
||||
.marginTop("auto")
|
||||
.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("Edit")
|
||||
.paddingVertical(0.34, em)
|
||||
.paddingHorizontal(0.85, em)
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(0.45, em)
|
||||
.background("transparent")
|
||||
.color("var(--headertext)")
|
||||
.cursor("pointer")
|
||||
.fontSize(0.8, em)
|
||||
.marginRight(1.4, em)
|
||||
.marginTop("auto")
|
||||
.marginBottom(1, em)
|
||||
.flexShrink(0)
|
||||
.onClick((done) => {
|
||||
if (!done) return
|
||||
// Attach template dates for override events so scope='all' anchors correctly
|
||||
let eventForEdit = 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) {
|
||||
eventForEdit = {
|
||||
...this.event,
|
||||
_templateStart: new Date(template.time_start),
|
||||
_templateEnd: new Date(template.time_end)
|
||||
}
|
||||
}
|
||||
}
|
||||
this.onEdit(eventForEdit)
|
||||
})
|
||||
.onHover(function(hovering) {
|
||||
this.style.background = hovering ? "var(--divider)" : "transparent";
|
||||
})
|
||||
}
|
||||
})
|
||||
.width(100, pct)
|
||||
.alignItems("stretch")
|
||||
.background("var(--darkaccent)")
|
||||
.borderBottom("1px solid var(--divider)")
|
||||
.boxSizing("border-box")
|
||||
.flexShrink(0)
|
||||
}
|
||||
|
||||
// ── Body ─────────────────────────────────────────────────────────────────
|
||||
|
||||
renderBody(eventCals) {
|
||||
VStack(() => {
|
||||
|
||||
VStack(() => {
|
||||
this.prop("WHEN", () => {
|
||||
p(calendarUtil.formatEventTime(this.event))
|
||||
.margin(0)
|
||||
.fontSize(0.88, em)
|
||||
.color("var(--headertext)")
|
||||
})
|
||||
|
||||
if (this.event.recurrence) {
|
||||
this.prop("REPEATS", () => {
|
||||
p(this._recurrenceLabel())
|
||||
.margin(0)
|
||||
.fontSize(0.88, em)
|
||||
.color("var(--headertext)")
|
||||
})
|
||||
}
|
||||
|
||||
this.prop("CALENDARS", () => {
|
||||
HStack(() => {
|
||||
eventCals.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)
|
||||
})
|
||||
})
|
||||
.flexWrap("wrap")
|
||||
.gap(0.45, em)
|
||||
})
|
||||
|
||||
if (this.event.location) {
|
||||
this.prop("LOCATION", () => {
|
||||
p(this.event.location)
|
||||
.margin(0)
|
||||
.fontSize(0.88, em)
|
||||
.color("var(--headertext)")
|
||||
.lineHeight("1.5")
|
||||
})
|
||||
}
|
||||
|
||||
if (this.event.description) {
|
||||
this.prop("DESCRIPTION", () => {
|
||||
p(this.event.description)
|
||||
.margin(0)
|
||||
.fontSize(0.88, em)
|
||||
.color("var(--headertext)")
|
||||
.lineHeight("1.65")
|
||||
.whiteSpace("pre-wrap")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (this.event.attachments?.length > 0) {
|
||||
this.renderAttachments()
|
||||
}
|
||||
|
||||
})
|
||||
.flex(1)
|
||||
.overflowY("scroll")
|
||||
.width(100, pct)
|
||||
.boxSizing("border-box")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
_recurrenceLabel() {
|
||||
const r = this.event.recurrence
|
||||
if (!r) return ""
|
||||
if (r.frequency === 'daily') return "Daily"
|
||||
if (r.frequency === 'weekly' && r.interval === 2) return "Every 2 weeks"
|
||||
if (r.frequency === 'weekly') return "Weekly"
|
||||
if (r.frequency === 'monthly') return "Monthly"
|
||||
if (r.frequency === 'yearly') return "Yearly"
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── Attachments ───────────────────────────────────────────────────────────
|
||||
|
||||
renderAttachments() {
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
p("Attachments")
|
||||
.margin(0)
|
||||
.fontSize(0.82, em)
|
||||
.fontWeight("600")
|
||||
.color("var(--headertext)")
|
||||
.opacity(0.4)
|
||||
p("▼")
|
||||
.attr({ id: "desktop-attachments-chevron" })
|
||||
.margin(0)
|
||||
.fontSize(0.65, em)
|
||||
.color("var(--headertext)")
|
||||
.opacity(0.4)
|
||||
.display("inline-block")
|
||||
.transition("transform 0.22s ease")
|
||||
.transform(this.attachmentsOpen ? "rotate(180deg)" : "rotate(0deg)")
|
||||
.userSelect("none")
|
||||
})
|
||||
.gap(0.5, em)
|
||||
.alignItems("center")
|
||||
.cursor("pointer")
|
||||
.onClick((done) => { if (!done) return; this.toggleAttachments() })
|
||||
|
||||
VStack(() => {
|
||||
const images = this.event.attachments.filter(f => f.type?.startsWith("image/"))
|
||||
const files = this.event.attachments.filter(f => !f.type?.startsWith("image/"))
|
||||
|
||||
if (images.length > 0) {
|
||||
HStack(() => {
|
||||
images.forEach(file => {
|
||||
const url = `${config.SERVER}/db/images/events/${file.name}`
|
||||
VStack(() => {
|
||||
img(url, "100%", "100%")
|
||||
.objectFit("cover")
|
||||
.display("block")
|
||||
})
|
||||
.width("6.5em")
|
||||
.height("6.5em")
|
||||
.flexShrink(0)
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(6, px)
|
||||
.overflow("hidden")
|
||||
.cursor("pointer")
|
||||
.onClick((done) => { if (!done) return; $("filepreview-").open(file, url) })
|
||||
})
|
||||
})
|
||||
.flexWrap("wrap")
|
||||
.gap(0.5, em)
|
||||
.boxSizing("border-box")
|
||||
.width(100, pct)
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
VStack(() => {
|
||||
files.forEach(file => this.renderFile(file))
|
||||
})
|
||||
.gap(0.5, em)
|
||||
.width("max-content")
|
||||
.boxSizing("border-box")
|
||||
}
|
||||
})
|
||||
.attr({ id: "desktop-attachments-content" })
|
||||
.width(100, pct)
|
||||
.display(this.attachmentsOpen ? "" : "none")
|
||||
.gap(1, em)
|
||||
})
|
||||
.width(100, pct)
|
||||
.boxSizing("border-box")
|
||||
.paddingHorizontal(1.5, em)
|
||||
.paddingVertical(0.85, em)
|
||||
.gap(1, em)
|
||||
}
|
||||
|
||||
renderFile(file) {
|
||||
const url = `${config.SERVER}/db/images/events/${file.name}`
|
||||
HStack(() => {
|
||||
p("📎")
|
||||
.margin(0)
|
||||
.fontSize(0.9, em)
|
||||
p(file.original_name ?? file.name)
|
||||
.margin(0)
|
||||
.color("var(--headertext)")
|
||||
.fontSize(0.85, em)
|
||||
.overflow("hidden")
|
||||
.whiteSpace("nowrap")
|
||||
.textOverflow("ellipsis")
|
||||
})
|
||||
.gap(0.5, em)
|
||||
.alignItems("center")
|
||||
.padding(0.55, em)
|
||||
.background("var(--darkaccent)")
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(0.45, em)
|
||||
.boxSizing("border-box")
|
||||
.cursor("pointer")
|
||||
.onClick((done) => { if (!done) return; $("filepreview-").open(file, url) })
|
||||
}
|
||||
|
||||
toggleAttachments() {
|
||||
this.attachmentsOpen = !this.attachmentsOpen
|
||||
const content = this.$("#desktop-attachments-content")
|
||||
const chevron = this.$("#desktop-attachments-chevron")
|
||||
if (content) content.style.display = this.attachmentsOpen ? "" : "none"
|
||||
if (chevron) chevron.style.transform = this.attachmentsOpen ? "rotate(180deg)" : "rotate(0deg)"
|
||||
}
|
||||
|
||||
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) {
|
||||
$("modal-").forceClose()
|
||||
const deleteResult = { scope: scope ?? 'all', templateId, occurrenceDate, overrideId: isOverride ? event.id : null }
|
||||
if (this.onDeleted) this.onDeleted(deleteResult)
|
||||
} else {
|
||||
$("modal-")?.showError(result.error ?? "Failed to delete event.")
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to delete event:", err)
|
||||
$("modal-")?.showError("Failed to delete event.")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
register(DesktopEventDetails)
|
||||
1011
calendar/desktop/Events/DesktopEventForm.js
Normal file
1011
calendar/desktop/Events/DesktopEventForm.js
Normal file
File diff suppressed because it is too large
Load Diff
154
calendar/desktop/Events/FilePreview.js
Normal file
154
calendar/desktop/Events/FilePreview.js
Normal file
@@ -0,0 +1,154 @@
|
||||
class FilePreview extends Window {
|
||||
_visible = false
|
||||
_file = null
|
||||
_url = null
|
||||
|
||||
open(file, url) {
|
||||
this._file = file
|
||||
this._url = url
|
||||
this._visible = true
|
||||
this.rerender()
|
||||
}
|
||||
|
||||
close() {
|
||||
this._visible = false
|
||||
this.rerender()
|
||||
}
|
||||
|
||||
render() {
|
||||
this.style.position = "fixed"
|
||||
this.style.inset = "0"
|
||||
this.style.zIndex = "300"
|
||||
this.style.pointerEvents = this._visible ? "all" : "none"
|
||||
|
||||
if (!this._visible) return
|
||||
|
||||
const x = this.getX()
|
||||
const y = this.getY()
|
||||
const w = this.getWidth()
|
||||
const h = this.getHeight()
|
||||
|
||||
const type = this._file?.type ?? ""
|
||||
const isImage = type.startsWith("image/")
|
||||
const isPDF = type === "application/pdf"
|
||||
const isOffice = [
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/msword',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'text/plain',
|
||||
].includes(type)
|
||||
const displayName = this._file?.original_name ?? this._file?.name ?? "File"
|
||||
|
||||
VStack(() => {
|
||||
const panel = VStack(() => {
|
||||
// Header
|
||||
HStack(() => {
|
||||
HStack(() => {
|
||||
div().attr({ class: "tl tl-close" }).onClick((done) => { if (done) this.close() })
|
||||
div().attr({ class: "tl tl-min" })
|
||||
div().attr({ class: "tl tl-max" })
|
||||
})
|
||||
.attr({ class: "traffic-lights" })
|
||||
.flexShrink(0)
|
||||
|
||||
p(displayName)
|
||||
.margin(0)
|
||||
.fontSize(0.88, em)
|
||||
.fontWeight("600")
|
||||
.color("var(--headertext)")
|
||||
.overflow("hidden")
|
||||
.whiteSpace("nowrap")
|
||||
.textOverflow("ellipsis")
|
||||
.flex(1)
|
||||
.textAlign("center")
|
||||
|
||||
div().width("52px").flexShrink(0)
|
||||
})
|
||||
.paddingHorizontal(1, em)
|
||||
.paddingVertical(0.75, em)
|
||||
.alignItems("center")
|
||||
.gap(1, em)
|
||||
.background("var(--darkaccent)")
|
||||
.borderBottom("1px solid var(--divider)")
|
||||
.width(100, pct)
|
||||
.boxSizing("border-box")
|
||||
.flexShrink(0)
|
||||
|
||||
// Content
|
||||
if (isImage) {
|
||||
img(this._url, "auto", "auto")
|
||||
.display("block")
|
||||
.maxWidth(100, pct)
|
||||
.maxHeight(h - 48, px)
|
||||
.margin("0 auto")
|
||||
} else if (isPDF) {
|
||||
const wrap = div()
|
||||
.flex(1)
|
||||
.width(100, pct)
|
||||
.overflow("hidden")
|
||||
wrap.innerHTML = `<iframe src="${this._url}" style="width:100%;height:100%;border:none;display:block;"></iframe>`
|
||||
} else {
|
||||
const icon = {
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': "📄",
|
||||
'application/msword': "📄",
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': "📊",
|
||||
'application/vnd.ms-excel': "📊",
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': "📋",
|
||||
'application/vnd.ms-powerpoint': "📋",
|
||||
'text/plain': "📄",
|
||||
}[type] ?? "📎"
|
||||
|
||||
VStack(() => {
|
||||
p(icon).fontSize(3, em).margin(0)
|
||||
p(displayName)
|
||||
.margin(0)
|
||||
.fontSize(0.9, em)
|
||||
.color("var(--headertext)")
|
||||
.textAlign("center")
|
||||
|
||||
a(this._url, "Download")
|
||||
.attr({ download: displayName })
|
||||
.fontSize(0.85, em)
|
||||
.color("var(--quillred)")
|
||||
.fontWeight("600")
|
||||
.fontFamily("Arial")
|
||||
.textDecoration("none")
|
||||
.cursor("pointer")
|
||||
.marginTop(0.25, em)
|
||||
.onHover(function(hovering) {
|
||||
this.style.textDecoration = hovering ? "underline" : "none"
|
||||
})
|
||||
})
|
||||
.gap(0.5, em)
|
||||
.alignItems("center")
|
||||
.padding(2, em)
|
||||
}
|
||||
})
|
||||
.background("var(--window)")
|
||||
.backdropFilter("blur(18px)")
|
||||
.border("0.5px solid var(--window-border)")
|
||||
.borderRadius(12, px)
|
||||
.overflow("hidden")
|
||||
.width(100, pct)
|
||||
.boxSizing("border-box")
|
||||
|
||||
if (isPDF) panel.height(h, px)
|
||||
else panel.maxHeight(h, px)
|
||||
panel.onClick((done, e) => { e.stopPropagation() })
|
||||
})
|
||||
.position("fixed")
|
||||
.x(x, px)
|
||||
.y(y, px)
|
||||
.width(w, px)
|
||||
.height(h, px)
|
||||
.justifyContent(isPDF ? "flex-start" : "center")
|
||||
.alignItems("center")
|
||||
.onClick((done) => { if (done) this.close() })
|
||||
.onEvent("resize", () => this.rerender())
|
||||
}
|
||||
}
|
||||
|
||||
register(FilePreview)
|
||||
415
calendar/desktop/calendar.js
Normal file
415
calendar/desktop/calendar.js
Normal file
@@ -0,0 +1,415 @@
|
||||
import calendarUtil from "../calendarUtil.js"
|
||||
import "./DesktopToolbar.js"
|
||||
import "./DesktopSidebar.js"
|
||||
import "./DesktopMonthView.js"
|
||||
import "./Events/DesktopEventDetails.js"
|
||||
import "./Events/DesktopEventForm.js"
|
||||
import "./DesktopCalendarForm.js"
|
||||
|
||||
css(`
|
||||
calendar- {
|
||||
font-family: 'Arial';
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
`)
|
||||
|
||||
class Calendar extends Shadow {
|
||||
constructor() {
|
||||
super()
|
||||
this.currentDate = new Date();
|
||||
this.weekStartsOn = 0;
|
||||
this.calendars = (global.currentNetwork.data?.calendars ?? []).map(c => ({ ...c }))
|
||||
const storedCalIds = JSON.parse(localStorage.getItem(`calendarSelection_${global.profile.id}_${global.currentNetwork.id}`) || 'null')
|
||||
if (storedCalIds) {
|
||||
const restored = this.calendars.filter(c => storedCalIds.includes(c.id))
|
||||
this.selectedCalendars = restored.length > 0 ? restored : [...this.calendars]
|
||||
} else {
|
||||
this.selectedCalendars = [...this.calendars]
|
||||
}
|
||||
this.events = (global.currentNetwork.data?.events ?? []).map(event => ({
|
||||
...event,
|
||||
time_start: new Date(event.time_start),
|
||||
time_end: new Date(event.time_end)
|
||||
}))
|
||||
}
|
||||
|
||||
toggleCalendar(calendar) {
|
||||
const isSelected = this.selectedCalendars.some(c => c.id === calendar.id);
|
||||
if (isSelected && this.selectedCalendars.length === 1) return;
|
||||
if (isSelected) {
|
||||
this.selectedCalendars = this.selectedCalendars.filter(c => c.id !== calendar.id);
|
||||
} else {
|
||||
this.selectedCalendars = [...this.selectedCalendars, calendar];
|
||||
}
|
||||
localStorage.setItem(`calendarSelection_${global.profile.id}_${global.currentNetwork.id}`, JSON.stringify(this.selectedCalendars.map(c => c.id)))
|
||||
this.rerender();
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
HStack(() => {
|
||||
DesktopSidebar(
|
||||
this.currentDate,
|
||||
this.calendars,
|
||||
this.selectedCalendars,
|
||||
this.events,
|
||||
this.weekStartsOn,
|
||||
{
|
||||
onSelectDate: (date) => this.goToDate(date),
|
||||
onToggleCalendar: (cal) => this.toggleCalendar(cal),
|
||||
onNewCalendar: () => {
|
||||
let formEl
|
||||
$("modal-").open(() => {
|
||||
formEl = DesktopCalendarForm(this.calendars, (calendar) => this.addCalendar(calendar))
|
||||
})
|
||||
$("modal-")._closeOverride = async () => {
|
||||
const saved = formEl ? await formEl.trySave() : false
|
||||
if (saved === null) return
|
||||
if (saved) this.addCalendar(saved)
|
||||
$("modal-").forceClose()
|
||||
}
|
||||
},
|
||||
onEditCalendar: (cal) => this.openEditCalendarForm(cal)
|
||||
}
|
||||
)
|
||||
|
||||
VStack(() => {
|
||||
DesktopToolbar(this.currentDate, {
|
||||
goToPrevious: () => this.goToPrevious(),
|
||||
goToCurrent: () => this.goToCurrent(),
|
||||
goToNext: () => this.goToNext(),
|
||||
onNewEvent: () => {
|
||||
let formEl
|
||||
$("modal-").open(() => {
|
||||
formEl = DesktopEventForm(this.calendars, (event) => this.addEvent(event))
|
||||
})
|
||||
$("modal-")._closeOverride = () => formEl?.handleBack()
|
||||
}
|
||||
})
|
||||
|
||||
DesktopMonthView(this.selectedCalendars, this.events, this.currentDate, this.weekStartsOn,
|
||||
(event) => this.openEventDetails(event),
|
||||
(day, removeGhost) => {
|
||||
let formEl
|
||||
const onBack = () => {
|
||||
if (removeGhost) removeGhost()
|
||||
$("modal-").forceClose()
|
||||
}
|
||||
$("modal-").open(() => {
|
||||
formEl = DesktopEventForm(this.calendars, (event) => this.addEvent(event), null, null, onBack, day)
|
||||
})
|
||||
$("modal-")._closeOverride = () => formEl?.handleBack()
|
||||
}
|
||||
)
|
||||
.flex(1)
|
||||
.minHeight(0)
|
||||
.width(100, pct)
|
||||
.overflow("hidden")
|
||||
})
|
||||
.flex(1)
|
||||
.height(100, pct)
|
||||
.overflow("hidden")
|
||||
})
|
||||
.height(100, pct)
|
||||
.width(100, pct)
|
||||
.overflow("hidden")
|
||||
}
|
||||
|
||||
openEventDetails(event) {
|
||||
$("modal-").open(() => DesktopEventDetails(
|
||||
this.calendars,
|
||||
event,
|
||||
(editResult) => {
|
||||
if (editResult?.scope) {
|
||||
this.handleEditResult(editResult)
|
||||
} else {
|
||||
this.updateEvent(editResult)
|
||||
}
|
||||
this.rerender()
|
||||
},
|
||||
(deleteResult) => {
|
||||
this.handleDeleteResult(deleteResult)
|
||||
this.rerender()
|
||||
},
|
||||
(evt) => this.openEditForm(evt)
|
||||
))
|
||||
}
|
||||
|
||||
openEditForm(event) {
|
||||
let formEl
|
||||
const goBack = () => {
|
||||
$("modal-").forceClose()
|
||||
const currentEvent = this.events.find(e => e.id === event.id) ?? event
|
||||
this.openEventDetails({ ...currentEvent })
|
||||
}
|
||||
$("modal-").open(() => {
|
||||
formEl = DesktopEventForm(
|
||||
this.calendars,
|
||||
(editResult) => {
|
||||
if (editResult?.scope) {
|
||||
this.handleEditResult(editResult)
|
||||
this.rerender()
|
||||
const findId = editResult.scope === 'all' ? editResult.templateId : editResult.event.id
|
||||
const updatedEvt = this.events.find(e => e.id === findId)
|
||||
if (updatedEvt) setTimeout(() => this.openEventDetails(updatedEvt), 50)
|
||||
} else {
|
||||
this.updateEvent(editResult)
|
||||
this.openEventDetails(editResult)
|
||||
}
|
||||
},
|
||||
event,
|
||||
(deleteResult) => {
|
||||
this.handleDeleteResult(deleteResult)
|
||||
this.rerender()
|
||||
},
|
||||
goBack
|
||||
)
|
||||
})
|
||||
$("modal-")._closeOverride = () => formEl?.handleBack()
|
||||
}
|
||||
|
||||
addEvent(event) {
|
||||
this.events = [...this.events, this.parseEvent(event)]
|
||||
global.currentNetwork.data.events = [...global.currentNetwork.data.events, event]
|
||||
this.rerender()
|
||||
}
|
||||
|
||||
openEditCalendarForm(calendar) {
|
||||
let formEl
|
||||
$("modal-").open(() => {
|
||||
formEl = DesktopCalendarForm(
|
||||
this.calendars,
|
||||
(updated) => { this.updateCalendar(updated); $("modal-").forceClose() },
|
||||
calendar,
|
||||
(deletedId) => this.deleteCalendar(deletedId),
|
||||
() => $("modal-").forceClose()
|
||||
)
|
||||
})
|
||||
$("modal-")._closeOverride = async () => {
|
||||
const saved = formEl ? await formEl.trySave() : null
|
||||
if (saved === null) return
|
||||
this.updateCalendar(saved)
|
||||
$("modal-").forceClose()
|
||||
}
|
||||
}
|
||||
|
||||
addCalendar(calendar) {
|
||||
this.calendars = [...this.calendars, calendar]
|
||||
this.selectedCalendars = [...this.selectedCalendars, calendar]
|
||||
global.currentNetwork.data.calendars = [...global.currentNetwork.data.calendars, calendar]
|
||||
this.rerender()
|
||||
}
|
||||
|
||||
updateCalendar(calendar) {
|
||||
this.calendars = this.calendars.map(c => c.id === calendar.id ? calendar : c)
|
||||
this.selectedCalendars = this.selectedCalendars.map(c => c.id === calendar.id ? calendar : c)
|
||||
global.currentNetwork.data.calendars = global.currentNetwork.data.calendars.map(c => c.id === calendar.id ? calendar : c)
|
||||
this.rerender()
|
||||
}
|
||||
|
||||
deleteCalendar(id) {
|
||||
this.calendars = this.calendars.filter(c => c.id !== id)
|
||||
this.selectedCalendars = this.selectedCalendars.filter(c => c.id !== id)
|
||||
global.currentNetwork.data.calendars = global.currentNetwork.data.calendars.filter(c => c.id !== id)
|
||||
this.rerender()
|
||||
}
|
||||
|
||||
updateEvent(event) {
|
||||
const parsed = this.parseEvent(event)
|
||||
this.events = this.events.map(e => e.id === parsed.id ? parsed : e)
|
||||
global.currentNetwork.data.events = global.currentNetwork.data.events.map(e => e.id === event.id ? event : e)
|
||||
this.rerender()
|
||||
}
|
||||
|
||||
deleteEvent(id) {
|
||||
this.events = this.events.filter(e => e.id !== id)
|
||||
global.currentNetwork.data.events = global.currentNetwork.data.events.filter(e => e.id !== id)
|
||||
this.rerender()
|
||||
}
|
||||
|
||||
handleEditResult({ scope, event: resultEvent, templateId, occurrenceDate }) {
|
||||
const event = { ...resultEvent, time_start: new Date(resultEvent.time_start), time_end: new Date(resultEvent.time_end) };
|
||||
|
||||
if (scope === 'all') {
|
||||
// Preserve end_date from old template — it may have been set by a 'this and future' split.
|
||||
const oldTemplate = this.events.find(e => e.id === templateId);
|
||||
const oldEndDate = oldTemplate?.recurrence?.end_date ?? null;
|
||||
const recurrence = event.recurrence
|
||||
? { ...event.recurrence, end_date: event.recurrence.end_date ?? oldEndDate }
|
||||
: null;
|
||||
const mergedEvent = { ...event, recurrence };
|
||||
this.events = this.events.map(e => e.id === templateId ? mergedEvent : e);
|
||||
global.currentNetwork.data.events = global.currentNetwork.data.events.map(e =>
|
||||
e.id === templateId ? { ...resultEvent, recurrence } : e
|
||||
);
|
||||
|
||||
} else if (scope === 'single') {
|
||||
const alreadyExists = this.events.some(e => e.id === resultEvent.id);
|
||||
if (alreadyExists) {
|
||||
this.events = this.events.map(e => e.id === resultEvent.id ? event : e);
|
||||
global.currentNetwork.data.events = global.currentNetwork.data.events.map(e => e.id === resultEvent.id ? resultEvent : e);
|
||||
} else {
|
||||
this.events = [...this.events, event];
|
||||
global.currentNetwork.data.events = [...global.currentNetwork.data.events, resultEvent];
|
||||
}
|
||||
|
||||
} else if (scope === 'future') {
|
||||
const capDate = occurrenceDate ? new Date(occurrenceDate) : null;
|
||||
if (capDate) {
|
||||
const oldTemplate = this.events.find(e => e.id === templateId);
|
||||
const oldEndDate = oldTemplate?.recurrence?.end_date ? new Date(oldTemplate.recurrence.end_date) : null;
|
||||
|
||||
const baseRecurrence = event.recurrence ?? oldTemplate?.recurrence;
|
||||
const inheritedRecurrence = baseRecurrence
|
||||
? { ...baseRecurrence, end_date: oldEndDate ? oldEndDate.toISOString() : null }
|
||||
: null;
|
||||
const newTemplateEvent = { ...event, recurrence: inheritedRecurrence };
|
||||
const newTemplateRaw = { ...resultEvent, recurrence: inheritedRecurrence };
|
||||
|
||||
const descendantIds = new Set(
|
||||
this.events
|
||||
.filter(e => {
|
||||
if (!(e.parent_template_id === templateId && e.recurrence_id)) return false;
|
||||
const t = new Date(e.time_start);
|
||||
return t >= capDate && (!oldEndDate || t < oldEndDate);
|
||||
})
|
||||
.map(e => e.id)
|
||||
);
|
||||
|
||||
const newId = resultEvent.id;
|
||||
const updateAndFilter = (arr) => arr.map(e => {
|
||||
if (e.id === templateId && e.recurrence) {
|
||||
return { ...e, recurrence: { ...e.recurrence, end_date: capDate.toISOString() } };
|
||||
}
|
||||
if (e.recurrence_parent_id === templateId && e.recurrence_exception_date) {
|
||||
const exDate = new Date(e.recurrence_exception_date);
|
||||
if (exDate >= capDate && (!oldEndDate || exDate < oldEndDate)) {
|
||||
return { ...e, recurrence_parent_id: newId };
|
||||
}
|
||||
}
|
||||
return e;
|
||||
}).filter(e => {
|
||||
if (e.recurrence_parent_id === templateId && e.recurrence_exception_date) {
|
||||
return new Date(e.recurrence_exception_date) < capDate;
|
||||
}
|
||||
if (descendantIds.has(e.id) || descendantIds.has(e.recurrence_parent_id)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
this.events = [...updateAndFilter(this.events), newTemplateEvent];
|
||||
global.currentNetwork.data.events = [...updateAndFilter(global.currentNetwork.data.events), newTemplateRaw];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleDeleteResult({ scope, templateId, occurrenceDate, overrideId }) {
|
||||
if (scope === 'all') {
|
||||
// Promote non-cancelled overrides (single-event edits) to standalone; remove cancelled placeholders and template
|
||||
const promoteOverrides = (arr) => arr
|
||||
.filter(e => e.id !== templateId && !(e.recurrence_parent_id === templateId && e.is_cancelled))
|
||||
.map(e => e.recurrence_parent_id === templateId
|
||||
? { ...e, recurrence_parent_id: null, recurrence_exception_date: null }
|
||||
: e
|
||||
);
|
||||
this.events = promoteOverrides(this.events);
|
||||
global.currentNetwork.data.events = promoteOverrides(global.currentNetwork.data.events);
|
||||
} else if (scope === 'single') {
|
||||
if (overrideId) {
|
||||
this.events = this.events.map(e => e.id === overrideId ? { ...e, is_cancelled: true } : e);
|
||||
global.currentNetwork.data.events = global.currentNetwork.data.events.map(e => e.id === overrideId ? { ...e, is_cancelled: true } : e);
|
||||
} else if (occurrenceDate) {
|
||||
const occDate = new Date(occurrenceDate);
|
||||
const syntheticOverride = {
|
||||
id: `cancelled_${templateId}_${occurrenceDate}`,
|
||||
recurrence_parent_id: templateId,
|
||||
recurrence_exception_date: occDate,
|
||||
is_cancelled: true,
|
||||
time_start: occDate,
|
||||
time_end: occDate,
|
||||
calendars: [],
|
||||
all_day: false,
|
||||
};
|
||||
this.events = [...this.events, syntheticOverride];
|
||||
global.currentNetwork.data.events = [...global.currentNetwork.data.events, syntheticOverride];
|
||||
}
|
||||
} else if (scope === 'future') {
|
||||
const capDate = occurrenceDate ? new Date(occurrenceDate) : null;
|
||||
if (capDate) {
|
||||
const oldTemplate = this.events.find(e => e.id === templateId);
|
||||
// Server does a full delete when capDate <= time_start (no occurrences would remain)
|
||||
if (oldTemplate && capDate <= new Date(oldTemplate.time_start)) {
|
||||
const promoteOverrides = (arr) => arr
|
||||
.filter(e => e.id !== templateId && !(e.recurrence_parent_id === templateId && e.is_cancelled))
|
||||
.map(e => e.recurrence_parent_id === templateId
|
||||
? { ...e, recurrence_parent_id: null, recurrence_exception_date: null }
|
||||
: e
|
||||
);
|
||||
this.events = promoteOverrides(this.events);
|
||||
global.currentNetwork.data.events = promoteOverrides(global.currentNetwork.data.events);
|
||||
return;
|
||||
}
|
||||
const oldEndDate = oldTemplate?.recurrence?.end_date ? new Date(oldTemplate.recurrence.end_date) : null;
|
||||
const descendantIds = new Set(
|
||||
this.events
|
||||
.filter(e => {
|
||||
if (!(e.parent_template_id === templateId && e.recurrence_id)) return false;
|
||||
const t = new Date(e.time_start);
|
||||
return t >= capDate && (!oldEndDate || t < oldEndDate);
|
||||
})
|
||||
.map(e => e.id)
|
||||
);
|
||||
const updateAndFilter = (arr) => arr.map(e => {
|
||||
if (e.id === templateId && e.recurrence) {
|
||||
return { ...e, recurrence: { ...e.recurrence, end_date: capDate.toISOString() } };
|
||||
}
|
||||
// Promote future non-cancelled overrides to standalone events
|
||||
if (e.recurrence_parent_id === templateId && e.recurrence_exception_date
|
||||
&& new Date(e.recurrence_exception_date) >= capDate && !e.is_cancelled) {
|
||||
return { ...e, recurrence_parent_id: null, recurrence_exception_date: null };
|
||||
}
|
||||
return e;
|
||||
}).filter(e => {
|
||||
// Remove future cancelled placeholders (meaningless without the series)
|
||||
if (e.recurrence_parent_id === templateId && e.recurrence_exception_date) {
|
||||
return new Date(e.recurrence_exception_date) < capDate;
|
||||
}
|
||||
if (descendantIds.has(e.id) || descendantIds.has(e.recurrence_parent_id)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
this.events = updateAndFilter(this.events);
|
||||
global.currentNetwork.data.events = updateAndFilter(global.currentNetwork.data.events);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parseEvent(event) {
|
||||
return { ...event, time_start: new Date(event.time_start), time_end: new Date(event.time_end) }
|
||||
}
|
||||
|
||||
goToPrevious() {
|
||||
this.currentDate = calendarUtil.addMonths(this.currentDate, -1);
|
||||
this.rerender();
|
||||
}
|
||||
|
||||
goToCurrent() {
|
||||
this.currentDate = new Date();
|
||||
this.rerender();
|
||||
}
|
||||
|
||||
goToNext() {
|
||||
this.currentDate = calendarUtil.addMonths(this.currentDate, 1);
|
||||
this.rerender();
|
||||
}
|
||||
|
||||
goToDate(date) {
|
||||
if (calendarUtil.isSameMonth(this.currentDate, date)) return;
|
||||
this.currentDate = date;
|
||||
this.rerender();
|
||||
}
|
||||
}
|
||||
|
||||
register(Calendar)
|
||||
Reference in New Issue
Block a user