This commit is contained in:
metacryst
2026-04-28 20:05:00 -05:00
commit 0d6c7683ff
123 changed files with 20922 additions and 0 deletions

437
calendar/CalendarForm.js Normal file
View 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)

View 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
View 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
View 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)

View 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

File diff suppressed because it is too large Load Diff

View 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
View 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)

View 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
View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

View 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
View 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
View 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
View 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;
}
}

View 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
})
})

View 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)

View 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)

View 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)

View 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)

View 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)

View 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)

File diff suppressed because it is too large Load Diff

View 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)

View 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)

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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