diff --git a/README.md b/README.md index 25569cb..a28b62f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# CalenDat — Decentralized ATProto Spaces Calendar MVP +# CalendAT — Decentralized ATProto Spaces Calendar MVP -CalenDat is a collaborative calendar application built on **AT Protocol (ATProto) Spaces** for private and shared calendars. +CalendAT is a collaborative calendar application built on **AT Protocol (ATProto) Spaces** for private and shared calendars. --- @@ -31,7 +31,7 @@ CalenDat is a collaborative calendar application built on **AT Protocol (ATProto 4. **Decentralized Multi-User Event Sync**: - In ATProto Spaces, each collaborator writes events to their own repository within the space. - - CalenDat queries `com.atproto.space.listRecords` across all active collaborators in the space, providing a unified, decentralized calendar view. + - 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 connected multi-day bars and quick-add on click. @@ -48,7 +48,7 @@ CalenDat is a collaborative calendar application built on **AT Protocol (ATProto ### Space declaration -CalenDat uses the published `at.calend.calendar` Space declaration. OAuth uses the versioned `oauth-client-metadata-v2.json` URL so providers do not reuse the earlier invalid `community.lexicon.calendar` scope. +CalendAT creates `at.calend.calendar` Spaces from the published declaration. OAuth uses the versioned `oauth-client-metadata-v3.json` URL and a wildcard Space type restricted to CalendAT's event, RSVP, and metadata collections. This avoids declaration lookup failures on the Spaces alpha provider while preserving collection-level access limits. The declaration source is in `lexicons/at/calend/calendar.json`. The standalone Node 24 publisher covers the owned `at.calend.*` records: @@ -68,16 +68,16 @@ For initial publication before the DNS record exists, use `npm run lexicons:publ The old `community.lexicon.calendar` namespace belongs to `lexicon.community`. The client recognizes existing spaces with that legacy type, while OAuth and newly created calendars use `at.calend.calendar`. -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. +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-v2.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. +Production OAuth metadata is served at `https://calend.at/oauth-client-metadata-v3.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:at.calend.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. +The metadata requests `atproto` plus a `space:*` 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"`. +- 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. @@ -87,12 +87,13 @@ The metadata requests `atproto` plus a `space:at.calend.calendar` grant for cale - **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. +- **Preloading:** sign-in loads events and members for every calendar, including hidden calendars. Visibility changes show cached events immediately and refresh from the server. Spaces returns full event histories, so adjacent months already have their source data; recurring occurrences are prepared for the current, previous, and next months as you navigate, with at most five expanded months cached. Refreshes invalidate those expansions, and logout clears the cached data. +- **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. +- **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. +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. --- @@ -139,7 +140,7 @@ 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 versioned public `oauth-client-metadata-v2.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. +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 versioned public `oauth-client-metadata-v3.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 `docker-compose.yml` builds and runs `linux/amd64`, publishes `${PORT:-3000}:3000`, and restarts it unless stopped: diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts index 51be7df..7dde1fc 100644 --- a/e2e/calendar.spec.ts +++ b/e2e/calendar.spec.ts @@ -74,6 +74,40 @@ async function setup(page: Page): Promise { return state; } +test('desktop sidebar collapses, expands, and resizes', async ({ page }) => { + await setup(page); + const sidebar = page.locator('aside:visible'); + await expect(sidebar).toHaveCSS('width', '232px'); + const border = page.getByRole('separator', { name: 'Resize sidebar' }); + await border.focus(); + await page.keyboard.press('Home'); + await expect(sidebar).toHaveCSS('width', '200px'); + const bounds = await border.boundingBox(); + await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + 100); + await page.mouse.down(); + await page.mouse.move(300, bounds!.y + 100); + await page.mouse.up(); + await expect(sidebar).toHaveCSS('width', '300px'); + await page.getByRole('button', { name: 'Collapse calendar navigation' }).click(); + await expect(sidebar).toHaveCSS('width', '68px'); + await expect(sidebar.getByRole('button')).toHaveCount(2); + await expect(sidebar.getByRole('button', { name: 'Log out' })).toBeVisible(); + await page.getByRole('button', { name: 'Expand calendar navigation' }).click(); + await expect(sidebar).toHaveCSS('width', '300px'); +}); + +test('month weeks fill the available calendar height', async ({ page }) => { + await setup(page); + for (const height of [1000, 1400]) { + await page.setViewportSize({ width: 1440, height }); + const main = await page.locator('main').boundingBox(); + const lastWeek = await page.getByTestId(/^month-week-/).last().boundingBox(); + expect(main).not.toBeNull(); + expect(lastWeek).not.toBeNull(); + expect(Math.abs(lastWeek!.y + lastWeek!.height - main!.y - main!.height)).toBeLessThanOrEqual(1); + } +}); + test('keyboard shortcuts preserve browser modifiers and modal focus', async ({ page }) => { await setup(page); const prevented = await page.evaluate(() => ['t', 's', 'n'].map(key => { @@ -166,13 +200,14 @@ test('shows sharing permission failures and supports member removal and invitati 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(); + await expect(page.getByText('@member.test (did:plc:member)', { exact: true })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Open space in PDSls' })).toHaveAttribute('href', `https://pds.ls/${space}`); state.failWrites = false; await page.getByTitle('Remove collaborator').click(); - await expect(page.getByText('@member.test', { exact: true })).toHaveCount(0); + await expect(page.getByText('@member.test (did:plc:member)', { 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(); + await expect(page.getByText('@member.test (did:plc:member)', { exact: true })).toBeVisible(); expect(state.writes.find(write => write.endpoint.endsWith('addMember'))?.body).toEqual({ space, did: member }); }); diff --git a/index.html b/index.html index 8038ac3..22c8c86 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - CalenDat - ATProto Spaces Collaborative Calendar + CalendAT - ATProto Spaces Collaborative Calendar diff --git a/lexicons/at/calend/calendar.json b/lexicons/at/calend/calendar.json index eaa3d82..da37a8b 100644 --- a/lexicons/at/calend/calendar.json +++ b/lexicons/at/calend/calendar.json @@ -5,8 +5,8 @@ "main": { "type": "space", "key": "any", - "name": "CalenDat Calendar", - "description": "A private or shared CalenDat calendar.", + "name": "CalendAT Calendar", + "description": "A private or shared CalendAT calendar.", "collections": [ "community.lexicon.calendar.event", "community.lexicon.calendar.rsvp", diff --git a/public/oauth-client-metadata-v2.json b/public/oauth-client-metadata-v2.json index 92bc2e6..c85152d 100644 --- a/public/oauth-client-metadata-v2.json +++ b/public/oauth-client-metadata-v2.json @@ -1,6 +1,6 @@ { "client_id": "https://calend.at/oauth-client-metadata-v2.json", - "client_name": "CalenDat", + "client_name": "CalendAT", "client_uri": "https://calend.at", "redirect_uris": ["https://calend.at/oauth/callback"], "scope": "atproto space:at.calend.calendar?authority=*&collection=community.lexicon.calendar.event&collection=community.lexicon.calendar.rsvp&collection=community.lexicon.calendar.meta&manage=create&manage=update&manage=delete", diff --git a/public/oauth-client-metadata-v3.json b/public/oauth-client-metadata-v3.json index 094a54c..c682524 100644 --- a/public/oauth-client-metadata-v3.json +++ b/public/oauth-client-metadata-v3.json @@ -1,6 +1,6 @@ { "client_id": "https://calend.at/oauth-client-metadata-v3.json", - "client_name": "CalenDat", + "client_name": "CalendAT", "client_uri": "https://calend.at", "redirect_uris": ["https://calend.at/oauth/callback"], "scope": "atproto space:*?authority=*&collection=community.lexicon.calendar.event&collection=community.lexicon.calendar.rsvp&collection=community.lexicon.calendar.meta&manage=create&manage=update&manage=delete", diff --git a/public/oauth-client-metadata.json b/public/oauth-client-metadata.json index c785fab..daec2c0 100644 --- a/public/oauth-client-metadata.json +++ b/public/oauth-client-metadata.json @@ -1,6 +1,6 @@ { "client_id": "https://calend.at/oauth-client-metadata.json", - "client_name": "CalenDat", + "client_name": "CalendAT", "client_uri": "https://calend.at", "redirect_uris": ["https://calend.at/oauth/callback"], "scope": "atproto space:at.calend.calendar?authority=*&collection=community.lexicon.calendar.event&collection=community.lexicon.calendar.rsvp&collection=community.lexicon.calendar.meta&manage=create&manage=update&manage=delete", diff --git a/src/App.tsx b/src/App.tsx index 17f5ada..aeb6f3b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import { addMonths, subMonths, addWeeks, subWeeks, startOfMonth, endOfMonth, startOfWeek, endOfWeek, addDays } from 'date-fns'; +import { addMonths, subMonths, addWeeks, subWeeks, addDays } from 'date-fns'; import { Loader2 } from 'lucide-react'; import { atprotoService } from './services/atproto'; import { @@ -29,6 +29,7 @@ import { useAutomaticRefresh } from './hooks/useAutomaticRefresh'; import { calendarChanges, useCalendarNotifications } from './hooks/useCalendarNotifications'; import { markConflicts } from './utils/calendarOverlays'; import { LoadingToast } from './components/LoadingToast'; +import { usePrefetchedCalendarMonths } from './hooks/usePrefetchedCalendarMonths'; const MainApp: React.FC = () => { const toast = useToast(); @@ -76,22 +77,15 @@ const MainApp: React.FC = () => { 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. */ } + 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 prefetchedMonth = usePrefetchedCalendarMonths(events, currentDate); + const expanded = useMemo(() => ({ + events: markConflicts(prefetchedMonth.events.filter(event => visibleSpaceUris.includes(event.spaceUri))), + error: visibleSpaceUris.map(uri => prefetchedMonth.errorsBySpace[uri]).find(Boolean) || null, + }), [prefetchedMonth, visibleSpaceUris]); const reminderEvents = useMemo(() => { try { const now = new Date(); @@ -184,38 +178,40 @@ const MainApp: React.FC = () => { const isCurrent = () => request === contentRequest.current && activeSpaceUri.current === uri; setLoadingEvents(true); setContentError(null); - try { - const requested = Array.from(new Set([uri, ...visibleUrisRef.current])); - const content = await Promise.all(requested.map(async spaceUri => { + // Space records contain all dates. Retain hidden calendars too, so toggles + // and navigation can render cached content while refreshes are in flight. + const requested = spaces.map(space => space.uri); + const foreground = new Set([uri, ...visibleUrisRef.current]); + let pendingForeground = requested.filter(spaceUri => foreground.has(spaceUri)).length; + let succeeded = true; + const loads = requested.map(async spaceUri => { + try { const memberList = await atprotoService.listSpaceMembers(spaceUri); - if (!isCurrent()) return null; + if (!isCurrent()) return; 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); + if (!isCurrent()) return; + const calendar = spaces.find(space => space.uri === spaceUri); + const decorated = eventList.map(event => ({ ...event, calendarColor: calendar?.color, calendarName: calendar?.name })); + const previous = snapshots.current.get(spaceUri); + const notices = previous && visibleUrisRef.current.includes(spaceUri) + ? calendarChanges(previous, eventList, atprotoService.getCurrentDid() || '') : []; + snapshots.current.set(spaceUri, eventList); + setMembersBySpace(current => ({ ...current, [spaceUri]: memberList })); + if (spaceUri === uri) setMembers(memberList); + setEvents(current => [...current.filter(event => event.spaceUri !== spaceUri && requested.includes(event.spaceUri)), ...decorated]); + if (notices.length) notify(notices.length === 1 ? notices[0] : `${notices.length} calendar changes. ${notices[0]}`); + } catch { + if (isCurrent() && foreground.has(spaceUri)) { + succeeded = false; + setContentError('Could not load all calendar events and members. Check your connection and retry.'); + } + } finally { + if (foreground.has(spaceUri)) pendingForeground--; + if (isCurrent() && pendingForeground === 0) setLoadingEvents(false); } - 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) { - if (isCurrent()) setContentError('Could not load all calendar events and members. Check your connection and retry.'); - return false; - } finally { - if (isCurrent()) setLoadingEvents(false); - } + }); + await Promise.all(loads.filter((_, index) => foreground.has(requested[index]))); + return isCurrent() && succeeded; }, [activeSpace?.uri, spaces, notify]); useEffect(() => { @@ -231,7 +227,7 @@ const MainApp: React.FC = () => { try { const permission = await Notification.requestPermission(); setNotificationsEnabled(permission === 'granted'); - if (permission !== 'granted') toast.info('In-app reminders remain enabled while CalenDat is open.'); + if (permission !== 'granted') toast.info('In-app reminders remain enabled while CalendAT is open.'); } catch { toast.info('This browser supports in-app reminders only.'); } }; diff --git a/src/components/AuthModal.tsx b/src/components/AuthModal.tsx index ec1e299..747b6af 100644 --- a/src/components/AuthModal.tsx +++ b/src/components/AuthModal.tsx @@ -41,7 +41,7 @@ export const AuthModal: React.FC = ({ isOpen, onSuccess }) => {

