init
437
calendar/CalendarForm.js
Normal file
@@ -0,0 +1,437 @@
|
||||
import server from "/@server/server.js"
|
||||
|
||||
css(`
|
||||
calendarform- {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
calendarform- ::-webkit-scrollbar { display: none; width: 0; height: 0; }
|
||||
calendarform- ::-webkit-scrollbar-thumb { background: transparent; }
|
||||
calendarform- ::-webkit-scrollbar-track { background: transparent; }
|
||||
calendarform- input::placeholder,
|
||||
calendarform- textarea::placeholder {
|
||||
color: var(--headertext);
|
||||
opacity: 0.35;
|
||||
}
|
||||
#calendarform-toast-wrap {
|
||||
transition: max-height 0.25s ease, opacity 0.22s ease, padding-top 0.25s ease;
|
||||
}
|
||||
`)
|
||||
|
||||
class CalendarForm extends Shadow {
|
||||
static COLORS = [
|
||||
"#9E1C29", "#3D6FAD", "#2A8636", "#B38A1E",
|
||||
"#B85A1F", "#7A3FA3", "#B23D6B", "#2D8A87",
|
||||
"#3C9A5F", "#6E9A23", "#7A8428", "#9A2F7D",
|
||||
"#4F54A8", "#8A5A32", "#546B86", "#A67A1F",
|
||||
]
|
||||
|
||||
cardInputStyles(el) {
|
||||
return el
|
||||
.border("none")
|
||||
.outline("none")
|
||||
.background("transparent")
|
||||
.color("var(--text)")
|
||||
.fontSize(0.9, em)
|
||||
.fontFamily("Arial")
|
||||
.padding(0)
|
||||
.boxSizing("border-box")
|
||||
}
|
||||
|
||||
constructor(onBack = null, onCreated = null, editCalendar = null, onDelete = null, onSaveError = null) {
|
||||
super()
|
||||
this.onBack = onBack;
|
||||
this.onCreated = onCreated;
|
||||
this.editCalendar = editCalendar;
|
||||
this.onDelete = onDelete;
|
||||
this.onSaveError = onSaveError;
|
||||
this.selectedColor = editCalendar ? editCalendar.color : CalendarForm.COLORS[0];
|
||||
|
||||
if (editCalendar) {
|
||||
this.originalFormData = {
|
||||
name: editCalendar.name ?? "",
|
||||
description: editCalendar.description || "",
|
||||
color: editCalendar.color ?? ""
|
||||
}
|
||||
} else {
|
||||
this.originalFormData = {
|
||||
name: "",
|
||||
description: "",
|
||||
color: CalendarForm.COLORS[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const isEditMode = !!this.editCalendar;
|
||||
|
||||
form(() => {
|
||||
VStack(() => {
|
||||
// ── Header ────────────────────────────────────────────
|
||||
HStack(() => {
|
||||
button("Cancel")
|
||||
.attr({ type: "button" })
|
||||
.background("none")
|
||||
.border("none")
|
||||
.padding(0)
|
||||
.color("var(--quillred)")
|
||||
.fontSize(0.95, em)
|
||||
.fontFamily("Arial")
|
||||
.cursor("pointer")
|
||||
.flexShrink(0)
|
||||
.onTap(() => this.handleBack())
|
||||
|
||||
p(isEditMode ? "Edit Calendar" : "New Calendar")
|
||||
.margin(0)
|
||||
.fontSize(1, em)
|
||||
.fontWeight("700")
|
||||
.color("var(--headertext)")
|
||||
.fontFamily("Arial")
|
||||
.flex(1)
|
||||
.textAlign("center")
|
||||
|
||||
button("Save")
|
||||
.attr({ type: "submit" })
|
||||
.background("none")
|
||||
.border("none")
|
||||
.padding(0)
|
||||
.color("var(--quillred)")
|
||||
.fontSize(0.95, em)
|
||||
.fontWeight("600")
|
||||
.fontFamily("Arial")
|
||||
.cursor("pointer")
|
||||
.flexShrink(0)
|
||||
})
|
||||
.paddingHorizontal(1.25, em)
|
||||
.paddingVertical(0.9, em)
|
||||
.alignItems("center")
|
||||
.borderBottom("1px solid var(--divider)")
|
||||
.flexShrink(0)
|
||||
.background(util.darkMode() ? "transparent" : "var(--sidebottombars)")
|
||||
|
||||
// ── Error toast ───────────────────────────────────────
|
||||
VStack(() => {
|
||||
p("")
|
||||
.attr({ id: "calendarform-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: "calendarform-toast-wrap" })
|
||||
.alignItems("center")
|
||||
.overflow("hidden")
|
||||
.maxHeight(0)
|
||||
.opacity(0)
|
||||
.flexShrink(0)
|
||||
|
||||
// ── Scrollable body ───────────────────────────────────
|
||||
VStack(() => {
|
||||
// ── Name card ─────────────────────────────────────
|
||||
VStack(() => {
|
||||
input("Name", "100%")
|
||||
.attr({ name: "name", type: "text", value: this.editCalendar?.name ?? "" })
|
||||
.border("none")
|
||||
.outline("none")
|
||||
.background("transparent")
|
||||
.color("var(--text)")
|
||||
.fontSize(1.1, em)
|
||||
.fontFamily("Arial")
|
||||
.fontWeight("500")
|
||||
.padding("0.9em 1em")
|
||||
.boxSizing("border-box")
|
||||
})
|
||||
.background("var(--darkaccent)")
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(12, px)
|
||||
.marginHorizontal(1, em)
|
||||
.overflow("hidden")
|
||||
|
||||
VStack(() => {}).height(0.85, em)
|
||||
|
||||
// ── Description card ──────────────────────────────
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
p("📝")
|
||||
.margin(0)
|
||||
.fontSize(0.85, em)
|
||||
.flexShrink(0)
|
||||
.alignSelf("flex-start")
|
||||
.paddingTop(0.08, em)
|
||||
|
||||
const editDesc = this.editCalendar?.description
|
||||
textarea(editDesc ?? "Description")
|
||||
.attr({ name: "description" })
|
||||
.styles(this.cardInputStyles)
|
||||
.flex(1)
|
||||
.minHeight(3, em)
|
||||
.resize("none")
|
||||
.fieldSizing("content")
|
||||
.lineHeight("1.45")
|
||||
.onAppear(function() {
|
||||
if (editDesc) this.value = editDesc
|
||||
})
|
||||
})
|
||||
.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")
|
||||
|
||||
VStack(() => {}).height(0.85, em)
|
||||
|
||||
// ── Color card ────────────────────────────────────
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
p("Color")
|
||||
.margin(0)
|
||||
.fontSize(0.92, em)
|
||||
.color("var(--headertext)")
|
||||
.flex(1)
|
||||
})
|
||||
.paddingHorizontal(1, em)
|
||||
.paddingVertical(0.78, em)
|
||||
.alignItems("center")
|
||||
.borderBottom("1px solid var(--divider)")
|
||||
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
CalendarForm.COLORS.slice(0, 8).forEach(color => {
|
||||
const selected = this.selectedColor === color
|
||||
p("")
|
||||
.flex(1)
|
||||
.height(1.8, em)
|
||||
.background(color)
|
||||
.borderRadius(8, px)
|
||||
.border(`3px solid ${selected ? "var(--quillred)" : color}`)
|
||||
.boxSizing("border-box")
|
||||
.cursor("pointer")
|
||||
.attr({ "data-color": color })
|
||||
.onTap(() => {
|
||||
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)`
|
||||
})
|
||||
})
|
||||
})
|
||||
.gap(0.55, em)
|
||||
|
||||
HStack(() => {
|
||||
CalendarForm.COLORS.slice(8, 16).forEach(color => {
|
||||
const selected = this.selectedColor === color
|
||||
p("")
|
||||
.flex(1)
|
||||
.height(1.8, em)
|
||||
.background(color)
|
||||
.borderRadius(8, px)
|
||||
.border(`3px solid ${selected ? "var(--quillred)" : color}`)
|
||||
.boxSizing("border-box")
|
||||
.cursor("pointer")
|
||||
.attr({ "data-color": color })
|
||||
.onTap(() => {
|
||||
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)`
|
||||
})
|
||||
})
|
||||
})
|
||||
.gap(0.55, em)
|
||||
})
|
||||
.padding(1, em)
|
||||
.gap(0.55, em)
|
||||
})
|
||||
.background("var(--darkaccent)")
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(12, px)
|
||||
.marginHorizontal(1, em)
|
||||
.overflow("hidden")
|
||||
|
||||
if (isEditMode) {
|
||||
VStack(() => {}).height(0.85, em)
|
||||
|
||||
button("Delete Calendar")
|
||||
.attr({ type: "button" })
|
||||
.width("calc(100% - 2em)")
|
||||
.marginHorizontal(1, em)
|
||||
.padding(0.85, em)
|
||||
.boxSizing("border-box")
|
||||
.background("transparent")
|
||||
.color("var(--quillred)")
|
||||
.border("1.5px solid var(--quillred)")
|
||||
.borderRadius(12, px)
|
||||
.fontSize(0.95, em)
|
||||
.fontFamily("Arial")
|
||||
.fontWeight("600")
|
||||
.cursor("pointer")
|
||||
.onTap(() => this.handleDelete())
|
||||
}
|
||||
|
||||
VStack(() => {}).height(1.5, em)
|
||||
})
|
||||
.overflowY("scroll")
|
||||
.flex(1)
|
||||
.paddingTop(0.85, em)
|
||||
})
|
||||
.height(100, pct)
|
||||
.onSubmit((e) => {
|
||||
e.preventDefault()
|
||||
this.handleSubmit(this.getFormData())
|
||||
})
|
||||
.onKeyDown(e => {
|
||||
if (e.key === "Enter" && e.target.tagName !== "TEXTAREA" && e.target.tagName !== "BUTTON") e.preventDefault()
|
||||
})
|
||||
})
|
||||
.height(100, pct)
|
||||
}
|
||||
|
||||
showError(msg) {
|
||||
const wrap = this.$("#calendarform-toast-wrap")
|
||||
const toast = this.$("#calendarform-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.$("#calendarform-toast-wrap")
|
||||
if (!wrap) return
|
||||
clearTimeout(this._errorTimer)
|
||||
wrap.style.maxHeight = "0"
|
||||
wrap.style.opacity = "0"
|
||||
wrap.style.paddingTop = "0"
|
||||
}
|
||||
|
||||
getFormData() {
|
||||
return {
|
||||
name: this.$('[name="name"]').value,
|
||||
color: this.selectedColor,
|
||||
description: this.$('[name="description"]').value
|
||||
}
|
||||
}
|
||||
|
||||
isNewCalendarDirty() {
|
||||
const data = this.getFormData()
|
||||
const o = this.originalFormData
|
||||
return (
|
||||
data.name !== o.name ||
|
||||
(data.description || "") !== o.description ||
|
||||
data.color !== o.color
|
||||
)
|
||||
}
|
||||
|
||||
async handleBack() {
|
||||
this.hideError()
|
||||
if (this.onBack) this.onBack()
|
||||
}
|
||||
|
||||
async trySave() {
|
||||
if (!this.editCalendar) {
|
||||
if (!this.isNewCalendarDirty()) return false
|
||||
const data = this.getFormData()
|
||||
const payload = {
|
||||
name: data.name || "New calendar",
|
||||
description: data.description || null,
|
||||
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()
|
||||
const unchanged =
|
||||
data.name === this.originalFormData.name &&
|
||||
data.color === this.originalFormData.color &&
|
||||
(data.description || "") === this.originalFormData.description
|
||||
|
||||
if (unchanged) return this.editCalendar
|
||||
|
||||
const result = await server.editCalendar(
|
||||
this.editCalendar.id,
|
||||
{
|
||||
name: data.name || "New calendar",
|
||||
description: data.description || null,
|
||||
color: data.color
|
||||
},
|
||||
global.currentNetwork.id
|
||||
)
|
||||
|
||||
if (result.status === 200) return result.calendar
|
||||
|
||||
this.showError(result.error ?? "Failed to save calendar.")
|
||||
return null
|
||||
}
|
||||
|
||||
async handleSubmit(data) {
|
||||
this.hideError()
|
||||
|
||||
if (this.editCalendar && !data.name) {
|
||||
this.showError("Calendars must have a name.")
|
||||
this.onSaveError?.()
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: data.name || "New calendar",
|
||||
description: data.description || null,
|
||||
color: data.color
|
||||
}
|
||||
|
||||
if (this.editCalendar) {
|
||||
const result = await server.editCalendar(this.editCalendar.id, payload, global.currentNetwork.id);
|
||||
if (result.status === 200) {
|
||||
if (this.onCreated) this.onCreated(result.calendar);
|
||||
} else {
|
||||
this.showError(result.error ?? "Failed to update calendar.")
|
||||
this.onSaveError?.()
|
||||
}
|
||||
} else {
|
||||
const result = await server.addCalendar(payload, global.currentNetwork.id);
|
||||
if (result.status === 200) {
|
||||
if (this.onCreated) this.onCreated(result.calendar);
|
||||
} else {
|
||||
this.showError(result.error ?? "Failed to create calendar.")
|
||||
this.onSaveError?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleDelete() {
|
||||
this.hideError()
|
||||
const result = await server.deleteCalendar(
|
||||
this.editCalendar.id,
|
||||
global.currentNetwork.id
|
||||
);
|
||||
if (result.status === 200) {
|
||||
if (this.onDelete) this.onDelete(this.editCalendar.id);
|
||||
} else {
|
||||
this.showError(result.error ?? "Failed to delete calendar.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
register(CalendarForm)
|
||||
101
calendar/Day/DayHeaderRow.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import calendarUtil from "../calendarUtil.js";
|
||||
|
||||
class DayHeaderRow extends Shadow {
|
||||
constructor(groupedDay, calendars) {
|
||||
super()
|
||||
this.groupedDay = groupedDay;
|
||||
this.calendars = calendars;
|
||||
}
|
||||
|
||||
render() {
|
||||
const day = this.groupedDay.day;
|
||||
const today = calendarUtil.isToday(day);
|
||||
const allDayEvents = this.groupedDay.allDay;
|
||||
const maxEvents = allDayEvents.length;
|
||||
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
VStack(() => {
|
||||
p(day.toLocaleDateString("en-US", { weekday: "long" }).toUpperCase())
|
||||
.margin(0)
|
||||
.fontSize(0.72, em)
|
||||
.fontWeight("600")
|
||||
.letterSpacing(0.04, em)
|
||||
.opacity(today ? 1 : 0.5)
|
||||
.textAlign("left")
|
||||
|
||||
h3(day.toLocaleDateString("en-US", { month: "long", day: "numeric" }))
|
||||
.margin(0)
|
||||
.fontSize(1.35, em)
|
||||
.fontWeight("700")
|
||||
.lineHeight("1")
|
||||
})
|
||||
.color(today ? "var(--quillred)" : "var(--headertext)")
|
||||
.flex(1)
|
||||
.alignItems("flex-start")
|
||||
.justifyContent("center")
|
||||
.gap(0.3, em)
|
||||
.paddingTop(0.85, em)
|
||||
.paddingBottom(maxEvents > 0 ? (maxEvents * 2.0) + 0.7 : 0.35, em)
|
||||
.paddingHorizontal(0.75, em)
|
||||
.boxSizing("border-box")
|
||||
})
|
||||
.width(100, pct)
|
||||
.alignItems("stretch")
|
||||
|
||||
if (allDayEvents.length > 0) {
|
||||
this.allDaySection(allDayEvents);
|
||||
}
|
||||
})
|
||||
.width(100, pct)
|
||||
.position("relative")
|
||||
.background("var(--sidebottombars)")
|
||||
.borderBottom("1px solid var(--divider)")
|
||||
}
|
||||
|
||||
allDaySection(events) {
|
||||
const rowHeight = 1.75;
|
||||
const gap = 0.25;
|
||||
const totalHeight = events.length * rowHeight + (events.length - 1) * gap;
|
||||
|
||||
ZStack(() => {
|
||||
events.forEach((event, slot) => {
|
||||
const color = calendarUtil.getCalendarColor(this.calendars, event.calendars.find(id => this.calendars.some(c => c.id === id)) ?? event.calendars[0]);
|
||||
const topEm = slot * (rowHeight + gap);
|
||||
|
||||
HStack(() => {
|
||||
p(event.title)
|
||||
.margin(0)
|
||||
.fontSize(0.72, em)
|
||||
.fontWeight("600")
|
||||
.color("white")
|
||||
.whiteSpace("nowrap")
|
||||
.overflow("hidden")
|
||||
})
|
||||
.position("absolute")
|
||||
.top(topEm, em)
|
||||
.left(0.25, em)
|
||||
.right(0.25, em)
|
||||
.height(rowHeight, em)
|
||||
.padding(0.35, em)
|
||||
.background(color)
|
||||
.borderRadius(0.25, em)
|
||||
.alignItems("center")
|
||||
.boxSizing("border-box")
|
||||
.overflow("hidden")
|
||||
.pointerEvents("auto")
|
||||
.cursor("pointer")
|
||||
.onTap(() => $("bottomsheet-").showEvent(event))
|
||||
})
|
||||
})
|
||||
.position("absolute")
|
||||
.bottom(0.25, em)
|
||||
.left(0)
|
||||
.right(0)
|
||||
.height(totalHeight, em)
|
||||
.boxSizing("border-box")
|
||||
.pointerEvents("none")
|
||||
}
|
||||
}
|
||||
|
||||
register(DayHeaderRow)
|
||||
124
calendar/Day/DayView.js
Normal file
@@ -0,0 +1,124 @@
|
||||
import calendarUtil from "../calendarUtil.js"
|
||||
import "../Week/SpacerCell.js"
|
||||
import "../Week/TimedLabelsColumn.js"
|
||||
import "../Week/TimedWeekGrid.js"
|
||||
import "./DayHeaderRow.js"
|
||||
|
||||
css(`
|
||||
dayview- {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
dayview- .VStack::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0px;
|
||||
height: 0px;
|
||||
}
|
||||
|
||||
dayview- .VStack::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
dayview- .VStack::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
`)
|
||||
|
||||
let _saved = null;
|
||||
|
||||
class DayView extends Shadow {
|
||||
constructor(calendars, events, currentDate, onSlotTap = null, isCenter = false) {
|
||||
super()
|
||||
this.calendars = calendars;
|
||||
this.events = events;
|
||||
this.currentDate = currentDate;
|
||||
this.onSlotTap = onSlotTap;
|
||||
this.isCenter = isCenter;
|
||||
this.slots = calendarUtil.generateTimeSlots({ stepMinutes: 30 });
|
||||
this.slotHeight = 2;
|
||||
this.sidebarWidth = 3;
|
||||
}
|
||||
|
||||
render() {
|
||||
ZStack(() => {
|
||||
const filteredEvents = this.filterEventsForDay(this.events);
|
||||
const groupedDay = this.groupEventsForDay(filteredEvents);
|
||||
const weekNumber = calendarUtil.getWeekNumber(this.currentDate);
|
||||
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
SpacerCell(weekNumber, this.sidebarWidth)
|
||||
DayHeaderRow(groupedDay, this.calendars)
|
||||
})
|
||||
.boxSizing("border-box")
|
||||
.position("sticky")
|
||||
.top(0)
|
||||
.width(100, pct)
|
||||
.zIndex(2)
|
||||
|
||||
HStack(() => {
|
||||
TimedLabelsColumn(this.slots, this.slotHeight, this.sidebarWidth)
|
||||
TimedWeekGrid([groupedDay], this.slots, [groupedDay], this.calendars, this.slotHeight, "day", this.onSlotTap)
|
||||
})
|
||||
.boxSizing("border-box")
|
||||
.onAppear(() => {
|
||||
this.scrollToEight();
|
||||
})
|
||||
})
|
||||
})
|
||||
.position("relative")
|
||||
.gap(1, em)
|
||||
.boxSizing("border-box")
|
||||
.width(100, pct)
|
||||
.height(100, pct)
|
||||
.fontSize(0.9, em)
|
||||
.overscrollBehavior("none")
|
||||
.overflowY("scroll")
|
||||
.display("block")
|
||||
}
|
||||
|
||||
filterEventsForDay(events) {
|
||||
const dayStart = calendarUtil.startOfDay(this.currentDate);
|
||||
const dayEnd = calendarUtil.endOfDay(this.currentDate);
|
||||
const expanded = calendarUtil.expandRecurringEvents(events, dayStart, dayEnd);
|
||||
return expanded.filter(event => {
|
||||
const end = event.all_day ? calendarUtil.endOfDay(event.time_end) : calendarUtil.timedEnd(event);
|
||||
return calendarUtil.rangesOverlap(event.time_start, end, dayStart, dayEnd)
|
||||
&& this.calendars.some(c => event.calendars.some(c2 => c2 === c.id));
|
||||
});
|
||||
}
|
||||
|
||||
groupEventsForDay(events) {
|
||||
const dayStart = calendarUtil.startOfDay(this.currentDate);
|
||||
const dayEnd = calendarUtil.endOfDay(this.currentDate);
|
||||
return {
|
||||
day: this.currentDate,
|
||||
allDay: events.filter(event => {
|
||||
if (!event.all_day) return false;
|
||||
const end = calendarUtil.endOfDay(event.time_end);
|
||||
return calendarUtil.rangesOverlap(event.time_start, end, dayStart, dayEnd);
|
||||
}),
|
||||
timed: events.filter(event => {
|
||||
return !event.all_day && calendarUtil.rangesOverlap(event.time_start, calendarUtil.timedEnd(event), dayStart, dayEnd);
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
scrollToEight() {
|
||||
requestAnimationFrame(() => {
|
||||
const fs = parseFloat(getComputedStyle(this).fontSize);
|
||||
const slotsBeforeEight = this.slots.findIndex(s => s.hour24 === 8 && s.minute === 0);
|
||||
const defaultTarget = slotsBeforeEight * this.slotHeight * fs;
|
||||
if (this.isCenter) {
|
||||
const dateKey = calendarUtil.toDateInput(this.currentDate);
|
||||
this.scrollTop = (_saved?.dateKey === dateKey) ? _saved.scrollTop : defaultTarget;
|
||||
this.addEventListener('scroll', () => { _saved = { dateKey, scrollTop: this.scrollTop }; }, { passive: true });
|
||||
} else {
|
||||
this.scrollTop = defaultTarget;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
register(DayView)
|
||||
157
calendar/EventFileList.js
Normal file
@@ -0,0 +1,157 @@
|
||||
class EventFileList extends Shadow {
|
||||
constructor(existingAttachments, onDeleteExisting = null, onDeleteNew = null, onPreview = null) {
|
||||
super();
|
||||
this.existingAttachments = existingAttachments ?? [];
|
||||
this.newFiles = [];
|
||||
this.objectURLs = new Map();
|
||||
this.onDeleteExisting = onDeleteExisting;
|
||||
this.onDeleteNew = onDeleteNew;
|
||||
this.onPreview = onPreview;
|
||||
}
|
||||
|
||||
update(files) {
|
||||
this.newFiles.push(...Array.from(files));
|
||||
this.rerender();
|
||||
}
|
||||
|
||||
removeExisting(fileId) {
|
||||
this.existingAttachments = this.existingAttachments.filter(f => f.id !== fileId);
|
||||
this.rerender();
|
||||
}
|
||||
|
||||
removeNew(index) {
|
||||
const file = this.newFiles[index];
|
||||
if (this.objectURLs.has(file)) {
|
||||
URL.revokeObjectURL(this.objectURLs.get(file));
|
||||
this.objectURLs.delete(file);
|
||||
}
|
||||
this.newFiles.splice(index, 1);
|
||||
this.rerender();
|
||||
}
|
||||
|
||||
commitNew(insertedFiles) {
|
||||
this.newFiles.forEach(file => {
|
||||
if (this.objectURLs.has(file)) {
|
||||
URL.revokeObjectURL(this.objectURLs.get(file));
|
||||
this.objectURLs.delete(file);
|
||||
}
|
||||
});
|
||||
this.newFiles = [];
|
||||
this.existingAttachments = [...this.existingAttachments, ...insertedFiles];
|
||||
this.rerender();
|
||||
}
|
||||
|
||||
getObjectURL(file) {
|
||||
if (!this.objectURLs.has(file)) {
|
||||
this.objectURLs.set(file, URL.createObjectURL(file));
|
||||
}
|
||||
return this.objectURLs.get(file);
|
||||
}
|
||||
|
||||
render() {
|
||||
const hasFiles = this.existingAttachments.length > 0 || this.newFiles.length > 0;
|
||||
this.style.display = hasFiles ? "flex" : "none";
|
||||
this.style.padding = "1em";
|
||||
this.style.paddingTop = "0.5em";
|
||||
this.style.boxSizing = "border-box";
|
||||
|
||||
VStack(() => {
|
||||
this.existingAttachments.forEach(file => {
|
||||
const isImage = file.type?.startsWith("image/");
|
||||
const url = `${config.SERVER}/db/images/events/${file.name}`
|
||||
const row = HStack(() => {
|
||||
if (isImage) {
|
||||
img(`${config.UI}/db/images/events/${file.name}`, "1.5em", "1.5em")
|
||||
.objectFit("cover")
|
||||
.borderRadius(3, px)
|
||||
.flexShrink(0)
|
||||
} else {
|
||||
span("📎")
|
||||
}
|
||||
span(file.original_name ?? file.name)
|
||||
.overflow("hidden")
|
||||
.textOverflow("ellipsis")
|
||||
.whiteSpace("nowrap")
|
||||
.flex(1)
|
||||
|
||||
span(file.size_bytes != null ? `${(file.size_bytes / 1024).toFixed(1)} KB` : "")
|
||||
.opacity(0.5)
|
||||
.flexShrink(0)
|
||||
.fontSize(0.85, rem)
|
||||
.width("5em")
|
||||
|
||||
if (this.onDeleteExisting) {
|
||||
span("×")
|
||||
.color("var(--quillred)")
|
||||
.opacity(0.7)
|
||||
.fontSize(1.2, em)
|
||||
.fontWeight("600")
|
||||
.flexShrink(0)
|
||||
.cursor("pointer")
|
||||
.padding(0.5, em)
|
||||
.margin(-0.5, em)
|
||||
.onTap((e) => { e?.stopPropagation(); if (window.isMobile() === true) this.onDeleteExisting(file.id) })
|
||||
.onClick((done, e) => { e?.stopPropagation(); if (done && window.isMobile() === false) this.onDeleteExisting(file.id) })
|
||||
}
|
||||
})
|
||||
.alignItems("center")
|
||||
.gap(0.5, em)
|
||||
if (this.onPreview) {
|
||||
row.cursor("pointer")
|
||||
.onClick((done) => { if (done) this.onPreview(file, url) })
|
||||
}
|
||||
})
|
||||
|
||||
this.newFiles.forEach((file, index) => {
|
||||
const isImage = file.type.startsWith("image/");
|
||||
const url = this.getObjectURL(file)
|
||||
const row = HStack(() => {
|
||||
if (isImage) {
|
||||
img(url, "1.5em", "1.5em")
|
||||
.objectFit("cover")
|
||||
.borderRadius(3, px)
|
||||
.flexShrink(0)
|
||||
} else {
|
||||
span("📎")
|
||||
}
|
||||
span(file.name)
|
||||
.overflow("hidden")
|
||||
.textOverflow("ellipsis")
|
||||
.whiteSpace("nowrap")
|
||||
.flex(1)
|
||||
|
||||
span(`${(file.size / 1024).toFixed(1)} KB`)
|
||||
.opacity(0.5)
|
||||
.flexShrink(0)
|
||||
.fontSize(0.85, rem)
|
||||
.width("5em")
|
||||
|
||||
if (this.onDeleteNew) {
|
||||
span("×")
|
||||
.color("var(--quillred)")
|
||||
.opacity(0.7)
|
||||
.fontSize(1.2, em)
|
||||
.fontWeight("600")
|
||||
.flexShrink(0)
|
||||
.cursor("pointer")
|
||||
.padding(0.5, em)
|
||||
.margin(-0.5, em)
|
||||
.onTap((e) => { e?.stopPropagation(); if (window.isMobile() === true) this.onDeleteNew(index) })
|
||||
.onClick((done, e) => { e?.stopPropagation(); if (done && window.isMobile() === false) this.onDeleteNew(index) })
|
||||
}
|
||||
})
|
||||
.alignItems("center")
|
||||
.gap(0.5, em)
|
||||
if (this.onPreview) {
|
||||
row.cursor("pointer")
|
||||
.onClick((done) => { if (done) this.onPreview(file, url) })
|
||||
}
|
||||
})
|
||||
})
|
||||
.gap(1, em)
|
||||
.color("var(--text)")
|
||||
.fontSize(0.85, rem)
|
||||
.fontFamily("Arial")
|
||||
}
|
||||
}
|
||||
register(EventFileList)
|
||||
612
calendar/Events/EventDetails.js
Normal file
@@ -0,0 +1,612 @@
|
||||
import server from "/@server/server.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
|
||||
|
||||
const isOwner = this.event?.creator_id === global.profile?.id;
|
||||
|
||||
VStack(() => {
|
||||
this.renderHeader(isOwner)
|
||||
|
||||
// ── 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(isOwner) {
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
BackButton(false, true, () => $("bottomsheet-").toggle())
|
||||
if (isOwner) {
|
||||
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)
|
||||
1156
calendar/Events/EventForm.js
Normal file
298
calendar/Events/FilePreview.js
Normal file
@@ -0,0 +1,298 @@
|
||||
css(`
|
||||
filepreview- {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 300;
|
||||
pointer-events: none;
|
||||
font-family: 'Arial';
|
||||
}
|
||||
`)
|
||||
|
||||
class FilePreview extends Shadow {
|
||||
_file = null
|
||||
_url = null
|
||||
_panelEl = null
|
||||
|
||||
open(file, url) {
|
||||
this._file = file
|
||||
this._url = url
|
||||
this.rerender()
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (this._panelEl) {
|
||||
this._panelEl.style.transition = "transform 0.35s cubic-bezier(0.32, 0.72, 0, 1)"
|
||||
this._panelEl.style.transform = "translateY(0)"
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this._panelEl) return
|
||||
this._panelEl.style.transition = "transform 0.3s cubic-bezier(0.32, 0.72, 0, 1)"
|
||||
this._panelEl.style.transform = "translateY(100%)"
|
||||
setTimeout(() => {
|
||||
this._file = null
|
||||
this._url = null
|
||||
this._panelEl = null
|
||||
this.rerender()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this._file) {
|
||||
this.style.pointerEvents = "none"
|
||||
return
|
||||
}
|
||||
this.style.pointerEvents = "all"
|
||||
|
||||
const type = this._file?.type ?? ""
|
||||
const isImage = type.startsWith("image/")
|
||||
const isPDF = type === "application/pdf"
|
||||
const displayName = this._file?.original_name ?? this._file?.name ?? "File"
|
||||
|
||||
this._panelEl = VStack(() => {
|
||||
this._renderHeader(displayName, isImage)
|
||||
|
||||
if (isImage) {
|
||||
this._renderImageViewer()
|
||||
} else if (isPDF) {
|
||||
this._renderPDFViewer()
|
||||
} else {
|
||||
this._renderFileFallback(displayName)
|
||||
}
|
||||
})
|
||||
.position("fixed")
|
||||
.inset(0)
|
||||
.background(isImage ? "#000" : "var(--main)")
|
||||
.transform("translateY(100%)")
|
||||
|
||||
this._addPanelSwipe()
|
||||
}
|
||||
|
||||
_renderHeader(displayName, isImage) {
|
||||
HStack(() => {
|
||||
button("⬇")
|
||||
.fontSize(1.15, em)
|
||||
.color("var(--headertext)")
|
||||
.background("transparent")
|
||||
.border("none")
|
||||
.outline("none")
|
||||
.padding(0)
|
||||
.paddingLeft(1, rem)
|
||||
.flexShrink(0)
|
||||
.cursor("pointer")
|
||||
.onTap(() => window.open(this._url, "_blank"))
|
||||
|
||||
p(displayName)
|
||||
.margin(0)
|
||||
.flex(1)
|
||||
.fontSize(0.88, em)
|
||||
.fontWeight("600")
|
||||
.color("var(--headertext)")
|
||||
.overflow("hidden")
|
||||
.whiteSpace("nowrap")
|
||||
.textOverflow("ellipsis")
|
||||
.textAlign("center")
|
||||
|
||||
button("Done")
|
||||
.fontSize(0.88, em)
|
||||
.fontWeight("600")
|
||||
.color("var(--quillred)")
|
||||
.background("transparent")
|
||||
.border("none")
|
||||
.outline("none")
|
||||
.padding(0)
|
||||
.paddingRight(1, rem)
|
||||
.flexShrink(0)
|
||||
.cursor("pointer")
|
||||
.onTap(() => this.close())
|
||||
})
|
||||
.width(100, pct)
|
||||
.height(52, px)
|
||||
.gap(0.75, rem)
|
||||
.alignItems("center")
|
||||
.justifyContent("center")
|
||||
.background(util.darkMode() ? "var(--darkaccent)" : "var(--sidebottombars)")
|
||||
.borderBottom("1px solid var(--divider)")
|
||||
.boxSizing("border-box")
|
||||
.flexShrink(0)
|
||||
}
|
||||
|
||||
_renderImageViewer() {
|
||||
let imgEl = null
|
||||
|
||||
const container = VStack(() => {
|
||||
imgEl = img(this._url, "auto", "auto")
|
||||
.display("block")
|
||||
.maxWidth(100, pct)
|
||||
.maxHeight(100, pct)
|
||||
.objectFit("contain")
|
||||
.pointerEvents("none")
|
||||
})
|
||||
.flex(1)
|
||||
.width(100, pct)
|
||||
.alignItems("center")
|
||||
.justifyContent("center")
|
||||
.overflow("hidden")
|
||||
|
||||
if (imgEl) this._setupImageGestures(container, imgEl)
|
||||
}
|
||||
|
||||
_setupImageGestures(container, imgEl) {
|
||||
let scale = 1, tx = 0, ty = 0
|
||||
let pinchStartDist = null
|
||||
let panStartX = 0, panStartY = 0
|
||||
let swipeStartY = null, swipeStartTime = null
|
||||
let lastTap = 0
|
||||
|
||||
container.style.touchAction = "none"
|
||||
|
||||
const applyTransform = (animated = false) => {
|
||||
imgEl.style.transition = animated ? "transform 0.3s ease" : ""
|
||||
imgEl.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`
|
||||
}
|
||||
|
||||
const resetZoom = () => {
|
||||
scale = 1; tx = 0; ty = 0
|
||||
applyTransform(true)
|
||||
}
|
||||
|
||||
container.addEventListener("touchstart", (e) => {
|
||||
e.stopPropagation()
|
||||
if (e.touches.length === 2) {
|
||||
pinchStartDist = Math.hypot(
|
||||
e.touches[1].clientX - e.touches[0].clientX,
|
||||
e.touches[1].clientY - e.touches[0].clientY
|
||||
)
|
||||
swipeStartY = null
|
||||
} else if (e.touches.length === 1) {
|
||||
panStartX = e.touches[0].clientX - tx
|
||||
panStartY = e.touches[0].clientY - ty
|
||||
if (scale <= 1) {
|
||||
swipeStartY = e.touches[0].clientY
|
||||
swipeStartTime = Date.now()
|
||||
}
|
||||
}
|
||||
}, { passive: true })
|
||||
|
||||
container.addEventListener("touchmove", (e) => {
|
||||
if (e.touches.length === 2 && pinchStartDist !== null) {
|
||||
const dist = Math.hypot(
|
||||
e.touches[1].clientX - e.touches[0].clientX,
|
||||
e.touches[1].clientY - e.touches[0].clientY
|
||||
)
|
||||
scale = Math.min(Math.max(scale * (dist / pinchStartDist), 1), 6)
|
||||
pinchStartDist = dist
|
||||
applyTransform()
|
||||
} else if (e.touches.length === 1 && scale > 1) {
|
||||
tx = e.touches[0].clientX - panStartX
|
||||
ty = e.touches[0].clientY - panStartY
|
||||
applyTransform()
|
||||
} else if (e.touches.length === 1 && swipeStartY !== null) {
|
||||
const dy = e.touches[0].clientY - swipeStartY
|
||||
if (dy > 5 && this._panelEl) {
|
||||
this._panelEl.style.transition = ""
|
||||
this._panelEl.style.transform = `translateY(${Math.max(0, dy)}px)`
|
||||
}
|
||||
}
|
||||
}, { passive: true })
|
||||
|
||||
container.addEventListener("touchend", (e) => {
|
||||
if (e.touches.length < 2) pinchStartDist = null
|
||||
|
||||
if (swipeStartY !== null) {
|
||||
const dy = e.changedTouches[0].clientY - swipeStartY
|
||||
const vel = dy / Math.max(1, Date.now() - swipeStartTime)
|
||||
if (dy > window.innerHeight * 0.25 || vel > 0.5) {
|
||||
this.close()
|
||||
} else if (this._panelEl) {
|
||||
this._panelEl.style.transition = "transform 0.3s ease"
|
||||
this._panelEl.style.transform = "translateY(0)"
|
||||
}
|
||||
swipeStartY = null
|
||||
}
|
||||
|
||||
if (scale < 1) resetZoom()
|
||||
|
||||
const now = Date.now()
|
||||
if (e.changedTouches.length === 1 && now - lastTap < 300) {
|
||||
scale > 1 ? resetZoom() : (() => { scale = 2.5; applyTransform(true) })()
|
||||
}
|
||||
lastTap = now
|
||||
}, { passive: true })
|
||||
}
|
||||
|
||||
_addPanelSwipe() {
|
||||
let startY = null, startTime = null
|
||||
|
||||
this._panelEl.addEventListener("touchstart", (e) => {
|
||||
if (e.touches.length !== 1) { startY = null; return }
|
||||
startY = e.touches[0].clientY
|
||||
startTime = Date.now()
|
||||
}, { passive: true })
|
||||
|
||||
this._panelEl.addEventListener("touchmove", (e) => {
|
||||
if (startY === null || e.touches.length !== 1) return
|
||||
const dy = e.touches[0].clientY - startY
|
||||
if (dy > 0 && this._panelEl) {
|
||||
this._panelEl.style.transition = ""
|
||||
this._panelEl.style.transform = `translateY(${dy}px)`
|
||||
}
|
||||
}, { passive: true })
|
||||
|
||||
this._panelEl.addEventListener("touchend", (e) => {
|
||||
if (startY === null) return
|
||||
const dy = e.changedTouches[0].clientY - startY
|
||||
const vel = dy / Math.max(1, Date.now() - startTime)
|
||||
if (dy > window.innerHeight * 0.25 || vel > 0.5) {
|
||||
this.close()
|
||||
} else if (this._panelEl) {
|
||||
this._panelEl.style.transition = "transform 0.3s ease"
|
||||
this._panelEl.style.transform = "translateY(0)"
|
||||
}
|
||||
startY = null
|
||||
}, { passive: true })
|
||||
}
|
||||
|
||||
_renderPDFViewer() {
|
||||
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>`
|
||||
}
|
||||
|
||||
_renderFileFallback(displayName) {
|
||||
const type = this._file?.type ?? ""
|
||||
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, "Open / Download")
|
||||
.attr({ download: displayName })
|
||||
.fontSize(0.9, em)
|
||||
.color("var(--quillred)")
|
||||
.fontWeight("600")
|
||||
.textDecoration("none")
|
||||
.marginTop(0.5, em)
|
||||
})
|
||||
.flex(1)
|
||||
.alignItems("center")
|
||||
.justifyContent("center")
|
||||
.gap(0.75, em)
|
||||
}
|
||||
}
|
||||
|
||||
register(FilePreview)
|
||||
219
calendar/Month/MonthGrid.js
Normal file
@@ -0,0 +1,219 @@
|
||||
import calendarUtil from "../calendarUtil.js";
|
||||
|
||||
css(`
|
||||
monthgrid- {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
monthgrid-::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
`)
|
||||
|
||||
class MonthGrid extends Shadow {
|
||||
constructor(weeks, calendars, onDayTap = null) {
|
||||
super()
|
||||
this.weeks = weeks;
|
||||
this.calendars = calendars;
|
||||
this.onDayTap = onDayTap;
|
||||
this.maxVisible = 3; // caps both rendering and row height calculation
|
||||
|
||||
// em
|
||||
this.dateFontSize = 1.2;
|
||||
this.dateLineHeight = 1;
|
||||
this.paddingTop = 1.5;
|
||||
this.paddingBottom = 0.55;
|
||||
|
||||
// em
|
||||
this.pillHeight = 1.15;
|
||||
this.pillGap = 0.2;
|
||||
this.rowBottomPadding = 0.2;
|
||||
|
||||
this.headerHeight = this.paddingTop + (this.dateFontSize * this.dateLineHeight) + this.paddingBottom;
|
||||
this.rowHeight = this.headerHeight + this.maxVisible * (this.pillHeight + this.pillGap) + this.rowBottomPadding;
|
||||
}
|
||||
|
||||
render() {
|
||||
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)
|
||||
.flex(1)
|
||||
.overflowY("scroll")
|
||||
}
|
||||
|
||||
renderWeekRow(week, isLastWeek) {
|
||||
ZStack(() => {
|
||||
this.renderCellLayer(week, isLastWeek)
|
||||
this.renderPillLayer(week)
|
||||
})
|
||||
.position("relative")
|
||||
.width(100, pct)
|
||||
.height(this.rowHeight + 0.5, 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, isLast, 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")
|
||||
.paddingHorizontal(0.2, em)
|
||||
.paddingVertical(0.125, em)
|
||||
.borderRadius(25, pct)
|
||||
.textAlign("center")
|
||||
.opacity(isCurrentMonth ? 1 : 0)
|
||||
.lineHeight(`${this.dateLineHeight}`)
|
||||
|
||||
})
|
||||
.position("relative")
|
||||
.justifyContent("center")
|
||||
.paddingTop(this.paddingTop, em)
|
||||
.paddingHorizontal(0.4, em)
|
||||
.paddingBottom(this.paddingBottom, em)
|
||||
})
|
||||
.flex(1)
|
||||
.width(0, px)
|
||||
.minWidth(0)
|
||||
.height(100, pct)
|
||||
.borderBottom(isLastWeek ? "1px solid transparent" : "0.5px solid var(--divider)")
|
||||
.boxSizing("border-box")
|
||||
.overflow("hidden")
|
||||
.alignItems("stretch")
|
||||
.cursor("pointer")
|
||||
.onTap(() => { this.onDayTap(day) })
|
||||
}
|
||||
|
||||
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) => {
|
||||
if (!dayData.isCurrentMonth) return;
|
||||
const overflow = Math.max(0, dayData.events.length - this.maxVisible);
|
||||
if (overflow === 0) return;
|
||||
|
||||
const leftPct = (col / 7) * 100;
|
||||
|
||||
p(`+${overflow} more`)
|
||||
.margin(0)
|
||||
.fontSize(0.62, em)
|
||||
.fontWeight("600")
|
||||
.color("var(--headertext)")
|
||||
.opacity(0.5)
|
||||
.position("absolute")
|
||||
.bottom(this.rowBottomPadding, em)
|
||||
.left(leftPct, pct)
|
||||
.width(100 / 7, pct)
|
||||
.paddingHorizontal(0.4, em)
|
||||
.zIndex(2)
|
||||
})
|
||||
})
|
||||
.position("absolute")
|
||||
.top(0).left(0).right(0).bottom(0)
|
||||
.pointerEvents("none")
|
||||
}
|
||||
|
||||
renderPill({ startCol, endCol, event }, week, row) {
|
||||
const color = 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 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 colWidthPct = 100 / 7;
|
||||
const leftInsetPct = clippedLeft ? 0 : 0.025 * colWidthPct;
|
||||
const rightInsetPct = clippedRight ? 0 : 0.025 * colWidthPct;
|
||||
|
||||
const brLeft = clippedLeft ? 0 : 5;
|
||||
const brRight = clippedRight ? 0 : 5;
|
||||
|
||||
HStack(() => {
|
||||
p(event.title || "Untitled")
|
||||
.margin(0)
|
||||
.fontSize(0.9, em)
|
||||
.fontWeight("600")
|
||||
.color("white")
|
||||
.whiteSpace("nowrap")
|
||||
.overflow("hidden")
|
||||
})
|
||||
.position("absolute")
|
||||
.top(topEm, em)
|
||||
.left(leftPct + leftInsetPct, pct)
|
||||
.width(widthPct - leftInsetPct - rightInsetPct, pct)
|
||||
.height(this.pillHeight, em)
|
||||
.paddingHorizontal(0.4, 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("auto")
|
||||
.cursor("pointer")
|
||||
.onTap(() => $("bottomsheet-").showEvent(event))
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Only render spans that touch at least one current-month day
|
||||
return spans.filter(span => {
|
||||
for (let col = span.startCol; col <= span.endCol; col++) {
|
||||
if (week.days[col]?.isCurrentMonth) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
register(MonthGrid)
|
||||
31
calendar/Month/MonthHeaderRow.js
Normal file
@@ -0,0 +1,31 @@
|
||||
class MonthHeaderRow extends Shadow {
|
||||
constructor(weekStartsOn) {
|
||||
super()
|
||||
this.weekStartsOn = weekStartsOn;
|
||||
}
|
||||
|
||||
render() {
|
||||
const dayNames = ["S", "M", "T", "W", "T", "F", "S"];
|
||||
const ordered = Array.from({ length: 7}, (_, i) => dayNames[(this.weekStartsOn + i) % 7]);
|
||||
|
||||
HStack(() => {
|
||||
ordered.forEach(name => {
|
||||
p(name)
|
||||
.margin(0)
|
||||
.fontSize(.8, em)
|
||||
.fontWeight("500")
|
||||
.letterSpacing(0.04, em)
|
||||
.color("var(--headertext)")
|
||||
.opacity(0.5)
|
||||
.flex(1)
|
||||
.textAlign("center")
|
||||
.paddingVertical(0.6, em)
|
||||
.boxSizing("border-box")
|
||||
})
|
||||
})
|
||||
.width(100, pct)
|
||||
.borderBottom("1px solid var(--divider)")
|
||||
}
|
||||
}
|
||||
|
||||
register(MonthHeaderRow)
|
||||
137
calendar/Month/MonthView.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import calendarUtil from "../calendarUtil.js"
|
||||
import "./MonthHeaderRow.js"
|
||||
import "./MonthGrid.js"
|
||||
|
||||
css(`
|
||||
monthview- {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
`)
|
||||
|
||||
class MonthView extends Shadow {
|
||||
constructor(calendars, events, currentDate, weekStartsOn = 0, onDayTap = null) {
|
||||
super()
|
||||
this.calendars = calendars;
|
||||
this.events = events;
|
||||
this.currentDate = currentDate;
|
||||
this.weekStartsOn = weekStartsOn;
|
||||
this.onDayTap = onDayTap;
|
||||
}
|
||||
|
||||
render() {
|
||||
const weeks = this.buildMonthWeeks();
|
||||
|
||||
VStack(() => {
|
||||
MonthHeaderRow(this.weekStartsOn)
|
||||
MonthGrid(weeks, this.calendars, this.onDayTap)
|
||||
})
|
||||
.width(100, pct)
|
||||
.height(100, pct)
|
||||
.boxSizing("border-box")
|
||||
.fontSize(0.9, em)
|
||||
}
|
||||
|
||||
buildMonthWeeks() {
|
||||
const month = this.currentDate.getMonth();
|
||||
|
||||
const allDays = calendarUtil.buildMonthGrid(this.currentDate, this.weekStartsOn);
|
||||
const gridStart = allDays[0];
|
||||
|
||||
// Split into weeks
|
||||
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 expanded = calendarUtil.expandRecurringEvents(this.events, gridStart, gridEnd);
|
||||
const relevantEvents = expanded.filter(event =>
|
||||
calendarUtil.rangesOverlap(event.time_start, event.time_end, gridStart, gridEnd)
|
||||
&& this.calendars.some(c => event.calendars?.some(id => id === c.id))
|
||||
);
|
||||
|
||||
// Build week data with slot maps (for spanning event row alignment)
|
||||
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]);
|
||||
|
||||
// Events that appear in this week
|
||||
const weekEvents = events.filter(event =>
|
||||
calendarUtil.rangesOverlap(event.time_start, event.time_end, weekStart, weekEnd)
|
||||
);
|
||||
|
||||
// Sort: all-day / multi-day first, then timed; then by start
|
||||
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;
|
||||
});
|
||||
|
||||
// slotRows[row] = array of 7 entries (null or event)
|
||||
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));
|
||||
|
||||
// Find first slot row where all cols [startCol..endCol] are free
|
||||
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
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Transpose: slotMap[colIndex] = ordered list of slot entries (or null gaps)
|
||||
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;
|
||||
}
|
||||
// Clamp to week boundaries for events that start/end outside the week
|
||||
return date.getTime() < week[0].getTime() ? 0 : 6;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
register(MonthView)
|
||||
126
calendar/Toolbar/BottomBar.js
Normal file
@@ -0,0 +1,126 @@
|
||||
css(`
|
||||
bottombar- {
|
||||
pointer-events: none;
|
||||
}
|
||||
bottombar- select {
|
||||
-webkit-appearance: none;
|
||||
text-align-last: center;
|
||||
-webkit-text-align-last: center;
|
||||
}
|
||||
`)
|
||||
|
||||
class BottomBar extends Shadow {
|
||||
baseUrl = `${config.UI}/apps/calendar/icons`
|
||||
|
||||
constructor(callbacks = {}) {
|
||||
super()
|
||||
this.onAddEvent = callbacks.onAddEvent;
|
||||
this.onCalendarOptions = callbacks.onCalendarOptions;
|
||||
this.onChangeView = callbacks.onChangeView;
|
||||
this.viewMode = callbacks.viewMode;
|
||||
this.hideViewSelect = callbacks.hideViewSelect ?? false;
|
||||
}
|
||||
|
||||
getImageURL(iconName) {
|
||||
let imgUrl = `${this.baseUrl}/${iconName}`
|
||||
if(util.darkMode()) {
|
||||
imgUrl += "light"
|
||||
}
|
||||
imgUrl += ".svg"
|
||||
imgUrl = imgUrl.toLowerCase()
|
||||
return imgUrl
|
||||
}
|
||||
|
||||
render() {
|
||||
HStack(() => {
|
||||
// Left: view mode select
|
||||
if (!this.hideViewSelect) select(() => {
|
||||
["day", "week", "month"].forEach(mode => {
|
||||
const o = option(mode.charAt(0).toUpperCase() + mode.slice(1))
|
||||
.attr({ value: mode })
|
||||
if (mode === this.viewMode) o.attr({ selected: true })
|
||||
})
|
||||
})
|
||||
.fontSize(1, em)
|
||||
.paddingVertical(0.6, em)
|
||||
.paddingHorizontal(0.75, em)
|
||||
.background("var(--button-color)")
|
||||
.backdropFilter("blur(10px)")
|
||||
.borderRadius(100, px)
|
||||
.pointerEvents("auto")
|
||||
.border(`.5px solid ${util.darkMode() ? "var(--accent)" : "transparent"}`)
|
||||
.color(util.darkMode() ? "var(--text)" : "white")
|
||||
.onTouch(function (start) {
|
||||
if(start) {
|
||||
this.background("var(--main)")
|
||||
} else {
|
||||
this.background("var(--button-color)")
|
||||
}
|
||||
})
|
||||
.fontFamily("Arial")
|
||||
.outline("none")
|
||||
.appearance("none")
|
||||
.textAlign("center")
|
||||
.onChange(e => this.onChangeView(e.target.value))
|
||||
|
||||
// Right: add event + calendar options
|
||||
HStack(() => {
|
||||
const addEventImg = img(this.getImageURL("addevent"), "1em", "1em")
|
||||
.paddingVertical(0.75, em).paddingHorizontal(1, em)
|
||||
.onTap(async () => {
|
||||
this.onAddEvent()
|
||||
addEventImg.src = `${this.baseUrl}/addeventlightselected.svg`
|
||||
const sheet = $("bottomsheet-")
|
||||
const revert = () => {
|
||||
if (!sheet.isOpen) {
|
||||
addEventImg.src = this.getImageURL("addevent")
|
||||
sheet.sheetEl.removeEventListener("transitionend", revert)
|
||||
}
|
||||
}
|
||||
sheet.sheetEl.addEventListener("transitionend", revert)
|
||||
})
|
||||
|
||||
const calendarSheetImg = img(this.getImageURL("calbutton"), "1.1em")
|
||||
.paddingVertical(0.75, em).paddingHorizontal(1, em)
|
||||
.onTap(async () => {
|
||||
this.onCalendarOptions()
|
||||
calendarSheetImg.src = `${this.baseUrl}/calbuttonlightselected.svg`
|
||||
const sheet = $("bottomsheet-")
|
||||
const revert = () => {
|
||||
if (!sheet.isOpen) {
|
||||
calendarSheetImg.src = this.getImageURL("calbutton")
|
||||
sheet.sheetEl.removeEventListener("transitionend", revert)
|
||||
}
|
||||
}
|
||||
sheet.sheetEl.addEventListener("transitionend", revert)
|
||||
})
|
||||
})
|
||||
.background("var(--button-color)")
|
||||
.pointerEvents("auto")
|
||||
.onTouch(function (start) {
|
||||
if(start) {
|
||||
this.background("var(--main)")
|
||||
} else {
|
||||
this.background("var(--button-color)")
|
||||
}
|
||||
})
|
||||
.backdropFilter("blur(10px)")
|
||||
.alignItems("center")
|
||||
.flexShrink(0)
|
||||
.borderRadius(100, px)
|
||||
.border(`0.5px solid ${util.darkMode() ? "var(--accent)" : "transparent"}`)
|
||||
.overflow("hidden")
|
||||
})
|
||||
.position("absolute")
|
||||
.left(4, vw)
|
||||
.bottom(0.75, em)
|
||||
.zIndex(2)
|
||||
.justifyContent(this.hideViewSelect ? "flex-end" : "space-between")
|
||||
.width(92, vw)
|
||||
.onEvent("themechange", () => {
|
||||
this.rerender()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
register(BottomBar)
|
||||
256
calendar/Toolbar/CalendarOptions.js
Normal file
@@ -0,0 +1,256 @@
|
||||
import "../../components/BackButton.js"
|
||||
import "../CalendarForm.js"
|
||||
|
||||
class CalendarOptions extends Shadow {
|
||||
baseUrl = `${config.UI}/apps/calendar/icons`
|
||||
|
||||
getImageURL(iconName) {
|
||||
let imgUrl = `${this.baseUrl}/${iconName}`
|
||||
if (util.darkMode()) imgUrl += "light"
|
||||
imgUrl += ".svg"
|
||||
return imgUrl.toLowerCase()
|
||||
}
|
||||
|
||||
constructor(calendars = [], selectedCalendars = [], actions) {
|
||||
super()
|
||||
this.calendars = calendars;
|
||||
this.selectedCalendars = selectedCalendars;
|
||||
this.onSelection = actions.onSelection;
|
||||
this.onCalendarAdded = actions.onCalendarAdded;
|
||||
this.onCalendarUpdated = actions.onCalendarUpdated;
|
||||
this.onCalendarDeleted = actions.onCalendarDeleted;
|
||||
}
|
||||
|
||||
isSelected(calendar) {
|
||||
return this.selectedCalendars.some(c => c.id === calendar.id);
|
||||
}
|
||||
|
||||
toggleCalendar(calendar) {
|
||||
const isSelected = this.isSelected(calendar);
|
||||
|
||||
if (isSelected && this.selectedCalendars.length === 1) return;
|
||||
|
||||
let newSelectedIds;
|
||||
if (isSelected) {
|
||||
newSelectedIds = this.selectedCalendars
|
||||
.filter(c => c.id !== calendar.id)
|
||||
.map(c => c.id);
|
||||
} else {
|
||||
newSelectedIds = [...this.selectedCalendars.map(c => c.id), calendar.id];
|
||||
}
|
||||
|
||||
this.selectedCalendars = this.calendars.filter(c => newSelectedIds.includes(c.id));
|
||||
this.rerender();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.calFormSheet = BottomSheet(200)
|
||||
|
||||
const doClose = () => {
|
||||
$("bottomsheet-")._closeOverride = null
|
||||
$("bottomsheet-").setSheet(false)
|
||||
setTimeout(() => this.onSelection(this.selectedCalendars), 300)
|
||||
}
|
||||
$("bottomsheet-")._closeOverride = doClose
|
||||
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
BackButton(false, true, doClose)
|
||||
.zIndex(3)
|
||||
|
||||
h2("Calendars")
|
||||
.position("absolute")
|
||||
.zIndex(2)
|
||||
.color("var(--headertext)")
|
||||
.paddingVertical(0.5, em)
|
||||
.margin(0)
|
||||
.top(0)
|
||||
.left(0)
|
||||
.width(100, pct)
|
||||
})
|
||||
.position("relative")
|
||||
.background(util.darkMode() ? "var(--darkred)" : "var(--sidebottombars)")
|
||||
.borderTopLeftRadius("10px").borderTopRightRadius("10px")
|
||||
.border("1px solid var(--divider)")
|
||||
.textAlign("center")
|
||||
|
||||
if (this.calendars.length > 0) {
|
||||
h3("Visibility")
|
||||
.marginBottom(0)
|
||||
.marginLeft(1.25, em)
|
||||
|
||||
VStack(() => {
|
||||
this.calendars.forEach(cal => {
|
||||
const selected = this.isSelected(cal);
|
||||
|
||||
HStack(() => {
|
||||
HStack(() => {
|
||||
p("")
|
||||
.width(2, em)
|
||||
.height(2, em)
|
||||
.background(selected ? cal.color : "transparent")
|
||||
.borderRadius(100, pct)
|
||||
.border(`3.5px solid ${cal.color}`)
|
||||
.boxSizing("border-box")
|
||||
p(cal.name)
|
||||
.fontSize(1.1, em)
|
||||
.opacity(selected ? 1 : 0.5)
|
||||
})
|
||||
.gap(1, em)
|
||||
.alignItems("center")
|
||||
.flex(1)
|
||||
.cursor("pointer")
|
||||
.onTap(async () => {
|
||||
await capacitor.Haptics.impact({ style: capacitor.ImpactStyle.Light });
|
||||
this.toggleCalendar(cal)
|
||||
})
|
||||
|
||||
p("i")
|
||||
.width(1.5, em)
|
||||
.height(1.5, em)
|
||||
.borderRadius(100, pct)
|
||||
.border("2px solid var(--text)")
|
||||
.boxSizing("border-box")
|
||||
.display(cal.owner_id === global.profile.id ? "" : "none")
|
||||
.textAlign("center")
|
||||
.lineHeight("1.4em")
|
||||
.fontSize(1.3, em)
|
||||
.fontWeight("bold")
|
||||
.color("var(--text)")
|
||||
.opacity(0.5)
|
||||
.flexShrink(0)
|
||||
.cursor("pointer")
|
||||
.onTap(async () => {
|
||||
await capacitor.Haptics.impact({ style: capacitor.ImpactStyle.Light });
|
||||
let calFormEl
|
||||
const closeCalForm = () => {
|
||||
this.calFormSheet._closeOverride = null
|
||||
this.calFormSheet.setSheet(false)
|
||||
}
|
||||
const onSaveErrorEdit = () => {
|
||||
this.calFormSheet._closeOverride = () => this.calFormSheet.forceClose()
|
||||
}
|
||||
this.calFormSheet.show(() => {
|
||||
calFormEl = CalendarForm(
|
||||
closeCalForm,
|
||||
(updated) => {
|
||||
closeCalForm()
|
||||
setTimeout(() => {
|
||||
this.calendars = this.calendars.map(c => c.id === updated.id ? updated : c);
|
||||
this.selectedCalendars = this.selectedCalendars.map(c => c.id === updated.id ? updated : c);
|
||||
this.onCalendarUpdated(updated);
|
||||
this.rerender();
|
||||
}, 300);
|
||||
},
|
||||
cal,
|
||||
(deletedId) => {
|
||||
closeCalForm()
|
||||
setTimeout(() => {
|
||||
this.calendars = this.calendars.filter(c => c.id !== deletedId);
|
||||
this.selectedCalendars = this.selectedCalendars.filter(c => c.id !== deletedId);
|
||||
this.onCalendarDeleted(deletedId);
|
||||
this.rerender();
|
||||
}, 300);
|
||||
},
|
||||
onSaveErrorEdit
|
||||
).height(100, pct)
|
||||
})
|
||||
this.calFormSheet._closeOverride = async () => {
|
||||
const saved = await calFormEl?.trySave()
|
||||
if (saved === null) {
|
||||
this.calFormSheet.setSheet(true)
|
||||
this.calFormSheet._closeOverride = () => this.calFormSheet.forceClose()
|
||||
return
|
||||
}
|
||||
closeCalForm()
|
||||
if (saved !== cal) {
|
||||
setTimeout(() => {
|
||||
this.calendars = this.calendars.map(c => c.id === saved.id ? saved : c)
|
||||
this.selectedCalendars = this.selectedCalendars.map(c => c.id === saved.id ? saved : c)
|
||||
this.onCalendarUpdated(saved)
|
||||
this.rerender()
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.padding(0.75, em)
|
||||
.gap(0.75, em)
|
||||
.alignItems("center")
|
||||
})
|
||||
})
|
||||
.background("var(--darkaccent)")
|
||||
.margin(1, em)
|
||||
.marginTop(0.5, em)
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(12, px)
|
||||
}
|
||||
|
||||
HStack(() => {
|
||||
img(this.getImageURL("calendar"), "1.5em", "1.5em")
|
||||
p("Add Calendar")
|
||||
.fontSize(1.1, em)
|
||||
.paddingLeft(0.5, em)
|
||||
})
|
||||
.background("var(--darkaccent)")
|
||||
.color("var(--text)")
|
||||
.gap(0.75, em)
|
||||
.alignItems("center")
|
||||
.padding(1, em)
|
||||
.paddingVertical(0.75, em)
|
||||
.marginHorizontal(1, em)
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(12, px)
|
||||
.cursor("pointer")
|
||||
.onTap(async () => {
|
||||
await capacitor.Haptics.impact({ style: capacitor.ImpactStyle.Light });
|
||||
let calFormEl
|
||||
const closeDirect = () => {
|
||||
this.calFormSheet._closeOverride = null
|
||||
this.calFormSheet.setSheet(false)
|
||||
}
|
||||
const onSaveErrorAdd = () => {
|
||||
this.calFormSheet._closeOverride = () => this.calFormSheet.forceClose()
|
||||
}
|
||||
this.calFormSheet.show(() => {
|
||||
calFormEl = CalendarForm(
|
||||
closeDirect,
|
||||
(calendar) => {
|
||||
closeDirect()
|
||||
setTimeout(() => {
|
||||
this.calendars = [...this.calendars, calendar]
|
||||
this.selectedCalendars = [...this.selectedCalendars, calendar]
|
||||
this.onCalendarAdded(calendar)
|
||||
this.rerender()
|
||||
}, 300)
|
||||
},
|
||||
null,
|
||||
null,
|
||||
onSaveErrorAdd
|
||||
).height(100, pct)
|
||||
})
|
||||
this.calFormSheet._closeOverride = async () => {
|
||||
const saved = await calFormEl?.trySave()
|
||||
if (saved === null) {
|
||||
this.calFormSheet.setSheet(true)
|
||||
this.calFormSheet._closeOverride = () => this.calFormSheet.forceClose()
|
||||
return
|
||||
}
|
||||
closeDirect()
|
||||
if (saved) {
|
||||
setTimeout(() => {
|
||||
this.calendars = [...this.calendars, saved]
|
||||
this.selectedCalendars = [...this.selectedCalendars, saved]
|
||||
this.onCalendarAdded(saved)
|
||||
this.rerender()
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.width(100, pct)
|
||||
.boxSizing("border-box")
|
||||
}
|
||||
}
|
||||
|
||||
register(CalendarOptions)
|
||||
123
calendar/Toolbar/CalendarToolbar.js
Normal file
@@ -0,0 +1,123 @@
|
||||
import calendarUtil from "../calendarUtil.js"
|
||||
import "./ToolbarPopout.js";
|
||||
|
||||
class CalendarToolbar extends Shadow {
|
||||
constructor(currentDate, weekStartsOn, actions, calendars, events, showPopout, options = {}) {
|
||||
super()
|
||||
this.currentDate = currentDate
|
||||
this.weekStartsOn = weekStartsOn
|
||||
this.goToPrevious = actions.goToPrevious;
|
||||
this.goToCurrent = actions.goToCurrent;
|
||||
this.goToNext = actions.goToNext;
|
||||
this.goToDate = actions.goToDate;
|
||||
this.calendars = calendars;
|
||||
this.events = events;
|
||||
this.showPopout = showPopout;
|
||||
this.onBack = options.onBack ?? null;
|
||||
this.viewModeOverride = options.viewModeOverride ?? null;
|
||||
}
|
||||
|
||||
render() {
|
||||
let popout;
|
||||
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
if (this.onBack) {
|
||||
BackButton(true, false, this.onBack)
|
||||
.position("absolute")
|
||||
.left(0.75, em)
|
||||
.bottom(0.2, em)
|
||||
.transform("scale(0.75)")
|
||||
.transformOrigin("left center")
|
||||
}
|
||||
|
||||
h2(this.getToolbarLabel(this.currentDate))
|
||||
.margin(0)
|
||||
.fontWeight("600")
|
||||
.color("var(--headertext)")
|
||||
.paddingLeft(this.onBack ? 2 : 0, em)
|
||||
.state("label", function (label) {
|
||||
if (label) {
|
||||
this.innerText = label;
|
||||
}
|
||||
})
|
||||
|
||||
HStack(() => {
|
||||
if (!window.isMobile()) {
|
||||
this.navButton("‹", this.goToPrevious)
|
||||
this.navButton("Today", this.goToCurrent)
|
||||
this.navButton("›", this.goToNext)
|
||||
}
|
||||
this.navButton("⊞", () => this.togglePopout(popout))
|
||||
})
|
||||
.gap(0.5, em)
|
||||
.alignItems("center")
|
||||
})
|
||||
.boxSizing("border-box")
|
||||
.width(100, pct)
|
||||
.alignItems("flex-end")
|
||||
.position("relative")
|
||||
.overflow("visible")
|
||||
.paddingBottom(0.9, em)
|
||||
.paddingHorizontal(1, em)
|
||||
.justifyContent("space-between")
|
||||
|
||||
popout = ToolbarPopout(this.currentDate, this.weekStartsOn, this.calendars, this.events, this.showPopout, (date) => this.goToDate(date), (date) => {
|
||||
this.$("h2").attr({ "label": this.getToolbarLabel(date)});
|
||||
});
|
||||
})
|
||||
.overflow("visible")
|
||||
.borderBottom("1px solid var(--main)")
|
||||
.width(100, pct)
|
||||
}
|
||||
|
||||
togglePopout(popout) {
|
||||
this.showPopout = !this.showPopout;
|
||||
$("calendar-").showPopout = this.showPopout;
|
||||
if (popout) {
|
||||
popout.showPopout = this.showPopout;
|
||||
popout.style.maxHeight = this.showPopout ? `${popout.layout.openHeight}em` : "0em";
|
||||
}
|
||||
if (!this.showPopout) {
|
||||
const newLabel = this.getToolbarLabel(this.currentDate)
|
||||
const oldLabel = this.$("h2").innerText;
|
||||
this.$("h2").attr({ "label": newLabel });
|
||||
if (newLabel !== oldLabel) {
|
||||
setTimeout(() => {
|
||||
this.rerender();
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
navButton(label, handler) {
|
||||
const isWide = label === "Today";
|
||||
return button(label)
|
||||
.onTap(handler)
|
||||
.paddingVertical(0.45, em)
|
||||
.fontSize(1, rem)
|
||||
.paddingHorizontal(isWide ? 0.9 : 0.8, em)
|
||||
.border("1px solid var(--desktop-item-border)")
|
||||
.borderRadius(0.6, em)
|
||||
.background("var(--desktop-item-background)")
|
||||
.color("var(--text)")
|
||||
.cursor("pointer");
|
||||
}
|
||||
|
||||
getToolbarLabel(date) {
|
||||
const viewMode = this.viewModeOverride ?? $("calendar-").viewMode;
|
||||
|
||||
if (viewMode === "week") {
|
||||
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
||||
}
|
||||
if (viewMode === "day") {
|
||||
return date.toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" });
|
||||
}
|
||||
if (viewMode === "month") {
|
||||
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
register(CalendarToolbar)
|
||||
519
calendar/Toolbar/ToolbarPopout.js
Normal file
@@ -0,0 +1,519 @@
|
||||
import calendarUtil from "../calendarUtil.js";
|
||||
|
||||
class ToolbarPopout extends Shadow {
|
||||
swipeTranslate = 0;
|
||||
isSwiping = false;
|
||||
swipeDragStartX = null;
|
||||
swipeDragStartY = null;
|
||||
swipeDragStartTime = null;
|
||||
swipeAxisLocked = false;
|
||||
swipeIsHorizontal = false;
|
||||
|
||||
SWIPE_COMMIT_DISTANCE = window.outerWidth * 0.25;
|
||||
SWIPE_VELOCITY_THRESHOLD = 0.4;
|
||||
|
||||
static DAY_NAMES = ["S", "M", "T", "W", "T", "F", "S"];
|
||||
|
||||
constructor(currentDate, weekStartsOn, calendars, events, showPopout, goToDate, onMonthChange) {
|
||||
super()
|
||||
this.currentDate = currentDate;
|
||||
this.weekStartsOn = weekStartsOn;
|
||||
this.calendars = calendars;
|
||||
this.events = events;
|
||||
|
||||
this.selectedDate = this.currentDate;
|
||||
this.showPopout = showPopout;
|
||||
this.goToDate = goToDate;
|
||||
this.onMonthChange = onMonthChange;
|
||||
|
||||
this.swipeDidMove = false;
|
||||
|
||||
if (this.miniCurrentDate === undefined) {
|
||||
this.miniCurrentDate = this.currentDate;
|
||||
}
|
||||
|
||||
this.layout = this.buildLayout();
|
||||
|
||||
// Caches
|
||||
this.monthCache = new Map();
|
||||
this._miniPanels = null;
|
||||
this._monthEventsVersion = 0;
|
||||
|
||||
this.buildDerivedData();
|
||||
}
|
||||
|
||||
buildLayout() {
|
||||
const fs = 0.75, hp = 0.4, dch = 1.55, dh = 0.28, dmt = 0.15, cp = 0.4;
|
||||
return {
|
||||
fontSize: fs,
|
||||
headerPadding: hp,
|
||||
headerRowHeight: fs + hp * 2,
|
||||
dateCircleHeight: dch,
|
||||
dotHeight: dh,
|
||||
dotMarginTop: dmt,
|
||||
cellPadding: cp,
|
||||
rowHeight: cp + dch + dh + dmt,
|
||||
get lastRowHeight() { return cp + dch; },
|
||||
get gridHeight() { return this.rowHeight * 5 + this.lastRowHeight; },
|
||||
get openHeight() { return this.headerRowHeight * fs + this.gridHeight; },
|
||||
};
|
||||
}
|
||||
|
||||
buildDerivedData() {
|
||||
this.colorByCalendarId = new Map(
|
||||
(this.calendars || []).map(calendar => [calendar.id, calendar.color])
|
||||
);
|
||||
|
||||
// Version string for invalidating month cache when events update
|
||||
this.eventsVersion = JSON.stringify(
|
||||
(this.events || []).map(ev => [
|
||||
ev.id,
|
||||
ev.calendars,
|
||||
ev.recurrence_id,
|
||||
+new Date(ev.time_start),
|
||||
+new Date(ev.time_end)
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
getMiniPanels() {
|
||||
if (!this._miniPanels) {
|
||||
this._miniPanels = this.$$("[data-mini-panel]");
|
||||
}
|
||||
return this._miniPanels;
|
||||
}
|
||||
|
||||
getMonthKey(date) {
|
||||
return [
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
this.weekStartsOn,
|
||||
this.eventsVersion
|
||||
].join("|");
|
||||
}
|
||||
|
||||
render() {
|
||||
const l = this.layout;
|
||||
const ordered = Array.from(
|
||||
{ length: 7 },
|
||||
(_, i) => ToolbarPopout.DAY_NAMES[(this.weekStartsOn + i) % 7]
|
||||
);
|
||||
const today = new Date();
|
||||
|
||||
// Remove stale cache
|
||||
this._miniPanels = null;
|
||||
|
||||
// Only render the current month panel
|
||||
// Previous/Next injected programmatically whenever user begins
|
||||
// swiping - touch event never interrupted by rerenders
|
||||
const panelOffsets = [0];
|
||||
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
ordered.forEach(name => {
|
||||
p(name)
|
||||
.margin(0)
|
||||
.fontSize(l.fontSize, em)
|
||||
.fontWeight("600")
|
||||
.color("var(--headertext)")
|
||||
.opacity(0.45)
|
||||
.flex(1)
|
||||
.textAlign("center")
|
||||
.paddingVertical(l.headerPadding, em)
|
||||
.boxSizing("border-box")
|
||||
})
|
||||
})
|
||||
.width(100, pct)
|
||||
|
||||
ZStack(() => {
|
||||
panelOffsets.forEach(offset => {
|
||||
const viewDate = calendarUtil.addMonths(this.miniCurrentDate, offset);
|
||||
const { weeks } = this.getMonthData(viewDate);
|
||||
|
||||
VStack(() => {
|
||||
weeks.forEach((week, wi) => {
|
||||
const isLast = wi === weeks.length - 1;
|
||||
|
||||
HStack(() => {
|
||||
week.forEach(({ day, isCurrentMonth, calendarColors }) => {
|
||||
const isToday = calendarUtil.isSameDay(day, today);
|
||||
const isSelected = calendarUtil.isSameDay(day, this.selectedDate);
|
||||
|
||||
VStack(() => {
|
||||
p(day.getDate())
|
||||
.margin(0)
|
||||
.fontSize(l.fontSize, em)
|
||||
.fontWeight(isSelected ? "700" : "400")
|
||||
.color(isSelected || isToday ? "white" : "var(--headertext)")
|
||||
.background(
|
||||
isToday
|
||||
? "var(--quillred)"
|
||||
: isSelected
|
||||
? "var(--lightaccent)"
|
||||
: "transparent"
|
||||
)
|
||||
.width(l.dateCircleHeight, em)
|
||||
.height(l.dateCircleHeight, em)
|
||||
.borderRadius(25, pct)
|
||||
.textAlign("center")
|
||||
.lineHeight(`${l.dateCircleHeight}em`)
|
||||
.opacity(isCurrentMonth ? 1 : 0.28)
|
||||
.boxSizing("border-box")
|
||||
|
||||
// Only show event dots if they exist
|
||||
if (calendarColors.length > 0) {
|
||||
HStack(() => {
|
||||
calendarColors.slice(0, 3).forEach(color => {
|
||||
VStack(() => {})
|
||||
.width(l.dotHeight, em)
|
||||
.height(l.dotHeight, em)
|
||||
.borderRadius(50, pct)
|
||||
.background(color)
|
||||
.flexShrink(0)
|
||||
})
|
||||
})
|
||||
.gap(0.18, em)
|
||||
.justifyContent("center")
|
||||
.alignItems("center")
|
||||
.marginTop(l.dotMarginTop, em)
|
||||
.height(l.dotHeight, em)
|
||||
} else {
|
||||
// Spacer for dot-less cells
|
||||
VStack(() => {})
|
||||
.marginTop(l.dotMarginTop, em)
|
||||
.height(l.dotHeight, em)
|
||||
}
|
||||
})
|
||||
.flex(1)
|
||||
.alignItems("center")
|
||||
.paddingTop(l.cellPadding, em)
|
||||
.paddingBottom(isLast ? 0 : l.cellPadding, em)
|
||||
.boxSizing("border-box")
|
||||
.onTap(() => {
|
||||
if (!this.swipeDidMove) {
|
||||
this.selectedDate = day;
|
||||
if (!this.goToDate(day)) {
|
||||
this.rerender();
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
.width(100, pct)
|
||||
.background("var(--minicalendarbackground)")
|
||||
})
|
||||
})
|
||||
.position("absolute")
|
||||
.width(100, pct)
|
||||
.height(100, pct)
|
||||
.transform(`translateX(${offset * 100}%)`)
|
||||
.willChange("transform")
|
||||
.attr({ "data-mini-panel": offset })
|
||||
})
|
||||
})
|
||||
.position("relative")
|
||||
.overflow("hidden")
|
||||
.width(100, pct)
|
||||
.height(l.gridHeight, em)
|
||||
.onTouch((start, e) => this.handleSwipeTouch(start, e))
|
||||
})
|
||||
.width(100, pct)
|
||||
.boxSizing("border-box")
|
||||
.overflow("hidden")
|
||||
.maxHeight(this.showPopout ? l.openHeight : 0, em)
|
||||
.transition("max-height 0.3s ease")
|
||||
}
|
||||
|
||||
// Build previous/next calendars and insert them without triggering a rerender
|
||||
injectSidePanels() {
|
||||
const currentPanel = this.$("[data-mini-panel='0']");
|
||||
if (!currentPanel) return;
|
||||
const container = currentPanel.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
// Already injected (shouldn't happen, but guard anyway).
|
||||
if (this.$("[data-mini-panel='-1']")) return;
|
||||
|
||||
const l = this.layout;
|
||||
const today = new Date();
|
||||
|
||||
[-1, 1].forEach(offset => {
|
||||
const viewDate = calendarUtil.addMonths(this.miniCurrentDate, offset);
|
||||
const { weeks } = this.getMonthData(viewDate);
|
||||
|
||||
// Outer panel div — mirrors the VStack styling applied in render().
|
||||
const panel = document.createElement("div");
|
||||
panel.classList.add("VStack");
|
||||
panel.setAttribute("data-mini-panel", offset);
|
||||
panel.style.cssText = `
|
||||
position: absolute; top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
display: flex; flex-direction: column;
|
||||
transform: translateX(${offset * 100}%);
|
||||
will-change: transform;
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
|
||||
weeks.forEach((week, wi) => {
|
||||
const isLast = wi === weeks.length - 1;
|
||||
const row = document.createElement("div");
|
||||
|
||||
row.classList.add("HStack");
|
||||
row.style.cssText = `
|
||||
display: flex; flex-direction: row;
|
||||
width: 100%;
|
||||
background: var(--minicalendarbackground);
|
||||
`;
|
||||
|
||||
week.forEach(({ day, isCurrentMonth, calendarColors }) => {
|
||||
const isToday = calendarUtil.isSameDay(day, today);
|
||||
const isSelected = calendarUtil.isSameDay(day, this.selectedDate);
|
||||
|
||||
const cell = document.createElement("div");
|
||||
cell.classList.add("VStack");
|
||||
cell.style.cssText = `
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: ${l.cellPadding}em;
|
||||
padding-bottom: ${isLast ? 0 : l.cellPadding}em;
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
|
||||
const circle = document.createElement("p");
|
||||
|
||||
circle.textContent = day.getDate();
|
||||
circle.style.cssText = `
|
||||
margin: 0;
|
||||
font-size: ${l.fontSize}em;
|
||||
font-weight: ${isSelected ? "700" : "400"};
|
||||
color: ${isSelected || isToday ? "white" : "var(--headertext)"};
|
||||
background: ${isToday ? "var(--quillred)" : isSelected ? "var(--lightaccent)" : "transparent"};
|
||||
width: ${l.dateCircleHeight}em;
|
||||
height: ${l.dateCircleHeight}em;
|
||||
border-radius: 25%;
|
||||
text-align: center;
|
||||
line-height: ${l.dateCircleHeight}em;
|
||||
opacity: ${isCurrentMonth ? 1 : 0.28};
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
cell.appendChild(circle);
|
||||
|
||||
const spacer = document.createElement("div");
|
||||
spacer.classList.add("HStack");
|
||||
spacer.style.cssText = `
|
||||
margin-top: ${l.dotMarginTop}em;
|
||||
height: ${l.dotHeight}em;
|
||||
display: flex; flex-direction: row;
|
||||
justify-content: center; align-items: center;
|
||||
gap: 0.18em;
|
||||
`;
|
||||
if (calendarColors.length > 0) {
|
||||
calendarColors.slice(0, 3).forEach(color => {
|
||||
const dot = document.createElement("div");
|
||||
|
||||
dot.classList.add("VStack");
|
||||
dot.style.cssText = `
|
||||
width: ${l.dotHeight}em; height: ${l.dotHeight}em;
|
||||
border-radius: 50%; background: ${color}; flex-shrink: 0;
|
||||
`;
|
||||
spacer.appendChild(dot);
|
||||
});
|
||||
}
|
||||
cell.appendChild(spacer);
|
||||
row.appendChild(cell);
|
||||
});
|
||||
|
||||
panel.appendChild(row);
|
||||
});
|
||||
|
||||
container.appendChild(panel);
|
||||
});
|
||||
|
||||
// Invalidate the panel cache so getMiniPanels() picks up the new nodes.
|
||||
this._miniPanels = null;
|
||||
}
|
||||
|
||||
handleSwipeTouch(start, e) {
|
||||
if (start) {
|
||||
this.swipeDragStartX = e.touches[0].clientX;
|
||||
this.swipeDragStartY = e.touches[0].clientY;
|
||||
this.swipeDragStartTime = Date.now();
|
||||
this.isSwiping = true;
|
||||
this.swipeDidMove = false;
|
||||
this.swipeAxisLocked = false;
|
||||
this.swipeIsHorizontal = false;
|
||||
|
||||
// Inject previous/next calendar
|
||||
this.injectSidePanels();
|
||||
|
||||
document.addEventListener("touchmove", this.onSwipeMove, { passive: true });
|
||||
} else {
|
||||
if (!this.isSwiping) return;
|
||||
document.removeEventListener("touchmove", this.onSwipeMove);
|
||||
|
||||
if (!this.swipeIsHorizontal) {
|
||||
this.isSwiping = false;
|
||||
this.swipeDragStartX = null;
|
||||
this.swipeDragStartY = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
const delta = endX - this.swipeDragStartX;
|
||||
const elapsed = Date.now() - this.swipeDragStartTime;
|
||||
const velocity = Math.abs(delta) / elapsed;
|
||||
const shouldCommit =
|
||||
Math.abs(delta) > this.SWIPE_COMMIT_DISTANCE ||
|
||||
velocity > this.SWIPE_VELOCITY_THRESHOLD;
|
||||
|
||||
if (shouldCommit && delta < 0) this.commitSwipe("next");
|
||||
else if (shouldCommit && delta > 0) this.commitSwipe("previous");
|
||||
else this.snapBack();
|
||||
|
||||
this.isSwiping = false;
|
||||
this.swipeDragStartX = null;
|
||||
this.swipeDragStartY = null;
|
||||
}
|
||||
}
|
||||
|
||||
onSwipeMove = (e) => {
|
||||
if (!this.isSwiping) return;
|
||||
|
||||
const dx = e.touches[0].clientX - this.swipeDragStartX;
|
||||
const dy = e.touches[0].clientY - this.swipeDragStartY;
|
||||
|
||||
if (!this.swipeAxisLocked) {
|
||||
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
|
||||
this.swipeAxisLocked = true;
|
||||
this.swipeIsHorizontal = Math.abs(dx) > Math.abs(dy);
|
||||
}
|
||||
|
||||
if (!this.swipeIsHorizontal) return;
|
||||
|
||||
this.swipeDidMove = true;
|
||||
const delta = e.touches[0].clientX - this.swipeDragStartX;
|
||||
this.swipeTranslate = delta;
|
||||
this.applySwipeTransform(delta, false);
|
||||
}
|
||||
|
||||
applySwipeTransform(delta, animated) {
|
||||
const panels = this.getMiniPanels();
|
||||
panels.forEach(panel => {
|
||||
const offset = parseInt(panel.getAttribute("data-mini-panel"));
|
||||
panel.style.transition = animated ? "transform 300ms ease" : "";
|
||||
panel.style.transform = `translateX(calc(${offset * 100}% + ${delta}px))`;
|
||||
});
|
||||
}
|
||||
|
||||
commitSwipe(direction) {
|
||||
const sign = direction === "next" ? 1 : -1;
|
||||
const panels = this.getMiniPanels();
|
||||
const container = panels[0]?.parentElement;
|
||||
|
||||
// Snap panels to their current drag position
|
||||
panels.forEach(panel => {
|
||||
const offset = parseInt(panel.getAttribute("data-mini-panel"));
|
||||
panel.style.transition = "none";
|
||||
panel.style.transform = `translateX(calc(${offset * 100}% + ${this.swipeTranslate}px))`;
|
||||
});
|
||||
|
||||
// Force reflow so the browser registers the snap as a committed state
|
||||
panels[0]?.getBoundingClientRect();
|
||||
|
||||
panels.forEach(panel => {
|
||||
const offset = parseInt(panel.getAttribute("data-mini-panel"));
|
||||
panel.style.transition = "transform 300ms ease";
|
||||
panel.style.transform = `translateX(calc(${(offset - sign) * 100}%))`;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
this.miniCurrentDate = direction === "next"
|
||||
? calendarUtil.addMonths(this.miniCurrentDate, 1)
|
||||
: calendarUtil.addMonths(this.miniCurrentDate, -1);
|
||||
|
||||
this.onMonthChange(this.miniCurrentDate)
|
||||
this.swipeTranslate = 0;
|
||||
this.rerender();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
snapBack() {
|
||||
const panels = this.getMiniPanels();
|
||||
panels.forEach(panel => {
|
||||
const offset = parseInt(panel.getAttribute("data-mini-panel"));
|
||||
panel.style.transition = "transform 300ms ease";
|
||||
panel.style.transform = `translateX(${offset * 100}%)`;
|
||||
});
|
||||
this.swipeTranslate = 0;
|
||||
}
|
||||
|
||||
getMonthData(date) {
|
||||
const monthKey = this.getMonthKey(date);
|
||||
const cached = this.monthCache.get(monthKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
|
||||
const firstOfMonth = new Date(year, month, 1);
|
||||
const gridStart = calendarUtil.startOfWeek(firstOfMonth, this.weekStartsOn);
|
||||
|
||||
const allDays = Array.from({ length: 42 }, (_, i) => calendarUtil.addDays(gridStart, i));
|
||||
const weeks = [];
|
||||
for (let w = 0; w < 6; w++) {
|
||||
weeks.push(allDays.slice(w * 7, w * 7 + 7));
|
||||
}
|
||||
|
||||
const gridEnd = calendarUtil.endOfDay(allDays[41]);
|
||||
|
||||
const colorsByDay = new Map();
|
||||
allDays.forEach(day => {
|
||||
colorsByDay.set(calendarUtil.toDateInput(day), []);
|
||||
});
|
||||
|
||||
const expanded = calendarUtil.expandRecurringEvents(this.events || [], gridStart, gridEnd);
|
||||
const relevantEvents = 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);
|
||||
});
|
||||
|
||||
relevantEvents.forEach(ev => {
|
||||
const colors = (ev.calendars || [])
|
||||
.map(id => this.colorByCalendarId.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 eventEnd = effectiveEnd < gridEnd ? effectiveEnd : gridEnd;
|
||||
|
||||
while (cursor < eventEnd) {
|
||||
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 result = {
|
||||
weeks: weeks.map(week =>
|
||||
week.map(day => ({
|
||||
day,
|
||||
isCurrentMonth: day.getMonth() === month,
|
||||
calendarColors: colorsByDay.get(calendarUtil.toDateInput(day)) || []
|
||||
}))
|
||||
)
|
||||
};
|
||||
|
||||
this.monthCache.set(monthKey, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
register(ToolbarPopout)
|
||||
29
calendar/Week/SpacerCell.js
Normal file
@@ -0,0 +1,29 @@
|
||||
class SpacerCell extends Shadow {
|
||||
constructor(weekNumber, sidebarWidth) {
|
||||
super()
|
||||
this.weekNumber = weekNumber;
|
||||
this.sidebarWidth = sidebarWidth
|
||||
}
|
||||
|
||||
render() {
|
||||
VStack(() => {
|
||||
p(`W${this.weekNumber}`)
|
||||
.fontSize(0.9, em)
|
||||
.fontWeight("600")
|
||||
.color("var(--headertext)")
|
||||
})
|
||||
.width(this.sidebarWidth, em)
|
||||
.paddingHorizontal(0.5, em)
|
||||
.flexShrink(0)
|
||||
.flexGrow(0)
|
||||
.justifyContent("center")
|
||||
.alignItems("center")
|
||||
.borderRight("1px solid var(--divider)")
|
||||
.borderBottom("1px solid var(--divider)")
|
||||
.borderTop("1px solid var(--sidebottombars)")
|
||||
.background("var(--sidebottombars)")
|
||||
.boxSizing("border-box")
|
||||
}
|
||||
}
|
||||
|
||||
register(SpacerCell)
|
||||
37
calendar/Week/TimedLabelsColumn.js
Normal file
@@ -0,0 +1,37 @@
|
||||
class TimedLabelsColumn extends Shadow {
|
||||
constructor(slots, slotHeight, sidebarWidth) {
|
||||
super()
|
||||
this.slots = slots;
|
||||
this.slotHeight = slotHeight;
|
||||
this.sidebarWidth = sidebarWidth
|
||||
}
|
||||
|
||||
render() {
|
||||
VStack(() => {
|
||||
this.slots.forEach(slot => {
|
||||
const isHour = slot.minute === 0;
|
||||
|
||||
VStack(() => {
|
||||
p(isHour ? slot.label : "")
|
||||
.margin(0)
|
||||
.fontSize(0.9, em)
|
||||
.color("var(--headertext)")
|
||||
.transform("translateY(-0.55em)")
|
||||
})
|
||||
.height(this.slotHeight, em)
|
||||
.justifyContent("flex-start")
|
||||
.alignItems("center")
|
||||
.paddingHorizontal(0.5, em)
|
||||
.width(3, em)
|
||||
.boxSizing("border-box")
|
||||
})
|
||||
})
|
||||
.paddingTop(this.slotHeight, em)
|
||||
.width(this.sidebarWidth, em)
|
||||
.background("var(--sidebottombars)")
|
||||
.boxSizing("border-box")
|
||||
.borderRight("1px solid var(--divider)")
|
||||
}
|
||||
}
|
||||
|
||||
register(TimedLabelsColumn)
|
||||
338
calendar/Week/TimedWeekGrid.js
Normal file
@@ -0,0 +1,338 @@
|
||||
import calendarUtil from "../calendarUtil.js";
|
||||
|
||||
class TimedWeekGrid extends Shadow {
|
||||
constructor(weekDays, slots, groupedDays, calendars, slotHeight, viewMode = "week", onSlotTap = null) {
|
||||
super()
|
||||
this.weekDays = weekDays;
|
||||
this.slots = slots;
|
||||
this.groupedDays = groupedDays;
|
||||
this.calendars = calendars;
|
||||
this.slotHeight = slotHeight;
|
||||
this.viewMode = viewMode;
|
||||
this.onSlotTap = onSlotTap;
|
||||
this.ghostSlot = null;
|
||||
|
||||
this._layoutCache = new WeakMap();
|
||||
}
|
||||
|
||||
render() {
|
||||
const totalGridHeight = this.slots.length * this.slotHeight;
|
||||
const minutesPerSlot = this.minutesPerSlot();
|
||||
const gridStartMinutes = this.gridStartMinutes();
|
||||
|
||||
const slotGridBackground = [
|
||||
`repeating-linear-gradient(to bottom,`,
|
||||
` transparent,`,
|
||||
` transparent calc(${this.slotHeight}em - 1px),`,
|
||||
` var(--divider) calc(${this.slotHeight}em - 1px),`,
|
||||
` var(--divider) ${this.slotHeight}em,`,
|
||||
` transparent ${this.slotHeight}em,`,
|
||||
` transparent calc(${this.slotHeight * 2}em - 1px),`,
|
||||
` var(--lightDivider) calc(${this.slotHeight * 2}em - 1px),`,
|
||||
` var(--lightDivider) ${this.slotHeight * 2}em`,
|
||||
`)`
|
||||
].join("\n");
|
||||
|
||||
HStack(() => {
|
||||
this.groupedDays.forEach((group, index) => {
|
||||
const today = calendarUtil.isToday(group.day);
|
||||
const isLast = index === this.groupedDays.length - 1;
|
||||
const dayStart = calendarUtil.startOfDay(group.day);
|
||||
const dayEnd = calendarUtil.endOfDay(group.day);
|
||||
|
||||
// Build ghost event for this day if applicable
|
||||
const ghostForThisDay = this.ghostSlot &&
|
||||
calendarUtil.startOfDay(this.ghostSlot.day).getTime() === dayStart.getTime();
|
||||
const ghostEvent = ghostForThisDay ? {
|
||||
_isGhost: true,
|
||||
id: '__ghost__',
|
||||
time_start: new Date(dayStart.getTime() + this.ghostSlot.startMinutes * 60000),
|
||||
time_end: new Date(dayStart.getTime() + (this.ghostSlot.startMinutes + 30) * 60000),
|
||||
calendars: [],
|
||||
all_day: false,
|
||||
title: '+'
|
||||
} : null;
|
||||
|
||||
// Extend the ghost by 1ms on each side for layout only — this makes
|
||||
// touching time boundaries (ghost ends exactly when event starts, or vice versa)
|
||||
// register as overlapping so directly adjacent slots trigger a split.
|
||||
const ghostForLayout = ghostEvent ? {
|
||||
...ghostEvent,
|
||||
time_start: new Date(ghostEvent.time_start.getTime() - 1),
|
||||
time_end: new Date(ghostEvent.time_end.getTime() + 1),
|
||||
} : null;
|
||||
|
||||
const layoutEvents = ghostForLayout ? [...group.timed, ghostForLayout] : group.timed;
|
||||
const layout = this.computeLayout(layoutEvents, group.day);
|
||||
const layoutEntries = [...layout.entries()];
|
||||
|
||||
ZStack(() => {
|
||||
// Slot grid background
|
||||
VStack(() => { })
|
||||
.width(100, pct)
|
||||
.height(totalGridHeight, em)
|
||||
.backgroundImage(slotGridBackground)
|
||||
.backgroundSize(`100% ${this.slotHeight * 2}em`)
|
||||
.pointerEvents("none")
|
||||
|
||||
// Tap overlay — catches taps on empty slots
|
||||
VStack(() => {})
|
||||
.position("absolute").top(0).left(0).right(0)
|
||||
.height(totalGridHeight, em)
|
||||
.zIndex(0)
|
||||
.pointerEvents("auto")
|
||||
.onTap((e) => this.handleOverlayTap(e, group.day))
|
||||
|
||||
// Event pills + ghost pill
|
||||
ZStack(() => {
|
||||
group.timed.forEach(event => {
|
||||
const color = calendarUtil.getCalendarColor(this.calendars, event.calendars.find(id => this.calendars.some(c => c.id === id)) ?? event.calendars[0]);
|
||||
const top = this.eventTopEm(event, minutesPerSlot, gridStartMinutes, dayStart);
|
||||
const height = this.eventHeightEm(event, minutesPerSlot, dayStart, dayEnd);
|
||||
|
||||
const clippedTop = event.time_start < dayStart;
|
||||
const clippedBottom = event.time_end > dayEnd;
|
||||
|
||||
const borderTop = clippedTop ? 0 : 7.5;
|
||||
const borderBottom = clippedBottom ? 0 : 7.5;
|
||||
|
||||
const { col, total, startMs, endMs } = layout.get(event);
|
||||
const isSplit = total > 1;
|
||||
const gapPct = isSplit ? 1 : 0;
|
||||
|
||||
let span = 1;
|
||||
for (let nextCol = col + 1; nextCol < total; nextCol++) {
|
||||
const blocked = layoutEntries.some(([other, o]) =>
|
||||
other !== event && o.col === nextCol &&
|
||||
calendarUtil.rangesOverlap(startMs, endMs, o.startMs, o.endMs)
|
||||
);
|
||||
if (blocked) break;
|
||||
span++;
|
||||
}
|
||||
|
||||
const widthPct = (span * 100 / total) - gapPct;
|
||||
const leftPct = col * (100 / total) + (col > 0 ? gapPct : 0);
|
||||
|
||||
VStack(() => {
|
||||
p(event.title || "Untitled")
|
||||
.margin(0)
|
||||
.fontSize(this.viewMode === "day" ? 1 : 0.5, em)
|
||||
.fontWeight("600")
|
||||
.color("white")
|
||||
.lineHeight(this.viewMode === "day" ? "1.6" : "1.2")
|
||||
.overflow("hidden")
|
||||
})
|
||||
.position("absolute")
|
||||
.top(top + 2, em)
|
||||
.left(leftPct, pct)
|
||||
.width(widthPct, pct)
|
||||
.height(height, em)
|
||||
.minHeight(0.25, em)
|
||||
.paddingVertical(isSplit ? 0.2 : 0.35, em)
|
||||
.paddingHorizontal(0.35, em)
|
||||
.background(color)
|
||||
.borderTopLeftRadius(`${borderTop}px`)
|
||||
.borderTopRightRadius(`${borderTop}px`)
|
||||
.borderBottomLeftRadius(`${borderBottom}px`)
|
||||
.borderBottomRightRadius(`${borderBottom}px`)
|
||||
.boxSizing("border-box")
|
||||
.zIndex(1)
|
||||
.textAlign("left")
|
||||
.pointerEvents("auto")
|
||||
.cursor("pointer")
|
||||
.onTap(() => $("bottomsheet-").showEvent(event))
|
||||
})
|
||||
|
||||
// Ghost pill
|
||||
if (ghostEvent) {
|
||||
const { col, total, startMs, endMs } = layout.get(ghostForLayout);
|
||||
const isSplit = total > 1;
|
||||
const gapPct = isSplit ? 1 : 0;
|
||||
|
||||
let span = 1;
|
||||
for (let nextCol = col + 1; nextCol < total; nextCol++) {
|
||||
const blocked = layoutEntries.some(([other, o]) =>
|
||||
other !== ghostForLayout && o.col === nextCol &&
|
||||
calendarUtil.rangesOverlap(startMs, endMs, o.startMs, o.endMs)
|
||||
);
|
||||
if (blocked) break;
|
||||
span++;
|
||||
}
|
||||
|
||||
const widthPct = (span * 100 / total) - gapPct;
|
||||
const leftPct = col * (100 / total) + (col > 0 ? gapPct : 0);
|
||||
const top = this.eventTopEm(ghostEvent, minutesPerSlot, gridStartMinutes, dayStart);
|
||||
const height = this.eventHeightEm(ghostEvent, minutesPerSlot, dayStart, dayEnd);
|
||||
const ghostDateTime = new Date(dayStart.getTime() + this.ghostSlot.startMinutes * 60000);
|
||||
|
||||
VStack(() => {
|
||||
p("+")
|
||||
.margin(0)
|
||||
.fontSize(this.viewMode === "day" ? 1.5 : 0.9, em)
|
||||
.fontWeight("700")
|
||||
.color("white")
|
||||
})
|
||||
.position("absolute")
|
||||
.top(top + this.slotHeight, em)
|
||||
.left(leftPct, pct)
|
||||
.width(widthPct, pct)
|
||||
.height(height, em)
|
||||
.minHeight(0.25, em)
|
||||
.background("var(--quillred)")
|
||||
.borderRadius("7.5px")
|
||||
.boxSizing("border-box")
|
||||
.zIndex(1)
|
||||
.alignItems("center")
|
||||
.justifyContent("center")
|
||||
.pointerEvents("auto")
|
||||
.cursor("pointer")
|
||||
.onTap(() => {
|
||||
this.ghostSlot = null;
|
||||
this.rerender();
|
||||
if (this.onSlotTap) this.onSlotTap(ghostDateTime);
|
||||
})
|
||||
}
|
||||
})
|
||||
.position("absolute")
|
||||
.top(0)
|
||||
.left(0)
|
||||
.right(0)
|
||||
.bottom(0)
|
||||
.pointerEvents("none")
|
||||
.boxSizing("border-box")
|
||||
})
|
||||
.flex(1)
|
||||
.width(0, px)
|
||||
.minWidth(0)
|
||||
.height(totalGridHeight, em)
|
||||
.position("relative")
|
||||
.background(today && this.viewMode !== "day" ? "var(--desktop-item-background)" : "transparent")
|
||||
.borderRight(isLast ? "1px solid transparent" : "1px solid var(--divider)")
|
||||
.boxSizing("border-box")
|
||||
.overflow("hidden")
|
||||
})
|
||||
})
|
||||
.position("relative")
|
||||
.width(100, pct)
|
||||
.minHeight(totalGridHeight, em)
|
||||
}
|
||||
|
||||
handleOverlayTap(e, day) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const clientY = e.clientY !== undefined ? e.clientY
|
||||
: (e.changedTouches?.[0]?.clientY ?? e.touches?.[0]?.clientY ?? 0);
|
||||
const fs = parseFloat(getComputedStyle(this).fontSize);
|
||||
const relY = clientY - rect.top - (this.slotHeight * fs);
|
||||
const raw = (relY / (this.slotHeight * fs)) * this.minutesPerSlot() + this.gridStartMinutes();
|
||||
const snapped = Math.floor(raw / 30) * 30;
|
||||
const clamped = Math.max(0, Math.min(23 * 60 + 30, snapped));
|
||||
this.ghostSlot = { day, startMinutes: clamped };
|
||||
this.rerender();
|
||||
}
|
||||
|
||||
// 3 passes, O(n^2) worst case.
|
||||
// - Adjacency list is built once after Pass 1 and shared by Passes 2 and 3
|
||||
// - The inner loop also breaks early once it reaches an event that starts
|
||||
// after the current one ends,
|
||||
computeLayout(events, day) {
|
||||
if (this._layoutCache.has(events)) return this._layoutCache.get(events);
|
||||
|
||||
const MIN_DURATION_MS = 30 * 60 * 1000;
|
||||
const dayStart = calendarUtil.startOfDay(day);
|
||||
const dayEnd = calendarUtil.endOfDay(day);
|
||||
const result = new Map();
|
||||
const columns = [];
|
||||
|
||||
const sorted = [...events].sort((a, b) => a.time_start - b.time_start);
|
||||
|
||||
// Pass 1: assign each event to a column — O(n log n)
|
||||
sorted.forEach(event => {
|
||||
const startMs = Math.max(event.time_start.getTime(), dayStart.getTime());
|
||||
const rawEndMs = Math.min(event.time_end.getTime(), dayEnd.getTime());
|
||||
const endMs = Math.max(rawEndMs, startMs + MIN_DURATION_MS);
|
||||
|
||||
let col = columns.findIndex(colEnd => colEnd <= startMs);
|
||||
if (col === -1) {
|
||||
col = columns.length;
|
||||
columns.push(endMs);
|
||||
} else {
|
||||
columns[col] = endMs;
|
||||
}
|
||||
|
||||
result.set(event, { col, total: 0, startMs, endMs });
|
||||
});
|
||||
|
||||
// Build adjacency list
|
||||
const neighbors = new Map(sorted.map(e => [e, []]));
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const { startMs: as, endMs: ae } = result.get(sorted[i]);
|
||||
for (let j = i + 1; j < sorted.length; j++) {
|
||||
const { startMs: bs, endMs: be } = result.get(sorted[j]);
|
||||
if (bs >= ae) break;
|
||||
if (calendarUtil.rangesOverlap(as, ae, bs, be)) {
|
||||
neighbors.get(sorted[i]).push(sorted[j]);
|
||||
neighbors.get(sorted[j]).push(sorted[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: each event's total = highest col among its direct neighbors + 1
|
||||
sorted.forEach(event => {
|
||||
let maxCol = result.get(event).col;
|
||||
neighbors.get(event).forEach(other => {
|
||||
maxCol = Math.max(maxCol, result.get(other).col);
|
||||
});
|
||||
result.get(event).total = maxCol + 1;
|
||||
});
|
||||
|
||||
// Pass 3: BFS — propagate max total across connected clusters so events
|
||||
// linked only via a bridge event still share the same column width.
|
||||
const visited = new Set();
|
||||
sorted.forEach(event => {
|
||||
if (visited.has(event)) return;
|
||||
|
||||
const cluster = [];
|
||||
const queue = [event];
|
||||
while (queue.length) {
|
||||
const cur = queue.pop();
|
||||
if (visited.has(cur)) continue;
|
||||
visited.add(cur);
|
||||
cluster.push(cur);
|
||||
neighbors.get(cur).forEach(other => {
|
||||
if (!visited.has(other)) queue.push(other);
|
||||
});
|
||||
}
|
||||
|
||||
const clusterMax = Math.max(...cluster.map(e => result.get(e).total));
|
||||
cluster.forEach(e => { result.get(e).total = clusterMax; });
|
||||
});
|
||||
|
||||
this._layoutCache.set(events, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
eventTopEm(event, minutesPerSlot, gridStartMinutes, dayStart) {
|
||||
const startMs = Math.max(event.time_start.getTime(), dayStart.getTime());
|
||||
const start = new Date(startMs);
|
||||
const minutesFromGridStart = (start.getHours() * 60 + start.getMinutes()) - gridStartMinutes;
|
||||
return (minutesFromGridStart / minutesPerSlot) * this.slotHeight;
|
||||
}
|
||||
|
||||
eventHeightEm(event, minutesPerSlot, dayStart, dayEnd) {
|
||||
const startMs = Math.max(event.time_start.getTime(), dayStart.getTime());
|
||||
const endMs = Math.min(event.time_end.getTime(), dayEnd.getTime());
|
||||
const durationMinutes = Math.max(30, (endMs - startMs) / 60000);
|
||||
return (durationMinutes / minutesPerSlot) * this.slotHeight;
|
||||
}
|
||||
|
||||
minutesPerSlot() {
|
||||
if (this.slots.length < 2) return 30;
|
||||
return this.slots[1].totalMinutes - this.slots[0].totalMinutes;
|
||||
}
|
||||
|
||||
gridStartMinutes() {
|
||||
return this.slots.length ? this.slots[0].totalMinutes : 0;
|
||||
}
|
||||
}
|
||||
|
||||
register(TimedWeekGrid)
|
||||
160
calendar/Week/WeekHeaderRow.js
Normal file
@@ -0,0 +1,160 @@
|
||||
import calendarUtil from "../calendarUtil.js";
|
||||
|
||||
class WeekHeaderRow extends Shadow {
|
||||
constructor(groupedDays, calendars, onDayTap = null) {
|
||||
super()
|
||||
this.groupedDays = groupedDays;
|
||||
this.calendars = calendars;
|
||||
this.onDayTap = onDayTap;
|
||||
}
|
||||
|
||||
render() {
|
||||
const allDayEvents = this.collectAllDayEvents();
|
||||
const maxEventsPerDay = Math.max(0, ...this.groupedDays.map(g => g.allDay.length))
|
||||
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
this.groupedDays.forEach((group, index) => {
|
||||
const day = group.day;
|
||||
const today = calendarUtil.isToday(day);
|
||||
const isLast = index === this.groupedDays.length - 1;
|
||||
|
||||
VStack(() => {
|
||||
h3(day.getDate())
|
||||
.margin(0)
|
||||
.fontSize(1.35, em)
|
||||
.fontWeight("700")
|
||||
.lineHeight("1")
|
||||
.textAlign("center")
|
||||
|
||||
p(day.toLocaleDateString("en-US", { weekday: "short" }).toUpperCase())
|
||||
.margin(0)
|
||||
.fontSize(0.72, em)
|
||||
.fontWeight("600")
|
||||
.letterSpacing(0.04, em)
|
||||
.opacity(today ? 1 : 0.5)
|
||||
.textAlign("center")
|
||||
})
|
||||
.color(today ? "var(--quillred)" : "var(--headertext)")
|
||||
.flex(1)
|
||||
.width(0, px)
|
||||
.minWidth(0)
|
||||
.justifyContent("center")
|
||||
.alignItems("center")
|
||||
.gap(0.5, em)
|
||||
.paddingTop(0.85, em)
|
||||
.background(today ? "var(--desktop-item-background)" : "transparent")
|
||||
.borderRight(isLast ? "1px solid transparent" : "1px solid var(--divider)")
|
||||
.boxSizing("border-box")
|
||||
.paddingBottom(maxEventsPerDay > 0 ? (maxEventsPerDay * 2.0) + 0.7 : 0.35, em)
|
||||
.cursor("pointer")
|
||||
.onTap(() => { this.onDayTap(day) })
|
||||
})
|
||||
})
|
||||
.width(100, pct)
|
||||
.alignItems("stretch")
|
||||
|
||||
this.allDayRow(allDayEvents, maxEventsPerDay);
|
||||
})
|
||||
.width(100, pct)
|
||||
.position("relative")
|
||||
.background("var(--sidebottombars)")
|
||||
.borderBottom("1px solid var(--divider)")
|
||||
}
|
||||
|
||||
allDayRow(allDayEvents, maxEventsPerDay) {
|
||||
if (allDayEvents.length === 0) return;
|
||||
|
||||
const rowHeight = 1.75;
|
||||
const gap = 0.25;
|
||||
const totalHeight = maxEventsPerDay * rowHeight + (maxEventsPerDay - 1) * gap;
|
||||
|
||||
ZStack(() => {
|
||||
allDayEvents.forEach(({ event, startIndex, endIndex, clippedLeft, clippedRight }) => {
|
||||
this.spanningEvent(event, startIndex, endIndex, rowHeight, gap, clippedLeft, clippedRight);
|
||||
})
|
||||
})
|
||||
.position("absolute")
|
||||
.bottom(0.25, em)
|
||||
.left(0, px)
|
||||
.width(100, pct)
|
||||
.height(totalHeight, em)
|
||||
.boxSizing("border-box")
|
||||
.pointerEvents("none")
|
||||
}
|
||||
|
||||
spanningEvent(event, startIndex, endIndex, rowHeight, gap, clippedLeft, clippedRight) {
|
||||
const totalDays = this.groupedDays.length;
|
||||
const spanCount = endIndex - startIndex + 1;
|
||||
const leftPct = (startIndex / totalDays) * 100;
|
||||
const widthPct = (spanCount / totalDays) * 100;
|
||||
const color = calendarUtil.getCalendarColor(this.calendars, event.calendars.find(id => this.calendars.some(c => c.id === id)) ?? event.calendars[0]);
|
||||
|
||||
const id = event.id ?? event.title;
|
||||
const slot = this.groupedDays[startIndex].allDay.findIndex(e => (e.id ?? e.title) === id);
|
||||
const topEm = slot * (rowHeight + gap);
|
||||
|
||||
const borderLeft = clippedLeft ? 0 : 0.25;
|
||||
const borderRight = clippedRight ? 0 : 0.25;
|
||||
const leftPad = clippedLeft ? 0 : 1.25;
|
||||
const rightPad = clippedRight ? 0 : 1.25;
|
||||
|
||||
HStack(() => {
|
||||
p(event.title)
|
||||
.margin(0)
|
||||
.fontSize(0.72, em)
|
||||
.fontWeight("600")
|
||||
.color("white")
|
||||
.whiteSpace("nowrap")
|
||||
.overflow("hidden")
|
||||
})
|
||||
.position("absolute")
|
||||
.top(topEm, em)
|
||||
.left(leftPct + leftPad, pct)
|
||||
.width(widthPct - leftPad - rightPad, pct)
|
||||
.height(rowHeight, em)
|
||||
.padding(0.35, em)
|
||||
.background(color)
|
||||
.borderTopLeftRadius(`${borderLeft}em`)
|
||||
.borderBottomLeftRadius(`${borderLeft}em`)
|
||||
.borderTopRightRadius(`${borderRight}em`)
|
||||
.borderBottomRightRadius(`${borderRight}em`)
|
||||
.alignItems("center")
|
||||
.boxSizing("border-box")
|
||||
.overflow("hidden")
|
||||
.pointerEvents("auto")
|
||||
.cursor("pointer")
|
||||
.onTap(() => $("bottomsheet-").showEvent(event))
|
||||
}
|
||||
|
||||
collectAllDayEvents() {
|
||||
const seen = new Map();
|
||||
// Key by id + time_start date: events spanning multiple days share the same time_start
|
||||
// so they merge into one bar; different occurrences of the same recurring template
|
||||
// have different time_start dates and render as separate bars.
|
||||
const eventKey = (event) => {
|
||||
const d = event.time_start instanceof Date ? event.time_start : new Date(event.time_start);
|
||||
return `${event.id ?? event.title}_${calendarUtil.toDateInput(d)}`;
|
||||
};
|
||||
|
||||
this.groupedDays.forEach((group, index) => {
|
||||
group.allDay.forEach(event => {
|
||||
const key = eventKey(event);
|
||||
if (!seen.has(key)) {
|
||||
seen.set(key, { event, startIndex: index, endIndex: index });
|
||||
} else {
|
||||
seen.get(key).endIndex = index;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const lastIndex = this.groupedDays.length - 1;
|
||||
return Array.from(seen.values()).map(entry => ({
|
||||
...entry,
|
||||
clippedLeft: entry.startIndex === 0 && calendarUtil.startOfDay(entry.event.time_start) < calendarUtil.startOfDay(this.groupedDays[0].day),
|
||||
clippedRight: entry.endIndex === lastIndex && calendarUtil.startOfDay(entry.event.time_end) > calendarUtil.startOfDay(this.groupedDays[lastIndex].day)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
register(WeekHeaderRow)
|
||||
142
calendar/Week/WeekView.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import calendarUtil from "../calendarUtil.js"
|
||||
import "./SpacerCell.js"
|
||||
import "./TimedLabelsColumn.js"
|
||||
import "./TimedWeekGrid.js"
|
||||
import "./WeekHeaderRow.js"
|
||||
|
||||
css(`
|
||||
weekview- {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
weekview- .VStack::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0px;
|
||||
height: 0px;
|
||||
}
|
||||
|
||||
weekview- .VStack::-webkit-scrollbar-thumb {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
weekview- .VStack::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
`)
|
||||
|
||||
let _saved = null;
|
||||
|
||||
class WeekView extends Shadow {
|
||||
constructor(calendars, events, currentDate, weekStartsOn = 0, onSlotTap = null, onDayTap = null, isCenter = false) {
|
||||
super()
|
||||
this.calendars = calendars;
|
||||
this.events = events;
|
||||
this.currentDate = currentDate;
|
||||
this.weekStartsOn = weekStartsOn;
|
||||
this.onSlotTap = onSlotTap;
|
||||
this.onDayTap = onDayTap;
|
||||
this.isCenter = isCenter;
|
||||
this.slots = calendarUtil.generateTimeSlots({ stepMinutes: 30 });
|
||||
this.slotHeight = 2;
|
||||
this.sidebarWidth = 3;
|
||||
}
|
||||
|
||||
render() {
|
||||
ZStack(() => {
|
||||
const visibleWeekStart = calendarUtil.startOfWeek(this.currentDate, this.weekStartsOn);
|
||||
const weekDays = this.getWeekDays(visibleWeekStart);
|
||||
const groupedDays = this.groupEventsByWeekDay(
|
||||
this.filterEventsForWeek(this.events, weekDays),
|
||||
weekDays
|
||||
);
|
||||
const weekNumber = calendarUtil.getWeekNumber(visibleWeekStart);
|
||||
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
SpacerCell(weekNumber, this.sidebarWidth)
|
||||
WeekHeaderRow(groupedDays, this.calendars, this.onDayTap)
|
||||
})
|
||||
.boxSizing("border-box")
|
||||
.position("sticky")
|
||||
.top(0)
|
||||
.width(100, pct)
|
||||
.zIndex(2)
|
||||
|
||||
HStack(() => {
|
||||
TimedLabelsColumn(this.slots, this.slotHeight, this.sidebarWidth)
|
||||
TimedWeekGrid(weekDays, this.slots, groupedDays, this.calendars, this.slotHeight, "week", this.onSlotTap)
|
||||
})
|
||||
.boxSizing("border-box")
|
||||
.onAppear(() => {
|
||||
// console.log("groupedDays:", groupedDays)
|
||||
this.scrollToEight();
|
||||
})
|
||||
})
|
||||
})
|
||||
.position("relative")
|
||||
.gap(1, em)
|
||||
.boxSizing("border-box")
|
||||
.width(100, pct)
|
||||
.height(100, pct)
|
||||
.fontSize(0.9, em)
|
||||
.overscrollBehavior("none")
|
||||
.overflowY("scroll")
|
||||
.display("block")
|
||||
}
|
||||
|
||||
getWeekDays(weekStart) {
|
||||
return Array.from({ length: 7 }, (_, i) => calendarUtil.addDays(weekStart, i));
|
||||
}
|
||||
|
||||
filterEventsForWeek(events, weekDays) {
|
||||
const rangeStart = calendarUtil.startOfDay(weekDays[0]);
|
||||
const rangeEnd = calendarUtil.endOfDay(weekDays[6]);
|
||||
const expanded = calendarUtil.expandRecurringEvents(events, rangeStart, rangeEnd);
|
||||
return expanded.filter(event => {
|
||||
const end = event.all_day ? calendarUtil.endOfDay(event.time_end) : calendarUtil.timedEnd(event);
|
||||
return weekDays.some(day => calendarUtil.rangesOverlap(
|
||||
event.time_start,
|
||||
end,
|
||||
calendarUtil.startOfDay(day),
|
||||
calendarUtil.endOfDay(day)
|
||||
) && this.calendars.some(c => event.calendars?.some(id => id === c.id)));
|
||||
});
|
||||
}
|
||||
|
||||
scrollToEight() {
|
||||
requestAnimationFrame(() => {
|
||||
const fs = parseFloat(getComputedStyle(this).fontSize);
|
||||
const slotsBeforeEight = this.slots.findIndex(s => s.hour24 === 8 && s.minute === 0);
|
||||
const defaultTarget = slotsBeforeEight * this.slotHeight * fs;
|
||||
if (this.isCenter) {
|
||||
const dateKey = calendarUtil.toDateInput(calendarUtil.startOfWeek(this.currentDate, this.weekStartsOn));
|
||||
this.scrollTop = (_saved?.dateKey === dateKey) ? _saved.scrollTop : defaultTarget;
|
||||
this.addEventListener('scroll', () => { _saved = { dateKey, scrollTop: this.scrollTop }; }, { passive: true });
|
||||
} else {
|
||||
this.scrollTop = defaultTarget;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
groupEventsByWeekDay(events, weekDays) {
|
||||
return weekDays.map(day => {
|
||||
const dayStart = calendarUtil.startOfDay(day);
|
||||
const dayEnd = calendarUtil.endOfDay(day)
|
||||
|
||||
return {
|
||||
day,
|
||||
allDay: events.filter(event => {
|
||||
if (!event.all_day) return false;
|
||||
const end = calendarUtil.endOfDay(event.time_end);
|
||||
return calendarUtil.rangesOverlap(event.time_start, end, dayStart, dayEnd);
|
||||
}),
|
||||
timed: events.filter(event => {
|
||||
return !event.all_day && calendarUtil.rangesOverlap(event.time_start, calendarUtil.timedEnd(event), dayStart, dayEnd);
|
||||
})
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
register(WeekView)
|
||||
582
calendar/calendar.js
Normal file
@@ -0,0 +1,582 @@
|
||||
import calendarUtil from "./calendarUtil.js"
|
||||
import "./Week/WeekView.js"
|
||||
import "./Month/MonthView.js"
|
||||
import "./Day/DayView.js"
|
||||
import "./Events/EventForm.js"
|
||||
import "./Events/EventDetails.js"
|
||||
import "./Events/FilePreview.js"
|
||||
import "./Toolbar/CalendarToolbar.js"
|
||||
import "./Toolbar/CalendarOptions.js"
|
||||
import "./Toolbar/BottomBar.js"
|
||||
import "./CalendarForm.js"
|
||||
import "../components/BottomSheet.js"
|
||||
import "/_/code/components/LoadingCircle.js"
|
||||
|
||||
css(`
|
||||
calendar- {
|
||||
font-family: 'Arial';
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
calendar- h1 {
|
||||
font-family: 'Bona';
|
||||
}
|
||||
`)
|
||||
|
||||
class Calendar extends Shadow {
|
||||
swipeTranslate = 0; // current drag offset in px
|
||||
isSwiping = false;
|
||||
isCommitting = false;
|
||||
swipeDragStartX = null;
|
||||
swipeDragStartY = null;
|
||||
swipeDragStartTime = null;
|
||||
swipeAxisLocked = false;
|
||||
swipeIsHorizontal = false;
|
||||
|
||||
get basePath() {
|
||||
return window.location.pathname.replace(/\/day\/[^/]+$/, '').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
get urlDayDate() {
|
||||
const match = window.location.pathname.match(/\/day\/(\d{4}-\d{2}-\d{2})$/)
|
||||
return match ? new Date(match[1] + 'T00:00:00') : null
|
||||
}
|
||||
|
||||
SWIPE_COMMIT_DISTANCE = window.outerWidth * 0.35; // 35% of screen
|
||||
SWIPE_VELOCITY_THRESHOLD = 0.4; // px/ms
|
||||
|
||||
calendars = [];
|
||||
events = [];
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.currentDate = new Date();
|
||||
this.viewMode = localStorage.getItem(`calendarViewMode_${global.profile.id}`) || "month";
|
||||
this.weekStartsOn = 0;
|
||||
this.showPopout = false;
|
||||
this.calendars = [...global.currentNetwork.data.calendars];
|
||||
// Restore previously-selected calendars from localStorage; fall back to all
|
||||
const storedCalIds = JSON.parse(localStorage.getItem(`calendarSelection_${global.profile.id}_${global.currentNetwork.id}`) || 'null')
|
||||
if (storedCalIds) {
|
||||
console.log(storedCalIds)
|
||||
const restored = this.calendars.filter(c => storedCalIds.includes(c.id))
|
||||
this.selectedCalendars = restored.length > 0 ? restored : [...this.calendars]
|
||||
} else {
|
||||
console.log("nope")
|
||||
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)
|
||||
}));
|
||||
}
|
||||
|
||||
render() {
|
||||
const dayDate = this.urlDayDate
|
||||
ZStack(() => {
|
||||
VStack(() => {
|
||||
if (dayDate) {
|
||||
CalendarToolbar(
|
||||
dayDate,
|
||||
this.weekStartsOn,
|
||||
{
|
||||
goToPrevious: () => this.subpathNavigateToDate(calendarUtil.addDays(dayDate, -1)),
|
||||
goToCurrent: () => this.subpathNavigateToDate(new Date()),
|
||||
goToNext: () => this.subpathNavigateToDate(calendarUtil.addDays(dayDate, 1)),
|
||||
goToDate: (date) => this.subpathNavigateToDate(date),
|
||||
},
|
||||
this.selectedCalendars,
|
||||
this.events,
|
||||
this.showPopout,
|
||||
{ onBack: () => navigateTo(this.basePath), viewModeOverride: "day" }
|
||||
)
|
||||
} else {
|
||||
CalendarToolbar(this.currentDate, this.weekStartsOn, {
|
||||
goToPrevious: () => this.goToPrevious(),
|
||||
goToCurrent: () => this.goToCurrent(),
|
||||
goToNext: () => this.goToNext(),
|
||||
goToDate: (date) => this.goToDate(date)
|
||||
}, this.selectedCalendars, this.events, this.showPopout)
|
||||
}
|
||||
|
||||
if (global.appRefreshing) {
|
||||
LoadingCircle()
|
||||
} else {
|
||||
ZStack(() => {
|
||||
// Three panels (previous/current/next) for swipe transitions
|
||||
[-1, 0, 1].forEach(offset => {
|
||||
let viewDate;
|
||||
if (dayDate) {
|
||||
viewDate = calendarUtil.addDays(dayDate, offset);
|
||||
} else if (this.viewMode === "week") {
|
||||
viewDate = calendarUtil.addDays(this.currentDate, offset * 7);
|
||||
} else if (this.viewMode === "month") {
|
||||
viewDate = calendarUtil.addMonths(this.currentDate, offset);
|
||||
} else if (this.viewMode === "day") {
|
||||
viewDate = calendarUtil.addDays(this.currentDate, offset);
|
||||
}
|
||||
|
||||
ZStack(() => {
|
||||
const isCenter = offset === 0;
|
||||
if (dayDate) {
|
||||
DayView(this.selectedCalendars, this.events, viewDate, (dateTime) => this.openNewEventForm(dateTime), isCenter)
|
||||
} else if (this.viewMode === "week") {
|
||||
WeekView(this.selectedCalendars, this.events, viewDate, this.weekStartsOn, (dateTime) => this.openNewEventForm(dateTime), (day) => window.navigateTo(`${this.basePath}/day/${calendarUtil.toDateInput(day)}`), isCenter)
|
||||
} else if (this.viewMode === "month") {
|
||||
MonthView(this.selectedCalendars, this.events, viewDate, this.weekStartsOn, (day) => {
|
||||
if (!calendarUtil.isSameMonth(day, viewDate)) {
|
||||
this.commitSwipe(day > viewDate ? "next" : "previous")
|
||||
} else {
|
||||
window.navigateTo(`${this.basePath}/day/${calendarUtil.toDateInput(day)}`)
|
||||
}
|
||||
})
|
||||
} else if (this.viewMode === "day") {
|
||||
DayView(this.selectedCalendars, this.events, viewDate, (dateTime) => this.openNewEventForm(dateTime), isCenter)
|
||||
}
|
||||
})
|
||||
.position("absolute")
|
||||
.width(100, pct)
|
||||
.height(100, pct)
|
||||
.transform(`translateX(${offset * 100}%)`)
|
||||
.attr({ "data-swipe-panel": offset })
|
||||
})
|
||||
})
|
||||
.position("relative")
|
||||
.overflow("hidden")
|
||||
.width(100, pct)
|
||||
.flex(1)
|
||||
.onTouch((start, e) => this.handleSwipeTouch(start, e))
|
||||
}
|
||||
})
|
||||
.height(100, pct)
|
||||
|
||||
ActionSheetPopup()
|
||||
|
||||
FilePreview()
|
||||
|
||||
const sheet = BottomSheet();
|
||||
// Exposed so child views (WeekView, DayView, etc.) can open event details
|
||||
sheet.showEvent = (event) => {
|
||||
let dirty = false;
|
||||
sheet.show(
|
||||
() => EventDetails(
|
||||
this.calendars,
|
||||
event,
|
||||
(updateResult) => {
|
||||
if (updateResult?.scope) {
|
||||
this.handleEditResult(updateResult);
|
||||
} else {
|
||||
const updatedEvent = updateResult;
|
||||
this.events = this.events.map(e => {
|
||||
if (e.id !== updatedEvent.id) return e;
|
||||
if (updatedEvent._isOccurrence) return { ...e, calendars: updatedEvent.calendars };
|
||||
return { ...updatedEvent, time_start: new Date(updatedEvent.time_start), time_end: new Date(updatedEvent.time_end) };
|
||||
});
|
||||
global.currentNetwork.data.events = global.currentNetwork.data.events.map(e => {
|
||||
if (e.id !== updatedEvent.id) return e;
|
||||
if (updatedEvent._isOccurrence) return { ...e, calendars: updatedEvent.calendars };
|
||||
return updatedEvent;
|
||||
});
|
||||
}
|
||||
dirty = true;
|
||||
},
|
||||
(deleteResult) => {
|
||||
this.handleDeleteResult(deleteResult);
|
||||
dirty = false;
|
||||
this.rerender();
|
||||
}
|
||||
),
|
||||
() => { if (dirty) { dirty = false; this.rerender(); } }
|
||||
);
|
||||
};
|
||||
|
||||
BottomBar({
|
||||
onAddEvent: () => this.openNewEventForm(dayDate ?? null),
|
||||
hideViewSelect: !!dayDate,
|
||||
onCalendarOptions: () => $("bottomsheet-").show(() => CalendarOptions(this.calendars, this.selectedCalendars, {
|
||||
onSelection: (newSelectedCalendars) => {
|
||||
this.selectedCalendars = newSelectedCalendars;
|
||||
localStorage.setItem(`calendarSelection_${global.profile.id}_${global.currentNetwork.id}`, JSON.stringify(newSelectedCalendars.map(c => c.id)))
|
||||
this.rerender();
|
||||
},
|
||||
onCalendarAdded: (newCalendar) => {
|
||||
this.calendars = [...this.calendars, newCalendar];
|
||||
global.currentNetwork.data.calendars = [...global.currentNetwork.data.calendars, newCalendar];
|
||||
},
|
||||
onCalendarUpdated: (updatedCalendar) => {
|
||||
this.calendars = this.calendars.map(c => c.id === updatedCalendar.id ? updatedCalendar : c);
|
||||
this.selectedCalendars = this.selectedCalendars.map(c => c.id === updatedCalendar.id ? updatedCalendar : c);
|
||||
global.currentNetwork.data.calendars = global.currentNetwork.data.calendars.map(c => c.id === updatedCalendar.id ? updatedCalendar : c);
|
||||
},
|
||||
onCalendarDeleted: (deletedId) => {
|
||||
this.calendars = this.calendars.filter(c => c.id !== deletedId);
|
||||
this.selectedCalendars = this.selectedCalendars.filter(c => c.id !== deletedId);
|
||||
global.currentNetwork.data.calendars = global.currentNetwork.data.calendars.filter(c => c.id !== deletedId);
|
||||
}
|
||||
})),
|
||||
viewMode: this.viewMode,
|
||||
onChangeView: (mode) => { this.viewMode = mode; localStorage.setItem(`calendarViewMode_${global.profile.id}`, mode); this.rerender(); }
|
||||
})
|
||||
})
|
||||
.position("relative")
|
||||
.overflowY("hidden")
|
||||
.boxSizing("border-box")
|
||||
.height(100, pct)
|
||||
.width(100, pct)
|
||||
.onNavigate(() => this.rerender())
|
||||
}
|
||||
|
||||
subpathNavigateToDate(date) {
|
||||
this.currentDate = date
|
||||
window.history.replaceState({}, '', `${this.basePath}/day/${calendarUtil.toDateInput(date)}`)
|
||||
this.rerender()
|
||||
}
|
||||
|
||||
openNewEventForm(initialDate = null) {
|
||||
let formEl
|
||||
const sheet = $("bottomsheet-")
|
||||
const onSaveError = () => {
|
||||
sheet._closeOverride = () => sheet.forceClose()
|
||||
}
|
||||
sheet.show(() => {
|
||||
formEl = EventForm(this.calendars, (event) => this.updateEvents(event), null, null, null, initialDate, onSaveError)
|
||||
})
|
||||
sheet._closeOverride = () => {
|
||||
sheet.setSheet(true)
|
||||
formEl?.handleBack()
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Inherit recurrence from form (or old template's rule). A's old end_date caps the new series to avoid overlap with independent later splits.
|
||||
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 };
|
||||
|
||||
// Collect descendants in [capDate, oldEndDate) only; independent splits at/beyond oldEndDate are preserved
|
||||
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() } };
|
||||
}
|
||||
// Migrate overrides in [capDate, oldEndDate) to the new template (mirrors server migration)
|
||||
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 => {
|
||||
// Overrides of the old template stay if they're before capDate.
|
||||
// Migrated overrides now have recurrence_parent_id = newId so they pass through.
|
||||
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 and past-promoted overrides that are still linked
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateEvents(event) {
|
||||
this.events = [...this.events, { ...event, time_start: new Date(event.time_start), time_end: new Date(event.time_end) }];
|
||||
global.currentNetwork.data.events = [...global.currentNetwork.data.events, event];
|
||||
this.rerender();
|
||||
}
|
||||
|
||||
changeView(type) {
|
||||
if (this.viewMode === type || this.viewMode === "day") return false;
|
||||
this.viewMode = type;
|
||||
localStorage.setItem('calendarViewMode', type)
|
||||
this.rerender();
|
||||
return true;
|
||||
}
|
||||
|
||||
goToPrevious() { this.navigate("previous"); }
|
||||
goToCurrent() { this.currentDate = new Date(); this.rerender(); }
|
||||
goToNext() { this.navigate("next"); }
|
||||
goToDate(date) {
|
||||
const prev = this.currentDate;
|
||||
this.currentDate = date;
|
||||
|
||||
if (this.viewMode === "week") {
|
||||
if (calendarUtil.isSameWeek(prev, date)) return false;
|
||||
} else if (this.viewMode === "month") {
|
||||
if (calendarUtil.isSameMonth(prev, date)) return false;
|
||||
}
|
||||
this.rerender();
|
||||
return true;
|
||||
}
|
||||
|
||||
navigate(direction) {
|
||||
const sign = direction === "next" ? 1 : -1;
|
||||
if (this.viewMode === "week") { this.currentDate = calendarUtil.addDays(this.currentDate, sign * 7) }
|
||||
else if (this.viewMode === "day") { this.currentDate = calendarUtil.addDays(this.currentDate, sign) }
|
||||
else if (this.viewMode === "month") { this.currentDate = calendarUtil.addMonths(this.currentDate, sign) }
|
||||
this.rerender();
|
||||
}
|
||||
|
||||
handleSwipeTouch(start, e) {
|
||||
if (start) {
|
||||
// Block new swipes during active animations
|
||||
if (this.isCommitting) return;
|
||||
|
||||
if ($("home-").sidebarOpen) return;
|
||||
|
||||
const startX = e.touches[0].clientX;
|
||||
const sidebarOpenZone = window.outerWidth / 10;
|
||||
const sidebarCloseZone = window.outerWidth * 5 / 6;
|
||||
if (startX < sidebarOpenZone || startX > sidebarCloseZone) return;
|
||||
|
||||
this.swipeDragStartX = e.touches[0].clientX;
|
||||
this.swipeDragStartY = e.touches[0].clientY;
|
||||
this.swipeDragStartTime = Date.now();
|
||||
this.isSwiping = true;
|
||||
this.swipeAxisLocked = false;
|
||||
this.swipeIsHorizontal = false;
|
||||
document.addEventListener("touchmove", this.onSwipeMove, { passive: true });
|
||||
} else {
|
||||
if (!this.isSwiping) return;
|
||||
document.removeEventListener("touchmove", this.onSwipeMove);
|
||||
|
||||
if (!this.swipeIsHorizontal) {
|
||||
this.isSwiping = false;
|
||||
this.swipeDragStartX = null;
|
||||
this.swipeDragStartY = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
const delta = endX - this.swipeDragStartX;
|
||||
const elapsed = Date.now() - this.swipeDragStartTime;
|
||||
const velocity = Math.abs(delta) / elapsed;
|
||||
const shouldCommit = Math.abs(delta) > this.SWIPE_COMMIT_DISTANCE || velocity > this.SWIPE_VELOCITY_THRESHOLD;
|
||||
|
||||
if (shouldCommit && delta < 0) {
|
||||
this.commitSwipe("next");
|
||||
} else if (shouldCommit && delta > 0) {
|
||||
this.commitSwipe("previous");
|
||||
} else {
|
||||
this.snapBack();
|
||||
}
|
||||
|
||||
this.isSwiping = false;
|
||||
this.swipeDragStartX = null;
|
||||
this.swipeDragStartY = null;
|
||||
}
|
||||
}
|
||||
|
||||
onSwipeMove = (e) => {
|
||||
if (!this.isSwiping) return;
|
||||
|
||||
const dx = e.touches[0].clientX - this.swipeDragStartX;
|
||||
const dy = e.touches[0].clientY - this.swipeDragStartY;
|
||||
|
||||
if (!this.swipeAxisLocked) {
|
||||
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
|
||||
this.swipeAxisLocked = true;
|
||||
this.swipeIsHorizontal = Math.abs(dx) > Math.abs(dy);
|
||||
}
|
||||
if (!this.swipeIsHorizontal) return;
|
||||
|
||||
const delta = e.touches[0].clientX - this.swipeDragStartX;
|
||||
this.swipeTranslate = delta;
|
||||
this.applySwipeTransform(delta, false);
|
||||
}
|
||||
|
||||
applySwipeTransform(delta, animated) {
|
||||
const panels = this.$$("[data-swipe-panel]", this);
|
||||
panels.forEach(panel => {
|
||||
const offset = parseInt(panel.getAttribute("data-swipe-panel"));
|
||||
panel.style.transition = animated ? "transform 300ms ease" : "";
|
||||
panel.style.transform = `translateX(calc(${offset * 100}% + ${delta}px))`;
|
||||
});
|
||||
}
|
||||
|
||||
commitSwipe(direction) {
|
||||
const dayDate = this.urlDayDate
|
||||
const sign = direction === "next" ? 1 : -1;
|
||||
let nextDate;
|
||||
if (dayDate) {
|
||||
nextDate = calendarUtil.addDays(dayDate, sign);
|
||||
} else if (this.viewMode === "week") {
|
||||
nextDate = calendarUtil.addDays(this.currentDate, sign * 7);
|
||||
} else if (this.viewMode === "day") {
|
||||
nextDate = calendarUtil.addDays(this.currentDate, sign);
|
||||
} else {
|
||||
nextDate = calendarUtil.addMonths(this.currentDate, sign);
|
||||
}
|
||||
|
||||
this.isCommitting = true;
|
||||
|
||||
const screenWidth = window.innerWidth;
|
||||
const currentDelta = this.swipeTranslate;
|
||||
|
||||
const panels = this.$$("[data-swipe-panel]", this);
|
||||
panels.forEach(panel => {
|
||||
const offset = parseInt(panel.getAttribute("data-swipe-panel"));
|
||||
panel.style.transition = "";
|
||||
panel.style.transform = `translateX(calc(${offset * 100}% + ${currentDelta}px))`;
|
||||
panel.getBoundingClientRect(); // force reflow so transition fires from current position
|
||||
panel.style.transition = "transform 300ms ease";
|
||||
panel.style.transform = `translateX(calc(${offset * 100}% + ${sign * -screenWidth}px))`;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
this.swipeTranslate = 0;
|
||||
this.isCommitting = false;
|
||||
if (dayDate) {
|
||||
this.subpathNavigateToDate(nextDate)
|
||||
} else {
|
||||
this.currentDate = nextDate;
|
||||
this.rerender();
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
snapBack() {
|
||||
const panels = this.$$("[data-swipe-panel]", this);
|
||||
panels.forEach(panel => {
|
||||
const offset = parseInt(panel.getAttribute("data-swipe-panel"));
|
||||
panel.style.transition = "transform 300ms ease";
|
||||
panel.style.transform = `translateX(${offset * 100}%)`;
|
||||
});
|
||||
this.swipeTranslate = 0;
|
||||
}
|
||||
}
|
||||
|
||||
register(Calendar)
|
||||
307
calendar/calendarUtil.js
Normal file
@@ -0,0 +1,307 @@
|
||||
export default class calendarUtil {
|
||||
static addDays(date, days) {
|
||||
const d = new Date(date);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d;
|
||||
}
|
||||
|
||||
static addMonths(date, months) {
|
||||
const d = new Date(date);
|
||||
const day = d.getDate();
|
||||
d.setMonth(d.getMonth() + months);
|
||||
// setMonth overflows short months (e.g. Jan 31 + 1 → Mar 3); clamp to last day of target month
|
||||
if (d.getDate() !== day) d.setDate(0);
|
||||
return d;
|
||||
}
|
||||
|
||||
static rangesOverlap(startA, endA, startB, endB) {
|
||||
return startA < endB && endA > startB;
|
||||
}
|
||||
|
||||
static getCalendarColor(calendars, calendarId) {
|
||||
return calendars.find(c => c.id === calendarId)?.color ?? "#888888";
|
||||
}
|
||||
|
||||
static getWeekNumber(date) {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
|
||||
}
|
||||
|
||||
static generateTimeSlots({ startHour = 0, endHour = 24, stepMinutes = 60, use12Hour = true } = {}) {
|
||||
const slots = [];
|
||||
|
||||
for (let minutes = startHour * 60; minutes <= endHour * 60; minutes += stepMinutes) { // inclusive endHour
|
||||
const hour24 = Math.floor(minutes / 60);
|
||||
const minute = minutes % 60;
|
||||
|
||||
let label;
|
||||
if (use12Hour) {
|
||||
const suffix = (hour24 % 24) < 12 ? "am" : "pm";
|
||||
const hour12 = hour24 % 12 || 12;
|
||||
label = minute === 0
|
||||
? `${hour12}${suffix}`
|
||||
: `${hour12}:${String(minute).padStart(2, "0")}${suffix}`;
|
||||
} else {
|
||||
label = `${String(hour24).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
slots.push({
|
||||
hour24,
|
||||
minute,
|
||||
totalMinutes: minutes,
|
||||
label
|
||||
});
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
static isToday(date) {
|
||||
const now = new Date();
|
||||
return (
|
||||
now.getFullYear() === date.getFullYear() &&
|
||||
now.getMonth() === date.getMonth() &&
|
||||
now.getDate() === date.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
static isSameDay(a, b) {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
static isSameWeek(a, b) {
|
||||
return calendarUtil.isSameDay(this.startOfWeek(a), this.startOfWeek(b));
|
||||
}
|
||||
|
||||
static isSameMonth(a, b) {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth()
|
||||
);
|
||||
}
|
||||
|
||||
static startOfWeek(date, weekStartsOn = 0) {
|
||||
const d = new Date(date);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
|
||||
const day = d.getDay(); // 0=Sun, 1=Mon ...
|
||||
const diff = (day - weekStartsOn + 7) % 7;
|
||||
|
||||
d.setDate(d.getDate() - diff);
|
||||
return d;
|
||||
}
|
||||
|
||||
static startOfDay(date) {
|
||||
const d = new Date(date);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
static endOfDay(date) {
|
||||
const d = new Date(date);
|
||||
d.setHours(24, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
static toDateInput(date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
static toTimeInput(date) {
|
||||
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Returns the effective end time for overlap checks, ensuring zero-duration
|
||||
// timed events (start === end) still register as occupying their start moment.
|
||||
static timedEnd(event) {
|
||||
return event.time_end > event.time_start
|
||||
? event.time_end
|
||||
: new Date(event.time_start.getTime() + 1);
|
||||
}
|
||||
|
||||
// Converts a date string + all_day flag to a UTC ISO string.
|
||||
// When all_day is true and isEnd is true, sets time to 23:59:59.999 so
|
||||
// rangesOverlap (strict endA > startB) catches same-day events.
|
||||
static toISO(str, allDay, isEnd = false) {
|
||||
if (!allDay) return new Date(str).toISOString()
|
||||
const d = new Date(str + "T00:00:00")
|
||||
if (isEnd) d.setHours(23, 59, 59, 999)
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
// Formats an event's time range as a human-readable string.
|
||||
static formatEventTime(event) {
|
||||
const start = new Date(event.time_start)
|
||||
const end = new Date(event.time_end)
|
||||
const dateFmt = { weekday: "short", month: "short", day: "numeric" }
|
||||
const timeFmt = { hour: "numeric", minute: "2-digit" }
|
||||
if (event.all_day) {
|
||||
const startStr = start.toLocaleDateString("en-US", dateFmt)
|
||||
if (calendarUtil.isSameDay(start, end)) return `All day · ${startStr}`
|
||||
return `All day · ${startStr} – ${end.toLocaleDateString("en-US", dateFmt)}`
|
||||
}
|
||||
return `${start.toLocaleDateString("en-US", dateFmt)} · ${start.toLocaleTimeString("en-US", timeFmt)} – ${end.toLocaleTimeString("en-US", timeFmt)}`
|
||||
}
|
||||
|
||||
// Formats a Date to a short 12-hour time string, e.g. "3pm" or "3:30pm".
|
||||
static formatTimeShort(date) {
|
||||
const h = date.getHours()
|
||||
const m = date.getMinutes()
|
||||
const suffix = h < 12 ? "am" : "pm"
|
||||
const h12 = h % 12 || 12
|
||||
return m === 0 ? `${h12}${suffix}` : `${h12}:${String(m).padStart(2, "0")}${suffix}`
|
||||
}
|
||||
|
||||
// Returns a relative time string, e.g. "3h ago", "2d ago", "just now".
|
||||
static timeAgo(dateStr) {
|
||||
const seconds = Math.floor((Date.now() - new Date(dateStr)) / 1000)
|
||||
if (seconds < 60) return "just now"
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days}d ago`
|
||||
const weeks = Math.floor(days / 7)
|
||||
if (weeks < 5) return `${weeks}w ago`
|
||||
const months = Math.floor(days / 30)
|
||||
if (months < 12) return `${months}mo ago`
|
||||
return `${Math.floor(days / 365)}y ago`
|
||||
}
|
||||
|
||||
// Returns a flat array of 42 Date objects covering the 6-week grid for the
|
||||
// given month, aligned to weekStartsOn (0 = Sun, 1 = Mon).
|
||||
static buildMonthGrid(date, weekStartsOn = 0) {
|
||||
const firstOfMonth = new Date(date.getFullYear(), date.getMonth(), 1)
|
||||
const gridStart = calendarUtil.startOfWeek(firstOfMonth, weekStartsOn)
|
||||
return Array.from({ length: 42 }, (_, i) => calendarUtil.addDays(gridStart, i))
|
||||
}
|
||||
|
||||
// Generates every occurrence Date of a recurring template within [rangeStart, rangeEnd].
|
||||
static generateOccurrenceDates(template, rangeStart, rangeEnd) {
|
||||
const rule = template.recurrence;
|
||||
if (!rule) return [];
|
||||
|
||||
const duration = template.time_end.getTime() - template.time_start.getTime();
|
||||
const seriesStart = new Date(template.time_start);
|
||||
const endDate = rule.end_date ? new Date(rule.end_date) : null;
|
||||
const maxCount = rule.count ?? Infinity;
|
||||
const interval = rule.interval ?? 1;
|
||||
const dates = [];
|
||||
let count = 0;
|
||||
|
||||
// Generous overlap: use at least 1 day of coverage so zero-duration all-day events aren't missed.
|
||||
const inRange = (occ) => {
|
||||
const occEnd = new Date(occ.getTime() + Math.max(duration, 86400000));
|
||||
return occ <= rangeEnd && occEnd >= rangeStart;
|
||||
};
|
||||
|
||||
if (rule.frequency === 'weekly' && rule.days_of_week?.length > 0) {
|
||||
const sortedDays = [...rule.days_of_week].sort((a, b) => a - b);
|
||||
|
||||
// Anchor on the Sunday of the week containing seriesStart
|
||||
const sun = new Date(seriesStart);
|
||||
sun.setDate(sun.getDate() - sun.getDay());
|
||||
sun.setHours(seriesStart.getHours(), seriesStart.getMinutes(), seriesStart.getSeconds(), 0);
|
||||
|
||||
weekLoop: for (let weekOffset = 0; ; weekOffset += interval) {
|
||||
const weekSun = new Date(sun);
|
||||
weekSun.setDate(sun.getDate() + weekOffset * 7);
|
||||
|
||||
if (weekSun > rangeEnd) break;
|
||||
|
||||
for (const dayIdx of sortedDays) {
|
||||
const occ = new Date(weekSun);
|
||||
occ.setDate(weekSun.getDate() + dayIdx);
|
||||
|
||||
if (occ < seriesStart) continue;
|
||||
if (endDate && occ >= endDate) break weekLoop;
|
||||
if (count >= maxCount) break weekLoop;
|
||||
count++;
|
||||
|
||||
if (occ > rangeEnd) break weekLoop;
|
||||
if (inRange(occ)) dates.push(occ);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const advance = (d) => {
|
||||
const next = new Date(d);
|
||||
const day = next.getDate();
|
||||
switch (rule.frequency) {
|
||||
case 'daily': next.setDate(next.getDate() + interval); break;
|
||||
case 'weekly': next.setDate(next.getDate() + interval * 7); break;
|
||||
case 'monthly': next.setMonth(next.getMonth() + interval); break;
|
||||
case 'yearly': next.setFullYear(next.getFullYear() + interval); break;
|
||||
}
|
||||
// Clamp month/year overflow (e.g. Jan 31 + 1 month → Mar 3, or Feb 29 + 1 year → Mar 1)
|
||||
if ((rule.frequency === 'monthly' || rule.frequency === 'yearly') && next.getDate() !== day) {
|
||||
next.setDate(0);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
let current = new Date(seriesStart);
|
||||
while (current <= rangeEnd) {
|
||||
if (endDate && current >= endDate) break;
|
||||
if (count >= maxCount) break;
|
||||
count++;
|
||||
|
||||
if (inRange(current)) dates.push(new Date(current));
|
||||
current = advance(current);
|
||||
}
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
// Expands recurring template events into concrete occurrences within [rangeStart, rangeEnd].
|
||||
// Override rows replace their corresponding virtual occurrence; cancelled ones are skipped.
|
||||
// Non-recurring events pass through unchanged.
|
||||
static expandRecurringEvents(events, rangeStart, rangeEnd) {
|
||||
const templates = events.filter(e => e.recurrence_id && !e.recurrence_parent_id);
|
||||
const overrides = events.filter(e => !!e.recurrence_parent_id);
|
||||
const regular = events.filter(e => !e.recurrence_id && !e.recurrence_parent_id);
|
||||
|
||||
const overrideMap = {};
|
||||
overrides.forEach(ov => {
|
||||
const pid = ov.recurrence_parent_id;
|
||||
if (!overrideMap[pid]) overrideMap[pid] = {};
|
||||
const key = new Date(ov.recurrence_exception_date).toDateString();
|
||||
overrideMap[pid][key] = ov;
|
||||
});
|
||||
|
||||
const result = [...regular];
|
||||
|
||||
templates.forEach(template => {
|
||||
const templateOverrides = overrideMap[template.id] || {};
|
||||
const duration = template.time_end.getTime() - template.time_start.getTime();
|
||||
|
||||
calendarUtil.generateOccurrenceDates(template, rangeStart, rangeEnd).forEach(occDate => {
|
||||
const override = templateOverrides[occDate.toDateString()];
|
||||
|
||||
if (override) {
|
||||
if (!override.is_cancelled) result.push(override);
|
||||
} else {
|
||||
result.push({
|
||||
...template,
|
||||
time_start: occDate,
|
||||
time_end: new Date(occDate.getTime() + duration),
|
||||
_isOccurrence: true,
|
||||
_occurrenceDate: occDate,
|
||||
_templateStart: new Date(template.time_start),
|
||||
_templateEnd: new Date(template.time_end),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
285
calendar/calendarUtil.test.js
Normal file
@@ -0,0 +1,285 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import calendarUtil from './calendarUtil.js'
|
||||
|
||||
// ─── rangesOverlap ────────────────────────────────────────────────────────────
|
||||
describe('rangesOverlap', () => {
|
||||
const d = (h) => new Date(`2024-01-01T${String(h).padStart(2,'0')}:00:00`)
|
||||
|
||||
it('overlapping ranges', () => {
|
||||
expect(calendarUtil.rangesOverlap(d(1), d(5), d(3), d(7))).toBe(true)
|
||||
})
|
||||
it('adjacent ranges (touching) do not overlap', () => {
|
||||
expect(calendarUtil.rangesOverlap(d(1), d(3), d(3), d(5))).toBe(false)
|
||||
})
|
||||
it('non-overlapping ranges', () => {
|
||||
expect(calendarUtil.rangesOverlap(d(1), d(2), d(5), d(7))).toBe(false)
|
||||
})
|
||||
it('one range contained within the other', () => {
|
||||
expect(calendarUtil.rangesOverlap(d(1), d(10), d(3), d(7))).toBe(true)
|
||||
})
|
||||
it('identical ranges overlap', () => {
|
||||
expect(calendarUtil.rangesOverlap(d(1), d(5), d(1), d(5))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── startOfWeek ─────────────────────────────────────────────────────────────
|
||||
describe('startOfWeek', () => {
|
||||
it('Sunday start — Wednesday lands on previous Sunday', () => {
|
||||
const wed = new Date(2024, 0, 3) // Wednesday Jan 3 (local)
|
||||
const result = calendarUtil.startOfWeek(wed, 0)
|
||||
expect(result.getDay()).toBe(0)
|
||||
expect(result.getDate()).toBe(31) // Dec 31
|
||||
})
|
||||
it('Monday start — Wednesday lands on previous Monday', () => {
|
||||
const wed = new Date(2024, 0, 3) // Wednesday Jan 3 (local)
|
||||
const result = calendarUtil.startOfWeek(wed, 1)
|
||||
expect(result.getDay()).toBe(1)
|
||||
expect(result.getDate()).toBe(1) // Jan 1
|
||||
})
|
||||
it('returns start of same day when date is already weekStartsOn', () => {
|
||||
const sun = new Date(2024, 0, 7) // Sunday Jan 7 (local)
|
||||
const result = calendarUtil.startOfWeek(sun, 0)
|
||||
expect(result.toDateString()).toBe(sun.toDateString())
|
||||
})
|
||||
it('zeros out the time', () => {
|
||||
const d = new Date(2024, 0, 3, 15, 30) // Jan 3 15:30 local
|
||||
const result = calendarUtil.startOfWeek(d)
|
||||
expect(result.getHours()).toBe(0)
|
||||
expect(result.getMinutes()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── buildMonthGrid ───────────────────────────────────────────────────────────
|
||||
describe('buildMonthGrid', () => {
|
||||
it('always returns 42 dates', () => {
|
||||
expect(calendarUtil.buildMonthGrid(new Date(2024, 0, 1))).toHaveLength(42)
|
||||
expect(calendarUtil.buildMonthGrid(new Date(2024, 1, 1))).toHaveLength(42)
|
||||
})
|
||||
it('grid starts on a Sunday when weekStartsOn=0', () => {
|
||||
const grid = calendarUtil.buildMonthGrid(new Date(2024, 0, 1), 0)
|
||||
expect(grid[0].getDay()).toBe(0)
|
||||
})
|
||||
it('grid starts on a Monday when weekStartsOn=1', () => {
|
||||
const grid = calendarUtil.buildMonthGrid(new Date(2024, 0, 1), 1)
|
||||
expect(grid[0].getDay()).toBe(1)
|
||||
})
|
||||
it('grid includes all days of the month', () => {
|
||||
const grid = calendarUtil.buildMonthGrid(new Date(2024, 2, 1)) // March 2024
|
||||
const marchDays = grid.filter(d => d.getMonth() === 2)
|
||||
expect(marchDays).toHaveLength(31)
|
||||
})
|
||||
it('consecutive dates are exactly 1 day apart', () => {
|
||||
const grid = calendarUtil.buildMonthGrid(new Date(2024, 0, 1))
|
||||
for (let i = 1; i < grid.length; i++) {
|
||||
const diff = grid[i] - grid[i - 1]
|
||||
expect(diff).toBe(86400000)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ─── getWeekNumber ────────────────────────────────────────────────────────────
|
||||
describe('getWeekNumber', () => {
|
||||
it('Jan 1 2024 is week 1', () => {
|
||||
expect(calendarUtil.getWeekNumber(new Date(2024, 0, 1))).toBe(1)
|
||||
})
|
||||
it('Dec 31 2023 is week 52', () => {
|
||||
expect(calendarUtil.getWeekNumber(new Date(2023, 11, 31))).toBe(52)
|
||||
})
|
||||
it('Jan 4 2021 is week 1 (ISO rule: first week has Thursday)', () => {
|
||||
expect(calendarUtil.getWeekNumber(new Date(2021, 0, 4))).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── timedEnd ─────────────────────────────────────────────────────────────────
|
||||
describe('timedEnd', () => {
|
||||
const t = (iso) => new Date(iso)
|
||||
|
||||
it('returns time_end when event has positive duration', () => {
|
||||
const event = { time_start: t('2024-01-01T10:00'), time_end: t('2024-01-01T11:00') }
|
||||
expect(calendarUtil.timedEnd(event)).toEqual(event.time_end)
|
||||
})
|
||||
it('returns start+1ms for zero-duration event', () => {
|
||||
const start = t('2024-01-01T10:00')
|
||||
const event = { time_start: start, time_end: start }
|
||||
expect(calendarUtil.timedEnd(event).getTime()).toBe(start.getTime() + 1)
|
||||
})
|
||||
it('returns start+1ms when end < start', () => {
|
||||
const event = { time_start: t('2024-01-01T10:00'), time_end: t('2024-01-01T09:00') }
|
||||
expect(calendarUtil.timedEnd(event).getTime()).toBe(event.time_start.getTime() + 1)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── toISO ───────────────────────────────────────────────────────────────────
|
||||
describe('toISO', () => {
|
||||
it('non-all-day: parses string directly', () => {
|
||||
const str = '2024-06-15T14:30:00'
|
||||
expect(new Date(calendarUtil.toISO(str, false)).getFullYear()).toBe(2024)
|
||||
})
|
||||
it('all-day start: midnight', () => {
|
||||
const iso = calendarUtil.toISO('2024-06-15', true, false)
|
||||
expect(new Date(iso).getHours()).toBe(0)
|
||||
expect(new Date(iso).getMinutes()).toBe(0)
|
||||
})
|
||||
it('all-day end: 23:59:59.999', () => {
|
||||
const iso = calendarUtil.toISO('2024-06-15', true, true)
|
||||
const d = new Date(iso)
|
||||
expect(d.getHours()).toBe(23)
|
||||
expect(d.getMinutes()).toBe(59)
|
||||
expect(d.getSeconds()).toBe(59)
|
||||
expect(d.getMilliseconds()).toBe(999)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── generateOccurrenceDates ──────────────────────────────────────────────────
|
||||
describe('generateOccurrenceDates', () => {
|
||||
const range = [new Date('2024-01-01'), new Date('2024-12-31')]
|
||||
|
||||
const makeTemplate = (overrides) => ({
|
||||
time_start: new Date('2024-01-01T10:00'),
|
||||
time_end: new Date('2024-01-01T11:00'),
|
||||
recurrence: { frequency: 'daily', interval: 1 },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
it('daily — correct count for January', () => {
|
||||
const tmpl = makeTemplate({ recurrence: { frequency: 'daily', interval: 1 } })
|
||||
const rangeEnd = new Date('2024-01-31T23:59:59')
|
||||
const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], rangeEnd)
|
||||
expect(dates).toHaveLength(31)
|
||||
})
|
||||
|
||||
it('daily with interval=2 — every other day', () => {
|
||||
const tmpl = makeTemplate({ recurrence: { frequency: 'daily', interval: 2 } })
|
||||
const rangeEnd = new Date('2024-01-10')
|
||||
const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], rangeEnd)
|
||||
// Jan 1, 3, 5, 7, 9 = 5
|
||||
expect(dates).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('daily with count limit', () => {
|
||||
const tmpl = makeTemplate({ recurrence: { frequency: 'daily', interval: 1, count: 5 } })
|
||||
const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], range[1])
|
||||
expect(dates).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('daily with end_date stops before end_date', () => {
|
||||
const tmpl = makeTemplate({ recurrence: { frequency: 'daily', interval: 1, end_date: '2024-01-06' } })
|
||||
const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], range[1])
|
||||
// Jan 1-5 (end_date exclusive)
|
||||
expect(dates).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('weekly — every Monday for 4 weeks', () => {
|
||||
const tmpl = makeTemplate({
|
||||
time_start: new Date('2024-01-01T10:00'), // Monday
|
||||
recurrence: { frequency: 'weekly', interval: 1, count: 4 },
|
||||
})
|
||||
const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], range[1])
|
||||
expect(dates).toHaveLength(4)
|
||||
dates.forEach(d => expect(d.getDay()).toBe(1)) // all Mondays
|
||||
})
|
||||
|
||||
it('weekly with days_of_week — Mon+Wed+Fri', () => {
|
||||
const tmpl = makeTemplate({
|
||||
time_start: new Date('2024-01-01T10:00'), // Monday
|
||||
recurrence: { frequency: 'weekly', interval: 1, days_of_week: [1, 3, 5], count: 6 },
|
||||
})
|
||||
const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], range[1])
|
||||
expect(dates).toHaveLength(6)
|
||||
dates.forEach(d => expect([1, 3, 5]).toContain(d.getDay()))
|
||||
})
|
||||
|
||||
it('monthly — same day each month for 3 months', () => {
|
||||
const tmpl = makeTemplate({ recurrence: { frequency: 'monthly', interval: 1, count: 3 } })
|
||||
const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], range[1])
|
||||
expect(dates).toHaveLength(3)
|
||||
dates.forEach(d => expect(d.getDate()).toBe(1))
|
||||
})
|
||||
|
||||
it('yearly — same day each year', () => {
|
||||
const tmpl = makeTemplate({ recurrence: { frequency: 'yearly', interval: 1, count: 3 } })
|
||||
const rangeEnd = new Date('2026-12-31')
|
||||
const dates = calendarUtil.generateOccurrenceDates(tmpl, range[0], rangeEnd)
|
||||
expect(dates).toHaveLength(3)
|
||||
expect(dates[0].getFullYear()).toBe(2024)
|
||||
expect(dates[1].getFullYear()).toBe(2025)
|
||||
expect(dates[2].getFullYear()).toBe(2026)
|
||||
})
|
||||
|
||||
it('no recurrence rule returns empty array', () => {
|
||||
const tmpl = { time_start: new Date(), time_end: new Date(), recurrence: null }
|
||||
expect(calendarUtil.generateOccurrenceDates(tmpl, range[0], range[1])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ─── expandRecurringEvents ───────────────────────────────────────────────────
|
||||
describe('expandRecurringEvents', () => {
|
||||
const rangeStart = new Date('2024-01-01')
|
||||
const rangeEnd = new Date('2024-01-31')
|
||||
|
||||
const baseTemplate = {
|
||||
id: 1,
|
||||
title: 'Standup',
|
||||
recurrence_id: 'abc',
|
||||
recurrence_parent_id: null,
|
||||
time_start: new Date(2024, 0, 1, 9, 0),
|
||||
time_end: new Date(2024, 0, 1, 9, 30),
|
||||
recurrence: { frequency: 'daily', interval: 1, count: 5 },
|
||||
all_day: false,
|
||||
}
|
||||
|
||||
it('non-recurring events pass through unchanged', () => {
|
||||
const regular = { id: 99, title: 'One-off', recurrence_id: null, recurrence_parent_id: null }
|
||||
const result = calendarUtil.expandRecurringEvents([regular], rangeStart, rangeEnd)
|
||||
expect(result).toContain(regular)
|
||||
})
|
||||
|
||||
it('template expands into correct number of occurrences', () => {
|
||||
const result = calendarUtil.expandRecurringEvents([baseTemplate], rangeStart, rangeEnd)
|
||||
expect(result).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('occurrences have _isOccurrence flag', () => {
|
||||
const result = calendarUtil.expandRecurringEvents([baseTemplate], rangeStart, rangeEnd)
|
||||
result.forEach(e => expect(e._isOccurrence).toBe(true))
|
||||
})
|
||||
|
||||
it('override replaces its occurrence', () => {
|
||||
const override = {
|
||||
id: 2,
|
||||
title: 'Standup (rescheduled)',
|
||||
recurrence_id: null,
|
||||
recurrence_parent_id: 1,
|
||||
recurrence_exception_date: new Date(2024, 0, 2, 9, 0),
|
||||
time_start: new Date(2024, 0, 2, 10, 0),
|
||||
time_end: new Date(2024, 0, 2, 10, 30),
|
||||
is_cancelled: false,
|
||||
}
|
||||
const result = calendarUtil.expandRecurringEvents([baseTemplate, override], rangeStart, rangeEnd)
|
||||
const jan2 = result.filter(e => {
|
||||
const d = new Date(e.time_start)
|
||||
return d.getMonth() === 0 && d.getDate() === 2
|
||||
})
|
||||
expect(jan2).toHaveLength(1)
|
||||
expect(jan2[0].title).toBe('Standup (rescheduled)')
|
||||
})
|
||||
|
||||
it('cancelled override removes the occurrence', () => {
|
||||
const cancelled = {
|
||||
id: 3,
|
||||
recurrence_id: null,
|
||||
recurrence_parent_id: 1,
|
||||
recurrence_exception_date: new Date(2024, 0, 2, 9, 0),
|
||||
time_start: new Date(2024, 0, 2, 9, 0),
|
||||
time_end: new Date(2024, 0, 2, 9, 30),
|
||||
is_cancelled: true,
|
||||
}
|
||||
const result = calendarUtil.expandRecurringEvents([baseTemplate, cancelled], rangeStart, rangeEnd)
|
||||
const jan2 = result.filter(e => {
|
||||
const d = new Date(e.time_start)
|
||||
return d.getMonth() === 0 && d.getDate() === 2
|
||||
})
|
||||
expect(jan2).toHaveLength(0)
|
||||
expect(result).toHaveLength(4) // 5 minus the cancelled one
|
||||
})
|
||||
})
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
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
@@ -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)
|
||||
4
calendar/icons/addevent.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="5 5 90 90" width="90" height="90">
|
||||
<path d="m38.223 43.109v13.391c-0.007812 1.4023 0.54688 2.75 1.5391 3.7383 0.98828 0.99219 2.3359 1.5469 3.7383 1.5391h13.391c1.3984 0 2.7383-0.55859 3.7188-1.5547l32.281-32.277c0.99609-0.98437 1.5586-2.3242 1.5586-3.7227 0-1.3984-0.5625-2.7422-1.5586-3.7227l-13.391-13.391c-0.98047-1-2.3203-1.5625-3.7227-1.5547-1.3984 0-2.7383 0.55859-3.7227 1.5547l-32.305 32.281c-0.97266 0.99219-1.5195 2.3281-1.5273 3.7188zm4.5273 0c0-0.19531 0.082031-0.38672 0.22266-0.52734l32.277-32.277c0.29297-0.28906 0.76172-0.28906 1.0547 0l13.391 13.391c0.28906 0.29297 0.28906 0.76172 0 1.0547l-32.277 32.277c-0.14062 0.14062-0.33203 0.22266-0.52734 0.22266h-13.391c-0.20312 0.007812-0.39844-0.070312-0.53906-0.21094-0.14063-0.14062-0.21875-0.33594-0.21094-0.53906z" fill="#FFFFFF"/>
|
||||
<path d="m92.195 44.723c-1.2578 0-2.2773 1.0195-2.2773 2.2773v33.141c0 2.5898-1.0312 5.0781-2.8633 6.9141-1.8359 1.832-4.3242 2.8633-6.9141 2.8633h-60.281c-5.3984 0-9.7773-4.3789-9.7773-9.7773v-60.281c0-5.3984 4.3789-9.7773 9.7773-9.7773h33.141c0.64453 0.074219 1.2891-0.13281 1.7695-0.56641 0.48438-0.42969 0.75781-1.0469 0.75781-1.6953 0-0.64844-0.27344-1.2656-0.75781-1.6992-0.48047-0.42969-1.125-0.63672-1.7695-0.56641h-33.141c-3.7891 0.007812-7.4258 1.5195-10.105 4.1992-2.6797 2.6797-4.1914 6.3164-4.1992 10.105v60.281c0.007812 3.7891 1.5195 7.4258 4.1992 10.105 2.6797 2.6797 6.3164 4.1914 10.105 4.1992h60.281c3.7891-0.007812 7.4258-1.5195 10.105-4.1992 2.6797-2.6797 4.1914-6.3164 4.1992-10.105v-33.141c0-1.2461-1.0039-2.2617-2.25-2.2773z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
4
calendar/icons/addeventlight.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="5 5 90 90" width="90" height="90">
|
||||
<path d="m38.223 43.109v13.391c-0.007812 1.4023 0.54688 2.75 1.5391 3.7383 0.98828 0.99219 2.3359 1.5469 3.7383 1.5391h13.391c1.3984 0 2.7383-0.55859 3.7188-1.5547l32.281-32.277c0.99609-0.98437 1.5586-2.3242 1.5586-3.7227 0-1.3984-0.5625-2.7422-1.5586-3.7227l-13.391-13.391c-0.98047-1-2.3203-1.5625-3.7227-1.5547-1.3984 0-2.7383 0.55859-3.7227 1.5547l-32.305 32.281c-0.97266 0.99219-1.5195 2.3281-1.5273 3.7188zm4.5273 0c0-0.19531 0.082031-0.38672 0.22266-0.52734l32.277-32.277c0.29297-0.28906 0.76172-0.28906 1.0547 0l13.391 13.391c0.28906 0.29297 0.28906 0.76172 0 1.0547l-32.277 32.277c-0.14062 0.14062-0.33203 0.22266-0.52734 0.22266h-13.391c-0.20312 0.007812-0.39844-0.070312-0.53906-0.21094-0.14063-0.14062-0.21875-0.33594-0.21094-0.53906z" fill="#FFE9C8"/>
|
||||
<path d="m92.195 44.723c-1.2578 0-2.2773 1.0195-2.2773 2.2773v33.141c0 2.5898-1.0312 5.0781-2.8633 6.9141-1.8359 1.832-4.3242 2.8633-6.9141 2.8633h-60.281c-5.3984 0-9.7773-4.3789-9.7773-9.7773v-60.281c0-5.3984 4.3789-9.7773 9.7773-9.7773h33.141c0.64453 0.074219 1.2891-0.13281 1.7695-0.56641 0.48438-0.42969 0.75781-1.0469 0.75781-1.6953 0-0.64844-0.27344-1.2656-0.75781-1.6992-0.48047-0.42969-1.125-0.63672-1.7695-0.56641h-33.141c-3.7891 0.007812-7.4258 1.5195-10.105 4.1992-2.6797 2.6797-4.1914 6.3164-4.1992 10.105v60.281c0.007812 3.7891 1.5195 7.4258 4.1992 10.105 2.6797 2.6797 6.3164 4.1914 10.105 4.1992h60.281c3.7891-0.007812 7.4258-1.5195 10.105-4.1992 2.6797-2.6797 4.1914-6.3164 4.1992-10.105v-33.141c0-1.2461-1.0039-2.2617-2.25-2.2773z" fill="#FFE9C8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
4
calendar/icons/addeventlightselected.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="5 5 90 90" width="90" height="90">
|
||||
<path d="m38.223 43.109v13.391c-0.007812 1.4023 0.54688 2.75 1.5391 3.7383 0.98828 0.99219 2.3359 1.5469 3.7383 1.5391h13.391c1.3984 0 2.7383-0.55859 3.7188-1.5547l32.281-32.277c0.99609-0.98437 1.5586-2.3242 1.5586-3.7227 0-1.3984-0.5625-2.7422-1.5586-3.7227l-13.391-13.391c-0.98047-1-2.3203-1.5625-3.7227-1.5547-1.3984 0-2.7383 0.55859-3.7227 1.5547l-32.305 32.281c-0.97266 0.99219-1.5195 2.3281-1.5273 3.7188zm4.5273 0c0-0.19531 0.082031-0.38672 0.22266-0.52734l32.277-32.277c0.29297-0.28906 0.76172-0.28906 1.0547 0l13.391 13.391c0.28906 0.29297 0.28906 0.76172 0 1.0547l-32.277 32.277c-0.14062 0.14062-0.33203 0.22266-0.52734 0.22266h-13.391c-0.20312 0.007812-0.39844-0.070312-0.53906-0.21094-0.14063-0.14062-0.21875-0.33594-0.21094-0.53906z" fill="#BD2D2D" stroke="#FFD5A4" stroke-width="0.5"/>
|
||||
<path d="m92.195 44.723c-1.2578 0-2.2773 1.0195-2.2773 2.2773v33.141c0 2.5898-1.0312 5.0781-2.8633 6.9141-1.8359 1.832-4.3242 2.8633-6.9141 2.8633h-60.281c-5.3984 0-9.7773-4.3789-9.7773-9.7773v-60.281c0-5.3984 4.3789-9.7773 9.7773-9.7773h33.141c0.64453 0.074219 1.2891-0.13281 1.7695-0.56641 0.48438-0.42969 0.75781-1.0469 0.75781-1.6953 0-0.64844-0.27344-1.2656-0.75781-1.6992-0.48047-0.42969-1.125-0.63672-1.7695-0.56641h-33.141c-3.7891 0.007812-7.4258 1.5195-10.105 4.1992-2.6797 2.6797-4.1914 6.3164-4.1992 10.105v60.281c0.007812 3.7891 1.5195 7.4258 4.1992 10.105 2.6797 2.6797 6.3164 4.1914 10.105 4.1992h60.281c3.7891-0.007812 7.4258-1.5195 10.105-4.1992 2.6797-2.6797 4.1914-6.3164 4.1992-10.105v-33.141c0-1.2461-1.0039-2.2617-2.25-2.2773z" fill="#BD2D2D" stroke="#FFD5A4" stroke-width="0.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
3
calendar/icons/calbutton.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="4 5.5 92 89" width="90" height="89">
|
||||
<path d="m26.703 4.9766c-3.5625 0-6.4766 2.9141-6.4766 6.4766v2.5938h-6.0312c-5.4688 0-9.9102 4.4531-9.9102 9.9219v61.133c0 5.4688 4.4414 9.9258 9.9102 9.9258l71.598-0.003906c5.4688 0 9.9219-4.4531 9.9219-9.9258v-61.129c0-5.4688-4.4531-9.9219-9.9219-9.9219h-6.0195l-0.003907-2.5977c0-3.5625-2.9258-6.4766-6.4883-6.4766s-6.4727 2.9141-6.4727 6.4766l0.003906 2.5977h-33.633v-2.5977c0-3.5625-2.9141-6.4766-6.4766-6.4766zm0 2.8594c2.0312 0 3.6172 1.5859 3.6172 3.6172v8.582c0 2.0312-1.5859 3.6172-3.6172 3.6172s-3.6172-1.5898-3.6172-3.6172v-8.582c0-2.0312 1.5859-3.6172 3.6172-3.6172zm46.578 0c2.0312 0 3.6289 1.5859 3.6289 3.6172v8.582c0 2.0312-1.5977 3.6172-3.6289 3.6172s-3.6172-1.5898-3.6172-3.6172l0.003907-4.5547v-4.0312c0-2.0312 1.5859-3.6172 3.6172-3.6172zm-59.086 9.0664h6.0312v3.1289c0 3.5625 2.9141 6.4766 6.4766 6.4766s6.4766-2.9141 6.4766-6.4766v-3.1289h33.629v3.1289c0 3.5625 2.9102 6.4766 6.4727 6.4766s6.4883-2.9141 6.4883-6.4766v-3.1289h6.0195c3.9375 0 7.0664 3.1289 7.0664 7.0664v9.1016h-85.711v-9.1016c0-3.9375 3.1172-7.0664 7.0547-7.0664zm-7.0547 19.023h85.715v49.176c0 3.9375-3.1289 7.0664-7.0664 7.0664l-71.594-0.003907c-3.9375 0-7.0547-3.1289-7.0547-7.0664zm15.703 7.7305c-2.4219 0-4.418 1.9844-4.418 4.4062v8.3203c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3203c0-2.4219-1.9961-4.4062-4.418-4.4062zm22.988 0c-2.4219 0-4.418 1.9844-4.418 4.4062v8.3203c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3203c0-2.4219-1.9961-4.4062-4.418-4.4062zm22.98 0c-2.4219 0-4.4102 1.9844-4.4102 4.4062v8.3203c0 2.4219 1.9883 4.4102 4.4102 4.4102h8.3398c2.4219 0 4.4102-1.9883 4.4102-4.4102v-8.3203c0-2.4219-1.9883-4.4062-4.4102-4.4062zm-45.965 2.8594h8.332c0.89062 0 1.5586 0.66406 1.5586 1.5469v8.3203c0 0.88281-0.66797 1.5508-1.5586 1.5508h-8.332c-0.89062 0-1.5625-0.66797-1.5625-1.5508v-8.3203c0-0.88281 0.67188-1.5469 1.5625-1.5469zm22.988 0h8.332c0.89062 0 1.5586 0.66406 1.5586 1.5469v8.3203c0 0.88281-0.66797 1.5508-1.5586 1.5508h-8.332c-0.89063 0-1.5586-0.66797-1.5586-1.5508l-0.003906-8.3203c0-0.88281 0.66797-1.5469 1.5586-1.5469zm22.98 0h8.3398c0.89062 0 1.5625 0.66406 1.5625 1.5469v8.3203c0 0.88281-0.67188 1.5508-1.5625 1.5508h-8.3398c-0.89062 0-1.5508-0.66797-1.5508-1.5508l-0.003906-8.3203c0-0.88281 0.66016-1.5469 1.5508-1.5469zm-45.969 20.172c-2.4219 0-4.418 2-4.418 4.418v8.3086c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3086c0-2.4219-1.9961-4.418-4.418-4.418zm22.988 0c-2.4219 0-4.418 2-4.418 4.418v8.3086c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3086c0-2.4219-1.9961-4.418-4.418-4.418zm22.98 0c-2.4219 0-4.4102 2-4.4102 4.418v8.3086c0 2.4219 1.9883 4.4102 4.4102 4.4102h8.3398c2.4219 0 4.4102-1.9883 4.4102-4.4102v-8.3086c0-2.4219-1.9883-4.418-4.4102-4.418zm-45.969 2.8555h8.332c0.89062 0 1.5586 0.67969 1.5586 1.5625v8.3086c0 0.88281-0.66797 1.5586-1.5586 1.5586h-8.332c-0.89062 0-1.5625-0.67578-1.5625-1.5586v-8.3086c0-0.88281 0.67188-1.5625 1.5625-1.5625zm22.988 0h8.332c0.89062 0 1.5586 0.67969 1.5586 1.5625v8.3086c0 0.88281-0.66797 1.5586-1.5586 1.5586l-8.332 0.003906c-0.89063 0-1.5586-0.67578-1.5586-1.5586v-8.3086c0-0.88281 0.66797-1.5625 1.5586-1.5625zm22.98 0h8.3398c0.89062 0 1.5625 0.67969 1.5625 1.5625v8.3086c0 0.88281-0.67188 1.5586-1.5625 1.5586h-8.3398c-0.89062 0-1.5508-0.67578-1.5508-1.5586v-8.3086c0-0.88281 0.66016-1.5625 1.5508-1.5625z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
3
calendar/icons/calbuttonlight.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="4 5.5 92 89" width="90" height="89">
|
||||
<path d="m26.703 4.9766c-3.5625 0-6.4766 2.9141-6.4766 6.4766v2.5938h-6.0312c-5.4688 0-9.9102 4.4531-9.9102 9.9219v61.133c0 5.4688 4.4414 9.9258 9.9102 9.9258l71.598-0.003906c5.4688 0 9.9219-4.4531 9.9219-9.9258v-61.129c0-5.4688-4.4531-9.9219-9.9219-9.9219h-6.0195l-0.003907-2.5977c0-3.5625-2.9258-6.4766-6.4883-6.4766s-6.4727 2.9141-6.4727 6.4766l0.003906 2.5977h-33.633v-2.5977c0-3.5625-2.9141-6.4766-6.4766-6.4766zm0 2.8594c2.0312 0 3.6172 1.5859 3.6172 3.6172v8.582c0 2.0312-1.5859 3.6172-3.6172 3.6172s-3.6172-1.5898-3.6172-3.6172v-8.582c0-2.0312 1.5859-3.6172 3.6172-3.6172zm46.578 0c2.0312 0 3.6289 1.5859 3.6289 3.6172v8.582c0 2.0312-1.5977 3.6172-3.6289 3.6172s-3.6172-1.5898-3.6172-3.6172l0.003907-4.5547v-4.0312c0-2.0312 1.5859-3.6172 3.6172-3.6172zm-59.086 9.0664h6.0312v3.1289c0 3.5625 2.9141 6.4766 6.4766 6.4766s6.4766-2.9141 6.4766-6.4766v-3.1289h33.629v3.1289c0 3.5625 2.9102 6.4766 6.4727 6.4766s6.4883-2.9141 6.4883-6.4766v-3.1289h6.0195c3.9375 0 7.0664 3.1289 7.0664 7.0664v9.1016h-85.711v-9.1016c0-3.9375 3.1172-7.0664 7.0547-7.0664zm-7.0547 19.023h85.715v49.176c0 3.9375-3.1289 7.0664-7.0664 7.0664l-71.594-0.003907c-3.9375 0-7.0547-3.1289-7.0547-7.0664zm15.703 7.7305c-2.4219 0-4.418 1.9844-4.418 4.4062v8.3203c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3203c0-2.4219-1.9961-4.4062-4.418-4.4062zm22.988 0c-2.4219 0-4.418 1.9844-4.418 4.4062v8.3203c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3203c0-2.4219-1.9961-4.4062-4.418-4.4062zm22.98 0c-2.4219 0-4.4102 1.9844-4.4102 4.4062v8.3203c0 2.4219 1.9883 4.4102 4.4102 4.4102h8.3398c2.4219 0 4.4102-1.9883 4.4102-4.4102v-8.3203c0-2.4219-1.9883-4.4062-4.4102-4.4062zm-45.965 2.8594h8.332c0.89062 0 1.5586 0.66406 1.5586 1.5469v8.3203c0 0.88281-0.66797 1.5508-1.5586 1.5508h-8.332c-0.89062 0-1.5625-0.66797-1.5625-1.5508v-8.3203c0-0.88281 0.67188-1.5469 1.5625-1.5469zm22.988 0h8.332c0.89062 0 1.5586 0.66406 1.5586 1.5469v8.3203c0 0.88281-0.66797 1.5508-1.5586 1.5508h-8.332c-0.89063 0-1.5586-0.66797-1.5586-1.5508l-0.003906-8.3203c0-0.88281 0.66797-1.5469 1.5586-1.5469zm22.98 0h8.3398c0.89062 0 1.5625 0.66406 1.5625 1.5469v8.3203c0 0.88281-0.67188 1.5508-1.5625 1.5508h-8.3398c-0.89062 0-1.5508-0.66797-1.5508-1.5508l-0.003906-8.3203c0-0.88281 0.66016-1.5469 1.5508-1.5469zm-45.969 20.172c-2.4219 0-4.418 2-4.418 4.418v8.3086c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3086c0-2.4219-1.9961-4.418-4.418-4.418zm22.988 0c-2.4219 0-4.418 2-4.418 4.418v8.3086c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3086c0-2.4219-1.9961-4.418-4.418-4.418zm22.98 0c-2.4219 0-4.4102 2-4.4102 4.418v8.3086c0 2.4219 1.9883 4.4102 4.4102 4.4102h8.3398c2.4219 0 4.4102-1.9883 4.4102-4.4102v-8.3086c0-2.4219-1.9883-4.418-4.4102-4.418zm-45.969 2.8555h8.332c0.89062 0 1.5586 0.67969 1.5586 1.5625v8.3086c0 0.88281-0.66797 1.5586-1.5586 1.5586h-8.332c-0.89062 0-1.5625-0.67578-1.5625-1.5586v-8.3086c0-0.88281 0.67188-1.5625 1.5625-1.5625zm22.988 0h8.332c0.89062 0 1.5586 0.67969 1.5586 1.5625v8.3086c0 0.88281-0.66797 1.5586-1.5586 1.5586l-8.332 0.003906c-0.89063 0-1.5586-0.67578-1.5586-1.5586v-8.3086c0-0.88281 0.66797-1.5625 1.5586-1.5625zm22.98 0h8.3398c0.89062 0 1.5625 0.67969 1.5625 1.5625v8.3086c0 0.88281-0.67188 1.5586-1.5625 1.5586h-8.3398c-0.89062 0-1.5508-0.67578-1.5508-1.5586v-8.3086c0-0.88281 0.66016-1.5625 1.5508-1.5625z" fill="#FFE9C8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
3
calendar/icons/calbuttonlightselected.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="4 5.5 92 89" width="90" height="89">
|
||||
<path d="m26.703 4.9766c-3.5625 0-6.4766 2.9141-6.4766 6.4766v2.5938h-6.0312c-5.4688 0-9.9102 4.4531-9.9102 9.9219v61.133c0 5.4688 4.4414 9.9258 9.9102 9.9258l71.598-0.003906c5.4688 0 9.9219-4.4531 9.9219-9.9258v-61.129c0-5.4688-4.4531-9.9219-9.9219-9.9219h-6.0195l-0.003907-2.5977c0-3.5625-2.9258-6.4766-6.4883-6.4766s-6.4727 2.9141-6.4727 6.4766l0.003906 2.5977h-33.633v-2.5977c0-3.5625-2.9141-6.4766-6.4766-6.4766zm0 2.8594c2.0312 0 3.6172 1.5859 3.6172 3.6172v8.582c0 2.0312-1.5859 3.6172-3.6172 3.6172s-3.6172-1.5898-3.6172-3.6172v-8.582c0-2.0312 1.5859-3.6172 3.6172-3.6172zm46.578 0c2.0312 0 3.6289 1.5859 3.6289 3.6172v8.582c0 2.0312-1.5977 3.6172-3.6289 3.6172s-3.6172-1.5898-3.6172-3.6172l0.003907-4.5547v-4.0312c0-2.0312 1.5859-3.6172 3.6172-3.6172zm-59.086 9.0664h6.0312v3.1289c0 3.5625 2.9141 6.4766 6.4766 6.4766s6.4766-2.9141 6.4766-6.4766v-3.1289h33.629v3.1289c0 3.5625 2.9102 6.4766 6.4727 6.4766s6.4883-2.9141 6.4883-6.4766v-3.1289h6.0195c3.9375 0 7.0664 3.1289 7.0664 7.0664v9.1016h-85.711v-9.1016c0-3.9375 3.1172-7.0664 7.0547-7.0664zm-7.0547 19.023h85.715v49.176c0 3.9375-3.1289 7.0664-7.0664 7.0664l-71.594-0.003907c-3.9375 0-7.0547-3.1289-7.0547-7.0664zm15.703 7.7305c-2.4219 0-4.418 1.9844-4.418 4.4062v8.3203c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3203c0-2.4219-1.9961-4.4062-4.418-4.4062zm22.988 0c-2.4219 0-4.418 1.9844-4.418 4.4062v8.3203c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3203c0-2.4219-1.9961-4.4062-4.418-4.4062zm22.98 0c-2.4219 0-4.4102 1.9844-4.4102 4.4062v8.3203c0 2.4219 1.9883 4.4102 4.4102 4.4102h8.3398c2.4219 0 4.4102-1.9883 4.4102-4.4102v-8.3203c0-2.4219-1.9883-4.4062-4.4102-4.4062zm-45.965 2.8594h8.332c0.89062 0 1.5586 0.66406 1.5586 1.5469v8.3203c0 0.88281-0.66797 1.5508-1.5586 1.5508h-8.332c-0.89062 0-1.5625-0.66797-1.5625-1.5508v-8.3203c0-0.88281 0.67188-1.5469 1.5625-1.5469zm22.988 0h8.332c0.89062 0 1.5586 0.66406 1.5586 1.5469v8.3203c0 0.88281-0.66797 1.5508-1.5586 1.5508h-8.332c-0.89063 0-1.5586-0.66797-1.5586-1.5508l-0.003906-8.3203c0-0.88281 0.66797-1.5469 1.5586-1.5469zm22.98 0h8.3398c0.89062 0 1.5625 0.66406 1.5625 1.5469v8.3203c0 0.88281-0.67188 1.5508-1.5625 1.5508h-8.3398c-0.89062 0-1.5508-0.66797-1.5508-1.5508l-0.003906-8.3203c0-0.88281 0.66016-1.5469 1.5508-1.5469zm-45.969 20.172c-2.4219 0-4.418 2-4.418 4.418v8.3086c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3086c0-2.4219-1.9961-4.418-4.418-4.418zm22.988 0c-2.4219 0-4.418 2-4.418 4.418v8.3086c0 2.4219 2 4.4102 4.418 4.4102h8.332c2.4219 0 4.418-1.9883 4.418-4.4102v-8.3086c0-2.4219-1.9961-4.418-4.418-4.418zm22.98 0c-2.4219 0-4.4102 2-4.4102 4.418v8.3086c0 2.4219 1.9883 4.4102 4.4102 4.4102h8.3398c2.4219 0 4.4102-1.9883 4.4102-4.4102v-8.3086c0-2.4219-1.9883-4.418-4.4102-4.418zm-45.969 2.8555h8.332c0.89062 0 1.5586 0.67969 1.5586 1.5625v8.3086c0 0.88281-0.66797 1.5586-1.5586 1.5586h-8.332c-0.89062 0-1.5625-0.67578-1.5625-1.5586v-8.3086c0-0.88281 0.67188-1.5625 1.5625-1.5625zm22.988 0h8.332c0.89062 0 1.5586 0.67969 1.5586 1.5625v8.3086c0 0.88281-0.66797 1.5586-1.5586 1.5586l-8.332 0.003906c-0.89063 0-1.5586-0.67578-1.5586-1.5586v-8.3086c0-0.88281 0.66797-1.5625 1.5586-1.5625zm22.98 0h8.3398c0.89062 0 1.5625 0.67969 1.5625 1.5625v8.3086c0 0.88281-0.67188 1.5586-1.5625 1.5586h-8.3398c-0.89062 0-1.5508-0.67578-1.5508-1.5586v-8.3086c0-0.88281 0.66016-1.5625 1.5508-1.5625z" fill="#BD2D2D" stroke="#FFD5A4" stroke-width="0.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
3
calendar/icons/calendar.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="92" height="73" viewBox="0 0 92 73" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M34.7556 0C34.3767 0 34.0134 0.15234 33.7439 0.42188C33.4782 0.69141 33.3259 1.05469 33.3298 1.43358V5.24998H24.6189C21.2712 5.24998 18.5251 7.99608 18.5251 11.3477V21.1758C15.5368 34.2268 9.5993 46.4138 1.1541 56.8008C1.14629 56.8086 1.13848 56.8203 1.13457 56.832L0.736131 57.3477C-1.09197 59.7305 0.704881 63.3593 3.70883 63.3593H18.5248V65.2343C18.5248 69.1445 21.7201 72.3438 25.6303 72.3438H84.3183C88.2285 72.3438 91.4277 69.1445 91.4277 65.2343V21.4574V21.3988V19.3246V19.2894V11.348C91.4277 7.9964 88.6816 5.2542 85.33 5.2542H76.6308V1.4339C76.6308 1.05499 76.4824 0.68781 76.2129 0.4183C75.9433 0.14877 75.5761 0.000329971 75.1973 0.000329971C74.8184 0.000329971 74.4551 0.15267 74.1895 0.42221C73.9199 0.69174 73.7715 1.05502 73.7715 1.43391V5.25421L36.1895 5.2503V1.4339C36.1895 1.05499 36.0411 0.687814 35.7715 0.418303C35.502 0.148773 35.1344 0 34.7556 0ZM24.6186 8.1094H33.3295V11.9375C33.3334 12.7266 33.9701 13.3594 34.7553 13.3633C35.1342 13.3633 35.4975 13.2149 35.767 12.9492C36.0365 12.6797 36.1888 12.3164 36.1888 11.9375V8.10944H73.7748V11.9375H73.7709C73.7748 12.7227 74.4115 13.3594 75.1967 13.3633C75.5756 13.3633 75.9389 13.2149 76.2084 12.9493C76.478 12.6798 76.6264 12.3165 76.6303 11.9376V8.10948H85.3295C87.1342 8.10948 88.5678 9.53918 88.5678 11.3478V19.965H21.3798V19.3634V19.2892V11.3478C21.3798 9.5431 22.8139 8.1133 24.6186 8.1094ZM21.0874 22.8204H88.1774C85.0797 35.2464 79.2985 46.8514 71.2084 56.8004C71.1966 56.8082 71.1888 56.8199 71.1771 56.8317L70.7865 57.3473C69.2631 59.3356 66.9076 60.5035 64.4037 60.5035H19.9627C19.9588 60.4996 19.951 60.4996 19.9471 60.4996C19.9432 60.4996 19.9353 60.4996 19.9314 60.5035H3.70844C2.88813 60.5035 2.51314 59.7379 3.00922 59.0894L3.37641 58.605L3.38813 58.5855C11.8881 48.1285 17.9354 35.9104 21.0874 22.8204ZM88.5644 31.5274V65.2344C88.5644 67.6016 86.6816 69.4961 84.3144 69.4961H25.6304C23.2632 69.4961 21.3804 67.6016 21.3804 65.2344V63.3594H64.4074C67.798 63.3594 70.9933 61.7774 73.0558 59.086L73.423 58.6016C80.0127 50.5 85.1264 41.3354 88.5644 31.5274Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
3
calendar/icons/calendarlight.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="88" height="69" viewBox="0 0 88 69" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M33.4525 0C33.0878 0 32.7382 0.145299 32.4788 0.40238C32.2231 0.659453 32.0765 1.00594 32.0802 1.36732V5.00732H23.6959C20.4737 5.00732 17.8306 7.6265 17.8306 10.8232V20.197C14.9543 32.6448 9.23941 44.2685 1.11083 54.1754C1.10331 54.1829 1.09579 54.1941 1.09203 54.2052L0.708533 54.697C-1.05103 56.9697 0.678454 60.4309 3.56978 60.4309H17.8303V62.2192C17.8303 65.9487 20.9058 69 24.6694 69H81.1571C84.9207 69 88 65.9487 88 62.2192V20.4656V20.4097V18.4314V18.3978V10.8235C88 7.6268 85.3568 5.01135 82.1309 5.01135H73.7579V1.36762C73.7579 1.00623 73.615 0.656019 73.3555 0.398966C73.0961 0.141894 72.7427 0.00031472 72.378 0.00031472C72.0133 0.00031472 71.6637 0.145613 71.408 0.402695C71.1486 0.659767 71.0057 1.00626 71.0057 1.36763V5.01136L34.8327 5.00763V1.36763C34.8327 1.00623 34.6898 0.656023 34.4304 0.398969C34.171 0.141897 33.8172 0 33.4525 0ZM23.6956 7.73458H32.0799V11.3857C32.0837 12.1383 32.6965 12.7419 33.4522 12.7456C33.817 12.7456 34.1666 12.6041 34.426 12.3507C34.6854 12.0937 34.8321 11.7472 34.8321 11.3858V7.73462H71.0089V11.3858H71.0052C71.0089 12.1347 71.6218 12.742 72.3775 12.7457C72.7422 12.7457 73.0919 12.6041 73.3513 12.3508C73.6107 12.0937 73.7536 11.7472 73.7573 11.3858V7.73466H82.1304C83.8674 7.73466 85.2473 9.09828 85.2473 10.8233V19.0422H20.5783V18.4684V18.3977V10.8233C20.5783 9.10202 21.9585 7.7383 23.6956 7.73458ZM20.2968 21.7656H84.8715C81.8899 33.6173 76.3255 44.6859 68.5387 54.1751C68.5274 54.1825 68.5199 54.1937 68.5086 54.2049L68.1326 54.6967C66.6663 56.5931 64.3991 57.707 61.9891 57.707H19.2143C19.2105 57.7033 19.203 57.7033 19.1992 57.7033C19.1955 57.7033 19.1879 57.7033 19.1842 57.707H3.56941C2.77985 57.707 2.41892 56.9768 2.8964 56.3582L3.24983 55.8963L3.26111 55.8776C11.4424 45.904 17.2629 34.2506 20.2968 21.7656ZM85.244 30.0702V62.2192C85.244 64.477 83.4318 66.284 81.1533 66.284H24.6695C22.391 66.284 20.5788 64.477 20.5788 62.2192V60.4309H61.9927C65.2561 60.4309 68.3316 58.922 70.3168 56.355L70.6702 55.893C77.013 48.1659 81.9349 39.4249 85.244 30.0702Z" fill="#FFE9C8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
3
calendar/icons/calendarlightselected.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="89" height="70" viewBox="0 0 89 70" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M72.8799 0.25C73.3087 0.25 73.7257 0.416994 74.0332 0.72168C74.3407 1.02634 74.5097 1.44034 74.5098 1.86719V5.26172H82.6328C85.9944 5.26172 88.7518 7.98625 88.752 11.3232V62.7188C88.752 66.5884 85.5587 69.75 81.6592 69.75H25.1709C21.2714 69.7497 18.082 66.5881 18.082 62.7188V61.1807H4.07129C0.977827 61.1803 -0.877206 57.485 1.0127 55.0439L1.01367 55.043L1.38574 54.5645C1.39618 54.5468 1.40769 54.5308 1.41992 54.5166L2.17188 53.5859C9.87157 43.9211 15.3033 32.6784 18.083 20.665V11.3232C18.083 7.98644 20.84 5.25781 24.1982 5.25781H32.332V1.86719C32.3283 1.43709 32.5035 1.02844 32.8037 0.726562L32.8047 0.724609C33.1103 0.421755 33.523 0.250102 33.9541 0.25C34.383 0.25 34.801 0.417052 35.1084 0.72168C35.4158 1.02632 35.5848 1.44043 35.585 1.86719V5.25781L71.2578 5.26074V1.86719C71.2579 1.44034 71.4264 1.03058 71.7324 0.726562L71.8516 0.618164C72.1394 0.381566 72.503 0.250021 72.8799 0.25ZM85.4961 31.9873C82.1901 40.8638 77.4261 49.1646 71.3701 56.5439L71.3711 56.5449L71.0176 57.0068L71.0166 57.0078C68.9839 59.6362 65.8347 61.1807 62.4941 61.1807H21.3311V62.7197C21.3313 64.838 23.0304 66.5342 25.1719 66.5342H81.6553C83.7967 66.5342 85.4958 64.838 85.4961 62.7197V31.9873ZM20.9941 22.5156C17.9401 34.9588 12.1246 46.5722 3.96777 56.5205L3.96582 56.5254L3.95898 56.5371L3.9502 56.5479L3.59668 57.0098V57.0107C3.40752 57.2558 3.40294 57.4916 3.4834 57.6533C3.56401 57.8154 3.75623 57.957 4.07129 57.957H19.6533C19.6787 57.9529 19.7005 57.9531 19.7012 57.9531C19.7016 57.9531 19.7235 57.9529 19.749 57.957H62.4912C64.8238 57.957 67.0177 56.8788 68.4365 55.0439L68.8115 54.5527L68.8223 54.5391L68.835 54.5273L68.8359 54.5264C68.8373 54.5249 68.8394 54.5221 68.8418 54.5195C68.8444 54.5167 68.8488 54.5135 68.8535 54.5088C76.5454 45.133 82.0603 34.211 85.0508 22.5156H20.9941ZM23.9014 8.5C22.4425 8.64669 21.3301 9.84168 21.3301 11.3232V19.292H85.499V11.3232C85.499 9.73882 84.2339 8.48461 82.6328 8.48438H74.5098V11.8887C74.5053 12.3144 74.3363 12.724 74.0293 13.0283L74.0283 13.0293C73.7219 13.3286 73.3091 13.496 72.8799 13.4961H72.8779C71.9873 13.4915 71.2613 12.7747 71.2568 11.8867L71.2559 11.6357H71.2607V8.48438H35.584V11.8857C35.584 12.3152 35.4093 12.7253 35.1035 13.0283L35.1025 13.0293C34.796 13.3287 34.3835 13.4961 33.9541 13.4961H33.9531C33.0628 13.4917 32.3365 12.779 32.332 11.8867V8.48438H24.1982L23.9014 8.5Z" fill="#BD2D2D" stroke="#FFD5A4" stroke-width="0.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |