Compare commits
18 Commits
d1ce5d0951
...
feature/ev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
676468d3ec | ||
|
|
1f2484da3d | ||
|
|
edc5f8af85 | ||
|
|
4999836a57 | ||
|
|
9d5e58f858 | ||
|
|
819d5b876a | ||
|
|
2913912cb8 | ||
|
|
fe6ec6e0af | ||
|
|
36496e8cdb | ||
|
|
556955f349 | ||
|
|
4f21c89103 | ||
|
|
8140fddc8b | ||
|
|
13cdb605ca | ||
|
|
45ab939b7f | ||
|
|
23035b6ab4 | ||
|
|
3f267e3b13 | ||
|
|
be99a02e78 | ||
|
|
a8d79cf138 |
129
messages/en.json
129
messages/en.json
@@ -175,6 +175,14 @@
|
|||||||
"account_display_name": "Display Name",
|
"account_display_name": "Display Name",
|
||||||
"account_display_name_placeholder": "Your name",
|
"account_display_name_placeholder": "Your name",
|
||||||
"account_email": "Email",
|
"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_save_profile": "Save Profile",
|
||||||
"account_appearance": "Appearance",
|
"account_appearance": "Appearance",
|
||||||
"account_theme": "Theme",
|
"account_theme": "Theme",
|
||||||
@@ -251,5 +259,124 @@
|
|||||||
"entity_kanban_column": "column",
|
"entity_kanban_column": "column",
|
||||||
"entity_member": "member",
|
"entity_member": "member",
|
||||||
"entity_role": "role",
|
"entity_role": "role",
|
||||||
"entity_invite": "invite"
|
"entity_invite": "invite",
|
||||||
|
"entity_event": "event",
|
||||||
|
"nav_events": "Events",
|
||||||
|
"nav_chat": "Chat",
|
||||||
|
"chat_title": "Chat",
|
||||||
|
"chat_subtitle": "Team messaging and communication",
|
||||||
|
"events_title": "Events",
|
||||||
|
"events_subtitle": "Organize and manage your events",
|
||||||
|
"events_new": "New Event",
|
||||||
|
"events_create": "Create Event",
|
||||||
|
"events_empty_title": "No events yet",
|
||||||
|
"events_empty_desc": "Create your first event to get started",
|
||||||
|
"events_no_dates": "No dates set",
|
||||||
|
"events_tab_all": "All Events",
|
||||||
|
"events_tab_planning": "Planning",
|
||||||
|
"events_tab_active": "Active",
|
||||||
|
"events_tab_completed": "Completed",
|
||||||
|
"events_tab_archived": "Archived",
|
||||||
|
"events_status_planning": "Planning",
|
||||||
|
"events_status_active": "Active",
|
||||||
|
"events_status_completed": "Completed",
|
||||||
|
"events_status_archived": "Archived",
|
||||||
|
"events_form_name": "Event Name",
|
||||||
|
"events_form_name_placeholder": "e.g., Summer Conference 2026",
|
||||||
|
"events_form_description": "Description",
|
||||||
|
"events_form_description_placeholder": "Brief description of the event...",
|
||||||
|
"events_form_start_date": "Start Date",
|
||||||
|
"events_form_end_date": "End Date",
|
||||||
|
"events_form_venue": "Venue",
|
||||||
|
"events_form_venue_placeholder": "e.g., Convention Center",
|
||||||
|
"events_form_venue_address_placeholder": "Venue address",
|
||||||
|
"events_form_color": "Color",
|
||||||
|
"events_form_select_color": "Select color {color}",
|
||||||
|
"events_creating": "Creating...",
|
||||||
|
"events_saving": "Saving...",
|
||||||
|
"events_deleting": "Deleting...",
|
||||||
|
"events_updated": "Event updated",
|
||||||
|
"events_created": "Event \"{name}\" created",
|
||||||
|
"events_deleted": "Event deleted",
|
||||||
|
"events_delete_title": "Delete Event?",
|
||||||
|
"events_delete_desc": "This will permanently delete {name} and all its data. This action cannot be undone.",
|
||||||
|
"events_delete_confirm": "Delete Event",
|
||||||
|
"events_days_ago": "{count} days ago",
|
||||||
|
"events_today": "Today!",
|
||||||
|
"events_tomorrow": "Tomorrow",
|
||||||
|
"events_in_days": "In {count} days",
|
||||||
|
"events_overview": "Overview",
|
||||||
|
"events_modules": "Modules",
|
||||||
|
"events_details": "Event Details",
|
||||||
|
"events_start_date": "Start Date",
|
||||||
|
"events_end_date": "End Date",
|
||||||
|
"events_venue": "Venue",
|
||||||
|
"events_not_set": "Not set",
|
||||||
|
"events_all_events": "All Events",
|
||||||
|
"events_team": "Team",
|
||||||
|
"events_team_count": "Team ({count})",
|
||||||
|
"events_team_manage": "Manage",
|
||||||
|
"events_team_empty": "No team members assigned yet",
|
||||||
|
"events_more_members": "+{count} more",
|
||||||
|
"events_mod_tasks": "Tasks",
|
||||||
|
"events_mod_tasks_desc": "Manage tasks, milestones, and progress",
|
||||||
|
"events_mod_files": "Files",
|
||||||
|
"events_mod_files_desc": "Documents, contracts, and media",
|
||||||
|
"events_mod_schedule": "Schedule",
|
||||||
|
"events_mod_schedule_desc": "Event timeline and program",
|
||||||
|
"events_mod_budget": "Budget",
|
||||||
|
"events_mod_budget_desc": "Income, expenses, and tracking",
|
||||||
|
"events_mod_guests": "Guests",
|
||||||
|
"events_mod_guests_desc": "Guest list and registration",
|
||||||
|
"events_mod_team": "Team",
|
||||||
|
"events_mod_team_desc": "Team members and shift scheduling",
|
||||||
|
"events_mod_sponsors": "Sponsors",
|
||||||
|
"events_mod_sponsors_desc": "Sponsors, partners, and deliverables",
|
||||||
|
"module_coming_soon": "Coming Soon",
|
||||||
|
"module_coming_soon_desc": "This module is under development and will be available soon.",
|
||||||
|
"team_title": "Event Team",
|
||||||
|
"team_subtitle": "Manage team members and their roles for this event.",
|
||||||
|
"team_add_member": "Add Member",
|
||||||
|
"team_role_lead": "Lead",
|
||||||
|
"team_role_manager": "Manager",
|
||||||
|
"team_role_member": "Member",
|
||||||
|
"team_empty": "No team members assigned yet. Add members from your organization.",
|
||||||
|
"team_remove_confirm": "Remove {name} from this event's team?",
|
||||||
|
"team_remove_btn": "Remove",
|
||||||
|
"team_added": "{name} added to team",
|
||||||
|
"team_removed": "{name} removed from team",
|
||||||
|
"team_updated": "Role updated",
|
||||||
|
"team_select_member": "Select a member",
|
||||||
|
"team_select_role": "Select role",
|
||||||
|
"team_already_assigned": "Already on team",
|
||||||
|
"team_departments": "Departments",
|
||||||
|
"team_roles": "Roles",
|
||||||
|
"team_all": "All",
|
||||||
|
"team_no_department": "Unassigned",
|
||||||
|
"team_add_department": "Add Department",
|
||||||
|
"team_add_role": "Add Role",
|
||||||
|
"team_edit_department": "Edit Department",
|
||||||
|
"team_edit_role": "Edit Role",
|
||||||
|
"team_dept_name": "Department name",
|
||||||
|
"team_role_name": "Role name",
|
||||||
|
"team_dept_created": "Department created",
|
||||||
|
"team_dept_updated": "Department updated",
|
||||||
|
"team_dept_deleted": "Department deleted",
|
||||||
|
"team_role_created": "Role created",
|
||||||
|
"team_role_updated": "Role updated",
|
||||||
|
"team_role_deleted": "Role deleted",
|
||||||
|
"team_dept_delete_confirm": "Delete department {name}? Members will be unassigned from it.",
|
||||||
|
"team_role_delete_confirm": "Delete role {name}? Members will lose this role assignment.",
|
||||||
|
"team_view_by_dept": "By department",
|
||||||
|
"team_view_list": "List view",
|
||||||
|
"team_member_count": "{count} members",
|
||||||
|
"team_assign_dept": "Assign departments",
|
||||||
|
"team_notes": "Notes",
|
||||||
|
"team_notes_placeholder": "Optional notes about this member...",
|
||||||
|
"overview_subtitle": "Welcome back. Here's what's happening.",
|
||||||
|
"overview_stat_events": "Events",
|
||||||
|
"overview_upcoming_events": "Upcoming Events",
|
||||||
|
"overview_upcoming_empty": "No upcoming events. Create one to get started.",
|
||||||
|
"overview_view_all_events": "View all events",
|
||||||
|
"overview_more_members": "+{count} more"
|
||||||
}
|
}
|
||||||
129
messages/et.json
129
messages/et.json
@@ -175,6 +175,14 @@
|
|||||||
"account_display_name": "Kuvatav nimi",
|
"account_display_name": "Kuvatav nimi",
|
||||||
"account_display_name_placeholder": "Sinu nimi",
|
"account_display_name_placeholder": "Sinu nimi",
|
||||||
"account_email": "E-post",
|
"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_save_profile": "Salvesta profiil",
|
||||||
"account_appearance": "Välimus",
|
"account_appearance": "Välimus",
|
||||||
"account_theme": "Teema",
|
"account_theme": "Teema",
|
||||||
@@ -251,5 +259,124 @@
|
|||||||
"entity_kanban_column": "veeru",
|
"entity_kanban_column": "veeru",
|
||||||
"entity_member": "liikme",
|
"entity_member": "liikme",
|
||||||
"entity_role": "rolli",
|
"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"
|
||||||
}
|
}
|
||||||
48
src/lib/api/events.test.ts
Normal file
48
src/lib/api/events.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
// Test the slugify logic (extracted inline since it's not exported)
|
||||||
|
function slugify(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\s-]/g, '')
|
||||||
|
.replace(/[\s_]+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
.slice(0, 60) || 'event';
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('events API - slugify', () => {
|
||||||
|
it('converts simple name to slug', () => {
|
||||||
|
expect(slugify('Summer Conference')).toBe('summer-conference');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles special characters', () => {
|
||||||
|
expect(slugify('Music & Arts Festival 2026!')).toBe('music-arts-festival-2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses multiple spaces and dashes', () => {
|
||||||
|
expect(slugify('My Big Event')).toBe('my-big-event');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims leading/trailing dashes', () => {
|
||||||
|
expect(slugify('--hello--')).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates to 60 characters', () => {
|
||||||
|
const longName = 'A'.repeat(100);
|
||||||
|
expect(slugify(longName).length).toBeLessThanOrEqual(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "event" for empty string', () => {
|
||||||
|
expect(slugify('')).toBe('event');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles unicode characters', () => {
|
||||||
|
const result = slugify('Ürituse Korraldamine');
|
||||||
|
expect(result).toBe('rituse-korraldamine');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles numbers', () => {
|
||||||
|
expect(slugify('Event 2026 Q1')).toBe('event-2026-q1');
|
||||||
|
});
|
||||||
|
});
|
||||||
550
src/lib/api/events.ts
Normal file
550
src/lib/api/events.ts
Normal file
@@ -0,0 +1,550 @@
|
|||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
import type { Database } from '$lib/supabase/types';
|
||||||
|
import { createLogger } from '$lib/utils/logger';
|
||||||
|
|
||||||
|
const log = createLogger('api.events');
|
||||||
|
|
||||||
|
export interface Event {
|
||||||
|
id: string;
|
||||||
|
org_id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string | null;
|
||||||
|
status: 'planning' | 'active' | 'completed' | 'archived';
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
venue_name: string | null;
|
||||||
|
venue_address: string | null;
|
||||||
|
cover_image_url: string | null;
|
||||||
|
color: string | null;
|
||||||
|
created_by: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventMember {
|
||||||
|
id: string;
|
||||||
|
event_id: string;
|
||||||
|
user_id: string;
|
||||||
|
role: 'lead' | 'manager' | 'member';
|
||||||
|
role_id: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
assigned_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventRole {
|
||||||
|
id: string;
|
||||||
|
event_id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
sort_order: number;
|
||||||
|
is_default: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventDepartment {
|
||||||
|
id: string;
|
||||||
|
event_id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
description: string | null;
|
||||||
|
sort_order: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventMemberDepartment {
|
||||||
|
id: string;
|
||||||
|
event_member_id: string;
|
||||||
|
department_id: string;
|
||||||
|
assigned_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventMemberWithDetails extends EventMember {
|
||||||
|
profile?: { id: string; email: string; full_name: string | null; avatar_url: string | null; phone: string | null; discord_handle: string | null; shirt_size: string | null; hoodie_size: string | null };
|
||||||
|
event_role?: EventRole;
|
||||||
|
departments: EventDepartment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventWithCounts extends Event {
|
||||||
|
member_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\s-]/g, '')
|
||||||
|
.replace(/[\s_]+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
.slice(0, 60) || 'event';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEvents(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
orgId: string,
|
||||||
|
status?: string
|
||||||
|
): Promise<EventWithCounts[]> {
|
||||||
|
let query = supabase
|
||||||
|
.from('events')
|
||||||
|
.select('*, event_members(count)')
|
||||||
|
.eq('org_id', orgId)
|
||||||
|
.order('start_date', { ascending: true, nullsFirst: false });
|
||||||
|
|
||||||
|
if (status && status !== 'all') {
|
||||||
|
query = query.eq('status', status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await query;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('fetchEvents failed', { error, data: { orgId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const events: EventWithCounts[] = (data ?? []).map((e: any) => ({
|
||||||
|
...e,
|
||||||
|
member_count: e.event_members?.[0]?.count ?? 0,
|
||||||
|
event_members: undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
log.debug('fetchEvents ok', { data: { count: events.length } });
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEvent(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
eventId: string
|
||||||
|
): Promise<Event | null> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('events')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', eventId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (error.code === 'PGRST116') return null;
|
||||||
|
log.error('fetchEvent failed', { error, data: { eventId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return data as unknown as Event;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEventBySlug(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
orgId: string,
|
||||||
|
eventSlug: string
|
||||||
|
): Promise<Event | null> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('events')
|
||||||
|
.select('*')
|
||||||
|
.eq('org_id', orgId)
|
||||||
|
.eq('slug', eventSlug)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (error.code === 'PGRST116') return null;
|
||||||
|
log.error('fetchEventBySlug failed', { error, data: { orgId, eventSlug } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return data as unknown as Event;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEvent(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
orgId: string,
|
||||||
|
userId: string,
|
||||||
|
params: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
venue_name?: string;
|
||||||
|
venue_address?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
): Promise<Event> {
|
||||||
|
const baseSlug = slugify(params.name);
|
||||||
|
|
||||||
|
// Ensure unique slug within org
|
||||||
|
const { data: existing } = await supabase
|
||||||
|
.from('events')
|
||||||
|
.select('slug')
|
||||||
|
.eq('org_id', orgId)
|
||||||
|
.like('slug', `${baseSlug}%`);
|
||||||
|
|
||||||
|
let slug = baseSlug;
|
||||||
|
if (existing && existing.length > 0) {
|
||||||
|
const existingSlugs = new Set(existing.map((e: any) => e.slug));
|
||||||
|
if (existingSlugs.has(slug)) {
|
||||||
|
let i = 2;
|
||||||
|
while (existingSlugs.has(`${baseSlug}-${i}`)) i++;
|
||||||
|
slug = `${baseSlug}-${i}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('events')
|
||||||
|
.insert({
|
||||||
|
org_id: orgId,
|
||||||
|
name: params.name,
|
||||||
|
slug,
|
||||||
|
description: params.description ?? null,
|
||||||
|
start_date: params.start_date ?? null,
|
||||||
|
end_date: params.end_date ?? null,
|
||||||
|
venue_name: params.venue_name ?? null,
|
||||||
|
venue_address: params.venue_address ?? null,
|
||||||
|
color: params.color ?? null,
|
||||||
|
created_by: userId,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('createEvent failed', { error, data: { orgId, name: params.name } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
log.info('createEvent ok', { data: { id: data.id, name: params.name, slug } });
|
||||||
|
return data as unknown as Event;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEvent(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
eventId: string,
|
||||||
|
params: Partial<Pick<Event, 'name' | 'description' | 'status' | 'start_date' | 'end_date' | 'venue_name' | 'venue_address' | 'cover_image_url' | 'color'>>
|
||||||
|
): Promise<Event> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('events')
|
||||||
|
.update({ ...params, updated_at: new Date().toISOString() })
|
||||||
|
.eq('id', eventId)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('updateEvent failed', { error, data: { eventId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
log.info('updateEvent ok', { data: { id: data.id } });
|
||||||
|
return data as unknown as Event;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEvent(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
eventId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('events')
|
||||||
|
.delete()
|
||||||
|
.eq('id', eventId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('deleteEvent failed', { error, data: { eventId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
log.info('deleteEvent ok', { data: { eventId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchEventMembers(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
eventId: string
|
||||||
|
): Promise<EventMemberWithDetails[]> {
|
||||||
|
const { data: members, error } = await supabase
|
||||||
|
.from('event_members')
|
||||||
|
.select('*')
|
||||||
|
.eq('event_id', eventId)
|
||||||
|
.order('assigned_at');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('fetchEventMembers failed', { error, data: { eventId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!members || members.length === 0) return [];
|
||||||
|
|
||||||
|
// Fetch profiles separately (same pattern as org_members)
|
||||||
|
const userIds = members.map((m: any) => m.user_id);
|
||||||
|
const { data: profiles } = await (supabase as any)
|
||||||
|
.from('profiles')
|
||||||
|
.select('id, email, full_name, avatar_url, phone, discord_handle, shirt_size, hoodie_size')
|
||||||
|
.in('id', userIds);
|
||||||
|
|
||||||
|
const profileMap = Object.fromEntries((profiles ?? []).map((p: any) => [p.id, p]));
|
||||||
|
|
||||||
|
// Fetch roles for this event
|
||||||
|
const { data: roles } = await (supabase as any)
|
||||||
|
.from('event_roles')
|
||||||
|
.select('*')
|
||||||
|
.eq('event_id', eventId);
|
||||||
|
const roleMap = Object.fromEntries((roles ?? []).map((r: any) => [r.id, r]));
|
||||||
|
|
||||||
|
// Fetch member-department assignments
|
||||||
|
const memberIds = members.map((m: any) => m.id);
|
||||||
|
const { data: memberDepts } = await (supabase as any)
|
||||||
|
.from('event_member_departments')
|
||||||
|
.select('*')
|
||||||
|
.in('event_member_id', memberIds);
|
||||||
|
|
||||||
|
// Fetch departments for this event
|
||||||
|
const { data: departments } = await (supabase as any)
|
||||||
|
.from('event_departments')
|
||||||
|
.select('*')
|
||||||
|
.eq('event_id', eventId);
|
||||||
|
const deptMap = Object.fromEntries((departments ?? []).map((d: any) => [d.id, d]));
|
||||||
|
|
||||||
|
// Build member-to-departments map
|
||||||
|
const memberDeptMap: Record<string, EventDepartment[]> = {};
|
||||||
|
for (const md of (memberDepts ?? [])) {
|
||||||
|
const dept = deptMap[(md as any).department_id];
|
||||||
|
if (dept) {
|
||||||
|
if (!memberDeptMap[(md as any).event_member_id]) memberDeptMap[(md as any).event_member_id] = [];
|
||||||
|
memberDeptMap[(md as any).event_member_id].push(dept as unknown as EventDepartment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return members.map((m: any) => ({
|
||||||
|
...m,
|
||||||
|
profile: profileMap[m.user_id] ?? undefined,
|
||||||
|
event_role: m.role_id ? (roleMap[m.role_id] as unknown as EventRole) ?? undefined : undefined,
|
||||||
|
departments: memberDeptMap[m.id] ?? [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addEventMember(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
eventId: string,
|
||||||
|
userId: string,
|
||||||
|
params: { role?: 'lead' | 'manager' | 'member'; role_id?: string; notes?: string } = {}
|
||||||
|
): Promise<EventMember> {
|
||||||
|
const { data, error } = await (supabase as any)
|
||||||
|
.from('event_members')
|
||||||
|
.upsert({
|
||||||
|
event_id: eventId,
|
||||||
|
user_id: userId,
|
||||||
|
role: params.role ?? 'member',
|
||||||
|
role_id: params.role_id ?? null,
|
||||||
|
notes: params.notes ?? null,
|
||||||
|
}, { onConflict: 'event_id,user_id' })
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('addEventMember failed', { error, data: { eventId, userId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return data as unknown as EventMember;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeEventMember(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
eventId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('event_members')
|
||||||
|
.delete()
|
||||||
|
.eq('event_id', eventId)
|
||||||
|
.eq('user_id', userId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('removeEventMember failed', { error, data: { eventId, userId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Event Roles
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function fetchEventRoles(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
eventId: string
|
||||||
|
): Promise<EventRole[]> {
|
||||||
|
const { data, error } = await (supabase as any)
|
||||||
|
.from('event_roles')
|
||||||
|
.select('*')
|
||||||
|
.eq('event_id', eventId)
|
||||||
|
.order('sort_order');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('fetchEventRoles failed', { error, data: { eventId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return (data ?? []) as unknown as EventRole[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEventRole(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
eventId: string,
|
||||||
|
params: { name: string; color?: string; sort_order?: number }
|
||||||
|
): Promise<EventRole> {
|
||||||
|
const { data, error } = await (supabase as any)
|
||||||
|
.from('event_roles')
|
||||||
|
.insert({
|
||||||
|
event_id: eventId,
|
||||||
|
name: params.name,
|
||||||
|
color: params.color ?? '#6366f1',
|
||||||
|
sort_order: params.sort_order ?? 0,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('createEventRole failed', { error, data: { eventId, name: params.name } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return data as unknown as EventRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEventRole(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
roleId: string,
|
||||||
|
params: Partial<Pick<EventRole, 'name' | 'color' | 'sort_order' | 'is_default'>>
|
||||||
|
): Promise<EventRole> {
|
||||||
|
const { data, error } = await (supabase as any)
|
||||||
|
.from('event_roles')
|
||||||
|
.update(params)
|
||||||
|
.eq('id', roleId)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('updateEventRole failed', { error, data: { roleId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return data as unknown as EventRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEventRole(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
roleId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const { error } = await (supabase as any)
|
||||||
|
.from('event_roles')
|
||||||
|
.delete()
|
||||||
|
.eq('id', roleId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('deleteEventRole failed', { error, data: { roleId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Event Departments
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function fetchEventDepartments(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
eventId: string
|
||||||
|
): Promise<EventDepartment[]> {
|
||||||
|
const { data, error } = await (supabase as any)
|
||||||
|
.from('event_departments')
|
||||||
|
.select('*')
|
||||||
|
.eq('event_id', eventId)
|
||||||
|
.order('sort_order');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('fetchEventDepartments failed', { error, data: { eventId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return (data ?? []) as unknown as EventDepartment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEventDepartment(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
eventId: string,
|
||||||
|
params: { name: string; color?: string; description?: string; sort_order?: number }
|
||||||
|
): Promise<EventDepartment> {
|
||||||
|
const { data, error } = await (supabase as any)
|
||||||
|
.from('event_departments')
|
||||||
|
.insert({
|
||||||
|
event_id: eventId,
|
||||||
|
name: params.name,
|
||||||
|
color: params.color ?? '#00A3E0',
|
||||||
|
description: params.description ?? null,
|
||||||
|
sort_order: params.sort_order ?? 0,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('createEventDepartment failed', { error, data: { eventId, name: params.name } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return data as unknown as EventDepartment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEventDepartment(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
deptId: string,
|
||||||
|
params: Partial<Pick<EventDepartment, 'name' | 'color' | 'description' | 'sort_order'>>
|
||||||
|
): Promise<EventDepartment> {
|
||||||
|
const { data, error } = await (supabase as any)
|
||||||
|
.from('event_departments')
|
||||||
|
.update(params)
|
||||||
|
.eq('id', deptId)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('updateEventDepartment failed', { error, data: { deptId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return data as unknown as EventDepartment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEventDepartment(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
deptId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const { error } = await (supabase as any)
|
||||||
|
.from('event_departments')
|
||||||
|
.delete()
|
||||||
|
.eq('id', deptId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('deleteEventDepartment failed', { error, data: { deptId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Member-Department Assignments
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function assignMemberDepartment(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
eventMemberId: string,
|
||||||
|
departmentId: string
|
||||||
|
): Promise<EventMemberDepartment> {
|
||||||
|
const { data, error } = await (supabase as any)
|
||||||
|
.from('event_member_departments')
|
||||||
|
.upsert(
|
||||||
|
{ event_member_id: eventMemberId, department_id: departmentId },
|
||||||
|
{ onConflict: 'event_member_id,department_id' }
|
||||||
|
)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('assignMemberDepartment failed', { error, data: { eventMemberId, departmentId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return data as unknown as EventMemberDepartment;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unassignMemberDepartment(
|
||||||
|
supabase: SupabaseClient<Database>,
|
||||||
|
eventMemberId: string,
|
||||||
|
departmentId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const { error } = await (supabase as any)
|
||||||
|
.from('event_member_departments')
|
||||||
|
.delete()
|
||||||
|
.eq('event_member_id', eventMemberId)
|
||||||
|
.eq('department_id', departmentId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
log.error('unassignMemberDepartment failed', { error, data: { eventMemberId, departmentId } });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,63 +123,63 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col h-full gap-2">
|
<div class="flex flex-col h-full">
|
||||||
<!-- Navigation bar -->
|
<!-- Navigation bar -->
|
||||||
<div class="flex items-center justify-between px-2">
|
<div class="flex items-center justify-between px-4 py-2 shrink-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-1">
|
||||||
<button
|
<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}
|
onclick={prev}
|
||||||
aria-label="Previous"
|
aria-label="Previous"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="material-symbols-rounded"
|
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
|
>chevron_left</span
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
<span
|
<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
|
>{headerTitle}</span
|
||||||
>
|
>
|
||||||
<button
|
<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}
|
onclick={next}
|
||||||
aria-label="Next"
|
aria-label="Next"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="material-symbols-rounded"
|
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
|
>chevron_right</span
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
<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}
|
onclick={goToToday}
|
||||||
>
|
>
|
||||||
Today
|
Today
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<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'
|
'day'
|
||||||
? 'bg-primary text-night'
|
? 'bg-primary text-background'
|
||||||
: 'text-light/60 hover:text-light'}"
|
: 'text-light/50 hover:text-white'}"
|
||||||
onclick={() => (currentView = "day")}>Day</button
|
onclick={() => (currentView = "day")}>Day</button
|
||||||
>
|
>
|
||||||
<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'
|
'week'
|
||||||
? 'bg-primary text-night'
|
? 'bg-primary text-background'
|
||||||
: 'text-light/60 hover:text-light'}"
|
: 'text-light/50 hover:text-white'}"
|
||||||
onclick={() => (currentView = "week")}>Week</button
|
onclick={() => (currentView = "week")}>Week</button
|
||||||
>
|
>
|
||||||
<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'
|
'month'
|
||||||
? 'bg-primary text-night'
|
? 'bg-primary text-background'
|
||||||
: 'text-light/60 hover:text-light'}"
|
: 'text-light/50 hover:text-white'}"
|
||||||
onclick={() => (currentView = "month")}>Month</button
|
onclick={() => (currentView = "month")}>Month</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -187,48 +187,40 @@
|
|||||||
|
|
||||||
<!-- Month View -->
|
<!-- Month View -->
|
||||||
{#if currentView === "month"}
|
{#if currentView === "month"}
|
||||||
<div
|
<div class="flex flex-col flex-1 min-h-0">
|
||||||
class="flex flex-col flex-1 gap-2 min-h-0 bg-background rounded-xl p-2"
|
|
||||||
>
|
|
||||||
<!-- Day Headers -->
|
<!-- 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}
|
{#each weekDayHeaders as day}
|
||||||
<div class="flex items-center justify-center py-2 px-2">
|
<div class="flex items-center justify-center py-2">
|
||||||
<span
|
<span class="font-body text-[11px] text-light/40 uppercase tracking-wider">{day}</span>
|
||||||
class="font-heading text-h4 text-white text-center"
|
|
||||||
>{day}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Calendar Grid -->
|
<!-- Calendar Grid -->
|
||||||
<div
|
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
class="flex-1 flex flex-col gap-2 min-h-0 rounded-lg overflow-hidden"
|
|
||||||
>
|
|
||||||
{#each weeks as week}
|
{#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}
|
{#each week as day}
|
||||||
{@const dayEvents = getEventsForDay(day)}
|
{@const dayEvents = getEventsForDay(day)}
|
||||||
{@const isToday = isSameDay(day, today)}
|
{@const isToday = isSameDay(day, today)}
|
||||||
{@const inMonth = isCurrentMonth(day)}
|
{@const inMonth = isCurrentMonth(day)}
|
||||||
<div
|
<button
|
||||||
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
|
type="button"
|
||||||
{!inMonth ? 'opacity-50' : ''}"
|
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)}
|
onclick={() => onDateClick?.(day)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="font-body text-body text-white {isToday
|
class="text-[12px] font-body w-6 h-6 flex items-center justify-center rounded-full shrink-0
|
||||||
? 'text-primary font-bold'
|
{isToday ? 'bg-primary text-background font-bold' : 'text-light/60'}"
|
||||||
: ''}"
|
|
||||||
>
|
>
|
||||||
{day.getDate()}
|
{day.getDate()}
|
||||||
</span>
|
</span>
|
||||||
{#each dayEvents.slice(0, 2) as event}
|
{#each dayEvents.slice(0, 2) as event}
|
||||||
<button
|
<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"
|
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 ??
|
style="background-color: {event.color ?? '#00A3E0'}"
|
||||||
'#00A3E0'}"
|
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onEventClick?.(event);
|
onEventClick?.(event);
|
||||||
@@ -238,12 +230,9 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{#if dayEvents.length > 2}
|
{#if dayEvents.length > 2}
|
||||||
<span
|
<span class="text-[10px] text-light/30 mt-0.5 px-1">+{dayEvents.length - 2}</span>
|
||||||
class="text-body-sm text-light/40 mt-0.5"
|
|
||||||
>+{dayEvents.length - 2} more</span
|
|
||||||
>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -253,40 +242,25 @@
|
|||||||
|
|
||||||
<!-- Week View -->
|
<!-- Week View -->
|
||||||
{#if currentView === "week"}
|
{#if currentView === "week"}
|
||||||
<div
|
<div class="flex flex-col flex-1 min-h-0">
|
||||||
class="flex flex-col flex-1 gap-2 min-h-0 bg-background rounded-xl p-2"
|
<div class="grid grid-cols-7 flex-1 overflow-hidden">
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="grid grid-cols-7 gap-2 flex-1 rounded-lg overflow-hidden"
|
|
||||||
>
|
|
||||||
{#each weekDates as day}
|
{#each weekDates as day}
|
||||||
{@const dayEvents = getEventsForDay(day)}
|
{@const dayEvents = getEventsForDay(day)}
|
||||||
{@const isToday = isSameDay(day, today)}
|
{@const isToday = isSameDay(day, today)}
|
||||||
<div class="flex flex-col overflow-hidden">
|
<div class="flex flex-col overflow-hidden border-r border-light/5 last:border-r-0">
|
||||||
<div class="px-2 py-2 text-center">
|
<div class="px-2 py-2 text-center border-b border-light/5">
|
||||||
<div
|
<div class="text-[11px] font-body uppercase tracking-wider {isToday ? 'text-primary' : 'text-light/40'}">
|
||||||
class="font-heading text-h4 {isToday
|
|
||||||
? 'text-primary'
|
|
||||||
: 'text-white'}"
|
|
||||||
>
|
|
||||||
{weekDayHeaders[(day.getDay() + 6) % 7]}
|
{weekDayHeaders[(day.getDay() + 6) % 7]}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="text-body-sm font-heading mt-0.5 {isToday ? 'text-primary' : 'text-white'}">
|
||||||
class="font-body text-body-md {isToday
|
|
||||||
? 'text-primary'
|
|
||||||
: 'text-light/60'}"
|
|
||||||
>
|
|
||||||
{day.getDate()}
|
{day.getDate()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="flex-1 px-1.5 py-1.5 space-y-1 overflow-y-auto">
|
||||||
class="bg-night flex-1 px-2 pb-2 space-y-1 overflow-y-auto"
|
|
||||||
>
|
|
||||||
{#each dayEvents as event}
|
{#each dayEvents as event}
|
||||||
<button
|
<button
|
||||||
class="w-full px-2 py-1.5 rounded-[4px] text-body-sm font-bold font-body text-night truncate text-left"
|
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 ??
|
style="background-color: {event.color ?? '#00A3E0'}"
|
||||||
'#00A3E0'}"
|
|
||||||
onclick={() => onEventClick?.(event)}
|
onclick={() => onEventClick?.(event)}
|
||||||
>
|
>
|
||||||
{event.title}
|
{event.title}
|
||||||
@@ -302,27 +276,24 @@
|
|||||||
<!-- Day View -->
|
<!-- Day View -->
|
||||||
{#if currentView === "day"}
|
{#if currentView === "day"}
|
||||||
{@const dayEvents = getEventsForDay(currentDate)}
|
{@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}
|
{#if dayEvents.length === 0}
|
||||||
<div class="text-center text-light/40 py-12">
|
<div class="flex flex-col items-center justify-center h-full text-light/40">
|
||||||
<p class="font-body text-body">No events for this day</p>
|
<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>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each dayEvents as event}
|
{#each dayEvents as event}
|
||||||
<button
|
<button
|
||||||
class="w-full text-left p-3 rounded-[8px] transition-colors hover:opacity-80"
|
class="w-full text-left p-3 rounded-xl border border-light/5 hover:border-light/10 transition-all"
|
||||||
style="background-color: {event.color ??
|
style="border-left: 3px solid {event.color ?? '#00A3E0'}"
|
||||||
'#00A3E0'}20; border-left: 3px solid {event.color ??
|
|
||||||
'#00A3E0'}"
|
|
||||||
onclick={() => onEventClick?.(event)}
|
onclick={() => onEventClick?.(event)}
|
||||||
>
|
>
|
||||||
<div class="font-heading text-h5 text-white">
|
<div class="font-heading text-body-sm text-white">
|
||||||
{event.title}
|
{event.title}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="text-[12px] text-light/40 mt-1">
|
||||||
class="font-body text-body-md text-light/60 mt-1"
|
|
||||||
>
|
|
||||||
{new Date(event.start_time).toLocaleTimeString(
|
{new Date(event.start_time).toLocaleTimeString(
|
||||||
"en-US",
|
"en-US",
|
||||||
{ hour: "numeric", minute: "2-digit" },
|
{ hour: "numeric", minute: "2-digit" },
|
||||||
@@ -333,9 +304,7 @@
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{#if event.description}
|
{#if event.description}
|
||||||
<div
|
<div class="text-[12px] text-light/30 mt-1.5 line-clamp-2">
|
||||||
class="font-body text-body-md text-light/50 mt-2"
|
|
||||||
>
|
|
||||||
{event.description}
|
{event.description}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -42,17 +42,15 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="flex flex-col min-w-0 h-full overflow-hidden">
|
||||||
class="bg-night rounded-[32px] overflow-hidden flex flex-col min-w-0 h-full"
|
|
||||||
>
|
|
||||||
<!-- Lock Banner -->
|
<!-- Lock Banner -->
|
||||||
{#if locked}
|
{#if locked}
|
||||||
<div
|
<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
|
<span
|
||||||
class="material-symbols-rounded text-warning"
|
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
|
lock
|
||||||
</span>
|
</span>
|
||||||
@@ -64,42 +62,35 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="flex items-center gap-2 px-4 py-5">
|
<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-h1 text-white truncate">
|
<h2 class="flex-1 font-heading text-body-sm text-white truncate">
|
||||||
{document.name}
|
{document.name}
|
||||||
</h2>
|
</h2>
|
||||||
{#if locked}
|
{#if locked}
|
||||||
<Button size="md" disabled>
|
<Button size="sm" disabled>Locked</Button>
|
||||||
<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>
|
|
||||||
{:else if mode === "edit"}
|
{:else if mode === "edit"}
|
||||||
<Button size="md" onclick={handleEditClick}>
|
<Button size="sm" onclick={handleEditClick}>
|
||||||
{isEditing ? "Preview" : "Edit"}
|
{isEditing ? "Preview" : "Edit"}
|
||||||
</Button>
|
</Button>
|
||||||
{:else}
|
{:else}
|
||||||
<Button size="md" onclick={handleEditClick}>Edit</Button>
|
<Button size="sm" onclick={handleEditClick}>Edit</Button>
|
||||||
{/if}
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="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"
|
aria-label="More options"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="material-symbols-rounded text-light"
|
class="material-symbols-rounded text-light/40 hover:text-white"
|
||||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
||||||
>
|
>
|
||||||
more_horiz
|
more_horiz
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</div>
|
||||||
|
|
||||||
<!-- Editor Area -->
|
<!-- 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} />
|
<Editor {document} {onSave} editable={isEditing} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,9 +5,6 @@
|
|||||||
Button,
|
Button,
|
||||||
Modal,
|
Modal,
|
||||||
Input,
|
Input,
|
||||||
Avatar,
|
|
||||||
IconButton,
|
|
||||||
Icon,
|
|
||||||
} from "$lib/components/ui";
|
} from "$lib/components/ui";
|
||||||
import { DocumentViewer } from "$lib/components/documents";
|
import { DocumentViewer } from "$lib/components/documents";
|
||||||
import { createLogger } from "$lib/utils/logger";
|
import { createLogger } from "$lib/utils/logger";
|
||||||
@@ -490,97 +487,101 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full gap-4">
|
<div class="flex h-full gap-0">
|
||||||
<!-- Files Panel -->
|
<!-- Files Panel -->
|
||||||
<div
|
<div class="flex flex-col flex-1 min-w-0 h-full overflow-hidden">
|
||||||
class="bg-night rounded-[32px] flex flex-col gap-4 px-4 py-5 overflow-hidden flex-1 min-w-0 h-full"
|
<!-- Toolbar: Breadcrumbs + Actions -->
|
||||||
>
|
<div class="flex items-center gap-2 px-6 py-3 border-b border-light/5 shrink-0">
|
||||||
<!-- Header -->
|
<!-- Breadcrumb Path -->
|
||||||
<header class="flex items-center gap-2 p-1">
|
<nav class="flex items-center gap-1 flex-1 min-w-0 overflow-x-auto">
|
||||||
<Avatar name={title} size="md" />
|
{#each breadcrumbPath as crumb, i}
|
||||||
<h1 class="flex-1 font-heading text-h1 text-white">{title}</h1>
|
{#if i > 0}
|
||||||
<Button size="md" onclick={handleAdd}>{m.btn_new()}</Button>
|
<span
|
||||||
<IconButton title={m.files_toggle_view()} onclick={toggleViewMode}>
|
class="material-symbols-rounded text-light/20 shrink-0"
|
||||||
<Icon
|
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
||||||
name={viewMode === "list" ? "grid_view" : "view_list"}
|
>
|
||||||
size={24}
|
chevron_right
|
||||||
/>
|
</span>
|
||||||
</IconButton>
|
{/if}
|
||||||
</header>
|
<a
|
||||||
|
href={getFolderUrl(crumb.id)}
|
||||||
<!-- Breadcrumb Path -->
|
class="px-2 py-1 rounded-lg text-body-sm font-body whitespace-nowrap transition-colors
|
||||||
<nav class="flex items-center gap-2 text-h3 font-heading">
|
{crumb.id === currentFolderId
|
||||||
{#each breadcrumbPath as crumb, i}
|
? 'text-white bg-dark/30'
|
||||||
{#if i > 0}
|
: 'text-light/50 hover:text-white hover:bg-dark/30'}
|
||||||
<span
|
{dragOverBreadcrumb === (crumb.id ?? '__root__')
|
||||||
class="material-symbols-rounded text-light/30"
|
? 'ring-2 ring-primary bg-primary/10'
|
||||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
: ''}"
|
||||||
>
|
ondragover={(e) => {
|
||||||
chevron_right
|
e.preventDefault();
|
||||||
</span>
|
e.stopPropagation();
|
||||||
{/if}
|
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
||||||
<a
|
dragOverBreadcrumb = crumb.id ?? "__root__";
|
||||||
href={getFolderUrl(crumb.id)}
|
}}
|
||||||
class="px-3 py-1 rounded-xl transition-colors
|
ondragleave={() => {
|
||||||
{crumb.id === currentFolderId
|
dragOverBreadcrumb = undefined;
|
||||||
? 'text-white'
|
}}
|
||||||
: 'text-light/60 hover:text-primary'}
|
ondrop={async (e) => {
|
||||||
{dragOverBreadcrumb === (crumb.id ?? '__root__')
|
e.preventDefault();
|
||||||
? 'ring-2 ring-primary bg-primary/10'
|
e.stopPropagation();
|
||||||
: ''}"
|
dragOverBreadcrumb = undefined;
|
||||||
ondragover={(e) => {
|
if (!draggedItem) return;
|
||||||
e.preventDefault();
|
if (draggedItem.parent_id === crumb.id) {
|
||||||
e.stopPropagation();
|
resetDragState();
|
||||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
|
return;
|
||||||
dragOverBreadcrumb = crumb.id ?? "__root__";
|
}
|
||||||
}}
|
const draggedName = draggedItem.name;
|
||||||
ondragleave={() => {
|
await handleMove(draggedItem.id, crumb.id);
|
||||||
dragOverBreadcrumb = undefined;
|
toasts.success(
|
||||||
}}
|
`Moved "${draggedName}" to "${crumb.name}"`,
|
||||||
ondrop={async (e) => {
|
);
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
dragOverBreadcrumb = undefined;
|
|
||||||
if (!draggedItem) return;
|
|
||||||
if (draggedItem.parent_id === crumb.id) {
|
|
||||||
resetDragState();
|
resetDragState();
|
||||||
return;
|
}}
|
||||||
}
|
>
|
||||||
const draggedName = draggedItem.name;
|
{#if i === 0}
|
||||||
await handleMove(draggedItem.id, crumb.id);
|
<span class="material-symbols-rounded" style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;">home</span>
|
||||||
toasts.success(
|
{:else}
|
||||||
`Moved "${draggedName}" to "${crumb.name}"`,
|
{crumb.name}
|
||||||
);
|
{/if}
|
||||||
resetDragState();
|
</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
|
||||||
>
|
>
|
||||||
{crumb.name}
|
</button>
|
||||||
</a>
|
</div>
|
||||||
{/each}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- File List/Grid -->
|
<!-- 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"}
|
{#if viewMode === "list"}
|
||||||
<div
|
<div
|
||||||
class="flex flex-col gap-1"
|
class="flex flex-col gap-0.5"
|
||||||
ondragover={handleContainerDragOver}
|
ondragover={handleContainerDragOver}
|
||||||
ondrop={handleDropOnEmpty}
|
ondrop={handleDropOnEmpty}
|
||||||
role="list"
|
role="list"
|
||||||
>
|
>
|
||||||
{#if currentFolderItems.length === 0}
|
{#if currentFolderItems.length === 0}
|
||||||
<div class="text-center text-light/40 py-8 text-sm">
|
<div class="flex flex-col items-center justify-center text-light/40 py-16">
|
||||||
<p>
|
<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>
|
||||||
No files yet. Drag files here or create a new
|
<p class="text-body-sm">{m.files_empty()}</p>
|
||||||
one.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each currentFolderItems as item}
|
{#each currentFolderItems as item}
|
||||||
<button
|
<button
|
||||||
type="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
|
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' : ''}
|
{selectedDoc?.id === item.id ? 'bg-dark/50 ring-1 ring-primary/20' : ''}
|
||||||
{draggedItem?.id === item.id ? 'opacity-50' : ''}
|
{draggedItem?.id === item.id ? 'opacity-50' : ''}
|
||||||
{dragOverFolder === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}"
|
{dragOverFolder === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@@ -595,24 +596,20 @@
|
|||||||
oncontextmenu={(e) =>
|
oncontextmenu={(e) =>
|
||||||
handleContextMenu(e, item)}
|
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;"
|
|
||||||
>
|
|
||||||
{getDocIcon(item)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span
|
<span
|
||||||
class="font-body text-body text-white truncate flex-1"
|
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>
|
||||||
|
<span
|
||||||
|
class="font-body text-body-sm text-white truncate flex-1"
|
||||||
>{item.name}</span
|
>{item.name}</span
|
||||||
>
|
>
|
||||||
{#if item.type === "folder"}
|
{#if item.type === "folder"}
|
||||||
<span
|
<span
|
||||||
class="material-symbols-rounded text-light/50"
|
class="material-symbols-rounded text-light/20"
|
||||||
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
||||||
>
|
>
|
||||||
chevron_right
|
chevron_right
|
||||||
</span>
|
</span>
|
||||||
@@ -624,26 +621,22 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<!-- Grid View -->
|
<!-- Grid View -->
|
||||||
<div
|
<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}
|
ondragover={handleContainerDragOver}
|
||||||
ondrop={handleDropOnEmpty}
|
ondrop={handleDropOnEmpty}
|
||||||
role="list"
|
role="list"
|
||||||
>
|
>
|
||||||
{#if currentFolderItems.length === 0}
|
{#if currentFolderItems.length === 0}
|
||||||
<div
|
<div class="col-span-full flex flex-col items-center justify-center text-light/40 py-16">
|
||||||
class="col-span-full text-center text-light/40 py-8 text-sm"
|
<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>
|
||||||
<p>
|
|
||||||
No files yet. Drag files here or create a new
|
|
||||||
one.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each currentFolderItems as item}
|
{#each currentFolderItems as item}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex flex-col items-center gap-2 p-4 rounded-xl transition-colors hover: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' : ''}
|
{selectedDoc?.id === item.id ? 'bg-dark/50 border-primary/20' : ''}
|
||||||
{draggedItem?.id === item.id ? 'opacity-50' : ''}
|
{draggedItem?.id === item.id ? 'opacity-50' : ''}
|
||||||
{dragOverFolder === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}"
|
{dragOverFolder === item.id ? 'ring-2 ring-primary bg-primary/10' : ''}"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@@ -659,13 +652,13 @@
|
|||||||
handleContextMenu(e, item)}
|
handleContextMenu(e, item)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="material-symbols-rounded text-light"
|
class="material-symbols-rounded {item.type === 'folder' ? 'text-amber-400' : item.type === 'kanban' ? 'text-purple-400' : 'text-light/40'}"
|
||||||
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
|
style="font-size: 40px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 40;"
|
||||||
>
|
>
|
||||||
{getDocIcon(item)}
|
{getDocIcon(item)}
|
||||||
</span>
|
</span>
|
||||||
<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
|
>{item.name}</span
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
@@ -678,7 +671,7 @@
|
|||||||
|
|
||||||
<!-- Compact Editor Panel (shown when a doc is selected) -->
|
<!-- Compact Editor Panel (shown when a doc is selected) -->
|
||||||
{#if selectedDoc}
|
{#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
|
<DocumentViewer
|
||||||
document={selectedDoc}
|
document={selectedDoc}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="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}
|
class:opacity-50={isDragging}
|
||||||
{draggable}
|
{draggable}
|
||||||
{ondragstart}
|
{ondragstart}
|
||||||
@@ -67,25 +67,25 @@
|
|||||||
{#if ondelete}
|
{#if ondelete}
|
||||||
<button
|
<button
|
||||||
type="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}
|
onclick={handleDelete}
|
||||||
aria-label="Delete card"
|
aria-label="Delete card"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="material-symbols-rounded text-light/40 hover:text-error"
|
class="material-symbols-rounded text-light/30 hover:text-error"
|
||||||
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||||
>
|
>
|
||||||
delete
|
close
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Tags / Chips -->
|
<!-- Tags / Chips -->
|
||||||
{#if card.tags && card.tags.length > 0}
|
{#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}
|
{#each card.tags as tag}
|
||||||
<span
|
<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'}"
|
style="background-color: {tag.color || '#00A3E0'}"
|
||||||
>
|
>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
@@ -95,55 +95,40 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Title -->
|
<!-- 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}
|
{card.title}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Bottom row: details + avatar -->
|
<!-- Bottom row: details + avatar -->
|
||||||
{#if hasFooter}
|
{#if hasFooter}
|
||||||
<div class="flex items-center justify-between w-full">
|
<div class="flex items-center justify-between w-full mt-0.5">
|
||||||
<div class="flex gap-1 items-center">
|
<div class="flex gap-2 items-center text-[11px] text-light/40">
|
||||||
<!-- Due date -->
|
|
||||||
{#if card.due_date}
|
{#if card.due_date}
|
||||||
<div class="flex items-center">
|
<span class="flex items-center gap-0.5">
|
||||||
<span
|
<span
|
||||||
class="material-symbols-rounded text-light p-1"
|
class="material-symbols-rounded"
|
||||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
|
||||||
>
|
>calendar_today</span>
|
||||||
calendar_today
|
{formatDueDate(card.due_date)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
|
||||||
class="font-body text-[12px] text-light leading-none"
|
|
||||||
>
|
|
||||||
{formatDueDate(card.due_date)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Checklist -->
|
|
||||||
{#if (card.checklist_total ?? 0) > 0}
|
{#if (card.checklist_total ?? 0) > 0}
|
||||||
<div class="flex items-center">
|
<span class="flex items-center gap-0.5">
|
||||||
<span
|
<span
|
||||||
class="material-symbols-rounded text-light p-1"
|
class="material-symbols-rounded"
|
||||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
|
||||||
>
|
>check_box</span>
|
||||||
check_box
|
{card.checklist_done ?? 0}/{card.checklist_total}
|
||||||
</span>
|
</span>
|
||||||
<span
|
|
||||||
class="font-body text-[12px] text-light leading-none"
|
|
||||||
>
|
|
||||||
{card.checklist_done ?? 0}/{card.checklist_total}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Assignee avatar -->
|
|
||||||
{#if card.assignee_id}
|
{#if card.assignee_id}
|
||||||
<Avatar
|
<Avatar
|
||||||
name={card.assignee_name || "?"}
|
name={card.assignee_name || "?"}
|
||||||
src={card.assignee_avatar}
|
src={card.assignee_avatar}
|
||||||
size="sm"
|
size="xs"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
111
src/lib/components/message/utils/markdown.test.ts
Normal file
111
src/lib/components/message/utils/markdown.test.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { renderMentions, isEmojiOnly, formatTime, formatFileSize } from './markdown';
|
||||||
|
|
||||||
|
describe('markdown utils', () => {
|
||||||
|
describe('renderMentions', () => {
|
||||||
|
it('renders @user:server.com as a mention button', () => {
|
||||||
|
const result = renderMentions('Hello @alice:matrix.org');
|
||||||
|
expect(result).toContain('class="mention-ping"');
|
||||||
|
expect(result).toContain('data-user-id="@alice:matrix.org"');
|
||||||
|
expect(result).toContain('@alice</button>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders @everyone as a special mention', () => {
|
||||||
|
const result = renderMentions('Hey @everyone');
|
||||||
|
expect(result).toContain('mention-everyone');
|
||||||
|
expect(result).toContain('@everyone');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders @here as a special mention', () => {
|
||||||
|
const result = renderMentions('Attention @here');
|
||||||
|
expect(result).toContain('mention-everyone');
|
||||||
|
expect(result).toContain('@here');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders @room as a special mention', () => {
|
||||||
|
const result = renderMentions('FYI @room');
|
||||||
|
expect(result).toContain('mention-everyone');
|
||||||
|
expect(result).toContain('@room');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves plain text unchanged', () => {
|
||||||
|
const result = renderMentions('Hello world');
|
||||||
|
expect(result).toBe('Hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple mentions', () => {
|
||||||
|
const result = renderMentions('@alice:matrix.org and @bob:example.com');
|
||||||
|
expect(result).toContain('data-user-id="@alice:matrix.org"');
|
||||||
|
expect(result).toContain('data-user-id="@bob:example.com"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isEmojiOnly', () => {
|
||||||
|
it('returns true for single emoji', () => {
|
||||||
|
expect(isEmojiOnly('😀')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for multiple emojis', () => {
|
||||||
|
expect(isEmojiOnly('😀🎉🔥')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true for emojis with spaces', () => {
|
||||||
|
expect(isEmojiOnly('😀 🎉')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for text with emoji', () => {
|
||||||
|
expect(isEmojiOnly('hello 😀')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for plain text', () => {
|
||||||
|
expect(isEmojiOnly('hello world')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for empty string', () => {
|
||||||
|
expect(isEmojiOnly('')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for whitespace only', () => {
|
||||||
|
expect(isEmojiOnly(' ')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatTime', () => {
|
||||||
|
it('formats timestamp to HH:MM', () => {
|
||||||
|
// Create a date at 14:30
|
||||||
|
const date = new Date(2024, 0, 15, 14, 30, 0);
|
||||||
|
const result = formatTime(date.getTime());
|
||||||
|
expect(result).toMatch(/14:30/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats midnight correctly', () => {
|
||||||
|
const date = new Date(2024, 0, 15, 0, 0, 0);
|
||||||
|
const result = formatTime(date.getTime());
|
||||||
|
expect(result).toMatch(/00:00/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatFileSize', () => {
|
||||||
|
it('returns empty string for undefined', () => {
|
||||||
|
expect(formatFileSize(undefined)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string for 0', () => {
|
||||||
|
expect(formatFileSize(0)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats bytes', () => {
|
||||||
|
expect(formatFileSize(500)).toBe('500 B');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats kilobytes', () => {
|
||||||
|
expect(formatFileSize(1024)).toBe('1.0 KB');
|
||||||
|
expect(formatFileSize(1536)).toBe('1.5 KB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats megabytes', () => {
|
||||||
|
expect(formatFileSize(1024 * 1024)).toBe('1.0 MB');
|
||||||
|
expect(formatFileSize(1.5 * 1024 * 1024)).toBe('1.5 MB');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -124,93 +124,88 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-8">
|
<div class="flex flex-col gap-6 max-w-2xl">
|
||||||
<!-- Organization Details -->
|
<!-- 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">
|
<!-- Avatar Upload -->
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-2">
|
||||||
<!-- Avatar Upload -->
|
<span class="font-body text-body-sm text-light/60">Avatar</span>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex items-center gap-4">
|
||||||
<span class="font-body text-body-sm text-light">Avatar</span>
|
<Avatar name={orgName || "?"} src={avatarUrl} size="lg" />
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex gap-2">
|
||||||
<Avatar name={orgName || "?"} src={avatarUrl} size="lg" />
|
<input
|
||||||
<div class="flex gap-2">
|
type="file"
|
||||||
<input
|
accept="image/*"
|
||||||
type="file"
|
class="hidden"
|
||||||
accept="image/*"
|
bind:this={avatarInput}
|
||||||
class="hidden"
|
onchange={handleAvatarUpload}
|
||||||
bind:this={avatarInput}
|
/>
|
||||||
onchange={handleAvatarUpload}
|
<Button
|
||||||
/>
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => avatarInput?.click()}
|
||||||
|
loading={isUploading}
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
|
{#if avatarUrl}
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="tertiary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={() => avatarInput?.click()}
|
onclick={removeAvatar}
|
||||||
loading={isUploading}
|
|
||||||
>
|
>
|
||||||
Upload
|
Remove
|
||||||
</Button>
|
</Button>
|
||||||
{#if avatarUrl}
|
{/if}
|
||||||
<Button
|
|
||||||
variant="tertiary"
|
|
||||||
size="sm"
|
|
||||||
onclick={removeAvatar}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
</div>
|
||||||
label="Name"
|
<Input
|
||||||
bind:value={orgName}
|
label="Name"
|
||||||
placeholder="Organization name"
|
bind:value={orgName}
|
||||||
/>
|
placeholder="Organization name"
|
||||||
<Input
|
/>
|
||||||
label="URL slug (yoursite.com/...)"
|
<Input
|
||||||
bind:value={orgSlug}
|
label="URL slug (yoursite.com/...)"
|
||||||
placeholder="my-org"
|
bind:value={orgSlug}
|
||||||
/>
|
placeholder="my-org"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<Button size="sm" onclick={saveGeneralSettings} loading={isSaving}
|
||||||
|
>Save Changes</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Danger Zone -->
|
||||||
|
{#if isOwner}
|
||||||
|
<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>
|
<div>
|
||||||
<Button onclick={saveGeneralSettings} loading={isSaving}
|
<Button variant="danger" size="sm" onclick={onDelete}
|
||||||
>Save Changes</Button
|
>Delete Organization</Button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Danger Zone -->
|
<!-- Leave Organization (non-owners) -->
|
||||||
{#if isOwner}
|
{#if !isOwner}
|
||||||
<div class="flex flex-col gap-4">
|
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-3">
|
||||||
<h4 class="font-heading text-h4 text-white">Danger Zone</h4>
|
<h4 class="font-heading text-body-sm text-white">Leave Organization</h4>
|
||||||
<p class="font-body text-body text-white">
|
<p class="font-body text-[11px] text-light/40">
|
||||||
Permanently delete this organization and all its data.
|
Leave this organization. You will need to be re-invited to rejoin.
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<Button variant="danger" onclick={onDelete}
|
<Button variant="secondary" size="sm" onclick={onLeave}
|
||||||
>Delete Organization</Button
|
>Leave {org.name}</Button
|
||||||
>
|
>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</div>
|
||||||
|
{/if}
|
||||||
<!-- 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.
|
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
<Button variant="secondary" onclick={onLeave}
|
|
||||||
>Leave {org.name}</Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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 { toasts } from "$lib/stores/toast.svelte";
|
||||||
import {
|
import {
|
||||||
extractCalendarId,
|
extractCalendarId,
|
||||||
@@ -108,184 +108,88 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6 max-w-2xl">
|
<div class="space-y-3 max-w-2xl">
|
||||||
<Card>
|
<!-- Google Calendar -->
|
||||||
<div class="p-6">
|
<div class="bg-dark/30 border border-light/5 rounded-xl p-5">
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<div
|
<div class="w-10 h-10 bg-white rounded-xl flex items-center justify-center shrink-0">
|
||||||
class="w-12 h-12 bg-white rounded-lg flex items-center justify-center"
|
<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"/>
|
||||||
<svg class="w-8 h-8" viewBox="0 0 24 24">
|
<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
|
<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"/>
|
||||||
fill="#4285F4"
|
<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"/>
|
||||||
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"
|
</svg>
|
||||||
/>
|
</div>
|
||||||
<path
|
<div class="flex-1 min-w-0">
|
||||||
fill="#34A853"
|
<h3 class="font-heading text-body-sm text-white">Google Calendar</h3>
|
||||||
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"
|
<p class="text-[11px] text-light/40 mt-0.5">
|
||||||
/>
|
Sync events between your organization and Google Calendar.
|
||||||
<path
|
</p>
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{#if orgCalendar}
|
{#if orgCalendar}
|
||||||
<div
|
<div class="mt-3 p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
|
||||||
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">
|
||||||
>
|
<div class="min-w-0 flex-1">
|
||||||
<div
|
<p class="text-[11px] font-medium text-green-400">Connected</p>
|
||||||
class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3 bg-green-500/10 rounded-lg"
|
<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>
|
||||||
<div class="min-w-0 flex-1">
|
<p class="text-[10px] text-light/30 mt-1">Events sync both ways.</p>
|
||||||
<p
|
<a
|
||||||
class="text-sm font-medium text-green-400"
|
href="https://calendar.google.com/calendar/u/0/r?cid={encodeURIComponent(orgCalendar.calendar_id)}"
|
||||||
>
|
target="_blank"
|
||||||
Connected
|
rel="noopener noreferrer"
|
||||||
</p>
|
class="inline-flex items-center gap-1 text-[11px] text-blue-400 hover:text-blue-300 mt-1.5"
|
||||||
<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>
|
|
||||||
<a
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
Open in Google Calendar
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onclick={disconnectOrgCalendar}
|
|
||||||
>Disconnect</Button
|
|
||||||
>
|
>
|
||||||
|
<span class="material-symbols-rounded" style="font-size: 14px;">open_in_new</span>
|
||||||
|
Open in Google Calendar
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="danger" size="sm" onclick={disconnectOrgCalendar}>Disconnect</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if !serviceAccountEmail}
|
</div>
|
||||||
<div
|
{:else if !serviceAccountEmail}
|
||||||
class="mt-4 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg"
|
<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-sm text-yellow-400 font-medium">
|
<p class="text-[10px] text-light/40 mt-1">
|
||||||
Setup required
|
A server administrator needs to configure the <code class="bg-light/10 px-1 rounded">GOOGLE_SERVICE_ACCOUNT_KEY</code> environment variable.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-light/50 mt-1">
|
</div>
|
||||||
A server administrator needs to configure the <code
|
{:else}
|
||||||
class="bg-light/10 px-1 rounded"
|
<div class="mt-3">
|
||||||
>GOOGLE_SERVICE_ACCOUNT_KEY</code
|
<Button size="sm" onclick={() => (showConnectModal = true)}>Connect Google Calendar</Button>
|
||||||
> environment variable before calendars can be connected.
|
</div>
|
||||||
</p>
|
{/if}
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="mt-4">
|
|
||||||
<Button onclick={() => (showConnectModal = true)}
|
|
||||||
>Connect Google Calendar</Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<!-- Discord (coming soon) -->
|
||||||
<div class="p-6 opacity-50">
|
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 opacity-40">
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<div
|
<div class="w-10 h-10 bg-[#5865F2] rounded-xl flex items-center justify-center shrink-0">
|
||||||
class="w-12 h-12 bg-[#7289da] rounded-lg flex items-center justify-center"
|
<span class="material-symbols-rounded text-white" style="font-size: 22px;">forum</span>
|
||||||
>
|
</div>
|
||||||
<svg
|
<div class="flex-1">
|
||||||
class="w-7 h-7 text-white"
|
<h3 class="font-heading text-body-sm text-white">Discord</h3>
|
||||||
viewBox="0 0 24 24"
|
<p class="text-[11px] text-light/40 mt-0.5">Get notifications in your Discord server.</p>
|
||||||
fill="currentColor"
|
<p class="text-[10px] text-light/30 mt-1">Coming soon</p>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<!-- Slack (coming soon) -->
|
||||||
<div class="p-6 opacity-50">
|
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 opacity-40">
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<div
|
<div class="w-10 h-10 bg-[#4A154B] rounded-xl flex items-center justify-center shrink-0">
|
||||||
class="w-12 h-12 bg-[#4A154B] rounded-lg flex items-center justify-center"
|
<span class="material-symbols-rounded text-white" style="font-size: 22px;">tag</span>
|
||||||
>
|
</div>
|
||||||
<svg
|
<div class="flex-1">
|
||||||
class="w-7 h-7 text-white"
|
<h3 class="font-heading text-body-sm text-white">Slack</h3>
|
||||||
viewBox="0 0 24 24"
|
<p class="text-[11px] text-light/40 mt-0.5">Get notifications in your Slack workspace.</p>
|
||||||
fill="currentColor"
|
<p class="text-[10px] text-light/30 mt-1">Coming soon</p>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Connect Calendar Modal -->
|
<!-- Connect Calendar Modal -->
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Modal,
|
Modal,
|
||||||
Card,
|
|
||||||
Input,
|
Input,
|
||||||
Select,
|
Select,
|
||||||
Avatar,
|
Avatar,
|
||||||
@@ -169,113 +168,97 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-4 max-w-2xl">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg font-semibold text-light">
|
<div>
|
||||||
{m.settings_members_title({
|
<h2 class="font-heading text-body text-white">
|
||||||
count: String(members.length),
|
{m.settings_members_title({
|
||||||
})}
|
count: String(members.length),
|
||||||
</h2>
|
})}
|
||||||
<Button onclick={() => (showInviteModal = true)}>
|
</h2>
|
||||||
<svg
|
</div>
|
||||||
class="w-4 h-4 mr-2"
|
<Button size="sm" icon="person_add" onclick={() => (showInviteModal = true)}>
|
||||||
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>
|
|
||||||
{m.settings_members_invite()}
|
{m.settings_members_invite()}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pending Invites -->
|
<!-- Pending Invites -->
|
||||||
{#if invites.length > 0}
|
{#if invites.length > 0}
|
||||||
<Card>
|
<div class="bg-dark/30 border border-light/5 rounded-xl p-4">
|
||||||
<div class="p-4">
|
<h3 class="text-body-sm font-heading text-light/60 mb-3">
|
||||||
<h3 class="text-sm font-medium text-light/70 mb-3">
|
{m.settings_members_pending()}
|
||||||
{m.settings_members_pending()}
|
</h3>
|
||||||
</h3>
|
<div class="space-y-2">
|
||||||
<div class="space-y-2">
|
{#each invites as invite}
|
||||||
{#each invites as invite}
|
<div
|
||||||
<div
|
class="flex items-center justify-between py-2 px-3 bg-light/5 rounded-lg"
|
||||||
class="flex items-center justify-between py-2 px-3 bg-light/5 rounded-lg"
|
>
|
||||||
>
|
<div>
|
||||||
<div>
|
<p class="text-body-sm text-white">{invite.email}</p>
|
||||||
<p class="text-light">{invite.email}</p>
|
<p class="text-[11px] text-light/40">
|
||||||
<p class="text-xs text-light/40">
|
Invited as {invite.role} • Expires {new Date(
|
||||||
Invited as {invite.role} • Expires {new Date(
|
invite.expires_at,
|
||||||
invite.expires_at,
|
).toLocaleDateString()}
|
||||||
).toLocaleDateString()}
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="tertiary"
|
|
||||||
size="sm"
|
|
||||||
onclick={() =>
|
|
||||||
navigator.clipboard.writeText(
|
|
||||||
`${window.location.origin}/invite/${invite.token}`,
|
|
||||||
)}
|
|
||||||
>{m.settings_members_copy_link()}</Button
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => cancelInvite(invite.id)}
|
|
||||||
>Cancel</Button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
<div class="flex items-center gap-1.5">
|
||||||
</div>
|
<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}`,
|
||||||
|
)}
|
||||||
|
title={m.settings_members_copy_link()}
|
||||||
|
>
|
||||||
|
<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)}
|
||||||
|
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Members List -->
|
<!-- Members List -->
|
||||||
<Card>
|
<div class="bg-dark/30 border border-light/5 rounded-xl overflow-hidden">
|
||||||
<div class="divide-y divide-light/10">
|
<div class="divide-y divide-light/5">
|
||||||
{#each members as member}
|
{#each members as member}
|
||||||
{@const rawProfile = member.profiles}
|
{@const rawProfile = member.profiles}
|
||||||
{@const profile = Array.isArray(rawProfile)
|
{@const profile = Array.isArray(rawProfile)
|
||||||
? rawProfile[0]
|
? rawProfile[0]
|
||||||
: rawProfile}
|
: rawProfile}
|
||||||
<div
|
<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="flex items-center gap-3">
|
||||||
<div
|
<Avatar
|
||||||
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary font-medium"
|
name={profile?.full_name || profile?.email || "?"}
|
||||||
>
|
src={profile?.avatar_url}
|
||||||
{(profile?.full_name ||
|
size="sm"
|
||||||
profile?.email ||
|
/>
|
||||||
"?")[0].toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<p class="text-light font-medium">
|
<p class="text-body-sm text-white">
|
||||||
{profile?.full_name ||
|
{profile?.full_name ||
|
||||||
profile?.email ||
|
profile?.email ||
|
||||||
"Unknown User"}
|
"Unknown User"}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-light/50">
|
<p class="text-[11px] text-light/40">
|
||||||
{profile?.email || "No email"}
|
{profile?.email || "No email"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-2">
|
||||||
<span
|
<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(
|
style="background-color: {roles.find(
|
||||||
(r) => r.name.toLowerCase() === member.role,
|
(r) => r.name.toLowerCase() === member.role,
|
||||||
)?.color ?? '#6366f1'}20; color: {roles.find(
|
)?.color ?? '#6366f1'}20; color: {roles.find(
|
||||||
@@ -283,18 +266,20 @@
|
|||||||
)?.color ?? '#6366f1'}">{member.role}</span
|
)?.color ?? '#6366f1'}">{member.role}</span
|
||||||
>
|
>
|
||||||
{#if member.user_id !== userId && member.role !== "owner"}
|
{#if member.user_id !== userId && member.role !== "owner"}
|
||||||
<Button
|
<button
|
||||||
variant="tertiary"
|
type="button"
|
||||||
size="sm"
|
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
|
||||||
onclick={() => openMemberModal(member)}
|
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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Invite Member Modal -->
|
<!-- Invite Member Modal -->
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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 { toasts } from "$lib/stores/toast.svelte";
|
||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
import type { Database } from "$lib/supabase/types";
|
import type { Database } from "$lib/supabase/types";
|
||||||
@@ -188,86 +188,72 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-4 max-w-2xl">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-light">Roles</h2>
|
<h2 class="font-heading text-body text-white">Roles</h2>
|
||||||
<p class="text-sm text-light/50">
|
<p class="text-body-sm text-light/40 mt-0.5">
|
||||||
Create custom roles with specific permissions.
|
Create custom roles with specific permissions.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onclick={() => openRoleModal()} icon="add">
|
<Button size="sm" onclick={() => openRoleModal()} icon="add">
|
||||||
Create Role
|
Create Role
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4">
|
<div class="flex flex-col gap-2">
|
||||||
{#each roles as role}
|
{#each roles as role}
|
||||||
<Card>
|
<div class="bg-dark/30 border border-light/5 rounded-xl px-4 py-3 hover:border-light/10 transition-colors">
|
||||||
<div class="p-4">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex items-center gap-3">
|
<div
|
||||||
<div
|
class="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
class="w-3 h-3 rounded-full"
|
style="background-color: {role.color}"
|
||||||
style="background-color: {role.color}"
|
></div>
|
||||||
></div>
|
<span class="text-body-sm font-medium text-white">{role.name}</span>
|
||||||
<span class="font-medium text-light"
|
{#if role.is_system}
|
||||||
>{role.name}</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_system}
|
{#if role.is_default}
|
||||||
<span
|
<span class="text-[10px] text-primary bg-primary/10 px-1.5 py-0.5 rounded-md">Default</span>
|
||||||
class="text-xs text-light/40 bg-light/10 px-2 py-0.5 rounded"
|
{/if}
|
||||||
>System</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{#if role.is_default}
|
|
||||||
<span
|
|
||||||
class="text-xs text-primary bg-primary/10 px-2 py-0.5 rounded"
|
|
||||||
>Default</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{#if !role.is_system || role.name !== "Owner"}
|
|
||||||
<Button
|
|
||||||
variant="tertiary"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => openRoleModal(role)}
|
|
||||||
>Edit</Button
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{#if !role.is_system}
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => deleteRole(role)}
|
|
||||||
>Delete</Button
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex items-center gap-1.5">
|
||||||
{#if role.permissions.includes("*")}
|
{#if !role.is_system || role.name !== "Owner"}
|
||||||
<span
|
<button
|
||||||
class="text-xs bg-light/10 text-light/70 px-2 py-1 rounded"
|
type="button"
|
||||||
>All Permissions</span
|
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
|
||||||
|
onclick={() => openRoleModal(role)}
|
||||||
|
title="Edit"
|
||||||
>
|
>
|
||||||
{:else}
|
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">edit</span>
|
||||||
{#each role.permissions.slice(0, 6) as perm}
|
</button>
|
||||||
<span
|
{/if}
|
||||||
class="text-xs bg-light/10 text-light/50 px-2 py-1 rounded"
|
{#if !role.is_system}
|
||||||
>{perm}</span
|
<button
|
||||||
>
|
type="button"
|
||||||
{/each}
|
class="p-1.5 text-light/40 hover:text-error hover:bg-error/10 rounded-lg transition-colors"
|
||||||
{#if role.permissions.length > 6}
|
onclick={() => deleteRole(role)}
|
||||||
<span class="text-xs text-light/40"
|
title="Delete"
|
||||||
>+{role.permissions.length - 6} more</span
|
>
|
||||||
>
|
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">delete</span>
|
||||||
{/if}
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#if role.permissions.includes("*")}
|
||||||
|
<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-[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-[10px] text-light/30">+{role.permissions.length - 6} more</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</script>
|
||||||
|
|
||||||
<div
|
<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 -->
|
<!-- 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">
|
<div class="flex-1 flex items-center gap-2 min-w-0">
|
||||||
<span class="font-heading text-h4 text-white truncate">{title}</span
|
<span class="font-heading text-body-sm text-white truncate">{title}</span>
|
||||||
>
|
<span
|
||||||
<div
|
class="text-[11px] text-light/40 bg-light/5 px-1.5 py-0.5 rounded-md shrink-0"
|
||||||
class="bg-dark flex items-center justify-center p-1 rounded-lg shrink-0"
|
>{count}</span>
|
||||||
>
|
|
||||||
<span class="font-heading text-h6 text-white">{count}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{#if onMore}
|
{#if onMore}
|
||||||
<button
|
<button
|
||||||
type="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}
|
onclick={onMore}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="material-symbols-rounded text-light"
|
class="material-symbols-rounded text-light/40 hover:text-white"
|
||||||
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
|
||||||
>
|
>
|
||||||
more_horiz
|
more_horiz
|
||||||
</span>
|
</span>
|
||||||
@@ -45,7 +42,7 @@
|
|||||||
|
|
||||||
<!-- Cards container -->
|
<!-- Cards container -->
|
||||||
<div
|
<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}
|
{#if children}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
@@ -54,8 +51,10 @@
|
|||||||
|
|
||||||
<!-- Add button -->
|
<!-- Add button -->
|
||||||
{#if onAddCard}
|
{#if onAddCard}
|
||||||
<Button variant="secondary" fullWidth icon="add" onclick={onAddCard}>
|
<div class="px-2 pb-2">
|
||||||
Add card
|
<Button variant="tertiary" fullWidth size="sm" icon="add" onclick={onAddCard}>
|
||||||
</Button>
|
Add card
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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 AssigneePicker } from './AssigneePicker.svelte';
|
||||||
export { default as ContextMenu } from './ContextMenu.svelte';
|
export { default as ContextMenu } from './ContextMenu.svelte';
|
||||||
export { default as PageSkeleton } from './PageSkeleton.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 ImagePreviewModal } from './ImagePreviewModal.svelte';
|
||||||
export { default as Twemoji } from './Twemoji.svelte';
|
export { default as Twemoji } from './Twemoji.svelte';
|
||||||
export { default as EmojiPicker } from './EmojiPicker.svelte';
|
export { default as EmojiPicker } from './EmojiPicker.svelte';
|
||||||
|
|||||||
@@ -288,6 +288,13 @@ export const roomSummaries = derived(rooms, ($rooms): RoomSummary[] => {
|
|||||||
return summaries;
|
return summaries;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Total unread count across all rooms (for nav badge)
|
||||||
|
*/
|
||||||
|
export const totalUnreadCount = derived(roomSummaries, ($summaries): number => {
|
||||||
|
return $summaries.reduce((sum, room) => sum + (room.isSpace ? 0 : room.unreadCount), 0);
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Messages
|
// Messages
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -360,6 +360,100 @@ export type Database = {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
event_members: {
|
||||||
|
Row: {
|
||||||
|
assigned_at: string | null
|
||||||
|
event_id: string
|
||||||
|
id: string
|
||||||
|
role: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
assigned_at?: string | null
|
||||||
|
event_id: string
|
||||||
|
id?: string
|
||||||
|
role?: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
assigned_at?: string | null
|
||||||
|
event_id?: string
|
||||||
|
id?: string
|
||||||
|
role?: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "event_members_event_id_fkey"
|
||||||
|
columns: ["event_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "events"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
events: {
|
||||||
|
Row: {
|
||||||
|
color: string | null
|
||||||
|
cover_image_url: string | null
|
||||||
|
created_at: string | null
|
||||||
|
created_by: string | null
|
||||||
|
description: string | null
|
||||||
|
end_date: string | null
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
org_id: string
|
||||||
|
slug: string
|
||||||
|
start_date: string | null
|
||||||
|
status: string
|
||||||
|
updated_at: string | null
|
||||||
|
venue_address: string | null
|
||||||
|
venue_name: string | null
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
color?: string | null
|
||||||
|
cover_image_url?: string | null
|
||||||
|
created_at?: string | null
|
||||||
|
created_by?: string | null
|
||||||
|
description?: string | null
|
||||||
|
end_date?: string | null
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
org_id: string
|
||||||
|
slug: string
|
||||||
|
start_date?: string | null
|
||||||
|
status?: string
|
||||||
|
updated_at?: string | null
|
||||||
|
venue_address?: string | null
|
||||||
|
venue_name?: string | null
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
color?: string | null
|
||||||
|
cover_image_url?: string | null
|
||||||
|
created_at?: string | null
|
||||||
|
created_by?: string | null
|
||||||
|
description?: string | null
|
||||||
|
end_date?: string | null
|
||||||
|
id?: string
|
||||||
|
name?: string
|
||||||
|
org_id?: string
|
||||||
|
slug?: string
|
||||||
|
start_date?: string | null
|
||||||
|
status?: string
|
||||||
|
updated_at?: string | null
|
||||||
|
venue_address?: string | null
|
||||||
|
venue_name?: string | null
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "events_org_id_fkey"
|
||||||
|
columns: ["org_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "organizations"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
kanban_boards: {
|
kanban_boards: {
|
||||||
Row: {
|
Row: {
|
||||||
created_at: string | null
|
created_at: string | null
|
||||||
@@ -613,6 +707,50 @@ export type Database = {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
matrix_credentials: {
|
||||||
|
Row: {
|
||||||
|
access_token: string
|
||||||
|
created_at: string
|
||||||
|
device_id: string | null
|
||||||
|
homeserver_url: string
|
||||||
|
id: string
|
||||||
|
matrix_user_id: string
|
||||||
|
org_id: string
|
||||||
|
updated_at: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Insert: {
|
||||||
|
access_token: string
|
||||||
|
created_at?: string
|
||||||
|
device_id?: string | null
|
||||||
|
homeserver_url: string
|
||||||
|
id?: string
|
||||||
|
matrix_user_id: string
|
||||||
|
org_id: string
|
||||||
|
updated_at?: string
|
||||||
|
user_id: string
|
||||||
|
}
|
||||||
|
Update: {
|
||||||
|
access_token?: string
|
||||||
|
created_at?: string
|
||||||
|
device_id?: string | null
|
||||||
|
homeserver_url?: string
|
||||||
|
id?: string
|
||||||
|
matrix_user_id?: string
|
||||||
|
org_id?: string
|
||||||
|
updated_at?: string
|
||||||
|
user_id?: string
|
||||||
|
}
|
||||||
|
Relationships: [
|
||||||
|
{
|
||||||
|
foreignKeyName: "matrix_credentials_org_id_fkey"
|
||||||
|
columns: ["org_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "organizations"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
org_google_calendars: {
|
org_google_calendars: {
|
||||||
Row: {
|
Row: {
|
||||||
calendar_id: string
|
calendar_id: string
|
||||||
@@ -748,6 +886,13 @@ export type Database = {
|
|||||||
referencedRelation: "org_roles"
|
referencedRelation: "org_roles"
|
||||||
referencedColumns: ["id"]
|
referencedColumns: ["id"]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
foreignKeyName: "org_members_user_id_profiles_fk"
|
||||||
|
columns: ["user_id"]
|
||||||
|
isOneToOne: false
|
||||||
|
referencedRelation: "profiles"
|
||||||
|
referencedColumns: ["id"]
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
org_roles: {
|
org_roles: {
|
||||||
@@ -803,6 +948,7 @@ export type Database = {
|
|||||||
created_at: string | null
|
created_at: string | null
|
||||||
icon_url: string | null
|
icon_url: string | null
|
||||||
id: string
|
id: string
|
||||||
|
matrix_space_id: string | null
|
||||||
name: string
|
name: string
|
||||||
slug: string
|
slug: string
|
||||||
theme_color: string | null
|
theme_color: string | null
|
||||||
@@ -813,6 +959,7 @@ export type Database = {
|
|||||||
created_at?: string | null
|
created_at?: string | null
|
||||||
icon_url?: string | null
|
icon_url?: string | null
|
||||||
id?: string
|
id?: string
|
||||||
|
matrix_space_id?: string | null
|
||||||
name: string
|
name: string
|
||||||
slug: string
|
slug: string
|
||||||
theme_color?: string | null
|
theme_color?: string | null
|
||||||
@@ -823,6 +970,7 @@ export type Database = {
|
|||||||
created_at?: string | null
|
created_at?: string | null
|
||||||
icon_url?: string | null
|
icon_url?: string | null
|
||||||
id?: string
|
id?: string
|
||||||
|
matrix_space_id?: string | null
|
||||||
name?: string
|
name?: string
|
||||||
slug?: string
|
slug?: string
|
||||||
theme_color?: string | null
|
theme_color?: string | null
|
||||||
@@ -1148,7 +1296,7 @@ export const Constants = {
|
|||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
// ── Convenience type aliases ──────────────────────────
|
// ── Convenience type aliases ─────────────────────────────────────────
|
||||||
export type MemberRole = 'owner' | 'admin' | 'editor' | 'viewer';
|
export type MemberRole = 'owner' | 'admin' | 'editor' | 'viewer';
|
||||||
type PublicTables = Database['public']['Tables']
|
type PublicTables = Database['public']['Tables']
|
||||||
|
|
||||||
@@ -1171,3 +1319,6 @@ export type Team = PublicTables['teams']['Row']
|
|||||||
export type OrgGoogleCalendar = PublicTables['org_google_calendars']['Row']
|
export type OrgGoogleCalendar = PublicTables['org_google_calendars']['Row']
|
||||||
export type ActivityLog = PublicTables['activity_log']['Row']
|
export type ActivityLog = PublicTables['activity_log']['Row']
|
||||||
export type UserPreferences = PublicTables['user_preferences']['Row']
|
export type UserPreferences = PublicTables['user_preferences']['Row']
|
||||||
|
export type MatrixCredentials = PublicTables['matrix_credentials']['Row']
|
||||||
|
export type EventRow = PublicTables['events']['Row']
|
||||||
|
export type EventMemberRow = PublicTables['event_members']['Row']
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from "svelte";
|
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 { createOrganization, generateSlug } from "$lib/api/organizations";
|
||||||
import { toasts } from "$lib/stores/toast.svelte";
|
import { toasts } from "$lib/stores/toast.svelte";
|
||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
@@ -58,108 +58,53 @@
|
|||||||
<title>Organizations | Root</title>
|
<title>Organizations | Root</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-h-screen bg-dark">
|
<div class="min-h-screen bg-background">
|
||||||
<header class="border-b border-light/10 bg-surface">
|
<!-- Header -->
|
||||||
<div
|
<header class="border-b border-light/5">
|
||||||
class="max-w-6xl mx-auto px-6 py-4 flex items-center justify-between"
|
<div class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
>
|
<div class="flex items-center gap-3">
|
||||||
<h1 class="text-xl font-bold text-light">Root Org</h1>
|
<span class="material-symbols-rounded text-primary" style="font-size: 28px; font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 28;">hub</span>
|
||||||
<div class="flex items-center gap-4">
|
<span class="font-heading text-h4 text-white">Root</span>
|
||||||
<a href="/style" class="text-sm text-light/60 hover:text-light"
|
</div>
|
||||||
>Style Guide</a
|
<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">
|
<form method="POST" action="/auth/logout">
|
||||||
<Button variant="tertiary" size="sm" type="submit"
|
<Button variant="tertiary" size="sm" type="submit">Sign Out</Button>
|
||||||
>Sign Out</Button
|
|
||||||
>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="max-w-6xl mx-auto px-6 py-8">
|
<main class="max-w-5xl mx-auto px-6 py-8">
|
||||||
<div class="flex items-center justify-between mb-8">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-light">
|
<h2 class="font-heading text-h3 text-white">Your Organizations</h2>
|
||||||
Your Organizations
|
<p class="text-body-sm text-light/40 mt-1">Select an organization to get started</p>
|
||||||
</h2>
|
|
||||||
<p class="text-light/50 mt-1">
|
|
||||||
Select an organization to get started
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onclick={() => (showCreateModal = true)}>
|
<Button size="sm" icon="add" onclick={() => (showCreateModal = true)}>New Organization</Button>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if organizations.length === 0}
|
{#if organizations.length === 0}
|
||||||
<Card>
|
<div class="bg-dark/30 border border-light/5 rounded-xl p-12 text-center">
|
||||||
<div class="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>
|
||||||
<svg
|
<h3 class="font-heading text-body text-white mb-1">No organizations yet</h3>
|
||||||
class="w-16 h-16 mx-auto mb-4 text-light/30"
|
<p class="text-body-sm text-light/40 mb-6">Create your first organization to start collaborating</p>
|
||||||
viewBox="0 0 24 24"
|
<Button size="sm" icon="add" onclick={() => (showCreateModal = true)}>Create Organization</Button>
|
||||||
fill="none"
|
</div>
|
||||||
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>
|
|
||||||
</Card>
|
|
||||||
{:else}
|
{: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}
|
{#each organizations as org}
|
||||||
<a href="/{org.slug}" class="block group">
|
<a href="/{org.slug}" class="block group">
|
||||||
<Card
|
<div class="bg-dark/30 border border-light/5 hover:border-light/10 rounded-xl p-5 transition-all h-full">
|
||||||
class="h-full hover:ring-1 hover:ring-primary/50 transition-all"
|
<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">
|
||||||
<div class="p-6">
|
{org.name.charAt(0).toUpperCase()}
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
<h3
|
<span class="text-[10px] px-2 py-0.5 bg-light/5 rounded-md text-light/40 capitalize font-body">{org.role}</span>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
<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>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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)
|
// 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
|
locals.supabase
|
||||||
.from('org_members')
|
.from('org_members')
|
||||||
.select('role, role_id')
|
.select('role, role_id')
|
||||||
@@ -49,9 +49,9 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
|||||||
.eq('org_id', org.id)
|
.eq('org_id', org.id)
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.limit(10),
|
.limit(10),
|
||||||
locals.supabase
|
(locals.supabase as any)
|
||||||
.from('profiles')
|
.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)
|
.eq('id', user.id)
|
||||||
.single(),
|
.single(),
|
||||||
locals.supabase
|
locals.supabase
|
||||||
@@ -68,7 +68,11 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
|||||||
.from('documents')
|
.from('documents')
|
||||||
.select('id', { count: 'exact', head: true })
|
.select('id', { count: 'exact', head: true })
|
||||||
.eq('org_id', org.id)
|
.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;
|
const { data: membership } = membershipResult;
|
||||||
@@ -81,6 +85,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
|||||||
documentCount: docCountResult.count ?? 0,
|
documentCount: docCountResult.count ?? 0,
|
||||||
folderCount: folderCountResult.count ?? 0,
|
folderCount: folderCountResult.count ?? 0,
|
||||||
kanbanCount: kanbanCountResult.count ?? 0,
|
kanbanCount: kanbanCountResult.count ?? 0,
|
||||||
|
eventCount: eventCountResult.count ?? 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
@@ -103,16 +108,16 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
|||||||
|
|
||||||
// Fetch profiles separately since org_members.user_id FK points to auth.users, not profiles
|
// 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);
|
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) {
|
if (memberUserIds.length > 0) {
|
||||||
const { data: memberProfiles } = await locals.supabase
|
const { data: memberProfiles } = await (locals.supabase as any)
|
||||||
.from('profiles')
|
.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);
|
.in('id', memberUserIds);
|
||||||
|
|
||||||
if (memberProfiles) {
|
if (memberProfiles) {
|
||||||
memberProfilesMap = Object.fromEntries(memberProfiles.map(p => [p.id, p]));
|
memberProfilesMap = Object.fromEntries(memberProfiles.map((p: any) => [p.id, p]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +126,15 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
|||||||
profiles: (m.user_id ? memberProfilesMap[m.user_id] : null) ?? null
|
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 {
|
return {
|
||||||
org,
|
org,
|
||||||
userRole: membership.role,
|
userRole: membership.role,
|
||||||
@@ -128,6 +142,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
|
|||||||
members,
|
members,
|
||||||
recentActivity: recentActivity ?? [],
|
recentActivity: recentActivity ?? [],
|
||||||
stats,
|
stats,
|
||||||
|
upcomingEvents: upcomingEvents ?? [],
|
||||||
user,
|
user,
|
||||||
profile: profile ?? { id: user.id, email: user.email ?? '', full_name: null, avatar_url: null }
|
profile: profile ?? { id: user.id, email: user.email ?? '', full_name: null, avatar_url: null }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page, navigating } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import { on } from "svelte/events";
|
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 { SupabaseClient } from "@supabase/supabase-js";
|
||||||
import type { Database } from "$lib/supabase/types";
|
import type { Database } from "$lib/supabase/types";
|
||||||
import { hasPermission, type Permission } from "$lib/utils/permissions";
|
import { hasPermission, type Permission } from "$lib/utils/permissions";
|
||||||
import { setContext } from "svelte";
|
import { setContext } from "svelte";
|
||||||
import * as m from "$lib/paraglide/messages";
|
import * as m from "$lib/paraglide/messages";
|
||||||
|
import { totalUnreadCount } from "$lib/stores/matrix";
|
||||||
|
|
||||||
interface Member {
|
interface Member {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -122,10 +123,16 @@
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
{
|
||||||
|
href: `/${data.org.slug}/events`,
|
||||||
|
label: m.nav_events(),
|
||||||
|
icon: "celebration",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: `/${data.org.slug}/chat`,
|
href: `/${data.org.slug}/chat`,
|
||||||
label: "Chat",
|
label: "Chat",
|
||||||
icon: "chat",
|
icon: "chat",
|
||||||
|
badge: $totalUnreadCount > 0 ? ($totalUnreadCount > 99 ? "99+" : String($totalUnreadCount)) : null,
|
||||||
},
|
},
|
||||||
// Settings requires settings.view or admin role
|
// Settings requires settings.view or admin role
|
||||||
...(canAccess("settings.view")
|
...(canAccess("settings.view")
|
||||||
@@ -223,6 +230,11 @@
|
|||||||
? 'opacity-0 max-w-0 overflow-hidden'
|
? 'opacity-0 max-w-0 overflow-hidden'
|
||||||
: 'opacity-100 max-w-[200px]'}">{item.label}</span
|
: 'opacity-100 max-w-[200px]'}">{item.label}</span
|
||||||
>
|
>
|
||||||
|
{#if item.badge}
|
||||||
|
<span class="ml-auto bg-primary text-background text-xs font-bold px-1.5 py-0.5 rounded-full min-w-[18px] text-center shrink-0">
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -333,21 +345,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
<!-- Main Content Area -->
|
||||||
<main class="flex-1 bg-night rounded-[32px] overflow-auto relative">
|
<main class="flex-1 bg-night rounded-[32px] overflow-hidden relative">
|
||||||
{#if $navigating}
|
{@render children()}
|
||||||
{@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}
|
|
||||||
{@render children()}
|
|
||||||
{/if}
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
<script lang="ts">
|
<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";
|
import * as m from "$lib/paraglide/messages";
|
||||||
|
|
||||||
interface ActivityEntry {
|
interface ActivityEntry {
|
||||||
@@ -15,6 +23,17 @@
|
|||||||
} | null;
|
} | 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 {
|
interface Props {
|
||||||
data: {
|
data: {
|
||||||
org: { id: string; name: string; slug: string };
|
org: { id: string; name: string; slug: string };
|
||||||
@@ -24,8 +43,10 @@
|
|||||||
documentCount: number;
|
documentCount: number;
|
||||||
folderCount: number;
|
folderCount: number;
|
||||||
kanbanCount: number;
|
kanbanCount: number;
|
||||||
|
eventCount: number;
|
||||||
};
|
};
|
||||||
recentActivity: ActivityEntry[];
|
recentActivity: ActivityEntry[];
|
||||||
|
upcomingEvents: UpcomingEvent[];
|
||||||
members: {
|
members: {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -48,321 +69,174 @@
|
|||||||
documentCount: 0,
|
documentCount: 0,
|
||||||
folderCount: 0,
|
folderCount: 0,
|
||||||
kanbanCount: 0,
|
kanbanCount: 0,
|
||||||
|
eventCount: 0,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const recentActivity = $derived(data.recentActivity ?? []);
|
const recentActivity = $derived(data.recentActivity ?? []);
|
||||||
|
const upcomingEvents = $derived(data.upcomingEvents ?? []);
|
||||||
const members = $derived(data.members ?? []);
|
const members = $derived(data.members ?? []);
|
||||||
|
|
||||||
const isAdmin = $derived(
|
const isAdmin = $derived(
|
||||||
data.userRole === "owner" || data.userRole === "admin",
|
data.userRole === "owner" || data.userRole === "admin",
|
||||||
);
|
);
|
||||||
|
const isEditor = $derived(
|
||||||
const statCards = $derived([
|
["owner", "admin", "editor"].includes(data.userRole),
|
||||||
{
|
);
|
||||||
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 quickLinks = $derived([
|
const quickLinks = $derived([
|
||||||
{
|
{ label: m.nav_events(), icon: "celebration", href: `/${data.org.slug}/events`, color: "text-primary" },
|
||||||
label: m.nav_files(),
|
{ label: m.nav_files(), icon: "cloud", href: `/${data.org.slug}/documents`, color: "text-emerald-400" },
|
||||||
icon: "cloud",
|
{ label: m.nav_calendar(), icon: "calendar_today", href: `/${data.org.slug}/calendar`, color: "text-blue-400" },
|
||||||
href: `/${data.org.slug}/documents`,
|
{ label: "Chat", icon: "chat", href: `/${data.org.slug}/chat`, color: "text-purple-400" },
|
||||||
},
|
|
||||||
{
|
|
||||||
label: m.nav_calendar(),
|
|
||||||
icon: "calendar_today",
|
|
||||||
href: `/${data.org.slug}/calendar`,
|
|
||||||
},
|
|
||||||
...(isAdmin
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
label: m.nav_settings(),
|
|
||||||
icon: "settings",
|
|
||||||
href: `/${data.org.slug}/settings`,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{data.org.name} | Root</title>
|
<title>{data.org.name} | Root</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex flex-col h-full p-4 lg:p-5 gap-6 overflow-auto">
|
<div class="flex flex-col h-full overflow-auto">
|
||||||
<!-- Header -->
|
<PageHeader title={data.org.name} subtitle={m.overview_subtitle()}>
|
||||||
<header>
|
{#snippet actions()}
|
||||||
<h1 class="text-h1 font-heading text-white">{data.org.name}</h1>
|
{#if isEditor}
|
||||||
<p class="text-body text-light/60 font-body">{m.overview_title()}</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Stats Grid -->
|
|
||||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{#each statCards as stat}
|
|
||||||
{#if stat.href}
|
|
||||||
<a
|
<a
|
||||||
href={stat.href}
|
href="/{data.org.slug}/events"
|
||||||
class="bg-night rounded-2xl p-5 flex flex-col gap-3 hover:bg-night/80 transition-colors group"
|
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"
|
||||||
>
|
|
||||||
<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
|
<span
|
||||||
class="material-symbols-rounded mb-3"
|
class="material-symbols-rounded"
|
||||||
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
|
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
||||||
|
>celebration</span
|
||||||
>
|
>
|
||||||
history
|
{m.nav_events()}
|
||||||
</span>
|
</a>
|
||||||
<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}
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="flex-1 p-6 overflow-auto">
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar: Quick Links + Members -->
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div class="flex flex-col gap-6">
|
<!-- Left Column: Upcoming Events + Activity -->
|
||||||
<!-- Quick Links -->
|
<div class="lg:col-span-2 flex flex-col gap-6">
|
||||||
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3">
|
<!-- Upcoming Events -->
|
||||||
<h2 class="text-h3 font-heading text-white">
|
<SectionCard title={m.overview_upcoming_events()}>
|
||||||
{m.overview_quick_links()}
|
{#snippet titleRight()}
|
||||||
</h2>
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
{#each quickLinks as link}
|
|
||||||
<a
|
<a
|
||||||
href={link.href}
|
href="/{data.org.slug}/events"
|
||||||
class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-light hover:bg-dark/50 hover:text-white transition-colors"
|
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
|
<span
|
||||||
class="material-symbols-rounded text-light/50"
|
class="material-symbols-rounded mb-2"
|
||||||
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
style="font-size: 40px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 40;"
|
||||||
|
>celebration</span
|
||||||
>
|
>
|
||||||
{link.icon}
|
<p class="text-body-sm">{m.overview_upcoming_empty()}</p>
|
||||||
</span>
|
</div>
|
||||||
<span class="text-body">{link.label}</span>
|
{:else}
|
||||||
</a>
|
<div class="flex flex-col gap-1">
|
||||||
{/each}
|
{#each upcomingEvents as event}
|
||||||
</div>
|
<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}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<!-- Recent Activity -->
|
||||||
|
<SectionCard title={m.activity_title()}>
|
||||||
|
<ActivityFeed entries={recentActivity} />
|
||||||
|
</SectionCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Team Members Preview -->
|
<!-- Right Column: Quick Links + Team -->
|
||||||
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3">
|
<div class="flex flex-col gap-6">
|
||||||
<div class="flex items-center justify-between">
|
<SectionCard title={m.overview_quick_links()}>
|
||||||
<h2 class="text-h3 font-heading text-white">
|
<QuickLinkGrid links={quickLinks} />
|
||||||
{m.overview_stat_members()}
|
</SectionCard>
|
||||||
</h2>
|
|
||||||
<span class="text-body-sm text-light/40"
|
<SectionCard title={m.overview_stat_members()}>
|
||||||
>{stats.memberCount}</span
|
{#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="flex items-center gap-3 bg-dark/30 border border-light/5 hover:border-light/10 rounded-xl p-4 transition-all group"
|
||||||
>
|
>
|
||||||
</div>
|
<div class="w-10 h-10 rounded-xl bg-light/5 flex items-center justify-center">
|
||||||
<div class="flex flex-col gap-2">
|
<span
|
||||||
{#each members.slice(0, 5) as member}
|
class="material-symbols-rounded text-light/40 group-hover:text-white transition-colors"
|
||||||
<div class="flex items-center gap-3 px-1 py-1">
|
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 22;"
|
||||||
<Avatar
|
>settings</span
|
||||||
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>
|
</div>
|
||||||
{/each}
|
<div>
|
||||||
{#if stats.memberCount > 5}
|
<p class="text-body-sm text-white group-hover:text-primary transition-colors">
|
||||||
<a
|
{m.nav_settings()}
|
||||||
href="/{data.org.slug}/settings"
|
</p>
|
||||||
class="text-body-sm text-primary hover:underline text-center pt-1"
|
<p class="text-[11px] text-light/30">{m.settings_general_title()}</p>
|
||||||
>
|
</div>
|
||||||
+{stats.memberCount - 5} more
|
</a>
|
||||||
</a>
|
{/if}
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</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;
|
email: string;
|
||||||
full_name: string | null;
|
full_name: string | null;
|
||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
discord_handle: string | null;
|
||||||
|
shirt_size: string | null;
|
||||||
|
hoodie_size: string | null;
|
||||||
};
|
};
|
||||||
preferences: {
|
preferences: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,10 +38,16 @@
|
|||||||
// Profile state
|
// Profile state
|
||||||
let fullName = $state(data.profile.full_name ?? "");
|
let fullName = $state(data.profile.full_name ?? "");
|
||||||
let avatarUrl = $state(data.profile.avatar_url ?? null);
|
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 isSaving = $state(false);
|
||||||
let isUploading = $state(false);
|
let isUploading = $state(false);
|
||||||
let avatarInput = $state<HTMLInputElement | null>(null);
|
let avatarInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const clothingSizes = ["XS", "S", "M", "L", "XL", "XXL", "3XL"];
|
||||||
|
|
||||||
// Preferences state
|
// Preferences state
|
||||||
let theme = $state(data.preferences?.theme ?? "dark");
|
let theme = $state(data.preferences?.theme ?? "dark");
|
||||||
let accentColor = $state(data.preferences?.accent_color ?? "#00A3E0");
|
let accentColor = $state(data.preferences?.accent_color ?? "#00A3E0");
|
||||||
@@ -57,6 +67,10 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
fullName = data.profile.full_name ?? "";
|
fullName = data.profile.full_name ?? "";
|
||||||
avatarUrl = data.profile.avatar_url ?? null;
|
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";
|
theme = data.preferences?.theme ?? "dark";
|
||||||
accentColor = data.preferences?.accent_color ?? "#00A3E0";
|
accentColor = data.preferences?.accent_color ?? "#00A3E0";
|
||||||
useOrgTheme = data.preferences?.use_org_theme ?? true;
|
useOrgTheme = data.preferences?.use_org_theme ?? true;
|
||||||
@@ -161,9 +175,15 @@
|
|||||||
|
|
||||||
async function saveProfile() {
|
async function saveProfile() {
|
||||||
isSaving = true;
|
isSaving = true;
|
||||||
const { error } = await supabase
|
const { error } = await (supabase as any)
|
||||||
.from("profiles")
|
.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);
|
.eq("id", data.profile.id);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -227,25 +247,17 @@
|
|||||||
<title>Account Settings | Root</title>
|
<title>Account Settings | Root</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
|
<div class="flex-1 p-6 overflow-auto">
|
||||||
<!-- Header -->
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
<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">
|
|
||||||
<!-- Profile Section -->
|
<!-- Profile Section -->
|
||||||
<div class="bg-background rounded-[32px] p-6 flex flex-col gap-6">
|
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5">
|
||||||
<h2 class="font-heading text-h3 text-white">
|
<h2 class="font-heading text-body text-white">
|
||||||
{m.account_profile()}
|
{m.account_profile()}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<!-- Avatar -->
|
<!-- Avatar -->
|
||||||
<div class="flex flex-col gap-3">
|
<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
|
>{m.account_photo()}</span
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
@@ -313,9 +325,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Appearance Section -->
|
||||||
<div class="bg-background rounded-[32px] p-6 flex flex-col gap-6">
|
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5">
|
||||||
<h2 class="font-heading text-h3 text-white">
|
<h2 class="font-heading text-body text-white">
|
||||||
{m.account_appearance()}
|
{m.account_appearance()}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@@ -333,7 +397,7 @@
|
|||||||
|
|
||||||
<!-- Accent Color -->
|
<!-- Accent Color -->
|
||||||
<div class="flex flex-col gap-2">
|
<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
|
>{m.account_accent_color()}</span
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap gap-2 items-center">
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
@@ -371,10 +435,10 @@
|
|||||||
<!-- Use Org Theme -->
|
<!-- Use Org Theme -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-body text-body text-white">
|
<p class="font-body text-body-sm text-white">
|
||||||
{m.account_use_org_theme()}
|
{m.account_use_org_theme()}
|
||||||
</p>
|
</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()}
|
{m.account_use_org_theme_desc()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -396,20 +460,20 @@
|
|||||||
|
|
||||||
<!-- Language -->
|
<!-- Language -->
|
||||||
<div class="flex flex-col gap-2">
|
<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
|
>{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()}
|
{m.account_language_desc()}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-2 mt-1">
|
<div class="flex gap-2 mt-1">
|
||||||
{#each locales as locale}
|
{#each locales as locale}
|
||||||
<button
|
<button
|
||||||
type="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
|
locale
|
||||||
? 'bg-primary text-night'
|
? 'bg-primary text-background'
|
||||||
: 'bg-light/10 text-light/70 hover:bg-light/20'}"
|
: 'bg-light/5 text-light/50 hover:bg-light/10'}"
|
||||||
onclick={() => handleLanguageChange(locale)}
|
onclick={() => handleLanguageChange(locale)}
|
||||||
>
|
>
|
||||||
{localeLabels[locale] ?? locale}
|
{localeLabels[locale] ?? locale}
|
||||||
@@ -426,16 +490,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Security & Sessions Section -->
|
<!-- Security & Sessions Section -->
|
||||||
<div class="bg-background rounded-[32px] p-6 flex flex-col gap-6">
|
<div class="bg-dark/30 border border-light/5 rounded-xl p-5 flex flex-col gap-5">
|
||||||
<h2 class="font-heading text-h3 text-white">
|
<h2 class="font-heading text-body text-white">
|
||||||
{m.account_security()}
|
{m.account_security()}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<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()}
|
{m.account_password()}
|
||||||
</p>
|
</p>
|
||||||
<p class="font-body text-body-sm text-light/50">
|
<p class="font-body text-[11px] text-light/40">
|
||||||
{m.account_password_desc()}
|
{m.account_password_desc()}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
@@ -460,11 +524,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-light/10 pt-4 flex flex-col gap-2">
|
<div class="border-t border-light/5 pt-4 flex flex-col gap-2">
|
||||||
<p class="font-body text-body text-white">
|
<p class="font-body text-body-sm text-white">
|
||||||
{m.account_active_sessions()}
|
{m.account_active_sessions()}
|
||||||
</p>
|
</p>
|
||||||
<p class="font-body text-body-sm text-light/50">
|
<p class="font-body text-[11px] text-light/40">
|
||||||
{m.account_sessions_desc()}
|
{m.account_sessions_desc()}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-2">
|
<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>
|
<title>Calendar - {data.org.name} | Root</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
|
<div class="flex flex-col h-full">
|
||||||
<!-- Header -->
|
<!-- Toolbar -->
|
||||||
<header class="flex items-center gap-2 p-1">
|
<div class="flex items-center gap-2 px-6 py-3 border-b border-light/5 shrink-0">
|
||||||
<h1 class="flex-1 font-heading text-h1 text-white">
|
<div class="flex-1"></div>
|
||||||
{m.calendar_title()}
|
<Button size="sm" onclick={() => handleDateClick(new Date())}
|
||||||
</h1>
|
|
||||||
<Button size="md" onclick={() => handleDateClick(new Date())}
|
|
||||||
>{m.btn_new()}</Button
|
>{m.btn_new()}</Button
|
||||||
>
|
>
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
@@ -502,10 +500,10 @@
|
|||||||
: []),
|
: []),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</header>
|
</div>
|
||||||
|
|
||||||
<!-- Calendar Grid -->
|
<!-- Calendar Grid -->
|
||||||
<div class="flex-1 overflow-auto">
|
<div class="flex-1 overflow-auto p-4">
|
||||||
<Calendar
|
<Calendar
|
||||||
events={allEvents}
|
events={allEvents}
|
||||||
onDateClick={handleDateClick}
|
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>
|
||||||
@@ -79,15 +79,39 @@
|
|||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredRooms = $derived(
|
// All non-space rooms (exclude Space entries themselves from the list)
|
||||||
|
const allRooms = $derived(
|
||||||
|
$roomSummaries.filter((r) => !r.isSpace),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Org rooms: rooms that belong to any Space
|
||||||
|
const orgRooms = $derived(
|
||||||
|
allRooms.filter((r) => r.parentSpaceId && !r.isDirect),
|
||||||
|
);
|
||||||
|
|
||||||
|
// DMs: direct messages (not tied to org)
|
||||||
|
const dmRooms = $derived(
|
||||||
|
allRooms.filter((r) => r.isDirect),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Other rooms: not in a space and not a DM
|
||||||
|
const otherRooms = $derived(
|
||||||
|
allRooms.filter((r) => !r.parentSpaceId && !r.isDirect),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply search filter across all sections
|
||||||
|
const filterBySearch = (rooms: typeof allRooms) =>
|
||||||
roomSearchQuery.trim()
|
roomSearchQuery.trim()
|
||||||
? $roomSummaries.filter(
|
? rooms.filter(
|
||||||
(room) =>
|
(room) =>
|
||||||
room.name.toLowerCase().includes(roomSearchQuery.toLowerCase()) ||
|
room.name.toLowerCase().includes(roomSearchQuery.toLowerCase()) ||
|
||||||
room.topic?.toLowerCase().includes(roomSearchQuery.toLowerCase()),
|
room.topic?.toLowerCase().includes(roomSearchQuery.toLowerCase()),
|
||||||
)
|
)
|
||||||
: $roomSummaries,
|
: rooms;
|
||||||
);
|
|
||||||
|
const filteredOrgRooms = $derived(filterBySearch(orgRooms));
|
||||||
|
const filteredDmRooms = $derived(filterBySearch(dmRooms));
|
||||||
|
const filteredOtherRooms = $derived(filterBySearch(otherRooms));
|
||||||
|
|
||||||
const currentMembers = $derived(
|
const currentMembers = $derived(
|
||||||
$selectedRoomId ? getRoomMembers($selectedRoomId) : [],
|
$selectedRoomId ? getRoomMembers($selectedRoomId) : [],
|
||||||
@@ -140,6 +164,9 @@
|
|||||||
accessToken: credentials.accessToken,
|
accessToken: credentials.accessToken,
|
||||||
deviceId: credentials.deviceId || null,
|
deviceId: credentials.deviceId || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Check if org has a Matrix Space, auto-create if not
|
||||||
|
await ensureOrgSpace(credentials);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error("Failed to init Matrix client:", e);
|
console.error("Failed to init Matrix client:", e);
|
||||||
toasts.error("Failed to connect to chat. Please re-login.");
|
toasts.error("Failed to connect to chat. Please re-login.");
|
||||||
@@ -149,6 +176,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureOrgSpace(credentials: LoginCredentials) {
|
||||||
|
try {
|
||||||
|
const spaceRes = await fetch(`/api/matrix-space?org_id=${data.org.id}`);
|
||||||
|
const spaceResult = await spaceRes.json();
|
||||||
|
|
||||||
|
if (!spaceResult.spaceId) {
|
||||||
|
// No Space yet — create one using the user's credentials
|
||||||
|
const createRes = await fetch("/api/matrix-space", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
org_id: data.org.id,
|
||||||
|
action: "create",
|
||||||
|
homeserver_url: credentials.homeserverUrl,
|
||||||
|
access_token: credentials.accessToken,
|
||||||
|
org_name: data.org.name,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const createResult = await createRes.json();
|
||||||
|
if (createResult.spaceId) {
|
||||||
|
toasts.success(`Organization space created`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to ensure org space:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleMatrixLogin() {
|
async function handleMatrixLogin() {
|
||||||
if (!matrixUsername.trim() || !matrixPassword.trim()) {
|
if (!matrixUsername.trim() || !matrixPassword.trim()) {
|
||||||
toasts.error("Please enter username and password");
|
toasts.error("Please enter username and password");
|
||||||
@@ -337,9 +392,9 @@
|
|||||||
<!-- Matrix Login Modal -->
|
<!-- Matrix Login Modal -->
|
||||||
{#if showMatrixLogin}
|
{#if showMatrixLogin}
|
||||||
<div class="h-full flex items-center justify-center">
|
<div class="h-full flex items-center justify-center">
|
||||||
<div class="bg-night rounded-[32px] p-8 w-full max-w-md">
|
<div class="bg-dark/30 border border-light/5 rounded-xl p-8 w-full max-w-md">
|
||||||
<h2 class="font-heading text-h3 text-white mb-2">Connect to Chat</h2>
|
<h2 class="font-heading text-body text-white mb-1">Connect to Chat</h2>
|
||||||
<p class="text-light/50 text-body mb-6">
|
<p class="text-body-sm text-light/50 mb-6">
|
||||||
Enter your Matrix credentials to enable messaging.
|
Enter your Matrix credentials to enable messaging.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -355,12 +410,12 @@
|
|||||||
placeholder="@user:matrix.org"
|
placeholder="@user:matrix.org"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-body-sm font-body text-light mb-1">Password</label>
|
<label class="block text-body-sm font-body text-light/60 mb-1">Password</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={matrixPassword}
|
bind:value={matrixPassword}
|
||||||
placeholder="Password"
|
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"
|
class="w-full bg-dark border border-light/10 rounded-xl px-3 py-2 text-white font-body text-body-sm placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === "Enter") handleMatrixLogin();
|
if (e.key === "Enter") handleMatrixLogin();
|
||||||
}}
|
}}
|
||||||
@@ -383,9 +438,9 @@
|
|||||||
<div class="h-full flex items-center justify-center">
|
<div class="h-full flex items-center justify-center">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div
|
<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>
|
></div>
|
||||||
<p class="text-light/50">
|
<p class="text-body-sm text-light/40">
|
||||||
{#if isInitializing}
|
{#if isInitializing}
|
||||||
Connecting to Matrix...
|
Connecting to Matrix...
|
||||||
{:else if $syncState === "CATCHUP"}
|
{:else if $syncState === "CATCHUP"}
|
||||||
@@ -405,157 +460,227 @@
|
|||||||
{:else if matrixClient}
|
{:else if matrixClient}
|
||||||
<MatrixProvider client={matrixClient}>
|
<MatrixProvider client={matrixClient}>
|
||||||
{#snippet children()}
|
{#snippet children()}
|
||||||
<div class="h-full flex gap-2 min-h-0">
|
<div class="h-full flex min-h-0">
|
||||||
<!-- Chat Sidebar -->
|
<!-- Chat Sidebar -->
|
||||||
<aside class="w-56 bg-night rounded-[32px] flex flex-col overflow-hidden shrink-0">
|
<aside class="w-56 border-r border-light/5 flex flex-col overflow-hidden shrink-0">
|
||||||
<header class="px-3 py-5">
|
<div class="flex items-center gap-2 px-3 py-2.5 border-b border-light/5">
|
||||||
<div class="flex items-center gap-2">
|
<span class="flex-1 font-heading text-body-sm text-white">Messages</span>
|
||||||
<span class="material-symbols-rounded text-light" style="font-size: 20px;">chat</span>
|
<button
|
||||||
<span class="flex-1 font-heading text-light text-base">Messages</span>
|
class="p-1 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
|
||||||
<button
|
onclick={() => (showStartDMModal = true)}
|
||||||
class="text-light hover:text-primary transition-colors"
|
title="New message"
|
||||||
onclick={() => (showStartDMModal = true)}
|
>
|
||||||
title="New message"
|
<span class="material-symbols-rounded" style="font-size: 18px;">add</span>
|
||||||
>
|
</button>
|
||||||
<span class="material-symbols-rounded" style="font-size: 20px;">add</span>
|
</div>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Room search -->
|
<!-- Room search -->
|
||||||
<div class="px-3 pb-2">
|
<div class="px-2 py-2">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<span
|
<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;"
|
style="font-size: 16px;"
|
||||||
>search</span>
|
>search</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={roomSearchQuery}
|
bind:value={roomSearchQuery}
|
||||||
placeholder="Search rooms..."
|
placeholder="Search..."
|
||||||
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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Room actions -->
|
<!-- Room list (sectioned) -->
|
||||||
<div class="flex items-center justify-between px-3 py-1">
|
<nav class="flex-1 overflow-y-auto px-1.5 pb-2">
|
||||||
<span class="text-xs font-semibold text-light/40 uppercase tracking-wider">
|
{#if allRooms.length === 0}
|
||||||
Rooms {roomSearchQuery ? `(${filteredRooms.length})` : ""}
|
<p class="text-light/30 text-[12px] text-center py-8">
|
||||||
</span>
|
|
||||||
<div class="flex gap-1">
|
|
||||||
<button
|
|
||||||
class="w-6 h-6 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors"
|
|
||||||
onclick={() => (showCreateRoomModal = true)}
|
|
||||||
title="Create room"
|
|
||||||
>
|
|
||||||
<span class="material-symbols-rounded" style="font-size: 16px;">add_circle</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Room list -->
|
|
||||||
<nav class="flex-1 overflow-y-auto px-2 pb-2">
|
|
||||||
{#if filteredRooms.length === 0}
|
|
||||||
<p class="text-light/40 text-sm text-center py-8">
|
|
||||||
{roomSearchQuery ? "No matching rooms" : "No rooms yet"}
|
{roomSearchQuery ? "No matching rooms" : "No rooms yet"}
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="flex flex-col gap-1">
|
<!-- Org / Space Rooms -->
|
||||||
{#each filteredRooms as room (room.roomId)}
|
{#if filteredOrgRooms.length > 0}
|
||||||
<li>
|
<div class="mb-1.5">
|
||||||
|
<div class="flex items-center justify-between px-2 py-1">
|
||||||
|
<span class="text-[10px] font-body text-light/30 uppercase tracking-wider">Organization</span>
|
||||||
<button
|
<button
|
||||||
class="w-full flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors text-left
|
class="p-0.5 text-light/30 hover:text-white hover:bg-dark/50 rounded transition-colors"
|
||||||
{$selectedRoomId === room.roomId ? 'bg-primary/20' : 'hover:bg-light/5'}"
|
onclick={() => (showCreateRoomModal = true)}
|
||||||
onclick={() => handleRoomSelect(room.roomId)}
|
title="Create room"
|
||||||
>
|
>
|
||||||
<Avatar src={room.avatarUrl} name={room.name} size="xs" />
|
<span class="material-symbols-rounded" style="font-size: 14px;">add</span>
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<span class="font-bold text-sm text-light 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">
|
|
||||||
{room.unreadCount > 99 ? "99+" : room.unreadCount}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</div>
|
||||||
{/each}
|
<ul class="flex flex-col gap-0.5">
|
||||||
</ul>
|
{#each filteredOrgRooms as room (room.roomId)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg transition-colors text-left
|
||||||
|
{$selectedRoomId === room.roomId ? 'bg-primary/10 text-white' : 'text-light/60 hover:bg-dark/50 hover:text-white'}"
|
||||||
|
onclick={() => handleRoomSelect(room.roomId)}
|
||||||
|
>
|
||||||
|
<Avatar src={room.avatarUrl} name={room.name} size="xs" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<span class="text-[12px] font-body truncate block">{room.name}</span>
|
||||||
|
</div>
|
||||||
|
{#if room.unreadCount > 0}
|
||||||
|
<span class="bg-primary text-background text-[10px] px-1.5 py-0.5 rounded-full min-w-[16px] text-center font-bold">
|
||||||
|
{room.unreadCount > 99 ? "99+" : room.unreadCount}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Direct Messages -->
|
||||||
|
{#if filteredDmRooms.length > 0}
|
||||||
|
<div class="mb-1.5">
|
||||||
|
<div class="flex items-center justify-between px-2 py-1">
|
||||||
|
<span class="text-[10px] font-body text-light/30 uppercase tracking-wider">Direct Messages</span>
|
||||||
|
<button
|
||||||
|
class="p-0.5 text-light/30 hover:text-white hover:bg-dark/50 rounded transition-colors"
|
||||||
|
onclick={() => (showStartDMModal = true)}
|
||||||
|
title="New DM"
|
||||||
|
>
|
||||||
|
<span class="material-symbols-rounded" style="font-size: 14px;">add</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul class="flex flex-col gap-0.5">
|
||||||
|
{#each filteredDmRooms as room (room.roomId)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg transition-colors text-left
|
||||||
|
{$selectedRoomId === room.roomId ? 'bg-primary/10 text-white' : 'text-light/60 hover:bg-dark/50 hover:text-white'}"
|
||||||
|
onclick={() => handleRoomSelect(room.roomId)}
|
||||||
|
>
|
||||||
|
<Avatar src={room.avatarUrl} name={room.name} size="xs" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<span class="text-[12px] font-body truncate block">{room.name}</span>
|
||||||
|
</div>
|
||||||
|
{#if room.unreadCount > 0}
|
||||||
|
<span class="bg-primary text-background text-[10px] px-1.5 py-0.5 rounded-full min-w-[16px] text-center font-bold">
|
||||||
|
{room.unreadCount > 99 ? "99+" : room.unreadCount}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Other Rooms (not in a space, not DMs) -->
|
||||||
|
{#if filteredOtherRooms.length > 0}
|
||||||
|
<div class="mb-1.5">
|
||||||
|
<div class="flex items-center justify-between px-2 py-1">
|
||||||
|
<span class="text-[10px] font-body text-light/30 uppercase tracking-wider">Rooms</span>
|
||||||
|
<button
|
||||||
|
class="p-0.5 text-light/30 hover:text-white hover:bg-dark/50 rounded transition-colors"
|
||||||
|
onclick={() => (showCreateRoomModal = true)}
|
||||||
|
title="Create room"
|
||||||
|
>
|
||||||
|
<span class="material-symbols-rounded" style="font-size: 14px;">add</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul class="flex flex-col gap-0.5">
|
||||||
|
{#each filteredOtherRooms as room (room.roomId)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg transition-colors text-left
|
||||||
|
{$selectedRoomId === room.roomId ? 'bg-primary/10 text-white' : 'text-light/60 hover:bg-dark/50 hover:text-white'}"
|
||||||
|
onclick={() => handleRoomSelect(room.roomId)}
|
||||||
|
>
|
||||||
|
<Avatar src={room.avatarUrl} name={room.name} size="xs" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<span class="text-[12px] font-body truncate block">{room.name}</span>
|
||||||
|
</div>
|
||||||
|
{#if room.unreadCount > 0}
|
||||||
|
<span class="bg-primary text-background text-[10px] px-1.5 py-0.5 rounded-full min-w-[16px] text-center font-bold">
|
||||||
|
{room.unreadCount > 99 ? "99+" : room.unreadCount}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- User footer -->
|
<!-- 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">
|
<div class="flex items-center gap-2">
|
||||||
<Avatar name={$auth.userId || "User"} size="xs" status="online" />
|
<Avatar name={$auth.userId || "User"} size="xs" status="online" />
|
||||||
<div class="flex-1 min-w-0">
|
<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>
|
</div>
|
||||||
<button
|
<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}
|
onclick={handleLogout}
|
||||||
title="Disconnect chat"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main Chat Area -->
|
<!-- 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}
|
{#if $selectedRoomId}
|
||||||
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
|
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
<!-- Room Header -->
|
<!-- 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}
|
{#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" />
|
<Avatar src={room.avatarUrl} name={room.name} size="sm" />
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h2 class="font-heading text-h5 text-light truncate">{room.name}</h2>
|
<h2 class="font-heading text-body-sm text-white truncate">{room.name}</h2>
|
||||||
<p class="text-xs text-light/50">
|
<p class="text-[11px] text-light/40">
|
||||||
{room.memberCount} members{room.isEncrypted ? " · Encrypted" : ""}
|
{room.memberCount} members{room.isEncrypted ? " · Encrypted" : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<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={() => (showMessageSearch = !showMessageSearch)}
|
onclick={() => (showMessageSearch = !showMessageSearch)}
|
||||||
title="Search messages"
|
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>
|
||||||
<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)}
|
onclick={() => (showRoomInfo = !showRoomInfo)}
|
||||||
title="Room info"
|
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>
|
||||||
<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)}
|
onclick={() => (showMemberList = !showMemberList)}
|
||||||
title="Members"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</header>
|
</div>
|
||||||
|
|
||||||
<!-- Message search panel -->
|
<!-- Message search panel -->
|
||||||
{#if showMessageSearch}
|
{#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">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={messageSearchQuery}
|
bind:value={messageSearchQuery}
|
||||||
placeholder="Search messages in this room..."
|
placeholder="Search messages..."
|
||||||
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"
|
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
|
<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 = ""; }}
|
onclick={() => { showMessageSearch = false; messageSearchQuery = ""; }}
|
||||||
>
|
>
|
||||||
<span class="material-symbols-rounded" style="font-size: 16px;">close</span>
|
<span class="material-symbols-rounded" style="font-size: 16px;">close</span>
|
||||||
@@ -563,22 +688,22 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if messageSearchQuery && messageSearchResults.length > 0}
|
{#if messageSearchQuery && messageSearchResults.length > 0}
|
||||||
<div class="mt-2 max-h-48 overflow-y-auto">
|
<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" : ""}
|
{messageSearchResults.length} result{messageSearchResults.length !== 1 ? "s" : ""}
|
||||||
</p>
|
</p>
|
||||||
{#each messageSearchResults.slice(0, 20) as result}
|
{#each messageSearchResults.slice(0, 20) as result}
|
||||||
<button
|
<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 = ""; }}
|
onclick={() => { showMessageSearch = false; messageSearchQuery = ""; }}
|
||||||
>
|
>
|
||||||
<p class="text-xs text-primary">{result.senderName}</p>
|
<p class="text-[11px] text-primary">{result.senderName}</p>
|
||||||
<p class="text-sm text-light truncate">{result.content}</p>
|
<p class="text-body-sm text-white truncate">{result.content}</p>
|
||||||
<p class="text-xs text-light/30">{new Date(result.timestamp).toLocaleString()}</p>
|
<p class="text-[10px] text-light/30">{new Date(result.timestamp).toLocaleString()}</p>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if messageSearchQuery}
|
{: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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -592,11 +717,11 @@
|
|||||||
role="region"
|
role="region"
|
||||||
>
|
>
|
||||||
{#if isDraggingFile}
|
{#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">
|
<div class="text-center">
|
||||||
<span class="material-symbols-rounded text-primary mb-4 block" style="font-size: 64px;">upload_file</span>
|
<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-xl font-semibold text-primary">Drop to upload</p>
|
<p class="text-body-sm font-heading text-primary">Drop to upload</p>
|
||||||
<p class="text-sm text-light/60 mt-1">Release to send file</p>
|
<p class="text-[12px] text-light/40 mt-0.5">Release to send file</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -627,7 +752,7 @@
|
|||||||
<!-- Side panels -->
|
<!-- Side panels -->
|
||||||
{#if showRoomInfo}
|
{#if showRoomInfo}
|
||||||
{#each $roomSummaries.filter((r) => r.roomId === $selectedRoomId) as currentRoom}
|
{#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
|
<RoomInfoPanel
|
||||||
room={currentRoom}
|
room={currentRoom}
|
||||||
members={currentMembers}
|
members={currentMembers}
|
||||||
@@ -636,7 +761,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
{/each}
|
{/each}
|
||||||
{:else if showMemberList}
|
{: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} />
|
<MemberList members={currentMembers} />
|
||||||
</aside>
|
</aside>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -645,10 +770,10 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<!-- No room selected -->
|
<!-- No room selected -->
|
||||||
<div class="flex-1 flex items-center justify-center">
|
<div class="flex-1 flex items-center justify-center">
|
||||||
<div class="text-center text-light/40">
|
<div class="text-center text-light/30">
|
||||||
<span class="material-symbols-rounded mb-4 block" style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300;">chat</span>
|
<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>
|
||||||
<h2 class="font-heading text-h4 text-light/50 mb-2">Select a room</h2>
|
<p class="text-body-sm text-light/40 mb-1">Select a room</p>
|
||||||
<p class="text-body text-light/30">Choose a conversation to start chatting</p>
|
<p class="text-[12px] text-light/20">Choose a conversation to start chatting</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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>
|
<title>Files - {data.org.name} | Root</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="h-full p-4 lg:p-5">
|
<div class="h-full p-6">
|
||||||
<FileBrowser
|
<FileBrowser
|
||||||
org={data.org}
|
org={data.org}
|
||||||
bind:documents
|
bind:documents
|
||||||
|
|||||||
49
src/routes/[orgSlug]/events/+layout.svelte
Normal file
49
src/routes/[orgSlug]/events/+layout.svelte
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import { navigating, page } from "$app/stores";
|
||||||
|
import { PageHeader, ContentSkeleton } from "$lib/components/ui";
|
||||||
|
import * as m from "$lib/paraglide/messages";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: {
|
||||||
|
org: { id: string; name: string; slug: string };
|
||||||
|
userRole: string;
|
||||||
|
};
|
||||||
|
children: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data, children }: Props = $props();
|
||||||
|
|
||||||
|
// Only show the events list header when on the events list page itself,
|
||||||
|
// not on event detail pages (which have their own layout)
|
||||||
|
const isEventsList = $derived(
|
||||||
|
$page.url.pathname === `/${data.org.slug}/events`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isNavigatingToList = $derived(
|
||||||
|
$navigating?.to?.url.pathname === `/${data.org.slug}/events`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const showListLayout = $derived(isEventsList || isNavigatingToList);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if showListLayout}
|
||||||
|
<div class="flex flex-col h-full overflow-hidden">
|
||||||
|
<PageHeader
|
||||||
|
title={m.events_title()}
|
||||||
|
subtitle={m.events_subtitle()}
|
||||||
|
icon="celebration"
|
||||||
|
iconColor="text-primary"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if isNavigatingToList && !isEventsList}
|
||||||
|
<ContentSkeleton variant="list" />
|
||||||
|
{:else}
|
||||||
|
<div class="flex-1 overflow-hidden">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
29
src/routes/[orgSlug]/events/+page.server.ts
Normal file
29
src/routes/[orgSlug]/events/+page.server.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { fetchEvents } from '$lib/api/events';
|
||||||
|
import { createLogger } from '$lib/utils/logger';
|
||||||
|
|
||||||
|
const log = createLogger('page.events');
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, locals, url }) => {
|
||||||
|
const { session, user } = await locals.safeGetSession();
|
||||||
|
if (!session || !user) error(401, 'Unauthorized');
|
||||||
|
|
||||||
|
const { data: org } = await locals.supabase
|
||||||
|
.from('organizations')
|
||||||
|
.select('id')
|
||||||
|
.eq('slug', params.orgSlug)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!org) error(404, 'Organization not found');
|
||||||
|
|
||||||
|
const statusFilter = url.searchParams.get('status') || 'all';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await fetchEvents(locals.supabase, org.id, statusFilter);
|
||||||
|
return { events, statusFilter };
|
||||||
|
} catch (e: any) {
|
||||||
|
log.error('Failed to load events', { error: e, data: { orgId: org.id } });
|
||||||
|
return { events: [], statusFilter };
|
||||||
|
}
|
||||||
|
};
|
||||||
365
src/routes/[orgSlug]/events/+page.svelte
Normal file
365
src/routes/[orgSlug]/events/+page.svelte
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
import { EventCard, TabBar, Button } from "$lib/components/ui";
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
import type { Database } from "$lib/supabase/types";
|
||||||
|
import { toasts } from "$lib/stores/ui";
|
||||||
|
import * as m from "$lib/paraglide/messages";
|
||||||
|
|
||||||
|
interface EventItem {
|
||||||
|
id: string;
|
||||||
|
org_id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string | null;
|
||||||
|
status: "planning" | "active" | "completed" | "archived";
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
venue_name: string | null;
|
||||||
|
color: string | null;
|
||||||
|
member_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: {
|
||||||
|
org: { id: string; name: string; slug: string };
|
||||||
|
userRole: string;
|
||||||
|
events: EventItem[];
|
||||||
|
statusFilter: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
const supabase = getContext<SupabaseClient<Database>>("supabase");
|
||||||
|
|
||||||
|
const isEditor = $derived(
|
||||||
|
["owner", "admin", "editor"].includes(data.userRole),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create event modal
|
||||||
|
let showCreateModal = $state(false);
|
||||||
|
let newEventName = $state("");
|
||||||
|
let newEventDescription = $state("");
|
||||||
|
let newEventStartDate = $state("");
|
||||||
|
let newEventEndDate = $state("");
|
||||||
|
let newEventVenue = $state("");
|
||||||
|
let newEventColor = $state("#00A3E0");
|
||||||
|
let creating = $state(false);
|
||||||
|
|
||||||
|
const statusTabs = $derived([
|
||||||
|
{ value: "all", label: m.events_tab_all(), icon: "apps" },
|
||||||
|
{ value: "planning", label: m.events_tab_planning(), icon: "edit_note" },
|
||||||
|
{ value: "active", label: m.events_tab_active(), icon: "play_circle" },
|
||||||
|
{ value: "completed", label: m.events_tab_completed(), icon: "check_circle" },
|
||||||
|
{ value: "archived", label: m.events_tab_archived(), icon: "archive" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const presetColors = [
|
||||||
|
"#00A3E0",
|
||||||
|
"#8B5CF6",
|
||||||
|
"#EC4899",
|
||||||
|
"#F59E0B",
|
||||||
|
"#10B981",
|
||||||
|
"#EF4444",
|
||||||
|
"#6366F1",
|
||||||
|
"#14B8A6",
|
||||||
|
];
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!newEventName.trim()) return;
|
||||||
|
creating = true;
|
||||||
|
try {
|
||||||
|
const { data: created, error } = await (supabase as any)
|
||||||
|
.from("events")
|
||||||
|
.insert({
|
||||||
|
org_id: data.org.id,
|
||||||
|
name: newEventName.trim(),
|
||||||
|
slug: newEventName
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\s-]/g, "")
|
||||||
|
.replace(/[\s_]+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.slice(0, 60) || "event",
|
||||||
|
description: newEventDescription.trim() || null,
|
||||||
|
start_date: newEventStartDate || null,
|
||||||
|
end_date: newEventEndDate || null,
|
||||||
|
venue_name: newEventVenue.trim() || null,
|
||||||
|
color: newEventColor,
|
||||||
|
created_by: (await supabase.auth.getUser()).data.user?.id,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
toasts.success(m.events_created({ name: created.name }));
|
||||||
|
showCreateModal = false;
|
||||||
|
resetForm();
|
||||||
|
goto(`/${data.org.slug}/events/${created.slug}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
toasts.error(e.message || "Failed to create event");
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
newEventName = "";
|
||||||
|
newEventDescription = "";
|
||||||
|
newEventStartDate = "";
|
||||||
|
newEventEndDate = "";
|
||||||
|
newEventVenue = "";
|
||||||
|
newEventColor = "#00A3E0";
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchStatus(status: string) {
|
||||||
|
const url = new URL($page.url);
|
||||||
|
if (status === "all") {
|
||||||
|
url.searchParams.delete("status");
|
||||||
|
} else {
|
||||||
|
url.searchParams.set("status", status);
|
||||||
|
}
|
||||||
|
goto(url.toString(), { replaceState: true, invalidateAll: true });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{m.events_title()} | {data.org.name}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<!-- Toolbar: Status Tabs + Create Button -->
|
||||||
|
<div class="flex items-center justify-between px-6 py-3 border-b border-light/5 shrink-0">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{#each statusTabs as tab}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-body-sm font-body transition-colors {data.statusFilter ===
|
||||||
|
tab.value
|
||||||
|
? 'bg-primary text-background'
|
||||||
|
: 'text-light/50 hover:text-white hover:bg-dark/50'}"
|
||||||
|
onclick={() => switchStatus(tab.value)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded"
|
||||||
|
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||||
|
>{tab.icon}</span
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if isEditor}
|
||||||
|
<Button size="sm" icon="add" onclick={() => (showCreateModal = true)}>
|
||||||
|
{m.events_new()}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Events Grid -->
|
||||||
|
<div class="flex-1 overflow-auto p-6">
|
||||||
|
{#if data.events.length === 0}
|
||||||
|
<div class="flex flex-col items-center justify-center h-full text-light/40">
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded mb-4"
|
||||||
|
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
|
||||||
|
>celebration</span
|
||||||
|
>
|
||||||
|
<p class="text-h3 font-heading mb-2">{m.events_empty_title()}</p>
|
||||||
|
<p class="text-body text-light/30">{m.events_empty_desc()}</p>
|
||||||
|
{#if isEditor}
|
||||||
|
<div class="mt-4">
|
||||||
|
<Button icon="add" onclick={() => (showCreateModal = true)}>
|
||||||
|
{m.events_create()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
|
{#each data.events as event}
|
||||||
|
<EventCard
|
||||||
|
name={event.name}
|
||||||
|
slug={event.slug}
|
||||||
|
status={event.status}
|
||||||
|
startDate={event.start_date}
|
||||||
|
endDate={event.end_date}
|
||||||
|
color={event.color}
|
||||||
|
venueName={event.venue_name}
|
||||||
|
href="/{data.org.slug}/events/{event.slug}"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Event Modal -->
|
||||||
|
{#if showCreateModal}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
|
||||||
|
onkeydown={(e) => e.key === "Escape" && (showCreateModal = false)}
|
||||||
|
onclick={(e) => e.target === e.currentTarget && (showCreateModal = false)}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={m.events_create()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-night rounded-2xl w-full max-w-lg shadow-2xl border border-light/10"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between p-5 border-b border-light/5">
|
||||||
|
<h2 class="text-h3 font-heading text-white">{m.events_create()}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-light/40 hover:text-white transition-colors"
|
||||||
|
onclick={() => (showCreateModal = false)}
|
||||||
|
aria-label={m.btn_close()}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded"
|
||||||
|
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
|
||||||
|
>close</span
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="p-5 flex flex-col gap-4"
|
||||||
|
onsubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCreate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<!-- Name -->
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
for="event-name"
|
||||||
|
class="text-body-sm text-light/60 font-body"
|
||||||
|
>{m.events_form_name()}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="event-name"
|
||||||
|
type="text"
|
||||||
|
bind:value={newEventName}
|
||||||
|
placeholder={m.events_form_name_placeholder()}
|
||||||
|
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
for="event-desc"
|
||||||
|
class="text-body-sm text-light/60 font-body"
|
||||||
|
>{m.events_form_description()}</label
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
id="event-desc"
|
||||||
|
bind:value={newEventDescription}
|
||||||
|
placeholder={m.events_form_description_placeholder()}
|
||||||
|
rows="2"
|
||||||
|
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body text-white placeholder:text-light/30 focus:outline-none focus:border-primary resize-none"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dates -->
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
for="event-start"
|
||||||
|
class="text-body-sm text-light/60 font-body"
|
||||||
|
>{m.events_form_start_date()}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="event-start"
|
||||||
|
type="date"
|
||||||
|
bind:value={newEventStartDate}
|
||||||
|
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body text-white focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
for="event-end"
|
||||||
|
class="text-body-sm text-light/60 font-body"
|
||||||
|
>{m.events_form_end_date()}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="event-end"
|
||||||
|
type="date"
|
||||||
|
bind:value={newEventEndDate}
|
||||||
|
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body text-white focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Venue -->
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
for="event-venue"
|
||||||
|
class="text-body-sm text-light/60 font-body"
|
||||||
|
>{m.events_form_venue()}</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="event-venue"
|
||||||
|
type="text"
|
||||||
|
bind:value={newEventVenue}
|
||||||
|
placeholder={m.events_form_venue_placeholder()}
|
||||||
|
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Color -->
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
|
<label class="text-body-sm text-light/60 font-body"
|
||||||
|
>{m.events_form_color()}</label
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#each presetColors as color}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-7 h-7 rounded-full border-2 transition-all {newEventColor ===
|
||||||
|
color
|
||||||
|
? 'border-white scale-110'
|
||||||
|
: 'border-transparent hover:border-light/30'}"
|
||||||
|
style="background-color: {color}"
|
||||||
|
onclick={() => (newEventColor = color)}
|
||||||
|
aria-label={m.events_form_select_color({ color })}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-end gap-3 pt-2 border-t border-light/5"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
|
||||||
|
onclick={() => {
|
||||||
|
showCreateModal = false;
|
||||||
|
resetForm();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.btn_cancel()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!newEventName.trim() || creating}
|
||||||
|
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{creating ? m.events_creating() : m.events_create()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
31
src/routes/[orgSlug]/events/[eventSlug]/+layout.server.ts
Normal file
31
src/routes/[orgSlug]/events/[eventSlug]/+layout.server.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
import { fetchEventBySlug, fetchEventMembers, fetchEventRoles, fetchEventDepartments } from '$lib/api/events';
|
||||||
|
import { createLogger } from '$lib/utils/logger';
|
||||||
|
|
||||||
|
const log = createLogger('page.event-detail');
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ params, locals, parent }) => {
|
||||||
|
const { session, user } = await locals.safeGetSession();
|
||||||
|
if (!session || !user) error(401, 'Unauthorized');
|
||||||
|
|
||||||
|
const parentData = await parent() as { org: { id: string; name: string; slug: string } };
|
||||||
|
const orgId = parentData.org.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const event = await fetchEventBySlug(locals.supabase, orgId, params.eventSlug);
|
||||||
|
if (!event) error(404, 'Event not found');
|
||||||
|
|
||||||
|
const [members, roles, departments] = await Promise.all([
|
||||||
|
fetchEventMembers(locals.supabase, event.id),
|
||||||
|
fetchEventRoles(locals.supabase, event.id),
|
||||||
|
fetchEventDepartments(locals.supabase, event.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { event, eventMembers: members, eventRoles: roles, eventDepartments: departments };
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.status === 404) throw e;
|
||||||
|
log.error('Failed to load event', { error: e, data: { orgId, eventSlug: params.eventSlug } });
|
||||||
|
error(500, 'Failed to load event');
|
||||||
|
}
|
||||||
|
};
|
||||||
198
src/routes/[orgSlug]/events/[eventSlug]/+layout.svelte
Normal file
198
src/routes/[orgSlug]/events/[eventSlug]/+layout.svelte
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } 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 {
|
||||||
|
if (exact) return $page.url.pathname === href;
|
||||||
|
return $page.url.pathname.startsWith(href);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
>
|
||||||
|
{mod.label}
|
||||||
|
</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 as any)
|
||||||
|
.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 as any)
|
||||||
|
.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}
|
||||||
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>
|
||||||
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>
|
||||||
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,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,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>
|
||||||
32
src/routes/[orgSlug]/events/[eventSlug]/tasks/+page.svelte
Normal file
32
src/routes/[orgSlug]/events/[eventSlug]/tasks/+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_tasks()} | {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;"
|
||||||
|
>task_alt</span
|
||||||
|
>
|
||||||
|
<h2 class="text-h3 font-heading text-white mb-2">{m.events_mod_tasks()}</h2>
|
||||||
|
<p class="text-body-sm text-light/30 text-center max-w-sm">
|
||||||
|
{m.events_mod_tasks_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>
|
||||||
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 as any)
|
||||||
|
.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 as any)
|
||||||
|
.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 as any)
|
||||||
|
.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 as any)
|
||||||
|
.from("event_member_departments")
|
||||||
|
.delete()
|
||||||
|
.eq("event_member_id", editingMember.id)
|
||||||
|
.eq("department_id", deptId);
|
||||||
|
}
|
||||||
|
for (const deptId of toAdd) {
|
||||||
|
await (supabase as any)
|
||||||
|
.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 as any)
|
||||||
|
.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 as any)
|
||||||
|
.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 as any)
|
||||||
|
.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 as any)
|
||||||
|
.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 as any)
|
||||||
|
.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 as any)
|
||||||
|
.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 as any)
|
||||||
|
.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>
|
||||||
@@ -494,13 +494,13 @@
|
|||||||
>
|
>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex flex-col h-full p-4 lg:p-5 gap-4">
|
<div class="flex flex-col h-full">
|
||||||
<!-- Header -->
|
<!-- Board toolbar -->
|
||||||
<header class="flex items-center gap-2 p-1">
|
<div class="flex items-center gap-2 px-6 py-3 border-b border-light/5 shrink-0">
|
||||||
{#if isRenamingBoard && selectedBoard}
|
{#if isRenamingBoard && selectedBoard}
|
||||||
<input
|
<input
|
||||||
type="text"
|
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}
|
bind:value={renameBoardValue}
|
||||||
onkeydown={(e) => {
|
onkeydown={(e) => {
|
||||||
if (e.key === "Enter") confirmBoardRename();
|
if (e.key === "Enter") confirmBoardRename();
|
||||||
@@ -509,12 +509,30 @@
|
|||||||
onblur={confirmBoardRename}
|
onblur={confirmBoardRename}
|
||||||
autofocus
|
autofocus
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else if selectedBoard}
|
||||||
<h1 class="flex-1 font-heading text-h1 text-white">
|
<h2 class="font-heading text-body text-white">{selectedBoard.name}</h2>
|
||||||
{selectedBoard ? selectedBoard.name : m.kanban_title()}
|
|
||||||
</h1>
|
|
||||||
{/if}
|
{/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
|
>{m.btn_new()}</Button
|
||||||
>
|
>
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
@@ -539,28 +557,10 @@
|
|||||||
: []),
|
: []),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</header>
|
</div>
|
||||||
|
|
||||||
<!-- 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 -->
|
<!-- Kanban Board -->
|
||||||
<div class="flex-1 overflow-hidden">
|
<div class="flex-1 overflow-hidden p-4">
|
||||||
{#if selectedBoard}
|
{#if selectedBoard}
|
||||||
<KanbanBoard
|
<KanbanBoard
|
||||||
columns={selectedBoard.columns}
|
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,33 +274,24 @@
|
|||||||
<title>Settings - {data.org.name} | Root</title>
|
<title>Settings - {data.org.name} | Root</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex flex-col h-full p-4 lg:p-5 gap-4 overflow-auto">
|
<div class="flex flex-col h-full">
|
||||||
<!-- Header -->
|
<!-- Tab Navigation -->
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-wrap gap-1 px-6 py-3 border-b border-light/5 shrink-0">
|
||||||
<header class="flex flex-wrap items-center gap-2 p-1 rounded-[32px]">
|
{#each tabs as tab}
|
||||||
<Avatar name="Settings" size="md" />
|
<button
|
||||||
<h1 class="flex-1 font-heading text-h1 text-white">
|
type="button"
|
||||||
{m.settings_title()}
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-body-sm font-body transition-colors {activeTab === tab.id
|
||||||
</h1>
|
? 'bg-primary text-background'
|
||||||
<IconButton title="More options">
|
: 'text-light/50 hover:text-white hover:bg-dark/50'}"
|
||||||
<Icon name="more_horiz" size={24} />
|
onclick={() => (activeTab = tab.id)}
|
||||||
</IconButton>
|
>
|
||||||
</header>
|
{tab.label}
|
||||||
|
</button>
|
||||||
<!-- Pill Tab Navigation -->
|
{/each}
|
||||||
<div class="flex flex-wrap gap-4">
|
|
||||||
{#each tabs as tab}
|
|
||||||
<Button
|
|
||||||
variant={activeTab === tab.id ? "primary" : "secondary"}
|
|
||||||
size="md"
|
|
||||||
onclick={() => (activeTab = tab.id)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</Button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-auto p-6">
|
||||||
|
|
||||||
<!-- General Tab -->
|
<!-- General Tab -->
|
||||||
{#if activeTab === "general"}
|
{#if activeTab === "general"}
|
||||||
<SettingsGeneral
|
<SettingsGeneral
|
||||||
@@ -331,74 +322,73 @@
|
|||||||
|
|
||||||
<!-- Tags Tab -->
|
<!-- Tags Tab -->
|
||||||
{#if activeTab === "tags"}
|
{#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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold text-light">
|
<h2 class="text-body font-heading text-white">
|
||||||
{m.settings_tags_title()}
|
{m.settings_tags_title()}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-sm text-light/50">
|
<p class="text-body-sm text-light/50 mt-0.5">
|
||||||
{m.settings_tags_desc()}
|
{m.settings_tags_desc()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onclick={() => openTagModal()} icon="add">
|
<Button size="sm" onclick={() => openTagModal()} icon="add">
|
||||||
{m.settings_tags_create()}
|
{m.settings_tags_create()}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if orgTags.length === 0 && tagsLoaded}
|
{#if orgTags.length === 0 && tagsLoaded}
|
||||||
<Card>
|
<div class="bg-dark/30 border border-light/5 rounded-xl p-8 text-center">
|
||||||
<div class="p-8 text-center">
|
<span
|
||||||
<span
|
class="material-symbols-rounded text-light/20 mb-3 block"
|
||||||
class="material-symbols-rounded text-light/20 mb-4 block"
|
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
|
||||||
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
|
>label</span
|
||||||
>label</span
|
>
|
||||||
>
|
<p class="text-body-sm text-light/40">{m.settings_tags_empty()}</p>
|
||||||
<p class="text-light/50">{m.settings_tags_empty()}</p>
|
</div>
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid gap-3">
|
<div class="flex flex-col gap-2">
|
||||||
{#each orgTags as tag}
|
{#each orgTags as tag}
|
||||||
<Card>
|
<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 justify-between p-4">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex items-center gap-3">
|
<div
|
||||||
<div
|
class="w-7 h-7 rounded-lg flex items-center justify-center shrink-0"
|
||||||
class="w-8 h-8 rounded-lg flex items-center justify-center"
|
style="background-color: {tag.color || '#00A3E0'}"
|
||||||
style="background-color: {tag.color ||
|
>
|
||||||
'#00A3E0'}"
|
<span
|
||||||
|
class="material-symbols-rounded text-night"
|
||||||
|
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 600, 'GRAD' 0, 'opsz' 16;"
|
||||||
|
>label</span
|
||||||
>
|
>
|
||||||
<span
|
|
||||||
class="material-symbols-rounded text-night"
|
|
||||||
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 600, 'GRAD' 0, 'opsz' 18;"
|
|
||||||
>label</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-light font-medium">
|
|
||||||
{tag.name}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-light/40">
|
|
||||||
{tag.color || "#00A3E0"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div>
|
||||||
<Button
|
<p class="text-body-sm text-white font-medium">
|
||||||
variant="tertiary"
|
{tag.name}
|
||||||
size="sm"
|
</p>
|
||||||
onclick={() => openTagModal(tag)}
|
<p class="text-[11px] text-light/30">
|
||||||
>Edit</Button
|
{tag.color || "#00A3E0"}
|
||||||
>
|
</p>
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onclick={() => deleteOrgTag(tag)}
|
|
||||||
>Delete</Button
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
<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)}
|
||||||
|
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={() => deleteOrgTag(tag)}
|
||||||
|
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>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -416,6 +406,7 @@
|
|||||||
serviceAccountEmail={data.serviceAccountEmail ?? null}
|
serviceAccountEmail={data.serviceAccountEmail ?? null}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create/Edit Tag Modal -->
|
<!-- Create/Edit Tag Modal -->
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
// Cast supabase to any to bypass typed client — matrix_credentials table
|
|
||||||
// was added in migration 020 but types haven't been regenerated yet.
|
|
||||||
// TODO: Remove casts after running `supabase gen types`
|
|
||||||
const db = (supabase: any) => supabase;
|
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||||
const session = await locals.safeGetSession();
|
const session = await locals.safeGetSession();
|
||||||
if (!session.user) {
|
if (!session.user) {
|
||||||
@@ -17,7 +12,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
|
|||||||
return json({ error: 'org_id is required' }, { status: 400 });
|
return json({ error: 'org_id is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await db(locals.supabase)
|
const { data, error } = await locals.supabase
|
||||||
.from('matrix_credentials')
|
.from('matrix_credentials')
|
||||||
.select('homeserver_url, matrix_user_id, access_token, device_id')
|
.select('homeserver_url, matrix_user_id, access_token, device_id')
|
||||||
.eq('user_id', session.user.id)
|
.eq('user_id', session.user.id)
|
||||||
@@ -44,7 +39,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
|||||||
return json({ error: 'Missing required fields' }, { status: 400 });
|
return json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = await db(locals.supabase)
|
const { error } = await locals.supabase
|
||||||
.from('matrix_credentials')
|
.from('matrix_credentials')
|
||||||
.upsert(
|
.upsert(
|
||||||
{
|
{
|
||||||
@@ -76,7 +71,7 @@ export const DELETE: RequestHandler = async ({ url, locals }) => {
|
|||||||
return json({ error: 'org_id is required' }, { status: 400 });
|
return json({ error: 'org_id is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { error } = await db(locals.supabase)
|
const { error } = await locals.supabase
|
||||||
.from('matrix_credentials')
|
.from('matrix_credentials')
|
||||||
.delete()
|
.delete()
|
||||||
.eq('user_id', session.user.id)
|
.eq('user_id', session.user.id)
|
||||||
|
|||||||
168
src/routes/api/matrix-space/+server.ts
Normal file
168
src/routes/api/matrix-space/+server.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET: Retrieve the Matrix Space ID for an org
|
||||||
|
*/
|
||||||
|
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||||
|
const session = await locals.safeGetSession();
|
||||||
|
if (!session.user) {
|
||||||
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = url.searchParams.get('org_id');
|
||||||
|
if (!orgId) {
|
||||||
|
return json({ error: 'org_id is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await locals.supabase
|
||||||
|
.from('organizations')
|
||||||
|
.select('matrix_space_id')
|
||||||
|
.eq('id', orgId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return json({ error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ spaceId: data?.matrix_space_id ?? null });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST: Create a Matrix Space for an org, or link an existing one.
|
||||||
|
*
|
||||||
|
* Body options:
|
||||||
|
* - { org_id, action: "create", homeserver_url, access_token, org_name }
|
||||||
|
* Creates a new Space on the homeserver and stores the ID.
|
||||||
|
* - { org_id, action: "link", space_id }
|
||||||
|
* Links an existing Matrix Space ID to the org.
|
||||||
|
*/
|
||||||
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
|
const session = await locals.safeGetSession();
|
||||||
|
if (!session.user) {
|
||||||
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { org_id, action } = body;
|
||||||
|
|
||||||
|
if (!org_id || !action) {
|
||||||
|
return json({ error: 'org_id and action are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'create') {
|
||||||
|
const { homeserver_url, access_token, org_name } = body;
|
||||||
|
if (!homeserver_url || !access_token || !org_name) {
|
||||||
|
return json({ error: 'homeserver_url, access_token, and org_name are required for create' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a Matrix Space via the Client-Server API
|
||||||
|
const createRes = await fetch(`${homeserver_url}/_matrix/client/v3/createRoom`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${access_token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: org_name,
|
||||||
|
topic: `Organization space for ${org_name}`,
|
||||||
|
visibility: 'private',
|
||||||
|
creation_content: {
|
||||||
|
type: 'm.space',
|
||||||
|
},
|
||||||
|
initial_state: [
|
||||||
|
{
|
||||||
|
type: 'm.room.guest_access',
|
||||||
|
state_key: '',
|
||||||
|
content: { guest_access: 'can_join' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
power_level_content_override: {
|
||||||
|
invite: 50,
|
||||||
|
kick: 50,
|
||||||
|
ban: 50,
|
||||||
|
events_default: 0,
|
||||||
|
state_default: 50,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createRes.ok) {
|
||||||
|
const err = await createRes.json().catch(() => ({}));
|
||||||
|
return json({ error: err.error || 'Failed to create Matrix Space' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { room_id: spaceId } = await createRes.json();
|
||||||
|
|
||||||
|
// Also create default #general room inside the space
|
||||||
|
const generalRes = await fetch(`${homeserver_url}/_matrix/client/v3/createRoom`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${access_token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: 'General',
|
||||||
|
topic: 'General discussion',
|
||||||
|
visibility: 'private',
|
||||||
|
preset: 'private_chat',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (generalRes.ok) {
|
||||||
|
const { room_id: generalRoomId } = await generalRes.json();
|
||||||
|
|
||||||
|
// Add #general as a child of the space
|
||||||
|
await fetch(
|
||||||
|
`${homeserver_url}/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state/m.space.child/${encodeURIComponent(generalRoomId)}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${access_token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
via: [new URL(homeserver_url).hostname],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store space ID in org record
|
||||||
|
const { error: updateError } = await locals.supabase
|
||||||
|
.from('organizations')
|
||||||
|
.update({ matrix_space_id: spaceId })
|
||||||
|
.eq('id', org_id);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
return json({ error: updateError.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ spaceId, created: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Failed to create Matrix Space:', e);
|
||||||
|
return json({ error: e.message || 'Failed to create Matrix Space' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'link') {
|
||||||
|
const { space_id } = body;
|
||||||
|
if (!space_id) {
|
||||||
|
return json({ error: 'space_id is required for link action' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: updateError } = await locals.supabase
|
||||||
|
.from('organizations')
|
||||||
|
.update({ matrix_space_id: space_id })
|
||||||
|
.eq('id', org_id);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
return json({ error: updateError.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ spaceId: space_id, linked: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ error: 'Invalid action. Use "create" or "link".' }, { status: 400 });
|
||||||
|
};
|
||||||
184
src/routes/api/matrix-space/members/+server.ts
Normal file
184
src/routes/api/matrix-space/members/+server.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST: Invite a user to the org's Matrix Space (and its child rooms).
|
||||||
|
*
|
||||||
|
* Body: { org_id, matrix_user_id, homeserver_url, access_token }
|
||||||
|
*/
|
||||||
|
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||||
|
const session = await locals.safeGetSession();
|
||||||
|
if (!session.user) {
|
||||||
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { org_id, matrix_user_id, homeserver_url, access_token } = body;
|
||||||
|
|
||||||
|
if (!org_id || !matrix_user_id || !homeserver_url || !access_token) {
|
||||||
|
return json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get org's Matrix Space ID
|
||||||
|
const { data: org } = await locals.supabase
|
||||||
|
.from('organizations')
|
||||||
|
.select('matrix_space_id')
|
||||||
|
.eq('id', org_id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!org?.matrix_space_id) {
|
||||||
|
return json({ error: 'Organization has no Matrix Space' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaceId = org.matrix_space_id;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Invite to the Space itself
|
||||||
|
const inviteRes = await matrixInvite(homeserver_url, access_token, spaceId, matrix_user_id);
|
||||||
|
if (!inviteRes.ok) {
|
||||||
|
const err = await inviteRes.json().catch(() => ({}));
|
||||||
|
// M_FORBIDDEN means already joined, which is fine
|
||||||
|
if (err.errcode !== 'M_FORBIDDEN') {
|
||||||
|
errors.push(`Space invite: ${err.error || 'failed'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also invite to all child rooms of the space
|
||||||
|
try {
|
||||||
|
const stateRes = await fetch(
|
||||||
|
`${homeserver_url}/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state`,
|
||||||
|
{
|
||||||
|
headers: { 'Authorization': `Bearer ${access_token}` },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (stateRes.ok) {
|
||||||
|
const stateEvents = await stateRes.json();
|
||||||
|
const childRoomIds = stateEvents
|
||||||
|
.filter((e: any) => e.type === 'm.space.child' && e.content?.via)
|
||||||
|
.map((e: any) => e.state_key);
|
||||||
|
|
||||||
|
for (const childRoomId of childRoomIds) {
|
||||||
|
const childInvite = await matrixInvite(homeserver_url, access_token, childRoomId, matrix_user_id);
|
||||||
|
if (!childInvite.ok) {
|
||||||
|
const err = await childInvite.json().catch(() => ({}));
|
||||||
|
if (err.errcode !== 'M_FORBIDDEN') {
|
||||||
|
errors.push(`Room ${childRoomId}: ${err.error || 'failed'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.push('Failed to fetch space children');
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: errors.length === 0,
|
||||||
|
invited: matrix_user_id,
|
||||||
|
spaceId,
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE: Kick a user from the org's Matrix Space (and its child rooms).
|
||||||
|
*
|
||||||
|
* Query: ?org_id=...&matrix_user_id=...&homeserver_url=...&access_token=...
|
||||||
|
*/
|
||||||
|
export const DELETE: RequestHandler = async ({ url, locals }) => {
|
||||||
|
const session = await locals.safeGetSession();
|
||||||
|
if (!session.user) {
|
||||||
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const org_id = url.searchParams.get('org_id');
|
||||||
|
const matrix_user_id = url.searchParams.get('matrix_user_id');
|
||||||
|
const homeserver_url = url.searchParams.get('homeserver_url');
|
||||||
|
const access_token = url.searchParams.get('access_token');
|
||||||
|
|
||||||
|
if (!org_id || !matrix_user_id || !homeserver_url || !access_token) {
|
||||||
|
return json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: org } = await locals.supabase
|
||||||
|
.from('organizations')
|
||||||
|
.select('matrix_space_id')
|
||||||
|
.eq('id', org_id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!org?.matrix_space_id) {
|
||||||
|
return json({ error: 'Organization has no Matrix Space' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaceId = org.matrix_space_id;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Kick from child rooms first, then from the space
|
||||||
|
try {
|
||||||
|
const stateRes = await fetch(
|
||||||
|
`${homeserver_url}/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state`,
|
||||||
|
{
|
||||||
|
headers: { 'Authorization': `Bearer ${access_token}` },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (stateRes.ok) {
|
||||||
|
const stateEvents = await stateRes.json();
|
||||||
|
const childRoomIds = stateEvents
|
||||||
|
.filter((e: any) => e.type === 'm.space.child' && e.content?.via)
|
||||||
|
.map((e: any) => e.state_key);
|
||||||
|
|
||||||
|
for (const childRoomId of childRoomIds) {
|
||||||
|
const kickRes = await matrixKick(homeserver_url, access_token, childRoomId, matrix_user_id);
|
||||||
|
if (!kickRes.ok) {
|
||||||
|
const err = await kickRes.json().catch(() => ({}));
|
||||||
|
if (err.errcode !== 'M_FORBIDDEN') {
|
||||||
|
errors.push(`Room ${childRoomId}: ${err.error || 'failed'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.push('Failed to fetch space children');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kick from the space itself
|
||||||
|
const kickRes = await matrixKick(homeserver_url, access_token, spaceId, matrix_user_id);
|
||||||
|
if (!kickRes.ok) {
|
||||||
|
const err = await kickRes.json().catch(() => ({}));
|
||||||
|
if (err.errcode !== 'M_FORBIDDEN') {
|
||||||
|
errors.push(`Space kick: ${err.error || 'failed'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
success: errors.length === 0,
|
||||||
|
kicked: matrix_user_id,
|
||||||
|
spaceId,
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper: invite a user to a room
|
||||||
|
async function matrixInvite(homeserver: string, token: string, roomId: string, userId: string) {
|
||||||
|
return fetch(`${homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/invite`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ user_id: userId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: kick a user from a room
|
||||||
|
async function matrixKick(homeserver: string, token: string, roomId: string, userId: string) {
|
||||||
|
return fetch(`${homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/kick`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ user_id: userId, reason: 'Removed from organization' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Tilt+Warp&family=Work+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Tilt+Warp&family=Work+Sans:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap');
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,400,0,0&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..48,400,0,0&display=swap');
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
@import 'highlight.js/styles/github-dark.css';
|
||||||
@plugin '@tailwindcss/forms';
|
@plugin '@tailwindcss/forms';
|
||||||
@plugin '@tailwindcss/typography';
|
@plugin '@tailwindcss/typography';
|
||||||
|
|
||||||
@@ -102,4 +103,68 @@
|
|||||||
.prose h1, .prose h2, .prose h3, .prose h4 { @apply text-light font-heading; margin: 0.75em 0 0.5em; }
|
.prose h1, .prose h2, .prose h3, .prose h4 { @apply text-light font-heading; margin: 0.75em 0 0.5em; }
|
||||||
.prose a { @apply text-primary underline; }
|
.prose a { @apply text-primary underline; }
|
||||||
.prose hr { @apply border-t border-dark my-4; }
|
.prose hr { @apply border-t border-dark my-4; }
|
||||||
|
.prose img { @apply max-w-full rounded-sm; }
|
||||||
|
.prose table { @apply w-full border-collapse my-2; }
|
||||||
|
.prose th, .prose td { @apply border border-dark p-2 text-left; }
|
||||||
|
.prose th { @apply bg-night font-semibold; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat: Inline Twemoji sizing */
|
||||||
|
.twemoji-inline {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1.2em;
|
||||||
|
height: 1.2em;
|
||||||
|
vertical-align: -0.2em;
|
||||||
|
margin: 0 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat: Emoji-only messages show larger emojis */
|
||||||
|
.emoji-only .twemoji-inline {
|
||||||
|
width: 2.8em;
|
||||||
|
height: 2.8em;
|
||||||
|
vertical-align: -0.3em;
|
||||||
|
margin: 0 0.075em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.twemoji {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: -0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat: Mention styles */
|
||||||
|
.mention-ping {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||||
|
color: var(--color-primary);
|
||||||
|
padding: 0 0.25em;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
border: none;
|
||||||
|
transition: background-color 150ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-ping:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-primary) 35%, transparent);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-everyone {
|
||||||
|
background-color: color-mix(in srgb, var(--color-warning) 20%, transparent);
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mention-everyone:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-warning) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat: Message highlight animation for reply scroll */
|
||||||
|
@keyframes message-highlight {
|
||||||
|
0%, 100% { background-color: transparent; }
|
||||||
|
50% { background-color: rgba(0, 163, 224, 0.2); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-highlight {
|
||||||
|
animation: message-highlight 1s ease-in-out 2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,97 +36,76 @@
|
|||||||
<title>Style Guide | Root</title>
|
<title>Style Guide | Root</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="min-h-screen bg-dark p-8">
|
<div class="min-h-screen bg-background">
|
||||||
<div class="max-w-6xl mx-auto space-y-12">
|
<!-- Header -->
|
||||||
<!-- Back Button -->
|
<header class="border-b border-light/5">
|
||||||
<a
|
<div class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
href="/"
|
<div class="flex items-center gap-3">
|
||||||
class="inline-flex items-center gap-2 text-light/60 hover:text-light transition-colors"
|
<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>
|
||||||
<svg
|
</a>
|
||||||
class="w-5 h-5"
|
<span class="font-heading text-body text-white">Style Guide</span>
|
||||||
viewBox="0 0 24 24"
|
<span class="text-[11px] text-light/30 font-body">All UI components and their variants</span>
|
||||||
fill="none"
|
</div>
|
||||||
stroke="currentColor"
|
</div>
|
||||||
stroke-width="2"
|
</header>
|
||||||
>
|
|
||||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
Back to Home
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Header -->
|
<div class="max-w-5xl mx-auto px-6 py-8 space-y-10">
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Colors - Figma Design System -->
|
<!-- Colors -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2
|
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Colors</h2>
|
||||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-3">
|
||||||
>
|
<div class="space-y-1.5">
|
||||||
Colors
|
<div class="w-full h-16 rounded-xl bg-background border border-light/10"></div>
|
||||||
</h2>
|
<p class="text-[12px] text-light/60 font-body">Background</p>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
<code class="text-[10px] text-light/30">#05090F</code>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-1.5">
|
||||||
<div class="w-full h-20 rounded-[32px] bg-night"></div>
|
<div class="w-full h-16 rounded-xl bg-night"></div>
|
||||||
<p class="text-sm text-light/60">Night</p>
|
<p class="text-[12px] text-light/60 font-body">Night</p>
|
||||||
<code class="text-xs text-light/40">#0A121F</code>
|
<code class="text-[10px] text-light/30">#0A121F</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-1.5">
|
||||||
<div class="w-full h-20 rounded-[32px] bg-dark"></div>
|
<div class="w-full h-16 rounded-xl bg-dark"></div>
|
||||||
<p class="text-sm text-light/60">Dark</p>
|
<p class="text-[12px] text-light/60 font-body">Dark</p>
|
||||||
<code class="text-xs text-light/40">#14243E</code>
|
<code class="text-[10px] text-light/30">#14243E</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-1.5">
|
||||||
<div class="w-full h-20 rounded-[32px] bg-light"></div>
|
<div class="w-full h-16 rounded-xl bg-light"></div>
|
||||||
<p class="text-sm text-light/60">Light</p>
|
<p class="text-[12px] text-light/60 font-body">Light</p>
|
||||||
<code class="text-xs text-light/40">#E5E6F0</code>
|
<code class="text-[10px] text-light/30">#E5E6F0</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-1.5">
|
||||||
<div class="w-full h-20 rounded-[32px] bg-primary"></div>
|
<div class="w-full h-16 rounded-xl bg-primary"></div>
|
||||||
<p class="text-sm text-light/60">Primary</p>
|
<p class="text-[12px] text-light/60 font-body">Primary</p>
|
||||||
<code class="text-xs text-light/40">#00A3E0</code>
|
<code class="text-[10px] text-light/30">#00A3E0</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-1.5">
|
||||||
<div class="w-full h-20 rounded-[32px] bg-success"></div>
|
<div class="w-full h-16 rounded-xl bg-success"></div>
|
||||||
<p class="text-sm text-light/60">Success</p>
|
<p class="text-[12px] text-light/60 font-body">Success</p>
|
||||||
<code class="text-xs text-light/40">#33E000</code>
|
<code class="text-[10px] text-light/30">#33E000</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-1.5">
|
||||||
<div class="w-full h-20 rounded-[32px] bg-warning"></div>
|
<div class="w-full h-16 rounded-xl bg-warning"></div>
|
||||||
<p class="text-sm text-light/60">Warning</p>
|
<p class="text-[12px] text-light/60 font-body">Warning</p>
|
||||||
<code class="text-xs text-light/40">#FFAB00</code>
|
<code class="text-[10px] text-light/30">#FFAB00</code>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-1.5">
|
||||||
<div class="w-full h-20 rounded-[32px] bg-error"></div>
|
<div class="w-full h-16 rounded-xl bg-error"></div>
|
||||||
<p class="text-sm text-light/60">Error</p>
|
<p class="text-[12px] text-light/60 font-body">Error</p>
|
||||||
<code class="text-xs text-light/40">#E03D00</code>
|
<code class="text-[10px] text-light/30">#E03D00</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Buttons -->
|
<!-- Buttons -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2
|
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Buttons</h2>
|
||||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
|
||||||
>
|
|
||||||
Buttons
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
<h3 class="text-body-sm font-heading text-light/60 mb-2">Variants</h3>
|
||||||
Variants
|
|
||||||
</h3>
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<Button variant="primary">Primary</Button>
|
<Button variant="primary">Primary</Button>
|
||||||
<Button variant="secondary">Secondary</Button>
|
<Button variant="secondary">Secondary</Button>
|
||||||
@@ -137,9 +116,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
<h3 class="text-body-sm font-heading text-light/60 mb-2">Sizes</h3>
|
||||||
Sizes
|
|
||||||
</h3>
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<Button size="sm">Small</Button>
|
<Button size="sm">Small</Button>
|
||||||
<Button size="md">Medium</Button>
|
<Button size="md">Medium</Button>
|
||||||
@@ -148,9 +125,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
<h3 class="text-body-sm font-heading text-light/60 mb-2">With Icons</h3>
|
||||||
With Icons (Material Symbols)
|
|
||||||
</h3>
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<Button icon="add">Add Item</Button>
|
<Button icon="add">Add Item</Button>
|
||||||
<Button variant="secondary" icon="edit">Edit</Button>
|
<Button variant="secondary" icon="edit">Edit</Button>
|
||||||
@@ -160,9 +135,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
<h3 class="text-body-sm font-heading text-light/60 mb-2">States</h3>
|
||||||
States
|
|
||||||
</h3>
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<Button>Normal</Button>
|
<Button>Normal</Button>
|
||||||
<Button disabled>Disabled</Button>
|
<Button disabled>Disabled</Button>
|
||||||
@@ -171,9 +144,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
<h3 class="text-body-sm font-heading text-light/60 mb-2">Full Width</h3>
|
||||||
Full Width
|
|
||||||
</h3>
|
|
||||||
<div class="max-w-sm">
|
<div class="max-w-sm">
|
||||||
<Button fullWidth icon="rocket_launch"
|
<Button fullWidth icon="rocket_launch"
|
||||||
>Full Width Button</Button
|
>Full Width Button</Button
|
||||||
@@ -185,11 +156,7 @@
|
|||||||
|
|
||||||
<!-- Inputs -->
|
<!-- Inputs -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2
|
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Inputs</h2>
|
||||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
|
||||||
>
|
|
||||||
Inputs
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="grid md:grid-cols-2 gap-6">
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
<Input
|
<Input
|
||||||
@@ -229,11 +196,7 @@
|
|||||||
|
|
||||||
<!-- Textarea -->
|
<!-- Textarea -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2
|
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Textarea</h2>
|
||||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
|
||||||
>
|
|
||||||
Textarea
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="grid md:grid-cols-2 gap-6">
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -251,11 +214,7 @@
|
|||||||
|
|
||||||
<!-- Select -->
|
<!-- Select -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2
|
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Select</h2>
|
||||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
|
||||||
>
|
|
||||||
Select
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="grid md:grid-cols-2 gap-6">
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
<Select
|
<Select
|
||||||
@@ -273,17 +232,11 @@
|
|||||||
|
|
||||||
<!-- Avatars -->
|
<!-- Avatars -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2
|
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Avatars</h2>
|
||||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
|
||||||
>
|
|
||||||
Avatars
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
<h3 class="text-body-sm font-heading text-light/60 mb-2">Sizes</h3>
|
||||||
Sizes
|
|
||||||
</h3>
|
|
||||||
<div class="flex items-end gap-4">
|
<div class="flex items-end gap-4">
|
||||||
<Avatar name="John Doe" size="sm" />
|
<Avatar name="John Doe" size="sm" />
|
||||||
<Avatar name="John Doe" size="md" />
|
<Avatar name="John Doe" size="md" />
|
||||||
@@ -293,9 +246,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
<h3 class="text-body-sm font-heading text-light/60 mb-2">With Status</h3>
|
||||||
With Status (placeholder)
|
|
||||||
</h3>
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<Avatar name="Online User" size="lg" />
|
<Avatar name="Online User" size="lg" />
|
||||||
<Avatar name="Away User" size="lg" />
|
<Avatar name="Away User" size="lg" />
|
||||||
@@ -305,9 +256,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
<h3 class="text-body-sm font-heading text-light/60 mb-2">Color Generation</h3>
|
||||||
Different Names (Color Generation)
|
|
||||||
</h3>
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<Avatar name="Alice" size="lg" />
|
<Avatar name="Alice" size="lg" />
|
||||||
<Avatar name="Bob" size="lg" />
|
<Avatar name="Bob" size="lg" />
|
||||||
@@ -321,17 +270,11 @@
|
|||||||
|
|
||||||
<!-- Chips -->
|
<!-- Chips -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2
|
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Chips</h2>
|
||||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
|
||||||
>
|
|
||||||
Chips
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
<h3 class="text-body-sm font-heading text-light/60 mb-2">Variants</h3>
|
||||||
Variants
|
|
||||||
</h3>
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<Chip variant="primary">Primary</Chip>
|
<Chip variant="primary">Primary</Chip>
|
||||||
<Chip variant="success">Success</Chip>
|
<Chip variant="success">Success</Chip>
|
||||||
@@ -345,11 +288,7 @@
|
|||||||
|
|
||||||
<!-- List Items -->
|
<!-- List Items -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2
|
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">List Items</h2>
|
||||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
|
||||||
>
|
|
||||||
List Items
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="max-w-[240px] space-y-2">
|
<div class="max-w-[240px] space-y-2">
|
||||||
<ListItem icon="info">Default Item</ListItem>
|
<ListItem icon="info">Default Item</ListItem>
|
||||||
@@ -364,11 +303,7 @@
|
|||||||
|
|
||||||
<!-- Org Header -->
|
<!-- Org Header -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2
|
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Organization Header</h2>
|
||||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
|
||||||
>
|
|
||||||
Organization Header
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="max-w-[240px] space-y-4">
|
<div class="max-w-[240px] space-y-4">
|
||||||
<OrgHeader name="Acme Corp" role="Admin" />
|
<OrgHeader name="Acme Corp" role="Admin" />
|
||||||
@@ -379,11 +314,7 @@
|
|||||||
|
|
||||||
<!-- Calendar Day -->
|
<!-- Calendar Day -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2
|
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Calendar Day</h2>
|
||||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
|
||||||
>
|
|
||||||
Calendar Day
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="flex gap-1 max-w-[720px]">
|
<div class="flex gap-1 max-w-[720px]">
|
||||||
<CalendarDay day="Mon" isHeader />
|
<CalendarDay day="Mon" isHeader />
|
||||||
@@ -403,17 +334,11 @@
|
|||||||
|
|
||||||
<!-- Badges -->
|
<!-- Badges -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2
|
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Badges</h2>
|
||||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
|
||||||
>
|
|
||||||
Badges
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
<h3 class="text-body-sm font-heading text-light/60 mb-2">Variants</h3>
|
||||||
Variants
|
|
||||||
</h3>
|
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex flex-wrap gap-3">
|
||||||
<Badge variant="default">Default</Badge>
|
<Badge variant="default">Default</Badge>
|
||||||
<Badge variant="primary">Primary</Badge>
|
<Badge variant="primary">Primary</Badge>
|
||||||
@@ -425,9 +350,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
<h3 class="text-body-sm font-heading text-light/60 mb-2">Sizes</h3>
|
||||||
Sizes
|
|
||||||
</h3>
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<Badge size="sm">Small</Badge>
|
<Badge size="sm">Small</Badge>
|
||||||
<Badge size="md">Medium</Badge>
|
<Badge size="md">Medium</Badge>
|
||||||
@@ -439,11 +362,7 @@
|
|||||||
|
|
||||||
<!-- Cards -->
|
<!-- Cards -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2
|
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Cards</h2>
|
||||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
|
||||||
>
|
|
||||||
Cards
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="grid md:grid-cols-3 gap-6">
|
<div class="grid md:grid-cols-3 gap-6">
|
||||||
<Card variant="default">
|
<Card variant="default">
|
||||||
@@ -469,17 +388,11 @@
|
|||||||
|
|
||||||
<!-- Toggle -->
|
<!-- Toggle -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2
|
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Toggle</h2>
|
||||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
|
||||||
>
|
|
||||||
Toggle
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
<h3 class="text-body-sm font-heading text-light/60 mb-2">Sizes</h3>
|
||||||
Sizes
|
|
||||||
</h3>
|
|
||||||
<div class="flex items-center gap-6">
|
<div class="flex items-center gap-6">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Toggle size="sm" />
|
<Toggle size="sm" />
|
||||||
@@ -497,9 +410,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
<h3 class="text-body-sm font-heading text-light/60 mb-2">States</h3>
|
||||||
States
|
|
||||||
</h3>
|
|
||||||
<div class="flex items-center gap-6">
|
<div class="flex items-center gap-6">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Toggle />
|
<Toggle />
|
||||||
@@ -520,17 +431,11 @@
|
|||||||
|
|
||||||
<!-- Spinners -->
|
<!-- Spinners -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2
|
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Spinners</h2>
|
||||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
|
||||||
>
|
|
||||||
Spinners
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
<h3 class="text-body-sm font-heading text-light/60 mb-2">Sizes</h3>
|
||||||
Sizes
|
|
||||||
</h3>
|
|
||||||
<div class="flex items-center gap-6">
|
<div class="flex items-center gap-6">
|
||||||
<Spinner size="sm" />
|
<Spinner size="sm" />
|
||||||
<Spinner size="md" />
|
<Spinner size="md" />
|
||||||
@@ -539,9 +444,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
<h3 class="text-body-sm font-heading text-light/60 mb-2">Colors</h3>
|
||||||
Colors
|
|
||||||
</h3>
|
|
||||||
<div class="flex items-center gap-6">
|
<div class="flex items-center gap-6">
|
||||||
<Spinner color="primary" />
|
<Spinner color="primary" />
|
||||||
<Spinner color="light" />
|
<Spinner color="light" />
|
||||||
@@ -555,11 +458,7 @@
|
|||||||
|
|
||||||
<!-- Modal -->
|
<!-- Modal -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2
|
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Modal</h2>
|
||||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
|
||||||
>
|
|
||||||
Modal
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Button onclick={() => (modalOpen = true)}>Open Modal</Button>
|
<Button onclick={() => (modalOpen = true)}>Open Modal</Button>
|
||||||
@@ -584,18 +483,12 @@
|
|||||||
|
|
||||||
<!-- Typography -->
|
<!-- Typography -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2
|
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Typography</h2>
|
||||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
|
||||||
>
|
|
||||||
Typography
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-5">
|
||||||
<!-- Headings (Tilt Warp) -->
|
<!-- Headings (Tilt Warp) -->
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
<h3 class="text-body-sm font-heading text-light/60 mb-2">Headings — Tilt Warp</h3>
|
||||||
Headings — Tilt Warp
|
|
||||||
</h3>
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-baseline gap-4">
|
<div class="flex items-baseline gap-4">
|
||||||
<span
|
<span
|
||||||
@@ -644,9 +537,7 @@
|
|||||||
|
|
||||||
<!-- Button Text (Tilt Warp) -->
|
<!-- Button Text (Tilt Warp) -->
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
<h3 class="text-body-sm font-heading text-light/60 mb-2">Button Text — Tilt Warp</h3>
|
||||||
Button Text — Tilt Warp
|
|
||||||
</h3>
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-baseline gap-4">
|
<div class="flex items-baseline gap-4">
|
||||||
<span
|
<span
|
||||||
@@ -680,9 +571,7 @@
|
|||||||
|
|
||||||
<!-- Body Text (Work Sans) -->
|
<!-- Body Text (Work Sans) -->
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-light/80 mb-3">
|
<h3 class="text-body-sm font-heading text-light/60 mb-2">Body — Work Sans</h3>
|
||||||
Body — Work Sans
|
|
||||||
</h3>
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-baseline gap-4">
|
<div class="flex items-baseline gap-4">
|
||||||
<span
|
<span
|
||||||
@@ -721,11 +610,7 @@
|
|||||||
|
|
||||||
<!-- Toasts -->
|
<!-- Toasts -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2
|
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Toasts</h2>
|
||||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
|
||||||
>
|
|
||||||
Toasts
|
|
||||||
</h2>
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<Toast
|
<Toast
|
||||||
variant="success"
|
variant="success"
|
||||||
@@ -752,15 +637,9 @@
|
|||||||
|
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2
|
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Logo</h2>
|
||||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
<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">
|
||||||
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">
|
|
||||||
<div class="flex flex-col items-center gap-2">
|
<div class="flex flex-col items-center gap-2">
|
||||||
<Logo size="sm" />
|
<Logo size="sm" />
|
||||||
<span class="text-xs text-light/60">Small</span>
|
<span class="text-xs text-light/60">Small</span>
|
||||||
@@ -774,16 +653,9 @@
|
|||||||
|
|
||||||
<!-- ContentHeader -->
|
<!-- ContentHeader -->
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2
|
<h2 class="font-heading text-body text-white border-b border-light/5 pb-2">Content Header</h2>
|
||||||
class="text-2xl font-semibold text-light border-b border-light/10 pb-2"
|
<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">
|
||||||
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">
|
|
||||||
<ContentHeader
|
<ContentHeader
|
||||||
title="Page Title"
|
title="Page Title"
|
||||||
actionLabel="+ New"
|
actionLabel="+ New"
|
||||||
@@ -796,10 +668,8 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="text-center py-8 border-t border-light/10">
|
<footer class="text-center py-8 border-t border-light/5">
|
||||||
<p class="text-light/40 text-sm">
|
<p class="text-[11px] text-light/30 font-body">Root Organization Platform — Style Guide</p>
|
||||||
Root Organization Platform - Style Guide
|
|
||||||
</p>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
2
supabase/migrations/021_org_matrix_space.sql
Normal file
2
supabase/migrations/021_org_matrix_space.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add Matrix Space ID to organizations for org <-> space mapping
|
||||||
|
ALTER TABLE organizations ADD COLUMN IF NOT EXISTS matrix_space_id TEXT;
|
||||||
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 uuid_generate_v4(),
|
||||||
|
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 uuid_generate_v4(),
|
||||||
|
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 uuid_generate_v4(),
|
||||||
|
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;
|
||||||
@@ -10,6 +10,13 @@ export default defineConfig({
|
|||||||
sveltekit(),
|
sveltekit(),
|
||||||
paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide' })
|
paraglideVitePlugin({ project: './project.inlang', outdir: './src/lib/paraglide' })
|
||||||
],
|
],
|
||||||
|
optimizeDeps: {
|
||||||
|
exclude: ['@matrix-org/matrix-sdk-crypto-wasm']
|
||||||
|
},
|
||||||
|
ssr: {
|
||||||
|
noExternal: [],
|
||||||
|
external: ['@matrix-org/matrix-sdk-crypto-wasm']
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
watch: {
|
watch: {
|
||||||
// Reduce file-watcher overhead on Windows — ignore heavy dirs
|
// Reduce file-watcher overhead on Windows — ignore heavy dirs
|
||||||
|
|||||||
Reference in New Issue
Block a user