7 Commits

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

svelte-check: 0 errors, vitest: 112/112 passed
2026-02-07 11:03:58 +02:00
AlacrisDevs
2913912cb8 feat: UI overhaul - component library + route layouts with instant headers
- Created 11 reusable UI components: PageHeader, SectionCard, StatCard, StatusBadge, TabBar, MemberList, ActivityFeed, EventCard, ContentSkeleton, QuickLinkGrid, ModuleCard
- Created route-specific +layout.svelte for documents, calendar, kanban, events, settings, account
- Each layout renders PageHeader instantly from parent data, shows ContentSkeleton during navigation
- Removed full-page PageSkeleton from parent layout
- Refactored all pages to use new components instead of inline markup
- Overview page: uses StatCard, SectionCard, EventCard, ActivityFeed, MemberList, QuickLinkGrid
- Events list: uses EventCard, Button components
- Event detail: uses ModuleCard, SectionCard
- Settings/Account/Calendar/Kanban: headers in layouts, toolbars in pages
- Added i18n keys for overview page (EN + ET)
- 0 errors, 112 tests pass
2026-02-07 10:44:53 +02:00
55 changed files with 3720 additions and 1776 deletions

View File

@@ -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",
@@ -254,6 +262,9 @@
"entity_invite": "invite", "entity_invite": "invite",
"entity_event": "event", "entity_event": "event",
"nav_events": "Events", "nav_events": "Events",
"nav_chat": "Chat",
"chat_title": "Chat",
"chat_subtitle": "Team messaging and communication",
"events_title": "Events", "events_title": "Events",
"events_subtitle": "Organize and manage your events", "events_subtitle": "Organize and manage your events",
"events_new": "New Event", "events_new": "New Event",
@@ -320,5 +331,52 @@
"events_mod_team": "Team", "events_mod_team": "Team",
"events_mod_team_desc": "Team members and shift scheduling", "events_mod_team_desc": "Team members and shift scheduling",
"events_mod_sponsors": "Sponsors", "events_mod_sponsors": "Sponsors",
"events_mod_sponsors_desc": "Sponsors, partners, and deliverables" "events_mod_sponsors_desc": "Sponsors, partners, and deliverables",
"module_coming_soon": "Coming Soon",
"module_coming_soon_desc": "This module is under development and will be available soon.",
"team_title": "Event Team",
"team_subtitle": "Manage team members and their roles for this event.",
"team_add_member": "Add Member",
"team_role_lead": "Lead",
"team_role_manager": "Manager",
"team_role_member": "Member",
"team_empty": "No team members assigned yet. Add members from your organization.",
"team_remove_confirm": "Remove {name} from this event's team?",
"team_remove_btn": "Remove",
"team_added": "{name} added to team",
"team_removed": "{name} removed from team",
"team_updated": "Role updated",
"team_select_member": "Select a member",
"team_select_role": "Select role",
"team_already_assigned": "Already on team",
"team_departments": "Departments",
"team_roles": "Roles",
"team_all": "All",
"team_no_department": "Unassigned",
"team_add_department": "Add Department",
"team_add_role": "Add Role",
"team_edit_department": "Edit Department",
"team_edit_role": "Edit Role",
"team_dept_name": "Department name",
"team_role_name": "Role name",
"team_dept_created": "Department created",
"team_dept_updated": "Department updated",
"team_dept_deleted": "Department deleted",
"team_role_created": "Role created",
"team_role_updated": "Role updated",
"team_role_deleted": "Role deleted",
"team_dept_delete_confirm": "Delete department {name}? Members will be unassigned from it.",
"team_role_delete_confirm": "Delete role {name}? Members will lose this role assignment.",
"team_view_by_dept": "By department",
"team_view_list": "List view",
"team_member_count": "{count} members",
"team_assign_dept": "Assign departments",
"team_notes": "Notes",
"team_notes_placeholder": "Optional notes about this member...",
"overview_subtitle": "Welcome back. Here's what's happening.",
"overview_stat_events": "Events",
"overview_upcoming_events": "Upcoming Events",
"overview_upcoming_empty": "No upcoming events. Create one to get started.",
"overview_view_all_events": "View all events",
"overview_more_members": "+{count} more"
} }