- CalenDat + CalendAT

Sign in through your AT Protocol provider

@@ -74,7 +74,7 @@ export const AuthModal: React.FC = ({ isOpen, onSuccess }) => {

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

- +
+ + + PDSls +
@@ -358,7 +370,7 @@ export const ShareSpaceModal: React.FC = ({ )}
- {member.handle ? `@${member.handle}` : member.did} + {member.handle ? `@${member.handle} (${member.did})` : member.did}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 5a2f597..997fa37 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { Plus, LogOut, @@ -8,6 +8,7 @@ import { Eye, EyeOff, Settings, + ChevronsLeft, } from "lucide-react"; import { CalendarSpace } from "../types/calendar"; import { atprotoService } from "../services/atproto"; @@ -38,29 +39,57 @@ export const Sidebar: React.FC = ({ onToggleVisible, }) => { const [showArchived, setShowArchived] = useState(false); + const [collapsed, setCollapsed] = useState(false); + const [sidebarWidth, setSidebarWidth] = useState(232); + const resizing = useRef(false); + const [isResizing, setIsResizing] = useState(false); const mobileRef = useModalFocus(isMobileOpen, () => onCloseMobile?.()); const currentHandle = atprotoService.getCurrentHandle(); const currentDid = atprotoService.getCurrentDid(); + useEffect(() => { + const stopResizing = () => { + resizing.current = false; + setIsResizing(false); + }; + const resize = (event: PointerEvent) => { + if (!resizing.current) return; + setSidebarWidth(Math.min(360, Math.max(200, event.clientX))); + }; + window.addEventListener("pointermove", resize); + window.addEventListener("pointerup", stopResizing); + return () => { + window.removeEventListener("pointermove", resize); + window.removeEventListener("pointerup", stopResizing); + }; + }, []); + + const desktopCollapsed = collapsed && !isMobileOpen; + const content = ( -