Compare commits
15 Commits
feature/ma
...
dcee479839
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcee479839 | ||
|
|
f9dc950394 | ||
|
|
202f0fe9a1 | ||
|
|
d304129e5c | ||
|
|
676468d3ec | ||
|
|
1f2484da3d | ||
|
|
edc5f8af85 | ||
|
|
4999836a57 | ||
|
|
9d5e58f858 | ||
|
|
819d5b876a | ||
|
|
2913912cb8 | ||
|
|
fe6ec6e0af | ||
|
|
36496e8cdb | ||
|
|
556955f349 | ||
|
|
4f21c89103 |
@@ -7,3 +7,10 @@ GOOGLE_API_KEY=your_google_api_key
|
||||
# Paste the full JSON key file contents, or base64-encode it
|
||||
# The calendar must be shared with the service account email (with "Make changes to events" permission)
|
||||
GOOGLE_SERVICE_ACCOUNT_KEY=
|
||||
|
||||
# Matrix / Synapse integration
|
||||
# The homeserver URL where your Synapse instance is running
|
||||
MATRIX_HOMESERVER_URL=https://matrix.example.com
|
||||
# Synapse Admin API shared secret or admin access token
|
||||
# Used to auto-provision Matrix accounts for users
|
||||
MATRIX_ADMIN_TOKEN=
|
||||
|
||||
138
messages/en.json
138
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,133 @@
|
||||
"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",
|
||||
"chat_join_title": "Join Chat",
|
||||
"chat_join_description": "Chat is powered by Matrix, an open standard for secure, decentralized communication.",
|
||||
"chat_join_consent": "By joining, a Matrix account will be created for you using your current profile details (name, email, and avatar).",
|
||||
"chat_join_learn_more": "Learn more about Matrix",
|
||||
"chat_join_button": "Join Chat",
|
||||
"chat_joining": "Setting up your account...",
|
||||
"chat_join_success": "Chat account created! Welcome.",
|
||||
"chat_join_error": "Failed to set up chat. Please try again.",
|
||||
"chat_disconnect": "Disconnect from Chat"
|
||||
}
|
||||
138
messages/et.json
138
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,133 @@
|
||||
"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",
|
||||
"chat_join_title": "Liitu vestlusega",
|
||||
"chat_join_description": "Vestlus põhineb Matrixil — avatud standardil turvalise ja detsentraliseeritud suhtluse jaoks.",
|
||||
"chat_join_consent": "Liitudes luuakse sulle Matrixi konto sinu praeguste profiiliandmete (nimi, e-post ja avatar) põhjal.",
|
||||
"chat_join_learn_more": "Loe Matrixi kohta lähemalt",
|
||||
"chat_join_button": "Liitu vestlusega",
|
||||
"chat_joining": "Konto seadistamine...",
|
||||
"chat_join_success": "Vestluskonto loodud! Tere tulemast.",
|
||||
"chat_join_error": "Vestluse seadistamine ebaõnnestus. Proovi uuesti.",
|
||||
"chat_disconnect": "Katkesta vestlusühendus"
|
||||
}
|
||||
165
package-lock.json
generated
165
package-lock.json
generated
@@ -35,6 +35,7 @@
|
||||
"@types/twemoji": "^13.1.1",
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"playwright": "^1.58.0",
|
||||
"supabase": "^2.76.1",
|
||||
"svelte": "^5.48.2",
|
||||
"svelte-check": "^4.3.5",
|
||||
"tailwindcss": "^4.1.18",
|
||||
@@ -558,6 +559,19 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"minipass": "^7.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -2513,6 +2527,23 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/bin-links": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bin-links/-/bin-links-6.0.0.tgz",
|
||||
"integrity": "sha512-X4CiKlcV2GjnCMwnKAfbVWpHa++65th9TuzAEYtZoATiOE2DQKhSp4CJlyLoTqdhBKlXjpXjCTYPNNFS33Fi6w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cmd-shim": "^8.0.0",
|
||||
"npm-normalize-package-bin": "^5.0.0",
|
||||
"proc-log": "^6.0.0",
|
||||
"read-cmd-shim": "^6.0.0",
|
||||
"write-file-atomic": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
@@ -2563,6 +2594,16 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -2572,6 +2613,16 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cmd-shim": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz",
|
||||
"integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -3194,6 +3245,16 @@
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/imurmurhash": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/is-core-module": {
|
||||
"version": "2.16.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
|
||||
@@ -3791,6 +3852,19 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
|
||||
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
@@ -3874,6 +3948,16 @@
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/npm-normalize-package-bin": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz",
|
||||
"integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
@@ -4082,6 +4166,16 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/proc-log": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
|
||||
"integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz",
|
||||
@@ -4289,6 +4383,16 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cmd-shim": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz",
|
||||
"integrity": "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
@@ -4627,6 +4731,26 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supabase": {
|
||||
"version": "2.76.1",
|
||||
"resolved": "https://registry.npmjs.org/supabase/-/supabase-2.76.1.tgz",
|
||||
"integrity": "sha512-wWN7trvmcFfI/T4Jr1t4eKSD3JUCMsisssDAoywwMP7HlF4lVrAxQyROah3uBRV05RuEFhO74mFlYr4+koGb0Q==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bin-links": "^6.0.0",
|
||||
"https-proxy-agent": "^7.0.2",
|
||||
"node-fetch": "^3.3.2",
|
||||
"tar": "7.5.7"
|
||||
},
|
||||
"bin": {
|
||||
"supabase": "bin/supabase"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-preserve-symlinks-flag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
@@ -4722,6 +4846,23 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.7",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
|
||||
"integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.1.0",
|
||||
"yallist": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -5237,6 +5378,20 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/write-file-atomic": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.0.tgz",
|
||||
"integrity": "sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"imurmurhash": "^0.1.4",
|
||||
"signal-exit": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
@@ -5258,6 +5413,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/zimmerframe": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"test:unit": "vitest",
|
||||
"test": "npm run test:unit -- --run"
|
||||
"test": "npm run test:unit -- --run",
|
||||
"db:push": "npx supabase db push",
|
||||
"db:types": "npx supabase gen types --lang=typescript --project-id zlworzrghsrokdkuckez --schema public > src/lib/supabase/types.ts",
|
||||
"db:migrate": "npm run db:push && npm run db:types"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inlang/paraglide-js": "^2.10.0",
|
||||
@@ -26,6 +29,7 @@
|
||||
"@types/twemoji": "^13.1.1",
|
||||
"@vitest/browser-playwright": "^4.0.18",
|
||||
"playwright": "^1.58.0",
|
||||
"supabase": "^2.76.1",
|
||||
"svelte": "^5.48.2",
|
||||
"svelte-check": "^4.3.5",
|
||||
"tailwindcss": "^4.1.18",
|
||||
|
||||
266
src/lib/api/event-tasks.ts
Normal file
266
src/lib/api/event-tasks.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import type { Database, EventTaskColumn, EventTask } from '$lib/supabase/types';
|
||||
import { createLogger } from '$lib/utils/logger';
|
||||
|
||||
const log = createLogger('api.event-tasks');
|
||||
|
||||
export interface TaskColumnWithTasks extends EventTaskColumn {
|
||||
cards: EventTask[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Columns
|
||||
// ============================================================
|
||||
|
||||
export async function fetchTaskColumns(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventId: string
|
||||
): Promise<TaskColumnWithTasks[]> {
|
||||
const { data: columns, error: colErr } = await supabase
|
||||
.from('event_task_columns')
|
||||
.select('*')
|
||||
.eq('event_id', eventId)
|
||||
.order('position');
|
||||
|
||||
if (colErr) {
|
||||
log.error('Failed to fetch task columns', { error: colErr, data: { eventId } });
|
||||
throw colErr;
|
||||
}
|
||||
|
||||
const { data: tasks, error: taskErr } = await supabase
|
||||
.from('event_tasks')
|
||||
.select('*')
|
||||
.eq('event_id', eventId)
|
||||
.order('position');
|
||||
|
||||
if (taskErr) {
|
||||
log.error('Failed to fetch tasks', { error: taskErr, data: { eventId } });
|
||||
throw taskErr;
|
||||
}
|
||||
|
||||
const tasksByColumn = new Map<string, EventTask[]>();
|
||||
for (const task of tasks ?? []) {
|
||||
const arr = tasksByColumn.get(task.column_id) ?? [];
|
||||
arr.push(task);
|
||||
tasksByColumn.set(task.column_id, arr);
|
||||
}
|
||||
|
||||
return (columns ?? []).map((col) => ({
|
||||
...col,
|
||||
cards: tasksByColumn.get(col.id) ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createTaskColumn(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventId: string,
|
||||
name: string,
|
||||
position?: number
|
||||
): Promise<EventTaskColumn> {
|
||||
if (position === undefined) {
|
||||
const { count } = await supabase
|
||||
.from('event_task_columns')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('event_id', eventId);
|
||||
position = count ?? 0;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('event_task_columns')
|
||||
.insert({ event_id: eventId, name, position })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
log.error('Failed to create task column', { error, data: { eventId, name } });
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function renameTaskColumn(
|
||||
supabase: SupabaseClient<Database>,
|
||||
columnId: string,
|
||||
name: string
|
||||
): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('event_task_columns')
|
||||
.update({ name })
|
||||
.eq('id', columnId);
|
||||
|
||||
if (error) {
|
||||
log.error('Failed to rename task column', { error, data: { columnId, name } });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTaskColumn(
|
||||
supabase: SupabaseClient<Database>,
|
||||
columnId: string
|
||||
): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('event_task_columns')
|
||||
.delete()
|
||||
.eq('id', columnId);
|
||||
|
||||
if (error) {
|
||||
log.error('Failed to delete task column', { error, data: { columnId } });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Tasks
|
||||
// ============================================================
|
||||
|
||||
export async function createTask(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventId: string,
|
||||
columnId: string,
|
||||
title: string,
|
||||
createdBy?: string
|
||||
): Promise<EventTask> {
|
||||
const { count } = await supabase
|
||||
.from('event_tasks')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('column_id', columnId);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('event_tasks')
|
||||
.insert({
|
||||
event_id: eventId,
|
||||
column_id: columnId,
|
||||
title,
|
||||
position: count ?? 0,
|
||||
created_by: createdBy ?? null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error || !data) {
|
||||
log.error('Failed to create task', { error, data: { eventId, columnId, title } });
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateTask(
|
||||
supabase: SupabaseClient<Database>,
|
||||
taskId: string,
|
||||
updates: Partial<Pick<EventTask, 'title' | 'description' | 'priority' | 'due_date' | 'color' | 'assignee_id'>>
|
||||
): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('event_tasks')
|
||||
.update({ ...updates, updated_at: new Date().toISOString() })
|
||||
.eq('id', taskId);
|
||||
|
||||
if (error) {
|
||||
log.error('Failed to update task', { error, data: { taskId, updates } });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTask(
|
||||
supabase: SupabaseClient<Database>,
|
||||
taskId: string
|
||||
): Promise<void> {
|
||||
const { error } = await supabase
|
||||
.from('event_tasks')
|
||||
.delete()
|
||||
.eq('id', taskId);
|
||||
|
||||
if (error) {
|
||||
log.error('Failed to delete task', { error, data: { taskId } });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function moveTask(
|
||||
supabase: SupabaseClient<Database>,
|
||||
taskId: string,
|
||||
newColumnId: string,
|
||||
newPosition: number
|
||||
): Promise<void> {
|
||||
// Fetch all tasks in the target column
|
||||
const { data: colTasks, error: fetchErr } = await supabase
|
||||
.from('event_tasks')
|
||||
.select('id, position')
|
||||
.eq('column_id', newColumnId)
|
||||
.order('position');
|
||||
|
||||
if (fetchErr) {
|
||||
log.error('Failed to fetch column tasks for reorder', { error: fetchErr });
|
||||
throw fetchErr;
|
||||
}
|
||||
|
||||
// Build the new order
|
||||
const existing = (colTasks ?? []).filter((t) => t.id !== taskId);
|
||||
existing.splice(newPosition, 0, { id: taskId, position: newPosition });
|
||||
|
||||
// Update positions + column for changed tasks
|
||||
const updates = existing
|
||||
.map((t, i) => ({ id: t.id, position: i, column_id: newColumnId }))
|
||||
.filter((t, i) => {
|
||||
const orig = colTasks?.find((c) => c.id === t.id);
|
||||
return !orig || orig.position !== i || t.id === taskId;
|
||||
});
|
||||
|
||||
if (updates.length > 0) {
|
||||
await Promise.all(
|
||||
updates.map((u) =>
|
||||
supabase
|
||||
.from('event_tasks')
|
||||
.update({ column_id: u.column_id, position: u.position, updated_at: new Date().toISOString() })
|
||||
.eq('id', u.id)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Realtime
|
||||
// ============================================================
|
||||
|
||||
export interface RealtimeChangePayload<T = Record<string, unknown>> {
|
||||
event: 'INSERT' | 'UPDATE' | 'DELETE';
|
||||
new: T;
|
||||
old: Partial<T>;
|
||||
}
|
||||
|
||||
export function subscribeToEventTasks(
|
||||
supabase: SupabaseClient<Database>,
|
||||
eventId: string,
|
||||
columnIds: string[],
|
||||
onColumnChange: (payload: RealtimeChangePayload<EventTaskColumn>) => void,
|
||||
onTaskChange: (payload: RealtimeChangePayload<EventTask>) => void
|
||||
) {
|
||||
const channel = supabase.channel(`event-tasks:${eventId}`);
|
||||
const columnIdSet = new Set(columnIds);
|
||||
|
||||
channel
|
||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'event_task_columns', filter: `event_id=eq.${eventId}` },
|
||||
(payload) => onColumnChange({
|
||||
event: payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE',
|
||||
new: payload.new as EventTaskColumn,
|
||||
old: payload.old as Partial<EventTaskColumn>,
|
||||
})
|
||||
)
|
||||
.on('postgres_changes', { event: '*', schema: 'public', table: 'event_tasks', filter: `event_id=eq.${eventId}` },
|
||||
(payload) => {
|
||||
const task = (payload.new ?? payload.old) as Partial<EventTask>;
|
||||
const colId = task.column_id ?? (payload.old as Partial<EventTask>)?.column_id;
|
||||
if (colId && !columnIdSet.has(colId)) return;
|
||||
|
||||
onTaskChange({
|
||||
event: payload.eventType as 'INSERT' | 'UPDATE' | 'DELETE',
|
||||
new: payload.new as EventTask,
|
||||
old: payload.old as Partial<EventTask>,
|
||||
});
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return channel;
|
||||
}
|
||||
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: string;
|
||||
role_id: string | null;
|
||||
notes: string | null;
|
||||
assigned_at: string | null;
|
||||
}
|
||||
|
||||
export interface EventRole {
|
||||
id: string;
|
||||
event_id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
sort_order: number;
|
||||
is_default: boolean;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface EventDepartment {
|
||||
id: string;
|
||||
event_id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
description: string | null;
|
||||
sort_order: number;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface EventMemberDepartment {
|
||||
id: string;
|
||||
event_member_id: string;
|
||||
department_id: string;
|
||||
assigned_at: string | null;
|
||||
}
|
||||
|
||||
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
|
||||
.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 => [p.id, p]));
|
||||
|
||||
// Fetch roles for this event
|
||||
const { data: roles } = await supabase
|
||||
.from('event_roles')
|
||||
.select('*')
|
||||
.eq('event_id', eventId);
|
||||
const roleMap = Object.fromEntries((roles ?? []).map(r => [r.id, r]));
|
||||
|
||||
// Fetch member-department assignments
|
||||
const memberIds = members.map((m: any) => m.id);
|
||||
const { data: memberDepts } = await supabase
|
||||
.from('event_member_departments')
|
||||
.select('*')
|
||||
.in('event_member_id', memberIds);
|
||||
|
||||
// Fetch departments for this event
|
||||
const { data: departments } = await supabase
|
||||
.from('event_departments')
|
||||
.select('*')
|
||||
.eq('event_id', eventId);
|
||||
const deptMap = Object.fromEntries((departments ?? []).map(d => [d.id, d]));
|
||||
|
||||
// Build member-to-departments map
|
||||
const memberDeptMap: Record<string, EventDepartment[]> = {};
|
||||
for (const md of (memberDepts ?? [])) {
|
||||
const dept = deptMap[md.department_id];
|
||||
if (dept) {
|
||||
if (!memberDeptMap[md.event_member_id]) memberDeptMap[md.event_member_id] = [];
|
||||
memberDeptMap[md.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -83,6 +83,13 @@
|
||||
dragOverCardIndex = null;
|
||||
}
|
||||
|
||||
function dropIndicatorClass(card: KanbanCard, cardIndex: number, columnId: string, totalCards: number): string {
|
||||
if (!draggedCard || draggedCard.id === card.id || dragOverCardIndex?.columnId !== columnId) return '';
|
||||
if (dragOverCardIndex.index === cardIndex) return 'shadow-[0_-3px_0_0_var(--color-primary)]';
|
||||
if (dragOverCardIndex.index === cardIndex + 1 && cardIndex === totalCards - 1) return 'shadow-[0_3px_0_0_var(--color-primary)]';
|
||||
return '';
|
||||
}
|
||||
|
||||
function handleCardDragOver(e: DragEvent, columnId: string, index: number) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -92,6 +99,12 @@
|
||||
const midY = rect.top + rect.height / 2;
|
||||
const dropIndex = e.clientY < midY ? index : index + 1;
|
||||
|
||||
// Skip update if the index hasn't changed (prevents flicker)
|
||||
if (
|
||||
dragOverCardIndex?.columnId === columnId &&
|
||||
dragOverCardIndex?.index === dropIndex
|
||||
) return;
|
||||
|
||||
dragOverColumn = columnId;
|
||||
dragOverCardIndex = { columnId, index: dropIndex };
|
||||
}
|
||||
@@ -144,6 +157,7 @@
|
||||
column.id
|
||||
? 'ring-2 ring-primary'
|
||||
: ''}"
|
||||
data-column-id={column.id}
|
||||
ondragover={(e) => handleColumnDragOver(e, column.id)}
|
||||
ondragleave={handleColumnDragLeave}
|
||||
ondrop={(e) => handleDrop(e, column.id)}
|
||||
@@ -238,14 +252,8 @@
|
||||
<!-- Cards -->
|
||||
<div class="flex-1 overflow-y-auto flex flex-col gap-0">
|
||||
{#each column.cards as card, cardIndex}
|
||||
<!-- Drop indicator before card -->
|
||||
{#if draggedCard && dragOverCardIndex?.columnId === column.id && dragOverCardIndex?.index === cardIndex && draggedCard.id !== card.id}
|
||||
<div
|
||||
class="h-1 bg-primary rounded-full mx-2 my-1 transition-all"
|
||||
></div>
|
||||
{/if}
|
||||
<div
|
||||
class="mb-2"
|
||||
class="mb-2 relative {dropIndicatorClass(card, cardIndex, column.id, column.cards.length)}"
|
||||
ondragover={(e) =>
|
||||
handleCardDragOver(e, column.id, cardIndex)}
|
||||
>
|
||||
@@ -261,12 +269,6 @@
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Drop indicator at end of column -->
|
||||
{#if draggedCard && dragOverCardIndex?.columnId === column.id && dragOverCardIndex?.index === column.cards.length}
|
||||
<div
|
||||
class="h-1 bg-primary rounded-full mx-2 my-1 transition-all"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Add Card Button -->
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
ondelete?: (cardId: string) => void;
|
||||
draggable?: boolean;
|
||||
ondragstart?: (e: DragEvent) => void;
|
||||
ondragend?: (e: DragEvent) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -30,6 +31,7 @@
|
||||
ondelete,
|
||||
draggable = true,
|
||||
ondragstart,
|
||||
ondragend,
|
||||
}: Props = $props();
|
||||
|
||||
function handleDelete(e: MouseEvent) {
|
||||
@@ -57,35 +59,37 @@
|
||||
|
||||
<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}
|
||||
data-card-id={card.id}
|
||||
{draggable}
|
||||
{ondragstart}
|
||||
{ondragend}
|
||||
{onclick}
|
||||
>
|
||||
<!-- Delete button (top-right, visible on hover) -->
|
||||
{#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 +99,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>
|
||||
|
||||
@@ -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}
|
||||
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>
|
||||
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}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,54 +1,24 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
size?: "sm" | "md" | "lg";
|
||||
showText?: boolean;
|
||||
}
|
||||
|
||||
let { size = "md", showText = false }: Props = $props();
|
||||
let { size = "md"}: Props = $props();
|
||||
|
||||
const iconSizes = {
|
||||
sm: "w-8 h-8",
|
||||
md: "w-10 h-10",
|
||||
lg: "w-12 h-12",
|
||||
};
|
||||
|
||||
const textSizes = {
|
||||
sm: "text-[14px]",
|
||||
md: "text-[18px]",
|
||||
lg: "text-[22px]",
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="shrink-0 {iconSizes[size]} transition-all duration-300">
|
||||
<svg
|
||||
viewBox="0 0 38 21"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-full h-auto"
|
||||
>
|
||||
<path
|
||||
d="M0 0.5C0 0.224 0.224 0 0.5 0H37.5C37.776 0 38 0.224 38 0.5V12.203C38 12.479 37.776 12.703 37.5 12.703H0.5C0.224 12.703 0 12.479 0 12.203V0.5Z"
|
||||
fill="#00A3E0"
|
||||
fill-opacity="0.2"
|
||||
/>
|
||||
<circle cx="11.5" cy="7.5" r="5" fill="#00A3E0" />
|
||||
<circle cx="23.5" cy="7.5" r="5" fill="#00A3E0" />
|
||||
<path
|
||||
d="M12.25 15.04C12.25 15.04 15 20.25 18.75 20.25C22.5 20.25 25.25 15.04 25.25 15.04"
|
||||
stroke="#00A3E0"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="38" height="21" viewBox="0 0 38 21" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.9111 0V2.61267H29.5355V5.4138L31.9111 5.4153V12.7031H35.1244V5.4153H37.5V2.61301H35.1244V0.000337601L31.9111 0ZM5.58767 2.37769C5.23245 2.38519 4.89438 2.43094 4.57528 2.51844C4.06906 2.65729 3.61441 2.86428 3.21203 3.14195V2.61267H0V12.7027H3.21203V7.23079C3.21203 6.53662 3.45949 6.03894 3.95272 5.73601C4.33617 5.49071 4.88257 5.38553 5.58767 5.41496V2.37769Z" fill="#E5E6F0"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.7211 2.30908C10.9683 2.30908 10.2674 2.44188 9.61838 2.70692C8.96933 2.95935 8.40458 3.31827 7.92436 3.78526C7.44408 4.25226 7.06757 4.80136 6.79499 5.43244C6.53539 6.06352 6.40515 6.75138 6.40515 7.49605C6.40515 8.24072 6.53539 8.92862 6.79499 9.55967C7.06757 10.1907 7.44408 10.7459 7.92436 11.2254C8.40458 11.6924 8.96933 12.0585 9.61838 12.3236C10.2674 12.576 10.9683 12.7028 11.7211 12.7028C12.4869 12.7028 13.1879 12.576 13.8239 12.3236C14.4729 12.0585 15.0377 11.6924 15.5179 11.2254C15.9982 10.7459 16.3676 10.1907 16.6271 9.55967C16.8998 8.92862 17.0359 8.24072 17.0359 7.49605C17.0359 6.75138 16.8998 6.06352 16.6271 5.43244C16.3676 4.80136 15.9982 4.25226 15.5179 3.78526C15.0377 3.31827 14.4729 2.95935 13.8239 2.70692C13.1879 2.44188 12.4869 2.30908 11.7211 2.30908ZM11.7211 5.12999C12.3572 5.12999 12.8627 5.35012 13.2392 5.79189C13.6286 6.22101 13.8239 6.78925 13.8239 7.49606C13.8239 8.19024 13.6285 8.76445 13.2392 9.21887C12.8627 9.66062 12.3572 9.88187 11.7211 9.88187C11.0851 9.88187 10.5725 9.66062 10.1831 9.21887C9.80663 8.76445 9.61838 8.19024 9.61838 7.49606C9.61838 6.78925 9.80663 6.22101 10.1831 5.79189C10.5725 5.35013 11.0851 5.12999 11.7211 5.12999Z" fill="#E5E6F0"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M23.4405 2.30908C22.6878 2.30908 21.9868 2.44188 21.3378 2.70692C20.6888 2.95935 20.124 3.31827 19.6438 3.78526C19.1635 4.25226 18.787 4.80136 18.5144 5.43244C18.2548 6.06352 18.1246 6.75138 18.1246 7.49605C18.1246 8.24072 18.2548 8.92862 18.5144 9.55967C18.787 10.1907 19.1635 10.7459 19.6438 11.2254C20.124 11.6924 20.6888 12.0585 21.3378 12.3236C21.9868 12.576 22.6878 12.7028 23.4405 12.7028C24.2064 12.7028 24.9073 12.576 25.5433 12.3236C26.1924 12.0585 26.7571 11.6924 27.2373 11.2254C27.7176 10.7459 28.087 10.1907 28.3466 9.55967C28.6192 8.92862 28.7553 8.24072 28.7553 7.49605C28.7553 6.75138 28.6192 6.06352 28.3466 5.43244C28.087 4.80136 27.7176 4.25226 27.2373 3.78526C26.7571 3.31827 26.1924 2.95935 25.5433 2.70692C24.9073 2.44188 24.2064 2.30908 23.4405 2.30908ZM23.4405 5.12999C24.0766 5.12999 24.5822 5.35012 24.9586 5.79189C25.348 6.22101 25.5433 6.78925 25.5433 7.49606C25.5433 8.19024 25.3479 8.76445 24.9586 9.21887C24.5822 9.66062 24.0766 9.88187 23.4405 9.88187C22.8045 9.88187 22.2919 9.66062 21.9025 9.21887C21.5261 8.76445 21.3378 8.19024 21.3378 7.49606C21.3378 6.78925 21.5261 6.22101 21.9025 5.79189C22.2919 5.35013 22.8045 5.12999 23.4405 5.12999Z" fill="#E5E6F0"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.2456 15.0433C12.2456 15.788 12.3758 16.4758 12.6355 17.107C12.908 17.738 13.2845 18.2931 13.7648 18.7727C14.2451 19.2397 14.8098 19.6058 15.4589 19.8709C16.1078 20.1232 16.8088 20.2501 17.5616 20.2501C18.3274 20.2501 19.0283 20.1233 19.6643 19.8709C20.3134 19.6058 20.8781 19.2397 21.3584 18.7727C21.8387 18.2931 22.2092 17.738 22.4688 17.107C22.7414 16.4758 22.8776 15.788 22.8776 15.0433H19.6643C19.6643 15.7375 19.4702 16.3117 19.0808 16.7661C18.7043 17.2078 18.1976 17.4292 17.5616 17.4292C16.9256 17.4292 16.4117 17.2078 16.0223 16.7661C15.6459 16.3117 15.4589 15.7375 15.4589 15.0433H12.2456Z" fill="#E5E6F0"/>
|
||||
</svg>
|
||||
</div>
|
||||
{#if showText}
|
||||
<span
|
||||
class="font-heading {textSizes[
|
||||
size
|
||||
]} text-primary leading-none whitespace-nowrap transition-all duration-300"
|
||||
>
|
||||
Root
|
||||
</span>
|
||||
{/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>
|
||||
@@ -26,6 +26,17 @@ 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';
|
||||
|
||||
@@ -360,6 +360,323 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
event_departments: {
|
||||
Row: {
|
||||
color: string
|
||||
created_at: string | null
|
||||
description: string | null
|
||||
event_id: string
|
||||
id: string
|
||||
name: string
|
||||
sort_order: number
|
||||
}
|
||||
Insert: {
|
||||
color?: string
|
||||
created_at?: string | null
|
||||
description?: string | null
|
||||
event_id: string
|
||||
id?: string
|
||||
name: string
|
||||
sort_order?: number
|
||||
}
|
||||
Update: {
|
||||
color?: string
|
||||
created_at?: string | null
|
||||
description?: string | null
|
||||
event_id?: string
|
||||
id?: string
|
||||
name?: string
|
||||
sort_order?: number
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "event_departments_event_id_fkey"
|
||||
columns: ["event_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "events"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
event_member_departments: {
|
||||
Row: {
|
||||
assigned_at: string | null
|
||||
department_id: string
|
||||
event_member_id: string
|
||||
id: string
|
||||
}
|
||||
Insert: {
|
||||
assigned_at?: string | null
|
||||
department_id: string
|
||||
event_member_id: string
|
||||
id?: string
|
||||
}
|
||||
Update: {
|
||||
assigned_at?: string | null
|
||||
department_id?: string
|
||||
event_member_id?: string
|
||||
id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "event_member_departments_department_id_fkey"
|
||||
columns: ["department_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "event_departments"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "event_member_departments_event_member_id_fkey"
|
||||
columns: ["event_member_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "event_members"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
event_members: {
|
||||
Row: {
|
||||
assigned_at: string | null
|
||||
event_id: string
|
||||
id: string
|
||||
notes: string | null
|
||||
role: string
|
||||
role_id: string | null
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
assigned_at?: string | null
|
||||
event_id: string
|
||||
id?: string
|
||||
notes?: string | null
|
||||
role?: string
|
||||
role_id?: string | null
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
assigned_at?: string | null
|
||||
event_id?: string
|
||||
id?: string
|
||||
notes?: string | null
|
||||
role?: string
|
||||
role_id?: string | null
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "event_members_event_id_fkey"
|
||||
columns: ["event_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "events"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "event_members_role_id_fkey"
|
||||
columns: ["role_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "event_roles"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
event_roles: {
|
||||
Row: {
|
||||
color: string
|
||||
created_at: string | null
|
||||
event_id: string
|
||||
id: string
|
||||
is_default: boolean
|
||||
name: string
|
||||
sort_order: number
|
||||
}
|
||||
Insert: {
|
||||
color?: string
|
||||
created_at?: string | null
|
||||
event_id: string
|
||||
id?: string
|
||||
is_default?: boolean
|
||||
name: string
|
||||
sort_order?: number
|
||||
}
|
||||
Update: {
|
||||
color?: string
|
||||
created_at?: string | null
|
||||
event_id?: string
|
||||
id?: string
|
||||
is_default?: boolean
|
||||
name?: string
|
||||
sort_order?: number
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "event_roles_event_id_fkey"
|
||||
columns: ["event_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "events"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
event_task_columns: {
|
||||
Row: {
|
||||
color: string | null
|
||||
created_at: string | null
|
||||
event_id: string
|
||||
id: string
|
||||
name: string
|
||||
position: number
|
||||
}
|
||||
Insert: {
|
||||
color?: string | null
|
||||
created_at?: string | null
|
||||
event_id: string
|
||||
id?: string
|
||||
name: string
|
||||
position?: number
|
||||
}
|
||||
Update: {
|
||||
color?: string | null
|
||||
created_at?: string | null
|
||||
event_id?: string
|
||||
id?: string
|
||||
name?: string
|
||||
position?: number
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "event_task_columns_event_id_fkey"
|
||||
columns: ["event_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "events"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
event_tasks: {
|
||||
Row: {
|
||||
assignee_id: string | null
|
||||
color: string | null
|
||||
column_id: string
|
||||
created_at: string | null
|
||||
created_by: string | null
|
||||
description: string | null
|
||||
due_date: string | null
|
||||
event_id: string
|
||||
id: string
|
||||
position: number
|
||||
priority: string | null
|
||||
title: string
|
||||
updated_at: string | null
|
||||
}
|
||||
Insert: {
|
||||
assignee_id?: string | null
|
||||
color?: string | null
|
||||
column_id: string
|
||||
created_at?: string | null
|
||||
created_by?: string | null
|
||||
description?: string | null
|
||||
due_date?: string | null
|
||||
event_id: string
|
||||
id?: string
|
||||
position?: number
|
||||
priority?: string | null
|
||||
title: string
|
||||
updated_at?: string | null
|
||||
}
|
||||
Update: {
|
||||
assignee_id?: string | null
|
||||
color?: string | null
|
||||
column_id?: string
|
||||
created_at?: string | null
|
||||
created_by?: string | null
|
||||
description?: string | null
|
||||
due_date?: string | null
|
||||
event_id?: string
|
||||
id?: string
|
||||
position?: number
|
||||
priority?: string | null
|
||||
title?: string
|
||||
updated_at?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "event_tasks_column_id_fkey"
|
||||
columns: ["column_id"]
|
||||
isOneToOne: false
|
||||
referencedRelation: "event_task_columns"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "event_tasks_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
|
||||
@@ -888,25 +1205,37 @@ export type Database = {
|
||||
Row: {
|
||||
avatar_url: string | null
|
||||
created_at: string | null
|
||||
discord_handle: string | null
|
||||
email: string
|
||||
full_name: string | null
|
||||
hoodie_size: string | null
|
||||
id: string
|
||||
phone: string | null
|
||||
shirt_size: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
Insert: {
|
||||
avatar_url?: string | null
|
||||
created_at?: string | null
|
||||
discord_handle?: string | null
|
||||
email: string
|
||||
full_name?: string | null
|
||||
hoodie_size?: string | null
|
||||
id: string
|
||||
phone?: string | null
|
||||
shirt_size?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
Update: {
|
||||
avatar_url?: string | null
|
||||
created_at?: string | null
|
||||
discord_handle?: string | null
|
||||
email?: string
|
||||
full_name?: string | null
|
||||
hoodie_size?: string | null
|
||||
id?: string
|
||||
phone?: string | null
|
||||
shirt_size?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
Relationships: []
|
||||
@@ -1202,27 +1531,15 @@ export const Constants = {
|
||||
},
|
||||
} as const
|
||||
|
||||
// ── Convenience type aliases ─────────────────────────────────────────
|
||||
export type MemberRole = 'owner' | 'admin' | 'editor' | 'viewer';
|
||||
type PublicTables = Database['public']['Tables']
|
||||
|
||||
export type Organization = PublicTables['organizations']['Row']
|
||||
export type OrgMember = PublicTables['org_members']['Row']
|
||||
export type OrgRole = PublicTables['org_roles']['Row']
|
||||
export type OrgInvite = PublicTables['org_invites']['Row']
|
||||
export type Profile = PublicTables['profiles']['Row']
|
||||
export type Document = PublicTables['documents']['Row']
|
||||
export type DocumentLock = PublicTables['document_locks']['Row']
|
||||
export type CalendarEvent = PublicTables['calendar_events']['Row']
|
||||
export type KanbanBoard = PublicTables['kanban_boards']['Row']
|
||||
export type KanbanColumn = PublicTables['kanban_columns']['Row']
|
||||
export type KanbanCard = PublicTables['kanban_cards']['Row']
|
||||
export type KanbanComment = PublicTables['kanban_comments']['Row']
|
||||
export type KanbanLabel = PublicTables['kanban_labels']['Row']
|
||||
export type KanbanChecklistItem = PublicTables['kanban_checklist_items']['Row']
|
||||
export type Tag = PublicTables['tags']['Row']
|
||||
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']
|
||||
// Convenience type aliases
|
||||
export type Profile = Tables<'profiles'>;
|
||||
export type Organization = Tables<'organizations'>;
|
||||
export type Document = Tables<'documents'>;
|
||||
export type KanbanBoard = Tables<'kanban_boards'>;
|
||||
export type KanbanColumn = Tables<'kanban_columns'>;
|
||||
export type KanbanCard = Tables<'kanban_cards'>;
|
||||
export type CalendarEvent = Tables<'calendar_events'>;
|
||||
export type OrgRole = Tables<'org_roles'>;
|
||||
export type EventTaskColumn = Tables<'event_task_columns'>;
|
||||
export type EventTask = Tables<'event_tasks'>;
|
||||
export type MemberRole = string;
|
||||
|
||||
@@ -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')
|
||||
@@ -51,7 +51,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
||||
.limit(10),
|
||||
locals.supabase
|
||||
.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,12 +108,12 @@ 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
|
||||
.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) {
|
||||
@@ -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 }
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
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";
|
||||
@@ -123,6 +123,11 @@
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
href: `/${data.org.slug}/events`,
|
||||
label: m.nav_events(),
|
||||
icon: "celebration",
|
||||
},
|
||||
{
|
||||
href: `/${data.org.slug}/chat`,
|
||||
label: "Chat",
|
||||
@@ -142,8 +147,15 @@
|
||||
]);
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
const navTo = $navigating?.to?.url.pathname;
|
||||
if (navTo) return navTo.startsWith(href);
|
||||
return $page.url.pathname.startsWith(href);
|
||||
}
|
||||
|
||||
function isNavigatingTo(href: string): boolean {
|
||||
const navTo = $navigating?.to?.url.pathname;
|
||||
return !!navTo && navTo.startsWith(href) && !$page.url.pathname.startsWith(href);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Figma-matched layout: bg-background with gap-4 padding -->
|
||||
@@ -225,7 +237,11 @@
|
||||
? 'opacity-0 max-w-0 overflow-hidden'
|
||||
: 'opacity-100 max-w-[200px]'}">{item.label}</span
|
||||
>
|
||||
{#if item.badge}
|
||||
{#if isNavigatingTo(item.href)}
|
||||
<span class="ml-auto shrink-0 {sidebarCollapsed ? 'hidden' : ''}">
|
||||
<span class="block w-4 h-4 border-2 border-background/30 border-t-background rounded-full animate-spin"></span>
|
||||
</span>
|
||||
{:else 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>
|
||||
@@ -332,29 +348,13 @@
|
||||
class="flex items-center justify-center"
|
||||
>
|
||||
<Logo
|
||||
size={sidebarCollapsed ? "sm" : "md"}
|
||||
showText={!sidebarCollapsed}
|
||||
/>
|
||||
size={sidebarCollapsed ? "sm" : "md"} />
|
||||
</a>
|
||||
</div>
|
||||
</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;
|
||||
@@ -163,7 +177,13 @@
|
||||
isSaving = true;
|
||||
const { error } = await supabase
|
||||
.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>
|
||||
@@ -2,7 +2,8 @@
|
||||
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 { Avatar, Button, Modal } from "$lib/components/ui";
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
import {
|
||||
MessageList,
|
||||
MessageInput,
|
||||
@@ -51,13 +52,8 @@
|
||||
// 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);
|
||||
let showJoinScreen = $state(false);
|
||||
let isProvisioning = $state(false);
|
||||
|
||||
// Chat UI state
|
||||
let showCreateRoomModal = $state(false);
|
||||
@@ -140,13 +136,13 @@
|
||||
deviceId: result.credentials.device_id,
|
||||
});
|
||||
} else {
|
||||
// No stored credentials — show login form
|
||||
showMatrixLogin = true;
|
||||
// No stored credentials — show join screen
|
||||
showJoinScreen = true;
|
||||
isInitializing = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load Matrix credentials:", e);
|
||||
showMatrixLogin = true;
|
||||
showJoinScreen = true;
|
||||
isInitializing = false;
|
||||
}
|
||||
});
|
||||
@@ -169,8 +165,8 @@
|
||||
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;
|
||||
toasts.error(m.chat_join_error());
|
||||
showJoinScreen = true;
|
||||
} finally {
|
||||
isInitializing = false;
|
||||
}
|
||||
@@ -204,41 +200,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMatrixLogin() {
|
||||
if (!matrixUsername.trim() || !matrixPassword.trim()) {
|
||||
toasts.error("Please enter username and password");
|
||||
return;
|
||||
}
|
||||
|
||||
isLoggingIn = true;
|
||||
async function handleJoinChat() {
|
||||
isProvisioning = 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", {
|
||||
const res = await fetch("/api/matrix-provision", {
|
||||
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,
|
||||
}),
|
||||
body: JSON.stringify({ org_id: data.org.id }),
|
||||
});
|
||||
|
||||
showMatrixLogin = false;
|
||||
await initFromCredentials(credentials);
|
||||
toasts.success("Connected to chat!");
|
||||
const result = await res.json();
|
||||
if (!res.ok) throw new Error(result.error || "Provisioning failed");
|
||||
|
||||
showJoinScreen = false;
|
||||
await initFromCredentials({
|
||||
homeserverUrl: result.credentials.homeserver_url,
|
||||
userId: result.credentials.matrix_user_id,
|
||||
accessToken: result.credentials.access_token,
|
||||
deviceId: result.credentials.device_id,
|
||||
});
|
||||
|
||||
if (result.provisioned) {
|
||||
toasts.success(m.chat_join_success());
|
||||
}
|
||||
} catch (e: any) {
|
||||
toasts.error(e.message || "Login failed");
|
||||
toasts.error(e.message || m.chat_join_error());
|
||||
} finally {
|
||||
isLoggingIn = false;
|
||||
isProvisioning = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +243,7 @@
|
||||
});
|
||||
|
||||
matrixClient = null;
|
||||
showMatrixLogin = true;
|
||||
showJoinScreen = true;
|
||||
auth.set({
|
||||
isLoggedIn: false,
|
||||
userId: null,
|
||||
@@ -389,47 +377,37 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Matrix Login Modal -->
|
||||
{#if showMatrixLogin}
|
||||
<!-- Join Chat consent screen -->
|
||||
{#if showJoinScreen}
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="bg-night rounded-[32px] p-8 w-full max-w-md">
|
||||
<h2 class="font-heading text-h3 text-white mb-2">Connect to Chat</h2>
|
||||
<p class="text-light/50 text-body mb-6">
|
||||
Enter your Matrix credentials to enable messaging.
|
||||
<div class="bg-dark/30 border border-light/5 rounded-xl p-8 w-full max-w-md text-center">
|
||||
<span
|
||||
class="material-symbols-rounded text-primary mb-4 inline-block"
|
||||
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
|
||||
>chat</span>
|
||||
<h2 class="font-heading text-h3 text-white mb-2">{m.chat_join_title()}</h2>
|
||||
<p class="text-body-sm text-light/50 mb-4">
|
||||
{m.chat_join_description()}
|
||||
</p>
|
||||
<p class="text-body-sm text-light/40 mb-6">
|
||||
{m.chat_join_consent()}
|
||||
</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 mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={matrixPassword}
|
||||
placeholder="Password"
|
||||
class="w-full bg-dark border border-light/10 rounded-2xl px-4 py-3 text-white font-body text-body 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}
|
||||
onclick={handleJoinChat}
|
||||
disabled={isProvisioning}
|
||||
>
|
||||
{isLoggingIn ? "Connecting..." : "Connect"}
|
||||
{isProvisioning ? m.chat_joining() : m.chat_join_button()}
|
||||
</Button>
|
||||
</div>
|
||||
<a
|
||||
href="https://matrix.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-block mt-4 text-[12px] text-light/30 hover:text-light/50 transition-colors"
|
||||
>
|
||||
{m.chat_join_learn_more()} →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -438,9 +416,9 @@
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="animate-spin w-12 h-12 border-4 border-primary border-t-transparent rounded-full mx-auto mb-4"
|
||||
class="animate-spin w-10 h-10 border-3 border-primary border-t-transparent rounded-full mx-auto mb-4"
|
||||
></div>
|
||||
<p class="text-light/50">
|
||||
<p class="text-body-sm text-light/40">
|
||||
{#if isInitializing}
|
||||
Connecting to Matrix...
|
||||
{:else if $syncState === "CATCHUP"}
|
||||
@@ -460,56 +438,50 @@
|
||||
{:else if matrixClient}
|
||||
<MatrixProvider client={matrixClient}>
|
||||
{#snippet children()}
|
||||
<div class="h-full flex gap-2 min-h-0">
|
||||
<div class="h-full flex min-h-0">
|
||||
<!-- Chat Sidebar -->
|
||||
<aside class="w-56 bg-night rounded-[32px] flex flex-col overflow-hidden shrink-0">
|
||||
<header class="px-3 py-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="material-symbols-rounded text-light" style="font-size: 20px;">chat</span>
|
||||
<span class="flex-1 font-heading text-light text-base">Messages</span>
|
||||
<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="text-light hover:text-primary transition-colors"
|
||||
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: 20px;">add</span>
|
||||
<span class="material-symbols-rounded" style="font-size: 18px;">add</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Room search -->
|
||||
<div class="px-3 pb-2">
|
||||
<div class="px-2 py-2">
|
||||
<div class="relative">
|
||||
<span
|
||||
class="material-symbols-rounded absolute left-3 top-1/2 -translate-y-1/2 text-light/40"
|
||||
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 rooms..."
|
||||
class="w-full pl-9 pr-3 py-2 bg-dark text-light text-sm rounded-lg border border-light/10 placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||
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-2 pb-2">
|
||||
<nav class="flex-1 overflow-y-auto px-1.5 pb-2">
|
||||
{#if allRooms.length === 0}
|
||||
<p class="text-light/40 text-sm text-center py-8">
|
||||
<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-2">
|
||||
<div class="mb-1.5">
|
||||
<div class="flex items-center justify-between px-2 py-1">
|
||||
<span class="text-xs font-semibold text-light/40 uppercase tracking-wider">
|
||||
<span class="material-symbols-rounded align-middle" style="font-size: 14px;">workspaces</span>
|
||||
Organization
|
||||
</span>
|
||||
<span class="text-[10px] font-body text-light/30 uppercase tracking-wider">Organization</span>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors"
|
||||
class="p-0.5 text-light/30 hover:text-white hover:bg-dark/50 rounded transition-colors"
|
||||
onclick={() => (showCreateRoomModal = true)}
|
||||
title="Create room"
|
||||
>
|
||||
@@ -520,16 +492,16 @@
|
||||
{#each filteredOrgRooms as room (room.roomId)}
|
||||
<li>
|
||||
<button
|
||||
class="w-full flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors text-left
|
||||
{$selectedRoomId === room.roomId ? 'bg-primary/20' : 'hover:bg-light/5'}"
|
||||
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="font-bold text-sm text-light truncate block">{room.name}</span>
|
||||
<span class="text-[12px] font-body truncate block">{room.name}</span>
|
||||
</div>
|
||||
{#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">
|
||||
<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}
|
||||
@@ -542,14 +514,11 @@
|
||||
|
||||
<!-- Direct Messages -->
|
||||
{#if filteredDmRooms.length > 0}
|
||||
<div class="mb-2">
|
||||
<div class="mb-1.5">
|
||||
<div class="flex items-center justify-between px-2 py-1">
|
||||
<span class="text-xs font-semibold text-light/40 uppercase tracking-wider">
|
||||
<span class="material-symbols-rounded align-middle" style="font-size: 14px;">chat_bubble</span>
|
||||
Direct Messages
|
||||
</span>
|
||||
<span class="text-[10px] font-body text-light/30 uppercase tracking-wider">Direct Messages</span>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors"
|
||||
class="p-0.5 text-light/30 hover:text-white hover:bg-dark/50 rounded transition-colors"
|
||||
onclick={() => (showStartDMModal = true)}
|
||||
title="New DM"
|
||||
>
|
||||
@@ -560,16 +529,16 @@
|
||||
{#each filteredDmRooms as room (room.roomId)}
|
||||
<li>
|
||||
<button
|
||||
class="w-full flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors text-left
|
||||
{$selectedRoomId === room.roomId ? 'bg-primary/20' : 'hover:bg-light/5'}"
|
||||
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="font-bold text-sm text-light truncate block">{room.name}</span>
|
||||
<span class="text-[12px] font-body truncate block">{room.name}</span>
|
||||
</div>
|
||||
{#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">
|
||||
<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}
|
||||
@@ -582,14 +551,11 @@
|
||||
|
||||
<!-- Other Rooms (not in a space, not DMs) -->
|
||||
{#if filteredOtherRooms.length > 0}
|
||||
<div class="mb-2">
|
||||
<div class="mb-1.5">
|
||||
<div class="flex items-center justify-between px-2 py-1">
|
||||
<span class="text-xs font-semibold text-light/40 uppercase tracking-wider">
|
||||
<span class="material-symbols-rounded align-middle" style="font-size: 14px;">tag</span>
|
||||
Rooms
|
||||
</span>
|
||||
<span class="text-[10px] font-body text-light/30 uppercase tracking-wider">Rooms</span>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors"
|
||||
class="p-0.5 text-light/30 hover:text-white hover:bg-dark/50 rounded transition-colors"
|
||||
onclick={() => (showCreateRoomModal = true)}
|
||||
title="Create room"
|
||||
>
|
||||
@@ -600,16 +566,16 @@
|
||||
{#each filteredOtherRooms as room (room.roomId)}
|
||||
<li>
|
||||
<button
|
||||
class="w-full flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors text-left
|
||||
{$selectedRoomId === room.roomId ? 'bg-primary/20' : 'hover:bg-light/5'}"
|
||||
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="font-bold text-sm text-light truncate block">{room.name}</span>
|
||||
<span class="text-[12px] font-body truncate block">{room.name}</span>
|
||||
</div>
|
||||
{#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">
|
||||
<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}
|
||||
@@ -623,76 +589,76 @@
|
||||
</nav>
|
||||
|
||||
<!-- User footer -->
|
||||
<footer class="p-3 border-t border-light/10">
|
||||
<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-xs font-medium text-light truncate">{$auth.userId}</p>
|
||||
<p class="text-[11px] text-light/50 truncate">{$auth.userId}</p>
|
||||
</div>
|
||||
<button
|
||||
class="text-light/50 hover:text-light p-1 rounded-lg hover:bg-light/10 transition-colors"
|
||||
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: 18px;">logout</span>
|
||||
<span class="material-symbols-rounded" style="font-size: 16px;">logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Chat Area -->
|
||||
<main class="flex-1 flex flex-col min-h-0 overflow-hidden bg-night rounded-[32px]">
|
||||
<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 -->
|
||||
<header class="h-14 px-5 flex items-center border-b border-light/10">
|
||||
<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-3 w-full">
|
||||
<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-h5 text-light truncate">{room.name}</h2>
|
||||
<p class="text-xs text-light/50">
|
||||
<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-2 text-light/50 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||
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: 20px;">search</span>
|
||||
<span class="material-symbols-rounded" style="font-size: 18px;">search</span>
|
||||
</button>
|
||||
<button
|
||||
class="p-2 text-light/50 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||
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: 20px;">info</span>
|
||||
<span class="material-symbols-rounded" style="font-size: 18px;">info</span>
|
||||
</button>
|
||||
<button
|
||||
class="p-2 text-light/50 hover:text-light hover:bg-light/10 rounded-lg transition-colors"
|
||||
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: 20px;">group</span>
|
||||
<span class="material-symbols-rounded" style="font-size: 18px;">group</span>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<!-- Message search panel -->
|
||||
{#if showMessageSearch}
|
||||
<div class="border-b border-light/10 p-3 bg-dark/50">
|
||||
<div class="border-b border-light/5 px-4 py-2.5">
|
||||
<div class="relative">
|
||||
<span class="material-symbols-rounded absolute left-3 top-1/2 -translate-y-1/2 text-light/40" style="font-size: 16px;">search</span>
|
||||
<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 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"
|
||||
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/40 hover:text-light"
|
||||
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>
|
||||
@@ -700,22 +666,22 @@
|
||||
</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">
|
||||
<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-2 hover:bg-light/5 rounded transition-colors"
|
||||
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-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>
|
||||
<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-sm text-light/40 mt-2">No results found</p>
|
||||
<p class="text-body-sm text-light/30 mt-2">No results found</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -729,11 +695,11 @@
|
||||
role="region"
|
||||
>
|
||||
{#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="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-4 block" style="font-size: 64px;">upload_file</span>
|
||||
<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>
|
||||
<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}
|
||||
@@ -764,7 +730,7 @@
|
||||
<!-- Side panels -->
|
||||
{#if showRoomInfo}
|
||||
{#each $roomSummaries.filter((r) => r.roomId === $selectedRoomId) as currentRoom}
|
||||
<aside class="w-72 border-l border-light/10 bg-dark/30">
|
||||
<aside class="w-72 border-l border-light/5">
|
||||
<RoomInfoPanel
|
||||
room={currentRoom}
|
||||
members={currentMembers}
|
||||
@@ -773,7 +739,7 @@
|
||||
</aside>
|
||||
{/each}
|
||||
{:else if showMemberList}
|
||||
<aside class="w-64 border-l border-light/10 bg-dark/30">
|
||||
<aside class="w-64 border-l border-light/5">
|
||||
<MemberList members={currentMembers} />
|
||||
</aside>
|
||||
{/if}
|
||||
@@ -782,10 +748,10 @@
|
||||
{:else}
|
||||
<!-- No room selected -->
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center text-light/40">
|
||||
<span class="material-symbols-rounded mb-4 block" style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300;">chat</span>
|
||||
<h2 class="font-heading text-h4 text-light/50 mb-2">Select a room</h2>
|
||||
<p class="text-body text-light/30">Choose a conversation to start chatting</p>
|
||||
<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}
|
||||
|
||||
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
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
deleteCard,
|
||||
deleteColumn,
|
||||
subscribeToBoard,
|
||||
type RealtimeChangePayload,
|
||||
type ColumnWithCards,
|
||||
type BoardWithColumns,
|
||||
} from "$lib/api/kanban";
|
||||
import {
|
||||
getLockInfo,
|
||||
@@ -24,8 +27,8 @@
|
||||
RealtimeChannel,
|
||||
SupabaseClient,
|
||||
} from "@supabase/supabase-js";
|
||||
import type { Database, KanbanCard, Document } from "$lib/supabase/types";
|
||||
import type { BoardWithColumns } from "$lib/api/kanban";
|
||||
import type { Database, KanbanCard, KanbanColumn, Document } from "$lib/supabase/types";
|
||||
import { untrack } from "svelte";
|
||||
|
||||
const log = createLogger("page.file-viewer");
|
||||
|
||||
@@ -194,16 +197,68 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Incremental realtime handlers (avoid full refetch)
|
||||
function handleColumnRealtime(payload: RealtimeChangePayload<KanbanColumn>) {
|
||||
if (!kanbanBoard) return;
|
||||
const { event } = payload;
|
||||
if (event === "INSERT") {
|
||||
const col: ColumnWithCards = { ...payload.new, cards: [] };
|
||||
kanbanBoard = { ...kanbanBoard, columns: [...kanbanBoard.columns, col].sort((a: ColumnWithCards, b: ColumnWithCards) => a.position - b.position) };
|
||||
} else if (event === "UPDATE") {
|
||||
kanbanBoard = { ...kanbanBoard, columns: kanbanBoard.columns.map((c: ColumnWithCards) => c.id === payload.new.id ? { ...c, ...payload.new } : c).sort((a: ColumnWithCards, b: ColumnWithCards) => a.position - b.position) };
|
||||
} else if (event === "DELETE") {
|
||||
const deletedId = payload.old.id;
|
||||
if (deletedId) {
|
||||
kanbanBoard = { ...kanbanBoard, columns: kanbanBoard.columns.filter((c: ColumnWithCards) => c.id !== deletedId) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const colIds = kanbanBoard.columns.map((c) => c.id);
|
||||
function handleCardRealtime(payload: RealtimeChangePayload<KanbanCard>) {
|
||||
if (!kanbanBoard) return;
|
||||
const { event } = payload;
|
||||
|
||||
// Skip realtime updates for cards in an in-flight optimistic move
|
||||
if (event === "UPDATE" && optimisticMoveIds.size > 0) {
|
||||
const cardId = payload.new?.id ?? payload.old?.id;
|
||||
if (cardId && optimisticMoveIds.has(cardId)) return;
|
||||
}
|
||||
|
||||
if (event === "INSERT") {
|
||||
const card = payload.new;
|
||||
if (!card.column_id) return;
|
||||
kanbanBoard = { ...kanbanBoard, columns: kanbanBoard.columns.map((col: ColumnWithCards) => col.id === card.column_id ? { ...col, cards: [...col.cards, card].sort((a: KanbanCard, b: KanbanCard) => a.position - b.position) } : col) };
|
||||
} else if (event === "UPDATE") {
|
||||
const card = payload.new;
|
||||
kanbanBoard = { ...kanbanBoard, columns: kanbanBoard.columns.map((col: ColumnWithCards) => {
|
||||
if (col.id === card.column_id) {
|
||||
const exists = col.cards.some((c: KanbanCard) => c.id === card.id);
|
||||
const updatedCards = exists ? col.cards.map((c: KanbanCard) => c.id === card.id ? { ...c, ...card } : c) : [...col.cards, card];
|
||||
return { ...col, cards: updatedCards.sort((a: KanbanCard, b: KanbanCard) => a.position - b.position) };
|
||||
}
|
||||
return { ...col, cards: col.cards.filter((c: KanbanCard) => c.id !== card.id) };
|
||||
}) };
|
||||
} else if (event === "DELETE") {
|
||||
const deletedId = payload.old.id;
|
||||
if (deletedId) {
|
||||
kanbanBoard = { ...kanbanBoard, columns: kanbanBoard.columns.map((col: ColumnWithCards) => ({ ...col, cards: col.cards.filter((c: KanbanCard) => c.id !== deletedId) })) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let currentKanbanBoardId = $derived(kanbanBoard?.id ?? null);
|
||||
|
||||
$effect(() => {
|
||||
const boardId = currentKanbanBoardId;
|
||||
if (!boardId) return;
|
||||
|
||||
const colIds = (untrack(() => kanbanBoard)?.columns ?? []).map((c: ColumnWithCards) => c.id);
|
||||
const channel = subscribeToBoard(
|
||||
supabase,
|
||||
kanbanBoard.id,
|
||||
boardId,
|
||||
colIds,
|
||||
() => loadKanbanBoard(),
|
||||
() => loadKanbanBoard(),
|
||||
handleColumnRealtime,
|
||||
handleCardRealtime,
|
||||
);
|
||||
realtimeChannel = channel;
|
||||
|
||||
@@ -244,17 +299,77 @@
|
||||
}
|
||||
});
|
||||
|
||||
async function handleCardMove(
|
||||
// Track card IDs with in-flight optimistic moves to suppress realtime echoes
|
||||
let optimisticMoveIds = new Set<string>();
|
||||
|
||||
function handleCardMove(
|
||||
cardId: string,
|
||||
toColumnId: string,
|
||||
toPosition: number,
|
||||
) {
|
||||
try {
|
||||
await moveCard(supabase, cardId, toColumnId, toPosition);
|
||||
} catch (err) {
|
||||
if (!kanbanBoard) return;
|
||||
|
||||
const fromColId = kanbanBoard.columns.find((c: ColumnWithCards) =>
|
||||
c.cards.some((card: KanbanCard) => card.id === cardId),
|
||||
)?.id;
|
||||
if (!fromColId) return;
|
||||
|
||||
// Build fully immutable new columns for instant Svelte reactivity
|
||||
let movedCard: KanbanCard | undefined;
|
||||
const newColumns = kanbanBoard.columns.map((col: ColumnWithCards) => {
|
||||
if (col.id === fromColId && fromColId !== toColumnId) {
|
||||
const filtered = col.cards.filter((c: KanbanCard) => {
|
||||
if (c.id === cardId) { movedCard = { ...c, column_id: toColumnId }; return false; }
|
||||
return true;
|
||||
});
|
||||
return { ...col, cards: filtered };
|
||||
}
|
||||
if (col.id === toColumnId && fromColId !== toColumnId) {
|
||||
const cards = [...col.cards];
|
||||
return { ...col, cards };
|
||||
}
|
||||
if (col.id === fromColId && fromColId === toColumnId) {
|
||||
const card = col.cards.find((c: KanbanCard) => c.id === cardId);
|
||||
if (!card) return col;
|
||||
movedCard = { ...card };
|
||||
const without = col.cards.filter((c: KanbanCard) => c.id !== cardId);
|
||||
const cards = [...without.slice(0, toPosition), movedCard, ...without.slice(toPosition)];
|
||||
return { ...col, cards };
|
||||
}
|
||||
return col;
|
||||
});
|
||||
|
||||
const finalColumns = (fromColId !== toColumnId && movedCard)
|
||||
? newColumns.map((col: ColumnWithCards) => {
|
||||
if (col.id === toColumnId) {
|
||||
const cards = [...col.cards];
|
||||
cards.splice(toPosition, 0, movedCard!);
|
||||
return { ...col, cards };
|
||||
}
|
||||
return col;
|
||||
})
|
||||
: newColumns;
|
||||
|
||||
// Track affected IDs for realtime suppression
|
||||
const affectedIds = new Set<string>();
|
||||
affectedIds.add(cardId);
|
||||
const targetCol = finalColumns.find((c: ColumnWithCards) => c.id === toColumnId);
|
||||
targetCol?.cards.forEach((c: KanbanCard) => affectedIds.add(c.id));
|
||||
affectedIds.forEach((id: string) => optimisticMoveIds.add(id));
|
||||
|
||||
// Instant optimistic update
|
||||
kanbanBoard = { ...kanbanBoard, columns: finalColumns };
|
||||
|
||||
// Persist in background
|
||||
moveCard(supabase, cardId, toColumnId, toPosition)
|
||||
.catch((err) => {
|
||||
log.error("Failed to move card", { error: err });
|
||||
toasts.error("Failed to move card");
|
||||
}
|
||||
loadKanbanBoard();
|
||||
})
|
||||
.finally(() => {
|
||||
affectedIds.forEach((id) => optimisticMoveIds.delete(id));
|
||||
});
|
||||
}
|
||||
|
||||
function handleCardClick(card: KanbanCard) {
|
||||
@@ -356,7 +471,7 @@
|
||||
for (const col of json.columns) {
|
||||
// Check if column already exists by name
|
||||
let targetCol = kanbanBoard.columns.find(
|
||||
(c) =>
|
||||
(c: ColumnWithCards) =>
|
||||
c.name.toLowerCase() ===
|
||||
(col.name || "").toLowerCase(),
|
||||
);
|
||||
@@ -419,9 +534,9 @@
|
||||
if (!kanbanBoard) return;
|
||||
const exportData = {
|
||||
board: kanbanBoard.name,
|
||||
columns: kanbanBoard.columns.map((col) => ({
|
||||
columns: kanbanBoard.columns.map((col: ColumnWithCards) => ({
|
||||
name: col.name,
|
||||
cards: col.cards.map((card) => ({
|
||||
cards: col.cards.map((card: KanbanCard) => ({
|
||||
title: card.title,
|
||||
description: card.description,
|
||||
priority: card.priority,
|
||||
@@ -545,9 +660,9 @@
|
||||
if (kanbanBoard) {
|
||||
kanbanBoard = {
|
||||
...kanbanBoard,
|
||||
columns: kanbanBoard.columns.map((col) => ({
|
||||
columns: kanbanBoard.columns.map((col: ColumnWithCards) => ({
|
||||
...col,
|
||||
cards: col.cards.map((c) =>
|
||||
cards: col.cards.map((c: KanbanCard) =>
|
||||
c.id === updatedCard.id ? updatedCard : c,
|
||||
),
|
||||
})),
|
||||
|
||||
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
|
||||
.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');
|
||||
}
|
||||
};
|
||||
214
src/routes/[orgSlug]/events/[eventSlug]/+layout.svelte
Normal file
214
src/routes/[orgSlug]/events/[eventSlug]/+layout.svelte
Normal file
@@ -0,0 +1,214 @@
|
||||
<script lang="ts">
|
||||
import { page, navigating } from "$app/stores";
|
||||
import { Avatar } from "$lib/components/ui";
|
||||
import type { Snippet } from "svelte";
|
||||
import type { Event, EventMemberWithDetails, EventRole, EventDepartment } from "$lib/api/events";
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
org: { id: string; name: string; slug: string };
|
||||
userRole: string;
|
||||
event: Event;
|
||||
eventMembers: EventMemberWithDetails[];
|
||||
eventRoles: EventRole[];
|
||||
eventDepartments: EventDepartment[];
|
||||
};
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { data, children }: Props = $props();
|
||||
|
||||
const basePath = $derived(
|
||||
`/${data.org.slug}/events/${data.event.slug}`,
|
||||
);
|
||||
|
||||
const modules = $derived([
|
||||
{
|
||||
href: basePath,
|
||||
label: m.events_overview(),
|
||||
icon: "dashboard",
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
href: `${basePath}/tasks`,
|
||||
label: m.events_mod_tasks(),
|
||||
icon: "task_alt",
|
||||
},
|
||||
{
|
||||
href: `${basePath}/files`,
|
||||
label: m.events_mod_files(),
|
||||
icon: "folder",
|
||||
},
|
||||
{
|
||||
href: `${basePath}/schedule`,
|
||||
label: m.events_mod_schedule(),
|
||||
icon: "calendar_today",
|
||||
},
|
||||
{
|
||||
href: `${basePath}/budget`,
|
||||
label: m.events_mod_budget(),
|
||||
icon: "account_balance_wallet",
|
||||
},
|
||||
{
|
||||
href: `${basePath}/guests`,
|
||||
label: m.events_mod_guests(),
|
||||
icon: "groups",
|
||||
},
|
||||
{
|
||||
href: `${basePath}/team`,
|
||||
label: m.events_mod_team(),
|
||||
icon: "badge",
|
||||
},
|
||||
{
|
||||
href: `${basePath}/sponsors`,
|
||||
label: m.events_mod_sponsors(),
|
||||
icon: "handshake",
|
||||
},
|
||||
]);
|
||||
|
||||
function isModuleActive(href: string, exact?: boolean): boolean {
|
||||
const navTo = $navigating?.to?.url.pathname;
|
||||
if (navTo) {
|
||||
if (exact) return navTo === href;
|
||||
return navTo.startsWith(href);
|
||||
}
|
||||
if (exact) return $page.url.pathname === href;
|
||||
return $page.url.pathname.startsWith(href);
|
||||
}
|
||||
|
||||
function isNavigatingToModule(href: string, exact?: boolean): boolean {
|
||||
const navTo = $navigating?.to?.url.pathname;
|
||||
if (!navTo) return false;
|
||||
const isTarget = exact ? navTo === href : navTo.startsWith(href);
|
||||
const isCurrent = exact ? $page.url.pathname === href : $page.url.pathname.startsWith(href);
|
||||
return isTarget && !isCurrent;
|
||||
}
|
||||
|
||||
function getStatusColor(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
planning: "bg-amber-400",
|
||||
active: "bg-emerald-400",
|
||||
completed: "bg-blue-400",
|
||||
archived: "bg-light/40",
|
||||
};
|
||||
return map[status] ?? "bg-light/40";
|
||||
}
|
||||
|
||||
function formatDateCompact(dateStr: string | null): string {
|
||||
if (!dateStr) return "";
|
||||
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex h-full">
|
||||
<!-- Event Module Sidebar -->
|
||||
<aside
|
||||
class="w-56 shrink-0 bg-dark/30 border-r border-light/5 flex flex-col overflow-hidden"
|
||||
>
|
||||
<!-- Event Header -->
|
||||
<div class="p-4 border-b border-light/5">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<div
|
||||
class="w-2.5 h-2.5 rounded-full shrink-0 {getStatusColor(
|
||||
data.event.status,
|
||||
)}"
|
||||
></div>
|
||||
<h2
|
||||
class="text-body font-heading text-white truncate"
|
||||
title={data.event.name}
|
||||
>
|
||||
{data.event.name}
|
||||
</h2>
|
||||
</div>
|
||||
{#if data.event.start_date}
|
||||
<p class="text-[11px] text-light/40 flex items-center gap-1">
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 12px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 12;"
|
||||
>calendar_today</span
|
||||
>
|
||||
{formatDateCompact(data.event.start_date)}{data.event
|
||||
.end_date
|
||||
? ` — ${formatDateCompact(data.event.end_date)}`
|
||||
: ""}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Module Navigation -->
|
||||
<nav class="flex-1 flex flex-col gap-0.5 p-2 overflow-auto">
|
||||
{#each modules as mod}
|
||||
<a
|
||||
href={mod.href}
|
||||
class="flex items-center gap-2.5 px-3 py-2 rounded-xl text-body-sm font-body transition-colors {isModuleActive(
|
||||
mod.href,
|
||||
mod.exact,
|
||||
)
|
||||
? 'bg-primary text-background'
|
||||
: 'text-light/60 hover:text-white hover:bg-dark/50'}"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
||||
>{mod.icon}</span
|
||||
>
|
||||
<span class="flex-1">{mod.label}</span>
|
||||
{#if isNavigatingToModule(mod.href, mod.exact)}
|
||||
<span class="block w-3.5 h-3.5 border-2 border-background/30 border-t-background rounded-full animate-spin shrink-0"></span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Event Team Preview -->
|
||||
<div class="p-3 border-t border-light/5">
|
||||
<p class="text-[11px] text-light/40 mb-2 px-1">
|
||||
{m.events_team_count({ count: String(data.eventMembers.length) })}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-1 px-1">
|
||||
{#each data.eventMembers.slice(0, 8) as member}
|
||||
<div title={member.profile?.full_name || member.profile?.email || "Member"}>
|
||||
<Avatar
|
||||
name={member.profile?.full_name ||
|
||||
member.profile?.email ||
|
||||
"?"}
|
||||
src={member.profile?.avatar_url}
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{#if data.eventMembers.length > 8}
|
||||
<div
|
||||
class="w-6 h-6 rounded-full bg-dark flex items-center justify-center text-[10px] text-light/50"
|
||||
>
|
||||
+{data.eventMembers.length - 8}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Back link -->
|
||||
<a
|
||||
href="/{data.org.slug}/events"
|
||||
class="flex items-center gap-2 px-4 py-3 border-t border-light/5 text-body-sm text-light/40 hover:text-white transition-colors"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||
>arrow_back</span
|
||||
>
|
||||
{m.events_all_events()}
|
||||
</a>
|
||||
</aside>
|
||||
|
||||
<!-- Module Content -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
544
src/routes/[orgSlug]/events/[eventSlug]/+page.svelte
Normal file
544
src/routes/[orgSlug]/events/[eventSlug]/+page.svelte
Normal file
@@ -0,0 +1,544 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { ModuleCard, SectionCard, StatusBadge } 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 type { Event, EventMemberWithDetails, EventRole, EventDepartment } from "$lib/api/events";
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
org: { id: string; name: string; slug: string };
|
||||
userRole: string;
|
||||
event: Event;
|
||||
eventMembers: EventMemberWithDetails[];
|
||||
eventRoles: EventRole[];
|
||||
eventDepartments: EventDepartment[];
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||
|
||||
const isEditor = $derived(
|
||||
["owner", "admin", "editor"].includes(data.userRole),
|
||||
);
|
||||
|
||||
// Edit mode
|
||||
let editing = $state(false);
|
||||
let editName = $state("");
|
||||
let editDescription = $state("");
|
||||
let editStatus = $state<string>("planning");
|
||||
let editStartDate = $state("");
|
||||
let editEndDate = $state("");
|
||||
let editVenueName = $state("");
|
||||
let editVenueAddress = $state("");
|
||||
let saving = $state(false);
|
||||
|
||||
// Sync edit fields when data changes or edit mode opens
|
||||
$effect(() => {
|
||||
if (editing) {
|
||||
editName = data.event.name;
|
||||
editDescription = data.event.description ?? "";
|
||||
editStatus = data.event.status;
|
||||
editStartDate = data.event.start_date ?? "";
|
||||
editEndDate = data.event.end_date ?? "";
|
||||
editVenueName = data.event.venue_name ?? "";
|
||||
editVenueAddress = data.event.venue_address ?? "";
|
||||
}
|
||||
});
|
||||
|
||||
// Delete confirmation
|
||||
let showDeleteConfirm = $state(false);
|
||||
let deleting = $state(false);
|
||||
|
||||
const basePath = $derived(
|
||||
`/${data.org.slug}/events/${data.event.slug}`,
|
||||
);
|
||||
|
||||
const statusOptions = $derived([
|
||||
{ value: "planning", label: m.events_status_planning(), icon: "edit_note", color: "text-amber-400" },
|
||||
{ value: "active", label: m.events_status_active(), icon: "play_circle", color: "text-emerald-400" },
|
||||
{ value: "completed", label: m.events_status_completed(), icon: "check_circle", color: "text-blue-400" },
|
||||
{ value: "archived", label: m.events_status_archived(), icon: "archive", color: "text-light/40" },
|
||||
]);
|
||||
|
||||
const moduleCards = $derived([
|
||||
{
|
||||
href: `${basePath}/tasks`,
|
||||
label: m.events_mod_tasks(),
|
||||
icon: "task_alt",
|
||||
description: m.events_mod_tasks_desc(),
|
||||
color: "text-emerald-400",
|
||||
bg: "bg-emerald-400/10",
|
||||
},
|
||||
{
|
||||
href: `${basePath}/files`,
|
||||
label: m.events_mod_files(),
|
||||
icon: "folder",
|
||||
description: m.events_mod_files_desc(),
|
||||
color: "text-blue-400",
|
||||
bg: "bg-blue-400/10",
|
||||
},
|
||||
{
|
||||
href: `${basePath}/schedule`,
|
||||
label: m.events_mod_schedule(),
|
||||
icon: "calendar_today",
|
||||
description: m.events_mod_schedule_desc(),
|
||||
color: "text-purple-400",
|
||||
bg: "bg-purple-400/10",
|
||||
},
|
||||
{
|
||||
href: `${basePath}/budget`,
|
||||
label: m.events_mod_budget(),
|
||||
icon: "account_balance_wallet",
|
||||
description: m.events_mod_budget_desc(),
|
||||
color: "text-amber-400",
|
||||
bg: "bg-amber-400/10",
|
||||
},
|
||||
{
|
||||
href: `${basePath}/guests`,
|
||||
label: m.events_mod_guests(),
|
||||
icon: "groups",
|
||||
description: m.events_mod_guests_desc(),
|
||||
color: "text-pink-400",
|
||||
bg: "bg-pink-400/10",
|
||||
},
|
||||
{
|
||||
href: `${basePath}/team`,
|
||||
label: m.events_mod_team(),
|
||||
icon: "badge",
|
||||
description: m.events_mod_team_desc(),
|
||||
color: "text-teal-400",
|
||||
bg: "bg-teal-400/10",
|
||||
},
|
||||
{
|
||||
href: `${basePath}/sponsors`,
|
||||
label: m.events_mod_sponsors(),
|
||||
icon: "handshake",
|
||||
description: m.events_mod_sponsors_desc(),
|
||||
color: "text-orange-400",
|
||||
bg: "bg-orange-400/10",
|
||||
},
|
||||
]);
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return m.events_not_set();
|
||||
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function daysUntilEvent(): string {
|
||||
if (!data.event.start_date) return "";
|
||||
const now = new Date();
|
||||
const start = new Date(data.event.start_date);
|
||||
const diff = Math.ceil(
|
||||
(start.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
if (diff < 0) return m.events_days_ago({ count: String(Math.abs(diff)) });
|
||||
if (diff === 0) return m.events_today();
|
||||
if (diff === 1) return m.events_tomorrow();
|
||||
return m.events_in_days({ count: String(diff) });
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving = true;
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from("events")
|
||||
.update({
|
||||
name: editName.trim(),
|
||||
description: editDescription.trim() || null,
|
||||
status: editStatus,
|
||||
start_date: editStartDate || null,
|
||||
end_date: editEndDate || null,
|
||||
venue_name: editVenueName.trim() || null,
|
||||
venue_address: editVenueAddress.trim() || null,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", data.event.id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toasts.success(m.events_updated());
|
||||
editing = false;
|
||||
// Refresh the page data
|
||||
goto(`/${data.org.slug}/events/${data.event.slug}`, {
|
||||
invalidateAll: true,
|
||||
});
|
||||
} catch (e: any) {
|
||||
toasts.error(e.message || "Failed to update event");
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
deleting = true;
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from("events")
|
||||
.delete()
|
||||
.eq("id", data.event.id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toasts.success(m.events_deleted());
|
||||
goto(`/${data.org.slug}/events`);
|
||||
} catch (e: any) {
|
||||
toasts.error(e.message || "Failed to delete event");
|
||||
} finally {
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.event.name} | {data.org.name}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col h-full overflow-auto">
|
||||
<!-- Event Header -->
|
||||
<header class="px-6 py-5 border-b border-light/5">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
{#if editing}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editName}
|
||||
class="text-h1 font-heading text-white bg-transparent border-b border-primary focus:outline-none w-full"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-4 h-4 rounded-full shrink-0"
|
||||
style="background-color: {data.event.color ||
|
||||
'#00A3E0'}"
|
||||
></div>
|
||||
<h1 class="text-h1 font-heading text-white">
|
||||
{data.event.name}
|
||||
</h1>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="flex items-center gap-4 mt-2 text-body-sm text-light/50"
|
||||
>
|
||||
{#if editing}
|
||||
<select
|
||||
bind:value={editStatus}
|
||||
class="bg-dark border border-light/10 rounded-lg px-2 py-1 text-body-sm text-white focus:outline-none"
|
||||
>
|
||||
{#each statusOptions as opt}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<span
|
||||
class="capitalize flex items-center gap-1 {statusOptions.find(
|
||||
(s) => s.value === data.event.status,
|
||||
)?.color ?? 'text-light/40'}"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||
>{statusOptions.find(
|
||||
(s) => s.value === data.event.status,
|
||||
)?.icon ?? "help"}</span
|
||||
>
|
||||
{data.event.status}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if data.event.start_date && !editing}
|
||||
<span class="flex items-center gap-1">
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||
>calendar_today</span
|
||||
>
|
||||
{formatDate(data.event.start_date)}
|
||||
</span>
|
||||
{@const countdown = daysUntilEvent()}
|
||||
{#if countdown}
|
||||
<span class="text-primary font-bold"
|
||||
>{countdown}</span
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if data.event.venue_name && !editing}
|
||||
<span class="flex items-center gap-1">
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||
>location_on</span
|
||||
>
|
||||
{data.event.venue_name}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isEditor}
|
||||
<div class="flex items-center gap-2">
|
||||
{#if editing}
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1.5 text-body-sm text-light/60 hover:text-white transition-colors"
|
||||
onclick={() => (editing = false)}
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1.5 bg-primary text-background rounded-lg text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50"
|
||||
disabled={saving}
|
||||
onclick={handleSave}
|
||||
>
|
||||
{saving ? m.events_saving() : m.btn_save()}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 text-light/40 hover:text-white transition-colors rounded-lg hover:bg-dark/50"
|
||||
title={m.btn_edit()}
|
||||
onclick={() => (editing = true)}
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
||||
>edit</span
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 text-light/40 hover:text-error transition-colors rounded-lg hover:bg-error/10"
|
||||
title={m.btn_delete()}
|
||||
onclick={() => (showDeleteConfirm = true)}
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded"
|
||||
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
||||
>delete</span
|
||||
>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Edit fields (when editing) -->
|
||||
{#if editing}
|
||||
<div class="px-6 py-4 border-b border-light/5 flex flex-col gap-3">
|
||||
<textarea
|
||||
bind:value={editDescription}
|
||||
placeholder="Event description..."
|
||||
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 w-full"
|
||||
></textarea>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<input
|
||||
type="date"
|
||||
bind:value={editStartDate}
|
||||
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
|
||||
placeholder="Start date"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={editEndDate}
|
||||
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
|
||||
placeholder="End date"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editVenueName}
|
||||
placeholder={m.events_form_venue_placeholder()}
|
||||
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editVenueAddress}
|
||||
placeholder={m.events_form_venue_address_placeholder()}
|
||||
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Overview Content -->
|
||||
<div class="flex-1 p-6 overflow-auto">
|
||||
<!-- Description -->
|
||||
{#if data.event.description && !editing}
|
||||
<p class="text-body text-light/60 mb-6 max-w-2xl">
|
||||
{data.event.description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Module Cards Grid -->
|
||||
<h2 class="text-h3 font-heading text-white mb-4">{m.events_modules()}</h2>
|
||||
<div
|
||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3"
|
||||
>
|
||||
{#each moduleCards as mod}
|
||||
<ModuleCard
|
||||
label={mod.label}
|
||||
description={mod.description}
|
||||
icon={mod.icon}
|
||||
href={mod.href}
|
||||
color={mod.color}
|
||||
bg={mod.bg}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Event Details Section -->
|
||||
<div class="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Info Card -->
|
||||
<SectionCard title={m.events_details()}>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="material-symbols-rounded text-light/30"
|
||||
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
||||
>calendar_today</span
|
||||
>
|
||||
<div>
|
||||
<p class="text-[11px] text-light/40">
|
||||
{m.events_start_date()}
|
||||
</p>
|
||||
<p class="text-body-sm text-white">
|
||||
{formatDate(data.event.start_date)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="material-symbols-rounded text-light/30"
|
||||
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
||||
>event</span
|
||||
>
|
||||
<div>
|
||||
<p class="text-[11px] text-light/40">{m.events_end_date()}</p>
|
||||
<p class="text-body-sm text-white">
|
||||
{formatDate(data.event.end_date)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if data.event.venue_name}
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="material-symbols-rounded text-light/30"
|
||||
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
||||
>location_on</span
|
||||
>
|
||||
<div>
|
||||
<p class="text-[11px] text-light/40">{m.events_venue()}</p>
|
||||
<p class="text-body-sm text-white">
|
||||
{data.event.venue_name}
|
||||
</p>
|
||||
{#if data.event.venue_address}
|
||||
<p class="text-[11px] text-light/40">
|
||||
{data.event.venue_address}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<!-- Team Card -->
|
||||
<SectionCard title={m.events_team_count({ count: String(data.eventMembers.length) })}>
|
||||
{#snippet titleRight()}
|
||||
<a
|
||||
href="{basePath}/team"
|
||||
class="text-[12px] text-primary hover:underline"
|
||||
>{m.events_team_manage()}</a
|
||||
>
|
||||
{/snippet}
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each data.eventMembers.slice(0, 6) as member}
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-[11px] font-bold text-primary shrink-0">
|
||||
{(member.profile?.full_name || member.profile?.email || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-body-sm text-white truncate">
|
||||
{member.profile?.full_name ||
|
||||
member.profile?.email ||
|
||||
"Unknown"}
|
||||
</p>
|
||||
<p class="text-[11px] text-light/40 capitalize">
|
||||
{member.role}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if data.eventMembers.length > 6}
|
||||
<a
|
||||
href="{basePath}/team"
|
||||
class="text-body-sm text-primary hover:underline text-center pt-1"
|
||||
>
|
||||
{m.events_more_members({ count: String(data.eventMembers.length - 6) })}
|
||||
</a>
|
||||
{/if}
|
||||
{#if data.eventMembers.length === 0}
|
||||
<p class="text-body-sm text-light/30 text-center py-4">
|
||||
{m.events_team_empty()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation -->
|
||||
{#if showDeleteConfirm}
|
||||
<!-- 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" && (showDeleteConfirm = false)}
|
||||
onclick={(e) =>
|
||||
e.target === e.currentTarget && (showDeleteConfirm = false)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={m.events_delete_title()}
|
||||
>
|
||||
<div
|
||||
class="bg-night rounded-2xl w-full max-w-sm shadow-2xl border border-light/10 p-6"
|
||||
>
|
||||
<h2 class="text-h3 font-heading text-white mb-2">{m.events_delete_title()}</h2>
|
||||
<p class="text-body-sm text-light/50 mb-6">
|
||||
{m.events_delete_desc({ name: data.event.name })}
|
||||
</p>
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
|
||||
onclick={() => (showDeleteConfirm = false)}
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-error text-white rounded-xl text-body-sm hover:bg-error/80 transition-colors disabled:opacity-50"
|
||||
disabled={deleting}
|
||||
onclick={handleDelete}
|
||||
>
|
||||
{deleting ? m.events_deleting() : m.events_delete_confirm()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
return await parent();
|
||||
};
|
||||
32
src/routes/[orgSlug]/events/[eventSlug]/budget/+page.svelte
Normal file
32
src/routes/[orgSlug]/events/[eventSlug]/budget/+page.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
org: { id: string; name: string; slug: string };
|
||||
event: { name: string; slug: string };
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.events_mod_budget()} | {data.event.name} | {data.org.name}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col items-center justify-center h-full text-light/40 p-6">
|
||||
<span
|
||||
class="material-symbols-rounded mb-4"
|
||||
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
|
||||
>account_balance_wallet</span
|
||||
>
|
||||
<h2 class="text-h3 font-heading text-white mb-2">{m.events_mod_budget()}</h2>
|
||||
<p class="text-body-sm text-light/30 text-center max-w-sm">
|
||||
{m.events_mod_budget_desc()}
|
||||
</p>
|
||||
<span
|
||||
class="mt-4 text-[11px] text-light/20 bg-light/5 px-3 py-1 rounded-full"
|
||||
>{m.module_coming_soon()}</span
|
||||
>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
return await parent();
|
||||
};
|
||||
32
src/routes/[orgSlug]/events/[eventSlug]/files/+page.svelte
Normal file
32
src/routes/[orgSlug]/events/[eventSlug]/files/+page.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
org: { id: string; name: string; slug: string };
|
||||
event: { name: string; slug: string };
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.events_mod_files()} | {data.event.name} | {data.org.name}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col items-center justify-center h-full text-light/40 p-6">
|
||||
<span
|
||||
class="material-symbols-rounded mb-4"
|
||||
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
|
||||
>folder</span
|
||||
>
|
||||
<h2 class="text-h3 font-heading text-white mb-2">{m.events_mod_files()}</h2>
|
||||
<p class="text-body-sm text-light/30 text-center max-w-sm">
|
||||
{m.events_mod_files_desc()}
|
||||
</p>
|
||||
<span
|
||||
class="mt-4 text-[11px] text-light/20 bg-light/5 px-3 py-1 rounded-full"
|
||||
>{m.module_coming_soon()}</span
|
||||
>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
return await parent();
|
||||
};
|
||||
32
src/routes/[orgSlug]/events/[eventSlug]/guests/+page.svelte
Normal file
32
src/routes/[orgSlug]/events/[eventSlug]/guests/+page.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
org: { id: string; name: string; slug: string };
|
||||
event: { name: string; slug: string };
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.events_mod_guests()} | {data.event.name} | {data.org.name}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col items-center justify-center h-full text-light/40 p-6">
|
||||
<span
|
||||
class="material-symbols-rounded mb-4"
|
||||
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
|
||||
>groups</span
|
||||
>
|
||||
<h2 class="text-h3 font-heading text-white mb-2">{m.events_mod_guests()}</h2>
|
||||
<p class="text-body-sm text-light/30 text-center max-w-sm">
|
||||
{m.events_mod_guests_desc()}
|
||||
</p>
|
||||
<span
|
||||
class="mt-4 text-[11px] text-light/20 bg-light/5 px-3 py-1 rounded-full"
|
||||
>{m.module_coming_soon()}</span
|
||||
>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
return await parent();
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
org: { id: string; name: string; slug: string };
|
||||
event: { name: string; slug: string };
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.events_mod_schedule()} | {data.event.name} | {data.org.name}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col items-center justify-center h-full text-light/40 p-6">
|
||||
<span
|
||||
class="material-symbols-rounded mb-4"
|
||||
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
|
||||
>calendar_today</span
|
||||
>
|
||||
<h2 class="text-h3 font-heading text-white mb-2">{m.events_mod_schedule()}</h2>
|
||||
<p class="text-body-sm text-light/30 text-center max-w-sm">
|
||||
{m.events_mod_schedule_desc()}
|
||||
</p>
|
||||
<span
|
||||
class="mt-4 text-[11px] text-light/20 bg-light/5 px-3 py-1 rounded-full"
|
||||
>{m.module_coming_soon()}</span
|
||||
>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
return await parent();
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
org: { id: string; name: string; slug: string };
|
||||
event: { name: string; slug: string };
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.events_mod_sponsors()} | {data.event.name} | {data.org.name}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col items-center justify-center h-full text-light/40 p-6">
|
||||
<span
|
||||
class="material-symbols-rounded mb-4"
|
||||
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
|
||||
>handshake</span
|
||||
>
|
||||
<h2 class="text-h3 font-heading text-white mb-2">{m.events_mod_sponsors()}</h2>
|
||||
<p class="text-body-sm text-light/30 text-center max-w-sm">
|
||||
{m.events_mod_sponsors_desc()}
|
||||
</p>
|
||||
<span
|
||||
class="mt-4 text-[11px] text-light/20 bg-light/5 px-3 py-1 rounded-full"
|
||||
>{m.module_coming_soon()}</span
|
||||
>
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { fetchTaskColumns } from '$lib/api/event-tasks';
|
||||
import { createLogger } from '$lib/utils/logger';
|
||||
|
||||
const log = createLogger('page.event-tasks');
|
||||
|
||||
export const load: PageServerLoad = async ({ parent, locals }) => {
|
||||
const parentData = await parent();
|
||||
const event = parentData.event;
|
||||
|
||||
try {
|
||||
const taskColumns = await fetchTaskColumns(locals.supabase, event.id);
|
||||
return { ...parentData, taskColumns };
|
||||
} catch (e) {
|
||||
log.error('Failed to load task columns', { error: e, data: { eventId: event.id } });
|
||||
return { ...parentData, taskColumns: [] };
|
||||
}
|
||||
};
|
||||
397
src/routes/[orgSlug]/events/[eventSlug]/tasks/+page.svelte
Normal file
397
src/routes/[orgSlug]/events/[eventSlug]/tasks/+page.svelte
Normal file
@@ -0,0 +1,397 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onDestroy } from "svelte";
|
||||
import { untrack } from "svelte";
|
||||
import type { SupabaseClient, RealtimeChannel } from "@supabase/supabase-js";
|
||||
import type { Database, EventTask, EventTaskColumn } from "$lib/supabase/types";
|
||||
import {
|
||||
type TaskColumnWithTasks,
|
||||
type RealtimeChangePayload,
|
||||
fetchTaskColumns,
|
||||
createTaskColumn,
|
||||
createTask,
|
||||
deleteTask,
|
||||
deleteTaskColumn,
|
||||
renameTaskColumn,
|
||||
moveTask,
|
||||
subscribeToEventTasks,
|
||||
} from "$lib/api/event-tasks";
|
||||
import type { Event } from "$lib/api/events";
|
||||
import { KanbanBoard } from "$lib/components/kanban";
|
||||
import type { ColumnWithCards } from "$lib/api/kanban";
|
||||
import type { KanbanCard } from "$lib/supabase/types";
|
||||
import { Button, Modal, Input } from "$lib/components/ui";
|
||||
import { toasts } from "$lib/stores/toast.svelte";
|
||||
import { createLogger } from "$lib/utils/logger";
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
|
||||
const log = createLogger("page.event-tasks");
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
org: { id: string; name: string; slug: string };
|
||||
userRole: string;
|
||||
event: Event;
|
||||
taskColumns: TaskColumnWithTasks[];
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||
|
||||
let taskColumns = $state<TaskColumnWithTasks[]>(data.taskColumns);
|
||||
let realtimeChannel = $state<RealtimeChannel | null>(null);
|
||||
let optimisticMoveIds = new Set<string>();
|
||||
|
||||
// Add column modal
|
||||
let showAddColumnModal = $state(false);
|
||||
let newColumnName = $state("");
|
||||
|
||||
// Add card modal
|
||||
let showAddCardModal = $state(false);
|
||||
let targetColumnId = $state<string | null>(null);
|
||||
let newCardTitle = $state("");
|
||||
|
||||
const canEdit = $derived(
|
||||
["owner", "admin", "editor"].includes(data.userRole),
|
||||
);
|
||||
|
||||
// Map TaskColumnWithTasks to ColumnWithCards for KanbanBoard component
|
||||
const boardColumns = $derived<ColumnWithCards[]>(
|
||||
taskColumns.map((col) => ({
|
||||
...col,
|
||||
board_id: data.event.id,
|
||||
cards: col.cards as unknown as KanbanCard[],
|
||||
})),
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// Realtime
|
||||
// ============================================================
|
||||
|
||||
function handleColumnRealtime(payload: RealtimeChangePayload<EventTaskColumn>) {
|
||||
const { event } = payload;
|
||||
if (event === "INSERT") {
|
||||
const col: TaskColumnWithTasks = { ...payload.new, cards: [] };
|
||||
taskColumns = [...taskColumns, col].sort((a, b) => a.position - b.position);
|
||||
} else if (event === "UPDATE") {
|
||||
taskColumns = taskColumns.map((c) => c.id === payload.new.id ? { ...c, ...payload.new } : c).sort((a, b) => a.position - b.position);
|
||||
} else if (event === "DELETE") {
|
||||
const deletedId = payload.old.id;
|
||||
if (deletedId) {
|
||||
taskColumns = taskColumns.filter((c) => c.id !== deletedId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleTaskRealtime(payload: RealtimeChangePayload<EventTask>) {
|
||||
const { event } = payload;
|
||||
|
||||
if (event === "UPDATE" && optimisticMoveIds.size > 0) {
|
||||
const taskId = payload.new?.id ?? payload.old?.id;
|
||||
if (taskId && optimisticMoveIds.has(taskId)) return;
|
||||
}
|
||||
|
||||
if (event === "INSERT") {
|
||||
const task = payload.new;
|
||||
if (!task.column_id) return;
|
||||
taskColumns = taskColumns.map((col) =>
|
||||
col.id === task.column_id
|
||||
? { ...col, cards: [...col.cards, task].sort((a, b) => a.position - b.position) }
|
||||
: col,
|
||||
);
|
||||
} else if (event === "UPDATE") {
|
||||
const task = payload.new;
|
||||
taskColumns = taskColumns.map((col) => {
|
||||
if (col.id === task.column_id) {
|
||||
const exists = col.cards.some((c) => c.id === task.id);
|
||||
const updatedCards = exists
|
||||
? col.cards.map((c) => (c.id === task.id ? { ...c, ...task } : c))
|
||||
: [...col.cards, task];
|
||||
return { ...col, cards: updatedCards.sort((a, b) => a.position - b.position) };
|
||||
}
|
||||
return { ...col, cards: col.cards.filter((c) => c.id !== task.id) };
|
||||
});
|
||||
} else if (event === "DELETE") {
|
||||
const deletedId = payload.old.id;
|
||||
if (deletedId) {
|
||||
taskColumns = taskColumns.map((col) => ({
|
||||
...col,
|
||||
cards: col.cards.filter((c) => c.id !== deletedId),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let currentEventId = $derived(data.event.id);
|
||||
|
||||
$effect(() => {
|
||||
const eventId = currentEventId;
|
||||
if (!eventId) return;
|
||||
|
||||
const colIds = (untrack(() => taskColumns)).map((c) => c.id);
|
||||
const channel = subscribeToEventTasks(
|
||||
supabase,
|
||||
eventId,
|
||||
colIds,
|
||||
handleColumnRealtime,
|
||||
handleTaskRealtime,
|
||||
);
|
||||
realtimeChannel = channel;
|
||||
|
||||
return () => {
|
||||
if (channel) {
|
||||
supabase.removeChannel(channel);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (realtimeChannel) {
|
||||
supabase.removeChannel(realtimeChannel);
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Handlers
|
||||
// ============================================================
|
||||
|
||||
async function reloadColumns() {
|
||||
try {
|
||||
taskColumns = await fetchTaskColumns(supabase, data.event.id);
|
||||
} catch (e) {
|
||||
log.error("Failed to reload task columns", { error: e });
|
||||
}
|
||||
}
|
||||
|
||||
function handleCardMove(cardId: string, toColumnId: string, toPosition: number) {
|
||||
const fromColId = taskColumns.find((c) =>
|
||||
c.cards.some((card) => card.id === cardId),
|
||||
)?.id;
|
||||
if (!fromColId) return;
|
||||
|
||||
// Optimistic update
|
||||
let movedCard: EventTask | undefined;
|
||||
const newColumns = taskColumns.map((col) => {
|
||||
if (col.id === fromColId && fromColId !== toColumnId) {
|
||||
const filtered = col.cards.filter((c) => {
|
||||
if (c.id === cardId) { movedCard = { ...c, column_id: toColumnId }; return false; }
|
||||
return true;
|
||||
});
|
||||
return { ...col, cards: filtered };
|
||||
}
|
||||
if (col.id === toColumnId && fromColId !== toColumnId) {
|
||||
return { ...col, cards: [...col.cards] };
|
||||
}
|
||||
if (col.id === fromColId && fromColId === toColumnId) {
|
||||
const card = col.cards.find((c) => c.id === cardId);
|
||||
if (!card) return col;
|
||||
movedCard = { ...card };
|
||||
const without = col.cards.filter((c) => c.id !== cardId);
|
||||
return { ...col, cards: [...without.slice(0, toPosition), movedCard, ...without.slice(toPosition)] };
|
||||
}
|
||||
return col;
|
||||
});
|
||||
|
||||
const finalColumns = (fromColId !== toColumnId && movedCard)
|
||||
? newColumns.map((col) => {
|
||||
if (col.id === toColumnId) {
|
||||
const cards = [...col.cards];
|
||||
cards.splice(toPosition, 0, movedCard!);
|
||||
return { ...col, cards };
|
||||
}
|
||||
return col;
|
||||
})
|
||||
: newColumns;
|
||||
|
||||
const affectedIds = new Set<string>();
|
||||
affectedIds.add(cardId);
|
||||
const targetCol = finalColumns.find((c) => c.id === toColumnId);
|
||||
targetCol?.cards.forEach((c) => affectedIds.add(c.id));
|
||||
affectedIds.forEach((id) => optimisticMoveIds.add(id));
|
||||
|
||||
taskColumns = finalColumns;
|
||||
|
||||
moveTask(supabase, cardId, toColumnId, toPosition)
|
||||
.catch((err) => {
|
||||
log.error("Failed to persist task move", { error: err });
|
||||
reloadColumns();
|
||||
})
|
||||
.finally(() => {
|
||||
affectedIds.forEach((id) => optimisticMoveIds.delete(id));
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAddColumn() {
|
||||
const name = newColumnName.trim();
|
||||
if (!name) return;
|
||||
|
||||
try {
|
||||
await createTaskColumn(supabase, data.event.id, name);
|
||||
newColumnName = "";
|
||||
showAddColumnModal = false;
|
||||
await reloadColumns();
|
||||
} catch (e) {
|
||||
log.error("Failed to create column", { error: e });
|
||||
toasts.error("Failed to create column");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteColumn(columnId: string) {
|
||||
try {
|
||||
await deleteTaskColumn(supabase, columnId);
|
||||
taskColumns = taskColumns.filter((c) => c.id !== columnId);
|
||||
} catch (e) {
|
||||
log.error("Failed to delete column", { error: e });
|
||||
toasts.error("Failed to delete column");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRenameColumn(columnId: string, newName: string) {
|
||||
try {
|
||||
await renameTaskColumn(supabase, columnId, newName);
|
||||
taskColumns = taskColumns.map((c) =>
|
||||
c.id === columnId ? { ...c, name: newName } : c,
|
||||
);
|
||||
} catch (e) {
|
||||
log.error("Failed to rename column", { error: e });
|
||||
toasts.error("Failed to rename column");
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddCard(columnId: string) {
|
||||
targetColumnId = columnId;
|
||||
newCardTitle = "";
|
||||
showAddCardModal = true;
|
||||
}
|
||||
|
||||
async function submitAddCard() {
|
||||
const title = newCardTitle.trim();
|
||||
if (!title || !targetColumnId) return;
|
||||
|
||||
try {
|
||||
await createTask(supabase, data.event.id, targetColumnId, title, data.event.created_by ?? undefined);
|
||||
newCardTitle = "";
|
||||
showAddCardModal = false;
|
||||
await reloadColumns();
|
||||
} catch (e) {
|
||||
log.error("Failed to create task", { error: e });
|
||||
toasts.error("Failed to create task");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteCard(cardId: string) {
|
||||
try {
|
||||
await deleteTask(supabase, cardId);
|
||||
taskColumns = taskColumns.map((col) => ({
|
||||
...col,
|
||||
cards: col.cards.filter((c) => c.id !== cardId),
|
||||
}));
|
||||
} catch (e) {
|
||||
log.error("Failed to delete task", { error: e });
|
||||
toasts.error("Failed to delete task");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.events_mod_tasks()} | {data.event.name} | {data.org.name}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-light/5">
|
||||
<h2 class="text-h4 font-heading text-white">{m.events_mod_tasks()}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Board -->
|
||||
<div class="flex-1 overflow-hidden p-4">
|
||||
{#if boardColumns.length > 0}
|
||||
<KanbanBoard
|
||||
columns={boardColumns}
|
||||
onCardMove={handleCardMove}
|
||||
onAddCard={handleAddCard}
|
||||
onAddColumn={() => (showAddColumnModal = true)}
|
||||
onDeleteCard={handleDeleteCard}
|
||||
onDeleteColumn={handleDeleteColumn}
|
||||
onRenameColumn={handleRenameColumn}
|
||||
{canEdit}
|
||||
/>
|
||||
{:else}
|
||||
<div class="h-full flex items-center justify-center text-light/40">
|
||||
<div class="text-center">
|
||||
<span
|
||||
class="material-symbols-rounded mb-4 block text-[48px] leading-none"
|
||||
>task_alt</span
|
||||
>
|
||||
<p class="text-body-sm mb-4">No task columns yet</p>
|
||||
{#if canEdit}
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="add"
|
||||
onclick={() => (showAddColumnModal = true)}
|
||||
>
|
||||
Add column
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Column Modal -->
|
||||
<Modal
|
||||
isOpen={showAddColumnModal}
|
||||
title="Add Column"
|
||||
onClose={() => (showAddColumnModal = false)}
|
||||
>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddColumn();
|
||||
}}
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<Input
|
||||
label="Column name"
|
||||
placeholder="e.g. In Review"
|
||||
bind:value={newColumnName}
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={() => (showAddColumnModal = false)}>Cancel</Button
|
||||
>
|
||||
<Button type="submit" disabled={!newColumnName.trim()}>Create</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<!-- Add Card Modal -->
|
||||
<Modal
|
||||
isOpen={showAddCardModal}
|
||||
title="Add Task"
|
||||
onClose={() => (showAddCardModal = false)}
|
||||
>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitAddCard();
|
||||
}}
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<Input
|
||||
label="Task title"
|
||||
placeholder="What needs to be done?"
|
||||
bind:value={newCardTitle}
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onclick={() => (showAddCardModal = false)}>Cancel</Button
|
||||
>
|
||||
<Button type="submit" disabled={!newCardTitle.trim()}>Add Task</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ parent }) => {
|
||||
return await parent();
|
||||
};
|
||||
878
src/routes/[orgSlug]/events/[eventSlug]/team/+page.svelte
Normal file
878
src/routes/[orgSlug]/events/[eventSlug]/team/+page.svelte
Normal file
@@ -0,0 +1,878 @@
|
||||
<script lang="ts">
|
||||
import { Avatar, Button, Modal } 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 type {
|
||||
Event,
|
||||
EventMemberWithDetails,
|
||||
EventRole,
|
||||
EventDepartment,
|
||||
} from "$lib/api/events";
|
||||
import * as m from "$lib/paraglide/messages";
|
||||
|
||||
interface OrgMember {
|
||||
id: string;
|
||||
user_id: string;
|
||||
role: string;
|
||||
profiles: {
|
||||
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;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
org: { id: string; name: string; slug: string };
|
||||
userRole: string;
|
||||
members: OrgMember[];
|
||||
event: Event;
|
||||
eventMembers: EventMemberWithDetails[];
|
||||
eventRoles: EventRole[];
|
||||
eventDepartments: EventDepartment[];
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||
|
||||
const isEditor = $derived(
|
||||
["owner", "admin", "editor"].includes(data.userRole),
|
||||
);
|
||||
|
||||
// Local mutable state
|
||||
let teamMembers = $state<EventMemberWithDetails[]>(data.eventMembers);
|
||||
let roles = $state<EventRole[]>(data.eventRoles);
|
||||
let departments = $state<EventDepartment[]>(data.eventDepartments);
|
||||
|
||||
$effect(() => {
|
||||
teamMembers = data.eventMembers;
|
||||
roles = data.eventRoles;
|
||||
departments = data.eventDepartments;
|
||||
});
|
||||
|
||||
// View mode
|
||||
let viewMode = $state<"list" | "dept">("list");
|
||||
let filterDeptId = $state<string | null>(null);
|
||||
|
||||
// Filtered members
|
||||
const filteredMembers = $derived(() => {
|
||||
if (!filterDeptId) return teamMembers;
|
||||
if (filterDeptId === "__unassigned__")
|
||||
return teamMembers.filter((tm) => tm.departments.length === 0);
|
||||
return teamMembers.filter((tm) =>
|
||||
tm.departments.some((d) => d.id === filterDeptId),
|
||||
);
|
||||
});
|
||||
|
||||
// Group by department for dept view
|
||||
const membersByDept = $derived(() => {
|
||||
const groups: { dept: EventDepartment | null; members: EventMemberWithDetails[] }[] = [];
|
||||
for (const dept of departments) {
|
||||
const deptMembers = teamMembers.filter((tm) =>
|
||||
tm.departments.some((d) => d.id === dept.id),
|
||||
);
|
||||
if (deptMembers.length > 0) groups.push({ dept, members: deptMembers });
|
||||
}
|
||||
const unassigned = teamMembers.filter((tm) => tm.departments.length === 0);
|
||||
if (unassigned.length > 0) groups.push({ dept: null, members: unassigned });
|
||||
return groups;
|
||||
});
|
||||
|
||||
// Org members not yet on the team
|
||||
const availableMembers = $derived(
|
||||
data.members.filter(
|
||||
(om) => !teamMembers.some((tm) => tm.user_id === om.user_id),
|
||||
),
|
||||
);
|
||||
|
||||
// ---- Add member modal ----
|
||||
let showAddModal = $state(false);
|
||||
let selectedUserId = $state("");
|
||||
let selectedRoleId = $state("");
|
||||
let selectedDeptIds = $state<string[]>([]);
|
||||
let addNotes = $state("");
|
||||
let adding = $state(false);
|
||||
|
||||
// ---- Edit member modal ----
|
||||
let editingMember = $state<EventMemberWithDetails | null>(null);
|
||||
let editRoleId = $state("");
|
||||
let editDeptIds = $state<string[]>([]);
|
||||
let editNotes = $state("");
|
||||
let updatingMember = $state(false);
|
||||
|
||||
// ---- Remove confirmation ----
|
||||
let memberToRemove = $state<EventMemberWithDetails | null>(null);
|
||||
let removing = $state(false);
|
||||
|
||||
// ---- Department/Role management ----
|
||||
let showDeptModal = $state(false);
|
||||
let editingDept = $state<EventDepartment | null>(null);
|
||||
let deptName = $state("");
|
||||
let deptColor = $state("#00A3E0");
|
||||
let savingDept = $state(false);
|
||||
|
||||
let showRoleModal = $state(false);
|
||||
let editingRole = $state<EventRole | null>(null);
|
||||
let roleName = $state("");
|
||||
let roleColor = $state("#6366f1");
|
||||
let savingRole = $state(false);
|
||||
|
||||
const presetColors = [
|
||||
"#00A3E0", "#8B5CF6", "#EC4899", "#F59E0B",
|
||||
"#10B981", "#EF4444", "#6366F1", "#14B8A6",
|
||||
"#F97316", "#3B82F6",
|
||||
];
|
||||
|
||||
function getMemberName(member: EventMemberWithDetails): string {
|
||||
return member.profile?.full_name || member.profile?.email || "Unknown";
|
||||
}
|
||||
|
||||
// ---- CRUD: Add member ----
|
||||
async function handleAdd() {
|
||||
if (!selectedUserId) return;
|
||||
adding = true;
|
||||
try {
|
||||
const { data: inserted, error } = await supabase
|
||||
.from("event_members")
|
||||
.upsert(
|
||||
{
|
||||
event_id: data.event.id,
|
||||
user_id: selectedUserId,
|
||||
role: "member",
|
||||
role_id: selectedRoleId || null,
|
||||
notes: addNotes.trim() || null,
|
||||
},
|
||||
{ onConflict: "event_id,user_id" },
|
||||
)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
|
||||
// Assign departments
|
||||
for (const deptId of selectedDeptIds) {
|
||||
await supabase
|
||||
.from("event_member_departments")
|
||||
.upsert(
|
||||
{ event_member_id: inserted.id, department_id: deptId },
|
||||
{ onConflict: "event_member_id,department_id" },
|
||||
);
|
||||
}
|
||||
|
||||
const orgMember = data.members.find((om) => om.user_id === selectedUserId);
|
||||
const profile = orgMember?.profiles ?? undefined;
|
||||
const assignedDepts = departments.filter((d) => selectedDeptIds.includes(d.id));
|
||||
const assignedRole = roles.find((r) => r.id === selectedRoleId);
|
||||
|
||||
teamMembers = [
|
||||
...teamMembers,
|
||||
{
|
||||
...inserted,
|
||||
profile,
|
||||
event_role: assignedRole,
|
||||
departments: assignedDepts,
|
||||
},
|
||||
];
|
||||
|
||||
const name = profile?.full_name || profile?.email || "Member";
|
||||
toasts.success(m.team_added({ name }));
|
||||
showAddModal = false;
|
||||
selectedUserId = "";
|
||||
selectedRoleId = "";
|
||||
selectedDeptIds = [];
|
||||
addNotes = "";
|
||||
} catch (e: any) {
|
||||
toasts.error(e.message || "Failed to add member");
|
||||
} finally {
|
||||
adding = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CRUD: Edit member ----
|
||||
function openEditMember(member: EventMemberWithDetails) {
|
||||
editingMember = member;
|
||||
editRoleId = member.role_id ?? "";
|
||||
editDeptIds = member.departments.map((d) => d.id);
|
||||
editNotes = member.notes ?? "";
|
||||
}
|
||||
|
||||
async function handleEditSave() {
|
||||
if (!editingMember) return;
|
||||
updatingMember = true;
|
||||
try {
|
||||
// Update member record
|
||||
const { error } = await supabase
|
||||
.from("event_members")
|
||||
.update({
|
||||
role_id: editRoleId || null,
|
||||
notes: editNotes.trim() || null,
|
||||
})
|
||||
.eq("id", editingMember.id);
|
||||
if (error) throw error;
|
||||
|
||||
// Sync departments: remove old, add new
|
||||
const oldDeptIds = editingMember.departments.map((d) => d.id);
|
||||
const toRemove = oldDeptIds.filter((id) => !editDeptIds.includes(id));
|
||||
const toAdd = editDeptIds.filter((id) => !oldDeptIds.includes(id));
|
||||
|
||||
for (const deptId of toRemove) {
|
||||
await supabase
|
||||
.from("event_member_departments")
|
||||
.delete()
|
||||
.eq("event_member_id", editingMember.id)
|
||||
.eq("department_id", deptId);
|
||||
}
|
||||
for (const deptId of toAdd) {
|
||||
await supabase
|
||||
.from("event_member_departments")
|
||||
.upsert(
|
||||
{ event_member_id: editingMember.id, department_id: deptId },
|
||||
{ onConflict: "event_member_id,department_id" },
|
||||
);
|
||||
}
|
||||
|
||||
// Update local state
|
||||
const updatedRole = roles.find((r) => r.id === editRoleId);
|
||||
const updatedDepts = departments.filter((d) => editDeptIds.includes(d.id));
|
||||
teamMembers = teamMembers.map((tm) =>
|
||||
tm.id === editingMember!.id
|
||||
? {
|
||||
...tm,
|
||||
role_id: editRoleId || null,
|
||||
event_role: updatedRole,
|
||||
departments: updatedDepts,
|
||||
notes: editNotes.trim() || null,
|
||||
}
|
||||
: tm,
|
||||
);
|
||||
|
||||
toasts.success(m.team_updated());
|
||||
editingMember = null;
|
||||
} catch (e: any) {
|
||||
toasts.error(e.message || "Failed to update member");
|
||||
} finally {
|
||||
updatingMember = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CRUD: Remove member ----
|
||||
async function handleRemove() {
|
||||
if (!memberToRemove) return;
|
||||
removing = true;
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from("event_members")
|
||||
.delete()
|
||||
.eq("id", memberToRemove.id);
|
||||
if (error) throw error;
|
||||
|
||||
const name = getMemberName(memberToRemove);
|
||||
teamMembers = teamMembers.filter((tm) => tm.id !== memberToRemove!.id);
|
||||
toasts.success(m.team_removed({ name }));
|
||||
memberToRemove = null;
|
||||
} catch (e: any) {
|
||||
toasts.error(e.message || "Failed to remove member");
|
||||
} finally {
|
||||
removing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CRUD: Departments ----
|
||||
function openDeptModal(dept?: EventDepartment) {
|
||||
editingDept = dept ?? null;
|
||||
deptName = dept?.name ?? "";
|
||||
deptColor = dept?.color ?? "#00A3E0";
|
||||
showDeptModal = true;
|
||||
}
|
||||
|
||||
async function handleSaveDept() {
|
||||
if (!deptName.trim()) return;
|
||||
savingDept = true;
|
||||
try {
|
||||
if (editingDept) {
|
||||
const { data: updated, error } = await supabase
|
||||
.from("event_departments")
|
||||
.update({ name: deptName.trim(), color: deptColor })
|
||||
.eq("id", editingDept.id)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
departments = departments.map((d) =>
|
||||
d.id === editingDept!.id ? updated : d,
|
||||
);
|
||||
toasts.success(m.team_dept_updated());
|
||||
} else {
|
||||
const { data: created, error } = await supabase
|
||||
.from("event_departments")
|
||||
.insert({
|
||||
event_id: data.event.id,
|
||||
name: deptName.trim(),
|
||||
color: deptColor,
|
||||
sort_order: departments.length,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
departments = [...departments, created];
|
||||
toasts.success(m.team_dept_created());
|
||||
}
|
||||
showDeptModal = false;
|
||||
} catch (e: any) {
|
||||
toasts.error(e.message || "Failed to save department");
|
||||
} finally {
|
||||
savingDept = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteDept(dept: EventDepartment) {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from("event_departments")
|
||||
.delete()
|
||||
.eq("id", dept.id);
|
||||
if (error) throw error;
|
||||
departments = departments.filter((d) => d.id !== dept.id);
|
||||
// Remove from members' local state
|
||||
teamMembers = teamMembers.map((tm) => ({
|
||||
...tm,
|
||||
departments: tm.departments.filter((d) => d.id !== dept.id),
|
||||
}));
|
||||
toasts.success(m.team_dept_deleted());
|
||||
} catch (e: any) {
|
||||
toasts.error(e.message || "Failed to delete department");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- CRUD: Roles ----
|
||||
function openRoleModal(role?: EventRole) {
|
||||
editingRole = role ?? null;
|
||||
roleName = role?.name ?? "";
|
||||
roleColor = role?.color ?? "#6366f1";
|
||||
showRoleModal = true;
|
||||
}
|
||||
|
||||
async function handleSaveRole() {
|
||||
if (!roleName.trim()) return;
|
||||
savingRole = true;
|
||||
try {
|
||||
if (editingRole) {
|
||||
const { data: updated, error } = await supabase
|
||||
.from("event_roles")
|
||||
.update({ name: roleName.trim(), color: roleColor })
|
||||
.eq("id", editingRole.id)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
roles = roles.map((r) =>
|
||||
r.id === editingRole!.id ? updated : r,
|
||||
);
|
||||
toasts.success(m.team_role_updated());
|
||||
} else {
|
||||
const { data: created, error } = await supabase
|
||||
.from("event_roles")
|
||||
.insert({
|
||||
event_id: data.event.id,
|
||||
name: roleName.trim(),
|
||||
color: roleColor,
|
||||
sort_order: roles.length,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
roles = [...roles, created];
|
||||
toasts.success(m.team_role_created());
|
||||
}
|
||||
showRoleModal = false;
|
||||
} catch (e: any) {
|
||||
toasts.error(e.message || "Failed to save role");
|
||||
} finally {
|
||||
savingRole = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteRole(role: EventRole) {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from("event_roles")
|
||||
.delete()
|
||||
.eq("id", role.id);
|
||||
if (error) throw error;
|
||||
roles = roles.filter((r) => r.id !== role.id);
|
||||
// Clear role from members' local state
|
||||
teamMembers = teamMembers.map((tm) =>
|
||||
tm.role_id === role.id
|
||||
? { ...tm, role_id: null, event_role: undefined }
|
||||
: tm,
|
||||
);
|
||||
toasts.success(m.team_role_deleted());
|
||||
} catch (e: any) {
|
||||
toasts.error(e.message || "Failed to delete role");
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDeptSelection(arr: string[], id: string): string[] {
|
||||
return arr.includes(id) ? arr.filter((x) => x !== id) : [...arr, id];
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{m.events_mod_team()} | {data.event.name} | {data.org.name}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col h-full overflow-auto">
|
||||
<!-- Header toolbar -->
|
||||
<div class="flex items-center justify-between px-6 py-3 border-b border-light/5 shrink-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<div>
|
||||
<h2 class="font-heading text-body text-white">{m.team_title()}</h2>
|
||||
<p class="text-[11px] text-light/40">{m.team_member_count({ count: String(teamMembers.length) })}</p>
|
||||
</div>
|
||||
<!-- View toggle -->
|
||||
<div class="flex items-center bg-dark/50 rounded-lg p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 px-2.5 py-1 rounded-md text-[11px] transition-colors {viewMode === 'list' ? 'bg-primary text-background' : 'text-light/50 hover:text-white'}"
|
||||
onclick={() => (viewMode = "list")}
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 14px;">list</span>
|
||||
{m.team_view_list()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 px-2.5 py-1 rounded-md text-[11px] transition-colors {viewMode === 'dept' ? 'bg-primary text-background' : 'text-light/50 hover:text-white'}"
|
||||
onclick={() => (viewMode = "dept")}
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 14px;">account_tree</span>
|
||||
{m.team_view_by_dept()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if isEditor}
|
||||
<Button size="sm" variant="secondary" icon="domain" onclick={() => openDeptModal()}>
|
||||
{m.team_add_department()}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" icon="badge" onclick={() => openRoleModal()}>
|
||||
{m.team_add_role()}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
icon="person_add"
|
||||
onclick={() => (showAddModal = true)}
|
||||
disabled={availableMembers.length === 0}
|
||||
>
|
||||
{m.team_add_member()}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- Sidebar: Departments & Roles -->
|
||||
<aside class="w-52 shrink-0 border-r border-light/5 overflow-auto p-3 flex flex-col gap-4">
|
||||
<!-- Departments -->
|
||||
<div>
|
||||
<p class="text-[10px] text-light/30 uppercase tracking-wider font-body mb-2 px-1">{m.team_departments()}</p>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-[12px] transition-colors {filterDeptId === null ? 'bg-primary text-background' : 'text-light/60 hover:text-white hover:bg-dark/50'}"
|
||||
onclick={() => (filterDeptId = null)}
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 16px;">apps</span>
|
||||
{m.team_all()}
|
||||
<span class="ml-auto text-[10px] opacity-60">{teamMembers.length}</span>
|
||||
</button>
|
||||
{#each departments as dept}
|
||||
<div class="group flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-[12px] transition-colors {filterDeptId === dept.id ? 'bg-primary text-background' : 'text-light/60 hover:text-white hover:bg-dark/50'}"
|
||||
onclick={() => (filterDeptId = dept.id)}
|
||||
>
|
||||
<div class="w-2 h-2 rounded-full shrink-0" style="background-color: {dept.color}"></div>
|
||||
<span class="truncate">{dept.name}</span>
|
||||
<span class="ml-auto text-[10px] opacity-60">{teamMembers.filter((tm) => tm.departments.some((d) => d.id === dept.id)).length}</span>
|
||||
</button>
|
||||
{#if isEditor}
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 text-light/20 hover:text-white opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onclick={() => openDeptModal(dept)}
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 14px;">edit</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-[12px] transition-colors {filterDeptId === '__unassigned__' ? 'bg-primary text-background' : 'text-light/40 hover:text-white hover:bg-dark/50'}"
|
||||
onclick={() => (filterDeptId = "__unassigned__")}
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 16px;">help_outline</span>
|
||||
{m.team_no_department()}
|
||||
<span class="ml-auto text-[10px] opacity-60">{teamMembers.filter((tm) => tm.departments.length === 0).length}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Roles legend -->
|
||||
<div>
|
||||
<p class="text-[10px] text-light/30 uppercase tracking-wider font-body mb-2 px-1">{m.team_roles()}</p>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
{#each roles as role}
|
||||
<div class="group flex items-center">
|
||||
<div class="flex-1 flex items-center gap-2 px-2.5 py-1.5 text-[12px] text-light/50">
|
||||
<div class="w-2 h-2 rounded-full shrink-0" style="background-color: {role.color}"></div>
|
||||
<span class="truncate">{role.name}</span>
|
||||
{#if role.is_default}
|
||||
<span class="text-[9px] text-primary bg-primary/10 px-1 rounded">default</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isEditor}
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 text-light/20 hover:text-white opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onclick={() => openRoleModal(role)}
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 14px;">edit</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="flex-1 overflow-auto p-6">
|
||||
{#if teamMembers.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: 56px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;">badge</span>
|
||||
<p class="text-body-sm text-light/30 text-center max-w-sm">{m.team_empty()}</p>
|
||||
{#if isEditor && availableMembers.length > 0}
|
||||
<div class="mt-4">
|
||||
<Button size="sm" icon="person_add" onclick={() => (showAddModal = true)}>{m.team_add_member()}</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if viewMode === "dept"}
|
||||
<!-- Department grouped view -->
|
||||
<div class="flex flex-col gap-6">
|
||||
{#each membersByDept() as group}
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
{#if group.dept}
|
||||
<div class="w-2.5 h-2.5 rounded-full" style="background-color: {group.dept.color}"></div>
|
||||
<h3 class="text-body-sm font-heading text-white">{group.dept.name}</h3>
|
||||
{:else}
|
||||
<span class="material-symbols-rounded text-light/30" style="font-size: 16px;">help_outline</span>
|
||||
<h3 class="text-body-sm font-heading text-light/40">{m.team_no_department()}</h3>
|
||||
{/if}
|
||||
<span class="text-[10px] text-light/30">{group.members.length}</span>
|
||||
</div>
|
||||
<div class="bg-dark/30 border border-light/5 rounded-xl overflow-hidden">
|
||||
<div class="divide-y divide-light/5">
|
||||
{#each group.members as member}
|
||||
{@render memberRow(member)}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Flat list view -->
|
||||
<div class="bg-dark/30 border border-light/5 rounded-xl overflow-hidden">
|
||||
<div class="divide-y divide-light/5">
|
||||
{#each filteredMembers() as member}
|
||||
{@render memberRow(member)}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet memberRow(member: EventMemberWithDetails)}
|
||||
<div class="flex items-center justify-between px-4 py-3 hover:bg-light/5 transition-colors">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Avatar
|
||||
name={member.profile?.full_name || member.profile?.email || "?"}
|
||||
src={member.profile?.avatar_url}
|
||||
size="sm"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<p class="text-body-sm text-white truncate">
|
||||
{member.profile?.full_name || member.profile?.email || "Unknown"}
|
||||
</p>
|
||||
<div class="flex items-center gap-1.5 flex-wrap mt-0.5">
|
||||
{#if member.event_role}
|
||||
<span
|
||||
class="px-1.5 py-0.5 text-[10px] rounded-md font-body"
|
||||
style="background-color: {member.event_role.color}20; color: {member.event_role.color}"
|
||||
>{member.event_role.name}</span>
|
||||
{/if}
|
||||
{#each member.departments as dept}
|
||||
<span class="flex items-center gap-0.5 text-[10px] text-light/40">
|
||||
<span class="w-1.5 h-1.5 rounded-full inline-block" style="background-color: {dept.color}"></span>
|
||||
{dept.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-0.5 text-[10px] text-light/30">
|
||||
{#if member.profile?.phone}
|
||||
<span class="flex items-center gap-0.5"><span class="material-symbols-rounded" style="font-size: 12px;">phone</span>{member.profile.phone}</span>
|
||||
{/if}
|
||||
{#if member.profile?.discord_handle}
|
||||
<span class="flex items-center gap-0.5"><span class="material-symbols-rounded" style="font-size: 12px;">chat</span>{member.profile.discord_handle}</span>
|
||||
{/if}
|
||||
{#if member.profile?.shirt_size}
|
||||
<span>T: {member.profile.shirt_size}</span>
|
||||
{/if}
|
||||
{#if member.profile?.hoodie_size}
|
||||
<span>H: {member.profile.hoodie_size}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if isEditor}
|
||||
<div class="flex items-center gap-1 shrink-0 ml-2">
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
|
||||
onclick={() => openEditMember(member)}
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 text-light/40 hover:text-error hover:bg-error/10 rounded-lg transition-colors"
|
||||
onclick={() => (memberToRemove = member)}
|
||||
title={m.team_remove_btn()}
|
||||
>
|
||||
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">person_remove</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- Add Member Modal -->
|
||||
<Modal isOpen={showAddModal} onClose={() => (showAddModal = false)} title={m.team_add_member()}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Select org member -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="add-member-select" class="text-body-sm text-light/60 font-body">{m.team_select_member()}</label>
|
||||
<select id="add-member-select" bind:value={selectedUserId} 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="" disabled>{m.team_select_member()}</option>
|
||||
{#each availableMembers as om}
|
||||
<option value={om.user_id}>{om.profiles?.full_name || om.profiles?.email || om.user_id}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Select role -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="add-role-select" class="text-body-sm text-light/60 font-body">{m.team_select_role()}</label>
|
||||
<select id="add-role-select" bind:value={selectedRoleId} 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="">—</option>
|
||||
{#each roles as role}
|
||||
<option value={role.id}>{role.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Select departments (chips) -->
|
||||
{#if departments.length > 0}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-body-sm text-light/60 font-body">{m.team_assign_dept()}</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each departments as dept}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] border transition-colors {selectedDeptIds.includes(dept.id) ? 'border-primary bg-primary/10 text-white' : 'border-light/10 text-light/50 hover:border-light/20'}"
|
||||
onclick={() => (selectedDeptIds = toggleDeptSelection(selectedDeptIds, dept.id))}
|
||||
>
|
||||
<div class="w-2 h-2 rounded-full" style="background-color: {dept.color}"></div>
|
||||
{dept.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="add-notes" class="text-body-sm text-light/60 font-body">{m.team_notes()}</label>
|
||||
<textarea
|
||||
id="add-notes"
|
||||
bind:value={addNotes}
|
||||
placeholder={m.team_notes_placeholder()}
|
||||
rows="2"
|
||||
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<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={() => { showAddModal = false; selectedUserId = ""; selectedRoleId = ""; selectedDeptIds = []; addNotes = ""; }}>
|
||||
{m.btn_cancel()}
|
||||
</button>
|
||||
<button type="button" disabled={!selectedUserId || adding} 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" onclick={handleAdd}>
|
||||
{adding ? "..." : m.team_add_member()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Edit Member Modal -->
|
||||
<Modal isOpen={!!editingMember} onClose={() => (editingMember = null)} title="Edit Member">
|
||||
{#if editingMember}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3 p-3 bg-dark/30 rounded-xl">
|
||||
<Avatar name={editingMember.profile?.full_name || editingMember.profile?.email || "?"} src={editingMember.profile?.avatar_url} size="sm" />
|
||||
<div>
|
||||
<p class="text-body-sm text-white">{editingMember.profile?.full_name || editingMember.profile?.email || "Unknown"}</p>
|
||||
<p class="text-[11px] text-light/40">{editingMember.profile?.email || ""}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Role -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="edit-role-select" class="text-body-sm text-light/60 font-body">{m.team_select_role()}</label>
|
||||
<select id="edit-role-select" bind:value={editRoleId} 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="">—</option>
|
||||
{#each roles as role}
|
||||
<option value={role.id}>{role.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Departments -->
|
||||
{#if departments.length > 0}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-body-sm text-light/60 font-body">{m.team_assign_dept()}</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each departments as dept}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] border transition-colors {editDeptIds.includes(dept.id) ? 'border-primary bg-primary/10 text-white' : 'border-light/10 text-light/50 hover:border-light/20'}"
|
||||
onclick={() => (editDeptIds = toggleDeptSelection(editDeptIds, dept.id))}
|
||||
>
|
||||
<div class="w-2 h-2 rounded-full" style="background-color: {dept.color}"></div>
|
||||
{dept.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="edit-notes" class="text-body-sm text-light/60 font-body">{m.team_notes()}</label>
|
||||
<textarea id="edit-notes" bind:value={editNotes} placeholder={m.team_notes_placeholder()} rows="2" class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary resize-none"></textarea>
|
||||
</div>
|
||||
|
||||
<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={() => (editingMember = null)}>{m.btn_cancel()}</button>
|
||||
<button type="button" disabled={updatingMember} 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" onclick={handleEditSave}>
|
||||
{updatingMember ? "..." : m.btn_save()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
<!-- Department Modal -->
|
||||
<Modal isOpen={showDeptModal} onClose={() => (showDeptModal = false)} title={editingDept ? m.team_edit_department() : m.team_add_department()}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="dept-name" class="text-body-sm text-light/60 font-body">{m.team_dept_name()}</label>
|
||||
<input id="dept-name" type="text" bind:value={deptName} placeholder={m.team_dept_name()} class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-body-sm text-light/60 font-body">Color</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{#each presetColors as c}
|
||||
<button type="button" class="w-6 h-6 rounded-full border-2 transition-all {deptColor === c ? 'border-white scale-110' : 'border-transparent hover:border-light/30'}" style="background-color: {c}" onclick={() => (deptColor = c)}></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if editingDept}
|
||||
<button type="button" class="text-[11px] text-error hover:underline self-start" onclick={() => { handleDeleteDept(editingDept!); showDeptModal = false; }}>
|
||||
{m.team_dept_delete_confirm({ name: editingDept.name })}
|
||||
</button>
|
||||
{/if}
|
||||
<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={() => (showDeptModal = false)}>{m.btn_cancel()}</button>
|
||||
<button type="button" disabled={!deptName.trim() || savingDept} 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" onclick={handleSaveDept}>
|
||||
{savingDept ? "..." : m.btn_save()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Role Modal -->
|
||||
<Modal isOpen={showRoleModal} onClose={() => (showRoleModal = false)} title={editingRole ? m.team_edit_role() : m.team_add_role()}>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="role-name" class="text-body-sm text-light/60 font-body">{m.team_role_name()}</label>
|
||||
<input id="role-name" type="text" bind:value={roleName} placeholder={m.team_role_name()} class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-body-sm text-light/60 font-body">Color</span>
|
||||
<div class="flex items-center gap-2">
|
||||
{#each presetColors as c}
|
||||
<button type="button" class="w-6 h-6 rounded-full border-2 transition-all {roleColor === c ? 'border-white scale-110' : 'border-transparent hover:border-light/30'}" style="background-color: {c}" onclick={() => (roleColor = c)}></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if editingRole}
|
||||
<button type="button" class="text-[11px] text-error hover:underline self-start" onclick={() => { handleDeleteRole(editingRole!); showRoleModal = false; }}>
|
||||
{m.team_role_delete_confirm({ name: editingRole.name })}
|
||||
</button>
|
||||
{/if}
|
||||
<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={() => (showRoleModal = false)}>{m.btn_cancel()}</button>
|
||||
<button type="button" disabled={!roleName.trim() || savingRole} 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" onclick={handleSaveRole}>
|
||||
{savingRole ? "..." : m.btn_save()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Remove Confirmation -->
|
||||
{#if memberToRemove}
|
||||
<!-- 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" && (memberToRemove = null)}
|
||||
onclick={(e) => e.target === e.currentTarget && (memberToRemove = null)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={m.team_remove_btn()}
|
||||
>
|
||||
<div class="bg-night rounded-2xl w-full max-w-sm shadow-2xl border border-light/10 p-6">
|
||||
<h2 class="text-h3 font-heading text-white mb-2">{m.team_remove_btn()}</h2>
|
||||
<p class="text-body-sm text-light/50 mb-6">{m.team_remove_confirm({ name: getMemberName(memberToRemove) })}</p>
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<button type="button" class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors" onclick={() => (memberToRemove = null)}>{m.btn_cancel()}</button>
|
||||
<button type="button" class="px-4 py-2 bg-error text-white rounded-xl text-body-sm hover:bg-error/80 transition-colors disabled:opacity-50" disabled={removing} onclick={handleRemove}>
|
||||
{removing ? "..." : m.team_remove_btn()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
31
src/routes/[orgSlug]/kanban/+layout.svelte
Normal file
31
src/routes/[orgSlug]/kanban/+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("/kanban") ?? false,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full overflow-hidden">
|
||||
<PageHeader title={m.kanban_title()} icon="view_kanban" iconColor="text-purple-400" />
|
||||
|
||||
{#if isNavigatingHere}
|
||||
<ContentSkeleton variant="kanban" />
|
||||
{:else}
|
||||
<div class="flex-1 overflow-hidden">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -73,6 +73,9 @@
|
||||
let cardModalMode = $state<"edit" | "create">("edit");
|
||||
let realtimeChannel = $state<RealtimeChannel | null>(null);
|
||||
|
||||
// Track card IDs with in-flight optimistic moves to suppress realtime echoes
|
||||
let optimisticMoveIds = new Set<string>();
|
||||
|
||||
async function loadBoard(boardId: string) {
|
||||
selectedBoard = await fetchBoardWithColumns(supabase, boardId);
|
||||
}
|
||||
@@ -118,6 +121,12 @@
|
||||
if (!selectedBoard) return;
|
||||
const { event } = payload;
|
||||
|
||||
// Skip realtime updates for cards that are part of an in-flight optimistic move
|
||||
if (event === "UPDATE" && optimisticMoveIds.size > 0) {
|
||||
const cardId = payload.new?.id ?? payload.old?.id;
|
||||
if (cardId && optimisticMoveIds.has(cardId)) return;
|
||||
}
|
||||
|
||||
if (event === "INSERT") {
|
||||
const card = payload.new;
|
||||
if (!card.column_id) return;
|
||||
@@ -412,37 +421,73 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCardMove(
|
||||
function handleCardMove(
|
||||
cardId: string,
|
||||
toColumnId: string,
|
||||
toPosition: number,
|
||||
) {
|
||||
if (!selectedBoard) return;
|
||||
|
||||
// Optimistic UI update - move card immediately
|
||||
const fromColumn = selectedBoard.columns.find((c) =>
|
||||
const fromColId = selectedBoard.columns.find((c) =>
|
||||
c.cards.some((card) => card.id === cardId),
|
||||
);
|
||||
const toColumn = selectedBoard.columns.find((c) => c.id === toColumnId);
|
||||
)?.id;
|
||||
if (!fromColId) return;
|
||||
|
||||
if (!fromColumn || !toColumn) return;
|
||||
|
||||
const cardIndex = fromColumn.cards.findIndex((c) => c.id === cardId);
|
||||
if (cardIndex === -1) return;
|
||||
|
||||
const [movedCard] = fromColumn.cards.splice(cardIndex, 1);
|
||||
movedCard.column_id = toColumnId;
|
||||
toColumn.cards.splice(toPosition, 0, movedCard);
|
||||
|
||||
// Trigger reactivity
|
||||
selectedBoard = { ...selectedBoard };
|
||||
|
||||
// Persist to database in background
|
||||
moveCard(supabase, cardId, toColumnId, toPosition).catch((err) => {
|
||||
log.error("Failed to persist card move", { error: err });
|
||||
// Reload to sync state on error
|
||||
loadBoard(selectedBoard!.id);
|
||||
// Build fully immutable new columns so Svelte sees fresh references instantly
|
||||
let movedCard: KanbanCard | undefined;
|
||||
const newColumns = selectedBoard.columns.map((col) => {
|
||||
if (col.id === fromColId && fromColId !== toColumnId) {
|
||||
const filtered = col.cards.filter((c) => {
|
||||
if (c.id === cardId) { movedCard = { ...c, column_id: toColumnId }; return false; }
|
||||
return true;
|
||||
});
|
||||
return { ...col, cards: filtered };
|
||||
}
|
||||
if (col.id === toColumnId && fromColId !== toColumnId) {
|
||||
const cards = [...col.cards];
|
||||
return { ...col, cards, _insertAt: toPosition } as typeof col & { _insertAt: number };
|
||||
}
|
||||
if (col.id === fromColId && fromColId === toColumnId) {
|
||||
const card = col.cards.find((c) => c.id === cardId);
|
||||
if (!card) return col;
|
||||
movedCard = { ...card };
|
||||
const without = col.cards.filter((c) => c.id !== cardId);
|
||||
const cards = [...without.slice(0, toPosition), movedCard, ...without.slice(toPosition)];
|
||||
return { ...col, cards };
|
||||
}
|
||||
return col;
|
||||
});
|
||||
|
||||
const finalColumns = (fromColId !== toColumnId && movedCard)
|
||||
? newColumns.map((col) => {
|
||||
if (col.id === toColumnId) {
|
||||
const cards = [...col.cards];
|
||||
cards.splice(toPosition, 0, movedCard!);
|
||||
return { ...col, cards };
|
||||
}
|
||||
return col;
|
||||
})
|
||||
: newColumns;
|
||||
|
||||
const affectedIds = new Set<string>();
|
||||
affectedIds.add(cardId);
|
||||
const targetCol = finalColumns.find((c) => c.id === toColumnId);
|
||||
targetCol?.cards.forEach((c) => affectedIds.add(c.id));
|
||||
affectedIds.forEach((id) => optimisticMoveIds.add(id));
|
||||
|
||||
// Single synchronous assignment — Svelte picks this up instantly
|
||||
selectedBoard = { ...selectedBoard, columns: finalColumns };
|
||||
|
||||
// Persist to database in background (fire-and-forget, NOT awaited)
|
||||
moveCard(supabase, cardId, toColumnId, toPosition)
|
||||
.catch((err) => {
|
||||
log.error("Failed to persist card move", { error: err });
|
||||
loadBoard(selectedBoard!.id);
|
||||
})
|
||||
.finally(() => {
|
||||
affectedIds.forEach((id) => optimisticMoveIds.delete(id));
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function handleCardClick(card: KanbanCard) {
|
||||
@@ -494,13 +539,13 @@
|
||||
>
|
||||
</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">
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Board toolbar -->
|
||||
<div class="flex items-center gap-2 px-6 py-3 border-b border-light/5 shrink-0">
|
||||
{#if isRenamingBoard && selectedBoard}
|
||||
<input
|
||||
type="text"
|
||||
class="flex-1 bg-dark border border-primary rounded-lg px-3 py-1 text-white font-heading text-h1 focus:outline-none"
|
||||
class="flex-1 bg-dark border border-primary rounded-lg px-3 py-1 text-white font-heading text-body focus:outline-none"
|
||||
bind:value={renameBoardValue}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter") confirmBoardRename();
|
||||
@@ -509,12 +554,30 @@
|
||||
onblur={confirmBoardRename}
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<h1 class="flex-1 font-heading text-h1 text-white">
|
||||
{selectedBoard ? selectedBoard.name : m.kanban_title()}
|
||||
</h1>
|
||||
{:else if selectedBoard}
|
||||
<h2 class="font-heading text-body text-white">{selectedBoard.name}</h2>
|
||||
{/if}
|
||||
<Button size="md" onclick={() => (showCreateBoardModal = true)}
|
||||
|
||||
{#if boards.length > 1}
|
||||
<div class="flex gap-1 ml-2">
|
||||
{#each boards as board}
|
||||
<button
|
||||
type="button"
|
||||
class="px-3 py-1 rounded-lg text-[12px] font-body whitespace-nowrap transition-colors {selectedBoard?.id ===
|
||||
board.id
|
||||
? 'bg-primary text-background'
|
||||
: 'text-light/50 hover:text-white hover:bg-dark/50'}"
|
||||
onclick={() => loadBoard(board.id)}
|
||||
>
|
||||
{board.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<Button size="sm" onclick={() => (showCreateBoardModal = true)}
|
||||
>{m.btn_new()}</Button
|
||||
>
|
||||
<ContextMenu
|
||||
@@ -539,28 +602,10 @@
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<!-- Board selector (compact) -->
|
||||
{#if boards.length > 1}
|
||||
<div class="flex gap-2 overflow-x-auto pb-2">
|
||||
{#each boards as board}
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-[32px] text-sm font-body whitespace-nowrap transition-colors {selectedBoard?.id ===
|
||||
board.id
|
||||
? 'bg-primary text-night'
|
||||
: 'bg-dark text-light hover:bg-dark/80'}"
|
||||
onclick={() => loadBoard(board.id)}
|
||||
>
|
||||
{board.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Kanban Board -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div class="flex-1 overflow-hidden p-4">
|
||||
{#if selectedBoard}
|
||||
<KanbanBoard
|
||||
columns={selectedBoard.columns}
|
||||
|
||||
35
src/routes/[orgSlug]/settings/+layout.svelte
Normal file
35
src/routes/[orgSlug]/settings/+layout.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<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("/settings") ?? false,
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full overflow-hidden">
|
||||
<PageHeader
|
||||
title={m.settings_title()}
|
||||
icon="settings"
|
||||
iconColor="text-light/50"
|
||||
/>
|
||||
|
||||
{#if isNavigatingHere}
|
||||
<ContentSkeleton variant="settings" />
|
||||
{:else}
|
||||
<div class="flex-1 overflow-auto">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -274,32 +274,23 @@
|
||||
<title>Settings - {data.org.name} | Root</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col h-full p-4 lg:p-5 gap-4 overflow-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<header class="flex flex-wrap items-center gap-2 p-1 rounded-[32px]">
|
||||
<Avatar name="Settings" size="md" />
|
||||
<h1 class="flex-1 font-heading text-h1 text-white">
|
||||
{m.settings_title()}
|
||||
</h1>
|
||||
<IconButton title="More options">
|
||||
<Icon name="more_horiz" size={24} />
|
||||
</IconButton>
|
||||
</header>
|
||||
|
||||
<!-- Pill Tab Navigation -->
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Tab Navigation -->
|
||||
<div class="flex flex-wrap gap-1 px-6 py-3 border-b border-light/5 shrink-0">
|
||||
{#each tabs as tab}
|
||||
<Button
|
||||
variant={activeTab === tab.id ? "primary" : "secondary"}
|
||||
size="md"
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-body-sm font-body transition-colors {activeTab === tab.id
|
||||
? 'bg-primary text-background'
|
||||
: 'text-light/50 hover:text-white hover:bg-dark/50'}"
|
||||
onclick={() => (activeTab = tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</Button>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto p-6">
|
||||
|
||||
<!-- General Tab -->
|
||||
{#if activeTab === "general"}
|
||||
@@ -331,74 +322,73 @@
|
||||
|
||||
<!-- Tags Tab -->
|
||||
{#if activeTab === "tags"}
|
||||
<div class="space-y-6 max-w-2xl">
|
||||
<div class="space-y-4 max-w-2xl">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-light">
|
||||
<h2 class="text-body font-heading text-white">
|
||||
{m.settings_tags_title()}
|
||||
</h2>
|
||||
<p class="text-sm text-light/50">
|
||||
<p class="text-body-sm text-light/50 mt-0.5">
|
||||
{m.settings_tags_desc()}
|
||||
</p>
|
||||
</div>
|
||||
<Button onclick={() => openTagModal()} icon="add">
|
||||
<Button size="sm" onclick={() => openTagModal()} icon="add">
|
||||
{m.settings_tags_create()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if orgTags.length === 0 && tagsLoaded}
|
||||
<Card>
|
||||
<div class="p-8 text-center">
|
||||
<div class="bg-dark/30 border border-light/5 rounded-xl p-8 text-center">
|
||||
<span
|
||||
class="material-symbols-rounded text-light/20 mb-4 block"
|
||||
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
|
||||
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;"
|
||||
>label</span
|
||||
>
|
||||
<p class="text-light/50">{m.settings_tags_empty()}</p>
|
||||
<p class="text-body-sm text-light/40">{m.settings_tags_empty()}</p>
|
||||
</div>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="grid gap-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each orgTags as tag}
|
||||
<Card>
|
||||
<div class="flex items-center justify-between p-4">
|
||||
<div class="flex items-center justify-between px-4 py-3 bg-dark/30 border border-light/5 rounded-xl hover:border-light/10 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center"
|
||||
style="background-color: {tag.color ||
|
||||
'#00A3E0'}"
|
||||
class="w-7 h-7 rounded-lg flex items-center justify-center shrink-0"
|
||||
style="background-color: {tag.color || '#00A3E0'}"
|
||||
>
|
||||
<span
|
||||
class="material-symbols-rounded text-night"
|
||||
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 600, 'GRAD' 0, 'opsz' 18;"
|
||||
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 600, 'GRAD' 0, 'opsz' 16;"
|
||||
>label</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-light font-medium">
|
||||
<p class="text-body-sm text-white font-medium">
|
||||
{tag.name}
|
||||
</p>
|
||||
<p class="text-xs text-light/40">
|
||||
<p class="text-[11px] text-light/30">
|
||||
{tag.color || "#00A3E0"}
|
||||
</p>
|
||||
</div>
|
||||
</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={() => openTagModal(tag)}
|
||||
>Edit</Button
|
||||
title="Edit"
|
||||
>
|
||||
<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;">edit</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1.5 text-light/40 hover:text-error hover:bg-error/10 rounded-lg transition-colors"
|
||||
onclick={() => deleteOrgTag(tag)}
|
||||
>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>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -416,6 +406,7 @@
|
||||
serviceAccountEmail={data.serviceAccountEmail ?? null}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Tag Modal -->
|
||||
|
||||
234
src/routes/api/matrix-provision/+server.ts
Normal file
234
src/routes/api/matrix-provision/+server.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* POST /api/matrix-provision
|
||||
*
|
||||
* Auto-provisions a Matrix account for the authenticated Supabase user.
|
||||
* Uses the Synapse Admin API to:
|
||||
* 1. Register a new Matrix user (or retrieve existing)
|
||||
* 2. Login to get a fresh access token
|
||||
* 3. Set display name and avatar from the Supabase profile
|
||||
* 4. Store credentials in matrix_credentials table
|
||||
*
|
||||
* Body: { org_id: string }
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const session = await locals.safeGetSession();
|
||||
if (!session.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!env.MATRIX_HOMESERVER_URL || !env.MATRIX_ADMIN_TOKEN) {
|
||||
return json({ error: 'Matrix integration not configured' }, { status: 503 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { org_id } = body;
|
||||
|
||||
if (!org_id) {
|
||||
return json({ error: 'org_id is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if credentials already exist
|
||||
const { data: existing } = await locals.supabase
|
||||
.from('matrix_credentials')
|
||||
.select('homeserver_url, matrix_user_id, access_token, device_id')
|
||||
.eq('user_id', session.user.id)
|
||||
.eq('org_id', org_id)
|
||||
.single();
|
||||
|
||||
if (existing) {
|
||||
return json({ credentials: existing, provisioned: false });
|
||||
}
|
||||
|
||||
// Fetch user profile from Supabase
|
||||
const { data: profile } = await locals.supabase
|
||||
.from('profiles')
|
||||
.select('email, full_name, avatar_url')
|
||||
.eq('id', session.user.id)
|
||||
.single();
|
||||
|
||||
if (!profile) {
|
||||
return json({ error: 'User profile not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// After the guard above, these are guaranteed to be defined
|
||||
const homeserverUrl = env.MATRIX_HOMESERVER_URL!;
|
||||
const adminToken = env.MATRIX_ADMIN_TOKEN!;
|
||||
|
||||
// Derive Matrix username from Supabase user ID (safe, unique, no collisions)
|
||||
const matrixLocalpart = `root_${session.user.id.replace(/-/g, '')}`;
|
||||
const password = generatePassword();
|
||||
|
||||
try {
|
||||
// Step 1: Register user via Synapse Admin API
|
||||
const registerRes = await fetch(
|
||||
`${homeserverUrl}/_synapse/admin/v2/users/@${matrixLocalpart}:${getServerName(homeserverUrl)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${adminToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
password,
|
||||
displayname: profile.full_name || profile.email,
|
||||
admin: false,
|
||||
deactivated: false,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!registerRes.ok) {
|
||||
const err = await registerRes.json().catch(() => ({}));
|
||||
console.error('Matrix register failed:', registerRes.status, err);
|
||||
return json({ error: 'Failed to create Matrix account' }, { status: 500 });
|
||||
}
|
||||
|
||||
// Step 2: Login to get access token
|
||||
const loginRes = await fetch(`${homeserverUrl}/_matrix/client/v3/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'm.login.password',
|
||||
identifier: {
|
||||
type: 'm.id.user',
|
||||
user: matrixLocalpart,
|
||||
},
|
||||
password,
|
||||
initial_device_display_name: 'Root v2 Web',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!loginRes.ok) {
|
||||
const err = await loginRes.json().catch(() => ({}));
|
||||
console.error('Matrix login failed:', loginRes.status, err);
|
||||
return json({ error: 'Failed to login to Matrix account' }, { status: 500 });
|
||||
}
|
||||
|
||||
const loginData = await loginRes.json();
|
||||
const matrixUserId = loginData.user_id;
|
||||
const accessToken = loginData.access_token;
|
||||
const deviceId = loginData.device_id;
|
||||
|
||||
// Step 3: Set avatar if available
|
||||
if (profile.avatar_url) {
|
||||
try {
|
||||
await setMatrixAvatar(homeserverUrl, accessToken, profile.avatar_url);
|
||||
} catch (e) {
|
||||
console.warn('Failed to set Matrix avatar:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Store credentials in Supabase
|
||||
const { error: upsertError } = await locals.supabase
|
||||
.from('matrix_credentials')
|
||||
.upsert(
|
||||
{
|
||||
user_id: session.user.id,
|
||||
org_id,
|
||||
homeserver_url: homeserverUrl,
|
||||
matrix_user_id: matrixUserId,
|
||||
access_token: accessToken,
|
||||
device_id: deviceId ?? null,
|
||||
},
|
||||
{ onConflict: 'user_id,org_id' }
|
||||
);
|
||||
|
||||
if (upsertError) {
|
||||
console.error('Failed to store Matrix credentials:', upsertError);
|
||||
return json({ error: 'Failed to store credentials' }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({
|
||||
credentials: {
|
||||
homeserver_url: homeserverUrl,
|
||||
matrix_user_id: matrixUserId,
|
||||
access_token: accessToken,
|
||||
device_id: deviceId,
|
||||
},
|
||||
provisioned: true,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Matrix provisioning error:', e);
|
||||
return json({ error: 'Matrix provisioning failed' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the server name from the homeserver URL.
|
||||
* e.g. "https://matrix.example.com" -> "matrix.example.com"
|
||||
*/
|
||||
function getServerName(homeserverUrl: string): string {
|
||||
try {
|
||||
return new URL(homeserverUrl).hostname;
|
||||
} catch {
|
||||
return homeserverUrl.replace(/^https?:\/\//, '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random password for the Matrix account.
|
||||
* The user never sees this — auth is token-based after provisioning.
|
||||
*/
|
||||
function generatePassword(): string {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*';
|
||||
let password = '';
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
for (const byte of array) {
|
||||
password += chars[byte % chars.length];
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an avatar from a URL and upload it to Matrix, then set as profile avatar.
|
||||
*/
|
||||
async function setMatrixAvatar(homeserverUrl: string, accessToken: string, avatarUrl: string): Promise<void> {
|
||||
// Download the avatar
|
||||
const imgRes = await fetch(avatarUrl);
|
||||
if (!imgRes.ok) return;
|
||||
|
||||
const blob = await imgRes.blob();
|
||||
const contentType = imgRes.headers.get('content-type') || 'image/png';
|
||||
|
||||
// Upload to Matrix content repository
|
||||
const uploadRes = await fetch(
|
||||
`${homeserverUrl}/_matrix/media/v3/upload?filename=avatar`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': contentType,
|
||||
},
|
||||
body: blob,
|
||||
}
|
||||
);
|
||||
|
||||
if (!uploadRes.ok) return;
|
||||
|
||||
const { content_uri } = await uploadRes.json();
|
||||
|
||||
// Set as profile avatar
|
||||
// We need the user ID from the access token — extract from a whoami call
|
||||
const whoamiRes = await fetch(`${homeserverUrl}/_matrix/client/v3/account/whoami`, {
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!whoamiRes.ok) return;
|
||||
const { user_id } = await whoamiRes.json();
|
||||
|
||||
await fetch(
|
||||
`${homeserverUrl}/_matrix/client/v3/profile/${encodeURIComponent(user_id)}/avatar_url`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ avatar_url: content_uri }),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -36,97 +36,76 @@
|
||||
<title>Style Guide | Root</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-dark p-8">
|
||||
<div class="max-w-6xl mx-auto space-y-12">
|
||||
<!-- Back Button -->
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center gap-2 text-light/60 hover:text-light transition-colors"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Home
|
||||
</a>
|
||||
|
||||
<div class="min-h-screen bg-background">
|
||||
<!-- Header -->
|
||||
<header class="text-center space-y-4">
|
||||
<h1 class="text-4xl font-bold text-light">Root Style Guide</h1>
|
||||
<p class="text-light/60">All UI components and their variants</p>
|
||||
<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">
|
||||
<a href="/" class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors">
|
||||
<span class="material-symbols-rounded" style="font-size: 20px;">arrow_back</span>
|
||||
</a>
|
||||
<span class="font-heading text-body text-white">Style Guide</span>
|
||||
<span class="text-[11px] text-light/30 font-body">All UI components and their variants</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Colors - Figma Design System -->
|
||||
<div class="max-w-5xl mx-auto px-6 py-8 space-y-10">
|
||||
|
||||
<!-- Colors -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Colors
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
class="w-full h-20 rounded-[32px] bg-background border border-light/20"
|
||||
></div>
|
||||
<p class="text-sm text-light/60">Background</p>
|
||||
<code class="text-xs text-light/40">#05090F</code>
|
||||
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Colors</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<div class="w-full h-16 rounded-xl bg-background border border-light/10"></div>
|
||||
<p class="text-[12px] text-light/60 font-body">Background</p>
|
||||
<code class="text-[10px] text-light/30">#05090F</code>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="w-full h-20 rounded-[32px] bg-night"></div>
|
||||
<p class="text-sm text-light/60">Night</p>
|
||||
<code class="text-xs text-light/40">#0A121F</code>
|
||||
<div class="space-y-1.5">
|
||||
<div class="w-full h-16 rounded-xl bg-night"></div>
|
||||
<p class="text-[12px] text-light/60 font-body">Night</p>
|
||||
<code class="text-[10px] text-light/30">#0A121F</code>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="w-full h-20 rounded-[32px] bg-dark"></div>
|
||||
<p class="text-sm text-light/60">Dark</p>
|
||||
<code class="text-xs text-light/40">#14243E</code>
|
||||
<div class="space-y-1.5">
|
||||
<div class="w-full h-16 rounded-xl bg-dark"></div>
|
||||
<p class="text-[12px] text-light/60 font-body">Dark</p>
|
||||
<code class="text-[10px] text-light/30">#14243E</code>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="w-full h-20 rounded-[32px] bg-light"></div>
|
||||
<p class="text-sm text-light/60">Light</p>
|
||||
<code class="text-xs text-light/40">#E5E6F0</code>
|
||||
<div class="space-y-1.5">
|
||||
<div class="w-full h-16 rounded-xl bg-light"></div>
|
||||
<p class="text-[12px] text-light/60 font-body">Light</p>
|
||||
<code class="text-[10px] text-light/30">#E5E6F0</code>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="w-full h-20 rounded-[32px] bg-primary"></div>
|
||||
<p class="text-sm text-light/60">Primary</p>
|
||||
<code class="text-xs text-light/40">#00A3E0</code>
|
||||
<div class="space-y-1.5">
|
||||
<div class="w-full h-16 rounded-xl bg-primary"></div>
|
||||
<p class="text-[12px] text-light/60 font-body">Primary</p>
|
||||
<code class="text-[10px] text-light/30">#00A3E0</code>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="w-full h-20 rounded-[32px] bg-success"></div>
|
||||
<p class="text-sm text-light/60">Success</p>
|
||||
<code class="text-xs text-light/40">#33E000</code>
|
||||
<div class="space-y-1.5">
|
||||
<div class="w-full h-16 rounded-xl bg-success"></div>
|
||||
<p class="text-[12px] text-light/60 font-body">Success</p>
|
||||
<code class="text-[10px] text-light/30">#33E000</code>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="w-full h-20 rounded-[32px] bg-warning"></div>
|
||||
<p class="text-sm text-light/60">Warning</p>
|
||||
<code class="text-xs text-light/40">#FFAB00</code>
|
||||
<div class="space-y-1.5">
|
||||
<div class="w-full h-16 rounded-xl bg-warning"></div>
|
||||
<p class="text-[12px] text-light/60 font-body">Warning</p>
|
||||
<code class="text-[10px] text-light/30">#FFAB00</code>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="w-full h-20 rounded-[32px] bg-error"></div>
|
||||
<p class="text-sm text-light/60">Error</p>
|
||||
<code class="text-xs text-light/40">#E03D00</code>
|
||||
<div class="space-y-1.5">
|
||||
<div class="w-full h-16 rounded-xl bg-error"></div>
|
||||
<p class="text-[12px] text-light/60 font-body">Error</p>
|
||||
<code class="text-[10px] text-light/30">#E03D00</code>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Buttons -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Buttons
|
||||
</h2>
|
||||
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Buttons</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
Variants
|
||||
</h3>
|
||||
<h3 class="text-body-sm font-heading text-light/60 mb-2">Variants</h3>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button variant="primary">Primary</Button>
|
||||
<Button variant="secondary">Secondary</Button>
|
||||
@@ -137,9 +116,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
Sizes
|
||||
</h3>
|
||||
<h3 class="text-body-sm font-heading text-light/60 mb-2">Sizes</h3>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button size="sm">Small</Button>
|
||||
<Button size="md">Medium</Button>
|
||||
@@ -148,9 +125,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
With Icons (Material Symbols)
|
||||
</h3>
|
||||
<h3 class="text-body-sm font-heading text-light/60 mb-2">With Icons</h3>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Button icon="add">Add Item</Button>
|
||||
<Button variant="secondary" icon="edit">Edit</Button>
|
||||
@@ -160,9 +135,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
States
|
||||
</h3>
|
||||
<h3 class="text-body-sm font-heading text-light/60 mb-2">States</h3>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Button>Normal</Button>
|
||||
<Button disabled>Disabled</Button>
|
||||
@@ -171,9 +144,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
Full Width
|
||||
</h3>
|
||||
<h3 class="text-body-sm font-heading text-light/60 mb-2">Full Width</h3>
|
||||
<div class="max-w-sm">
|
||||
<Button fullWidth icon="rocket_launch"
|
||||
>Full Width Button</Button
|
||||
@@ -185,11 +156,7 @@
|
||||
|
||||
<!-- Inputs -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Inputs
|
||||
</h2>
|
||||
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Inputs</h2>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<Input
|
||||
@@ -229,11 +196,7 @@
|
||||
|
||||
<!-- Textarea -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Textarea
|
||||
</h2>
|
||||
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Textarea</h2>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<Textarea
|
||||
@@ -251,11 +214,7 @@
|
||||
|
||||
<!-- Select -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Select
|
||||
</h2>
|
||||
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Select</h2>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<Select
|
||||
@@ -273,17 +232,11 @@
|
||||
|
||||
<!-- Avatars -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Avatars
|
||||
</h2>
|
||||
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Avatars</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
Sizes
|
||||
</h3>
|
||||
<h3 class="text-body-sm font-heading text-light/60 mb-2">Sizes</h3>
|
||||
<div class="flex items-end gap-4">
|
||||
<Avatar name="John Doe" size="sm" />
|
||||
<Avatar name="John Doe" size="md" />
|
||||
@@ -293,9 +246,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
With Status (placeholder)
|
||||
</h3>
|
||||
<h3 class="text-body-sm font-heading text-light/60 mb-2">With Status</h3>
|
||||
<div class="flex items-center gap-4">
|
||||
<Avatar name="Online User" size="lg" />
|
||||
<Avatar name="Away User" size="lg" />
|
||||
@@ -305,9 +256,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
Different Names (Color Generation)
|
||||
</h3>
|
||||
<h3 class="text-body-sm font-heading text-light/60 mb-2">Color Generation</h3>
|
||||
<div class="flex items-center gap-4">
|
||||
<Avatar name="Alice" size="lg" />
|
||||
<Avatar name="Bob" size="lg" />
|
||||
@@ -321,17 +270,11 @@
|
||||
|
||||
<!-- Chips -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Chips
|
||||
</h2>
|
||||
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Chips</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
Variants
|
||||
</h3>
|
||||
<h3 class="text-body-sm font-heading text-light/60 mb-2">Variants</h3>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Chip variant="primary">Primary</Chip>
|
||||
<Chip variant="success">Success</Chip>
|
||||
@@ -345,11 +288,7 @@
|
||||
|
||||
<!-- List Items -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
List Items
|
||||
</h2>
|
||||
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">List Items</h2>
|
||||
|
||||
<div class="max-w-[240px] space-y-2">
|
||||
<ListItem icon="info">Default Item</ListItem>
|
||||
@@ -364,11 +303,7 @@
|
||||
|
||||
<!-- Org Header -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Organization Header
|
||||
</h2>
|
||||
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Organization Header</h2>
|
||||
|
||||
<div class="max-w-[240px] space-y-4">
|
||||
<OrgHeader name="Acme Corp" role="Admin" />
|
||||
@@ -379,11 +314,7 @@
|
||||
|
||||
<!-- Calendar Day -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Calendar Day
|
||||
</h2>
|
||||
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Calendar Day</h2>
|
||||
|
||||
<div class="flex gap-1 max-w-[720px]">
|
||||
<CalendarDay day="Mon" isHeader />
|
||||
@@ -403,17 +334,11 @@
|
||||
|
||||
<!-- Badges -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Badges
|
||||
</h2>
|
||||
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Badges</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
Variants
|
||||
</h3>
|
||||
<h3 class="text-body-sm font-heading text-light/60 mb-2">Variants</h3>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Badge variant="default">Default</Badge>
|
||||
<Badge variant="primary">Primary</Badge>
|
||||
@@ -425,9 +350,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
Sizes
|
||||
</h3>
|
||||
<h3 class="text-body-sm font-heading text-light/60 mb-2">Sizes</h3>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Badge size="sm">Small</Badge>
|
||||
<Badge size="md">Medium</Badge>
|
||||
@@ -439,11 +362,7 @@
|
||||
|
||||
<!-- Cards -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Cards
|
||||
</h2>
|
||||
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Cards</h2>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
<Card variant="default">
|
||||
@@ -469,17 +388,11 @@
|
||||
|
||||
<!-- Toggle -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Toggle
|
||||
</h2>
|
||||
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Toggle</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
Sizes
|
||||
</h3>
|
||||
<h3 class="text-body-sm font-heading text-light/60 mb-2">Sizes</h3>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle size="sm" />
|
||||
@@ -497,9 +410,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
States
|
||||
</h3>
|
||||
<h3 class="text-body-sm font-heading text-light/60 mb-2">States</h3>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<Toggle />
|
||||
@@ -520,17 +431,11 @@
|
||||
|
||||
<!-- Spinners -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Spinners
|
||||
</h2>
|
||||
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Spinners</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-5">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
Sizes
|
||||
</h3>
|
||||
<h3 class="text-body-sm font-heading text-light/60 mb-2">Sizes</h3>
|
||||
<div class="flex items-center gap-6">
|
||||
<Spinner size="sm" />
|
||||
<Spinner size="md" />
|
||||
@@ -539,9 +444,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
Colors
|
||||
</h3>
|
||||
<h3 class="text-body-sm font-heading text-light/60 mb-2">Colors</h3>
|
||||
<div class="flex items-center gap-6">
|
||||
<Spinner color="primary" />
|
||||
<Spinner color="light" />
|
||||
@@ -555,11 +458,7 @@
|
||||
|
||||
<!-- Modal -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Modal
|
||||
</h2>
|
||||
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Modal</h2>
|
||||
|
||||
<div>
|
||||
<Button onclick={() => (modalOpen = true)}>Open Modal</Button>
|
||||
@@ -584,18 +483,12 @@
|
||||
|
||||
<!-- Typography -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Typography
|
||||
</h2>
|
||||
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Typography</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-5">
|
||||
<!-- Headings (Tilt Warp) -->
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
Headings — Tilt Warp
|
||||
</h3>
|
||||
<h3 class="text-body-sm font-heading text-light/60 mb-2">Headings — Tilt Warp</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-baseline gap-4">
|
||||
<span
|
||||
@@ -644,9 +537,7 @@
|
||||
|
||||
<!-- Button Text (Tilt Warp) -->
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
Button Text — Tilt Warp
|
||||
</h3>
|
||||
<h3 class="text-body-sm font-heading text-light/60 mb-2">Button Text — Tilt Warp</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-baseline gap-4">
|
||||
<span
|
||||
@@ -680,9 +571,7 @@
|
||||
|
||||
<!-- Body Text (Work Sans) -->
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
||||
Body — Work Sans
|
||||
</h3>
|
||||
<h3 class="text-body-sm font-heading text-light/60 mb-2">Body — Work Sans</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-baseline gap-4">
|
||||
<span
|
||||
@@ -721,11 +610,7 @@
|
||||
|
||||
<!-- Toasts -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Toasts
|
||||
</h2>
|
||||
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Toasts</h2>
|
||||
<div class="space-y-4">
|
||||
<Toast
|
||||
variant="success"
|
||||
@@ -752,15 +637,9 @@
|
||||
|
||||
<!-- Logo -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Logo
|
||||
</h2>
|
||||
<p class="text-light/60">
|
||||
Brand logo component with size variants.
|
||||
</p>
|
||||
<div class="flex items-center gap-8 bg-night p-6 rounded-xl">
|
||||
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Logo</h2>
|
||||
<p class="text-[12px] text-light/40 font-body">Brand logo component with size variants.</p>
|
||||
<div class="flex items-center gap-8 bg-dark/30 border border-light/5 p-5 rounded-xl">
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<Logo size="sm" />
|
||||
<span class="text-xs text-light/60">Small</span>
|
||||
@@ -774,16 +653,9 @@
|
||||
|
||||
<!-- ContentHeader -->
|
||||
<section class="space-y-4">
|
||||
<h2
|
||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
||||
>
|
||||
Content Header
|
||||
</h2>
|
||||
<p class="text-light/60">
|
||||
Page header component with avatar, title, action button, and
|
||||
more menu.
|
||||
</p>
|
||||
<div class="bg-night p-6 rounded-xl space-y-4">
|
||||
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Content Header</h2>
|
||||
<p class="text-[12px] text-light/40 font-body">Page header component with avatar, title, action button, and more menu.</p>
|
||||
<div class="bg-dark/30 border border-light/5 p-5 rounded-xl space-y-4">
|
||||
<ContentHeader
|
||||
title="Page Title"
|
||||
actionLabel="+ New"
|
||||
@@ -796,10 +668,8 @@
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="text-center py-8 border-t border-light/10">
|
||||
<p class="text-light/40 text-sm">
|
||||
Root Organization Platform - Style Guide
|
||||
</p>
|
||||
<footer class="text-center py-8 border-t border-light/5">
|
||||
<p class="text-[11px] text-light/30 font-body">Root Organization Platform — Style Guide</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
84
supabase/migrations/022_events.sql
Normal file
84
supabase/migrations/022_events.sql
Normal file
@@ -0,0 +1,84 @@
|
||||
-- Events: the core project entity within an organization
|
||||
-- Each event represents a project (conference, festival, meetup, etc.)
|
||||
|
||||
CREATE TABLE events (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'planning' CHECK (status IN ('planning', 'active', 'completed', 'archived')),
|
||||
start_date DATE,
|
||||
end_date DATE,
|
||||
venue_name TEXT,
|
||||
venue_address TEXT,
|
||||
cover_image_url TEXT,
|
||||
color TEXT,
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(org_id, slug)
|
||||
);
|
||||
|
||||
-- Event members: subset of org members assigned to an event
|
||||
CREATE TABLE event_members (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('lead', 'manager', 'member')),
|
||||
assigned_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(event_id, user_id)
|
||||
);
|
||||
|
||||
-- Indexes
|
||||
CREATE INDEX idx_events_org ON events(org_id);
|
||||
CREATE INDEX idx_events_status ON events(org_id, status);
|
||||
CREATE INDEX idx_events_dates ON events(start_date, end_date);
|
||||
CREATE INDEX idx_event_members_event ON event_members(event_id);
|
||||
CREATE INDEX idx_event_members_user ON event_members(user_id);
|
||||
|
||||
-- RLS
|
||||
ALTER TABLE events ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE event_members ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Events: org members can view, editors+ can manage
|
||||
CREATE POLICY "Org members can view events" ON events FOR SELECT
|
||||
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = events.org_id AND user_id = auth.uid()));
|
||||
|
||||
CREATE POLICY "Editors can manage events" ON events FOR ALL
|
||||
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = events.org_id AND user_id = auth.uid() AND role IN ('owner', 'admin', 'editor')));
|
||||
|
||||
-- Event members: org members can view, editors+ can manage
|
||||
CREATE POLICY "Org members can view event members" ON event_members FOR SELECT
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM events e
|
||||
JOIN org_members om ON e.org_id = om.org_id
|
||||
WHERE e.id = event_members.event_id AND om.user_id = auth.uid()
|
||||
));
|
||||
|
||||
CREATE POLICY "Editors can manage event members" ON event_members FOR ALL
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM events e
|
||||
JOIN org_members om ON e.org_id = om.org_id
|
||||
WHERE e.id = event_members.event_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
|
||||
));
|
||||
|
||||
-- Enable realtime
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE events;
|
||||
|
||||
-- Auto-add creator as event lead
|
||||
CREATE OR REPLACE FUNCTION public.handle_new_event()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NEW.created_by IS NOT NULL THEN
|
||||
INSERT INTO public.event_members (event_id, user_id, role)
|
||||
VALUES (NEW.id, NEW.created_by, 'lead')
|
||||
ON CONFLICT (event_id, user_id) DO NOTHING;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
CREATE TRIGGER on_event_created
|
||||
AFTER INSERT ON events
|
||||
FOR EACH ROW EXECUTE FUNCTION public.handle_new_event();
|
||||
142
supabase/migrations/023_event_team_management.sql
Normal file
142
supabase/migrations/023_event_team_management.sql
Normal file
@@ -0,0 +1,142 @@
|
||||
-- Event Team Management: departments, roles, and member-department assignments
|
||||
-- Supports real-world event teams where members have roles and belong to departments
|
||||
|
||||
-- ============================================================
|
||||
-- 1. Event Roles: customizable per-event position types
|
||||
-- ============================================================
|
||||
CREATE TABLE event_roles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#6366f1',
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
is_default BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(event_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_event_roles_event ON event_roles(event_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 2. Event Departments: teams/areas within an event
|
||||
-- ============================================================
|
||||
CREATE TABLE event_departments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#00A3E0',
|
||||
description TEXT,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(event_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_event_departments_event ON event_departments(event_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 3. Evolve event_members: add role_id FK, keep role text as fallback
|
||||
-- ============================================================
|
||||
ALTER TABLE event_members
|
||||
ADD COLUMN role_id UUID REFERENCES event_roles(id) ON DELETE SET NULL,
|
||||
ADD COLUMN notes TEXT;
|
||||
|
||||
-- ============================================================
|
||||
-- 4. Member-Department assignments (many-to-many)
|
||||
-- ============================================================
|
||||
CREATE TABLE event_member_departments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_member_id UUID NOT NULL REFERENCES event_members(id) ON DELETE CASCADE,
|
||||
department_id UUID NOT NULL REFERENCES event_departments(id) ON DELETE CASCADE,
|
||||
assigned_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(event_member_id, department_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_emd_member ON event_member_departments(event_member_id);
|
||||
CREATE INDEX idx_emd_department ON event_member_departments(department_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 5. RLS policies
|
||||
-- ============================================================
|
||||
ALTER TABLE event_roles ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE event_departments ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE event_member_departments ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Event roles: org members can view, editors+ can manage
|
||||
CREATE POLICY "Org members can view event roles" ON event_roles FOR SELECT
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM events e
|
||||
JOIN org_members om ON e.org_id = om.org_id
|
||||
WHERE e.id = event_roles.event_id AND om.user_id = auth.uid()
|
||||
));
|
||||
|
||||
CREATE POLICY "Editors can manage event roles" ON event_roles FOR ALL
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM events e
|
||||
JOIN org_members om ON e.org_id = om.org_id
|
||||
WHERE e.id = event_roles.event_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
|
||||
));
|
||||
|
||||
-- Event departments: org members can view, editors+ can manage
|
||||
CREATE POLICY "Org members can view event departments" ON event_departments FOR SELECT
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM events e
|
||||
JOIN org_members om ON e.org_id = om.org_id
|
||||
WHERE e.id = event_departments.event_id AND om.user_id = auth.uid()
|
||||
));
|
||||
|
||||
CREATE POLICY "Editors can manage event departments" ON event_departments FOR ALL
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM events e
|
||||
JOIN org_members om ON e.org_id = om.org_id
|
||||
WHERE e.id = event_departments.event_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
|
||||
));
|
||||
|
||||
-- Member-department assignments: org members can view, editors+ can manage
|
||||
CREATE POLICY "Org members can view member departments" ON event_member_departments FOR SELECT
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM event_members em
|
||||
JOIN events e ON em.event_id = e.id
|
||||
JOIN org_members om ON e.org_id = om.org_id
|
||||
WHERE em.id = event_member_departments.event_member_id AND om.user_id = auth.uid()
|
||||
));
|
||||
|
||||
CREATE POLICY "Editors can manage member departments" ON event_member_departments FOR ALL
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM event_members em
|
||||
JOIN events e ON em.event_id = e.id
|
||||
JOIN org_members om ON e.org_id = om.org_id
|
||||
WHERE em.id = event_member_departments.event_member_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
|
||||
));
|
||||
|
||||
-- ============================================================
|
||||
-- 6. Auto-seed default roles when a new event is created
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION public.seed_event_defaults()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Default roles (generalized from real event data)
|
||||
INSERT INTO public.event_roles (event_id, name, color, sort_order, is_default) VALUES
|
||||
(NEW.id, 'Head Organizer', '#EF4444', 0, false),
|
||||
(NEW.id, 'Team Lead', '#8B5CF6', 1, false),
|
||||
(NEW.id, 'Organizer', '#F59E0B', 2, true),
|
||||
(NEW.id, 'Volunteer', '#10B981', 3, false),
|
||||
(NEW.id, 'Sponsor', '#00A3E0', 4, false);
|
||||
|
||||
-- Default departments (generalized from real event data)
|
||||
INSERT INTO public.event_departments (event_id, name, color, sort_order) VALUES
|
||||
(NEW.id, 'Logistics', '#F59E0B', 0),
|
||||
(NEW.id, 'IT & Tech', '#6366F1', 1),
|
||||
(NEW.id, 'Marketing', '#EC4899', 2),
|
||||
(NEW.id, 'Finance', '#10B981', 3),
|
||||
(NEW.id, 'Program', '#8B5CF6', 4),
|
||||
(NEW.id, 'Sponsorship', '#00A3E0', 5),
|
||||
(NEW.id, 'Design', '#F97316', 6),
|
||||
(NEW.id, 'Volunteers', '#14B8A6', 7);
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
CREATE TRIGGER on_event_created_seed_defaults
|
||||
AFTER INSERT ON events
|
||||
FOR EACH ROW EXECUTE FUNCTION public.seed_event_defaults();
|
||||
8
supabase/migrations/024_profile_extended_fields.sql
Normal file
8
supabase/migrations/024_profile_extended_fields.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Extended profile fields: contact info and clothing sizes
|
||||
-- These are collected during onboarding and visible to event team managers
|
||||
|
||||
ALTER TABLE profiles
|
||||
ADD COLUMN phone TEXT,
|
||||
ADD COLUMN discord_handle TEXT,
|
||||
ADD COLUMN shirt_size TEXT,
|
||||
ADD COLUMN hoodie_size TEXT;
|
||||
99
supabase/migrations/025_event_tasks.sql
Normal file
99
supabase/migrations/025_event_tasks.sql
Normal file
@@ -0,0 +1,99 @@
|
||||
-- Event Tasks: kanban-style task management scoped to events
|
||||
-- Uses the same KanbanBoard component but with event-specific tables
|
||||
|
||||
-- ============================================================
|
||||
-- 1. Task columns: kanban columns per event
|
||||
-- ============================================================
|
||||
CREATE TABLE event_task_columns (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
color TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_event_task_columns_event ON event_task_columns(event_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 2. Tasks: cards within columns
|
||||
-- ============================================================
|
||||
CREATE TABLE event_tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
column_id UUID NOT NULL REFERENCES event_task_columns(id) ON DELETE CASCADE,
|
||||
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
priority TEXT CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
|
||||
due_date DATE,
|
||||
color TEXT,
|
||||
assignee_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_event_tasks_column ON event_tasks(column_id);
|
||||
CREATE INDEX idx_event_tasks_event ON event_tasks(event_id);
|
||||
CREATE INDEX idx_event_tasks_assignee ON event_tasks(assignee_id);
|
||||
|
||||
-- ============================================================
|
||||
-- 3. RLS policies
|
||||
-- ============================================================
|
||||
ALTER TABLE event_task_columns ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE event_tasks ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Task columns: org members can view, editors+ can manage
|
||||
CREATE POLICY "Org members can view event task columns" ON event_task_columns FOR SELECT
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM events e
|
||||
JOIN org_members om ON e.org_id = om.org_id
|
||||
WHERE e.id = event_task_columns.event_id AND om.user_id = auth.uid()
|
||||
));
|
||||
|
||||
CREATE POLICY "Editors can manage event task columns" ON event_task_columns FOR ALL
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM events e
|
||||
JOIN org_members om ON e.org_id = om.org_id
|
||||
WHERE e.id = event_task_columns.event_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
|
||||
));
|
||||
|
||||
-- Tasks: org members can view, editors+ can manage
|
||||
CREATE POLICY "Org members can view event tasks" ON event_tasks FOR SELECT
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM events e
|
||||
JOIN org_members om ON e.org_id = om.org_id
|
||||
WHERE e.id = event_tasks.event_id AND om.user_id = auth.uid()
|
||||
));
|
||||
|
||||
CREATE POLICY "Editors can manage event tasks" ON event_tasks FOR ALL
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM events e
|
||||
JOIN org_members om ON e.org_id = om.org_id
|
||||
WHERE e.id = event_tasks.event_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
|
||||
));
|
||||
|
||||
-- ============================================================
|
||||
-- 4. Enable realtime
|
||||
-- ============================================================
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE event_task_columns;
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE event_tasks;
|
||||
|
||||
-- ============================================================
|
||||
-- 5. Auto-seed default columns when a new event is created
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION public.seed_event_task_columns()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.event_task_columns (event_id, name, position) VALUES
|
||||
(NEW.id, 'To Do', 0),
|
||||
(NEW.id, 'In Progress', 1),
|
||||
(NEW.id, 'Done', 2);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
CREATE TRIGGER on_event_created_seed_task_columns
|
||||
AFTER INSERT ON events
|
||||
FOR EACH ROW EXECUTE FUNCTION public.seed_event_task_columns();
|
||||
12
synapse/docker-compose.yml
Normal file
12
synapse/docker-compose.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
services:
|
||||
synapse:
|
||||
image: matrixdotorg/synapse:latest
|
||||
container_name: root-org-synapse
|
||||
ports:
|
||||
- "8008:8008"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
environment:
|
||||
- SYNAPSE_SERVER_NAME=localhost
|
||||
- SYNAPSE_REPORT_STATS=no
|
||||
restart: unless-stopped
|
||||
174
tests/e2e/kanban-perf.spec.ts
Normal file
174
tests/e2e/kanban-perf.spec.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login, TEST_ORG_SLUG } from './helpers';
|
||||
|
||||
/**
|
||||
* Measures the REAL end-to-end latency a user experiences when dragging
|
||||
* a kanban card from one column to another using native mouse events.
|
||||
*
|
||||
* Uses MutationObserver set up BEFORE the drag starts, then performs
|
||||
* a real mouse drag-and-drop sequence. Measures from mouse-up to
|
||||
* card appearing in the target column DOM.
|
||||
*/
|
||||
test.describe('Kanban card move latency', () => {
|
||||
test('real drag-drop: card should appear in target column fast', async ({ page }) => {
|
||||
await login(page);
|
||||
await page.goto(`/${TEST_ORG_SLUG}/kanban`, { waitUntil: 'networkidle' });
|
||||
|
||||
// Click into the first board if we're on the board selector
|
||||
const anyBoard = page.locator('text=/board/i').first();
|
||||
if (await anyBoard.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await anyBoard.click();
|
||||
}
|
||||
|
||||
// Wait for columns
|
||||
await page.waitForSelector('[data-column-id]', { timeout: 10000 });
|
||||
|
||||
// Ensure there's a card to move — check first column
|
||||
const firstCard = page.locator('[data-column-id]').first().locator('[data-card-id]').first();
|
||||
if (!(await firstCard.isVisible({ timeout: 2000 }).catch(() => false))) {
|
||||
const addBtn = page.locator('[data-column-id]').first().getByRole('button', { name: /add card/i });
|
||||
if (await addBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await addBtn.click();
|
||||
await page.waitForTimeout(300);
|
||||
const titleInput = page.locator('input[placeholder="Card title"]');
|
||||
await titleInput.fill('Perf Test Card');
|
||||
await page.getByRole('button', { name: 'Add Card', exact: true }).click();
|
||||
await page.waitForSelector('[data-card-id]', { timeout: 5000 });
|
||||
} else {
|
||||
console.log('Cannot create card, skipping');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Gather IDs
|
||||
const cardId = await page.locator('[data-column-id]').first().locator('[data-card-id]').first().getAttribute('data-card-id');
|
||||
const columns = page.locator('[data-column-id]');
|
||||
if ((await columns.count()) < 2) { console.log('Need 2+ columns'); return; }
|
||||
const dstColId = await columns.nth(1).getAttribute('data-column-id');
|
||||
|
||||
console.log(`Card: ${cardId}`);
|
||||
console.log(`Target column: ${dstColId}`);
|
||||
|
||||
// Set up a MutationObserver on the target column BEFORE the drag starts
|
||||
// This will record the exact timestamp when the card DOM node appears
|
||||
await page.evaluate(({ cardId, dstColId }) => {
|
||||
const dstCol = document.querySelector(`[data-column-id="${dstColId}"]`);
|
||||
if (!dstCol) return;
|
||||
|
||||
(window as any).__cardAppearedAt = 0;
|
||||
(window as any).__mouseUpAt = 0;
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if ((window as any).__cardAppearedAt > 0) return;
|
||||
const found = dstCol.querySelector(`[data-card-id="${cardId}"]`);
|
||||
if (found) {
|
||||
(window as any).__cardAppearedAt = performance.now();
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
observer.observe(dstCol, { childList: true, subtree: true });
|
||||
|
||||
// Also hook into mouseup to record the exact drop moment
|
||||
document.addEventListener('mouseup', () => {
|
||||
if ((window as any).__mouseUpAt === 0) {
|
||||
(window as any).__mouseUpAt = performance.now();
|
||||
}
|
||||
}, { once: true, capture: true });
|
||||
}, { cardId, dstColId });
|
||||
|
||||
// Get bounding boxes
|
||||
const card = page.locator('[data-column-id]').first().locator('[data-card-id]').first();
|
||||
const cardBox = await card.boundingBox();
|
||||
const targetBox = await columns.nth(1).boundingBox();
|
||||
if (!cardBox || !targetBox) { console.log('No bounding boxes'); return; }
|
||||
|
||||
const srcX = cardBox.x + cardBox.width / 2;
|
||||
const srcY = cardBox.y + cardBox.height / 2;
|
||||
const dstX = targetBox.x + targetBox.width / 2;
|
||||
const dstY = targetBox.y + 100;
|
||||
|
||||
// Perform REAL mouse drag
|
||||
await page.mouse.move(srcX, srcY);
|
||||
await page.mouse.down();
|
||||
// Small move to trigger dragstart
|
||||
await page.mouse.move(srcX + 5, srcY, { steps: 2 });
|
||||
await page.waitForTimeout(50); // Let browser register the drag
|
||||
// Move to target
|
||||
await page.mouse.move(dstX, dstY, { steps: 3 });
|
||||
await page.waitForTimeout(50); // Let dragover register
|
||||
// Drop
|
||||
await page.mouse.up();
|
||||
|
||||
// Wait a bit for everything to settle
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Read the timestamps
|
||||
const result = await page.evaluate(() => {
|
||||
return {
|
||||
mouseUpAt: (window as any).__mouseUpAt as number,
|
||||
cardAppearedAt: (window as any).__cardAppearedAt as number,
|
||||
};
|
||||
});
|
||||
|
||||
const dropToRender = result.cardAppearedAt > 0 && result.mouseUpAt > 0
|
||||
? result.cardAppearedAt - result.mouseUpAt
|
||||
: -1;
|
||||
|
||||
console.log(`\n========================================`);
|
||||
console.log(` mouseup timestamp: ${result.mouseUpAt.toFixed(1)}`);
|
||||
console.log(` card appeared at: ${result.cardAppearedAt.toFixed(1)}`);
|
||||
console.log(` DROP → RENDER LATENCY: ${dropToRender.toFixed(1)}ms`);
|
||||
if (dropToRender < 0) {
|
||||
console.log(' ⚠️ Card never appeared or mouseup not captured');
|
||||
console.log(' (HTML5 drag may not have fired — trying synthetic fallback)');
|
||||
} else if (dropToRender < 20) {
|
||||
console.log(' ✅ INSTANT (<20ms)');
|
||||
} else if (dropToRender < 50) {
|
||||
console.log(' ✅ VERY FAST (<50ms)');
|
||||
} else if (dropToRender < 100) {
|
||||
console.log(' ⚠️ PERCEPTIBLE (50-100ms)');
|
||||
} else if (dropToRender < 500) {
|
||||
console.log(' ❌ SLOW (100-500ms)');
|
||||
} else {
|
||||
console.log(' ❌ VERY SLOW (>500ms)');
|
||||
}
|
||||
console.log(`========================================\n`);
|
||||
|
||||
// If native drag didn't work, fall back to synthetic events to at least measure Svelte
|
||||
if (dropToRender < 0) {
|
||||
console.log('Falling back to synthetic drag events...');
|
||||
const synthLatency = await page.evaluate(({ cardId, dstColId }) => {
|
||||
return new Promise<number>((resolve) => {
|
||||
const cardEl = document.querySelector(`[data-card-id="${cardId}"]`);
|
||||
const dstCol = document.querySelector(`[data-column-id="${dstColId}"]`);
|
||||
if (!cardEl || !dstCol) { resolve(-1); return; }
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const found = dstCol.querySelector(`[data-card-id="${cardId}"]`);
|
||||
if (found) { observer.disconnect(); resolve(performance.now() - t0); }
|
||||
});
|
||||
observer.observe(dstCol, { childList: true, subtree: true });
|
||||
|
||||
const dt = new DataTransfer();
|
||||
dt.setData('text/plain', cardId!);
|
||||
cardEl.dispatchEvent(new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: dt }));
|
||||
dstCol.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt }));
|
||||
const t0 = performance.now();
|
||||
dstCol.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt }));
|
||||
|
||||
setTimeout(() => {
|
||||
observer.disconnect();
|
||||
const found = dstCol.querySelector(`[data-card-id="${cardId}"]`);
|
||||
resolve(found ? performance.now() - t0 : 9999);
|
||||
}, 2000);
|
||||
});
|
||||
}, { cardId, dstColId });
|
||||
console.log(` Synthetic drop→render: ${synthLatency.toFixed(1)}ms`);
|
||||
}
|
||||
|
||||
// Verify card moved
|
||||
const cardInTarget = columns.nth(1).locator(`[data-card-id="${cardId}"]`);
|
||||
const visible = await cardInTarget.isVisible({ timeout: 3000 }).catch(() => false);
|
||||
console.log(` Card visible in target: ${visible}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user