📅 Add calendar functionality

This commit is contained in:
Danny Morabito 2025-04-03 17:05:37 +02:00
parent 6434102635
commit a5348a3c62
Signed by: dannym
GPG key ID: 7CC8056A5A04557E
13 changed files with 1328 additions and 142 deletions

View file

@ -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`
<div class="calendar-event" @click=${this.handleClick}>
${this.event.title}
${when(
this.event.type === 'time',
() => html`
<div class="event-time">
${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' })}`,
)}
</div>
`,
)}
</div>
`;
}
}

View file

@ -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`
<arx-dialog
.open=${this.open}
@close=${this.handleClose}
>
<arx-fieldset .legend=${this.event.title}>
<div class="event-detail">
<span class="event-detail-label">Start:</span>
<span class="event-detail-value">${formatDateTime(this.event.start)}</span>
</div>
${when(
end instanceof Date,
() => html`
<div class="event-detail">
<span class="event-detail-label">End:</span>
<span class="event-detail-value">${formatDateTime(end!)}</span>
</div>
`,
)}
${when(
location,
() => html`
<div class="event-detail">
<span class="event-detail-label">Location:</span>
<span class="event-detail-value">${location}</span>
</div>
`,
)}
${when(
description,
() => html`
<div class="event-detail">
<span class="event-detail-label">Description:</span>
<span class="event-detail-value">${description}</span>
</div>
`,
)}
</arx-fieldset>
</arx-dialog>
`;
}
}

View file

@ -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`
<arx-dialog width="500px" title="Add Event" .open=${this.open}>
<arx-fieldset legend="Title">
<arx-input
type="text"
name="title"
.value=${this.newEvent.title}
@change=${this.handleInputChange}
required
></arx-input>
</arx-fieldset>
<arx-fieldset legend="Description">
<arx-textarea
name="description"
.value=${this.newEvent.description}
@change=${this.handleInputChange}
></arx-textarea>
</arx-fieldset>
<arx-fieldset legend="Location">
<arx-input
type="text"
name="location"
.value=${this.newEvent.location}
@change=${this.handleInputChange}
></arx-input>
</arx-fieldset>
<arx-fieldset>
<arx-toggle
label="All-day event"
name="allDay"
.checked=${this.newEvent.allDay}
@change=${this.handleInputChange}
></arx-toggle>
</arx-fieldset>
<div class="form-row">
<arx-fieldset legend="Start Date">
<arx-input
type="date"
name="startDate"
.value=${this.newEvent.startDate}
@change=${this.handleInputChange}
required
></arx-input>
</arx-fieldset>
${when(
!this.newEvent.allDay,
() => html`
<arx-fieldset legend="Start Time">
<arx-input
type="time"
name="startTime"
.value=${this.newEvent.startTime}
@change=${this.handleInputChange}
required
></arx-input>
</arx-fieldset>
`,
)}
</div>
<div class="form-row">
<arx-fieldset legend="End Date">
<arx-input
type="date"
name="endDate"
.value=${this.newEvent.endDate}
@change=${this.handleInputChange}
></arx-input>
</arx-fieldset>
${when(
!this.newEvent.allDay,
() => html`
<arx-fieldset legend="End Time">
<arx-input
type="time"
name="endTime"
.value=${this.newEvent.endTime}
@change=${this.handleInputChange}
?required=${this.newEvent.endDate !== ''}
></arx-input>
</arx-fieldset>
`,
)}
</div>
<div class="dialog-footer">
<arx-button variant="secondary" @click=${this.handleClose} label="Cancel"></arx-button>
<arx-button variant="primary" @click=${this.handleSubmit} type="submit" label="Create Event"></arx-button>
</div>
</arx-dialog>
`;
}
}

View file

@ -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`
<div class="calendar-header">
<div class="calendar-title-row">
<arx-button
variant="secondary"
@click=${this.handlePrev}
label="Previous"
></arx-button>
<h2 class="calendar-title">${this.dateRange}</h2>
<arx-button
variant="secondary"
@click=${this.handleNext}
label="Next"
></arx-button>
</div>
<div class="calendar-controls">
<div class="calendar-view-toggle">
${when(
!this.isToday,
() => html`
<arx-button
variant="primary"
@click=${this.handleToday}
label="Today"
></arx-button>
`,
)}
<arx-button
variant="primary"
@click=${this.handleAddEvent}
label="Add Event"
>
<iconify-icon icon="mdi:calendar-plus" slot="prefix"></iconify-icon>
</arx-button>
<arx-toggle
label="Week View"
.checked=${this.view === 'week'}
@change=${this.handleViewChange}
></arx-toggle>
</div>
</div>
</div>
`;
}
}

