e.target === e.currentTarget && this._handleCancel()}"
+
-
-
-
- ${
- this.showInput
- ? html`
-
- `
- : ''
- }
-
-
+ ${when(
+ this.showInput,
+ () => html`
+
+ `,
+ )}
+
-
+
`;
}
diff --git a/src/components/General/Toggle.ts b/src/components/General/Toggle.ts
index ed9fac5..e203e99 100644
--- a/src/components/General/Toggle.ts
+++ b/src/components/General/Toggle.ts
@@ -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;
diff --git a/src/components/InitialSetup.ts b/src/components/InitialSetup.ts
index 76dfd1e..708e4e8 100644
--- a/src/components/InitialSetup.ts
+++ b/src/components/InitialSetup.ts
@@ -17,6 +17,7 @@ import '@components/General/Fieldset';
import '@components/General/Input';
import '@components/LoadingView';
import '@components/ProgressSteps';
+import '@components/RelayLogs';
@customElement('arx-initial-setup')
export class InitialSetup extends LitElement {
@@ -395,8 +396,7 @@ export class InitialSetup extends LitElement {
Relay is running with PID: ${this.relayStatus.pid}
-
${this.relayStatus.logs.slice(-5).join('\n')}
- `,
+
`,
)}
Having trouble? Our team is here to help if you encounter any
diff --git a/src/components/RelayLogs.ts b/src/components/RelayLogs.ts
new file mode 100644
index 0000000..d3939cc
--- /dev/null
+++ b/src/components/RelayLogs.ts
@@ -0,0 +1,39 @@
+import { ansiToLitHtml } from '@/utils/ansiToLitHtml';
+import { LitElement, css, html } from 'lit';
+import { customElement, property } from 'lit/decorators.js';
+import { map } from 'lit/directives/map.js';
+
+@customElement('arx-relay-logs')
+export class RelayLogs extends LitElement {
+ static override styles = css`
+ :host {
+ display: block;
+ font-family: var(--font-family, "Inter", system-ui, sans-serif);
+ margin: 0 auto;
+ line-height: 1.6;
+ color: var(--color-base-content);
+ }
+
+ .relay-logs {
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ overflow-x: auto;
+ max-height: 200px;
+ padding: 1rem;
+ border: 2px solid var(--color-base-300);
+ border-radius: var(--radius-box);
+ border-radius: var(--radius-md);
+ background-color: var(--color-code-block);
+ color: var(--color-code-block-content);
+ }
+ `;
+
+ @property({ type: Array }) logs: string[] = [];
+
+ override render() {
+ return html`
+
${map(this.logs, (log) => ansiToLitHtml(log))}
+ `;
+ }
+}
diff --git a/src/routes/Calendar.ts b/src/routes/Calendar.ts
new file mode 100644
index 0000000..08bb3c6
--- /dev/null
+++ b/src/routes/Calendar.ts
@@ -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`
+
+
+ ${when(
+ this.view === 'month',
+ () => html`
+
+ `,
+ () => html`
+
+ `,
+ )}
+
+
+
+
+ `;
+ }
+}
diff --git a/src/routes/Settings.ts b/src/routes/Settings.ts
index 4886845..32edf75 100644
--- a/src/routes/Settings.ts
+++ b/src/routes/Settings.ts
@@ -12,6 +12,7 @@ import '@components/General/Button';
import '@components/General/Card';
import '@components/General/Fieldset';
import '@components/General/Input';
+import '@components/RelayLogs';
@customElement('arx-settings')
export class EveSettings extends LitElement {
@@ -61,11 +62,22 @@ export class EveSettings extends LitElement {
@state() private profile: NDKUserProfile | undefined;
@state() private error: string | undefined;
@state() private darkMode = false;
+ @state() private relayStatus: { running: boolean; pid: number | null; logs: string[] } = {
+ running: false,
+ pid: null,
+ logs: [],
+ };
+
+ private async updateRelayStatus() {
+ this.relayStatus = await window.relay.getStatus();
+ if (this.relayStatus.running) setTimeout(() => this.updateRelayStatus(), 2000);
+ }
protected override async firstUpdated() {
try {
this.profile = await getUserProfile();
this.darkMode = localStorage.getItem('darkMode') === 'true';
+ this.updateRelayStatus();
this.loading = false;
} catch (err) {
this.error = 'Failed to load profile';
@@ -176,6 +188,14 @@ export class EveSettings extends LitElement {
>
+
+
+
+ ${this.relayStatus.running ? `Relay is running. PID: ${this.relayStatus.pid}` : 'Relay is not running'}
+
+
+
+
`;
}
}
diff --git a/src/routes/router.ts b/src/routes/router.ts
index ab7a796..7e93762 100644
--- a/src/routes/router.ts
+++ b/src/routes/router.ts
@@ -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: {},
diff --git a/src/style.css b/src/style.css
index bb02569..cee2999 100644
--- a/src/style.css
+++ b/src/style.css
@@ -34,6 +34,8 @@
--color-warning-content: oklch(41% 0.112 45.904);
--color-error: oklch(70% 0.191 22.216);
--color-error-content: oklch(39% 0.141 25.723);
+ --color-code-block: oklch(95% 0.038 75.164);
+ --color-code-block-content: oklch(40% 0.123 38.172);
--radius-selector: 1rem;
--radius-field: 0.5rem;
@@ -68,6 +70,8 @@ body.dark {
--color-warning-content: oklch(19.106% 0.026 112.757);
--color-error: oklch(68.22% 0.206 24.43);
--color-error-content: oklch(13.644% 0.041 24.43);
+ --color-code-block: oklch(20% 0.05 270);
+ --color-code-block-content: oklch(90% 0.076 70.697);
--radius-selector: 1rem;
--radius-field: 0.5rem;
--radius-box: 1rem;
diff --git a/src/utils/ansiToLitHtml.ts b/src/utils/ansiToLitHtml.ts
new file mode 100644
index 0000000..e1d2aa9
--- /dev/null
+++ b/src/utils/ansiToLitHtml.ts
@@ -0,0 +1,84 @@
+import { unsafeHTML } from 'lit/directives/unsafe-html.js';
+
+export function ansiToLitHtml(text: string) {
+ const textColors: Record
= {
+ '30': 'black',
+ '31': 'red',
+ '32': 'green',
+ '33': 'yellow',
+ '34': 'blue',
+ '35': 'magenta',
+ '36': 'cyan',
+ '37': 'white',
+ '90': 'gray',
+ '91': 'lightred',
+ '92': 'lightgreen',
+ '93': 'lightyellow',
+ '94': 'lightblue',
+ '95': 'lightmagenta',
+ '96': 'lightcyan',
+ '97': 'white',
+ };
+
+ const bgColors: Record = {
+ '40': 'black',
+ '41': 'red',
+ '42': 'green',
+ '43': 'yellow',
+ '44': 'blue',
+ '45': 'magenta',
+ '46': 'cyan',
+ '47': 'white',
+ '100': 'gray',
+ '101': 'lightred',
+ '102': 'lightgreen',
+ '103': 'lightyellow',
+ '104': 'lightblue',
+ '105': 'lightmagenta',
+ '106': 'lightcyan',
+ '107': 'white',
+ };
+
+ const styles: Record = {
+ '1': 'font-weight: bold;',
+ '3': 'font-style: italic;',
+ '4': 'text-decoration: underline;',
+ };
+
+ const ESC = '\u001b';
+ const ansiPattern = new RegExp(`${ESC}\\[(\\d+(?:;\\d+)*)m`, 'g');
+ const openTags: string[] = [];
+
+ let result = text.replace(ansiPattern, (_, codes) => {
+ const codeArray = codes.split(';');
+
+ if (codeArray.includes('0')) {
+ const closeTags = openTags.length > 0 ? ''.repeat(openTags.length) : '';
+ openTags.length = 0;
+ return closeTags;
+ }
+
+ let style = '';
+
+ for (const code of codeArray) {
+ if (textColors[code]) {
+ style += `color: ${textColors[code]};`;
+ } else if (bgColors[code]) {
+ style += `background-color: ${bgColors[code]};`;
+ } else if (styles[code]) {
+ style += styles[code];
+ }
+ }
+
+ if (style) {
+ openTags.push('span');
+ return ``;
+ }
+
+ return '';
+ });
+
+ if (openTags.length > 0) result += ''.repeat(openTags.length);
+
+ return unsafeHTML(`${result}\n`);
+}