diff --git a/resources/css/lib/modal.css b/resources/css/lib/modal.css index f4613ee..aba3203 100644 --- a/resources/css/lib/modal.css +++ b/resources/css/lib/modal.css @@ -144,9 +144,9 @@ dialog { @apply opacity-0 invisible; pointer-events: none; transition: - translate 350ms cubic-bezier(0,0,.2,1), - opacity 150ms cubic-bezier(0,0,.2,1), - visibility 150ms cubic-bezier(0,0,.2,1); + translate 350ms ease-in-out, + opacity 150ms ease-in-out, + visibility 150ms ease-in-out; &.is-visible { @apply opacity-100 visible h-auto; @@ -154,14 +154,34 @@ dialog { } .modal-aside-list { - @apply list-none m-0 pt-3 flex flex-wrap justify-start gap-2; + @apply list-none m-0 pt-3 flex flex-col justify-start items-start gap-2; li { - @apply text-white rounded-full px-3 py-1 text-sm; - background: rgba(0, 0, 0, 0.25); + @apply text-white rounded-full pl-9 pr-3 py-1 text-sm; + background-color: rgba(0, 0, 0, 0.25); + background-position: 0.75rem 50%; + background-repeat: no-repeat; + background-size: 1rem; + + &.is-visible { + animation: modal-aside-list 250ms ease-in-out forwards; + } + + &[data-aside-key="title"] { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 16v-4'/%3E%3Cpath d='M12 8h.01'/%3E%3C/svg%3E"); + } + &[data-aside-key="date"] { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 2v4'/%3E%3Cpath d='M16 2v4'/%3E%3Crect width='18' height='18' x='3' y='4' rx='2'/%3E%3Cpath d='M3 10h18'/%3E%3C/svg%3E"); + } + &[data-aside-key="location"] { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0'/%3E%3Ccircle cx='12' cy='10' r='3'/%3E%3C/svg%3E"); + } + &[data-aside-key="repeat"] { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m17 2 4 4-4 4'/%3E%3Cpath d='M3 11v-1a4 4 0 0 1 4-4h14'/%3E%3Cpath d='m7 22-4-4 4-4'/%3E%3Cpath d='M21 13v1a4 4 0 0 1-4 4H3'/%3E%3C/svg%3E"); + } strong { - @apply font-semibold; + @apply hidden; } } } @@ -241,3 +261,18 @@ dialog { @apply h-full min-h-0 pt-2 pr-6 pb-6 pl-1 overflow-y-auto; } } + +/** + * animations + */ +@keyframes modal-aside-list +{ + from { + opacity: 0; + transform: translateX(-1rem); + } + to { + opacity: 100; + transform: translateX(0); + } +} diff --git a/resources/js/app.js b/resources/js/app.js index 65fd681..c8888c0 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -14,27 +14,27 @@ const SELECTORS = { 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]', - monthlyMode: '[data-monthly-mode]', - monthlyDays: '[data-monthly-days]', - monthlyWeekday: '[data-monthly-weekday]', - modalDialog: 'dialog', - modalContent: '#modal', - modalClassSource: '[data-modal-class]', - modalAssist: '#modal-aside', - modalAssistList: '[data-modal-aside-list]', - modalExpand: '[data-modal-expand]', tabsRoot: '[data-tabs]', tabButton: '[role=\"tab\"]', tabPanel: '[role=\"tabpanel\"]', - monthDay: '.calendar.month .day', - monthDayEvent: 'a.event', - monthDayMore: '[data-day-more]', - monthDayMoreWrap: '.more-events', }; function syncModalRootClass(modal) { @@ -74,36 +74,72 @@ function clearModalRootClass(modal) { modal.dataset.appliedClass = ''; } -function renderModalAssist(dialog, items = [], show = false) { +function renderModalAside(dialog, items = [], show = false) { if (!dialog) return; - const assist = dialog.querySelector(SELECTORS.modalAssist); - if (!assist) return; + const aside = dialog.querySelector(SELECTORS.modalAside); + if (!aside) return; - const list = assist.querySelector(SELECTORS.modalAssistList); + 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) { - list.innerHTML = ''; + 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 li = document.createElement('li'); - const strong = document.createElement('strong'); - strong.textContent = `${label}:`; + const key = String(label || '').trim().toLowerCase().replace(/\s+/g, '-'); + usedKeys.add(key); - const text = document.createElement('span'); - text.textContent = ` ${value}`; + let li = existing.get(key); + if (!li) { + li = document.createElement('li'); + li.dataset.asideKey = key; + li.classList.add('is-visible'); - li.appendChild(strong); - li.appendChild(text); - list.appendChild(li); + 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(); + } }); } - assist.classList.toggle('is-visible', show); - assist.setAttribute('aria-hidden', show ? 'false' : 'true'); + aside.classList.toggle('is-visible', show); + aside.setAttribute('aria-hidden', show ? 'false' : 'true'); + aside.dataset.asideSignature = signature; } -function clearModalAssist(dialog) { - renderModalAssist(dialog, [], false); +function clearModalAside(dialog) { + renderModalAside(dialog, [], false); } /** @@ -398,18 +434,44 @@ function initNaturalEventParser(root = document) { return new Date(year, month - 1, day, hours, minutes, 0, 0); }; - const formatInputValue = (value, type) => { - if (!value) return ''; + const formatDayLong = (date) => ( + date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) + ); - if (type === 'date') { - const date = readDateInputValue(value); - if (!date) return value; - return date.toLocaleDateString(undefined, { weekday: 'short', month: 'short', 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 datetime = readDatetimeInputValue(value); - if (!datetime) return value; - return datetime.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); + 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) => { @@ -525,6 +587,62 @@ function initNaturalEventParser(root = document) { }; }; + 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 parseLocationToken = (text) => { const lower = text.toLowerCase(); const index = lower.lastIndexOf(' at '); @@ -538,10 +656,11 @@ function initNaturalEventParser(root = document) { return candidate; }; - const parseTitleToken = (text, dateToken, timeToken, locationToken) => { + const parseTitleToken = (text, dateToken, timeToken, relativeToken, locationToken) => { const boundaries = []; if (dateToken) boundaries.push(dateToken.index); if (timeToken) boundaries.push(timeToken.index); + if (relativeToken) boundaries.push(relativeToken.index); const lower = text.toLowerCase(); const durationIndex = lower.indexOf(' for '); @@ -564,11 +683,12 @@ function initNaturalEventParser(root = document) { const trimmed = (text || '').trim(); if (!trimmed) return null; + const relativeToken = parseRelativeToken(trimmed, baseDate); const dateToken = parseDateToken(trimmed, baseDate); const timeToken = parseTimeToken(trimmed); const durationMinutes = parseDurationMinutes(trimmed); const location = parseLocationToken(trimmed); - const title = parseTitleToken(trimmed, dateToken, timeToken, location); + const title = parseTitleToken(trimmed, dateToken, timeToken, relativeToken, location); const allDay = /\ball(?:\s|-)?day\b/i.test(trimmed); return { @@ -576,6 +696,7 @@ function initNaturalEventParser(root = document) { location, dateToken, timeToken, + relativeToken, durationMinutes, allDay, }; @@ -601,7 +722,7 @@ function initNaturalEventParser(root = document) { if (!dialog || !modal || modal.classList.contains('natural-expanded')) return; modal.classList.add('natural-collapsed'); - renderModalAssist(dialog, [], true); + renderModalAside(dialog, [], true); }; const applyParsedData = (input) => { @@ -624,7 +745,7 @@ function initNaturalEventParser(root = document) { const parsed = parseNaturalEventText(input.value, new Date()); if (!parsed) { if (modal?.classList.contains('natural-collapsed')) { - renderModalAssist(dialog, [], true); + renderModalAside(dialog, [], true); } return; } @@ -640,9 +761,11 @@ function initNaturalEventParser(root = document) { const existingStart = startInput.type === 'date' ? readDateInputValue(startInput.value) : readDatetimeInputValue(startInput.value); - const baseDate = parsed.dateToken?.date - ? new Date(parsed.dateToken.date.getTime()) - : (existingStart ? new Date(existingStart.getTime()) : new Date()); + 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) { @@ -653,7 +776,7 @@ function initNaturalEventParser(root = document) { const dateOnly = toLocalDateInputValue(baseDate); startInput.value = dateOnly; endInput.value = dateOnly; - } else if (parsed.dateToken || parsed.timeToken) { + } else if (parsed.dateToken || parsed.timeToken || parsed.relativeToken) { if (allDayToggle?.checked) { allDayToggle.checked = false; allDayToggle.dispatchEvent(new Event('change', { bubbles: true })); @@ -677,18 +800,23 @@ function initNaturalEventParser(root = document) { if (parsed.location) { summaryItems.push({ label: 'Location', value: parsed.location }); } - if (parsed.allDay) { - summaryItems.push({ label: 'All day', value: 'Yes' }); - } - if ((parsed.dateToken || parsed.timeToken || parsed.allDay) && startInput.value) { - summaryItems.push({ label: 'Start', value: formatInputValue(startInput.value, startInput.type) }); - } - if ((parsed.dateToken || parsed.timeToken || parsed.allDay) && endInput.value) { - summaryItems.push({ label: 'End', value: formatInputValue(endInput.value, endInput.type) }); + 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 (modal?.classList.contains('natural-collapsed')) { - renderModalAssist(dialog, summaryItems, true); + renderModalAside(dialog, summaryItems, true); } }; @@ -717,7 +845,7 @@ function initNaturalEventParser(root = document) { modal.classList.remove('natural-collapsed'); modal.classList.add('natural-expanded'); - clearModalAssist(dialog); + clearModalAside(dialog); }); } @@ -754,7 +882,7 @@ function initModalHandlers(root = document) { modal.classList.remove('natural-collapsed', 'natural-expanded'); modal.innerHTML = ''; clearModalRootClass(modal); - clearModalAssist(dialog); + clearModalAside(dialog); if (isEvent && prevUrl) { history.replaceState({}, '', prevUrl); diff --git a/resources/svg/icons/calendar.svg b/resources/svg/icons/calendar.svg index 6917119..2be4995 100644 --- a/resources/svg/icons/calendar.svg +++ b/resources/svg/icons/calendar.svg @@ -1 +1 @@ - \ No newline at end of file +