📅 Add calendar functionality
This commit is contained in:
parent
6434102635
commit
a5348a3c62
13 changed files with 1328 additions and 142 deletions
70
src/components/Calendar/CalendarEvent.ts
Normal file
70
src/components/Calendar/CalendarEvent.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
88
src/components/Calendar/CalendarEventDetailsDialog.ts
Normal file
88
src/components/Calendar/CalendarEventDetailsDialog.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
169
src/components/Calendar/CalendarEventDialog.ts
Normal file
169
src/components/Calendar/CalendarEventDialog.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
126
src/components/Calendar/CalendarHeader.ts
Normal file
126
src/components/Calendar/CalendarHeader.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
212
src/components/Calendar/CalendarMonthView.ts
Normal file
212
src/components/Calendar/CalendarMonthView.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
114
src/components/Calendar/CalendarWeekView.ts
Normal file
114
src/components/Calendar/CalendarWeekView.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
165
src/components/General/Dialog.ts
Normal file
165
src/components/General/Dialog.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
330
src/routes/Calendar.ts
Normal 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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: {},
|
||||||
|
|
Loading…
Add table
Reference in a new issue