Removes tab panel from event modal; adds better layout and icons to event modal; improves location misunderstanding with natural lang parser

This commit is contained in:
Andrew Gioia 2026-02-20 13:55:38 -05:00
parent d77ea9f5e5
commit 27a3b6c72e
Signed by: andrew
GPG Key ID: FC09694A000800C8
10 changed files with 491 additions and 411 deletions

View File

@ -74,6 +74,8 @@
--outline-width-md: 1.5px;
--background-image-scrollbar: linear-gradient(to bottom, rgba(255, 255, 255, 0), rgba(255, 255, 255, 1));
--radius-xs: 0.25rem;
--radius-sm: 0.375rem;
--radius-md: 0.6667rem;

View File

@ -30,6 +30,17 @@ details {
}
}
/* simple variant */
&.details--simple
{
summary {
&::before,
&::after {
content: none;
}
}
}
&[open] {
summary::after {
@apply rotate-180;

View File

@ -4,3 +4,17 @@
background-image: var(--event-map);
width: calc(100% + 3rem);
}
/* event form fields */
#event-form {
@apply flex flex-col gap-4 pt-6;
.event-field {
display: grid;
grid-template-columns: 3rem auto;
.event-field-icon {
@apply pt-2;
}
}
}

View File

@ -178,6 +178,13 @@ article.settings {
h3 + .input-row {
@apply mt-6;
}
.input-rows {
@apply flex flex-col gap-3;
.input-row + .input-row {
@apply mt-0;
}
}
@container style(--can-scroll: 1) {
.input-row--actions {
@apply !border-black;

View File

@ -22,7 +22,7 @@ dialog {
max-height: calc(100dvh - 5rem);
width: 91.666667%;
max-width: 36rem;
transition: translate 150ms cubic-bezier(0,0,.2,1);
transition: translate 150ms ease-in-out;
box-shadow: 0 1.5rem 4rem -0.5rem rgba(0, 0, 0, 0.4);
/* close button */
@ -54,11 +54,17 @@ dialog {
/* main content wrapper */
section.modal-body {
@apply flex flex-col px-6 pb-8;
@apply flex flex-col px-6 pb-8 overflow-y-auto;
&.no-margin {
@apply p-0;
}
/* overlay gradient to signal scrollability */
&::after {
@apply h-8 w-full absolute bottom-20 left-0 bg-scrollbar;
content: '';
}
}
/* standard form with 1rem gap between rows */
@ -73,7 +79,7 @@ dialog {
/* footer */
footer {
@apply sticky bottom-0 bg-white px-6 py-4 border-t-md border-gray-300 flex justify-between;
@apply sticky bottom-0 bg-white px-6 h-20 border-t-md border-gray-300 flex items-center justify-between;
}
/* event modal with a map */
@ -139,9 +145,10 @@ dialog {
/* extra container over the backdrop below the modal */
#modal-aside {
@apply w-11/12 max-w-3xl justify-self-center px-4 h-0;
@apply flex flex-row justify-between items-start gap-2 translate-y-4;
@apply opacity-0 invisible;
@apply justify-self-center pl-4 h-0 flex flex-row justify-between items-start gap-2;
@apply translate-y-4 opacity-0 invisible;
width: 91.666667%;
max-width: 36rem;
pointer-events: none;
transition:
translate 250ms ease-in-out,

View File

@ -464,6 +464,28 @@ function initNaturalEventParser(root = document) {
return null;
};
const isAtTimeCandidate = (candidate) => {
const normalized = String(candidate || '')
.trim()
.toLowerCase()
.replace(/\./g, '');
if (normalized === '') return false;
if (normalized === 'noon' || normalized === 'midnight') return true;
// 1-2 digit + a|p|am|pm (with/without space), e.g. 3p, 3 p, 3pm, 3 pm
if (/^\d{1,2}\s*(a|p|am|pm)$/.test(normalized)) {
return true;
}
// h:mm / hh:mm with optional a|p|am|pm, or plain 24-hour format (e.g. 15:30)
if (/^\d{1,2}:\d{2}(?:\s*(a|p|am|pm))?$/.test(normalized)) {
return true;
}
return false;
};
const parseTimeToken = (text) => {
const lowered = text.toLowerCase();
const noonIndex = lowered.indexOf(' at noon');
@ -476,18 +498,20 @@ function initNaturalEventParser(root = document) {
return { hours: 0, minutes: 0, index: midnightIndex };
}
const match = text.match(/\bat\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\b/i);
const match = text.match(/\bat\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm|a|p)?\b/i);
if (!match) return null;
let hours = parseInt(match[1], 10);
const minutes = parseInt(match[2] || '0', 10);
const meridiem = (match[3] || '').toLowerCase();
const meridiem = (match[3] || '').toLowerCase().replace(/\./g, '');
if (Number.isNaN(hours) || Number.isNaN(minutes)) return null;
if (minutes < 0 || minutes > 59) return null;
if (meridiem) {
if (hours === 12 && meridiem === 'am') hours = 0;
if (hours < 12 && meridiem === 'pm') hours += 12;
if (hours < 1 || hours > 12) return null;
if (hours === 12 && meridiem.startsWith('a')) hours = 0;
if (hours < 12 && meridiem.startsWith('p')) hours += 12;
} else if (hours > 23) {
return null;
}
@ -646,9 +670,26 @@ function initNaturalEventParser(root = document) {
const index = lower.lastIndexOf(' at ');
if (index === -1) return null;
const candidate = text.slice(index + 4).trim().replace(/[.,;]+$/, '');
let 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 the phrase ends with a dangling "at", trim it first.
// Example: "... at 11am at" -> candidate "11am"
candidate = candidate.replace(/\s+at\s*$/i, '').trim();
if (!candidate) return null;
// If the extracted candidate starts with a time token, strip it.
// Example: "11am at Pediatric Alliance" -> "Pediatric Alliance"
const leadingTimeMatch = candidate.match(/^(?:\d{1,2}(?::\d{2})?\s*(?:a|p|am|pm)|\d{1,2}:\d{2}|noon|midnight)\b/i);
if (leadingTimeMatch) {
candidate = candidate
.slice(leadingTimeMatch[0].length)
.replace(/^\s*at\s*/i, '')
.trim();
}
if (!candidate) return null;
if (isAtTimeCandidate(candidate)) return null;
if (/\bfor\s+\d+\s*(minutes?|mins?|hours?|hrs?)\b/i.test(candidate)) return null;
return candidate;
@ -857,8 +898,15 @@ function initNaturalEventParser(root = document) {
titleInput.value = parsed.title;
}
if (parsed.location && locationInput) {
if (locationInput) {
if (parsed.location) {
locationInput.value = parsed.location;
input.dataset.nlLocationApplied = '1';
} else if (input.dataset.nlLocationApplied === '1') {
// Clear stale parser-written values (e.g. interim "at 3" while typing "at 3pm")
locationInput.value = '';
input.dataset.nlLocationApplied = '0';
}
}
const existingStart = startInput.type === 'date'
@ -1395,4 +1443,3 @@ export function handleEventModalAfterSwap(target) {
target.closest('dialog')?.showModal();
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 14v2.2l1.6 1"/><path d="M16 2v4"/><path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5"/><path d="M3 10h5"/><path d="M8 2v4"/><circle cx="16" cy="16" r="6"/></svg>

After

Width:  |  Height:  |  Size: 375 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 5H3"/><path d="M15 12H3"/><path d="M17 19H3"/></svg>

After

Width:  |  Height:  |  Size: 247 B

View File

@ -1,4 +1,4 @@
<x-modal.content modal-class="modal--wide modal--square modal--event">
<x-modal.content modal-class="modal--event">
<x-modal.title class="input">
@if ($event->exists)
<h2>{{ __('Edit event details') }}</h2>
@ -14,7 +14,7 @@
/>
@endif
</x-modal.title>
<x-modal.body class="no-margin">
<x-modal.body>
@include('event.partials.form', [
'calendar' => $calendar,
'event' => $event,

View File

@ -11,40 +11,32 @@
@method('PUT')
@endif
<div class="tab-panels" data-tabs>
<menu class="tabs tabs--vertical" role="tablist" aria-orientation="vertical">
<li id="tab-details" role="tab" aria-controls="tab-details" aria-selected="true">
<x-button type="button">
<x-icon-info-circle width="20" />
<span>Details</span>
</x-button>
</li>
<li id="tab-repeat" role="tab" aria-controls="tab-repeat" aria-selected="false">
<x-button type="button">
<x-icon-repeat width="20" />
<span>Repeat</span>
</x-button>
</li>
<li id="tab-attendees" role="tab" aria-controls="tab-attendees" aria-selected="false">
<x-button type="button">
<x-icon-user-circle width="20" />
<span>Guests</span>
</x-button>
</li>
<li id="tab-description" role="tab" aria-controls="tab-description" aria-selected="false">
<x-button type="button">
<x-icon-user-circle width="20" />
<span>Description</span>
</x-button>
</li>
</menu>
<div class="panels">
<div id="tab-details" role="tabpanel" aria-labelledby="tab-btn-details">
{{-- Calendar --}}
{{-- Title --}}
<div class="event-field">
<div class="event-field-icon"><!-- empty --></div>
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.label for="calendar_uri" :value="__('common.calendar')" />
<x-input.text
id="title"
name="title"
type="text"
:value="old('title', $event->meta?->title ?? '')"
placeholder="Event title..."
required
:autofocus="$event->exists"
/>
<x-input.error class="mt-2" :messages="$errors->get('title')" />
</div>
</div>
</div>
{{-- Calendar --}}
<div class="event-field">
<div class="event-field-icon">
<x-icon-calendar />
</div>
<div class="input-row input-row--1">
<div class="input-cell">
<div class="relative" data-calendar-picker>
<input type="hidden" id="calendar_uri" name="calendar_uri" value="{{ $selectedCalendarUri ?? '' }}" data-calendar-picker-input>
@ -83,43 +75,20 @@
</div>
</div>
</div>
{{-- Title --}}
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.label for="title" :value="__('Title')" />
<x-input.text
id="title"
name="title"
type="text"
:value="old('title', $event->meta?->title ?? '')"
required
:autofocus="$event->exists"
/>
<x-input.error class="mt-2" :messages="$errors->get('title')" />
</div>
</div>
{{-- Description --}}
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.label for="description" :value="__('Description')" />
<x-input.textarea
id="description"
name="description"
rows="3">{{ old('description', $event->meta?->description ?? '') }}</x-input.textarea>
<x-input.error :messages="$errors->get('description')" />
</div>
</div>
{{-- Location --}}
<div class="event-field">
<div class="event-field-icon">
<x-icon-pin />
</div>
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.label for="location" :value="__('Location')" />
<x-input.text
id="location"
name="location"
:value="old('location', $event->meta?->location ?? '')"
placeholder="Location..."
{{-- live suggestions via htmx --}}
hx-get="{{ route('location.suggest') }}"
hx-trigger="keyup changed delay:300ms"
@ -142,11 +111,16 @@
<input type="hidden" id="loc_lon" name="loc_lon" />
</div>
</div>
</div>
{{-- Start / End --}}
<div class="event-field">
<div class="event-field-icon">
<x-icon-calendar-clock />
</div>
<div class="input-rows">
<div class="input-row input-row--1-1">
<div class="input-cell">
<x-input.label for="start_at" :value="__('Starts')" />
<x-input.text
id="start_at"
name="start_at"
@ -154,11 +128,11 @@
:value="old('start_at', $start)"
data-event-start
required
aria-label="Start date and time"
/>
<x-input.error :messages="$errors->get('start_at')" />
</div>
<div class="input-cell">
<x-input.label for="end_at" :value="__('Ends')" />
<x-input.text
id="end_at"
name="end_at"
@ -166,14 +140,13 @@
:value="old('end_at', $end)"
data-event-end
required
aria-label="End date and time"
/>
<x-input.error :messages="$errors->get('end_at')" />
</div>
</div>
{{-- All-day --}}
<div class="input-row input-row--1">
<div class="input-cell">
<div class="input-cell ml-2px">
<x-input.checkbox-label
label="{{ __('All day event') }}"
id="all_day"
@ -185,13 +158,15 @@
</div>
</div>
</div>
</div>
{{-- recurrence --}}
<div id="tab-repeat" role="tabpanel" aria-labelledby="tab-btn-repeat" hidden>
<div class="event-field">
<div class="event-field-icon">
<x-icon-repeat />
</div>
<div class="input-row input-row--1">
<div class="input-cell">
<div class="mt-3">
<x-input.label for="repeat_frequency" :value="__('calendar.event.recurrence.frequency')" />
<x-input.select
id="repeat_frequency"
name="repeat_frequency"
@ -289,17 +264,18 @@
</div>
</div>
</div>
<div class="mt-4 text-sm text-gray-600 {{ $repeatFrequency !== 'yearly' ? 'hidden' : '' }}" data-recurrence-section="yearly">
{{ __('calendar.event.recurrence.yearly_hint') }}
</div>
</div>
</div>
</div>
</div>
{{-- attendees --}}
<div id="tab-attendees" role="tabpanel" aria-labelledby="tab-btn-attendees" hidden>
<div class="event-field">
<div class="event-field-icon">
<x-icon-user-circle />
</div>
<div class="input-row input-row--1">
<div class="input-cell">
<input type="hidden" name="attendees_present" value="1">
@ -422,6 +398,20 @@
</div>
</div>
{{-- Description --}}
<div class="event-field">
<div class="event-field-icon">
<x-icon-description />
</div>
<div class="input-row input-row--1">
<div class="input-cell">
<x-input.textarea
id="description"
name="description"
placeholder="Description..."
rows="3">{{ old('description', $event->meta?->description ?? '') }}</x-input.textarea>
<x-input.error :messages="$errors->get('description')" />
</div>
</div>
</div>