📅 Add calendar functionality

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

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

@ -0,0 +1,330 @@
import type { CalendarEventData } from '@/components/Calendar/CalendarEvent';
import { getSigner, ndk } from '@/ndk';
import { NDKEvent, type NDKKind } from '@nostr-dev-kit/ndk';
import { LitElement, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { when } from 'lit/directives/when.js';
import '@components/Calendar/CalendarEvent';
import '@components/Calendar/CalendarEventDetailsDialog';
import '@components/Calendar/CalendarEventDialog';
import '@components/Calendar/CalendarHeader';
import '@components/Calendar/CalendarMonthView';
import '@components/Calendar/CalendarWeekView';
type CalendarView = 'month' | 'week';
interface NostrEvent {
id: string;
pubkey: string;
created_at: number | undefined;
kind: number;
content: string;
tags: string[][];
}
@customElement('arx-calendar-route')
export default class CalendarRoute extends LitElement {
@state()
private currentDate = new Date();
@state()
private selectedDate: Date | null = null;
@state()
private view: CalendarView = 'month';
@state()
private locale = 'en-US';
@state()
private events: CalendarEventData[] = [];
@state()
private showAddEventDialog = false;
@state()
private selectedEvent: CalendarEventData | null = null;
override connectedCallback() {
super.connectedCallback();
this.loadLocale();
this.loadEvents();
}
private loadLocale() {
try {
const options = localStorage.getItem('dateTimeFormatOptions');
if (options) {
const { locale } = JSON.parse(options);
if (locale) {
this.locale = locale;
}
}
} catch {
// Do nothing
}
}
private async loadEvents() {
await getSigner();
try {
const dateEvents = await ndk.fetchEvents({
kinds: [31922 as NDKKind],
});
const timeEvents = await ndk.fetchEvents({
kinds: [31923 as NDKKind],
});
const events = [
...Array.from(dateEvents).map((event) => this.parseDateEvent(event as unknown as NostrEvent)),
...Array.from(timeEvents).map((event) => this.parseTimeEvent(event as unknown as NostrEvent)),
];
this.events = events;
} catch (error) {
console.error('Error loading events:', error);
}
}
private parseDateEvent(event: NostrEvent): CalendarEventData {
const startTag = event.tags.find((t) => t[0] === 'start');
const titleTag = event.tags.find((t) => t[0] === 'title');
if (!startTag || !titleTag) {
throw new Error('Invalid event: missing required tags');
}
const start = new Date(startTag[1]);
const endTag = event.tags.find((t) => t[0] === 'end');
const end = endTag ? new Date(endTag[1]) : undefined;
return {
id: event.id,
title: titleTag[1],
start,
end,
description: event.content,
location: event.tags.find((t) => t[0] === 'location')?.[1],
participants: event.tags.filter((t) => t[0] === 'p').map((t) => t[1]),
tags: event.tags.filter((t) => t[0] === 't').map((t) => t[1]),
type: 'date',
};
}
private parseTimeEvent(event: NostrEvent): CalendarEventData {
const startTag = event.tags.find((t) => t[0] === 'start');
const titleTag = event.tags.find((t) => t[0] === 'title');
if (!startTag || !titleTag) {
throw new Error('Invalid event: missing required tags');
}
const start = new Date(Number.parseInt(startTag[1]) * 1000);
const endTag = event.tags.find((t) => t[0] === 'end');
const end = endTag ? new Date(Number.parseInt(endTag[1]) * 1000) : undefined;
return {
id: event.id,
title: titleTag[1],
start,
end,
description: event.content,
location: event.tags.find((t) => t[0] === 'location')?.[1],
participants: event.tags.filter((t) => t[0] === 'p').map((t) => t[1]),
tags: event.tags.filter((t) => t[0] === 't').map((t) => t[1]),
type: 'time',
};
}
private handlePrev() {
if (this.view === 'month') {
const newDate = new Date(this.currentDate);
newDate.setMonth(newDate.getMonth() - 1);
if (newDate.getMonth() === this.currentDate.getMonth()) newDate.setDate(0);
this.currentDate = newDate;
} else {
const newDate = new Date(this.currentDate);
newDate.setDate(newDate.getDate() - 7);
this.currentDate = newDate;
}
}
private handleNext() {
if (this.view === 'month') {
const newDate = new Date(this.currentDate);
newDate.setMonth(newDate.getMonth() + 1);
if (newDate.getMonth() === this.currentDate.getMonth()) newDate.setDate(32);
this.currentDate = newDate;
} else {
const newDate = new Date(this.currentDate);
newDate.setDate(newDate.getDate() + 7);
this.currentDate = newDate;
}
}
private handleToday() {
this.currentDate = new Date();
}
private handleViewChange(e: CustomEvent) {
this.view = e.detail.view;
}
private handleAddEvent() {
this.showAddEventDialog = true;
}
private handleCloseDialog() {
this.showAddEventDialog = false;
}
private handleDateClick(e: CustomEvent) {
this.selectedDate = e.detail.date;
}
private handleEventClick(e: CustomEvent) {
this.selectedEvent = e.detail.event;
}
private handleCloseEventDetails() {
this.selectedEvent = null;
}
private async handleSubmit(e: CustomEvent) {
await getSigner();
try {
const event = new NDKEvent(ndk);
const { allDay, ...newEvent } = e.detail.event;
event.kind = allDay ? 31922 : 31923;
event.content = newEvent.description;
const startDate = new Date(newEvent.startDate);
if (allDay) {
startDate.setHours(0, 0, 0, 0);
} else if (newEvent.startTime) {
const [hours, minutes] = newEvent.startTime.split(':');
startDate.setHours(Number.parseInt(hours), Number.parseInt(minutes));
}
const tags = [
['title', newEvent.title],
['start', allDay ? startDate.toISOString().split('T')[0] : Math.floor(startDate.getTime() / 1000).toString()],
];
if (newEvent.endDate) {
const endDate = new Date(newEvent.endDate);
if (allDay) {
endDate.setHours(0, 0, 0, 0);
} else if (newEvent.endTime) {
const [hours, minutes] = newEvent.endTime.split(':');
endDate.setHours(Number.parseInt(hours), Number.parseInt(minutes));
}
tags.push([
'end',
allDay ? endDate.toISOString().split('T')[0] : Math.floor(endDate.getTime() / 1000).toString(),
]);
}
if (newEvent.location) {
tags.push(['location', newEvent.location]);
}
for (const participant of newEvent.participants) {
tags.push(['p', participant]);
}
for (const tag of newEvent.tags) {
tags.push(['t', tag]);
}
event.tags = tags;
await event.sign();
await event.publish();
this.handleCloseDialog();
await this.loadEvents();
} catch (error) {
console.error('Error creating event:', error);
}
}
private formatDateRange(): string {
if (this.view === 'month') {
return this.currentDate.toLocaleDateString(this.locale, { month: 'long', year: 'numeric' });
}
const weekDays = this.getWeekDays();
const firstDay = weekDays[0];
const lastDay = weekDays[6];
if (firstDay.getMonth() === lastDay.getMonth())
return `${firstDay.toLocaleDateString(this.locale, { month: 'long', day: 'numeric' })} - ${lastDay.toLocaleDateString(this.locale, { day: 'numeric' })}, ${firstDay.getFullYear()}`;
return `${firstDay.toLocaleDateString(this.locale, { month: 'long', day: 'numeric' })} - ${lastDay.toLocaleDateString(this.locale, { month: 'long', day: 'numeric' })}, ${firstDay.getFullYear()}`;
}
private getWeekDays(): Date[] {
const days: Date[] = [];
const currentDay = this.currentDate.getDay();
const startOfWeek = new Date(this.currentDate);
startOfWeek.setDate(this.currentDate.getDate() - currentDay);
for (let i = 0; i < 7; i++) {
const date = new Date(startOfWeek);
date.setDate(startOfWeek.getDate() + i);
days.push(date);
}
return days;
}
override render() {
return html`
<arx-calendar-header
.view=${this.view}
.currentDate=${this.currentDate}
.isToday=${this.currentDate.toDateString() === new Date().toDateString()}
.dateRange=${this.formatDateRange()}
@prev=${this.handlePrev}
@next=${this.handleNext}
@today=${this.handleToday}
@add-event=${this.handleAddEvent}
@view-change=${this.handleViewChange}
></arx-calendar-header>
${when(
this.view === 'month',
() => html`
<arx-calendar-month-view
.currentDate=${this.currentDate}
.selectedDate=${this.selectedDate}
.events=${this.events}
.locale=${this.locale}
@date-click=${this.handleDateClick}
@event-click=${this.handleEventClick}
></arx-calendar-month-view>
`,
() => html`
<arx-calendar-week-view
.currentDate=${this.currentDate}
.events=${this.events}
.locale=${this.locale}
@event-click=${this.handleEventClick}
></arx-calendar-week-view>
`,
)}
<arx-calendar-event-dialog
.open=${this.showAddEventDialog}
@close=${this.handleCloseDialog}
@submit=${this.handleSubmit}
></arx-calendar-event-dialog>
<arx-calendar-event-details-dialog
.event=${this.selectedEvent}
.open=${!!this.selectedEvent}
.locale=${this.locale}
@close=${this.handleCloseEventDetails}
></arx-calendar-event-details-dialog>
`;
}
}

View file

@ -4,6 +4,7 @@ import '@routes/Arbor/Home';
import '@routes/Arbor/NewPost';
import '@routes/Arbor/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: {},