diff --git a/messages/en.json b/messages/en.json index bdd2a21..101b623 100644 --- a/messages/en.json +++ b/messages/en.json @@ -175,6 +175,14 @@ "account_display_name": "Display Name", "account_display_name_placeholder": "Your name", "account_email": "Email", + "account_phone": "Phone", + "account_phone_placeholder": "+372 ...", + "account_discord": "Discord", + "account_discord_placeholder": "username", + "account_contact_info": "Contact & Sizing", + "account_shirt_size": "Shirt Size", + "account_hoodie_size": "Hoodie Size", + "account_size_placeholder": "Select size", "account_save_profile": "Save Profile", "account_appearance": "Appearance", "account_theme": "Theme", @@ -251,5 +259,124 @@ "entity_kanban_column": "column", "entity_member": "member", "entity_role": "role", - "entity_invite": "invite" + "entity_invite": "invite", + "entity_event": "event", + "nav_events": "Events", + "nav_chat": "Chat", + "chat_title": "Chat", + "chat_subtitle": "Team messaging and communication", + "events_title": "Events", + "events_subtitle": "Organize and manage your events", + "events_new": "New Event", + "events_create": "Create Event", + "events_empty_title": "No events yet", + "events_empty_desc": "Create your first event to get started", + "events_no_dates": "No dates set", + "events_tab_all": "All Events", + "events_tab_planning": "Planning", + "events_tab_active": "Active", + "events_tab_completed": "Completed", + "events_tab_archived": "Archived", + "events_status_planning": "Planning", + "events_status_active": "Active", + "events_status_completed": "Completed", + "events_status_archived": "Archived", + "events_form_name": "Event Name", + "events_form_name_placeholder": "e.g., Summer Conference 2026", + "events_form_description": "Description", + "events_form_description_placeholder": "Brief description of the event...", + "events_form_start_date": "Start Date", + "events_form_end_date": "End Date", + "events_form_venue": "Venue", + "events_form_venue_placeholder": "e.g., Convention Center", + "events_form_venue_address_placeholder": "Venue address", + "events_form_color": "Color", + "events_form_select_color": "Select color {color}", + "events_creating": "Creating...", + "events_saving": "Saving...", + "events_deleting": "Deleting...", + "events_updated": "Event updated", + "events_created": "Event \"{name}\" created", + "events_deleted": "Event deleted", + "events_delete_title": "Delete Event?", + "events_delete_desc": "This will permanently delete {name} and all its data. This action cannot be undone.", + "events_delete_confirm": "Delete Event", + "events_days_ago": "{count} days ago", + "events_today": "Today!", + "events_tomorrow": "Tomorrow", + "events_in_days": "In {count} days", + "events_overview": "Overview", + "events_modules": "Modules", + "events_details": "Event Details", + "events_start_date": "Start Date", + "events_end_date": "End Date", + "events_venue": "Venue", + "events_not_set": "Not set", + "events_all_events": "All Events", + "events_team": "Team", + "events_team_count": "Team ({count})", + "events_team_manage": "Manage", + "events_team_empty": "No team members assigned yet", + "events_more_members": "+{count} more", + "events_mod_tasks": "Tasks", + "events_mod_tasks_desc": "Manage tasks, milestones, and progress", + "events_mod_files": "Files", + "events_mod_files_desc": "Documents, contracts, and media", + "events_mod_schedule": "Schedule", + "events_mod_schedule_desc": "Event timeline and program", + "events_mod_budget": "Budget", + "events_mod_budget_desc": "Income, expenses, and tracking", + "events_mod_guests": "Guests", + "events_mod_guests_desc": "Guest list and registration", + "events_mod_team": "Team", + "events_mod_team_desc": "Team members and shift scheduling", + "events_mod_sponsors": "Sponsors", + "events_mod_sponsors_desc": "Sponsors, partners, and deliverables", + "module_coming_soon": "Coming Soon", + "module_coming_soon_desc": "This module is under development and will be available soon.", + "team_title": "Event Team", + "team_subtitle": "Manage team members and their roles for this event.", + "team_add_member": "Add Member", + "team_role_lead": "Lead", + "team_role_manager": "Manager", + "team_role_member": "Member", + "team_empty": "No team members assigned yet. Add members from your organization.", + "team_remove_confirm": "Remove {name} from this event's team?", + "team_remove_btn": "Remove", + "team_added": "{name} added to team", + "team_removed": "{name} removed from team", + "team_updated": "Role updated", + "team_select_member": "Select a member", + "team_select_role": "Select role", + "team_already_assigned": "Already on team", + "team_departments": "Departments", + "team_roles": "Roles", + "team_all": "All", + "team_no_department": "Unassigned", + "team_add_department": "Add Department", + "team_add_role": "Add Role", + "team_edit_department": "Edit Department", + "team_edit_role": "Edit Role", + "team_dept_name": "Department name", + "team_role_name": "Role name", + "team_dept_created": "Department created", + "team_dept_updated": "Department updated", + "team_dept_deleted": "Department deleted", + "team_role_created": "Role created", + "team_role_updated": "Role updated", + "team_role_deleted": "Role deleted", + "team_dept_delete_confirm": "Delete department {name}? Members will be unassigned from it.", + "team_role_delete_confirm": "Delete role {name}? Members will lose this role assignment.", + "team_view_by_dept": "By department", + "team_view_list": "List view", + "team_member_count": "{count} members", + "team_assign_dept": "Assign departments", + "team_notes": "Notes", + "team_notes_placeholder": "Optional notes about this member...", + "overview_subtitle": "Welcome back. Here's what's happening.", + "overview_stat_events": "Events", + "overview_upcoming_events": "Upcoming Events", + "overview_upcoming_empty": "No upcoming events. Create one to get started.", + "overview_view_all_events": "View all events", + "overview_more_members": "+{count} more" } \ No newline at end of file diff --git a/messages/et.json b/messages/et.json index deba226..ec3232a 100644 --- a/messages/et.json +++ b/messages/et.json @@ -175,6 +175,14 @@ "account_display_name": "Kuvatav nimi", "account_display_name_placeholder": "Sinu nimi", "account_email": "E-post", + "account_phone": "Telefon", + "account_phone_placeholder": "+372 ...", + "account_discord": "Discord", + "account_discord_placeholder": "kasutajanimi", + "account_contact_info": "Kontakt ja suurused", + "account_shirt_size": "Särgi suurus", + "account_hoodie_size": "Pusa suurus", + "account_size_placeholder": "Vali suurus", "account_save_profile": "Salvesta profiil", "account_appearance": "Välimus", "account_theme": "Teema", @@ -251,5 +259,124 @@ "entity_kanban_column": "veeru", "entity_member": "liikme", "entity_role": "rolli", - "entity_invite": "kutse" + "entity_invite": "kutse", + "entity_event": "ürituse", + "nav_events": "Üritused", + "nav_chat": "Vestlus", + "chat_title": "Vestlus", + "chat_subtitle": "Meeskonna sõnumid ja suhtlus", + "events_title": "Üritused", + "events_subtitle": "Korralda ja halda oma üritusi", + "events_new": "Uus üritus", + "events_create": "Loo üritus", + "events_empty_title": "Üritusi pole veel", + "events_empty_desc": "Loo oma esimene üritus alustamiseks", + "events_no_dates": "Kuupäevad määramata", + "events_tab_all": "Kõik üritused", + "events_tab_planning": "Planeerimisel", + "events_tab_active": "Aktiivne", + "events_tab_completed": "Lõpetatud", + "events_tab_archived": "Arhiveeritud", + "events_status_planning": "Planeerimisel", + "events_status_active": "Aktiivne", + "events_status_completed": "Lõpetatud", + "events_status_archived": "Arhiveeritud", + "events_form_name": "Ürituse nimi", + "events_form_name_placeholder": "nt Suvekonverents 2026", + "events_form_description": "Kirjeldus", + "events_form_description_placeholder": "Ürituse lühikirjeldus...", + "events_form_start_date": "Alguskuupäev", + "events_form_end_date": "Lõppkuupäev", + "events_form_venue": "Toimumiskoht", + "events_form_venue_placeholder": "nt Konverentsikeskus", + "events_form_venue_address_placeholder": "Toimumiskoha aadress", + "events_form_color": "Värv", + "events_form_select_color": "Vali värv {color}", + "events_creating": "Loomine...", + "events_saving": "Salvestamine...", + "events_deleting": "Kustutamine...", + "events_updated": "Üritus uuendatud", + "events_created": "Üritus \"{name}\" loodud", + "events_deleted": "Üritus kustutatud", + "events_delete_title": "Kustuta üritus?", + "events_delete_desc": "See kustutab jäädavalt ürituse {name} ja kõik selle andmed. Seda toimingut ei saa tagasi võtta.", + "events_delete_confirm": "Kustuta üritus", + "events_days_ago": "{count} päeva tagasi", + "events_today": "Täna!", + "events_tomorrow": "Homme", + "events_in_days": "{count} päeva pärast", + "events_overview": "Ülevaade", + "events_modules": "Moodulid", + "events_details": "Ürituse andmed", + "events_start_date": "Alguskuupäev", + "events_end_date": "Lõppkuupäev", + "events_venue": "Toimumiskoht", + "events_not_set": "Määramata", + "events_all_events": "Kõik üritused", + "events_team": "Meeskond", + "events_team_count": "Meeskond ({count})", + "events_team_manage": "Halda", + "events_team_empty": "Meeskonnaliikmeid pole veel määratud", + "events_more_members": "+{count} veel", + "events_mod_tasks": "Ülesanded", + "events_mod_tasks_desc": "Halda ülesandeid, verstaposte ja edenemist", + "events_mod_files": "Failid", + "events_mod_files_desc": "Dokumendid, lepingud ja meedia", + "events_mod_schedule": "Ajakava", + "events_mod_schedule_desc": "Ürituse ajakava ja programm", + "events_mod_budget": "Eelarve", + "events_mod_budget_desc": "Tulud, kulud ja jälgimine", + "events_mod_guests": "Külalised", + "events_mod_guests_desc": "Külaliste nimekiri ja registreerimine", + "events_mod_team": "Meeskond", + "events_mod_team_desc": "Meeskonnaliikmed ja vahetuste planeerimine", + "events_mod_sponsors": "Sponsorid", + "events_mod_sponsors_desc": "Sponsorid, partnerid ja kohustused", + "module_coming_soon": "Tulekul", + "module_coming_soon_desc": "See moodul on arendamisel ja saab peagi kättesaadavaks.", + "team_title": "Ürituse meeskond", + "team_subtitle": "Halda meeskonnaliikmeid ja nende rolle selle ürituse jaoks.", + "team_add_member": "Lisa liige", + "team_role_lead": "Juht", + "team_role_manager": "Haldur", + "team_role_member": "Liige", + "team_empty": "Meeskonnaliikmeid pole veel määratud. Lisa liikmeid oma organisatsioonist.", + "team_remove_confirm": "Eemalda {name} selle ürituse meeskonnast?", + "team_remove_btn": "Eemalda", + "team_added": "{name} lisatud meeskonda", + "team_removed": "{name} eemaldatud meeskonnast", + "team_updated": "Roll uuendatud", + "team_select_member": "Vali liige", + "team_select_role": "Vali roll", + "team_already_assigned": "Juba meeskonnas", + "team_departments": "Osakonnad", + "team_roles": "Rollid", + "team_all": "Kõik", + "team_no_department": "Määramata", + "team_add_department": "Lisa osakond", + "team_add_role": "Lisa roll", + "team_edit_department": "Muuda osakonda", + "team_edit_role": "Muuda rolli", + "team_dept_name": "Osakonna nimi", + "team_role_name": "Rolli nimi", + "team_dept_created": "Osakond loodud", + "team_dept_updated": "Osakond uuendatud", + "team_dept_deleted": "Osakond kustutatud", + "team_role_created": "Roll loodud", + "team_role_updated": "Roll uuendatud", + "team_role_deleted": "Roll kustutatud", + "team_dept_delete_confirm": "Kustuta osakond {name}? Liikmed eemaldatakse sellest.", + "team_role_delete_confirm": "Kustuta roll {name}? Liikmed kaotavad selle rolli.", + "team_view_by_dept": "Osakondade järgi", + "team_view_list": "Nimekirja vaade", + "team_member_count": "{count} liiget", + "team_assign_dept": "Määra osakonnad", + "team_notes": "Märkmed", + "team_notes_placeholder": "Valikulised märkmed selle liikme kohta...", + "overview_subtitle": "Tere tagasi. Siin on ülevaade toimuvast.", + "overview_stat_events": "Üritused", + "overview_upcoming_events": "Tulevased üritused", + "overview_upcoming_empty": "Tulevasi üritusi pole. Loo üks alustamiseks.", + "overview_view_all_events": "Vaata kõiki üritusi", + "overview_more_members": "+{count} veel" } \ No newline at end of file diff --git a/src/lib/api/events.test.ts b/src/lib/api/events.test.ts new file mode 100644 index 0000000..7365d75 --- /dev/null +++ b/src/lib/api/events.test.ts @@ -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'); + }); +}); diff --git a/src/lib/api/events.ts b/src/lib/api/events.ts new file mode 100644 index 0000000..8fef5d9 --- /dev/null +++ b/src/lib/api/events.ts @@ -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, + orgId: string, + status?: string +): Promise { + 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, + eventId: string +): Promise { + 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, + orgId: string, + eventSlug: string +): Promise { + 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, + orgId: string, + userId: string, + params: { + name: string; + description?: string; + start_date?: string; + end_date?: string; + venue_name?: string; + venue_address?: string; + color?: string; + } +): Promise { + 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, + eventId: string, + params: Partial> +): Promise { + 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, + eventId: string +): Promise { + 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, + eventId: string +): Promise { + 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 = {}; + 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, + eventId: string, + userId: string, + params: { role?: 'lead' | 'manager' | 'member'; role_id?: string; notes?: string } = {} +): Promise { + 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, + eventId: string, + userId: string +): Promise { + 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, + eventId: string +): Promise { + 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, + eventId: string, + params: { name: string; color?: string; sort_order?: number } +): Promise { + 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, + roleId: string, + params: Partial> +): Promise { + 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, + roleId: string +): Promise { + 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, + eventId: string +): Promise { + 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, + eventId: string, + params: { name: string; color?: string; description?: string; sort_order?: number } +): Promise { + 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, + deptId: string, + params: Partial> +): Promise { + 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, + deptId: string +): Promise { + 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, + eventMemberId: string, + departmentId: string +): Promise { + 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, + eventMemberId: string, + departmentId: string +): Promise { + 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; + } +} diff --git a/src/lib/components/calendar/Calendar.svelte b/src/lib/components/calendar/Calendar.svelte index f2bad56..20061cc 100644 --- a/src/lib/components/calendar/Calendar.svelte +++ b/src/lib/components/calendar/Calendar.svelte @@ -123,63 +123,63 @@ }); -
+
-
-
+
+
{headerTitle}
-
+
@@ -187,48 +187,40 @@ {#if currentView === "month"} -
+
-
+
{#each weekDayHeaders as day} -
- {day} +
+ {day}
{/each}
-
+
{#each weeks as week} -
+
{#each week as day} {@const dayEvents = getEventsForDay(day)} {@const isToday = isSameDay(day, today)} {@const inMonth = isCurrentMonth(day)} -
onDateClick?.(day)} > {day.getDate()} {#each dayEvents.slice(0, 2) as event} {/each} {#if dayEvents.length > 2} - +{dayEvents.length - 2} more + +{dayEvents.length - 2} {/if} -
+ {/each}
{/each} @@ -253,40 +242,25 @@ {#if currentView === "week"} -
-
+
+
{#each weekDates as day} {@const dayEvents = getEventsForDay(day)} {@const isToday = isSameDay(day, today)} -
-
-
+
+
+
{weekDayHeaders[(day.getDay() + 6) % 7]}
-
+
{day.getDate()}
-
+
{#each dayEvents as event} + {:else if mode === "edit"} - {:else} - + {/if} - +
-
+
diff --git a/src/lib/components/documents/FileBrowser.svelte b/src/lib/components/documents/FileBrowser.svelte index 8e44fee..fae3729 100644 --- a/src/lib/components/documents/FileBrowser.svelte +++ b/src/lib/components/documents/FileBrowser.svelte @@ -5,9 +5,6 @@ Button, Modal, Input, - Avatar, - IconButton, - Icon, } from "$lib/components/ui"; import { DocumentViewer } from "$lib/components/documents"; import { createLogger } from "$lib/utils/logger"; @@ -490,97 +487,101 @@ } -
+
-
- -
- -

{title}

- - - - -
- - - + +
-
+
{#if viewMode === "list"}
{#if currentFolderItems.length === 0} -
-

- No files yet. Drag files here or create a new - one. -

+
+ folder_open +

{m.files_empty()}

{:else} {#each currentFolderItems as item} @@ -678,7 +671,7 @@ {#if selectedDoc} -
+
- delete + close {/if} {#if card.tags && card.tags.length > 0} -
+
{#each card.tags as tag} {tag.name} @@ -95,55 +95,40 @@ {/if} -

+

{card.title}

{#if hasFooter} -
-
- +
+
{#if card.due_date} -
+ - calendar_today - - - {formatDueDate(card.due_date)} - -
+ class="material-symbols-rounded" + style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;" + >calendar_today + {formatDueDate(card.due_date)} + {/if} - {#if (card.checklist_total ?? 0) > 0} -
+ - check_box - - - {card.checklist_done ?? 0}/{card.checklist_total} - -
+ class="material-symbols-rounded" + style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;" + >check_box + {card.checklist_done ?? 0}/{card.checklist_total} + {/if}
- {#if card.assignee_id} {/if}
diff --git a/src/lib/components/settings/SettingsGeneral.svelte b/src/lib/components/settings/SettingsGeneral.svelte index a3881c5..644f12a 100644 --- a/src/lib/components/settings/SettingsGeneral.svelte +++ b/src/lib/components/settings/SettingsGeneral.svelte @@ -124,93 +124,88 @@ } -
+
-

Organization details

+
+

Organization details

-
-
- -
- Avatar -
- -
- + +
+ Avatar +
+ +
+ + + {#if avatarUrl} - {#if avatarUrl} - - {/if} -
+ {/if}
- - +
+ + +
+ +
+
+ + + {#if isOwner} +
+

Danger Zone

+

+ Permanently delete this organization and all its data. +

- Delete Organization
+ {/if} - - {#if isOwner} -
-

Danger Zone

-

- Permanently delete this organization and all its data. -

-
- -
+ + {#if !isOwner} +
+

Leave Organization

+

+ Leave this organization. You will need to be re-invited to rejoin. +

+
+
- {/if} - - - {#if !isOwner} -
-

- Leave Organization -

-

- Leave this organization. You will need to be re-invited to - rejoin. -

-
- -
-
- {/if} -
+
+ {/if}
diff --git a/src/lib/components/settings/SettingsIntegrations.svelte b/src/lib/components/settings/SettingsIntegrations.svelte index ee12a1c..91d5702 100644 --- a/src/lib/components/settings/SettingsIntegrations.svelte +++ b/src/lib/components/settings/SettingsIntegrations.svelte @@ -1,5 +1,5 @@ -
- -
-
-
- - - - - - -
-
-

- Google Calendar -

-

- Sync events between your organization and Google - Calendar. -

+
+ +
+
+
+ + + + + + +
+
+

Google Calendar

+

+ Sync events between your organization and Google Calendar. +

- {#if orgCalendar} -
-
-
-

- Connected -

-

- {orgCalendar.calendar_name || - "Google Calendar"} -

-

- {orgCalendar.calendar_id} -

-

- Events sync both ways — create here or - in Google Calendar. -

- - - - - - - Open in Google Calendar - -
- +
+
+

Connected

+

{orgCalendar.calendar_name || "Google Calendar"}

+

{orgCalendar.calendar_id}

+

Events sync both ways.

+ + open_in_new + Open in Google Calendar +
+
- {:else if !serviceAccountEmail} -
-

- Setup required -

-

- A server administrator needs to configure the GOOGLE_SERVICE_ACCOUNT_KEY environment variable before calendars can be connected. -

-
- {:else} -
- -
- {/if} -
+
+ {:else if !serviceAccountEmail} +
+

Setup required

+

+ A server administrator needs to configure the GOOGLE_SERVICE_ACCOUNT_KEY environment variable. +

+
+ {:else} +
+ +
+ {/if}
- +
- -
-
-
- - - -
-
-

Discord

-

- Get notifications in your Discord server. -

-

Coming soon

-
+ +
+
+
+ forum +
+
+

Discord

+

Get notifications in your Discord server.

+

Coming soon

- +
- -
-
-
- - - -
-
-

Slack

-

- Get notifications in your Slack workspace. -

-

Coming soon

-
+ +
+
+
+ tag +
+
+

Slack

+

Get notifications in your Slack workspace.

+

Coming soon

- +
diff --git a/src/lib/components/settings/SettingsMembers.svelte b/src/lib/components/settings/SettingsMembers.svelte index 7ffa104..b5a4d58 100644 --- a/src/lib/components/settings/SettingsMembers.svelte +++ b/src/lib/components/settings/SettingsMembers.svelte @@ -2,7 +2,6 @@ import { Button, Modal, - Card, Input, Select, Avatar, @@ -169,113 +168,97 @@ } -
+
-

- {m.settings_members_title({ - count: String(members.length), - })} -

-
{#if invites.length > 0} - -
-

- {m.settings_members_pending()} -

-
- {#each invites as invite} -
-
-

{invite.email}

-

- Invited as {invite.role} • Expires {new Date( - invite.expires_at, - ).toLocaleDateString()} -

-
-
- - -
+
+

+ {m.settings_members_pending()} +

+
+ {#each invites as invite} +
+
+

{invite.email}

+

+ Invited as {invite.role} • Expires {new Date( + invite.expires_at, + ).toLocaleDateString()} +

- {/each} -
+
+ + +
+
+ {/each}
- +
{/if} - -
+
+
{#each members as member} {@const rawProfile = member.profiles} {@const profile = Array.isArray(rawProfile) ? rawProfile[0] : rawProfile}
-
- {(profile?.full_name || - profile?.email || - "?")[0].toUpperCase()} -
+
-

+

{profile?.full_name || profile?.email || "Unknown User"}

-

+

{profile?.email || "No email"}

-
+
{member.role} {#if member.user_id !== userId && member.role !== "owner"} - + edit + {/if}
{/each}
- +
diff --git a/src/lib/components/settings/SettingsRoles.svelte b/src/lib/components/settings/SettingsRoles.svelte index 13115b6..4a775f7 100644 --- a/src/lib/components/settings/SettingsRoles.svelte +++ b/src/lib/components/settings/SettingsRoles.svelte @@ -1,5 +1,5 @@ -
+
-

Roles

-

+

Roles

+

Create custom roles with specific permissions.

-
-
+
{#each roles as role} - -
-
-
-
- {role.name} - {#if role.is_system} - System - {/if} - {#if role.is_default} - Default - {/if} -
-
- {#if !role.is_system || role.name !== "Owner"} - - {/if} - {#if !role.is_system} - - {/if} -
+
+
+
+
+ {role.name} + {#if role.is_system} + System + {/if} + {#if role.is_default} + Default + {/if}
-
- {#if role.permissions.includes("*")} - All Permissions + {#if !role.is_system || role.name !== "Owner"} + + {/if} + {#if !role.is_system} + {/if}
- +
+ {#if role.permissions.includes("*")} + All Permissions + {:else} + {#each role.permissions.slice(0, 6) as perm} + {perm} + {/each} + {#if role.permissions.length > 6} + +{role.permissions.length - 6} more + {/if} + {/if} +
+
{/each}
diff --git a/src/lib/components/ui/ActivityFeed.svelte b/src/lib/components/ui/ActivityFeed.svelte new file mode 100644 index 0000000..1f2db1d --- /dev/null +++ b/src/lib/components/ui/ActivityFeed.svelte @@ -0,0 +1,132 @@ + + +{#if entries.length === 0} +
+ history +

{emptyLabel ?? m.activity_empty()}

+
+{:else} +
+ {#each entries as entry} +
+ {getActivityIcon(entry.action)} +
+

+ {getDescription(entry)} +

+
+ {formatTimeAgo(entry.created_at)} +
+ {/each} +
+{/if} diff --git a/src/lib/components/ui/ContentSkeleton.svelte b/src/lib/components/ui/ContentSkeleton.svelte new file mode 100644 index 0000000..e6e59da --- /dev/null +++ b/src/lib/components/ui/ContentSkeleton.svelte @@ -0,0 +1,109 @@ + + +
+ {#if variant === "kanban"} +
+ {#each Array(3) as _} +
+
+ + +
+ {#each Array(3) as __} + + {/each} +
+ {/each} +
+ {:else if variant === "files"} +
+ +
+ + +
+
+ {#each Array(12) as _} + + {/each} +
+ {:else if variant === "calendar"} +
+ + + +
+ +
+
+ {#each Array(7) as _} + + {/each} + {#each Array(35) as _} + + {/each} +
+ {:else if variant === "settings"} +
+ + + + +
+ {:else if variant === "list"} +
+ {#each Array(4) as _} + + {/each} +
+
+ {#each Array(5) as _} + + {/each} +
+ {:else if variant === "detail"} +
+
+ + +
+
+ + +
+
+ {:else} +
+ {#each Array(4) as _} + + {/each} +
+
+
+ +
+
+ + +
+
+ {/if} +
+ + diff --git a/src/lib/components/ui/EventCard.svelte b/src/lib/components/ui/EventCard.svelte new file mode 100644 index 0000000..4285283 --- /dev/null +++ b/src/lib/components/ui/EventCard.svelte @@ -0,0 +1,116 @@ + + +{#if compact} + + +
+
+

+ {name} +

+
+ {#if startDate} + {formatDate(startDate)}{endDate + ? ` — ${formatDate(endDate)}` + : ""} + {/if} + {#if venueName} + · {venueName} + {/if} +
+
+ +
+{:else} + + +
+
+
+

+ {name} +

+
+ +
+ +
+ {#if startDate} + + calendar_today + {formatDate(startDate)}{endDate + ? ` — ${formatDate(endDate)}` + : ""} + + {/if} + {#if venueName} + + location_on + {venueName} + + {/if} +
+
+{/if} diff --git a/src/lib/components/ui/KanbanColumn.svelte b/src/lib/components/ui/KanbanColumn.svelte index f604b1b..990988e 100644 --- a/src/lib/components/ui/KanbanColumn.svelte +++ b/src/lib/components/ui/KanbanColumn.svelte @@ -14,28 +14,25 @@
-
+
- {title} -
- {count} -
+ {title} + {count}
{#if onMore} +
+ +
{/if}
diff --git a/src/lib/components/ui/MemberList.svelte b/src/lib/components/ui/MemberList.svelte new file mode 100644 index 0000000..97878a0 --- /dev/null +++ b/src/lib/components/ui/MemberList.svelte @@ -0,0 +1,71 @@ + + +
+ {#each visible as member} +
+ +
+

+ {member.profiles?.full_name || + member.profiles?.email || + "Unknown"} +

+

+ {member.role} +

+
+
+ {/each} + {#if remaining > 0 && moreHref && moreLabel} + + {moreLabel} + + {/if} + {#if members.length === 0 && emptyLabel} +

+ {emptyLabel} +

+ {/if} +
diff --git a/src/lib/components/ui/ModuleCard.svelte b/src/lib/components/ui/ModuleCard.svelte new file mode 100644 index 0000000..daec6e7 --- /dev/null +++ b/src/lib/components/ui/ModuleCard.svelte @@ -0,0 +1,40 @@ + + + +
+ {icon} +
+

+ {label} +

+

{description}

+
diff --git a/src/lib/components/ui/PageHeader.svelte b/src/lib/components/ui/PageHeader.svelte new file mode 100644 index 0000000..034b157 --- /dev/null +++ b/src/lib/components/ui/PageHeader.svelte @@ -0,0 +1,46 @@ + + +
+
+ {#if icon} + {icon} + {/if} +
+

{title}

+ {#if subtitle} +

{subtitle}

+ {/if} +
+
+ {#if actions} +
+ {@render actions()} +
+ {/if} +
diff --git a/src/lib/components/ui/QuickLinkGrid.svelte b/src/lib/components/ui/QuickLinkGrid.svelte new file mode 100644 index 0000000..40707e6 --- /dev/null +++ b/src/lib/components/ui/QuickLinkGrid.svelte @@ -0,0 +1,30 @@ + + +
+ {#each links as link} + + {link.icon} + {link.label} + + {/each} +
diff --git a/src/lib/components/ui/SectionCard.svelte b/src/lib/components/ui/SectionCard.svelte new file mode 100644 index 0000000..bdb4e27 --- /dev/null +++ b/src/lib/components/ui/SectionCard.svelte @@ -0,0 +1,41 @@ + + +
+ {#if title || titleRight} +
+ {#if title} +

{title}

+ {/if} + {#if titleRight} + {@render titleRight()} + {/if} +
+ {/if} + {@render children()} +
diff --git a/src/lib/components/ui/StatCard.svelte b/src/lib/components/ui/StatCard.svelte new file mode 100644 index 0000000..54f06ea --- /dev/null +++ b/src/lib/components/ui/StatCard.svelte @@ -0,0 +1,58 @@ + + +{#if href} + +
+ {icon} +
+
+

{value}

+

{label}

+
+
+{:else} +
+
+ {icon} +
+
+

{value}

+

{label}

+
+
+{/if} diff --git a/src/lib/components/ui/StatusBadge.svelte b/src/lib/components/ui/StatusBadge.svelte new file mode 100644 index 0000000..7e45307 --- /dev/null +++ b/src/lib/components/ui/StatusBadge.svelte @@ -0,0 +1,30 @@ + + +{status} diff --git a/src/lib/components/ui/TabBar.svelte b/src/lib/components/ui/TabBar.svelte new file mode 100644 index 0000000..fa033cf --- /dev/null +++ b/src/lib/components/ui/TabBar.svelte @@ -0,0 +1,37 @@ + + +
+ {#each tabs as tab} + + {/each} +
diff --git a/src/lib/components/ui/index.ts b/src/lib/components/ui/index.ts index eb32b1c..305a794 100644 --- a/src/lib/components/ui/index.ts +++ b/src/lib/components/ui/index.ts @@ -26,6 +26,17 @@ export { default as Icon } from './Icon.svelte'; export { default as AssigneePicker } from './AssigneePicker.svelte'; export { default as ContextMenu } from './ContextMenu.svelte'; export { default as PageSkeleton } from './PageSkeleton.svelte'; +export { default as PageHeader } from './PageHeader.svelte'; +export { default as SectionCard } from './SectionCard.svelte'; +export { default as StatCard } from './StatCard.svelte'; +export { default as StatusBadge } from './StatusBadge.svelte'; +export { default as TabBar } from './TabBar.svelte'; +export { default as MemberList } from './MemberList.svelte'; +export { default as ActivityFeed } from './ActivityFeed.svelte'; +export { default as EventCard } from './EventCard.svelte'; +export { default as ContentSkeleton } from './ContentSkeleton.svelte'; +export { default as QuickLinkGrid } from './QuickLinkGrid.svelte'; +export { default as ModuleCard } from './ModuleCard.svelte'; export { default as ImagePreviewModal } from './ImagePreviewModal.svelte'; export { default as Twemoji } from './Twemoji.svelte'; export { default as EmojiPicker } from './EmojiPicker.svelte'; diff --git a/src/lib/supabase/types.ts b/src/lib/supabase/types.ts index 50bac1e..b7df722 100644 --- a/src/lib/supabase/types.ts +++ b/src/lib/supabase/types.ts @@ -360,6 +360,100 @@ export type Database = { }, ] } + event_members: { + Row: { + assigned_at: string | null + event_id: string + id: string + role: string + user_id: string + } + Insert: { + assigned_at?: string | null + event_id: string + id?: string + role?: string + user_id: string + } + Update: { + assigned_at?: string | null + event_id?: string + id?: string + role?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "event_members_event_id_fkey" + columns: ["event_id"] + isOneToOne: false + referencedRelation: "events" + referencedColumns: ["id"] + }, + ] + } + events: { + Row: { + color: string | null + cover_image_url: string | null + created_at: string | null + created_by: string | null + description: string | null + end_date: string | null + id: string + name: string + org_id: string + slug: string + start_date: string | null + status: string + updated_at: string | null + venue_address: string | null + venue_name: string | null + } + Insert: { + color?: string | null + cover_image_url?: string | null + created_at?: string | null + created_by?: string | null + description?: string | null + end_date?: string | null + id?: string + name: string + org_id: string + slug: string + start_date?: string | null + status?: string + updated_at?: string | null + venue_address?: string | null + venue_name?: string | null + } + Update: { + color?: string | null + cover_image_url?: string | null + created_at?: string | null + created_by?: string | null + description?: string | null + end_date?: string | null + id?: string + name?: string + org_id?: string + slug?: string + start_date?: string | null + status?: string + updated_at?: string | null + venue_address?: string | null + venue_name?: string | null + } + Relationships: [ + { + foreignKeyName: "events_org_id_fkey" + columns: ["org_id"] + isOneToOne: false + referencedRelation: "organizations" + referencedColumns: ["id"] + }, + ] + } kanban_boards: { Row: { created_at: string | null @@ -1226,3 +1320,5 @@ export type OrgGoogleCalendar = PublicTables['org_google_calendars']['Row'] export type ActivityLog = PublicTables['activity_log']['Row'] export type UserPreferences = PublicTables['user_preferences']['Row'] export type MatrixCredentials = PublicTables['matrix_credentials']['Row'] +export type EventRow = PublicTables['events']['Row'] +export type EventMemberRow = PublicTables['event_members']['Row'] diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 18e1545..a7e5e7d 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,6 +1,6 @@ {data.org.name} | Root -
- -
-

{data.org.name}

-

{m.overview_title()}

-
- - -
- {#each statCards as stat} - {#if stat.href} +
+ + {#snippet actions()} + {#if isEditor} -
- - {stat.icon} - -
-
-

- {stat.value} -

-

{stat.label}

-
-
- {:else} -
-
- - {stat.icon} - -
-
-

- {stat.value} -

-

{stat.label}

-
-
- {/if} - {/each} -
- -
- -
-

- {m.activity_title()} -

- - {#if recentActivity.length === 0} -
celebration - history - -

{m.activity_empty()}

-
- {:else} -
- {#each recentActivity as entry} -
- - {getActivityIcon(entry.action)} - -
-

- {getActivityDescription(entry)} -

-

- {formatTimeAgo(entry.created_at)} -

-
-
- {/each} -
+ {m.nav_events()} + {/if} + {/snippet} + + +
+ +
+ + + +
- -
- -
-

- {m.overview_quick_links()} -

-
- {#each quickLinks as link} +
+ +
+ + + {#snippet titleRight()} {m.overview_view_all_events()} + {/snippet} + + {#if upcomingEvents.length === 0} +
celebration - {link.icon} - - {link.label} - - {/each} -
+

{m.overview_upcoming_empty()}

+
+ {:else} +
+ {#each upcomingEvents as event} + + {/each} +
+ {/if} + + + + + +
- -
-
-

- {m.overview_stat_members()} -

- {stats.memberCount} +
+ + + + + + {#snippet titleRight()} + {stats.memberCount} + {/snippet} + + + + {#if isAdmin} + -
-
diff --git a/src/routes/[orgSlug]/account/+layout.svelte b/src/routes/[orgSlug]/account/+layout.svelte new file mode 100644 index 0000000..ed00bc3 --- /dev/null +++ b/src/routes/[orgSlug]/account/+layout.svelte @@ -0,0 +1,36 @@ + + +
+ + + {#if isNavigatingHere} + + {:else} +
+ {@render children()} +
+ {/if} +
diff --git a/src/routes/[orgSlug]/account/+page.svelte b/src/routes/[orgSlug]/account/+page.svelte index d05b6ce..e3df42b 100644 --- a/src/routes/[orgSlug]/account/+page.svelte +++ b/src/routes/[orgSlug]/account/+page.svelte @@ -16,6 +16,10 @@ email: string; full_name: string | null; avatar_url: string | null; + phone: string | null; + discord_handle: string | null; + shirt_size: string | null; + hoodie_size: string | null; }; preferences: { id: string; @@ -34,10 +38,16 @@ // Profile state let fullName = $state(data.profile.full_name ?? ""); let avatarUrl = $state(data.profile.avatar_url ?? null); + let phone = $state(data.profile.phone ?? ""); + let discordHandle = $state(data.profile.discord_handle ?? ""); + let shirtSize = $state(data.profile.shirt_size ?? ""); + let hoodieSize = $state(data.profile.hoodie_size ?? ""); let isSaving = $state(false); let isUploading = $state(false); let avatarInput = $state(null); + const clothingSizes = ["XS", "S", "M", "L", "XL", "XXL", "3XL"]; + // Preferences state let theme = $state(data.preferences?.theme ?? "dark"); let accentColor = $state(data.preferences?.accent_color ?? "#00A3E0"); @@ -57,6 +67,10 @@ $effect(() => { fullName = data.profile.full_name ?? ""; avatarUrl = data.profile.avatar_url ?? null; + phone = data.profile.phone ?? ""; + discordHandle = data.profile.discord_handle ?? ""; + shirtSize = data.profile.shirt_size ?? ""; + hoodieSize = data.profile.hoodie_size ?? ""; theme = data.preferences?.theme ?? "dark"; accentColor = data.preferences?.accent_color ?? "#00A3E0"; useOrgTheme = data.preferences?.use_org_theme ?? true; @@ -161,9 +175,15 @@ async function saveProfile() { isSaving = true; - const { error } = await supabase + const { error } = await (supabase as any) .from("profiles") - .update({ full_name: fullName || null }) + .update({ + full_name: fullName || null, + phone: phone || null, + discord_handle: discordHandle || null, + shirt_size: shirtSize || null, + hoodie_size: hoodieSize || null, + }) .eq("id", data.profile.id); if (error) { @@ -227,25 +247,17 @@ Account Settings | Root -
- -
-

{m.account_title()}

-

- {m.account_subtitle()} -

-
- -
+
+
-
-

+
+

{m.account_profile()}

- {m.account_photo()}
@@ -313,9 +325,61 @@
+ +
+

+ {m.account_contact_info()} +

+ + + + + +
+
+ {m.account_shirt_size()} + +
+
+ {m.account_hoodie_size()} + +
+
+ +
+ +
+
+ -
-

+
+

{m.account_appearance()}

@@ -333,7 +397,7 @@
- {m.account_accent_color()}
@@ -371,10 +435,10 @@
-

+

{m.account_use_org_theme()}

-

+

{m.account_use_org_theme_desc()}

@@ -396,20 +460,20 @@
- {m.account_language()} -

+

{m.account_language_desc()}

{#each locales as locale}
-
-

+
+

{m.account_security()}

-

+

{m.account_password()}

-

+

{m.account_password_desc()}

@@ -460,11 +524,11 @@
-
-

+

+

{m.account_active_sessions()}

-

+

{m.account_sessions_desc()}

diff --git a/src/routes/[orgSlug]/calendar/+layout.svelte b/src/routes/[orgSlug]/calendar/+layout.svelte new file mode 100644 index 0000000..76cd2eb --- /dev/null +++ b/src/routes/[orgSlug]/calendar/+layout.svelte @@ -0,0 +1,31 @@ + + +
+ + + {#if isNavigatingHere} + + {:else} +
+ {@render children()} +
+ {/if} +
diff --git a/src/routes/[orgSlug]/calendar/+page.svelte b/src/routes/[orgSlug]/calendar/+page.svelte index bda44a0..a33f42b 100644 --- a/src/routes/[orgSlug]/calendar/+page.svelte +++ b/src/routes/[orgSlug]/calendar/+page.svelte @@ -456,13 +456,11 @@ Calendar - {data.org.name} | Root -
- -
-

- {m.calendar_title()} -

- -
+
-
+
+ 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`), + ); + + +
+ + + {#if isNavigatingHere && !$navigating?.from?.url.pathname?.includes(`/${data.org.slug}/chat`)} + + {:else} +
+ {@render children()} +
+ {/if} +
diff --git a/src/routes/[orgSlug]/chat/+page.svelte b/src/routes/[orgSlug]/chat/+page.svelte index 41e1da2..56638e3 100644 --- a/src/routes/[orgSlug]/chat/+page.svelte +++ b/src/routes/[orgSlug]/chat/+page.svelte @@ -392,9 +392,9 @@ {#if showMatrixLogin}
-
-

Connect to Chat

-

+

+

Connect to Chat

+

Enter your Matrix credentials to enable messaging.

@@ -410,12 +410,12 @@ placeholder="@user:matrix.org" />
- + { if (e.key === "Enter") handleMatrixLogin(); }} @@ -438,9 +438,9 @@
-

+

{#if isInitializing} Connecting to Matrix... {:else if $syncState === "CATCHUP"} @@ -460,56 +460,50 @@ {:else if matrixClient} {#snippet children()} -

+
-