Compare commits
19 Commits
e55881b38b
...
feature/ev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
676468d3ec | ||
|
|
1f2484da3d | ||
|
|
edc5f8af85 | ||
|
|
4999836a57 | ||
|
|
9d5e58f858 | ||
|
|
819d5b876a | ||
|
|
2913912cb8 | ||
|
|
fe6ec6e0af | ||
|
|
36496e8cdb | ||
|
|
556955f349 | ||
|
|
4f21c89103 | ||
|
|
8140fddc8b | ||
|
|
13cdb605ca | ||
|
|
45ab939b7f | ||
|
|
23035b6ab4 | ||
|
|
3f267e3b13 | ||
|
|
be99a02e78 | ||
|
|
a8d79cf138 | ||
|
|
d1ce5d0951 |
129
messages/en.json
129
messages/en.json
@@ -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"
|
||||
}
|
||||
129
messages/et.json
129
messages/et.json
@@ -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
336
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
48
src/lib/api/events.test.ts
Normal file
48
src/lib/api/events.test.ts
Normal 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
550
src/lib/api/events.ts
Normal 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
663
src/lib/cache/index.ts
vendored
Normal 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
195
src/lib/cache/mediaCache.ts
vendored
Normal 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()],
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
300
src/lib/components/chat-layout/ChatArea.svelte
Normal file
300
src/lib/components/chat-layout/ChatArea.svelte
Normal 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>
|
||||
340
src/lib/components/chat-layout/Sidebar.svelte
Normal file
340
src/lib/components/chat-layout/Sidebar.svelte
Normal 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>
|
||||
2
src/lib/components/chat-layout/index.ts
Normal file
2
src/lib/components/chat-layout/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Sidebar } from './Sidebar.svelte';
|
||||
export { default as ChatArea } from './ChatArea.svelte';
|
||||
346
src/lib/components/chat-settings/UserSettingsModal.svelte
Normal file
346
src/lib/components/chat-settings/UserSettingsModal.svelte
Normal 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}
|
||||
1
src/lib/components/chat-settings/index.ts
Normal file
1
src/lib/components/chat-settings/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as UserSettingsModal } from './UserSettingsModal.svelte';
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
103
src/lib/components/matrix/CreateRoomModal.svelte
Normal file
103
src/lib/components/matrix/CreateRoomModal.svelte
Normal 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}
|
||||
174
src/lib/components/matrix/CreateSpaceModal.svelte
Normal file
174
src/lib/components/matrix/CreateSpaceModal.svelte
Normal 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}
|
||||
88
src/lib/components/matrix/EmojiAutocomplete.svelte
Normal file
88
src/lib/components/matrix/EmojiAutocomplete.svelte
Normal 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}
|
||||
32
src/lib/components/matrix/MatrixProvider.svelte
Normal file
32
src/lib/components/matrix/MatrixProvider.svelte
Normal 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()}
|
||||
102
src/lib/components/matrix/MemberList.svelte
Normal file
102
src/lib/components/matrix/MemberList.svelte
Normal 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}
|
||||
91
src/lib/components/matrix/MentionAutocomplete.svelte
Normal file
91
src/lib/components/matrix/MentionAutocomplete.svelte
Normal 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}
|
||||
761
src/lib/components/matrix/MessageInput.svelte
Normal file
761
src/lib/components/matrix/MessageInput.svelte
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.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>
|
||||
478
src/lib/components/matrix/MessageList.svelte
Normal file
478
src/lib/components/matrix/MessageList.svelte
Normal 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>
|
||||
261
src/lib/components/matrix/RoomInfoPanel.svelte
Normal file
261
src/lib/components/matrix/RoomInfoPanel.svelte
Normal 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}
|
||||
187
src/lib/components/matrix/RoomSettingsModal.svelte
Normal file
187
src/lib/components/matrix/RoomSettingsModal.svelte
Normal 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>
|
||||
139
src/lib/components/matrix/StartDMModal.svelte
Normal file
139
src/lib/components/matrix/StartDMModal.svelte
Normal 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>
|
||||
113
src/lib/components/matrix/SyncRecoveryBanner.svelte
Normal file
113
src/lib/components/matrix/SyncRecoveryBanner.svelte
Normal 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}
|
||||
27
src/lib/components/matrix/TypingIndicator.svelte
Normal file
27
src/lib/components/matrix/TypingIndicator.svelte
Normal 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}
|
||||
127
src/lib/components/matrix/UserProfileModal.svelte
Normal file
127
src/lib/components/matrix/UserProfileModal.svelte
Normal 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>
|
||||
12
src/lib/components/matrix/index.ts
Normal file
12
src/lib/components/matrix/index.ts
Normal 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';
|
||||
202
src/lib/components/message/MessageContainer.svelte
Normal file
202
src/lib/components/message/MessageContainer.svelte
Normal 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>
|
||||
10
src/lib/components/message/index.ts
Normal file
10
src/lib/components/message/index.ts
Normal 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';
|
||||
199
src/lib/components/message/parts/MessageActions.svelte
Normal file
199
src/lib/components/message/parts/MessageActions.svelte
Normal 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>
|
||||
27
src/lib/components/message/parts/MessageContent.svelte
Normal file
27
src/lib/components/message/parts/MessageContent.svelte
Normal 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}
|
||||
103
src/lib/components/message/parts/MessageMedia.svelte
Normal file
103
src/lib/components/message/parts/MessageMedia.svelte
Normal 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}
|
||||
117
src/lib/components/message/parts/MessageReactions.svelte
Normal file
117
src/lib/components/message/parts/MessageReactions.svelte
Normal 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}
|
||||
52
src/lib/components/message/parts/MessageReadReceipts.svelte
Normal file
52
src/lib/components/message/parts/MessageReadReceipts.svelte
Normal 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}
|
||||
9
src/lib/components/message/parts/index.ts
Normal file
9
src/lib/components/message/parts/index.ts
Normal 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';
|
||||
13
src/lib/components/message/utils/index.ts
Normal file
13
src/lib/components/message/utils/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Message utilities barrel export
|
||||
*/
|
||||
|
||||
export {
|
||||
renderMarkdown,
|
||||
renderEmojisAsTwemoji,
|
||||
renderMentions,
|
||||
isEmojiOnly,
|
||||
formatTime,
|
||||
formatFullTime,
|
||||
formatFileSize,
|
||||
} from './markdown';
|
||||
111
src/lib/components/message/utils/markdown.test.ts
Normal file
111
src/lib/components/message/utils/markdown.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
168
src/lib/components/message/utils/markdown.ts
Normal file
168
src/lib/components/message/utils/markdown.ts
Normal 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`;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
132
src/lib/components/ui/ActivityFeed.svelte
Normal file
132
src/lib/components/ui/ActivityFeed.svelte
Normal 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}
|
||||
@@ -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>
|
||||
|
||||
109
src/lib/components/ui/ContentSkeleton.svelte
Normal file
109
src/lib/components/ui/ContentSkeleton.svelte
Normal 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>
|
||||
168
src/lib/components/ui/EmojiPicker.svelte
Normal file
168
src/lib/components/ui/EmojiPicker.svelte
Normal 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>
|
||||
116
src/lib/components/ui/EventCard.svelte
Normal file
116
src/lib/components/ui/EventCard.svelte
Normal 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}
|
||||
63
src/lib/components/ui/ImagePreviewModal.svelte
Normal file
63
src/lib/components/ui/ImagePreviewModal.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
71
src/lib/components/ui/MemberList.svelte
Normal file
71
src/lib/components/ui/MemberList.svelte
Normal 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>
|
||||
40
src/lib/components/ui/ModuleCard.svelte
Normal file
40
src/lib/components/ui/ModuleCard.svelte
Normal 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>
|
||||
46
src/lib/components/ui/PageHeader.svelte
Normal file
46
src/lib/components/ui/PageHeader.svelte
Normal 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>
|
||||
30
src/lib/components/ui/QuickLinkGrid.svelte
Normal file
30
src/lib/components/ui/QuickLinkGrid.svelte
Normal 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>
|
||||
41
src/lib/components/ui/SectionCard.svelte
Normal file
41
src/lib/components/ui/SectionCard.svelte
Normal 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>
|
||||
58
src/lib/components/ui/StatCard.svelte
Normal file
58
src/lib/components/ui/StatCard.svelte
Normal 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}
|
||||
30
src/lib/components/ui/StatusBadge.svelte
Normal file
30
src/lib/components/ui/StatusBadge.svelte
Normal 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
|
||||
>
|
||||
37
src/lib/components/ui/TabBar.svelte
Normal file
37
src/lib/components/ui/TabBar.svelte
Normal 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>
|
||||
21
src/lib/components/ui/Twemoji.svelte
Normal file
21
src/lib/components/ui/Twemoji.svelte
Normal 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"
|
||||
/>
|
||||
114
src/lib/components/ui/VirtualList.svelte
Normal file
114
src/lib/components/ui/VirtualList.svelte
Normal 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>
|
||||
@@ -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
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
101
src/lib/matrix/context.ts
Normal 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
90
src/lib/matrix/index.ts
Normal 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
79
src/lib/matrix/matrix-sdk-augment.d.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
103
src/lib/matrix/messageUtils.spec.ts
Normal file
103
src/lib/matrix/messageUtils.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
63
src/lib/matrix/messageUtils.ts
Normal file
63
src/lib/matrix/messageUtils.ts
Normal 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`;
|
||||
}
|
||||
75
src/lib/matrix/sdk-types.spec.ts
Normal file
75
src/lib/matrix/sdk-types.spec.ts
Normal 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
249
src/lib/matrix/sdk-types.ts
Normal 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
217
src/lib/matrix/sync.ts
Normal 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
88
src/lib/matrix/types.ts
Normal 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
23
src/lib/services/index.ts
Normal 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';
|
||||
354
src/lib/services/reactions.ts
Normal file
354
src/lib/services/reactions.ts
Normal 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
841
src/lib/stores/matrix.ts
Normal 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
196
src/lib/stores/theme.ts
Normal 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
55
src/lib/stores/ui.ts
Normal 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);
|
||||
}
|
||||
@@ -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
596
src/lib/utils/emojiData.ts
Normal 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
30
src/lib/utils/twemoji.ts
Normal 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`;
|
||||
}
|
||||
176
src/lib/utils/twemojiGlobal.ts
Normal file
176
src/lib/utils/twemojiGlobal.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
36
src/routes/[orgSlug]/account/+layout.svelte
Normal file
36
src/routes/[orgSlug]/account/+layout.svelte
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
31
src/routes/[orgSlug]/calendar/+layout.svelte
Normal file
31
src/routes/[orgSlug]/calendar/+layout.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
36
src/routes/[orgSlug]/chat/+layout.svelte
Normal file
36
src/routes/[orgSlug]/chat/+layout.svelte
Normal 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>
|
||||
794
src/routes/[orgSlug]/chat/+page.svelte
Normal file
794
src/routes/[orgSlug]/chat/+page.svelte
Normal 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}
|
||||
31
src/routes/[orgSlug]/documents/+layout.svelte
Normal file
31
src/routes/[orgSlug]/documents/+layout.svelte
Normal 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>
|
||||
@@ -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
|
||||
|
||||
49
src/routes/[orgSlug]/events/+layout.svelte
Normal file
49
src/routes/[orgSlug]/events/+layout.svelte
Normal 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}
|
||||
29
src/routes/[orgSlug]/events/+page.server.ts
Normal file
29
src/routes/[orgSlug]/events/+page.server.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
365
src/routes/[orgSlug]/events/+page.svelte
Normal file
365
src/routes/[orgSlug]/events/+page.svelte
Normal 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}
|
||||
31
src/routes/[orgSlug]/events/[eventSlug]/+layout.server.ts
Normal file
31
src/routes/[orgSlug]/events/[eventSlug]/+layout.server.ts
Normal 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
Reference in New Issue
Block a user