📅 Add calendar functionality
This commit is contained in:
parent
6434102635
commit
a5348a3c62
13 changed files with 1328 additions and 142 deletions
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
Add a link
Reference in a new issue