init
This commit is contained in:
126
calendar/Toolbar/BottomBar.js
Normal file
126
calendar/Toolbar/BottomBar.js
Normal file
@@ -0,0 +1,126 @@
|
||||
css(`
|
||||
bottombar- {
|
||||
pointer-events: none;
|
||||
}
|
||||
bottombar- select {
|
||||
-webkit-appearance: none;
|
||||
text-align-last: center;
|
||||
-webkit-text-align-last: center;
|
||||
}
|
||||
`)
|
||||
|
||||
class BottomBar extends Shadow {
|
||||
baseUrl = `${config.UI}/apps/calendar/icons`
|
||||
|
||||
constructor(callbacks = {}) {
|
||||
super()
|
||||
this.onAddEvent = callbacks.onAddEvent;
|
||||
this.onCalendarOptions = callbacks.onCalendarOptions;
|
||||
this.onChangeView = callbacks.onChangeView;
|
||||
this.viewMode = callbacks.viewMode;
|
||||
this.hideViewSelect = callbacks.hideViewSelect ?? false;
|
||||
}
|
||||
|
||||
getImageURL(iconName) {
|
||||
let imgUrl = `${this.baseUrl}/${iconName}`
|
||||
if(util.darkMode()) {
|
||||
imgUrl += "light"
|
||||
}
|
||||
imgUrl += ".svg"
|
||||
imgUrl = imgUrl.toLowerCase()
|
||||
return imgUrl
|
||||
}
|
||||
|
||||
render() {
|
||||
HStack(() => {
|
||||
// Left: view mode select
|
||||
if (!this.hideViewSelect) select(() => {
|
||||
["day", "week", "month"].forEach(mode => {
|
||||
const o = option(mode.charAt(0).toUpperCase() + mode.slice(1))
|
||||
.attr({ value: mode })
|
||||
if (mode === this.viewMode) o.attr({ selected: true })
|
||||
})
|
||||
})
|
||||
.fontSize(1, em)
|
||||
.paddingVertical(0.6, em)
|
||||
.paddingHorizontal(0.75, em)
|
||||
.background("var(--button-color)")
|
||||
.backdropFilter("blur(10px)")
|
||||
.borderRadius(100, px)
|
||||
.pointerEvents("auto")
|
||||
.border(`.5px solid ${util.darkMode() ? "var(--accent)" : "transparent"}`)
|
||||
.color(util.darkMode() ? "var(--text)" : "white")
|
||||
.onTouch(function (start) {
|
||||
if(start) {
|
||||
this.background("var(--main)")
|
||||
} else {
|
||||
this.background("var(--button-color)")
|
||||
}
|
||||
})
|
||||
.fontFamily("Arial")
|
||||
.outline("none")
|
||||
.appearance("none")
|
||||
.textAlign("center")
|
||||
.onChange(e => this.onChangeView(e.target.value))
|
||||
|
||||
// Right: add event + calendar options
|
||||
HStack(() => {
|
||||
const addEventImg = img(this.getImageURL("addevent"), "1em", "1em")
|
||||
.paddingVertical(0.75, em).paddingHorizontal(1, em)
|
||||
.onTap(async () => {
|
||||
this.onAddEvent()
|
||||
addEventImg.src = `${this.baseUrl}/addeventlightselected.svg`
|
||||
const sheet = $("bottomsheet-")
|
||||
const revert = () => {
|
||||
if (!sheet.isOpen) {
|
||||
addEventImg.src = this.getImageURL("addevent")
|
||||
sheet.sheetEl.removeEventListener("transitionend", revert)
|
||||
}
|
||||
}
|
||||
sheet.sheetEl.addEventListener("transitionend", revert)
|
||||
})
|
||||
|
||||
const calendarSheetImg = img(this.getImageURL("calbutton"), "1.1em")
|
||||
.paddingVertical(0.75, em).paddingHorizontal(1, em)
|
||||
.onTap(async () => {
|
||||
this.onCalendarOptions()
|
||||
calendarSheetImg.src = `${this.baseUrl}/calbuttonlightselected.svg`
|
||||
const sheet = $("bottomsheet-")
|
||||
const revert = () => {
|
||||
if (!sheet.isOpen) {
|
||||
calendarSheetImg.src = this.getImageURL("calbutton")
|
||||
sheet.sheetEl.removeEventListener("transitionend", revert)
|
||||
}
|
||||
}
|
||||
sheet.sheetEl.addEventListener("transitionend", revert)
|
||||
})
|
||||
})
|
||||
.background("var(--button-color)")
|
||||
.pointerEvents("auto")
|
||||
.onTouch(function (start) {
|
||||
if(start) {
|
||||
this.background("var(--main)")
|
||||
} else {
|
||||
this.background("var(--button-color)")
|
||||
}
|
||||
})
|
||||
.backdropFilter("blur(10px)")
|
||||
.alignItems("center")
|
||||
.flexShrink(0)
|
||||
.borderRadius(100, px)
|
||||
.border(`0.5px solid ${util.darkMode() ? "var(--accent)" : "transparent"}`)
|
||||
.overflow("hidden")
|
||||
})
|
||||
.position("absolute")
|
||||
.left(4, vw)
|
||||
.bottom(0.75, em)
|
||||
.zIndex(2)
|
||||
.justifyContent(this.hideViewSelect ? "flex-end" : "space-between")
|
||||
.width(92, vw)
|
||||
.onEvent("themechange", () => {
|
||||
this.rerender()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
register(BottomBar)
|
||||
256
calendar/Toolbar/CalendarOptions.js
Normal file
256
calendar/Toolbar/CalendarOptions.js
Normal file
@@ -0,0 +1,256 @@
|
||||
import "../../components/BackButton.js"
|
||||
import "../CalendarForm.js"
|
||||
|
||||
class CalendarOptions extends Shadow {
|
||||
baseUrl = `${config.UI}/apps/calendar/icons`
|
||||
|
||||
getImageURL(iconName) {
|
||||
let imgUrl = `${this.baseUrl}/${iconName}`
|
||||
if (util.darkMode()) imgUrl += "light"
|
||||
imgUrl += ".svg"
|
||||
return imgUrl.toLowerCase()
|
||||
}
|
||||
|
||||
constructor(calendars = [], selectedCalendars = [], actions) {
|
||||
super()
|
||||
this.calendars = calendars;
|
||||
this.selectedCalendars = selectedCalendars;
|
||||
this.onSelection = actions.onSelection;
|
||||
this.onCalendarAdded = actions.onCalendarAdded;
|
||||
this.onCalendarUpdated = actions.onCalendarUpdated;
|
||||
this.onCalendarDeleted = actions.onCalendarDeleted;
|
||||
}
|
||||
|
||||
isSelected(calendar) {
|
||||
return this.selectedCalendars.some(c => c.id === calendar.id);
|
||||
}
|
||||
|
||||
toggleCalendar(calendar) {
|
||||
const isSelected = this.isSelected(calendar);
|
||||
|
||||
if (isSelected && this.selectedCalendars.length === 1) return;
|
||||
|
||||
let newSelectedIds;
|
||||
if (isSelected) {
|
||||
newSelectedIds = this.selectedCalendars
|
||||
.filter(c => c.id !== calendar.id)
|
||||
.map(c => c.id);
|
||||
} else {
|
||||
newSelectedIds = [...this.selectedCalendars.map(c => c.id), calendar.id];
|
||||
}
|
||||
|
||||
this.selectedCalendars = this.calendars.filter(c => newSelectedIds.includes(c.id));
|
||||
this.rerender();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.calFormSheet = BottomSheet(200)
|
||||
|
||||
const doClose = () => {
|
||||
$("bottomsheet-")._closeOverride = null
|
||||
$("bottomsheet-").setSheet(false)
|
||||
setTimeout(() => this.onSelection(this.selectedCalendars), 300)
|
||||
}
|
||||
$("bottomsheet-")._closeOverride = doClose
|
||||
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
BackButton(false, true, doClose)
|
||||
.zIndex(3)
|
||||
|
||||
h2("Calendars")
|
||||
.position("absolute")
|
||||
.zIndex(2)
|
||||
.color("var(--headertext)")
|
||||
.paddingVertical(0.5, em)
|
||||
.margin(0)
|
||||
.top(0)
|
||||
.left(0)
|
||||
.width(100, pct)
|
||||
})
|
||||
.position("relative")
|
||||
.background(util.darkMode() ? "var(--darkred)" : "var(--sidebottombars)")
|
||||
.borderTopLeftRadius("10px").borderTopRightRadius("10px")
|
||||
.border("1px solid var(--divider)")
|
||||
.textAlign("center")
|
||||
|
||||
if (this.calendars.length > 0) {
|
||||
h3("Visibility")
|
||||
.marginBottom(0)
|
||||
.marginLeft(1.25, em)
|
||||
|
||||
VStack(() => {
|
||||
this.calendars.forEach(cal => {
|
||||
const selected = this.isSelected(cal);
|
||||
|
||||
HStack(() => {
|
||||
HStack(() => {
|
||||
p("")
|
||||
.width(2, em)
|
||||
.height(2, em)
|
||||
.background(selected ? cal.color : "transparent")
|
||||
.borderRadius(100, pct)
|
||||
.border(`3.5px solid ${cal.color}`)
|
||||
.boxSizing("border-box")
|
||||
p(cal.name)
|
||||
.fontSize(1.1, em)
|
||||
.opacity(selected ? 1 : 0.5)
|
||||
})
|
||||
.gap(1, em)
|
||||
.alignItems("center")
|
||||
.flex(1)
|
||||
.cursor("pointer")
|
||||
.onTap(async () => {
|
||||
await capacitor.Haptics.impact({ style: capacitor.ImpactStyle.Light });
|
||||
this.toggleCalendar(cal)
|
||||
})
|
||||
|
||||
p("i")
|
||||
.width(1.5, em)
|
||||
.height(1.5, em)
|
||||
.borderRadius(100, pct)
|
||||
.border("2px solid var(--text)")
|
||||
.boxSizing("border-box")
|
||||
.display(cal.owner_id === global.profile.id ? "" : "none")
|
||||
.textAlign("center")
|
||||
.lineHeight("1.4em")
|
||||
.fontSize(1.3, em)
|
||||
.fontWeight("bold")
|
||||
.color("var(--text)")
|
||||
.opacity(0.5)
|
||||
.flexShrink(0)
|
||||
.cursor("pointer")
|
||||
.onTap(async () => {
|
||||
await capacitor.Haptics.impact({ style: capacitor.ImpactStyle.Light });
|
||||
let calFormEl
|
||||
const closeCalForm = () => {
|
||||
this.calFormSheet._closeOverride = null
|
||||
this.calFormSheet.setSheet(false)
|
||||
}
|
||||
const onSaveErrorEdit = () => {
|
||||
this.calFormSheet._closeOverride = () => this.calFormSheet.forceClose()
|
||||
}
|
||||
this.calFormSheet.show(() => {
|
||||
calFormEl = CalendarForm(
|
||||
closeCalForm,
|
||||
(updated) => {
|
||||
closeCalForm()
|
||||
setTimeout(() => {
|
||||
this.calendars = this.calendars.map(c => c.id === updated.id ? updated : c);
|
||||
this.selectedCalendars = this.selectedCalendars.map(c => c.id === updated.id ? updated : c);
|
||||
this.onCalendarUpdated(updated);
|
||||
this.rerender();
|
||||
}, 300);
|
||||
},
|
||||
cal,
|
||||
(deletedId) => {
|
||||
closeCalForm()
|
||||
setTimeout(() => {
|
||||
this.calendars = this.calendars.filter(c => c.id !== deletedId);
|
||||
this.selectedCalendars = this.selectedCalendars.filter(c => c.id !== deletedId);
|
||||
this.onCalendarDeleted(deletedId);
|
||||
this.rerender();
|
||||
}, 300);
|
||||
},
|
||||
onSaveErrorEdit
|
||||
).height(100, pct)
|
||||
})
|
||||
this.calFormSheet._closeOverride = async () => {
|
||||
const saved = await calFormEl?.trySave()
|
||||
if (saved === null) {
|
||||
this.calFormSheet.setSheet(true)
|
||||
this.calFormSheet._closeOverride = () => this.calFormSheet.forceClose()
|
||||
return
|
||||
}
|
||||
closeCalForm()
|
||||
if (saved !== cal) {
|
||||
setTimeout(() => {
|
||||
this.calendars = this.calendars.map(c => c.id === saved.id ? saved : c)
|
||||
this.selectedCalendars = this.selectedCalendars.map(c => c.id === saved.id ? saved : c)
|
||||
this.onCalendarUpdated(saved)
|
||||
this.rerender()
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.padding(0.75, em)
|
||||
.gap(0.75, em)
|
||||
.alignItems("center")
|
||||
})
|
||||
})
|
||||
.background("var(--darkaccent)")
|
||||
.margin(1, em)
|
||||
.marginTop(0.5, em)
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(12, px)
|
||||
}
|
||||
|
||||
HStack(() => {
|
||||
img(this.getImageURL("calendar"), "1.5em", "1.5em")
|
||||
p("Add Calendar")
|
||||
.fontSize(1.1, em)
|
||||
.paddingLeft(0.5, em)
|
||||
})
|
||||
.background("var(--darkaccent)")
|
||||
.color("var(--text)")
|
||||
.gap(0.75, em)
|
||||
.alignItems("center")
|
||||
.padding(1, em)
|
||||
.paddingVertical(0.75, em)
|
||||
.marginHorizontal(1, em)
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(12, px)
|
||||
.cursor("pointer")
|
||||
.onTap(async () => {
|
||||
await capacitor.Haptics.impact({ style: capacitor.ImpactStyle.Light });
|
||||
let calFormEl
|
||||
const closeDirect = () => {
|
||||
this.calFormSheet._closeOverride = null
|
||||
this.calFormSheet.setSheet(false)
|
||||
}
|
||||
const onSaveErrorAdd = () => {
|
||||
this.calFormSheet._closeOverride = () => this.calFormSheet.forceClose()
|
||||
}
|
||||
this.calFormSheet.show(() => {
|
||||
calFormEl = CalendarForm(
|
||||
closeDirect,
|
||||
(calendar) => {
|
||||
closeDirect()
|
||||
setTimeout(() => {
|
||||
this.calendars = [...this.calendars, calendar]
|
||||
this.selectedCalendars = [...this.selectedCalendars, calendar]
|
||||
this.onCalendarAdded(calendar)
|
||||
this.rerender()
|
||||
}, 300)
|
||||
},
|
||||
null,
|
||||
null,
|
||||
onSaveErrorAdd
|
||||
).height(100, pct)
|
||||
})
|
||||
this.calFormSheet._closeOverride = async () => {
|
||||
const saved = await calFormEl?.trySave()
|
||||
if (saved === null) {
|
||||
this.calFormSheet.setSheet(true)
|
||||
this.calFormSheet._closeOverride = () => this.calFormSheet.forceClose()
|
||||
return
|
||||
}
|
||||
closeDirect()
|
||||
if (saved) {
|
||||
setTimeout(() => {
|
||||
this.calendars = [...this.calendars, saved]
|
||||
this.selectedCalendars = [...this.selectedCalendars, saved]
|
||||
this.onCalendarAdded(saved)
|
||||
this.rerender()
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.width(100, pct)
|
||||
.boxSizing("border-box")
|
||||
}
|
||||
}
|
||||
|
||||
register(CalendarOptions)
|
||||
123
calendar/Toolbar/CalendarToolbar.js
Normal file
123
calendar/Toolbar/CalendarToolbar.js
Normal file
@@ -0,0 +1,123 @@
|
||||
import calendarUtil from "../calendarUtil.js"
|
||||
import "./ToolbarPopout.js";
|
||||
|
||||
class CalendarToolbar extends Shadow {
|
||||
constructor(currentDate, weekStartsOn, actions, calendars, events, showPopout, options = {}) {
|
||||
super()
|
||||
this.currentDate = currentDate
|
||||
this.weekStartsOn = weekStartsOn
|
||||
this.goToPrevious = actions.goToPrevious;
|
||||
this.goToCurrent = actions.goToCurrent;
|
||||
this.goToNext = actions.goToNext;
|
||||
this.goToDate = actions.goToDate;
|
||||
this.calendars = calendars;
|
||||
this.events = events;
|
||||
this.showPopout = showPopout;
|
||||
this.onBack = options.onBack ?? null;
|
||||
this.viewModeOverride = options.viewModeOverride ?? null;
|
||||
}
|
||||
|
||||
render() {
|
||||
let popout;
|
||||
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
if (this.onBack) {
|
||||
BackButton(true, false, this.onBack)
|
||||
.position("absolute")
|
||||
.left(0.75, em)
|
||||
.bottom(0.2, em)
|
||||
.transform("scale(0.75)")
|
||||
.transformOrigin("left center")
|
||||
}
|
||||
|
||||
h2(this.getToolbarLabel(this.currentDate))
|
||||
.margin(0)
|
||||
.fontWeight("600")
|
||||
.color("var(--headertext)")
|
||||
.paddingLeft(this.onBack ? 2 : 0, em)
|
||||
.state("label", function (label) {
|
||||
if (label) {
|
||||
this.innerText = label;
|
||||
}
|
||||
})
|
||||
|
||||
HStack(() => {
|
||||
if (!window.isMobile()) {
|
||||
this.navButton("‹", this.goToPrevious)
|
||||
this.navButton("Today", this.goToCurrent)
|
||||
this.navButton("›", this.goToNext)
|
||||
}
|
||||
this.navButton("⊞", () => this.togglePopout(popout))
|
||||
})
|
||||
.gap(0.5, em)
|
||||
.alignItems("center")
|
||||
})
|
||||
.boxSizing("border-box")
|
||||
.width(100, pct)
|
||||
.alignItems("flex-end")
|
||||
.position("relative")
|
||||
.overflow("visible")
|
||||
.paddingBottom(0.9, em)
|
||||
.paddingHorizontal(1, em)
|
||||
.justifyContent("space-between")
|
||||
|
||||
popout = ToolbarPopout(this.currentDate, this.weekStartsOn, this.calendars, this.events, this.showPopout, (date) => this.goToDate(date), (date) => {
|
||||
this.$("h2").attr({ "label": this.getToolbarLabel(date)});
|
||||
});
|
||||
})
|
||||
.overflow("visible")
|
||||
.borderBottom("1px solid var(--main)")
|
||||
.width(100, pct)
|
||||
}
|
||||
|
||||
togglePopout(popout) {
|
||||
this.showPopout = !this.showPopout;
|
||||
$("calendar-").showPopout = this.showPopout;
|
||||
if (popout) {
|
||||
popout.showPopout = this.showPopout;
|
||||
popout.style.maxHeight = this.showPopout ? `${popout.layout.openHeight}em` : "0em";
|
||||
}
|
||||
if (!this.showPopout) {
|
||||
const newLabel = this.getToolbarLabel(this.currentDate)
|
||||
const oldLabel = this.$("h2").innerText;
|
||||
this.$("h2").attr({ "label": newLabel });
|
||||
if (newLabel !== oldLabel) {
|
||||
setTimeout(() => {
|
||||
this.rerender();
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
navButton(label, handler) {
|
||||
const isWide = label === "Today";
|
||||
return button(label)
|
||||
.onTap(handler)
|
||||
.paddingVertical(0.45, em)
|
||||
.fontSize(1, rem)
|
||||
.paddingHorizontal(isWide ? 0.9 : 0.8, em)
|
||||
.border("1px solid var(--desktop-item-border)")
|
||||
.borderRadius(0.6, em)
|
||||
.background("var(--desktop-item-background)")
|
||||
.color("var(--text)")
|
||||
.cursor("pointer");
|
||||
}
|
||||
|
||||
getToolbarLabel(date) {
|
||||
const viewMode = this.viewModeOverride ?? $("calendar-").viewMode;
|
||||
|
||||
if (viewMode === "week") {
|
||||
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
||||
}
|
||||
if (viewMode === "day") {
|
||||
return date.toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" });
|
||||
}
|
||||
if (viewMode === "month") {
|
||||
return date.toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
register(CalendarToolbar)
|
||||
519
calendar/Toolbar/ToolbarPopout.js
Normal file
519
calendar/Toolbar/ToolbarPopout.js
Normal file
@@ -0,0 +1,519 @@
|
||||
import calendarUtil from "../calendarUtil.js";
|
||||
|
||||
class ToolbarPopout extends Shadow {
|
||||
swipeTranslate = 0;
|
||||
isSwiping = false;
|
||||
swipeDragStartX = null;
|
||||
swipeDragStartY = null;
|
||||
swipeDragStartTime = null;
|
||||
swipeAxisLocked = false;
|
||||
swipeIsHorizontal = false;
|
||||
|
||||
SWIPE_COMMIT_DISTANCE = window.outerWidth * 0.25;
|
||||
SWIPE_VELOCITY_THRESHOLD = 0.4;
|
||||
|
||||
static DAY_NAMES = ["S", "M", "T", "W", "T", "F", "S"];
|
||||
|
||||
constructor(currentDate, weekStartsOn, calendars, events, showPopout, goToDate, onMonthChange) {
|
||||
super()
|
||||
this.currentDate = currentDate;
|
||||
this.weekStartsOn = weekStartsOn;
|
||||
this.calendars = calendars;
|
||||
this.events = events;
|
||||
|
||||
this.selectedDate = this.currentDate;
|
||||
this.showPopout = showPopout;
|
||||
this.goToDate = goToDate;
|
||||
this.onMonthChange = onMonthChange;
|
||||
|
||||
this.swipeDidMove = false;
|
||||
|
||||
if (this.miniCurrentDate === undefined) {
|
||||
this.miniCurrentDate = this.currentDate;
|
||||
}
|
||||
|
||||
this.layout = this.buildLayout();
|
||||
|
||||
// Caches
|
||||
this.monthCache = new Map();
|
||||
this._miniPanels = null;
|
||||
this._monthEventsVersion = 0;
|
||||
|
||||
this.buildDerivedData();
|
||||
}
|
||||
|
||||
buildLayout() {
|
||||
const fs = 0.75, hp = 0.4, dch = 1.55, dh = 0.28, dmt = 0.15, cp = 0.4;
|
||||
return {
|
||||
fontSize: fs,
|
||||
headerPadding: hp,
|
||||
headerRowHeight: fs + hp * 2,
|
||||
dateCircleHeight: dch,
|
||||
dotHeight: dh,
|
||||
dotMarginTop: dmt,
|
||||
cellPadding: cp,
|
||||
rowHeight: cp + dch + dh + dmt,
|
||||
get lastRowHeight() { return cp + dch; },
|
||||
get gridHeight() { return this.rowHeight * 5 + this.lastRowHeight; },
|
||||
get openHeight() { return this.headerRowHeight * fs + this.gridHeight; },
|
||||
};
|
||||
}
|
||||
|
||||
buildDerivedData() {
|
||||
this.colorByCalendarId = new Map(
|
||||
(this.calendars || []).map(calendar => [calendar.id, calendar.color])
|
||||
);
|
||||
|
||||
// Version string for invalidating month cache when events update
|
||||
this.eventsVersion = JSON.stringify(
|
||||
(this.events || []).map(ev => [
|
||||
ev.id,
|
||||
ev.calendars,
|
||||
ev.recurrence_id,
|
||||
+new Date(ev.time_start),
|
||||
+new Date(ev.time_end)
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
getMiniPanels() {
|
||||
if (!this._miniPanels) {
|
||||
this._miniPanels = this.$$("[data-mini-panel]");
|
||||
}
|
||||
return this._miniPanels;
|
||||
}
|
||||
|
||||
getMonthKey(date) {
|
||||
return [
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
this.weekStartsOn,
|
||||
this.eventsVersion
|
||||
].join("|");
|
||||
}
|
||||
|
||||
render() {
|
||||
const l = this.layout;
|
||||
const ordered = Array.from(
|
||||
{ length: 7 },
|
||||
(_, i) => ToolbarPopout.DAY_NAMES[(this.weekStartsOn + i) % 7]
|
||||
);
|
||||
const today = new Date();
|
||||
|
||||
// Remove stale cache
|
||||
this._miniPanels = null;
|
||||
|
||||
// Only render the current month panel
|
||||
// Previous/Next injected programmatically whenever user begins
|
||||
// swiping - touch event never interrupted by rerenders
|
||||
const panelOffsets = [0];
|
||||
|
||||
VStack(() => {
|
||||
HStack(() => {
|
||||
ordered.forEach(name => {
|
||||
p(name)
|
||||
.margin(0)
|
||||
.fontSize(l.fontSize, em)
|
||||
.fontWeight("600")
|
||||
.color("var(--headertext)")
|
||||
.opacity(0.45)
|
||||
.flex(1)
|
||||
.textAlign("center")
|
||||
.paddingVertical(l.headerPadding, em)
|
||||
.boxSizing("border-box")
|
||||
})
|
||||
})
|
||||
.width(100, pct)
|
||||
|
||||
ZStack(() => {
|
||||
panelOffsets.forEach(offset => {
|
||||
const viewDate = calendarUtil.addMonths(this.miniCurrentDate, offset);
|
||||
const { weeks } = this.getMonthData(viewDate);
|
||||
|
||||
VStack(() => {
|
||||
weeks.forEach((week, wi) => {
|
||||
const isLast = wi === weeks.length - 1;
|
||||
|
||||
HStack(() => {
|
||||
week.forEach(({ day, isCurrentMonth, calendarColors }) => {
|
||||
const isToday = calendarUtil.isSameDay(day, today);
|
||||
const isSelected = calendarUtil.isSameDay(day, this.selectedDate);
|
||||
|
||||
VStack(() => {
|
||||
p(day.getDate())
|
||||
.margin(0)
|
||||
.fontSize(l.fontSize, em)
|
||||
.fontWeight(isSelected ? "700" : "400")
|
||||
.color(isSelected || isToday ? "white" : "var(--headertext)")
|
||||
.background(
|
||||
isToday
|
||||
? "var(--quillred)"
|
||||
: isSelected
|
||||
? "var(--lightaccent)"
|
||||
: "transparent"
|
||||
)
|
||||
.width(l.dateCircleHeight, em)
|
||||
.height(l.dateCircleHeight, em)
|
||||
.borderRadius(25, pct)
|
||||
.textAlign("center")
|
||||
.lineHeight(`${l.dateCircleHeight}em`)
|
||||
.opacity(isCurrentMonth ? 1 : 0.28)
|
||||
.boxSizing("border-box")
|
||||
|
||||
// Only show event dots if they exist
|
||||
if (calendarColors.length > 0) {
|
||||
HStack(() => {
|
||||
calendarColors.slice(0, 3).forEach(color => {
|
||||
VStack(() => {})
|
||||
.width(l.dotHeight, em)
|
||||
.height(l.dotHeight, em)
|
||||
.borderRadius(50, pct)
|
||||
.background(color)
|
||||
.flexShrink(0)
|
||||
})
|
||||
})
|
||||
.gap(0.18, em)
|
||||
.justifyContent("center")
|
||||
.alignItems("center")
|
||||
.marginTop(l.dotMarginTop, em)
|
||||
.height(l.dotHeight, em)
|
||||
} else {
|
||||
// Spacer for dot-less cells
|
||||
VStack(() => {})
|
||||
.marginTop(l.dotMarginTop, em)
|
||||
.height(l.dotHeight, em)
|
||||
}
|
||||
})
|
||||
.flex(1)
|
||||
.alignItems("center")
|
||||
.paddingTop(l.cellPadding, em)
|
||||
.paddingBottom(isLast ? 0 : l.cellPadding, em)
|
||||
.boxSizing("border-box")
|
||||
.onTap(() => {
|
||||
if (!this.swipeDidMove) {
|
||||
this.selectedDate = day;
|
||||
if (!this.goToDate(day)) {
|
||||
this.rerender();
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
.width(100, pct)
|
||||
.background("var(--minicalendarbackground)")
|
||||
})
|
||||
})
|
||||
.position("absolute")
|
||||
.width(100, pct)
|
||||
.height(100, pct)
|
||||
.transform(`translateX(${offset * 100}%)`)
|
||||
.willChange("transform")
|
||||
.attr({ "data-mini-panel": offset })
|
||||
})
|
||||
})
|
||||
.position("relative")
|
||||
.overflow("hidden")
|
||||
.width(100, pct)
|
||||
.height(l.gridHeight, em)
|
||||
.onTouch((start, e) => this.handleSwipeTouch(start, e))
|
||||
})
|
||||
.width(100, pct)
|
||||
.boxSizing("border-box")
|
||||
.overflow("hidden")
|
||||
.maxHeight(this.showPopout ? l.openHeight : 0, em)
|
||||
.transition("max-height 0.3s ease")
|
||||
}
|
||||
|
||||
// Build previous/next calendars and insert them without triggering a rerender
|
||||
injectSidePanels() {
|
||||
const currentPanel = this.$("[data-mini-panel='0']");
|
||||
if (!currentPanel) return;
|
||||
const container = currentPanel.parentElement;
|
||||
if (!container) return;
|
||||
|
||||
// Already injected (shouldn't happen, but guard anyway).
|
||||
if (this.$("[data-mini-panel='-1']")) return;
|
||||
|
||||
const l = this.layout;
|
||||
const today = new Date();
|
||||
|
||||
[-1, 1].forEach(offset => {
|
||||
const viewDate = calendarUtil.addMonths(this.miniCurrentDate, offset);
|
||||
const { weeks } = this.getMonthData(viewDate);
|
||||
|
||||
// Outer panel div — mirrors the VStack styling applied in render().
|
||||
const panel = document.createElement("div");
|
||||
panel.classList.add("VStack");
|
||||
panel.setAttribute("data-mini-panel", offset);
|
||||
panel.style.cssText = `
|
||||
position: absolute; top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
display: flex; flex-direction: column;
|
||||
transform: translateX(${offset * 100}%);
|
||||
will-change: transform;
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
|
||||
weeks.forEach((week, wi) => {
|
||||
const isLast = wi === weeks.length - 1;
|
||||
const row = document.createElement("div");
|
||||
|
||||
row.classList.add("HStack");
|
||||
row.style.cssText = `
|
||||
display: flex; flex-direction: row;
|
||||
width: 100%;
|
||||
background: var(--minicalendarbackground);
|
||||
`;
|
||||
|
||||
week.forEach(({ day, isCurrentMonth, calendarColors }) => {
|
||||
const isToday = calendarUtil.isSameDay(day, today);
|
||||
const isSelected = calendarUtil.isSameDay(day, this.selectedDate);
|
||||
|
||||
const cell = document.createElement("div");
|
||||
cell.classList.add("VStack");
|
||||
cell.style.cssText = `
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: ${l.cellPadding}em;
|
||||
padding-bottom: ${isLast ? 0 : l.cellPadding}em;
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
|
||||
const circle = document.createElement("p");
|
||||
|
||||
circle.textContent = day.getDate();
|
||||
circle.style.cssText = `
|
||||
margin: 0;
|
||||
font-size: ${l.fontSize}em;
|
||||
font-weight: ${isSelected ? "700" : "400"};
|
||||
color: ${isSelected || isToday ? "white" : "var(--headertext)"};
|
||||
background: ${isToday ? "var(--quillred)" : isSelected ? "var(--lightaccent)" : "transparent"};
|
||||
width: ${l.dateCircleHeight}em;
|
||||
height: ${l.dateCircleHeight}em;
|
||||
border-radius: 25%;
|
||||
text-align: center;
|
||||
line-height: ${l.dateCircleHeight}em;
|
||||
opacity: ${isCurrentMonth ? 1 : 0.28};
|
||||
box-sizing: border-box;
|
||||
`;
|
||||
cell.appendChild(circle);
|
||||
|
||||
const spacer = document.createElement("div");
|
||||
spacer.classList.add("HStack");
|
||||
spacer.style.cssText = `
|
||||
margin-top: ${l.dotMarginTop}em;
|
||||
height: ${l.dotHeight}em;
|
||||
display: flex; flex-direction: row;
|
||||
justify-content: center; align-items: center;
|
||||
gap: 0.18em;
|
||||
`;
|
||||
if (calendarColors.length > 0) {
|
||||
calendarColors.slice(0, 3).forEach(color => {
|
||||
const dot = document.createElement("div");
|
||||
|
||||
dot.classList.add("VStack");
|
||||
dot.style.cssText = `
|
||||
width: ${l.dotHeight}em; height: ${l.dotHeight}em;
|
||||
border-radius: 50%; background: ${color}; flex-shrink: 0;
|
||||
`;
|
||||
spacer.appendChild(dot);
|
||||
});
|
||||
}
|
||||
cell.appendChild(spacer);
|
||||
row.appendChild(cell);
|
||||
});
|
||||
|
||||
panel.appendChild(row);
|
||||
});
|
||||
|
||||
container.appendChild(panel);
|
||||
});
|
||||
|
||||
// Invalidate the panel cache so getMiniPanels() picks up the new nodes.
|
||||
this._miniPanels = null;
|
||||
}
|
||||
|
||||
handleSwipeTouch(start, e) {
|
||||
if (start) {
|
||||
this.swipeDragStartX = e.touches[0].clientX;
|
||||
this.swipeDragStartY = e.touches[0].clientY;
|
||||
this.swipeDragStartTime = Date.now();
|
||||
this.isSwiping = true;
|
||||
this.swipeDidMove = false;
|
||||
this.swipeAxisLocked = false;
|
||||
this.swipeIsHorizontal = false;
|
||||
|
||||
// Inject previous/next calendar
|
||||
this.injectSidePanels();
|
||||
|
||||
document.addEventListener("touchmove", this.onSwipeMove, { passive: true });
|
||||
} else {
|
||||
if (!this.isSwiping) return;
|
||||
document.removeEventListener("touchmove", this.onSwipeMove);
|
||||
|
||||
if (!this.swipeIsHorizontal) {
|
||||
this.isSwiping = false;
|
||||
this.swipeDragStartX = null;
|
||||
this.swipeDragStartY = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
const delta = endX - this.swipeDragStartX;
|
||||
const elapsed = Date.now() - this.swipeDragStartTime;
|
||||
const velocity = Math.abs(delta) / elapsed;
|
||||
const shouldCommit =
|
||||
Math.abs(delta) > this.SWIPE_COMMIT_DISTANCE ||
|
||||
velocity > this.SWIPE_VELOCITY_THRESHOLD;
|
||||
|
||||
if (shouldCommit && delta < 0) this.commitSwipe("next");
|
||||
else if (shouldCommit && delta > 0) this.commitSwipe("previous");
|
||||
else this.snapBack();
|
||||
|
||||
this.isSwiping = false;
|
||||
this.swipeDragStartX = null;
|
||||
this.swipeDragStartY = null;
|
||||
}
|
||||
}
|
||||
|
||||
onSwipeMove = (e) => {
|
||||
if (!this.isSwiping) return;
|
||||
|
||||
const dx = e.touches[0].clientX - this.swipeDragStartX;
|
||||
const dy = e.touches[0].clientY - this.swipeDragStartY;
|
||||
|
||||
if (!this.swipeAxisLocked) {
|
||||
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
|
||||
this.swipeAxisLocked = true;
|
||||
this.swipeIsHorizontal = Math.abs(dx) > Math.abs(dy);
|
||||
}
|
||||
|
||||
if (!this.swipeIsHorizontal) return;
|
||||
|
||||
this.swipeDidMove = true;
|
||||
const delta = e.touches[0].clientX - this.swipeDragStartX;
|
||||
this.swipeTranslate = delta;
|
||||
this.applySwipeTransform(delta, false);
|
||||
}
|
||||
|
||||
applySwipeTransform(delta, animated) {
|
||||
const panels = this.getMiniPanels();
|
||||
panels.forEach(panel => {
|
||||
const offset = parseInt(panel.getAttribute("data-mini-panel"));
|
||||
panel.style.transition = animated ? "transform 300ms ease" : "";
|
||||
panel.style.transform = `translateX(calc(${offset * 100}% + ${delta}px))`;
|
||||
});
|
||||
}
|
||||
|
||||
commitSwipe(direction) {
|
||||
const sign = direction === "next" ? 1 : -1;
|
||||
const panels = this.getMiniPanels();
|
||||
const container = panels[0]?.parentElement;
|
||||
|
||||
// Snap panels to their current drag position
|
||||
panels.forEach(panel => {
|
||||
const offset = parseInt(panel.getAttribute("data-mini-panel"));
|
||||
panel.style.transition = "none";
|
||||
panel.style.transform = `translateX(calc(${offset * 100}% + ${this.swipeTranslate}px))`;
|
||||
});
|
||||
|
||||
// Force reflow so the browser registers the snap as a committed state
|
||||
panels[0]?.getBoundingClientRect();
|
||||
|
||||
panels.forEach(panel => {
|
||||
const offset = parseInt(panel.getAttribute("data-mini-panel"));
|
||||
panel.style.transition = "transform 300ms ease";
|
||||
panel.style.transform = `translateX(calc(${(offset - sign) * 100}%))`;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
this.miniCurrentDate = direction === "next"
|
||||
? calendarUtil.addMonths(this.miniCurrentDate, 1)
|
||||
: calendarUtil.addMonths(this.miniCurrentDate, -1);
|
||||
|
||||
this.onMonthChange(this.miniCurrentDate)
|
||||
this.swipeTranslate = 0;
|
||||
this.rerender();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
snapBack() {
|
||||
const panels = this.getMiniPanels();
|
||||
panels.forEach(panel => {
|
||||
const offset = parseInt(panel.getAttribute("data-mini-panel"));
|
||||
panel.style.transition = "transform 300ms ease";
|
||||
panel.style.transform = `translateX(${offset * 100}%)`;
|
||||
});
|
||||
this.swipeTranslate = 0;
|
||||
}
|
||||
|
||||
getMonthData(date) {
|
||||
const monthKey = this.getMonthKey(date);
|
||||
const cached = this.monthCache.get(monthKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth();
|
||||
|
||||
const firstOfMonth = new Date(year, month, 1);
|
||||
const gridStart = calendarUtil.startOfWeek(firstOfMonth, this.weekStartsOn);
|
||||
|
||||
const allDays = Array.from({ length: 42 }, (_, i) => calendarUtil.addDays(gridStart, i));
|
||||
const weeks = [];
|
||||
for (let w = 0; w < 6; w++) {
|
||||
weeks.push(allDays.slice(w * 7, w * 7 + 7));
|
||||
}
|
||||
|
||||
const gridEnd = calendarUtil.endOfDay(allDays[41]);
|
||||
|
||||
const colorsByDay = new Map();
|
||||
allDays.forEach(day => {
|
||||
colorsByDay.set(calendarUtil.toDateInput(day), []);
|
||||
});
|
||||
|
||||
const expanded = calendarUtil.expandRecurringEvents(this.events || [], gridStart, gridEnd);
|
||||
const relevantEvents = expanded.filter(ev => {
|
||||
const end = ev.all_day ? calendarUtil.endOfDay(ev.time_end) : ev.time_end;
|
||||
return calendarUtil.rangesOverlap(ev.time_start, end, gridStart, gridEnd);
|
||||
});
|
||||
|
||||
relevantEvents.forEach(ev => {
|
||||
const colors = (ev.calendars || [])
|
||||
.map(id => this.colorByCalendarId.get(id))
|
||||
.filter(Boolean);
|
||||
if (colors.length === 0) return;
|
||||
|
||||
const effectiveEnd = ev.all_day ? calendarUtil.endOfDay(ev.time_end) : ev.time_end;
|
||||
let cursor = calendarUtil.startOfDay(
|
||||
ev.time_start > gridStart ? ev.time_start : gridStart
|
||||
);
|
||||
const eventEnd = effectiveEnd < gridEnd ? effectiveEnd : gridEnd;
|
||||
|
||||
while (cursor < eventEnd) {
|
||||
const key = calendarUtil.toDateInput(cursor);
|
||||
const arr = colorsByDay.get(key);
|
||||
if (arr) {
|
||||
colors.forEach(color => {
|
||||
if (!arr.includes(color) && arr.length < 3) arr.push(color);
|
||||
});
|
||||
}
|
||||
cursor = calendarUtil.addDays(cursor, 1);
|
||||
}
|
||||
});
|
||||
|
||||
const result = {
|
||||
weeks: weeks.map(week =>
|
||||
week.map(day => ({
|
||||
day,
|
||||
isCurrentMonth: day.getMonth() === month,
|
||||
calendarColors: colorsByDay.get(calendarUtil.toDateInput(day)) || []
|
||||
}))
|
||||
)
|
||||
};
|
||||
|
||||
this.monthCache.set(monthKey, result);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
register(ToolbarPopout)
|
||||
Reference in New Issue
Block a user