diff --git a/src/components/Calendar/CalendarEvent.ts b/src/components/Calendar/CalendarEvent.ts new file mode 100644 index 0000000..45af645 --- /dev/null +++ b/src/components/Calendar/CalendarEvent.ts @@ -0,0 +1,70 @@ +import { LitElement, css, html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; + +export interface CalendarEventData { + id: string; + title: string; + start: Date; + end?: Date; + description: string; + location?: string; + participants?: string[]; + tags?: string[]; + type: 'date' | 'time'; +} + +@customElement('arx-calendar-event') +export class CalendarEvent extends LitElement { + @property({ type: Object }) + event!: CalendarEventData; + + @property({ type: String }) + locale = 'en-US'; + + static override styles = css` + .calendar-event { + background: var(--color-primary); + color: var(--color-primary-content); + padding: 0.25rem; + margin: 0.25rem 0; + border-radius: var(--radius-selector); + font-size: 0.875rem; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; + } + + .event-time { + font-size: 0.75rem; + opacity: 0.8; + } + `; + + private handleClick() { + this.dispatchEvent(new CustomEvent('event-click', { detail: { event: this.event } })); + } + + override render() { + return html` +
+ ${this.event.title} + ${when( + this.event.type === 'time', + () => html` +
+ ${this.event.start.toLocaleTimeString(this.locale, { hour: '2-digit', minute: '2-digit' })} + ${when( + this.event.end, + () => + html` - ${this.event.end!.toLocaleTimeString(this.locale, { hour: '2-digit', minute: '2-digit' })}`, + )} +
+ `, + )} +
+ `; + } +} diff --git a/src/components/Calendar/CalendarEventDetailsDialog.ts b/src/components/Calendar/CalendarEventDetailsDialog.ts new file mode 100644 index 0000000..98b51be --- /dev/null +++ b/src/components/Calendar/CalendarEventDetailsDialog.ts @@ -0,0 +1,88 @@ +import formatDateTime from '@/utils/formatDateTime'; +import { LitElement, css, html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; +import type { CalendarEventData } from './CalendarEvent'; + +import '@components/General/Dialog'; +import '@components/General/Fieldset'; + +@customElement('arx-calendar-event-details-dialog') +export class CalendarEventDetailsDialog extends LitElement { + @property({ type: Object }) + event: CalendarEventData | null = null; + + @property({ type: Boolean }) + open = false; + + @property({ type: String }) + locale = 'en-US'; + + static override styles = css` + .event-detail { + display: flex; + gap: 0.5rem; + } + + .event-detail-label { + font-weight: 500; + color: var(--color-base-content); + min-width: 80px; + } + + .event-detail-value { + color: var(--color-base-content); + } + `; + + private handleClose() { + this.dispatchEvent(new CustomEvent('close')); + } + + override render() { + if (!this.open || !this.event) return html``; + + const { end, location, description } = this.event; + + return html` + + +
+ Start: + ${formatDateTime(this.event.start)} +
+ ${when( + end instanceof Date, + () => html` +
+ End: + ${formatDateTime(end!)} +
+ `, + )} + ${when( + location, + () => html` +
+ Location: + ${location} +
+ `, + )} + ${when( + description, + () => html` +
+ Description: + ${description} +
+ `, + )} +
+
+ `; + } +} diff --git a/src/components/Calendar/CalendarEventDialog.ts b/src/components/Calendar/CalendarEventDialog.ts new file mode 100644 index 0000000..b2bfcae --- /dev/null +++ b/src/components/Calendar/CalendarEventDialog.ts @@ -0,0 +1,169 @@ +import { LitElement, css, html } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; + +import '@components/General/Button'; +import { StyledInput } from '@components/General/Input'; +import { StyledTextarea } from '@components/General/Textarea'; +import { StyledToggle } from '@components/General/Toggle'; + +interface InputEvent extends Event { + detail: { + value: string; + }; +} + +@customElement('arx-calendar-event-dialog') +export class CalendarEventDialog extends LitElement { + @property({ type: Boolean }) + open = false; + + @state() + private newEvent = { + title: '', + description: '', + location: '', + startDate: '', + startTime: '', + endDate: '', + endTime: '', + allDay: false, + participants: [] as string[], + tags: [] as string[], + }; + + static override styles = css` + .form-input { + width: 100%; + padding: 0.5rem; + border: 1px solid var(--color-base-300); + border-radius: var(--radius-selector); + background: var(--color-base-200); + color: var(--color-base-content); + } + + .form-row { + display: flex; + gap: 1rem; + } + `; + + private handleClose() { + this.dispatchEvent(new CustomEvent('close')); + } + + private handleInputChange(e: Event) { + if (e.target instanceof StyledToggle) { + const checked = e.target.checked; + this.newEvent = { + ...this.newEvent, + [e.target.name]: checked, + }; + } else if (e.target instanceof StyledTextarea || e.target instanceof StyledInput) { + const inputEvent = e as InputEvent; + this.newEvent = { + ...this.newEvent, + [e.target.name]: inputEvent.detail.value, + }; + } + } + + private handleSubmit(e: Event) { + e.preventDefault(); + this.dispatchEvent(new CustomEvent('submit', { detail: { event: this.newEvent } })); + } + + override render() { + if (!this.open) return html``; + + return html` + + + + + + + + + + + + + +
+ + + + ${when( + !this.newEvent.allDay, + () => html` + + + + `, + )} +
+
+ + + + ${when( + !this.newEvent.allDay, + () => html` + + + + `, + )} +
+ +
+ `; + } +} diff --git a/src/components/Calendar/CalendarHeader.ts b/src/components/Calendar/CalendarHeader.ts new file mode 100644 index 0000000..efb0512 --- /dev/null +++ b/src/components/Calendar/CalendarHeader.ts @@ -0,0 +1,126 @@ +import { LitElement, css, html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; + +type CalendarView = 'month' | 'week'; + +@customElement('arx-calendar-header') +export class CalendarHeader extends LitElement { + @property({ type: String }) + view: CalendarView = 'month'; + + @property({ type: Date }) + currentDate = new Date(); + + @property({ type: Boolean }) + isToday = false; + + @property({ type: String }) + dateRange = ''; + + static override styles = css` + .calendar-header { + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 2rem; + } + + .calendar-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + } + + .calendar-title { + font-size: 1.5rem; + font-weight: 600; + color: var(--color-base-content); + text-align: center; + flex: 1; + } + + .calendar-controls { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + flex-wrap: wrap; + } + + .calendar-view-toggle { + display: flex; + align-items: center; + justify-content: center; + gap: 1rem; + } + `; + + private handlePrev() { + this.dispatchEvent(new CustomEvent('prev')); + } + + private handleNext() { + this.dispatchEvent(new CustomEvent('next')); + } + + private handleToday() { + this.dispatchEvent(new CustomEvent('today')); + } + + private handleAddEvent() { + this.dispatchEvent(new CustomEvent('add-event')); + } + + private handleViewChange(e: CustomEvent) { + this.dispatchEvent(new CustomEvent('view-change', { detail: { view: e.detail.checked ? 'week' : 'month' } })); + } + + override render() { + return html` +
+
+ +

${this.dateRange}

+ +
+
+
+ ${when( + !this.isToday, + () => html` + + `, + )} + + + + +
+
+
+ `; + } +} diff --git a/src/components/Calendar/CalendarMonthView.ts b/src/components/Calendar/CalendarMonthView.ts new file mode 100644 index 0000000..5580fb3 --- /dev/null +++ b/src/components/Calendar/CalendarMonthView.ts @@ -0,0 +1,212 @@ +import { LitElement, css, html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import type { CalendarEventData } from './CalendarEvent'; + +@customElement('arx-calendar-month-view') +export class CalendarMonthView extends LitElement { + @property({ type: Date }) + currentDate = new Date(); + + @property({ type: Date }) + selectedDate: Date | null = null; + + @property({ type: Array }) + events: CalendarEventData[] = []; + + @property({ type: String }) + locale = 'en-US'; + + static override styles = css` + .calendar-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 0.5rem; + background: var(--color-base-100); + padding: 1rem; + border-radius: var(--radius-selector); + box-shadow: var(--shadow-sm); + } + + .calendar-day-header { + text-align: center; + padding: 0.5rem; + font-weight: 500; + color: var(--color-base-content); + opacity: 0.7; + } + + .calendar-day { + aspect-ratio: 1; + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: center; + min-height: 100px; + position: relative; + overflow: hidden; + border: 1px solid var(--color-base-300); + border-radius: var(--radius-selector); + cursor: pointer; + transition: all 0.2s ease; + background: var(--color-base-200); + color: var(--color-base-content); + } + + .calendar-day-number { + text-align: center; + padding: 0.25rem; + font-weight: 500; + } + + .calendar-day:hover { + background: var(--color-base-300); + transform: translateY(-2px); + } + + .calendar-day.selected { + background: var(--color-primary); + color: var(--color-primary-content); + border-color: var(--color-primary); + } + + .calendar-day.today { + border: 2px solid var(--color-primary); + font-weight: 600; + } + + .calendar-day.other-month { + opacity: 0.5; + background: var(--color-base-100); + } + + .calendar-day-events { + flex: 1; + overflow-y: auto; + max-height: 100px; + width: 100%; + } + `; + + private getDaysInMonth(date: Date): number { + return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); + } + + private getFirstDayOfMonth(date: Date): number { + return new Date(date.getFullYear(), date.getMonth(), 1).getDay(); + } + + private getCalendarDays(): Date[] { + const days: Date[] = []; + const firstDay = this.getFirstDayOfMonth(this.currentDate); + const daysInMonth = this.getDaysInMonth(this.currentDate); + const daysInPrevMonth = this.getDaysInMonth( + new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() - 1), + ); + + for (let i = firstDay - 1; i >= 0; i--) { + days.push(new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() - 1, daysInPrevMonth - i)); + } + + for (let i = 1; i <= daysInMonth; i++) { + days.push(new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), i)); + } + + const remainingDays = 42 - days.length; // 6 rows * 7 days + for (let i = 1; i <= remainingDays; i++) { + days.push(new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + 1, i)); + } + + return days; + } + + private isToday(date: Date): boolean { + const today = new Date(); + return ( + date.getDate() === today.getDate() && + date.getMonth() === today.getMonth() && + date.getFullYear() === today.getFullYear() + ); + } + + private isSelected(date: Date): boolean { + if (!this.selectedDate) return false; + return ( + date.getDate() === this.selectedDate.getDate() && + date.getMonth() === this.selectedDate.getMonth() && + date.getFullYear() === this.selectedDate.getFullYear() + ); + } + + private isOtherMonth(date: Date): boolean { + return date.getMonth() !== this.currentDate.getMonth(); + } + + private getEventsForDate(date: Date): CalendarEventData[] { + return this.events.filter((event) => { + const eventStart = new Date(event.start); + if ( + eventStart.getDate() === date.getDate() && + eventStart.getMonth() === date.getMonth() && + eventStart.getFullYear() === date.getFullYear() + ) { + return true; + } + + if (!event.end) return false; + + const eventEnd = new Date(event.end); + + return ( + eventEnd.getDate() === date.getDate() && + eventEnd.getMonth() === date.getMonth() && + eventEnd.getFullYear() === date.getFullYear() + ); + }); + } + + private handleDateClick(date: Date) { + this.dispatchEvent(new CustomEvent('date-click', { detail: { date } })); + } + + private handleEventClick(event: CalendarEventData) { + this.dispatchEvent(new CustomEvent('event-click', { detail: { event } })); + } + + override render() { + const firstDay = new Date(2024, 0, 1); + const dayNames = Array.from({ length: 7 }, (_, i) => { + const date = new Date(firstDay); + date.setDate(firstDay.getDate() + i); + return date.toLocaleDateString(this.locale, { weekday: 'short' }); + }); + + return html` +
+ ${dayNames.map((day) => html`
${day}
`)} + ${this.getCalendarDays().map( + (date) => html` +
this.handleDateClick(date)} + > +
${date.getDate()}
+
+ ${this.getEventsForDate(date).map( + (event) => html` + this.handleEventClick(e.detail.event)} + > + `, + )} +
+
+ `, + )} +
+ `; + } +} diff --git a/src/components/Calendar/CalendarWeekView.ts b/src/components/Calendar/CalendarWeekView.ts new file mode 100644 index 0000000..50a086d --- /dev/null +++ b/src/components/Calendar/CalendarWeekView.ts @@ -0,0 +1,114 @@ +import { LitElement, css, html } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import type { CalendarEventData } from './CalendarEvent'; + +@customElement('arx-calendar-week-view') +export class CalendarWeekView extends LitElement { + @property({ type: Date }) + currentDate = new Date(); + + @property({ type: Array }) + events: CalendarEventData[] = []; + + @property({ type: String }) + locale = 'en-US'; + + static override styles = css` + .week-view { + display: grid; + grid-template-columns: 1fr; + gap: 0.5rem; + } + + .week-day { + display: grid; + grid-template-columns: 100px 1fr; + gap: 1rem; + padding: 1rem; + background: var(--color-base-100); + border-radius: var(--radius-selector); + } + + .week-day-header { + font-weight: 600; + color: var(--color-base-content); + } + + .week-day-content { + min-height: 100px; + border-left: 1px solid var(--color-base-300); + padding-left: 1rem; + } + `; + + private getWeekDays(): Date[] { + const days: Date[] = []; + const currentDay = this.currentDate.getDay(); + const startOfWeek = new Date(this.currentDate); + startOfWeek.setDate(this.currentDate.getDate() - currentDay); + + for (let i = 0; i < 7; i++) { + const date = new Date(startOfWeek); + date.setDate(startOfWeek.getDate() + i); + days.push(date); + } + + return days; + } + + private getEventsForDate(date: Date): CalendarEventData[] { + return this.events.filter((event) => { + const eventStart = new Date(event.start); + if ( + eventStart.getDate() === date.getDate() && + eventStart.getMonth() === date.getMonth() && + eventStart.getFullYear() === date.getFullYear() + ) { + return true; + } + + if (!event.end) return false; + + const eventEnd = new Date(event.end); + + return ( + eventEnd.getDate() === date.getDate() && + eventEnd.getMonth() === date.getMonth() && + eventEnd.getFullYear() === date.getFullYear() + ); + }); + } + + private handleEventClick(event: CalendarEventData) { + this.dispatchEvent(new CustomEvent('event-click', { detail: { event } })); + } + + override render() { + return html` +
+ ${this.getWeekDays().map( + (date) => html` +
+
+ ${date.toLocaleDateString(this.locale, { weekday: 'long' })} +
+ ${date.toLocaleDateString(this.locale, { month: 'short', day: 'numeric' })} +
+
+ ${this.getEventsForDate(date).map( + (event) => html` + this.handleEventClick(e.detail.event)} + > + `, + )} +
+
+ `, + )} +
+ `; + } +} diff --git a/src/components/General/Button.ts b/src/components/General/Button.ts index 86c4487..2506722 100644 --- a/src/components/General/Button.ts +++ b/src/components/General/Button.ts @@ -187,6 +187,7 @@ export class StyledButton extends LitElement { private _handleClick(e: MouseEvent) { e.preventDefault(); + e.stopPropagation(); if (this.disabled || this.loading) { return; } @@ -207,8 +208,8 @@ export class StyledButton extends LitElement { name: this.name, value: this.value, }, - bubbles: true, - composed: true, + bubbles: false, + composed: false, }), ); return; diff --git a/src/components/General/Dialog.ts b/src/components/General/Dialog.ts new file mode 100644 index 0000000..b78740a --- /dev/null +++ b/src/components/General/Dialog.ts @@ -0,0 +1,165 @@ +import { LitElement, css, html } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; + +@customElement('arx-dialog') +export class EveDialog extends LitElement { + @property({ type: String }) override title = ''; + @property({ type: Boolean }) open = false; + @property({ type: Boolean }) closeOnOverlayClick = true; + @property({ type: Boolean }) closeOnEscape = true; + @property({ type: String }) width = '420px'; + @property({ type: String }) maxWidth = '90%'; + + @state() private _previousFocus: HTMLElement | null = null; + + static override styles = css` + :host { + display: block; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 999999; + } + + .overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: oklch(from var(--color-base-content) l c h / 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + backdrop-filter: blur(4px); + } + + .overlay.active { + opacity: 1; + pointer-events: all; + } + + .dialog-container { + background-color: var(--color-base-100); + border-radius: var(--radius-box); + border: var(--border) solid var(--color-base-300); + box-shadow: calc(var(--depth) * 4px) calc(var(--depth) * 4px) + calc(var(--depth) * 8px) + oklch(from var(--color-base-content) l c h / 0.2), + calc(var(--depth) * -1px) calc(var(--depth) * -1px) + calc(var(--depth) * 4px) oklch(from var(--color-base-100) l c h / 0.4); + width: var(--dialog-width, 420px); + max-width: var(--dialog-max-width, 90%); + padding: 28px; + transform: scale(0.95) translateY(10px); + transition: transform 0.25s cubic-bezier(0.1, 1, 0.2, 1); + color: var(--color-base-content); + } + + .overlay.active .dialog-container { + transform: scale(1) translateY(0); + } + + .dialog-header { + font-size: 18px; + font-weight: 600; + margin: 0 0 16px 0; + line-height: 1.4; + color: var(--color-base-content); + } + + .dialog-content { + margin-bottom: 24px; + } + + .dialog-footer { + display: flex; + justify-content: flex-end; + gap: 12px; + } + + .dialog-container:focus-within { + box-shadow: calc(var(--depth) * 4px) calc(var(--depth) * 4px) + calc(var(--depth) * 8px) + oklch(from var(--color-base-content) l c h / 0.2), + calc(var(--depth) * -1px) calc(var(--depth) * -1px) + calc(var(--depth) * 4px) oklch(from var(--color-base-100) l c h / 0.4), + 0 0 0 2px oklch(from var(--color-accent) l c h / 0.2); + } + `; + + constructor() { + super(); + this._handleKeyDown = this._handleKeyDown.bind(this); + } + + override connectedCallback() { + super.connectedCallback(); + document.addEventListener('keydown', this._handleKeyDown); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener('keydown', this._handleKeyDown); + } + + override updated(changedProps: Map) { + if (changedProps.has('open') && this.open) { + this._previousFocus = document.activeElement as HTMLElement; + // Focus the dialog container after rendering + setTimeout(() => { + const container = this.shadowRoot?.querySelector('.dialog-container') as HTMLElement; + if (container) container.focus(); + }, 50); + } else if (changedProps.has('open') && !this.open && this._previousFocus) { + this._previousFocus.focus(); + } + } + + private _handleKeyDown(e: KeyboardEvent) { + if (!this.open || !this.closeOnEscape) return; + if (e.key === 'Escape') this.close(); + } + + private _handleOverlayClick(e: MouseEvent) { + if (this.closeOnOverlayClick && e.target === e.currentTarget) { + this.close(); + } + } + + show() { + this.open = true; + } + + close() { + this.open = false; + this.dispatchEvent(new CustomEvent('close')); + } + + override render() { + return html` +
+
+ ${this.title ? html`
${this.title}
` : ''} +
+ +
+ +
+
+ `; + } +} diff --git a/src/components/General/Input.ts b/src/components/General/Input.ts index 3b20e82..52b19e1 100644 --- a/src/components/General/Input.ts +++ b/src/components/General/Input.ts @@ -13,15 +13,22 @@ export class StyledInput extends LitElement { @property() placeholder = ''; @property() value = ''; @property({ type: Boolean }) disabled = false; - @property() type: 'text' | 'number' | 'password' = 'text'; + @property() type: 'text' | 'number' | 'password' | 'date' | 'time' = 'text'; @property() name = ''; @property({ type: Boolean }) required = false; @property() label = ''; + @state() override lang = 'en-US'; @query('input') private _input!: HTMLInputElement; @state() private _value = ''; + override connectedCallback(): void { + super.connectedCallback(); + const dateTimeFormatOptions = JSON.parse(localStorage.getItem('dateTimeFormatOptions') || '{}'); + if (dateTimeFormatOptions?.locale) this.lang = dateTimeFormatOptions.locale; + } + protected override firstUpdated(_changedProperties: PropertyValues): void { this._value = this.value; } @@ -122,6 +129,7 @@ export class StyledInput extends LitElement { placeholder=${this.placeholder} type=${this.type} name=${this.name} + lang=${this.lang} @input=${this._handleInput} @focus=${this._handleFocus} @blur=${this._handleBlur} diff --git a/src/components/General/Prompt.ts b/src/components/General/Prompt.ts index 8bb5142..ac2fab1 100644 --- a/src/components/General/Prompt.ts +++ b/src/components/General/Prompt.ts @@ -1,8 +1,9 @@ import type { ArxInputChangeEvent } from '@components/General/Input'; import { LitElement, css, html } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; -import { classMap } from 'lit/directives/class-map.js'; +import { when } from 'lit/directives/when.js'; +import '@components/General/Dialog'; import '@components/General/Input'; @customElement('arx-prompt') @@ -16,122 +17,19 @@ export class EvePrompt extends LitElement { @property({ type: String }) defaultValue = ''; @state() private _inputValue = ''; - private _previousFocus: HTMLElement | null = null; static override styles = css` :host { display: block; } - - .overlay { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: oklch(from var(--color-base-content) l c h / 0.6); - display: flex; - align-items: center; - justify-content: center; - z-index: 9999; - opacity: 0; - pointer-events: none; - transition: opacity 0.2s ease; - backdrop-filter: blur(4px); - } - - .overlay.active { - opacity: 1; - pointer-events: all; - } - - .prompt-container { - background-color: var(--color-base-100); - border-radius: var(--radius-box); - border: var(--border) solid var(--color-base-300); - box-shadow: calc(var(--depth) * 4px) calc(var(--depth) * 4px) - calc(var(--depth) * 8px) - oklch(from var(--color-base-content) l c h / 0.2), - calc(var(--depth) * -1px) calc(var(--depth) * -1px) - calc(var(--depth) * 4px) oklch(from var(--color-base-100) l c h / 0.4); - width: 90%; - max-width: 420px; - padding: 28px; - transform: scale(0.95) translateY(10px); - transition: transform 0.25s cubic-bezier(0.1, 1, 0.2, 1); - color: var(--color-base-content); - } - - .overlay.active .prompt-container { - transform: scale(1) translateY(0); - } - - .prompt-header { - font-size: 18px; - font-weight: 600; - margin: 0 0 16px 0; - line-height: 1.4; - color: var(--color-base-content); - } - - .buttons { - display: flex; - justify-content: flex-end; - gap: 12px; - margin-top: 24px; - } - - .prompt-container:focus-within { - box-shadow: calc(var(--depth) * 4px) calc(var(--depth) * 4px) - calc(var(--depth) * 8px) - oklch(from var(--color-base-content) l c h / 0.2), - calc(var(--depth) * -1px) calc(var(--depth) * -1px) - calc(var(--depth) * 4px) oklch(from var(--color-base-100) l c h / 0.4), - 0 0 0 2px oklch(from var(--color-accent) l c h / 0.2); - } `; - constructor() { - super(); - this._handleKeyDown = this._handleKeyDown.bind(this); - } - - override connectedCallback() { - super.connectedCallback(); - document.addEventListener('keydown', this._handleKeyDown); - } - - override disconnectedCallback() { - super.disconnectedCallback(); - document.removeEventListener('keydown', this._handleKeyDown); - } - override updated(changedProps: Map) { if (changedProps.has('open') && this.open) { this._inputValue = this.defaultValue; - this._previousFocus = document.activeElement as HTMLElement; - - // Focus the input or save button after rendering - setTimeout(() => { - if (this.showInput) { - const input = this.shadowRoot?.querySelector('.input-field') as HTMLElement; - if (input) input.focus(); - } else { - const saveBtn = this.shadowRoot?.querySelector('.save-btn') as HTMLElement; - if (saveBtn) saveBtn.focus(); - } - }, 50); - } else if (changedProps.has('open') && !this.open && this._previousFocus) { - this._previousFocus.focus(); } } - private _handleKeyDown(e: KeyboardEvent) { - if (!this.open) return; - if (e.key === 'Escape') this._handleCancel(); - if (e.key === 'Enter' && !e.shiftKey) this._handleSave(); - } - private _handleInputChange(e: ArxInputChangeEvent) { this._inputValue = e.detail.value; } @@ -152,43 +50,37 @@ export class EvePrompt extends LitElement { override render() { return html` -
-
-
${this.promptText}
- - ${ - this.showInput - ? html` - - ` - : '' - } - -
- - - - -
+ ${when( + this.showInput, + () => html` + + `, + )} +
+ +
-
+ `; } diff --git a/src/components/General/Toggle.ts b/src/components/General/Toggle.ts index ed9fac5..e203e99 100644 --- a/src/components/General/Toggle.ts +++ b/src/components/General/Toggle.ts @@ -11,6 +11,11 @@ export class StyledToggle extends LitElement { @property({ type: Boolean }) required = false; @property() value = 'on'; + override connectedCallback() { + super.connectedCallback(); + if (!this.checked) this.value = 'off'; + } + static override styles = css` :host { display: inline-block; diff --git a/src/routes/Calendar.ts b/src/routes/Calendar.ts new file mode 100644 index 0000000..08bb3c6 --- /dev/null +++ b/src/routes/Calendar.ts @@ -0,0 +1,330 @@ +import type { CalendarEventData } from '@/components/Calendar/CalendarEvent'; +import { getSigner, ndk } from '@/ndk'; +import { NDKEvent, type NDKKind } from '@nostr-dev-kit/ndk'; +import { LitElement, html } from 'lit'; +import { customElement, state } from 'lit/decorators.js'; +import { when } from 'lit/directives/when.js'; + +import '@components/Calendar/CalendarEvent'; +import '@components/Calendar/CalendarEventDetailsDialog'; +import '@components/Calendar/CalendarEventDialog'; +import '@components/Calendar/CalendarHeader'; +import '@components/Calendar/CalendarMonthView'; +import '@components/Calendar/CalendarWeekView'; + +type CalendarView = 'month' | 'week'; + +interface NostrEvent { + id: string; + pubkey: string; + created_at: number | undefined; + kind: number; + content: string; + tags: string[][]; +} + +@customElement('arx-calendar-route') +export default class CalendarRoute extends LitElement { + @state() + private currentDate = new Date(); + + @state() + private selectedDate: Date | null = null; + + @state() + private view: CalendarView = 'month'; + + @state() + private locale = 'en-US'; + + @state() + private events: CalendarEventData[] = []; + + @state() + private showAddEventDialog = false; + + @state() + private selectedEvent: CalendarEventData | null = null; + + override connectedCallback() { + super.connectedCallback(); + this.loadLocale(); + this.loadEvents(); + } + + private loadLocale() { + try { + const options = localStorage.getItem('dateTimeFormatOptions'); + if (options) { + const { locale } = JSON.parse(options); + if (locale) { + this.locale = locale; + } + } + } catch { + // Do nothing + } + } + + private async loadEvents() { + await getSigner(); + try { + const dateEvents = await ndk.fetchEvents({ + kinds: [31922 as NDKKind], + }); + const timeEvents = await ndk.fetchEvents({ + kinds: [31923 as NDKKind], + }); + + const events = [ + ...Array.from(dateEvents).map((event) => this.parseDateEvent(event as unknown as NostrEvent)), + ...Array.from(timeEvents).map((event) => this.parseTimeEvent(event as unknown as NostrEvent)), + ]; + + this.events = events; + } catch (error) { + console.error('Error loading events:', error); + } + } + + private parseDateEvent(event: NostrEvent): CalendarEventData { + const startTag = event.tags.find((t) => t[0] === 'start'); + const titleTag = event.tags.find((t) => t[0] === 'title'); + if (!startTag || !titleTag) { + throw new Error('Invalid event: missing required tags'); + } + + const start = new Date(startTag[1]); + const endTag = event.tags.find((t) => t[0] === 'end'); + const end = endTag ? new Date(endTag[1]) : undefined; + + return { + id: event.id, + title: titleTag[1], + start, + end, + description: event.content, + location: event.tags.find((t) => t[0] === 'location')?.[1], + participants: event.tags.filter((t) => t[0] === 'p').map((t) => t[1]), + tags: event.tags.filter((t) => t[0] === 't').map((t) => t[1]), + type: 'date', + }; + } + + private parseTimeEvent(event: NostrEvent): CalendarEventData { + const startTag = event.tags.find((t) => t[0] === 'start'); + const titleTag = event.tags.find((t) => t[0] === 'title'); + if (!startTag || !titleTag) { + throw new Error('Invalid event: missing required tags'); + } + + const start = new Date(Number.parseInt(startTag[1]) * 1000); + const endTag = event.tags.find((t) => t[0] === 'end'); + const end = endTag ? new Date(Number.parseInt(endTag[1]) * 1000) : undefined; + + return { + id: event.id, + title: titleTag[1], + start, + end, + description: event.content, + location: event.tags.find((t) => t[0] === 'location')?.[1], + participants: event.tags.filter((t) => t[0] === 'p').map((t) => t[1]), + tags: event.tags.filter((t) => t[0] === 't').map((t) => t[1]), + type: 'time', + }; + } + + private handlePrev() { + if (this.view === 'month') { + const newDate = new Date(this.currentDate); + newDate.setMonth(newDate.getMonth() - 1); + if (newDate.getMonth() === this.currentDate.getMonth()) newDate.setDate(0); + this.currentDate = newDate; + } else { + const newDate = new Date(this.currentDate); + newDate.setDate(newDate.getDate() - 7); + this.currentDate = newDate; + } + } + + private handleNext() { + if (this.view === 'month') { + const newDate = new Date(this.currentDate); + newDate.setMonth(newDate.getMonth() + 1); + if (newDate.getMonth() === this.currentDate.getMonth()) newDate.setDate(32); + this.currentDate = newDate; + } else { + const newDate = new Date(this.currentDate); + newDate.setDate(newDate.getDate() + 7); + this.currentDate = newDate; + } + } + + private handleToday() { + this.currentDate = new Date(); + } + + private handleViewChange(e: CustomEvent) { + this.view = e.detail.view; + } + + private handleAddEvent() { + this.showAddEventDialog = true; + } + + private handleCloseDialog() { + this.showAddEventDialog = false; + } + + private handleDateClick(e: CustomEvent) { + this.selectedDate = e.detail.date; + } + + private handleEventClick(e: CustomEvent) { + this.selectedEvent = e.detail.event; + } + + private handleCloseEventDetails() { + this.selectedEvent = null; + } + + private async handleSubmit(e: CustomEvent) { + await getSigner(); + try { + const event = new NDKEvent(ndk); + const { allDay, ...newEvent } = e.detail.event; + event.kind = allDay ? 31922 : 31923; + event.content = newEvent.description; + + const startDate = new Date(newEvent.startDate); + if (allDay) { + startDate.setHours(0, 0, 0, 0); + } else if (newEvent.startTime) { + const [hours, minutes] = newEvent.startTime.split(':'); + startDate.setHours(Number.parseInt(hours), Number.parseInt(minutes)); + } + + const tags = [ + ['title', newEvent.title], + ['start', allDay ? startDate.toISOString().split('T')[0] : Math.floor(startDate.getTime() / 1000).toString()], + ]; + + if (newEvent.endDate) { + const endDate = new Date(newEvent.endDate); + if (allDay) { + endDate.setHours(0, 0, 0, 0); + } else if (newEvent.endTime) { + const [hours, minutes] = newEvent.endTime.split(':'); + endDate.setHours(Number.parseInt(hours), Number.parseInt(minutes)); + } + tags.push([ + 'end', + allDay ? endDate.toISOString().split('T')[0] : Math.floor(endDate.getTime() / 1000).toString(), + ]); + } + + if (newEvent.location) { + tags.push(['location', newEvent.location]); + } + + for (const participant of newEvent.participants) { + tags.push(['p', participant]); + } + + for (const tag of newEvent.tags) { + tags.push(['t', tag]); + } + + event.tags = tags; + await event.sign(); + await event.publish(); + + this.handleCloseDialog(); + await this.loadEvents(); + } catch (error) { + console.error('Error creating event:', error); + } + } + + private formatDateRange(): string { + if (this.view === 'month') { + return this.currentDate.toLocaleDateString(this.locale, { month: 'long', year: 'numeric' }); + } + + const weekDays = this.getWeekDays(); + const firstDay = weekDays[0]; + const lastDay = weekDays[6]; + + if (firstDay.getMonth() === lastDay.getMonth()) + return `${firstDay.toLocaleDateString(this.locale, { month: 'long', day: 'numeric' })} - ${lastDay.toLocaleDateString(this.locale, { day: 'numeric' })}, ${firstDay.getFullYear()}`; + + return `${firstDay.toLocaleDateString(this.locale, { month: 'long', day: 'numeric' })} - ${lastDay.toLocaleDateString(this.locale, { month: 'long', day: 'numeric' })}, ${firstDay.getFullYear()}`; + } + + private getWeekDays(): Date[] { + const days: Date[] = []; + const currentDay = this.currentDate.getDay(); + const startOfWeek = new Date(this.currentDate); + startOfWeek.setDate(this.currentDate.getDate() - currentDay); + + for (let i = 0; i < 7; i++) { + const date = new Date(startOfWeek); + date.setDate(startOfWeek.getDate() + i); + days.push(date); + } + + return days; + } + + override render() { + return html` + + + ${when( + this.view === 'month', + () => html` + + `, + () => html` + + `, + )} + + + + + `; + } +} diff --git a/src/routes/router.ts b/src/routes/router.ts index ab7a796..7e93762 100644 --- a/src/routes/router.ts +++ b/src/routes/router.ts @@ -4,6 +4,7 @@ import '@routes/Arbor/Home'; import '@routes/Arbor/NewPost'; import '@routes/Arbor/NewTopic'; import '@routes/Arbor/TopicView'; +import '@routes/Calendar'; import '@routes/Home'; import '@routes/Profile'; import '@routes/Settings'; @@ -36,6 +37,11 @@ export default class EveRouter extends LitElement { params: {}, component: literal`arx-eve-home`, }, + { + pattern: 'calendar', + params: {}, + component: literal`arx-calendar-route`, + }, { pattern: 'profile/:npub', params: {},