648 lines
19 KiB
JavaScript
648 lines
19 KiB
JavaScript
import './bootstrap';
|
|
import htmx from 'htmx.org';
|
|
|
|
const SELECTORS = {
|
|
calendarToggle: '.calendar-toggle',
|
|
calendarViewForm: '#calendar-view',
|
|
calendarExpandToggle: '[data-calendar-expand]',
|
|
colorPicker: '[data-colorpicker]',
|
|
colorPickerColor: '[data-colorpicker-color]',
|
|
colorPickerHex: '[data-colorpicker-hex]',
|
|
colorPickerRandom: '[data-colorpicker-random]',
|
|
eventAllDayToggle: '[data-all-day-toggle]',
|
|
eventStartInput: '[data-event-start]',
|
|
eventEndInput: '[data-event-end]',
|
|
recurrenceFrequency: '[data-recurrence-frequency]',
|
|
recurrenceInterval: '[data-recurrence-interval]',
|
|
recurrenceUnit: '[data-recurrence-unit]',
|
|
recurrenceSection: '[data-recurrence-section]',
|
|
monthlyMode: '[data-monthly-mode]',
|
|
monthlyDays: '[data-monthly-days]',
|
|
monthlyWeekday: '[data-monthly-weekday]',
|
|
modalDialog: 'dialog',
|
|
modalContent: '#modal',
|
|
tabsRoot: '[data-tabs]',
|
|
tabButton: '[role=\"tab\"]',
|
|
tabPanel: '[role=\"tabpanel\"]',
|
|
monthDay: '.calendar.month .day',
|
|
monthDayEvent: 'a.event',
|
|
monthDayMore: '[data-day-more]',
|
|
monthDayMoreWrap: '.more-events',
|
|
};
|
|
|
|
/**
|
|
*
|
|
* htmx/global
|
|
*/
|
|
|
|
// make html globally visible to use the devtools and extensions
|
|
window.htmx = htmx;
|
|
|
|
// global htmx config
|
|
htmx.config.historyEnabled = true; // HX-Boost back/forward support
|
|
htmx.logger = console.log; // verbose logging during dev
|
|
|
|
// csrf on htmx requests
|
|
document.addEventListener('htmx:configRequest', (evt) => {
|
|
const token = document.querySelector('meta[name="csrf-token"]')?.content
|
|
if (token) evt.detail.headers['X-CSRF-TOKEN'] = token
|
|
})
|
|
|
|
// modal htmx tracking
|
|
document.addEventListener('htmx:beforeSwap', (evt) => {
|
|
const target = evt.detail?.target || evt.target;
|
|
if (target && target.id === 'modal') {
|
|
target.dataset.prevUrl = window.location.href;
|
|
}
|
|
});
|
|
|
|
/**
|
|
*
|
|
* global auth expiry redirect (fetch/axios)
|
|
*/
|
|
|
|
const AUTH_REDIRECT_STATUSES = new Set([401, 419]);
|
|
const redirectToLogin = () => {
|
|
if (window.location.pathname !== '/login') {
|
|
window.location.assign('/login');
|
|
}
|
|
};
|
|
|
|
if (window.fetch) {
|
|
const originalFetch = window.fetch.bind(window);
|
|
window.fetch = async (...args) => {
|
|
const response = await originalFetch(...args);
|
|
if (response && AUTH_REDIRECT_STATUSES.has(response.status)) {
|
|
redirectToLogin();
|
|
}
|
|
return response;
|
|
};
|
|
}
|
|
|
|
if (window.axios) {
|
|
window.axios.interceptors.response.use(
|
|
(response) => response,
|
|
(error) => {
|
|
const status = error?.response?.status;
|
|
if (AUTH_REDIRECT_STATUSES.has(status)) {
|
|
redirectToLogin();
|
|
}
|
|
return Promise.reject(error);
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* calendar ui improvements
|
|
*/
|
|
|
|
// progressive enhancement on html form with no JS
|
|
document.addEventListener('change', (event) => {
|
|
const target = event.target;
|
|
|
|
if (target?.matches(SELECTORS.calendarToggle)) {
|
|
const slug = target.value;
|
|
const show = target.checked;
|
|
|
|
document
|
|
.querySelectorAll(`[data-calendar="${slug}"]`)
|
|
.forEach(el => el.classList.toggle('hidden', !show));
|
|
return;
|
|
}
|
|
|
|
const form = target?.form;
|
|
if (!form || form.id !== 'calendar-view') return;
|
|
if (target.name !== 'view') return;
|
|
|
|
form.requestSubmit();
|
|
});
|
|
|
|
// close event modal on back/forward navigation
|
|
window.addEventListener('popstate', () => {
|
|
if (!document.querySelector('article#calendar')) return;
|
|
|
|
const dialog = document.querySelector('dialog');
|
|
if (!dialog?.open) return;
|
|
|
|
const modal = dialog.querySelector('#modal');
|
|
if (!modal?.querySelector('[data-modal-kind="event"]')) return;
|
|
|
|
dialog.close();
|
|
});
|
|
|
|
/**
|
|
* event form all-day toggle
|
|
*/
|
|
function initEventAllDayToggles(root = document) {
|
|
const toDate = (value) => {
|
|
if (!value) return '';
|
|
return value.split('T')[0];
|
|
};
|
|
|
|
const withTime = (date, time) => {
|
|
if (!date) return '';
|
|
return `${date}T${time}`;
|
|
};
|
|
|
|
root.querySelectorAll(SELECTORS.eventAllDayToggle).forEach((toggle) => {
|
|
if (toggle.__allDayWired) return;
|
|
toggle.__allDayWired = true;
|
|
|
|
const form = toggle.closest('form');
|
|
if (!form) return;
|
|
|
|
const start = form.querySelector(SELECTORS.eventStartInput);
|
|
const end = form.querySelector(SELECTORS.eventEndInput);
|
|
if (!start || !end) return;
|
|
|
|
const apply = () => {
|
|
if (toggle.checked) {
|
|
if (start.type === 'datetime-local') {
|
|
start.dataset.datetimeValue = start.value;
|
|
}
|
|
if (end.type === 'datetime-local') {
|
|
end.dataset.datetimeValue = end.value;
|
|
}
|
|
|
|
const startDate = toDate(start.value);
|
|
const endDate = toDate(end.value);
|
|
|
|
start.type = 'date';
|
|
end.type = 'date';
|
|
|
|
if (startDate) start.value = startDate;
|
|
if (endDate) end.value = endDate;
|
|
|
|
if (start.value && end.value && end.value < start.value) {
|
|
end.value = start.value;
|
|
}
|
|
} else {
|
|
const startDate = toDate(start.value);
|
|
const endDate = toDate(end.value);
|
|
const startTime = (start.dataset.datetimeValue || '').split('T')[1] || '09:00';
|
|
const endTime = (end.dataset.datetimeValue || '').split('T')[1] || '10:00';
|
|
|
|
start.type = 'datetime-local';
|
|
end.type = 'datetime-local';
|
|
|
|
start.value = start.dataset.datetimeValue || withTime(startDate, startTime);
|
|
end.value = end.dataset.datetimeValue || withTime(endDate || startDate, endTime);
|
|
}
|
|
};
|
|
|
|
toggle.addEventListener('change', apply);
|
|
apply();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* recurrence preset selector
|
|
*/
|
|
function initRecurrenceControls(root = document) {
|
|
const sections = root.querySelectorAll(SELECTORS.recurrenceSection);
|
|
if (!sections.length) return;
|
|
|
|
const select = root.querySelector(SELECTORS.recurrenceFrequency);
|
|
const intervalRow = root.querySelector(SELECTORS.recurrenceInterval);
|
|
const intervalUnit = root.querySelector(SELECTORS.recurrenceUnit);
|
|
const monthModes = root.querySelectorAll(SELECTORS.monthlyMode);
|
|
const monthDays = root.querySelector(SELECTORS.monthlyDays);
|
|
const monthWeekday = root.querySelector(SELECTORS.monthlyWeekday);
|
|
|
|
if (!select) return;
|
|
|
|
const unitMap = {
|
|
daily: 'day',
|
|
weekly: 'week',
|
|
monthly: 'month',
|
|
yearly: 'year',
|
|
};
|
|
|
|
const applyMonthlyMode = () => {
|
|
if (!monthDays || !monthWeekday) return;
|
|
const modeInput = Array.from(monthModes).find((input) => input.checked);
|
|
const mode = modeInput?.value || 'days';
|
|
|
|
if (mode === 'weekday') {
|
|
monthDays.classList.add('hidden');
|
|
monthWeekday.classList.remove('hidden');
|
|
} else {
|
|
monthDays.classList.remove('hidden');
|
|
monthWeekday.classList.add('hidden');
|
|
}
|
|
};
|
|
|
|
const apply = () => {
|
|
const value = select.value;
|
|
const show = value !== '';
|
|
|
|
sections.forEach((section) => {
|
|
const type = section.getAttribute('data-recurrence-section');
|
|
if (!show) {
|
|
section.classList.add('hidden');
|
|
return;
|
|
}
|
|
section.classList.toggle('hidden', type !== value);
|
|
});
|
|
|
|
if (intervalRow) {
|
|
intervalRow.classList.toggle('hidden', !show);
|
|
}
|
|
|
|
if (intervalUnit) {
|
|
const unit = unitMap[value] || 'day';
|
|
intervalUnit.textContent = unit ? `${unit}(s)` : '';
|
|
}
|
|
|
|
if (value === 'monthly') {
|
|
applyMonthlyMode();
|
|
}
|
|
};
|
|
|
|
select.addEventListener('change', apply);
|
|
monthModes.forEach((input) => input.addEventListener('change', applyMonthlyMode));
|
|
|
|
apply();
|
|
}
|
|
|
|
/**
|
|
*
|
|
* modal behaviors (backdrop close + url restore)
|
|
*/
|
|
function initModalHandlers(root = document) {
|
|
const dialog = root.querySelector(SELECTORS.modalDialog);
|
|
if (!dialog || dialog.__modalWired) return;
|
|
dialog.__modalWired = true;
|
|
|
|
dialog.addEventListener('click', (event) => {
|
|
if (event.target === dialog) {
|
|
dialog.close();
|
|
}
|
|
});
|
|
|
|
dialog.addEventListener('close', () => {
|
|
const modal = dialog.querySelector(SELECTORS.modalContent);
|
|
if (!modal) return;
|
|
|
|
const isEvent = modal.querySelector('[data-modal-kind="event"]');
|
|
const prevUrl = modal.dataset.prevUrl;
|
|
modal.innerHTML = '';
|
|
|
|
if (isEvent && prevUrl) {
|
|
history.replaceState({}, '', prevUrl);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* tabs (simple modal panels)
|
|
*/
|
|
function initTabs(root = document) {
|
|
root.querySelectorAll(SELECTORS.tabsRoot).forEach((tabs) => {
|
|
if (tabs.__tabsWired) return;
|
|
tabs.__tabsWired = true;
|
|
|
|
const buttons = tabs.querySelectorAll(SELECTORS.tabButton);
|
|
const panels = tabs.querySelectorAll(SELECTORS.tabPanel);
|
|
if (!buttons.length || !panels.length) return;
|
|
|
|
const activate = (button) => {
|
|
buttons.forEach((btn) => {
|
|
const isActive = btn === button;
|
|
btn.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
|
});
|
|
|
|
panels.forEach((panel) => {
|
|
const id = button.getAttribute('aria-controls');
|
|
panel.hidden = panel.id !== id;
|
|
});
|
|
};
|
|
|
|
buttons.forEach((btn) => {
|
|
btn.addEventListener('click', (event) => {
|
|
event.preventDefault();
|
|
activate(btn);
|
|
});
|
|
});
|
|
|
|
const current = tabs.querySelector('[role="tab"][aria-selected="true"]') || buttons[0];
|
|
if (current) {
|
|
activate(current);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
*
|
|
* auto-scroll time views to 8am on load (when daytime hours are disabled)
|
|
*/
|
|
function initTimeViewAutoScroll(root = document)
|
|
{
|
|
// make sure we're on a time calendar
|
|
const calendar = root.querySelector('.calendar.time');
|
|
if (!calendar) return;
|
|
|
|
// get out if we're autoscrolled or daytime hours is set
|
|
if (calendar.dataset.autoscrolled === '1') return;
|
|
if (calendar.dataset.daytimeHoursEnabled === '1') return;
|
|
|
|
// find the target minute (8:00am)
|
|
const target = calendar.querySelector('[data-slot-minutes="480"]');
|
|
if (!target) return;
|
|
|
|
// get the scroll container and offset
|
|
const container = calendar.closest('article') || document.querySelector('article#calendar');
|
|
if (!container) return;
|
|
const header = container.querySelector('header');
|
|
const headerOffset = header ? header.offsetHeight : 0;
|
|
const containerRect = container.getBoundingClientRect();
|
|
const targetRect = target.getBoundingClientRect();
|
|
const top = targetRect.top - containerRect.top + container.scrollTop - headerOffset - 12;
|
|
|
|
// scroll
|
|
container.scrollTo({ top: Math.max(top, 0), behavior: 'auto' });
|
|
calendar.dataset.autoscrolled = '1';
|
|
}
|
|
|
|
/**
|
|
*
|
|
* calendar sidebar expand toggle
|
|
*/
|
|
|
|
document.addEventListener('click', (event) => {
|
|
const toggle = event.target.closest(SELECTORS.calendarExpandToggle);
|
|
if (!toggle) return;
|
|
|
|
event.preventDefault();
|
|
|
|
const main = toggle.closest('main');
|
|
if (!main) return;
|
|
|
|
const isExpanded = main.classList.toggle('expanded');
|
|
toggle.setAttribute('aria-pressed', isExpanded ? 'true' : 'false');
|
|
});
|
|
|
|
/**
|
|
*
|
|
* color picker component
|
|
* native <input type="color"> + hex + random palette)
|
|
*/
|
|
|
|
function initColorPickers(root = document) {
|
|
const isHex = (v) => /^#?[0-9a-fA-F]{6}$/.test((v || '').trim());
|
|
|
|
const normalize = (v) => {
|
|
let s = (v || '').trim();
|
|
if (!s) return null;
|
|
if (!s.startsWith('#')) s = '#' + s;
|
|
if (!isHex(s)) return null;
|
|
return s.toUpperCase();
|
|
};
|
|
|
|
const pickRandom = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
|
|
|
const wire = (el) => {
|
|
// avoid double-binding when htmx swaps
|
|
if (el.__colorpickerWired) return;
|
|
el.__colorpickerWired = true;
|
|
|
|
const color = el.querySelector(SELECTORS.colorPickerColor);
|
|
const hex = el.querySelector(SELECTORS.colorPickerHex);
|
|
const btn = el.querySelector(SELECTORS.colorPickerRandom);
|
|
|
|
if (!color || !hex) return;
|
|
|
|
let palette = [];
|
|
try {
|
|
palette = JSON.parse(el.getAttribute('data-palette') || '[]');
|
|
} catch {
|
|
palette = [];
|
|
}
|
|
|
|
const setValue = (val) => {
|
|
const n = normalize(val);
|
|
if (!n) return false;
|
|
|
|
color.value = n;
|
|
hex.value = n;
|
|
|
|
// bubble input/change for any listeners (htmx, previews, etc.)
|
|
color.dispatchEvent(new Event('input', { bubbles: true }));
|
|
color.dispatchEvent(new Event('change', { bubbles: true }));
|
|
return true;
|
|
};
|
|
|
|
// init sync from native input
|
|
hex.value = normalize(color.value) || '#000000';
|
|
|
|
// native picker -> hex field
|
|
color.addEventListener('input', () => {
|
|
const n = normalize(color.value);
|
|
if (n) hex.value = n;
|
|
});
|
|
|
|
// hex typing -> native picker (on blur + Enter)
|
|
const commitHex = () => {
|
|
const ok = setValue(hex.value);
|
|
if (!ok) hex.value = normalize(color.value) || hex.value;
|
|
};
|
|
|
|
hex.addEventListener('blur', commitHex);
|
|
hex.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
commitHex();
|
|
}
|
|
});
|
|
|
|
// random button
|
|
if (btn && palette.length) {
|
|
btn.addEventListener('click', (e) => {
|
|
e.preventDefault(); // defensive: never submit, never navigate
|
|
e.stopPropagation();
|
|
|
|
let next = pickRandom(palette);
|
|
if (palette.length > 1) {
|
|
const current = normalize(color.value);
|
|
// avoid re-rolling the same number if possible
|
|
while (normalize(next) === current) next = pickRandom(palette);
|
|
}
|
|
setValue(next);
|
|
});
|
|
}
|
|
};
|
|
|
|
root.querySelectorAll(SELECTORS.colorPicker).forEach(wire);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* month view overflow handling (progressive enhancement)
|
|
*/
|
|
|
|
function initMonthOverflow(root = document) {
|
|
const days = root.querySelectorAll(SELECTORS.monthDay);
|
|
days.forEach((day) => updateMonthOverflow(day));
|
|
}
|
|
|
|
function ensureDayMoreButton(dayEl) {
|
|
let wrapper = dayEl.querySelector(SELECTORS.monthDayMoreWrap);
|
|
if (!wrapper) {
|
|
wrapper = document.createElement('div');
|
|
wrapper.className = 'more-events';
|
|
dayEl.appendChild(wrapper);
|
|
}
|
|
|
|
let button = wrapper.querySelector(SELECTORS.monthDayMore);
|
|
if (!button) {
|
|
button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'day-more hidden';
|
|
button.setAttribute('data-day-more', '');
|
|
wrapper.appendChild(button);
|
|
}
|
|
|
|
return button;
|
|
}
|
|
|
|
function formatMoreLabel(dayEl, count) {
|
|
const template = dayEl.getAttribute('data-more-label') || ':count more';
|
|
return template.replace(':count', count);
|
|
}
|
|
|
|
function lessLabel(dayEl) {
|
|
return dayEl.getAttribute('data-less-label') || 'Show less';
|
|
}
|
|
|
|
function updateMonthOverflow(dayEl) {
|
|
if (!dayEl) return;
|
|
|
|
const events = Array.from(dayEl.querySelectorAll(SELECTORS.monthDayEvent))
|
|
.filter((el) => !el.classList.contains('hidden'));
|
|
|
|
const moreButton = ensureDayMoreButton(dayEl);
|
|
|
|
if (!events.length) {
|
|
moreButton.textContent = '';
|
|
moreButton.classList.add('hidden');
|
|
moreButton.removeAttribute('aria-expanded');
|
|
dayEl.classList.remove('day--event-overflow');
|
|
dayEl.setAttribute('data-event-visible', '0');
|
|
return;
|
|
}
|
|
|
|
if (dayEl.classList.contains('is-expanded')) {
|
|
moreButton.textContent = lessLabel(dayEl);
|
|
moreButton.classList.remove('hidden');
|
|
moreButton.setAttribute('aria-expanded', 'true');
|
|
dayEl.classList.remove('day--event-overflow');
|
|
dayEl.setAttribute('data-event-visible', String(events.length));
|
|
return;
|
|
}
|
|
|
|
const wrapper = moreButton.closest(SELECTORS.monthDayMoreWrap);
|
|
let wrapperHeight = wrapper ? wrapper.getBoundingClientRect().height : 0;
|
|
if (wrapperHeight === 0 && wrapper) {
|
|
const wasHidden = moreButton.classList.contains('hidden');
|
|
const prevVisibility = moreButton.style.visibility;
|
|
|
|
if (wasHidden) {
|
|
moreButton.classList.remove('hidden');
|
|
moreButton.style.visibility = 'hidden';
|
|
}
|
|
|
|
wrapperHeight = wrapper.getBoundingClientRect().height || 0;
|
|
|
|
if (wasHidden) {
|
|
moreButton.classList.add('hidden');
|
|
moreButton.style.visibility = prevVisibility;
|
|
}
|
|
}
|
|
|
|
const prevVisibility = dayEl.style.visibility;
|
|
dayEl.style.visibility = 'hidden';
|
|
dayEl.removeAttribute('data-event-visible');
|
|
dayEl.classList.remove('day--event-overflow');
|
|
|
|
const availableHeight = dayEl.clientHeight - wrapperHeight;
|
|
let hiddenCount = 0;
|
|
events.forEach((eventEl) => {
|
|
const bottom = eventEl.offsetTop + eventEl.offsetHeight;
|
|
if (bottom > availableHeight + 0.5) {
|
|
hiddenCount += 1;
|
|
}
|
|
});
|
|
|
|
dayEl.style.visibility = prevVisibility;
|
|
|
|
const visibleCount = Math.max(0, events.length - hiddenCount);
|
|
dayEl.setAttribute('data-event-visible', String(visibleCount));
|
|
|
|
if (hiddenCount > 0) {
|
|
moreButton.textContent = formatMoreLabel(dayEl, hiddenCount);
|
|
moreButton.classList.remove('hidden');
|
|
moreButton.setAttribute('aria-expanded', 'false');
|
|
dayEl.classList.add('day--event-overflow');
|
|
} else {
|
|
moreButton.textContent = '';
|
|
moreButton.classList.add('hidden');
|
|
moreButton.removeAttribute('aria-expanded');
|
|
dayEl.classList.remove('day--event-overflow');
|
|
}
|
|
}
|
|
|
|
// show more events in a month calendar day when some are hidden
|
|
document.addEventListener('click', (event) => {
|
|
const button = event.target.closest(SELECTORS.monthDayMore);
|
|
if (!button) return;
|
|
|
|
const dayEl = button.closest(SELECTORS.monthDay);
|
|
if (!dayEl) return;
|
|
|
|
dayEl.classList.toggle('is-expanded');
|
|
updateMonthOverflow(dayEl);
|
|
});
|
|
|
|
// month day resizer
|
|
let monthResizeTimer;
|
|
window.addEventListener('resize', () => {
|
|
if (!document.querySelector(SELECTORS.monthDay)) return;
|
|
window.clearTimeout(monthResizeTimer);
|
|
monthResizeTimer = window.setTimeout(() => initMonthOverflow(), 100);
|
|
});
|
|
|
|
/**
|
|
*
|
|
* initialization
|
|
*/
|
|
|
|
function initUI() {
|
|
initColorPickers();
|
|
initEventAllDayToggles();
|
|
initRecurrenceControls();
|
|
initModalHandlers();
|
|
initTabs();
|
|
initTimeViewAutoScroll();
|
|
initMonthOverflow();
|
|
}
|
|
|
|
// initial bind
|
|
document.addEventListener('DOMContentLoaded', initUI);
|
|
|
|
// rebind in htmx for swapped content
|
|
document.addEventListener('htmx:afterSwap', (e) => {
|
|
const target = e.detail?.target || e.target;
|
|
if (target && target.id === 'modal') {
|
|
target.closest('dialog')?.showModal();
|
|
}
|
|
|
|
initColorPickers(e.target);
|
|
initEventAllDayToggles(e.target);
|
|
initRecurrenceControls(e.target);
|
|
initModalHandlers(e.target);
|
|
initTabs(e.target);
|
|
initTimeViewAutoScroll(e.target);
|
|
initMonthOverflow(e.target);
|
|
});
|