📅 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) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (this.disabled || this.loading) {
|
||||
return;
|
||||
}
|
||||
|
@ -207,8 +208,8 @@ export class StyledButton extends LitElement {
|
|||
name: this.name,
|
||||
value: this.value,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
bubbles: false,
|
||||
composed: false,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
|
|
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() value = '';
|
||||
@property({ type: Boolean }) disabled = false;
|
||||
@property() type: 'text' | 'number' | 'password' = 'text';
|
||||
@property() type: 'text' | 'number' | 'password' | 'date' | 'time' = 'text';
|
||||
@property() name = '';
|
||||
@property({ type: Boolean }) required = false;
|
||||
@property() label = '';
|
||||
@state() override lang = 'en-US';
|
||||
|
||||
@query('input') private _input!: HTMLInputElement;
|
||||
|
||||
@state() private _value = '';
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
const dateTimeFormatOptions = JSON.parse(localStorage.getItem('dateTimeFormatOptions') || '{}');
|
||||
if (dateTimeFormatOptions?.locale) this.lang = dateTimeFormatOptions.locale;
|
||||
}
|
||||
|
||||
protected override firstUpdated(_changedProperties: PropertyValues): void {
|
||||
this._value = this.value;
|
||||
}
|
||||
|
@ -122,6 +129,7 @@ export class StyledInput extends LitElement {
|
|||
placeholder=${this.placeholder}
|
||||
type=${this.type}
|
||||
name=${this.name}
|
||||
lang=${this.lang}
|
||||
@input=${this._handleInput}
|
||||
@focus=${this._handleFocus}
|
||||
@blur=${this._handleBlur}
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import type { ArxInputChangeEvent } from '@components/General/Input';
|
||||
import { LitElement, css, html } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { when } from 'lit/directives/when.js';
|
||||
|
||||
import '@components/General/Dialog';
|
||||
import '@components/General/Input';
|
||||
|
||||
@customElement('arx-prompt')
|
||||
|
@ -16,122 +17,19 @@ export class EvePrompt extends LitElement {
|
|||
@property({ type: String }) defaultValue = '';
|
||||
|
||||
@state() private _inputValue = '';
|
||||
private _previousFocus: HTMLElement | null = null;
|
||||
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: oklch(from var(--color-base-content) l c h / 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.overlay.active {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.prompt-container {
|
||||
background-color: var(--color-base-100);
|
||||
border-radius: var(--radius-box);
|
||||
border: var(--border) solid var(--color-base-300);
|
||||
box-shadow: calc(var(--depth) * 4px) calc(var(--depth) * 4px)
|
||||
calc(var(--depth) * 8px)
|
||||
oklch(from var(--color-base-content) l c h / 0.2),
|
||||
calc(var(--depth) * -1px) calc(var(--depth) * -1px)
|
||||
calc(var(--depth) * 4px) oklch(from var(--color-base-100) l c h / 0.4);
|
||||
width: 90%;
|
||||
max-width: 420px;
|
||||
padding: 28px;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
transition: transform 0.25s cubic-bezier(0.1, 1, 0.2, 1);
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
.overlay.active .prompt-container {
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
|
||||
.prompt-header {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 1.4;
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.prompt-container:focus-within {
|
||||
box-shadow: calc(var(--depth) * 4px) calc(var(--depth) * 4px)
|
||||
calc(var(--depth) * 8px)
|
||||
oklch(from var(--color-base-content) l c h / 0.2),
|
||||
calc(var(--depth) * -1px) calc(var(--depth) * -1px)
|
||||
calc(var(--depth) * 4px) oklch(from var(--color-base-100) l c h / 0.4),
|
||||
0 0 0 2px oklch(from var(--color-accent) l c h / 0.2);
|
||||
}
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._handleKeyDown = this._handleKeyDown.bind(this);
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener('keydown', this._handleKeyDown);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('keydown', this._handleKeyDown);
|
||||
}
|
||||
|
||||
override updated(changedProps: Map<string, unknown>) {
|
||||
if (changedProps.has('open') && this.open) {
|
||||
this._inputValue = this.defaultValue;
|
||||
this._previousFocus = document.activeElement as HTMLElement;
|
||||
|
||||
// Focus the input or save button after rendering
|
||||
setTimeout(() => {
|
||||
if (this.showInput) {
|
||||
const input = this.shadowRoot?.querySelector('.input-field') as HTMLElement;
|
||||
if (input) input.focus();
|
||||
} else {
|
||||
const saveBtn = this.shadowRoot?.querySelector('.save-btn') as HTMLElement;
|
||||
if (saveBtn) saveBtn.focus();
|
||||
}
|
||||
}, 50);
|
||||
} else if (changedProps.has('open') && !this.open && this._previousFocus) {
|
||||
this._previousFocus.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private _handleKeyDown(e: KeyboardEvent) {
|
||||
if (!this.open) return;
|
||||
if (e.key === 'Escape') this._handleCancel();
|
||||
if (e.key === 'Enter' && !e.shiftKey) this._handleSave();
|
||||
}
|
||||
|
||||
private _handleInputChange(e: ArxInputChangeEvent) {
|
||||
this._inputValue = e.detail.value;
|
||||
}
|
||||
|
@ -152,43 +50,37 @@ export class EvePrompt extends LitElement {
|
|||
|
||||
override render() {
|
||||
return html`
|
||||
<div
|
||||
class="${classMap({ overlay: true, active: this.open })}"
|
||||
@click="${(e: MouseEvent) => e.target === e.currentTarget && this._handleCancel()}"
|
||||
<arx-dialog
|
||||
.title=${this.promptText}
|
||||
.open=${this.open}
|
||||
.closeOnOverlayClick=${false}
|
||||
.closeOnEscape=${false}
|
||||
>
|
||||
<div class="prompt-container">
|
||||
<div class="prompt-header">${this.promptText}</div>
|
||||
|
||||
${
|
||||
this.showInput
|
||||
? html`
|
||||
<arx-input
|
||||
type="text"
|
||||
class="input-field"
|
||||
.value=${this._inputValue}
|
||||
@change=${this._handleInputChange}
|
||||
placeholder=${this.placeholder}
|
||||
></arx-input>
|
||||
`
|
||||
: ''
|
||||
}
|
||||
|
||||
<div class="buttons">
|
||||
<arx-button
|
||||
variant="accent"
|
||||
label=${this.cancelText}
|
||||
@click=${this._handleCancel}
|
||||
>
|
||||
</arx-button>
|
||||
<arx-button
|
||||
variant="secondary"
|
||||
label=${this.saveText}
|
||||
@click=${this._handleSave}
|
||||
>
|
||||
</arx-button>
|
||||
</div>
|
||||
${when(
|
||||
this.showInput,
|
||||
() => html`
|
||||
<arx-input
|
||||
type="text"
|
||||
class="input-field"
|
||||
.value=${this._inputValue}
|
||||
@change=${this._handleInputChange}
|
||||
placeholder=${this.placeholder}
|
||||
></arx-input>
|
||||
`,
|
||||
)}
|
||||
<div slot="footer">
|
||||
<arx-button
|
||||
variant="accent"
|
||||
label=${this.cancelText}
|
||||
@click=${this._handleCancel}
|
||||
></arx-button>
|
||||
<arx-button
|
||||
variant="secondary"
|
||||
label=${this.saveText}
|
||||
@click=${this._handleSave}
|
||||
></arx-button>
|
||||
</div>
|
||||
</div>
|
||||
</arx-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
|
|
|
@ -11,6 +11,11 @@ export class StyledToggle extends LitElement {
|
|||
@property({ type: Boolean }) required = false;
|
||||
@property() value = 'on';
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (!this.checked) this.value = 'off';
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
|
|
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/NewTopic';
|
||||
import '@routes/Arbor/TopicView';
|
||||
import '@routes/Calendar';
|
||||
import '@routes/Home';
|
||||
import '@routes/Profile';
|
||||
import '@routes/Settings';
|
||||
|
@ -36,6 +37,11 @@ export default class EveRouter extends LitElement {
|
|||
params: {},
|
||||
component: literal`arx-eve-home`,
|
||||
},
|
||||
{
|
||||
pattern: 'calendar',
|
||||
params: {},
|
||||
component: literal`arx-calendar-route`,
|
||||
},
|
||||
{
|
||||
pattern: 'profile/:npub',
|
||||
params: {},
|
||||
|
|
Loading…
Add table
Reference in a new issue