View File

@@ -175,6 +175,14 @@
"account_display_name": "Kuvatav nimi", "account_display_name": "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",
@@ -254,6 +262,9 @@
"entity_invite": "kutse", "entity_invite": "kutse",
"entity_event": "ürituse", "entity_event": "ürituse",
"nav_events": "Üritused", "nav_events": "Üritused",
"nav_chat": "Vestlus",
"chat_title": "Vestlus",
"chat_subtitle": "Meeskonna sõnumid ja suhtlus",
"events_title": "Üritused", "events_title": "Üritused",
"events_subtitle": "Korralda ja halda oma üritusi", "events_subtitle": "Korralda ja halda oma üritusi",
"events_new": "Uus üritus", "events_new": "Uus üritus",
@@ -320,5 +331,52 @@
"events_mod_team": "Meeskond", "events_mod_team": "Meeskond",
"events_mod_team_desc": "Meeskonnaliikmed ja vahetuste planeerimine", "events_mod_team_desc": "Meeskonnaliikmed ja vahetuste planeerimine",
"events_mod_sponsors": "Sponsorid", "events_mod_sponsors": "Sponsorid",
"events_mod_sponsors_desc": "Sponsorid, partnerid ja kohustused" "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"
} }

View File

@@ -27,9 +27,44 @@ export interface EventMember {
event_id: string; event_id: string;
user_id: string; user_id: string;
role: 'lead' | 'manager' | 'member'; role: 'lead' | 'manager' | 'member';
role_id: string | null;
notes: string | null;
assigned_at: string; 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 { export interface EventWithCounts extends Event {
member_count: number; member_count: number;
} }
@@ -211,7 +246,7 @@ export async function deleteEvent(
export async function fetchEventMembers( export async function fetchEventMembers(
supabase: SupabaseClient<Database>, supabase: SupabaseClient<Database>,
eventId: string eventId: string
): Promise<(EventMember & { profile?: { id: string; email: string; full_name: string | null; avatar_url: string | null } })[]> { ): Promise<EventMemberWithDetails[]> {
const { data: members, error } = await supabase const { data: members, error } = await supabase
.from('event_members') .from('event_members')
.select('*') .select('*')
@@ -227,16 +262,49 @@ export async function fetchEventMembers(
// Fetch profiles separately (same pattern as org_members) // Fetch profiles separately (same pattern as org_members)
const userIds = members.map((m: any) => m.user_id); const userIds = members.map((m: any) => m.user_id);
const { data: profiles } = await supabase const { data: profiles } = await (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', userIds); .in('id', userIds);
const profileMap = Object.fromEntries((profiles ?? []).map(p => [p.id, p])); 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) => ({ return members.map((m: any) => ({
...m, ...m,
profile: profileMap[m.user_id] ?? undefined, 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] ?? [],
})); }));
} }
@@ -244,11 +312,17 @@ export async function addEventMember(
supabase: SupabaseClient<Database>, supabase: SupabaseClient<Database>,
eventId: string, eventId: string,
userId: string, userId: string,
role: 'lead' | 'manager' | 'member' = 'member' params: { role?: 'lead' | 'manager' | 'member'; role_id?: string; notes?: string } = {}
): Promise<EventMember> { ): Promise<EventMember> {
const { data, error } = await supabase const { data, error } = await (supabase as any)
.from('event_members') .from('event_members')
.upsert({ event_id: eventId, user_id: userId, role }, { onConflict: 'event_id,user_id' }) .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() .select()
.single(); .single();
@@ -275,3 +349,202 @@ export async function removeEventMember(
throw error; 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;
}
}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 -->

View File

@@ -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 -->

View File

@@ -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>

View File

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

View File

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

View File

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

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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';

View File

@@ -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>

View File

@@ -20,7 +20,7 @@ export const load: LayoutServerLoad = async ({ params, locals }) => {
} }
// Now fetch membership, members, activity, and user profile in parallel (all depend on org.id) // 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 }
}; };

View File

@@ -1,10 +1,10 @@
<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";
@@ -345,23 +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("/events")
? "default"
: target.includes("/settings")
? "settings"
: "default"}
<PageSkeleton variant={skeletonVariant} />
{:else}
{@render children()}
{/if}
</main> </main>
</div> </div>