View file

@ -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`
<div class="calendar-grid">
${dayNames.map((day) => html`<div class="calendar-day-header">${day}</div>`)}
${this.getCalendarDays().map(
(date) => html`
<div
class="calendar-day ${this.isToday(date) ? 'today' : ''} ${
this.isSelected(date) ? 'selected' : ''
} ${this.isOtherMonth(date) ? 'other-month' : ''}"
@click=${() => this.handleDateClick(date)}
>
<div class="calendar-day-number">${date.getDate()}</div>
<div class="calendar-day-events">
${this.getEventsForDate(date).map(
(event) => html`
<arx-calendar-event
.event=${event}
.locale=${this.locale}
@event-click=${(e: CustomEvent) => this.handleEventClick(e.detail.event)}
></arx-calendar-event>
`,
)}
</div>
</div>
`,
)}
</div>
`;
}
}

View file

@ -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`
<div class="week-view">
${this.getWeekDays().map(
(date) => html`
<div class="week-day">
<div class="week-day-header">
${date.toLocaleDateString(this.locale, { weekday: 'long' })}
<br />
${date.toLocaleDateString(this.locale, { month: 'short', day: 'numeric' })}
</div>
<div class="week-day-content">
${this.getEventsForDate(date).map(
(event) => html`
<arx-calendar-event
.event=${event}
.locale=${this.locale}
@event-click=${(e: CustomEvent) => this.handleEventClick(e.detail.event)}
></arx-calendar-event>
`,
)}
</div>
</div>
`,
)}
</div>
`;
}
}

View file

@ -187,6 +187,7 @@ export class StyledButton extends LitElement {
private _handleClick(e: MouseEvent) { private _handleClick(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
if (this.disabled || this.loading) { if (this.disabled || this.loading) {
return; return;
} }
@ -207,8 +208,8 @@ export class StyledButton extends LitElement {
name: this.name, name: this.name,
value: this.value, value: this.value,
}, },
bubbles: true, bubbles: false,
composed: true, composed: false,
}), }),
); );
return; return;

View file

@ -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<string, unknown>) {
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`
<div
class="${classMap({ overlay: true, active: this.open })}"
@click=${this._handleOverlayClick}
style="--dialog-width: ${this.width}; --dialog-max-width: ${this.maxWidth};"
>
<div class="dialog-container" tabindex="-1">
${this.title ? html`<div class="dialog-header">${this.title}</div>` : ''}
<div class="dialog-content">
<slot></slot>
</div>
<div class="dialog-footer">
<slot name="footer"></slot>
</div>
</div>
</div>
`;
}
}

View file

