1687 lines
57 KiB
JavaScript
1687 lines
57 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]',
|
|
eventTitleInput: 'input[name="title"]',
|
|
eventLocationInput: 'input[name="location"]',
|
|
modalDialog: 'dialog',
|
|
modalContent: '#modal',
|
|
modalClassSource: '[data-modal-class]',
|
|
modalAside: '#modal-aside',
|
|
modalAsideList: '[data-modal-aside-list]',
|
|
modalExpand: '[data-modal-expand]',
|
|
monthDay: '.calendar.month .day',
|
|
monthDayEvent: 'a.event',
|
|
monthDayMore: '[data-day-more]',
|
|
monthDayMoreWrap: '.more-events',
|
|
monthlyMode: '[data-monthly-mode]',
|
|
monthlyDays: '[data-monthly-days]',
|
|
monthlyWeekday: '[data-monthly-weekday]',
|
|
naturalEventInput: '[data-natural-event-input]',
|
|
recurrenceFrequency: '[data-recurrence-frequency]',
|
|
recurrenceInterval: '[data-recurrence-interval]',
|
|
recurrenceUnit: '[data-recurrence-unit]',
|
|
recurrenceSection: '[data-recurrence-section]',
|
|
tabsRoot: '[data-tabs]',
|
|
tabButton: '[role=\"tab\"]',
|
|
tabPanel: '[role=\"tabpanel\"]',
|
|
attendeesRoot: '[data-attendees]',
|
|
attendeesList: '[data-attendees-list]',
|
|
attendeeTemplate: 'template[data-attendee-template]',
|
|
attendeeLookup: '[data-attendee-lookup]',
|
|
attendeeSuggestions: '#attendee-suggestions',
|
|
attendeeAddManual: '[data-attendee-add-manual]',
|
|
attendeePick: '[data-attendee-pick]',
|
|
attendeeRemove: '[data-attendee-remove]',
|
|
attendeeRow: '[data-attendee-row]',
|
|
attendeeRole: '[data-attendee-role]',
|
|
attendeeName: '[data-attendee-name]',
|
|
attendeeOptional: '[data-attendee-optional]',
|
|
attendeeDisplay: '[data-attendee-display]',
|
|
attendeeVerified: '[data-attendee-verified]',
|
|
attendeeEmail: '[data-attendee-email]',
|
|
attendeeUri: '[data-attendee-uri]',
|
|
};
|
|
|
|
function syncModalRootClass(modal) {
|
|
if (!modal) return;
|
|
|
|
const previous = (modal.dataset.appliedClass || '')
|
|
.split(/\s+/)
|
|
.filter(Boolean);
|
|
|
|
if (previous.length) {
|
|
modal.classList.remove(...previous);
|
|
}
|
|
|
|
const source = modal.querySelector(SELECTORS.modalClassSource);
|
|
const next = (source?.dataset?.modalClass || '')
|
|
.split(/\s+/)
|
|
.filter(Boolean);
|
|
|
|
if (next.length) {
|
|
modal.classList.add(...next);
|
|
}
|
|
|
|
modal.dataset.appliedClass = next.join(' ');
|
|
}
|
|
|
|
function clearModalRootClass(modal) {
|
|
if (!modal) return;
|
|
|
|
const previous = (modal.dataset.appliedClass || '')
|
|
.split(/\s+/)
|
|
.filter(Boolean);
|
|
|
|
if (previous.length) {
|
|
modal.classList.remove(...previous);
|
|
}
|
|
|
|
modal.dataset.appliedClass = '';
|
|
}
|
|
|
|
function renderModalAside(dialog, items = [], show = false) {
|
|
if (!dialog) return;
|
|
|
|
const aside = dialog.querySelector(SELECTORS.modalAside);
|
|
if (!aside) return;
|
|
|
|
const signature = `${show ? '1' : '0'}|${items.map((item) => `${item.label}:${item.value}`).join('|')}`;
|
|
if (aside.dataset.asideSignature === signature) {
|
|
return;
|
|
}
|
|
|
|
const list = aside.querySelector(SELECTORS.modalAsideList);
|
|
if (list) {
|
|
const existing = new Map(
|
|
Array.from(list.querySelectorAll('li[data-aside-key]')).map((li) => [li.dataset.asideKey, li])
|
|
);
|
|
const usedKeys = new Set();
|
|
|
|
items.forEach(({ label, value }) => {
|
|
const key = String(label || '').trim().toLowerCase().replace(/\s+/g, '-');
|
|
usedKeys.add(key);
|
|
|
|
let li = existing.get(key);
|
|
if (!li) {
|
|
li = document.createElement('li');
|
|
li.dataset.asideKey = key;
|
|
li.classList.add('is-visible');
|
|
|
|
const strong = document.createElement('strong');
|
|
const text = document.createElement('span');
|
|
li.appendChild(strong);
|
|
li.appendChild(text);
|
|
list.appendChild(li);
|
|
|
|
li.addEventListener('animationend', () => {
|
|
li.classList.remove('is-visible');
|
|
}, { once: true });
|
|
}
|
|
|
|
const strong = li.querySelector('strong');
|
|
const text = li.querySelector('span');
|
|
const nextLabel = `${label}:`;
|
|
const nextValue = ` ${value}`;
|
|
|
|
if (strong && strong.textContent !== nextLabel) {
|
|
strong.textContent = nextLabel;
|
|
}
|
|
if (text && text.textContent !== nextValue) {
|
|
text.textContent = nextValue;
|
|
}
|
|
});
|
|
|
|
existing.forEach((li, key) => {
|
|
if (!usedKeys.has(key)) {
|
|
li.remove();
|
|
}
|
|
});
|
|
}
|
|
|
|
aside.classList.toggle('is-visible', show);
|
|
aside.setAttribute('aria-hidden', show ? 'false' : 'true');
|
|
aside.dataset.asideSignature = signature;
|
|
}
|
|
|
|
function clearModalAside(dialog) {
|
|
renderModalAside(dialog, [], false);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* 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;
|
|
syncModalRootClass(target);
|
|
}
|
|
});
|
|
|
|
/**
|
|
*
|
|
* 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
|
|
*
|
|
* converts the datetime-local inputs into regular date inputs, maintaining the date
|
|
*/
|
|
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((toggleHost) => {
|
|
const toggle = toggleHost.matches('input[type="checkbox"]')
|
|
? toggleHost
|
|
: toggleHost.querySelector('input[type="checkbox"]');
|
|
|
|
if (!toggle || toggle.__allDayWired) return;
|
|
toggle.__allDayWired = true;
|
|
|
|
const form = toggle.closest('form') || toggleHost.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();
|
|
}
|
|
|
|
/**
|
|
*
|
|
* natural-language event parser (create modal header input)
|
|
*/
|
|
function initNaturalEventParser(root = document) {
|
|
const monthMap = {
|
|
jan: 0, january: 0,
|
|
feb: 1, february: 1,
|
|
mar: 2, march: 2,
|
|
apr: 3, april: 3,
|
|
may: 4,
|
|
jun: 5, june: 5,
|
|
jul: 6, july: 6,
|
|
aug: 7, august: 7,
|
|
sep: 8, sept: 8, september: 8,
|
|
oct: 9, october: 9,
|
|
nov: 10, november: 10,
|
|
dec: 11, december: 11,
|
|
};
|
|
|
|
const pad = (value) => String(value).padStart(2, '0');
|
|
|
|
const toLocalDateInputValue = (date) => (
|
|
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
|
);
|
|
|
|
const toLocalDatetimeInputValue = (date) => (
|
|
`${toLocalDateInputValue(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
);
|
|
|
|
const readDateInputValue = (value) => {
|
|
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
|
|
const [year, month, day] = value.split('-').map((n) => parseInt(n, 10));
|
|
if ([year, month, day].some((n) => Number.isNaN(n))) return null;
|
|
return new Date(year, month - 1, day, 0, 0, 0, 0);
|
|
};
|
|
|
|
const readDatetimeInputValue = (value) => {
|
|
if (!value || !value.includes('T')) return null;
|
|
const [datePart, timePart] = value.split('T');
|
|
if (!datePart || !timePart) return null;
|
|
const [year, month, day] = datePart.split('-').map((n) => parseInt(n, 10));
|
|
const [hours, minutes] = timePart.split(':').map((n) => parseInt(n, 10));
|
|
if ([year, month, day, hours, minutes].some((n) => Number.isNaN(n))) return null;
|
|
return new Date(year, month - 1, day, hours, minutes, 0, 0);
|
|
};
|
|
|
|
const formatDayLong = (date) => (
|
|
date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })
|
|
);
|
|
|
|
const formatTimeShort = (date) => (
|
|
date
|
|
.toLocaleTimeString(undefined, {
|
|
hour: 'numeric',
|
|
minute: date.getMinutes() === 0 ? undefined : '2-digit',
|
|
})
|
|
.toLowerCase()
|
|
.replace(/\./g, '')
|
|
.replace(/\s+/g, '')
|
|
);
|
|
|
|
const formatDateSummary = (startDate, endDate, allDay) => {
|
|
if (!startDate) return '';
|
|
|
|
const startDay = formatDayLong(startDate);
|
|
|
|
if (allDay) {
|
|
if (endDate && endDate.toDateString() !== startDate.toDateString()) {
|
|
return `${startDay} to ${formatDayLong(endDate)}, all day`;
|
|
}
|
|
return `${startDay}, all day`;
|
|
}
|
|
|
|
const startTime = formatTimeShort(startDate);
|
|
if (!endDate) {
|
|
return `${startDay}, ${startTime}`;
|
|
}
|
|
|
|
const endTime = formatTimeShort(endDate);
|
|
if (endDate.toDateString() === startDate.toDateString()) {
|
|
return `${startDay}, ${startTime} to ${endTime}`;
|
|
}
|
|
|
|
return `${startDay}, ${startTime} to ${formatDayLong(endDate)}, ${endTime}`;
|
|
};
|
|
|
|
const parseDurationMinutes = (text) => {
|
|
const match = text.match(/\bfor\s+(\d+)\s*(minutes?|mins?|hours?|hrs?)\b/i);
|
|
if (!match) return 60;
|
|
|
|
const count = parseInt(match[1], 10);
|
|
const unit = (match[2] || '').toLowerCase();
|
|
if (Number.isNaN(count) || count <= 0) return 60;
|
|
|
|
if (unit.startsWith('hour') || unit.startsWith('hr')) {
|
|
return count * 60;
|
|
}
|
|
|
|
return count;
|
|
};
|
|
|
|
const parseDateToken = (text, now) => {
|
|
const lower = text.toLowerCase();
|
|
|
|
const todayMatch = lower.match(/\btoday\b/);
|
|
if (todayMatch) {
|
|
const date = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
|
return { date, index: todayMatch.index ?? 0 };
|
|
}
|
|
|
|
const tomorrowMatch = lower.match(/\btomorrow\b/);
|
|
if (tomorrowMatch) {
|
|
const date = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 0, 0);
|
|
return { date, index: tomorrowMatch.index ?? 0 };
|
|
}
|
|
|
|
const monthMatch = text.match(/\b(?:on\s+)?([A-Za-z]{3,9})\.?\s+(\d{1,2})(?:,\s*(\d{4}))?\b/);
|
|
if (monthMatch) {
|
|
const monthToken = (monthMatch[1] || '').toLowerCase();
|
|
const month = monthMap[monthToken];
|
|
const day = parseInt(monthMatch[2], 10);
|
|
const explicitYear = monthMatch[3] ? parseInt(monthMatch[3], 10) : null;
|
|
|
|
if (month !== undefined && !Number.isNaN(day) && day >= 1 && day <= 31) {
|
|
const thisYear = now.getFullYear();
|
|
let date = new Date(explicitYear || thisYear, month, day, 0, 0, 0, 0);
|
|
if (!explicitYear) {
|
|
const today = new Date(thisYear, now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
|
if (date < today) {
|
|
date = new Date(thisYear + 1, month, day, 0, 0, 0, 0);
|
|
}
|
|
}
|
|
return { date, index: monthMatch.index ?? 0 };
|
|
}
|
|
}
|
|
|
|
const numericMatch = text.match(/\b(?:on\s+)?(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?\b/);
|
|
if (numericMatch) {
|
|
const month = parseInt(numericMatch[1], 10) - 1;
|
|
const day = parseInt(numericMatch[2], 10);
|
|
const yearToken = numericMatch[3];
|
|
const thisYear = now.getFullYear();
|
|
let year = thisYear;
|
|
|
|
if (yearToken) {
|
|
year = parseInt(yearToken, 10);
|
|
if (year < 100) year += 2000;
|
|
}
|
|
|
|
if (!Number.isNaN(month) && !Number.isNaN(day)) {
|
|
let date = new Date(year, month, day, 0, 0, 0, 0);
|
|
if (!yearToken) {
|
|
const today = new Date(thisYear, now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
|
if (date < today) {
|
|
date = new Date(thisYear + 1, month, day, 0, 0, 0, 0);
|
|
}
|
|
}
|
|
return { date, index: numericMatch.index ?? 0 };
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const parseTimeToken = (text) => {
|
|
const lowered = text.toLowerCase();
|
|
const noonIndex = lowered.indexOf(' at noon');
|
|
if (noonIndex !== -1) {
|
|
return { hours: 12, minutes: 0, index: noonIndex };
|
|
}
|
|
|
|
const midnightIndex = lowered.indexOf(' at midnight');
|
|
if (midnightIndex !== -1) {
|
|
return { hours: 0, minutes: 0, index: midnightIndex };
|
|
}
|
|
|
|
const match = text.match(/\bat\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\b/i);
|
|
if (!match) return null;
|
|
|
|
let hours = parseInt(match[1], 10);
|
|
const minutes = parseInt(match[2] || '0', 10);
|
|
const meridiem = (match[3] || '').toLowerCase();
|
|
|
|
if (Number.isNaN(hours) || Number.isNaN(minutes)) return null;
|
|
|
|
if (meridiem) {
|
|
if (hours === 12 && meridiem === 'am') hours = 0;
|
|
if (hours < 12 && meridiem === 'pm') hours += 12;
|
|
} else if (hours > 23) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
hours,
|
|
minutes,
|
|
index: match.index ?? 0,
|
|
};
|
|
};
|
|
|
|
const parseRelativeToken = (text, now) => {
|
|
const addOffset = (days, minutes) => {
|
|
const date = new Date(now.getTime());
|
|
date.setMinutes(date.getMinutes() + (days * 24 * 60) + minutes);
|
|
return date;
|
|
};
|
|
|
|
let match = text.match(/\bin\s+(\d+)\s+days?(?:\s+and\s+(\d+)\s+minutes?)?\b/i);
|
|
if (match) {
|
|
const days = parseInt(match[1], 10);
|
|
const minutes = parseInt(match[2] || '0', 10);
|
|
if (!Number.isNaN(days) && !Number.isNaN(minutes)) {
|
|
return {
|
|
date: addOffset(days, minutes),
|
|
index: match.index ?? 0,
|
|
};
|
|
}
|
|
}
|
|
|
|
match = text.match(/\b(\d+)\s+days?(?:\s+and\s+(\d+)\s+minutes?)?\s+from\s+now\b/i);
|
|
if (match) {
|
|
const days = parseInt(match[1], 10);
|
|
const minutes = parseInt(match[2] || '0', 10);
|
|
if (!Number.isNaN(days) && !Number.isNaN(minutes)) {
|
|
return {
|
|
date: addOffset(days, minutes),
|
|
index: match.index ?? 0,
|
|
};
|
|
}
|
|
}
|
|
|
|
match = text.match(/\bin\s+(\d+)\s+minutes?\b/i);
|
|
if (match) {
|
|
const minutes = parseInt(match[1], 10);
|
|
if (!Number.isNaN(minutes)) {
|
|
return {
|
|
date: addOffset(0, minutes),
|
|
index: match.index ?? 0,
|
|
};
|
|
}
|
|
}
|
|
|
|
match = text.match(/\b(\d+)\s+minutes?\s+from\s+now\b/i);
|
|
if (match) {
|
|
const minutes = parseInt(match[1], 10);
|
|
if (!Number.isNaN(minutes)) {
|
|
return {
|
|
date: addOffset(0, minutes),
|
|
index: match.index ?? 0,
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const weekdayCodeMap = {
|
|
su: 'SU', sun: 'SU', sunday: 'SU', sundays: 'SU',
|
|
mo: 'MO', mon: 'MO', monday: 'MO', mondays: 'MO',
|
|
tu: 'TU', tue: 'TU', tues: 'TU', tuesday: 'TU', tuesdays: 'TU',
|
|
we: 'WE', wed: 'WE', wednesday: 'WE', wednesdays: 'WE',
|
|
th: 'TH', thu: 'TH', thur: 'TH', thurs: 'TH', thursday: 'TH', thursdays: 'TH',
|
|
fr: 'FR', fri: 'FR', friday: 'FR', fridays: 'FR',
|
|
sa: 'SA', sat: 'SA', saturday: 'SA', saturdays: 'SA',
|
|
};
|
|
|
|
const weekdayNameMap = {
|
|
SU: 'Sunday',
|
|
MO: 'Monday',
|
|
TU: 'Tuesday',
|
|
WE: 'Wednesday',
|
|
TH: 'Thursday',
|
|
FR: 'Friday',
|
|
SA: 'Saturday',
|
|
};
|
|
|
|
const weekdayCodeFromDate = (date) => ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'][date.getDay()];
|
|
|
|
const parseRecurrenceToken = (text) => {
|
|
const repeatMatch = text.match(/\brepeats?\b([\s\S]*)$/i);
|
|
if (!repeatMatch) return null;
|
|
|
|
const tail = (repeatMatch[1] || '').trim().toLowerCase();
|
|
const recurrence = {
|
|
frequency: null,
|
|
interval: 1,
|
|
weekdays: [],
|
|
index: repeatMatch.index ?? 0,
|
|
};
|
|
|
|
const everyNumbered = tail.match(/\bevery\s+(\d+)\s*(day|week|month|year)s?\b/i);
|
|
if (everyNumbered) {
|
|
const count = parseInt(everyNumbered[1], 10);
|
|
const unit = everyNumbered[2].toLowerCase();
|
|
if (!Number.isNaN(count) && count > 0) {
|
|
recurrence.interval = count;
|
|
}
|
|
recurrence.frequency = {
|
|
day: 'daily',
|
|
week: 'weekly',
|
|
month: 'monthly',
|
|
year: 'yearly',
|
|
}[unit] || null;
|
|
} else if (/\b(daily|every day)\b/i.test(tail)) {
|
|
recurrence.frequency = 'daily';
|
|
} else if (/\b(weekly|every week)\b/i.test(tail)) {
|
|
recurrence.frequency = 'weekly';
|
|
} else if (/\b(monthly|every month)\b/i.test(tail)) {
|
|
recurrence.frequency = 'monthly';
|
|
} else if (/\b(yearly|every year)\b/i.test(tail)) {
|
|
recurrence.frequency = 'yearly';
|
|
}
|
|
|
|
if (!recurrence.frequency) {
|
|
return null;
|
|
}
|
|
|
|
if (recurrence.frequency === 'weekly') {
|
|
const onMatch = tail.match(/\bon\s+(.+)$/i);
|
|
if (onMatch) {
|
|
const weekdays = [];
|
|
const tokens = onMatch[1]
|
|
.replace(/[,/]/g, ' ')
|
|
.replace(/\band\b/g, ' ')
|
|
.split(/\s+/)
|
|
.map((token) => token.trim().toLowerCase().replace(/[^a-z]/g, ''))
|
|
.filter(Boolean);
|
|
|
|
tokens.forEach((token) => {
|
|
const code = weekdayCodeMap[token];
|
|
if (code && !weekdays.includes(code)) {
|
|
weekdays.push(code);
|
|
}
|
|
});
|
|
|
|
recurrence.weekdays = weekdays;
|
|
}
|
|
}
|
|
|
|
return recurrence;
|
|
};
|
|
|
|
const parseLocationToken = (text) => {
|
|
const lower = text.toLowerCase();
|
|
const index = lower.lastIndexOf(' at ');
|
|
if (index === -1) return null;
|
|
|
|
const candidate = text.slice(index + 4).trim().replace(/[.,;]+$/, '');
|
|
if (!candidate) return null;
|
|
if (/^\d{1,2}(?::\d{2})?\s*(am|pm)?\b/i.test(candidate)) return null;
|
|
if (/\bfor\s+\d+\s*(minutes?|mins?|hours?|hrs?)\b/i.test(candidate)) return null;
|
|
|
|
return candidate;
|
|
};
|
|
|
|
const parseTitleToken = (text, dateToken, timeToken, relativeToken, recurrenceToken, locationToken) => {
|
|
const boundaries = [];
|
|
if (dateToken) boundaries.push(dateToken.index);
|
|
if (timeToken) boundaries.push(timeToken.index);
|
|
if (relativeToken) boundaries.push(relativeToken.index);
|
|
if (recurrenceToken) boundaries.push(recurrenceToken.index);
|
|
|
|
const lower = text.toLowerCase();
|
|
const durationIndex = lower.indexOf(' for ');
|
|
if (durationIndex !== -1) boundaries.push(durationIndex);
|
|
|
|
if (locationToken) {
|
|
const locationIndex = lower.lastIndexOf(' at ');
|
|
if (locationIndex !== -1) boundaries.push(locationIndex);
|
|
}
|
|
|
|
if (!boundaries.length) {
|
|
return text.trim();
|
|
}
|
|
|
|
const end = Math.min(...boundaries);
|
|
return text.slice(0, end).trim().replace(/[,:;.\-]+$/, '').trim();
|
|
};
|
|
|
|
const parseNaturalEventText = (text, baseDate) => {
|
|
const trimmed = (text || '').trim();
|
|
if (!trimmed) return null;
|
|
|
|
const relativeToken = parseRelativeToken(trimmed, baseDate);
|
|
const recurrenceToken = parseRecurrenceToken(trimmed);
|
|
const dateToken = parseDateToken(trimmed, baseDate);
|
|
const timeToken = parseTimeToken(trimmed);
|
|
const durationMinutes = parseDurationMinutes(trimmed);
|
|
const location = parseLocationToken(trimmed);
|
|
const title = parseTitleToken(trimmed, dateToken, timeToken, relativeToken, recurrenceToken, location);
|
|
const allDay = /\ball(?:\s|-)?day\b/i.test(trimmed);
|
|
|
|
return {
|
|
title,
|
|
location,
|
|
dateToken,
|
|
timeToken,
|
|
relativeToken,
|
|
recurrenceToken,
|
|
durationMinutes,
|
|
allDay,
|
|
};
|
|
};
|
|
|
|
const findFormFromNaturalInput = (input) => {
|
|
const dialog = input.closest('dialog');
|
|
if (dialog) {
|
|
return dialog.querySelector('#event-form');
|
|
}
|
|
|
|
const modal = input.closest(SELECTORS.modalContent);
|
|
if (modal) {
|
|
return modal.querySelector('#event-form');
|
|
}
|
|
|
|
return document.querySelector('#event-form');
|
|
};
|
|
|
|
const enableNaturalCollapsedMode = (input) => {
|
|
const dialog = input.closest('dialog');
|
|
const modal = dialog?.querySelector(SELECTORS.modalContent);
|
|
if (!dialog || !modal || modal.classList.contains('natural-expanded')) return;
|
|
|
|
modal.classList.add('natural-collapsed');
|
|
renderModalAside(dialog, [], true);
|
|
};
|
|
|
|
const clearRecurrenceInputs = (form) => {
|
|
const frequencyInput = form.querySelector('select[name="repeat_frequency"]');
|
|
const intervalInput = form.querySelector('input[name="repeat_interval"]');
|
|
const weekdayInputs = Array.from(form.querySelectorAll('input[name="repeat_weekdays[]"]'));
|
|
const monthlyModeInputs = Array.from(form.querySelectorAll('input[name="repeat_monthly_mode"]'));
|
|
const monthDayInputs = Array.from(form.querySelectorAll('input[name="repeat_month_days[]"]'));
|
|
|
|
if (frequencyInput) {
|
|
frequencyInput.value = '';
|
|
frequencyInput.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
if (intervalInput) {
|
|
intervalInput.value = '1';
|
|
}
|
|
weekdayInputs.forEach((input) => {
|
|
input.checked = false;
|
|
});
|
|
monthlyModeInputs.forEach((input) => {
|
|
input.checked = input.value === 'days';
|
|
});
|
|
monthDayInputs.forEach((input) => {
|
|
input.checked = false;
|
|
});
|
|
};
|
|
|
|
const applyRecurrenceInputs = (form, recurrenceToken, anchorDate) => {
|
|
const frequencyInput = form.querySelector('select[name="repeat_frequency"]');
|
|
const intervalInput = form.querySelector('input[name="repeat_interval"]');
|
|
const weekdayInputs = Array.from(form.querySelectorAll('input[name="repeat_weekdays[]"]'));
|
|
const monthlyModeInputs = Array.from(form.querySelectorAll('input[name="repeat_monthly_mode"]'));
|
|
const monthDayInputs = Array.from(form.querySelectorAll('input[name="repeat_month_days[]"]'));
|
|
|
|
if (!frequencyInput || !recurrenceToken?.frequency) {
|
|
return;
|
|
}
|
|
|
|
frequencyInput.value = recurrenceToken.frequency;
|
|
frequencyInput.dispatchEvent(new Event('change', { bubbles: true }));
|
|
|
|
if (intervalInput) {
|
|
intervalInput.value = String(recurrenceToken.interval || 1);
|
|
}
|
|
|
|
if (recurrenceToken.frequency === 'weekly') {
|
|
let weekdays = recurrenceToken.weekdays || [];
|
|
if (!weekdays.length && anchorDate) {
|
|
weekdays = [weekdayCodeFromDate(anchorDate)];
|
|
}
|
|
weekdayInputs.forEach((input) => {
|
|
input.checked = weekdays.includes(input.value);
|
|
});
|
|
} else {
|
|
weekdayInputs.forEach((input) => {
|
|
input.checked = false;
|
|
});
|
|
}
|
|
|
|
if (recurrenceToken.frequency === 'monthly') {
|
|
monthlyModeInputs.forEach((input) => {
|
|
input.checked = input.value === 'days';
|
|
});
|
|
|
|
const dayOfMonth = anchorDate ? anchorDate.getDate() : null;
|
|
monthDayInputs.forEach((input) => {
|
|
input.checked = dayOfMonth ? Number(input.value) === dayOfMonth : false;
|
|
});
|
|
} else {
|
|
monthDayInputs.forEach((input) => {
|
|
input.checked = false;
|
|
});
|
|
}
|
|
};
|
|
|
|
const recurrenceSummary = (recurrenceToken, anchorDate) => {
|
|
if (!recurrenceToken?.frequency) return '';
|
|
|
|
const interval = recurrenceToken.interval || 1;
|
|
|
|
if (recurrenceToken.frequency === 'daily') {
|
|
return interval === 1 ? 'Daily' : `Every ${interval} days`;
|
|
}
|
|
|
|
if (recurrenceToken.frequency === 'weekly') {
|
|
const days = (recurrenceToken.weekdays || []).length
|
|
? recurrenceToken.weekdays
|
|
: (anchorDate ? [weekdayCodeFromDate(anchorDate)] : []);
|
|
const dayLabels = days.map((code) => weekdayNameMap[code] || code);
|
|
const head = interval === 1 ? 'Every week' : `Every ${interval} weeks`;
|
|
return dayLabels.length ? `${head} on ${dayLabels.join(' and ')}` : head;
|
|
}
|
|
|
|
if (recurrenceToken.frequency === 'monthly') {
|
|
return interval === 1 ? 'Monthly' : `Every ${interval} months`;
|
|
}
|
|
|
|
if (recurrenceToken.frequency === 'yearly') {
|
|
return interval === 1 ? 'Yearly' : `Every ${interval} years`;
|
|
}
|
|
|
|
return '';
|
|
};
|
|
|
|
const applyParsedData = (input) => {
|
|
const dialog = input.closest('dialog');
|
|
const modal = dialog?.querySelector(SELECTORS.modalContent);
|
|
const form = findFormFromNaturalInput(input);
|
|
if (!form) return;
|
|
|
|
const titleInput = form.querySelector(SELECTORS.eventTitleInput);
|
|
const locationInput = form.querySelector(SELECTORS.eventLocationInput);
|
|
const startInput = form.querySelector(SELECTORS.eventStartInput);
|
|
const endInput = form.querySelector(SELECTORS.eventEndInput);
|
|
const allDayToggleHost = form.querySelector(SELECTORS.eventAllDayToggle);
|
|
const allDayToggle = allDayToggleHost?.matches('input[type="checkbox"]')
|
|
? allDayToggleHost
|
|
: allDayToggleHost?.querySelector('input[type="checkbox"]');
|
|
|
|
if (!titleInput || !startInput || !endInput) return;
|
|
|
|
const parsed = parseNaturalEventText(input.value, new Date());
|
|
if (!parsed) {
|
|
if (modal?.classList.contains('natural-collapsed')) {
|
|
renderModalAside(dialog, [], true);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (parsed.title) {
|
|
titleInput.value = parsed.title;
|
|
}
|
|
|
|
if (parsed.location && locationInput) {
|
|
locationInput.value = parsed.location;
|
|
}
|
|
|
|
const existingStart = startInput.type === 'date'
|
|
? readDateInputValue(startInput.value)
|
|
: readDatetimeInputValue(startInput.value);
|
|
const baseDate = parsed.relativeToken?.date
|
|
? new Date(parsed.relativeToken.date.getTime())
|
|
: parsed.dateToken?.date
|
|
? new Date(parsed.dateToken.date.getTime())
|
|
: (existingStart ? new Date(existingStart.getTime()) : new Date());
|
|
|
|
if (parsed.allDay) {
|
|
if (allDayToggle && !allDayToggle.checked) {
|
|
allDayToggle.checked = true;
|
|
allDayToggle.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
|
|
const dateOnly = toLocalDateInputValue(baseDate);
|
|
startInput.value = dateOnly;
|
|
endInput.value = dateOnly;
|
|
} else if (parsed.dateToken || parsed.timeToken || parsed.relativeToken) {
|
|
if (allDayToggle?.checked) {
|
|
allDayToggle.checked = false;
|
|
allDayToggle.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
|
|
const hours = parsed.timeToken?.hours ?? (existingStart ? existingStart.getHours() : 9);
|
|
const minutes = parsed.timeToken?.minutes ?? (existingStart ? existingStart.getMinutes() : 0);
|
|
baseDate.setHours(hours, minutes, 0, 0);
|
|
|
|
const end = new Date(baseDate.getTime());
|
|
end.setMinutes(end.getMinutes() + parsed.durationMinutes);
|
|
|
|
startInput.value = toLocalDatetimeInputValue(baseDate);
|
|
endInput.value = toLocalDatetimeInputValue(end);
|
|
}
|
|
|
|
if (parsed.recurrenceToken) {
|
|
applyRecurrenceInputs(form, parsed.recurrenceToken, baseDate);
|
|
input.dataset.nlRecurrenceApplied = '1';
|
|
} else if (input.dataset.nlRecurrenceApplied === '1') {
|
|
clearRecurrenceInputs(form);
|
|
input.dataset.nlRecurrenceApplied = '0';
|
|
}
|
|
|
|
const summaryItems = [];
|
|
if (parsed.title) {
|
|
summaryItems.push({ label: 'Title', value: parsed.title });
|
|
}
|
|
if (parsed.location) {
|
|
summaryItems.push({ label: 'Location', value: parsed.location });
|
|
}
|
|
if (parsed.dateToken || parsed.timeToken || parsed.relativeToken || parsed.allDay) {
|
|
const summaryStart = startInput.type === 'date'
|
|
? readDateInputValue(startInput.value)
|
|
: readDatetimeInputValue(startInput.value);
|
|
const summaryEnd = endInput.type === 'date'
|
|
? readDateInputValue(endInput.value)
|
|
: readDatetimeInputValue(endInput.value);
|
|
const allDayState = Boolean(allDayToggle?.checked);
|
|
const summaryDate = formatDateSummary(summaryStart, summaryEnd, allDayState);
|
|
|
|
if (summaryDate) {
|
|
summaryItems.push({ label: 'Date', value: summaryDate });
|
|
}
|
|
}
|
|
if (parsed.recurrenceToken) {
|
|
const repeat = recurrenceSummary(parsed.recurrenceToken, baseDate);
|
|
if (repeat) {
|
|
summaryItems.push({ label: 'Repeat', value: repeat });
|
|
}
|
|
}
|
|
|
|
if (modal?.classList.contains('natural-collapsed')) {
|
|
renderModalAside(dialog, summaryItems, true);
|
|
}
|
|
};
|
|
|
|
if (!document.__naturalEventDelegated) {
|
|
document.__naturalEventDelegated = true;
|
|
|
|
document.addEventListener('input', (event) => {
|
|
const input = event.target?.closest?.(SELECTORS.naturalEventInput);
|
|
if (!input) return;
|
|
applyParsedData(input);
|
|
});
|
|
|
|
document.addEventListener('change', (event) => {
|
|
const input = event.target?.closest?.(SELECTORS.naturalEventInput);
|
|
if (!input) return;
|
|
applyParsedData(input);
|
|
});
|
|
|
|
document.addEventListener('click', (event) => {
|
|
const button = event.target?.closest?.(SELECTORS.modalExpand);
|
|
if (!button) return;
|
|
|
|
const dialog = button.closest('dialog');
|
|
const modal = dialog?.querySelector(SELECTORS.modalContent);
|
|
if (!dialog || !modal) return;
|
|
|
|
modal.classList.remove('natural-collapsed');
|
|
modal.classList.add('natural-expanded');
|
|
clearModalAside(dialog);
|
|
});
|
|
}
|
|
|
|
root.querySelectorAll(SELECTORS.naturalEventInput).forEach((input) => {
|
|
enableNaturalCollapsedMode(input);
|
|
|
|
if (input.value?.trim()) {
|
|
applyParsedData(input);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
*
|
|
* 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.classList.remove('natural-collapsed', 'natural-expanded');
|
|
modal.innerHTML = '';
|
|
clearModalRootClass(modal);
|
|
clearModalAside(dialog);
|
|
|
|
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 tabEls = Array.from(tabs.querySelectorAll(SELECTORS.tabButton));
|
|
const panels = Array.from(tabs.querySelectorAll(SELECTORS.tabPanel));
|
|
if (!tabEls.length || !panels.length) return;
|
|
|
|
const getPanelForTab = (tab, index) => {
|
|
const controls = tab.getAttribute('aria-controls');
|
|
|
|
if (controls) {
|
|
const panelById = tabs.querySelector(`[role="tabpanel"]#${controls}`);
|
|
if (panelById) return panelById;
|
|
}
|
|
|
|
return panels[index] || null;
|
|
};
|
|
|
|
const panelByTab = new Map();
|
|
tabEls.forEach((tab, index) => {
|
|
const panel = getPanelForTab(tab, index);
|
|
if (panel) {
|
|
panelByTab.set(tab, panel);
|
|
}
|
|
|
|
// <li role="tab"> is not focusable by default.
|
|
if (!tab.hasAttribute('tabindex')) {
|
|
tab.setAttribute('tabindex', '-1');
|
|
}
|
|
});
|
|
|
|
const activate = (activeTab, moveFocus = false) => {
|
|
tabEls.forEach((tab) => {
|
|
const isActive = tab === activeTab;
|
|
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
|
tab.setAttribute('tabindex', isActive ? '0' : '-1');
|
|
});
|
|
|
|
panels.forEach((panel) => {
|
|
panel.hidden = true;
|
|
});
|
|
|
|
const panel = panelByTab.get(activeTab);
|
|
if (panel) {
|
|
panel.hidden = false;
|
|
}
|
|
|
|
if (moveFocus && typeof activeTab.focus === 'function') {
|
|
activeTab.focus();
|
|
}
|
|
};
|
|
|
|
tabs.addEventListener('click', (event) => {
|
|
const tab = event.target.closest(SELECTORS.tabButton);
|
|
if (!tab || !tabs.contains(tab)) return;
|
|
|
|
event.preventDefault();
|
|
activate(tab, false);
|
|
});
|
|
|
|
tabs.addEventListener('keydown', (event) => {
|
|
const currentTab = event.target.closest(SELECTORS.tabButton);
|
|
if (!currentTab || !tabs.contains(currentTab)) return;
|
|
|
|
const currentIndex = tabEls.indexOf(currentTab);
|
|
if (currentIndex === -1) return;
|
|
|
|
let nextIndex = null;
|
|
const horizontal = ['ArrowLeft', 'ArrowRight'];
|
|
const vertical = ['ArrowUp', 'ArrowDown'];
|
|
|
|
if (event.key === 'Home') nextIndex = 0;
|
|
if (event.key === 'End') nextIndex = tabEls.length - 1;
|
|
if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
|
nextIndex = (currentIndex - 1 + tabEls.length) % tabEls.length;
|
|
}
|
|
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
|
nextIndex = (currentIndex + 1) % tabEls.length;
|
|
}
|
|
|
|
const orientation = tabs.querySelector('[role="tablist"]')?.getAttribute('aria-orientation') || 'horizontal';
|
|
if (orientation === 'vertical' && horizontal.includes(event.key)) return;
|
|
if (orientation !== 'vertical' && vertical.includes(event.key)) return;
|
|
|
|
if (nextIndex === null) return;
|
|
|
|
event.preventDefault();
|
|
activate(tabEls[nextIndex], true);
|
|
});
|
|
|
|
const current = tabs.querySelector('[role="tab"][aria-selected="true"]') || tabEls[0];
|
|
if (current) activate(current, false);
|
|
});
|
|
}
|
|
|
|
/**
|
|
*
|
|
* 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';
|
|
}
|
|
|
|
/**
|
|
*
|
|
* attendees form controls (contact lookup + email fallback)
|
|
*/
|
|
function initAttendeeControls(root = document) {
|
|
const normalizeEmail = (value) => String(value || '').trim().toLowerCase();
|
|
const isValidEmail = (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizeEmail(value));
|
|
|
|
const formatDisplay = (name, email) => {
|
|
const n = String(name || '').trim();
|
|
const e = normalizeEmail(email);
|
|
if (!e) return '';
|
|
return n ? `${n} <${e}>` : e;
|
|
};
|
|
|
|
root.querySelectorAll(SELECTORS.attendeesRoot).forEach((container) => {
|
|
if (container.__attendeesWired) return;
|
|
container.__attendeesWired = true;
|
|
|
|
const list = container.querySelector(SELECTORS.attendeesList);
|
|
const template = container.querySelector(SELECTORS.attendeeTemplate);
|
|
const lookup = container.querySelector(SELECTORS.attendeeLookup);
|
|
const suggestions = container.querySelector(SELECTORS.attendeeSuggestions);
|
|
const addManual = container.querySelector(SELECTORS.attendeeAddManual);
|
|
if (!list || !template || !lookup) return;
|
|
|
|
const nextIndex = () => {
|
|
const current = parseInt(container.dataset.nextIndex || '0', 10);
|
|
const safe = Number.isNaN(current) ? 0 : current;
|
|
container.dataset.nextIndex = String(safe + 1);
|
|
return safe;
|
|
};
|
|
|
|
const updateRole = (row) => {
|
|
const optionalInput = row.querySelector(SELECTORS.attendeeOptional);
|
|
const roleInput = row.querySelector(SELECTORS.attendeeRole);
|
|
if (!optionalInput || !roleInput) return;
|
|
roleInput.value = optionalInput.checked ? 'OPT-PARTICIPANT' : 'REQ-PARTICIPANT';
|
|
};
|
|
|
|
const wireRow = (row) => {
|
|
if (!row || row.__attendeeRowWired) return;
|
|
row.__attendeeRowWired = true;
|
|
|
|
const optionalInput = row.querySelector(SELECTORS.attendeeOptional);
|
|
if (optionalInput) {
|
|
optionalInput.addEventListener('change', () => updateRole(row));
|
|
}
|
|
|
|
updateRole(row);
|
|
};
|
|
|
|
const hasEmail = (email) => {
|
|
const normalized = normalizeEmail(email);
|
|
if (!normalized) return true;
|
|
|
|
return Array.from(list.querySelectorAll(`${SELECTORS.attendeeRow} ${SELECTORS.attendeeEmail}`))
|
|
.some((input) => normalizeEmail(input.value) === normalized);
|
|
};
|
|
|
|
const createRow = () => {
|
|
const index = nextIndex();
|
|
const html = template.innerHTML.replaceAll('__INDEX__', String(index)).trim();
|
|
if (!html) return null;
|
|
|
|
const fragment = document.createElement('div');
|
|
fragment.innerHTML = html;
|
|
return fragment.firstElementChild;
|
|
};
|
|
|
|
const addAttendee = ({ email, name = '', attendeeUri = '', verified = false }) => {
|
|
const normalizedEmail = normalizeEmail(email);
|
|
if (!isValidEmail(normalizedEmail)) return false;
|
|
if (hasEmail(normalizedEmail)) return false;
|
|
|
|
const row = createRow();
|
|
if (!row) return false;
|
|
|
|
const uriInput = row.querySelector(SELECTORS.attendeeUri);
|
|
const emailInput = row.querySelector(SELECTORS.attendeeEmail);
|
|
const nameInput = row.querySelector(SELECTORS.attendeeName);
|
|
const display = row.querySelector(SELECTORS.attendeeDisplay);
|
|
const verifiedEl = row.querySelector(SELECTORS.attendeeVerified);
|
|
|
|
if (uriInput) uriInput.value = attendeeUri || `mailto:${normalizedEmail}`;
|
|
if (emailInput) emailInput.value = normalizedEmail;
|
|
if (nameInput) nameInput.value = String(name || '').trim();
|
|
if (display) display.textContent = formatDisplay(name, normalizedEmail);
|
|
if (verifiedEl) {
|
|
verifiedEl.classList.toggle('hidden', !verified);
|
|
}
|
|
|
|
list.appendChild(row);
|
|
wireRow(row);
|
|
return true;
|
|
};
|
|
|
|
const addFromLookupIfEmail = () => {
|
|
const raw = lookup.value;
|
|
if (!isValidEmail(raw)) return false;
|
|
|
|
const ok = addAttendee({
|
|
email: raw,
|
|
attendeeUri: `mailto:${normalizeEmail(raw)}`,
|
|
verified: false,
|
|
});
|
|
|
|
if (ok) {
|
|
lookup.value = '';
|
|
if (suggestions) suggestions.innerHTML = '';
|
|
}
|
|
|
|
return ok;
|
|
};
|
|
|
|
container.addEventListener('click', (event) => {
|
|
const removeButton = event.target.closest(SELECTORS.attendeeRemove);
|
|
if (removeButton && container.contains(removeButton)) {
|
|
event.preventDefault();
|
|
const row = removeButton.closest(SELECTORS.attendeeRow);
|
|
row?.remove();
|
|
return;
|
|
}
|
|
|
|
const pickButton = event.target.closest(SELECTORS.attendeePick);
|
|
if (pickButton && container.contains(pickButton)) {
|
|
event.preventDefault();
|
|
const email = pickButton.dataset.attendeeEmail || '';
|
|
const name = pickButton.dataset.attendeeName || '';
|
|
const attendeeUri = pickButton.dataset.attendeeUri || '';
|
|
const verified = pickButton.dataset.attendeeVerified === '1';
|
|
|
|
const ok = addAttendee({ email, name, attendeeUri, verified });
|
|
if (ok) {
|
|
lookup.value = '';
|
|
if (suggestions) suggestions.innerHTML = '';
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (addManual && event.target.closest(SELECTORS.attendeeAddManual)) {
|
|
event.preventDefault();
|
|
addFromLookupIfEmail();
|
|
}
|
|
});
|
|
|
|
lookup.addEventListener('keydown', (event) => {
|
|
if (event.key !== 'Enter') return;
|
|
event.preventDefault();
|
|
addFromLookupIfEmail();
|
|
});
|
|
|
|
list.querySelectorAll(SELECTORS.attendeeRow).forEach(wireRow);
|
|
});
|
|
}
|
|
|
|
/**
|
|
*
|
|
* 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();
|
|
initNaturalEventParser();
|
|
initEventAllDayToggles();
|
|
initRecurrenceControls();
|
|
initAttendeeControls();
|
|
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') {
|
|
syncModalRootClass(target);
|
|
target.closest('dialog')?.showModal();
|
|
}
|
|
|
|
initColorPickers(e.target);
|
|
initNaturalEventParser(e.target);
|
|
initEventAllDayToggles(e.target);
|
|
initRecurrenceControls(e.target);
|
|
initAttendeeControls(e.target);
|
|
initModalHandlers(e.target);
|
|
initTabs(e.target);
|
|
initTimeViewAutoScroll(e.target);
|
|
initMonthOverflow(e.target);
|
|
});
|