Compare commits
2 Commits
e024639209
...
8073284fab
| Author | SHA1 | Date | |
|---|---|---|---|
| 8073284fab | |||
| 1184673721 |
@ -212,7 +212,7 @@
|
|||||||
|
|
||||||
/* all day bar */
|
/* all day bar */
|
||||||
ol.day {
|
ol.day {
|
||||||
@apply sticky top-42 grid col-span-2 bg-white border-b border-primary z-10 overflow-x-hidden;
|
@apply sticky top-40 grid col-span-2 bg-white border-b border-primary z-10 overflow-x-hidden;
|
||||||
box-shadow: 0 0.25rem 0.5rem -0.25rem rgba(0,0,0,0.15);
|
box-shadow: 0 0.25rem 0.5rem -0.25rem rgba(0,0,0,0.15);
|
||||||
padding: 0.25rem 0 0.2rem 6rem;
|
padding: 0.25rem 0 0.2rem 6rem;
|
||||||
|
|
||||||
@ -556,6 +556,9 @@
|
|||||||
hgroup {
|
hgroup {
|
||||||
@apply top-22;
|
@apply top-22;
|
||||||
}
|
}
|
||||||
|
ol.day {
|
||||||
|
@apply top-42;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (height <= 50rem)
|
@media (height <= 50rem)
|
||||||
|
|||||||
@ -1,14 +1,12 @@
|
|||||||
.close-modal {
|
/**
|
||||||
@apply hidden;
|
* modal uses a <dialog> as the backdrop and grid, with #modal and #modal-aside on top
|
||||||
}
|
*/
|
||||||
|
|
||||||
dialog {
|
dialog {
|
||||||
@apply grid fixed inset-0 m-0 p-0 pointer-events-none;
|
@apply grid fixed inset-0 m-0 p-0 pointer-events-none;
|
||||||
@apply place-items-center bg-transparent opacity-0 invisible;
|
@apply place-items-center bg-transparent opacity-0 invisible;
|
||||||
@apply w-full h-full max-w-none max-h-none overflow-clip;
|
@apply w-full h-full max-w-none max-h-none overflow-clip;
|
||||||
background-color: rgba(26, 26, 26, 0.75);
|
background-color: rgba(26, 26, 26, 0.75);
|
||||||
backdrop-filter: blur(0.25rem);
|
backdrop-filter: blur(0.25rem);
|
||||||
/*(grid-template-rows: minmax(20dvh, 2rem) 1fr; */
|
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
scrollbar-gutter: auto;
|
scrollbar-gutter: auto;
|
||||||
transition:
|
transition:
|
||||||
@ -17,6 +15,7 @@ dialog {
|
|||||||
visibility 150ms cubic-bezier(0,0,.2,1);
|
visibility 150ms cubic-bezier(0,0,.2,1);
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
|
||||||
|
/* primary modal container */
|
||||||
#modal {
|
#modal {
|
||||||
@apply relative rounded-xl bg-white border-gray-200 p-0;
|
@apply relative rounded-xl bg-white border-gray-200 p-0;
|
||||||
@apply flex flex-col items-start col-start-1 translate-y-4 overflow-hidden;
|
@apply flex flex-col items-start col-start-1 translate-y-4 overflow-hidden;
|
||||||
@ -26,15 +25,18 @@ dialog {
|
|||||||
transition: translate 150ms cubic-bezier(0,0,.2,1);
|
transition: translate 150ms cubic-bezier(0,0,.2,1);
|
||||||
box-shadow: 0 1.5rem 4rem -0.5rem rgba(0, 0, 0, 0.4);
|
box-shadow: 0 1.5rem 4rem -0.5rem rgba(0, 0, 0, 0.4);
|
||||||
|
|
||||||
|
/* close button */
|
||||||
> .close-modal {
|
> .close-modal {
|
||||||
@apply block absolute top-4 right-4 z-3;
|
@apply block absolute top-4 right-4 z-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
> .content {
|
/* content wrapper and content defaults */
|
||||||
|
> .modal-content {
|
||||||
@apply grid w-full h-full overflow-hidden;
|
@apply grid w-full h-full overflow-hidden;
|
||||||
grid-template-rows: 1fr;
|
|
||||||
|
|
||||||
/* set the grid based on which elements the content section has */
|
/* set the grid based on which elements the content section has */
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
|
||||||
&:has(header):has(footer) {
|
&:has(header):has(footer) {
|
||||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
@ -48,20 +50,6 @@ 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 pr-16;
|
@apply sticky top-0 bg-white flex items-center px-6 min-h-20 h-20 z-2 pr-16;
|
||||||
|
|
||||||
&.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* main content wrapper */
|
/* main content wrapper */
|
||||||
@ -78,12 +66,10 @@ dialog {
|
|||||||
@apply flex flex-col gap-4;
|
@apply flex flex-col gap-4;
|
||||||
|
|
||||||
/* paneled modals get different behavior */
|
/* paneled modals get different behavior */
|
||||||
&.settings {
|
&.settings:has(.tab-panels) {
|
||||||
&:has(.tab-panels) {
|
|
||||||
@apply flex-1 min-h-0 gap-0;
|
@apply flex-1 min-h-0 gap-0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* footer */
|
/* footer */
|
||||||
footer {
|
footer {
|
||||||
@ -98,20 +84,136 @@ dialog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.wide {
|
/* wider version */
|
||||||
|
&.modal--wide {
|
||||||
max-width: 48rem;
|
max-width: 48rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.square {
|
/* forced height on variable content modals */
|
||||||
|
&.modal--square {
|
||||||
block-size: clamp(32rem, 72dvh, 54rem);
|
block-size: clamp(32rem, 72dvh, 54rem);
|
||||||
max-block-size: calc(100dvh - 5rem);
|
max-block-size: calc(100dvh - 5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* event form modal with a natural language parser and enhanced interactions */
|
||||||
|
&.modal--event
|
||||||
|
{
|
||||||
|
header.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* when the event-create natural language parser is collapsed */
|
||||||
|
&.natural-collapsed {
|
||||||
|
@apply rounded-full;
|
||||||
|
block-size: auto;
|
||||||
|
|
||||||
|
header.input {
|
||||||
|
@apply border-none;
|
||||||
|
|
||||||
|
input {
|
||||||
|
@apply rounded-full;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> .modal-content {
|
||||||
|
grid-template-rows: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .modal-content > section.modal-body,
|
||||||
|
> .modal-content > footer {
|
||||||
|
@apply hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
pointer-events: none;
|
||||||
|
transition:
|
||||||
|
translate 350ms ease-in-out,
|
||||||
|
opacity 150ms ease-in-out,
|
||||||
|
visibility 150ms ease-in-out;
|
||||||
|
|
||||||
|
&.is-visible {
|
||||||
|
@apply opacity-100 visible h-auto;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-aside-list {
|
||||||
|
@apply list-none m-0 pt-3 flex flex-col justify-start items-start gap-2;
|
||||||
|
|
||||||
|
li {
|
||||||
|
@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 hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-aside-expand {
|
||||||
|
@apply whitespace-nowrap bg-transparent border-none mt-1 text-sm underline;
|
||||||
|
@apply text-white/90 hover:text-white cursor-pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* reposition #modal and #aside when the #aside is used */
|
||||||
|
&:has(#modal-aside) {
|
||||||
|
grid-template-rows: 1fr 0;
|
||||||
|
}
|
||||||
|
&:has(#modal-aside.is-visible) {
|
||||||
|
grid-template-rows: 40dvh auto;
|
||||||
|
|
||||||
|
#modal {
|
||||||
|
@apply self-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
#modal-aside {
|
||||||
|
@apply self-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hide backdrop before the modal is open */
|
||||||
&::backdrop {
|
&::backdrop {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* open interactions */
|
||||||
&[open] {
|
&[open] {
|
||||||
@apply opacity-100 visible;
|
@apply opacity-100 visible;
|
||||||
pointer-events: inherit;
|
pointer-events: inherit;
|
||||||
@ -120,6 +222,10 @@ dialog {
|
|||||||
@apply translate-y-0;
|
@apply translate-y-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#modal-aside.is-visible {
|
||||||
|
@apply translate-y-0;
|
||||||
|
}
|
||||||
|
|
||||||
&::backdrop {
|
&::backdrop {
|
||||||
@apply opacity-100;
|
@apply opacity-100;
|
||||||
}
|
}
|
||||||
@ -155,3 +261,18 @@ dialog {
|
|||||||
@apply h-full min-h-0 pt-2 pr-6 pb-6 pl-1 overflow-y-auto;
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -14,24 +14,27 @@ const SELECTORS = {
|
|||||||
eventEndInput: '[data-event-end]',
|
eventEndInput: '[data-event-end]',
|
||||||
eventTitleInput: 'input[name="title"]',
|
eventTitleInput: 'input[name="title"]',
|
||||||
eventLocationInput: 'input[name="location"]',
|
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]',
|
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]',
|
||||||
recurrenceSection: '[data-recurrence-section]',
|
recurrenceSection: '[data-recurrence-section]',
|
||||||
monthlyMode: '[data-monthly-mode]',
|
|
||||||
monthlyDays: '[data-monthly-days]',
|
|
||||||
monthlyWeekday: '[data-monthly-weekday]',
|
|
||||||
modalDialog: 'dialog',
|
|
||||||
modalContent: '#modal',
|
|
||||||
modalClassSource: '[data-modal-class]',
|
|
||||||
tabsRoot: '[data-tabs]',
|
tabsRoot: '[data-tabs]',
|
||||||
tabButton: '[role=\"tab\"]',
|
tabButton: '[role=\"tab\"]',
|
||||||
tabPanel: '[role=\"tabpanel\"]',
|
tabPanel: '[role=\"tabpanel\"]',
|
||||||
monthDay: '.calendar.month .day',
|
|
||||||
monthDayEvent: 'a.event',
|
|
||||||
monthDayMore: '[data-day-more]',
|
|
||||||
monthDayMoreWrap: '.more-events',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function syncModalRootClass(modal) {
|
function syncModalRootClass(modal) {
|
||||||
@ -71,6 +74,74 @@ function clearModalRootClass(modal) {
|
|||||||
modal.dataset.appliedClass = '';
|
modal.dataset.appliedClass = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderModalAside(dialog, items = [], show = false) {
|
||||||
|
if (!dialog) return;
|
||||||
|
|
||||||
|
const aside = dialog.querySelector(SELECTORS.modalAside);
|
||||||
|
if (!aside) return;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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 key = String(label || '').trim().toLowerCase().replace(/\s+/g, '-');
|
||||||
|
usedKeys.add(key);
|
||||||
|
|
||||||
|
let li = existing.get(key);
|
||||||
|
if (!li) {
|
||||||
|
li = document.createElement('li');
|
||||||
|
li.dataset.asideKey = key;
|
||||||
|
li.classList.add('is-visible');
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
aside.classList.toggle('is-visible', show);
|
||||||
|
aside.setAttribute('aria-hidden', show ? 'false' : 'true');
|
||||||
|
aside.dataset.asideSignature = signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearModalAside(dialog) {
|
||||||
|
renderModalAside(dialog, [], false);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* htmx/global
|
* htmx/global
|
||||||
@ -174,7 +245,10 @@ window.addEventListener('popstate', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
*
|
||||||
* event form all-day toggle
|
* event form all-day toggle
|
||||||
|
*
|
||||||
|
* converts the datetime-local inputs into regular date inputs, maintaining the date
|
||||||
*/
|
*/
|
||||||
function initEventAllDayToggles(root = document) {
|
function initEventAllDayToggles(root = document) {
|
||||||
const toDate = (value) => {
|
const toDate = (value) => {
|
||||||
@ -243,6 +317,7 @@ function initEventAllDayToggles(root = document) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
*
|
||||||
* recurrence preset selector
|
* recurrence preset selector
|
||||||
*/
|
*/
|
||||||
function initRecurrenceControls(root = document) {
|
function initRecurrenceControls(root = document) {
|
||||||
@ -313,6 +388,7 @@ function initRecurrenceControls(root = document) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
*
|
||||||
* natural-language event parser (create modal header input)
|
* natural-language event parser (create modal header input)
|
||||||
*/
|
*/
|
||||||
function initNaturalEventParser(root = document) {
|
function initNaturalEventParser(root = document) {
|
||||||
@ -332,12 +408,23 @@ function initNaturalEventParser(root = document) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const pad = (value) => String(value).padStart(2, '0');
|
const pad = (value) => String(value).padStart(2, '0');
|
||||||
const toLocalInputValue = (date) => (
|
|
||||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
const toLocalDateInputValue = (date) => (
|
||||||
`T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||||
);
|
);
|
||||||
|
|
||||||
const readLocalInputValue = (value) => {
|
const toLocalDatetimeInputValue = (date) => (
|
||||||
|
`${toLocalDateInputValue(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const readDateInputValue = (value) => {
|
||||||
|
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
|
||||||
|
const [year, month, day] = value.split('-').map((n) => parseInt(n, 10));
|
||||||
|
if ([year, month, day].some((n) => Number.isNaN(n))) return null;
|
||||||
|
return new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readDatetimeInputValue = (value) => {
|
||||||
if (!value || !value.includes('T')) return null;
|
if (!value || !value.includes('T')) return null;
|
||||||
const [datePart, timePart] = value.split('T');
|
const [datePart, timePart] = value.split('T');
|
||||||
if (!datePart || !timePart) return null;
|
if (!datePart || !timePart) return null;
|
||||||
@ -347,6 +434,46 @@ function initNaturalEventParser(root = document) {
|
|||||||
return new Date(year, month - 1, day, hours, minutes, 0, 0);
|
return new Date(year, month - 1, day, hours, minutes, 0, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatDayLong = (date) => (
|
||||||
|
date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', 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 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) => {
|
const parseDurationMinutes = (text) => {
|
||||||
const match = text.match(/\bfor\s+(\d+)\s*(minutes?|mins?|hours?|hrs?)\b/i);
|
const match = text.match(/\bfor\s+(\d+)\s*(minutes?|mins?|hours?|hrs?)\b/i);
|
||||||
if (!match) return 60;
|
if (!match) return 60;
|
||||||
@ -386,8 +513,7 @@ function initNaturalEventParser(root = document) {
|
|||||||
|
|
||||||
if (month !== undefined && !Number.isNaN(day) && day >= 1 && day <= 31) {
|
if (month !== undefined && !Number.isNaN(day) && day >= 1 && day <= 31) {
|
||||||
const thisYear = now.getFullYear();
|
const thisYear = now.getFullYear();
|
||||||
let year = explicitYear || thisYear;
|
let date = new Date(explicitYear || thisYear, month, day, 0, 0, 0, 0);
|
||||||
let date = new Date(year, month, day, 0, 0, 0, 0);
|
|
||||||
if (!explicitYear) {
|
if (!explicitYear) {
|
||||||
const today = new Date(thisYear, now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
const today = new Date(thisYear, now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
||||||
if (date < today) {
|
if (date < today) {
|
||||||
@ -461,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 parseLocationToken = (text) => {
|
||||||
const lower = text.toLowerCase();
|
const lower = text.toLowerCase();
|
||||||
const index = lower.lastIndexOf(' at ');
|
const index = lower.lastIndexOf(' at ');
|
||||||
@ -474,10 +656,11 @@ function initNaturalEventParser(root = document) {
|
|||||||
return candidate;
|
return candidate;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parseTitleToken = (text, dateToken, timeToken, locationToken) => {
|
const parseTitleToken = (text, dateToken, timeToken, relativeToken, locationToken) => {
|
||||||
const boundaries = [];
|
const boundaries = [];
|
||||||
if (dateToken) boundaries.push(dateToken.index);
|
if (dateToken) boundaries.push(dateToken.index);
|
||||||
if (timeToken) boundaries.push(timeToken.index);
|
if (timeToken) boundaries.push(timeToken.index);
|
||||||
|
if (relativeToken) boundaries.push(relativeToken.index);
|
||||||
|
|
||||||
const lower = text.toLowerCase();
|
const lower = text.toLowerCase();
|
||||||
const durationIndex = lower.indexOf(' for ');
|
const durationIndex = lower.indexOf(' for ');
|
||||||
@ -500,18 +683,22 @@ function initNaturalEventParser(root = document) {
|
|||||||
const trimmed = (text || '').trim();
|
const trimmed = (text || '').trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
const relativeToken = parseRelativeToken(trimmed, baseDate);
|
||||||
const dateToken = parseDateToken(trimmed, baseDate);
|
const dateToken = parseDateToken(trimmed, baseDate);
|
||||||
const timeToken = parseTimeToken(trimmed);
|
const timeToken = parseTimeToken(trimmed);
|
||||||
const durationMinutes = parseDurationMinutes(trimmed);
|
const durationMinutes = parseDurationMinutes(trimmed);
|
||||||
const location = parseLocationToken(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 {
|
return {
|
||||||
title,
|
title,
|
||||||
location,
|
location,
|
||||||
dateToken,
|
dateToken,
|
||||||
timeToken,
|
timeToken,
|
||||||
|
relativeToken,
|
||||||
durationMinutes,
|
durationMinutes,
|
||||||
|
allDay,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -529,7 +716,18 @@ function initNaturalEventParser(root = document) {
|
|||||||
return document.querySelector('#event-form');
|
return document.querySelector('#event-form');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const enableNaturalCollapsedMode = (input) => {
|
||||||
|
const dialog = input.closest('dialog');
|
||||||
|
const modal = dialog?.querySelector(SELECTORS.modalContent);
|
||||||
|
if (!dialog || !modal || modal.classList.contains('natural-expanded')) return;
|
||||||
|
|
||||||
|
modal.classList.add('natural-collapsed');
|
||||||
|
renderModalAside(dialog, [], true);
|
||||||
|
};
|
||||||
|
|
||||||
const applyParsedData = (input) => {
|
const applyParsedData = (input) => {
|
||||||
|
const dialog = input.closest('dialog');
|
||||||
|
const modal = dialog?.querySelector(SELECTORS.modalContent);
|
||||||
const form = findFormFromNaturalInput(input);
|
const form = findFormFromNaturalInput(input);
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
|
|
||||||
@ -545,7 +743,12 @@ function initNaturalEventParser(root = document) {
|
|||||||
if (!titleInput || !startInput || !endInput) return;
|
if (!titleInput || !startInput || !endInput) return;
|
||||||
|
|
||||||
const parsed = parseNaturalEventText(input.value, new Date());
|
const parsed = parseNaturalEventText(input.value, new Date());
|
||||||
if (!parsed) return;
|
if (!parsed) {
|
||||||
|
if (modal?.classList.contains('natural-collapsed')) {
|
||||||
|
renderModalAside(dialog, [], true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (parsed.title) {
|
if (parsed.title) {
|
||||||
titleInput.value = parsed.title;
|
titleInput.value = parsed.title;
|
||||||
@ -555,27 +758,66 @@ function initNaturalEventParser(root = document) {
|
|||||||
locationInput.value = parsed.location;
|
locationInput.value = parsed.location;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!parsed.dateToken && !parsed.timeToken) return;
|
const existingStart = startInput.type === 'date'
|
||||||
|
? readDateInputValue(startInput.value)
|
||||||
|
: readDatetimeInputValue(startInput.value);
|
||||||
|
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) {
|
||||||
|
allDayToggle.checked = true;
|
||||||
|
allDayToggle.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateOnly = toLocalDateInputValue(baseDate);
|
||||||
|
startInput.value = dateOnly;
|
||||||
|
endInput.value = dateOnly;
|
||||||
|
} else if (parsed.dateToken || parsed.timeToken || parsed.relativeToken) {
|
||||||
if (allDayToggle?.checked) {
|
if (allDayToggle?.checked) {
|
||||||
allDayToggle.checked = false;
|
allDayToggle.checked = false;
|
||||||
allDayToggle.dispatchEvent(new Event('change', { bubbles: true }));
|
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 hours = parsed.timeToken?.hours ?? (existingStart ? existingStart.getHours() : 9);
|
||||||
const minutes = parsed.timeToken?.minutes ?? (existingStart ? existingStart.getMinutes() : 0);
|
const minutes = parsed.timeToken?.minutes ?? (existingStart ? existingStart.getMinutes() : 0);
|
||||||
base.setHours(hours, minutes, 0, 0);
|
baseDate.setHours(hours, minutes, 0, 0);
|
||||||
|
|
||||||
const end = new Date(base.getTime());
|
const end = new Date(baseDate.getTime());
|
||||||
end.setMinutes(end.getMinutes() + parsed.durationMinutes);
|
end.setMinutes(end.getMinutes() + parsed.durationMinutes);
|
||||||
|
|
||||||
startInput.value = toLocalInputValue(base);
|
startInput.value = toLocalDatetimeInputValue(baseDate);
|
||||||
endInput.value = toLocalInputValue(end);
|
endInput.value = toLocalDatetimeInputValue(end);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryItems = [];
|
||||||
|
if (parsed.title) {
|
||||||
|
summaryItems.push({ label: 'Title', value: parsed.title });
|
||||||
|
}
|
||||||
|
if (parsed.location) {
|
||||||
|
summaryItems.push({ label: 'Location', value: parsed.location });
|
||||||
|
}
|
||||||
|
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')) {
|
||||||
|
renderModalAside(dialog, summaryItems, true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!document.__naturalEventDelegated) {
|
if (!document.__naturalEventDelegated) {
|
||||||
@ -592,9 +834,24 @@ function initNaturalEventParser(root = document) {
|
|||||||
if (!input) return;
|
if (!input) return;
|
||||||
applyParsedData(input);
|
applyParsedData(input);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (event) => {
|
||||||
|
const button = event.target?.closest?.(SELECTORS.modalExpand);
|
||||||
|
if (!button) return;
|
||||||
|
|
||||||
|
const dialog = button.closest('dialog');
|
||||||
|
const modal = dialog?.querySelector(SELECTORS.modalContent);
|
||||||
|
if (!dialog || !modal) return;
|
||||||
|
|
||||||
|
modal.classList.remove('natural-collapsed');
|
||||||
|
modal.classList.add('natural-expanded');
|
||||||
|
clearModalAside(dialog);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
root.querySelectorAll(SELECTORS.naturalEventInput).forEach((input) => {
|
root.querySelectorAll(SELECTORS.naturalEventInput).forEach((input) => {
|
||||||
|
enableNaturalCollapsedMode(input);
|
||||||
|
|
||||||
if (input.value?.trim()) {
|
if (input.value?.trim()) {
|
||||||
applyParsedData(input);
|
applyParsedData(input);
|
||||||
}
|
}
|
||||||
@ -622,8 +879,10 @@ function initModalHandlers(root = document) {
|
|||||||
|
|
||||||
const isEvent = modal.querySelector('[data-modal-kind="event"]');
|
const isEvent = modal.querySelector('[data-modal-kind="event"]');
|
||||||
const prevUrl = modal.dataset.prevUrl;
|
const prevUrl = modal.dataset.prevUrl;
|
||||||
|
modal.classList.remove('natural-collapsed', 'natural-expanded');
|
||||||
modal.innerHTML = '';
|
modal.innerHTML = '';
|
||||||
clearModalRootClass(modal);
|
clearModalRootClass(modal);
|
||||||
|
clearModalAside(dialog);
|
||||||
|
|
||||||
if (isEvent && prevUrl) {
|
if (isEvent && prevUrl) {
|
||||||
history.replaceState({}, '', prevUrl);
|
history.replaceState({}, '', prevUrl);
|
||||||
|
|||||||
@ -1 +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" class="lucide lucide-calendar-icon lucide-calendar"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/></svg>
|
<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="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/></svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 345 B After Width: | Height: | Size: 294 B |
@ -11,7 +11,7 @@
|
|||||||
@if(filled($modalClass))
|
@if(filled($modalClass))
|
||||||
data-modal-class="{{ trim((string) $modalClass) }}"
|
data-modal-class="{{ trim((string) $modalClass) }}"
|
||||||
@endif
|
@endif
|
||||||
{{ $attributes->class('content') }}
|
{{ $attributes->class('modal-content') }}
|
||||||
>
|
>
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,4 +3,10 @@
|
|||||||
hx-target="this"
|
hx-target="this"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
</div>
|
</div>
|
||||||
|
<div id="modal-aside" aria-hidden="true">
|
||||||
|
<ul class="modal-aside-list" data-modal-aside-list></ul>
|
||||||
|
<button type="button" class="modal-aside-expand" data-modal-expand>
|
||||||
|
Edit details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
<x-modal.content modal-class="wide square">
|
<x-modal.content modal-class="modal--wide modal--square modal--event">
|
||||||
<x-modal.title class="input">
|
<x-modal.title class="input">
|
||||||
@if ($event->exists)
|
@if ($event->exists)
|
||||||
<h2>{{ __('Edit event details') }}</h2>
|
<h2>{{ __('Edit event details') }}</h2>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user