@ -13,15 +13,22 @@ export class StyledInput extends LitElement {
@property() placeholder = ''; @property() placeholder = '';
@property() value = ''; @property() value = '';
@property({ type: Boolean }) disabled = false; @property({ type: Boolean }) disabled = false;
@property() type: 'text' | 'number' | 'password' = 'text'; @property() type: 'text' | 'number' | 'password' | 'date' | 'time' = 'text';
@property() name = ''; @property() name = '';
@property({ type: Boolean }) required = false; @property({ type: Boolean }) required = false;
@property() label = ''; @property() label = '';
@state() override lang = 'en-US';
@query('input') private _input!: HTMLInputElement; @query('input') private _input!: HTMLInputElement;
@state() private _value = ''; @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 { protected override firstUpdated(_changedProperties: PropertyValues): void {
this._value = this.value; this._value = this.value;
} }
@ -122,6 +129,7 @@ export class StyledInput extends LitElement {
placeholder=${this.placeholder} placeholder=${this.placeholder}
type=${this.type} type=${this.type}
name=${this.name} name=${this.name}
lang=${this.lang}
@input=${this._handleInput} @input=${this._handleInput}
@focus=${this._handleFocus} @focus=${this._handleFocus}
@blur=${this._handleBlur} @blur=${this._handleBlur}

View file

@ -1,8 +1,9 @@
import type { ArxInputChangeEvent } from '@components/General/Input'; import type { ArxInputChangeEvent } from '@components/General/Input';
import { LitElement, css, html } from 'lit'; import { LitElement, css, html } from 'lit';
import { customElement, property, state } from 'lit/decorators.js'; 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'; import '@components/General/Input';
@customElement('arx-prompt') @customElement('arx-prompt')
@ -16,122 +17,19 @@ export class EvePrompt extends LitElement {
@property({ type: String }) defaultValue = ''; @property({ type: String }) defaultValue = '';
@state() private _inputValue = ''; @state() private _inputValue = '';
private _previousFocus: HTMLElement | null = null;
static override styles = css` static override styles = css`
:host { :host {
display: block; 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<string, unknown>) { override updated(changedProps: Map<string, unknown>) {
if (changedProps.has('open') && this.open) { if (changedProps.has('open') && this.open) {
this._inputValue = this.defaultValue; 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) { private _handleInputChange(e: ArxInputChangeEvent) {
this._inputValue = e.detail.value; this._inputValue = e.detail.value;
} }
@ -152,43 +50,37 @@ export class EvePrompt extends LitElement {
override render() { override render() {
return html` return html`
<div <arx-dialog
class="${classMap({ overlay: true, active: this.open })}" .title=${this.promptText}
@click="${(e: MouseEvent) => e.target === e.currentTarget && this._handleCancel()}" .open=${this.open}
.closeOnOverlayClick=${false}
.closeOnEscape=${false}
> >
<div class="prompt-container"> ${when(
<div class="prompt-header">${this.promptText}</div> this.showInput,
() => html`
${ <arx-input
this.showInput type="text"
? html` class="input-field"
<arx-input .value=${this._inputValue}
type="text" @change=${this._handleInputChange}
class="input-field" placeholder=${this.placeholder}
.value=${this._inputValue} ></arx-input>
@change=${this._handleInputChange} `,
placeholder=${this.placeholder} )}
></arx-input> <div slot="footer">
` <arx-button
: '' variant="accent"
} label=${this.cancelText}
@click=${this._handleCancel}
<div class="buttons"> ></arx-button>
<arx-button <arx-button
variant="accent" variant="secondary"
label=${this.cancelText} label=${this.saveText}
@click=${this._handleCancel} @click=${this._handleSave}
> ></arx-button>
</arx-button>
<arx-button
variant="secondary"
label=${this.saveText}
@click=${this._handleSave}
>
</arx-button>
</div>
</div> </div>
</div> </arx-dialog>
`; `;
} }

View file

@ -11,6 +11,11 @@ export class StyledToggle extends LitElement {
@property({ type: Boolean }) required = false; @property({ type: Boolean }) required = false;
@property() value = 'on'; @property() value = 'on';
override connectedCallback() {
super.connectedCallback();
if (!this.checked) this.value = 'off';
}
static override styles = css` static override styles = css`
:host { :host {
display: inline-block; display: inline-block;

330
src/routes/Calendar.ts Normal file
View file

@ -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`
<arx-calendar-header
.view=${this.view}
.currentDate=${this.currentDate}
.isToday=${this.currentDate.toDateString() === new Date().toDateString()}
.dateRange=${this.formatDateRange()}
@prev=${this.handlePrev}
@next=${this.handleNext}
@today=${this.handleToday}
@add-event=${this.handleAddEvent}
@view-change=${this.handleViewChange}
></arx-calendar-header>
${when(
this.view === 'month',
() => html`
<arx-calendar-month-view
.currentDate=${this.currentDate}
.selectedDate=${this.selectedDate}
.events=${this.events}
.locale=${this.locale}
@date-click=${this.handleDateClick}
@event-click=${this.handleEventClick}
></arx-calendar-month-view>
`,
() => html`
<arx-calendar-week-view
.currentDate=${this.currentDate}
.events=${this.events}
.locale=${this.locale}
@event-click=${this.handleEventClick}
></arx-calendar-week-view>
`,
)}
<arx-calendar-event-dialog
.open=${this.showAddEventDialog}
@close=${this.handleCloseDialog}
@submit=${this.handleSubmit}
></arx-calendar-event-dialog>
<arx-calendar-event-details-dialog
.event=${this.selectedEvent}
.open=${!!this.selectedEvent}
.locale=${this.locale}
@close=${this.handleCloseEventDetails}
></arx-calendar-event-details-dialog>
`;
}
}

View file

@ -4,6 +4,7 @@ import '@routes/Arbor/Home';
import '@routes/Arbor/NewPost'; import '@routes/Arbor/NewPost';
import '@routes/Arbor/NewTopic'; import '@routes/Arbor/NewTopic';
import '@routes/Arbor/TopicView'; import '@routes/Arbor/TopicView';
import '@routes/Calendar';
import '@routes/Home'; import '@routes/Home';
import '@routes/Profile'; import '@routes/Profile';
import '@routes/Settings'; import '@routes/Settings';
@ -36,6 +37,11 @@ export default class EveRouter extends LitElement {
params: {}, params: {},
component: literal`arx-eve-home`, component: literal`arx-eve-home`,
}, },
{
pattern: 'calendar',
params: {},
component: literal`arx-calendar-route`,
},
{ {
pattern: 'profile/:npub', pattern: 'profile/:npub',
params: {}, params: {},