View File

@@ -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>

View File

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

View File

@@ -16,6 +16,10 @@
email: string; 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">

View File

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

View File

@@ -456,13 +456,11 @@
<title>Calendar - {data.org.name} | Root</title> <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}

View File

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

View File

@@ -392,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>
@@ -410,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();
}} }}
@@ -438,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"}
@@ -460,56 +460,50 @@
{: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 list (sectioned) --> <!-- Room list (sectioned) -->
<nav class="flex-1 overflow-y-auto px-2 pb-2"> <nav class="flex-1 overflow-y-auto px-1.5 pb-2">
{#if allRooms.length === 0} {#if allRooms.length === 0}
<p class="text-light/40 text-sm text-center py-8"> <p class="text-light/30 text-[12px] text-center py-8">
{roomSearchQuery ? "No matching rooms" : "No rooms yet"} {roomSearchQuery ? "No matching rooms" : "No rooms yet"}
</p> </p>
{:else} {:else}
<!-- Org / Space Rooms --> <!-- Org / Space Rooms -->
{#if filteredOrgRooms.length > 0} {#if filteredOrgRooms.length > 0}
<div class="mb-2"> <div class="mb-1.5">
<div class="flex items-center justify-between px-2 py-1"> <div class="flex items-center justify-between px-2 py-1">
<span class="text-xs font-semibold text-light/40 uppercase tracking-wider"> <span class="text-[10px] font-body text-light/30 uppercase tracking-wider">Organization</span>
<span class="material-symbols-rounded align-middle" style="font-size: 14px;">workspaces</span>
Organization
</span>
<button <button
class="w-5 h-5 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors" class="p-0.5 text-light/30 hover:text-white hover:bg-dark/50 rounded transition-colors"
onclick={() => (showCreateRoomModal = true)} onclick={() => (showCreateRoomModal = true)}
title="Create room" title="Create room"
> >
@@ -520,16 +514,16 @@
{#each filteredOrgRooms as room (room.roomId)} {#each filteredOrgRooms as room (room.roomId)}
<li> <li>
<button <button
class="w-full flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors text-left class="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg transition-colors text-left
{$selectedRoomId === room.roomId ? 'bg-primary/20' : 'hover:bg-light/5'}" {$selectedRoomId === room.roomId ? 'bg-primary/10 text-white' : 'text-light/60 hover:bg-dark/50 hover:text-white'}"
onclick={() => handleRoomSelect(room.roomId)} onclick={() => handleRoomSelect(room.roomId)}
> >
<Avatar src={room.avatarUrl} name={room.name} size="xs" /> <Avatar src={room.avatarUrl} name={room.name} size="xs" />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<span class="font-bold text-sm text-light truncate block">{room.name}</span> <span class="text-[12px] font-body truncate block">{room.name}</span>
</div> </div>
{#if room.unreadCount > 0} {#if room.unreadCount > 0}
<span class="bg-primary text-white text-xs px-1.5 py-0.5 rounded-full min-w-[18px] text-center"> <span class="bg-primary text-background text-[10px] px-1.5 py-0.5 rounded-full min-w-[16px] text-center font-bold">
{room.unreadCount > 99 ? "99+" : room.unreadCount} {room.unreadCount > 99 ? "99+" : room.unreadCount}
</span> </span>
{/if} {/if}
@@ -542,14 +536,11 @@
<!-- Direct Messages --> <!-- Direct Messages -->
{#if filteredDmRooms.length > 0} {#if filteredDmRooms.length > 0}
<div class="mb-2"> <div class="mb-1.5">
<div class="flex items-center justify-between px-2 py-1"> <div class="flex items-center justify-between px-2 py-1">
<span class="text-xs font-semibold text-light/40 uppercase tracking-wider"> <span class="text-[10px] font-body text-light/30 uppercase tracking-wider">Direct Messages</span>
<span class="material-symbols-rounded align-middle" style="font-size: 14px;">chat_bubble</span>
Direct Messages
</span>
<button <button
class="w-5 h-5 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors" class="p-0.5 text-light/30 hover:text-white hover:bg-dark/50 rounded transition-colors"
onclick={() => (showStartDMModal = true)} onclick={() => (showStartDMModal = true)}
title="New DM" title="New DM"
> >
@@ -560,16 +551,16 @@
{#each filteredDmRooms as room (room.roomId)} {#each filteredDmRooms as room (room.roomId)}
<li> <li>
<button <button
class="w-full flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors text-left class="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg transition-colors text-left
{$selectedRoomId === room.roomId ? 'bg-primary/20' : 'hover:bg-light/5'}" {$selectedRoomId === room.roomId ? 'bg-primary/10 text-white' : 'text-light/60 hover:bg-dark/50 hover:text-white'}"
onclick={() => handleRoomSelect(room.roomId)} onclick={() => handleRoomSelect(room.roomId)}
> >
<Avatar src={room.avatarUrl} name={room.name} size="xs" /> <Avatar src={room.avatarUrl} name={room.name} size="xs" />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<span class="font-bold text-sm text-light truncate block">{room.name}</span> <span class="text-[12px] font-body truncate block">{room.name}</span>
</div> </div>
{#if room.unreadCount > 0} {#if room.unreadCount > 0}
<span class="bg-primary text-white text-xs px-1.5 py-0.5 rounded-full min-w-[18px] text-center"> <span class="bg-primary text-background text-[10px] px-1.5 py-0.5 rounded-full min-w-[16px] text-center font-bold">
{room.unreadCount > 99 ? "99+" : room.unreadCount} {room.unreadCount > 99 ? "99+" : room.unreadCount}
</span> </span>
{/if} {/if}
@@ -582,14 +573,11 @@
<!-- Other Rooms (not in a space, not DMs) --> <!-- Other Rooms (not in a space, not DMs) -->
{#if filteredOtherRooms.length > 0} {#if filteredOtherRooms.length > 0}
<div class="mb-2"> <div class="mb-1.5">
<div class="flex items-center justify-between px-2 py-1"> <div class="flex items-center justify-between px-2 py-1">
<span class="text-xs font-semibold text-light/40 uppercase tracking-wider"> <span class="text-[10px] font-body text-light/30 uppercase tracking-wider">Rooms</span>
<span class="material-symbols-rounded align-middle" style="font-size: 14px;">tag</span>
Rooms
</span>
<button <button
class="w-5 h-5 flex items-center justify-center text-light/40 hover:text-light hover:bg-light/10 rounded transition-colors" class="p-0.5 text-light/30 hover:text-white hover:bg-dark/50 rounded transition-colors"
onclick={() => (showCreateRoomModal = true)} onclick={() => (showCreateRoomModal = true)}
title="Create room" title="Create room"
> >
@@ -600,16 +588,16 @@
{#each filteredOtherRooms as room (room.roomId)} {#each filteredOtherRooms as room (room.roomId)}
<li> <li>
<button <button
class="w-full flex items-center gap-2 pl-1 pr-2 py-1 rounded-[50px] transition-colors text-left class="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg transition-colors text-left
{$selectedRoomId === room.roomId ? 'bg-primary/20' : 'hover:bg-light/5'}" {$selectedRoomId === room.roomId ? 'bg-primary/10 text-white' : 'text-light/60 hover:bg-dark/50 hover:text-white'}"
onclick={() => handleRoomSelect(room.roomId)} onclick={() => handleRoomSelect(room.roomId)}
> >
<Avatar src={room.avatarUrl} name={room.name} size="xs" /> <Avatar src={room.avatarUrl} name={room.name} size="xs" />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<span class="font-bold text-sm text-light truncate block">{room.name}</span> <span class="text-[12px] font-body truncate block">{room.name}</span>
</div> </div>
{#if room.unreadCount > 0} {#if room.unreadCount > 0}
<span class="bg-primary text-white text-xs px-1.5 py-0.5 rounded-full min-w-[18px] text-center"> <span class="bg-primary text-background text-[10px] px-1.5 py-0.5 rounded-full min-w-[16px] text-center font-bold">
{room.unreadCount > 99 ? "99+" : room.unreadCount} {room.unreadCount > 99 ? "99+" : room.unreadCount}
</span> </span>
{/if} {/if}
@@ -623,76 +611,76 @@
</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>
@@ -700,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}
@@ -729,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}
@@ -764,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}
@@ -773,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}
@@ -782,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}

View File

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

View File

@@ -22,7 +22,7 @@
<title>Files - {data.org.name} | Root</title> <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

View File

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

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { page } from "$app/stores"; import { page } from "$app/stores";
import { Avatar } from "$lib/components/ui"; import { EventCard, TabBar, Button } from "$lib/components/ui";
import { getContext } from "svelte"; import { getContext } from "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";
@@ -68,45 +68,6 @@
"#14B8A6", "#14B8A6",
]; ];
function getStatusColor(status: string): string {
const map: 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",
};
return map[status] ?? "text-light/40 bg-light/5";
}
function getStatusIcon(status: string): string {
const map: Record<string, string> = {
planning: "edit_note",
active: "play_circle",
completed: "check_circle",
archived: "archive",
};
return map[status] ?? "help";
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function formatDateRange(
start: string | null,
end: string | null,
): string {
if (!start && !end) return m.events_no_dates();
if (start && !end) return formatDate(start);
if (!start && end) return `Until ${formatDate(end)}`;
return `${formatDate(start)}${formatDate(end)}`;
}
async function handleCreate() { async function handleCreate() {
if (!newEventName.trim()) return; if (!newEventName.trim()) return;
creating = true; creating = true;
@@ -171,166 +132,66 @@
</svelte:head> </svelte:head>
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
<!-- Header --> <!-- Toolbar: Status Tabs + Create Button -->
<header <div class="flex items-center justify-between px-6 py-3 border-b border-light/5 shrink-0">
class="flex items-center justify-between px-6 py-5 border-b border-light/5" <div class="flex items-center gap-1">
> {#each statusTabs as tab}
<div> <button
<h1 class="text-h1 font-heading text-white">{m.events_title()}</h1> type="button"
<p class="text-body-sm text-light/50 mt-1"> class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-body-sm font-body transition-colors {data.statusFilter ===
{m.events_subtitle()} tab.value
</p> ? '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> </div>
{#if isEditor} {#if isEditor}
<button <Button size="sm" icon="add" onclick={() => (showCreateModal = true)}>
type="button"
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"
onclick={() => (showCreateModal = true)}
>
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>add</span
>
{m.events_new()} {m.events_new()}
</button> </Button>
{/if} {/if}
</header>
<!-- Status Tabs -->
<div class="flex items-center gap-1 px-6 py-3 border-b border-light/5">
{#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/60 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> </div>
<!-- Events Grid --> <!-- Events Grid -->
<div class="flex-1 overflow-auto p-6"> <div class="flex-1 overflow-auto p-6">
{#if data.events.length === 0} {#if data.events.length === 0}
<div <div class="flex flex-col items-center justify-center h-full text-light/40">
class="flex flex-col items-center justify-center h-full text-light/40"
>
<span <span
class="material-symbols-rounded mb-4" class="material-symbols-rounded mb-4"
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;" style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
>celebration</span >celebration</span
> >
<p class="text-h3 font-heading mb-2">{m.events_empty_title()}</p> <p class="text-h3 font-heading mb-2">{m.events_empty_title()}</p>
<p class="text-body text-light/30"> <p class="text-body text-light/30">{m.events_empty_desc()}</p>
{m.events_empty_desc()}
</p>
{#if isEditor} {#if isEditor}
<button <div class="mt-4">
type="button" <Button icon="add" onclick={() => (showCreateModal = true)}>
class="mt-4 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" {m.events_create()}
onclick={() => (showCreateModal = true)} </Button>
> </div>
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>add</span
>
{m.events_create()}
</button>
{/if} {/if}
</div> </div>
{:else} {:else}
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{#each data.events as event} {#each data.events as event}
<a <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}" href="/{data.org.slug}/events/{event.slug}"
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" />
>
<!-- Color bar + Status -->
<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: {event.color ||
'#00A3E0'}"
></div>
<h3
class="text-body font-heading text-white group-hover:text-primary transition-colors truncate"
>
{event.name}
</h3>
</div>
<span
class="text-[11px] font-body px-2 py-0.5 rounded-full capitalize {getStatusColor(
event.status,
)}"
>
{event.status}
</span>
</div>
<!-- Description -->
{#if event.description}
<p
class="text-body-sm text-light/50 line-clamp-2"
>
{event.description}
</p>
{/if}
<!-- Meta row -->
<div
class="flex items-center gap-4 text-[12px] text-light/40 mt-auto pt-2"
>
<!-- Date -->
<div 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
>
<span
>{formatDateRange(
event.start_date,
event.end_date,
)}</span
>
</div>
<!-- Venue -->
{#if event.venue_name}
<div 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
>
<span class="truncate max-w-[120px]"
>{event.venue_name}</span
>
</div>
{/if}
<!-- Members -->
<div class="flex items-center gap-1 ml-auto">
<span
class="material-symbols-rounded"
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
>group</span
>
<span>{event.member_count}</span>
</div>
</div>
</a>
{/each} {/each}
</div> </div>
{/if} {/if}

View File

@@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types'; import type { LayoutServerLoad } from './$types';
import { fetchEventBySlug, fetchEventMembers } from '$lib/api/events'; import { fetchEventBySlug, fetchEventMembers, fetchEventRoles, fetchEventDepartments } from '$lib/api/events';
import { createLogger } from '$lib/utils/logger'; import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.event-detail'); const log = createLogger('page.event-detail');
@@ -16,9 +16,13 @@ export const load: LayoutServerLoad = async ({ params, locals, parent }) => {
const event = await fetchEventBySlug(locals.supabase, orgId, params.eventSlug); const event = await fetchEventBySlug(locals.supabase, orgId, params.eventSlug);
if (!event) error(404, 'Event not found'); if (!event) error(404, 'Event not found');
const members = await fetchEventMembers(locals.supabase, event.id); 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 }; return { event, eventMembers: members, eventRoles: roles, eventDepartments: departments };
} catch (e: any) { } catch (e: any) {
if (e?.status === 404) throw e; if (e?.status === 404) throw e;
log.error('Failed to load event', { error: e, data: { orgId, eventSlug: params.eventSlug } }); log.error('Failed to load event', { error: e, data: { orgId, eventSlug: params.eventSlug } });

View File

@@ -2,7 +2,7 @@
import { page } from "$app/stores"; import { page } from "$app/stores";
import { Avatar } from "$lib/components/ui"; import { Avatar } from "$lib/components/ui";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
import type { Event, EventMember } from "$lib/api/events"; import type { Event, EventMemberWithDetails, EventRole, EventDepartment } from "$lib/api/events";
import * as m from "$lib/paraglide/messages"; import * as m from "$lib/paraglide/messages";
interface Props { interface Props {
@@ -10,14 +10,9 @@
org: { id: string; name: string; slug: string }; org: { id: string; name: string; slug: string };
userRole: string; userRole: string;
event: Event; event: Event;
eventMembers: (EventMember & { eventMembers: EventMemberWithDetails[];
profile?: { eventRoles: EventRole[];
id: string; eventDepartments: EventDepartment[];
email: string;
full_name: string | null;
avatar_url: string | null;
};
})[];
}; };
children: Snippet; children: Snippet;
} }

View File

@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { Avatar } from "$lib/components/ui"; import { ModuleCard, SectionCard, StatusBadge } from "$lib/components/ui";
import { getContext } from "svelte"; import { getContext } from "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";
import { toasts } from "$lib/stores/ui"; import { toasts } from "$lib/stores/ui";
import type { Event, EventMember } from "$lib/api/events"; import type { Event, EventMemberWithDetails, EventRole, EventDepartment } from "$lib/api/events";
import * as m from "$lib/paraglide/messages"; import * as m from "$lib/paraglide/messages";
interface Props { interface Props {
@@ -13,14 +13,9 @@
org: { id: string; name: string; slug: string }; org: { id: string; name: string; slug: string };
userRole: string; userRole: string;
event: Event; event: Event;
eventMembers: (EventMember & { eventMembers: EventMemberWithDetails[];
profile?: { eventRoles: EventRole[];
id: string; eventDepartments: EventDepartment[];
email: string;
full_name: string | null;
avatar_url: string | null;
};
})[];
}; };
} }
@@ -394,36 +389,21 @@
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3"
> >
{#each moduleCards as mod} {#each moduleCards as mod}
<a <ModuleCard
label={mod.label}
description={mod.description}
icon={mod.icon}
href={mod.href} href={mod.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" color={mod.color}
> bg={mod.bg}
<div />
class="w-10 h-10 rounded-xl {mod.bg} flex items-center justify-center"
>
<span
class="material-symbols-rounded {mod.color}"
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 22;"
>{mod.icon}</span
>
</div>
<h3
class="text-body font-heading text-white group-hover:text-primary transition-colors"
>
{mod.label}
</h3>
<p class="text-[12px] text-light/40">{mod.description}</p>
</a>
{/each} {/each}
</div> </div>
<!-- Event Details Section --> <!-- Event Details Section -->
<div class="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Info Card --> <!-- Info Card -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5"> <SectionCard title={m.events_details()}>
<h3 class="text-body font-heading text-white mb-3">
{m.events_details()}
</h3>
<div class="flex flex-col gap-2.5"> <div class="flex flex-col gap-2.5">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span <span
@@ -474,41 +454,30 @@
</div> </div>
{/if} {/if}
</div> </div>
</div> </SectionCard>
<!-- Team Card --> <!-- Team Card -->
<div class="bg-dark/30 border border-light/5 rounded-xl p-5"> <SectionCard title={m.events_team_count({ count: String(data.eventMembers.length) })}>
<div class="flex items-center justify-between mb-3"> {#snippet titleRight()}
<h3 class="text-body font-heading text-white">
{m.events_team_count({ count: String(data.eventMembers.length) })}
</h3>
<a <a
href="{basePath}/team" href="{basePath}/team"
class="text-[12px] text-primary hover:underline" class="text-[12px] text-primary hover:underline"
>{m.events_team_manage()}</a >{m.events_team_manage()}</a
> >
</div> {/snippet}
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
{#each data.eventMembers.slice(0, 6) as member} {#each data.eventMembers.slice(0, 6) as member}
<div class="flex items-center gap-2.5"> <div class="flex items-center gap-2.5">
<Avatar <div class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-[11px] font-bold text-primary shrink-0">
name={member.profile?.full_name || {(member.profile?.full_name || member.profile?.email || "?").charAt(0).toUpperCase()}
member.profile?.email || </div>
"?"}
src={member.profile?.avatar_url}
size="sm"
/>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p <p class="text-body-sm text-white truncate">
class="text-body-sm text-white truncate"
>
{member.profile?.full_name || {member.profile?.full_name ||
member.profile?.email || member.profile?.email ||
"Unknown"} "Unknown"}
</p> </p>
<p <p class="text-[11px] text-light/40 capitalize">
class="text-[11px] text-light/40 capitalize"
>
{member.role} {member.role}
</p> </p>
</div> </div>
@@ -528,7 +497,7 @@
</p> </p>
{/if} {/if}
</div> </div>
</div> </SectionCard>
</div> </div>
</div> </div>
</div> </div>

View 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>

View 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>

View 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>

View 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_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>

View 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_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>

View 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>

View 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}

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import type { Snippet } from "svelte";
import { navigating } from "$app/stores";
import { PageHeader, ContentSkeleton } from "$lib/components/ui";
import * as m from "$lib/paraglide/messages";
interface Props {
data: {
org: { id: string; name: string; slug: string };
};
children: Snippet;
}
let { data, children }: Props = $props();
const isNavigatingHere = $derived(
$navigating?.to?.url.pathname.includes("/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>

View File

@@ -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}

View 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>

View File

@@ -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 -->

View File

@@ -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 &mdash; Tilt Warp</h3>
Headings &mdash; 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 &mdash; Tilt Warp</h3>
Button Text &mdash; 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 &mdash; Work Sans</h3>
Body &mdash; 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 &mdash; Style Guide</p>
Root Organization Platform - Style Guide
</p>
</footer> </footer>
</div> </div>
</div> </div>

View 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();

View 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;