Event modal improvements and new natural language parser when creating an event!
This commit is contained in:
parent
87047cf4c3
commit
e024639209
@ -19,6 +19,10 @@ textarea {
|
|||||||
&[disabled] {
|
&[disabled] {
|
||||||
@apply opacity-50 cursor-not-allowed;
|
@apply opacity-50 cursor-not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.input--lg {
|
||||||
|
@apply h-13 text-lg rounded-lg;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -47,10 +47,20 @@ dialog {
|
|||||||
|
|
||||||
/* modal header */
|
/* modal header */
|
||||||
header {
|
header {
|
||||||
@apply sticky top-0 bg-white flex items-center px-6 min-h-20 h-20 z-2;
|
@apply sticky top-0 bg-white flex items-center px-6 min-h-20 h-20 z-2 pr-16;
|
||||||
|
|
||||||
h2 {
|
&.input {
|
||||||
@apply pr-12;
|
@apply border-b border-gray-400;
|
||||||
|
|
||||||
|
input {
|
||||||
|
@apply w-full -ml-2 border-0 rounded-sm shadow-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* if there are panels, move it down */
|
||||||
|
+ section.modal-body .tabs,
|
||||||
|
+ section.modal-body .panels {
|
||||||
|
@apply pt-4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,9 @@ const SELECTORS = {
|
|||||||
eventAllDayToggle: '[data-all-day-toggle]',
|
eventAllDayToggle: '[data-all-day-toggle]',
|
||||||
eventStartInput: '[data-event-start]',
|
eventStartInput: '[data-event-start]',
|
||||||
eventEndInput: '[data-event-end]',
|
eventEndInput: '[data-event-end]',
|
||||||
|
eventTitleInput: 'input[name="title"]',
|
||||||
|
eventLocationInput: 'input[name="location"]',
|
||||||
|
naturalEventInput: '[data-natural-event-input]',
|
||||||
recurrenceFrequency: '[data-recurrence-frequency]',
|
recurrenceFrequency: '[data-recurrence-frequency]',
|
||||||
recurrenceInterval: '[data-recurrence-interval]',
|
recurrenceInterval: '[data-recurrence-interval]',
|
||||||
recurrenceUnit: '[data-recurrence-unit]',
|
recurrenceUnit: '[data-recurrence-unit]',
|
||||||
@ -184,11 +187,15 @@ function initEventAllDayToggles(root = document) {
|
|||||||
return `${date}T${time}`;
|
return `${date}T${time}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
root.querySelectorAll(SELECTORS.eventAllDayToggle).forEach((toggle) => {
|
root.querySelectorAll(SELECTORS.eventAllDayToggle).forEach((toggleHost) => {
|
||||||
if (toggle.__allDayWired) return;
|
const toggle = toggleHost.matches('input[type="checkbox"]')
|
||||||
|
? toggleHost
|
||||||
|
: toggleHost.querySelector('input[type="checkbox"]');
|
||||||
|
|
||||||
|
if (!toggle || toggle.__allDayWired) return;
|
||||||
toggle.__allDayWired = true;
|
toggle.__allDayWired = true;
|
||||||
|
|
||||||
const form = toggle.closest('form');
|
const form = toggle.closest('form') || toggleHost.closest('form');
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
|
|
||||||
const start = form.querySelector(SELECTORS.eventStartInput);
|
const start = form.querySelector(SELECTORS.eventStartInput);
|
||||||
@ -305,6 +312,295 @@ function initRecurrenceControls(root = document) {
|
|||||||
apply();
|
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 toLocalInputValue = (date) => (
|
||||||
|
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
||||||
|
`T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const readLocalInputValue = (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 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 year = explicitYear || thisYear;
|
||||||
|
let date = new Date(year, 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 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, locationToken) => {
|
||||||
|
const boundaries = [];
|
||||||
|
if (dateToken) boundaries.push(dateToken.index);
|
||||||
|
if (timeToken) boundaries.push(timeToken.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 dateToken = parseDateToken(trimmed, baseDate);
|
||||||
|
const timeToken = parseTimeToken(trimmed);
|
||||||
|
const durationMinutes = parseDurationMinutes(trimmed);
|
||||||
|
const location = parseLocationToken(trimmed);
|
||||||
|
const title = parseTitleToken(trimmed, dateToken, timeToken, location);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
location,
|
||||||
|
dateToken,
|
||||||
|
timeToken,
|
||||||
|
durationMinutes,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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 applyParsedData = (input) => {
|
||||||
|
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) return;
|
||||||
|
|
||||||
|
if (parsed.title) {
|
||||||
|
titleInput.value = parsed.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.location && locationInput) {
|
||||||
|
locationInput.value = parsed.location;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.dateToken && !parsed.timeToken) return;
|
||||||
|
|
||||||
|
if (allDayToggle?.checked) {
|
||||||
|
allDayToggle.checked = false;
|
||||||
|
allDayToggle.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingStart = readLocalInputValue(startInput.value);
|
||||||
|
const base = parsed.dateToken?.date
|
||||||
|
? new Date(parsed.dateToken.date.getTime())
|
||||||
|
: (existingStart ? new Date(existingStart.getTime()) : new Date());
|
||||||
|
|
||||||
|
const hours = parsed.timeToken?.hours ?? (existingStart ? existingStart.getHours() : 9);
|
||||||
|
const minutes = parsed.timeToken?.minutes ?? (existingStart ? existingStart.getMinutes() : 0);
|
||||||
|
base.setHours(hours, minutes, 0, 0);
|
||||||
|
|
||||||
|
const end = new Date(base.getTime());
|
||||||
|
end.setMinutes(end.getMinutes() + parsed.durationMinutes);
|
||||||
|
|
||||||
|
startInput.value = toLocalInputValue(base);
|
||||||
|
endInput.value = toLocalInputValue(end);
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
root.querySelectorAll(SELECTORS.naturalEventInput).forEach((input) => {
|
||||||
|
if (input.value?.trim()) {
|
||||||
|
applyParsedData(input);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* modal behaviors (backdrop close + url restore)
|
* modal behaviors (backdrop close + url restore)
|
||||||
@ -721,6 +1017,7 @@ window.addEventListener('resize', () => {
|
|||||||
|
|
||||||
function initUI() {
|
function initUI() {
|
||||||
initColorPickers();
|
initColorPickers();
|
||||||
|
initNaturalEventParser();
|
||||||
initEventAllDayToggles();
|
initEventAllDayToggles();
|
||||||
initRecurrenceControls();
|
initRecurrenceControls();
|
||||||
initModalHandlers();
|
initModalHandlers();
|
||||||
@ -741,6 +1038,7 @@ document.addEventListener('htmx:afterSwap', (e) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initColorPickers(e.target);
|
initColorPickers(e.target);
|
||||||
|
initNaturalEventParser(e.target);
|
||||||
initEventAllDayToggles(e.target);
|
initEventAllDayToggles(e.target);
|
||||||
initRecurrenceControls(e.target);
|
initRecurrenceControls(e.target);
|
||||||
initModalHandlers(e.target);
|
initModalHandlers(e.target);
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
name="{{ $name }}"
|
name="{{ $name }}"
|
||||||
value="{{ $value }}"
|
value="{{ $value }}"
|
||||||
placeholder="{{ $placeholder }}"
|
placeholder="{{ $placeholder }}"
|
||||||
{{ $attributes->merge(['class' => 'text']) }}
|
{{ $attributes->merge(['class' => 'text '.$class]) }}
|
||||||
@if($style !== '') style="{{ $style }}" @endif
|
@if($style !== '') style="{{ $style }}" @endif
|
||||||
@required($required)
|
@required($required)
|
||||||
@disabled($disabled) />
|
@disabled($disabled) />
|
||||||
|
|||||||
@ -1,6 +1,18 @@
|
|||||||
<x-modal.content modal-class="wide event-form-modal square">
|
<x-modal.content modal-class="wide square">
|
||||||
<x-modal.title>
|
<x-modal.title class="input">
|
||||||
<h2>{{ $event->exists ? __('Edit event details') : __('Create a new event') }}</h2>
|
@if ($event->exists)
|
||||||
|
<h2>{{ __('Edit event details') }}</h2>
|
||||||
|
@else
|
||||||
|
<label for="event-natural-input" class="sr-only">Describe event</label>
|
||||||
|
<x-input.text
|
||||||
|
id="event-natural-input"
|
||||||
|
type="text"
|
||||||
|
class="input--lg"
|
||||||
|
placeholder="Start typing to create an event..."
|
||||||
|
data-natural-event-input
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
@endif
|
||||||
</x-modal.title>
|
</x-modal.title>
|
||||||
<x-modal.body class="no-margin">
|
<x-modal.body class="no-margin">
|
||||||
@include('event.partials.form', [
|
@include('event.partials.form', [
|
||||||
|
|||||||
@ -45,7 +45,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
:value="old('title', $event->meta?->title ?? '')"
|
:value="old('title', $event->meta?->title ?? '')"
|
||||||
required
|
required
|
||||||
autofocus
|
:autofocus="$event->exists"
|
||||||
/>
|
/>
|
||||||
<x-input.error class="mt-2" :messages="$errors->get('title')" />
|
<x-input.error class="mt-2" :messages="$errors->get('title')" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user