diff --git a/resources/js/app.js b/resources/js/app.js index 31a1958..af5affe 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,172 +1,21 @@ import './bootstrap'; import htmx from 'htmx.org'; +import { handleEventModalAfterSwap, initEventModalGlobals, initEventModalUI } from './modules/event-modal'; 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]', - calendarPicker: '[data-calendar-picker]', - calendarPickerInput: '[data-calendar-picker-input]', - calendarPickerToggle: '[data-calendar-picker-toggle]', - calendarPickerMenu: '[data-calendar-picker-menu]', - calendarPickerLabel: '[data-calendar-picker-label]', - calendarPickerColorDot: '[data-calendar-picker-color]', - calendarPickerOption: '[data-calendar-picker-option]', - 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 */ @@ -183,14 +32,6 @@ document.addEventListener('htmx:configRequest', (evt) => { 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); - } -}); /** * @@ -254,969 +95,6 @@ document.addEventListener('change', (event) => { 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); - } - - //
  • 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); - }); -} /** * @@ -1250,164 +128,8 @@ function initTimeViewAutoScroll(root = document) 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 */ @@ -1517,68 +239,6 @@ function initColorPickers(root = document) { root.querySelectorAll(SELECTORS.colorPicker).forEach(wire); } -/** - * - * calendar picker (custom dropdown with color chip) - */ -function initCalendarPickers(root = document) { - root.querySelectorAll(SELECTORS.calendarPicker).forEach((picker) => { - if (picker.__calendarPickerWired) return; - picker.__calendarPickerWired = true; - - const input = picker.querySelector(SELECTORS.calendarPickerInput); - const toggle = picker.querySelector(SELECTORS.calendarPickerToggle); - const menu = picker.querySelector(SELECTORS.calendarPickerMenu); - const label = picker.querySelector(SELECTORS.calendarPickerLabel); - const colorDot = picker.querySelector(SELECTORS.calendarPickerColorDot); - - if (!input || !toggle || !menu || !label || !colorDot) return; - - const close = () => { - menu.classList.add('hidden'); - toggle.setAttribute('aria-expanded', 'false'); - }; - - const open = () => { - menu.classList.remove('hidden'); - toggle.setAttribute('aria-expanded', 'true'); - }; - - toggle.addEventListener('click', (event) => { - event.preventDefault(); - if (menu.classList.contains('hidden')) open(); - else close(); - }); - - picker.addEventListener('click', (event) => { - const option = event.target.closest(SELECTORS.calendarPickerOption); - if (!option || !picker.contains(option)) return; - - event.preventDefault(); - - const uri = option.dataset.calendarPickerUri || ''; - const name = option.dataset.calendarPickerName || ''; - const color = option.dataset.calendarPickerColor || '#64748b'; - - input.value = uri; - label.textContent = name; - colorDot.style.backgroundColor = color; - close(); - }); - - document.addEventListener('click', (event) => { - if (!picker.contains(event.target)) { - close(); - } - }); - - document.addEventListener('keydown', (event) => { - if (event.key === 'Escape') { - close(); - } - }); - }); -} /** * @@ -1716,20 +376,17 @@ window.addEventListener('resize', () => { monthResizeTimer = window.setTimeout(() => initMonthOverflow(), 100); }); + + /** * * initialization */ function initUI() { - initCalendarPickers(); + initEventModalGlobals(); + initEventModalUI(); initColorPickers(); - initNaturalEventParser(); - initEventAllDayToggles(); - initRecurrenceControls(); - initAttendeeControls(); - initModalHandlers(); - initTabs(); initTimeViewAutoScroll(); initMonthOverflow(); } @@ -1740,19 +397,11 @@ 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); - initCalendarPickers(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); + handleEventModalAfterSwap(target); + + initEventModalUI(target); + initColorPickers(target); + initTimeViewAutoScroll(target); + initMonthOverflow(target); }); diff --git a/resources/js/modules/event-modal.js b/resources/js/modules/event-modal.js new file mode 100644 index 0000000..8c11d25 --- /dev/null +++ b/resources/js/modules/event-modal.js @@ -0,0 +1,1398 @@ +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]', + calendarPicker: '[data-calendar-picker]', + calendarPickerInput: '[data-calendar-picker-input]', + calendarPickerToggle: '[data-calendar-picker-toggle]', + calendarPickerMenu: '[data-calendar-picker-menu]', + calendarPickerLabel: '[data-calendar-picker-label]', + calendarPickerColorDot: '[data-calendar-picker-color]', + calendarPickerOption: '[data-calendar-picker-option]', + 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); +} + +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); + } + + //
  • 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); + }); +} + + +/** + * + * 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 + hex + random palette) + */ + +/** + * + * calendar picker (custom dropdown with color chip) + */ +function initCalendarPickers(root = document) { + root.querySelectorAll(SELECTORS.calendarPicker).forEach((picker) => { + if (picker.__calendarPickerWired) return; + picker.__calendarPickerWired = true; + + const input = picker.querySelector(SELECTORS.calendarPickerInput); + const toggle = picker.querySelector(SELECTORS.calendarPickerToggle); + const menu = picker.querySelector(SELECTORS.calendarPickerMenu); + const label = picker.querySelector(SELECTORS.calendarPickerLabel); + const colorDot = picker.querySelector(SELECTORS.calendarPickerColorDot); + + if (!input || !toggle || !menu || !label || !colorDot) return; + + const close = () => { + menu.classList.add('hidden'); + toggle.setAttribute('aria-expanded', 'false'); + }; + + const open = () => { + menu.classList.remove('hidden'); + toggle.setAttribute('aria-expanded', 'true'); + }; + + toggle.addEventListener('click', (event) => { + event.preventDefault(); + if (menu.classList.contains('hidden')) open(); + else close(); + }); + + picker.addEventListener('click', (event) => { + const option = event.target.closest(SELECTORS.calendarPickerOption); + if (!option || !picker.contains(option)) return; + + event.preventDefault(); + + const uri = option.dataset.calendarPickerUri || ''; + const name = option.dataset.calendarPickerName || ''; + const color = option.dataset.calendarPickerColor || '#64748b'; + + input.value = uri; + label.textContent = name; + colorDot.style.backgroundColor = color; + close(); + }); + + document.addEventListener('click', (event) => { + if (!picker.contains(event.target)) { + close(); + } + }); + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + close(); + } + }); + }); +} + + +export function initEventModalGlobals() { + if (document.__eventModalGlobalsWired) return; + document.__eventModalGlobalsWired = true; + + // 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); + } + }); + + // 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(); + }); +} + +export function initEventModalUI(root = document) { + initCalendarPickers(root); + initNaturalEventParser(root); + initEventAllDayToggles(root); + initRecurrenceControls(root); + initAttendeeControls(root); + initModalHandlers(root); + initTabs(root); +} + +export function handleEventModalAfterSwap(target) { + if (target && target.id === 'modal') { + syncModalRootClass(target); + target.closest('dialog')?.showModal(); + } +} +