diff --git a/resources/css/lib/input.css b/resources/css/lib/input.css index 8fc2a6f..c966bbc 100644 --- a/resources/css/lib/input.css +++ b/resources/css/lib/input.css @@ -19,6 +19,10 @@ textarea { &[disabled] { @apply opacity-50 cursor-not-allowed; } + + &.input--lg { + @apply h-13 text-lg rounded-lg; + } } /** diff --git a/resources/css/lib/modal.css b/resources/css/lib/modal.css index 6340556..335bac6 100644 --- a/resources/css/lib/modal.css +++ b/resources/css/lib/modal.css @@ -47,10 +47,20 @@ dialog { /* modal 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 { - @apply pr-12; + &.input { + @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; + } } } diff --git a/resources/js/app.js b/resources/js/app.js index 2dcd02e..01e9834 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -12,6 +12,9 @@ const SELECTORS = { eventAllDayToggle: '[data-all-day-toggle]', eventStartInput: '[data-event-start]', eventEndInput: '[data-event-end]', + eventTitleInput: 'input[name="title"]', + eventLocationInput: 'input[name="location"]', + naturalEventInput: '[data-natural-event-input]', recurrenceFrequency: '[data-recurrence-frequency]', recurrenceInterval: '[data-recurrence-interval]', recurrenceUnit: '[data-recurrence-unit]', @@ -184,11 +187,15 @@ function initEventAllDayToggles(root = document) { return `${date}T${time}`; }; - root.querySelectorAll(SELECTORS.eventAllDayToggle).forEach((toggle) => { - if (toggle.__allDayWired) return; + 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'); + const form = toggle.closest('form') || toggleHost.closest('form'); if (!form) return; const start = form.querySelector(SELECTORS.eventStartInput); @@ -305,6 +312,295 @@ function initRecurrenceControls(root = document) { 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) @@ -721,6 +1017,7 @@ window.addEventListener('resize', () => { function initUI() { initColorPickers(); + initNaturalEventParser(); initEventAllDayToggles(); initRecurrenceControls(); initModalHandlers(); @@ -741,6 +1038,7 @@ document.addEventListener('htmx:afterSwap', (e) => { } initColorPickers(e.target); + initNaturalEventParser(e.target); initEventAllDayToggles(e.target); initRecurrenceControls(e.target); initModalHandlers(e.target); diff --git a/resources/views/components/input/text.blade.php b/resources/views/components/input/text.blade.php index 8879abb..4d0241b 100644 --- a/resources/views/components/input/text.blade.php +++ b/resources/views/components/input/text.blade.php @@ -13,7 +13,7 @@ name="{{ $name }}" value="{{ $value }}" placeholder="{{ $placeholder }}" - {{ $attributes->merge(['class' => 'text']) }} + {{ $attributes->merge(['class' => 'text '.$class]) }} @if($style !== '') style="{{ $style }}" @endif @required($required) @disabled($disabled) /> diff --git a/resources/views/event/partials/form-modal.blade.php b/resources/views/event/partials/form-modal.blade.php index cd7db5c..bf23fec 100644 --- a/resources/views/event/partials/form-modal.blade.php +++ b/resources/views/event/partials/form-modal.blade.php @@ -1,6 +1,18 @@ - - -

{{ $event->exists ? __('Edit event details') : __('Create a new event') }}

+ + + @if ($event->exists) +

{{ __('Edit event details') }}

+ @else + + + @endif
@include('event.partials.form', [ diff --git a/resources/views/event/partials/form.blade.php b/resources/views/event/partials/form.blade.php index 0606ef1..e890e1d 100644 --- a/resources/views/event/partials/form.blade.php +++ b/resources/views/event/partials/form.blade.php @@ -45,7 +45,7 @@ type="text" :value="old('title', $event->meta?->title ?? '')" required - autofocus + :autofocus="$event->exists" />