diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b6c4558 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +dist +.git +.DS_Store +.env +.env.* +*.local +*.log +coverage +playwright-report +test-results +e2e +tests diff --git a/.gitignore b/.gitignore index d70bb9c..d24254c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ node_modules dist .DS_Store *.local +playwright-report +test-results diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5e8521e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM node:24-alpine AS build +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:24-alpine AS runtime +ENV NODE_ENV=production +ENV NO_UPDATE_CHECK=1 +WORKDIR /app + +RUN npm install --global serve@14.2.4 && npm cache clean --force +COPY --from=build /app/dist ./dist +COPY serve.json ./serve.json + +USER node +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ + CMD wget -q -O /dev/null http://127.0.0.1:3000/ || exit 1 +CMD ["serve", "dist", "--config", "/app/serve.json", "--listen", "tcp://0.0.0.0:3000", "--no-clipboard", "--no-port-switching"] diff --git a/README.md b/README.md index 9fa0f47..5eb72cf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CalenDat — Decentralized ATProto Spaces Calendar MVP -CalenDat is a collaborative calendar application built on top of the **AT Protocol (ATProto)**, leveraging the new **ATProto Spaces** protocol for permissioned, private, and shared data. +CalenDat is a collaborative calendar application built on **AT Protocol (ATProto) Spaces** for private and shared calendars. --- @@ -34,9 +34,9 @@ CalenDat is a collaborative calendar application built on top of the **AT Protoc - CalenDat queries `com.atproto.space.listRecords` across all active collaborators in the space, providing a unified, decentralized calendar view. 5. **Rich Interactive UI & Polish**: - - **Month Grid View**: Visual day grid with event badges, dynamic row sizing, quick-add on click, and "+N more" modal popover. + - **Month Grid View**: Visual day grid with connected multi-day bars and quick-add on click. - **Week Timeline View**: 24-hour time-slot column view with duration-scaled event cards, auto-scroll to 8:00 AM, and real-time "Now" indicator. - - **Agenda View**: Chronological grouped list view with direct meeting links, resolved collaborator handles & avatars, venue addresses, and live search & filters. + - **Agenda View**: Events for the selected month, with one entry per event, meeting links, collaborator profiles, search, and upcoming/past filters. - **Dark Mode Support**: Seamless Light / Dark / System theme toggle with persistence. - **iCalendar (.ics) Export**: Export spaces or individual events to standard `.ics` for Google Calendar, Apple Calendar, and Outlook. - **Mobile Drawer**: Fully responsive navigation drawer on mobile and tablet devices. @@ -46,10 +46,31 @@ CalenDat is a collaborative calendar application built on top of the **AT Protoc ## Authentication & PDS Endpoint -CalenDat connects to the testing PDS sandbox supporting ATProto spaces: -* **PDS Endpoint**: `https://spaces-alpha.host.bsky.network` -* **Test Account**: `david.spaces-alpha.bsky.network` -* One-click login button built into the UI for instant testing. +CalenDat uses AT Protocol OAuth. Enter a handle, DID, or PDS URL and complete sign-in with that provider. CalenDat never accepts or stores account passwords. + +Production OAuth metadata is served at `https://calend.at/oauth-client-metadata.json`, with the callback at `https://calend.at/oauth/callback`. Deploys must preserve that JSON file exactly and route `/oauth/callback` to the SPA. Set `VITE_PUBLIC_URL` only when hosting a separately registered metadata document whose `client_id`, `client_uri`, and callback URLs all use that same public origin. Local development uses the AT Protocol loopback client at `127.0.0.1`; the browser OAuth SDK redirects `localhost` to that address and refresh tokens are intentionally short-lived. + +The metadata requests `atproto` plus a `space:community.lexicon.calendar` grant for calendar event, RSVP, and metadata records under any authority, with create, update, and delete management access. The [permissioned-data proposal](https://github.com/bluesky-social/proposals/blob/main/0016-permissioned-data/README.md) defines the Spaces OAuth consent boundary. OAuth requires a provider supporting this Spaces alpha grant. No client secret or separate registration is needed; publish the metadata JSON with the app, serve it as `application/json`, and serve the app at the callback path. A live sign-in must be verified after deployment. + +## Calendar dates and timezones + +- Timed events store UTC instants and an IANA `timeZone` for editing. The calendar views show the viewer's local time. The editor rejects nonexistent times during the spring daylight-saving transition and preserves the original instant when an unchanged time is ambiguous during the autumn transition. +- All-day events use explicit `allDay: true`, `startDate`, and exclusive `endDate` fields. These are CalenDat extensions to the event record. Their `startsAt` and `endsAt` are UTC midnight timestamps for compatibility. The form shows an inclusive end date: September 11–12 is stored with `endDate: "2026-09-13"`. +- Month and week views use connected multi-day bars, wrapping at week boundaries. Agenda lists each event once. Events ending at midnight do not occupy the following day. The week view keeps all-day and multi-day events above its scrolling timeline. +- Existing records without explicit all-day metadata remain timed events. Their original intent cannot be recovered reliably from timestamps alone; edit and save them with the All-Day Event option when needed. +- All-day `.ics` exports use date values and an exclusive end date. + +## Recurrence, collaboration, and calendar tools + +- **Recurring events:** daily, weekly, monthly, and yearly schedules with an interval, end date, or occurrence count. Edit or delete one occurrence or the whole series. Dragging moves one occurrence. Recurrence follows the event's IANA timezone across daylight-saving changes; timed values are still stored in UTC. Nonexistent local times are skipped without consuming the occurrence count. Imported custom RRULEs are preserved unless recurrence controls change. Expansion is limited to 1,000 occurrences per series and 5,000 overall per visible window, with a 20,000-candidate work limit; unsupported rules produce a visible error. +- **Overlays:** sidebar rows and right-hand eye buttons toggle visibility. Visible calendars have a shadow tinted in their own color. Hiding removes their events immediately. The header calendar menu chooses the target for new events and tools without changing visibility. Each calendar's settings button edits its name, description, color, and sharing. Timed overlaps have a `!` marker. Loading appears in a floating toast. +- **Refresh:** every 2 minutes while online, visible, and free of an open dialog or pending load. Failures back off to at most 15 minutes. Manual Refresh remains available. Collaborators' moved or cancelled events produce in-app notices after a successful refresh. +- **Reminders:** choose an event reminder and optionally enable desktop notifications with the bell button. Reminders require CalenDat to remain open; there is no background push delivery. All-day reminders use midnight in the viewer's local timezone. Reminder history is stored per account in the browser to avoid duplicate notices. +- **Import:** Calendar tools accepts local `.ics` files up to 5 MB and 2,000 VEVENT components, previews the import, and skips previously imported UIDs. Retrying a partial import skips successful writes. Recurrence rules, exclusions, and individual overrides are supported; RDATE, multiple RRULEs, and THISANDFUTURE are rejected explicitly. Floating times use the importing user's timezone with a warning. Subscription feeds are deferred. +- **Lifecycle:** owners can archive, restore, or permanently delete calendars. Archiving is a shared CalenDat visibility/editing setting, not a server access restriction. Deletion requires typing the calendar name. Collaborators can request to leave; servers that restrict member removal to owners show an error asking the owner to remove access. +- **Keyboard and mobile:** dialogs trap focus and restore it on close. Bare `N` creates an event, `T` goes to today, `1`/`2`/`3` switches views, and `[`/`]` navigates dates. Shortcuts pause while typing or using a dialog and preserve Cmd/Ctrl/Alt combinations. Open an event and change its date/time to reschedule on touch devices. + +Recurrence, reminders, archive status, and explicit all-day dates are CalenDat record extensions. Other clients may not understand them. The Spaces alpha write API has no conditional record update, so simultaneous edits to the same series can still overwrite each other; occurrence edits read the latest record before merging their changes. --- @@ -63,8 +84,18 @@ npm install ### 2. Run Tests ```bash npm test +npm run test:timezones ``` +Browser tests run the real UI and ATProto client against intercepted test responses, without accessing a live account: + +```bash +npx playwright install chromium +npm run test:browser +``` + +The browser suite covers sharing and clipboard permission failures, failed saves, drag and mobile rescheduling, navigation, all-day editing, timezone conversion, daylight-saving times, overlays, recurrence scope, and modal focus. Service and hook tests cover session refresh, import retries, lifecycle permissions, reminder deduplication, and automatic refresh backoff. Date handling and recurrence tests run in UTC, New York, Madrid, and Tokyo. Export tests verify the included VTIMEZONE definitions independently through `ical.js`. + ### 3. Development Server ```bash npm run dev @@ -76,3 +107,27 @@ The application will launch on `http://localhost:5180`. npm run build npm run preview ``` + +### 5. Docker Deployment + +Build and run the production image: + +```bash +docker build --platform linux/amd64 -t calendat . +docker run --rm --platform linux/amd64 -p 3000:3000 calendat +``` + +The multi-stage image uses Node 24 Alpine to run `npm ci` and `npm run build`, then serves the built static files with lightweight `serve@14.2.4` on port 3000. The container includes the public `oauth-client-metadata.json` file and rewrites `/oauth/callback` to the SPA entry point. Use `https://calend.at` as the production origin; the hosting platform must provide HTTPS for OAuth because the container itself serves plain HTTP. + +For Docker Compose, the included `compose.yaml` builds and runs `linux/amd64`, publishes `${PORT:-3000}:3000`, and restarts it unless stopped: + +```bash +docker compose up -d --build +docker compose down +``` + +Set a different host port when needed: + +```bash +PORT=8080 docker compose up -d +``` diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..53180e6 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,8 @@ +services: + calendat: + platform: linux/amd64 + build: . + image: calendat:latest + ports: + - "${PORT:-3000}:3000" + restart: unless-stopped diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts new file mode 100644 index 0000000..551d2a9 --- /dev/null +++ b/e2e/auth.spec.ts @@ -0,0 +1,22 @@ +import { expect, test } from '@playwright/test'; + +test('shows OAuth callback errors and retries sign-in without password fields', async ({ page }) => { + await page.route('**/src/services/oauth.ts*', route => route.fulfill({ + contentType: 'application/javascript', + body: `export const normalizeOAuthIdentifier = input => input.trim().replace(/^@/, ''); + export async function getOAuthClient() { + return { + init: async () => { throw new Error('Authorization was declined'); }, + signIn: async input => { throw new Error('Provider unavailable: ' + input); }, + revoke: async () => {} + }; + }`, + })); + await page.goto('/oauth/callback?error=access_denied&state=test'); + await expect(page.getByRole('alert')).toHaveText('Authorization was declined'); + await expect(page.locator('input[type=password]')).toHaveCount(0); + await page.getByLabel('Handle, DID, or PDS URL').fill('https://pds.example'); + await page.getByRole('button', { name: 'Continue with AT Protocol' }).click(); + await expect(page.getByRole('alert')).toHaveText('Provider unavailable: https://pds.example'); + await expect(page.getByRole('button', { name: 'Continue with AT Protocol' })).toBeEnabled(); +}); diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts new file mode 100644 index 0000000..51be7df --- /dev/null +++ b/e2e/calendar.spec.ts @@ -0,0 +1,287 @@ +import { expect, test, type Page } from '@playwright/test'; +import { mockOAuth } from './oauthFixture'; + +const owner = 'did:plc:owner'; +const member = 'did:plc:member'; +const space = `at://${owner}/space/community.lexicon.calendar/team`; +const collection = 'community.lexicon.calendar.event'; +const makeEvent = (rkey: string, name: string, startsAt: string, endsAt: string, extra = {}) => ({ + rkey, cid: 'bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku', collection, + value: { $type: collection, name, startsAt, endsAt, createdAt: '2026-09-01T00:00:00Z', ...extra }, +}); + +type ServerState = { + events: ReturnType[]; + members: string[]; + writes: { endpoint: string; body: any }[]; + failWrites: boolean; + failMembers: boolean; +}; + +async function setup(page: Page): Promise { + const state: ServerState = { + events: [ + makeEvent('overnight', 'Overnight workshop', '2026-09-11T20:00:00Z', '2026-09-13T00:00:00Z'), + makeEvent('october', 'October planning', '2026-10-02T08:00:00Z', '2026-10-02T09:00:00Z'), + ], + members: [owner, member], writes: [], failWrites: false, failMembers: false, + }; + await page.clock.setFixedTime(new Date('2026-09-11T12:00:00Z')); + await mockOAuth(page, owner, 'https://calendar-tests.invalid'); + await page.route('**/xrpc/**', async route => { + const request = route.request(); + const url = new URL(request.url()); + const endpoint = url.pathname.split('/').pop()!; + const body = request.method() === 'POST' ? request.postDataJSON() || {} : {}; + const reply = (data: unknown, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(data) }); + if (endpoint.endsWith('refreshSession') || endpoint.endsWith('getSession')) { + return reply({ did: owner, handle: 'owner.test', accessJwt: 'test-access', refreshJwt: 'test-refresh', active: true, emailConfirmed: true, didDoc: { id: owner } }); + } + if (endpoint.endsWith('listSpaces')) return reply({ spaces: [{ uri: space }] }); + if (endpoint.endsWith('getProfile')) { + const did = url.searchParams.get('actor')!; + return reply({ did, handle: did === owner ? 'owner.test' : 'member.test', displayName: did === owner ? 'Owner' : 'Member' }); + } + if (endpoint.endsWith('resolveHandle')) return reply({ did: member }); + if (endpoint.endsWith('listMembers')) return state.failMembers + ? reply({ error: 'Forbidden', message: 'Member access denied' }, 403) + : reply({ members: state.members.map(did => ({ did })) }); + if (endpoint.endsWith('getRecord')) { + if (url.searchParams.get('collection')?.endsWith('.meta')) return reply({ uri: space, cid: 'bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku', value: { name: 'Team', color: '#0284c7' } }); + const event = state.events.find(event => event.rkey === url.searchParams.get('rkey')); + return event ? reply({ uri: `${space}/${owner}/${collection}/${event.rkey}`, cid: event.cid, value: event.value }) : reply({ error: 'RecordNotFound' }, 400); + } + if (endpoint.endsWith('listRecords')) { + if (url.searchParams.get('collection')?.endsWith('.rsvp')) return reply({ records: [] }); + return reply({ records: url.searchParams.get('repo') === owner ? state.events : [makeEvent('foreign', 'Member appointment', '2026-09-11T10:00:00Z', '2026-09-11T11:00:00Z')] }); + } + if (request.method() === 'POST') { + state.writes.push({ endpoint, body }); + if (state.failWrites) return reply({ error: 'Forbidden', message: 'Save denied by server' }, 403); + if (endpoint.endsWith('addMember')) state.members = [...new Set([...state.members, body.did])]; + if (endpoint.endsWith('removeMember')) state.members = state.members.filter(did => did !== body.did); + if (endpoint.endsWith('putRecord')) { + const existing = state.events.find(event => event.rkey === body.rkey); + if (existing) existing.value = body.record; + } + if (endpoint.endsWith('createRecord')) state.events.push({ rkey: 'created', cid: 'bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku', collection, value: body.record }); + return reply({ uri: `${space}/${owner}/${collection}/${body.rkey || 'created'}`, cid: 'bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku' }); + } + return reply({ error: 'UnexpectedEndpoint', message: endpoint }, 500); + }); + await page.goto('/'); + await expect(page.getByText('Overnight workshop').first()).toBeVisible(); + return state; +} + +test('keyboard shortcuts preserve browser modifiers and modal focus', async ({ page }) => { + await setup(page); + const prevented = await page.evaluate(() => ['t', 's', 'n'].map(key => { + const event = new KeyboardEvent('keydown', { key, metaKey: true, bubbles: true, cancelable: true }); + document.body.dispatchEvent(event); + return event.defaultPrevented; + })); + expect(prevented).toEqual([false, false, false]); + await expect(page.getByLabel('Event title', { exact: true })).toHaveCount(0); + await page.getByRole('button', { name: 'New Event', exact: true }).focus(); + await page.keyboard.press('n'); + const dialog = page.getByRole('dialog', { name: 'New Calendar Event' }); + await expect(dialog).toBeVisible(); + await page.getByLabel('Event title', { exact: true }).fill('Typing n and t'); + for (let i = 0; i < 35; i++) { + await page.keyboard.press('Tab'); + expect(await dialog.evaluate(element => element.contains(document.activeElement))).toBe(true); + } + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + await expect(page.getByRole('button', { name: 'New Event', exact: true })).toBeFocused(); +}); + +test('mobile navigation fits and events can be rescheduled through touch controls', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + const state = await setup(page); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + await page.getByTitle('Toggle spaces menu').click(); + const navigation = page.getByRole('dialog', { name: 'Calendar navigation' }); + await expect(navigation).toBeVisible(); + await page.keyboard.press('Shift+Tab'); + expect(await navigation.evaluate(element => element.contains(document.activeElement))).toBe(true); + await page.keyboard.press('Escape'); + await page.getByText('Overnight workshop', { exact: true }).first().click(); + await page.getByLabel('Start date', { exact: true }).fill('2026-09-12'); + await page.getByRole('button', { name: 'Save Changes', exact: true }).click(); + await expect(page.getByLabel('Event title', { exact: true })).toHaveCount(0); + expect(state.events.find(event => event.rkey === 'overnight')!.value.startsAt).toBe('2026-09-12T20:00:00.000Z'); +}); + +test('agenda follows header navigation and shows each multi-day event once', async ({ page }) => { + await setup(page); + await page.getByRole('button', { name: 'Agenda', exact: true }).click(); + await expect(page.getByText('Overnight workshop', { exact: true })).toHaveCount(1); + await expect(page.getByText('October planning', { exact: true })).toHaveCount(0); + await page.getByTitle('Next', { exact: true }).click(); + await expect(page.getByText('October planning', { exact: true })).toBeVisible(); + await expect(page.getByText('Overnight workshop', { exact: true })).toHaveCount(0); +}); + +test('renders a multi-day event as one connected month bar', async ({ page }) => { + await setup(page); + const bar = page.getByTestId('month-span-overnight-1'); + await expect(bar).toBeVisible(); + await expect(bar).toHaveCSS('grid-column-start', '6'); + await expect(bar).toHaveCSS('grid-column-end', '8'); + const barBox = await bar.boundingBox(); + const dayBox = await page.getByTestId('month-day-2026-09-11').boundingBox(); + expect(barBox!.width).toBeGreaterThan(dayBox!.width * 1.8); + await page.screenshot({ path: 'test-results/calendar-spans.png', fullPage: true }); + await expect(page.getByTestId('month-day-2026-09-12').getByText('Overnight workshop')).toHaveCount(0); +}); + +test('keeps a failed event save open, then saves a duration crossing midnight', async ({ page }) => { + const state = await setup(page); + await page.getByRole('button', { name: 'New Event', exact: true }).click(); + await page.getByLabel('Event title', { exact: true }).fill('Late meeting'); + await page.getByLabel('Start date', { exact: true }).fill('2026-09-11'); + await page.getByLabel('Start time', { exact: true }).fill('23:30'); + await page.getByRole('button', { name: '1h', exact: true }).click(); + await expect(page.getByLabel('End date', { exact: true })).toHaveValue('2026-09-12'); + await expect(page.getByLabel('End time', { exact: true })).toHaveValue('00:30'); + state.failWrites = true; + await page.getByRole('button', { name: 'Create Event', exact: true }).click(); + await expect(page.getByText('Save denied by server', { exact: true })).toBeVisible(); + await expect(page.getByLabel('Event title', { exact: true })).toHaveValue('Late meeting'); + state.failWrites = false; + await page.getByRole('button', { name: 'Create Event', exact: true }).click(); + await expect(page.getByLabel('Event title', { exact: true })).toHaveCount(0); + const created = state.events.find(event => event.value.name === 'Late meeting')!; + expect(created.value.startsAt).toBe('2026-09-11T21:30:00.000Z'); + expect(created.value.endsAt).toBe('2026-09-11T22:30:00.000Z'); + expect((created.value as any).status).toBe('community.lexicon.calendar.event#scheduled'); + await expect(page.getByRole('button', { name: /Late meeting, Team/ })).toBeVisible(); +}); + +test('shows sharing permission failures and supports member removal and invitation', async ({ page }) => { + const state = await setup(page); + await page.getByTitle('Manage collaborators').click(); + state.failWrites = true; + await page.getByTitle('Remove collaborator').click(); + await expect(page.getByText('Save denied by server', { exact: true })).toBeVisible(); + await expect(page.getByText('@member.test', { exact: true })).toBeVisible(); + state.failWrites = false; + await page.getByTitle('Remove collaborator').click(); + await expect(page.getByText('@member.test', { exact: true })).toHaveCount(0); + await page.getByPlaceholder('e.g. alice.spaces-alpha.bsky.network or did:plc:...').fill('member.test'); + await page.getByRole('button', { name: 'Invite', exact: true }).click(); + await expect(page.getByText('@member.test', { exact: true })).toBeVisible(); + expect(state.writes.find(write => write.endpoint.endsWith('addMember'))?.body).toEqual({ space, did: member }); +}); + +test('reports a clipboard permission failure instead of claiming the address was copied', async ({ page }) => { + await setup(page); + await page.evaluate(() => Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: async () => { throw new Error('Clipboard denied'); } }, + })); + await page.getByTitle('Manage collaborators').click(); + await page.getByRole('button', { name: 'Copy', exact: true }).click(); + await expect(page.getByText('Could not copy the calendar address. Select and copy it manually.')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Copied', exact: true })).toHaveCount(0); +}); + +test('protects another member event and reschedules an owned event through drag and drop', async ({ page }) => { + const state = await setup(page); + const foreign = page.getByRole('button', { name: /Member appointment, Team/ }); + await expect(foreign).toHaveAttribute('draggable', 'false'); + await foreign.click(); + await expect(page.getByRole('button', { name: 'Save Changes' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Delete Event' })).toHaveCount(0); + await page.keyboard.press('Escape'); + const source = page.getByTestId('month-span-overnight-1'); + await source.dragTo(page.getByTestId('month-day-2026-09-14')); + await expect.poll(() => state.writes.filter(write => write.endpoint.endsWith('putRecord')).length).toBe(1); + const update = state.writes.find(write => write.endpoint.endsWith('putRecord'))!.body; + expect(update.repo).toBe(owner); + expect(update.record.startsAt).toBe('2026-09-14T20:00:00.000Z'); + expect(update.record.endsAt).toBe('2026-09-16T00:00:00.000Z'); + await expect(page.getByTestId('month-span-overnight-1')).toHaveCount(0); + await expect(page.getByTestId('month-span-overnight-2')).toBeVisible(); +}); + +test('creates and reopens all-day dates without shifting them', async ({ page }) => { + const state = await setup(page); + await page.getByRole('button', { name: 'New Event', exact: true }).click(); + await page.getByLabel('Event title', { exact: true }).fill('Conference'); + await page.getByRole('checkbox', { name: 'All-day event' }).check(); + await page.getByLabel('Start date', { exact: true }).fill('2026-09-15'); + await page.getByLabel('End date', { exact: true }).fill('2026-09-16'); + await page.getByRole('button', { name: 'Create Event', exact: true }).click(); + await expect(page.getByLabel('Event title', { exact: true })).toHaveCount(0); + const created = state.events.find(event => event.value.name === 'Conference')!.value as any; + expect(created).toMatchObject({ allDay: true, startDate: '2026-09-15', endDate: '2026-09-17', startsAt: '2026-09-15T00:00:00.000Z', endsAt: '2026-09-17T00:00:00.000Z' }); + const conference = page.getByTestId('month-span-created-2'); + await expect(conference).toBeVisible(); + await expect(conference).toHaveCSS('grid-column-start', '3'); + await expect(conference).toHaveCSS('grid-column-end', '5'); + await conference.click(); + await expect(page.getByLabel('End date', { exact: true })).toHaveValue('2026-09-16'); +}); + +test('retains the second occurrence of an ambiguous DST time when only the title changes', async ({ page }) => { + const state = await setup(page); + state.events.push(makeEvent('dst', 'DST meeting', '2026-11-01T06:30:00.000Z', '2026-11-01T07:30:00.000Z', { timeZone: 'America/New_York', allDay: false })); + await page.getByTitle('Refresh space events').click(); + await page.getByTitle('Next', { exact: true }).click(); + await page.getByTitle('Next', { exact: true }).click(); + await page.getByRole('button', { name: /DST meeting, Team/ }).first().click(); + await expect(page.getByLabel('Time zone', { exact: true })).toHaveValue('America/New_York'); + await expect(page.getByLabel('Start time', { exact: true })).toHaveValue('01:30'); + await page.getByLabel('Event title', { exact: true }).fill('Renamed DST meeting'); + await page.getByRole('button', { name: 'Save Changes', exact: true }).click(); + await expect(page.getByLabel('Event title', { exact: true })).toHaveCount(0); + expect(state.events.find(event => event.rkey === 'dst')!.value.startsAt).toBe('2026-11-01T06:30:00.000Z'); +}); + +test('does not move events on a denied drag save', async ({ page }) => { + const state = await setup(page); + state.failWrites = true; + const source = page.getByTestId('month-span-overnight-1'); + await source.dragTo(page.getByTestId('month-day-2026-09-14')); + await expect(page.getByText('Save denied by server', { exact: true })).toBeVisible(); + await expect(source).toBeVisible(); + await expect(page.getByTestId('month-span-overnight-2')).toHaveCSS('grid-column', '1 / 2'); + expect(state.events.find(event => event.rkey === 'overnight')!.value.startsAt).toBe('2026-09-11T20:00:00Z'); +}); + +test.describe('western timezone', () => { + test.use({ timezoneId: 'America/Los_Angeles' }); + + test('keeps all-day events on their dates and above the scrolling week timeline', async ({ page }) => { + const state = await setup(page); + state.events.push(makeEvent('allday', 'Team holiday', '2026-09-11T00:00:00Z', '2026-09-13T00:00:00Z', { allDay: true, startDate: '2026-09-11', endDate: '2026-09-13' })); + await page.getByTitle('Refresh space events').click(); + const holiday = page.getByTestId('month-span-allday-1'); + await expect(holiday).toBeVisible(); + await expect(holiday).toHaveCSS('grid-column-start', '6'); + await expect(holiday).toHaveCSS('grid-column-end', '8'); + await page.getByRole('button', { name: 'Week', exact: true }).click(); + const weekHoliday = page.getByTestId('week-span-allday'); + await expect(weekHoliday).toBeVisible(); + await weekHoliday.click(); + await expect(page.getByLabel('Start date', { exact: true })).toHaveValue('2026-09-11'); + await expect(page.getByLabel('End date', { exact: true })).toHaveValue('2026-09-12'); + }); +}); + +test('saves a real one-hour duration across the repeated autumn hour', async ({ page }) => { + const state = await setup(page); + await page.getByRole('button', { name: 'New Event', exact: true }).click(); + await page.getByLabel('Event title', { exact: true }).fill('Clock change'); + await page.getByLabel('Time zone', { exact: true }).selectOption('America/New_York'); + await page.getByLabel('Start date', { exact: true }).fill('2026-11-01'); + await page.getByLabel('Start time', { exact: true }).fill('01:30'); + await page.getByRole('button', { name: '1h', exact: true }).click(); + await page.getByRole('button', { name: 'Create Event', exact: true }).click(); + await expect(page.getByLabel('Event title', { exact: true })).toHaveCount(0); + const saved = state.events.find(event => event.value.name === 'Clock change')!.value; + expect(new Date(saved.endsAt).getTime() - new Date(saved.startsAt).getTime()).toBe(3600000); +}); diff --git a/e2e/features.spec.ts b/e2e/features.spec.ts new file mode 100644 index 0000000..1d1250b --- /dev/null +++ b/e2e/features.spec.ts @@ -0,0 +1,172 @@ +import { expect, test, type Page } from '@playwright/test'; +import { mockOAuth } from './oauthFixture'; + +const owner = 'did:plc:owner'; +const work = `at://${owner}/space/community.lexicon.calendar/work`; +const home = `at://${owner}/space/community.lexicon.calendar/home`; +const collection = 'community.lexicon.calendar.event'; +const cid = 'bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku'; + +type State = { events: Record; writes: any[]; archived: Record; metadata: Record; delayReads: boolean }; +const event = (rkey: string, name: string, startsAt: string, extra = {}) => ({ rkey, cid, collection, value: { $type: collection, name, startsAt, endsAt: new Date(new Date(startsAt).getTime() + 3600000).toISOString(), createdAt: '2026-09-01T00:00:00Z', ...extra } }); + +async function setup(page: Page): Promise { + const state: State = { + events: { + [work]: [event('work-event', 'Blue work event', '2026-09-11T09:00:00Z'), event('series', 'Daily standup', '2026-09-11T10:00:00Z', { timeZone: 'UTC', recurrence: 'FREQ=DAILY;COUNT=3' })], + [home]: [event('home-event', 'Orange home event', '2026-09-11T12:00:00Z')], + }, writes: [], archived: {}, metadata: {}, delayReads: false, + }; + await page.clock.setFixedTime(new Date('2026-09-11T12:00:00Z')); + await mockOAuth(page, owner, 'https://features.invalid'); + await page.route('**/xrpc/**', async route => { + const request = route.request(); + const endpoint = new URL(request.url()).pathname.split('/').pop()!; + const body = request.method() === 'POST' ? request.postDataJSON() || {} : {}; + const params = new URL(request.url()).searchParams; + const value = (name: string) => body[name] ?? params.get(name); + const reply = (data: unknown, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(data) }); + if (endpoint.endsWith('refreshSession') || endpoint.endsWith('getSession')) return reply({ did: owner, handle: 'owner.test', accessJwt: 'x', refreshJwt: 'y', active: true, didDoc: { id: owner } }); + if (endpoint.endsWith('listSpaces')) return reply({ spaces: [{ uri: work }, { uri: home }] }); + if (endpoint.endsWith('getProfile')) return reply({ did: owner, handle: 'owner.test', displayName: 'Owner' }); + if (endpoint.endsWith('listMembers')) return reply({ members: [{ did: owner }] }); + if (endpoint.endsWith('getRecord')) { + const requested = value('space')!; + if (String(value('collection')).endsWith('.meta')) return reply({ uri: requested, cid, value: { name: requested === work ? 'Work' : 'Home', color: requested === work ? '#2563eb' : '#f97316', archived: state.archived[requested], createdAt: '2026-09-01T00:00:00Z', ...state.metadata[requested] } }); + const saved = (state.events[requested] || []).find(item => item.rkey === value('rkey')); + if (saved) return reply({ uri: `${requested}/${owner}/${collection}/${saved.rkey}`, cid, value: saved.value }); + return reply({ error: 'RecordNotFound' }, 400); + } + if (endpoint.endsWith('listRecords')) { + if (state.delayReads) await new Promise(resolve => setTimeout(resolve, 700)); + return reply({ records: state.events[value('space')!] || state.events[work] }); + } + if (request.method() === 'POST') { + state.writes.push({ endpoint, body }); + if (endpoint.endsWith('putRecord') && body.collection === collection) { + const saved = (state.events[body.space] || []).find(item => item.rkey === body.rkey); + if (saved) saved.value = body.record; + } + if (endpoint.endsWith('putRecord') && body.collection?.endsWith('.meta')) { + state.archived[body.space] = Boolean(body.record.archived); + state.metadata[body.space] = body.record; + } + if (endpoint.endsWith('createRecord') && body.collection === collection) state.events[body.space].push({ rkey: 'imported', cid, collection, value: body.record }); + return reply({ uri: `${body.space}/${owner}/${collection}/${body.rkey || 'created'}`, cid }); + } + return reply({}); + }); + await page.goto('/'); + await expect(page.getByRole('button', { name: /Blue work event, Work/ })).toBeVisible(); + return state; +} + +test('shows overlay events in their calendar colors and switches calendars immediately', async ({ page }) => { + await setup(page); + const blue = page.getByRole('button', { name: /Blue work event, Work/ }).first(); + await expect(blue).toBeVisible(); + await expect(blue).toHaveAttribute('style', /border-left-color: rgb\(37, 99, 235\)/); + await page.getByLabel('Show Home', { exact: true }).click(); + const orange = page.getByRole('button', { name: /Orange home event, Home/ }).first(); + await expect(orange).toBeVisible(); + await expect(orange).toHaveAttribute('style', /border-left-color: rgb\(249, 115, 22\)/); + await expect(page.getByLabel('Calendar for new events')).toHaveValue(work); + await page.getByLabel('Hide Work', { exact: true }).click(); + await expect(orange).toBeVisible(); + await expect(blue).toHaveCount(0); +}); + +test('persists an occurrence edit separately and a series edit on the source record', async ({ page }) => { + const state = await setup(page); + await page.getByRole('button', { name: /Daily standup, Work/ }).first().click(); + await expect(page.getByText('Apply changes to')).toBeVisible(); + await page.getByLabel('Event title').fill('Moved standup'); + await page.getByRole('button', { name: 'Save Changes', exact: true }).click(); + await expect.poll(() => state.writes.filter(write => write.endpoint.endsWith('putRecord')).length).toBe(1); + const occurrence = state.writes[0].body.record; + expect(occurrence.recurrence).toBe('FREQ=DAILY;COUNT=3'); + expect(Object.values(occurrence.occurrenceOverrides || {}).some((value: any) => value.name === 'Moved standup')).toBe(true); + await page.getByRole('button', { name: /Daily standup, Work/ }).first().click(); + await page.getByLabel('Whole series').check(); + await page.getByLabel('Event title').fill('Renamed series'); + await page.getByRole('button', { name: 'Save Changes', exact: true }).click(); + await expect.poll(() => state.writes.filter(write => write.endpoint.endsWith('putRecord')).length).toBe(2); + expect(state.writes[1].body.record.name).toBe('Renamed series'); + expect(state.writes[1].body.record.recurrence).toBe('FREQ=DAILY;COUNT=3'); +}); + +test('imports a local ICS file into the selected calendar', async ({ page }) => { + const state = await setup(page); + await page.getByLabel('Calendar for new events').selectOption(home); + await page.getByRole('button', { name: 'Calendar tools', exact: true }).click(); + const ics = 'BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:imported-home\r\nSUMMARY:Imported at home\r\nDTSTART:20260911T150000Z\r\nDTEND:20260911T160000Z\r\nEND:VEVENT\r\nEND:VCALENDAR'; + await page.locator('input[type="file"]').setInputFiles({ name: 'home.ics', mimeType: 'text/calendar', buffer: Buffer.from(ics) }); + await expect(page.getByText(/home\.ics, 1 events/)).toBeVisible(); + await page.getByRole('button', { name: 'Import to Home', exact: true }).click(); + await expect.poll(() => state.events[home].some(item => item.value.name === 'Imported at home')).toBe(true); + const write = state.writes.find(item => item.endpoint.endsWith('createRecord')); + expect(write.body.space).toBe(home); + expect(write.body.record.importUid).toBe('imported-home'); +}); + +test('archives, restores, and deletes through calendar tools', async ({ page }) => { + const state = await setup(page); + await page.getByRole('button', { name: 'Calendar tools', exact: true }).click(); + await page.getByRole('button', { name: 'Archive calendar', exact: true }).click(); + await expect(page.getByLabel('Calendar for new events')).toHaveValue(home); + await expect.poll(() => state.writes.some(item => item.endpoint.endsWith('putRecord') && item.body.record.archived === true)).toBe(true); + const archive = state.writes.find(item => item.endpoint.endsWith('putRecord') && item.body.record.archived === true); + expect(archive.body.space).toBe(work); + await page.getByRole('button', { name: 'Show archived calendars', exact: true }).click(); + await page.getByRole('button', { name: 'Calendar settings for Work', exact: true }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Calendar tools', exact: true }).click(); + await page.getByRole('button', { name: 'Restore calendar', exact: true }).click(); + await expect.poll(() => state.writes.some(item => item.endpoint.endsWith('putRecord') && item.body.record.archived === false)).toBe(true); + await page.getByRole('button', { name: 'Calendar settings for Work', exact: true }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Calendar tools', exact: true }).click(); + await page.getByRole('button', { name: 'Delete calendar', exact: true }).click(); + await page.getByPlaceholder('Work').fill('Work'); + await page.getByRole('button', { name: 'Delete permanently', exact: true }).click(); + await expect.poll(() => state.writes.some(item => item.endpoint.endsWith('deleteSpace'))).toBe(true); + expect(state.writes.find(item => item.endpoint.endsWith('deleteSpace')).body.space).toBe(work); +}); + +test('loading floats without moving the grid, and row visibility keeps its own color', async ({ page }) => { + const state = await setup(page); + await expect(page.getByLabel(/^Today:/)).toHaveText('11'); + await expect(page.getByText('Loading calendar…')).toHaveCount(0); + const grid = page.getByTestId('month-day-2026-09-11'); + const before = await grid.boundingBox(); + state.delayReads = true; + await page.getByTitle('Refresh space events').click(); + await expect(page.getByText('Loading calendar…')).toBeVisible(); + expect(await grid.boundingBox()).toEqual(before); + await expect(page.getByText('Loading calendar…')).toHaveCount(0); + await page.getByLabel('Toggle Home visibility').click(); + await expect(page.getByLabel('Calendar for new events')).toHaveValue(work); + await expect(page.getByLabel('Hide Home', { exact: true })).toBeVisible(); + const row = page.getByTestId('calendar-row-home'); + await expect(row).toHaveCSS('box-shadow', /.+/); + expect(await row.evaluate(element => getComputedStyle(element).boxShadow)).not.toBe('none'); + const eye = await page.getByLabel('Hide Home', { exact: true }).boundingBox(); + const name = await page.getByLabel('Toggle Home visibility').boundingBox(); + expect(eye!.x).toBeGreaterThan(name!.x + name!.width); + await page.getByLabel('Hide Work', { exact: true }).click(); + await expect(page.getByRole('button', { name: /Blue work event, Work/ })).toHaveCount(0); + await page.screenshot({ path: 'test-results/calendar-controls.png', fullPage: true }); +}); + +test('edits another calendar properties without changing the event target or visibility', async ({ page }) => { + const state = await setup(page); + await page.getByLabel('Calendar settings for Home', { exact: true }).click(); + const dialog = page.getByRole('dialog', { name: 'Calendar settings' }); + await dialog.getByLabel('Calendar name', { exact: true }).fill('Family'); + await dialog.getByLabel('Description', { exact: true }).fill('Our family plans'); + await dialog.getByLabel('Use #10b981 calendar color').click(); + await dialog.getByRole('button', { name: 'Save', exact: true }).click(); + await expect.poll(() => state.metadata[home]?.name).toBe('Family'); + expect(state.metadata[home]).toMatchObject({ description: 'Our family plans', color: '#10b981' }); + await dialog.getByRole('button', { name: 'Done', exact: true }).click(); + await expect(page.getByLabel('Calendar for new events')).toHaveValue(work); + await expect(page.getByLabel('Show Family', { exact: true })).toBeVisible(); +}); diff --git a/e2e/oauthFixture.ts b/e2e/oauthFixture.ts new file mode 100644 index 0000000..4f1bf01 --- /dev/null +++ b/e2e/oauthFixture.ts @@ -0,0 +1,19 @@ +import type { Page } from '@playwright/test'; + +// Substitute the OAuth session at the network boundary; keep the real XRPC client. +export async function mockOAuth(page: Page, did: string, service: string) { + await page.route('**/src/services/oauth.ts*', route => route.fulfill({ + contentType: 'application/javascript', + body: `export const normalizeOAuthIdentifier = input => input.trim().replace(/^@/, ''); + export async function getOAuthClient() { + return { + init: async () => ({ session: { + sub: ${JSON.stringify(did)}, + did: ${JSON.stringify(did)}, + fetchHandler: (url, init) => fetch(new URL(url, ${JSON.stringify(service)}), init) + }}), + revoke: async () => {} + }; + }`, + })); +} diff --git a/package-lock.json b/package-lock.json index ab2bc18..c6ee3a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,21 +9,31 @@ "version": "1.0.0", "dependencies": { "@atproto/api": "^0.0.0-spaces-alpha-20260818163953", + "@atproto/oauth-client-browser": "^0.5.7", + "@atproto/oauth-types": "^0.7.6", "@atproto/space": "^0.0.0-spaces-alpha-20260818163953", + "@touch4it/ical-timezones": "^1.9.0", "clsx": "^2.1.1", "date-fns": "^4.4.0", + "date-fns-tz": "^3.2.0", + "ical.js": "^2.2.1", "lucide-react": "^1.43.0", "react": "^19.2.8", "react-dom": "^19.2.8", + "rrule": "^2.8.1", "tailwind-merge": "^3.6.0", "vite-plugin-node-polyfills": "^0.28.0" }, "devDependencies": { + "@playwright/test": "^1.63.0", + "@testing-library/react": "^16.3.3", "@types/node": "^26.5.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", "@vitejs/plugin-react": "^6.1.1", "autoprefixer": "^10.5.5", + "fake-indexeddb": "^6.2.5", + "jsdom": "^30.0.1", "postcss": "^8.5.28", "tailwindcss": "^3.4.19", "typescript": "^7.0.2", @@ -44,6 +54,133 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@atproto-labs/did-resolver": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@atproto-labs/did-resolver/-/did-resolver-0.3.9.tgz", + "integrity": "sha512-KBgUdQQ76XL/xjQbuMfVBOEa5jaragR9NPq1uMoM4iXKnW5GGcEMHMPu+WrTxYNBku0b7MhPUfCs1pKtyLBP6g==", + "license": "MIT", + "dependencies": { + "@atproto-labs/fetch": "^0.3.6", + "@atproto-labs/pipe": "^0.2.4", + "@atproto-labs/simple-store": "^0.5.1", + "@atproto-labs/simple-store-memory": "^0.2.6", + "@atproto/did": "^0.5.5", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/fetch": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.3.6.tgz", + "integrity": "sha512-ro/J/cnqIp4bnSzDl8N3SkruvQcE3Pze+1H70mKdFfyFcdhPr+7O4lggl4VCbhI84Yqgz47c0xC6F/Yl39SxDA==", + "license": "MIT", + "dependencies": { + "@atproto-labs/pipe": "^0.2.4" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/handle-resolver": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/@atproto-labs/handle-resolver/-/handle-resolver-0.4.9.tgz", + "integrity": "sha512-I2xrVsgw0M1Gp5qAWQwX8aoq6E79PnNHuWX+mg7zMRpuazA5DWiHoJOyBZHPeEtbtYO3F8g3le7PRL8imaWljw==", + "license": "MIT", + "dependencies": { + "@atproto-labs/simple-store": "^0.5.1", + "@atproto-labs/simple-store-memory": "^0.2.6", + "@atproto/did": "^0.5.5", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/identity-resolver": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.4.9.tgz", + "integrity": "sha512-dVfbDHybOlDimEdUSbDfCQpMbmf25LNpffxCqSE//hLD86wiUzJQwJn2sHfskZTfwGD896wyY8musfrR3vDnOA==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "^0.3.9", + "@atproto-labs/handle-resolver": "^0.4.9" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/pipe": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.2.4.tgz", + "integrity": "sha512-n67jCcrC+ouAeO10cWkpPzzLMlDi/lDCU30Us+LGqhOPhT6c4t5ASdBLQi9W3jUQtRzQBt3G9zipF+xKWNvVbw==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/simple-store": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store/-/simple-store-0.5.1.tgz", + "integrity": "sha512-vfvoDhu6ds6BT3Pqe+d2/LBU1WpDRtt69y6zltlfMCoucPm82m1j50/Wb6xTvv+oZ+2j0JyZVXsmXQ12SWYQJg==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/simple-store-memory": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@atproto-labs/simple-store-memory/-/simple-store-memory-0.2.6.tgz", + "integrity": "sha512-DD1v7MEfYF3BAcEpMTTpzfBLWLoI2HuyBhku2YpjHoqPrRrhCtE1alkxfPHjtjJVIjuU/XNU0cF/wB3+5FiKsA==", + "license": "MIT", + "dependencies": { + "@atproto-labs/simple-store": "^0.5.1", + "lru-cache": "^10.2.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto-labs/simple-store-memory/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/@atproto/api": { "version": "0.0.0-spaces-alpha-20260818163953", "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.0.0-spaces-alpha-20260818163953.tgz", @@ -109,6 +246,18 @@ "node": ">=22" } }, + "node_modules/@atproto/did": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@atproto/did/-/did-0.5.5.tgz", + "integrity": "sha512-ibCxTPsPQtrtX+iidnE0bkMBHuoWFBdVfPFfCAlUKY6HfkqdUUxItzvQu9AMucYDhP6vJ15HLVj+QhNY2jtIvw==", + "license": "MIT", + "dependencies": { + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@atproto/jwk": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/@atproto/jwk/-/jwk-0.7.4.tgz", @@ -122,6 +271,33 @@ "node": ">=22" } }, + "node_modules/@atproto/jwk-jose": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@atproto/jwk-jose/-/jwk-jose-0.2.4.tgz", + "integrity": "sha512-gzDoA0JTwnc0ZJOBLM7WX9xFxtynRS2K1Bofb8epzoMWDQvyvfbPcfkdPrKFM7NXCFUVpGpBnsCB8KFPTf1rCg==", + "license": "MIT", + "dependencies": { + "@atproto/jwk": "^0.7.4", + "jose": "^5.2.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/jwk-webcrypto": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@atproto/jwk-webcrypto/-/jwk-webcrypto-0.3.4.tgz", + "integrity": "sha512-UsFIUozqnRecXPo6HgKV4PW4FqYHxX1V3iAe0rRV6Q2RSfYD8V2mZ89pv8NpvJynnaqJArBQ7HZlcg0F4tRYhA==", + "license": "MIT", + "dependencies": { + "@atproto/jwk": "^0.7.4", + "@atproto/jwk-jose": "^0.2.4", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@atproto/lex-cbor": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/@atproto/lex-cbor/-/lex-cbor-0.1.6.tgz", @@ -178,6 +354,120 @@ "node": ">=22" } }, + "node_modules/@atproto/oauth-client": { + "version": "0.8.7", + "resolved": "https://registry.npmjs.org/@atproto/oauth-client/-/oauth-client-0.8.7.tgz", + "integrity": "sha512-DbWMFcy6t2HyyPKVEhKeh8xNNwM6sokPl/Qk7ctZLvEu+FT4GKsUppgf2hkWqeUyVc7nacu+nE7dCkYwPZmBKA==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "^0.3.9", + "@atproto-labs/fetch": "^0.3.6", + "@atproto-labs/handle-resolver": "^0.4.9", + "@atproto-labs/identity-resolver": "^0.4.9", + "@atproto-labs/simple-store": "^0.5.1", + "@atproto-labs/simple-store-memory": "^0.2.6", + "@atproto/did": "^0.5.5", + "@atproto/jwk": "^0.7.4", + "@atproto/oauth-types": "^0.7.6", + "@atproto/xrpc": "^0.8.13", + "core-js": "^3.50.0", + "multiformats": "^13.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client-browser": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@atproto/oauth-client-browser/-/oauth-client-browser-0.5.7.tgz", + "integrity": "sha512-AvCl5Of2NDW/yUhDSyOufmhmGnfa9ScNPSzOVYLDrJvm2t/5DdHoRu4gmtwwe2L6rf6hbUUFziA80drJm+0D0w==", + "license": "MIT", + "dependencies": { + "@atproto-labs/did-resolver": "^0.3.9", + "@atproto-labs/handle-resolver": "^0.4.9", + "@atproto-labs/simple-store": "^0.5.1", + "@atproto/did": "^0.5.5", + "@atproto/jwk": "^0.7.4", + "@atproto/jwk-webcrypto": "^0.3.4", + "@atproto/oauth-client": "^0.8.7", + "@atproto/oauth-types": "^0.7.6", + "core-js": "^3.50.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client/node_modules/@atproto/common-web": { + "version": "0.5.12", + "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.5.12.tgz", + "integrity": "sha512-FumNnUOUsk68gP+jU0sXY2wY2piqFV9/qU2YaoQq/IZknhJBlLYCzjQXXCN0FCzxAZWD0YUzgydeyuRvFoAxfA==", + "license": "MIT", + "dependencies": { + "@atproto/lex-data": "^0.1.7", + "@atproto/lex-json": "^0.1.6", + "@atproto/syntax": "^0.7.6", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client/node_modules/@atproto/lexicon": { + "version": "0.7.14", + "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.7.14.tgz", + "integrity": "sha512-hDG2+61JTrvxDFtoojDFxKmZkFVl/lR/jFi82gSN0zNDjr+dlpUGKlG9MoPBkUKK83+Pi+I2jZ55xXboJq6JTA==", + "license": "MIT", + "dependencies": { + "@atproto/common-web": "^0.5.12", + "@atproto/syntax": "^0.7.6", + "multiformats": "^13.0.0", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client/node_modules/@atproto/syntax": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.7.6.tgz", + "integrity": "sha512-luKQTcWw1H1jLmYvX34ldBqNjz2orF082njmcF9fxbWTqVhsO+TpY0FHRKVPoV7dwL0Y3L16DNz1ma0LFGH25g==", + "license": "MIT", + "dependencies": { + "iso-datestring-validator": "^2.2.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-client/node_modules/@atproto/xrpc": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.8.13.tgz", + "integrity": "sha512-htpi93/Zqyk5WsT+RrWemxMcJFJx2M4VT8i8avuWJTyr7AZ42qvPsIdVsaPdOOS3rZwodq/SdZXJFgspJ4yc5g==", + "license": "MIT", + "dependencies": { + "@atproto/lexicon": "^0.7.14", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@atproto/oauth-types": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.7.6.tgz", + "integrity": "sha512-FgFe2mJOUgJSQBxKBwLWv+lFyDxBnlqA+wfSLabCNnabtNuSO3UQI0CGI72K7/OQRQvdwkcYQMoUuw3Rqex/mg==", + "license": "MIT", + "dependencies": { + "@atproto/did": "^0.5.5", + "@atproto/jwk": "^0.7.4", + "zod": "^3.23.8" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@atproto/space": { "version": "0.0.0-spaces-alpha-20260818163953", "resolved": "https://registry.npmjs.org/@atproto/space/-/space-0.0.0-spaces-alpha-20260818163953.tgz", @@ -223,6 +513,214 @@ "node": ">=22" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz", + "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.13.tgz", + "integrity": "sha512-i9ZylF5QNhmNfPA9l0vHAWK4kPrbIp6g9lKgaiIFsIBz2F/WNB7OLrzlNNcCOm+h42bkaSD2v1PG+IBPHhc3ZA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -341,6 +839,22 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", @@ -631,6 +1145,72 @@ } } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@touch4it/ical-timezones": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@touch4it/ical-timezones/-/ical-timezones-1.9.0.tgz", + "integrity": "sha512-UAiZMrFlgMdOIaJDPsKu5S7OecyMLr3GGALJTYkRgHmsHAA/8Ixm1qD09ELP2X7U1lqgrctEgvKj9GzMbczC+g==", + "license": "ISC", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1103,14 +1683,39 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/@vitest/spy": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", - "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "node_modules/@vitest/spy": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", + "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/any-promise": { @@ -1154,6 +1759,17 @@ "dev": true, "license": "MIT" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/asn1.js": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", @@ -1294,6 +1910,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1712,6 +2338,20 @@ "integrity": "sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==", "license": "MIT" }, + "node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -1793,6 +2433,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1813,6 +2467,35 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/date-fns": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", @@ -1823,6 +2506,22 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/date-fns-tz": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz", + "integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==", + "license": "MIT", + "peerDependencies": { + "date-fns": "^3.0.0 || ^4.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -1857,6 +2556,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/des.js": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", @@ -1907,6 +2617,14 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/domain-browser": { "version": "4.22.0", "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-4.22.0.tgz", @@ -1961,6 +2679,19 @@ "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", "license": "MIT" }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -2043,6 +2774,16 @@ "node": ">=12.0.0" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -2337,12 +3078,31 @@ "minimalistic-crypto-utils": "^1.0.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/https-browserify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", "integrity": "sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==", "license": "MIT" }, + "node_modules/ical.js": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ical.js/-/ical.js-2.2.1.tgz", + "integrity": "sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg==", + "license": "MPL-2.0" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -2493,6 +3253,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -2566,6 +3333,55 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -2850,6 +3666,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/lucide-react": { "version": "1.43.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.43.0.tgz", @@ -2859,6 +3685,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2888,6 +3725,13 @@ "safe-buffer": "^5.1.2" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3208,6 +4052,19 @@ "node": ">= 0.10" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -3323,6 +4180,35 @@ "node": ">=10" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -3494,6 +4380,22 @@ "dev": true, "license": "MIT" }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -3642,6 +4544,14 @@ "react": "^19.2.8" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/read-cache": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz", @@ -3698,6 +4608,16 @@ "node": ">= 12.13.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -3833,6 +4753,15 @@ "@rolldown/binding-win32-x64-msvc": "1.2.7" } }, + "node_modules/rrule": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz", + "integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -3903,6 +4832,19 @@ "node": ">=10" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -4138,6 +5080,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", @@ -4284,6 +5233,26 @@ "tlds": "bin.js" } }, + "node_modules/tldts": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz", + "integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.12" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz", + "integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==", + "dev": true, + "license": "MIT" + }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -4311,6 +5280,42 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tr46/node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -4388,6 +5393,16 @@ "multiformats": "^13.0.0" } }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", @@ -4662,6 +5677,54 @@ "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz", + "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which-typed-array": { "version": "1.1.22", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", @@ -4700,6 +5763,23 @@ "node": ">=8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 97ba609..7be1e21 100644 --- a/package.json +++ b/package.json @@ -7,25 +7,37 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run", + "test:timezones": "TZ=UTC vitest run tests/timezoneBehavior.test.ts tests/recurrence.test.ts && TZ=America/New_York vitest run tests/timezoneBehavior.test.ts tests/recurrence.test.ts && TZ=Europe/Madrid vitest run tests/timezoneBehavior.test.ts tests/recurrence.test.ts && TZ=Asia/Tokyo vitest run tests/timezoneBehavior.test.ts tests/recurrence.test.ts", + "test:browser": "playwright test" }, "dependencies": { "@atproto/api": "^0.0.0-spaces-alpha-20260818163953", + "@atproto/oauth-client-browser": "^0.5.7", + "@atproto/oauth-types": "^0.7.6", "@atproto/space": "^0.0.0-spaces-alpha-20260818163953", + "@touch4it/ical-timezones": "^1.9.0", "clsx": "^2.1.1", "date-fns": "^4.4.0", + "date-fns-tz": "^3.2.0", + "ical.js": "^2.2.1", "lucide-react": "^1.43.0", "react": "^19.2.8", "react-dom": "^19.2.8", + "rrule": "^2.8.1", "tailwind-merge": "^3.6.0", "vite-plugin-node-polyfills": "^0.28.0" }, "devDependencies": { + "@playwright/test": "^1.63.0", + "@testing-library/react": "^16.3.3", "@types/node": "^26.5.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", "@vitejs/plugin-react": "^6.1.1", "autoprefixer": "^10.5.5", + "fake-indexeddb": "^6.2.5", + "jsdom": "^30.0.1", "postcss": "^8.5.28", "tailwindcss": "^3.4.19", "typescript": "^7.0.2", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..50ee959 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + use: { + baseURL: 'http://127.0.0.1:5181', + viewport: { width: 1440, height: 1000 }, + timezoneId: 'Europe/Madrid', + trace: 'retain-on-failure', + }, + webServer: { + command: 'npm run dev -- --port 5181', + url: 'http://127.0.0.1:5181', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..8e343d3 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,3478 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@atproto/api': + specifier: ^0.0.0-spaces-alpha-20260818163953 + version: 0.0.0-spaces-alpha-20260818163953 + '@atproto/space': + specifier: ^0.0.0-spaces-alpha-20260818163953 + version: 0.0.0-spaces-alpha-20260818163953 + '@touch4it/ical-timezones': + specifier: ^1.9.0 + version: 1.9.0 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + date-fns: + specifier: ^4.4.0 + version: 4.4.0 + date-fns-tz: + specifier: ^3.2.0 + version: 3.2.0(date-fns@4.4.0) + ical.js: + specifier: ^2.2.1 + version: 2.2.1 + lucide-react: + specifier: ^1.43.0 + version: 1.44.0(react@19.3.0) + react: + specifier: ^19.2.8 + version: 19.3.0 + react-dom: + specifier: ^19.2.8 + version: 19.3.0(react@19.3.0) + rrule: + specifier: ^2.8.1 + version: 2.8.1 + tailwind-merge: + specifier: ^3.6.0 + version: 3.6.0 + vite-plugin-node-polyfills: + specifier: ^0.28.0 + version: 0.28.0(vite@8.3.0(@types/node@26.5.1)(jiti@1.21.7)) + devDependencies: + '@playwright/test': + specifier: ^1.63.0 + version: 1.63.0 + '@testing-library/react': + specifier: ^16.3.3 + version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@types/node': + specifier: ^26.5.0 + version: 26.5.1 + '@types/react': + specifier: ^19.2.18 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.2.7 + version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: ^6.1.1 + version: 6.1.1(vite@8.3.0(@types/node@26.5.1)(jiti@1.21.7)) + autoprefixer: + specifier: ^10.5.5 + version: 10.5.6(postcss@8.5.28) + jsdom: + specifier: ^30.0.1 + version: 30.0.1(@noble/hashes@1.8.0) + postcss: + specifier: ^8.5.28 + version: 8.5.28 + tailwindcss: + specifier: ^3.4.19 + version: 3.4.19 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + vite: + specifier: ^8.2.2 + version: 8.3.0(@types/node@26.5.1)(jiti@1.21.7) + vitest: + specifier: ^5.0.0 + version: 5.0.0(@types/node@26.5.1)(jsdom@30.0.1(@noble/hashes@1.8.0))(vite@8.3.0(@types/node@26.5.1)(jiti@1.21.7)) + +packages: + + '@alloc/quick-lru@5.3.0': + resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==} + engines: {node: '>=10'} + + '@asamuzakjp/css-color@6.0.7': + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@atproto/api@0.0.0-spaces-alpha-20260818163953': + resolution: {integrity: sha512-5TZ3rHXTLGRQRxw/H17pwOvcuaAKXZFy24w//LCcfFcSuCdWIBxV9MDvBU47xCxotNOATw2/TENhmkaVWu2QJQ==} + engines: {node: '>=22'} + + '@atproto/common-web@0.0.0-spaces-alpha-20260818163953': + resolution: {integrity: sha512-9D7IJ5HAsQxFWibfL1+F26envBEoDR24e1q8diXrA8LfzVoRLP3zUlPWDG02zaumxgAPIIO5Yr5yuhzHYusEUg==} + engines: {node: '>=22'} + + '@atproto/common@0.0.0-spaces-alpha-20260818163953': + resolution: {integrity: sha512-lzXomMvIHMFbmXPpsnuNUok4yZB4E67FJyNL/D3RVqxzL+OgkyPilsOYPIVzCRLZFzrVdy8Lah7kfLdxaHptgQ==} + engines: {node: '>=22'} + + '@atproto/crypto@0.0.0-spaces-alpha-20260818163953': + resolution: {integrity: sha512-Cjqoa3BKUFaqkdZkiZy+aQJTPxFGFv0yNmbsF21zh+gK3Hvkjdgxg7TjlaFasLIy6RKY1k9weD3SztVKKMoZzw==} + engines: {node: '>=22'} + + '@atproto/jwk@0.7.4': + resolution: {integrity: sha512-tq7TUDmNfe1yDfpRgdGQMJdl9TUlJmREQNCag9yg5w8Evu+TOiFiLgiOCbo7X4ouRPSgd1DpOzXbUa8UyKKMZA==} + engines: {node: '>=22'} + + '@atproto/lex-cbor@0.1.6': + resolution: {integrity: sha512-PxLQy7jzV8u9zTGURNF8ZczLPk+ZH+WNx/2z/z7RacJbu7sWNpx26U2pC7cIkWtt4jyDaruaLhssw3w3atLotA==} + engines: {node: '>=22'} + + '@atproto/lex-data@0.1.7': + resolution: {integrity: sha512-kW/dPLqo/WgCLV+XESR4JKwV6c1rZWJGOfuPupZGTjEDAKoBbKXdaEzX9/1vKQYbZ9U3j0DS/n7OFFK7wBugyQ==} + engines: {node: '>=22'} + + '@atproto/lex-json@0.1.6': + resolution: {integrity: sha512-mvrAd0lbyuecIHjyld8QN6MN6CBf4j0GCxLzegsvLh0SvDf+GbYWklkcQqmITL44yFQOwmA/QNIQj0Uvh7+R/g==} + engines: {node: '>=22'} + + '@atproto/lexicon@0.0.0-spaces-alpha-20260818163953': + resolution: {integrity: sha512-ncg3KVMl4aLj+fEJp1vu3Og6ZkKo/tBzz+5XClsk8zYAwRe6qNsJ+cYW5GrECxPvxuCvUra7ko9wMCwz3VJFmg==} + engines: {node: '>=22'} + + '@atproto/space@0.0.0-spaces-alpha-20260818163953': + resolution: {integrity: sha512-0TQwGc7tD+ctccBD7ZD52BJ8RKUKR7hN2MtzDztJbTJSLIY4GYdr6XTnwdlsJBfDBvIqhpFp/9oHdELZLecuyw==} + engines: {node: '>=22'} + + '@atproto/syntax@0.0.0-spaces-alpha-20260818163953': + resolution: {integrity: sha512-Xl2fpD/NcfVOWpHR86HKym2XNDYHmgiqThZXoYMwh9XUo+/LgVZYGVpSecfKRBcQ5tNGiRivE1Fvio5YDfJ3lA==} + engines: {node: '>=22'} + + '@atproto/xrpc@0.0.0-spaces-alpha-20260818163953': + resolution: {integrity: sha512-AW2zqwpUh8C4pkiDNbmM1FLjmVm5TShaGKDlGAiTMuxf43rfQ+FeBucb9H5w2iaIUQJ594dN3lHXijBATkGNcw==} + engines: {node: '>=22'} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.1': + resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.2.2': + resolution: {integrity: sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.12': + resolution: {integrity: sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-project/types@0.149.0': + resolution: {integrity: sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + + '@rolldown/binding-android-arm-eabi@1.2.8': + resolution: {integrity: sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.8': + resolution: {integrity: sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.8': + resolution: {integrity: sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.8': + resolution: {integrity: sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.8': + resolution: {integrity: sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + resolution: {integrity: sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.8': + resolution: {integrity: sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.8': + resolution: {integrity: sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.8': + resolution: {integrity: sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.8': + resolution: {integrity: sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.8': + resolution: {integrity: sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.8': + resolution: {integrity: sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.8': + resolution: {integrity: sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.8': + resolution: {integrity: sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.8': + resolution: {integrity: sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/plugin-inject@5.0.5': + resolution: {integrity: sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@touch4it/ical-timezones@1.9.0': + resolution: {integrity: sha512-UAiZMrFlgMdOIaJDPsKu5S7OecyMLr3GGALJTYkRgHmsHAA/8Ixm1qD09ELP2X7U1lqgrctEgvKj9GzMbczC+g==} + engines: {node: '>= 14.0.0'} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@26.5.1': + resolution: {integrity: sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitejs/plugin-react@6.1.1': + resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + + '@vitest/mocker@5.0.0': + resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.0': + resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + asn1.js@4.10.1: + resolution: {integrity: sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==} + + assert@2.1.0: + resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + autoprefixer@10.5.6: + resolution: {integrity: sha512-HiH4oYNc5+DQEx/b8FPfMj+WHH/WWUBmbN5r1Uf/ixtfyFkk+wGIlfJT/RD5eAyzlwkeYRtZUCI8qrEi26pl2g==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + await-lock@3.0.0: + resolution: {integrity: sha512-eO6fLiSnrJrMdjWMNK8zbVRXPs2TKJg78iKZd9wDpN3na5tcoV6EoeiOlMgk2QaAQ1gIrK1YuMsJHXWqz89tSA==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.11.21: + resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bn.js@4.12.5: + resolution: {integrity: sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==} + + bn.js@5.2.5: + resolution: {integrity: sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + brorand@1.1.0: + resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==} + + browser-resolve@2.0.0: + resolution: {integrity: sha512-7sWsQlYL2rGLy2IWm8WL8DCTJvYLc/qlOnsakDac87SOoCd16WLsaAMdCiAqsTNHIe+SXfaqyxyo6THoWqs8WQ==} + + browserify-aes@1.2.0: + resolution: {integrity: sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==} + + browserify-cipher@1.0.1: + resolution: {integrity: sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==} + + browserify-des@1.0.2: + resolution: {integrity: sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==} + + browserify-rsa@4.1.1: + resolution: {integrity: sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==} + engines: {node: '>= 0.10'} + + browserify-sign@4.2.6: + resolution: {integrity: sha512-sd+Q65fjlWCYWtZKXiKfrUc8d+4jtp/8f0W2NkwzLtoW4bI6UDnWusLWIurHnmurW0XShIRxpwiOX4EoPtXUAg==} + engines: {node: '>= 0.10'} + + browserify-zlib@0.2.0: + resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==} + + browserslist@4.28.9: + resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-xor@1.0.3: + resolution: {integrity: sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + builtin-status-codes@3.0.0: + resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + + cborg@4.5.8: + resolution: {integrity: sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw==} + hasBin: true + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + cipher-base@1.0.7: + resolution: {integrity: sha512-Mz9QMT5fJe7bKI7MH31UilT5cEK5EHHRCccw/YRFsRY47AuNgaV6HY3rscp0/I4Q+tTW/5zoqpSeRRI54TkDWA==} + engines: {node: '>= 0.10'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + console-browserify@1.2.0: + resolution: {integrity: sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==} + + constants-browserify@1.0.0: + resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + create-ecdh@4.0.4: + resolution: {integrity: sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==} + + create-hash@1.2.0: + resolution: {integrity: sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==} + + create-hmac@1.1.7: + resolution: {integrity: sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==} + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + crypto-browserify@3.12.1: + resolution: {integrity: sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==} + engines: {node: '>= 0.10'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + date-fns-tz@3.2.0: + resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==} + peerDependencies: + date-fns: ^3.0.0 || ^4.0.0 + + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + des.js@1.1.0: + resolution: {integrity: sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + diffie-hellman@5.0.3: + resolution: {integrity: sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + domain-browser@4.22.0: + resolution: {integrity: sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw==} + engines: {node: '>=10'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.426: + resolution: {integrity: sha512-2Gcq6inCQs/AqfHP3f5ftCzk+pqeW2VlA1LgmPqEj2hfBO8HiZRspNYkD4HzFBe4u6lGqca4BspFr6Ix4Q400g==} + + elliptic@6.6.1: + resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==} + + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + evp_bytestokey@1.0.3: + resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hash-base@3.0.5: + resolution: {integrity: sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==} + engines: {node: '>= 0.10'} + + hash-base@3.1.2: + resolution: {integrity: sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==} + engines: {node: '>= 0.8'} + + hash.js@1.1.7: + resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hmac-drbg@1.0.1: + resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + https-browserify@1.0.0: + resolution: {integrity: sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==} + + ical.js@2.2.1: + resolution: {integrity: sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-nan@1.3.2: + resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + iso-datestring-validator@2.2.2: + resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==} + + isomorphic-timers-promises@1.0.1: + resolution: {integrity: sha512-u4sej9B1LPSxTGKB/HiuzvEQnXH0ECYkSVQU39koSwmFAxhlEAFl9RdTvLv4TOTQUgBS5O3O5fwUxk6byBZ+IQ==} + engines: {node: '>=10'} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lucide-react@1.44.0: + resolution: {integrity: sha512-2egNApH4hX4j/qdCgRublh88+9u3mEhz9iSlW5ckm4kaQEqZbXbMr0l5u5JZLy8nmWRx2dbHGQkEDYz6C9aCgw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magic-string@1.3.1: + resolution: {integrity: sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + md5.js@1.3.5: + resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + miller-rabin@4.0.1: + resolution: {integrity: sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==} + hasBin: true + + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + + minimalistic-crypto-utils@1.0.1: + resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} + + multiformats@13.4.2: + resolution: {integrity: sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.55: + resolution: {integrity: sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==} + engines: {node: '>=18'} + + node-stdlib-browser@1.3.1: + resolution: {integrity: sha512-X75ZN8DCLftGM5iKwoYLA3rjnrAEs97MkzvSd4q2746Tgpg8b8XWiBGiBG4ZpgcAqBgtgPHTiAc8ZMCvZuikDw==} + engines: {node: '>=10'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + os-browserify@0.3.0: + resolution: {integrity: sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parse-asn1@5.1.9: + resolution: {integrity: sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg==} + engines: {node: '>= 0.10'} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + pbkdf2@3.1.6: + resolution: {integrity: sha512-BT6eelPB1EyGHo8pC0o9Bl6k6SYVhKO1jEbd3lcTrtr7XHdjP8BW1YpfCV3G9Kwkxgattk+S5q2/RvuttCsS1g==} + engines: {node: '>= 0.10'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@5.0.0: + resolution: {integrity: sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==} + engines: {node: '>=10'} + + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.4: + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + public-encrypt@4.0.3: + resolution: {integrity: sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==} + + punycode@1.4.1: + resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + + querystring-es3@0.2.1: + resolution: {integrity: sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==} + engines: {node: '>=0.4.x'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + randomfill@1.0.4: + resolution: {integrity: sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==} + + react-dom@19.3.0: + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} + peerDependencies: + react: ^19.3.0 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.2: + resolution: {integrity: sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + ripemd160@2.0.3: + resolution: {integrity: sha512-5Di9UC0+8h1L6ZD2d7awM7E/T4uA1fJRlx6zk/NvdCCVEoAnFqvHmCuNeIKoCeIixBX/q8uM+6ycDvF8woqosA==} + engines: {node: '>= 0.8'} + + rolldown@1.2.8: + resolution: {integrity: sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + rrule@2.8.1: + resolution: {integrity: sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + sha.js@2.4.12: + resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} + engines: {node: '>= 0.10'} + hasBin: true + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + stream-browserify@3.0.0: + resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} + + stream-http@3.2.0: + resolution: {integrity: sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A==} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + timers-browserify@2.0.12: + resolution: {integrity: sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==} + engines: {node: '>=0.6.0'} + + tinybench@6.1.4: + resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tlds@1.261.0: + resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} + hasBin: true + + tldts-core@7.4.12: + resolution: {integrity: sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==} + + tldts@7.4.12: + resolution: {integrity: sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==} + hasBin: true + + to-buffer@1.2.2: + resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} + engines: {node: '>= 0.4'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tty-browserify@0.0.1: + resolution: {integrity: sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + uint8arrays@5.1.1: + resolution: {integrity: sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==} + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + undici@8.10.2: + resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} + engines: {node: '>=22.19.0'} + + unicode-segmenter@0.14.5: + resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==} + + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + url@0.11.4: + resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} + engines: {node: '>= 0.4'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + + varint@6.0.0: + resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==} + + vite-plugin-node-polyfills@0.28.0: + resolution: {integrity: sha512-NXct/ci2ef4fRyCfTb8fk2HmR80Rv7icLd+cRH41TnUugDzdKMFKqFPpZYCFUInZMMem9bkLv5pkq02+7Xu7+w==} + peerDependencies: + vite: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + + vite@8.3.0: + resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.0: + resolution: {integrity: sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.0 + '@vitest/browser-preview': 5.0.0 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.0 + '@vitest/coverage-v8': 5.0.0 + '@vitest/ui': 5.0.0 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vm-browserify@1.1.2: + resolution: {integrity: sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==} + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.1: + resolution: {integrity: sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==} + engines: {node: ^22.14.0 || >=24.0.0} + + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@alloc/quick-lru@5.3.0': {} + + '@asamuzakjp/css-color@6.0.7': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + + '@atproto/api@0.0.0-spaces-alpha-20260818163953': + dependencies: + '@atproto/common-web': 0.0.0-spaces-alpha-20260818163953 + '@atproto/lexicon': 0.0.0-spaces-alpha-20260818163953 + '@atproto/syntax': 0.0.0-spaces-alpha-20260818163953 + '@atproto/xrpc': 0.0.0-spaces-alpha-20260818163953 + await-lock: 3.0.0 + multiformats: 13.4.2 + tlds: 1.261.0 + zod: 3.25.76 + + '@atproto/common-web@0.0.0-spaces-alpha-20260818163953': + dependencies: + '@atproto/lex-data': 0.1.7 + '@atproto/lex-json': 0.1.6 + '@atproto/syntax': 0.0.0-spaces-alpha-20260818163953 + zod: 3.25.76 + + '@atproto/common@0.0.0-spaces-alpha-20260818163953': + dependencies: + '@atproto/common-web': 0.0.0-spaces-alpha-20260818163953 + '@atproto/lex-cbor': 0.1.6 + '@atproto/lex-data': 0.1.7 + multiformats: 13.4.2 + pino: 10.3.1 + varint: 6.0.0 + + '@atproto/crypto@0.0.0-spaces-alpha-20260818163953': + dependencies: + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + uint8arrays: 5.1.1 + + '@atproto/jwk@0.7.4': + dependencies: + multiformats: 13.4.2 + zod: 3.25.76 + + '@atproto/lex-cbor@0.1.6': + dependencies: + '@atproto/lex-data': 0.1.7 + cborg: 4.5.8 + tslib: 2.8.1 + + '@atproto/lex-data@0.1.7': + dependencies: + multiformats: 13.4.2 + tslib: 2.8.1 + unicode-segmenter: 0.14.5 + + '@atproto/lex-json@0.1.6': + dependencies: + '@atproto/lex-data': 0.1.7 + tslib: 2.8.1 + + '@atproto/lexicon@0.0.0-spaces-alpha-20260818163953': + dependencies: + '@atproto/common-web': 0.0.0-spaces-alpha-20260818163953 + '@atproto/syntax': 0.0.0-spaces-alpha-20260818163953 + multiformats: 13.4.2 + zod: 3.25.76 + + '@atproto/space@0.0.0-spaces-alpha-20260818163953': + dependencies: + '@atproto/common': 0.0.0-spaces-alpha-20260818163953 + '@atproto/crypto': 0.0.0-spaces-alpha-20260818163953 + '@atproto/jwk': 0.7.4 + '@atproto/lex-cbor': 0.1.6 + '@atproto/lex-data': 0.1.7 + '@noble/hashes': 1.8.0 + jose: 5.10.0 + zod: 3.25.76 + + '@atproto/syntax@0.0.0-spaces-alpha-20260818163953': + dependencies: + iso-datestring-validator: 2.2.2 + tslib: 2.8.1 + + '@atproto/xrpc@0.0.0-spaces-alpha-20260818163953': + dependencies: + '@atproto/lexicon': 0.0.0-spaces-alpha-20260818163953 + zod: 3.25.76 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/runtime@7.29.7': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.1': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.2.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.12(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@exodus/bytes@1.15.1(@noble/hashes@1.8.0)': + optionalDependencies: + '@noble/hashes': 1.8.0 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.3 + + '@oxc-project/types@0.149.0': {} + + '@pinojs/redact@0.4.0': {} + + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + + '@rolldown/binding-android-arm-eabi@1.2.8': + optional: true + + '@rolldown/binding-android-arm64@1.2.8': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.8': + optional: true + + '@rolldown/binding-darwin-x64@1.2.8': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.8': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.8': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.8': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.8': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.8': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.8': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.8': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.8': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/plugin-inject@5.0.5': + dependencies: + '@rollup/pluginutils': 5.4.0 + estree-walker: 2.0.2 + magic-string: 0.30.21 + + '@rollup/pluginutils@5.4.0': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.7 + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@touch4it/ical-timezones@1.9.0': {} + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@26.5.1': + dependencies: + undici-types: 8.9.0 + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitejs/plugin-react@6.1.1(vite@8.3.0(@types/node@26.5.1)(jiti@1.21.7))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.0(@types/node@26.5.1)(jiti@1.21.7) + + '@vitest/mocker@5.0.0(vite@8.3.0(@types/node@26.5.1)(jiti@1.21.7))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.0 + estree-walker: 3.0.3 + magic-string: 1.3.1 + optionalDependencies: + vite: 8.3.0(@types/node@26.5.1)(jiti@1.21.7) + + '@vitest/spy@5.0.0': {} + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + asn1.js@4.10.1: + dependencies: + bn.js: 4.12.5 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + + assert@2.1.0: + dependencies: + call-bind: 1.0.9 + is-nan: 1.3.2 + object-is: 1.1.6 + object.assign: 4.1.7 + util: 0.12.5 + + assertion-error@2.0.1: {} + + atomic-sleep@1.0.0: {} + + autoprefixer@10.5.6(postcss@8.5.28): + dependencies: + browserslist: 4.28.9 + caniuse-lite: 1.0.30001810 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.28 + postcss-value-parser: 4.2.0 + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + await-lock@3.0.0: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.11.21: {} + + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + + binary-extensions@2.3.0: {} + + bn.js@4.12.5: {} + + bn.js@5.2.5: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + brorand@1.1.0: {} + + browser-resolve@2.0.0: + dependencies: + resolve: 1.22.12 + + browserify-aes@1.2.0: + dependencies: + buffer-xor: 1.0.3 + cipher-base: 1.0.7 + create-hash: 1.2.0 + evp_bytestokey: 1.0.3 + inherits: 2.0.4 + safe-buffer: 5.2.1 + + browserify-cipher@1.0.1: + dependencies: + browserify-aes: 1.2.0 + browserify-des: 1.0.2 + evp_bytestokey: 1.0.3 + + browserify-des@1.0.2: + dependencies: + cipher-base: 1.0.7 + des.js: 1.1.0 + inherits: 2.0.4 + safe-buffer: 5.2.1 + + browserify-rsa@4.1.1: + dependencies: + bn.js: 5.2.5 + randombytes: 2.1.0 + safe-buffer: 5.2.1 + + browserify-sign@4.2.6: + dependencies: + bn.js: 5.2.5 + browserify-rsa: 4.1.1 + create-hash: 1.2.0 + create-hmac: 1.1.7 + elliptic: 6.6.1 + inherits: 2.0.4 + parse-asn1: 5.1.9 + readable-stream: 2.3.8 + safe-buffer: 5.2.1 + + browserify-zlib@0.2.0: + dependencies: + pako: 1.0.11 + + browserslist@4.28.9: + dependencies: + baseline-browser-mapping: 2.11.21 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.426 + node-releases: 2.0.55 + update-browserslist-db: 1.3.2(browserslist@4.28.9) + + buffer-xor@1.0.3: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + builtin-status-codes@3.0.0: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001810: {} + + cborg@4.5.8: {} + + chai@6.2.2: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + cipher-base@1.0.7: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + + clsx@2.1.1: {} + + commander@4.1.1: {} + + console-browserify@1.2.0: {} + + constants-browserify@1.0.0: {} + + core-util-is@1.0.3: {} + + create-ecdh@4.0.4: + dependencies: + bn.js: 4.12.5 + elliptic: 6.6.1 + + create-hash@1.2.0: + dependencies: + cipher-base: 1.0.7 + inherits: 2.0.4 + md5.js: 1.3.5 + ripemd160: 2.0.3 + sha.js: 2.4.12 + + create-hmac@1.1.7: + dependencies: + cipher-base: 1.0.7 + create-hash: 1.2.0 + inherits: 2.0.4 + ripemd160: 2.0.3 + safe-buffer: 5.2.1 + sha.js: 2.4.12 + + create-require@1.1.1: {} + + crypto-browserify@3.12.1: + dependencies: + browserify-cipher: 1.0.1 + browserify-sign: 4.2.6 + create-ecdh: 4.0.4 + create-hash: 1.2.0 + create-hmac: 1.1.7 + diffie-hellman: 5.0.3 + hash-base: 3.0.5 + inherits: 2.0.4 + pbkdf2: 3.1.6 + public-encrypt: 4.0.3 + randombytes: 2.1.0 + randomfill: 1.0.4 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + data-urls@7.0.0(@noble/hashes@1.8.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + + date-fns-tz@3.2.0(date-fns@4.4.0): + dependencies: + date-fns: 4.4.0 + + date-fns@4.4.0: {} + + decimal.js@10.6.0: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + dequal@2.0.3: {} + + des.js@1.1.0: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + + detect-libc@2.1.2: {} + + didyoumean@1.2.2: {} + + diffie-hellman@5.0.3: + dependencies: + bn.js: 4.12.5 + miller-rabin: 4.0.1 + randombytes: 2.1.0 + + dlv@1.1.3: {} + + dom-accessibility-api@0.5.16: {} + + domain-browser@4.22.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.426: {} + + elliptic@6.6.1: + dependencies: + bn.js: 4.12.5 + brorand: 1.1.0 + hash.js: 1.1.7 + hmac-drbg: 1.0.1 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + + entities@8.1.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.2: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + escalade@3.2.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + events@3.3.0: {} + + evp_bytestokey@1.0.3: + dependencies: + md5.js: 1.3.5 + safe-buffer: 5.2.1 + + expect-type@1.4.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.20.3: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + fraction.js@5.3.4: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + generator-function@2.0.1: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + gopd@1.2.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hash-base@3.0.5: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + + hash-base@3.1.2: + dependencies: + inherits: 2.0.4 + readable-stream: 2.3.8 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + + hash.js@1.1.7: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hmac-drbg@1.0.1: + dependencies: + hash.js: 1.1.7 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + + html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + + https-browserify@1.0.0: {} + + ical.js@2.2.1: {} + + ieee754@1.2.1: {} + + inherits@2.0.4: {} + + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-callable@1.2.7: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-extglob@2.1.1: {} + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-nan@1.3.2: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + + is-number@7.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.22 + + isarray@1.0.0: {} + + isarray@2.0.5: {} + + iso-datestring-validator@2.2.2: {} + + isomorphic-timers-promises@1.0.1: {} + + jiti@1.21.7: {} + + jose@5.10.0: {} + + js-tokens@4.0.0: {} + + jsdom@30.0.1(@noble/hashes@1.8.0): + dependencies: + '@asamuzakjp/css-color': 6.0.7 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.12(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@1.8.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.1(@noble/hashes@1.8.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lru-cache@11.5.2: {} + + lucide-react@1.44.0(react@19.3.0): + dependencies: + react: 19.3.0 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magic-string@1.3.1: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + math-intrinsics@1.1.0: {} + + md5.js@1.3.5: + dependencies: + hash-base: 3.0.5 + inherits: 2.0.4 + safe-buffer: 5.2.1 + + mdn-data@2.27.1: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + miller-rabin@4.0.1: + dependencies: + bn.js: 4.12.5 + brorand: 1.1.0 + + minimalistic-assert@1.0.1: {} + + minimalistic-crypto-utils@1.0.1: {} + + multiformats@13.4.2: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.18: {} + + node-releases@2.0.55: {} + + node-stdlib-browser@1.3.1: + dependencies: + assert: 2.1.0 + browser-resolve: 2.0.0 + browserify-zlib: 0.2.0 + buffer: 5.7.1 + console-browserify: 1.2.0 + constants-browserify: 1.0.0 + create-require: 1.1.1 + crypto-browserify: 3.12.1 + domain-browser: 4.22.0 + events: 3.3.0 + https-browserify: 1.0.0 + isomorphic-timers-promises: 1.0.1 + os-browserify: 0.3.0 + path-browserify: 1.0.1 + pkg-dir: 5.0.0 + process: 0.11.10 + punycode: 1.4.1 + querystring-es3: 0.2.1 + readable-stream: 3.6.2 + stream-browserify: 3.0.0 + stream-http: 3.2.0 + string_decoder: 1.3.0 + timers-browserify: 2.0.12 + tty-browserify: 0.0.1 + url: 0.11.4 + util: 0.12.5 + vm-browserify: 1.1.2 + + normalize-path@3.0.0: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + object-inspect@1.13.4: {} + + object-is@1.1.6: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + obug@2.2.1: {} + + on-exit-leak-free@2.1.2: {} + + os-browserify@0.3.0: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + pako@1.0.11: {} + + parse-asn1@5.1.9: + dependencies: + asn1.js: 4.10.1 + browserify-aes: 1.2.0 + evp_bytestokey: 1.0.3 + pbkdf2: 3.1.6 + safe-buffer: 5.2.1 + + parse5@8.0.1: + dependencies: + entities: 8.1.0 + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-parse@1.0.7: {} + + pbkdf2@3.1.6: + dependencies: + create-hash: 1.2.0 + create-hmac: 1.1.7 + ripemd160: 2.0.3 + safe-buffer: 5.2.1 + sha.js: 2.4.12 + to-buffer: 1.2.2 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.7: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + pirates@4.0.7: {} + + pkg-dir@5.0.0: + dependencies: + find-up: 5.0.0 + + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + + possible-typed-array-names@1.1.0: {} + + postcss-import@15.1.0(postcss@8.5.28): + dependencies: + postcss: 8.5.28 + postcss-value-parser: 4.2.0 + read-cache: 1.0.2 + resolve: 1.22.12 + + postcss-js@4.1.0(postcss@8.5.28): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.28 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.28): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.28 + + postcss-nested@6.2.0(postcss@8.5.28): + dependencies: + postcss: 8.5.28 + postcss-selector-parser: 6.1.4 + + postcss-selector-parser@6.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + process-nextick-args@2.0.1: {} + + process-warning@5.1.0: {} + + process@0.11.10: {} + + public-encrypt@4.0.3: + dependencies: + bn.js: 4.12.5 + browserify-rsa: 4.1.1 + create-hash: 1.2.0 + parse-asn1: 5.1.9 + randombytes: 2.1.0 + safe-buffer: 5.2.1 + + punycode@1.4.1: {} + + punycode@2.3.1: {} + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + querystring-es3@0.2.1: {} + + queue-microtask@1.2.3: {} + + quick-format-unescaped@4.0.4: {} + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + randomfill@1.0.4: + dependencies: + randombytes: 2.1.0 + safe-buffer: 5.2.1 + + react-dom@19.3.0(react@19.3.0): + dependencies: + react: 19.3.0 + scheduler: 0.28.0 + + react-is@17.0.2: {} + + react@19.3.0: {} + + read-cache@1.0.2: {} + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + require-from-string@2.0.2: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + ripemd160@2.0.3: + dependencies: + hash-base: 3.1.2 + inherits: 2.0.4 + + rolldown@1.2.8: + dependencies: + '@oxc-project/types': 0.149.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.8 + '@rolldown/binding-android-arm64': 1.2.8 + '@rolldown/binding-darwin-arm64': 1.2.8 + '@rolldown/binding-darwin-x64': 1.2.8 + '@rolldown/binding-freebsd-x64': 1.2.8 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.8 + '@rolldown/binding-linux-arm64-gnu': 1.2.8 + '@rolldown/binding-linux-arm64-musl': 1.2.8 + '@rolldown/binding-linux-ppc64-gnu': 1.2.8 + '@rolldown/binding-linux-s390x-gnu': 1.2.8 + '@rolldown/binding-linux-x64-gnu': 1.2.8 + '@rolldown/binding-linux-x64-musl': 1.2.8 + '@rolldown/binding-openharmony-arm64': 1.2.8 + '@rolldown/binding-win32-arm64-msvc': 1.2.8 + '@rolldown/binding-win32-x64-msvc': 1.2.8 + + rrule@2.8.1: + dependencies: + tslib: 2.8.1 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safe-stable-stringify@2.5.0: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.28.0: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + setimmediate@1.0.5: {} + + sha.js@2.4.12: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + split2@4.2.0: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + stream-browserify@3.0.0: + dependencies: + inherits: 2.0.4 + readable-stream: 3.6.2 + + stream-http@3.2.0: + dependencies: + builtin-status-codes: 3.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + xtend: 4.0.2 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-tree@3.2.4: {} + + tailwind-merge@3.6.0: {} + + tailwindcss@3.4.19: + dependencies: + '@alloc/quick-lru': 5.3.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.28 + postcss-import: 15.1.0(postcss@8.5.28) + postcss-js: 4.1.0(postcss@8.5.28) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.28) + postcss-nested: 6.2.0(postcss@8.5.28) + postcss-selector-parser: 6.1.4 + resolve: 1.22.12 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + timers-browserify@2.0.12: + dependencies: + setimmediate: 1.0.5 + + tinybench@6.1.4: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tlds@1.261.0: {} + + tldts-core@7.4.12: {} + + tldts@7.4.12: + dependencies: + tldts-core: 7.4.12 + + to-buffer@1.2.2: + dependencies: + isarray: 2.0.5 + safe-buffer: 5.2.1 + typed-array-buffer: 1.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.12 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + ts-interface-checker@0.1.13: {} + + tslib@2.8.1: {} + + tty-browserify@0.0.1: {} + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + uint8arrays@5.1.1: + dependencies: + multiformats: 13.4.2 + + undici-types@8.9.0: {} + + undici@8.10.2: {} + + unicode-segmenter@0.14.5: {} + + update-browserslist-db@1.3.2(browserslist@4.28.9): + dependencies: + browserslist: 4.28.9 + escalade: 3.2.0 + picocolors: 1.1.1 + + url@0.11.4: + dependencies: + punycode: 1.4.1 + qs: 6.16.0 + + util-deprecate@1.0.2: {} + + util@0.12.5: + dependencies: + inherits: 2.0.4 + is-arguments: 1.2.0 + is-generator-function: 1.1.2 + is-typed-array: 1.1.15 + which-typed-array: 1.1.22 + + varint@6.0.0: {} + + vite-plugin-node-polyfills@0.28.0(vite@8.3.0(@types/node@26.5.1)(jiti@1.21.7)): + dependencies: + '@rollup/plugin-inject': 5.0.5 + node-stdlib-browser: 1.3.1 + vite: 8.3.0(@types/node@26.5.1)(jiti@1.21.7) + transitivePeerDependencies: + - rollup + + vite@8.3.0(@types/node@26.5.1)(jiti@1.21.7): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.8 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.5.1 + fsevents: 2.3.3 + jiti: 1.21.7 + + vitest@5.0.0(@types/node@26.5.1)(jsdom@30.0.1(@noble/hashes@1.8.0))(vite@8.3.0(@types/node@26.5.1)(jiti@1.21.7)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.0(vite@8.3.0(@types/node@26.5.1)(jiti@1.21.7)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.3.1 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 6.1.4 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + vite: 8.3.0(@types/node@26.5.1)(jiti@1.21.7) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.5.1 + jsdom: 30.0.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - msw + + vm-browserify@1.1.2: {} + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.1(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + which-typed-array@1.1.22: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + xtend@4.0.2: {} + + yocto-queue@0.1.0: {} + + zod@3.25.76: {} diff --git a/public/oauth-client-metadata.json b/public/oauth-client-metadata.json new file mode 100644 index 0000000..d62518f --- /dev/null +++ b/public/oauth-client-metadata.json @@ -0,0 +1,12 @@ +{ + "client_id": "https://calend.at/oauth-client-metadata.json", + "client_name": "CalenDat", + "client_uri": "https://calend.at", + "redirect_uris": ["https://calend.at/oauth/callback"], + "scope": "atproto space:community.lexicon.calendar?authority=*&collection=community.lexicon.calendar.event&collection=community.lexicon.calendar.rsvp&collection=community.lexicon.calendar.meta&manage=create&manage=update&manage=delete", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "application_type": "web", + "dpop_bound_access_tokens": true +} diff --git a/serve.json b/serve.json new file mode 100644 index 0000000..771b313 --- /dev/null +++ b/serve.json @@ -0,0 +1,18 @@ +{ + "cleanUrls": false, + "directoryListing": false, + "rewrites": [ + { "source": "/", "destination": "/index.html" }, + { "source": "/oauth/callback", "destination": "/index.html" } + ], + "headers": [ + { + "source": "**", + "headers": [{ "key": "Cache-Control", "value": "no-cache" }] + }, + { + "source": "assets/**", + "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] + } + ] +} diff --git a/src/App.tsx b/src/App.tsx index bf0b0b7..17f5ada 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,10 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { addMonths, subMonths, addWeeks, subWeeks } from 'date-fns'; +import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; +import { addMonths, subMonths, addWeeks, subWeeks, startOfMonth, endOfMonth, startOfWeek, endOfWeek, addDays } from 'date-fns'; import { Loader2 } from 'lucide-react'; import { atprotoService } from './services/atproto'; import { CalendarEventItem, + CalendarEventRecord, CalendarSpace, CalendarViewType, SpaceMember, @@ -20,6 +21,14 @@ import { NewCalendarModal } from './components/NewCalendarModal'; import { ThemeProvider } from './context/ThemeContext'; import { ToastProvider, useToast } from './components/Toast'; import { generateIcs, downloadIcsFile } from './utils/ical'; +import { expandRecurringEvents } from './utils/recurrence'; +import { saveEventChanges } from './services/eventEditing'; +import { CalendarToolsModal } from './components/CalendarToolsModal'; +import { setCalendarArchived, deleteCalendarSpace, leaveCalendarSpace } from './services/calendarLifecycle'; +import { useAutomaticRefresh } from './hooks/useAutomaticRefresh'; +import { calendarChanges, useCalendarNotifications } from './hooks/useCalendarNotifications'; +import { markConflicts } from './utils/calendarOverlays'; +import { LoadingToast } from './components/LoadingToast'; const MainApp: React.FC = () => { const toast = useToast(); @@ -27,107 +36,205 @@ const MainApp: React.FC = () => { const [authenticated, setAuthenticated] = useState(false); const [checkingAuth, setCheckingAuth] = useState(true); - // Spaces & Active Selection const [spaces, setSpaces] = useState([]); const [activeSpace, setActiveSpace] = useState(null); const [loadingSpaces, setLoadingSpaces] = useState(false); + const [spacesError, setSpacesError] = useState(null); + const spacesRequest = useRef(0); + const contentRequest = useRef(0); + const selectionVersion = useRef(0); + const activeSpaceUri = useRef(null); - // Members & Events for Active Space const [members, setMembers] = useState([]); const [events, setEvents] = useState([]); const [loadingEvents, setLoadingEvents] = useState(false); + const pendingEventSaves = useRef(new Set()); + const [savingEvents, setSavingEvents] = useState(0); + const [contentError, setContentError] = useState(null); + const [visibleSpaceUris, setVisibleSpaceUris] = useState([]); + const visibleUrisRef = useRef([]); + const [membersBySpace, setMembersBySpace] = useState>({}); + const snapshots = useRef(new Map()); + const [notificationsEnabled, setNotificationsEnabled] = useState(false); - // Navigation & View const [currentDate, setCurrentDate] = useState(new Date()); const [currentView, setCurrentView] = useState('month'); - // Mobile sidebar drawer const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); - // Modals const [isShareModalOpen, setIsShareModalOpen] = useState(false); const [isNewSpaceModalOpen, setIsNewSpaceModalOpen] = useState(false); const [isEventModalOpen, setIsEventModalOpen] = useState(false); const [selectedEvent, setSelectedEvent] = useState(null); const [newEventInitialDate, setNewEventInitialDate] = useState(undefined); + const [isToolsOpen, setIsToolsOpen] = useState(false); + const [settingsSpace, setSettingsSpace] = useState(null); + const [toolsSpace, setToolsSpace] = useState(null); + const account = authenticated ? atprotoService.getCurrentDid() : null; + const modalOpen = isEventModalOpen || isShareModalOpen || isNewSpaceModalOpen || isToolsOpen || isMobileSidebarOpen; + + const notify = useCallback((message: string) => { + toast.info(message); + if (notificationsEnabled && typeof Notification !== 'undefined' && Notification.permission === 'granted') { + try { new Notification('CalenDat', { body: message }); } catch { /* In-app notifications remain available on mobile browsers. */ } + } + }, [toast.info, notificationsEnabled]); + + const recurrenceWindow = useMemo(() => ({ + start: startOfWeek(startOfMonth(currentDate)), + end: addDays(endOfWeek(endOfMonth(currentDate)), 1), + }), [currentDate]); + const expanded = useMemo(() => { + try { + const raw = events.filter(event => visibleSpaceUris.includes(event.spaceUri)); + return { events: markConflicts(expandRecurringEvents(raw, recurrenceWindow.start, recurrenceWindow.end)), error: null }; + } catch (error) { + return { events: [] as CalendarEventItem[], error: error instanceof Error ? error.message : 'Could not expand recurring events.' }; + } + }, [events, visibleSpaceUris, recurrenceWindow]); + const reminderEvents = useMemo(() => { + try { + const now = new Date(); + return expandRecurringEvents(events.filter(event => visibleSpaceUris.includes(event.spaceUri)), addDays(now, -1), addDays(now, 2)); + } catch { return []; } + }, [events, visibleSpaceUris]); + useCalendarNotifications(account, reminderEvents, notify); + + const toggleVisibleSpace = (uri: string) => { + const next = visibleUrisRef.current.includes(uri) + ? visibleUrisRef.current.filter(value => value !== uri) + : [...visibleUrisRef.current, uri]; + visibleUrisRef.current = next; + setVisibleSpaceUris(next); + contentRequest.current++; + }; + + const selectSpace = useCallback((space: CalendarSpace | null) => { + if (activeSpaceUri.current !== (space?.uri ?? null)) { + const initialSelection = activeSpaceUri.current === null; + activeSpaceUri.current = space?.uri ?? null; + selectionVersion.current++; + contentRequest.current++; + setMembers([]); + if (!space || initialSelection) { + const visible = space && !space.archived ? [space.uri] : []; + visibleUrisRef.current = visible; + setVisibleSpaceUris(visible); + } + if (!space) { + setEvents([]); + setMembersBySpace({}); + } + setContentError(null); + setLoadingEvents(!!space); + setIsEventModalOpen(false); + setIsShareModalOpen(false); + setIsToolsOpen(false); + setSelectedEvent(null); + } + setActiveSpace(space); + }, []); - // Check initial session useEffect(() => { + let cancelled = false; (async () => { setCheckingAuth(true); const session = await atprotoService.resumeSession(); + if (cancelled) return; if (session) { setAuthenticated(true); } setCheckingAuth(false); })(); + return () => { cancelled = true; }; }, []); - // Load spaces when authenticated const loadSpaces = useCallback(async () => { + const request = ++spacesRequest.current; setLoadingSpaces(true); + setSpacesError(null); try { const list = await atprotoService.listCalendarSpaces(); + if (request !== spacesRequest.current) return; setSpaces(list); - // If no active space or active space not in list, select first if (list.length > 0) { - setActiveSpace((prev) => { - if (!prev) return list[0]; - const exists = list.find((s) => s.uri === prev.uri); - return exists || list[0]; - }); + selectSpace(list.find((s) => s.uri === activeSpaceUri.current) || list.find(space => !space.archived) || list[0]); } else { - // If user has zero calendar spaces, create one automatically - try { - const newDefault = await atprotoService.createCalendarSpace({ - name: 'Personal & Shared Calendar', - description: 'My primary ATProto private calendar space', - }); - setSpaces([newDefault]); - setActiveSpace(newDefault); - } catch (e) { - console.error('Failed to auto-create default calendar space', e); - } + selectSpace(null); } } catch (err) { - console.error('Failed to load spaces:', err); + if (request === spacesRequest.current) setSpacesError('Could not load your calendars. Check your connection and try again.'); } finally { - setLoadingSpaces(false); + if (request === spacesRequest.current) setLoadingSpaces(false); } - }, []); + }, [selectSpace]); useEffect(() => { if (authenticated) { loadSpaces(); } + return () => { spacesRequest.current++; }; }, [authenticated, loadSpaces]); - // Load members and events when active space changes const loadSpaceContent = useCallback(async () => { - if (!activeSpace) return; + const uri = activeSpace?.uri; + if (!uri || activeSpaceUri.current !== uri) return false; + const request = ++contentRequest.current; + const isCurrent = () => request === contentRequest.current && activeSpaceUri.current === uri; setLoadingEvents(true); + setContentError(null); try { - // 1. Load members - const memberList = await atprotoService.listSpaceMembers(activeSpace.uri); - setMembers(memberList); - - // 2. Load events across all members - const memberDids = memberList.map((m) => m.did); - const eventList = await atprotoService.listSpaceEvents(activeSpace.uri, memberDids); - setEvents(eventList); + const requested = Array.from(new Set([uri, ...visibleUrisRef.current])); + const content = await Promise.all(requested.map(async spaceUri => { + const memberList = await atprotoService.listSpaceMembers(spaceUri); + if (!isCurrent()) return null; + const eventList = await atprotoService.listSpaceEvents(spaceUri, memberList.map(member => member.did)); + return { uri: spaceUri, members: memberList, events: eventList }; + })); + if (!isCurrent()) return false; + const bySpace: Record = {}; + const allEvents: CalendarEventItem[] = []; + const notices: string[] = []; + for (const entry of content) { + if (!entry) continue; + bySpace[entry.uri] = entry.members; + const calendar = spaces.find(space => space.uri === entry.uri); + allEvents.push(...entry.events.map(event => ({ ...event, calendarColor: calendar?.color, calendarName: calendar?.name }))); + const previous = snapshots.current.get(entry.uri); + if (previous && visibleUrisRef.current.includes(entry.uri)) notices.push(...calendarChanges(previous, entry.events, atprotoService.getCurrentDid() || '')); + snapshots.current.set(entry.uri, entry.events); + } + setMembers(bySpace[uri] || []); + setMembersBySpace(bySpace); + setEvents(allEvents); + if (notices.length) notify(notices.length === 1 ? notices[0] : `${notices.length} calendar changes. ${notices[0]}`); + return true; } catch (err) { - console.error('Failed to load space content:', err); + if (isCurrent()) setContentError('Could not load all calendar events and members. Check your connection and retry.'); + return false; } finally { - setLoadingEvents(false); + if (isCurrent()) setLoadingEvents(false); } - }, [activeSpace]); + }, [activeSpace?.uri, spaces, notify]); useEffect(() => { loadSpaceContent(); - }, [loadSpaceContent]); + return () => { contentRequest.current++; }; + }, [loadSpaceContent, visibleSpaceUris]); + + useAutomaticRefresh(authenticated && !!activeSpace && !activeSpace.archived, loadSpaceContent, loadingEvents || savingEvents > 0 || modalOpen); + + const enableNotifications = async () => { + if (notificationsEnabled) { setNotificationsEnabled(false); return; } + if (typeof Notification === 'undefined') { toast.info('This browser supports in-app reminders only.'); return; } + try { + const permission = await Notification.requestPermission(); + setNotificationsEnabled(permission === 'granted'); + if (permission !== 'granted') toast.info('In-app reminders remain enabled while CalenDat is open.'); + } catch { toast.info('This browser supports in-app reminders only.'); } + }; - // Navigation handlers const handlePrevDate = () => { if (currentView === 'week') { setCurrentDate((d) => subWeeks(d, 1)); @@ -149,6 +256,7 @@ const MainApp: React.FC = () => { }; const handleOpenNewEvent = (date?: Date) => { + if (activeSpace?.archived) { toast.info('Restore this calendar before adding events.'); return; } setSelectedEvent(null); setNewEventInitialDate(date || new Date()); setIsEventModalOpen(true); @@ -162,10 +270,15 @@ const MainApp: React.FC = () => { const handleExportIcs = () => { if (!activeSpace) return; + if (loadingEvents || contentError) { + toast.error('Wait until the calendar has loaded successfully before exporting.'); + return; + } try { - const ics = generateIcs(activeSpace, events); + const calendarEvents = events.filter(event => event.spaceUri === activeSpace.uri); + const ics = generateIcs(activeSpace, calendarEvents); downloadIcsFile(`${activeSpace.name.replace(/[^a-zA-Z0-9]/g, '_')}`, ics); - toast.success(`Exported ${events.length} events to .ics`); + toast.success(`Exported ${calendarEvents.length} events to .ics`); } catch { toast.error('Failed to export calendar'); } @@ -173,52 +286,106 @@ const MainApp: React.FC = () => { const handleRescheduleEvent = async ( event: CalendarEventItem, - newStartsAt: string, - newEndsAt: string + updates: Partial, ) => { - if (!activeSpace) return; - const prevEvents = [...events]; + if (!activeSpace || !visibleUrisRef.current.includes(event.spaceUri) || event.repo !== atprotoService.getCurrentDid()) { + toast.error('You can only reschedule your own events in the selected calendar.'); + return; + } + const version = selectionVersion.current; + const saveKey = event.series?.uri || event.uri; + if (pendingEventSaves.current.has(saveKey)) { + toast.info('This event is still saving. Try again when it finishes.'); + return; + } + pendingEventSaves.current.add(saveKey); + setSavingEvents(pendingEventSaves.current.size); try { - // Optimistic update - setEvents((prev) => - prev.map((e) => - e.rkey === event.rkey - ? { - ...e, - value: { - ...e.value, - startsAt: newStartsAt, - endsAt: newEndsAt, - status: 'community.lexicon.calendar.event#rescheduled' as const, - }, - } - : e - ) - ); - - await atprotoService.updateEvent(activeSpace.uri, event.rkey, { - startsAt: newStartsAt, - endsAt: newEndsAt, + await saveEventChanges(event, { + ...updates, status: 'community.lexicon.calendar.event#rescheduled', - }); + }, event.series ? 'occurrence' : 'series'); + if (version !== selectionVersion.current) return; + await loadSpaceContent(); toast.success(`Rescheduled "${event.value.name}"`); } catch (err: any) { - // Revert optimistic update - setEvents(prevEvents); + if (version !== selectionVersion.current) return; toast.error(err.message || 'Failed to reschedule event'); + } finally { + pendingEventSaves.current.delete(saveKey); + setSavingEvents(pendingEventSaves.current.size); } }; const handleSpaceUpdated = (updated: CalendarSpace) => { setSpaces((prev) => prev.map((s) => (s.uri === updated.uri ? updated : s))); - setActiveSpace(updated); + setSettingsSpace(updated); + if (activeSpaceUri.current === updated.uri) selectSpace(updated); + }; + + const openSettings = (space: CalendarSpace) => { + setSettingsSpace(space); + setIsShareModalOpen(true); + setIsMobileSidebarOpen(false); + }; + + const openTools = (space: CalendarSpace) => { + setToolsSpace(space); + setIsShareModalOpen(false); + setIsToolsOpen(true); }; + const handleLifecycle = async (action: 'archive' | 'restore' | 'delete' | 'leave') => { + if (!toolsSpace) return; + const target = toolsSpace; + if (action === 'archive' || action === 'restore') await setCalendarArchived(target, action === 'archive'); + else if (action === 'delete') await deleteCalendarSpace(target); + else await leaveCalendarSpace(target); + snapshots.current.delete(target.uri); + const next = action === 'delete' || action === 'leave' + ? spaces.filter(space => space.uri !== target.uri) + : spaces.map(space => space.uri === target.uri ? { ...space, archived: action === 'archive' } : space); + setSpaces(next); + const visible = action === 'restore' + ? Array.from(new Set([...visibleUrisRef.current, target.uri])) + : visibleUrisRef.current.filter(uri => uri !== target.uri); + visibleUrisRef.current = visible; + setVisibleSpaceUris(visible); + setEvents(previous => previous.filter(event => event.spaceUri !== target.uri)); + if (activeSpaceUri.current === target.uri) { + selectSpace(action === 'restore' ? next.find(space => space.uri === target.uri)! : next.find(space => !space.archived) || next[0] || null); + } + setIsToolsOpen(false); + toast.success(`${target.name}: ${action === 'leave' ? 'left calendar' : action === 'delete' ? 'deleted' : action === 'archive' ? 'archived' : 'restored'}`); + }; + + useEffect(() => { + if (!authenticated || modalOpen) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.isComposing) return; + if (document.querySelector('[role="dialog"][aria-modal="true"]')) return; + const target = event.target instanceof Element ? event.target : null; + if (target?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="textbox"]')) return; + const actions: Record void> = { + n: () => handleOpenNewEvent(), t: handleToday, + '1': () => setCurrentView('month'), '2': () => setCurrentView('week'), '3': () => setCurrentView('agenda'), + '[': handlePrevDate, ']': handleNextDate, + }; + const action = actions[event.key]; + if (action) { event.preventDefault(); action(); } + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [authenticated, modalOpen, activeSpace, currentView]); + const handleLogout = () => { atprotoService.logout(); + snapshots.current.clear(); + setNotificationsEnabled(false); + spacesRequest.current++; setAuthenticated(false); setSpaces([]); - setActiveSpace(null); + selectSpace(null); setEvents([]); setMembers([]); toast.info('Logged out from ATProto'); @@ -237,7 +404,6 @@ const MainApp: React.FC = () => { return (
- {/* Login Modal if not authenticated */} setAuthenticated(true)} @@ -245,20 +411,24 @@ const MainApp: React.FC = () => { {authenticated && ( <> - {/* Sidebar (Desktop + Mobile Drawer) */} setActiveSpace(s)} + onOpenSettings={openSettings} onOpenNewSpaceModal={() => setIsNewSpaceModalOpen(true)} onLogout={handleLogout} loadingSpaces={loadingSpaces} + visibleSpaceUris={visibleSpaceUris} + onToggleVisible={toggleVisibleSpace} isMobileOpen={isMobileSidebarOpen} onCloseMobile={() => setIsMobileSidebarOpen(false)} /> - {/* Main Calendar View Area */}
+ {spacesError && ( +
+ {spacesError} +
+ )} {activeSpace ? ( <>
{ view={currentView} onViewChange={setCurrentView} space={activeSpace} + spaces={spaces} + onSelectSpace={selectSpace} members={members} - onOpenShareModal={() => setIsShareModalOpen(true)} + onOpenShareModal={() => openSettings(activeSpace)} onOpenNewEventModal={() => handleOpenNewEvent()} onRefresh={loadSpaceContent} refreshing={loadingEvents} onToggleSidebar={() => setIsMobileSidebarOpen((prev) => !prev)} onExportIcs={handleExportIcs} + onOpenTools={() => openTools(activeSpace)} + onToggleNotifications={enableNotifications} + notificationsEnabled={notificationsEnabled} /> -
+
+ {activeSpace.archived && ( +
+ This calendar is archived.{' '} + +
+ )} + {expanded.error &&
{expanded.error}
} + {contentError && ( +
+ {contentError} +
+ )} {currentView === 'month' && ( handleOpenNewEvent(d)} @@ -293,7 +480,7 @@ const MainApp: React.FC = () => { {currentView === 'week' && ( handleOpenNewEvent(d)} @@ -303,7 +490,8 @@ const MainApp: React.FC = () => { {currentView === 'agenda' && ( handleOpenNewEvent()} @@ -318,30 +506,41 @@ const MainApp: React.FC = () => { )}
- {/* Modals */} {activeSpace && ( <> setIsShareModalOpen(false)} - space={activeSpace} + space={settingsSpace} onMembersUpdated={loadSpaceContent} onSpaceUpdated={handleSpaceUpdated} + onOpenTools={() => settingsSpace && openTools(settingsSpace)} /> { setIsEventModalOpen(false); setSelectedEvent(null); }} - spaceUri={activeSpace.uri} - memberDids={members.map((m) => m.did)} + spaceUri={selectedEvent?.spaceUri || activeSpace.uri} + memberDids={(membersBySpace[selectedEvent?.spaceUri || activeSpace.uri] || members).map((m) => m.did)} initialDate={newEventInitialDate} eventToEdit={selectedEvent} onSaved={loadSpaceContent} onDeleted={loadSpaceContent} /> + setIsToolsOpen(false)} + space={toolsSpace || activeSpace} + events={events.filter(event => event.spaceUri === toolsSpace?.uri)} + onImported={() => { void loadSpaceContent(); }} + onLifecycle={handleLifecycle} + /> )} @@ -350,9 +549,12 @@ const MainApp: React.FC = () => { onClose={() => setIsNewSpaceModalOpen(false)} onCreated={(newSpace) => { setSpaces((prev) => [newSpace, ...prev]); - setActiveSpace(newSpace); + visibleUrisRef.current = [...visibleUrisRef.current, newSpace.uri]; + setVisibleSpaceUris(visibleUrisRef.current); + selectSpace(newSpace); }} /> + )}
diff --git a/src/components/AuthModal.tsx b/src/components/AuthModal.tsx index 7bf8c18..ec1e299 100644 --- a/src/components/AuthModal.tsx +++ b/src/components/AuthModal.tsx @@ -1,17 +1,7 @@ import React, { useState } from 'react'; -import { - Calendar, - KeyRound, - User, - Server, - Loader2, - Sparkles, - ShieldCheck, - Eye, - EyeOff, -} from 'lucide-react'; +import { Calendar, Loader2, Server, User } from 'lucide-react'; import { atprotoService } from '../services/atproto'; -import { useToast } from './Toast'; +import { useModalFocus } from '../hooks/useModalFocus'; interface AuthModalProps { isOpen: boolean; @@ -19,57 +9,29 @@ interface AuthModalProps { } export const AuthModal: React.FC = ({ isOpen, onSuccess }) => { - const toast = useToast(); - - const [service, setService] = useState('https://spaces-alpha.host.bsky.network'); - const [identifier, setIdentifier] = useState('david.spaces-alpha.bsky.network'); - const [password, setPassword] = useState('david22798marcos'); - const [showPassword, setShowPassword] = useState(false); + const dialogRef = useModalFocus(isOpen, () => {}); + const [identifier, setIdentifier] = useState(''); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(() => atprotoService.getAuthenticationError()); if (!isOpen) return null; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setError(null); - setLoading(true); - try { - await atprotoService.login(identifier, password, service); - toast.success('Successfully authenticated with ATProto'); - onSuccess(); - } catch (err: any) { - setError(err.message || 'Authentication failed. Please verify your credentials and PDS endpoint.'); - } finally { - setLoading(false); - } - }; - - const handleQuickLogin = async () => { - setService('https://spaces-alpha.host.bsky.network'); - setIdentifier('david.spaces-alpha.bsky.network'); - setPassword('david22798marcos'); + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); setError(null); setLoading(true); try { - await atprotoService.login( - 'david.spaces-alpha.bsky.network', - 'david22798marcos', - 'https://spaces-alpha.host.bsky.network' - ); - toast.success('Signed in with David test account'); + await atprotoService.startOAuthLogin(identifier); onSuccess(); - } catch (err: any) { - setError(err.message || 'Failed to log in with test account.'); - } finally { + } catch (reason) { + setError(reason instanceof Error ? reason.message : 'Could not start OAuth sign-in.'); setLoading(false); } }; return (
-
- {/* Decorative background glow */} +
@@ -78,114 +40,49 @@ export const AuthModal: React.FC = ({ isOpen, onSuccess }) => {
-

+

CalenDat

-

- Decentralized Spaces Calendar on ATProto -

-
-
- - {/* Quick test button */} -
-
- - Spaces-Alpha Test Account +

Sign in through your AT Protocol provider

-

- Pre-configured with david.spaces-alpha.bsky.network -

- -
- -
-
- - or enter credentials - -
{error && ( -
+
{error}
)}
-
- -
- -
- + {identifier.startsWith('http') ? : } setIdentifier(e.target.value)} + onChange={(event) => setIdentifier(event.target.value)} required - placeholder="user.spaces-alpha.bsky.network" + autoComplete="username" + placeholder="you.example.com or https://your-pds.example" className="w-full pl-9 pr-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition" />
-
- -
- - setPassword(e.target.value)} - required - placeholder="••••••••••••" - className="w-full pl-9 pr-9 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent outline-none transition" - /> - -
-
+

+ You will continue securely with your provider. CalenDat never receives your password. +

diff --git a/src/components/CalendarAgendaView.tsx b/src/components/CalendarAgendaView.tsx index ee7efc6..074e62f 100644 --- a/src/components/CalendarAgendaView.tsx +++ b/src/components/CalendarAgendaView.tsx @@ -1,5 +1,5 @@ -import React, { useState, useMemo } from 'react'; -import { format, parseISO, isToday, isTomorrow, isPast, isValid } from 'date-fns'; +import React, { useEffect, useState, useMemo } from 'react'; +import { format, parseISO, isToday, isTomorrow, isValid, startOfMonth, endOfMonth, eachDayOfInterval } from 'date-fns'; import { Video, MapPin, @@ -11,10 +11,12 @@ import { } from 'lucide-react'; import { CalendarEventItem, CalendarSpace } from '../types/calendar'; import { atprotoService } from '../services/atproto'; +import { eventOccursOnDay, getEventTimeLabel, isAllDayEvent } from '../utils/dateUtils'; interface CalendarAgendaViewProps { events: CalendarEventItem[]; space: CalendarSpace; + currentDate: Date; onSelectEvent: (event: CalendarEventItem) => void; onCreateEvent: () => void; } @@ -26,19 +28,45 @@ export const CalendarAgendaView: React.FC = ({ space, onSelectEvent, onCreateEvent, + currentDate, }) => { const [searchQuery, setSearchQuery] = useState(''); const [filter, setFilter] = useState('all'); + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + const timer = setInterval(() => setNow(new Date()), 60_000); + return () => clearInterval(timer); + }, []); const currentDid = atprotoService.getCurrentDid(); + const monthDays = useMemo( + () => eachDayOfInterval({ start: startOfMonth(currentDate), end: endOfMonth(currentDate) }), + [currentDate] + ); + const monthEvents = useMemo( + () => events.filter((event) => monthDays.some((day) => eventOccursOnDay(event.value, day))), + [events, monthDays] + ); + + const eventIsPast = (event: CalendarEventItem) => { + const value = event.value; + if (isAllDayEvent(value)) { + const exclusiveEnd = value.endDate || value.startDate || format(parseISO(value.startsAt), 'yyyy-MM-dd'); + return exclusiveEnd <= format(now, 'yyyy-MM-dd'); + } + const start = parseISO(value.startsAt); + if (!isValid(start)) return false; + const end = value.endsAt ? parseISO(value.endsAt) : new Date(start.getTime() + 3600000); + return isValid(end) && end.getTime() <= now.getTime(); + }; - // Filter and search + // Filter and search. Every count is scoped to events that occupy this month. const filteredEvents = useMemo(() => { - return events.filter((event) => { + return monthEvents.filter((event) => { const val = event.value; const query = searchQuery.toLowerCase().trim(); - // Search matching if (query) { const matchesName = val.name?.toLowerCase().includes(query); const matchesDesc = val.description?.toLowerCase().includes(query); @@ -54,44 +82,42 @@ export const CalendarAgendaView: React.FC = ({ } } - // Time filtering if (filter !== 'all') { - const startDate = parseISO(val.startsAt); - if (!isValid(startDate)) return true; - - const isEventPast = isPast(val.endsAt ? parseISO(val.endsAt) : startDate); - if (filter === 'upcoming' && isEventPast && !isToday(startDate)) return false; + const isEventPast = eventIsPast(event); + if (filter === 'upcoming' && isEventPast) return false; if (filter === 'past' && !isEventPast) return false; } return true; }); - }, [events, searchQuery, filter]); + }, [monthEvents, searchQuery, filter, now]); + + const monthCounts = useMemo(() => ({ + all: monthEvents.length, + upcoming: monthEvents.filter((event) => !eventIsPast(event)).length, + past: monthEvents.filter(eventIsPast).length, + }), [monthEvents, now]); - // Group events by date (YYYY-MM-DD) const groupedEvents = useMemo(() => { const groups: { [dateStr: string]: CalendarEventItem[] } = {}; - filteredEvents.forEach((event) => { - try { - const dateStr = format(parseISO(event.value.startsAt), 'yyyy-MM-dd'); - if (!groups[dateStr]) groups[dateStr] = []; - groups[dateStr].push(event); - } catch { - // ignore malformed - } + eachDayOfInterval({ start: startOfMonth(currentDate), end: endOfMonth(currentDate) }).forEach((day) => { + const dateStr = format(day, 'yyyy-MM-dd'); + const previousDay = new Date(day.getFullYear(), day.getMonth(), day.getDate() - 1); + const dayEvents = filteredEvents.filter((event) => + eventOccursOnDay(event.value, day) && (dateStr === format(monthDays[0], 'yyyy-MM-dd') || !eventOccursOnDay(event.value, previousDay)), + ); + if (dayEvents.length > 0) groups[dateStr] = dayEvents; }); return groups; - }, [filteredEvents]); + }, [filteredEvents, currentDate, monthDays]); const sortedDates = Object.keys(groupedEvents).sort(); return (
- {/* Search & Filter Bar */}
- {/* Search Input */}
= ({ />
- {/* Filter Pills */}
- {/* Events List */}
{sortedDates.length === 0 ? (
@@ -193,24 +217,29 @@ export const CalendarAgendaView: React.FC = ({
{dayEvents.map((event) => { - const start = parseISO(event.value.startsAt); - const end = event.value.endsAt ? parseISO(event.value.endsAt) : null; const isVirtual = event.value.mode === 'community.lexicon.calendar.event#virtual'; const isOwner = event.repo === currentDid; const authorHandle = event.authorProfile?.handle; const authorName = event.authorProfile?.displayName || authorHandle; const location = event.value.locations?.[0]; + const eventColor = event.calendarColor || space.color; + const eventLabel = `${event.value.name}${event.calendarName ? `, ${event.calendarName}` : ''}${event.hasConflict ? ', Overlapping event' : ''}`; return (
onSelectEvent(event)} + onKeyDown={(e) => { if (!e.metaKey && !e.ctrlKey && !e.altKey && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onSelectEvent(event); } }} + role="button" + tabIndex={0} + aria-label={eventLabel} + title={event.calendarName ? `${event.value.name} · ${event.calendarName}` : event.value.name} className="p-4 bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700 shadow-sm hover:shadow transition cursor-pointer flex flex-col md:flex-row md:items-center justify-between gap-4" >
@@ -237,8 +266,7 @@ export const CalendarAgendaView: React.FC = ({
- {format(start, 'HH:mm')} - {end ? ` – ${format(end, 'HH:mm')}` : ''} + {isAllDayEvent(event.value) ? 'All day' : getEventTimeLabel(event.value, date)} {location?.name && ( @@ -257,6 +285,7 @@ export const CalendarAgendaView: React.FC = ({ target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} className="flex items-center gap-1 text-primary-600 dark:text-primary-400 hover:underline font-medium" > @@ -271,10 +300,11 @@ export const CalendarAgendaView: React.FC = ({ {event.value.description}

)} + {event.calendarName && {event.calendarName}} + {event.hasConflict && !}
- {/* Author Badge */}
{event.authorProfile?.avatar ? ( void; onRescheduleEvent?: ( event: CalendarEventItem, - newStartsAt: string, - newEndsAt: string + updates: Partial, ) => Promise | void; } - -const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; export const CalendarMonthView: React.FC = ({ currentDate, @@ -33,305 +32,201 @@ export const CalendarMonthView: React.FC = ({ onCreateEventOnDate, onRescheduleEvent, }) => { - const [popoverDay, setPopoverDay] = useState(null); - const [dragOverDay, setDragOverDay] = useState(null); - const [draggingEventRkey, setDraggingEventRkey] = useState(null); - - const { monthStart, days, weeksCount } = getMonthGrid(currentDate); - - const getEventsForDay = (day: Date) => { - return events.filter((ev) => { - try { - const evDate = parseISO(ev.value.startsAt); - return isSameDay(day, evDate); - } catch { - return false; - } - }); + const { monthStart, days } = getMonthGrid(currentDate); + const [draggingUri, setDraggingUri] = useState(null); + const currentDid = atprotoService.getCurrentDid(); + const weeks = useMemo( + () => + Array.from({ length: Math.ceil(days.length / 7) }, (_, index) => + days.slice(index * 7, index * 7 + 7), + ), + [days], + ); + const drop = (event: React.DragEvent, day: Date) => { + event.preventDefault(); + setDraggingUri(null); + const move = resolveEventDrop( + event.dataTransfer.getData("application/json") || + event.dataTransfer.getData("text/plain"), + events, + currentDid, + day, + ); + if (move) onRescheduleEvent?.(move.event, move.updates); }; - - const handleDropOnDay = (e: React.DragEvent, targetDay: Date) => { - e.preventDefault(); - setDragOverDay(null); - setDraggingEventRkey(null); - - const raw = e.dataTransfer.getData('application/json') || e.dataTransfer.getData('text/plain'); - if (!raw) return; - - let droppedEvent: CalendarEventItem | undefined; - try { - const parsed = JSON.parse(raw); - droppedEvent = parsed.value ? (parsed as CalendarEventItem) : events.find((ev) => ev.rkey === parsed.rkey); - } catch { - return; - } - - if (!droppedEvent || !droppedEvent.value?.startsAt) return; - - try { - const origStart = parseISO(droppedEvent.value.startsAt); - const durationMs = droppedEvent.value.endsAt - ? Math.max(0, parseISO(droppedEvent.value.endsAt).getTime() - origStart.getTime()) - : 60 * 60 * 1000; - - const newStart = new Date(targetDay); - newStart.setHours( - origStart.getHours(), - origStart.getMinutes(), - origStart.getSeconds(), - origStart.getMilliseconds() - ); - const newEnd = new Date(newStart.getTime() + durationMs); - - onRescheduleEvent?.(droppedEvent, newStart.toISOString(), newEnd.toISOString()); - } catch (err) { - console.error('Failed to reschedule event on drop:', err); + const drag = (event: React.DragEvent, item: CalendarEventItem, day: Date) => { + if (item.repo !== currentDid) return; + event.stopPropagation(); + event.dataTransfer.setData( + "text/plain", + JSON.stringify({ uri: item.uri, sourceDate: format(day, "yyyy-MM-dd") }), + ); + event.dataTransfer.effectAllowed = "move"; + setDraggingUri(item.uri); + }; + const activate = (event: React.KeyboardEvent, item: CalendarEventItem) => { + if ( + !event.metaKey && + !event.ctrlKey && + !event.altKey && + (event.key === "Enter" || event.key === " ") + ) { + event.preventDefault(); + onSelectEvent(item); } }; - - const popoverEvents = popoverDay ? getEventsForDay(popoverDay) : []; + const label = (item: CalendarEventItem) => + `${item.value.name}${item.calendarName ? `, ${item.calendarName}` : ""}${item.hasConflict ? ", Overlapping event" : ""}`; + const title = (item: CalendarEventItem) => + item.calendarName + ? `${item.value.name} · ${item.calendarName}` + : item.value.name; return ( -
- {/* Weekday headers */} -
- {WEEKDAYS.map((day) => ( +
+
+ {weekdays.map((day) => (
{day}
))}
- - {/* Month Days Grid */} -
- {days.map((day) => { - const isCurrentMonth = isSameMonth(day, monthStart); - const dayIsToday = isToday(day); - const dayEvents = getEventsForDay(day); - const dayKey = format(day, 'yyyy-MM-dd'); - const isDragOver = dragOverDay === dayKey; - - return ( -
onCreateEventOnDate(day)} - onDragOver={(e) => { - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - if (dragOverDay !== dayKey) { - setDragOverDay(dayKey); - } - }} - onDragEnter={(e) => { - e.preventDefault(); - setDragOverDay(dayKey); - }} - onDragLeave={(e) => { - if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) { - setDragOverDay(null); - } - }} - onDrop={(e) => handleDropOnDay(e, day)} - className={`min-h-[85px] p-1.5 md:p-2 flex flex-col group transition-colors cursor-pointer relative ${ - isDragOver - ? 'ring-2 ring-primary-500 bg-primary-50/70 dark:bg-primary-950/40 z-10' - : !isCurrentMonth - ? 'bg-slate-50/50 dark:bg-slate-950/40 text-slate-400 dark:text-slate-600' - : 'bg-white dark:bg-slate-900 hover:bg-slate-50/80 dark:hover:bg-slate-800/40 text-slate-800 dark:text-slate-200' - }`} - > - {/* Day Number + Quick Add */} -
- - {format(day, 'd')} - - - -
- - {/* Day Events */} -
- {dayEvents.slice(0, 3).map((event) => { - let timeStr = ''; - try { - timeStr = format(parseISO(event.value.startsAt), 'HH:mm'); - } catch {} - - const isVirtual = event.value.mode === 'community.lexicon.calendar.event#virtual'; - - return ( +
+ + {format(day, "d")} + - ); - })} - - {dayEvents.length > 3 && ( -
+
- +{dayEvents.length - 3} more - - )} -
-
- ); - })} -
- - {/* Day Events Popover / Modal for "+N more" */} - {popoverDay && ( -
-
-
-
- - {format(popoverDay, 'EEEE, MMMM d')} - - - {popoverEvents.length} events - -
- -
- -
- {popoverEvents.map((event) => { - let timeStr = ''; - try { - timeStr = format(parseISO(event.value.startsAt), 'HH:mm'); - } catch {} - - const isVirtual = event.value.mode === 'community.lexicon.calendar.event#virtual'; - + {singles.map((item) => ( + + ))} +
+
+ ); + })} +
+ {spans.map((span) => { + const color = span.event.calendarColor || space.color; return ( ); })}
- -
- - -
-
- )} + ); + })}
); }; diff --git a/src/components/CalendarToolsModal.tsx b/src/components/CalendarToolsModal.tsx new file mode 100644 index 0000000..f4b9bf5 --- /dev/null +++ b/src/components/CalendarToolsModal.tsx @@ -0,0 +1,117 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { Archive, FileUp, Loader2, RotateCcw, Trash2, Users, X } from 'lucide-react'; +import { CalendarEventItem, CalendarSpace } from '../types/calendar'; +import { atprotoService } from '../services/atproto'; +import { parseImportedCalendar } from '../utils/calendarImport'; +import { useModalFocus } from '../hooks/useModalFocus'; + +interface CalendarToolsModalProps { + isOpen: boolean; + onClose: () => void; + space: CalendarSpace; + events: CalendarEventItem[]; + onImported: () => void; + onLifecycle: (action: 'archive' | 'restore' | 'delete' | 'leave') => Promise; +} + +export const CalendarToolsModal: React.FC = ({ + isOpen, onClose, space, events, onImported, onLifecycle, +}) => { + const fileInput = useRef(null); + const importedUids = useRef(new Set()); + const selectionId = useRef(0); + const [fileName, setFileName] = useState(''); + const [parsed, setParsed] = useState | null>(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [progress, setProgress] = useState(0); + const [confirm, setConfirm] = useState<'delete' | 'leave' | null>(null); + const [confirmationName, setConfirmationName] = useState(''); + const requestClose = () => { if (!busy) onClose(); }; + const dialogRef = useModalFocus(isOpen, requestClose); + + useEffect(() => { + importedUids.current.clear(); + selectionId.current++; + setFileName(''); setParsed(null); setError(null); setProgress(0); + setConfirm(null); setConfirmationName(''); + }, [space.uri, isOpen]); + + const duplicates = useMemo(() => parsed?.events.filter(record => + record.importUid && events.some(event => event.value.importUid === record.importUid), + ).length || 0, [parsed, events]); + + const selectFile = async (file?: File) => { + if (!file) return; + const selection = ++selectionId.current; + setParsed(null); setProgress(0); setFileName(file.name); + if (file.size > 5 * 1024 * 1024) { setError('Calendar file is larger than 5 MB.'); return; } + events.forEach(event => { if (event.value.importUid) importedUids.current.add(event.value.importUid); }); + setError(null); + try { + const result = parseImportedCalendar(await file.text()); + if (selection === selectionId.current) setParsed(result); + } catch (reason) { + if (selection === selectionId.current) setError(reason instanceof Error ? reason.message : 'Could not read this calendar file.'); + } + }; + + const importEvents = async () => { + if (!parsed || busy || space.archived) return; + setBusy(true); setError(null); + try { + const members = await atprotoService.listSpaceMembers(space.uri); + const existing = await atprotoService.listSpaceEvents(space.uri, members.map(member => member.did)); + existing.forEach(event => { if (event.value.importUid) importedUids.current.add(event.value.importUid); }); + for (let index = 0; index < parsed.events.length; index++) { + const record = parsed.events[index]; + if (record.importUid && importedUids.current.has(record.importUid)) { setProgress(index + 1); continue; } + await atprotoService.createEvent(space.uri, record); + if (record.importUid) importedUids.current.add(record.importUid); + setProgress(index + 1); + } + onImported(); setParsed(null); setFileName(''); + } catch (reason) { + onImported(); + setError(`${reason instanceof Error ? reason.message : 'Import failed'}. Retry imports the remaining events.`); + } finally { setBusy(false); } + }; + + const lifecycle = async (action: 'archive' | 'restore' | 'delete' | 'leave') => { + if (busy) return; + setBusy(true); setError(null); + try { await onLifecycle(action); onClose(); } + catch (reason) { setError(reason instanceof Error ? reason.message : 'Calendar action failed.'); } + finally { setBusy(false); } + }; + + if (!isOpen) return null; + const actionLabel = space.archived ? 'Restore calendar' : 'Archive calendar'; + + return ( +
+
event.stopPropagation()} className="w-full max-w-lg overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900"> +
+

Calendar tools

{space.name}

+ +
+
+ {error &&

{error}

} +
+

Import an iCalendar file

+

Imports local .ics files. Calendar subscription feeds are not supported.

+ selectFile(event.target.files?.[0])} /> + + {space.archived &&

Restore this calendar before importing.

} + {parsed &&

{fileName}, {parsed.events.length} events, {duplicates} already imported.

{parsed.warnings.map(warning =>

{warning}

)}
} +
+
+

Calendar access

+ {space.isOwner ?
: } +
+ {confirm &&

{confirm === 'delete' ? 'This permanently deletes the calendar, its members, and its data. Type the calendar name to continue.' : 'This removes your access to the shared calendar.'}

{confirm === 'delete' && setConfirmationName(event.target.value)} placeholder={space.name} className="mt-2 w-full rounded border border-red-300 bg-white px-2 py-1.5 text-sm dark:bg-slate-900" />}
} +
+
+
+ ); +}; diff --git a/src/components/CalendarWeekView.tsx b/src/components/CalendarWeekView.tsx index 1911d4c..8e86cf7 100644 --- a/src/components/CalendarWeekView.tsx +++ b/src/components/CalendarWeekView.tsx @@ -1,18 +1,25 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { startOfWeek, endOfWeek, eachDayOfInterval, - isSameDay, isToday, format, - parseISO, - getHours, - getMinutes, - differenceInMinutes, -} from 'date-fns'; -import { Video, MapPin, Clock } from 'lucide-react'; -import { CalendarEventItem, CalendarSpace } from '../types/calendar'; +} from "date-fns"; +import { Video, MapPin, Clock } from "lucide-react"; +import { + CalendarEventItem, + CalendarEventRecord, + CalendarSpace, +} from "../types/calendar"; +import { atprotoService } from "../services/atproto"; +import { eventsOnDay, resolveEventDrop } from "../utils/calendarInteraction"; +import { + getEventDaySegment, + getEventTimeLabel, + isAllDayEvent, +} from "../utils/dateUtils"; +import { layoutWeekSpans } from "../utils/weekSpans"; interface CalendarWeekViewProps { currentDate: Date; @@ -22,8 +29,7 @@ interface CalendarWeekViewProps { onCreateEventOnDate: (date: Date) => void; onRescheduleEvent?: ( event: CalendarEventItem, - newStartsAt: string, - newEndsAt: string + updates: Partial, ) => Promise | void; } @@ -40,20 +46,26 @@ export const CalendarWeekView: React.FC = ({ }) => { const scrollContainerRef = useRef(null); const [now, setNow] = useState(new Date()); - const [dragOverSlot, setDragOverSlot] = useState<{ dayKey: string; hour: number } | null>(null); - const [draggingEventRkey, setDraggingEventRkey] = useState(null); + const [dragOverSlot, setDragOverSlot] = useState<{ + dayKey: string; + hour: number; + } | null>(null); + const [draggingEventUri, setDraggingEventUri] = useState(null); + const currentDid = atprotoService.getCurrentDid(); const weekStart = startOfWeek(currentDate); const weekEnd = endOfWeek(currentDate); const days = eachDayOfInterval({ start: weekStart, end: weekEnd }); + const weekSpans = useMemo( + () => layoutWeekSpans(events, days), + [events, currentDate], + ); - // Update current time every minute useEffect(() => { const timer = setInterval(() => setNow(new Date()), 60000); return () => clearInterval(timer); }, []); - // Auto-scroll to 8:00 AM or current hour on initial render useEffect(() => { if (scrollContainerRef.current) { const scrollHour = 8; @@ -61,63 +73,42 @@ export const CalendarWeekView: React.FC = ({ } }, []); - const getEventsForDay = (day: Date) => { - return events.filter((ev) => { - try { - const evDate = parseISO(ev.value.startsAt); - return isSameDay(day, evDate); - } catch { - return false; - } - }); - }; + const getEventsForDay = (day: Date) => eventsOnDay(events, day); const handleDropOnSlot = ( e: React.DragEvent, targetDay: Date, - targetHour: number + targetHour: number, ) => { e.preventDefault(); e.stopPropagation(); setDragOverSlot(null); - setDraggingEventRkey(null); - - const raw = e.dataTransfer.getData('application/json') || e.dataTransfer.getData('text/plain'); - if (!raw) return; - - let droppedEvent: CalendarEventItem | undefined; - try { - const parsed = JSON.parse(raw); - droppedEvent = parsed.value ? (parsed as CalendarEventItem) : events.find((ev) => ev.rkey === parsed.rkey); - } catch { - return; - } + setDraggingEventUri(null); - if (!droppedEvent || !droppedEvent.value?.startsAt) return; - - try { - const origStart = parseISO(droppedEvent.value.startsAt); - const durationMs = droppedEvent.value.endsAt - ? Math.max(0, parseISO(droppedEvent.value.endsAt).getTime() - origStart.getTime()) - : 60 * 60 * 1000; - - // Preserve original minutes (e.g., if 10:15, moving to hour 14 becomes 14:15) - const origMinutes = origStart.getMinutes(); - const newStart = new Date(targetDay); - newStart.setHours(targetHour, origMinutes, 0, 0); - const newEnd = new Date(newStart.getTime() + durationMs); - - onRescheduleEvent?.(droppedEvent, newStart.toISOString(), newEnd.toISOString()); - } catch (err) { - console.error('Failed to reschedule event on drop:', err); - } + const raw = + e.dataTransfer.getData("application/json") || + e.dataTransfer.getData("text/plain"); + const move = resolveEventDrop( + raw, + events, + currentDid, + targetDay, + targetHour, + ); + if (move) onRescheduleEvent?.(move.event, move.updates); }; - const handleDropOnColumn = (e: React.DragEvent, targetDay: Date) => { + const handleDropOnColumn = ( + e: React.DragEvent, + targetDay: Date, + ) => { e.preventDefault(); const rect = e.currentTarget.getBoundingClientRect(); const offsetY = e.clientY - rect.top; - const calculatedHour = Math.min(23, Math.max(0, Math.floor(offsetY / HOUR_HEIGHT))); + const calculatedHour = Math.min( + 23, + Math.max(0, Math.floor(offsetY / HOUR_HEIGHT)), + ); handleDropOnSlot(e, targetDay, calculatedHour); }; @@ -126,7 +117,6 @@ export const CalendarWeekView: React.FC = ({ return (
- {/* Week Header */}
@@ -135,16 +125,16 @@ export const CalendarWeekView: React.FC = ({ return (
- {format(day, 'EEE')} + {format(day, "EEE")}
- {format(day, 'd')} + {format(day, "d")}
); @@ -152,49 +142,201 @@ export const CalendarWeekView: React.FC = ({
- {/* Hourly Grid */} -
- {/* Hour Labels */} +
+
+ All-day +
+
+ {days.map((day) => { + const dayKey = format(day, "yyyy-MM-dd"); + const allDayEvents = getEventsForDay(day).filter( + (event) => + isAllDayEvent(event.value) && + !weekSpans.some((span) => span.event.uri === event.uri), + ); + return ( +
{ + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }} + onDrop={(e) => handleDropOnSlot(e, day, 0)} + > + {allDayEvents.map((event) => { + const isOwner = event.repo === currentDid; + const eventColor = event.calendarColor || space.color; + const eventLabel = `${event.value.name}${event.calendarName ? `, ${event.calendarName}` : ""}${event.hasConflict ? ", Overlapping event" : ""}`; + return ( + + ); + })} +
+ ); + })} +
+ {weekSpans.map((span) => { + const color = span.event.calendarColor || space.color; + const isOwner = span.event.repo === currentDid; + return ( + + ); + })} +
+
+
+ +
{HOURS.map((hour) => (
- {hour === 0 ? '' : `${String(hour).padStart(2, '0')}:00`} + {hour === 0 ? "" : `${String(hour).padStart(2, "0")}:00`}
))}
- {/* 7 Days Columns */}
{days.map((day) => { const dayIsToday = isToday(day); - const dayEvents = getEventsForDay(day); - const dayKey = format(day, 'yyyy-MM-dd'); + const dayEvents = getEventsForDay(day).filter( + (event) => + !weekSpans.some((span) => span.event.uri === event.uri), + ); + const dayKey = format(day, "yyyy-MM-dd"); return (
{ e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; + e.dataTransfer.dropEffect = "move"; }} onDrop={(e) => handleDropOnColumn(e, day)} > - {/* Horizontal hour lines (clickable & drop targets) */} {HOURS.map((hour) => { const isSlotDragOver = - dragOverSlot?.dayKey === dayKey && dragOverSlot?.hour === hour; + dragOverSlot?.dayKey === dayKey && + dragOverSlot?.hour === hour; return (
{ const newDate = new Date(day); @@ -203,8 +345,11 @@ export const CalendarWeekView: React.FC = ({ }} onDragOver={(e) => { e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - if (dragOverSlot?.dayKey !== dayKey || dragOverSlot?.hour !== hour) { + e.dataTransfer.dropEffect = "move"; + if ( + dragOverSlot?.dayKey !== dayKey || + dragOverSlot?.hour !== hour + ) { setDragOverSlot({ dayKey, hour }); } }} @@ -218,7 +363,9 @@ export const CalendarWeekView: React.FC = ({ !e.currentTarget.contains(e.relatedTarget as Node) ) { setDragOverSlot((prev) => - prev && prev.dayKey === dayKey && prev.hour === hour ? null : prev + prev && prev.dayKey === dayKey && prev.hour === hour + ? null + : prev, ); } }} @@ -226,14 +373,13 @@ export const CalendarWeekView: React.FC = ({ > {isSlotDragOver && (
- {String(hour).padStart(2, '0')}:00 + {String(hour).padStart(2, "0")}:00
)}
); })} - {/* Live Current Time Indicator */} {dayIsToday && (
= ({
)} - {/* Event Cards */} {dayEvents.map((event) => { try { - const start = parseISO(event.value.startsAt); - const end = event.value.endsAt - ? parseISO(event.value.endsAt) - : new Date(start.getTime() + 60 * 60 * 1000); - - const startMinutes = getHours(start) * 60 + getMinutes(start); - let durationMinutes = differenceInMinutes(end, start); - if (durationMinutes < 25) durationMinutes = 25; + if (isAllDayEvent(event.value)) return null; + const segment = getEventDaySegment(event.value, day); + if (!segment) return null; + const startMinutes = segment.startMinute; + const segmentMinutes = + segment.endMinute - segment.startMinute; + const durationMinutes = Math.min( + 1440 - startMinutes, + Math.max(25, segmentMinutes), + ); const topPx = (startMinutes / 60) * HOUR_HEIGHT; const heightPx = (durationMinutes / 60) * HOUR_HEIGHT; - const isVirtual = event.value.mode === 'community.lexicon.calendar.event#virtual'; - const isBeingDragged = draggingEventRkey === event.rkey; + const isVirtual = + event.value.mode === + "community.lexicon.calendar.event#virtual"; + const isOwner = event.repo === currentDid; + const eventColor = event.calendarColor || space.color; + const eventLabel = `${event.value.name}${event.calendarName ? `, ${event.calendarName}` : ""}${event.hasConflict ? ", Overlapping event" : ""}`; + const isBeingDragged = draggingEventUri === event.uri; return (
{ + if (!isOwner) return; e.stopPropagation(); - e.dataTransfer.setData('application/json', JSON.stringify(event)); - e.dataTransfer.setData('text/plain', JSON.stringify({ rkey: event.rkey, repo: event.repo })); - e.dataTransfer.effectAllowed = 'move'; - setDraggingEventRkey(event.rkey); + e.dataTransfer.setData( + "text/plain", + JSON.stringify({ + uri: event.uri, + sourceDate: format(day, "yyyy-MM-dd"), + }), + ); + e.dataTransfer.effectAllowed = "move"; + setDraggingEventUri(event.uri); }} onDragEnd={() => { - setDraggingEventRkey(null); + setDraggingEventUri(null); setDragOverSlot(null); }} onClick={(e) => { e.stopPropagation(); onSelectEvent(event); }} + onKeyDown={(e) => { + if ( + !e.metaKey && + !e.ctrlKey && + !e.altKey && + (e.key === "Enter" || e.key === " ") + ) { + e.preventDefault(); + onSelectEvent(event); + } + }} + role="button" + tabIndex={0} + aria-label={eventLabel} + title={ + event.calendarName + ? `${event.value.name} · ${event.calendarName}` + : event.value.name + } style={{ top: `${topPx}px`, - height: `${Math.max(24, heightPx - 2)}px`, - backgroundColor: `${space.color}22`, - borderLeftColor: space.color, + height: `${Math.max(1, heightPx - 2)}px`, + backgroundColor: `${eventColor}22`, + borderLeftColor: eventColor, }} className={`absolute inset-x-0.5 sm:inset-x-1 rounded-lg border-l-4 p-1 overflow-hidden shadow-sm hover:shadow-md transition group z-10 hover:brightness-95 ${ isBeingDragged - ? 'opacity-40 cursor-grabbing' - : draggingEventRkey - ? 'pointer-events-none opacity-60' - : 'cursor-grab active:cursor-grabbing' + ? "opacity-40 cursor-grabbing" + : draggingEventUri + ? "pointer-events-none opacity-60" + : isOwner + ? "cursor-grab active:cursor-grabbing" + : "cursor-pointer" }`} >
@@ -302,11 +482,20 @@ export const CalendarWeekView: React.FC = ({ )} {event.value.name} + {event.hasConflict && ( + + ! + + )}
- {format(start, 'HH:mm')} – {format(end, 'HH:mm')} + {getEventTimeLabel(event.value, day)}
{event.value.description && heightPx > 60 && ( diff --git a/src/components/EventModal.tsx b/src/components/EventModal.tsx index 09e3964..321bad0 100644 --- a/src/components/EventModal.tsx +++ b/src/components/EventModal.tsx @@ -1,4 +1,7 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from "react"; +import { RRule } from 'rrule'; +import { formatZonedDate } from "../utils/zonedDate"; +import { addDays, subDays } from "date-fns"; import { X, Calendar as CalendarIcon, @@ -14,7 +17,7 @@ import { Check, HelpCircle, Users, -} from 'lucide-react'; +} from "lucide-react"; import { CalendarEventItem, CalendarEventRecord, @@ -22,11 +25,18 @@ import { EventStatus, CalendarRsvpItem, RsvpStatus, -} from '../types/calendar'; -import { atprotoService } from '../services/atproto'; -import { formatLocalDate, formatLocalTime, toISOFromLocal, isEndTimeValid } from '../utils/dateUtils'; -import { generateIcs, downloadIcsFile } from '../utils/ical'; -import { useToast } from './Toast'; +} from "../types/calendar"; +import { atprotoService } from "../services/atproto"; +import { deleteCalendarEvent, EventEditScope, saveEventChanges } from '../services/eventEditing'; +import { validateRecurrenceRule } from '../utils/recurrence'; +import { useModalFocus } from '../hooks/useModalFocus'; +import { + formatLocalDate, + formatLocalTime, + toISOFromZoned, +} from "../utils/dateUtils"; +import { generateIcs, downloadIcsFile } from "../utils/ical"; +import { useToast } from "./Toast"; interface EventModalProps { isOpen: boolean; @@ -39,6 +49,16 @@ interface EventModalProps { onDeleted: () => void; } +function recurrenceUntilDate(rule: string, allDay: boolean, timeZone: string, parsed: ReturnType): string { + const raw = rule.match(/(?:^|;)UNTIL=(\d{8})(?:T\d{6}Z?)?(?:;|$)/i); + if (allDay && raw) return `${raw[1].slice(0, 4)}-${raw[1].slice(4, 6)}-${raw[1].slice(6, 8)}`; + return parsed.until ? formatZonedDate(parsed.until, timeZone, 'yyyy-MM-dd') : ''; +} + +function isCustomRecurrence(rule: string): boolean { + return rule.replace(/^RRULE:/i, '').split(';').some((part) => !['FREQ', 'INTERVAL', 'COUNT', 'UNTIL'].includes(part.split('=')[0].toUpperCase())); +} + export const EventModal: React.FC = ({ isOpen, onClose, @@ -51,47 +71,105 @@ export const EventModal: React.FC = ({ }) => { const toast = useToast(); - const [name, setName] = useState(''); - const [description, setDescription] = useState(''); - const [startDate, setStartDate] = useState(''); - const [startTime, setStartTime] = useState('09:00'); - const [endDate, setEndDate] = useState(''); - const [endTime, setEndTime] = useState('10:00'); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [startDate, setStartDate] = useState(""); + const [startTime, setStartTime] = useState("09:00"); + const [endDate, setEndDate] = useState(""); + const [endTime, setEndTime] = useState("10:00"); const [isAllDay, setIsAllDay] = useState(false); - const [mode, setMode] = useState('community.lexicon.calendar.event#virtual'); - const [status, setStatus] = useState('community.lexicon.calendar.event#scheduled'); - const [meetingUrl, setMeetingUrl] = useState(''); - const [locationName, setLocationName] = useState(''); - const [locationAddress, setLocationAddress] = useState(''); + const [timeZone, setTimeZone] = useState( + Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", + ); + const originalTimedRef = useRef<{ + startsAt: string; + endsAt?: string; + startDate: string; + startTime: string; + endDate: string; + endTime: string; + timeZone: string; + } | null>(null); + const [mode, setMode] = useState( + "community.lexicon.calendar.event#virtual", + ); + const [status, setStatus] = useState( + "community.lexicon.calendar.event#scheduled", + ); + const [meetingUrl, setMeetingUrl] = useState(""); + const [locationName, setLocationName] = useState(""); + const [locationAddress, setLocationAddress] = useState(""); + const [recurrenceFrequency, setRecurrenceFrequency] = useState('none'); + const [recurrenceInterval, setRecurrenceInterval] = useState('1'); + const [recurrenceEnds, setRecurrenceEnds] = useState<'never' | 'count' | 'until'>('never'); + const [recurrenceCount, setRecurrenceCount] = useState(''); + const [recurrenceUntil, setRecurrenceUntil] = useState(''); + const [recurrenceControlsChanged, setRecurrenceControlsChanged] = useState(false); + const [customRecurrence, setCustomRecurrence] = useState(false); + const originalRecurrenceRef = useRef(undefined); + const [reminderMinutes, setReminderMinutes] = useState(''); + const [editScope, setEditScope] = useState('series'); const [loading, setLoading] = useState(false); const [deleting, setDeleting] = useState(false); const [error, setError] = useState(null); + const saveInFlight = useRef(false); - // RSVP state const [rsvps, setRsvps] = useState([]); const [loadingRsvps, setLoadingRsvps] = useState(false); + const [rsvpError, setRsvpError] = useState(null); const [updatingRsvp, setUpdatingRsvp] = useState(null); + const rsvpRequestId = useRef(0); + const requestClose = () => { + if (!loading && !deleting && !saveInFlight.current && !updatingRsvp) onClose(); + }; + const modalRef = useModalFocus(isOpen, requestClose); - const loadRsvps = async () => { - if (!eventToEdit) return; + const loadRsvps = async (requestedEvent = eventToEdit) => { + if (!requestedEvent) return; + const requestId = ++rsvpRequestId.current; + const requestedUri = requestedEvent.uri; + const requestedSpaceUri = spaceUri; setLoadingRsvps(true); + setRsvpError(null); try { - const items = await atprotoService.listEventRsvps(spaceUri, eventToEdit.uri, memberDids); - setRsvps(items); - } catch (err) { - console.warn('Failed to load RSVPs for event:', err); + const items = await atprotoService.listEventRsvps( + requestedSpaceUri, + requestedUri, + memberDids, + ); + if ( + requestId === rsvpRequestId.current && + eventToEdit?.uri === requestedUri && + spaceUri === requestedSpaceUri + ) { + setRsvps(items); + } + } catch (err: any) { + if ( + requestId === rsvpRequestId.current && + eventToEdit?.uri === requestedUri + ) { + setRsvpError(err.message || "Failed to load RSVPs."); + } } finally { - setLoadingRsvps(false); + if (requestId === rsvpRequestId.current) setLoadingRsvps(false); } }; useEffect(() => { if (isOpen && eventToEdit) { + setRsvps([]); loadRsvps(); } else { + rsvpRequestId.current += 1; setRsvps([]); + setRsvpError(null); + setLoadingRsvps(false); } + return () => { + rsvpRequestId.current += 1; + }; }, [isOpen, eventToEdit, spaceUri]); const currentDid = atprotoService.getCurrentDid(); @@ -104,40 +182,56 @@ export const EventModal: React.FC = ({ try { await atprotoService.setEventRsvp(spaceUri, eventToEdit.uri, status); const label = - status === 'community.lexicon.calendar.rsvp#going' - ? 'Going' - : status === 'community.lexicon.calendar.rsvp#maybe' - ? 'Maybe' - : "Can't Go"; + status === "community.lexicon.calendar.rsvp#going" + ? "Going" + : status === "community.lexicon.calendar.rsvp#maybe" + ? "Maybe" + : "Can't Go"; toast.success(`RSVP updated to ${label}`); await loadRsvps(); } catch (err: any) { - toast.error(err.message || 'Failed to update RSVP'); + toast.error(err.message || "Failed to update RSVP"); } finally { setUpdatingRsvp(null); } }; - const goingRsvps = rsvps.filter((r) => r.value.status === 'community.lexicon.calendar.rsvp#going'); - const maybeRsvps = rsvps.filter((r) => r.value.status === 'community.lexicon.calendar.rsvp#maybe'); - const notGoingRsvps = rsvps.filter((r) => r.value.status === 'community.lexicon.calendar.rsvp#notgoing'); + const goingRsvps = rsvps.filter( + (r) => r.value.status === "community.lexicon.calendar.rsvp#going", + ); + const maybeRsvps = rsvps.filter( + (r) => r.value.status === "community.lexicon.calendar.rsvp#maybe", + ); + const notGoingRsvps = rsvps.filter( + (r) => r.value.status === "community.lexicon.calendar.rsvp#notgoing", + ); const totalRsvps = rsvps.length; const renderAttendeeChip = (rsvp: CalendarRsvpItem) => { const isMe = rsvp.repo === currentDid; const name = rsvp.memberProfile?.displayName || - (rsvp.memberProfile?.handle ? `@${rsvp.memberProfile.handle}` : rsvp.repo.slice(0, 12)); + (rsvp.memberProfile?.handle + ? `@${rsvp.memberProfile.handle}` + : rsvp.repo.slice(0, 12)); const avatar = rsvp.memberProfile?.avatar; return (
{avatar ? ( - + ) : (
{name.charAt(0).toUpperCase()} @@ -166,14 +260,32 @@ export const EventModal: React.FC = ({ RSVP & Attendees - {loadingRsvps && } + {loadingRsvps && ( + + )}
- {totalRsvps} response{totalRsvps === 1 ? '' : 's'} + {totalRsvps} response{totalRsvps === 1 ? "" : "s"}
- {/* RSVP Buttons */} + {rsvpError && ( +
+ {rsvpError} + +
+ )} +
- {/* Attendee Lists */}
{totalRsvps === 0 && !loadingRsvps ? ( -

No responses yet. Be the first to RSVP!

+

+ No responses yet. Be the first to RSVP! +

) : ( <> {goingRsvps.length > 0 && ( @@ -283,126 +402,250 @@ export const EventModal: React.FC = ({ ); }; - // Keyboard Escape listener useEffect(() => { if (!isOpen) return; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [isOpen, onClose]); - useEffect(() => { - if (!isOpen) return; + const editingEvent = editScope === 'series' && eventToEdit?.series + ? eventToEdit.series + : eventToEdit; - if (eventToEdit) { - const val = eventToEdit.value; - setName(val.name || ''); - setDescription(val.description || ''); + if (editingEvent) { + const val = editingEvent.value; + setName(val.name || ""); + setDescription(val.description || ""); const start = new Date(val.startsAt); - setStartDate(formatLocalDate(start)); - setStartTime(formatLocalTime(start)); + const end = val.endsAt ? new Date(val.endsAt) : new Date(start.getTime() + 3600000); + const storedZone = + val.timeZone || + Intl.DateTimeFormat().resolvedOptions().timeZone || + "UTC"; + setTimeZone(storedZone); + setStartDate( + val.allDay && val.startDate + ? val.startDate + : val.timeZone + ? formatZonedDate(start, storedZone, "yyyy-MM-dd") + : formatLocalDate(start), + ); + setStartTime( + val.allDay + ? "00:00" + : val.timeZone + ? formatZonedDate(start, storedZone, "HH:mm") + : formatLocalTime(start), + ); if (val.endsAt) { - const end = new Date(val.endsAt); - setEndDate(formatLocalDate(end)); - setEndTime(formatLocalTime(end)); + setEndDate( + val.allDay + ? val.endDate + ? formatLocalDate(subDays(new Date(`${val.endDate}T00:00:00`), 1)) + : formatLocalDate(subDays(end, 1)) + : val.timeZone + ? formatZonedDate(end, storedZone, "yyyy-MM-dd") + : formatLocalDate(end), + ); + setEndTime( + val.allDay + ? "00:00" + : val.timeZone + ? formatZonedDate(end, storedZone, "HH:mm") + : formatLocalTime(end), + ); } else { - setEndDate(formatLocalDate(start)); - setEndTime('10:00'); + setEndDate(formatZonedDate(end, storedZone, 'yyyy-MM-dd')); + setEndTime(formatZonedDate(end, storedZone, 'HH:mm')); } - // Check if all-day event (00:00 to 23:59) - if (val.startsAt.includes('T00:00:00') && val.endsAt?.includes('T23:59:00')) { - setIsAllDay(true); - } else { - setIsAllDay(false); - } - - setMode(val.mode || 'community.lexicon.calendar.event#virtual'); - setStatus(val.status || 'community.lexicon.calendar.event#scheduled'); - setMeetingUrl(val.uris?.[0]?.uri || ''); + setIsAllDay(val.allDay === true); + originalTimedRef.current = val.allDay + ? null + : { + startsAt: val.startsAt, + endsAt: val.endsAt, + startDate: formatZonedDate(start, storedZone, 'yyyy-MM-dd'), + startTime: formatZonedDate(start, storedZone, 'HH:mm'), + endDate: formatZonedDate(end, storedZone, 'yyyy-MM-dd'), + endTime: formatZonedDate(end, storedZone, 'HH:mm'), + timeZone: storedZone, + }; + + setMode(val.mode || "community.lexicon.calendar.event#virtual"); + setStatus(val.status || "community.lexicon.calendar.event#scheduled"); + setMeetingUrl(val.uris?.[0]?.uri || ""); const loc = val.locations?.[0]; - setLocationName(loc?.name || ''); - setLocationAddress(loc?.address || ''); + setLocationName(loc?.name || ""); + setLocationAddress(loc?.address || ""); + let recurrence: ReturnType | null = null; + try { recurrence = val.recurrence ? RRule.parseString(val.recurrence) : null; } catch { /* Preserve imported rules even if they cannot be edited here. */ } + originalRecurrenceRef.current = val.recurrence; + setRecurrenceControlsChanged(false); + setCustomRecurrence(Boolean(val.recurrence && (!recurrence || isCustomRecurrence(val.recurrence)))); + setRecurrenceFrequency(recurrence ? RRule.FREQUENCIES[recurrence.freq ?? RRule.DAILY].toLowerCase() : 'none'); + setRecurrenceInterval(String(recurrence?.interval || 1)); + setRecurrenceEnds(recurrence?.count ? 'count' : recurrence?.until ? 'until' : 'never'); + setRecurrenceCount(recurrence?.count ? String(recurrence.count) : ''); + setRecurrenceUntil(recurrence && val.recurrence ? recurrenceUntilDate(val.recurrence, val.allDay === true, storedZone, recurrence) : ''); + setReminderMinutes(val.reminderMinutes == null ? '' : String(val.reminderMinutes)); } else { - const d = initialDate || new Date(); + const d = new Date(initialDate || new Date()); + d.setMinutes(Math.floor(d.getMinutes() / 15) * 15, 0, 0); + const end = new Date(d.getTime() + 3600000); setStartDate(formatLocalDate(d)); - setEndDate(formatLocalDate(d)); - - const startH = d.getHours(); - const endH = (startH + 1) % 24; - const startM = Math.floor(d.getMinutes() / 15) * 15; - - const formattedStart = `${String(startH).padStart(2, '0')}:${String(startM).padStart(2, '0')}`; - const formattedEnd = `${String(endH).padStart(2, '0')}:${String(startM).padStart(2, '0')}`; - - setStartTime(formattedStart); - setEndTime(formattedEnd); + setEndDate(formatLocalDate(end)); + setStartTime(formatLocalTime(d)); + setEndTime(formatLocalTime(end)); setIsAllDay(false); + setTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"); + originalTimedRef.current = { + startsAt: d.toISOString(), endsAt: end.toISOString(), + startDate: formatLocalDate(d), startTime: formatLocalTime(d), + endDate: formatLocalDate(end), endTime: formatLocalTime(end), + timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC', + }; - setName(''); - setDescription(''); - setMode('community.lexicon.calendar.event#virtual'); - setStatus('community.lexicon.calendar.event#scheduled'); - setMeetingUrl(''); - setLocationName(''); - setLocationAddress(''); + setName(""); + setDescription(""); + setMode("community.lexicon.calendar.event#virtual"); + setStatus("community.lexicon.calendar.event#scheduled"); + setMeetingUrl(""); + setLocationName(""); + setLocationAddress(""); + setRecurrenceFrequency('none'); + setRecurrenceInterval('1'); + setRecurrenceEnds('never'); + setRecurrenceCount(''); + setRecurrenceUntil(''); + setRecurrenceControlsChanged(false); + setCustomRecurrence(false); + originalRecurrenceRef.current = undefined; + setReminderMinutes(''); } setError(null); - }, [isOpen, eventToEdit, initialDate]); + }, [isOpen, eventToEdit, initialDate, editScope]); + + useEffect(() => { + if (isOpen) setEditScope(eventToEdit?.recurrenceId ? 'occurrence' : 'series'); + }, [isOpen, eventToEdit]); if (!isOpen) return null; - const isMyEvent = !eventToEdit || eventToEdit.repo === atprotoService.getCurrentDid(); + const isMyEvent = + !eventToEdit || eventToEdit.repo === atprotoService.getCurrentDid(); + + // Preserve instants when a duration crosses the repeated autumn clock hour. + const setEndAfter = (date: string, time: string, minutes: number) => { + try { + const original = originalTimedRef.current; + const startsAt = original?.startDate === date && original.startTime === time && original.timeZone === timeZone + ? original.startsAt + : toISOFromZoned(date, time, timeZone); + const end = new Date(new Date(startsAt).getTime() + minutes * 60000); + const nextEndDate = formatZonedDate(end, timeZone, 'yyyy-MM-dd'); + const nextEndTime = formatZonedDate(end, timeZone, 'HH:mm'); + setEndDate(nextEndDate); + setEndTime(nextEndTime); + originalTimedRef.current = { + startsAt, endsAt: end.toISOString(), startDate: date, startTime: time, + endDate: nextEndDate, endTime: nextEndTime, timeZone, + }; + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Could not calculate the event duration.'); + } + }; - // Auto-adjust end time if start time is changed past end time const handleStartDateChange = (newStartDate: string) => { setStartDate(newStartDate); if (newStartDate > endDate) { - setEndDate(newStartDate); + if (isAllDay) setEndDate(newStartDate); + else setEndAfter(newStartDate, startTime, 60); } }; const handleStartTimeChange = (newStartTime: string) => { setStartTime(newStartTime); if (startDate === endDate && newStartTime >= endTime) { - const [h, m] = newStartTime.split(':').map(Number); - const nextHour = (h + 1) % 24; - setEndTime(`${String(nextHour).padStart(2, '0')}:${String(m).padStart(2, '0')}`); + setEndAfter(startDate, newStartTime, 60); } }; const setDuration = (minutes: number) => { - const [h, m] = startTime.split(':').map(Number); - const totalMinutes = h * 60 + m + minutes; - const newH = Math.floor(totalMinutes / 60) % 24; - const newM = totalMinutes % 60; - setEndTime(`${String(newH).padStart(2, '0')}:${String(newM).padStart(2, '0')}`); + setEndAfter(startDate, startTime, minutes); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!name.trim()) return; + if (!name.trim() || !isMyEvent || saveInFlight.current) return; - // Validate end >= start - const finalStartTime = isAllDay ? '00:00' : startTime; - const finalEndTime = isAllDay ? '23:59' : endTime; + const interval = Number(recurrenceInterval); + if (recurrenceFrequency !== 'none' && (!Number.isInteger(interval) || interval < 1)) { + setError('Repeat interval must be a whole number of at least 1.'); + return; + } + if (recurrenceFrequency !== 'none' && recurrenceEnds === 'count' && (!/^\d+$/.test(recurrenceCount) || Number(recurrenceCount) < 1)) { + setError('Repeat count must be a whole number of at least 1.'); + return; + } + if (recurrenceFrequency !== 'none' && recurrenceEnds === 'until' && !/^\d{4}-\d{2}-\d{2}$/.test(recurrenceUntil)) { + setError('Choose a date for when the repeat ends.'); + return; + } + const simplifiedRecurrence = recurrenceFrequency === 'none' + ? undefined + : [ + `FREQ=${recurrenceFrequency.toUpperCase()}`, + interval === 1 ? '' : `INTERVAL=${interval}`, + recurrenceEnds === 'count' ? `COUNT=${recurrenceCount}` : '', + recurrenceEnds === 'until' ? `UNTIL=${recurrenceUntil.replaceAll('-', '')}T235959` : '', + ].filter(Boolean).join(';'); + const recurrence = !recurrenceControlsChanged && originalRecurrenceRef.current !== undefined + ? originalRecurrenceRef.current + : simplifiedRecurrence; + const recurrenceError = recurrenceControlsChanged && recurrence ? validateRecurrenceRule(recurrence) : null; + if (recurrenceError) { + setError(recurrenceError); + return; + } - if (!isEndTimeValid(startDate, finalStartTime, endDate, finalEndTime)) { - setError('End date and time must be after start date and time.'); + const finalStartTime = isAllDay ? "00:00" : startTime; + const finalEndTime = isAllDay ? "00:00" : endTime; + if (isAllDay && endDate < startDate) { + setError("End date must be on or after the start date."); return; } + const finalEndDate = isAllDay + ? formatLocalDate(addDays(new Date(`${endDate}T00:00:00`), 1)) + : endDate; + saveInFlight.current = true; setLoading(true); setError(null); try { - const startIso = toISOFromLocal(startDate, finalStartTime); - const endIso = toISOFromLocal(endDate, finalEndTime); + const unchangedStart = + !isAllDay && + originalTimedRef.current && + originalTimedRef.current.startDate === startDate && + originalTimedRef.current.startTime === finalStartTime && + originalTimedRef.current.timeZone === timeZone; + const originalTimed = originalTimedRef.current; + const unchangedEnd = !isAllDay && originalTimed?.endDate === finalEndDate && originalTimed.endTime === finalEndTime && originalTimed.timeZone === timeZone; + const startIso = isAllDay + ? `${startDate}T00:00:00.000Z` + : unchangedStart && originalTimed + ? originalTimed.startsAt + : toISOFromZoned(startDate, finalStartTime, timeZone); + const endIso = isAllDay + ? `${finalEndDate}T00:00:00.000Z` + : unchangedEnd && originalTimed?.endsAt + ? originalTimed.endsAt + : toISOFromZoned(finalEndDate, finalEndTime, timeZone); + if (new Date(endIso).getTime() < new Date(startIso).getTime()) { + throw new Error('End date and time must be on or after start date and time.'); + } const locations = locationName.trim() ? [ @@ -413,49 +656,74 @@ export const EventModal: React.FC = ({ ] : undefined; - const eventPayload: Omit = { + const eventPayload: Omit = { name: name.trim(), description: description.trim() || undefined, startsAt: startIso, endsAt: endIso, + allDay: isAllDay, + startDate: isAllDay ? startDate : undefined, + endDate: isAllDay ? finalEndDate : undefined, + timeZone: isAllDay ? undefined : timeZone, mode, status, locations, - uris: meetingUrl.trim() ? [{ uri: meetingUrl.trim(), name: 'Meeting' }] : undefined, + uris: meetingUrl.trim() + ? [{ uri: meetingUrl.trim(), name: "Meeting" }] + : undefined, + reminderMinutes: reminderMinutes === '' ? undefined : Number(reminderMinutes), + ...((!eventToEdit || editScope === 'series') ? { recurrence } : {}), }; if (eventToEdit) { - await atprotoService.updateEvent(spaceUri, eventToEdit.rkey, eventPayload); - toast.success('Event updated successfully'); + const updates = editScope === 'occurrence' && eventToEdit.recurrenceId + ? Object.entries(eventPayload).reduce>((result, [key, value]) => { + const previous = eventToEdit.value[key as keyof CalendarEventRecord]; + if (JSON.stringify(previous) === JSON.stringify(value)) return result; + if (value === undefined && key === 'description') result.description = ''; + else if (value === undefined && key === 'locations') result.locations = []; + else if (value === undefined && key === 'uris') result.uris = []; + else if (value === undefined && key === 'reminderMinutes') result.reminderMinutes = null; + else (result as Record)[key] = value; + return result; + }, {}) + : eventPayload; + await saveEventChanges(eventToEdit, updates, editScope); + toast.success("Event updated successfully"); } else { await atprotoService.createEvent(spaceUri, eventPayload); - toast.success('Event created in space'); + toast.success("Event created in space"); } onSaved(); onClose(); } catch (err: any) { - setError(err.message || 'Failed to save event to space repo.'); + setError(err.message || "Failed to save event to space repo."); } finally { + saveInFlight.current = false; setLoading(false); } }; const handleDelete = async () => { - if (!eventToEdit) return; - if (!confirm('Are you sure you want to delete this event from the space?')) return; + if (!eventToEdit || saveInFlight.current) return; + const target = editScope === 'occurrence' ? 'this occurrence' : 'the whole series'; + if (!confirm(`Delete ${target}?`)) + return; + saveInFlight.current = true; setDeleting(true); setError(null); try { - await atprotoService.deleteEvent(spaceUri, eventToEdit.rkey); - toast.success('Event removed from space'); + await deleteCalendarEvent(eventToEdit, editScope); + toast.success("Event removed from space"); onDeleted(); onClose(); } catch (err: any) { - setError(err.message || 'Failed to delete event from space.'); + setError(err.message || "Failed to delete event from space."); } finally { + saveInFlight.current = false; setDeleting(false); } }; @@ -465,37 +733,48 @@ export const EventModal: React.FC = ({ const icsContent = generateIcs( { uri: spaceUri, - skey: '', - type: '', - authorityDid: '', + skey: "", + type: "", + authorityDid: "", name: eventToEdit.value.name, - color: '#0284c7', + color: "#0284c7", isOwner: true, }, - [eventToEdit] + [eventToEdit], + ); + downloadIcsFile( + `${eventToEdit.value.name.replace(/[^a-zA-Z0-9]/g, "_")}`, + icsContent, ); - downloadIcsFile(`${eventToEdit.value.name.replace(/[^a-zA-Z0-9]/g, '_')}`, icsContent); - toast.success('Downloaded event .ics'); + toast.success("Downloaded event .ics"); }; return (
e.stopPropagation()} > - {/* Header */}
-

- {!isMyEvent ? 'Event Details' : eventToEdit ? 'Edit Event' : 'New Calendar Event'} +

+ {!isMyEvent + ? "Event Details" + : eventToEdit + ? "Edit Event" + : "New Calendar Event"}

community.lexicon.calendar.event @@ -503,24 +782,28 @@ export const EventModal: React.FC = ({

- {/* Read-Only Details Card for Collaborator Events */} {!isMyEvent && eventToEdit ? (
- {/* Title & Status */}
- {eventToEdit.value.mode?.replace('community.lexicon.calendar.event#', '') || 'Virtual'} + {eventToEdit.value.mode?.replace( + "community.lexicon.calendar.event#", + "", + ) || "Virtual"} - {eventToEdit.value.status?.replace('community.lexicon.calendar.event#', '') || 'Scheduled'} + {eventToEdit.value.status?.replace( + "community.lexicon.calendar.event#", + "", + ) || "Scheduled"}

@@ -528,20 +811,26 @@ export const EventModal: React.FC = ({

- {/* Date & Time */}
- {startDate} {isAllDay ? '(All Day)' : `${startTime} – ${endTime}`} + {startDate}{" "} + {isAllDay ? "(All Day)" : `${startTime} – ${endTime}`}
{startDate !== endDate && ( -
Ends: {endDate}
+
+ Ends: {endDate} +
+ )} + {!isAllDay && timeZone && ( +
+ Time zone: {timeZone} +
)}
- {/* Meeting Link or Venue */} {meetingUrl && (
@@ -567,12 +856,15 @@ export const EventModal: React.FC = ({
{locationName}
- {locationAddress &&
{locationAddress}
} + {locationAddress && ( +
+ {locationAddress} +
+ )}
)} - {/* Description */} {description && (
@@ -584,15 +876,17 @@ export const EventModal: React.FC = ({
)} - {/* RSVP & Attendees */} {renderRsvpSection()} - {/* Author Attribution */}
- Author: {eventToEdit.authorProfile?.handle ? `@${eventToEdit.authorProfile.handle}` : eventToEdit.repo} + + Author:{" "} + {eventToEdit.authorProfile?.handle + ? `@${eventToEdit.authorProfile.handle}` + : eventToEdit.repo} +
- {/* Footer */}
) : ( /* Editable Form for Creator */ -
+ {error && (
{error}
)} - {/* Event Title */} + {eventToEdit?.recurrenceId && eventToEdit.series && ( +
+ Apply changes to +
+ + +
+
+ )} +
= ({ />
- {/* All Day Toggle */}
setIsAllDay(e.target.checked)} className="w-4 h-4 rounded text-primary-600 focus:ring-primary-500 cursor-pointer" />
- {/* Date & Time Grid */}
handleStartDateChange(e.target.value)} @@ -666,6 +979,7 @@ export const EventModal: React.FC = ({ {!isAllDay && ( handleStartTimeChange(e.target.value)} @@ -677,11 +991,12 @@ export const EventModal: React.FC = ({
setEndDate(e.target.value)} @@ -690,6 +1005,7 @@ export const EventModal: React.FC = ({ {!isAllDay && ( setEndTime(e.target.value)} @@ -700,10 +1016,85 @@ export const EventModal: React.FC = ({
- {/* Quick Duration Buttons */} + {!isAllDay && ( +
+ + +
+ )} + + {(!eventToEdit || editScope === 'series') && ( +
+ + {customRecurrence && !recurrenceControlsChanged &&

Custom repeat rule preserved until you change these controls.

} +
+ + {recurrenceFrequency !== 'none' && { setRecurrenceControlsChanged(true); setRecurrenceInterval(e.target.value); }} className="rounded-xl border border-slate-300 bg-white px-2.5 py-1.5 text-xs text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-white" />} +
+ {recurrenceFrequency !== 'none' && ( +
+ + {recurrenceEnds === 'count' && { setRecurrenceControlsChanged(true); setRecurrenceCount(e.target.value); }} className="rounded-xl border border-slate-300 bg-white px-2.5 py-1.5 text-xs text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-white" />} + {recurrenceEnds === 'until' && { setRecurrenceControlsChanged(true); setRecurrenceUntil(e.target.value); }} className="rounded-xl border border-slate-300 bg-white px-2.5 py-1.5 text-xs text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-white" />} +
+ )} +
+ )} + +
+ + +

Reminders appear while CalenDat is open.{isAllDay ? ' All-day reminders use midnight in your local timezone.' : ''}

+
+ + {eventToEdit && isMyEvent &&

Change the date and time below to reschedule, or drag the event in the calendar. Repeating events can be moved one occurrence at a time.

} + {!isAllDay && (
- Quick duration: + + Quick duration: +
)} - {/* Mode & Status */}
@@ -754,20 +1150,28 @@ export const EventModal: React.FC = ({ onChange={(e) => setStatus(e.target.value as EventStatus)} className="w-full px-3 py-2 text-xs rounded-xl border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary-500 outline-none transition" > - - - - + + + +
- {/* Virtual Meeting URL */} - {(mode === 'community.lexicon.calendar.event#virtual' || - mode === 'community.lexicon.calendar.event#hybrid') && ( + {(mode === "community.lexicon.calendar.event#virtual" || + mode === "community.lexicon.calendar.event#hybrid") && (
= ({
)} - {/* In-Person Venue / Location */} - {(mode === 'community.lexicon.calendar.event#inperson' || - mode === 'community.lexicon.calendar.event#hybrid') && ( + {(mode === "community.lexicon.calendar.event#inperson" || + mode === "community.lexicon.calendar.event#hybrid") && (
= ({
)} - {/* Description */}
- {/* RSVP & Attendees */} {renderRsvpSection()} - {/* Action Buttons */}
{eventToEdit ? (
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index f6f0324..819d818 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -10,6 +10,8 @@ import { Sun, Moon, Download, + Settings, + Bell, } from 'lucide-react'; import { format } from 'date-fns'; import { CalendarSpace, CalendarViewType, SpaceMember } from '../types/calendar'; @@ -23,6 +25,8 @@ interface HeaderProps { view: CalendarViewType; onViewChange: (view: CalendarViewType) => void; space: CalendarSpace; + spaces: CalendarSpace[]; + onSelectSpace: (space: CalendarSpace) => void; members: SpaceMember[]; onOpenShareModal: () => void; onOpenNewEventModal: () => void; @@ -30,6 +34,9 @@ interface HeaderProps { refreshing: boolean; onToggleSidebar?: () => void; onExportIcs?: () => void; + onOpenTools?: () => void; + onToggleNotifications?: () => void; + notificationsEnabled?: boolean; } export const Header: React.FC = ({ @@ -40,6 +47,8 @@ export const Header: React.FC = ({ view, onViewChange, space, + spaces, + onSelectSpace, members, onOpenShareModal, onOpenNewEventModal, @@ -47,14 +56,15 @@ export const Header: React.FC = ({ refreshing, onToggleSidebar, onExportIcs, + onOpenTools, + onToggleNotifications, + notificationsEnabled, }) => { const { resolvedTheme, toggleTheme } = useTheme(); return ( -
- {/* Left: Mobile menu toggle, Space Info & Date navigation */} -
- {/* Mobile sidebar button */} +
+
{onToggleSidebar && ( )} - {/* Space indicator */}
-

- {space.name} -

+ Private Space @@ -81,7 +99,6 @@ export const Header: React.FC = ({
- {/* Date navigation */}
- {/* Right: Controls & Actions */}
- {/* View Switcher Tabs (Desktop) */} + +
- {/* View Switcher Select (Mobile) */}
- {/* Refresh button */} - {/* Export .ics button */} {onExportIcs && ( )} - {/* Theme Toggle Button */} - {/* Collaborators / Share Button */} - {/* New Event Button */}
- {/* Form */} {error && (
diff --git a/src/components/ShareSpaceModal.tsx b/src/components/ShareSpaceModal.tsx index 9d31004..97f34e6 100644 --- a/src/components/ShareSpaceModal.tsx +++ b/src/components/ShareSpaceModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { X, UserPlus, @@ -15,6 +15,7 @@ import { import { CalendarSpace, SpaceMember } from '../types/calendar'; import { atprotoService, CALENDAR_COLORS } from '../services/atproto'; import { useToast } from './Toast'; +import { useModalFocus } from '../hooks/useModalFocus'; interface ShareSpaceModalProps { isOpen: boolean; @@ -22,6 +23,7 @@ interface ShareSpaceModalProps { space: CalendarSpace | null; onMembersUpdated: () => void; onSpaceUpdated?: (updatedSpace: CalendarSpace) => void; + onOpenTools?: () => void; } export const ShareSpaceModal: React.FC = ({ @@ -30,63 +32,74 @@ export const ShareSpaceModal: React.FC = ({ space, onMembersUpdated, onSpaceUpdated, + onOpenTools, }) => { const toast = useToast(); const [members, setMembers] = useState([]); const [loadingMembers, setLoadingMembers] = useState(false); + const [membersError, setMembersError] = useState(null); const [inviteIdentifier, setInviteIdentifier] = useState(''); const [inviting, setInviting] = useState(false); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); const [removingDid, setRemovingDid] = useState(null); - // Editable space details const [spaceName, setSpaceName] = useState(''); const [spaceColor, setSpaceColor] = useState(''); + const [spaceDescription, setSpaceDescription] = useState(''); const [savingSettings, setSavingSettings] = useState(false); + const membersRequestId = useRef(0); + const requestClose = () => { + if (!inviting && !removingDid && !savingSettings) onClose(); + }; + const dialogRef = useModalFocus(isOpen, requestClose); const currentDid = atprotoService.getCurrentDid(); - // Escape key listener - useEffect(() => { - if (!isOpen) return; - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [isOpen, onClose]); - useEffect(() => { if (isOpen && space) { + setMembers([]); loadMembers(); setSpaceName(space.name); setSpaceColor(space.color); + setSpaceDescription(space.description || ''); } + return () => { + membersRequestId.current += 1; + }; }, [isOpen, space]); - const loadMembers = async () => { - if (!space) return; + const loadMembers = async (requestedSpace = space) => { + if (!requestedSpace) return; + const requestId = ++membersRequestId.current; setLoadingMembers(true); - setError(null); + setMembersError(null); try { - const list = await atprotoService.listSpaceMembers(space.uri); - setMembers(list); + const list = await atprotoService.listSpaceMembers(requestedSpace.uri); + if (requestId === membersRequestId.current && space?.uri === requestedSpace.uri) { + setMembers(list); + } } catch (err: any) { - setError(err.message || 'Failed to load space members.'); + if (requestId === membersRequestId.current && space?.uri === requestedSpace.uri) { + setMembersError(err.message || 'Failed to load space members.'); + } } finally { - setLoadingMembers(false); + if (requestId === membersRequestId.current) setLoadingMembers(false); } }; if (!isOpen || !space) return null; - const handleCopyUri = () => { - navigator.clipboard.writeText(space.uri); - setCopied(true); - toast.success('Space URI copied to clipboard'); - setTimeout(() => setCopied(false), 2000); + const handleCopyUri = async () => { + try { + await navigator.clipboard.writeText(space.uri); + setCopied(true); + toast.success('Space URI copied to clipboard'); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error('Could not copy the calendar address. Select and copy it manually.'); + } }; const handleInvite = async (e: React.FormEvent) => { @@ -131,6 +144,7 @@ export const ShareSpaceModal: React.FC = ({ await atprotoService.updateSpaceMeta(space.uri, { name: spaceName.trim(), color: spaceColor, + description: spaceDescription.trim() || undefined, }); toast.success('Space settings updated'); if (onSpaceUpdated) { @@ -138,6 +152,7 @@ export const ShareSpaceModal: React.FC = ({ ...space, name: spaceName.trim(), color: spaceColor, + description: spaceDescription.trim() || undefined, }); } } catch { @@ -150,13 +165,13 @@ export const ShareSpaceModal: React.FC = ({ return (
e.stopPropagation()} > - {/* Header */}
= ({ />

- Manage "{space.name}" + Calendar settings

Access control, members, and calendar settings @@ -173,14 +188,13 @@ export const ShareSpaceModal: React.FC = ({

- {/* Space URI Info */}
- {/* Space Customization (if owner) */} {space.isOwner && (
-