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
+