19 Commits

Author SHA1 Message Date
AlacrisDevs
676468d3ec feat: extended profile fields (phone, discord, shirt/hoodie sizes) - Migration 024: add phone, discord_handle, shirt_size, hoodie_size to profiles - Account page: new Contact & Sizing section with phone, discord, size dropdowns - Save profile persists all new fields - Layout server: profile queries include new fields for org members and user - Event team page: member rows show phone, discord, T-shirt and hoodie sizes - EventMemberWithDetails profile type extended - i18n: 8 new keys in EN and ET - svelte-check: 0 errors, vitest: 112/112 passed 2026-02-07 12:53:56 +02:00
AlacrisDevs
1f2484da3d feat: event team management with departments and roles - Migration 023: event_roles, event_departments, event_member_departments tables - Auto-seed default roles (Head Organizer, Team Lead, Organizer, Volunteer, Sponsor) and departments (Logistics, IT & Tech, Marketing, Finance, Program, Sponsorship, Design, Volunteers) on event creation - API: full CRUD for roles, departments, member-department assignments - Enhanced fetchEventMembers with role + department resolution - Team page: sidebar with department filter + role legend, list/dept view toggle, add/edit/remove members with role + multi-department + notes - i18n: 25 new keys in EN and ET for team management - svelte-check: 0 errors, vitest: 112/112 passed 2026-02-07 12:47:34 +02:00
AlacrisDevs
edc5f8af85 feat: add event module pages (placeholders + full Team module) - 6 placeholder 'coming soon' pages: tasks, files, schedule, budget, guests, sponsors - Full Team module: add/remove members, change roles, role badges - Uses existing event_members DB table and API layer - i18n keys added for EN and ET (module placeholders + team) - svelte-check: 0 errors, vitest: 112/112 passed 2026-02-07 11:39:51 +02:00
AlacrisDevs
4999836a57 ui: overhaul org settings components (General, Members, Roles, Integrations) - SettingsGeneral: border-based cards, compact danger zone with error border - SettingsMembers: Avatar component, icon buttons, border-based list - SettingsRoles: icon buttons for edit/delete, smaller permission badges - SettingsIntegrations: compact integration cards, Material Symbols for coming-soon - Removed unused Card imports from all settings components - svelte-check: 0 errors, vitest: 112/112 passed 2026-02-07 11:23:49 +02:00
AlacrisDevs
9d5e58f858 ui: overhaul home page, style guide, account settings - Home page: bg-background, border-based org cards, material icons, compact typography - Style guide: new header with back button, rounded-xl swatches, consistent section headings - Account settings: replace bg-background rounded-[32px] with border-based rounded-xl cards - svelte-check: 0 errors, vitest: 112/112 passed 2026-02-07 11:18:23 +02:00
AlacrisDevs
819d5b876a ui: overhaul files, kanban, calendar, settings, chat modules
- FileBrowser: modernize breadcrumbs, toolbar, list/grid items, empty states
- KanbanColumn: remove fixed height, border-based styling, compact header
- KanbanCard: cleaner border styling, smaller tags, compact footer
- Calendar: compact nav bar, border grid, today circle indicator, day view empty state
- DocumentViewer: remove bg-night rounded-[32px], border-b header pattern
- Settings tags: inline border/rounded-xl cards, icon action buttons
- Chat: create +layout.svelte with PageHeader, overhaul sidebar and main area
- Chat i18n: add nav_chat, chat_title, chat_subtitle keys (en + et)

svelte-check: 0 errors, vitest: 112/112 passed
2026-02-07 11:03:58 +02:00
AlacrisDevs
2913912cb8 feat: UI overhaul - component library + route layouts with instant headers
- Created 11 reusable UI components: PageHeader, SectionCard, StatCard, StatusBadge, TabBar, MemberList, ActivityFeed, EventCard, ContentSkeleton, QuickLinkGrid, ModuleCard
- Created route-specific +layout.svelte for documents, calendar, kanban, events, settings, account
- Each layout renders PageHeader instantly from parent data, shows ContentSkeleton during navigation
- Removed full-page PageSkeleton from parent layout
- Refactored all pages to use new components instead of inline markup
- Overview page: uses StatCard, SectionCard, EventCard, ActivityFeed, MemberList, QuickLinkGrid
- Events list: uses EventCard, Button components
- Event detail: uses ModuleCard, SectionCard
- Settings/Account/Calendar/Kanban: headers in layouts, toolbars in pages
- Added i18n keys for overview page (EN + ET)
- 0 errors, 112 tests pass
2026-02-07 10:44:53 +02:00
AlacrisDevs
fe6ec6e0af i18n: add Paraglide messages for all events pages (EN + ET) 2026-02-07 10:16:13 +02:00
AlacrisDevs
36496e8cdb fix: event detail SSR children guard, state_referenced_locally warnings, a11y warnings 2026-02-07 10:09:00 +02:00
AlacrisDevs
556955f349 feat: Phase 1 - Events entity (migration, API, list page, detail layout with module sidebar, overview page) 2026-02-07 10:04:37 +02:00
AlacrisDevs
4f21c89103 Merge feature/matrix-chat-integration: full Matrix chat integration 2026-02-07 09:46:10 +02:00
AlacrisDevs
8140fddc8b test: add 20 unit tests for markdown utils (mentions, emoji-only, formatTime, formatFileSize) 2026-02-07 09:32:57 +02:00
AlacrisDevs
13cdb605ca chore: regenerate Supabase types (includes matrix_credentials + matrix_space_id), remove db() cast workarounds 2026-02-07 09:25:33 +02:00
AlacrisDevs
45ab939b7f feat: Matrix Space membership sync API (invite/kick members from org space + child rooms) 2026-02-07 02:02:11 +02:00
AlacrisDevs
23035b6ab4 feat: auto-provision Matrix Space per org + migration 021 + /api/matrix-space endpoint 2026-02-07 02:01:08 +02:00
AlacrisDevs
3f267e3b13 feat: room scoping (org/DM/other sections), unread badge on nav, highlight.js CSS 2026-02-07 01:59:34 +02:00
AlacrisDevs
be99a02e78 fix: add missing chat CSS (twemoji sizing, emoji-only, mentions, message highlight) 2026-02-07 01:53:06 +02:00
AlacrisDevs
a8d79cf138 fix: configure Vite to handle matrix-js-sdk WASM crypto module 2026-02-07 01:49:42 +02:00
AlacrisDevs
d1ce5d0951 feat: integrate Matrix chat (Option 2 - credentials stored in Supabase)
- Add matrix-js-sdk, marked, highlight.js, twemoji, @tanstack/svelte-virtual deps
- Copy Matrix core layer: /matrix/, /stores/matrix.ts, /cache/, /services/
- Copy Matrix components: matrix/, message/, chat-layout/, chat-settings/
- Copy UI components: EmojiPicker, Twemoji, ImagePreviewModal, VirtualList
- Copy utils: emojiData, twemoji, twemojiGlobal
- Replace lucide-svelte with Material Symbols in SyncRecoveryBanner
- Extend Avatar with xs size and status indicator prop
- Fix ui.ts store conflict: re-export toasts from toast.svelte.ts
- Add migration 020_matrix_credentials for storing Matrix tokens per user/org
- Add /api/matrix-credentials endpoint (GET/POST/DELETE)
- Create [orgSlug]/chat page with Matrix login form + full chat UI
- Add Chat to sidebar navigation
2026-02-07 01:44:06 +02:00
124 changed files with 17530 additions and 1469 deletions

View File

@@ -175,6 +175,14 @@
"account_display_name": "Display Name",
"account_display_name_placeholder": "Your name",
"account_email": "Email",
"account_phone": "Phone",
"account_phone_placeholder": "+372 ...",
"account_discord": "Discord",
"account_discord_placeholder": "username",
"account_contact_info": "Contact & Sizing",
"account_shirt_size": "Shirt Size",
"account_hoodie_size": "Hoodie Size",
"account_size_placeholder": "Select size",
"account_save_profile": "Save Profile",
"account_appearance": "Appearance",
"account_theme": "Theme",
@@ -251,5 +259,124 @@
"entity_kanban_column": "column",
"entity_member": "member",
"entity_role": "role",
"entity_invite": "invite"
"entity_invite": "invite",
"entity_event": "event",
"nav_events": "Events",
"nav_chat": "Chat",
"chat_title": "Chat",
"chat_subtitle": "Team messaging and communication",
"events_title": "Events",
"events_subtitle": "Organize and manage your events",
"events_new": "New Event",
"events_create": "Create Event",
"events_empty_title": "No events yet",
"events_empty_desc": "Create your first event to get started",
"events_no_dates": "No dates set",
"events_tab_all": "All Events",
"events_tab_planning": "Planning",
"events_tab_active": "Active",
"events_tab_completed": "Completed",
"events_tab_archived": "Archived",
"events_status_planning": "Planning",
"events_status_active": "Active",
"events_status_completed": "Completed",
"events_status_archived": "Archived",
"events_form_name": "Event Name",
"events_form_name_placeholder": "e.g., Summer Conference 2026",
"events_form_description": "Description",
"events_form_description_placeholder": "Brief description of the event...",
"events_form_start_date": "Start Date",
"events_form_end_date": "End Date",
"events_form_venue": "Venue",
"events_form_venue_placeholder": "e.g., Convention Center",
"events_form_venue_address_placeholder": "Venue address",
"events_form_color": "Color",
"events_form_select_color": "Select color {color}",
"events_creating": "Creating...",
"events_saving": "Saving...",
"events_deleting": "Deleting...",
"events_updated": "Event updated",
"events_created": "Event \"{name}\" created",
"events_deleted": "Event deleted",
"events_delete_title": "Delete Event?",
"events_delete_desc": "This will permanently delete {name} and all its data. This action cannot be undone.",
"events_delete_confirm": "Delete Event",
"events_days_ago": "{count} days ago",
"events_today": "Today!",
"events_tomorrow": "Tomorrow",
"events_in_days": "In {count} days",
"events_overview": "Overview",
"events_modules": "Modules",
"events_details": "Event Details",
"events_start_date": "Start Date",
"events_end_date": "End Date",
"events_venue": "Venue",
"events_not_set": "Not set",
"events_all_events": "All Events",
"events_team": "Team",
"events_team_count": "Team ({count})",
"events_team_manage": "Manage",
"events_team_empty": "No team members assigned yet",
"events_more_members": "+{count} more",
"events_mod_tasks": "Tasks",
"events_mod_tasks_desc": "Manage tasks, milestones, and progress",
"events_mod_files": "Files",
"events_mod_files_desc": "Documents, contracts, and media",
"events_mod_schedule": "Schedule",
"events_mod_schedule_desc": "Event timeline and program",
"events_mod_budget": "Budget",
"events_mod_budget_desc": "Income, expenses, and tracking",
"events_mod_guests": "Guests",
"events_mod_guests_desc": "Guest list and registration",
"events_mod_team": "Team",
"events_mod_team_desc": "Team members and shift scheduling",
"events_mod_sponsors": "Sponsors",
"events_mod_sponsors_desc": "Sponsors, partners, and deliverables",
"module_coming_soon": "Coming Soon",
"module_coming_soon_desc": "This module is under development and will be available soon.",
"team_title": "Event Team",
"team_subtitle": "Manage team members and their roles for this event.",
"team_add_member": "Add Member",
"team_role_lead": "Lead",
"team_role_manager": "Manager",
"team_role_member": "Member",
"team_empty": "No team members assigned yet. Add members from your organization.",
"team_remove_confirm": "Remove {name} from this event's team?",
"team_remove_btn": "Remove",
"team_added": "{name} added to team",
"team_removed": "{name} removed from team",
"team_updated": "Role updated",
"team_select_member": "Select a member",
"team_select_role": "Select role",
"team_already_assigned": "Already on team",
"team_departments": "Departments",
"team_roles": "Roles",
"team_all": "All",
"team_no_department": "Unassigned",
"team_add_department": "Add Department",
"team_add_role": "Add Role",
"team_edit_department": "Edit Department",
"team_edit_role": "Edit Role",
"team_dept_name": "Department name",
"team_role_name": "Role name",
"team_dept_created": "Department created",
"team_dept_updated": "Department updated",
"team_dept_deleted": "Department deleted",
"team_role_created": "Role created",
"team_role_updated": "Role updated",
"team_role_deleted": "Role deleted",
"team_dept_delete_confirm": "Delete department {name}? Members will be unassigned from it.",
"team_role_delete_confirm": "Delete role {name}? Members will lose this role assignment.",
"team_view_by_dept": "By department",
"team_view_list": "List view",
"team_member_count": "{count} members",
"team_assign_dept": "Assign departments",
"team_notes": "Notes",
"team_notes_placeholder": "Optional notes about this member...",
"overview_subtitle": "Welcome back. Here's what's happening.",
"overview_stat_events": "Events",
"overview_upcoming_events": "Upcoming Events",
"overview_upcoming_empty": "No upcoming events. Create one to get started.",
"overview_view_all_events": "View all events",
"overview_more_members": "+{count} more"
}

View File

@@ -175,6 +175,14 @@
"account_display_name": "Kuvatav nimi",
"account_display_name_placeholder": "Sinu nimi",
"account_email": "E-post",
"account_phone": "Telefon",
"account_phone_placeholder": "+372 ...",
"account_discord": "Discord",
"account_discord_placeholder": "kasutajanimi",
"account_contact_info": "Kontakt ja suurused",
"account_shirt_size": "Särgi suurus",
"account_hoodie_size": "Pusa suurus",
"account_size_placeholder": "Vali suurus",
"account_save_profile": "Salvesta profiil",
"account_appearance": "Välimus",
"account_theme": "Teema",
@@ -251,5 +259,124 @@
"entity_kanban_column": "veeru",
"entity_member": "liikme",
"entity_role": "rolli",
"entity_invite": "kutse"
"entity_invite": "kutse",
"entity_event": "ürituse",
"nav_events": "Üritused",
"nav_chat": "Vestlus",
"chat_title": "Vestlus",
"chat_subtitle": "Meeskonna sõnumid ja suhtlus",
"events_title": "Üritused",
"events_subtitle": "Korralda ja halda oma üritusi",
"events_new": "Uus üritus",
"events_create": "Loo üritus",
"events_empty_title": "Üritusi pole veel",
"events_empty_desc": "Loo oma esimene üritus alustamiseks",
"events_no_dates": "Kuupäevad määramata",
"events_tab_all": "Kõik üritused",
"events_tab_planning": "Planeerimisel",
"events_tab_active": "Aktiivne",
"events_tab_completed": "Lõpetatud",
"events_tab_archived": "Arhiveeritud",
"events_status_planning": "Planeerimisel",
"events_status_active": "Aktiivne",
"events_status_completed": "Lõpetatud",
"events_status_archived": "Arhiveeritud",
"events_form_name": "Ürituse nimi",
"events_form_name_placeholder": "nt Suvekonverents 2026",
"events_form_description": "Kirjeldus",
"events_form_description_placeholder": "Ürituse lühikirjeldus...",
"events_form_start_date": "Alguskuupäev",
"events_form_end_date": "Lõppkuupäev",
"events_form_venue": "Toimumiskoht",
"events_form_venue_placeholder": "nt Konverentsikeskus",
"events_form_venue_address_placeholder": "Toimumiskoha aadress",
"events_form_color": "Värv",
"events_form_select_color": "Vali värv {color}",
"events_creating": "Loomine...",
"events_saving": "Salvestamine...",
"events_deleting": "Kustutamine...",
"events_updated": "Üritus uuendatud",
"events_created": "Üritus \"{name}\" loodud",
"events_deleted": "Üritus kustutatud",
"events_delete_title": "Kustuta üritus?",
"events_delete_desc": "See kustutab jäädavalt ürituse {name} ja kõik selle andmed. Seda toimingut ei saa tagasi võtta.",
"events_delete_confirm": "Kustuta üritus",
"events_days_ago": "{count} päeva tagasi",
"events_today": "Täna!",
"events_tomorrow": "Homme",
"events_in_days": "{count} päeva pärast",
"events_overview": "Ülevaade",
"events_modules": "Moodulid",
"events_details": "Ürituse andmed",
"events_start_date": "Alguskuupäev",
"events_end_date": "Lõppkuupäev",
"events_venue": "Toimumiskoht",
"events_not_set": "Määramata",
"events_all_events": "Kõik üritused",
"events_team": "Meeskond",
"events_team_count": "Meeskond ({count})",
"events_team_manage": "Halda",
"events_team_empty": "Meeskonnaliikmeid pole veel määratud",
"events_more_members": "+{count} veel",
"events_mod_tasks": "Ülesanded",
"events_mod_tasks_desc": "Halda ülesandeid, verstaposte ja edenemist",
"events_mod_files": "Failid",
"events_mod_files_desc": "Dokumendid, lepingud ja meedia",
"events_mod_schedule": "Ajakava",
"events_mod_schedule_desc": "Ürituse ajakava ja programm",
"events_mod_budget": "Eelarve",
"events_mod_budget_desc": "Tulud, kulud ja jälgimine",
"events_mod_guests": "Külalised",
"events_mod_guests_desc": "Külaliste nimekiri ja registreerimine",
"events_mod_team": "Meeskond",
"events_mod_team_desc": "Meeskonnaliikmed ja vahetuste planeerimine",
"events_mod_sponsors": "Sponsorid",
"events_mod_sponsors_desc": "Sponsorid, partnerid ja kohustused",
"module_coming_soon": "Tulekul",
"module_coming_soon_desc": "See moodul on arendamisel ja saab peagi kättesaadavaks.",
"team_title": "Ürituse meeskond",
"team_subtitle": "Halda meeskonnaliikmeid ja nende rolle selle ürituse jaoks.",
"team_add_member": "Lisa liige",
"team_role_lead": "Juht",
"team_role_manager": "Haldur",
"team_role_member": "Liige",
"team_empty": "Meeskonnaliikmeid pole veel määratud. Lisa liikmeid oma organisatsioonist.",
"team_remove_confirm": "Eemalda {name} selle ürituse meeskonnast?",
"team_remove_btn": "Eemalda",
"team_added": "{name} lisatud meeskonda",
"team_removed": "{name} eemaldatud meeskonnast",
"team_updated": "Roll uuendatud",
"team_select_member": "Vali liige",
"team_select_role": "Vali roll",
"team_already_assigned": "Juba meeskonnas",
"team_departments": "Osakonnad",
"team_roles": "Rollid",
"team_all": "Kõik",
"team_no_department": "Määramata",
"team_add_department": "Lisa osakond",
"team_add_role": "Lisa roll",
"team_edit_department": "Muuda osakonda",
"team_edit_role": "Muuda rolli",
"team_dept_name": "Osakonna nimi",
"team_role_name": "Rolli nimi",
"team_dept_created": "Osakond loodud",
"team_dept_updated": "Osakond uuendatud",
"team_dept_deleted": "Osakond kustutatud",
"team_role_created": "Roll loodud",
"team_role_updated": "Roll uuendatud",
"team_role_deleted": "Roll kustutatud",
"team_dept_delete_confirm": "Kustuta osakond {name}? Liikmed eemaldatakse sellest.",
"team_role_delete_confirm": "Kustuta roll {name}? Liikmed kaotavad selle rolli.",
"team_view_by_dept": "Osakondade järgi",
"team_view_list": "Nimekirja vaade",
"team_member_count": "{count} liiget",
"team_assign_dept": "Määra osakonnad",
"team_notes": "Märkmed",
"team_notes_placeholder": "Valikulised märkmed selle liikme kohta...",
"overview_subtitle": "Tere tagasi. Siin on ülevaade toimuvast.",
"overview_stat_events": "Üritused",
"overview_upcoming_events": "Tulevased üritused",
"overview_upcoming_empty": "Tulevasi üritusi pole. Loo üks alustamiseks.",
"overview_view_all_events": "Vaata kõiki üritusi",
"overview_more_members": "+{count} veel"
}

336
package-lock.json generated
View File

@@ -11,11 +11,16 @@
"@inlang/paraglide-js": "^2.10.0",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.94.0",
"@tanstack/svelte-virtual": "^3.13.18",
"@tiptap/core": "^3.19.0",
"@tiptap/extension-placeholder": "^3.19.0",
"@tiptap/pm": "^3.19.0",
"@tiptap/starter-kit": "^3.19.0",
"google-auth-library": "^10.5.0"
"google-auth-library": "^10.5.0",
"highlight.js": "^11.11.1",
"marked": "^17.0.1",
"matrix-js-sdk": "^40.2.0-rc.0",
"twemoji": "^14.0.2"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.10.0",
@@ -26,6 +31,8 @@
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@types/marked": "^5.0.2",
"@types/twemoji": "^13.1.1",
"@vitest/browser-playwright": "^4.0.18",
"playwright": "^1.58.0",
"svelte": "^5.48.2",
@@ -37,6 +44,15 @@
"vitest-browser-svelte": "^2.0.2"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
@@ -546,7 +562,6 @@
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -557,7 +572,6 @@
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
@@ -568,7 +582,6 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
@@ -578,14 +591,12 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
@@ -632,6 +643,15 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
"version": "17.1.0",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-17.1.0.tgz",
"integrity": "sha512-yKPqBvKlHSqkt/UJh+Z+zLKQP8bd19OxokXYXh3VkKbW0+C44nPHsidSwd3SH+RxT+Ck2PDRwVcVXEnUft+/2g==",
"license": "Apache-2.0",
"engines": {
"node": ">= 18"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -1251,7 +1271,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz",
"integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
@@ -1655,6 +1674,32 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@tanstack/svelte-virtual": {
"version": "3.13.18",
"resolved": "https://registry.npmjs.org/@tanstack/svelte-virtual/-/svelte-virtual-3.13.18.tgz",
"integrity": "sha512-BHh8WkFK58eE9KzLctPQkCkvCj46LnM9tIGkpwo5Unx5YaBPf0uBJBqvSdc2jMwdT8gLXLHFHtCnSujlZP69BA==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"svelte": "^3.48.0 || ^4.0.0 || ^5.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.18",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
"integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/svelte-core": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz",
@@ -2087,7 +2132,12 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/events": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.3.tgz",
"integrity": "sha512-trOc4AAUThEz9hapPtSd7wf5tiQKvTtu5b371UxXdTuqzIh0ArcRspRP0i0Viu+LXstIQ1z96t1nsPxT9ol01g==",
"license": "MIT"
},
"node_modules/@types/linkify-it": {
@@ -2106,6 +2156,13 @@
"@types/mdurl": "^2"
}
},
"node_modules/@types/marked": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz",
"integrity": "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
@@ -2134,6 +2191,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/twemoji": {
"version": "13.1.1",
"resolved": "https://registry.npmjs.org/@types/twemoji/-/twemoji-13.1.1.tgz",
"integrity": "sha512-0qnUqLhaSSGsvLXiwWnmcuOza9oGnGwXpxXauB6rEHsU30Dfmvizzwx2TzwUjlsY4ox+39tdG89CpJ/i3J/Cvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"twemoji": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -2316,7 +2383,6 @@
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
@@ -2335,6 +2401,12 @@
"node": ">= 14"
}
},
"node_modules/another-json": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz",
"integrity": "sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg==",
"license": "Apache-2.0"
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
@@ -2369,7 +2441,6 @@
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -2396,7 +2467,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
@@ -2408,6 +2478,12 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/base-x": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -2446,6 +2522,15 @@
"balanced-match": "^1.0.0"
}
},
"node_modules/bs58": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
"integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
"license": "MIT",
"dependencies": {
"base-x": "^5.0.0"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -2482,7 +2567,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -2548,6 +2632,15 @@
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
@@ -2663,7 +2756,6 @@
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz",
"integrity": "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==",
"dev": true,
"license": "MIT"
},
"node_modules/eastasianwidth": {
@@ -2778,7 +2870,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true,
"license": "MIT"
},
"node_modules/esprima": {
@@ -2799,7 +2890,6 @@
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.2.tgz",
"integrity": "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
@@ -2812,6 +2902,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -2897,6 +2996,29 @@
"node": ">=12.20.0"
}
},
"node_modules/fs-extra": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/fs-extra/node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"license": "MIT",
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@@ -3003,7 +3125,6 @@
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/gtoken": {
@@ -3032,6 +3153,15 @@
"node": ">= 0.4"
}
},
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@@ -3096,6 +3226,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/is-network-error": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz",
"integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-reference": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
@@ -3166,6 +3308,18 @@
"node": ">=6"
}
},
"node_modules/jsonfile": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-5.0.0.tgz",
"integrity": "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==",
"license": "MIT",
"dependencies": {
"universalify": "^0.1.2"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
@@ -3187,6 +3341,15 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -3488,9 +3651,21 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"license": "MIT"
},
"node_modules/loglevel": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
@@ -3501,7 +3676,6 @@
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
@@ -3524,6 +3698,59 @@
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/marked": {
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
"integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/matrix-events-sdk": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz",
"integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==",
"license": "Apache-2.0"
},
"node_modules/matrix-js-sdk": {
"version": "40.2.0-rc.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-40.2.0-rc.0.tgz",
"integrity": "sha512-0c3rm+poCraYmxmQ/9QnfRiZEikriarHZCt1ukQl+xKny2tYLEFcFkASdE/ce6QCMPIwMZLJOVyOw+LvS2Xqtw==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^17.0.0",
"another-json": "^0.2.0",
"bs58": "^6.0.0",
"content-type": "^1.0.4",
"jwt-decode": "^4.0.0",
"loglevel": "^1.9.2",
"matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.16.1",
"oidc-client-ts": "^3.0.1",
"p-retry": "7",
"sdp-transform": "^3.0.0",
"unhomoglyph": "^1.0.6",
"uuid": "13"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/matrix-widget-api": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.17.0.tgz",
"integrity": "sha512-5FHoo3iEP3Bdlv5jsYPWOqj+pGdFQNLWnJLiB0V7Ygne7bb+Gsj3ibyFyHWC6BVw+Z+tSW4ljHpO17I9TwStwQ==",
"license": "Apache-2.0",
"dependencies": {
"@types/events": "^3.0.0",
"events": "^3.2.0"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
@@ -3658,12 +3885,39 @@
],
"license": "MIT"
},
"node_modules/oidc-client-ts": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz",
"integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==",
"license": "Apache-2.0",
"dependencies": {
"jwt-decode": "^4.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/p-retry": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz",
"integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==",
"license": "MIT",
"dependencies": {
"is-network-error": "^1.1.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -4170,6 +4424,15 @@
],
"license": "MIT"
},
"node_modules/sdp-transform": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-3.0.0.tgz",
"integrity": "sha512-gfYVRGxjHkGF2NPeUWHw5u6T/KGFtS5/drPms73gaSuMaVHKCY3lpLnGDfswVQO0kddeePoti09AwhYP4zA8dQ==",
"license": "MIT",
"bin": {
"sdp-verify": "checker.js"
}
},
"node_modules/set-cookie-parser": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz",
@@ -4381,7 +4644,6 @@
"version": "5.49.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.49.1.tgz",
"integrity": "sha512-jj95WnbKbXsXXngYj28a4zx8jeZx50CN/J4r0CEeax2pbfdsETv/J1K8V9Hbu3DCXnpHz5qAikICuxEooi7eNQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
@@ -4433,7 +4695,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
@@ -4521,6 +4782,24 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/twemoji": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/twemoji/-/twemoji-14.0.2.tgz",
"integrity": "sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA==",
"license": "MIT",
"dependencies": {
"fs-extra": "^8.0.1",
"jsonfile": "^5.0.0",
"twemoji-parser": "14.0.0",
"universalify": "^0.1.2"
}
},
"node_modules/twemoji-parser": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-14.0.0.tgz",
"integrity": "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -4548,6 +4827,21 @@
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
},
"node_modules/unhomoglyph": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/unhomoglyph/-/unhomoglyph-1.0.6.tgz",
"integrity": "sha512-7uvcWI3hWshSADBu4JpnyYbTVc7YlhF5GDW/oPD5AxIxl34k4wXR3WDkPnzLxkN32LiTCTKMQLtKVZiwki3zGg==",
"license": "MIT"
},
"node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/unplugin": {
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz",
@@ -4582,7 +4876,6 @@
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
@@ -4969,7 +5262,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true,
"license": "MIT"
}
}

View File

@@ -22,6 +22,8 @@
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@types/marked": "^5.0.2",
"@types/twemoji": "^13.1.1",
"@vitest/browser-playwright": "^4.0.18",
"playwright": "^1.58.0",
"svelte": "^5.48.2",
@@ -36,10 +38,15 @@
"@inlang/paraglide-js": "^2.10.0",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.94.0",
"@tanstack/svelte-virtual": "^3.13.18",
"@tiptap/core": "^3.19.0",
"@tiptap/extension-placeholder": "^3.19.0",
"@tiptap/pm": "^3.19.0",
"@tiptap/starter-kit": "^3.19.0",
"google-auth-library": "^10.5.0"
"google-auth-library": "^10.5.0",
"highlight.js": "^11.11.1",
"marked": "^17.0.1",
"matrix-js-sdk": "^40.2.0-rc.0",
"twemoji": "^14.0.2"
}
}

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
// Test the slugify logic (extracted inline since it's not exported)
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 60) || 'event';
}
describe('events API - slugify', () => {
it('converts simple name to slug', () => {
expect(slugify('Summer Conference')).toBe('summer-conference');
});
it('handles special characters', () => {
expect(slugify('Music & Arts Festival 2026!')).toBe('music-arts-festival-2026');
});
it('collapses multiple spaces and dashes', () => {
expect(slugify('My Big Event')).toBe('my-big-event');
});
it('trims leading/trailing dashes', () => {
expect(slugify('--hello--')).toBe('hello');
});
it('truncates to 60 characters', () => {
const longName = 'A'.repeat(100);
expect(slugify(longName).length).toBeLessThanOrEqual(60);
});
it('returns "event" for empty string', () => {
expect(slugify('')).toBe('event');
});
it('handles unicode characters', () => {
const result = slugify('Ürituse Korraldamine');
expect(result).toBe('rituse-korraldamine');
});
it('handles numbers', () => {
expect(slugify('Event 2026 Q1')).toBe('event-2026-q1');
});
});

550
src/lib/api/events.ts Normal file
View File

@@ -0,0 +1,550 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.events');
export interface Event {
id: string;
org_id: string;
name: string;
slug: string;
description: string | null;
status: 'planning' | 'active' | 'completed' | 'archived';
start_date: string | null;
end_date: string | null;
venue_name: string | null;
venue_address: string | null;
cover_image_url: string | null;
color: string | null;
created_by: string | null;
created_at: string;
updated_at: string;
}
export interface EventMember {
id: string;
event_id: string;
user_id: string;
role: 'lead' | 'manager' | 'member';
role_id: string | null;
notes: string | null;
assigned_at: string;
}
export interface EventRole {
id: string;
event_id: string;
name: string;
color: string;
sort_order: number;
is_default: boolean;
created_at: string;
}
export interface EventDepartment {
id: string;
event_id: string;
name: string;
color: string;
description: string | null;
sort_order: number;
created_at: string;
}
export interface EventMemberDepartment {
id: string;
event_member_id: string;
department_id: string;
assigned_at: string;
}
export interface EventMemberWithDetails extends EventMember {
profile?: { id: string; email: string; full_name: string | null; avatar_url: string | null; phone: string | null; discord_handle: string | null; shirt_size: string | null; hoodie_size: string | null };
event_role?: EventRole;
departments: EventDepartment[];
}
export interface EventWithCounts extends Event {
member_count: number;
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 60) || 'event';
}
export async function fetchEvents(
supabase: SupabaseClient<Database>,
orgId: string,
status?: string
): Promise<EventWithCounts[]> {
let query = supabase
.from('events')
.select('*, event_members(count)')
.eq('org_id', orgId)
.order('start_date', { ascending: true, nullsFirst: false });
if (status && status !== 'all') {
query = query.eq('status', status);
}
const { data, error } = await query;
if (error) {
log.error('fetchEvents failed', { error, data: { orgId } });
throw error;
}
const events: EventWithCounts[] = (data ?? []).map((e: any) => ({
...e,
member_count: e.event_members?.[0]?.count ?? 0,
event_members: undefined,
}));
log.debug('fetchEvents ok', { data: { count: events.length } });
return events;
}
export async function fetchEvent(
supabase: SupabaseClient<Database>,
eventId: string
): Promise<Event | null> {
const { data, error } = await supabase
.from('events')
.select('*')
.eq('id', eventId)
.single();
if (error) {
if (error.code === 'PGRST116') return null;
log.error('fetchEvent failed', { error, data: { eventId } });
throw error;
}
return data as unknown as Event;
}
export async function fetchEventBySlug(
supabase: SupabaseClient<Database>,
orgId: string,
eventSlug: string
): Promise<Event | null> {
const { data, error } = await supabase
.from('events')
.select('*')
.eq('org_id', orgId)
.eq('slug', eventSlug)
.single();
if (error) {
if (error.code === 'PGRST116') return null;
log.error('fetchEventBySlug failed', { error, data: { orgId, eventSlug } });
throw error;
}
return data as unknown as Event;
}
export async function createEvent(
supabase: SupabaseClient<Database>,
orgId: string,
userId: string,
params: {
name: string;
description?: string;
start_date?: string;
end_date?: string;
venue_name?: string;
venue_address?: string;
color?: string;
}
): Promise<Event> {
const baseSlug = slugify(params.name);
// Ensure unique slug within org
const { data: existing } = await supabase
.from('events')
.select('slug')
.eq('org_id', orgId)
.like('slug', `${baseSlug}%`);
let slug = baseSlug;
if (existing && existing.length > 0) {
const existingSlugs = new Set(existing.map((e: any) => e.slug));
if (existingSlugs.has(slug)) {
let i = 2;
while (existingSlugs.has(`${baseSlug}-${i}`)) i++;
slug = `${baseSlug}-${i}`;
}
}
const { data, error } = await supabase
.from('events')
.insert({
org_id: orgId,
name: params.name,
slug,
description: params.description ?? null,
start_date: params.start_date ?? null,
end_date: params.end_date ?? null,
venue_name: params.venue_name ?? null,
venue_address: params.venue_address ?? null,
color: params.color ?? null,
created_by: userId,
})
.select()
.single();
if (error) {
log.error('createEvent failed', { error, data: { orgId, name: params.name } });
throw error;
}
log.info('createEvent ok', { data: { id: data.id, name: params.name, slug } });
return data as unknown as Event;
}
export async function updateEvent(
supabase: SupabaseClient<Database>,
eventId: string,
params: Partial<Pick<Event, 'name' | 'description' | 'status' | 'start_date' | 'end_date' | 'venue_name' | 'venue_address' | 'cover_image_url' | 'color'>>
): Promise<Event> {
const { data, error } = await supabase
.from('events')
.update({ ...params, updated_at: new Date().toISOString() })
.eq('id', eventId)
.select()
.single();
if (error) {
log.error('updateEvent failed', { error, data: { eventId } });
throw error;
}
log.info('updateEvent ok', { data: { id: data.id } });
return data as unknown as Event;
}
export async function deleteEvent(
supabase: SupabaseClient<Database>,
eventId: string
): Promise<void> {
const { error } = await supabase
.from('events')
.delete()
.eq('id', eventId);
if (error) {
log.error('deleteEvent failed', { error, data: { eventId } });
throw error;
}
log.info('deleteEvent ok', { data: { eventId } });
}
export async function fetchEventMembers(
supabase: SupabaseClient<Database>,
eventId: string
): Promise<EventMemberWithDetails[]> {
const { data: members, error } = await supabase
.from('event_members')
.select('*')
.eq('event_id', eventId)
.order('assigned_at');
if (error) {
log.error('fetchEventMembers failed', { error, data: { eventId } });
throw error;
}
if (!members || members.length === 0) return [];
// Fetch profiles separately (same pattern as org_members)
const userIds = members.map((m: any) => m.user_id);
const { data: profiles } = await (supabase as any)
.from('profiles')
.select('id, email, full_name, avatar_url, phone, discord_handle, shirt_size, hoodie_size')
.in('id', userIds);
const profileMap = Object.fromEntries((profiles ?? []).map((p: any) => [p.id, p]));
// Fetch roles for this event
const { data: roles } = await (supabase as any)
.from('event_roles')
.select('*')
.eq('event_id', eventId);
const roleMap = Object.fromEntries((roles ?? []).map((r: any) => [r.id, r]));
// Fetch member-department assignments
const memberIds = members.map((m: any) => m.id);
const { data: memberDepts } = await (supabase as any)
.from('event_member_departments')
.select('*')
.in('event_member_id', memberIds);
// Fetch departments for this event
const { data: departments } = await (supabase as any)
.from('event_departments')
.select('*')
.eq('event_id', eventId);
const deptMap = Object.fromEntries((departments ?? []).map((d: any) => [d.id, d]));
// Build member-to-departments map
const memberDeptMap: Record<string, EventDepartment[]> = {};
for (const md of (memberDepts ?? [])) {
const dept = deptMap[(md as any).department_id];
if (dept) {
if (!memberDeptMap[(md as any).event_member_id]) memberDeptMap[(md as any).event_member_id] = [];
memberDeptMap[(md as any).event_member_id].push(dept as unknown as EventDepartment);
}
}
return members.map((m: any) => ({
...m,
profile: profileMap[m.user_id] ?? undefined,
event_role: m.role_id ? (roleMap[m.role_id] as unknown as EventRole) ?? undefined : undefined,
departments: memberDeptMap[m.id] ?? [],
}));
}
export async function addEventMember(
supabase: SupabaseClient<Database>,
eventId: string,
userId: string,
params: { role?: 'lead' | 'manager' | 'member'; role_id?: string; notes?: string } = {}
): Promise<EventMember> {
const { data, error } = await (supabase as any)
.from('event_members')
.upsert({
event_id: eventId,
user_id: userId,
role: params.role ?? 'member',
role_id: params.role_id ?? null,
notes: params.notes ?? null,
}, { onConflict: 'event_id,user_id' })
.select()
.single();
if (error) {
log.error('addEventMember failed', { error, data: { eventId, userId } });
throw error;
}
return data as unknown as EventMember;
}
export async function removeEventMember(
supabase: SupabaseClient<Database>,
eventId: string,
userId: string
): Promise<void> {
const { error } = await supabase
.from('event_members')
.delete()
.eq('event_id', eventId)
.eq('user_id', userId);
if (error) {
log.error('removeEventMember failed', { error, data: { eventId, userId } });
throw error;
}
}
// ============================================================
// Event Roles
// ============================================================
export async function fetchEventRoles(
supabase: SupabaseClient<Database>,
eventId: string
): Promise<EventRole[]> {
const { data, error } = await (supabase as any)
.from('event_roles')
.select('*')
.eq('event_id', eventId)
.order('sort_order');
if (error) {
log.error('fetchEventRoles failed', { error, data: { eventId } });
throw error;
}
return (data ?? []) as unknown as EventRole[];
}
export async function createEventRole(
supabase: SupabaseClient<Database>,
eventId: string,
params: { name: string; color?: string; sort_order?: number }
): Promise<EventRole> {
const { data, error } = await (supabase as any)
.from('event_roles')
.insert({
event_id: eventId,
name: params.name,
color: params.color ?? '#6366f1',
sort_order: params.sort_order ?? 0,
})
.select()
.single();
if (error) {
log.error('createEventRole failed', { error, data: { eventId, name: params.name } });
throw error;
}
return data as unknown as EventRole;
}
export async function updateEventRole(
supabase: SupabaseClient<Database>,
roleId: string,
params: Partial<Pick<EventRole, 'name' | 'color' | 'sort_order' | 'is_default'>>
): Promise<EventRole> {
const { data, error } = await (supabase as any)
.from('event_roles')
.update(params)
.eq('id', roleId)
.select()
.single();
if (error) {
log.error('updateEventRole failed', { error, data: { roleId } });
throw error;
}
return data as unknown as EventRole;
}
export async function deleteEventRole(
supabase: SupabaseClient<Database>,
roleId: string
): Promise<void> {
const { error } = await (supabase as any)
.from('event_roles')
.delete()
.eq('id', roleId);
if (error) {
log.error('deleteEventRole failed', { error, data: { roleId } });
throw error;
}
}
// ============================================================
// Event Departments
// ============================================================
export async function fetchEventDepartments(
supabase: SupabaseClient<Database>,
eventId: string
): Promise<EventDepartment[]> {
const { data, error } = await (supabase as any)
.from('event_departments')
.select('*')
.eq('event_id', eventId)
.order('sort_order');
if (error) {
log.error('fetchEventDepartments failed', { error, data: { eventId } });
throw error;
}
return (data ?? []) as unknown as EventDepartment[];
}
export async function createEventDepartment(
supabase: SupabaseClient<Database>,
eventId: string,
params: { name: string; color?: string; description?: string; sort_order?: number }
): Promise<EventDepartment> {
const { data, error } = await (supabase as any)
.from('event_departments')
.insert({
event_id: eventId,
name: params.name,
color: params.color ?? '#00A3E0',
description: params.description ?? null,
sort_order: params.sort_order ?? 0,
})
.select()
.single();
if (error) {
log.error('createEventDepartment failed', { error, data: { eventId, name: params.name } });
throw error;
}
return data as unknown as EventDepartment;
}
export async function updateEventDepartment(
supabase: SupabaseClient<Database>,
deptId: string,
params: Partial<Pick<EventDepartment, 'name' | 'color' | 'description' | 'sort_order'>>
): Promise<EventDepartment> {
const { data, error } = await (supabase as any)
.from('event_departments')
.update(params)
.eq('id', deptId)
.select()
.single();
if (error) {
log.error('updateEventDepartment failed', { error, data: { deptId } });
throw error;
}
return data as unknown as EventDepartment;
}
export async function deleteEventDepartment(
supabase: SupabaseClient<Database>,
deptId: string
): Promise<void> {
const { error } = await (supabase as any)
.from('event_departments')
.delete()
.eq('id', deptId);
if (error) {
log.error('deleteEventDepartment failed', { error, data: { deptId } });
throw error;
}
}
// ============================================================
// Member-Department Assignments
// ============================================================
export async function assignMemberDepartment(
supabase: SupabaseClient<Database>,
eventMemberId: string,
departmentId: string
): Promise<EventMemberDepartment> {
const { data, error } = await (supabase as any)
.from('event_member_departments')
.upsert(
{ event_member_id: eventMemberId, department_id: departmentId },
{ onConflict: 'event_member_id,department_id' }
)
.select()
.single();
if (error) {
log.error('assignMemberDepartment failed', { error, data: { eventMemberId, departmentId } });
throw error;
}
return data as unknown as EventMemberDepartment;
}
export async function unassignMemberDepartment(
supabase: SupabaseClient<Database>,
eventMemberId: string,
departmentId: string
): Promise<void> {
const { error } = await (supabase as any)
.from('event_member_departments')
.delete()
.eq('event_member_id', eventMemberId)
.eq('department_id', departmentId);
if (error) {
log.error('unassignMemberDepartment failed', { error, data: { eventMemberId, departmentId } });
throw error;
}
}

663
src/lib/cache/index.ts vendored Normal file
View File

@@ -0,0 +1,663 @@
/**
* IndexedDB-based cache for Matrix client data
* Stores messages, room state, and media blobs for offline access and faster loading
*/
import type { Message, RoomSummary } from '$lib/matrix/types';
const DB_NAME = 'matrix-cache';
const DB_VERSION = 1;
// Store names
const STORES = {
MESSAGES: 'messages',
ROOMS: 'rooms',
MEDIA: 'media',
SYNC_STATE: 'syncState',
AVATARS: 'avatars',
} as const;
interface CachedMessage extends Message {
roomId: string;
cachedAt: number;
}
interface CachedRoom {
roomId: string;
summary: RoomSummary;
cachedAt: number;
}
interface CachedMedia {
url: string;
blob: Blob;
mimeType: string;
cachedAt: number;
size: number;
}
interface CachedAvatar {
mxcUrl: string;
httpUrl: string;
blob: Blob;
cachedAt: number;
}
interface SyncStateCache {
key: string;
syncToken: string | null;
cachedAt: number;
}
let db: IDBDatabase | null = null;
/**
* Initialize the IndexedDB database
*/
export async function initCache(): Promise<void> {
if (db) return;
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const database = (event.target as IDBOpenDBRequest).result;
// Messages store with room index
if (!database.objectStoreNames.contains(STORES.MESSAGES)) {
const messageStore = database.createObjectStore(STORES.MESSAGES, {
keyPath: 'eventId',
});
messageStore.createIndex('roomId', 'roomId', { unique: false });
messageStore.createIndex('roomId_timestamp', ['roomId', 'timestamp'], { unique: false });
}
// Rooms store
if (!database.objectStoreNames.contains(STORES.ROOMS)) {
database.createObjectStore(STORES.ROOMS, { keyPath: 'roomId' });
}
// Media blob cache
if (!database.objectStoreNames.contains(STORES.MEDIA)) {
const mediaStore = database.createObjectStore(STORES.MEDIA, { keyPath: 'url' });
mediaStore.createIndex('cachedAt', 'cachedAt', { unique: false });
}
// Avatar cache
if (!database.objectStoreNames.contains(STORES.AVATARS)) {
const avatarStore = database.createObjectStore(STORES.AVATARS, { keyPath: 'mxcUrl' });
avatarStore.createIndex('cachedAt', 'cachedAt', { unique: false });
}
// Sync state cache
if (!database.objectStoreNames.contains(STORES.SYNC_STATE)) {
database.createObjectStore(STORES.SYNC_STATE, { keyPath: 'key' });
}
};
});
}
/**
* Get a transaction for the specified stores
*/
function getTransaction(storeNames: string | string[], mode: IDBTransactionMode = 'readonly'): IDBTransaction {
if (!db) throw new Error('Cache not initialized');
return db.transaction(storeNames, mode);
}
// ============ MESSAGE CACHE ============
/**
* Convert a nested Map to a serializable object for IndexedDB storage
* reactions: Map<emoji, Map<userId, eventId>> -> { emoji: { userId: eventId } }
*/
function serializeReactions(reactions: Map<string, Map<string, string>>): Record<string, Record<string, string>> {
const result: Record<string, Record<string, string>> = {};
for (const [emoji, userMap] of reactions.entries()) {
result[emoji] = {};
for (const [userId, eventId] of userMap.entries()) {
result[emoji][userId] = eventId;
}
}
return result;
}
/**
* Convert a serialized reactions object back to nested Map
*/
function deserializeReactions(obj: Record<string, Record<string, string>> | undefined): Map<string, Map<string, string>> {
const result = new Map<string, Map<string, string>>();
if (!obj || typeof obj !== 'object') return result;
for (const [emoji, userObj] of Object.entries(obj)) {
const userMap = new Map<string, string>();
if (userObj && typeof userObj === 'object') {
for (const [userId, eventId] of Object.entries(userObj)) {
userMap.set(userId, eventId);
}
}
result.set(emoji, userMap);
}
return result;
}
/**
* Cache messages for a room
*/
export async function cacheMessages(roomId: string, messages: Message[]): Promise<void> {
if (!db || messages.length === 0) return;
const tx = getTransaction(STORES.MESSAGES, 'readwrite');
const store = tx.objectStore(STORES.MESSAGES);
const now = Date.now();
for (const message of messages) {
// Serialize reactions Map to plain object for IndexedDB storage
const cached = {
...message,
reactions: serializeReactions(message.reactions),
roomId,
cachedAt: now,
};
store.put(cached);
}
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/**
* Get cached messages for a room
*/
export async function getCachedMessages(roomId: string, limit = 500): Promise<Message[]> {
if (!db) return [];
const tx = getTransaction(STORES.MESSAGES, 'readonly');
const store = tx.objectStore(STORES.MESSAGES);
const index = store.index('roomId');
return new Promise((resolve, reject) => {
const request = index.getAll(IDBKeyRange.only(roomId));
request.onsuccess = () => {
const cachedMessages = request.result as CachedMessage[];
const messages = cachedMessages
.sort((a, b) => a.timestamp - b.timestamp)
.slice(-limit)
.map(cached => ({
...cached,
// Deserialize reactions from plain object back to nested Map
// IndexedDB stores Maps as plain objects, so we need to restore them
reactions: deserializeReactions(cached.reactions as unknown as Record<string, Record<string, string>>),
}));
resolve(messages);
};
request.onerror = () => reject(request.error);
});
}
/**
* Get the latest cached message timestamp for a room
*/
export async function getLatestMessageTimestamp(roomId: string): Promise<number | null> {
if (!db) return null;
const messages = await getCachedMessages(roomId, 1);
return messages.length > 0 ? messages[messages.length - 1].timestamp : null;
}
/**
* Clear cached messages for a room
*/
export async function clearRoomMessages(roomId: string): Promise<void> {
if (!db) return;
const tx = getTransaction(STORES.MESSAGES, 'readwrite');
const store = tx.objectStore(STORES.MESSAGES);
const index = store.index('roomId');
return new Promise((resolve, reject) => {
const request = index.openKeyCursor(IDBKeyRange.only(roomId));
request.onsuccess = () => {
const cursor = request.result;
if (cursor) {
store.delete(cursor.primaryKey);
cursor.continue();
}
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
// ============ ROOM CACHE ============
// Track last cached state for diff-based updates
let _lastCachedRoomHashes = new Map<string, number>();
/**
* Simple hash function for detecting changes
*/
function hashRoomSummary(room: RoomSummary): number {
// Hash based on mutable fields that indicate a meaningful change
const str = `${room.name}|${room.lastActivity}|${room.unreadCount}|${room.memberCount}`;
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return hash;
}
/**
* Cache room summaries (full replacement)
*/
export async function cacheRooms(rooms: RoomSummary[]): Promise<void> {
if (!db || rooms.length === 0) return;
const tx = getTransaction(STORES.ROOMS, 'readwrite');
const store = tx.objectStore(STORES.ROOMS);
const now = Date.now();
// Update hash cache
_lastCachedRoomHashes.clear();
for (const room of rooms) {
const cached: CachedRoom = {
roomId: room.roomId,
summary: room,
cachedAt: now,
};
store.put(cached);
_lastCachedRoomHashes.set(room.roomId, hashRoomSummary(room));
}
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/**
* Diff-based room cache update - only writes changed rooms
* Returns number of rooms actually written
*/
export async function cacheRoomsDiff(rooms: RoomSummary[]): Promise<number> {
if (!db || rooms.length === 0) return 0;
const now = Date.now();
const changedRooms: RoomSummary[] = [];
const newHashes = new Map<string, number>();
// Detect changes using hash comparison
for (const room of rooms) {
const newHash = hashRoomSummary(room);
newHashes.set(room.roomId, newHash);
const oldHash = _lastCachedRoomHashes.get(room.roomId);
if (oldHash !== newHash) {
changedRooms.push(room);
}
}
// Detect removed rooms
const currentRoomIds = new Set(rooms.map(r => r.roomId));
const removedRoomIds: string[] = [];
for (const roomId of _lastCachedRoomHashes.keys()) {
if (!currentRoomIds.has(roomId)) {
removedRoomIds.push(roomId);
}
}
// Skip if no changes
if (changedRooms.length === 0 && removedRoomIds.length === 0) {
return 0;
}
const tx = getTransaction(STORES.ROOMS, 'readwrite');
const store = tx.objectStore(STORES.ROOMS);
// Write changed rooms
for (const room of changedRooms) {
const cached: CachedRoom = {
roomId: room.roomId,
summary: room,
cachedAt: now,
};
store.put(cached);
}
// Remove deleted rooms
for (const roomId of removedRoomIds) {
store.delete(roomId);
}
// Update hash cache
_lastCachedRoomHashes = newHashes;
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve(changedRooms.length + removedRoomIds.length);
tx.onerror = () => reject(tx.error);
});
}
/**
* Cache a single room (for incremental updates)
*/
export async function cacheRoom(room: RoomSummary): Promise<void> {
if (!db) return;
const tx = getTransaction(STORES.ROOMS, 'readwrite');
const store = tx.objectStore(STORES.ROOMS);
const cached: CachedRoom = {
roomId: room.roomId,
summary: room,
cachedAt: Date.now(),
};
store.put(cached);
_lastCachedRoomHashes.set(room.roomId, hashRoomSummary(room));
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/**
* Remove a single room from cache
*/
export async function uncacheRoom(roomId: string): Promise<void> {
if (!db) return;
const tx = getTransaction(STORES.ROOMS, 'readwrite');
tx.objectStore(STORES.ROOMS).delete(roomId);
_lastCachedRoomHashes.delete(roomId);
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/**
* Get all cached rooms
*/
export async function getCachedRooms(): Promise<RoomSummary[]> {
if (!db) return [];
const tx = getTransaction(STORES.ROOMS, 'readonly');
const store = tx.objectStore(STORES.ROOMS);
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => {
const rooms = (request.result as CachedRoom[]).map((r) => r.summary);
resolve(rooms);
};
request.onerror = () => reject(request.error);
});
}
// ============ MEDIA CACHE ============
/**
* Cache a media blob
*/
export async function cacheMedia(url: string, blob: Blob): Promise<void> {
if (!db) return;
const tx = getTransaction(STORES.MEDIA, 'readwrite');
const store = tx.objectStore(STORES.MEDIA);
const cached: CachedMedia = {
url,
blob,
mimeType: blob.type,
cachedAt: Date.now(),
size: blob.size,
};
store.put(cached);
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/**
* Get a cached media blob
*/
export async function getCachedMedia(url: string): Promise<Blob | null> {
if (!db) return null;
const tx = getTransaction(STORES.MEDIA, 'readonly');
const store = tx.objectStore(STORES.MEDIA);
return new Promise((resolve, reject) => {
const request = store.get(url);
request.onsuccess = () => {
const cached = request.result as CachedMedia | undefined;
resolve(cached?.blob ?? null);
};
request.onerror = () => reject(request.error);
});
}
// ============ AVATAR CACHE ============
/**
* Cache an avatar blob
*/
export async function cacheAvatar(mxcUrl: string, httpUrl: string, blob: Blob): Promise<void> {
if (!db) return;
const tx = getTransaction(STORES.AVATARS, 'readwrite');
const store = tx.objectStore(STORES.AVATARS);
const cached: CachedAvatar = {
mxcUrl,
httpUrl,
blob,
cachedAt: Date.now(),
};
store.put(cached);
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/**
* Get a cached avatar
*/
export async function getCachedAvatar(mxcUrl: string): Promise<{ httpUrl: string; blobUrl: string } | null> {
if (!db) return null;
const tx = getTransaction(STORES.AVATARS, 'readonly');
const store = tx.objectStore(STORES.AVATARS);
return new Promise((resolve, reject) => {
const request = store.get(mxcUrl);
request.onsuccess = () => {
const cached = request.result as CachedAvatar | undefined;
if (cached) {
const blobUrl = URL.createObjectURL(cached.blob);
resolve({ httpUrl: cached.httpUrl, blobUrl });
} else {
resolve(null);
}
};
request.onerror = () => reject(request.error);
});
}
// ============ SYNC STATE CACHE ============
/**
* Cache the sync token for resuming sync
*/
export async function cacheSyncToken(userId: string, token: string | null): Promise<void> {
if (!db) return;
const tx = getTransaction(STORES.SYNC_STATE, 'readwrite');
const store = tx.objectStore(STORES.SYNC_STATE);
const cached: SyncStateCache = {
key: userId,
syncToken: token,
cachedAt: Date.now(),
};
store.put(cached);
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/**
* Get the cached sync token
*/
export async function getCachedSyncToken(userId: string): Promise<string | null> {
if (!db) return null;
const tx = getTransaction(STORES.SYNC_STATE, 'readonly');
const store = tx.objectStore(STORES.SYNC_STATE);
return new Promise((resolve, reject) => {
const request = store.get(userId);
request.onsuccess = () => {
const cached = request.result as SyncStateCache | undefined;
resolve(cached?.syncToken ?? null);
};
request.onerror = () => reject(request.error);
});
}
// ============ CACHE MANAGEMENT ============
/**
* Get total cache size in bytes
*/
export async function getCacheSize(): Promise<number> {
if (!db) return 0;
let totalSize = 0;
// Media size
const mediaTx = getTransaction(STORES.MEDIA, 'readonly');
const mediaStore = mediaTx.objectStore(STORES.MEDIA);
await new Promise<void>((resolve) => {
const request = mediaStore.openCursor();
request.onsuccess = () => {
const cursor = request.result;
if (cursor) {
totalSize += (cursor.value as CachedMedia).size;
cursor.continue();
} else {
resolve();
}
};
});
return totalSize;
}
/**
* Clean up old cache entries
*/
export async function cleanupCache(maxAgeMs = 7 * 24 * 60 * 60 * 1000): Promise<void> {
if (!db) return;
const cutoff = Date.now() - maxAgeMs;
// Clean old media
const mediaTx = getTransaction(STORES.MEDIA, 'readwrite');
const mediaStore = mediaTx.objectStore(STORES.MEDIA);
const mediaIndex = mediaStore.index('cachedAt');
await new Promise<void>((resolve, reject) => {
const request = mediaIndex.openCursor(IDBKeyRange.upperBound(cutoff));
request.onsuccess = () => {
const cursor = request.result;
if (cursor) {
cursor.delete();
cursor.continue();
}
};
mediaTx.oncomplete = () => resolve();
mediaTx.onerror = () => reject(mediaTx.error);
});
// Clean old avatars
const avatarTx = getTransaction(STORES.AVATARS, 'readwrite');
const avatarStore = avatarTx.objectStore(STORES.AVATARS);
const avatarIndex = avatarStore.index('cachedAt');
await new Promise<void>((resolve, reject) => {
const request = avatarIndex.openCursor(IDBKeyRange.upperBound(cutoff));
request.onsuccess = () => {
const cursor = request.result;
if (cursor) {
cursor.delete();
cursor.continue();
}
};
avatarTx.oncomplete = () => resolve();
avatarTx.onerror = () => reject(avatarTx.error);
});
}
/**
* Clear all cache data
*/
export async function clearAllCache(): Promise<void> {
if (!db) return;
const storeNames = Object.values(STORES);
const tx = getTransaction(storeNames, 'readwrite');
for (const storeName of storeNames) {
tx.objectStore(storeName).clear();
}
return new Promise((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/**
* Clear cache for a specific user (on logout)
*/
export async function clearUserCache(userId: string): Promise<void> {
if (!db) return;
// Clear sync state for user
const syncTx = getTransaction(STORES.SYNC_STATE, 'readwrite');
syncTx.objectStore(STORES.SYNC_STATE).delete(userId);
// Clear all messages, rooms, media, avatars (full reset)
await clearAllCache();
}
/**
* Check if cache is available
*/
export function isCacheAvailable(): boolean {
return db !== null;
}

195
src/lib/cache/mediaCache.ts vendored Normal file
View File

@@ -0,0 +1,195 @@
/**
* Media caching utilities
* Provides cached access to avatars and media with blob storage
*/
import { getCachedMedia, cacheMedia, getCachedAvatar, cacheAvatar, isCacheAvailable } from './index';
// In-memory cache for blob URLs to avoid creating duplicates
const blobUrlCache = new Map<string, string>();
/**
* Fetch media with caching support
* Returns a blob URL that can be used directly in img/video/audio elements
*/
export async function fetchMediaCached(url: string): Promise<string> {
// Check in-memory cache first
const memCached = blobUrlCache.get(url);
if (memCached) return memCached;
// Check IndexedDB cache
if (isCacheAvailable()) {
const cachedBlob = await getCachedMedia(url);
if (cachedBlob) {
const blobUrl = URL.createObjectURL(cachedBlob);
blobUrlCache.set(url, blobUrl);
return blobUrl;
}
}
// Fetch from network
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
// Cache in IndexedDB
if (isCacheAvailable()) {
cacheMedia(url, blob).catch(() => { });
}
// Create and cache blob URL
const blobUrl = URL.createObjectURL(blob);
blobUrlCache.set(url, blobUrl);
return blobUrl;
} catch {
// Return original URL as fallback
return url;
}
}
/**
* Fetch avatar with caching support
* Handles mxc:// URLs with authenticated requests
*/
export async function fetchAvatarCached(
mxcUrl: string | null,
homeserverUrl: string,
size = 40
): Promise<string | null> {
if (!mxcUrl) return null;
// Check in-memory cache first (fastest)
const memCached = blobUrlCache.get(mxcUrl);
if (memCached) return memCached;
// Check IndexedDB cache
if (isCacheAvailable()) {
const cached = await getCachedAvatar(mxcUrl);
if (cached) {
blobUrlCache.set(mxcUrl, cached.blobUrl);
return cached.blobUrl;
}
}
// Get auth token for authenticated fetch
let accessToken: string | null = null;
try {
const creds = localStorage.getItem('matrix_credentials');
if (creds) {
accessToken = JSON.parse(creds).accessToken;
}
} catch { }
// Convert mxc:// to authenticated HTTP URL
const httpUrl = mxcToHttpAuth(mxcUrl, homeserverUrl, size);
if (!httpUrl) return null;
// Fetch from network with auth
try {
const headers: HeadersInit = {};
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
const response = await fetch(httpUrl, { headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
// Cache in IndexedDB
if (isCacheAvailable()) {
cacheAvatar(mxcUrl, httpUrl, blob).catch(() => { });
}
// Create and cache blob URL
const blobUrl = URL.createObjectURL(blob);
blobUrlCache.set(mxcUrl, blobUrl);
return blobUrl;
} catch {
return null;
}
}
/**
* Convert mxc:// URL to authenticated HTTP thumbnail URL
* Uses /_matrix/client/v1/media/ which requires auth but is the modern standard
*/
function mxcToHttpAuth(mxcUrl: string, homeserverUrl: string, size: number): string | null {
if (!mxcUrl.startsWith('mxc://')) return null;
const parts = mxcUrl.slice(6).split('/');
if (parts.length !== 2) return null;
const [serverName, mediaId] = parts;
// Use authenticated thumbnail endpoint
if (size <= 96) {
return `${homeserverUrl}/_matrix/client/v1/media/thumbnail/${serverName}/${mediaId}?width=${size}&height=${size}&method=crop`;
}
return `${homeserverUrl}/_matrix/client/v1/media/download/${serverName}/${mediaId}`;
}
/**
* Convert mxc:// URL to HTTP thumbnail URL (legacy, unauthenticated)
*/
function mxcToHttp(mxcUrl: string, homeserverUrl: string, size: number): string | null {
if (!mxcUrl.startsWith('mxc://')) return null;
const parts = mxcUrl.slice(6).split('/');
if (parts.length !== 2) return null;
const [serverName, mediaId] = parts;
// Use thumbnail endpoint for smaller sizes
if (size <= 96) {
return `${homeserverUrl}/_matrix/media/v3/thumbnail/${serverName}/${mediaId}?width=${size}&height=${size}&method=crop`;
}
// Use download endpoint for larger sizes
return `${homeserverUrl}/_matrix/media/v3/download/${serverName}/${mediaId}`;
}
/**
* Preload avatars for a list of users
* Call this when loading a room to cache avatars in advance
*/
export async function preloadAvatars(
avatarUrls: (string | null)[],
homeserverUrl: string
): Promise<void> {
const uniqueUrls = [...new Set(avatarUrls.filter(Boolean))] as string[];
// Preload in parallel, but limit concurrency
const batchSize = 5;
for (let i = 0; i < uniqueUrls.length; i += batchSize) {
const batch = uniqueUrls.slice(i, i + batchSize);
await Promise.all(
batch.map(url => fetchAvatarCached(url, homeserverUrl).catch(() => null))
);
}
}
/**
* Clear blob URL cache (call on logout)
*/
export function clearBlobUrlCache(): void {
for (const blobUrl of blobUrlCache.values()) {
URL.revokeObjectURL(blobUrl);
}
blobUrlCache.clear();
}
/**
* Get cache statistics
*/
export function getBlobCacheStats(): { count: number; urls: string[] } {
return {
count: blobUrlCache.size,
urls: [...blobUrlCache.keys()],
};
}

View File

@@ -123,63 +123,63 @@
});
</script>
<div class="flex flex-col h-full gap-2">
<div class="flex flex-col h-full">
<!-- Navigation bar -->
<div class="flex items-center justify-between px-2">
<div class="flex items-center gap-2">
<div class="flex items-center justify-between px-4 py-2 shrink-0">
<div class="flex items-center gap-1">
<button
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-full transition-colors"
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
onclick={prev}
aria-label="Previous"
>
<span
class="material-symbols-rounded"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>chevron_left</span
>
</button>
<span
class="font-heading text-h4 text-white min-w-[200px] text-center"
class="font-heading text-body-sm text-white min-w-[180px] text-center"
>{headerTitle}</span
>
<button
class="p-1 text-light/60 hover:text-light hover:bg-dark rounded-full transition-colors"
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
onclick={next}
aria-label="Next"
>
<span
class="material-symbols-rounded"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>chevron_right</span
>
</button>
<button
class="px-3 py-1 text-body-md font-body text-light/60 hover:text-white hover:bg-dark rounded-[32px] transition-colors ml-2"
class="px-2.5 py-1 text-body-sm font-body text-light/50 hover:text-white hover:bg-dark/50 rounded-lg transition-colors ml-1"
onclick={goToToday}
>
Today
</button>
</div>
<div class="flex bg-dark rounded-[32px] p-0.5">
<div class="flex gap-0.5 bg-dark/30 rounded-lg p-0.5">
<button
class="px-3 py-1 text-body-md font-body rounded-[32px] transition-colors {currentView ===
class="px-2.5 py-1 text-[12px] font-body rounded-md transition-colors {currentView ===
'day'
? 'bg-primary text-night'
: 'text-light/60 hover:text-light'}"
? 'bg-primary text-background'
: 'text-light/50 hover:text-white'}"
onclick={() => (currentView = "day")}>Day</button
>
<button
class="px-3 py-1 text-body-md font-body rounded-[32px] transition-colors {currentView ===
class="px-2.5 py-1 text-[12px] font-body rounded-md transition-colors {currentView ===
'week'
? 'bg-primary text-night'
: 'text-light/60 hover:text-light'}"
? 'bg-primary text-background'
: 'text-light/50 hover:text-white'}"
onclick={() => (currentView = "week")}>Week</button
>
<button
class="px-3 py-1 text-body-md font-body rounded-[32px] transition-colors {currentView ===
class="px-2.5 py-1 text-[12px] font-body rounded-md transition-colors {currentView ===
'month'
? 'bg-primary text-night'
: 'text-light/60 hover:text-light'}"
? 'bg-primary text-background'
: 'text-light/50 hover:text-white'}"
onclick={() => (currentView = "month")}>Month</button
>
</div>
@@ -187,48 +187,40 @@
<!-- Month View -->
{#if currentView === "month"}
<div
class="flex flex-col flex-1 gap-2 min-h-0 bg-background rounded-xl p-2"
>
<div class="flex flex-col flex-1 min-h-0">
<!-- Day Headers -->
<div class="grid grid-cols-7 gap-2">
<div class="grid grid-cols-7 border-b border-light/5">
{#each weekDayHeaders as day}
<div class="flex items-center justify-center py-2 px-2">
<span
class="font-heading text-h4 text-white text-center"
>{day}</span
>
<div class="flex items-center justify-center py-2">
<span class="font-body text-[11px] text-light/40 uppercase tracking-wider">{day}</span>
</div>
{/each}
</div>
<!-- Calendar Grid -->
<div
class="flex-1 flex flex-col gap-2 min-h-0 rounded-lg overflow-hidden"
>
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
{#each weeks as week}
<div class="grid grid-cols-7 gap-2 flex-1">
<div class="grid grid-cols-7 flex-1 border-b border-light/5 last:border-b-0">
{#each week as day}
{@const dayEvents = getEventsForDay(day)}
{@const isToday = isSameDay(day, today)}
{@const inMonth = isCurrentMonth(day)}
<div
class="bg-night rounded-none flex flex-col items-start px-2 py-2.5 overflow-hidden transition-colors hover:bg-dark/50 min-h-0 cursor-pointer
{!inMonth ? 'opacity-50' : ''}"
<button
type="button"
class="flex flex-col items-start px-1.5 py-1 overflow-hidden transition-colors hover:bg-dark/30 min-h-0 cursor-pointer border-r border-light/5 last:border-r-0
{!inMonth ? 'opacity-40' : ''}"
onclick={() => onDateClick?.(day)}
>
<span
class="font-body text-body text-white {isToday
? 'text-primary font-bold'
: ''}"
class="text-[12px] font-body w-6 h-6 flex items-center justify-center rounded-full shrink-0
{isToday ? 'bg-primary text-background font-bold' : 'text-light/60'}"
>
{day.getDate()}
</span>
{#each dayEvents.slice(0, 2) as event}
<button
class="w-full mt-1 px-2 py-0.5 rounded-[4px] text-body-sm font-bold font-body text-night truncate text-left"
style="background-color: {event.color ??
'#00A3E0'}"
class="w-full mt-0.5 px-1.5 py-0.5 rounded text-[11px] font-body text-night truncate text-left font-medium"
style="background-color: {event.color ?? '#00A3E0'}"
onclick={(e) => {
e.stopPropagation();
onEventClick?.(event);
@@ -238,12 +230,9 @@
</button>
{/each}
{#if dayEvents.length > 2}
<span
class="text-body-sm text-light/40 mt-0.5"
>+{dayEvents.length - 2} more</span
>
<span class="text-[10px] text-light/30 mt-0.5 px-1">+{dayEvents.length - 2}</span>
{/if}
</div>
</button>
{/each}
</div>
{/each}
@@ -253,40 +242,25 @@
<!-- Week View -->
{#if currentView === "week"}
<div
class="flex flex-col flex-1 gap-2 min-h-0 bg-background rounded-xl p-2"
>
<div
class="grid grid-cols-7 gap-2 flex-1 rounded-lg overflow-hidden"
>
<div class="flex flex-col flex-1 min-h-0">
<div class="grid grid-cols-7 flex-1 overflow-hidden">
{#each weekDates as day}
{@const dayEvents = getEventsForDay(day)}
{@const isToday = isSameDay(day, today)}
<div class="flex flex-col overflow-hidden">
<div class="px-2 py-2 text-center">
<div
class="font-heading text-h4 {isToday
? 'text-primary'
: 'text-white'}"
>
<div class="flex flex-col overflow-hidden border-r border-light/5 last:border-r-0">
<div class="px-2 py-2 text-center border-b border-light/5">
<div class="text-[11px] font-body uppercase tracking-wider {isToday ? 'text-primary' : 'text-light/40'}">
{weekDayHeaders[(day.getDay() + 6) % 7]}
</div>
<div
class="font-body text-body-md {isToday
? 'text-primary'
: 'text-light/60'}"
>
<div class="text-body-sm font-heading mt-0.5 {isToday ? 'text-primary' : 'text-white'}">
{day.getDate()}
</div>
</div>
<div
class="bg-night flex-1 px-2 pb-2 space-y-1 overflow-y-auto"
>
<div class="flex-1 px-1.5 py-1.5 space-y-1 overflow-y-auto">
{#each dayEvents as event}
<button
class="w-full px-2 py-1.5 rounded-[4px] text-body-sm font-bold font-body text-night truncate text-left"
style="background-color: {event.color ??
'#00A3E0'}"
class="w-full px-2 py-1.5 rounded text-[11px] font-body text-night truncate text-left font-medium"
style="background-color: {event.color ?? '#00A3E0'}"
onclick={() => onEventClick?.(event)}
>
{event.title}
@@ -302,27 +276,24 @@
<!-- Day View -->
{#if currentView === "day"}
{@const dayEvents = getEventsForDay(currentDate)}
<div class="flex-1 bg-night px-4 py-5 min-h-0 overflow-auto">
<div class="flex-1 px-4 py-4 min-h-0 overflow-auto">
{#if dayEvents.length === 0}
<div class="text-center text-light/40 py-12">
<p class="font-body text-body">No events for this day</p>
<div class="flex flex-col items-center justify-center h-full text-light/40">
<span class="material-symbols-rounded mb-3" style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;">event_busy</span>
<p class="text-body-sm">No events for this day</p>
</div>
{:else}
<div class="space-y-2">
{#each dayEvents as event}
<button
class="w-full text-left p-3 rounded-[8px] transition-colors hover:opacity-80"
style="background-color: {event.color ??
'#00A3E0'}20; border-left: 3px solid {event.color ??
'#00A3E0'}"
class="w-full text-left p-3 rounded-xl border border-light/5 hover:border-light/10 transition-all"
style="border-left: 3px solid {event.color ?? '#00A3E0'}"
onclick={() => onEventClick?.(event)}
>
<div class="font-heading text-h5 text-white">
<div class="font-heading text-body-sm text-white">
{event.title}
</div>
<div
class="font-body text-body-md text-light/60 mt-1"
>
<div class="text-[12px] text-light/40 mt-1">
{new Date(event.start_time).toLocaleTimeString(
"en-US",
{ hour: "numeric", minute: "2-digit" },
@@ -333,9 +304,7 @@
)}
</div>
{#if event.description}
<div
class="font-body text-body-md text-light/50 mt-2"
>
<div class="text-[12px] text-light/30 mt-1.5 line-clamp-2">
{event.description}
</div>
{/if}

View File

@@ -0,0 +1,300 @@
<script lang="ts">
import { Avatar } from "$lib/components/ui";
import {
MessageList,
MessageInput,
TypingIndicator,
MemberList,
RoomInfoPanel,
} from "$lib/components/matrix";
import type { Message, RoomSummary, RoomMember } from "$lib/matrix/types";
interface Props {
room: RoomSummary | null;
messages: Message[];
typingUsers: string[];
members: RoomMember[];
roomId: string;
replyToMessage: Message | null;
editingMessage: Message | null;
isLoadingMore: boolean;
onReact: (messageId: string, emoji: string) => void;
onEdit: (message: Message) => void;
onDelete: (messageId: string) => void;
onReply: (message: Message) => void;
onCancelReply: () => void;
onSaveEdit: (content: string) => void;
onCancelEdit: () => void;
onLoadMore: () => void;
onDragOver: (e: DragEvent) => void;
onDragLeave: (e: DragEvent) => void;
onDrop: (e: DragEvent) => void;
isDraggingFile: boolean;
}
let {
room,
messages,
typingUsers,
members,
roomId,
replyToMessage,
editingMessage,
isLoadingMore,
onReact,
onEdit,
onDelete,
onReply,
onCancelReply,
onSaveEdit,
onCancelEdit,
onLoadMore,
onDragOver,
onDragLeave,
onDrop,
isDraggingFile,
}: Props = $props();
let showMessageSearch = $state(false);
let messageSearchQuery = $state("");
let showRoomInfo = $state(false);
let showMemberList = $state(false);
// Simple local search (could be moved to a prop if needed)
const messageSearchResults = $derived(
messageSearchQuery.trim()
? messages.filter((m) =>
m.content.toLowerCase().includes(messageSearchQuery.toLowerCase()),
)
: [],
);
</script>
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
<!-- Room Header -->
{#if room}
<header
class="h-16 px-6 flex items-center border-b border-light/10 bg-dark/50"
>
<div class="flex items-center gap-3">
<Avatar src={room.avatarUrl} name={room.name} size="md" />
<div>
<div class="flex items-center gap-2">
<h2 class="font-semibold text-light">{room.name}</h2>
{#if room.isEncrypted}
<span class="text-green-400" title="End-to-end encrypted">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<path
d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"
/>
</svg>
</span>
{/if}
</div>
<p class="text-xs text-light/50">
{room.memberCount}
{room.memberCount === 1 ? "member" : "members"}{room.isEncrypted
? " • Encrypted"
: ""}
</p>
</div>
<!-- Search button -->
<button
class="ml-auto w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded transition-colors"
onclick={() => (showMessageSearch = !showMessageSearch)}
title="Search messages"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
</button>
<!-- Room info toggle button -->
<button
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded transition-colors"
onclick={() => (showRoomInfo = !showRoomInfo)}
title="Room info"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</button>
<!-- Member list toggle button -->
<button
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded transition-colors"
onclick={() => (showMemberList = !showMemberList)}
title="Toggle member list"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</button>
</div>
</header>
{/if}
<!-- Message search panel -->
{#if showMessageSearch}
<div class="border-b border-light/10 p-3 bg-dark/50">
<div class="relative">
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-light/40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
bind:value={messageSearchQuery}
placeholder="Search messages in this room..."
class="w-full pl-9 pr-8 py-2 bg-night text-light text-sm rounded-lg border border-light/10 placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
<button
class="absolute right-2 top-1/2 -translate-y-1/2 text-light/40 hover:text-light"
onclick={() => {
showMessageSearch = false;
messageSearchQuery = "";
}}
title="Close search"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{#if messageSearchQuery && messageSearchResults.length > 0}
<div class="mt-2 max-h-48 overflow-y-auto">
<p class="text-xs text-light/40 mb-2">
{messageSearchResults.length} result{messageSearchResults.length !==
1
? "s"
: ""}
</p>
{#each messageSearchResults.slice(0, 20) as result}
<button
class="w-full text-left px-3 py-2 hover:bg-light/5 rounded transition-colors"
onclick={() => {
showMessageSearch = false;
messageSearchQuery = "";
}}
>
<p class="text-xs text-primary">{result.senderName}</p>
<p class="text-sm text-light truncate">{result.content}</p>
<p class="text-xs text-light/30">
{new Date(result.timestamp).toLocaleString()}
</p>
</button>
{/each}
</div>
{:else if messageSearchQuery}
<p class="text-sm text-light/40 mt-2">No results found</p>
{/if}
</div>
{/if}
<!-- Main content area with optional member panel -->
<div
class="flex-1 flex min-h-0 overflow-hidden relative"
ondragover={onDragOver}
ondragleave={onDragLeave}
ondrop={onDrop}
role="region"
>
<!-- Drag overlay -->
{#if isDraggingFile}
<div
class="absolute inset-0 z-50 bg-primary/20 border-2 border-dashed border-primary rounded-lg flex items-center justify-center backdrop-blur-sm"
>
<div class="text-center">
<svg
class="w-16 h-16 mx-auto mb-4 text-primary"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<p class="text-xl font-semibold text-primary">Drop to upload</p>
<p class="text-sm text-light/60 mt-1">Release to send file</p>
</div>
</div>
{/if}
<!-- Messages column -->
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
<MessageList
{messages}
onReact={(msgId, emoji) => onReact(msgId, emoji)}
{onEdit}
{onDelete}
{onReply}
{onLoadMore}
isLoading={isLoadingMore}
/>
<TypingIndicator userNames={typingUsers} />
<MessageInput
{roomId}
replyTo={replyToMessage}
{onCancelReply}
{editingMessage}
{onSaveEdit}
{onCancelEdit}
/>
</div>
<!-- Side panels -->
{#if showRoomInfo && room}
<aside class="w-72 border-l border-light/10 bg-dark/30">
<RoomInfoPanel
{room}
{members}
onClose={() => (showRoomInfo = false)}
/>
</aside>
{:else if showMemberList}
<aside class="w-64 border-l border-light/10 bg-dark/30">
<MemberList {members} />
</aside>
{/if}
</div>
</div>

View File

@@ -0,0 +1,340 @@
<script lang="ts">
import { Avatar } from "$lib/components/ui";
import type { RoomSummary } from "$lib/matrix/types";
import type { AuthState } from "$lib/stores/matrix";
interface Props {
rooms: RoomSummary[];
selectedRoomId: string | null;
syncState: string;
auth: AuthState;
onRoomSelect: (roomId: string) => void;
onCreateRoom: () => void;
onCreateSpace: () => void;
onStartDM: () => void;
onLogout: () => void;
onOpenSettings?: () => void;
}
let {
rooms,
selectedRoomId,
syncState,
auth,
onRoomSelect,
onCreateRoom,
onCreateSpace,
onStartDM,
onLogout,
onOpenSettings,
}: Props = $props();
let searchQuery = $state("");
let expandedSpaces = $state<Set<string>>(new Set());
// Filter rooms based on search
const filteredRooms = $derived(
searchQuery.trim()
? rooms.filter(
(room) =>
room.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
room.topic?.toLowerCase().includes(searchQuery.toLowerCase()),
)
: rooms,
);
// Get spaces (organizations)
const spaces = $derived(filteredRooms.filter((room) => room.isSpace));
// Get rooms belonging to each space
const roomsBySpace = $derived(() => {
const map = new Map<string, RoomSummary[]>();
spaces.forEach((space) => {
map.set(
space.roomId,
filteredRooms.filter(
(room) => !room.isSpace && room.parentSpaceId === space.roomId,
),
);
});
return map;
});
// Get orphan rooms (messages) - rooms not belonging to any space and not spaces themselves
const orphanRooms = $derived(
filteredRooms.filter((room) => !room.isSpace && !room.parentSpaceId),
);
const isConnected = $derived(
syncState === "SYNCING" || syncState === "PREPARED",
);
function toggleSpace(spaceId: string) {
expandedSpaces = new Set(expandedSpaces);
if (expandedSpaces.has(spaceId)) {
expandedSpaces.delete(spaceId);
} else {
expandedSpaces.add(spaceId);
}
}
</script>
<aside class="w-64 bg-dark flex flex-col border-r border-light/10">
<!-- Header -->
<header class="p-4 border-b border-light/10">
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-primary">Root</h1>
<span
class="text-xs px-2 py-1 rounded-full {isConnected
? 'bg-green-500/20 text-green-400'
: 'bg-yellow-500/20 text-yellow-400'}"
>
{isConnected ? "Connected" : syncState}
</span>
</div>
</header>
<!-- Room List -->
<nav class="flex-1 overflow-y-auto p-2">
<!-- Search input -->
<div class="px-2 pb-2">
<div class="relative">
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-light/40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
bind:value={searchQuery}
placeholder="Search rooms..."
class="w-full pl-9 pr-3 py-2 bg-night text-light text-sm rounded-lg border border-light/10 placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
{#if searchQuery}
<button
class="absolute right-2 top-1/2 -translate-y-1/2 text-light/40 hover:text-light"
onclick={() => (searchQuery = "")}
title="Clear search"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{/if}
</div>
</div>
<!-- Organizations (Spaces) Section -->
<div class="mb-4">
<div class="flex items-center justify-between px-2 py-2">
<span
class="text-xs font-semibold text-light/40 uppercase tracking-wider"
>
Spaces
</span>
<button
class="w-6 h-6 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors"
onclick={onCreateSpace}
title="Create space"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
</div>
{#if spaces.length > 0}
<ul class="flex flex-col gap-1">
{#each spaces as space (space.roomId)}
<li>
<button
class="w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors text-left
{selectedRoomId === space.roomId
? 'bg-primary/20 text-primary'
: 'text-light hover:bg-light/5'}"
onclick={() => toggleSpace(space.roomId)}
>
<svg
class="w-4 h-4 transition-transform {expandedSpaces.has(
space.roomId,
)
? 'rotate-90'
: ''}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="9,18 15,12 9,6" />
</svg>
<Avatar src={space.avatarUrl} name={space.name} size="sm" />
<span class="font-medium truncate flex-1">{space.name}</span>
</button>
<!-- Child rooms of this space -->
{#if expandedSpaces.has(space.roomId)}
<ul class="ml-6 mt-1 flex flex-col gap-1">
{#each roomsBySpace().get(space.roomId) || [] as room (room.roomId)}
<li>
<button
class="w-full flex items-center gap-3 px-3 py-1.5 rounded-lg transition-colors text-left text-sm
{selectedRoomId === room.roomId
? 'bg-primary/20 text-primary'
: 'text-light/80 hover:bg-light/5'}"
onclick={() => onRoomSelect(room.roomId)}
>
<span class="text-light/40">#</span>
<span class="truncate flex-1">{room.name}</span>
{#if room.unreadCount > 0}
<span
class="bg-primary text-white text-xs px-1.5 py-0.5 rounded-full min-w-[18px] text-center"
>
{room.unreadCount > 99 ? "99+" : room.unreadCount}
</span>
{/if}
</button>
</li>
{/each}
</ul>
{/if}
</li>
{/each}
</ul>
{:else}
<p class="text-light/30 text-xs text-center py-2 px-2">
No spaces yet. Create one to organize your rooms.
</p>
{/if}
</div>
<!-- Messages (Orphan Rooms) Section -->
<div class="flex items-center justify-between px-2 py-2">
<span
class="text-xs font-semibold text-light/40 uppercase tracking-wider"
>
Messages {searchQuery ? `(${orphanRooms.length})` : ""}
</span>
<div class="flex gap-1">
<button
class="w-6 h-6 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors"
onclick={onStartDM}
title="Start direct message"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
/>
</svg>
</button>
<button
class="w-6 h-6 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors"
onclick={onCreateRoom}
title="Create room"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
</div>
</div>
{#if orphanRooms.length === 0 && spaces.length === 0}
<p class="text-light/40 text-sm text-center py-8">
{searchQuery ? "No matching rooms" : "No rooms yet"}
</p>
{:else if orphanRooms.length > 0}
<ul class="flex flex-col gap-1">
{#each orphanRooms as room (room.roomId)}
<li>
<button
class="w-full flex items-center gap-3 px-3 py-3 rounded-lg transition-colors text-left
{selectedRoomId === room.roomId
? 'bg-primary/20 text-primary'
: 'text-light hover:bg-light/5'}"
onclick={() => onRoomSelect(room.roomId)}
>
<Avatar src={room.avatarUrl} name={room.name} size="md" />
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<span class="font-medium truncate">{room.name}</span>
{#if room.unreadCount > 0}
<span
class="bg-primary text-white text-xs px-1.5 py-0.5 rounded-full min-w-[20px] text-center"
>
{room.unreadCount > 99 ? "99+" : room.unreadCount}
</span>
{/if}
</div>
{#if room.lastMessage}
<p class="text-xs text-light/40 truncate">
{room.lastMessage.senderName}: {room.lastMessage.content}
</p>
{/if}
</div>
</button>
</li>
{/each}
</ul>
{/if}
</nav>
<!-- User Section -->
<footer class="p-4 border-t border-light/10">
<div class="flex items-center gap-3">
<Avatar name={auth.userId || "User"} size="sm" status="online" />
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-light truncate">
{auth.userId}
</p>
</div>
<button
class="text-light/50 hover:text-light p-2 rounded-lg hover:bg-light/10 transition-colors"
onclick={onLogout}
title="Logout"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16,17 21,12 16,7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</button>
</div>
</footer>
</aside>

View File

@@ -0,0 +1,2 @@
export { default as Sidebar } from './Sidebar.svelte';
export { default as ChatArea } from './ChatArea.svelte';

View File

@@ -0,0 +1,346 @@
<script lang="ts">
import {
theme,
isDarkMode,
primaryColor,
PRESET_COLORS,
} from "$lib/stores/theme";
import { auth } from "$lib/stores/matrix";
import { getClient } from "$lib/matrix/client";
import { Avatar, Button, Input } from "$lib/components/ui";
interface Props {
open: boolean;
onClose: () => void;
}
let { open, onClose }: Props = $props();
// User profile state
let displayName = $state("");
let activeTab = $state<"profile" | "appearance" | "security">("profile");
let saving = $state(false);
let error = $state("");
// Derived values
const currentUserId = $derived($auth.userId || "@user");
const dark = $derived($isDarkMode);
const currentPrimary = $derived($primaryColor);
// Load user profile on open
$effect(() => {
if (open && currentUserId && currentUserId !== "@user") {
const client = getClient();
if (client) {
const user = client.getUser(currentUserId);
if (user) {
displayName = user.displayName || "";
}
}
}
});
function handleClose() {
onClose();
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
handleClose();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
handleClose();
}
}
async function handleSaveProfile() {
if (!displayName.trim()) return;
saving = true;
error = "";
try {
const client = getClient();
if (client) {
await client.setDisplayName(displayName.trim());
}
handleClose();
} catch (e: unknown) {
error = e instanceof Error ? e.message : "Failed to save profile";
} finally {
saving = false;
}
}
function handleColorSelect(color: string) {
theme.setPrimaryColor(color);
}
</script>
{#if open}
<div
class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
role="dialog"
aria-modal="true"
aria-label="User Settings"
tabindex="-1"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
>
<div
class="bg-night rounded-[24px] w-[90vw] max-w-[480px] max-h-[85vh] overflow-hidden flex flex-col"
role="document"
>
<!-- Header -->
<div
class="flex items-center justify-between p-5 border-b border-light/10"
>
<h2 class="text-xl font-heading text-light">Settings</h2>
<button
class="flex items-center justify-center size-8 rounded-full hover:bg-light/10 transition-colors"
onclick={handleClose}
aria-label="Close settings"
>
<svg
class="w-5 h-5 text-light"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<!-- Tabs -->
<div class="flex border-b border-light/10">
<button
class="flex-1 py-3 px-4 text-sm font-bold transition-colors {activeTab ===
'profile'
? 'text-primary bg-primary/10 border-b-2 border-primary'
: 'text-light hover:bg-light/5'}"
onclick={() => (activeTab = "profile")}
>
Profile
</button>
<button
class="flex-1 py-3 px-4 text-sm font-bold transition-colors {activeTab ===
'appearance'
? 'text-primary bg-primary/10 border-b-2 border-primary'
: 'text-light hover:bg-light/5'}"
onclick={() => (activeTab = "appearance")}
>
Appearance
</button>
<button
class="flex-1 py-3 px-4 text-sm font-bold transition-colors {activeTab ===
'security'
? 'text-primary bg-primary/10 border-b-2 border-primary'
: 'text-light hover:bg-light/5'}"
onclick={() => (activeTab = "security")}
>
Security
</button>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-5">
{#if activeTab === "profile"}
<!-- Profile Tab -->
<div class="flex flex-col gap-6">
<!-- Avatar Section -->
<div class="flex flex-col items-center gap-3">
<Avatar name={displayName || currentUserId} size="xl" />
<p class="text-text-muted text-sm">{currentUserId}</p>
{#if error}
<p class="text-error text-sm">{error}</p>
{/if}
</div>
<!-- Profile Fields -->
<div class="flex flex-col gap-4">
<Input
label="Display Name"
bind:value={displayName}
placeholder="Your display name"
/>
</div>
</div>
{:else if activeTab === "appearance"}
<!-- Appearance Tab -->
<div class="flex flex-col gap-6">
<!-- Theme Mode -->
<div class="flex flex-col gap-3">
<h3 class="text-light font-bold text-sm">Theme Mode</h3>
<div class="flex gap-3">
<button
class="flex-1 flex flex-col items-center gap-2 p-4 rounded-xl border-2 cursor-pointer transition-all {!dark
? 'border-primary bg-primary/10'
: 'border-light/10 bg-light/5 hover:bg-light/10'}"
onclick={() => theme.setMode("light")}
>
<svg
class="w-6 h-6 {!dark ? 'text-primary' : 'text-light'}"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 7c-2.76 0-5 2.24-5 5s2.24 5 5 5 5-2.24 5-5-2.24-5-5-5zM2 13h2c.55 0 1-.45 1-1s-.45-1-1-1H2c-.55 0-1 .45-1 1s.45 1 1 1zm18 0h2c.55 0 1-.45 1-1s-.45-1-1-1h-2c-.55 0-1 .45-1 1s.45 1 1 1zM11 2v2c0 .55.45 1 1 1s1-.45 1-1V2c0-.55-.45-1-1-1s-1 .45-1 1zm0 18v2c0 .55.45 1 1 1s1-.45 1-1v-2c0-.55-.45-1-1-1s-1 .45-1 1zM5.99 4.58c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0s.39-1.03 0-1.41L5.99 4.58zm12.37 12.37c-.39-.39-1.03-.39-1.41 0-.39.39-.39 1.03 0 1.41l1.06 1.06c.39.39 1.03.39 1.41 0 .39-.39.39-1.03 0-1.41l-1.06-1.06zm1.06-10.96c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06zM7.05 18.36c.39-.39.39-1.03 0-1.41-.39-.39-1.03-.39-1.41 0l-1.06 1.06c-.39.39-.39 1.03 0 1.41s1.03.39 1.41 0l1.06-1.06z"
/>
</svg>
<span
class="text-sm font-bold {!dark
? 'text-primary'
: 'text-light'}">Light</span
>
</button>
<button
class="flex-1 flex flex-col items-center gap-2 p-4 rounded-xl border-2 cursor-pointer transition-all {dark
? 'border-primary bg-primary/10'
: 'border-light/10 bg-light/5 hover:bg-light/10'}"
onclick={() => theme.setMode("dark")}
>
<svg
class="w-6 h-6 {dark ? 'text-primary' : 'text-light'}"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9c0-.46-.04-.92-.1-1.36-.98 1.37-2.58 2.26-4.4 2.26-2.98 0-5.4-2.42-5.4-5.4 0-1.81.89-3.42 2.26-4.4-.44-.06-.9-.1-1.36-.1z"
/>
</svg>
<span
class="text-sm font-bold {dark
? 'text-primary'
: 'text-light'}">Dark</span
>
</button>
</div>
</div>
<!-- Accent Color -->
<div class="flex flex-col gap-3">
<h3 class="text-light font-bold text-sm">Accent Color</h3>
<div class="grid grid-cols-6 gap-3">
{#each PRESET_COLORS as color (color.primary)}
<button
class="size-10 rounded-full cursor-pointer border-2 transition-all hover:scale-110 flex items-center justify-center {currentPrimary ===
color.primary
? 'border-white ring-2 ring-white/30'
: 'border-transparent'}"
style="background-color: {color.primary}"
title={color.name}
onclick={() => handleColorSelect(color.primary)}
>
{#if currentPrimary === color.primary}
<svg
class="w-4 h-4 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
>
<polyline points="20,6 9,17 4,12" />
</svg>
{/if}
</button>
{/each}
</div>
</div>
<!-- Custom Color -->
<div class="flex flex-col gap-2">
<label class="text-text-muted text-sm" for="custom-color"
>Custom Color</label
>
<div class="flex gap-3 items-center">
<input
id="custom-color"
type="color"
value={currentPrimary}
onchange={(e) => handleColorSelect(e.currentTarget.value)}
class="size-10 rounded-lg cursor-pointer border-none"
/>
<div class="flex-1">
<Input
value={currentPrimary}
placeholder="#00A3E0"
oninput={(e) =>
handleColorSelect((e.target as HTMLInputElement).value)}
/>
</div>
</div>
</div>
</div>
{:else if activeTab === "security"}
<!-- Security Tab -->
<div class="flex flex-col gap-6">
<!-- Device Info -->
<div class="flex flex-col gap-3">
<h3 class="text-light font-bold text-sm">This Device</h3>
<div class="flex flex-col gap-2 p-4 bg-dark/50 rounded-xl">
<div class="flex items-center justify-between">
<span class="text-text-muted text-sm">Device ID</span>
<code class="text-light text-sm bg-night px-2 py-1 rounded">
{$auth.deviceId || "Unknown"}
</code>
</div>
<div class="flex items-center justify-between">
<span class="text-text-muted text-sm">User ID</span>
<code class="text-light text-sm bg-night px-2 py-1 rounded">
{currentUserId}
</code>
</div>
<div class="flex items-center justify-between">
<span class="text-text-muted text-sm">Homeserver</span>
<code
class="text-light text-sm bg-night px-2 py-1 rounded truncate max-w-[200px]"
>
{$auth.homeserverUrl || "Unknown"}
</code>
</div>
</div>
</div>
<!-- Encryption Status -->
<div class="flex flex-col gap-3">
<h3 class="text-light font-bold text-sm">
End-to-End Encryption
</h3>
<div class="flex items-center gap-3 p-4 bg-dark/50 rounded-xl">
<svg
class="w-6 h-6 text-success"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-2 16l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z"
/>
</svg>
<div class="flex-1">
<p class="text-light font-medium">Encryption Enabled</p>
<p class="text-text-muted text-sm">
Your messages are end-to-end encrypted
</p>
</div>
</div>
</div>
</div>
{/if}
</div>
<!-- Footer -->
<div class="flex justify-end gap-3 p-5 border-t border-light/10">
<Button variant="secondary" onclick={handleClose}>Cancel</Button>
<Button onclick={handleSaveProfile} loading={saving}
>Save Changes</Button
>
</div>
</div>
</div>
{/if}

View File

@@ -0,0 +1 @@
export { default as UserSettingsModal } from './UserSettingsModal.svelte';

View File

@@ -42,17 +42,15 @@
}
</script>
<div
class="bg-night rounded-[32px] overflow-hidden flex flex-col min-w-0 h-full"
>
<div class="flex flex-col min-w-0 h-full overflow-hidden">
<!-- Lock Banner -->
{#if locked}
<div
class="flex items-center gap-2 px-4 py-2.5 bg-warning/10 border-b border-warning/20"
class="flex items-center gap-2 px-4 py-2 bg-warning/10 border-b border-warning/20 shrink-0"
>
<span
class="material-symbols-rounded text-warning"
style="font-size: 20px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
style="font-size: 18px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
lock
</span>
@@ -64,42 +62,35 @@
{/if}
<!-- Header -->
<header class="flex items-center gap-2 px-4 py-5">
<h2 class="flex-1 font-heading text-h1 text-white truncate">
<div class="flex items-center gap-2 px-5 py-3 border-b border-light/5 shrink-0">
<h2 class="flex-1 font-heading text-body-sm text-white truncate">
{document.name}
</h2>
{#if locked}
<Button size="md" disabled>
<span
class="material-symbols-rounded mr-1"
style="font-size: 16px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>lock</span
>
Locked
</Button>
<Button size="sm" disabled>Locked</Button>
{:else if mode === "edit"}
<Button size="md" onclick={handleEditClick}>
<Button size="sm" onclick={handleEditClick}>
{isEditing ? "Preview" : "Edit"}
</Button>
{:else}
<Button size="md" onclick={handleEditClick}>Edit</Button>
<Button size="sm" onclick={handleEditClick}>Edit</Button>
{/if}
<button
type="button"
class="p-1 hover:bg-dark rounded-full transition-colors"
class="p-1 hover:bg-dark/50 rounded-lg transition-colors"
aria-label="More options"
>
<span
class="material-symbols-rounded text-light"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
class="material-symbols-rounded text-light/40 hover:text-white"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
more_horiz
</span>
</button>
</header>
</div>
<!-- Editor Area -->
<div class="flex-1 bg-background rounded-[32px] mx-4 mb-4 overflow-auto">
<div class="flex-1 overflow-auto">
<Editor {document} {onSave} editable={isEditing} />
</div>
</div>

View File

@@ -5,9 +5,6 @@
Button,
Modal,
Input,
Avatar,
IconButton,
Icon,
} from "$lib/components/ui";
import { DocumentViewer } from "$lib/components/documents";
import { createLogger } from "$lib/utils/logger";
@@ -490,41 +487,28 @@
}
</script>
<div class="flex h-full gap-4">
<div class="flex h-full gap-0">
<!-- Files Panel -->
<div
class="bg-night rounded-[32px] flex flex-col gap-4 px-4 py-5 overflow-hidden flex-1 min-w-0 h-full"
>
<!-- Header -->
<header class="flex items-center gap-2 p-1">
<Avatar name={title} size="md" />
<h1 class="flex-1 font-heading text-h1 text-white">{title}</h1>
<Button size="md" onclick={handleAdd}>{m.btn_new()}</Button>
<IconButton title={m.files_toggle_view()} onclick={toggleViewMode}>
<Icon
name={viewMode === "list" ? "grid_view" : "view_list"}
size={24}
/>
</IconButton>
</header>
<div class="flex flex-col flex-1 min-w-0 h-full overflow-hidden">
<!-- Toolbar: Breadcrumbs + Actions -->
<div class="flex items-center gap-2 px-6 py-3 border-b border-light/5 shrink-0">
<!-- Breadcrumb Path -->
<nav class="flex items-center gap-2 text-h3 font-heading">
<nav class="flex items-center gap-1 flex-1 min-w-0 overflow-x-auto">
{#each breadcrumbPath as crumb, i}
{#if i > 0}
<span
class="material-symbols-rounded text-light/30"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
class="material-symbols-rounded text-light/20 shrink-0"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
chevron_right
</span>
{/if}
<a
href={getFolderUrl(crumb.id)}
class="px-3 py-1 rounded-xl transition-colors
class="px-2 py-1 rounded-lg text-body-sm font-body whitespace-nowrap transition-colors
{crumb.id === currentFolderId
? 'text-white'
: 'text-light/60 hover:text-primary'}
? 'text-white bg-dark/30'
: 'text-light/50 hover:text-white hover:bg-dark/30'}
{dragOverBreadcrumb === (crumb.id ?? '__root__')
? 'ring-2 ring-primary bg-primary/10'
: ''}"
@@ -554,33 +538,50 @@
resetDragState();
}}
>
{#if i === 0}
<span class="material-symbols-rounded" style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;">home</span>
{:else}
{crumb.name}
{/if}
</a>
{/each}
</nav>
<Button size="sm" icon="add" onclick={handleAdd}>{m.btn_new()}</Button>
<button
type="button"
class="p-1.5 rounded-lg text-light/40 hover:text-white hover:bg-dark/50 transition-colors"
title={m.files_toggle_view()}
onclick={toggleViewMode}
>
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>{viewMode === "list" ? "grid_view" : "view_list"}</span
>
</button>
</div>
<!-- File List/Grid -->
<div class="flex-1 overflow-auto min-h-0">
<div class="flex-1 overflow-auto min-h-0 p-4">
{#if viewMode === "list"}
<div
class="flex flex-col gap-1"
class="flex flex-col gap-0.5"
ondragover={handleContainerDragOver}
ondrop={handleDropOnEmpty}
role="list"
>
{#if currentFolderItems.length === 0}
<div class="text-center text-light/40 py-8 text-sm">
<p>
No files yet. Drag files here or create a new
one.
</p>
<div class="flex flex-col items-center justify-center text-light/40 py-16">
<span class="material-symbols-rounded mb-3" style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;">folder_open</span>
<p class="text-body-sm">{m.files_empty()}</p>
</div>
{:else}
{#each currentFolderItems as item}
<button
type="button"
class="flex items-center gap-2 h-10 pl-1 pr-2 py-1 rounded-[32px] w-full text-left transition-colors hover:bg-dark
{selectedDoc?.id === item.id ? 'bg-dark' : ''}
class="flex items-center gap-3 px-3 py-2 rounded-xl w-full text-left transition-colors hover:bg-dark/50
{selectedDoc?.id === item.id ? 'bg-dark/50 ring-1 ring-primary/20' : ''}
{draggedItem?.id === item.id ? 'opacity-50' : ''}
{dragOverFolder === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}"
draggable="true"
@@ -594,25 +595,21 @@
onauxclick={(e) => handleAuxClick(e, item)}
oncontextmenu={(e) =>
handleContextMenu(e, item)}
>
<div
class="w-8 h-8 flex items-center justify-center p-1"
>
<span
class="material-symbols-rounded text-light"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
class="material-symbols-rounded shrink-0 {item.type === 'folder' ? 'text-amber-400' : item.type === 'kanban' ? 'text-purple-400' : 'text-light/50'}"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>
{getDocIcon(item)}
</span>
</div>
<span
class="font-body text-body text-white truncate flex-1"
class="font-body text-body-sm text-white truncate flex-1"
>{item.name}</span
>
{#if item.type === "folder"}
<span
class="material-symbols-rounded text-light/50"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
class="material-symbols-rounded text-light/20"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
chevron_right
</span>
@@ -624,26 +621,22 @@
{:else}
<!-- Grid View -->
<div
class="grid grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4"
class="grid grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-2"
ondragover={handleContainerDragOver}
ondrop={handleDropOnEmpty}
role="list"
>
{#if currentFolderItems.length === 0}
<div
class="col-span-full text-center text-light/40 py-8 text-sm"
>
<p>
No files yet. Drag files here or create a new
one.
</p>
<div class="col-span-full flex flex-col items-center justify-center text-light/40 py-16">
<span class="material-symbols-rounded mb-3" style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;">folder_open</span>
<p class="text-body-sm">{m.files_empty()}</p>
</div>
{:else}
{#each currentFolderItems as item}
<button
type="button"
class="flex flex-col items-center gap-2 p-4 rounded-xl transition-colors hover:bg-dark
{selectedDoc?.id === item.id ? 'bg-dark' : ''}
class="flex flex-col items-center gap-2 p-3 rounded-xl border border-transparent transition-all hover:bg-dark/50 hover:border-light/5
{selectedDoc?.id === item.id ? 'bg-dark/50 border-primary/20' : ''}
{draggedItem?.id === item.id ? 'opacity-50' : ''}
{dragOverFolder === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}"
draggable="true"
@@ -659,13 +652,13 @@
handleContextMenu(e, item)}
>
<span
class="material-symbols-rounded text-light"
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
class="material-symbols-rounded {item.type === 'folder' ? 'text-amber-400' : item.type === 'kanban' ? 'text-purple-400' : 'text-light/40'}"
style="font-size: 40px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 40;"
>
{getDocIcon(item)}
</span>
<span
class="font-body text-body-md text-white text-center truncate w-full"
class="font-body text-[12px] text-white text-center truncate w-full"
>{item.name}</span
>
</button>
@@ -678,7 +671,7 @@
<!-- Compact Editor Panel (shown when a doc is selected) -->
{#if selectedDoc}
<div class="flex-1 min-w-0 h-full">
<div class="flex-1 min-w-0 h-full border-l border-light/5">
<DocumentViewer
document={selectedDoc}
onSave={handleSave}

View File

@@ -57,7 +57,7 @@
<button
type="button"
class="bg-night rounded-[16px] p-2 cursor-pointer hover:ring-1 hover:ring-primary/30 transition-all group w-full text-left overflow-clip flex flex-col gap-2 relative"
class="bg-night/80 border border-light/5 hover:border-light/10 rounded-xl px-3 py-2.5 cursor-pointer transition-all group w-full text-left flex flex-col gap-1.5 relative"
class:opacity-50={isDragging}
{draggable}
{ondragstart}
@@ -67,25 +67,25 @@
{#if ondelete}
<button
type="button"
class="absolute top-1 right-1 p-1 rounded-full opacity-0 group-hover:opacity-100 hover:bg-error/20 transition-all z-10"
class="absolute top-1.5 right-1.5 p-0.5 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-error/10 transition-all z-10"
onclick={handleDelete}
aria-label="Delete card"
>
<span
class="material-symbols-rounded text-light/40 hover:text-error"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
class="material-symbols-rounded text-light/30 hover:text-error"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>
delete
close
</span>
</button>
{/if}
<!-- Tags / Chips -->
{#if card.tags && card.tags.length > 0}
<div class="flex gap-[10px] items-start flex-wrap">
<div class="flex gap-1 items-start flex-wrap">
{#each card.tags as tag}
<span
class="rounded-[4px] px-1 py-[4px] font-body font-bold text-[14px] text-night leading-none overflow-clip"
class="rounded-[4px] px-1.5 py-0.5 font-body font-bold text-[11px] text-night leading-none"
style="background-color: {tag.color || '#00A3E0'}"
>
{tag.name}
@@ -95,55 +95,40 @@
{/if}
<!-- Title -->
<p class="font-body text-body text-white w-full leading-none p-1">
<p class="font-body text-body-sm text-white w-full leading-snug">
{card.title}
</p>
<!-- Bottom row: details + avatar -->
{#if hasFooter}
<div class="flex items-center justify-between w-full">
<div class="flex gap-1 items-center">
<!-- Due date -->
<div class="flex items-center justify-between w-full mt-0.5">
<div class="flex gap-2 items-center text-[11px] text-light/40">
{#if card.due_date}
<div class="flex items-center">
<span class="flex items-center gap-0.5">
<span
class="material-symbols-rounded text-light p-1"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
calendar_today
</span>
<span
class="font-body text-[12px] text-light leading-none"
>
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>calendar_today</span>
{formatDueDate(card.due_date)}
</span>
</div>
{/if}
<!-- Checklist -->
{#if (card.checklist_total ?? 0) > 0}
<div class="flex items-center">
<span class="flex items-center gap-0.5">
<span
class="material-symbols-rounded text-light p-1"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
check_box
</span>
<span
class="font-body text-[12px] text-light leading-none"
>
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>check_box</span>
{card.checklist_done ?? 0}/{card.checklist_total}
</span>
</div>
{/if}
</div>
<!-- Assignee avatar -->
{#if card.assignee_id}
<Avatar
name={card.assignee_name || "?"}
src={card.assignee_avatar}
size="sm"
size="xs"
/>
{/if}
</div>

View File

@@ -0,0 +1,103 @@
<script lang="ts">
import { Button, Input } from "$lib/components/ui";
import { createRoom } from "$lib/matrix";
import { toasts } from "$lib/stores/ui";
import { syncRoomsFromEvent, selectRoom } from "$lib/stores/matrix";
interface Props {
isOpen: boolean;
onClose: () => void;
}
let { isOpen, onClose }: Props = $props();
let roomName = $state("");
let isDirect = $state(false);
let isCreating = $state(false);
async function handleCreate() {
if (!roomName.trim()) {
toasts.error("Please enter a room name");
return;
}
isCreating = true;
try {
const result = await createRoom(roomName.trim(), isDirect);
toasts.success("Room created!");
// Add new room to list and select it
syncRoomsFromEvent("join", result.room_id);
selectRoom(result.room_id);
// Reset and close
roomName = "";
isDirect = false;
onClose();
} catch (e: any) {
console.error("Failed to create room:", e);
toasts.error(e.message || "Failed to create room");
} finally {
isCreating = false;
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
}
}
</script>
{#if isOpen}
<div
class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
onclick={onClose}
onkeydown={handleKeyDown}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div
class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4"
onclick={(e) => e.stopPropagation()}
role="document"
>
<h2 class="text-xl font-semibold text-light mb-4">Create New Room</h2>
<form
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
class="flex flex-col gap-4"
>
<Input
bind:value={roomName}
label="Room Name"
placeholder="My awesome room"
required
/>
<label class="flex items-center gap-3 text-light cursor-pointer">
<input
type="checkbox"
bind:checked={isDirect}
class="w-4 h-4 rounded border-light/30 bg-night text-primary focus:ring-primary"
/>
<span>Direct message (private 1:1 chat)</span>
</label>
<div class="flex gap-3 justify-end mt-2">
<Button variant="secondary" onclick={onClose} disabled={isCreating}>
Cancel
</Button>
<Button type="submit" loading={isCreating} disabled={isCreating}>
{isCreating ? "Creating..." : "Create Room"}
</Button>
</div>
</form>
</div>
</div>
{/if}

View File

@@ -0,0 +1,174 @@
<script lang="ts">
import { Button, Input } from "$lib/components/ui";
import { createSpace, getSpaces } from "$lib/matrix";
import { toasts } from "$lib/stores/ui";
import { syncRoomsFromEvent } from "$lib/stores/matrix";
interface Props {
isOpen: boolean;
onClose: () => void;
parentSpaceId?: string | null;
}
let { isOpen, onClose, parentSpaceId = null }: Props = $props();
let spaceName = $state("");
let spaceTopic = $state("");
let isPublic = $state(false);
let isCreating = $state(false);
// Get existing spaces for parent selection
const existingSpaces = $derived(getSpaces());
async function handleCreate() {
if (!spaceName.trim()) {
toasts.error("Please enter a space name");
return;
}
isCreating = true;
try {
const result = await createSpace(spaceName.trim(), {
topic: spaceTopic.trim() || undefined,
isPublic,
parentSpaceId: parentSpaceId || undefined,
});
toasts.success("Space created!");
// Sync the new space
syncRoomsFromEvent("join", result.room_id);
// Reset and close
spaceName = "";
spaceTopic = "";
isPublic = false;
onClose();
} catch (e: any) {
console.error("Failed to create space:", e);
toasts.error(e.message || "Failed to create space");
} finally {
isCreating = false;
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
}
}
</script>
{#if isOpen}
<div
class="fixed inset-0 bg-black/70 flex items-center justify-center z-50"
onclick={onClose}
onkeydown={handleKeyDown}
role="dialog"
aria-modal="true"
aria-labelledby="create-space-title"
tabindex="-1"
>
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="document"
>
<div class="flex items-center gap-3 mb-6">
<div class="w-12 h-12 rounded-xl bg-primary/20 flex items-center justify-center">
<svg class="w-6 h-6 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9,22 9,12 15,12 15,22" />
</svg>
</div>
<div>
<h2 id="create-space-title" class="text-xl font-semibold text-light">Create Space</h2>
<p class="text-sm text-light/60">Organize your rooms and team</p>
</div>
</div>
<form
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
class="flex flex-col gap-4"
>
<Input
bind:value={spaceName}
label="Space Name"
placeholder="My Organization"
required
/>
<div>
<label for="space-topic" class="block text-sm font-medium text-light/80 mb-1.5">
Description (optional)
</label>
<textarea
id="space-topic"
bind:value={spaceTopic}
placeholder="What is this space for?"
rows="2"
class="w-full px-4 py-2.5 bg-night text-light rounded-xl border border-light/20
placeholder:text-light/40 focus:outline-none focus:border-primary
focus:ring-1 focus:ring-primary resize-none"
></textarea>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-light/80">Visibility</span>
<label class="flex items-start gap-3 p-3 rounded-lg border border-light/10 hover:border-light/20 cursor-pointer transition-colors {!isPublic ? 'border-primary bg-primary/5' : ''}">
<input
type="radio"
name="visibility"
checked={!isPublic}
onchange={() => isPublic = false}
class="mt-0.5 w-4 h-4 text-primary focus:ring-primary"
/>
<div>
<span class="text-light font-medium">Private</span>
<p class="text-sm text-light/60">Only invited members can join</p>
</div>
</label>
<label class="flex items-start gap-3 p-3 rounded-lg border border-light/10 hover:border-light/20 cursor-pointer transition-colors {isPublic ? 'border-primary bg-primary/5' : ''}">
<input
type="radio"
name="visibility"
checked={isPublic}
onchange={() => isPublic = true}
class="mt-0.5 w-4 h-4 text-primary focus:ring-primary"
/>
<div>
<span class="text-light font-medium">Public</span>
<p class="text-sm text-light/60">Anyone can find and join this space</p>
</div>
</label>
</div>
{#if parentSpaceId}
<div class="px-3 py-2 bg-light/5 rounded-lg text-sm text-light/60">
<span class="text-light/40">Creating inside:</span>
<span class="text-light ml-1">
{existingSpaces.find(s => s.roomId === parentSpaceId)?.name || 'Parent Space'}
</span>
</div>
{/if}
<div class="flex gap-3 justify-end mt-2">
<Button variant="secondary" onclick={onClose} disabled={isCreating}>
Cancel
</Button>
<Button type="submit" loading={isCreating} disabled={isCreating}>
{isCreating ? "Creating..." : "Create Space"}
</Button>
</div>
</form>
</div>
</div>
{/if}

View File

@@ -0,0 +1,88 @@
<script lang="ts">
import Twemoji from '$lib/components/ui/Twemoji.svelte';
import { searchEmojis, type EmojiItem } from '$lib/utils/emojiData';
interface Props {
query: string;
onSelect: (emoji: string) => void;
onClose: () => void;
}
let {
query,
onSelect,
onClose,
}: Props = $props();
let selectedIndex = $state(0);
// Filter emojis based on query
const filteredEmojis = $derived(
searchEmojis(query).slice(0, 10)
);
// Reset selection when query changes
$effect(() => {
query;
selectedIndex = 0;
});
function handleKeyDown(e: KeyboardEvent) {
if (filteredEmojis.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
selectedIndex = (selectedIndex + 1) % filteredEmojis.length;
break;
case 'ArrowUp':
e.preventDefault();
selectedIndex = (selectedIndex - 1 + filteredEmojis.length) % filteredEmojis.length;
break;
case 'Enter':
case 'Tab':
e.preventDefault();
if (filteredEmojis[selectedIndex]) {
onSelect(filteredEmojis[selectedIndex].emoji);
}
break;
case 'Escape':
e.preventDefault();
onClose();
break;
}
}
// Expose keyboard handler for parent to call
export { handleKeyDown };
</script>
{#if filteredEmojis.length > 0}
<div
class="absolute z-50 bg-dark border border-light/10 rounded-lg shadow-xl overflow-hidden max-h-64 overflow-y-auto"
style="bottom: 100%; left: 0; margin-bottom: 8px; min-width: 280px;"
>
<div class="p-2 text-xs text-light/50 border-b border-light/10">
Emojis matching :{query}
</div>
{#each filteredEmojis as emoji, i}
<button
class="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors {i === selectedIndex ? 'bg-primary/20' : 'hover:bg-light/5'}"
onclick={() => onSelect(emoji.emoji)}
onmouseenter={() => selectedIndex = i}
>
<div class="w-6 h-6 flex items-center justify-center">
<Twemoji emoji={emoji.emoji} size={20} />
</div>
<div class="flex-1 min-w-0">
<p class="text-light">:{emoji.names[0]}:</p>
{#if emoji.names.length > 1}
<p class="text-xs text-light/40 truncate">
Also: {emoji.names.slice(1, 4).map(n => `:${n}:`).join(' ')}
</p>
{/if}
</div>
</button>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import type { Snippet } from "svelte";
import type { MatrixClient } from "matrix-js-sdk";
import { setMatrixContext } from "$lib/matrix/context";
import { setupSyncHandlers, removeSyncHandlers } from "$lib/matrix/sync";
interface Props {
client: MatrixClient;
children: Snippet;
}
let { client, children }: Props = $props();
// Store client reference for cleanup
let clientRef = client;
// Set the context during component initialization
setMatrixContext(clientRef);
// Setup sync handlers when provider mounts
onMount(() => {
setupSyncHandlers(clientRef);
});
// Cleanup when provider unmounts
onDestroy(() => {
removeSyncHandlers(clientRef);
});
</script>
{@render children()}

View File

@@ -0,0 +1,102 @@
<script lang="ts">
import { Avatar } from "$lib/components/ui";
import UserProfileModal from "./UserProfileModal.svelte";
import type { RoomMember } from "$lib/matrix/types";
import { userPresence } from "$lib/stores/matrix";
interface Props {
members: RoomMember[];
onMemberClick?: (member: RoomMember) => void;
onStartDM?: (roomId: string) => void;
}
let { members, onMemberClick, onStartDM }: Props = $props();
let selectedMember = $state<RoomMember | null>(null);
function handleMemberClick(member: RoomMember) {
if (onMemberClick) {
onMemberClick(member);
} else {
selectedMember = member;
}
}
// Sort: online first, then admins, then by name
const sortedMembers = $derived(
[...members].sort((a, b) => {
// Online status first
const aOnline = $userPresence.get(a.userId) === "online" ? 1 : 0;
const bOnline = $userPresence.get(b.userId) === "online" ? 1 : 0;
if (bOnline !== aOnline) return bOnline - aOnline;
// Power level descending
if (b.powerLevel !== a.powerLevel) {
return b.powerLevel - a.powerLevel;
}
// Then alphabetically
return a.name.localeCompare(b.name);
}),
);
function getRoleBadge(
powerLevel: number,
): { label: string; color: string } | null {
if (powerLevel >= 100) return { label: "Admin", color: "text-red-400" };
if (powerLevel >= 50) return { label: "Mod", color: "text-yellow-400" };
return null;
}
function getPresenceStatus(userId: string): "online" | "offline" | null {
const presence = $userPresence.get(userId);
if (presence === "online") return "online";
if (presence === "offline" || presence === "unavailable") return "offline";
return null;
}
</script>
<div class="flex flex-col h-full">
<header class="p-4 border-b border-light/10">
<h3 class="font-semibold text-light">Members ({members.length})</h3>
</header>
<div class="flex-1 overflow-y-auto">
{#each sortedMembers as member}
<button
class="w-full flex items-center gap-3 px-4 py-2 hover:bg-light/5 transition-colors text-left"
onclick={() => handleMemberClick(member)}
>
<Avatar
src={member.avatarUrl}
name={member.name}
size="sm"
status={getPresenceStatus(member.userId)}
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="text-light truncate">{member.name}</span>
{#if getRoleBadge(member.powerLevel)}
{@const badge = getRoleBadge(member.powerLevel)}
<span class="text-xs {badge?.color}">{badge?.label}</span>
{/if}
</div>
<p class="text-xs text-light/40 truncate">{member.userId}</p>
</div>
</button>
{/each}
{#if members.length === 0}
<div class="p-4 text-center text-light/40">
<p>No members</p>
</div>
{/if}
</div>
</div>
{#if selectedMember}
<UserProfileModal
member={selectedMember}
onClose={() => (selectedMember = null)}
{onStartDM}
/>
{/if}

View File

@@ -0,0 +1,91 @@
<script lang="ts">
import { Avatar } from '$lib/components/ui';
import type { RoomMember } from '$lib/matrix/types';
interface Props {
members: RoomMember[];
query: string;
onSelect: (member: RoomMember) => void;
onClose: () => void;
position?: { top: number; left: number };
}
let {
members,
query,
onSelect,
onClose,
position = { top: 0, left: 0 },
}: Props = $props();
let selectedIndex = $state(0);
// Filter members based on query
const filteredMembers = $derived(
members
.filter(m =>
m.name.toLowerCase().includes(query.toLowerCase()) ||
m.userId.toLowerCase().includes(query.toLowerCase())
)
.slice(0, 8)
);
// Reset selection when query changes
$effect(() => {
query;
selectedIndex = 0;
});
function handleKeyDown(e: KeyboardEvent) {
if (filteredMembers.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
selectedIndex = (selectedIndex + 1) % filteredMembers.length;
break;
case 'ArrowUp':
e.preventDefault();
selectedIndex = (selectedIndex - 1 + filteredMembers.length) % filteredMembers.length;
break;
case 'Enter':
case 'Tab':
e.preventDefault();
if (filteredMembers[selectedIndex]) {
onSelect(filteredMembers[selectedIndex]);
}
break;
case 'Escape':
e.preventDefault();
onClose();
break;
}
}
// Expose keyboard handler for parent to call
export { handleKeyDown };
</script>
{#if filteredMembers.length > 0}
<div
class="absolute z-50 bg-dark border border-light/10 rounded-lg shadow-xl overflow-hidden max-h-64 overflow-y-auto"
style="bottom: 100%; left: 0; margin-bottom: 8px; min-width: 250px;"
>
<div class="p-2 text-xs text-light/50 border-b border-light/10">
Members matching @{query}
</div>
{#each filteredMembers as member, i}
<button
class="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors {i === selectedIndex ? 'bg-primary/20' : 'hover:bg-light/5'}"
onclick={() => onSelect(member)}
onmouseenter={() => selectedIndex = i}
>
<Avatar src={member.avatarUrl} name={member.name} size="sm" />
<div class="flex-1 min-w-0">
<p class="text-light truncate">{member.name}</p>
<p class="text-xs text-light/40 truncate">{member.userId}</p>
</div>
</button>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,761 @@
<script lang="ts">
import { onDestroy, tick } from "svelte";
import {
sendMessage,
setTyping,
uploadFile,
sendFileMessage,
getRoomMembers,
} from "$lib/matrix";
import { toasts } from "$lib/stores/ui";
import {
auth,
addPendingMessage,
confirmPendingMessage,
removePendingMessage,
} from "$lib/stores/matrix";
import type { Message, RoomMember } from "$lib/matrix/types";
import MentionAutocomplete from "./MentionAutocomplete.svelte";
import EmojiAutocomplete from "./EmojiAutocomplete.svelte";
import EmojiPicker from "$lib/components/ui/EmojiPicker.svelte";
import { convertEmojiShortcodes } from "$lib/utils/emojiData";
import { getTwemojiUrl } from "$lib/utils/twemoji";
// Emoji detection regex
const emojiRegex =
/(\p{Emoji_Presentation}|\p{Emoji}\uFE0F|\p{Extended_Pictographic})/gu;
// Check if text contains emojis
function hasEmoji(text: string): boolean {
return emojiRegex.test(text);
}
// Render emojis as Twemoji images for preview
function renderEmojiPreview(text: string): string {
// Escape HTML first
const escaped = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\n/g, "<br>");
// Replace emojis with Twemoji images
return escaped.replace(emojiRegex, (emoji) => {
const url = getTwemojiUrl(emoji);
return `<img class="inline-block w-5 h-5 align-text-bottom" src="${url}" alt="${emoji}" draggable="false" />`;
});
}
interface Props {
roomId: string;
placeholder?: string;
disabled?: boolean;
replyTo?: Message | null;
onCancelReply?: () => void;
editingMessage?: Message | null;
onSaveEdit?: (content: string) => void;
onCancelEdit?: () => void;
}
let {
roomId,
placeholder = "Send a message...",
disabled = false,
replyTo = null,
onCancelReply,
editingMessage = null,
onSaveEdit,
onCancelEdit,
}: Props = $props();
let message = $state("");
let isSending = $state(false);
let isUploading = $state(false);
let inputRef: HTMLTextAreaElement;
let fileInputRef: HTMLInputElement;
let typingTimeout: ReturnType<typeof setTimeout> | null = null;
// Mention autocomplete state
let showMentions = $state(false);
let mentionQuery = $state("");
let mentionStartIndex = $state(0);
let autocompleteRef:
| { handleKeyDown: (e: KeyboardEvent) => void }
| undefined;
// Emoji picker state
let showEmojiPicker = $state(false);
let emojiButtonRef: HTMLButtonElement;
// Emoji autocomplete state
let showEmojiAutocomplete = $state(false);
let emojiQuery = $state("");
let emojiStartIndex = $state(0);
let emojiAutocompleteRef:
| { handleKeyDown: (e: KeyboardEvent) => void }
| undefined;
// Get room members for autocomplete
const roomMembers = $derived(getRoomMembers(roomId));
// Cleanup typing timeout on component destroy
onDestroy(() => {
if (typingTimeout) {
clearTimeout(typingTimeout);
setTyping(roomId, false).catch(() => {});
}
});
// Populate message when editing starts
$effect(() => {
if (editingMessage) {
message = editingMessage.content;
setTimeout(() => {
autoResize();
inputRef?.focus();
}, 0);
}
});
// Auto-resize textarea
function autoResize() {
if (!inputRef) return;
inputRef.style.height = "auto";
inputRef.style.height = Math.min(inputRef.scrollHeight, 200) + "px";
}
// Handle typing indicator
function handleTyping() {
// Clear existing timeout
if (typingTimeout) {
clearTimeout(typingTimeout);
}
// Send typing indicator
setTyping(roomId, true).catch(console.error);
// Stop typing after 3 seconds of no input
typingTimeout = setTimeout(() => {
setTyping(roomId, false).catch(console.error);
}, 3000);
}
// Handle input
function handleInput() {
autoResize();
if (message.trim()) {
handleTyping();
}
// Auto-convert completed emoji shortcodes like :heart: to actual emojis
autoConvertShortcodes();
// Check for @ mentions and : emoji shortcodes
checkForMention();
checkForEmoji();
}
// Auto-convert completed emoji shortcodes (e.g., :heart:) to actual emojis
function autoConvertShortcodes() {
if (!inputRef) return;
const cursorPos = inputRef.selectionStart;
// Look for completed shortcodes like :name:
const converted = convertEmojiShortcodes(message);
if (converted !== message) {
// Calculate cursor offset based on length difference
const lengthDiff = message.length - converted.length;
message = converted;
// Restore cursor position (adjusted for shorter string)
setTimeout(() => {
if (inputRef) {
const newPos = Math.max(0, cursorPos - lengthDiff);
inputRef.selectionStart = inputRef.selectionEnd = newPos;
}
}, 0);
}
}
// Check if user is typing an emoji shortcode
function checkForEmoji() {
if (!inputRef) return;
const cursorPos = inputRef.selectionStart;
const textBeforeCursor = message.slice(0, cursorPos);
// Find the last : before cursor
const lastColonIndex = textBeforeCursor.lastIndexOf(":");
if (lastColonIndex >= 0) {
const textAfterColon = textBeforeCursor.slice(lastColonIndex + 1);
// Check if there's a space before : (or it's at start) and no space after, and query is at least 2 chars
const charBeforeColon =
lastColonIndex > 0 ? message[lastColonIndex - 1] : " ";
if (
(charBeforeColon === " " ||
charBeforeColon === "\n" ||
lastColonIndex === 0) &&
!textAfterColon.includes(" ") &&
!textAfterColon.includes(":") &&
textAfterColon.length >= 2
) {
showEmojiAutocomplete = true;
emojiQuery = textAfterColon;
emojiStartIndex = lastColonIndex;
return;
}
}
showEmojiAutocomplete = false;
emojiQuery = "";
}
// Handle emoji selection from autocomplete
function handleEmojiSelect(emoji: string) {
// Replace :query with the emoji
const beforeEmoji = message.slice(0, emojiStartIndex);
const afterEmoji = message.slice(emojiStartIndex + emojiQuery.length + 1);
message = `${beforeEmoji}${emoji}${afterEmoji}`;
showEmojiAutocomplete = false;
emojiQuery = "";
// Focus back on textarea
inputRef?.focus();
}
// Check if user is typing a mention
function checkForMention() {
if (!inputRef) return;
const cursorPos = inputRef.selectionStart;
const textBeforeCursor = message.slice(0, cursorPos);
// Find the last @ before cursor that's not part of a completed mention
const lastAtIndex = textBeforeCursor.lastIndexOf("@");
if (lastAtIndex >= 0) {
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
// Check if there's a space before @ (or it's at start) and no space after
const charBeforeAt = lastAtIndex > 0 ? message[lastAtIndex - 1] : " ";
if (
(charBeforeAt === " " || charBeforeAt === "\n" || lastAtIndex === 0) &&
!textAfterAt.includes(" ")
) {
showMentions = true;
mentionQuery = textAfterAt;
mentionStartIndex = lastAtIndex;
return;
}
}
showMentions = false;
mentionQuery = "";
}
// Handle mention selection
function handleMentionSelect(member: RoomMember) {
// Replace @query with userId (userId already has @ prefix)
const beforeMention = message.slice(0, mentionStartIndex);
const afterMention = message.slice(
mentionStartIndex + mentionQuery.length + 1,
);
message = `${beforeMention}${member.userId} ${afterMention}`;
showMentions = false;
mentionQuery = "";
// Focus back on textarea
inputRef?.focus();
}
// Handle key press
function handleKeyDown(e: KeyboardEvent) {
// If mention autocomplete is open, let it handle navigation keys
if (
showMentions &&
["ArrowUp", "ArrowDown", "Tab", "Escape"].includes(e.key)
) {
autocompleteRef?.handleKeyDown(e);
return;
}
// Enter with mention autocomplete open selects the mention
if (showMentions && e.key === "Enter") {
e.preventDefault();
autocompleteRef?.handleKeyDown(e);
return;
}
// If emoji autocomplete is open, let it handle navigation keys
if (
showEmojiAutocomplete &&
["ArrowUp", "ArrowDown", "Tab", "Escape"].includes(e.key)
) {
emojiAutocompleteRef?.handleKeyDown(e);
return;
}
// Enter with emoji autocomplete open selects the emoji
if (showEmojiAutocomplete && e.key === "Enter") {
e.preventDefault();
emojiAutocompleteRef?.handleKeyDown(e);
return;
}
// Send on Enter (without Shift)
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
return;
}
// Auto-continue lists on Shift+Enter or regular Enter with list
if (e.key === "Enter" && e.shiftKey) {
const cursorPos = inputRef?.selectionStart || 0;
const textBefore = message.slice(0, cursorPos);
const currentLine = textBefore.split("\n").pop() || "";
// Check for numbered list (1. 2. etc)
const numberedMatch = currentLine.match(/^(\s*)(\d+)\.\s/);
if (numberedMatch) {
e.preventDefault();
const indent = numberedMatch[1];
const nextNum = parseInt(numberedMatch[2]) + 1;
const newText =
message.slice(0, cursorPos) +
`\n${indent}${nextNum}. ` +
message.slice(cursorPos);
message = newText;
setTimeout(() => {
if (inputRef) {
inputRef.selectionStart = inputRef.selectionEnd =
cursorPos + indent.length + String(nextNum).length + 4;
}
}, 0);
return;
}
// Check for bullet list (- or *)
const bulletMatch = currentLine.match(/^(\s*)([-*])\s/);
if (bulletMatch) {
e.preventDefault();
const indent = bulletMatch[1];
const bullet = bulletMatch[2];
const newText =
message.slice(0, cursorPos) +
`\n${indent}${bullet} ` +
message.slice(cursorPos);
message = newText;
setTimeout(() => {
if (inputRef) {
inputRef.selectionStart = inputRef.selectionEnd =
cursorPos + indent.length + 4;
}
}, 0);
return;
}
// Check for lettered sub-list (a. b. etc)
const letteredMatch = currentLine.match(/^(\s*)([a-z])\.\s/);
if (letteredMatch) {
e.preventDefault();
const indent = letteredMatch[1];
const nextLetter = String.fromCharCode(
letteredMatch[2].charCodeAt(0) + 1,
);
const newText =
message.slice(0, cursorPos) +
`\n${indent}${nextLetter}. ` +
message.slice(cursorPos);
message = newText;
setTimeout(() => {
if (inputRef) {
inputRef.selectionStart = inputRef.selectionEnd =
cursorPos + indent.length + 5;
}
}, 0);
return;
}
}
}
// Send message or save edit
async function handleSend() {
const trimmed = message.trim();
if (!trimmed || isSending || disabled) return;
// Convert emoji shortcodes like :heart: to actual emojis
const processedMessage = convertEmojiShortcodes(trimmed);
// Handle edit mode
if (editingMessage) {
if (processedMessage === editingMessage.content) {
// No changes, just cancel
onCancelEdit?.();
message = "";
return;
}
onSaveEdit?.(processedMessage);
message = "";
if (inputRef) {
inputRef.style.height = "auto";
}
return;
}
isSending = true;
// Clear typing indicator
if (typingTimeout) {
clearTimeout(typingTimeout);
typingTimeout = null;
}
setTyping(roomId, false).catch(console.error);
// Create a temporary event ID for the pending message
const tempEventId = `pending-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Add pending message immediately (optimistic update)
const pendingMessage: Message = {
eventId: tempEventId,
roomId,
sender: $auth.userId || "",
senderName: $auth.userId?.split(":")[0]?.replace("@", "") || "You",
senderAvatar: null,
content: processedMessage,
timestamp: Date.now(),
type: "text",
isEdited: false,
isRedacted: false,
isPending: true,
replyTo: replyTo?.eventId,
reactions: new Map(),
};
addPendingMessage(roomId, pendingMessage);
message = "";
// Clear reply
onCancelReply?.();
// Reset textarea height
if (inputRef) {
inputRef.style.height = "auto";
}
try {
const result = await sendMessage(
roomId,
processedMessage,
replyTo?.eventId,
);
// Confirm the pending message with the real event ID
if (result?.event_id) {
confirmPendingMessage(roomId, tempEventId, result.event_id);
} else {
// If no event ID returned, just mark as not pending
confirmPendingMessage(roomId, tempEventId, tempEventId);
}
} catch (e: any) {
console.error("Failed to send message:", e);
// Remove the pending message on failure
removePendingMessage(roomId, tempEventId);
toasts.error(e.message || "Failed to send message");
} finally {
isSending = false;
// Refocus after DOM settles from optimistic update
await tick();
inputRef?.focus();
}
}
// Handle file selection
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file || disabled) return;
// Reset input
input.value = "";
// Check file size (50MB limit)
const maxSize = 50 * 1024 * 1024;
if (file.size > maxSize) {
toasts.error("File too large. Maximum size is 50MB.");
return;
}
isUploading = true;
try {
toasts.info(`Uploading ${file.name}...`);
const contentUri = await uploadFile(file);
await sendFileMessage(roomId, file, contentUri);
toasts.success("File sent!");
} catch (e: any) {
console.error("Failed to upload file:", e);
toasts.error(e.message || "Failed to upload file");
} finally {
isUploading = false;
}
}
function openFilePicker() {
fileInputRef?.click();
}
</script>
<div class="border-t border-light/10">
<!-- Edit preview -->
{#if editingMessage}
<div class="px-4 pt-3 pb-0">
<div
class="flex items-center gap-2 px-3 py-2 bg-yellow-500/10 rounded-lg border-l-2 border-yellow-500"
>
<div class="flex-1 min-w-0">
<p class="text-xs text-yellow-400 font-medium">Editing message</p>
<p class="text-sm text-light/60 truncate">{editingMessage.content}</p>
</div>
<button
class="w-6 h-6 flex items-center justify-center text-light/40 hover:text-light rounded transition-colors"
onclick={() => {
onCancelEdit?.();
message = "";
}}
title="Cancel edit"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
{/if}
<!-- Reply preview -->
{#if replyTo && !editingMessage}
<div class="px-4 pt-3 pb-0">
<div
class="flex items-center gap-2 px-3 py-2 bg-light/5 rounded-lg border-l-2 border-primary"
>
<div class="flex-1 min-w-0">
<p class="text-xs text-primary font-medium">
Replying to {replyTo.senderName}
</p>
<p class="text-sm text-light/60 truncate">{replyTo.content}</p>
</div>
<button
class="w-6 h-6 flex items-center justify-center text-light/40 hover:text-light rounded transition-colors"
onclick={() => onCancelReply?.()}
title="Cancel reply"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
{/if}
<div class="p-4 flex items-end gap-3">
<!-- Hidden file input -->
<input
bind:this={fileInputRef}
type="file"
class="hidden"
onchange={handleFileSelect}
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.txt,.zip"
/>
<!-- Attachment button -->
<button
class="w-10 h-10 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded-full transition-colors shrink-0"
class:animate-pulse={isUploading}
title="Add attachment"
onclick={openFilePicker}
disabled={disabled || isUploading}
>
{#if isUploading}
<svg class="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
{:else}
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="16" />
<line x1="8" y1="12" x2="16" y2="12" />
</svg>
{/if}
</button>
<!-- Input area -->
<div class="flex-1 relative">
<!-- Mention autocomplete -->
{#if showMentions}
<MentionAutocomplete
bind:this={autocompleteRef}
members={roomMembers}
query={mentionQuery}
onSelect={handleMentionSelect}
onClose={() => (showMentions = false)}
/>
{/if}
<!-- Emoji autocomplete -->
{#if showEmojiAutocomplete}
<EmojiAutocomplete
bind:this={emojiAutocompleteRef}
query={emojiQuery}
onSelect={handleEmojiSelect}
onClose={() => (showEmojiAutocomplete = false)}
/>
{/if}
<!-- Input wrapper with emoji button inside -->
<div class="relative flex items-end">
<!-- Emoji preview overlay - shows rendered Twemoji -->
{#if message && hasEmoji(message)}
<div
class="absolute inset-0 pl-4 pr-12 py-3 pointer-events-none overflow-hidden rounded-2xl text-light whitespace-pre-wrap break-words"
style="min-height: 48px; max-height: 200px; line-height: 1.5;"
aria-hidden="true"
>
{@html renderEmojiPreview(message)}
</div>
{/if}
<textarea
bind:this={inputRef}
bind:value={message}
oninput={handleInput}
onkeydown={handleKeyDown}
{placeholder}
disabled={disabled || isSending}
rows="1"
class="w-full pl-4 pr-12 py-3 bg-dark rounded-2xl border border-light/20
placeholder:text-light/40 resize-none overflow-hidden
focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors {message && hasEmoji(message)
? 'text-transparent caret-light'
: 'text-light'}"
style="min-height: 48px; max-height: 200px;"
></textarea>
<!-- Emoji button inside input -->
<button
bind:this={emojiButtonRef}
type="button"
class="absolute right-3 bottom-3 w-6 h-6 flex items-center justify-center text-light/40 hover:text-light transition-colors"
onclick={() => (showEmojiPicker = !showEmojiPicker)}
title="Add emoji"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
<line x1="9" y1="9" x2="9.01" y2="9" />
<line x1="15" y1="9" x2="15.01" y2="9" />
</svg>
</button>
</div>
<!-- Emoji Picker -->
{#if showEmojiPicker}
<div class="absolute bottom-full right-0 mb-2">
<EmojiPicker
onSelect={(emoji) => {
message += emoji;
inputRef?.focus();
}}
onClose={() => (showEmojiPicker = false)}
position={{ x: 0, y: 0 }}
/>
</div>
{/if}
</div>
<!-- Send button -->
<button
class="w-10 h-10 flex items-center justify-center rounded-full transition-all shrink-0
{message.trim()
? 'bg-primary text-white hover:brightness-110'
: 'bg-light/10 text-light/30 cursor-not-allowed'}"
onclick={handleSend}
disabled={!message.trim() || isSending || disabled}
title="Send message"
>
{#if isSending}
<svg class="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
{:else}
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
{/if}
</button>
</div>
<!-- Character count (optional, show when > 1000) -->
{#if message.length > 1000}
<div
class="text-right text-xs mt-1 {message.length > 4000
? 'text-red-400'
: 'text-light/40'}"
>
{message.length} / 4000
</div>
{/if}
</div>

View File

@@ -0,0 +1,478 @@
<script lang="ts">
import { onMount, tick, untrack } from "svelte";
import { createVirtualizer, elementScroll } from "@tanstack/svelte-virtual";
import type { SvelteVirtualizer } from "@tanstack/svelte-virtual";
import { MessageContainer } from "$lib/components/message";
import type { Message as MessageType } from "$lib/matrix/types";
import { auth } from "$lib/stores/matrix";
interface Props {
messages: MessageType[];
onReact?: (messageId: string, emoji: string) => void;
onToggleReaction?: (
messageId: string,
emoji: string,
reactionEventId: string | null,
) => void;
onEdit?: (message: MessageType) => void;
onDelete?: (messageId: string) => void;
onReply?: (message: MessageType) => void;
onLoadMore?: () => void;
isLoading?: boolean;
enableVirtualization?: boolean;
}
let {
messages,
onReact,
onToggleReaction,
onEdit,
onDelete,
onReply,
onLoadMore,
isLoading = false,
enableVirtualization = false, // Disabled until we find a Svelte 5-compatible solution
}: Props = $props();
let containerRef: HTMLDivElement | undefined = $state();
let shouldAutoScroll = $state(true);
let previousMessageCount = $state(0);
// Filter out deleted/redacted messages (hide them like Discord)
const allVisibleMessages = $derived(messages.filter((m) => !m.isRedacted));
// Virtualizer state - managed via subscription
let virtualizer = $state<SvelteVirtualizer<HTMLDivElement, Element> | null>(
null,
);
let virtualizerCleanup: (() => void) | null = null;
// Estimate size based on message type
function estimateSize(index: number): number {
const msg = allVisibleMessages[index];
if (!msg) return 80;
if (msg.type === "image") return 300;
if (msg.type === "video") return 350;
if (msg.type === "file" || msg.type === "audio") return 100;
const lines = Math.ceil((msg.content?.length || 0) / 60);
return Math.max(60, Math.min(lines * 24 + 40, 400));
}
// Create/update virtualizer when container or messages change
$effect(() => {
if (
!containerRef ||
!enableVirtualization ||
allVisibleMessages.length === 0
) {
virtualizer = null;
return;
}
// Clean up previous subscription
if (virtualizerCleanup) {
virtualizerCleanup();
virtualizerCleanup = null;
}
// Create new virtualizer store
const store = createVirtualizer({
count: allVisibleMessages.length,
getScrollElement: () => containerRef!,
estimateSize,
overscan: 5,
getItemKey: (index) => allVisibleMessages[index]?.eventId ?? index,
scrollToFn: elementScroll,
});
// Subscribe to store updates
virtualizerCleanup = store.subscribe((v) => {
virtualizer = v;
});
// Cleanup on effect re-run or component destroy
return () => {
if (virtualizerCleanup) {
virtualizerCleanup();
virtualizerCleanup = null;
}
};
});
// Get virtual items for rendering (reactive to virtualizer changes)
const virtualItems = $derived(virtualizer?.getVirtualItems() ?? []);
const totalSize = $derived(virtualizer?.getTotalSize() ?? 0);
/**
* Svelte action for dynamic height measurement
* Re-measures when images/media finish loading
*/
function measureRow(node: HTMLElement, index: number) {
function measure() {
if (virtualizer) {
virtualizer.measureElement(node);
}
}
// Initial measurement
measure();
// Re-measure when images load
const images = node.querySelectorAll("img");
const imageHandlers: Array<() => void> = [];
images.forEach((img) => {
if (!img.complete) {
const handler = () => measure();
img.addEventListener("load", handler, { once: true });
img.addEventListener("error", handler, { once: true });
imageHandlers.push(() => {
img.removeEventListener("load", handler);
img.removeEventListener("error", handler);
});
}
});
// Re-measure when videos load metadata
const videos = node.querySelectorAll("video");
const videoHandlers: Array<() => void> = [];
videos.forEach((video) => {
if (video.readyState < 1) {
const handler = () => measure();
video.addEventListener("loadedmetadata", handler, { once: true });
videoHandlers.push(() =>
video.removeEventListener("loadedmetadata", handler),
);
}
});
return {
update(newIndex: number) {
// Re-measure on update
measure();
},
destroy() {
// Cleanup listeners
imageHandlers.forEach((cleanup) => cleanup());
videoHandlers.forEach((cleanup) => cleanup());
},
};
}
// Track if we're currently loading to prevent scroll jumps
let isLoadingMore = $state(false);
let scrollTopBeforeLoad = $state(0);
let scrollHeightBeforeLoad = $state(0);
// Check if we should auto-scroll and load more
function handleScroll() {
if (!containerRef) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef;
// Check if at bottom for auto-scroll
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
shouldAutoScroll = distanceToBottom < 100;
// Check if at top to load more messages (with debounce via isLoadingMore)
if (scrollTop < 100 && onLoadMore && !isLoading && !isLoadingMore) {
// Save scroll position before loading
isLoadingMore = true;
scrollTopBeforeLoad = scrollTop;
scrollHeightBeforeLoad = scrollHeight;
onLoadMore();
}
}
// Restore scroll position after loading older messages
$effect(() => {
if (!isLoading && isLoadingMore && containerRef) {
// Loading finished - restore scroll position
tick().then(() => {
if (containerRef) {
const newScrollHeight = containerRef.scrollHeight;
const addedHeight = newScrollHeight - scrollHeightBeforeLoad;
// Adjust scroll to maintain visual position
containerRef.scrollTop = scrollTopBeforeLoad + addedHeight;
}
isLoadingMore = false;
});
}
});
// Scroll to bottom
async function scrollToBottom(force = false) {
if (!containerRef) return;
if (force || shouldAutoScroll) {
await tick();
containerRef.scrollTop = containerRef.scrollHeight;
}
}
// Auto-scroll when new messages arrive (only if at bottom)
$effect(() => {
const count = allVisibleMessages.length;
if (count > previousMessageCount) {
if (shouldAutoScroll || previousMessageCount === 0) {
// User is at bottom or first load - scroll to new messages
scrollToBottom(true);
}
// If user is scrolled up, scroll anchoring handles it
}
previousMessageCount = count;
});
// Initial scroll to bottom
onMount(() => {
tick().then(() => {
scrollToBottom(true);
});
});
// Check if message should be grouped with previous
function shouldGroup(
current: MessageType,
previous: MessageType | null,
): boolean {
if (!previous) return false;
if (current.sender !== previous.sender) return false;
// Group if within 5 minutes
const timeDiff = current.timestamp - previous.timestamp;
return timeDiff < 5 * 60 * 1000;
}
// Check if we need a date separator
function needsDateSeparator(
current: MessageType,
previous: MessageType | null,
): boolean {
if (!previous) return true;
const currentDate = new Date(current.timestamp).toDateString();
const previousDate = new Date(previous.timestamp).toDateString();
return currentDate !== previousDate;
}
function formatDateSeparator(timestamp: number): string {
const date = new Date(timestamp);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === today.toDateString()) {
return "Today";
} else if (date.toDateString() === yesterday.toDateString()) {
return "Yesterday";
} else {
return date.toLocaleDateString([], {
weekday: "long",
month: "long",
day: "numeric",
year:
date.getFullYear() !== today.getFullYear() ? "numeric" : undefined,
});
}
}
// Get reply preview for a message
function getReplyPreview(replyToId: string): {
senderName: string;
content: string;
senderAvatar: string | null;
hasAttachment: boolean;
} | null {
const replyMessage = messages.find((m) => m.eventId === replyToId);
if (!replyMessage) return null;
const hasAttachment = ["image", "video", "audio", "file"].includes(
replyMessage.type,
);
let content = replyMessage.content;
if (hasAttachment && !content) {
content =
replyMessage.type === "image"
? "Click to see attachment"
: replyMessage.type === "video"
? "Video"
: replyMessage.type === "audio"
? "Audio"
: "File";
}
return {
senderName: replyMessage.senderName,
senderAvatar: replyMessage.senderAvatar,
content: content.slice(0, 50) + (content.length > 50 ? "..." : ""),
hasAttachment,
};
}
// Scroll to a specific message
function scrollToMessage(eventId: string) {
const element = document.getElementById(`message-${eventId}`);
if (element) {
element.scrollIntoView({ behavior: "smooth", block: "center" });
// Highlight briefly
element.classList.add("bg-primary/20");
setTimeout(() => element.classList.remove("bg-primary/20"), 2000);
}
}
</script>
<div class="relative flex-1 min-h-0">
<div
bind:this={containerRef}
class="h-full overflow-y-auto bg-night"
onscroll={handleScroll}
>
<!-- Load more button -->
{#if onLoadMore}
<div class="flex justify-center py-4">
<button
class="text-sm text-primary hover:underline disabled:opacity-50"
onclick={() => onLoadMore?.()}
disabled={isLoading}
>
{isLoading ? "Loading..." : "Load older messages"}
</button>
</div>
{/if}
<!-- Messages -->
{#if allVisibleMessages.length === 0}
<div
class="flex flex-col items-center justify-center h-full text-light/40"
>
<svg
class="w-16 h-16 mb-4 opacity-50"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
/>
</svg>
<p class="text-lg">No messages yet</p>
<p class="text-sm">Be the first to send a message!</p>
</div>
{:else if virtualizer && enableVirtualization}
<!-- TanStack Virtual: True DOM recycling -->
<div class="relative w-full" style="height: {totalSize}px;">
{#each virtualItems as virtualRow (virtualRow.key)}
{@const message = allVisibleMessages[virtualRow.index]}
{@const previousMessage =
virtualRow.index > 0
? allVisibleMessages[virtualRow.index - 1]
: null}
{@const isGrouped = shouldGroup(message, previousMessage)}
{@const showDateSeparator = needsDateSeparator(
message,
previousMessage,
)}
<div
class="absolute top-0 left-0 w-full"
style="transform: translateY({virtualRow.start}px);"
data-index={virtualRow.index}
use:measureRow={virtualRow.index}
>
<!-- Date separator -->
{#if showDateSeparator}
<div class="flex items-center gap-4 px-4 py-2 my-2">
<div class="flex-1 h-px bg-light/10"></div>
<span class="text-xs text-light/40 font-medium">
{formatDateSeparator(message.timestamp)}
</span>
<div class="flex-1 h-px bg-light/10"></div>
</div>
{/if}
<MessageContainer
{message}
{isGrouped}
isOwnMessage={message.sender === $auth.userId}
currentUserId={$auth.userId || ""}
onReact={(emoji: string) => onReact?.(message.eventId, emoji)}
onToggleReaction={(
emoji: string,
reactionEventId: string | null,
) => onToggleReaction?.(message.eventId, emoji, reactionEventId)}
onEdit={() => onEdit?.(message)}
onDelete={() => onDelete?.(message.eventId)}
onReply={() => onReply?.(message)}
onScrollToMessage={scrollToMessage}
replyPreview={message.replyTo
? getReplyPreview(message.replyTo)
: null}
/>
</div>
{/each}
</div>
{:else}
<!-- Fallback: Non-virtualized rendering for small lists -->
<div class="py-4">
{#each allVisibleMessages as message, i (message.eventId)}
{@const previousMessage = i > 0 ? allVisibleMessages[i - 1] : null}
{@const isGrouped = shouldGroup(message, previousMessage)}
{@const showDateSeparator = needsDateSeparator(
message,
previousMessage,
)}
<!-- Date separator -->
{#if showDateSeparator}
<div class="flex items-center gap-4 px-4 py-2 my-2">
<div class="flex-1 h-px bg-light/10"></div>
<span class="text-xs text-light/40 font-medium">
{formatDateSeparator(message.timestamp)}
</span>
<div class="flex-1 h-px bg-light/10"></div>
</div>
{/if}
<MessageContainer
{message}
{isGrouped}
isOwnMessage={message.sender === $auth.userId}
currentUserId={$auth.userId || ""}
onReact={(emoji: string) => onReact?.(message.eventId, emoji)}
onToggleReaction={(emoji: string, reactionEventId: string | null) =>
onToggleReaction?.(message.eventId, emoji, reactionEventId)}
onEdit={() => onEdit?.(message)}
onDelete={() => onDelete?.(message.eventId)}
onReply={() => onReply?.(message)}
onScrollToMessage={scrollToMessage}
replyPreview={message.replyTo
? getReplyPreview(message.replyTo)
: null}
/>
{/each}
</div>
{/if}
</div>
<!-- Scroll to bottom button -->
{#if !shouldAutoScroll && allVisibleMessages.length > 0}
<button
class="absolute bottom-4 right-4 p-3 bg-primary text-white rounded-full shadow-lg
hover:bg-primary/90 transition-all transform hover:scale-105
animate-in fade-in slide-in-from-bottom-2 duration-200"
onclick={() => scrollToBottom(true)}
title="Scroll to bottom"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6,9 12,15 18,9" />
</svg>
</button>
{/if}
</div>

View File

@@ -0,0 +1,261 @@
<script lang="ts">
import { Avatar } from "$lib/components/ui";
import RoomSettingsModal from "./RoomSettingsModal.svelte";
import {
getRoomNotificationLevel,
setRoomNotificationLevel,
} from "$lib/matrix";
import { toasts } from "$lib/stores/ui";
import type { RoomSummary, RoomMember } from "$lib/matrix/types";
interface Props {
room: RoomSummary;
members: RoomMember[];
onClose: () => void;
}
let { room, members, onClose }: Props = $props();
let showSettings = $state(false);
let isMuted = $state(getRoomNotificationLevel(room.roomId) === "mute");
let isTogglingMute = $state(false);
// Group members by role
const admins = $derived(members.filter((m) => m.powerLevel >= 100));
const moderators = $derived(
members.filter((m) => m.powerLevel >= 50 && m.powerLevel < 100),
);
const regularMembers = $derived(members.filter((m) => m.powerLevel < 50));
function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric",
});
}
async function toggleMute() {
isTogglingMute = true;
try {
const newLevel = isMuted ? "all" : "mute";
await setRoomNotificationLevel(room.roomId, newLevel);
isMuted = !isMuted;
toasts.success(isMuted ? "Room muted" : "Room unmuted");
} catch (e) {
toasts.error("Failed to change notification settings");
} finally {
isTogglingMute = false;
}
}
</script>
<div class="h-full flex flex-col bg-dark/50">
<!-- Header -->
<div class="p-4 border-b border-light/10 flex items-center justify-between">
<h2 class="font-semibold text-light">Room Info</h2>
<button
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded transition-colors"
onclick={onClose}
title="Close"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-4 space-y-6">
<!-- Room Avatar & Name -->
<div class="text-center">
<div class="flex justify-center mb-3">
<Avatar src={room.avatarUrl} name={room.name} size="xl" />
</div>
<h3 class="text-xl font-bold text-light">{room.name}</h3>
{#if room.topic}
<p class="text-sm text-light/60 mt-2">{room.topic}</p>
{/if}
<button
class="mt-3 px-4 py-1.5 text-sm text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
onclick={() => (showSettings = true)}
>
<span class="inline-flex items-center gap-1">
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
Edit Settings
</span>
</button>
<button
class="mt-2 px-4 py-1.5 text-sm rounded-lg transition-colors {isMuted
? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
: 'text-light/60 hover:text-light hover:bg-light/10'}"
onclick={toggleMute}
disabled={isTogglingMute}
>
<span class="inline-flex items-center gap-1">
{#if isMuted}
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 5L6 9H2v6h4l5 4V5z" />
<line x1="23" y1="9" x2="17" y2="15" />
<line x1="17" y1="9" x2="23" y2="15" />
</svg>
Muted
{:else}
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path
d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"
/>
</svg>
Notifications On
{/if}
</span>
</button>
</div>
<!-- Room Stats -->
<div class="grid grid-cols-2 gap-3">
<div class="bg-night rounded-lg p-3 text-center">
<p class="text-2xl font-bold text-light">{room.memberCount}</p>
<p class="text-xs text-light/50">Members</p>
</div>
<div class="bg-night rounded-lg p-3 text-center">
<p class="text-2xl font-bold text-light">
{room.isEncrypted ? "🔒" : "🔓"}
</p>
<p class="text-xs text-light/50">
{room.isEncrypted ? "Encrypted" : "Not Encrypted"}
</p>
</div>
</div>
<!-- Room Details -->
<div class="space-y-3">
<h4 class="text-sm font-semibold text-light/40 uppercase tracking-wider">
Details
</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-light/50">Room ID</span>
<span
class="text-light font-mono text-xs truncate max-w-[150px]"
title={room.roomId}
>
{room.roomId}
</span>
</div>
<div class="flex justify-between">
<span class="text-light/50">Type</span>
<span class="text-light"
>{room.isDirect ? "Direct Message" : "Room"}</span
>
</div>
{#if room.lastActivity}
<div class="flex justify-between">
<span class="text-light/50">Last Activity</span>
<span class="text-light">{formatDate(room.lastActivity)}</span>
</div>
{/if}
</div>
</div>
<!-- Members by Role -->
{#if admins.length > 0}
<div class="space-y-2">
<h4
class="text-sm font-semibold text-light/40 uppercase tracking-wider"
>
Admins ({admins.length})
</h4>
<ul class="space-y-1">
{#each admins as member}
<li
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-light/5"
>
<Avatar src={member.avatarUrl} name={member.name} size="xs" />
<span class="text-sm text-light truncate">{member.name}</span>
<span class="ml-auto text-xs text-yellow-400">👑</span>
</li>
{/each}
</ul>
</div>
{/if}
{#if moderators.length > 0}
<div class="space-y-2">
<h4
class="text-sm font-semibold text-light/40 uppercase tracking-wider"
>
Moderators ({moderators.length})
</h4>
<ul class="space-y-1">
{#each moderators as member}
<li
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-light/5"
>
<Avatar src={member.avatarUrl} name={member.name} size="xs" />
<span class="text-sm text-light truncate">{member.name}</span>
<span class="ml-auto text-xs text-blue-400">🛡️</span>
</li>
{/each}
</ul>
</div>
{/if}
<div class="space-y-2">
<h4 class="text-sm font-semibold text-light/40 uppercase tracking-wider">
Members ({regularMembers.length})
</h4>
<ul class="space-y-1">
{#each regularMembers.slice(0, 20) as member}
<li
class="flex items-center gap-2 px-2 py-1 rounded hover:bg-light/5"
>
<Avatar src={member.avatarUrl} name={member.name} size="xs" />
<span class="text-sm text-light truncate">{member.name}</span>
</li>
{/each}
{#if regularMembers.length > 20}
<li class="text-xs text-light/40 text-center py-2">
+{regularMembers.length - 20} more members
</li>
{/if}
</ul>
</div>
</div>
</div>
{#if showSettings}
<RoomSettingsModal {room} onClose={() => (showSettings = false)} />
{/if}

View File

@@ -0,0 +1,187 @@
<script lang="ts">
import { Avatar } from "$lib/components/ui";
import { setRoomName, setRoomTopic, setRoomAvatar } from "$lib/matrix";
import { toasts } from "$lib/stores/ui";
import { syncRoomsFromEvent } from "$lib/stores/matrix";
import type { RoomSummary } from "$lib/matrix/types";
interface Props {
room: RoomSummary;
onClose: () => void;
}
let { room, onClose }: Props = $props();
let name = $state(room.name);
let topic = $state(room.topic || "");
let isSaving = $state(false);
let avatarFile = $state<File | null>(null);
let avatarPreview = $state<string | null>(null);
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
function handleAvatarChange(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
avatarFile = file;
avatarPreview = URL.createObjectURL(file);
}
}
async function handleSave() {
isSaving = true;
try {
const promises: Promise<void>[] = [];
if (name !== room.name) {
promises.push(setRoomName(room.roomId, name));
}
if (topic !== (room.topic || "")) {
promises.push(setRoomTopic(room.roomId, topic));
}
if (avatarFile) {
promises.push(setRoomAvatar(room.roomId, avatarFile));
}
await Promise.all(promises);
syncRoomsFromEvent("update", room.roomId);
toasts.success("Room settings updated");
onClose();
} catch (e) {
console.error("Failed to update room settings:", e);
toasts.error("Failed to update room settings");
} finally {
isSaving = false;
}
}
const hasChanges = $derived(
name !== room.name || topic !== (room.topic || "") || avatarFile !== null,
);
</script>
<svelte:window onkeydown={handleKeydown} />
<div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
role="dialog"
aria-modal="true"
aria-labelledby="settings-title"
tabindex="-1"
>
<div
class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4"
role="document"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<div class="flex items-center justify-between mb-6">
<h2 id="settings-title" class="text-xl font-bold text-light">
Room Settings
</h2>
<button
class="w-8 h-8 flex items-center justify-center text-light/50 hover:text-light hover:bg-light/10 rounded transition-colors"
onclick={onClose}
title="Close"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<!-- Avatar -->
<div class="flex flex-col items-center mb-6">
<div class="relative group">
<Avatar src={avatarPreview || room.avatarUrl} {name} size="xl" />
<label
class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 rounded-full cursor-pointer transition-opacity"
>
<svg
class="w-6 h-6 text-white"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"
/>
<circle cx="12" cy="13" r="4" />
</svg>
<input
type="file"
accept="image/*"
class="hidden"
onchange={handleAvatarChange}
/>
</label>
</div>
<p class="text-xs text-light/40 mt-2">Click to change avatar</p>
</div>
<!-- Name -->
<div class="mb-4">
<label
for="room-name"
class="block text-sm font-medium text-light/60 mb-1"
>
Room Name
</label>
<input
id="room-name"
type="text"
bind:value={name}
class="w-full px-4 py-2 bg-night text-light rounded-lg border border-light/20 placeholder:text-light/40 focus:outline-none focus:border-primary"
placeholder="Enter room name"
/>
</div>
<!-- Topic -->
<div class="mb-6">
<label
for="room-topic"
class="block text-sm font-medium text-light/60 mb-1"
>
Topic
</label>
<textarea
id="room-topic"
bind:value={topic}
rows="3"
class="w-full px-4 py-2 bg-night text-light rounded-lg border border-light/20 placeholder:text-light/40 focus:outline-none focus:border-primary resize-none"
placeholder="What's this room about?"
></textarea>
</div>
<!-- Actions -->
<div class="flex gap-3">
<button
class="flex-1 px-4 py-2 text-light/60 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
onclick={onClose}
disabled={isSaving}
>
Cancel
</button>
<button
class="flex-1 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onclick={handleSave}
disabled={isSaving || !hasChanges}
>
{isSaving ? "Saving..." : "Save Changes"}
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,139 @@
<script lang="ts">
import { Avatar } from '$lib/components/ui';
import { searchUsers, createDirectMessage } from '$lib/matrix';
import { toasts } from '$lib/stores/ui';
interface Props {
onClose: () => void;
onDMCreated: (roomId: string) => void;
}
let { onClose, onDMCreated }: Props = $props();
let searchQuery = $state('');
let searchResults = $state<Array<{ userId: string; displayName: string; avatarUrl: string | null }>>([]);
let isSearching = $state(false);
let isCreating = $state(false);
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
function handleSearch() {
if (searchTimeout) clearTimeout(searchTimeout);
if (!searchQuery.trim()) {
searchResults = [];
return;
}
searchTimeout = setTimeout(async () => {
isSearching = true;
try {
searchResults = await searchUsers(searchQuery);
} catch (e) {
console.error('Search failed:', e);
} finally {
isSearching = false;
}
}, 300);
}
async function handleStartDM(userId: string) {
isCreating = true;
try {
const roomId = await createDirectMessage(userId);
toasts.success('Direct message started!');
onDMCreated(roomId);
onClose();
} catch (e: any) {
console.error('Failed to create DM:', e);
toasts.error(e.message || 'Failed to start direct message');
} finally {
isCreating = false;
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeyDown} />
<div
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
onclick={onClose}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div
class="bg-dark rounded-2xl p-6 w-full max-w-md mx-4"
onclick={(e) => e.stopPropagation()}
role="document"
>
<h2 class="text-xl font-bold text-light mb-4">Start a Direct Message</h2>
<div class="mb-4">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-light/40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
bind:value={searchQuery}
oninput={handleSearch}
placeholder="Search users by name or @user:server"
class="w-full pl-9 pr-4 py-3 bg-night text-light rounded-lg border border-light/20 placeholder:text-light/40 focus:outline-none focus:border-primary"
autofocus
/>
</div>
</div>
<div class="max-h-64 overflow-y-auto">
{#if isSearching}
<div class="text-center py-8 text-light/40">
<svg class="w-6 h-6 animate-spin mx-auto mb-2" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Searching...
</div>
{:else if searchResults.length > 0}
<ul class="space-y-1">
{#each searchResults as user}
<li>
<button
class="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-light/5 transition-colors text-left disabled:opacity-50"
onclick={() => handleStartDM(user.userId)}
disabled={isCreating}
>
<Avatar src={user.avatarUrl} name={user.displayName} size="sm" />
<div class="flex-1 min-w-0">
<p class="text-light font-medium truncate">{user.displayName}</p>
<p class="text-xs text-light/40 truncate">{user.userId}</p>
</div>
</button>
</li>
{/each}
</ul>
{:else if searchQuery}
<p class="text-center py-8 text-light/40">No users found</p>
{:else}
<p class="text-center py-8 text-light/40">
Search for a user to start a conversation
</p>
{/if}
</div>
<div class="flex justify-end gap-3 mt-6">
<button
class="px-4 py-2 text-light/60 hover:text-light transition-colors"
onclick={onClose}
>
Cancel
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import { syncState, syncError, clearState } from "$lib/stores/matrix";
import { clearAllCache } from "$lib/cache";
interface Props {
onHardRefresh?: () => void;
}
let { onHardRefresh }: Props = $props();
let isRefreshing = $state(false);
let dismissed = $state(false);
let consecutiveErrors = $state(0);
// Track consecutive sync errors
$effect(() => {
if ($syncState === "ERROR") {
consecutiveErrors++;
} else if ($syncState === "SYNCING" || $syncState === "PREPARED") {
consecutiveErrors = 0;
dismissed = false;
}
});
// Show banner after 3+ consecutive errors
const shouldShow = $derived(
!dismissed && consecutiveErrors >= 3 && $syncState === "ERROR",
);
async function handleHardRefresh() {
isRefreshing = true;
try {
// Clear local cache
await clearAllCache();
// Clear in-memory state
clearState();
// Trigger callback for full re-sync
onHardRefresh?.();
// Reload the page for clean state
window.location.reload();
} catch (error) {
console.error("[SyncRecovery] Hard refresh failed:", error);
isRefreshing = false;
}
}
function handleDismiss() {
dismissed = true;
}
</script>
{#if shouldShow}
<div
class="fixed top-4 left-1/2 -translate-x-1/2 z-50 max-w-md w-full mx-4
bg-red-900/90 backdrop-blur-sm border border-red-500/50
rounded-lg shadow-xl p-4 animate-in slide-in-from-top duration-300"
role="alert"
>
<div class="flex items-start gap-3">
<span
class="material-symbols-rounded text-red-400 flex-shrink-0 mt-0.5"
style="font-size: 20px;">warning</span
>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-red-100">Sync Connection Lost</h3>
<p class="text-sm text-red-200/80 mt-1">
{$syncError ||
"Unable to sync with the server. Your messages may be outdated."}
</p>
<div class="flex items-center gap-2 mt-3">
<button
class="flex items-center gap-2 px-3 py-1.5 bg-red-600 hover:bg-red-500
text-white text-sm font-medium rounded-md transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
onclick={handleHardRefresh}
disabled={isRefreshing}
>
<span
class="material-symbols-rounded {isRefreshing
? 'animate-spin'
: ''}"
style="font-size: 16px;">refresh</span
>
{isRefreshing ? "Refreshing..." : "Hard Refresh"}
</button>
<button
class="px-3 py-1.5 text-red-200 hover:text-white text-sm transition-colors"
onclick={handleDismiss}
>
Dismiss
</button>
</div>
</div>
<button
class="text-red-400 hover:text-red-200 transition-colors"
onclick={handleDismiss}
aria-label="Close"
>
<span class="material-symbols-rounded" style="font-size: 20px;"
>close</span
>
</button>
</div>
</div>
{/if}

View File

@@ -0,0 +1,27 @@
<script lang="ts">
interface Props {
userNames: string[];
}
let { userNames }: Props = $props();
function formatTypingText(names: string[]): string {
if (names.length === 0) return '';
if (names.length === 1) return `${names[0]} is typing`;
if (names.length === 2) return `${names[0]} and ${names[1]} are typing`;
if (names.length === 3) return `${names[0]}, ${names[1]}, and ${names[2]} are typing`;
return `${names[0]}, ${names[1]}, and ${names.length - 2} others are typing`;
}
</script>
{#if userNames.length > 0}
<div class="flex items-center gap-2 px-4 py-2 text-sm text-light/50">
<!-- Animated dots -->
<div class="flex gap-1">
<span class="w-2 h-2 bg-light/50 rounded-full animate-bounce" style="animation-delay: 0ms"></span>
<span class="w-2 h-2 bg-light/50 rounded-full animate-bounce" style="animation-delay: 150ms"></span>
<span class="w-2 h-2 bg-light/50 rounded-full animate-bounce" style="animation-delay: 300ms"></span>
</div>
<span>{formatTypingText(userNames)}</span>
</div>
{/if}

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { Avatar } from '$lib/components/ui';
import { createDirectMessage } from '$lib/matrix';
import { userPresence } from '$lib/stores/matrix';
import { toasts } from '$lib/stores/ui';
import type { RoomMember } from '$lib/matrix/types';
interface Props {
member: RoomMember;
onClose: () => void;
onStartDM?: (roomId: string) => void;
}
let { member, onClose, onStartDM }: Props = $props();
let isStartingDM = $state(false);
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
const presence = $derived($userPresence.get(member.userId) || 'offline');
const presenceLabel = $derived({
online: { text: 'Online', color: 'text-green-400' },
offline: { text: 'Offline', color: 'text-gray-400' },
unavailable: { text: 'Away', color: 'text-yellow-400' },
}[presence]);
async function handleStartDM() {
isStartingDM = true;
try {
const roomId = await createDirectMessage(member.userId);
toasts.success(`Started DM with ${member.name}`);
onStartDM?.(roomId);
onClose();
} catch (e) {
console.error('Failed to start DM:', e);
toasts.error('Failed to start direct message');
} finally {
isStartingDM = false;
}
}
function getRoleBadge(powerLevel: number): { label: string; color: string; icon: string } | null {
if (powerLevel >= 100) return { label: 'Admin', color: 'bg-red-500/20 text-red-400', icon: '👑' };
if (powerLevel >= 50) return { label: 'Moderator', color: 'bg-blue-500/20 text-blue-400', icon: '🛡️' };
return null;
}
const roleBadge = $derived(getRoleBadge(member.powerLevel));
</script>
<svelte:window onkeydown={handleKeydown} />
<div
class="fixed inset-0 bg-black/60 flex items-center justify-center z-50"
role="dialog"
aria-modal="true"
aria-labelledby="profile-title"
tabindex="-1"
onclick={onClose}
onkeydown={(e) => e.key === 'Enter' && onClose()}
>
<div
class="bg-dark rounded-2xl w-full max-w-sm mx-4 overflow-hidden"
role="document"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<!-- Header with gradient -->
<div class="h-24 bg-gradient-to-br from-primary/50 to-primary/20 relative">
<button
class="absolute top-3 right-3 w-8 h-8 flex items-center justify-center text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors"
onclick={onClose}
title="Close"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<!-- Avatar -->
<div class="flex justify-center -mt-12 relative z-10">
<div class="ring-4 ring-dark rounded-full">
<Avatar src={member.avatarUrl} name={member.name} size="xl" status={presence === 'online' ? 'online' : presence === 'unavailable' ? 'away' : 'offline'} />
</div>
</div>
<!-- Content -->
<div class="p-6 pt-3 text-center">
<h2 id="profile-title" class="text-xl font-bold text-light">{member.name}</h2>
<p class="text-sm text-light/50 mt-1">{member.userId}</p>
<!-- Status -->
<div class="flex items-center justify-center gap-2 mt-3">
<span class="w-2 h-2 rounded-full {presence === 'online' ? 'bg-green-400' : presence === 'unavailable' ? 'bg-yellow-400' : 'bg-gray-400'}"></span>
<span class="text-sm {presenceLabel.color}">{presenceLabel.text}</span>
</div>
<!-- Role badge -->
{#if roleBadge}
<div class="mt-3">
<span class="inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs {roleBadge.color}">
{roleBadge.icon} {roleBadge.label}
</span>
</div>
{/if}
<!-- Actions -->
<div class="mt-6 space-y-2">
<button
class="w-full px-4 py-2.5 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
onclick={handleStartDM}
disabled={isStartingDM}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
{isStartingDM ? 'Starting...' : 'Send Message'}
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,12 @@
export { default as MessageList } from './MessageList.svelte';
export { default as MessageInput } from './MessageInput.svelte';
export { default as TypingIndicator } from './TypingIndicator.svelte';
export { default as CreateRoomModal } from './CreateRoomModal.svelte';
export { default as CreateSpaceModal } from './CreateSpaceModal.svelte';
export { default as MemberList } from './MemberList.svelte';
export { default as StartDMModal } from './StartDMModal.svelte';
export { default as RoomInfoPanel } from './RoomInfoPanel.svelte';
export { default as RoomSettingsModal } from './RoomSettingsModal.svelte';
export { default as UserProfileModal } from './UserProfileModal.svelte';
export { default as MatrixProvider } from './MatrixProvider.svelte';
export { default as SyncRecoveryBanner } from './SyncRecoveryBanner.svelte';

View File

@@ -0,0 +1,202 @@
<script lang="ts">
import { Avatar } from "$lib/components/ui";
import { getReadReceiptsForEvent } from "$lib/matrix";
import type { Message } from "$lib/matrix/types";
import { formatTime } from "./utils";
import {
MessageContent,
MessageMedia,
MessageReactions,
MessageActions,
MessageReadReceipts,
} from "./parts";
interface ReplyPreview {
senderName: string;
content: string;
senderAvatar: string | null;
hasAttachment: boolean;
}
interface Props {
message: Message;
isGrouped?: boolean;
isOwnMessage?: boolean;
isPinned?: boolean;
currentUserId?: string;
replyPreview?: ReplyPreview | null;
onReact?: (emoji: string) => void;
onToggleReaction?: (emoji: string, reactionEventId: string | null) => void;
onEdit?: () => void;
onDelete?: () => void;
onReply?: () => void;
onPin?: () => void;
onScrollToMessage?: (eventId: string) => void;
}
let {
message,
isGrouped = false,
isOwnMessage = false,
isPinned = false,
currentUserId = "",
replyPreview = null,
onReact,
onToggleReaction,
onEdit,
onDelete,
onReply,
onPin,
onScrollToMessage,
}: Props = $props();
let showActions = $state(false);
// Get read receipts for own messages
const readReceipts = $derived(
isOwnMessage
? getReadReceiptsForEvent(message.roomId, message.eventId)
: [],
);
// Check if message has media
const hasMedia = $derived(
["image", "video", "audio", "file"].includes(message.type) && message.media,
);
</script>
<div
class="group relative px-4 py-0.5 hover:bg-light/5 transition-colors {message.isPending
? 'opacity-50'
: ''}"
onmouseenter={() => (showActions = true)}
onmouseleave={() => (showActions = false)}
role="article"
id="message-{message.eventId}"
>
<!-- Reply preview -->
{#if replyPreview && message.replyTo}
<button
class="flex items-center gap-1.5 ml-14 mt-1 text-xs hover:opacity-80 transition-opacity cursor-pointer"
onclick={() => onScrollToMessage?.(message.replyTo!)}
>
<div class="flex items-center gap-1.5">
<div class="flex shrink-0">
<Avatar
src={replyPreview.senderAvatar}
name={replyPreview.senderName}
size="xs"
/>
</div>
<span class="text-light/70 font-medium">{replyPreview.senderName}</span>
</div>
<span class="text-light/50 truncate max-w-xs">
{#if replyPreview.hasAttachment}
<svg
class="w-3 h-3 inline mr-0.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21,15 16,10 5,21" />
</svg>
{/if}
{replyPreview.content}
</span>
</button>
{/if}
{#if isGrouped}
<!-- Grouped message (same sender, close in time) -->
<div class="flex gap-4">
<div class="w-10 shrink-0 flex items-center justify-center">
<span
class="text-[10px] text-light/30 opacity-0 group-hover:opacity-100 transition-opacity"
>
{formatTime(message.timestamp)}
</span>
</div>
<div class="flex-1 min-w-0">
{#if hasMedia && message.media}
<MessageMedia
type={message.type as "image" | "video" | "audio" | "file"}
media={message.media}
altText={message.content}
/>
{:else}
<MessageContent
content={message.content}
isEdited={message.isEdited}
isRedacted={message.isRedacted}
/>
{/if}
</div>
</div>
{:else}
<!-- Full message with avatar - mt-4 creates gap between message groups -->
<div class="flex gap-4 mt-4 first:mt-0">
<div class="w-10 shrink-0">
<Avatar
src={message.senderAvatar}
name={message.senderName}
size="md"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-baseline gap-2 mb-0.5">
<span class="font-semibold text-light hover:underline cursor-pointer">
{message.senderName}
</span>
<span class="text-xs text-light/40">
{formatTime(message.timestamp)}
</span>
</div>
{#if hasMedia && message.media}
<MessageMedia
type={message.type as "image" | "video" | "audio" | "file"}
media={message.media}
altText={message.content}
/>
{:else}
<MessageContent
content={message.content}
isEdited={message.isEdited}
isRedacted={message.isRedacted}
/>
{/if}
</div>
</div>
{/if}
<!-- Reactions -->
<MessageReactions
reactions={message.reactions}
{currentUserId}
isRedacted={message.isRedacted}
{onReact}
{onToggleReaction}
/>
<!-- Read receipts (own messages only) -->
{#if isOwnMessage}
<MessageReadReceipts receipts={readReceipts} />
{/if}
<!-- Action buttons (show on hover) -->
{#if showActions && !message.isRedacted}
<MessageActions
{isOwnMessage}
{isPinned}
messageContent={message.content}
messageEventId={message.eventId}
{onReact}
{onReply}
{onEdit}
{onDelete}
{onPin}
/>
{/if}
</div>

View File

@@ -0,0 +1,10 @@
/**
* Message module barrel export
*
* This module provides modular message components following
* single responsibility principle.
*/
export { default as MessageContainer } from './MessageContainer.svelte';
export * from './parts';
export * from './utils';

View File

@@ -0,0 +1,199 @@
<script lang="ts">
import Twemoji from '$lib/components/ui/Twemoji.svelte';
import EmojiPicker from '$lib/components/ui/EmojiPicker.svelte';
interface Props {
isOwnMessage?: boolean;
isPinned?: boolean;
messageContent: string;
messageEventId: string;
onReact?: (emoji: string) => void;
onReply?: () => void;
onEdit?: () => void;
onDelete?: () => void;
onPin?: () => void;
}
let {
isOwnMessage = false,
isPinned = false,
messageContent,
messageEventId,
onReact,
onReply,
onEdit,
onDelete,
onPin,
}: Props = $props();
const quickReactions = ['👍', '❤️', '😂'];
let showEmojiPicker = $state(false);
let showContextMenu = $state(false);
let menuPosition = $state({ x: 0, y: 0 });
function openContextMenu(e: MouseEvent) {
const button = e.currentTarget as HTMLElement;
const rect = button.getBoundingClientRect();
const menuHeight = 200;
const viewportHeight = window.innerHeight;
let y = rect.bottom + 4;
if (y + menuHeight > viewportHeight) {
y = rect.top - menuHeight - 4;
}
menuPosition = { x: rect.right - 180, y: Math.max(8, y) };
showContextMenu = !showContextMenu;
showEmojiPicker = false;
}
function openEmojiPicker(e: MouseEvent) {
const button = e.currentTarget as HTMLElement;
const rect = button.getBoundingClientRect();
const menuHeight = 150;
const viewportHeight = window.innerHeight;
let y = rect.bottom + 4;
if (y + menuHeight > viewportHeight) {
y = rect.top - menuHeight - 4;
}
menuPosition = { x: rect.right - 220, y: Math.max(8, y) };
showEmojiPicker = !showEmojiPicker;
showContextMenu = false;
}
</script>
<div class="absolute right-4 -top-3 flex items-center gap-0.5 bg-dark border border-light/20 rounded-lg shadow-lg p-0.5">
<!-- Quick reactions -->
{#each quickReactions as emoji}
<button
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors"
onclick={() => onReact?.(emoji)}
title="React with {emoji}"
>
<Twemoji {emoji} size={18} />
</button>
{/each}
<!-- Emoji picker -->
<div class="relative">
<button
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/60 hover:text-light"
onclick={openEmojiPicker}
title="Add reaction"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
<line x1="9" y1="9" x2="9.01" y2="9" />
<line x1="15" y1="9" x2="15.01" y2="9" />
</svg>
</button>
{#if showEmojiPicker}
<EmojiPicker
position={menuPosition}
onSelect={(emoji) => onReact?.(emoji)}
onClose={() => (showEmojiPicker = false)}
/>
{/if}
</div>
<div class="w-px h-6 bg-light/20 mx-0.5"></div>
<!-- Reply button -->
<button
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/60 hover:text-light"
onclick={() => onReply?.()}
title="Reply"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9,17 4,12 9,7" />
<path d="M20,18 v-2 a4,4 0 0,0 -4,-4 H4" />
</svg>
</button>
<!-- Edit button (own messages only) -->
{#if isOwnMessage}
<button
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/60 hover:text-light"
onclick={() => onEdit?.()}
title="Edit"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
{/if}
<!-- Context menu -->
<div class="relative">
<button
class="w-8 h-8 flex items-center justify-center hover:bg-light/10 rounded transition-colors text-light/60 hover:text-light"
onclick={openContextMenu}
title="More options"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="1" />
<circle cx="19" cy="12" r="1" />
<circle cx="5" cy="12" r="1" />
</svg>
</button>
{#if showContextMenu}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed bg-dark border border-light/20 rounded-lg shadow-xl py-1 z-[100] min-w-[180px]"
style="left: {menuPosition.x}px; top: {menuPosition.y}px;"
onclick={(e) => e.stopPropagation()}
>
<button
class="w-full px-3 py-2 text-left text-sm text-light/80 hover:bg-light/10 flex items-center gap-2"
onclick={() => { onPin?.(); showContextMenu = false; }}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill={isPinned ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2">
<path d="M12 2L12 12M12 12L8 8M12 12L16 8" transform="rotate(45 12 12)" />
<line x1="5" y1="12" x2="19" y2="12" transform="rotate(45 12 12)" />
</svg>
{isPinned ? 'Unpin' : 'Pin'} message
</button>
<button
class="w-full px-3 py-2 text-left text-sm text-light/80 hover:bg-light/10 flex items-center gap-2"
onclick={() => { navigator.clipboard.writeText(messageContent); showContextMenu = false; }}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
Copy text
</button>
<button
class="w-full px-3 py-2 text-left text-sm text-light/80 hover:bg-light/10 flex items-center gap-2"
onclick={() => { navigator.clipboard.writeText(messageEventId); showContextMenu = false; }}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
Copy message ID
</button>
{#if isOwnMessage}
<div class="h-px bg-light/10 my-1"></div>
<button
class="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-red-500/10 flex items-center gap-2"
onclick={() => { onDelete?.(); showContextMenu = false; }}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3,6 5,6 21,6" />
<path d="M19,6 v14 a2,2 0 0,1 -2,2 H7 a2,2 0 0,1 -2,-2 V6 m3,0 V4 a2,2 0 0,1 2,-2 h4 a2,2 0 0,1 2,2 v2" />
</svg>
Delete message
</button>
{/if}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { renderMarkdown, isEmojiOnly } from '../utils';
interface Props {
content: string;
isEdited?: boolean;
isRedacted?: boolean;
}
let { content, isEdited = false, isRedacted = false }: Props = $props();
const emojiOnly = $derived(isEmojiOnly(content));
const renderedContent = $derived(renderMarkdown(content));
</script>
{#if isRedacted}
<p class="text-light break-words italic text-light/40">
This message was deleted
</p>
{:else}
<span class="text-light break-words {emojiOnly ? 'emoji-only' : 'prose'}">
{@html renderedContent}
</span>
{#if isEdited}
<span class="text-xs text-light/40 ml-1 whitespace-nowrap">(edited)</span>
{/if}
{/if}

View File

@@ -0,0 +1,103 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import ImagePreviewModal from '$lib/components/ui/ImagePreviewModal.svelte';
import { getAuthenticatedMediaUrl } from '$lib/matrix';
import { formatFileSize } from '../utils';
import type { MediaInfo } from '$lib/matrix/types';
interface Props {
type: 'image' | 'video' | 'audio' | 'file';
media: MediaInfo;
altText?: string;
}
let { type, media, altText = '' }: Props = $props();
let mediaUrl = $state<string | null>(null);
let isLoading = $state(true);
let showPreview = $state(false);
// Load authenticated media URL
$effect(() => {
if (media?.url) {
isLoading = true;
getAuthenticatedMediaUrl(media.url)
.then((url) => {
mediaUrl = url;
isLoading = false;
})
.catch(() => {
mediaUrl = media?.httpUrl || null;
isLoading = false;
});
}
});
// Cleanup blob URLs
onDestroy(() => {
if (mediaUrl?.startsWith('blob:')) {
URL.revokeObjectURL(mediaUrl);
}
});
</script>
{#if type === 'image'}
{#if isLoading}
<div class="w-48 h-32 bg-dark/50 rounded-lg animate-pulse flex items-center justify-center">
<span class="text-light/30 text-sm">Loading...</span>
</div>
{:else if mediaUrl}
<button
class="block max-w-md cursor-pointer"
onclick={() => (showPreview = true)}
>
<img
src={mediaUrl}
alt={altText}
class="rounded-lg max-h-80 object-contain bg-dark/50 hover:opacity-90 transition-opacity"
style="max-width: 100%;"
/>
</button>
{/if}
{:else if type === 'video' && mediaUrl}
<video
src={mediaUrl}
controls
class="rounded-lg max-w-md max-h-80 bg-dark/50"
>
<track kind="captions" />
</video>
{:else if type === 'audio' && mediaUrl}
<audio src={mediaUrl} controls class="w-full max-w-md">
<track kind="captions" />
</audio>
{:else if type === 'file'}
<a
href={mediaUrl || '#'}
download={media.filename}
class="flex items-center gap-3 px-4 py-3 bg-dark/50 rounded-lg hover:bg-dark/70 transition-colors max-w-sm"
>
<svg
class="w-8 h-8 text-primary shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14,2 14,8 20,8" />
</svg>
<div class="min-w-0">
<p class="text-light truncate">{media.filename || altText}</p>
<p class="text-xs text-light/50">{formatFileSize(media.size)}</p>
</div>
</a>
{/if}
{#if showPreview && mediaUrl}
<ImagePreviewModal
src={mediaUrl}
alt={altText}
onClose={() => (showPreview = false)}
/>
{/if}

View File

@@ -0,0 +1,117 @@
<script lang="ts">
import Twemoji from "$lib/components/ui/Twemoji.svelte";
import EmojiPicker from "$lib/components/ui/EmojiPicker.svelte";
interface Props {
reactions: Map<string, Map<string, string>>; // emoji -> userId -> reactionEventId
currentUserId: string;
isRedacted?: boolean;
onReact?: (emoji: string) => void;
onToggleReaction?: (emoji: string, reactionEventId: string | null) => void;
}
let {
reactions,
currentUserId,
isRedacted = false,
onReact,
onToggleReaction,
}: Props = $props();
let showPicker = $state(false);
// Track recently changed reactions for animation
let animatingReactions = $state<Set<string>>(new Set());
/**
* Get the reaction event ID if current user has reacted with this emoji
* O(1) access using nested Map structure
*/
function getUserReactionEventId(emoji: string): string | null {
const userMap = reactions.get(emoji);
if (!userMap) return null;
return userMap.get(currentUserId) ?? null;
}
/**
* Check if a reaction event ID indicates a pending (optimistic) reaction
*/
function isPendingReaction(eventId: string | null): boolean {
return eventId?.startsWith("~pending-") ?? false;
}
function handleClick(emoji: string) {
const reactionEventId = getUserReactionEventId(emoji);
// Trigger animation
animatingReactions.add(emoji);
setTimeout(() => {
animatingReactions = new Set(
[...animatingReactions].filter((e) => e !== emoji),
);
}, 300);
onToggleReaction?.(emoji, reactionEventId);
}
</script>
{#if reactions.size > 0}
<div class="flex flex-wrap items-center gap-1 mt-1 ml-14">
{#each [...reactions.entries()] as [emoji, userMap]}
{@const hasReacted = userMap.has(currentUserId)}
{@const reactionEventId = getUserReactionEventId(emoji)}
{@const isPending = isPendingReaction(reactionEventId)}
{@const isAnimating = animatingReactions.has(emoji)}
<button
class="reaction-badge flex items-center gap-1 px-2 py-0.5 rounded-full text-sm transition-all duration-200
{hasReacted
? 'bg-primary/20 text-primary border border-primary/30 hover:bg-primary/30'
: 'bg-light/10 hover:bg-light/20 text-light/60'}
{isPending ? 'opacity-70 animate-pulse' : ''}
{isAnimating ? 'scale-125' : 'scale-100'}"
onclick={() => handleClick(emoji)}
title={hasReacted ? "Remove reaction" : "Add reaction"}
>
<Twemoji {emoji} size={16} />
<span>{userMap.size}</span>
</button>
{/each}
<!-- Add reaction button -->
{#if !isRedacted}
<div class="relative">
<button
class="flex items-center justify-center w-7 h-7 rounded-full bg-light/5 hover:bg-light/10 text-light/40 hover:text-light/60 transition-colors"
onclick={() => (showPicker = !showPicker)}
title="Add reaction"
>
<svg
class="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
<line x1="9" y1="9" x2="9.01" y2="9" />
<line x1="15" y1="9" x2="15.01" y2="9" />
</svg>
</button>
{#if showPicker}
<div class="absolute bottom-full left-0 mb-2 z-50">
<EmojiPicker
onSelect={(emoji) => {
onReact?.(emoji);
showPicker = false;
}}
onClose={() => (showPicker = false)}
position={{ x: 0, y: 0 }}
/>
</div>
{/if}
</div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,52 @@
<script lang="ts">
interface ReadReceipt {
userId: string;
name: string;
avatarUrl: string | null;
timestamp?: number;
}
interface Props {
receipts: ReadReceipt[];
}
let { receipts }: Props = $props();
</script>
{#if receipts.length > 0}
<div
class="flex items-center gap-1 mt-1 ml-14"
title="Read by {receipts.map((r) => r.name).join(', ')}"
>
<span class="text-xs text-light/40 mr-1">Read by</span>
<div class="flex -space-x-1">
{#each receipts.slice(0, 5) as reader}
<div
class="w-4 h-4 rounded-full bg-dark border border-night overflow-hidden"
title={reader.name}
>
{#if reader.avatarUrl}
<img
src={reader.avatarUrl}
alt={reader.name}
class="w-full h-full object-cover"
/>
{:else}
<div
class="w-full h-full bg-primary/50 flex items-center justify-center text-[8px] text-white"
>
{reader.name[0]?.toUpperCase()}
</div>
{/if}
</div>
{/each}
{#if receipts.length > 5}
<div
class="w-4 h-4 rounded-full bg-light/20 border border-night flex items-center justify-center text-[8px] text-light"
>
+{receipts.length - 5}
</div>
{/if}
</div>
</div>
{/if}

View File

@@ -0,0 +1,9 @@
/**
* Message parts barrel export
*/
export { default as MessageContent } from './MessageContent.svelte';
export { default as MessageMedia } from './MessageMedia.svelte';
export { default as MessageReactions } from './MessageReactions.svelte';
export { default as MessageActions } from './MessageActions.svelte';
export { default as MessageReadReceipts } from './MessageReadReceipts.svelte';

View File

@@ -0,0 +1,13 @@
/**
* Message utilities barrel export
*/
export {
renderMarkdown,
renderEmojisAsTwemoji,
renderMentions,
isEmojiOnly,
formatTime,
formatFullTime,
formatFileSize,
} from './markdown';

View File

@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest';
import { renderMentions, isEmojiOnly, formatTime, formatFileSize } from './markdown';
describe('markdown utils', () => {
describe('renderMentions', () => {
it('renders @user:server.com as a mention button', () => {
const result = renderMentions('Hello @alice:matrix.org');
expect(result).toContain('class="mention-ping"');
expect(result).toContain('data-user-id="@alice:matrix.org"');
expect(result).toContain('@alice</button>');
});
it('renders @everyone as a special mention', () => {
const result = renderMentions('Hey @everyone');
expect(result).toContain('mention-everyone');
expect(result).toContain('@everyone');
});
it('renders @here as a special mention', () => {
const result = renderMentions('Attention @here');
expect(result).toContain('mention-everyone');
expect(result).toContain('@here');
});
it('renders @room as a special mention', () => {
const result = renderMentions('FYI @room');
expect(result).toContain('mention-everyone');
expect(result).toContain('@room');
});
it('leaves plain text unchanged', () => {
const result = renderMentions('Hello world');
expect(result).toBe('Hello world');
});
it('handles multiple mentions', () => {
const result = renderMentions('@alice:matrix.org and @bob:example.com');
expect(result).toContain('data-user-id="@alice:matrix.org"');
expect(result).toContain('data-user-id="@bob:example.com"');
});
});
describe('isEmojiOnly', () => {
it('returns true for single emoji', () => {
expect(isEmojiOnly('😀')).toBe(true);
});
it('returns true for multiple emojis', () => {
expect(isEmojiOnly('😀🎉🔥')).toBe(true);
});
it('returns true for emojis with spaces', () => {
expect(isEmojiOnly('😀 🎉')).toBe(true);
});
it('returns false for text with emoji', () => {
expect(isEmojiOnly('hello 😀')).toBe(false);
});
it('returns false for plain text', () => {
expect(isEmojiOnly('hello world')).toBe(false);
});
it('returns false for empty string', () => {
expect(isEmojiOnly('')).toBe(false);
});
it('returns false for whitespace only', () => {
expect(isEmojiOnly(' ')).toBe(false);
});
});
describe('formatTime', () => {
it('formats timestamp to HH:MM', () => {
// Create a date at 14:30
const date = new Date(2024, 0, 15, 14, 30, 0);
const result = formatTime(date.getTime());
expect(result).toMatch(/14:30/);
});
it('formats midnight correctly', () => {
const date = new Date(2024, 0, 15, 0, 0, 0);
const result = formatTime(date.getTime());
expect(result).toMatch(/00:00/);
});
});
describe('formatFileSize', () => {
it('returns empty string for undefined', () => {
expect(formatFileSize(undefined)).toBe('');
});
it('returns empty string for 0', () => {
expect(formatFileSize(0)).toBe('');
});
it('formats bytes', () => {
expect(formatFileSize(500)).toBe('500 B');
});
it('formats kilobytes', () => {
expect(formatFileSize(1024)).toBe('1.0 KB');
expect(formatFileSize(1536)).toBe('1.5 KB');
});
it('formats megabytes', () => {
expect(formatFileSize(1024 * 1024)).toBe('1.0 MB');
expect(formatFileSize(1.5 * 1024 * 1024)).toBe('1.5 MB');
});
});
});

View File

@@ -0,0 +1,168 @@
/**
* Markdown rendering utilities for messages
* Extracted from Message.svelte for reusability and testability
*/
import { marked } from 'marked';
import hljs from 'highlight.js';
import { getTwemojiUrl } from '$lib/utils/twemoji';
// Configure marked for safe rendering
marked.setOptions({
breaks: true,
gfm: true,
});
// Custom renderer for code blocks with syntax highlighting
const renderer = new marked.Renderer();
renderer.code = ({ text, lang }: { text: string; lang?: string }) => {
const language = lang && hljs.getLanguage(lang) ? lang : 'plaintext';
const highlighted = hljs.highlight(text, { language }).value;
return `<pre><code class="hljs language-${language}">${highlighted}</code></pre>`;
};
// LRU Cache for memoization (prevents memory leaks)
class LRUCache<K, V> {
private cache = new Map<K, V>();
constructor(private maxSize: number) { }
get(key: K): V | undefined {
const value = this.cache.get(key);
if (value !== undefined) {
// Move to end (most recently used)
this.cache.delete(key);
this.cache.set(key, value);
}
return value;
}
set(key: K, value: V): void {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.maxSize) {
// Delete oldest entry
const firstKey = this.cache.keys().next().value;
if (firstKey !== undefined) this.cache.delete(firstKey);
}
this.cache.set(key, value);
}
clear(): void {
this.cache.clear();
}
}
const markdownCache = new LRUCache<string, string>(200);
/**
* Convert emoji characters to Twemoji images
*/
export function renderEmojisAsTwemoji(text: string): string {
const emojiRegex = /(\p{Emoji_Presentation}|\p{Emoji}\uFE0F|\p{Extended_Pictographic})/gu;
return text.replace(emojiRegex, (emoji) => {
const url = getTwemojiUrl(emoji);
return `<img class="twemoji-inline" src="${url}" alt="${emoji}" draggable="false" />`;
});
}
/**
* Render @mentions as styled buttons
*/
export function renderMentions(text: string): string {
// Replace @userId mentions with styled spans
let result = text.replace(
/@([a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/g,
(match, userId) => {
const displayName = userId.split(':')[0];
return `<button class="mention-ping" data-user-id="@${userId}" onclick="window.dispatchEvent(new CustomEvent('show-user-profile', { detail: '@${userId}' }))">@${displayName}</button>`;
}
);
// Handle @everyone and @here mentions
result = result.replace(
/@(everyone|here|room)\b/gi,
'<span class="mention-ping mention-everyone">@$1</span>'
);
return result;
}
/**
* Render markdown content with memoization
*/
export function renderMarkdown(text: string): string {
// Check cache first
const cached = markdownCache.get(text);
if (cached) return cached;
// First handle mentions
let processed = renderMentions(text);
// Don't render markdown if it looks like plain text
const hasMarkdown = /[*_`#\[\]!|]/.test(text);
if (!hasMarkdown) {
processed = renderEmojisAsTwemoji(processed);
markdownCache.set(text, processed);
return processed;
}
try {
let result = marked.parse(processed, { async: false, renderer }) as string;
result = renderEmojisAsTwemoji(result);
markdownCache.set(text, result);
return result;
} catch {
const fallback = renderEmojisAsTwemoji(processed);
markdownCache.set(text, fallback);
return fallback;
}
}
/**
* Check if message is emoji-only
*/
export function isEmojiOnly(text: string): boolean {
const emojiRegex = /^[\s\p{Emoji_Presentation}\p{Emoji}\uFE0F\u200D]*$/u;
const hasEmoji = /\p{Emoji_Presentation}|\p{Emoji}\uFE0F/u.test(text);
return emojiRegex.test(text) && hasEmoji && text.trim().length > 0;
}
/**
* Format timestamp for display
*/
export function formatTime(timestamp: number): string {
return new Date(timestamp).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
/**
* Format timestamp for full display
*/
export function formatFullTime(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date();
const isToday = date.toDateString() === now.toDateString();
if (isToday) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else {
return date.toLocaleDateString([], {
month: 'short',
day: 'numeric',
});
}
}
/**
* Format file size for display
*/
export function formatFileSize(bytes?: number): string {
if (!bytes) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View File

@@ -124,15 +124,14 @@
}
</script>
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-6 max-w-2xl">
<!-- Organization Details -->
<h2 class="font-heading text-h2 text-white">Organization details</h2>
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5">
<h2 class="font-heading text-body text-white">Organization details</h2>
<div class="flex flex-col gap-8">
<div class="flex flex-col gap-4">
<!-- Avatar Upload -->
<div class="flex flex-col gap-2">
<span class="font-body text-body-sm text-light">Avatar</span>
<span class="font-body text-body-sm text-light/60">Avatar</span>
<div class="flex items-center gap-4">
<Avatar name={orgName || "?"} src={avatarUrl} size="lg" />
<div class="flex gap-2">
@@ -174,7 +173,7 @@
placeholder="my-org"
/>
<div>
<Button onclick={saveGeneralSettings} loading={isSaving}
<Button size="sm" onclick={saveGeneralSettings} loading={isSaving}
>Save Changes</Button
>
</div>
@@ -182,13 +181,13 @@
<!-- Danger Zone -->
{#if isOwner}
<div class="flex flex-col gap-4">
<h4 class="font-heading text-h4 text-white">Danger Zone</h4>
<p class="font-body text-body text-white">
<div class="bg-dark/30 border border-error/10 rounded-xl p-5 flex flex-col gap-3">
<h4 class="font-heading text-body-sm text-error">Danger Zone</h4>
<p class="font-body text-[11px] text-light/40">
Permanently delete this organization and all its data.
</p>
<div>
<Button variant="danger" onclick={onDelete}
<Button variant="danger" size="sm" onclick={onDelete}
>Delete Organization</Button
>
</div>
@@ -197,20 +196,16 @@
<!-- Leave Organization (non-owners) -->
{#if !isOwner}
<div class="flex flex-col gap-4">
<h4 class="font-heading text-h4 text-white">
Leave Organization
</h4>
<p class="font-body text-body text-white">
Leave this organization. You will need to be re-invited to
rejoin.
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-3">
<h4 class="font-heading text-body-sm text-white">Leave Organization</h4>
<p class="font-body text-[11px] text-light/40">
Leave this organization. You will need to be re-invited to rejoin.
</p>
<div>
<Button variant="secondary" onclick={onLeave}
<Button variant="secondary" size="sm" onclick={onLeave}
>Leave {org.name}</Button
>
</div>
</div>
{/if}
</div>
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Button, Modal, Card, Input } from "$lib/components/ui";
import { Button, Modal, Input } from "$lib/components/ui";
import { toasts } from "$lib/stores/toast.svelte";
import {
extractCalendarId,
@@ -108,184 +108,88 @@
}
</script>
<div class="space-y-6 max-w-2xl">
<Card>
<div class="p-6">
<div class="space-y-3 max-w-2xl">
<!-- Google Calendar -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5">
<div class="flex items-start gap-4">
<div
class="w-12 h-12 bg-white rounded-lg flex items-center justify-center"
>
<svg class="w-8 h-8" viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
<div class="w-10 h-10 bg-white rounded-xl flex items-center justify-center shrink-0">
<svg class="w-6 h-6" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-light">
Google Calendar
</h3>
<p class="text-sm text-light/50 mt-1">
Sync events between your organization and Google
Calendar.
<div class="flex-1 min-w-0">
<h3 class="font-heading text-body-sm text-white">Google Calendar</h3>
<p class="text-[11px] text-light/40 mt-0.5">
Sync events between your organization and Google Calendar.
</p>
{#if orgCalendar}
<div
class="mt-4 p-3 bg-green-500/10 border border-green-500/20 rounded-lg"
>
<div
class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3 bg-green-500/10 rounded-lg"
>
<div class="mt-3 p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<p
class="text-sm font-medium text-green-400"
>
Connected
</p>
<p class="text-light font-medium">
{orgCalendar.calendar_name ||
"Google Calendar"}
</p>
<p
class="text-xs text-light/50 truncate"
title={orgCalendar.calendar_id}
>
{orgCalendar.calendar_id}
</p>
<p class="text-xs text-light/40 mt-1">
Events sync both ways — create here or
in Google Calendar.
</p>
<p class="text-[11px] font-medium text-green-400">Connected</p>
<p class="text-body-sm text-white">{orgCalendar.calendar_name || "Google Calendar"}</p>
<p class="text-[10px] text-light/40 truncate" title={orgCalendar.calendar_id}>{orgCalendar.calendar_id}</p>
<p class="text-[10px] text-light/30 mt-1">Events sync both ways.</p>
<a
href="https://calendar.google.com/calendar/u/0/r?cid={encodeURIComponent(
orgCalendar.calendar_id,
)}"
href="https://calendar.google.com/calendar/u/0/r?cid={encodeURIComponent(orgCalendar.calendar_id)}"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-xs text-blue-400 hover:text-blue-300 mt-2"
class="inline-flex items-center gap-1 text-[11px] text-blue-400 hover:text-blue-300 mt-1.5"
>
<svg
class="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"
/>
<polyline points="15 3 21 3 21 9" />
<line
x1="10"
y1="14"
x2="21"
y2="3"
/>
</svg>
<span class="material-symbols-rounded" style="font-size: 14px;">open_in_new</span>
Open in Google Calendar
</a>
</div>
<Button
variant="danger"
size="sm"
onclick={disconnectOrgCalendar}
>Disconnect</Button
>
<Button variant="danger" size="sm" onclick={disconnectOrgCalendar}>Disconnect</Button>
</div>
</div>
{:else if !serviceAccountEmail}
<div
class="mt-4 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"
>
<p class="text-sm text-yellow-400 font-medium">
Setup required
</p>
<p class="text-xs text-light/50 mt-1">
A server administrator needs to configure the <code
class="bg-light/10 px-1 rounded"
>GOOGLE_SERVICE_ACCOUNT_KEY</code
> environment variable before calendars can be connected.
<div class="mt-3 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
<p class="text-[11px] text-yellow-400 font-medium">Setup required</p>
<p class="text-[10px] text-light/40 mt-1">
A server administrator needs to configure the <code class="bg-light/10 px-1 rounded">GOOGLE_SERVICE_ACCOUNT_KEY</code> environment variable.
</p>
</div>
{:else}
<div class="mt-4">
<Button onclick={() => (showConnectModal = true)}
>Connect Google Calendar</Button
>
<div class="mt-3">
<Button size="sm" onclick={() => (showConnectModal = true)}>Connect Google Calendar</Button>
</div>
{/if}
</div>
</div>
</div>
</Card>
<Card>
<div class="p-6 opacity-50">
<!-- Discord (coming soon) -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 opacity-40">
<div class="flex items-start gap-4">
<div
class="w-12 h-12 bg-[#7289da] rounded-lg flex items-center justify-center"
>
<svg
class="w-7 h-7 text-white"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z"
/>
</svg>
<div class="w-10 h-10 bg-[#5865F2] rounded-xl flex items-center justify-center shrink-0">
<span class="material-symbols-rounded text-white" style="font-size: 22px;">forum</span>
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-light">Discord</h3>
<p class="text-sm text-light/50 mt-1">
Get notifications in your Discord server.
</p>
<p class="text-xs text-light/40 mt-2">Coming soon</p>
<h3 class="font-heading text-body-sm text-white">Discord</h3>
<p class="text-[11px] text-light/40 mt-0.5">Get notifications in your Discord server.</p>
<p class="text-[10px] text-light/30 mt-1">Coming soon</p>
</div>
</div>
</div>
</Card>
<Card>
<div class="p-6 opacity-50">
<!-- Slack (coming soon) -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 opacity-40">
<div class="flex items-start gap-4">
<div
class="w-12 h-12 bg-[#4A154B] rounded-lg flex items-center justify-center"
>
<svg
class="w-7 h-7 text-white"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52a2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521a2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521a2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523a2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"
/>
</svg>
<div class="w-10 h-10 bg-[#4A154B] rounded-xl flex items-center justify-center shrink-0">
<span class="material-symbols-rounded text-white" style="font-size: 22px;">tag</span>
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-light">Slack</h3>
<p class="text-sm text-light/50 mt-1">
Get notifications in your Slack workspace.
</p>
<p class="text-xs text-light/40 mt-2">Coming soon</p>
<h3 class="font-heading text-body-sm text-white">Slack</h3>
<p class="text-[11px] text-light/40 mt-0.5">Get notifications in your Slack workspace.</p>
<p class="text-[10px] text-light/30 mt-1">Coming soon</p>
</div>
</div>
</div>
</Card>
</div>
<!-- Connect Calendar Modal -->

View File

@@ -2,7 +2,6 @@
import {
Button,
Modal,
Card,
Input,
Select,
Avatar,
@@ -169,41 +168,24 @@
}
</script>
<div class="space-y-6">
<div class="space-y-4 max-w-2xl">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-light">
<div>
<h2 class="font-heading text-body text-white">
{m.settings_members_title({
count: String(members.length),
})}
</h2>
<Button onclick={() => (showInviteModal = true)}>
<svg
class="w-4 h-4 mr-2"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle
cx="9"
cy="7"
r="4"
/><line x1="19" y1="8" x2="19" y2="14" /><line
x1="22"
y1="11"
x2="16"
y2="11"
/>
</svg>
</div>
<Button size="sm" icon="person_add" onclick={() => (showInviteModal = true)}>
{m.settings_members_invite()}
</Button>
</div>
<!-- Pending Invites -->
{#if invites.length > 0}
<Card>
<div class="p-4">
<h3 class="text-sm font-medium text-light/70 mb-3">
<div class="bg-dark/30 border border-light/5 rounded-xl p-4">
<h3 class="text-body-sm font-heading text-light/60 mb-3">
{m.settings_members_pending()}
</h3>
<div class="space-y-2">
@@ -212,70 +194,71 @@
class="flex items-center justify-between py-2 px-3 bg-light/5 rounded-lg"
>
<div>
<p class="text-light">{invite.email}</p>
<p class="text-xs text-light/40">
<p class="text-body-sm text-white">{invite.email}</p>
<p class="text-[11px] text-light/40">
Invited as {invite.role} • Expires {new Date(
invite.expires_at,
).toLocaleDateString()}
</p>
</div>
<div class="flex items-center gap-2">
<Button
variant="tertiary"
size="sm"
<div class="flex items-center gap-1.5">
<button
type="button"
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
onclick={() =>
navigator.clipboard.writeText(
`${window.location.origin}/invite/${invite.token}`,
)}
>{m.settings_members_copy_link()}</Button
title={m.settings_members_copy_link()}
>
<Button
variant="danger"
size="sm"
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">content_copy</span>
</button>
<button
type="button"
class="p-1.5 text-light/40 hover:text-error hover:bg-error/10 rounded-lg transition-colors"
onclick={() => cancelInvite(invite.id)}
>Cancel</Button
title="Cancel invite"
>
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">close</span>
</button>
</div>
</div>
{/each}
</div>
</div>
</Card>
{/if}
<!-- Members List -->
<Card>
<div class="divide-y divide-light/10">
<div class="bg-dark/30 border border-light/5 rounded-xl overflow-hidden">
<div class="divide-y divide-light/5">
{#each members as member}
{@const rawProfile = member.profiles}
{@const profile = Array.isArray(rawProfile)
? rawProfile[0]
: rawProfile}
<div
class="flex items-center justify-between p-4 hover:bg-light/5 transition-colors"
class="flex items-center justify-between px-4 py-3 hover:bg-light/5 transition-colors"
>
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
>
{(profile?.full_name ||
profile?.email ||
"?")[0].toUpperCase()}
</div>
<Avatar
name={profile?.full_name || profile?.email || "?"}
src={profile?.avatar_url}
size="sm"
/>
<div>
<p class="text-light font-medium">
<p class="text-body-sm text-white">
{profile?.full_name ||
profile?.email ||
"Unknown User"}
</p>
<p class="text-sm text-light/50">
<p class="text-[11px] text-light/40">
{profile?.email || "No email"}
</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<span
class="px-2 py-1 text-xs rounded-full capitalize"
class="px-2 py-0.5 text-[10px] rounded-md capitalize font-body"
style="background-color: {roles.find(
(r) => r.name.toLowerCase() === member.role,
)?.color ?? '#6366f1'}20; color: {roles.find(
@@ -283,18 +266,20 @@
)?.color ?? '#6366f1'}">{member.role}</span
>
{#if member.user_id !== userId && member.role !== "owner"}
<Button
variant="tertiary"
size="sm"
<button
type="button"
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
onclick={() => openMemberModal(member)}
>Edit</Button
title="Edit"
>
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">edit</span>
</button>
{/if}
</div>
</div>
{/each}
</div>
</Card>
</div>
</div>
<!-- Invite Member Modal -->

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { Button, Modal, Card, Input } from "$lib/components/ui";
import { Button, Modal, Input } from "$lib/components/ui";
import { toasts } from "$lib/stores/toast.svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
@@ -188,86 +188,72 @@
}
</script>
<div class="space-y-6">
<div class="space-y-4 max-w-2xl">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-light">Roles</h2>
<p class="text-sm text-light/50">
<h2 class="font-heading text-body text-white">Roles</h2>
<p class="text-body-sm text-light/40 mt-0.5">
Create custom roles with specific permissions.
</p>
</div>
<Button onclick={() => openRoleModal()} icon="add">
<Button size="sm" onclick={() => openRoleModal()} icon="add">
Create Role
</Button>
</div>
<div class="grid gap-4">
<div class="flex flex-col gap-2">
{#each roles as role}
<Card>
<div class="p-4">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3">
<div class="bg-dark/30 border border-light/5 rounded-xl px-4 py-3 hover:border-light/10 transition-colors">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div
class="w-3 h-3 rounded-full"
class="w-2.5 h-2.5 rounded-full shrink-0"
style="background-color: {role.color}"
></div>
<span class="font-medium text-light"
>{role.name}</span
>
<span class="text-body-sm font-medium text-white">{role.name}</span>
{#if role.is_system}
<span
class="text-xs text-light/40 bg-light/10 px-2 py-0.5 rounded"
>System</span
>
<span class="text-[10px] text-light/30 bg-light/5 px-1.5 py-0.5 rounded-md">System</span>
{/if}
{#if role.is_default}
<span
class="text-xs text-primary bg-primary/10 px-2 py-0.5 rounded"
>Default</span
>
<span class="text-[10px] text-primary bg-primary/10 px-1.5 py-0.5 rounded-md">Default</span>
{/if}
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1.5">
{#if !role.is_system || role.name !== "Owner"}
<Button
variant="tertiary"
size="sm"
<button
type="button"
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
onclick={() => openRoleModal(role)}
>Edit</Button
title="Edit"
>
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">edit</span>
</button>
{/if}
{#if !role.is_system}
<Button
variant="danger"
size="sm"
<button
type="button"
class="p-1.5 text-light/40 hover:text-error hover:bg-error/10 rounded-lg transition-colors"
onclick={() => deleteRole(role)}
>Delete</Button
title="Delete"
>
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">delete</span>
</button>
{/if}
</div>
</div>
<div class="flex flex-wrap gap-1">
{#if role.permissions.includes("*")}
<span
class="text-xs bg-light/10 text-light/70 px-2 py-1 rounded"
>All Permissions</span
>
<span class="text-[10px] bg-light/5 text-light/50 px-1.5 py-0.5 rounded-md">All Permissions</span>
{:else}
{#each role.permissions.slice(0, 6) as perm}
<span
class="text-xs bg-light/10 text-light/50 px-2 py-1 rounded"
>{perm}</span
>
<span class="text-[10px] bg-light/5 text-light/40 px-1.5 py-0.5 rounded-md">{perm}</span>
{/each}
{#if role.permissions.length > 6}
<span class="text-xs text-light/40"
>+{role.permissions.length - 6} more</span
>
<span class="text-[10px] text-light/30">+{role.permissions.length - 6} more</span>
{/if}
{/if}
</div>
</div>
</Card>
{/each}
</div>
</div>

View File

@@ -0,0 +1,132 @@
<script lang="ts">
import * as m from "$lib/paraglide/messages";
interface ActivityEntry {
id: string;
action: string;
entity_type: string;
entity_id: string | null;
entity_name: string | null;
created_at: string | null;
profiles: {
full_name: string | null;
email: string | null;
} | null;
}
interface Props {
entries: ActivityEntry[];
emptyLabel?: string;
}
let { entries, emptyLabel }: Props = $props();
function getEntityTypeLabel(entityType: string): string {
const map: Record<string, () => string> = {
document: m.entity_document,
folder: m.entity_folder,
kanban_board: m.entity_kanban_board,
kanban_card: m.entity_kanban_card,
kanban_column: m.entity_kanban_column,
member: m.entity_member,
role: m.entity_role,
invite: m.entity_invite,
event: m.entity_event,
};
return (map[entityType] ?? (() => entityType))();
}
function getActivityIcon(action: string): string {
const map: Record<string, string> = {
create: "add_circle",
update: "edit",
delete: "delete",
move: "drive_file_move",
rename: "edit_note",
};
return map[action] ?? "info";
}
function getActivityColor(action: string): string {
const map: Record<string, string> = {
create: "text-emerald-400",
update: "text-blue-400",
delete: "text-red-400",
move: "text-amber-400",
rename: "text-purple-400",
};
return map[action] ?? "text-light/50";
}
function formatTimeAgo(dateStr: string | null): string {
if (!dateStr) return "";
const now = Date.now();
const then = new Date(dateStr).getTime();
const diffMs = now - then;
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return m.activity_just_now();
if (diffMin < 60)
return m.activity_minutes_ago({ count: String(diffMin) });
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return m.activity_hours_ago({ count: String(diffHr) });
const diffDay = Math.floor(diffHr / 24);
return m.activity_days_ago({ count: String(diffDay) });
}
function getDescription(entry: ActivityEntry): string {
const userName =
entry.profiles?.full_name || entry.profiles?.email || "Someone";
const entityType = getEntityTypeLabel(entry.entity_type);
const name = entry.entity_name ?? "—";
const map: Record<string, () => string> = {
create: () =>
m.activity_created({ user: userName, entityType, name }),
update: () =>
m.activity_updated({ user: userName, entityType, name }),
delete: () =>
m.activity_deleted({ user: userName, entityType, name }),
move: () => m.activity_moved({ user: userName, entityType, name }),
rename: () =>
m.activity_renamed({ user: userName, entityType, name }),
};
return (map[entry.action] ?? map["update"]!)();
}
</script>
{#if entries.length === 0}
<div
class="flex flex-col items-center justify-center text-light/40 py-8"
>
<span
class="material-symbols-rounded mb-2"
style="font-size: 40px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 40;"
>history</span
>
<p class="text-body-sm">{emptyLabel ?? m.activity_empty()}</p>
</div>
{:else}
<div class="flex flex-col gap-0.5">
{#each entries as entry}
<div
class="flex items-start gap-3 px-3 py-2 rounded-xl hover:bg-dark/50 transition-colors"
>
<span
class="material-symbols-rounded {getActivityColor(
entry.action,
)} mt-0.5 shrink-0"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>{getActivityIcon(entry.action)}</span
>
<div class="flex-1 min-w-0">
<p class="text-body-sm text-light/70 leading-relaxed">
{getDescription(entry)}
</p>
</div>
<span class="text-[11px] text-light/30 shrink-0 mt-0.5"
>{formatTimeAgo(entry.created_at)}</span
>
</div>
{/each}
</div>
{/if}

View File

@@ -2,34 +2,62 @@
interface Props {
name: string;
src?: string | null;
size?: "sm" | "md" | "lg" | "xl";
size?: "xs" | "sm" | "md" | "lg" | "xl";
status?: "online" | "offline" | "away" | "dnd" | null;
}
let { name, src = null, size = "md" }: Props = $props();
let { name, src = null, size = "md", status = null }: Props = $props();
const initial = $derived(name ? name[0].toUpperCase() : "?");
const sizes = {
xs: { box: "w-6 h-6", text: "text-[10px]", radius: "rounded-[12px]" },
sm: { box: "w-8 h-8", text: "text-body", radius: "rounded-[16px]" },
md: { box: "w-12 h-12", text: "text-h3", radius: "rounded-[24px]" },
lg: { box: "w-16 h-16", text: "text-h2", radius: "rounded-[32px]" },
xl: { box: "w-24 h-24", text: "text-h1", radius: "rounded-[48px]" },
};
const statusSizes: Record<string, string> = {
xs: "w-2 h-2",
sm: "w-2.5 h-2.5",
md: "w-3 h-3",
lg: "w-3.5 h-3.5",
xl: "w-4 h-4",
};
const statusColors: Record<string, string> = {
online: "bg-success",
offline: "bg-light/30",
away: "bg-warning",
dnd: "bg-error",
};
</script>
{#if src}
<div class="relative inline-block shrink-0">
{#if src}
<img
{src}
alt={name}
class="{sizes[size].box} {sizes[size].radius} object-cover shrink-0"
/>
{:else}
{:else}
<div
class="{sizes[size].box} {sizes[size]
.radius} bg-primary flex items-center justify-center shrink-0"
>
<span class="font-heading {sizes[size].text} text-night leading-none">
<span
class="font-heading {sizes[size].text} text-night leading-none"
>
{initial}
</span>
</div>
{/if}
{/if}
{#if status}
<div
class="absolute bottom-0 right-0 rounded-full border-2 border-night {statusSizes[
size
]} {statusColors[status]}"
></div>
{/if}
</div>

View File

@@ -0,0 +1,109 @@
<script lang="ts">
import Skeleton from "./Skeleton.svelte";
interface Props {
variant?: "default" | "kanban" | "files" | "calendar" | "settings" | "list" | "detail";
}
let { variant = "default" }: Props = $props();
</script>
<div class="flex-1 p-6 animate-in">
{#if variant === "kanban"}
<div class="flex gap-3 h-full overflow-hidden">
{#each Array(3) as _}
<div class="flex-shrink-0 w-[256px] bg-dark/20 rounded-xl p-4 flex flex-col gap-3">
<div class="flex items-center gap-2">
<Skeleton variant="text" width="120px" height="1.25rem" />
<Skeleton variant="rectangular" width="24px" height="20px" class="rounded-lg" />
</div>
{#each Array(3) as __}
<Skeleton variant="card" height="72px" class="rounded-xl" />
{/each}
</div>
{/each}
</div>
{:else if variant === "files"}
<div class="flex items-center gap-2 mb-4">
<Skeleton variant="text" width="300px" height="2.5rem" class="rounded-xl" />
<div class="flex-1"></div>
<Skeleton variant="circular" width="36px" height="36px" />
<Skeleton variant="circular" width="36px" height="36px" />
</div>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{#each Array(12) as _}
<Skeleton variant="card" height="100px" class="rounded-xl" />
{/each}
</div>
{:else if variant === "calendar"}
<div class="flex items-center gap-2 mb-4">
<Skeleton variant="circular" width="32px" height="32px" />
<Skeleton variant="text" width="200px" height="1.5rem" />
<Skeleton variant="circular" width="32px" height="32px" />
<div class="flex-1"></div>
<Skeleton variant="rectangular" width="200px" height="32px" class="rounded-xl" />
</div>
<div class="grid grid-cols-7 gap-1">
{#each Array(7) as _}
<Skeleton variant="text" width="100%" height="2rem" />
{/each}
{#each Array(35) as _}
<Skeleton variant="rectangular" width="100%" height="72px" class="rounded-none" />
{/each}
</div>
{:else if variant === "settings"}
<div class="flex flex-col gap-4">
<Skeleton variant="text" width="160px" height="1.5rem" />
<Skeleton variant="text" lines={3} />
<Skeleton variant="rectangular" width="100%" height="48px" class="rounded-xl" />
<Skeleton variant="rectangular" width="100%" height="48px" class="rounded-xl" />
</div>
{:else if variant === "list"}
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
{#each Array(4) as _}
<Skeleton variant="card" height="72px" class="rounded-xl" />
{/each}
</div>
<div class="flex flex-col gap-2">
{#each Array(5) as _}
<Skeleton variant="rectangular" height="64px" class="rounded-xl" />
{/each}
</div>
{:else if variant === "detail"}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 flex flex-col gap-4">
<Skeleton variant="card" height="200px" class="rounded-xl" />
<Skeleton variant="card" height="300px" class="rounded-xl" />
</div>
<div class="flex flex-col gap-4">
<Skeleton variant="card" height="180px" class="rounded-xl" />
<Skeleton variant="card" height="120px" class="rounded-xl" />
</div>
</div>
{:else}
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
{#each Array(4) as _}
<Skeleton variant="card" height="72px" class="rounded-xl" />
{/each}
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2">
<Skeleton variant="card" height="300px" class="rounded-xl" />
</div>
<div class="flex flex-col gap-4">
<Skeleton variant="card" height="140px" class="rounded-xl" />
<Skeleton variant="card" height="200px" class="rounded-xl" />
</div>
</div>
{/if}
</div>
<style>
.animate-in {
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>

View File

@@ -0,0 +1,168 @@
<script lang="ts">
import Twemoji from "./Twemoji.svelte";
import {
emojiData,
searchEmojis,
getEmojisByCategory,
} from "$lib/utils/emojiData";
interface Props {
onSelect: (emoji: string) => void;
onClose: () => void;
position?: { x: number; y: number };
}
let { onSelect, onClose, position = { x: 0, y: 0 } }: Props = $props();
let searchQuery = $state("");
let activeCategory = $state("frequent");
let pickerRef: HTMLDivElement | null = $state(null);
let adjustedPosition = $state({ x: 0, y: 0 });
// Initialize position on first render
$effect(() => {
adjustedPosition = { x: position.x, y: position.y };
});
// Adjust position to stay within viewport
$effect(() => {
if (pickerRef) {
const rect = pickerRef.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let newX = position.x;
let newY = position.y;
// Adjust horizontal position
if (newX + rect.width > viewportWidth - 10) {
newX = viewportWidth - rect.width - 10;
}
if (newX < 10) newX = 10;
// Adjust vertical position
if (newY + rect.height > viewportHeight - 10) {
newY = position.y - rect.height - 40; // Position above the button
}
if (newY < 10) newY = 10;
adjustedPosition = { x: newX, y: newY };
}
});
// Emoji categories
const categories = [
{ id: "frequent", icon: "🕐", name: "Frequently Used" },
{ id: "smileys", icon: "😀", name: "Smileys & Emotion" },
{ id: "people", icon: "👋", name: "People & Body" },
{ id: "nature", icon: "🐻", name: "Animals & Nature" },
{ id: "food", icon: "🍕", name: "Food & Drink" },
{ id: "activities", icon: "⚽", name: "Activities" },
{ id: "travel", icon: "🚗", name: "Travel & Places" },
{ id: "objects", icon: "💡", name: "Objects" },
{ id: "symbols", icon: "❤️", name: "Symbols" },
];
// Frequently used emojis
const frequentEmojis = [
"👍",
"❤️",
"😂",
"🔥",
"👀",
"🙌",
"💯",
"✅",
"❌",
"🎉",
"😮",
"😢",
];
const filteredEmojis = $derived(() => {
if (searchQuery) {
// Search using emoji names
return searchEmojis(searchQuery).map((e) => e.emoji);
}
if (activeCategory === "frequent") {
return frequentEmojis;
}
// Get emojis from the data file by category
return getEmojisByCategory(activeCategory).map((e) => e.emoji);
});
function handleSelect(emoji: string) {
onSelect(emoji);
onClose();
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={pickerRef}
class="fixed bg-dark border border-light/20 rounded-xl shadow-2xl z-[100] w-[352px] overflow-hidden"
style="left: {adjustedPosition.x}px; top: {adjustedPosition.y}px;"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
role="dialog"
aria-label="Emoji picker"
tabindex="-1"
>
<!-- Search bar -->
<div class="p-2 border-b border-light/10">
<div class="relative">
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-light/40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
bind:value={searchQuery}
placeholder="Find the perfect emoji"
class="w-full bg-night/50 border border-light/10 rounded-lg pl-10 pr-4 py-2 text-sm text-light placeholder:text-light/40 focus:outline-none focus:border-primary/50"
/>
</div>
</div>
<!-- Category tabs -->
<div class="flex border-b border-light/10 px-1">
{#each categories as category}
<button
class="p-2 hover:bg-light/5 rounded transition-colors {activeCategory ===
category.id
? 'bg-light/10'
: ''}"
onclick={() => {
activeCategory = category.id;
searchQuery = "";
}}
title={category.name}
>
<Twemoji emoji={category.icon} size={18} />
</button>
{/each}
</div>
<!-- Emoji grid -->
<div class="h-[200px] overflow-y-auto p-2">
<div class="text-xs text-light/50 font-medium mb-2 px-1">
{categories.find((c) => c.id === activeCategory)?.name || "Emojis"}
</div>
<div class="grid grid-cols-8 gap-0.5">
{#each filteredEmojis() as emoji}
<button
class="w-9 h-9 flex items-center justify-center hover:bg-light/10 rounded transition-colors"
onclick={() => handleSelect(emoji)}
>
<Twemoji {emoji} size={22} />
</button>
{/each}
</div>
</div>
</div>

View File

@@ -0,0 +1,116 @@
<script lang="ts">
import StatusBadge from "./StatusBadge.svelte";
interface Props {
name: string;
slug: string;
status: string;
startDate: string | null;
endDate: string | null;
color: string | null;
venueName: string | null;
href: string;
compact?: boolean;
}
let {
name,
slug,
status,
startDate,
endDate,
color,
venueName,
href,
compact = false,
}: Props = $props();
function formatDate(dateStr: string | null): string {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
</script>
{#if compact}
<!-- Compact variant: single row for lists/sidebars -->
<a
{href}
class="flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-dark/50 transition-colors group"
>
<div
class="w-2.5 h-2.5 rounded-full shrink-0"
style="background-color: {color || '#00A3E0'}"
></div>
<div class="flex-1 min-w-0">
<p
class="text-body-sm text-white group-hover:text-primary transition-colors truncate"
>
{name}
</p>
<div class="flex items-center gap-2 mt-0.5">
{#if startDate}
<span class="text-[11px] text-light/40"
>{formatDate(startDate)}{endDate
? ` — ${formatDate(endDate)}`
: ""}</span
>
{/if}
{#if venueName}
<span class="text-[11px] text-light/30"
>· {venueName}</span
>
{/if}
</div>
</div>
<StatusBadge {status} />
</a>
{:else}
<!-- Full card variant: for grid layouts -->
<a
{href}
class="group bg-dark/30 hover:bg-dark/60 border border-light/5 hover:border-light/10 rounded-2xl p-5 flex flex-col gap-3 transition-all"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div
class="w-3 h-3 rounded-full"
style="background-color: {color || '#00A3E0'}"
></div>
<h3
class="text-body font-heading text-white group-hover:text-primary transition-colors truncate"
>
{name}
</h3>
</div>
<StatusBadge {status} />
</div>
<div class="flex items-center gap-3 text-[12px] text-light/40">
{#if startDate}
<span class="flex items-center gap-1">
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>calendar_today</span
>
{formatDate(startDate)}{endDate
? ` ${formatDate(endDate)}`
: ""}
</span>
{/if}
{#if venueName}
<span class="flex items-center gap-1">
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>location_on</span
>
{venueName}
</span>
{/if}
</div>
</a>
{/if}

View File

@@ -0,0 +1,63 @@
<script lang="ts">
interface Props {
src: string;
alt?: string;
onClose: () => void;
}
let { src, alt = "", onClose }: Props = $props();
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeyDown} />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[200] flex items-center justify-center bg-black/90 backdrop-blur-sm"
onclick={handleBackdropClick}
>
<!-- Close button -->
<button
class="absolute top-4 right-4 w-10 h-10 flex items-center justify-center rounded-full bg-light/10 hover:bg-light/20 transition-colors text-light"
onclick={onClose}
>
<svg class="w-6 h-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
<!-- Image container -->
<div class="max-w-[90vw] max-h-[90vh] flex items-center justify-center">
<img
{src}
{alt}
class="max-w-full max-h-[90vh] object-contain rounded-lg shadow-2xl"
/>
</div>
<!-- Open in new tab button -->
<a
href={src}
target="_blank"
rel="noopener noreferrer"
class="absolute bottom-4 right-4 px-4 py-2 rounded-lg bg-light/10 hover:bg-light/20 transition-colors text-light text-sm flex items-center gap-2"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
Open Original
</a>
</div>

View File

@@ -14,28 +14,25 @@
</script>
<div
class="bg-background flex flex-col gap-4 items-start overflow-hidden px-4 py-5 rounded-[32px] w-64 h-[512px]"
class="bg-dark/20 border border-light/5 flex flex-col overflow-hidden rounded-xl w-[272px] shrink-0 h-full"
>
<!-- Header -->
<div class="flex items-center gap-2 p-1 rounded-[32px] w-full">
<div class="flex items-center gap-2 px-3 py-2.5 border-b border-light/5">
<div class="flex-1 flex items-center gap-2 min-w-0">
<span class="font-heading text-h4 text-white truncate">{title}</span
>
<div
class="bg-dark flex items-center justify-center p-1 rounded-lg shrink-0"
>
<span class="font-heading text-h6 text-white">{count}</span>
</div>
<span class="font-heading text-body-sm text-white truncate">{title}</span>
<span
class="text-[11px] text-light/40 bg-light/5 px-1.5 py-0.5 rounded-md shrink-0"
>{count}</span>
</div>
{#if onMore}
<button
type="button"
class="p-1 flex items-center justify-center hover:bg-dark/50 rounded-full transition-colors"
class="p-0.5 flex items-center justify-center hover:bg-dark/50 rounded-lg transition-colors"
onclick={onMore}
>
<span
class="material-symbols-rounded text-light"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
class="material-symbols-rounded text-light/40 hover:text-white"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>
more_horiz
</span>
@@ -45,7 +42,7 @@
<!-- Cards container -->
<div
class="flex-1 flex flex-col gap-2 items-start overflow-y-auto w-full min-h-0"
class="flex-1 flex flex-col gap-1.5 p-2 overflow-y-auto min-h-0"
>
{#if children}
{@render children()}
@@ -54,8 +51,10 @@
<!-- Add button -->
{#if onAddCard}
<Button variant="secondary" fullWidth icon="add" onclick={onAddCard}>
<div class="px-2 pb-2">
<Button variant="tertiary" fullWidth size="sm" icon="add" onclick={onAddCard}>
Add card
</Button>
</div>
{/if}
</div>

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import Avatar from "./Avatar.svelte";
interface MemberItem {
id: string;
user_id: string;
role: string;
profiles: {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
} | null;
}
interface Props {
members: MemberItem[];
max?: number;
moreHref?: string;
moreLabel?: string;
emptyLabel?: string;
}
let {
members,
max = 6,
moreHref,
moreLabel,
emptyLabel,
}: Props = $props();
const visible = $derived(members.slice(0, max));
const remaining = $derived(members.length - max);
</script>
<div class="flex flex-col gap-1.5">
{#each visible as member}
<div class="flex items-center gap-2.5 px-1 py-1">
<Avatar
name={member.profiles?.full_name ||
member.profiles?.email ||
"?"}
src={member.profiles?.avatar_url}
size="sm"
/>
<div class="flex-1 min-w-0">
<p class="text-body-sm text-white truncate">
{member.profiles?.full_name ||
member.profiles?.email ||
"Unknown"}
</p>
<p class="text-[11px] text-light/40 capitalize">
{member.role}
</p>
</div>
</div>
{/each}
{#if remaining > 0 && moreHref && moreLabel}
<a
href={moreHref}
class="text-body-sm text-primary hover:underline text-center pt-1"
>
{moreLabel}
</a>
{/if}
{#if members.length === 0 && emptyLabel}
<p class="text-body-sm text-light/30 text-center py-4">
{emptyLabel}
</p>
{/if}
</div>

View File

@@ -0,0 +1,40 @@
<script lang="ts">
interface Props {
label: string;
description: string;
icon: string;
href: string;
color?: string;
bg?: string;
}
let {
label,
description,
icon,
href,
color = "text-primary",
bg = "bg-primary/10",
}: Props = $props();
</script>
<a
{href}
class="group bg-dark/30 hover:bg-dark/60 border border-light/5 hover:border-light/10 rounded-xl p-4 flex flex-col gap-2 transition-all"
>
<div
class="w-10 h-10 rounded-xl {bg} flex items-center justify-center"
>
<span
class="material-symbols-rounded {color}"
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 22;"
>{icon}</span
>
</div>
<h3
class="text-body font-heading text-white group-hover:text-primary transition-colors"
>
{label}
</h3>
<p class="text-[12px] text-light/40">{description}</p>
</a>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
title: string;
subtitle?: string;
icon?: string;
iconColor?: string;
actions?: Snippet;
class?: string;
}
let {
title,
subtitle,
icon,
iconColor = "text-white",
actions,
class: className = "",
}: Props = $props();
</script>
<header
class="flex items-center justify-between px-6 py-5 border-b border-light/5 shrink-0 {className}"
>
<div class="flex items-center gap-3 min-w-0">
{#if icon}
<span
class="material-symbols-rounded {iconColor} shrink-0"
style="font-size: 28px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 28;"
>{icon}</span
>
{/if}
<div class="min-w-0">
<h1 class="text-h1 font-heading text-white truncate">{title}</h1>
{#if subtitle}
<p class="text-body-sm text-light/50 mt-0.5">{subtitle}</p>
{/if}
</div>
</div>
{#if actions}
<div class="flex items-center gap-2 shrink-0 ml-4">
{@render actions()}
</div>
{/if}
</header>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
interface QuickLink {
label: string;
icon: string;
href: string;
color?: string;
}
interface Props {
links: QuickLink[];
}
let { links }: Props = $props();
</script>
<div class="grid grid-cols-2 gap-2">
{#each links as link}
<a
href={link.href}
class="flex flex-col items-center gap-1.5 p-3 rounded-xl bg-dark/30 hover:bg-dark/60 border border-light/5 hover:border-light/10 transition-all text-center"
>
<span
class="material-symbols-rounded {link.color ?? 'text-light/50'}"
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 22;"
>{link.icon}</span
>
<span class="text-[12px] text-light/60">{link.label}</span>
</a>
{/each}
</div>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
title?: string;
titleRight?: Snippet;
padding?: "sm" | "md" | "lg";
class?: string;
children: Snippet;
}
let {
title,
titleRight,
padding = "md",
class: className = "",
children,
}: Props = $props();
const paddingClasses = {
sm: "p-3",
md: "p-5",
lg: "p-6",
};
</script>
<div
class="bg-dark/30 border border-light/5 rounded-xl {paddingClasses[padding]} {className}"
>
{#if title || titleRight}
<div class="flex items-center justify-between mb-4">
{#if title}
<h2 class="text-body font-heading text-white">{title}</h2>
{/if}
{#if titleRight}
{@render titleRight()}
{/if}
</div>
{/if}
{@render children()}
</div>

View File

@@ -0,0 +1,58 @@
<script lang="ts">
interface Props {
label: string;
value: number | string;
icon: string;
color?: string;
bg?: string;
href?: string | null;
}
let {
label,
value,
icon,
color = "text-primary",
bg = "bg-primary/10",
href = null,
}: Props = $props();
</script>
{#if href}
<a
{href}
class="bg-dark/30 border border-light/5 hover:border-light/10 rounded-xl p-4 flex items-center gap-3 transition-all group"
>
<div
class="w-10 h-10 rounded-xl {bg} flex items-center justify-center shrink-0"
>
<span
class="material-symbols-rounded {color}"
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 22;"
>{icon}</span
>
</div>
<div>
<p class="text-xl font-bold text-white leading-none">{value}</p>
<p class="text-[12px] text-light/40 mt-0.5">{label}</p>
</div>
</a>
{:else}
<div
class="bg-dark/30 border border-light/5 rounded-xl p-4 flex items-center gap-3"
>
<div
class="w-10 h-10 rounded-xl {bg} flex items-center justify-center shrink-0"
>
<span
class="material-symbols-rounded {color}"
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 22;"
>{icon}</span
>
</div>
<div>
<p class="text-xl font-bold text-white leading-none">{value}</p>
<p class="text-[12px] text-light/40 mt-0.5">{label}</p>
</div>
</div>
{/if}

View File

@@ -0,0 +1,30 @@
<script lang="ts">
interface Props {
status: string;
size?: "sm" | "md";
}
let { status, size = "sm" }: Props = $props();
const colorMap: Record<string, string> = {
planning: "text-amber-400 bg-amber-400/10",
active: "text-emerald-400 bg-emerald-400/10",
completed: "text-blue-400 bg-blue-400/10",
archived: "text-light/40 bg-light/5",
draft: "text-light/40 bg-light/5",
sent: "text-amber-400 bg-amber-400/10",
signed: "text-emerald-400 bg-emerald-400/10",
fulfilled: "text-blue-400 bg-blue-400/10",
};
const sizeClasses = {
sm: "text-[10px] px-2 py-0.5",
md: "text-[12px] px-2.5 py-1",
};
const colors = $derived(colorMap[status] ?? "text-light/40 bg-light/5");
</script>
<span class="rounded-full capitalize {sizeClasses[size]} {colors}"
>{status}</span
>

View File

@@ -0,0 +1,37 @@
<script lang="ts">
interface Tab {
value: string;
label: string;
icon?: string;
}
interface Props {
tabs: Tab[];
active: string;
onchange: (value: string) => void;
}
let { tabs, active, onchange }: Props = $props();
</script>
<div class="flex items-center gap-1 px-6 py-3 border-b border-light/5 shrink-0">
{#each tabs as tab}
<button
type="button"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-body-sm font-body transition-colors {active ===
tab.value
? 'bg-primary text-background'
: 'text-light/50 hover:text-white hover:bg-dark/50'}"
onclick={() => onchange(tab.value)}
>
{#if tab.icon}
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>{tab.icon}</span
>
{/if}
{tab.label}
</button>
{/each}
</div>

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { getTwemojiUrl } from '$lib/utils/twemoji';
interface Props {
emoji: string;
size?: number;
class?: string;
}
let { emoji, size = 20, class: className = '' }: Props = $props();
const url = $derived(getTwemojiUrl(emoji));
</script>
<img
src={url}
alt={emoji}
class="inline-block align-text-bottom {className}"
style="width: {size}px; height: {size}px;"
draggable="false"
/>

View File

@@ -0,0 +1,114 @@
<script lang="ts" generics="T">
import { onMount, tick } from "svelte";
interface Props {
items: T[];
itemHeight: number;
overscan?: number;
containerClass?: string;
getKey: (item: T, index: number) => string;
children: import("svelte").Snippet<[T, number]>;
onScrollTop?: () => void;
onScrollBottom?: () => void;
}
let {
items,
itemHeight,
overscan = 5,
containerClass = "",
getKey,
children,
onScrollTop,
onScrollBottom,
}: Props = $props();
let containerRef: HTMLDivElement | null = $state(null);
let scrollTop = $state(0);
let containerHeight = $state(0);
// Calculate visible range
const visibleRange = $derived(() => {
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
const visibleCount = Math.ceil(containerHeight / itemHeight) + overscan * 2;
const endIndex = Math.min(items.length, startIndex + visibleCount);
return { startIndex, endIndex };
});
// Get visible items with their indices
const visibleItems = $derived(() => {
const { startIndex, endIndex } = visibleRange();
return items.slice(startIndex, endIndex).map((item, i) => ({
item,
index: startIndex + i,
}));
});
// Total height of the list
const totalHeight = $derived(items.length * itemHeight);
// Offset for visible items
const offsetY = $derived(visibleRange().startIndex * itemHeight);
function handleScroll(e: Event) {
const target = e.target as HTMLDivElement;
scrollTop = target.scrollTop;
// Check for scroll to top (load more)
if (target.scrollTop < 100 && onScrollTop) {
onScrollTop();
}
// Check for scroll to bottom
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
if (distanceToBottom < 100 && onScrollBottom) {
onScrollBottom();
}
}
function updateContainerHeight() {
if (containerRef) {
containerHeight = containerRef.clientHeight;
}
}
onMount(() => {
updateContainerHeight();
const resizeObserver = new ResizeObserver(updateContainerHeight);
if (containerRef) {
resizeObserver.observe(containerRef);
}
return () => resizeObserver.disconnect();
});
// Scroll to bottom
export async function scrollToBottom() {
await tick();
if (containerRef) {
containerRef.scrollTop = containerRef.scrollHeight;
}
}
// Scroll to specific index
export function scrollToIndex(index: number) {
if (containerRef) {
containerRef.scrollTop = index * itemHeight;
}
}
</script>
<div
bind:this={containerRef}
class="overflow-y-auto {containerClass}"
onscroll={handleScroll}
>
<div style="height: {totalHeight}px; position: relative;">
<div style="transform: translateY({offsetY}px);">
{#each visibleItems() as { item, index } (getKey(item, index))}
<div style="height: {itemHeight}px;">
{@render children(item, index)}
</div>
{/each}
</div>
</div>
</div>

View File

@@ -26,3 +26,18 @@ export { default as Icon } from './Icon.svelte';
export { default as AssigneePicker } from './AssigneePicker.svelte';
export { default as ContextMenu } from './ContextMenu.svelte';
export { default as PageSkeleton } from './PageSkeleton.svelte';
export { default as PageHeader } from './PageHeader.svelte';
export { default as SectionCard } from './SectionCard.svelte';
export { default as StatCard } from './StatCard.svelte';
export { default as StatusBadge } from './StatusBadge.svelte';
export { default as TabBar } from './TabBar.svelte';
export { default as MemberList } from './MemberList.svelte';
export { default as ActivityFeed } from './ActivityFeed.svelte';
export { default as EventCard } from './EventCard.svelte';
export { default as ContentSkeleton } from './ContentSkeleton.svelte';
export { default as QuickLinkGrid } from './QuickLinkGrid.svelte';
export { default as ModuleCard } from './ModuleCard.svelte';
export { default as ImagePreviewModal } from './ImagePreviewModal.svelte';
export { default as Twemoji } from './Twemoji.svelte';
export { default as EmojiPicker } from './EmojiPicker.svelte';
export { default as VirtualList } from './VirtualList.svelte';

1107
src/lib/matrix/client.ts Normal file

File diff suppressed because it is too large Load Diff

101
src/lib/matrix/context.ts Normal file
View File

@@ -0,0 +1,101 @@
/**
* Matrix Client Context
*
* Provides a Svelte Context-based approach for MatrixClient lifecycle management.
* Replaces the module singleton pattern for better testability and explicit dependencies.
*
* Usage:
* // In root layout or provider component:
* setMatrixContext(client);
*
* // In any child component:
* const client = getMatrixContext();
*/
import { getContext, setContext } from 'svelte';
import type { MatrixClient } from 'matrix-js-sdk';
// Unique symbol key for context (prevents collisions)
const MATRIX_CLIENT_KEY = Symbol('matrix-client');
// ============================================================================
// Context Types
// ============================================================================
export interface MatrixClientContext {
client: MatrixClient;
isReady: boolean;
}
// ============================================================================
// Context Setters
// ============================================================================
/**
* Set the MatrixClient in Svelte context.
* Must be called during component initialization (not in event handlers).
*/
export function setMatrixContext(client: MatrixClient): void {
setContext<MatrixClientContext>(MATRIX_CLIENT_KEY, {
client,
isReady: true,
});
}
/**
* Set an uninitialized context (for loading states)
*/
export function setMatrixContextPending(): void {
setContext<MatrixClientContext | null>(MATRIX_CLIENT_KEY, null);
}
// ============================================================================
// Context Getters
// ============================================================================
/**
* Get the MatrixClient from Svelte context.
* Throws if context is not set or client is not ready.
*
* @throws Error if called outside of a component that has MatrixProvider as ancestor
*/
export function getMatrixContext(): MatrixClient {
const ctx = getContext<MatrixClientContext | null>(MATRIX_CLIENT_KEY);
if (!ctx) {
throw new Error(
'Matrix client not available. Ensure this component is wrapped in MatrixProvider.'
);
}
if (!ctx.isReady) {
throw new Error('Matrix client is not ready yet.');
}
return ctx.client;
}
/**
* Get the MatrixClient context, returning null if not available.
* Safe version that doesn't throw.
*/
export function getMatrixContextSafe(): MatrixClient | null {
try {
const ctx = getContext<MatrixClientContext | null>(MATRIX_CLIENT_KEY);
return ctx?.isReady ? ctx.client : null;
} catch {
return null;
}
}
/**
* Check if Matrix context is available and ready
*/
export function hasMatrixContext(): boolean {
try {
const ctx = getContext<MatrixClientContext | null>(MATRIX_CLIENT_KEY);
return ctx?.isReady ?? false;
} catch {
return false;
}
}

90
src/lib/matrix/index.ts Normal file
View File

@@ -0,0 +1,90 @@
/**
* Matrix Module Index
*
* Re-exports all Matrix-related functionality for convenient imports.
*/
// Client
export {
initMatrixClient,
loginWithPassword,
getClient,
isClientInitialized,
stopClient,
logout,
getRooms,
getRoom,
sendMessage,
sendReaction,
removeReaction,
editMessage,
deleteMessage,
createRoom,
createSpace,
addRoomToSpace,
removeRoomFromSpace,
getSpaceChildren,
getSpaces,
joinRoom,
leaveRoom,
setTyping,
markRoomAsRead,
loadMoreMessages,
isRoomEncrypted,
getCryptoStatus,
uploadFile,
sendFileMessage,
getMediaUrl,
getAuthenticatedMediaUrl,
getAuthenticatedThumbnailUrl,
getRoomMembers,
getRoomReadReceipts,
getReadReceiptsForEvent,
searchMessagesLocal,
createDirectMessage,
findExistingDM,
searchUsers,
getUserPresence,
setPresence,
getRoomMembersPresence,
setRoomName,
setRoomTopic,
setRoomAvatar,
getRoomNotificationLevel,
setRoomNotificationLevel,
getPinnedMessages,
pinMessage,
unpinMessage,
type NotificationLevel,
type LoginCredentials,
type LoginWithPasswordParams,
type MatrixClient,
type Room,
type MatrixEvent,
} from './client';
// Sync
export {
setupSyncHandlers,
removeSyncHandlers,
} from './sync';
// Context (for dependency injection)
export {
setMatrixContext,
getMatrixContext,
getMatrixContextSafe,
hasMatrixContext,
type MatrixClientContext,
} from './context';
// Types
export type {
SyncState,
RoomMember,
Message,
RoomSummary,
TypingInfo,
ReadReceipt,
Space,
} from './types';

79
src/lib/matrix/matrix-sdk-augment.d.ts vendored Normal file
View File

@@ -0,0 +1,79 @@
/**
* Matrix SDK Type Augmentations
*
* Provides extended client start options that include pendingEventOrdering.
* Also augments TimelineEvents and AccountDataEvents with Matrix event types.
*/
import 'matrix-js-sdk';
declare module 'matrix-js-sdk' {
/**
* Extended start client options that include pendingEventOrdering
* which is supported by the SDK but missing from official types
*/
export interface IStartClientOpts {
initialSyncLimit?: number;
lazyLoadMembers?: boolean;
pendingEventOrdering?: 'chronological' | 'detached';
includeArchivedRooms?: boolean;
filter?: object;
}
/**
* Augment TimelineEvents to include Matrix event types
*/
export interface TimelineEvents {
'm.room.message': {
msgtype: string;
body: string;
format?: string;
formatted_body?: string;
'm.relates_to'?: {
rel_type?: string;
event_id?: string;
'm.in_reply_to'?: { event_id: string };
};
'm.new_content'?: {
msgtype: string;
body: string;
};
url?: string;
info?: Record<string, unknown>;
};
'm.reaction': {
'm.relates_to': {
rel_type: 'm.annotation';
event_id: string;
key: string;
};
};
'm.room.redaction': {
reason?: string;
};
}
/**
* Augment AccountDataEvents to include Matrix account data types
*/
export interface AccountDataEvents {
'm.direct': Record<string, string[]>;
'm.push_rules': unknown;
'm.ignored_user_list': { ignored_users: Record<string, object> };
}
/**
* Augment StateEvents to include Space-related state events
*/
export interface StateEvents {
'm.space.child': {
via?: string[];
suggested?: boolean;
order?: string;
};
'm.space.parent': {
via?: string[];
canonical?: boolean;
};
}
}

View File

@@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest';
import { getMessageType, stripReplyFallback, formatFileSize } from './messageUtils';
describe('messageUtils', () => {
describe('getMessageType', () => {
it('returns "image" for m.image msgtype', () => {
expect(getMessageType('m.image')).toBe('image');
});
it('returns "video" for m.video msgtype', () => {
expect(getMessageType('m.video')).toBe('video');
});
it('returns "audio" for m.audio msgtype', () => {
expect(getMessageType('m.audio')).toBe('audio');
});
it('returns "file" for m.file msgtype', () => {
expect(getMessageType('m.file')).toBe('file');
});
it('returns "notice" for m.notice msgtype', () => {
expect(getMessageType('m.notice')).toBe('notice');
});
it('returns "emote" for m.emote msgtype', () => {
expect(getMessageType('m.emote')).toBe('emote');
});
it('returns "text" for m.text msgtype', () => {
expect(getMessageType('m.text')).toBe('text');
});
it('returns "text" for unknown msgtype', () => {
expect(getMessageType('m.unknown')).toBe('text');
expect(getMessageType('')).toBe('text');
});
});
describe('stripReplyFallback', () => {
it('returns original content when hasReply is false', () => {
const content = '> quoted text\n\nactual message';
expect(stripReplyFallback(content, false)).toBe(content);
});
it('strips single-line reply fallback', () => {
const content = '> <@user:matrix.org> Hello\n\nMy reply';
expect(stripReplyFallback(content, true)).toBe('My reply');
});
it('strips multi-line reply fallback', () => {
const content = '> <@user:matrix.org> Hello\n> This is a longer message\n\nMy reply';
expect(stripReplyFallback(content, true)).toBe('My reply');
});
it('handles empty content', () => {
expect(stripReplyFallback('', true)).toBe('');
expect(stripReplyFallback('', false)).toBe('');
});
it('handles content with only reply fallback', () => {
const content = '> <@user:matrix.org> Hello\n\n';
expect(stripReplyFallback(content, true)).toBe('');
});
it('preserves content after reply fallback', () => {
const content = '> <@user:matrix.org> Hello\n\nFirst line\nSecond line';
expect(stripReplyFallback(content, true)).toBe('First line\nSecond line');
});
it('handles bare > lines', () => {
const content = '>\n\nMy message';
expect(stripReplyFallback(content, true)).toBe('My message');
});
});
describe('formatFileSize', () => {
it('returns empty string for undefined', () => {
expect(formatFileSize(undefined)).toBe('');
});
it('returns empty string for 0', () => {
expect(formatFileSize(0)).toBe('');
});
it('formats bytes correctly', () => {
expect(formatFileSize(500)).toBe('500 B');
expect(formatFileSize(1023)).toBe('1023 B');
});
it('formats kilobytes correctly', () => {
expect(formatFileSize(1024)).toBe('1.0 KB');
expect(formatFileSize(1536)).toBe('1.5 KB');
expect(formatFileSize(10240)).toBe('10.0 KB');
});
it('formats megabytes correctly', () => {
expect(formatFileSize(1024 * 1024)).toBe('1.0 MB');
expect(formatFileSize(1.5 * 1024 * 1024)).toBe('1.5 MB');
expect(formatFileSize(10 * 1024 * 1024)).toBe('10.0 MB');
});
});
});

View File

@@ -0,0 +1,63 @@
/**
* Message Utilities
*
* Shared utility functions for processing Matrix messages.
*/
import type { Message } from './types';
/**
* Determine message type from Matrix msgtype
*/
export function getMessageType(msgtype: string): Message['type'] {
switch (msgtype) {
case 'm.image':
return 'image';
case 'm.video':
return 'video';
case 'm.audio':
return 'audio';
case 'm.file':
return 'file';
case 'm.notice':
return 'notice';
case 'm.emote':
return 'emote';
default:
return 'text';
}
}
/**
* Strip Matrix reply fallback from message content
* Format: "> <@user> text\n\n actual message"
*/
export function stripReplyFallback(content: string, hasReply: boolean): string {
if (!hasReply) return content;
const lines = content.split('\n');
let startIndex = 0;
for (let i = 0; i < lines.length; i++) {
if (lines[i].startsWith('> ') || lines[i] === '>') {
startIndex = i + 1;
} else if (lines[i] === '') {
startIndex = i + 1;
break;
} else {
break;
}
}
return lines.slice(startIndex).join('\n').trim();
}
/**
* Format file size for display
*/
export function formatFileSize(bytes?: number): string {
if (!bytes) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View File

@@ -0,0 +1,75 @@
import { describe, it, expect, vi } from 'vitest';
import type { MatrixClient, Room } from 'matrix-js-sdk';
import {
onClientEvent,
removeClientEventListeners,
type ClientEventName,
type SyncStateValue,
} from './sdk-types';
describe('sdk-types', () => {
describe('onClientEvent', () => {
it('calls client.on with the correct event name', () => {
const mockClient = {
on: vi.fn(),
} as unknown as MatrixClient;
const handler = vi.fn();
onClientEvent(mockClient, 'sync', handler);
expect(mockClient.on).toHaveBeenCalledWith('sync', handler);
});
it('works with different event types', () => {
const mockClient = {
on: vi.fn(),
} as unknown as MatrixClient;
const events: ClientEventName[] = [
'sync',
'Room',
'Room.timeline',
'Room.redaction',
'RoomMember.typing',
'RoomMember.membership',
'RoomState.events',
'User.presence',
];
events.forEach((event) => {
const handler = vi.fn();
// Use type assertion since we're testing all event types in a loop
(onClientEvent as (client: MatrixClient, event: string, handler: (...args: unknown[]) => void) => void)(mockClient, event, handler);
expect(mockClient.on).toHaveBeenCalledWith(event, handler);
});
});
});
describe('removeClientEventListeners', () => {
it('calls client.removeAllListeners with the correct event name', () => {
const mockClient = {
removeAllListeners: vi.fn(),
} as unknown as MatrixClient;
removeClientEventListeners(mockClient, 'sync');
expect(mockClient.removeAllListeners).toHaveBeenCalledWith('sync');
});
});
describe('SyncStateValue type', () => {
it('accepts valid sync state values', () => {
const states: SyncStateValue[] = [
'STOPPED',
'SYNCING',
'PREPARED',
'CATCHUP',
'RECONNECTING',
'ERROR',
];
// This is a compile-time type check - if this compiles, the types are correct
expect(states).toHaveLength(6);
});
});
});

249
src/lib/matrix/sdk-types.ts Normal file
View File

@@ -0,0 +1,249 @@
/**
* Matrix SDK Type Extensions
*
* Type declarations to extend matrix-js-sdk types and provide
* better type safety for Matrix events and state.
*/
import type { MatrixClient, MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
// ============================================================================
// Event Types
// ============================================================================
/** Matrix event type strings */
export type MatrixEventType =
| 'm.room.message'
| 'm.room.name'
| 'm.room.topic'
| 'm.room.avatar'
| 'm.room.member'
| 'm.room.pinned_events'
| 'm.room.encryption'
| 'm.reaction'
| 'm.room.redaction';
/** Matrix state event type strings */
export type MatrixStateEventType =
| 'm.room.name'
| 'm.room.topic'
| 'm.room.avatar'
| 'm.room.pinned_events'
| 'm.room.encryption'
| 'm.room.member';
/** Push rule scope */
export type PushRuleScope = 'global' | 'device';
/** Push rule kind */
export type PushRuleKind = 'override' | 'underride' | 'sender' | 'room' | 'content';
// ============================================================================
// Client Event Names
// ============================================================================
/** Client event names for event listeners */
export type ClientEventName =
| 'sync'
| 'Room'
| 'Room.timeline'
| 'Room.redaction'
| 'RoomMember.typing'
| 'RoomMember.membership'
| 'RoomState.events'
| 'User.presence';
// ============================================================================
// Sync State
// ============================================================================
/** Sync state values */
export type SyncStateValue =
| 'STOPPED'
| 'SYNCING'
| 'PREPARED'
| 'CATCHUP'
| 'RECONNECTING'
| 'ERROR';
// ============================================================================
// Event Handlers
// ============================================================================
/** Sync event handler */
export type SyncEventHandler = (
state: SyncStateValue,
prevState: SyncStateValue | null,
data?: { error?: Error }
) => void;
/** Room event handler */
export type RoomEventHandler = (room: Room) => void;
/** Room timeline event handler */
export type RoomTimelineEventHandler = (
event: MatrixEvent,
room: Room | undefined,
toStartOfTimeline: boolean
) => void;
/** Room redaction event handler */
export type RoomRedactionEventHandler = (
event: MatrixEvent,
room: Room
) => void;
/** Room member typing event handler */
export type RoomMemberTypingEventHandler = (
event: MatrixEvent,
member: RoomMember
) => void;
/** Room member membership event handler */
export type RoomMemberMembershipEventHandler = (
event: MatrixEvent,
member: RoomMember
) => void;
/** Room state events handler */
export type RoomStateEventsEventHandler = (event: MatrixEvent) => void;
/** User presence event handler */
export type UserPresenceEventHandler = (
event: MatrixEvent,
user: { userId: string; presence: 'online' | 'offline' | 'unavailable' }
) => void;
// ============================================================================
// Pinned Events Content
// ============================================================================
/** Content for m.room.pinned_events state event */
export interface PinnedEventsContent {
pinned: string[];
[key: string]: unknown;
}
/** Content for m.room.avatar state event */
export interface RoomAvatarContent {
url: string;
[key: string]: unknown;
}
// ============================================================================
// Type-safe Client Extensions
// ============================================================================
/**
* Type-safe wrapper for client.on() with proper event typing
*/
export function onClientEvent(
client: MatrixClient,
event: 'sync',
handler: SyncEventHandler
): void;
export function onClientEvent(
client: MatrixClient,
event: 'Room',
handler: RoomEventHandler
): void;
export function onClientEvent(
client: MatrixClient,
event: 'Room.timeline',
handler: RoomTimelineEventHandler
): void;
export function onClientEvent(
client: MatrixClient,
event: 'Room.redaction',
handler: RoomRedactionEventHandler
): void;
export function onClientEvent(
client: MatrixClient,
event: 'RoomMember.typing',
handler: RoomMemberTypingEventHandler
): void;
export function onClientEvent(
client: MatrixClient,
event: 'RoomMember.membership',
handler: RoomMemberMembershipEventHandler
): void;
export function onClientEvent(
client: MatrixClient,
event: 'RoomState.events',
handler: RoomStateEventsEventHandler
): void;
export function onClientEvent(
client: MatrixClient,
event: 'User.presence',
handler: UserPresenceEventHandler
): void;
export function onClientEvent(
client: MatrixClient,
event: ClientEventName,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
handler: (...args: any[]) => void
): void {
// The SDK's type definitions are incomplete, so we use type assertion here
// but expose a type-safe API to consumers
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(client as any).on(event, handler);
}
/**
* Type-safe wrapper for client.removeAllListeners()
*/
export function removeClientEventListeners(
client: MatrixClient,
event: ClientEventName
): void {
(client as any).removeAllListeners(event);
}
/**
* Type-safe wrapper for sendStateEvent
*/
export async function sendTypedStateEvent<T extends Record<string, unknown>>(
client: MatrixClient,
roomId: string,
eventType: MatrixStateEventType,
content: T,
stateKey = ''
): Promise<{ event_id: string }> {
return (client as any).sendStateEvent(roomId, eventType, content, stateKey);
}
/**
* Type-safe wrapper for getStateEvents
*/
export function getTypedStateEvent(
room: Room,
eventType: MatrixStateEventType,
stateKey = ''
): MatrixEvent | null {
return room.currentState.getStateEvents(eventType as any, stateKey) as MatrixEvent | null;
}
/**
* Type-safe wrapper for push rules
*/
export async function addTypedPushRule(
client: MatrixClient,
scope: PushRuleScope,
kind: PushRuleKind,
ruleId: string,
body: { actions: string[]; conditions?: unknown[] }
): Promise<void> {
await (client as any).addPushRule(scope, kind, ruleId, body);
}
/**
* Type-safe wrapper for deleting push rules
*/
export async function deleteTypedPushRule(
client: MatrixClient,
scope: PushRuleScope,
kind: PushRuleKind,
ruleId: string
): Promise<void> {
await (client as any).deletePushRule(scope, kind, ruleId);
}

217
src/lib/matrix/sync.ts Normal file
View File

@@ -0,0 +1,217 @@
/**
* Matrix Sync Handler
*
* Manages the Matrix sync loop and updates Svelte stores accordingly.
*/
import type { MatrixClient, MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
import { get } from 'svelte/store';
import {
syncState,
syncError,
typingByRoom,
refreshRooms,
syncRoomsFromEvent,
upsertRoom,
addMessage,
addReaction,
removeReaction,
loadRoomMessages,
updatePresence,
selectedRoomId
} from '$lib/stores/matrix';
import type { Message } from '$lib/matrix/types';
import { getMessageType, stripReplyFallback } from '$lib/matrix/messageUtils';
import { onClientEvent, removeClientEventListeners, type SyncStateValue } from '$lib/matrix/sdk-types';
/**
* Set up event listeners on the Matrix client to sync with Svelte stores
*/
export function setupSyncHandlers(client: MatrixClient): void {
// Sync state changes
onClientEvent(client, 'sync', (state, prevState, data) => {
syncState.set(state as SyncStateValue);
if (state === 'ERROR') {
syncError.set(data?.error?.message || 'Sync error');
} else {
syncError.set(null);
}
// When sync is prepared, load rooms and refresh selected room messages
if (state === 'PREPARED' || state === 'SYNCING') {
refreshRooms();
// On initial sync completion, reload messages for the selected room
// This ensures we have the canonical state and removes any duplicates
// that may have been added during the sync process
if (state === 'PREPARED') {
const currentRoomId = get(selectedRoomId);
if (currentRoomId) {
loadRoomMessages(currentRoomId);
}
}
}
});
// New room events - use targeted update
onClientEvent(client, 'Room', (room: Room) => {
if (room?.roomId) {
syncRoomsFromEvent('join', room.roomId);
}
});
// Consolidated Room.timeline event dispatcher
// Handles messages, edits, and reactions in a single pass
onClientEvent(client, 'Room.timeline', (event, room, toStartOfTimeline) => {
if (!room || toStartOfTimeline) return;
const eventType = event.getType();
const content = event.getContent();
const sender = event.getSender();
// Dispatch based on event type
switch (eventType) {
case 'm.room.message': {
if (!sender) return;
// Skip edit events - handled by reloading messages
if (content['m.relates_to']?.rel_type === 'm.replace') {
loadRoomMessages(room.roomId);
return;
}
// Get sender info
const member = room.getMember(sender);
const senderName = member?.name || sender;
const senderAvatar = member?.getAvatarUrl(client.baseUrl, 40, 40, 'crop', true, true) || null;
// Determine message type
const type = getMessageType(content.msgtype || 'm.text');
// Strip reply fallback from content
const hasReply = !!content['m.relates_to']?.['m.in_reply_to'];
const messageContent = stripReplyFallback(content.body || '', hasReply);
const message: Message = {
eventId: event.getId() || '',
roomId: room.roomId,
sender,
senderName,
senderAvatar,
content: messageContent,
timestamp: event.getTs(),
type,
isEdited: false,
isRedacted: false,
replyTo: content['m.relates_to']?.['m.in_reply_to']?.event_id,
reactions: new Map(),
};
addMessage(room.roomId, message);
break;
}
case 'm.reaction': {
const relatesTo = content['m.relates_to'];
if (relatesTo?.rel_type === 'm.annotation') {
const targetEventId = relatesTo.event_id;
const emoji = relatesTo.key;
const reactionEventId = event.getId();
if (targetEventId && emoji && sender && reactionEventId) {
addReaction(room.roomId, targetEventId, emoji, sender, reactionEventId);
}
}
break;
}
}
});
// Consolidated Room.redaction handler
onClientEvent(client, 'Room.redaction', (event, room) => {
if (!room) return;
// Reload messages to reflect redactions (both messages and reactions)
loadRoomMessages(room.roomId);
});
// Typing indicators
onClientEvent(client, 'RoomMember.typing', (event) => {
const roomId = event.getRoomId();
if (!roomId) return;
const room = client.getRoom(roomId);
if (!room) return;
// Get list of typing users (excluding self)
const typingMembers = room.currentState.getStateEvents('m.room.member')
.filter((e: MatrixEvent) => {
const userId = e.getStateKey();
return userId !== client.getUserId();
})
.map((e: MatrixEvent) => e.getStateKey() || '')
.filter((userId: string) => {
// Check if user is actually typing
const memberEvent = room.getMember(userId);
return memberEvent?.typing;
});
typingByRoom.update(map => {
map.set(roomId, typingMembers);
return new Map(map);
});
});
// Room membership changes - targeted update for specific room
onClientEvent(client, 'RoomMember.membership', (event: MatrixEvent, member: RoomMember) => {
const roomId = event.getRoomId();
if (!roomId) return;
const membership = member?.membership;
if (membership === 'join') {
syncRoomsFromEvent('join', roomId);
} else if (membership === 'leave' || membership === 'ban') {
// Check if it's the current user leaving
const userId = member?.userId;
if (userId === client.getUserId()) {
syncRoomsFromEvent('leave', roomId);
} else {
// Another user left/was banned - just update the room
syncRoomsFromEvent('update', roomId);
}
} else {
syncRoomsFromEvent('update', roomId);
}
});
// Room state changes (name, avatar) - targeted update
onClientEvent(client, 'RoomState.events', (event: MatrixEvent) => {
const eventType = event.getType();
if (eventType === 'm.room.name' || eventType === 'm.room.avatar') {
const roomId = event.getRoomId();
if (roomId) {
syncRoomsFromEvent('update', roomId);
}
}
});
// User presence events
onClientEvent(client, 'User.presence', (event, user) => {
if (!user?.userId) return;
updatePresence(user.userId, user.presence || 'offline');
});
}
/**
* Remove event listeners (call on logout/cleanup)
*/
export function removeSyncHandlers(client: MatrixClient): void {
removeClientEventListeners(client, 'sync');
removeClientEventListeners(client, 'Room');
removeClientEventListeners(client, 'Room.timeline');
removeClientEventListeners(client, 'RoomMember.typing');
removeClientEventListeners(client, 'RoomMember.membership');
removeClientEventListeners(client, 'RoomState.events');
removeClientEventListeners(client, 'Room.redaction');
removeClientEventListeners(client, 'User.presence');
}

88
src/lib/matrix/types.ts Normal file
View File

@@ -0,0 +1,88 @@
/**
* Matrix Types
*
* Type definitions for Matrix events and data structures
*/
export type SyncState = 'STOPPED' | 'SYNCING' | 'PREPARED' | 'CATCHUP' | 'RECONNECTING' | 'ERROR';
export type PresenceState = 'online' | 'offline' | 'unavailable';
export interface UserPresence {
userId: string;
presence: PresenceState;
lastActiveAgo?: number;
statusMsg?: string;
currentlyActive?: boolean;
}
export interface RoomMember {
userId: string;
name: string;
avatarUrl: string | null;
membership: 'join' | 'invite' | 'leave' | 'ban';
powerLevel: number;
presence?: PresenceState;
}
export interface MediaInfo {
url: string; // mxc:// URL
httpUrl?: string; // HTTP URL for display
mimetype?: string;
size?: number;
width?: number;
height?: number;
filename?: string;
thumbnailUrl?: string;
}
export interface Message {
eventId: string;
roomId: string;
sender: string;
senderName: string;
senderAvatar: string | null;
content: string;
timestamp: number;
type: 'text' | 'image' | 'video' | 'audio' | 'file' | 'notice' | 'emote';
isEdited: boolean;
isRedacted: boolean;
isPending?: boolean; // True while message is being sent
replyTo?: string;
reactions: Map<string, Map<string, string>>; // emoji -> userId -> reactionEventId
media?: MediaInfo; // For image/video/audio/file messages
}
export interface RoomSummary {
roomId: string;
name: string;
avatarUrl: string | null;
topic: string | null;
isDirect: boolean;
isEncrypted: boolean;
isSpace: boolean; // True if this is a space (organization)
parentSpaceId: string | null; // The space this room belongs to, null for orphan rooms
memberCount: number;
unreadCount: number;
lastMessage: Message | null;
lastActivity: number;
}
export interface TypingInfo {
roomId: string;
userIds: string[];
}
export interface ReadReceipt {
eventId: string;
userId: string;
timestamp: number;
}
export interface Space {
roomId: string;
name: string;
avatarUrl: string | null;
childRooms: string[];
childSpaces: string[];
}

23
src/lib/services/index.ts Normal file
View File

@@ -0,0 +1,23 @@
/**
* Services Barrel Export
*
* Centralized exports for all service modules.
*/
export {
reactionService,
addReaction,
removeReaction,
toggleReaction,
clearPendingOperations,
isOperationPending,
hasPendingOperations,
pendingReactionsList,
categorizeReactionError,
isTransientError,
ReactionErrorType,
type ReactionOperation,
type ReactionOperationType,
type ReactionOperationStatus,
type CategorizedError,
} from './reactions';

View File

@@ -0,0 +1,354 @@
/**
* Reaction Service
*
* Handles all reaction operations with optimistic updates, idempotency checks,
* and proper error categorization. Extracted from +page.svelte to achieve
* separation of concerns.
*/
import { writable, derived, get } from 'svelte/store';
import {
sendReaction as matrixSendReaction,
removeReaction as matrixRemoveReaction,
} from '$lib/matrix/client';
import {
addReaction as storeAddReaction,
removeReaction as storeRemoveReaction,
} from '$lib/stores/matrix';
// ============================================================================
// Types
// ============================================================================
export type ReactionOperationType = 'add' | 'remove';
export type ReactionOperationStatus = 'pending' | 'success' | 'error';
export interface ReactionOperation {
roomId: string;
messageId: string;
emoji: string;
type: ReactionOperationType;
status: ReactionOperationStatus;
timestamp: number;
}
/**
* Error categories for reaction operations.
* Using typed discrimination instead of string matching.
*/
export enum ReactionErrorType {
/** User already has this reaction - idempotent, safe to ignore */
AlreadyReacted = 'ALREADY_REACTED',
/** Duplicate request in flight */
DuplicateRequest = 'DUPLICATE_REQUEST',
/** SDK internal state issue */
SdkStateError = 'SDK_STATE_ERROR',
/** Network connectivity issue */
NetworkError = 'NETWORK_ERROR',
/** Unknown/unexpected error */
Unknown = 'UNKNOWN',
}
export interface CategorizedError {
type: ReactionErrorType;
message: string;
original: unknown;
isTransient: boolean;
}
// ============================================================================
// Error Categorization
// ============================================================================
/**
* Categorize an error into a typed discrimination.
* Replaces the string-matching `isIgnorableReactionError` function.
*/
export function categorizeReactionError(error: unknown): CategorizedError {
const message = error instanceof Error ? error.message : String(error);
const lowerMessage = message.toLowerCase();
// Already reacted - idempotent operation
if (lowerMessage.includes('already') || lowerMessage.includes('duplicate')) {
return {
type: ReactionErrorType.AlreadyReacted,
message: 'Reaction already exists',
original: error,
isTransient: true,
};
}
// SDK state errors (chronological ordering, pending events)
if (lowerMessage.includes('chronological') || lowerMessage.includes('pending')) {
return {
type: ReactionErrorType.SdkStateError,
message: 'SDK state synchronization issue',
original: error,
isTransient: true,
};
}
// Network errors
if (
lowerMessage.includes('networkerror') ||
lowerMessage.includes('fetch failed') ||
lowerMessage.includes('network')
) {
return {
type: ReactionErrorType.NetworkError,
message: 'Network connectivity issue',
original: error,
isTransient: true,
};
}
// Unknown error - not transient, should be reported
return {
type: ReactionErrorType.Unknown,
message,
original: error,
isTransient: false,
};
}
/**
* Check if an error should be silently ignored (transient errors)
*/
export function isTransientError(error: unknown): boolean {
return categorizeReactionError(error).isTransient;
}
// ============================================================================
// Reaction Service Store
// ============================================================================
/**
* Internal store for tracking pending operations.
* Key format: `${roomId}:${messageId}:${emoji}`
*/
const pendingOperations = writable<Map<string, ReactionOperation>>(new Map());
/**
* Derived store: Check if any operations are pending
*/
export const hasPendingOperations = derived(
pendingOperations,
($ops) => $ops.size > 0
);
/**
* Derived store: Get all pending operations as array
*/
export const pendingReactionsList = derived(
pendingOperations,
($ops) => Array.from($ops.values())
);
// ============================================================================
// Service Functions
// ============================================================================
/**
* Generate a unique key for a reaction operation
*/
function getOperationKey(roomId: string, messageId: string, emoji: string): string {
return `${roomId}:${messageId}:${emoji}`;
}
/**
* Check if an operation is currently pending
*/
export function isOperationPending(roomId: string, messageId: string, emoji: string): boolean {
const key = getOperationKey(roomId, messageId, emoji);
return get(pendingOperations).has(key);
}
/**
* Generate a temporary event ID for optimistic updates
*/
function generateTempEventId(): string {
return `~pending-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
/**
* Add a reaction to a message.
* Applies optimistic update immediately, then confirms with server.
* On failure, rolls back the optimistic update without full reload.
*
* @returns Promise that resolves on success, rejects with CategorizedError on failure
*/
export async function addReaction(
roomId: string,
messageId: string,
emoji: string,
userId: string
): Promise<void> {
const key = getOperationKey(roomId, messageId, emoji);
// Idempotency check - prevent duplicate in-flight requests
if (get(pendingOperations).has(key)) {
return; // Silently ignore duplicate request
}
// Generate temporary ID for optimistic update
const tempEventId = generateTempEventId();
// Track the operation with temp ID for potential rollback
pendingOperations.update((ops) => {
ops.set(key, {
roomId,
messageId,
emoji,
type: 'add',
status: 'pending',
timestamp: Date.now(),
});
return new Map(ops);
});
// OPTIMISTIC UPDATE: Add reaction to store immediately
storeAddReaction(roomId, messageId, emoji, userId, tempEventId);
try {
// Send to Matrix server
await matrixSendReaction(roomId, messageId, emoji);
// Success - SDK sync will replace temp ID with real event ID
// Clean up pending state
pendingOperations.update((ops) => {
ops.delete(key);
return new Map(ops);
});
} catch (error) {
// ROLLBACK: Remove the optimistic reaction
storeRemoveReaction(roomId, messageId, emoji, userId);
// Clean up pending state
pendingOperations.update((ops) => {
ops.delete(key);
return new Map(ops);
});
const categorized = categorizeReactionError(error);
// Only throw for non-transient errors
if (!categorized.isTransient) {
throw categorized;
}
}
}
/**
* Remove a reaction from a message.
* Applies optimistic update immediately, then confirms with server.
* On failure, rolls back by re-adding the reaction without full reload.
*
* @returns Promise that resolves on success, rejects with CategorizedError on failure
*/
export async function removeReaction(
roomId: string,
messageId: string,
emoji: string,
userId: string,
reactionEventId: string
): Promise<void> {
const key = getOperationKey(roomId, messageId, emoji);
// Idempotency check
if (get(pendingOperations).has(key)) {
return;
}
// Track the operation
pendingOperations.update((ops) => {
ops.set(key, {
roomId,
messageId,
emoji,
type: 'remove',
status: 'pending',
timestamp: Date.now(),
});
return new Map(ops);
});
// OPTIMISTIC UPDATE: Remove from store immediately
storeRemoveReaction(roomId, messageId, emoji, userId);
try {
// Send redaction to Matrix server
await matrixRemoveReaction(roomId, reactionEventId);
// Clean up pending state
pendingOperations.update((ops) => {
ops.delete(key);
return new Map(ops);
});
} catch (error) {
// ROLLBACK: Re-add the reaction we just removed
storeAddReaction(roomId, messageId, emoji, userId, reactionEventId);
// Clean up pending state
pendingOperations.update((ops) => {
ops.delete(key);
return new Map(ops);
});
const categorized = categorizeReactionError(error);
// Only throw for non-transient errors
if (!categorized.isTransient) {
throw categorized;
}
}
}
/**
* Toggle a reaction on a message.
* If user has reacted, removes; otherwise adds.
*
* @param reactionEventId - The event ID of existing reaction (null if not reacted)
*/
export async function toggleReaction(
roomId: string,
messageId: string,
emoji: string,
userId: string,
reactionEventId: string | null
): Promise<void> {
if (reactionEventId) {
// User has already reacted - remove it
await removeReaction(roomId, messageId, emoji, userId, reactionEventId);
} else {
// User hasn't reacted - add it
await addReaction(roomId, messageId, emoji, userId);
}
}
/**
* Clear all pending operations (e.g., on logout or room switch)
*/
export function clearPendingOperations(): void {
pendingOperations.set(new Map());
}
// ============================================================================
// Exports
// ============================================================================
export const reactionService = {
// Actions
add: addReaction,
remove: removeReaction,
toggle: toggleReaction,
clear: clearPendingOperations,
// Queries
isPending: isOperationPending,
hasPending: hasPendingOperations,
pendingList: pendingReactionsList,
// Error handling
categorizeError: categorizeReactionError,
isTransient: isTransientError,
};

841
src/lib/stores/matrix.ts Normal file
View File

@@ -0,0 +1,841 @@
/**
* Matrix Stores
*
* Reactive Svelte stores that sync with Matrix client state.
* These stores are the single source of truth for Matrix data in the UI.
*/
import { writable, derived, get, readable } from 'svelte/store';
import type { Room, MatrixEvent, MatrixClient } from 'matrix-js-sdk';
import type { SyncState, RoomSummary, Message, TypingInfo, MediaInfo } from '$lib/matrix/types';
import { getClient, isClientInitialized, isRoomEncrypted } from '$lib/matrix/client';
import { getMessageType, stripReplyFallback } from '$lib/matrix/messageUtils';
import {
initCache,
cacheMessages,
getCachedMessages,
cacheRooms,
getCachedRooms,
isCacheAvailable,
} from '$lib/cache';
// ============================================================================
// Auth State
// ============================================================================
export interface AuthState {
isLoggedIn: boolean;
userId: string | null;
homeserverUrl: string | null;
accessToken: string | null;
deviceId: string | null;
}
const initialAuthState: AuthState = {
isLoggedIn: false,
userId: null,
homeserverUrl: null,
accessToken: null,
deviceId: null,
};
export const auth = writable<AuthState>(initialAuthState);
// ============================================================================
// Sync State
// ============================================================================
export const syncState = writable<SyncState>('STOPPED');
export const syncError = writable<string | null>(null);
// ============================================================================
// Rooms (Normalized Store Architecture)
// ============================================================================
/**
* PRIMARY STORE: Normalized Map<roomId, Room>
* All room operations are O(1) - no secondary index needed
*/
const _roomsById = writable<Map<string, Room>>(new Map());
/**
* DERIVED: Array view for iteration (computed from Map)
* Used by components that need to iterate over rooms
*/
export const rooms = derived(_roomsById, ($map) => [...$map.values()]);
/**
* O(1) room lookup by ID - direct Map access
*/
export function getRoom(roomId: string): Room | undefined {
return get(_roomsById).get(roomId);
}
/**
* O(1) upsert - no index maintenance required
*/
export function upsertRoom(room: Room): void {
_roomsById.update(map => {
const newMap = new Map(map);
newMap.set(room.roomId, room);
return newMap;
});
}
/**
* O(1) remove - no rebuild required
*/
export function removeRoom(roomId: string): void {
_roomsById.update(map => {
if (!map.has(roomId)) return map;
const newMap = new Map(map);
newMap.delete(roomId);
return newMap;
});
}
/**
* Bulk set rooms from SDK - used on initial sync
*/
function setRoomsFromSDK(): void {
if (!isClientInitialized()) return;
const client = getClient();
const joinedRooms = client.getRooms().filter(r => r.getMyMembership() === 'join');
const roomMap = new Map<string, Room>();
joinedRooms.forEach(room => roomMap.set(room.roomId, room));
_roomsById.set(roomMap);
}
/**
* @deprecated Use targeted update functions instead
* Kept for backward compatibility during migration
*/
export function refreshRooms(): void {
setRoomsFromSDK();
}
/**
* Sync rooms from SDK for a specific event type
* All operations are O(1)
*/
export function syncRoomsFromEvent(eventType: 'join' | 'leave' | 'update', roomId?: string): void {
if (!isClientInitialized()) return;
const client = getClient();
switch (eventType) {
case 'join': {
if (roomId) {
const room = client.getRoom(roomId);
if (room && room.getMyMembership() === 'join') {
upsertRoom(room);
}
}
break;
}
case 'leave': {
if (roomId) {
removeRoom(roomId);
}
break;
}
case 'update': {
if (roomId) {
const room = client.getRoom(roomId);
if (room && room.getMyMembership() === 'join') {
upsertRoom(room);
}
} else {
setRoomsFromSDK();
}
break;
}
}
}
export const selectedRoomId = writable<string | null>(null);
/**
* O(1) selected room lookup - uses Map directly
*/
export const selectedRoom = derived(
[_roomsById, selectedRoomId],
([$roomsById, $selectedRoomId]) => {
if (!$selectedRoomId) return null;
return $roomsById.get($selectedRoomId) ?? null;
}
);
// ============================================================================
// Room Summaries (Memoized Derived Store)
// ============================================================================
/**
* Memoization cache for room summaries
* Only recomputes when room IDs change or activity timestamps change
*/
interface RoomSummaryCache {
roomIds: Set<string>;
lastActivityMap: Map<string, number>;
summaries: RoomSummary[];
}
let _summaryCache: RoomSummaryCache | null = null;
/**
* Check if cache is valid (room set unchanged and no activity changes)
*/
function isSummaryCacheValid(currentRooms: Room[]): boolean {
if (!_summaryCache) return false;
// Check if room count changed
if (currentRooms.length !== _summaryCache.roomIds.size) return false;
// Check if any room was added/removed or activity changed
for (const room of currentRooms) {
if (!_summaryCache.roomIds.has(room.roomId)) return false;
const lastEvent = room.timeline[room.timeline.length - 1];
const lastActivity = lastEvent?.getTs() || 0;
const cachedActivity = _summaryCache.lastActivityMap.get(room.roomId) || 0;
if (lastActivity !== cachedActivity) return false;
}
return true;
}
/**
* Transform a Room to RoomSummary
*/
function roomToSummary(room: Room, spaceChildMap: Map<string, string>): RoomSummary {
const lastEvent = room.timeline[room.timeline.length - 1];
const createEvent = room.currentState.getStateEvents('m.room.create', '') as MatrixEvent | null;
const roomType = createEvent?.getContent()?.type;
const isSpace = roomType === 'm.space';
return {
roomId: room.roomId,
name: room.name || 'Unnamed Room',
avatarUrl: room.getAvatarUrl(getClient()?.baseUrl || '', 40, 40, 'crop') || null,
topic: (room.currentState.getStateEvents('m.room.topic', '') as MatrixEvent | null)?.getContent()?.topic || null,
isDirect: room.getDMInviter() !== undefined,
isEncrypted: room.hasEncryptionStateEvent(),
isSpace,
parentSpaceId: spaceChildMap.get(room.roomId) || null,
memberCount: room.getJoinedMemberCount(),
unreadCount: room.getUnreadNotificationCount() || 0,
lastMessage: lastEvent ? eventToMessage(lastEvent, room) : null,
lastActivity: lastEvent?.getTs() || 0,
};
}
/**
* Build space-child mapping for parent space detection
*/
function buildSpaceChildMap(rooms: Room[]): Map<string, string> {
const spaceChildMap = new Map<string, string>();
for (const room of rooms) {
const createEvent = room.currentState.getStateEvents('m.room.create', '') as MatrixEvent | null;
const roomType = createEvent?.getContent()?.type;
if (roomType === 'm.space') {
const childEvents = room.currentState.getStateEvents('m.space.child');
if (Array.isArray(childEvents)) {
for (const event of childEvents) {
const childId = (event as MatrixEvent).getStateKey();
if (childId && (event as MatrixEvent).getContent()?.via) {
spaceChildMap.set(childId, room.roomId);
}
}
}
}
}
return spaceChildMap;
}
/**
* MEMOIZED room summaries - only recomputes on actual changes
* Avoids O(n log n) sort on every sync event
*/
export const roomSummaries = derived(rooms, ($rooms): RoomSummary[] => {
// Fast path: return cached if valid
if (isSummaryCacheValid($rooms)) {
return _summaryCache!.summaries;
}
// Slow path: recompute summaries
const spaceChildMap = buildSpaceChildMap($rooms);
const summaries = $rooms
.map(room => roomToSummary(room, spaceChildMap))
.sort((a, b) => b.lastActivity - a.lastActivity);
// Update cache
const roomIds = new Set<string>();
const lastActivityMap = new Map<string, number>();
for (const room of $rooms) {
roomIds.add(room.roomId);
const lastEvent = room.timeline[room.timeline.length - 1];
lastActivityMap.set(room.roomId, lastEvent?.getTs() || 0);
}
_summaryCache = { roomIds, lastActivityMap, summaries };
return summaries;
});
/**
* Total unread count across all rooms (for nav badge)
*/
export const totalUnreadCount = derived(roomSummaries, ($summaries): number => {
return $summaries.reduce((sum, room) => sum + (room.isSpace ? 0 : room.unreadCount), 0);
});
// ============================================================================
// Messages
// ============================================================================
// Map of roomId -> messages array
export const messagesByRoom = writable<Map<string, Message[]>>(new Map());
// Secondary index: roomId -> eventId -> array index (for O(1) lookup)
const messageIndexByRoom = new Map<string, Map<string, number>>();
/**
* Rebuild the message index for a room
*/
function rebuildMessageIndex(roomId: string, messages: Message[]): void {
const indexMap = new Map<string, number>();
messages.forEach((msg, idx) => indexMap.set(msg.eventId, idx));
messageIndexByRoom.set(roomId, indexMap);
}
/**
* Get message index by eventId (O(1) lookup)
*/
function getMessageIndex(roomId: string, eventId: string): number {
return messageIndexByRoom.get(roomId)?.get(eventId) ?? -1;
}
// Derived: messages for selected room
export const currentMessages = derived(
[messagesByRoom, selectedRoomId],
([$messagesByRoom, $selectedRoomId]) => {
if (!$selectedRoomId) return [];
return $messagesByRoom.get($selectedRoomId) || [];
}
);
// ============================================================================
// Typing Indicators
// ============================================================================
export const typingByRoom = writable<Map<string, string[]>>(new Map());
export const currentTyping = derived(
[typingByRoom, selectedRoomId],
([$typingByRoom, $selectedRoomId]) => {
if (!$selectedRoomId) return [];
return $typingByRoom.get($selectedRoomId) || [];
}
);
// ============================================================================
// Helpers
// ============================================================================
/**
* LRU Cache for memoizing message transformations
* Prevents O(n) re-processing of timeline events
*/
class MessageCache {
private cache = new Map<string, { message: Message; timestamp: number }>();
private maxSize: number;
private maxAge: number; // in milliseconds
constructor(maxSize = 500, maxAgeMs = 5 * 60 * 1000) {
this.maxSize = maxSize;
this.maxAge = maxAgeMs;
}
get(eventId: string): Message | null {
const entry = this.cache.get(eventId);
if (!entry) return null;
// Check if entry is stale
if (Date.now() - entry.timestamp > this.maxAge) {
this.cache.delete(eventId);
return null;
}
// Move to end (most recently used)
this.cache.delete(eventId);
this.cache.set(eventId, entry);
return entry.message;
}
set(eventId: string, message: Message): void {
// Delete if exists to update position
if (this.cache.has(eventId)) {
this.cache.delete(eventId);
} else if (this.cache.size >= this.maxSize) {
// Delete oldest entry
const firstKey = this.cache.keys().next().value;
if (firstKey) this.cache.delete(firstKey);
}
this.cache.set(eventId, { message, timestamp: Date.now() });
}
invalidate(eventId: string): void {
this.cache.delete(eventId);
}
clear(): void {
this.cache.clear();
}
}
const messageCache = new MessageCache();
/**
* Convert a MatrixEvent to our Message type
* Uses memoization to prevent redundant transformations
*/
function eventToMessage(event: MatrixEvent, room?: Room | null, skipCache = false): Message | null {
if (event.getType() !== 'm.room.message') return null;
const eventId = event.getId();
if (!eventId) return null;
// Check cache first (unless skipCache is set for edited messages)
if (!skipCache) {
const cached = messageCache.get(eventId);
if (cached) return cached;
}
// Check if this is an edit (m.replace relation) - skip it as standalone message
const relatesTo = event.getContent()['m.relates_to'];
if (relatesTo?.rel_type === 'm.replace') return null;
// Get the actual content (use replacement if edited)
const replacingEvent = event.replacingEvent();
const content = replacingEvent
? replacingEvent.getContent()['m.new_content'] || event.getContent()
: event.getContent();
const sender = event.getSender();
if (!sender) return null;
// Get sender info
let senderName = sender;
let senderAvatar: string | null = null;
if (isClientInitialized()) {
const client = getClient();
const roomObj = room || client.getRoom(event.getRoomId() || '');
if (roomObj) {
const member = roomObj.getMember(sender);
if (member) {
senderName = member.name || sender;
senderAvatar = member.getAvatarUrl(client.baseUrl, 40, 40, 'crop', true, true) || null;
}
}
}
// Determine message type
const type = getMessageType(content.msgtype);
// Extract media info for image/video/audio/file messages
let media: MediaInfo | undefined;
if (['image', 'video', 'audio', 'file'].includes(type) && content.url) {
const client = isClientInitialized() ? getClient() : null;
const info = content.info || {};
media = {
url: content.url,
httpUrl: client ? (client.mxcUrlToHttp(content.url) || undefined) : undefined,
mimetype: info.mimetype,
size: info.size,
width: info.w,
height: info.h,
filename: content.filename || content.body,
thumbnailUrl: info.thumbnail_url && client
? (client.mxcUrlToHttp(info.thumbnail_url) || undefined)
: undefined,
};
}
// Aggregate reactions from related events
// Using nested Map: emoji -> userId -> reactionEventId
const reactions = new Map<string, Map<string, string>>();
// Strip Matrix reply fallback from content
const hasReply = !!content['m.relates_to']?.['m.in_reply_to'];
const messageContent = stripReplyFallback(content.body || '', hasReply);
const message: Message = {
eventId,
roomId: event.getRoomId() || '',
sender,
senderName,
senderAvatar,
content: messageContent,
timestamp: event.getTs(),
type,
isEdited: !!replacingEvent,
isRedacted: event.isRedacted(),
replyTo: content['m.relates_to']?.['m.in_reply_to']?.event_id,
reactions,
media,
};
// Cache the transformed message
messageCache.set(eventId, message);
return message;
}
/**
* Invalidate cached message (call when message is edited)
*/
export function invalidateMessageCache(eventId: string): void {
messageCache.invalidate(eventId);
}
// ============================================================================
// Actions
// ============================================================================
/**
* Load messages for a room
*/
export function loadRoomMessages(roomId: string): void {
if (!isClientInitialized()) return;
const client = getClient();
const room = client.getRoom(roomId);
if (!room) return;
const timeline = room.getLiveTimeline();
const events = timeline.getEvents();
// First, collect all reaction events
// Using nested Map: messageEventId -> emoji -> userId -> reactionEventId
const reactionsByEventId = new Map<string, Map<string, Map<string, string>>>();
for (const event of events) {
if (event.getType() === 'm.reaction' && !event.isRedacted()) {
const content = event.getContent();
const relatesTo = content['m.relates_to'];
if (relatesTo?.rel_type === 'm.annotation') {
const targetEventId = relatesTo.event_id;
const emoji = relatesTo.key;
const sender = event.getSender();
const reactionEventId = event.getId();
if (targetEventId && emoji && sender && reactionEventId) {
if (!reactionsByEventId.has(targetEventId)) {
reactionsByEventId.set(targetEventId, new Map());
}
const emojiMap = reactionsByEventId.get(targetEventId)!;
const userMap = emojiMap.get(emoji) ?? new Map<string, string>();
// O(1) check and set
if (!userMap.has(sender)) {
userMap.set(sender, reactionEventId);
emojiMap.set(emoji, userMap);
}
}
}
}
}
const messages = events
.filter(e => e.getType() === 'm.room.message')
.map(e => eventToMessage(e, room))
.filter((m): m is Message => m !== null)
.map(m => {
// Attach reactions to messages
const reactions = reactionsByEventId.get(m.eventId);
if (reactions) {
m.reactions = reactions;
}
return m;
});
messagesByRoom.update(map => {
map.set(roomId, messages);
return new Map(map);
});
// Rebuild O(1) lookup index
rebuildMessageIndex(roomId, messages);
// Cache messages in background
if (isCacheAvailable()) {
cacheMessages(roomId, messages).catch(() => {
// Silently ignore cache errors
});
}
}
/**
* Add a single message to a room
* Uses O(1) index lookup for deduplication - no linear scan fallback
* Index integrity is maintained by all mutation functions
*/
export function addMessage(roomId: string, message: Message): void {
messagesByRoom.update(map => {
const existing = map.get(roomId) || [];
const roomIndex = messageIndexByRoom.get(roomId) ?? new Map<string, number>();
// O(1) deduplication check - index is authoritative
if (roomIndex.has(message.eventId)) {
return map;
}
// Check for pending message match using index scan of pending messages
// This is O(p) where p = pending messages, typically 0-2
let pendingMatchIndex = -1;
for (let i = existing.length - 1; i >= 0 && i >= existing.length - 10; i--) {
const m = existing[i];
if (
m.isPending &&
m.sender === message.sender &&
m.content === message.content &&
Math.abs(m.timestamp - message.timestamp) < 30000
) {
pendingMatchIndex = i;
break;
}
}
if (pendingMatchIndex !== -1) {
// Replace pending message with confirmed one
const pendingMessage = existing[pendingMatchIndex];
const updatedMessages = [...existing];
updatedMessages[pendingMatchIndex] = { ...message, isPending: false };
map.set(roomId, updatedMessages);
// Update index: remove pending eventId, add real eventId
roomIndex.delete(pendingMessage.eventId);
roomIndex.set(message.eventId, pendingMatchIndex);
messageIndexByRoom.set(roomId, roomIndex);
return new Map(map);
}
// Append new message
const newMessages = [...existing, message];
map.set(roomId, newMessages);
// Update index
roomIndex.set(message.eventId, newMessages.length - 1);
messageIndexByRoom.set(roomId, roomIndex);
// Cache in background
if (isCacheAvailable()) {
cacheMessages(roomId, [message]).catch(() => { });
}
return new Map(map);
});
}
/**
* Add a pending message (optimistic update before send completes)
* Maintains index integrity for O(1) lookups
*/
export function addPendingMessage(roomId: string, message: Message): void {
messagesByRoom.update(map => {
const existing = map.get(roomId) || [];
const pendingMessage = { ...message, isPending: true };
const newMessages = [...existing, pendingMessage];
map.set(roomId, newMessages);
// Add to index
const roomIndex = messageIndexByRoom.get(roomId) ?? new Map<string, number>();
roomIndex.set(pendingMessage.eventId, newMessages.length - 1);
messageIndexByRoom.set(roomId, roomIndex);
return new Map(map);
});
}
/**
* Update a pending message with real event ID after send completes
* Maintains index integrity
*/
export function confirmPendingMessage(roomId: string, tempEventId: string, realEventId: string): void {
messagesByRoom.update(map => {
const messages = map.get(roomId);
if (!messages) return map;
const roomIndex = messageIndexByRoom.get(roomId);
const messageIdx = roomIndex?.get(tempEventId) ?? -1;
if (messageIdx === -1) return map;
const updatedMessages = [...messages];
updatedMessages[messageIdx] = {
...updatedMessages[messageIdx],
eventId: realEventId,
isPending: false,
};
map.set(roomId, updatedMessages);
// Update index: remove temp, add real
if (roomIndex) {
roomIndex.delete(tempEventId);
roomIndex.set(realEventId, messageIdx);
}
return new Map(map);
});
}
/**
* Remove a pending message (if send fails)
* Rebuilds index after removal to maintain integrity
*/
export function removePendingMessage(roomId: string, tempEventId: string): void {
messagesByRoom.update(map => {
const messages = map.get(roomId);
if (!messages) return map;
const filteredMessages = messages.filter(m => m.eventId !== tempEventId);
map.set(roomId, filteredMessages);
// Rebuild index for this room
rebuildMessageIndex(roomId, filteredMessages);
return new Map(map);
});
}
/**
* Add a reaction to a message
* Uses nested Map structure: emoji -> userId -> reactionEventId for O(1) access
* Uses O(1) index lookup for message finding
*/
export function addReaction(roomId: string, eventId: string, emoji: string, userId: string, reactionEventId: string): void {
messagesByRoom.update(map => {
const messages = map.get(roomId);
if (!messages) return map;
// O(1) lookup using index
const messageIndex = getMessageIndex(roomId, eventId);
if (messageIndex === -1) return map;
const message = messages[messageIndex];
const reactions = new Map(message.reactions);
// Get or create the user map for this emoji
const userMap = reactions.get(emoji) ?? new Map<string, string>();
// O(1) check and set
if (!userMap.has(userId)) {
userMap.set(userId, reactionEventId);
reactions.set(emoji, userMap);
}
const updatedMessages = [...messages];
updatedMessages[messageIndex] = { ...message, reactions };
map.set(roomId, updatedMessages);
return new Map(map);
});
}
/**
* Remove a reaction from a message
* Uses nested Map structure for O(1) access
* Uses O(1) index lookup for message finding
*/
export function removeReaction(roomId: string, eventId: string, emoji: string, userId: string): void {
messagesByRoom.update(map => {
const messages = map.get(roomId);
if (!messages) return map;
// O(1) lookup using index
const messageIndex = getMessageIndex(roomId, eventId);
if (messageIndex === -1) return map;
const message = messages[messageIndex];
const reactions = new Map(message.reactions);
const userMap = reactions.get(emoji);
if (userMap) {
// O(1) delete
userMap.delete(userId);
if (userMap.size === 0) {
reactions.delete(emoji);
} else {
reactions.set(emoji, userMap);
}
}
const updatedMessages = [...messages];
updatedMessages[messageIndex] = { ...message, reactions };
map.set(roomId, updatedMessages);
return new Map(map);
});
}
/**
* Select a room and load its messages
* Loads from cache first for instant display, then fetches fresh data
*/
export async function selectRoom(roomId: string | null): Promise<void> {
selectedRoomId.set(roomId);
if (roomId) {
// Load cached messages first for instant display
if (isCacheAvailable()) {
const cached = await getCachedMessages(roomId);
if (cached.length > 0) {
messagesByRoom.update(map => {
map.set(roomId, cached);
return new Map(map);
});
}
}
// Then load fresh messages (will update/replace cached)
loadRoomMessages(roomId);
}
}
// ============================================================================
// Presence
// ============================================================================
export type PresenceState = 'online' | 'offline' | 'unavailable';
// Map of userId -> presence state
export const userPresence = writable<Map<string, PresenceState>>(new Map());
/**
* Update a user's presence
*/
export function updatePresence(userId: string, presence: PresenceState): void {
userPresence.update(map => {
map.set(userId, presence);
return new Map(map);
});
}
/**
* Clear all state (on logout)
*/
export function clearState(): void {
auth.set(initialAuthState);
syncState.set('STOPPED');
syncError.set(null);
_roomsById.set(new Map());
selectedRoomId.set(null);
messagesByRoom.set(new Map());
typingByRoom.set(new Map());
userPresence.set(new Map());
messageCache.clear();
}

196
src/lib/stores/theme.ts Normal file
View File

@@ -0,0 +1,196 @@
/**
* Theme Store - Manages app theme (dark/light mode and accent colors)
*/
import { writable, derived } from 'svelte/store';
import { browser } from '$app/environment';
export type ThemeMode = 'dark' | 'light';
export interface ThemeColors {
primary: string;
name: string;
}
export const PRESET_COLORS: ThemeColors[] = [
{ name: 'Cyan', primary: '#00A3E0' },
{ name: 'Purple', primary: '#8B5CF6' },
{ name: 'Pink', primary: '#EC4899' },
{ name: 'Green', primary: '#10B981' },
{ name: 'Orange', primary: '#F97316' },
{ name: 'Red', primary: '#EF4444' },
];
const THEME_STORAGE_KEY = 'app_theme';
interface ThemeState {
mode: ThemeMode;
primaryColor: string;
}
const defaultTheme: ThemeState = {
mode: 'dark',
primaryColor: '#00A3E0',
};
// Convert hex to HSL
function hexToHSL(hex: string): { h: number; s: number; l: number } {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return { h: 0, s: 0, l: 0 };
let r = parseInt(result[1], 16) / 255;
let g = parseInt(result[2], 16) / 255;
let b = parseInt(result[3], 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return { h: h * 360, s: s * 100, l: l * 100 };
}
// Convert HSL to hex
function hslToHex(h: number, s: number, l: number): string {
s /= 100;
l /= 100;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}`;
}
// Generate derived colors from primary
function generateDerivedColors(primary: string, mode: ThemeMode) {
const { h, s } = hexToHSL(primary);
if (mode === 'dark') {
return {
night: hslToHex(h, Math.min(s, 40), 6), // 6% lightness - panels
dark: hslToHex(h, Math.min(s, 35), 10), // 10% lightness - elevated panels
background: hslToHex(h, Math.min(s, 30), 3), // 3% lightness - page background
light: '#e5e6f0', // Light color for text/icons
text: '#ffffff', // White text
textMuted: 'rgba(229, 230, 240, 0.5)',
};
} else {
// Light mode: use lower saturation to avoid too colorful backgrounds
const lightSat = Math.min(s, 30);
return {
night: hslToHex(h, lightSat, 92),
dark: hslToHex(h, lightSat, 85),
background: hslToHex(h, lightSat, 98),
light: '#1a1a2e',
text: '#0a121f',
textMuted: 'rgba(10, 18, 31, 0.6)',
};
}
}
function loadTheme(): ThemeState {
if (!browser) return defaultTheme;
try {
const stored = localStorage.getItem(THEME_STORAGE_KEY);
if (stored) {
return { ...defaultTheme, ...JSON.parse(stored) };
}
} catch (e) {
console.warn('Failed to load theme:', e);
}
return defaultTheme;
}
function saveTheme(theme: ThemeState): void {
if (!browser) return;
localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(theme));
}
function createThemeStore() {
const { subscribe, set, update } = writable<ThemeState>(loadTheme());
return {
subscribe,
setMode: (mode: ThemeMode) => {
update(state => {
const newState = { ...state, mode };
saveTheme(newState);
applyTheme(newState);
return newState;
});
},
setPrimaryColor: (color: string) => {
update(state => {
const newState = { ...state, primaryColor: color };
saveTheme(newState);
applyTheme(newState);
return newState;
});
},
toggleMode: () => {
update(state => {
const newMode: ThemeMode = state.mode === 'dark' ? 'light' : 'dark';
const newState: ThemeState = { ...state, mode: newMode };
saveTheme(newState);
applyTheme(newState);
return newState;
});
},
reset: () => {
set(defaultTheme);
saveTheme(defaultTheme);
applyTheme(defaultTheme);
},
};
}
export const theme = createThemeStore();
// Derived stores for convenience
export const isDarkMode = derived(theme, $t => $t.mode === 'dark');
export const primaryColor = derived(theme, $t => $t.primaryColor);
// Apply theme to document
export function applyTheme(state: ThemeState): void {
if (!browser) return;
const root = document.documentElement;
// Set mode class
root.classList.remove('dark', 'light');
root.classList.add(state.mode);
// Set CSS custom property for primary color
root.style.setProperty('--color-primary', state.primaryColor);
// Calculate hover variant
const { h, s, l } = hexToHSL(state.primaryColor);
root.style.setProperty('--color-primary-hover', hslToHex(h, s, Math.min(100, l + 10)));
// Generate and apply derived colors
const derived = generateDerivedColors(state.primaryColor, state.mode);
root.style.setProperty('--color-night', derived.night);
root.style.setProperty('--color-dark', derived.dark);
root.style.setProperty('--color-background', derived.background);
root.style.setProperty('--color-light', derived.light);
root.style.setProperty('--color-text', derived.text);
root.style.setProperty('--color-text-muted', derived.textMuted);
}
// Initialize theme on load
if (browser) {
applyTheme(loadTheme());
}

55
src/lib/stores/ui.ts Normal file
View File

@@ -0,0 +1,55 @@
/**
* UI Stores
*
* Re-exports toasts from the main toast store for backward compatibility
* with Matrix components, plus Matrix-specific UI state.
*/
import { writable } from 'svelte/store';
// Re-export toasts so Matrix components can import from '$lib/stores/ui'
export { toasts } from '$lib/stores/toast.svelte';
// ============================================================================
// Chat Layout State
// ============================================================================
export const sidebarOpen = writable(true);
export const membersPanelOpen = writable(false);
// ============================================================================
// Chat Modals
// ============================================================================
export type ModalType =
| 'none'
| 'createRoom'
| 'roomSettings'
| 'roomMembers'
| 'userProfile'
| 'settings';
export const activeModal = writable<ModalType>('none');
export const modalData = writable<any>(null);
export function openModal(type: ModalType, data?: any): void {
activeModal.set(type);
modalData.set(data ?? null);
}
export function closeModal(): void {
activeModal.set('none');
modalData.set(null);
}
// ============================================================================
// Loading States
// ============================================================================
export const isLoading = writable(false);
export const loadingMessage = writable<string | null>(null);
export function setLoading(loading: boolean, message?: string): void {
isLoading.set(loading);
loadingMessage.set(message ?? null);
}

View File

@@ -360,6 +360,100 @@ export type Database = {
},
]
}
event_members: {
Row: {
assigned_at: string | null
event_id: string
id: string
role: string
user_id: string
}
Insert: {
assigned_at?: string | null
event_id: string
id?: string
role?: string
user_id: string
}
Update: {
assigned_at?: string | null
event_id?: string
id?: string
role?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "event_members_event_id_fkey"
columns: ["event_id"]
isOneToOne: false
referencedRelation: "events"
referencedColumns: ["id"]
},
]
}
events: {
Row: {
color: string | null
cover_image_url: string | null
created_at: string | null
created_by: string | null
description: string | null
end_date: string | null
id: string
name: string
org_id: string
slug: string
start_date: string | null
status: string
updated_at: string | null
venue_address: string | null
venue_name: string | null
}
Insert: {
color?: string | null
cover_image_url?: string | null
created_at?: string | null
created_by?: string | null
description?: string | null
end_date?: string | null
id?: string
name: string
org_id: string
slug: string
start_date?: string | null
status?: string
updated_at?: string | null
venue_address?: string | null
venue_name?: string | null
}
Update: {
color?: string | null
cover_image_url?: string | null
created_at?: string | null
created_by?: string | null
description?: string | null
end_date?: string | null
id?: string
name?: string
org_id?: string
slug?: string
start_date?: string | null
status?: string
updated_at?: string | null
venue_address?: string | null
venue_name?: string | null
}
Relationships: [
{
foreignKeyName: "events_org_id_fkey"
columns: ["org_id"]
isOneToOne: false
referencedRelation: "organizations"
referencedColumns: ["id"]
},
]
}
kanban_boards: {
Row: {
created_at: string | null
@@ -613,6 +707,50 @@ export type Database = {
},
]
}
matrix_credentials: {
Row: {
access_token: string
created_at: string
device_id: string | null
homeserver_url: string
id: string
matrix_user_id: string
org_id: string
updated_at: string
user_id: string
}
Insert: {
access_token: string
created_at?: string
device_id?: string | null
homeserver_url: string
id?: string
matrix_user_id: string
org_id: string
updated_at?: string
user_id: string
}
Update: {
access_token?: string
created_at?: string
device_id?: string | null
homeserver_url?: string
id?: string
matrix_user_id?: string
org_id?: string
updated_at?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "matrix_credentials_org_id_fkey"
columns: ["org_id"]
isOneToOne: false
referencedRelation: "organizations"
referencedColumns: ["id"]
},
]
}
org_google_calendars: {
Row: {
calendar_id: string
@@ -748,6 +886,13 @@ export type Database = {
referencedRelation: "org_roles"
referencedColumns: ["id"]
},
{
foreignKeyName: "org_members_user_id_profiles_fk"
columns: ["user_id"]
isOneToOne: false
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
org_roles: {
@@ -803,6 +948,7 @@ export type Database = {
created_at: string | null
icon_url: string | null
id: string
matrix_space_id: string | null
name: string
slug: string
theme_color: string | null
@@ -813,6 +959,7 @@ export type Database = {
created_at?: string | null
icon_url?: string | null
id?: string
matrix_space_id?: string | null
name: string
slug: string
theme_color?: string | null
@@ -823,6 +970,7 @@ export type Database = {
created_at?: string | null
icon_url?: string | null
id?: string
matrix_space_id?: string | null
name?: string
slug?: string
theme_color?: string | null
@@ -1148,7 +1296,7 @@ export const Constants = {
},
} as const
// ── Convenience type aliases ──────────────────────────
// ── Convenience type aliases ─────────────────────────────────────────
export type MemberRole = 'owner' | 'admin' | 'editor' | 'viewer';
type PublicTables = Database['public']['Tables']
@@ -1171,3 +1319,6 @@ export type Team = PublicTables['teams']['Row']
export type OrgGoogleCalendar = PublicTables['org_google_calendars']['Row']
export type ActivityLog = PublicTables['activity_log']['Row']
export type UserPreferences = PublicTables['user_preferences']['Row']
export type MatrixCredentials = PublicTables['matrix_credentials']['Row']
export type EventRow = PublicTables['events']['Row']
export type EventMemberRow = PublicTables['event_members']['Row']

596
src/lib/utils/emojiData.ts Normal file
View File

@@ -0,0 +1,596 @@
// Emoji data with names for autocomplete
// Only includes emojis that have Twemoji support
export interface EmojiItem {
emoji: string;
names: string[];
category: string;
}
export const emojiData: EmojiItem[] = [
// Smileys & Emotion
{ emoji: "😀", names: ["grinning", "smile", "happy"], category: "smileys" },
{ emoji: "😃", names: ["smiley", "happy", "joy"], category: "smileys" },
{ emoji: "😄", names: ["smile", "happy", "joy"], category: "smileys" },
{ emoji: "😁", names: ["grin", "happy"], category: "smileys" },
{ emoji: "😆", names: ["laughing", "satisfied", "lol"], category: "smileys" },
{ emoji: "😅", names: ["sweat_smile", "nervous"], category: "smileys" },
{ emoji: "🤣", names: ["rofl", "rolling", "lmao"], category: "smileys" },
{ emoji: "😂", names: ["joy", "laugh", "lol", "crying_laughing"], category: "smileys" },
{ emoji: "🙂", names: ["slightly_smiling", "ok"], category: "smileys" },
{ emoji: "😊", names: ["blush", "happy", "smile"], category: "smileys" },
{ emoji: "😇", names: ["innocent", "angel", "halo"], category: "smileys" },
{ emoji: "🥰", names: ["smiling_hearts", "love", "adore"], category: "smileys" },
{ emoji: "😍", names: ["heart_eyes", "love", "crush"], category: "smileys" },
{ emoji: "🤩", names: ["star_struck", "excited", "wow"], category: "smileys" },
{ emoji: "😘", names: ["kissing_heart", "kiss", "love"], category: "smileys" },
{ emoji: "😗", names: ["kissing", "kiss"], category: "smileys" },
{ emoji: "😚", names: ["kissing_closed_eyes", "kiss"], category: "smileys" },
{ emoji: "😙", names: ["kissing_smiling_eyes", "kiss"], category: "smileys" },
{ emoji: "🥲", names: ["smiling_tear", "sad_happy"], category: "smileys" },
{ emoji: "😋", names: ["yum", "delicious", "tongue"], category: "smileys" },
{ emoji: "😛", names: ["stuck_out_tongue", "playful"], category: "smileys" },
{ emoji: "😜", names: ["wink_tongue", "crazy", "playful"], category: "smileys" },
{ emoji: "🤪", names: ["zany", "crazy", "wild"], category: "smileys" },
{ emoji: "😝", names: ["squinting_tongue", "playful"], category: "smileys" },
{ emoji: "🤑", names: ["money_mouth", "rich", "money"], category: "smileys" },
{ emoji: "🤗", names: ["hugging", "hug", "warm"], category: "smileys" },
{ emoji: "🤭", names: ["hand_over_mouth", "oops", "giggle"], category: "smileys" },
{ emoji: "🤫", names: ["shushing", "quiet", "secret"], category: "smileys" },
{ emoji: "🤔", names: ["thinking", "hmm", "consider"], category: "smileys" },
{ emoji: "🤐", names: ["zipper_mouth", "quiet", "secret"], category: "smileys" },
{ emoji: "🤨", names: ["raised_eyebrow", "skeptical", "sus"], category: "smileys" },
{ emoji: "😐", names: ["neutral", "meh", "blank"], category: "smileys" },
{ emoji: "😑", names: ["expressionless", "blank"], category: "smileys" },
{ emoji: "😶", names: ["no_mouth", "silent", "speechless"], category: "smileys" },
{ emoji: "😏", names: ["smirk", "smug"], category: "smileys" },
{ emoji: "😒", names: ["unamused", "meh", "bored"], category: "smileys" },
{ emoji: "🙄", names: ["eye_roll", "whatever", "annoyed"], category: "smileys" },
{ emoji: "😬", names: ["grimacing", "awkward", "cringe"], category: "smileys" },
{ emoji: "😌", names: ["relieved", "peaceful", "calm"], category: "smileys" },
{ emoji: "😔", names: ["pensive", "sad", "disappointed"], category: "smileys" },
{ emoji: "😪", names: ["sleepy", "tired"], category: "smileys" },
{ emoji: "🤤", names: ["drooling", "hungry", "want"], category: "smileys" },
{ emoji: "😴", names: ["sleeping", "zzz", "tired"], category: "smileys" },
{ emoji: "😷", names: ["mask", "sick", "ill"], category: "smileys" },
{ emoji: "🤒", names: ["thermometer", "sick", "fever"], category: "smileys" },
{ emoji: "🤕", names: ["bandage", "hurt", "injured"], category: "smileys" },
{ emoji: "🤢", names: ["nauseated", "sick", "green"], category: "smileys" },
{ emoji: "🤮", names: ["vomiting", "sick", "throw_up"], category: "smileys" },
{ emoji: "🤧", names: ["sneezing", "sick", "achoo"], category: "smileys" },
{ emoji: "🥵", names: ["hot", "heat", "sweating"], category: "smileys" },
{ emoji: "🥶", names: ["cold", "freezing", "frozen"], category: "smileys" },
{ emoji: "🥴", names: ["woozy", "drunk", "tipsy"], category: "smileys" },
{ emoji: "😵", names: ["dizzy", "dead", "knocked_out"], category: "smileys" },
{ emoji: "🤯", names: ["exploding_head", "mind_blown", "shocked"], category: "smileys" },
{ emoji: "🤠", names: ["cowboy", "yeehaw"], category: "smileys" },
{ emoji: "🥳", names: ["partying", "party", "celebrate"], category: "smileys" },
{ emoji: "🥸", names: ["disguised", "incognito"], category: "smileys" },
{ emoji: "😎", names: ["sunglasses", "cool", "awesome"], category: "smileys" },
{ emoji: "🤓", names: ["nerd", "geek", "smart"], category: "smileys" },
{ emoji: "🧐", names: ["monocle", "fancy", "hmm"], category: "smileys" },
{ emoji: "😕", names: ["confused", "puzzled"], category: "smileys" },
{ emoji: "😟", names: ["worried", "concerned"], category: "smileys" },
{ emoji: "🙁", names: ["slightly_frowning", "sad"], category: "smileys" },
{ emoji: "😮", names: ["open_mouth", "surprised", "wow"], category: "smileys" },
{ emoji: "😯", names: ["hushed", "surprised"], category: "smileys" },
{ emoji: "😲", names: ["astonished", "shocked", "wow"], category: "smileys" },
{ emoji: "😳", names: ["flushed", "embarrassed", "shy"], category: "smileys" },
{ emoji: "🥺", names: ["pleading", "puppy_eyes", "please"], category: "smileys" },
{ emoji: "😦", names: ["frowning", "sad"], category: "smileys" },
{ emoji: "😧", names: ["anguished", "worried"], category: "smileys" },
{ emoji: "😨", names: ["fearful", "scared", "afraid"], category: "smileys" },
{ emoji: "😰", names: ["anxious", "worried", "sweat"], category: "smileys" },
{ emoji: "😥", names: ["disappointed_relieved", "sad"], category: "smileys" },
{ emoji: "😢", names: ["cry", "sad", "tear"], category: "smileys" },
{ emoji: "😭", names: ["sob", "crying", "sad", "bawling"], category: "smileys" },
{ emoji: "😱", names: ["scream", "horror", "shocked"], category: "smileys" },
{ emoji: "😖", names: ["confounded", "frustrated"], category: "smileys" },
{ emoji: "😣", names: ["persevere", "struggling"], category: "smileys" },
{ emoji: "😞", names: ["disappointed", "sad"], category: "smileys" },
{ emoji: "😓", names: ["downcast_sweat", "tired"], category: "smileys" },
{ emoji: "😩", names: ["weary", "tired", "exhausted"], category: "smileys" },
{ emoji: "😫", names: ["tired_face", "exhausted"], category: "smileys" },
{ emoji: "🥱", names: ["yawning", "tired", "bored"], category: "smileys" },
{ emoji: "😤", names: ["triumph", "angry", "frustrated"], category: "smileys" },
{ emoji: "😡", names: ["rage", "angry", "mad"], category: "smileys" },
{ emoji: "😠", names: ["angry", "mad", "grumpy"], category: "smileys" },
{ emoji: "🤬", names: ["cursing", "swearing", "angry"], category: "smileys" },
{ emoji: "😈", names: ["smiling_imp", "devil", "evil"], category: "smileys" },
{ emoji: "👿", names: ["imp", "devil", "angry"], category: "smileys" },
{ emoji: "💀", names: ["skull", "dead", "death"], category: "smileys" },
{ emoji: "☠️", names: ["skull_crossbones", "death", "danger"], category: "smileys" },
{ emoji: "💩", names: ["poop", "shit", "crap"], category: "smileys" },
{ emoji: "🤡", names: ["clown", "joker"], category: "smileys" },
{ emoji: "👹", names: ["ogre", "monster", "japanese"], category: "smileys" },
{ emoji: "👺", names: ["goblin", "tengu", "japanese"], category: "smileys" },
{ emoji: "👻", names: ["ghost", "boo", "spooky"], category: "smileys" },
{ emoji: "👽", names: ["alien", "ufo", "space"], category: "smileys" },
{ emoji: "👾", names: ["space_invader", "alien", "game"], category: "smileys" },
{ emoji: "🤖", names: ["robot", "bot"], category: "smileys" },
{ emoji: "😺", names: ["smiley_cat", "happy_cat"], category: "smileys" },
{ emoji: "😸", names: ["smile_cat", "happy_cat"], category: "smileys" },
{ emoji: "😹", names: ["joy_cat", "laughing_cat"], category: "smileys" },
{ emoji: "😻", names: ["heart_eyes_cat", "love_cat"], category: "smileys" },
{ emoji: "😼", names: ["smirk_cat"], category: "smileys" },
{ emoji: "😽", names: ["kissing_cat"], category: "smileys" },
{ emoji: "🙀", names: ["scream_cat", "shocked_cat"], category: "smileys" },
{ emoji: "😿", names: ["crying_cat", "sad_cat"], category: "smileys" },
{ emoji: "😾", names: ["pouting_cat", "angry_cat"], category: "smileys" },
// Gestures & People
{ emoji: "👍", names: ["thumbsup", "like", "ok", "+1", "yes"], category: "people" },
{ emoji: "👎", names: ["thumbsdown", "dislike", "-1", "no"], category: "people" },
{ emoji: "👋", names: ["wave", "hello", "hi", "bye"], category: "people" },
{ emoji: "🤚", names: ["raised_back_hand", "stop"], category: "people" },
{ emoji: "🖐️", names: ["hand_splayed", "high_five"], category: "people" },
{ emoji: "✋", names: ["hand", "stop", "high_five"], category: "people" },
{ emoji: "🖖", names: ["vulcan", "spock", "star_trek"], category: "people" },
{ emoji: "👌", names: ["ok_hand", "perfect", "nice"], category: "people" },
{ emoji: "🤌", names: ["pinched_fingers", "italian"], category: "people" },
{ emoji: "🤏", names: ["pinching_hand", "small", "tiny"], category: "people" },
{ emoji: "✌️", names: ["v", "peace", "victory"], category: "people" },
{ emoji: "🤞", names: ["crossed_fingers", "luck", "hope"], category: "people" },
{ emoji: "🤟", names: ["love_you", "ily", "rock"], category: "people" },
{ emoji: "🤘", names: ["metal", "rock", "horns"], category: "people" },
{ emoji: "🤙", names: ["call_me", "shaka", "hang_loose"], category: "people" },
{ emoji: "👈", names: ["point_left", "left"], category: "people" },
{ emoji: "👉", names: ["point_right", "right"], category: "people" },
{ emoji: "👆", names: ["point_up", "up"], category: "people" },
{ emoji: "🖕", names: ["middle_finger", "fu"], category: "people" },
{ emoji: "👇", names: ["point_down", "down"], category: "people" },
{ emoji: "☝️", names: ["point_up_2", "one"], category: "people" },
{ emoji: "✊", names: ["fist", "punch", "power"], category: "people" },
{ emoji: "👊", names: ["punch", "fist_bump"], category: "people" },
{ emoji: "🤛", names: ["left_fist", "fist_bump"], category: "people" },
{ emoji: "🤜", names: ["right_fist", "fist_bump"], category: "people" },
{ emoji: "👏", names: ["clap", "applause", "bravo"], category: "people" },
{ emoji: "🙌", names: ["raised_hands", "hooray", "yay"], category: "people" },
{ emoji: "👐", names: ["open_hands", "hug"], category: "people" },
{ emoji: "🤲", names: ["palms_up", "prayer"], category: "people" },
{ emoji: "🤝", names: ["handshake", "deal", "agreement"], category: "people" },
{ emoji: "🙏", names: ["pray", "please", "thanks", "namaste"], category: "people" },
{ emoji: "✍️", names: ["writing", "write"], category: "people" },
{ emoji: "💪", names: ["muscle", "flex", "strong", "bicep"], category: "people" },
// Hearts & Love
{ emoji: "❤️", names: ["heart", "love", "red_heart"], category: "symbols" },
{ emoji: "🧡", names: ["orange_heart"], category: "symbols" },
{ emoji: "💛", names: ["yellow_heart"], category: "symbols" },
{ emoji: "💚", names: ["green_heart"], category: "symbols" },
{ emoji: "💙", names: ["blue_heart"], category: "symbols" },
{ emoji: "💜", names: ["purple_heart"], category: "symbols" },
{ emoji: "🖤", names: ["black_heart"], category: "symbols" },
{ emoji: "🤍", names: ["white_heart"], category: "symbols" },
{ emoji: "🤎", names: ["brown_heart"], category: "symbols" },
{ emoji: "💔", names: ["broken_heart", "heartbreak"], category: "symbols" },
{ emoji: "❤️‍🔥", names: ["heart_on_fire", "burning_heart"], category: "symbols" },
{ emoji: "❤️‍🩹", names: ["mending_heart", "healing"], category: "symbols" },
{ emoji: "💕", names: ["two_hearts", "love"], category: "symbols" },
{ emoji: "💞", names: ["revolving_hearts", "love"], category: "symbols" },
{ emoji: "💓", names: ["heartbeat", "love"], category: "symbols" },
{ emoji: "💗", names: ["heartpulse", "love", "growing_heart"], category: "symbols" },
{ emoji: "💖", names: ["sparkling_heart", "love"], category: "symbols" },
{ emoji: "💘", names: ["cupid", "arrow_heart", "love"], category: "symbols" },
{ emoji: "💝", names: ["gift_heart", "love"], category: "symbols" },
// Common Objects & Symbols
{ emoji: "🔥", names: ["fire", "hot", "lit", "flame"], category: "symbols" },
{ emoji: "✨", names: ["sparkles", "magic", "shine", "stars"], category: "symbols" },
{ emoji: "⭐", names: ["star", "favorite"], category: "symbols" },
{ emoji: "🌟", names: ["glowing_star", "awesome"], category: "symbols" },
{ emoji: "💫", names: ["dizzy", "star", "magic"], category: "symbols" },
{ emoji: "💥", names: ["boom", "collision", "explosion"], category: "symbols" },
{ emoji: "💢", names: ["anger", "angry", "mad"], category: "symbols" },
{ emoji: "💦", names: ["sweat_drops", "water", "wet"], category: "symbols" },
{ emoji: "💨", names: ["dash", "wind", "fast"], category: "symbols" },
{ emoji: "🎉", names: ["tada", "party", "celebrate", "hooray"], category: "symbols" },
{ emoji: "🎊", names: ["confetti", "party", "celebrate"], category: "symbols" },
{ emoji: "🎈", names: ["balloon", "party"], category: "symbols" },
{ emoji: "🎁", names: ["gift", "present"], category: "symbols" },
{ emoji: "🏆", names: ["trophy", "win", "award", "champion"], category: "symbols" },
{ emoji: "🥇", names: ["first_place", "gold", "winner"], category: "symbols" },
{ emoji: "🥈", names: ["second_place", "silver"], category: "symbols" },
{ emoji: "🥉", names: ["third_place", "bronze"], category: "symbols" },
{ emoji: "⚡", names: ["zap", "lightning", "electric", "thunder"], category: "symbols" },
{ emoji: "💡", names: ["bulb", "idea", "light"], category: "symbols" },
{ emoji: "💯", names: ["100", "perfect", "score", "hundred"], category: "symbols" },
{ emoji: "✅", names: ["white_check_mark", "check", "done", "yes"], category: "symbols" },
{ emoji: "❌", names: ["x", "cross", "no", "wrong"], category: "symbols" },
{ emoji: "❓", names: ["question", "what"], category: "symbols" },
{ emoji: "❗", names: ["exclamation", "important", "bang"], category: "symbols" },
{ emoji: "⚠️", names: ["warning", "alert", "caution"], category: "symbols" },
{ emoji: "🚫", names: ["no_entry", "forbidden", "prohibited"], category: "symbols" },
{ emoji: "⛔", names: ["no_entry_sign", "stop"], category: "symbols" },
{ emoji: "🔴", names: ["red_circle"], category: "symbols" },
{ emoji: "🟢", names: ["green_circle"], category: "symbols" },
{ emoji: "🔵", names: ["blue_circle"], category: "symbols" },
{ emoji: "⚪", names: ["white_circle"], category: "symbols" },
{ emoji: "⚫", names: ["black_circle"], category: "symbols" },
{ emoji: "🔶", names: ["large_orange_diamond"], category: "symbols" },
{ emoji: "🔷", names: ["large_blue_diamond"], category: "symbols" },
{ emoji: "▶️", names: ["play", "arrow_forward"], category: "symbols" },
{ emoji: "⏸️", names: ["pause"], category: "symbols" },
{ emoji: "⏹️", names: ["stop"], category: "symbols" },
{ emoji: "🔁", names: ["repeat", "loop"], category: "symbols" },
{ emoji: "🔀", names: ["shuffle", "random"], category: "symbols" },
{ emoji: "🔊", names: ["loud_sound", "volume"], category: "symbols" },
{ emoji: "🔇", names: ["mute", "silent"], category: "symbols" },
{ emoji: "🔔", names: ["bell", "notification"], category: "symbols" },
{ emoji: "🔕", names: ["no_bell", "mute"], category: "symbols" },
{ emoji: "📢", names: ["loudspeaker", "announcement"], category: "symbols" },
{ emoji: "📣", names: ["mega", "megaphone"], category: "symbols" },
{ emoji: "💬", names: ["speech_balloon", "chat", "comment"], category: "symbols" },
{ emoji: "💭", names: ["thought_balloon", "thinking"], category: "symbols" },
{ emoji: "🗨️", names: ["left_speech_bubble", "chat"], category: "symbols" },
{ emoji: "👀", names: ["eyes", "look", "see", "watching"], category: "people" },
{ emoji: "👁️", names: ["eye", "see"], category: "people" },
{ emoji: "👂", names: ["ear", "listen", "hear"], category: "people" },
{ emoji: "👃", names: ["nose", "smell"], category: "people" },
{ emoji: "👅", names: ["tongue", "taste"], category: "people" },
{ emoji: "👄", names: ["lips", "kiss", "mouth"], category: "people" },
{ emoji: "🧠", names: ["brain", "smart", "think"], category: "people" },
// Nature & Animals
{ emoji: "🐶", names: ["dog", "puppy", "pet"], category: "nature" },
{ emoji: "🐱", names: ["cat", "kitty", "pet"], category: "nature" },
{ emoji: "🐭", names: ["mouse"], category: "nature" },
{ emoji: "🐹", names: ["hamster"], category: "nature" },
{ emoji: "🐰", names: ["rabbit", "bunny"], category: "nature" },
{ emoji: "🦊", names: ["fox"], category: "nature" },
{ emoji: "🐻", names: ["bear"], category: "nature" },
{ emoji: "🐼", names: ["panda"], category: "nature" },
{ emoji: "🐨", names: ["koala"], category: "nature" },
{ emoji: "🐯", names: ["tiger"], category: "nature" },
{ emoji: "🦁", names: ["lion"], category: "nature" },
{ emoji: "🐮", names: ["cow"], category: "nature" },
{ emoji: "🐷", names: ["pig"], category: "nature" },
{ emoji: "🐸", names: ["frog"], category: "nature" },
{ emoji: "🐵", names: ["monkey"], category: "nature" },
{ emoji: "🙈", names: ["see_no_evil", "monkey"], category: "nature" },
{ emoji: "🙉", names: ["hear_no_evil", "monkey"], category: "nature" },
{ emoji: "🙊", names: ["speak_no_evil", "monkey"], category: "nature" },
{ emoji: "🐔", names: ["chicken"], category: "nature" },
{ emoji: "🐧", names: ["penguin"], category: "nature" },
{ emoji: "🐦", names: ["bird"], category: "nature" },
{ emoji: "🦆", names: ["duck"], category: "nature" },
{ emoji: "🦅", names: ["eagle"], category: "nature" },
{ emoji: "🦉", names: ["owl"], category: "nature" },
{ emoji: "🦇", names: ["bat"], category: "nature" },
{ emoji: "🐺", names: ["wolf"], category: "nature" },
{ emoji: "🐴", names: ["horse"], category: "nature" },
{ emoji: "🦄", names: ["unicorn"], category: "nature" },
{ emoji: "🐝", names: ["bee", "honeybee"], category: "nature" },
{ emoji: "🐛", names: ["bug", "caterpillar"], category: "nature" },
{ emoji: "🦋", names: ["butterfly"], category: "nature" },
{ emoji: "🐌", names: ["snail", "slow"], category: "nature" },
{ emoji: "🐞", names: ["ladybug", "beetle"], category: "nature" },
{ emoji: "🐍", names: ["snake"], category: "nature" },
{ emoji: "🐢", names: ["turtle"], category: "nature" },
{ emoji: "🐙", names: ["octopus"], category: "nature" },
{ emoji: "🦀", names: ["crab"], category: "nature" },
{ emoji: "🦐", names: ["shrimp"], category: "nature" },
{ emoji: "🦑", names: ["squid"], category: "nature" },
{ emoji: "🐠", names: ["fish", "tropical_fish"], category: "nature" },
{ emoji: "🐟", names: ["fish"], category: "nature" },
{ emoji: "🐬", names: ["dolphin"], category: "nature" },
{ emoji: "🐳", names: ["whale"], category: "nature" },
{ emoji: "🦈", names: ["shark"], category: "nature" },
{ emoji: "🐊", names: ["crocodile", "alligator"], category: "nature" },
{ emoji: "🐘", names: ["elephant"], category: "nature" },
{ emoji: "🦒", names: ["giraffe"], category: "nature" },
{ emoji: "🦓", names: ["zebra"], category: "nature" },
{ emoji: "🦍", names: ["gorilla"], category: "nature" },
{ emoji: "🐒", names: ["monkey"], category: "nature" },
// Plants & Flowers
{ emoji: "🌸", names: ["cherry_blossom", "sakura", "flower"], category: "nature" },
{ emoji: "🌹", names: ["rose", "flower"], category: "nature" },
{ emoji: "🌺", names: ["hibiscus", "flower"], category: "nature" },
{ emoji: "🌻", names: ["sunflower", "flower"], category: "nature" },
{ emoji: "🌼", names: ["blossom", "flower"], category: "nature" },
{ emoji: "🌷", names: ["tulip", "flower"], category: "nature" },
{ emoji: "🌱", names: ["seedling", "plant", "sprout"], category: "nature" },
{ emoji: "🌲", names: ["evergreen_tree", "tree"], category: "nature" },
{ emoji: "🌳", names: ["deciduous_tree", "tree"], category: "nature" },
{ emoji: "🌴", names: ["palm_tree", "tropical"], category: "nature" },
{ emoji: "🌵", names: ["cactus", "desert"], category: "nature" },
{ emoji: "🍀", names: ["four_leaf_clover", "lucky", "luck"], category: "nature" },
{ emoji: "🍁", names: ["maple_leaf", "fall", "autumn"], category: "nature" },
{ emoji: "🍂", names: ["fallen_leaf", "fall", "autumn"], category: "nature" },
{ emoji: "🍃", names: ["leaves", "wind"], category: "nature" },
// Weather & Sky
{ emoji: "☀️", names: ["sunny", "sun"], category: "nature" },
{ emoji: "🌤️", names: ["sun_behind_cloud", "partly_sunny"], category: "nature" },
{ emoji: "⛅", names: ["partly_sunny", "cloudy"], category: "nature" },
{ emoji: "🌥️", names: ["sun_behind_large_cloud"], category: "nature" },
{ emoji: "☁️", names: ["cloud", "cloudy"], category: "nature" },
{ emoji: "🌦️", names: ["sun_behind_rain_cloud"], category: "nature" },
{ emoji: "🌧️", names: ["cloud_with_rain", "rain", "rainy"], category: "nature" },
{ emoji: "⛈️", names: ["thunder_cloud_rain", "storm"], category: "nature" },
{ emoji: "🌩️", names: ["cloud_with_lightning", "thunder"], category: "nature" },
{ emoji: "🌨️", names: ["cloud_with_snow", "snow"], category: "nature" },
{ emoji: "❄️", names: ["snowflake", "cold", "winter"], category: "nature" },
{ emoji: "☃️", names: ["snowman", "winter"], category: "nature" },
{ emoji: "⛄", names: ["snowman_without_snow"], category: "nature" },
{ emoji: "🌪️", names: ["tornado"], category: "nature" },
{ emoji: "🌈", names: ["rainbow"], category: "nature" },
{ emoji: "🌊", names: ["ocean", "wave", "water"], category: "nature" },
{ emoji: "🌙", names: ["crescent_moon", "moon", "night"], category: "nature" },
{ emoji: "🌕", names: ["full_moon"], category: "nature" },
{ emoji: "🌑", names: ["new_moon", "dark"], category: "nature" },
{ emoji: "⚡", names: ["lightning", "zap", "electric"], category: "nature" },
// Food & Drink
{ emoji: "🍎", names: ["apple", "red_apple"], category: "food" },
{ emoji: "🍐", names: ["pear"], category: "food" },
{ emoji: "🍊", names: ["orange", "tangerine"], category: "food" },
{ emoji: "🍋", names: ["lemon"], category: "food" },
{ emoji: "🍌", names: ["banana"], category: "food" },
{ emoji: "🍉", names: ["watermelon"], category: "food" },
{ emoji: "🍇", names: ["grapes"], category: "food" },
{ emoji: "🍓", names: ["strawberry"], category: "food" },
{ emoji: "🍒", names: ["cherries"], category: "food" },
{ emoji: "🍑", names: ["peach"], category: "food" },
{ emoji: "🥭", names: ["mango"], category: "food" },
{ emoji: "🍍", names: ["pineapple"], category: "food" },
{ emoji: "🥥", names: ["coconut"], category: "food" },
{ emoji: "🥝", names: ["kiwi"], category: "food" },
{ emoji: "🍅", names: ["tomato"], category: "food" },
{ emoji: "🥑", names: ["avocado"], category: "food" },
{ emoji: "🥦", names: ["broccoli"], category: "food" },
{ emoji: "🌶️", names: ["hot_pepper", "chili", "spicy"], category: "food" },
{ emoji: "🌽", names: ["corn"], category: "food" },
{ emoji: "🥕", names: ["carrot"], category: "food" },
{ emoji: "🥔", names: ["potato"], category: "food" },
{ emoji: "🍞", names: ["bread"], category: "food" },
{ emoji: "🥐", names: ["croissant"], category: "food" },
{ emoji: "🧀", names: ["cheese"], category: "food" },
{ emoji: "🍳", names: ["fried_egg", "egg", "breakfast"], category: "food" },
{ emoji: "🥓", names: ["bacon"], category: "food" },
{ emoji: "🥩", names: ["steak", "meat"], category: "food" },
{ emoji: "🍗", names: ["chicken_leg", "poultry"], category: "food" },
{ emoji: "🍖", names: ["meat_on_bone"], category: "food" },
{ emoji: "🌭", names: ["hot_dog", "hotdog"], category: "food" },
{ emoji: "🍔", names: ["hamburger", "burger"], category: "food" },
{ emoji: "🍟", names: ["fries", "french_fries"], category: "food" },
{ emoji: "🍕", names: ["pizza"], category: "food" },
{ emoji: "🌮", names: ["taco"], category: "food" },
{ emoji: "🌯", names: ["burrito"], category: "food" },
{ emoji: "🥗", names: ["salad", "green_salad"], category: "food" },
{ emoji: "🍜", names: ["ramen", "noodles"], category: "food" },
{ emoji: "🍝", names: ["spaghetti", "pasta"], category: "food" },
{ emoji: "🍣", names: ["sushi"], category: "food" },
{ emoji: "🍱", names: ["bento", "lunch_box"], category: "food" },
{ emoji: "🍩", names: ["doughnut", "donut"], category: "food" },
{ emoji: "🍪", names: ["cookie"], category: "food" },
{ emoji: "🎂", names: ["birthday", "cake"], category: "food" },
{ emoji: "🍰", names: ["cake", "shortcake"], category: "food" },
{ emoji: "🧁", names: ["cupcake"], category: "food" },
{ emoji: "🍫", names: ["chocolate"], category: "food" },
{ emoji: "🍬", names: ["candy"], category: "food" },
{ emoji: "🍭", names: ["lollipop"], category: "food" },
{ emoji: "🍿", names: ["popcorn"], category: "food" },
{ emoji: "🍦", names: ["ice_cream", "icecream"], category: "food" },
{ emoji: "☕", names: ["coffee", "cafe"], category: "food" },
{ emoji: "🍵", names: ["tea"], category: "food" },
{ emoji: "🥤", names: ["cup_with_straw", "soda", "drink"], category: "food" },
{ emoji: "🍺", names: ["beer"], category: "food" },
{ emoji: "🍻", names: ["beers", "cheers"], category: "food" },
{ emoji: "🥂", names: ["champagne", "cheers", "toast"], category: "food" },
{ emoji: "🍷", names: ["wine", "wine_glass"], category: "food" },
{ emoji: "🥃", names: ["whisky", "tumbler_glass"], category: "food" },
{ emoji: "🍸", names: ["cocktail", "martini"], category: "food" },
// Activities & Sports
{ emoji: "⚽", names: ["soccer", "football"], category: "activities" },
{ emoji: "🏀", names: ["basketball"], category: "activities" },
{ emoji: "🏈", names: ["football", "american_football"], category: "activities" },
{ emoji: "⚾", names: ["baseball"], category: "activities" },
{ emoji: "🎾", names: ["tennis"], category: "activities" },
{ emoji: "🏐", names: ["volleyball"], category: "activities" },
{ emoji: "🏓", names: ["ping_pong", "table_tennis"], category: "activities" },
{ emoji: "🎱", names: ["pool", "billiards", "8ball"], category: "activities" },
{ emoji: "🎮", names: ["video_game", "gaming", "controller"], category: "activities" },
{ emoji: "🕹️", names: ["joystick", "gaming"], category: "activities" },
{ emoji: "🎲", names: ["dice", "game"], category: "activities" },
{ emoji: "🧩", names: ["puzzle", "piece"], category: "activities" },
{ emoji: "♟️", names: ["chess", "pawn"], category: "activities" },
{ emoji: "🎯", names: ["dart", "target", "bullseye"], category: "activities" },
{ emoji: "🎳", names: ["bowling"], category: "activities" },
{ emoji: "🎸", names: ["guitar"], category: "activities" },
{ emoji: "🎹", names: ["piano", "keyboard"], category: "activities" },
{ emoji: "🥁", names: ["drum"], category: "activities" },
{ emoji: "🎤", names: ["microphone", "mic", "karaoke"], category: "activities" },
{ emoji: "🎧", names: ["headphones", "music"], category: "activities" },
{ emoji: "🎬", names: ["clapper", "movie", "film"], category: "activities" },
{ emoji: "🎨", names: ["art", "palette", "paint"], category: "activities" },
{ emoji: "🎭", names: ["theater", "drama", "masks"], category: "activities" },
// Objects & Tech
{ emoji: "💻", names: ["laptop", "computer"], category: "objects" },
{ emoji: "🖥️", names: ["desktop", "computer"], category: "objects" },
{ emoji: "📱", names: ["phone", "iphone", "mobile"], category: "objects" },
{ emoji: "📷", names: ["camera"], category: "objects" },
{ emoji: "📸", names: ["camera_flash"], category: "objects" },
{ emoji: "📹", names: ["video_camera"], category: "objects" },
{ emoji: "🎥", names: ["movie_camera", "film"], category: "objects" },
{ emoji: "📺", names: ["tv", "television"], category: "objects" },
{ emoji: "📻", names: ["radio"], category: "objects" },
{ emoji: "🎙️", names: ["studio_microphone"], category: "objects" },
{ emoji: "⌨️", names: ["keyboard"], category: "objects" },
{ emoji: "🖱️", names: ["mouse", "computer_mouse"], category: "objects" },
{ emoji: "💾", names: ["floppy_disk", "save"], category: "objects" },
{ emoji: "💿", names: ["cd", "disc"], category: "objects" },
{ emoji: "📀", names: ["dvd", "disc"], category: "objects" },
{ emoji: "🔋", names: ["battery"], category: "objects" },
{ emoji: "🔌", names: ["plug", "electric"], category: "objects" },
{ emoji: "📧", names: ["email", "e-mail"], category: "objects" },
{ emoji: "📨", names: ["incoming_envelope", "email"], category: "objects" },
{ emoji: "📩", names: ["envelope_with_arrow", "email"], category: "objects" },
{ emoji: "📝", names: ["memo", "note", "pencil"], category: "objects" },
{ emoji: "📁", names: ["folder", "file_folder"], category: "objects" },
{ emoji: "📂", names: ["open_folder"], category: "objects" },
{ emoji: "📎", names: ["paperclip", "attachment"], category: "objects" },
{ emoji: "🔗", names: ["link", "chain"], category: "objects" },
{ emoji: "📌", names: ["pushpin", "pin"], category: "objects" },
{ emoji: "📍", names: ["round_pushpin", "location"], category: "objects" },
{ emoji: "✏️", names: ["pencil", "edit"], category: "objects" },
{ emoji: "🖊️", names: ["pen"], category: "objects" },
{ emoji: "🔍", names: ["mag", "search", "magnifying_glass"], category: "objects" },
{ emoji: "🔎", names: ["mag_right", "search"], category: "objects" },
{ emoji: "🔐", names: ["locked_key", "secure"], category: "objects" },
{ emoji: "🔒", names: ["lock", "locked", "secure"], category: "objects" },
{ emoji: "🔓", names: ["unlock", "unlocked"], category: "objects" },
{ emoji: "🔑", names: ["key"], category: "objects" },
{ emoji: "🗝️", names: ["old_key"], category: "objects" },
{ emoji: "🔧", names: ["wrench", "tool"], category: "objects" },
{ emoji: "🔨", names: ["hammer", "tool"], category: "objects" },
{ emoji: "⚙️", names: ["gear", "settings", "cog"], category: "objects" },
{ emoji: "🛠️", names: ["tools", "hammer_and_wrench"], category: "objects" },
{ emoji: "⚗️", names: ["alembic", "science"], category: "objects" },
{ emoji: "🧪", names: ["test_tube", "science"], category: "objects" },
{ emoji: "🧬", names: ["dna", "genetics"], category: "objects" },
{ emoji: "🔬", names: ["microscope", "science"], category: "objects" },
{ emoji: "🔭", names: ["telescope", "astronomy"], category: "objects" },
{ emoji: "📡", names: ["satellite", "antenna"], category: "objects" },
{ emoji: "💉", names: ["syringe", "needle", "vaccine"], category: "objects" },
{ emoji: "💊", names: ["pill", "medicine"], category: "objects" },
{ emoji: "🩹", names: ["bandage", "adhesive"], category: "objects" },
{ emoji: "🩺", names: ["stethoscope", "doctor"], category: "objects" },
{ emoji: "🚀", names: ["rocket", "launch", "ship"], category: "objects" },
{ emoji: "🛸", names: ["ufo", "flying_saucer"], category: "objects" },
{ emoji: "🛰️", names: ["satellite"], category: "objects" },
{ emoji: "💰", names: ["money_bag", "money", "cash"], category: "objects" },
{ emoji: "💵", names: ["dollar", "money", "cash"], category: "objects" },
{ emoji: "💳", names: ["credit_card", "card"], category: "objects" },
{ emoji: "💎", names: ["gem", "diamond", "jewel"], category: "objects" },
{ emoji: "⏰", names: ["alarm_clock", "clock"], category: "objects" },
{ emoji: "⏳", names: ["hourglass", "time"], category: "objects" },
{ emoji: "⌛", names: ["hourglass_done", "time"], category: "objects" },
{ emoji: "📅", names: ["calendar", "date"], category: "objects" },
{ emoji: "📆", names: ["tear_off_calendar", "calendar"], category: "objects" },
{ emoji: "🗓️", names: ["spiral_calendar"], category: "objects" },
{ emoji: "📊", names: ["chart", "bar_chart", "graph"], category: "objects" },
{ emoji: "📈", names: ["chart_up", "chart_with_upwards_trend"], category: "objects" },
{ emoji: "📉", names: ["chart_down", "chart_with_downwards_trend"], category: "objects" },
{ emoji: "📋", names: ["clipboard"], category: "objects" },
{ emoji: "📄", names: ["page", "document"], category: "objects" },
{ emoji: "📃", names: ["page_curl", "document"], category: "objects" },
{ emoji: "📑", names: ["bookmark_tabs"], category: "objects" },
{ emoji: "🔖", names: ["bookmark"], category: "objects" },
{ emoji: "🏷️", names: ["label", "tag"], category: "objects" },
{ emoji: "📚", names: ["books"], category: "objects" },
{ emoji: "📖", names: ["book", "open_book"], category: "objects" },
{ emoji: "📰", names: ["newspaper", "news"], category: "objects" },
{ emoji: "🗞️", names: ["rolled_newspaper", "news"], category: "objects" },
// Travel & Places
{ emoji: "🚗", names: ["car", "automobile"], category: "travel" },
{ emoji: "🚕", names: ["taxi", "cab"], category: "travel" },
{ emoji: "🚌", names: ["bus"], category: "travel" },
{ emoji: "🚎", names: ["trolleybus"], category: "travel" },
{ emoji: "🏎️", names: ["racing_car"], category: "travel" },
{ emoji: "🚓", names: ["police_car"], category: "travel" },
{ emoji: "🚑", names: ["ambulance"], category: "travel" },
{ emoji: "🚒", names: ["fire_engine", "fire_truck"], category: "travel" },
{ emoji: "🚚", names: ["truck"], category: "travel" },
{ emoji: "🚲", names: ["bike", "bicycle"], category: "travel" },
{ emoji: "🛵", names: ["scooter", "motor_scooter"], category: "travel" },
{ emoji: "🏍️", names: ["motorcycle"], category: "travel" },
{ emoji: "✈️", names: ["airplane", "plane"], category: "travel" },
{ emoji: "🚀", names: ["rocket"], category: "travel" },
{ emoji: "🚁", names: ["helicopter"], category: "travel" },
{ emoji: "🚢", names: ["ship", "boat"], category: "travel" },
{ emoji: "⛵", names: ["sailboat", "boat"], category: "travel" },
{ emoji: "🚤", names: ["speedboat", "boat"], category: "travel" },
{ emoji: "🚂", names: ["train", "steam_locomotive"], category: "travel" },
{ emoji: "🚃", names: ["railway_car", "train"], category: "travel" },
{ emoji: "🚄", names: ["bullet_train", "high_speed_train"], category: "travel" },
{ emoji: "🏠", names: ["house", "home"], category: "travel" },
{ emoji: "🏡", names: ["house_with_garden", "home"], category: "travel" },
{ emoji: "🏢", names: ["office", "building"], category: "travel" },
{ emoji: "🏣", names: ["post_office"], category: "travel" },
{ emoji: "🏥", names: ["hospital"], category: "travel" },
{ emoji: "🏦", names: ["bank"], category: "travel" },
{ emoji: "🏨", names: ["hotel"], category: "travel" },
{ emoji: "🏩", names: ["love_hotel"], category: "travel" },
{ emoji: "🏪", names: ["convenience_store", "store"], category: "travel" },
{ emoji: "🏫", names: ["school"], category: "travel" },
{ emoji: "🏬", names: ["department_store"], category: "travel" },
{ emoji: "🏭", names: ["factory"], category: "travel" },
{ emoji: "🏯", names: ["japanese_castle", "castle"], category: "travel" },
{ emoji: "🏰", names: ["castle", "european_castle"], category: "travel" },
{ emoji: "🗽", names: ["statue_of_liberty"], category: "travel" },
{ emoji: "🗼", names: ["tokyo_tower", "tower"], category: "travel" },
{ emoji: "🗻", names: ["mount_fuji", "mountain"], category: "travel" },
{ emoji: "🌋", names: ["volcano"], category: "travel" },
{ emoji: "🏔️", names: ["mountain", "snow_capped_mountain"], category: "travel" },
{ emoji: "⛰️", names: ["mountain"], category: "travel" },
{ emoji: "🏕️", names: ["camping", "tent"], category: "travel" },
{ emoji: "🏖️", names: ["beach", "beach_umbrella"], category: "travel" },
{ emoji: "🏜️", names: ["desert"], category: "travel" },
{ emoji: "🏝️", names: ["island", "desert_island"], category: "travel" },
{ emoji: "🌍", names: ["earth_africa", "globe", "world"], category: "travel" },
{ emoji: "🌎", names: ["earth_americas", "globe", "world"], category: "travel" },
{ emoji: "🌏", names: ["earth_asia", "globe", "world"], category: "travel" },
{ emoji: "🗺️", names: ["world_map", "map"], category: "travel" },
];
// Search emojis by name with relevance scoring
// Prioritizes exact matches and prefix matches over substring matches
export function searchEmojis(query: string): EmojiItem[] {
if (!query) return [];
const lowerQuery = query.toLowerCase();
// Score each emoji based on match quality
const scored = emojiData
.map(item => {
let bestScore = 0;
for (const name of item.names) {
if (name === lowerQuery) {
// Exact match - highest priority
bestScore = Math.max(bestScore, 100);
} else if (name.startsWith(lowerQuery)) {
// Prefix match - high priority, shorter names score higher
bestScore = Math.max(bestScore, 50 + (20 - name.length));
} else if (name.includes(lowerQuery)) {
// Substring match - lower priority
bestScore = Math.max(bestScore, 10);
}
}
return { item, score: bestScore };
})
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 10)
.map(({ item }) => item);
return scored;
}
// Get emoji by exact name
export function getEmojiByName(name: string): string | null {
const lowerName = name.toLowerCase();
const item = emojiData.find(e => e.names.includes(lowerName));
return item?.emoji || null;
}
// Get all emojis by category
export function getEmojisByCategory(category: string): EmojiItem[] {
return emojiData.filter(item => item.category === category);
}
// Get all unique categories
export function getCategories(): string[] {
return [...new Set(emojiData.map(e => e.category))];
}
// Convert emoji shortcodes like :heart: to actual emojis
export function convertEmojiShortcodes(text: string): string {
return text.replace(/:([a-zA-Z0-9_+-]+):/g, (match, name) => {
const emoji = getEmojiByName(name);
return emoji || match; // Keep original if no match
});
}

30
src/lib/utils/twemoji.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* Twemoji utility for rendering emojis as Twitter-style images
*/
import twemoji from 'twemoji';
/**
* Parse text and replace emojis with Twemoji images
*/
export function parseTwemoji(text: string): string {
return twemoji.parse(text, {
base: 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/',
folder: 'svg',
ext: '.svg',
className: 'twemoji',
});
}
/**
* Get Twemoji image URL for a single emoji
*/
export function getTwemojiUrl(emoji: string): string {
// Remove variation selector (FE0F) as Twemoji uses base codepoints
const codePoint = [...emoji]
.filter((char) => char.codePointAt(0) !== 0xfe0f)
.map((char) => char.codePointAt(0)?.toString(16))
.filter(Boolean)
.join('-')
.toLowerCase();
return `https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/${codePoint}.svg`;
}

View File

@@ -0,0 +1,176 @@
/**
* Global Twemoji replacement utility
* Automatically converts all emoji characters to Twemoji images throughout the DOM
*/
// Regex to match emojis including those with and without variation selectors
const emojiRegex = /(\p{Emoji_Presentation}|\p{Emoji}\uFE0F|\p{Extended_Pictographic})/gu;
// Elements to skip when processing
const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT', 'CODE', 'PRE', 'NOSCRIPT']);
/**
* Convert an emoji to a Twemoji image element
*/
function emojiToTwemoji(emoji: string): string {
// Remove variation selectors (FE0F) for Twemoji URL compatibility
// Twemoji uses base codepoints without variation selectors
const codePoint = [...emoji]
.filter((char) => char.codePointAt(0) !== 0xfe0f)
.map((char) => char.codePointAt(0)?.toString(16))
.filter(Boolean)
.join('-')
.toLowerCase();
return `<img class="twemoji-inline" src="https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/svg/${codePoint}.svg" alt="${emoji}" draggable="false" />`;
}
/**
* Check if a string contains any emojis
*/
function containsEmoji(text: string): boolean {
return emojiRegex.test(text);
}
/**
* Process a text node and replace emojis with Twemoji images
*/
function processTextNode(textNode: Text): void {
const text = textNode.textContent || '';
// Reset regex lastIndex
emojiRegex.lastIndex = 0;
if (!containsEmoji(text)) return;
// Reset regex lastIndex again after the check
emojiRegex.lastIndex = 0;
// Create a temporary container
const temp = document.createElement('span');
temp.innerHTML = text.replace(emojiRegex, (emoji) => emojiToTwemoji(emoji));
// Replace the text node with the processed content
const parent = textNode.parentNode;
if (parent) {
while (temp.firstChild) {
parent.insertBefore(temp.firstChild, textNode);
}
parent.removeChild(textNode);
}
}
/**
* Process all text nodes within an element
*/
function processElement(element: Element): void {
// Skip certain elements
if (SKIP_TAGS.has(element.tagName)) return;
// Skip elements that already contain twemoji
if (element.classList?.contains('twemoji-inline')) return;
// Skip elements with data-no-twemoji attribute
if (element.hasAttribute?.('data-no-twemoji')) return;
// Get all text nodes using TreeWalker
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
const parent = node.parentElement;
if (parent && SKIP_TAGS.has(parent.tagName)) {
return NodeFilter.FILTER_REJECT;
}
// Skip if parent is already a twemoji image
if (parent?.classList?.contains('twemoji-inline')) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
}
);
const textNodes: Text[] = [];
let node: Node | null;
while ((node = walker.nextNode())) {
textNodes.push(node as Text);
}
// Process each text node
textNodes.forEach(processTextNode);
}
/**
* Initialize global Twemoji replacement with MutationObserver
*/
export function initGlobalTwemoji(): () => void {
// Process existing content
processElement(document.body);
// Set up MutationObserver to watch for new content
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
// Process added nodes
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
processElement(node as Element);
} else if (node.nodeType === Node.TEXT_NODE) {
const parent = node.parentElement;
if (parent && !SKIP_TAGS.has(parent.tagName)) {
processTextNode(node as Text);
}
}
});
// Process character data changes (text content updates)
if (mutation.type === 'characterData' && mutation.target.nodeType === Node.TEXT_NODE) {
const parent = mutation.target.parentElement;
if (parent && !SKIP_TAGS.has(parent.tagName)) {
processTextNode(mutation.target as Text);
}
}
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
});
// Return cleanup function
return () => observer.disconnect();
}
/**
* Svelte action to apply Twemoji to an element and its descendants
*/
export function twemoji(node: HTMLElement): { destroy: () => void } {
processElement(node);
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((addedNode) => {
if (addedNode.nodeType === Node.ELEMENT_NODE) {
processElement(addedNode as Element);
} else if (addedNode.nodeType === Node.TEXT_NODE) {
processTextNode(addedNode as Text);
}
});
});
});
observer.observe(node, {
childList: true,
subtree: true,
characterData: true,
});
return {
destroy() {
observer.disconnect();
}
};
}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { getContext } from "svelte";
import { Button, Card, Modal, Input } from "$lib/components/ui";
import { Button, Modal, Input } from "$lib/components/ui";
import { createOrganization, generateSlug } from "$lib/api/organizations";
import { toasts } from "$lib/stores/toast.svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
@@ -58,108 +58,53 @@
<title>Organizations | Root</title>
</svelte:head>
<div class="min-h-screen bg-dark">
<header class="border-b border-light/10 bg-surface">
<div
class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between"
>
<h1 class="text-xl font-bold text-light">Root Org</h1>
<div class="flex items-center gap-4">
<a href="/style" class="text-sm text-light/60 hover:text-light"
>Style Guide</a
>
<div class="min-h-screen bg-background">
<!-- Header -->
<header class="border-b border-light/5">
<div class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="material-symbols-rounded text-primary" style="font-size: 28px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 28;">hub</span>
<span class="font-heading text-h4 text-white">Root</span>
</div>
<div class="flex items-center gap-2">
<a href="/style" class="px-3 py-1.5 text-[12px] text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors">Style Guide</a>
<form method="POST" action="/auth/logout">
<Button variant="tertiary" size="sm" type="submit"
>Sign Out</Button
>
<Button variant="tertiary" size="sm" type="submit">Sign Out</Button>
</form>
</div>
</div>
</header>
<main class="max-w-6xl mx-auto px-6 py-8">
<div class="flex items-center justify-between mb-8">
<main class="max-w-5xl mx-auto px-6 py-8">
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-2xl font-bold text-light">
Your Organizations
</h2>
<p class="text-light/50 mt-1">
Select an organization to get started
</p>
<h2 class="font-heading text-h3 text-white">Your Organizations</h2>
<p class="text-body-sm text-light/40 mt-1">Select an organization to get started</p>
</div>
<Button onclick={() => (showCreateModal = true)}>
<svg
class="w-4 h-4 mr-2"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
New Organization
</Button>
<Button size="sm" icon="add" onclick={() => (showCreateModal = true)}>New Organization</Button>
</div>
{#if organizations.length === 0}
<Card>
<div class="p-12 text-center">
<svg
class="w-16 h-16 mx-auto mb-4 text-light/30"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<h3 class="text-lg font-medium text-light mb-2">
No organizations yet
</h3>
<p class="text-light/50 mb-6">
Create your first organization to start collaborating
</p>
<Button onclick={() => (showCreateModal = true)}
>Create Organization</Button
>
<div class="bg-dark/30 border border-light/5 rounded-xl p-12 text-center">
<span class="material-symbols-rounded text-light/20 mb-3 block" style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;">groups</span>
<h3 class="font-heading text-body text-white mb-1">No organizations yet</h3>
<p class="text-body-sm text-light/40 mb-6">Create your first organization to start collaborating</p>
<Button size="sm" icon="add" onclick={() => (showCreateModal = true)}>Create Organization</Button>
</div>
</Card>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{#each organizations as org}
<a href="/{org.slug}" class="block group">
<Card
class="h-full hover:ring-1 hover:ring-primary/50 transition-all"
>
<div class="p-6">
<div
class="flex items-start justify-between mb-4"
>
<div
class="w-12 h-12 bg-primary/20 rounded-xl flex items-center justify-center text-primary font-bold text-lg"
>
<div class="bg-dark/30 border border-light/5 hover:border-light/10 rounded-xl p-5 transition-all h-full">
<div class="flex items-start justify-between mb-3">
<div class="w-10 h-10 bg-primary/10 rounded-xl flex items-center justify-center text-primary font-heading text-body">
{org.name.charAt(0).toUpperCase()}
</div>
<span
class="text-xs px-2 py-1 bg-light/10 rounded text-light/60 capitalize"
>
{org.role}
</span>
<span class="text-[10px] px-2 py-0.5 bg-light/5 rounded-md text-light/40 capitalize font-body">{org.role}</span>
</div>
<h3
class="text-lg font-semibold text-light group-hover:text-primary transition-colors"
>
{org.name}
</h3>
<p class="text-sm text-light/40 mt-1">
/{org.slug}
</p>
<h3 class="font-heading text-body-sm text-white group-hover:text-primary transition-colors">{org.name}</h3>
<p class="text-[11px] text-light/30 mt-0.5 font-body">/{org.slug}</p>
</div>
</Card>
</a>
{/each}
</div>

View File

@@ -20,7 +20,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
}
// Now fetch membership, members, activity, and user profile in parallel (all depend on org.id)
const [membershipResult, membersResult, activityResult, profileResult, docCountResult, folderCountResult, kanbanCountResult] = await Promise.all([
const [membershipResult, membersResult, activityResult, profileResult, docCountResult, folderCountResult, kanbanCountResult, eventCountResult] = await Promise.all([
locals.supabase
.from('org_members')
.select('role, role_id')
@@ -49,9 +49,9 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
.eq('org_id', org.id)
.order('created_at', { ascending: false })
.limit(10),
locals.supabase
(locals.supabase as any)
.from('profiles')
.select('id, email, full_name, avatar_url')
.select('id, email, full_name, avatar_url, phone, discord_handle, shirt_size, hoodie_size')
.eq('id', user.id)
.single(),
locals.supabase
@@ -68,7 +68,11 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
.from('documents')
.select('id', { count: 'exact', head: true })
.eq('org_id', org.id)
.eq('type', 'kanban')
.eq('type', 'kanban'),
locals.supabase
.from('events')
.select('id', { count: 'exact', head: true })
.eq('org_id', org.id)
]);
const { data: membership } = membershipResult;
@@ -81,6 +85,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
documentCount: docCountResult.count ?? 0,
folderCount: folderCountResult.count ?? 0,
kanbanCount: kanbanCountResult.count ?? 0,
eventCount: eventCountResult.count ?? 0,
};
if (!membership) {
@@ -103,16 +108,16 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
// Fetch profiles separately since org_members.user_id FK points to auth.users, not profiles
const memberUserIds = (rawMembers ?? []).map(m => m.user_id).filter((id): id is string => id !== null);
let memberProfilesMap: Record<string, { id: string; email: string; full_name: string | null; avatar_url: string | null }> = {};
let memberProfilesMap: Record<string, { id: string; email: string; full_name: string | null; avatar_url: string | null; phone: string | null; discord_handle: string | null; shirt_size: string | null; hoodie_size: string | null }> = {};
if (memberUserIds.length > 0) {
const { data: memberProfiles } = await locals.supabase
const { data: memberProfiles } = await (locals.supabase as any)
.from('profiles')
.select('id, email, full_name, avatar_url')
.select('id, email, full_name, avatar_url, phone, discord_handle, shirt_size, hoodie_size')
.in('id', memberUserIds);
if (memberProfiles) {
memberProfilesMap = Object.fromEntries(memberProfiles.map(p => [p.id, p]));
memberProfilesMap = Object.fromEntries(memberProfiles.map((p: any) => [p.id, p]));
}
}
@@ -121,6 +126,15 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
profiles: (m.user_id ? memberProfilesMap[m.user_id] : null) ?? null
}));
// Fetch upcoming events for the overview
const { data: upcomingEvents } = await locals.supabase
.from('events')
.select('id, name, slug, status, start_date, end_date, color, venue_name')
.eq('org_id', org.id)
.in('status', ['planning', 'active'])
.order('start_date', { ascending: true, nullsFirst: false })
.limit(5);
return {
org,
userRole: membership.role,
@@ -128,6 +142,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
members,
recentActivity: recentActivity ?? [],
stats,
upcomingEvents: upcomingEvents ?? [],
user,
profile: profile ?? { id: user.id, email: user.email ?? '', full_name: null, avatar_url: null }
};

View File

@@ -1,15 +1,16 @@
<script lang="ts">
import { page, navigating } from "$app/stores";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import type { Snippet } from "svelte";
import { getContext } from "svelte";
import { on } from "svelte/events";
import { Avatar, Logo, PageSkeleton } from "$lib/components/ui";
import { Avatar, Logo } from "$lib/components/ui";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import { hasPermission, type Permission } from "$lib/utils/permissions";
import { setContext } from "svelte";
import * as m from "$lib/paraglide/messages";
import { totalUnreadCount } from "$lib/stores/matrix";
interface Member {
id: string;
@@ -122,6 +123,17 @@
},
]
: []),
{
href: `/${data.org.slug}/events`,
label: m.nav_events(),
icon: "celebration",
},
{
href: `/${data.org.slug}/chat`,
label: "Chat",
icon: "chat",
badge: $totalUnreadCount > 0 ? ($totalUnreadCount > 99 ? "99+" : String($totalUnreadCount)) : null,
},
// Settings requires settings.view or admin role
...(canAccess("settings.view")
? [
@@ -218,6 +230,11 @@
? 'opacity-0 max-w-0 overflow-hidden'
: 'opacity-100 max-w-[200px]'}">{item.label}</span
>
{#if item.badge}
<span class="ml-auto bg-primary text-background text-xs font-bold px-1.5 py-0.5 rounded-full min-w-[18px] text-center shrink-0">
{item.badge}
</span>
{/if}
</a>
{/each}
</nav>
@@ -328,21 +345,7 @@
</aside>
<!-- Main Content Area -->
<main class="flex-1 bg-night rounded-[32px] overflow-auto relative">
{#if $navigating}
{@const target = $navigating.to?.url.pathname ?? ""}
{@const skeletonVariant = target.includes("/kanban")
? "kanban"
: target.includes("/documents")
? "files"
: target.includes("/calendar")
? "calendar"
: target.includes("/settings")
? "settings"
: "default"}
<PageSkeleton variant={skeletonVariant} />
{:else}
<main class="flex-1 bg-night rounded-[32px] overflow-hidden relative">
{@render children()}
{/if}
</main>
</div>

View File

@@ -1,5 +1,13 @@
<script lang="ts">
import { Avatar, Card } from "$lib/components/ui";
import {
PageHeader,
StatCard,
SectionCard,
EventCard,
ActivityFeed,
MemberList,
QuickLinkGrid,
} from "$lib/components/ui";
import * as m from "$lib/paraglide/messages";
interface ActivityEntry {
@@ -15,6 +23,17 @@
} | null;
}
interface UpcomingEvent {
id: string;
name: string;
slug: string;
status: string;
start_date: string | null;
end_date: string | null;
color: string | null;
venue_name: string | null;
}
interface Props {
data: {
org: { id: string; name: string; slug: string };
@@ -24,8 +43,10 @@
documentCount: number;
folderCount: number;
kanbanCount: number;
eventCount: number;
};
recentActivity: ActivityEntry[];
upcomingEvents: UpcomingEvent[];
members: {
id: string;
user_id: string;
@@ -48,322 +69,175 @@
documentCount: 0,
folderCount: 0,
kanbanCount: 0,
eventCount: 0,
},
);
const recentActivity = $derived(data.recentActivity ?? []);
const upcomingEvents = $derived(data.upcomingEvents ?? []);
const members = $derived(data.members ?? []);
const isAdmin = $derived(
data.userRole === "owner" || data.userRole === "admin",
);
const statCards = $derived([
{
label: m.overview_stat_members(),
value: stats.memberCount,
icon: "group",
href: isAdmin ? `/${data.org.slug}/settings` : null,
color: "text-blue-400",
bg: "bg-blue-400/10",
},
{
label: m.overview_stat_documents(),
value: stats.documentCount,
icon: "description",
href: `/${data.org.slug}/documents`,
color: "text-emerald-400",
bg: "bg-emerald-400/10",
},
{
label: m.overview_stat_folders(),
value: stats.folderCount,
icon: "folder",
href: `/${data.org.slug}/documents`,
color: "text-amber-400",
bg: "bg-amber-400/10",
},
{
label: m.overview_stat_boards(),
value: stats.kanbanCount,
icon: "view_kanban",
href: `/${data.org.slug}/documents`,
color: "text-purple-400",
bg: "bg-purple-400/10",
},
]);
const isEditor = $derived(
["owner", "admin", "editor"].includes(data.userRole),
);
const quickLinks = $derived([
{
label: m.nav_files(),
icon: "cloud",
href: `/${data.org.slug}/documents`,
},
{
label: m.nav_calendar(),
icon: "calendar_today",
href: `/${data.org.slug}/calendar`,
},
...(isAdmin
? [
{
label: m.nav_settings(),
icon: "settings",
href: `/${data.org.slug}/settings`,
},
]
: []),
{ label: m.nav_events(), icon: "celebration", href: `/${data.org.slug}/events`, color: "text-primary" },
{ label: m.nav_files(), icon: "cloud", href: `/${data.org.slug}/documents`, color: "text-emerald-400" },
{ label: m.nav_calendar(), icon: "calendar_today", href: `/${data.org.slug}/calendar`, color: "text-blue-400" },
{ label: "Chat", icon: "chat", href: `/${data.org.slug}/chat`, color: "text-purple-400" },
]);
function getEntityTypeLabel(entityType: string): string {
const map: Record<string, () => string> = {
document: m.entity_document,
folder: m.entity_folder,
kanban_board: m.entity_kanban_board,
kanban_card: m.entity_kanban_card,
kanban_column: m.entity_kanban_column,
member: m.entity_member,
role: m.entity_role,
invite: m.entity_invite,
};
return (map[entityType] ?? (() => entityType))();
}
function getActivityIcon(action: string): string {
const map: Record<string, string> = {
create: "add_circle",
update: "edit",
delete: "delete",
move: "drive_file_move",
rename: "edit_note",
};
return map[action] ?? "info";
}
function getActivityColor(action: string): string {
const map: Record<string, string> = {
create: "text-emerald-400",
update: "text-blue-400",
delete: "text-red-400",
move: "text-amber-400",
rename: "text-purple-400",
};
return map[action] ?? "text-light/50";
}
function formatTimeAgo(dateStr: string | null): string {
if (!dateStr) return "";
const now = Date.now();
const then = new Date(dateStr).getTime();
const diffMs = now - then;
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return m.activity_just_now();
if (diffMin < 60)
return m.activity_minutes_ago({ count: String(diffMin) });
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return m.activity_hours_ago({ count: String(diffHr) });
const diffDay = Math.floor(diffHr / 24);
return m.activity_days_ago({ count: String(diffDay) });
}
function getActivityDescription(entry: ActivityEntry): string {
const userName =
entry.profiles?.full_name || entry.profiles?.email || "Someone";
const entityType = getEntityTypeLabel(entry.entity_type);
const name = entry.entity_name ?? "—";
const map: Record<string, () => string> = {
create: () =>
m.activity_created({ user: userName, entityType, name }),
update: () =>
m.activity_updated({ user: userName, entityType, name }),
delete: () =>
m.activity_deleted({ user: userName, entityType, name }),
move: () => m.activity_moved({ user: userName, entityType, name }),
rename: () =>
m.activity_renamed({ user: userName, entityType, name }),
};
return (map[entry.action] ?? map["update"]!)();
}
</script>
<svelte:head>
<title>{data.org.name} | Root</title>
</svelte:head>
<div class="flex flex-col h-full p-4 lg:p-5 gap-6 overflow-auto">
<!-- Header -->
<header>
<h1 class="text-h1 font-heading text-white">{data.org.name}</h1>
<p class="text-body text-light/60 font-body">{m.overview_title()}</p>
</header>
<div class="flex flex-col h-full overflow-auto">
<PageHeader title={data.org.name} subtitle={m.overview_subtitle()}>
{#snippet actions()}
{#if isEditor}
<a
href="/{data.org.slug}/events"
class="flex items-center gap-2 px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors"
>
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>celebration</span
>
{m.nav_events()}
</a>
{/if}
{/snippet}
</PageHeader>
<div class="flex-1 p-6 overflow-auto">
<!-- Stats Grid -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
{#each statCards as stat}
{#if stat.href}
<a
href={stat.href}
class="bg-night rounded-2xl p-5 flex flex-col gap-3 hover:bg-night/80 transition-colors group"
>
<div
class="w-10 h-10 rounded-xl {stat.bg} flex items-center justify-center"
>
<span
class="material-symbols-rounded {stat.color}"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
{stat.icon}
</span>
</div>
<div>
<p class="text-2xl font-bold text-white">
{stat.value}
</p>
<p class="text-body-sm text-light/50">{stat.label}</p>
</div>
</a>
{:else}
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3">
<div
class="w-10 h-10 rounded-xl {stat.bg} flex items-center justify-center"
>
<span
class="material-symbols-rounded {stat.color}"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>
{stat.icon}
</span>
</div>
<div>
<p class="text-2xl font-bold text-white">
{stat.value}
</p>
<p class="text-body-sm text-light/50">{stat.label}</p>
</div>
</div>
{/if}
{/each}
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 flex-1 min-h-0">
<!-- Recent Activity -->
<div
class="lg:col-span-2 bg-night rounded-2xl p-5 flex flex-col gap-4 min-h-0"
>
<h2 class="text-h3 font-heading text-white">
{m.activity_title()}
</h2>
{#if recentActivity.length === 0}
<div
class="flex-1 flex flex-col items-center justify-center text-light/40 py-12"
>
<span
class="material-symbols-rounded mb-3"
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
>
history
</span>
<p class="text-body">{m.activity_empty()}</p>
</div>
{:else}
<div class="flex flex-col gap-1 overflow-auto flex-1">
{#each recentActivity as entry}
<div
class="flex items-start gap-3 px-3 py-2.5 rounded-xl hover:bg-dark/50 transition-colors"
>
<span
class="material-symbols-rounded {getActivityColor(
entry.action,
)} mt-0.5 shrink-0"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>
{getActivityIcon(entry.action)}
</span>
<div class="flex-1 min-w-0">
<p
class="text-body-sm text-light leading-relaxed"
>
{getActivityDescription(entry)}
</p>
<p class="text-[11px] text-light/40 mt-0.5">
{formatTimeAgo(entry.created_at)}
</p>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Sidebar: Quick Links + Members -->
<div class="flex flex-col gap-6">
<!-- Quick Links -->
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3">
<h2 class="text-h3 font-heading text-white">
{m.overview_quick_links()}
</h2>
<div class="flex flex-col gap-1">
{#each quickLinks as link}
<a
href={link.href}
class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-light hover:bg-dark/50 hover:text-white transition-colors"
>
<span
class="material-symbols-rounded text-light/50"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>
{link.icon}
</span>
<span class="text-body">{link.label}</span>
</a>
{/each}
</div>
</div>
<!-- Team Members Preview -->
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3">
<div class="flex items-center justify-between">
<h2 class="text-h3 font-heading text-white">
{m.overview_stat_members()}
</h2>
<span class="text-body-sm text-light/40"
>{stats.memberCount}</span
>
</div>
<div class="flex flex-col gap-2">
{#each members.slice(0, 5) as member}
<div class="flex items-center gap-3 px-1 py-1">
<Avatar
name={member.profiles?.full_name ||
member.profiles?.email ||
"?"}
src={member.profiles?.avatar_url}
size="sm"
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
<StatCard
label={m.overview_stat_events()}
value={stats.eventCount}
icon="celebration"
href="/{data.org.slug}/events"
color="text-primary"
bg="bg-primary/10"
/>
<StatCard
label={m.overview_stat_members()}
value={stats.memberCount}
icon="group"
href={isAdmin ? `/${data.org.slug}/settings` : null}
color="text-blue-400"
bg="bg-blue-400/10"
/>
<StatCard
label={m.overview_stat_documents()}
value={stats.documentCount}
icon="description"
href="/{data.org.slug}/documents"
color="text-emerald-400"
bg="bg-emerald-400/10"
/>
<StatCard
label={m.overview_stat_boards()}
value={stats.kanbanCount}
icon="view_kanban"
href="/{data.org.slug}/documents"
color="text-purple-400"
bg="bg-purple-400/10"
/>
<div class="flex-1 min-w-0">
<p class="text-body-sm text-white truncate">
{member.profiles?.full_name ||
member.profiles?.email ||
"Unknown"}
</p>
<p class="text-[11px] text-light/40 capitalize">
{member.role}
</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Left Column: Upcoming Events + Activity -->
<div class="lg:col-span-2 flex flex-col gap-6">
<!-- Upcoming Events -->
<SectionCard title={m.overview_upcoming_events()}>
{#snippet titleRight()}
<a
href="/{data.org.slug}/events"
class="text-[12px] text-primary hover:underline"
>{m.overview_view_all_events()}</a
>
{/snippet}
{#if upcomingEvents.length === 0}
<div class="flex flex-col items-center justify-center text-light/40 py-8">
<span
class="material-symbols-rounded mb-2"
style="font-size: 40px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 40;"
>celebration</span
>
<p class="text-body-sm">{m.overview_upcoming_empty()}</p>
</div>
{:else}
<div class="flex flex-col gap-1">
{#each upcomingEvents as event}
<EventCard
name={event.name}
slug={event.slug}
status={event.status}
startDate={event.start_date}
endDate={event.end_date}
color={event.color}
venueName={event.venue_name}
href="/{data.org.slug}/events/{event.slug}"
compact
/>
{/each}
{#if stats.memberCount > 5}
</div>
{/if}
</SectionCard>
<!-- Recent Activity -->
<SectionCard title={m.activity_title()}>
<ActivityFeed entries={recentActivity} />
</SectionCard>
</div>
<!-- Right Column: Quick Links + Team -->
<div class="flex flex-col gap-6">
<SectionCard title={m.overview_quick_links()}>
<QuickLinkGrid links={quickLinks} />
</SectionCard>
<SectionCard title={m.overview_stat_members()}>
{#snippet titleRight()}
<span class="text-[12px] text-light/30">{stats.memberCount}</span>
{/snippet}
<MemberList
{members}
max={6}
moreHref="/{data.org.slug}/settings"
moreLabel={m.overview_more_members({ count: String(Math.max(0, stats.memberCount - 6)) })}
/>
</SectionCard>
{#if isAdmin}
<a
href="/{data.org.slug}/settings"
class="text-body-sm text-primary hover:underline text-center pt-1"
class="flex items-center gap-3 bg-dark/30 border border-light/5 hover:border-light/10 rounded-xl p-4 transition-all group"
>
+{stats.memberCount - 5} more
<div class="w-10 h-10 rounded-xl bg-light/5 flex items-center justify-center">
<span
class="material-symbols-rounded text-light/40 group-hover:text-white transition-colors"
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 22;"
>settings</span
>
</div>
<div>
<p class="text-body-sm text-white group-hover:text-primary transition-colors">
{m.nav_settings()}
</p>
<p class="text-[11px] text-light/30">{m.settings_general_title()}</p>
</div>
</a>
{/if}
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { navigating } from "$app/stores";
import { PageHeader, ContentSkeleton } from "$lib/components/ui";
import * as m from "$lib/paraglide/messages";
interface Props {
data: {
org: { id: string; name: string; slug: string };
};
children: Snippet;
}
let { data, children }: Props = $props();
const isNavigatingHere = $derived(
$navigating?.to?.url.pathname.includes("/account") ?? false,
);
</script>
<div class="flex flex-col h-full overflow-hidden">
<PageHeader
title={m.account_title()}
subtitle={m.account_subtitle()}
icon="person"
iconColor="text-light/50"
/>
{#if isNavigatingHere}
<ContentSkeleton variant="settings" />
{:else}
<div class="flex-1 overflow-auto">
{@render children()}
</div>
{/if}
</div>

View File

@@ -16,6 +16,10 @@
email: string;
full_name: string | null;
avatar_url: string | null;
phone: string | null;
discord_handle: string | null;
shirt_size: string | null;
hoodie_size: string | null;
};
preferences: {
id: string;
@@ -34,10 +38,16 @@
// Profile state
let fullName = $state(data.profile.full_name ?? "");
let avatarUrl = $state(data.profile.avatar_url ?? null);
let phone = $state(data.profile.phone ?? "");
let discordHandle = $state(data.profile.discord_handle ?? "");
let shirtSize = $state(data.profile.shirt_size ?? "");
let hoodieSize = $state(data.profile.hoodie_size ?? "");
let isSaving = $state(false);
let isUploading = $state(false);
let avatarInput = $state<HTMLInputElement | null>(null);
const clothingSizes = ["XS", "S", "M", "L", "XL", "XXL", "3XL"];
// Preferences state
let theme = $state(data.preferences?.theme ?? "dark");
let accentColor = $state(data.preferences?.accent_color ?? "#00A3E0");
@@ -57,6 +67,10 @@
$effect(() => {
fullName = data.profile.full_name ?? "";
avatarUrl = data.profile.avatar_url ?? null;
phone = data.profile.phone ?? "";
discordHandle = data.profile.discord_handle ?? "";
shirtSize = data.profile.shirt_size ?? "";
hoodieSize = data.profile.hoodie_size ?? "";
theme = data.preferences?.theme ?? "dark";
accentColor = data.preferences?.accent_color ?? "#00A3E0";
useOrgTheme = data.preferences?.use_org_theme ?? true;
@@ -161,9 +175,15 @@
async function saveProfile() {
isSaving = true;
const { error } = await supabase
const { error } = await (supabase as any)
.from("profiles")
.update({ full_name: fullName || null })
.update({
full_name: fullName || null,
phone: phone || null,
discord_handle: discordHandle || null,
shirt_size: shirtSize || null,
hoodie_size: hoodieSize || null,
})
.eq("id", data.profile.id);
if (error) {
@@ -227,25 +247,17 @@
<title>Account Settings | Root</title>
</svelte:head>
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
<!-- Header -->
<div>
<h1 class="font-heading text-h1 text-white">{m.account_title()}</h1>
<p class="font-body text-body text-light/60 mt-1">
{m.account_subtitle()}
</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 flex-1 min-h-0">
<div class="flex-1 p-6 overflow-auto">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<!-- Profile Section -->
<div class="bg-background rounded-[32px] p-6 flex flex-col gap-6">
<h2 class="font-heading text-h3 text-white">
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5">
<h2 class="font-heading text-body text-white">
{m.account_profile()}
</h2>
<!-- Avatar -->
<div class="flex flex-col gap-3">
<span class="font-body text-body-sm text-light"
<span class="font-body text-body-sm text-light/60"
>{m.account_photo()}</span
>
<div class="flex items-center gap-4">
@@ -313,9 +325,61 @@
</div>
</div>
<!-- Contact & Sizing Section -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5">
<h2 class="font-heading text-body text-white">
{m.account_contact_info()}
</h2>
<Input
label={m.account_phone()}
bind:value={phone}
placeholder={m.account_phone_placeholder()}
/>
<Input
label={m.account_discord()}
bind:value={discordHandle}
placeholder={m.account_discord_placeholder()}
/>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<span class="font-body text-body-sm text-light/60">{m.account_shirt_size()}</span>
<select
bind:value={shirtSize}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
>
<option value="">{m.account_size_placeholder()}</option>
{#each clothingSizes as size}
<option value={size}>{size}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-1.5">
<span class="font-body text-body-sm text-light/60">{m.account_hoodie_size()}</span>
<select
bind:value={hoodieSize}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
>
<option value="">{m.account_size_placeholder()}</option>
{#each clothingSizes as size}
<option value={size}>{size}</option>
{/each}
</select>
</div>
</div>
<div>
<Button onclick={saveProfile} loading={isSaving}>
{m.account_save_profile()}
</Button>
</div>
</div>
<!-- Appearance Section -->
<div class="bg-background rounded-[32px] p-6 flex flex-col gap-6">
<h2 class="font-heading text-h3 text-white">
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5">
<h2 class="font-heading text-body text-white">
{m.account_appearance()}
</h2>
@@ -333,7 +397,7 @@
<!-- Accent Color -->
<div class="flex flex-col gap-2">
<span class="font-body text-body-sm text-light"
<span class="font-body text-body-sm text-light/60"
>{m.account_accent_color()}</span
>
<div class="flex flex-wrap gap-2 items-center">
@@ -371,10 +435,10 @@
<!-- Use Org Theme -->
<div class="flex items-center justify-between">
<div>
<p class="font-body text-body text-white">
<p class="font-body text-body-sm text-white">
{m.account_use_org_theme()}
</p>
<p class="font-body text-[12px] text-light/50">
<p class="font-body text-[11px] text-light/40">
{m.account_use_org_theme_desc()}
</p>
</div>
@@ -396,20 +460,20 @@
<!-- Language -->
<div class="flex flex-col gap-2">
<span class="font-body text-body-sm text-light"
<span class="font-body text-body-sm text-light/60"
>{m.account_language()}</span
>
<p class="font-body text-[12px] text-light/50">
<p class="font-body text-[11px] text-light/40">
{m.account_language_desc()}
</p>
<div class="flex gap-2 mt-1">
{#each locales as locale}
<button
type="button"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors {currentLocale ===
class="px-3 py-1.5 rounded-lg text-[12px] font-medium transition-colors {currentLocale ===
locale
? 'bg-primary text-night'
: 'bg-light/10 text-light/70 hover:bg-light/20'}"
? 'bg-primary text-background'
: 'bg-light/5 text-light/50 hover:bg-light/10'}"
onclick={() => handleLanguageChange(locale)}
>
{localeLabels[locale] ?? locale}
@@ -426,16 +490,16 @@
</div>
<!-- Security & Sessions Section -->
<div class="bg-background rounded-[32px] p-6 flex flex-col gap-6">
<h2 class="font-heading text-h3 text-white">
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5">
<h2 class="font-heading text-body text-white">
{m.account_security()}
</h2>
<div class="flex flex-col gap-2">
<p class="font-body text-body text-white">
<p class="font-body text-body-sm text-white">
{m.account_password()}
</p>
<p class="font-body text-body-sm text-light/50">
<p class="font-body text-[11px] text-light/40">
{m.account_password_desc()}
</p>
<div class="mt-2">
@@ -460,11 +524,11 @@
</div>
</div>
<div class="border-t border-light/10 pt-4 flex flex-col gap-2">
<p class="font-body text-body text-white">
<div class="border-t border-light/5 pt-4 flex flex-col gap-2">
<p class="font-body text-body-sm text-white">
{m.account_active_sessions()}
</p>
<p class="font-body text-body-sm text-light/50">
<p class="font-body text-[11px] text-light/40">
{m.account_sessions_desc()}
</p>
<div class="mt-2">

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { navigating } from "$app/stores";
import { PageHeader, ContentSkeleton } from "$lib/components/ui";
import * as m from "$lib/paraglide/messages";
interface Props {
data: {
org: { id: string; name: string; slug: string };
};
children: Snippet;
}
let { data, children }: Props = $props();
const isNavigatingHere = $derived(
$navigating?.to?.url.pathname.includes("/calendar") ?? false,
);
</script>
<div class="flex flex-col h-full overflow-hidden">
<PageHeader title={m.nav_calendar()} icon="calendar_today" iconColor="text-blue-400" />
{#if isNavigatingHere}
<ContentSkeleton variant="calendar" />
{:else}
<div class="flex-1 overflow-auto">
{@render children()}
</div>
{/if}
</div>

View File

@@ -456,13 +456,11 @@
<title>Calendar - {data.org.name} | Root</title>
</svelte:head>
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
<!-- Header -->
<header class="flex items-center gap-2 p-1">
<h1 class="flex-1 font-heading text-h1 text-white">
{m.calendar_title()}
</h1>
<Button size="md" onclick={() => handleDateClick(new Date())}
<div class="flex flex-col h-full">
<!-- Toolbar -->
<div class="flex items-center gap-2 px-6 py-3 border-b border-light/5 shrink-0">
<div class="flex-1"></div>
<Button size="sm" onclick={() => handleDateClick(new Date())}
>{m.btn_new()}</Button
>
<ContextMenu
@@ -502,10 +500,10 @@
: []),
]}
/>
</header>
</div>
<!-- Calendar Grid -->
<div class="flex-1 overflow-auto">
<div class="flex-1 overflow-auto p-4">
<Calendar
events={allEvents}
onDateClick={handleDateClick}

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { navigating } from "$app/stores";
import { PageHeader, ContentSkeleton } from "$lib/components/ui";
import * as m from "$lib/paraglide/messages";
interface Props {
data: {
org: { id: string; name: string; slug: string };
};
children: Snippet;
}
let { data, children }: Props = $props();
const isNavigatingHere = $derived(
$navigating?.to?.url.pathname?.includes(`/${data.org.slug}/chat`),
);
</script>
<div class="flex flex-col h-full overflow-hidden">
<PageHeader
title={m.chat_title()}
subtitle={m.chat_subtitle()}
icon="chat"
iconColor="text-primary"
/>
{#if isNavigatingHere && !$navigating?.from?.url.pathname?.includes(`/${data.org.slug}/chat`)}
<ContentSkeleton variant="default" />
{:else}
<div class="flex-1 overflow-hidden">
{@render children()}
</div>
{/if}
</div>

View File

@@ -0,0 +1,794 @@
<script lang="ts">
import { onMount, getContext } from "svelte";
import { browser } from "$app/environment";
import { page } from "$app/state";
import { Avatar, Button, Input, Modal } from "$lib/components/ui";
import {
MessageList,
MessageInput,
TypingIndicator,
CreateRoomModal,
MemberList,
StartDMModal,
RoomInfoPanel,
MatrixProvider,
} from "$lib/components/matrix";
import type { MatrixClient } from "matrix-js-sdk";
import {
initMatrixClient,
setupSyncHandlers,
logout as matrixLogout,
editMessage,
deleteMessage,
loadMoreMessages,
getRoomMembers,
searchMessagesLocal,
uploadFile,
sendFileMessage,
type LoginCredentials,
} from "$lib/matrix";
import {
auth,
syncState,
roomSummaries,
selectedRoomId,
selectRoom,
clearState,
currentMessages,
currentTyping,
loadRoomMessages,
} from "$lib/stores/matrix";
import { reactionService } from "$lib/services";
import { toasts } from "$lib/stores/toast.svelte";
import { initCache, cleanupCache } from "$lib/cache";
import { clearBlobUrlCache } from "$lib/cache/mediaCache";
import type { Message } from "$lib/matrix/types";
import type { SupabaseClient } from "@supabase/supabase-js";
const supabase = getContext<SupabaseClient>("supabase");
let data = $derived(page.data);
// Matrix state
let matrixClient = $state<MatrixClient | null>(null);
let isInitializing = $state(true);
let showMatrixLogin = $state(false);
// Matrix login form
let matrixHomeserver = $state("https://matrix.org");
let matrixUsername = $state("");
let matrixPassword = $state("");
let isLoggingIn = $state(false);
// Chat UI state
let showCreateRoomModal = $state(false);
let showStartDMModal = $state(false);
let replyToMessage = $state<Message | null>(null);
let editingMsg = $state<Message | null>(null);
let isLoadingMore = $state(false);
let showMemberList = $state(false);
let showRoomInfo = $state(false);
let roomSearchQuery = $state("");
let showMessageSearch = $state(false);
let messageSearchQuery = $state("");
let isDraggingFile = $state(false);
let isUploadingDrop = $state(false);
const messageSearchResults = $derived(
messageSearchQuery.trim() && $selectedRoomId
? searchMessagesLocal($selectedRoomId, messageSearchQuery)
: [],
);
// All non-space rooms (exclude Space entries themselves from the list)
const allRooms = $derived(
$roomSummaries.filter((r) => !r.isSpace),
);
// Org rooms: rooms that belong to any Space
const orgRooms = $derived(
allRooms.filter((r) => r.parentSpaceId && !r.isDirect),
);
// DMs: direct messages (not tied to org)
const dmRooms = $derived(
allRooms.filter((r) => r.isDirect),
);
// Other rooms: not in a space and not a DM
const otherRooms = $derived(
allRooms.filter((r) => !r.parentSpaceId && !r.isDirect),
);
// Apply search filter across all sections
const filterBySearch = (rooms: typeof allRooms) =>
roomSearchQuery.trim()
? rooms.filter(
(room) =>
room.name.toLowerCase().includes(roomSearchQuery.toLowerCase()) ||
room.topic?.toLowerCase().includes(roomSearchQuery.toLowerCase()),
)
: rooms;
const filteredOrgRooms = $derived(filterBySearch(orgRooms));
const filteredDmRooms = $derived(filterBySearch(dmRooms));
const filteredOtherRooms = $derived(filterBySearch(otherRooms));
const currentMembers = $derived(
$selectedRoomId ? getRoomMembers($selectedRoomId) : [],
);
onMount(async () => {
if (!browser) return;
try {
await initCache();
await cleanupCache(7 * 24 * 60 * 60 * 1000);
} catch (e) {
console.warn("Cache initialization failed:", e);
}
// Try to load credentials from Supabase
try {
const res = await fetch(`/api/matrix-credentials?org_id=${data.org.id}`);
const result = await res.json();
if (result.credentials) {
await initFromCredentials({
homeserverUrl: result.credentials.homeserver_url,
userId: result.credentials.matrix_user_id,
accessToken: result.credentials.access_token,
deviceId: result.credentials.device_id,
});
} else {
// No stored credentials — show login form
showMatrixLogin = true;
isInitializing = false;
}
} catch (e) {
console.error("Failed to load Matrix credentials:", e);
showMatrixLogin = true;
isInitializing = false;
}
});
async function initFromCredentials(credentials: LoginCredentials) {
try {
const client = await initMatrixClient(credentials);
matrixClient = client;
setupSyncHandlers(client);
auth.set({
isLoggedIn: true,
userId: credentials.userId,
homeserverUrl: credentials.homeserverUrl,
accessToken: credentials.accessToken,
deviceId: credentials.deviceId || null,
});
// Check if org has a Matrix Space, auto-create if not
await ensureOrgSpace(credentials);
} catch (e: unknown) {
console.error("Failed to init Matrix client:", e);
toasts.error("Failed to connect to chat. Please re-login.");
showMatrixLogin = true;
} finally {
isInitializing = false;
}
}
async function ensureOrgSpace(credentials: LoginCredentials) {
try {
const spaceRes = await fetch(`/api/matrix-space?org_id=${data.org.id}`);
const spaceResult = await spaceRes.json();
if (!spaceResult.spaceId) {
// No Space yet — create one using the user's credentials
const createRes = await fetch("/api/matrix-space", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
org_id: data.org.id,
action: "create",
homeserver_url: credentials.homeserverUrl,
access_token: credentials.accessToken,
org_name: data.org.name,
}),
});
const createResult = await createRes.json();
if (createResult.spaceId) {
toasts.success(`Organization space created`);
}
}
} catch (e) {
console.warn("Failed to ensure org space:", e);
}
}
async function handleMatrixLogin() {
if (!matrixUsername.trim() || !matrixPassword.trim()) {
toasts.error("Please enter username and password");
return;
}
isLoggingIn = true;
try {
const { loginWithPassword } = await import("$lib/matrix");
const credentials = await loginWithPassword({
homeserverUrl: matrixHomeserver,
username: matrixUsername.trim(),
password: matrixPassword,
});
// Save to Supabase
await fetch("/api/matrix-credentials", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
org_id: data.org.id,
homeserver_url: credentials.homeserverUrl,
matrix_user_id: credentials.userId,
access_token: credentials.accessToken,
device_id: credentials.deviceId,
}),
});
showMatrixLogin = false;
await initFromCredentials(credentials);
toasts.success("Connected to chat!");
} catch (e: any) {
toasts.error(e.message || "Login failed");
} finally {
isLoggingIn = false;
}
}
async function handleLogout() {
try {
await matrixLogout();
} catch {}
clearState();
clearBlobUrlCache();
// Remove from Supabase
await fetch(`/api/matrix-credentials?org_id=${data.org.id}`, {
method: "DELETE",
});
matrixClient = null;
showMatrixLogin = true;
auth.set({
isLoggedIn: false,
userId: null,
homeserverUrl: null,
accessToken: null,
deviceId: null,
});
}
function handleRoomSelect(roomId: string) {
selectRoom(roomId);
}
async function handleReact(messageId: string, emoji: string) {
if (!$selectedRoomId || !$auth.userId) return;
try {
await reactionService.add($selectedRoomId, messageId, emoji, $auth.userId);
} catch (e) {
const error = e as { message?: string };
toasts.error(error.message || "Failed to add reaction");
}
}
async function handleToggleReaction(
messageId: string,
emoji: string,
reactionEventId: string | null,
) {
if (!$selectedRoomId || !$auth.userId) return;
try {
await reactionService.toggle(
$selectedRoomId,
messageId,
emoji,
$auth.userId,
reactionEventId,
);
} catch (e) {
const error = e as { message?: string };
toasts.error(error.message || "Failed to toggle reaction");
}
}
function handleEditMessage(message: Message) {
editingMsg = message;
}
async function handleSaveEdit(newContent: string) {
if (!$selectedRoomId || !editingMsg) return;
try {
await editMessage($selectedRoomId, editingMsg.eventId, newContent);
editingMsg = null;
toasts.success("Message edited");
} catch (e: any) {
toasts.error(e.message || "Failed to edit message");
}
}
function cancelEdit() {
editingMsg = null;
}
async function handleDeleteMessage(messageId: string) {
if (!$selectedRoomId) return;
if (!confirm("Delete this message?")) return;
try {
await deleteMessage($selectedRoomId, messageId);
toasts.success("Message deleted");
} catch (e: any) {
toasts.error(e.message || "Failed to delete message");
}
}
function handleReply(message: Message) {
replyToMessage = message;
}
function cancelReply() {
replyToMessage = null;
}
function handleDragOver(e: DragEvent) {
e.preventDefault();
if (e.dataTransfer?.types.includes("Files")) isDraggingFile = true;
}
function handleDragLeave(e: DragEvent) {
e.preventDefault();
isDraggingFile = false;
}
async function handleDrop(e: DragEvent) {
e.preventDefault();
isDraggingFile = false;
if (!$selectedRoomId || isUploadingDrop) return;
const files = e.dataTransfer?.files;
if (!files || files.length === 0) return;
const file = files[0];
if (file.size > 50 * 1024 * 1024) {
toasts.error("File too large. Maximum size is 50MB.");
return;
}
isUploadingDrop = true;
try {
toasts.info(`Uploading ${file.name}...`);
const contentUri = await uploadFile(file);
await sendFileMessage($selectedRoomId, file, contentUri);
toasts.success("File sent!");
} catch (e: any) {
toasts.error(e.message || "Failed to upload file");
} finally {
isUploadingDrop = false;
}
}
async function handleLoadMore() {
if (!$selectedRoomId || isLoadingMore) return;
isLoadingMore = true;
try {
const result = await loadMoreMessages($selectedRoomId);
loadRoomMessages($selectedRoomId);
if (!result.hasMore) toasts.info("No more messages to load");
} catch (e: any) {
console.error("Failed to load more messages:", e);
} finally {
isLoadingMore = false;
}
}
</script>
<!-- Matrix Login Modal -->
{#if showMatrixLogin}
<div class="h-full flex items-center justify-center">
<div class="bg-dark/30 border border-light/5 rounded-xl p-8 w-full max-w-md">
<h2 class="font-heading text-body text-white mb-1">Connect to Chat</h2>
<p class="text-body-sm text-light/50 mb-6">
Enter your Matrix credentials to enable messaging.
</p>
<div class="space-y-4">
<Input
label="Homeserver URL"
bind:value={matrixHomeserver}
placeholder="https://matrix.org"
/>
<Input
label="Username"
bind:value={matrixUsername}
placeholder="@user:matrix.org"
/>
<div>
<label class="block text-body-sm font-body text-light/60 mb-1">Password</label>
<input
type="password"
bind:value={matrixPassword}
placeholder="Password"
class="w-full bg-dark border border-light/10 rounded-xl px-3 py-2 text-white font-body text-body-sm placeholder:text-light/30 focus:outline-none focus:border-primary"
onkeydown={(e) => {
if (e.key === "Enter") handleMatrixLogin();
}}
/>
</div>
<Button
variant="primary"
fullWidth
onclick={handleMatrixLogin}
disabled={isLoggingIn}
>
{isLoggingIn ? "Connecting..." : "Connect"}
</Button>
</div>
</div>
</div>
<!-- Loading state -->
{:else if isInitializing || ($syncState !== "PREPARED" && $syncState !== "SYNCING")}
<div class="h-full flex items-center justify-center">
<div class="text-center">
<div
class="animate-spin w-10 h-10 border-3 border-primary border-t-transparent rounded-full mx-auto mb-4"
></div>
<p class="text-body-sm text-light/40">
{#if isInitializing}
Connecting to Matrix...
{:else if $syncState === "CATCHUP"}
Catching up on messages...
{:else if $syncState === "RECONNECTING"}
Reconnecting...
{:else if $syncState === "ERROR"}
Connection error, retrying...
{:else}
Syncing...
{/if}
</p>
</div>
</div>
<!-- Main Chat UI -->
{:else if matrixClient}
<MatrixProvider client={matrixClient}>
{#snippet children()}
<div class="h-full flex min-h-0">
<!-- Chat Sidebar -->
<aside class="w-56 border-r border-light/5 flex flex-col overflow-hidden shrink-0">
<div class="flex items-center gap-2 px-3 py-2.5 border-b border-light/5">
<span class="flex-1 font-heading text-body-sm text-white">Messages</span>
<button
class="p-1 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
onclick={() => (showStartDMModal = true)}
title="New message"
>
<span class="material-symbols-rounded" style="font-size: 18px;">add</span>
</button>
</div>
<!-- Room search -->
<div class="px-2 py-2">
<div class="relative">
<span
class="material-symbols-rounded absolute left-2.5 top-1/2 -translate-y-1/2 text-light/30"
style="font-size: 16px;"
>search</span>
<input
type="text"
bind:value={roomSearchQuery}
placeholder="Search..."
class="w-full pl-8 pr-3 py-1.5 bg-dark/50 text-white text-[12px] rounded-lg border border-light/5 placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
</div>
</div>
<!-- Room list (sectioned) -->
<nav class="flex-1 overflow-y-auto px-1.5 pb-2">
{#if allRooms.length === 0}
<p class="text-light/30 text-[12px] text-center py-8">
{roomSearchQuery ? "No matching rooms" : "No rooms yet"}
</p>
{:else}
<!-- Org / Space Rooms -->
{#if filteredOrgRooms.length > 0}
<div class="mb-1.5">
<div class="flex items-center justify-between px-2 py-1">
<span class="text-[10px] font-body text-light/30 uppercase tracking-wider">Organization</span>
<button
class="p-0.5 text-light/30 hover:text-white hover:bg-dark/50 rounded transition-colors"
onclick={() => (showCreateRoomModal = true)}
title="Create room"
>
<span class="material-symbols-rounded" style="font-size: 14px;">add</span>
</button>
</div>
<ul class="flex flex-col gap-0.5">
{#each filteredOrgRooms as room (room.roomId)}
<li>
<button
class="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg transition-colors text-left
{$selectedRoomId === room.roomId ? 'bg-primary/10 text-white' : 'text-light/60 hover:bg-dark/50 hover:text-white'}"
onclick={() => handleRoomSelect(room.roomId)}
>
<Avatar src={room.avatarUrl} name={room.name} size="xs" />
<div class="flex-1 min-w-0">
<span class="text-[12px] font-body truncate block">{room.name}</span>
</div>
{#if room.unreadCount > 0}
<span class="bg-primary text-background text-[10px] px-1.5 py-0.5 rounded-full min-w-[16px] text-center font-bold">
{room.unreadCount > 99 ? "99+" : room.unreadCount}
</span>
{/if}
</button>
</li>
{/each}
</ul>
</div>
{/if}
<!-- Direct Messages -->
{#if filteredDmRooms.length > 0}
<div class="mb-1.5">
<div class="flex items-center justify-between px-2 py-1">
<span class="text-[10px] font-body text-light/30 uppercase tracking-wider">Direct Messages</span>
<button
class="p-0.5 text-light/30 hover:text-white hover:bg-dark/50 rounded transition-colors"
onclick={() => (showStartDMModal = true)}
title="New DM"
>
<span class="material-symbols-rounded" style="font-size: 14px;">add</span>
</button>
</div>
<ul class="flex flex-col gap-0.5">
{#each filteredDmRooms as room (room.roomId)}
<li>
<button
class="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg transition-colors text-left
{$selectedRoomId === room.roomId ? 'bg-primary/10 text-white' : 'text-light/60 hover:bg-dark/50 hover:text-white'}"
onclick={() => handleRoomSelect(room.roomId)}
>
<Avatar src={room.avatarUrl} name={room.name} size="xs" />
<div class="flex-1 min-w-0">
<span class="text-[12px] font-body truncate block">{room.name}</span>
</div>
{#if room.unreadCount > 0}
<span class="bg-primary text-background text-[10px] px-1.5 py-0.5 rounded-full min-w-[16px] text-center font-bold">
{room.unreadCount > 99 ? "99+" : room.unreadCount}
</span>
{/if}
</button>
</li>
{/each}
</ul>
</div>
{/if}
<!-- Other Rooms (not in a space, not DMs) -->
{#if filteredOtherRooms.length > 0}
<div class="mb-1.5">
<div class="flex items-center justify-between px-2 py-1">
<span class="text-[10px] font-body text-light/30 uppercase tracking-wider">Rooms</span>
<button
class="p-0.5 text-light/30 hover:text-white hover:bg-dark/50 rounded transition-colors"
onclick={() => (showCreateRoomModal = true)}
title="Create room"
>
<span class="material-symbols-rounded" style="font-size: 14px;">add</span>
</button>
</div>
<ul class="flex flex-col gap-0.5">
{#each filteredOtherRooms as room (room.roomId)}
<li>
<button
class="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg transition-colors text-left
{$selectedRoomId === room.roomId ? 'bg-primary/10 text-white' : 'text-light/60 hover:bg-dark/50 hover:text-white'}"
onclick={() => handleRoomSelect(room.roomId)}
>
<Avatar src={room.avatarUrl} name={room.name} size="xs" />
<div class="flex-1 min-w-0">
<span class="text-[12px] font-body truncate block">{room.name}</span>
</div>
{#if room.unreadCount > 0}
<span class="bg-primary text-background text-[10px] px-1.5 py-0.5 rounded-full min-w-[16px] text-center font-bold">
{room.unreadCount > 99 ? "99+" : room.unreadCount}
</span>
{/if}
</button>
</li>
{/each}
</ul>
</div>
{/if}
{/if}
</nav>
<!-- User footer -->
<div class="px-2 py-2 border-t border-light/5">
<div class="flex items-center gap-2">
<Avatar name={$auth.userId || "User"} size="xs" status="online" />
<div class="flex-1 min-w-0">
<p class="text-[11px] text-light/50 truncate">{$auth.userId}</p>
</div>
<button
class="p-1 text-light/30 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
onclick={handleLogout}
title="Disconnect chat"
>
<span class="material-symbols-rounded" style="font-size: 16px;">logout</span>
</button>
</div>
</div>
</aside>
<!-- Main Chat Area -->
<main class="flex-1 flex flex-col min-h-0 overflow-hidden">
{#if $selectedRoomId}
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
<!-- Room Header -->
<div class="px-4 py-2.5 flex items-center border-b border-light/5 shrink-0">
{#each $roomSummaries.filter((r) => r.roomId === $selectedRoomId) as room}
<div class="flex items-center gap-2.5 w-full">
<Avatar src={room.avatarUrl} name={room.name} size="sm" />
<div class="flex-1 min-w-0">
<h2 class="font-heading text-body-sm text-white truncate">{room.name}</h2>
<p class="text-[11px] text-light/40">
{room.memberCount} members{room.isEncrypted ? " · Encrypted" : ""}
</p>
</div>
<button
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
onclick={() => (showMessageSearch = !showMessageSearch)}
title="Search messages"
>
<span class="material-symbols-rounded" style="font-size: 18px;">search</span>
</button>
<button
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
onclick={() => (showRoomInfo = !showRoomInfo)}
title="Room info"
>
<span class="material-symbols-rounded" style="font-size: 18px;">info</span>
</button>
<button
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
onclick={() => (showMemberList = !showMemberList)}
title="Members"
>
<span class="material-symbols-rounded" style="font-size: 18px;">group</span>
</button>
</div>
{/each}
</div>
<!-- Message search panel -->
{#if showMessageSearch}
<div class="border-b border-light/5 px-4 py-2.5">
<div class="relative">
<span class="material-symbols-rounded absolute left-2.5 top-1/2 -translate-y-1/2 text-light/30" style="font-size: 16px;">search</span>
<input
type="text"
bind:value={messageSearchQuery}
placeholder="Search messages..."
class="w-full pl-8 pr-8 py-1.5 bg-dark/50 text-white text-[12px] rounded-lg border border-light/5 placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
<button
class="absolute right-2 top-1/2 -translate-y-1/2 text-light/30 hover:text-white"
onclick={() => { showMessageSearch = false; messageSearchQuery = ""; }}
>
<span class="material-symbols-rounded" style="font-size: 16px;">close</span>
</button>
</div>
{#if messageSearchQuery && messageSearchResults.length > 0}
<div class="mt-2 max-h-48 overflow-y-auto">
<p class="text-[11px] text-light/30 mb-1.5">
{messageSearchResults.length} result{messageSearchResults.length !== 1 ? "s" : ""}
</p>
{#each messageSearchResults.slice(0, 20) as result}
<button
class="w-full text-left px-3 py-1.5 hover:bg-dark/50 rounded-lg transition-colors"
onclick={() => { showMessageSearch = false; messageSearchQuery = ""; }}
>
<p class="text-[11px] text-primary">{result.senderName}</p>
<p class="text-body-sm text-white truncate">{result.content}</p>
<p class="text-[10px] text-light/30">{new Date(result.timestamp).toLocaleString()}</p>
</button>
{/each}
</div>
{:else if messageSearchQuery}
<p class="text-body-sm text-light/30 mt-2">No results found</p>
{/if}
</div>
{/if}
<!-- Messages area with drag-drop -->
<div
class="flex-1 flex min-h-0 overflow-hidden relative"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
role="region"
>
{#if isDraggingFile}
<div class="absolute inset-0 z-50 bg-primary/10 border-2 border-dashed border-primary rounded-xl flex items-center justify-center backdrop-blur-sm">
<div class="text-center">
<span class="material-symbols-rounded text-primary mb-3 block" style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;">upload_file</span>
<p class="text-body-sm font-heading text-primary">Drop to upload</p>
<p class="text-[12px] text-light/40 mt-0.5">Release to send file</p>
</div>
</div>
{/if}
<!-- Messages column -->
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
<MessageList
messages={$currentMessages}
onReact={handleReact}
onToggleReaction={handleToggleReaction}
onEdit={handleEditMessage}
onDelete={handleDeleteMessage}
onReply={handleReply}
onLoadMore={handleLoadMore}
isLoading={isLoadingMore}
/>
<TypingIndicator userNames={$currentTyping} />
<MessageInput
roomId={$selectedRoomId}
replyTo={replyToMessage}
onCancelReply={cancelReply}
editingMessage={editingMsg}
onSaveEdit={handleSaveEdit}
onCancelEdit={cancelEdit}
/>
</div>
<!-- Side panels -->
{#if showRoomInfo}
{#each $roomSummaries.filter((r) => r.roomId === $selectedRoomId) as currentRoom}
<aside class="w-72 border-l border-light/5">
<RoomInfoPanel
room={currentRoom}
members={currentMembers}
onClose={() => (showRoomInfo = false)}
/>
</aside>
{/each}
{:else if showMemberList}
<aside class="w-64 border-l border-light/5">
<MemberList members={currentMembers} />
</aside>
{/if}
</div>
</div>
{:else}
<!-- No room selected -->
<div class="flex-1 flex items-center justify-center">
<div class="text-center text-light/30">
<span class="material-symbols-rounded mb-3 block" style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;">chat</span>
<p class="text-body-sm text-light/40 mb-1">Select a room</p>
<p class="text-[12px] text-light/20">Choose a conversation to start chatting</p>
</div>
</div>
{/if}
</main>
</div>
{/snippet}
</MatrixProvider>
{/if}
<!-- Modals -->
<CreateRoomModal isOpen={showCreateRoomModal} onClose={() => (showCreateRoomModal = false)} />
{#if showStartDMModal}
<StartDMModal
onClose={() => (showStartDMModal = false)}
onDMCreated={(roomId) => handleRoomSelect(roomId)}
/>
{/if}

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { navigating } from "$app/stores";
import { PageHeader, ContentSkeleton } from "$lib/components/ui";
import * as m from "$lib/paraglide/messages";
interface Props {
data: {
org: { id: string; name: string; slug: string };
};
children: Snippet;
}
let { data, children }: Props = $props();
const isNavigatingHere = $derived(
$navigating?.to?.url.pathname.includes("/documents") && !$navigating?.to?.url.pathname.includes("/events"),
);
</script>
<div class="flex flex-col h-full overflow-hidden">
<PageHeader title={m.nav_files()} icon="cloud" iconColor="text-emerald-400" />
{#if isNavigatingHere}
<ContentSkeleton variant="files" />
{:else}
<div class="flex-1 overflow-auto">
{@render children()}
</div>
{/if}
</div>

View File

@@ -22,7 +22,7 @@
<title>Files - {data.org.name} | Root</title>
</svelte:head>
<div class="h-full p-4 lg:p-5">
<div class="h-full p-6">
<FileBrowser
org={data.org}
bind:documents

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { navigating, page } from "$app/stores";
import { PageHeader, ContentSkeleton } from "$lib/components/ui";
import * as m from "$lib/paraglide/messages";
interface Props {
data: {
org: { id: string; name: string; slug: string };
userRole: string;
};
children: Snippet;
}
let { data, children }: Props = $props();
// Only show the events list header when on the events list page itself,
// not on event detail pages (which have their own layout)
const isEventsList = $derived(
$page.url.pathname === `/${data.org.slug}/events`,
);
const isNavigatingToList = $derived(
$navigating?.to?.url.pathname === `/${data.org.slug}/events`,
);
const showListLayout = $derived(isEventsList || isNavigatingToList);
</script>
{#if showListLayout}
<div class="flex flex-col h-full overflow-hidden">
<PageHeader
title={m.events_title()}
subtitle={m.events_subtitle()}
icon="celebration"
iconColor="text-primary"
/>
{#if isNavigatingToList && !isEventsList}
<ContentSkeleton variant="list" />
{:else}
<div class="flex-1 overflow-hidden">
{@render children()}
</div>
{/if}
</div>
{:else}
{@render children()}
{/if}

View File

@@ -0,0 +1,29 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { fetchEvents } from '$lib/api/events';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.events');
export const load: PageServerLoad = async ({ params, locals, url }) => {
const { session, user } = await locals.safeGetSession();
if (!session || !user) error(401, 'Unauthorized');
const { data: org } = await locals.supabase
.from('organizations')
.select('id')
.eq('slug', params.orgSlug)
.single();
if (!org) error(404, 'Organization not found');
const statusFilter = url.searchParams.get('status') || 'all';
try {
const events = await fetchEvents(locals.supabase, org.id, statusFilter);
return { events, statusFilter };
} catch (e: any) {
log.error('Failed to load events', { error: e, data: { orgId: org.id } });
return { events: [], statusFilter };
}
};

View File

@@ -0,0 +1,365 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { EventCard, TabBar, Button } from "$lib/components/ui";
import { getContext } from "svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import { toasts } from "$lib/stores/ui";
import * as m from "$lib/paraglide/messages";
interface EventItem {
id: string;
org_id: string;
name: string;
slug: string;
description: string | null;
status: "planning" | "active" | "completed" | "archived";
start_date: string | null;
end_date: string | null;
venue_name: string | null;
color: string | null;
member_count: number;
}
interface Props {
data: {
org: { id: string; name: string; slug: string };
userRole: string;
events: EventItem[];
statusFilter: string;
};
}
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
const isEditor = $derived(
["owner", "admin", "editor"].includes(data.userRole),
);
// Create event modal
let showCreateModal = $state(false);
let newEventName = $state("");
let newEventDescription = $state("");
let newEventStartDate = $state("");
let newEventEndDate = $state("");
let newEventVenue = $state("");
let newEventColor = $state("#00A3E0");
let creating = $state(false);
const statusTabs = $derived([
{ value: "all", label: m.events_tab_all(), icon: "apps" },
{ value: "planning", label: m.events_tab_planning(), icon: "edit_note" },
{ value: "active", label: m.events_tab_active(), icon: "play_circle" },
{ value: "completed", label: m.events_tab_completed(), icon: "check_circle" },
{ value: "archived", label: m.events_tab_archived(), icon: "archive" },
]);
const presetColors = [
"#00A3E0",
"#8B5CF6",
"#EC4899",
"#F59E0B",
"#10B981",
"#EF4444",
"#6366F1",
"#14B8A6",
];
async function handleCreate() {
if (!newEventName.trim()) return;
creating = true;
try {
const { data: created, error } = await (supabase as any)
.from("events")
.insert({
org_id: data.org.id,
name: newEventName.trim(),
slug: newEventName
.trim()
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-")
.slice(0, 60) || "event",
description: newEventDescription.trim() || null,
start_date: newEventStartDate || null,
end_date: newEventEndDate || null,
venue_name: newEventVenue.trim() || null,
color: newEventColor,
created_by: (await supabase.auth.getUser()).data.user?.id,
})
.select()
.single();
if (error) throw error;
toasts.success(m.events_created({ name: created.name }));
showCreateModal = false;
resetForm();
goto(`/${data.org.slug}/events/${created.slug}`);
} catch (e: any) {
toasts.error(e.message || "Failed to create event");
} finally {
creating = false;
}
}
function resetForm() {
newEventName = "";
newEventDescription = "";
newEventStartDate = "";
newEventEndDate = "";
newEventVenue = "";
newEventColor = "#00A3E0";
}
function switchStatus(status: string) {
const url = new URL($page.url);
if (status === "all") {
url.searchParams.delete("status");
} else {
url.searchParams.set("status", status);
}
goto(url.toString(), { replaceState: true, invalidateAll: true });
}
</script>
<svelte:head>
<title>{m.events_title()} | {data.org.name}</title>
</svelte:head>
<div class="flex flex-col h-full">
<!-- Toolbar: Status Tabs + Create Button -->
<div class="flex items-center justify-between px-6 py-3 border-b border-light/5 shrink-0">
<div class="flex items-center gap-1">
{#each statusTabs as tab}
<button
type="button"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-body-sm font-body transition-colors {data.statusFilter ===
tab.value
? 'bg-primary text-background'
: 'text-light/50 hover:text-white hover:bg-dark/50'}"
onclick={() => switchStatus(tab.value)}
>
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>{tab.icon}</span
>
{tab.label}
</button>
{/each}
</div>
{#if isEditor}
<Button size="sm" icon="add" onclick={() => (showCreateModal = true)}>
{m.events_new()}
</Button>
{/if}
</div>
<!-- Events Grid -->
<div class="flex-1 overflow-auto p-6">
{#if data.events.length === 0}
<div class="flex flex-col items-center justify-center h-full text-light/40">
<span
class="material-symbols-rounded mb-4"
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
>celebration</span
>
<p class="text-h3 font-heading mb-2">{m.events_empty_title()}</p>
<p class="text-body text-light/30">{m.events_empty_desc()}</p>
{#if isEditor}
<div class="mt-4">
<Button icon="add" onclick={() => (showCreateModal = true)}>
{m.events_create()}
</Button>
</div>
{/if}
</div>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{#each data.events as event}
<EventCard
name={event.name}
slug={event.slug}
status={event.status}
startDate={event.start_date}
endDate={event.end_date}
color={event.color}
venueName={event.venue_name}
href="/{data.org.slug}/events/{event.slug}"
/>
{/each}
</div>
{/if}
</div>
</div>
<!-- Create Event Modal -->
{#if showCreateModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div
class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
onkeydown={(e) => e.key === "Escape" && (showCreateModal = false)}
onclick={(e) => e.target === e.currentTarget && (showCreateModal = false)}
role="dialog"
aria-modal="true"
aria-label={m.events_create()}
>
<div
class="bg-night rounded-2xl w-full max-w-lg shadow-2xl border border-light/10"
>
<div class="flex items-center justify-between p-5 border-b border-light/5">
<h2 class="text-h3 font-heading text-white">{m.events_create()}</h2>
<button
type="button"
class="text-light/40 hover:text-white transition-colors"
onclick={() => (showCreateModal = false)}
aria-label={m.btn_close()}
>
<span
class="material-symbols-rounded"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>close</span
>
</button>
</div>
<form
class="p-5 flex flex-col gap-4"
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
>
<!-- Name -->
<div class="flex flex-col gap-1.5">
<label
for="event-name"
class="text-body-sm text-light/60 font-body"
>{m.events_form_name()}</label
>
<input
id="event-name"
type="text"
bind:value={newEventName}
placeholder={m.events_form_name_placeholder()}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
required
/>
</div>
<!-- Description -->
<div class="flex flex-col gap-1.5">
<label
for="event-desc"
class="text-body-sm text-light/60 font-body"
>{m.events_form_description()}</label
>
<textarea
id="event-desc"
bind:value={newEventDescription}
placeholder={m.events_form_description_placeholder()}
rows="2"
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body text-white placeholder:text-light/30 focus:outline-none focus:border-primary resize-none"
></textarea>
</div>
<!-- Dates -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<label
for="event-start"
class="text-body-sm text-light/60 font-body"
>{m.events_form_start_date()}</label
>
<input
id="event-start"
type="date"
bind:value={newEventStartDate}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body text-white focus:outline-none focus:border-primary"
/>
</div>
<div class="flex flex-col gap-1.5">
<label
for="event-end"
class="text-body-sm text-light/60 font-body"
>{m.events_form_end_date()}</label
>
<input
id="event-end"
type="date"
bind:value={newEventEndDate}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body text-white focus:outline-none focus:border-primary"
/>
</div>
</div>
<!-- Venue -->
<div class="flex flex-col gap-1.5">
<label
for="event-venue"
class="text-body-sm text-light/60 font-body"
>{m.events_form_venue()}</label
>
<input
id="event-venue"
type="text"
bind:value={newEventVenue}
placeholder={m.events_form_venue_placeholder()}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
</div>
<!-- Color -->
<div class="flex flex-col gap-1.5">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="text-body-sm text-light/60 font-body"
>{m.events_form_color()}</label
>
<div class="flex items-center gap-2">
{#each presetColors as color}
<button
type="button"
class="w-7 h-7 rounded-full border-2 transition-all {newEventColor ===
color
? 'border-white scale-110'
: 'border-transparent hover:border-light/30'}"
style="background-color: {color}"
onclick={() => (newEventColor = color)}
aria-label={m.events_form_select_color({ color })}
></button>
{/each}
</div>
</div>
<!-- Actions -->
<div
class="flex items-center justify-end gap-3 pt-2 border-t border-light/5"
>
<button
type="button"
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => {
showCreateModal = false;
resetForm();
}}
>
{m.btn_cancel()}
</button>
<button
type="submit"
disabled={!newEventName.trim() || creating}
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{creating ? m.events_creating() : m.events_create()}
</button>
</div>
</form>
</div>
</div>
{/if}

View File

@@ -0,0 +1,31 @@
import { error } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { fetchEventBySlug, fetchEventMembers, fetchEventRoles, fetchEventDepartments } from '$lib/api/events';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.event-detail');
export const load: LayoutServerLoad = async ({ params, locals, parent }) => {
const { session, user } = await locals.safeGetSession();
if (!session || !user) error(401, 'Unauthorized');
const parentData = await parent() as { org: { id: string; name: string; slug: string } };
const orgId = parentData.org.id;
try {
const event = await fetchEventBySlug(locals.supabase, orgId, params.eventSlug);
if (!event) error(404, 'Event not found');
const [members, roles, departments] = await Promise.all([
fetchEventMembers(locals.supabase, event.id),
fetchEventRoles(locals.supabase, event.id),
fetchEventDepartments(locals.supabase, event.id),
]);
return { event, eventMembers: members, eventRoles: roles, eventDepartments: departments };
} catch (e: any) {
if (e?.status === 404) throw e;
log.error('Failed to load event', { error: e, data: { orgId, eventSlug: params.eventSlug } });
error(500, 'Failed to load event');
}
};

Some files were not shown because too many files have changed in this diff Show More