9 Commits

Author SHA1 Message Date
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
AlacrisDevs
fe6ec6e0af i18n: add Paraglide messages for all events pages (EN + ET) 2026-02-07 10:16:13 +02:00
AlacrisDevs
36496e8cdb fix: event detail SSR children guard, state_referenced_locally warnings, a11y warnings 2026-02-07 10:09:00 +02:00
AlacrisDevs
556955f349 feat: Phase 1 - Events entity (migration, API, list page, detail layout with module sidebar, overview page) 2026-02-07 10:04:37 +02:00
AlacrisDevs
4f21c89103 Merge feature/matrix-chat-integration: full Matrix chat integration 2026-02-07 09:46:10 +02:00
57 changed files with 4506 additions and 1515 deletions

View File

@@ -251,5 +251,100 @@
"entity_kanban_column": "column", "entity_kanban_column": "column",
"entity_member": "member", "entity_member": "member",
"entity_role": "role", "entity_role": "role",
"entity_invite": "invite" "entity_invite": "invite",
"entity_event": "event",
"nav_events": "Events",
"nav_chat": "Chat",
"chat_title": "Chat",
"chat_subtitle": "Team messaging and communication",
"events_title": "Events",
"events_subtitle": "Organize and manage your events",
"events_new": "New Event",
"events_create": "Create Event",
"events_empty_title": "No events yet",
"events_empty_desc": "Create your first event to get started",
"events_no_dates": "No dates set",
"events_tab_all": "All Events",
"events_tab_planning": "Planning",
"events_tab_active": "Active",
"events_tab_completed": "Completed",
"events_tab_archived": "Archived",
"events_status_planning": "Planning",
"events_status_active": "Active",
"events_status_completed": "Completed",
"events_status_archived": "Archived",
"events_form_name": "Event Name",
"events_form_name_placeholder": "e.g., Summer Conference 2026",
"events_form_description": "Description",
"events_form_description_placeholder": "Brief description of the event...",
"events_form_start_date": "Start Date",
"events_form_end_date": "End Date",
"events_form_venue": "Venue",
"events_form_venue_placeholder": "e.g., Convention Center",
"events_form_venue_address_placeholder": "Venue address",
"events_form_color": "Color",
"events_form_select_color": "Select color {color}",
"events_creating": "Creating...",
"events_saving": "Saving...",
"events_deleting": "Deleting...",
"events_updated": "Event updated",
"events_created": "Event \"{name}\" created",
"events_deleted": "Event deleted",
"events_delete_title": "Delete Event?",
"events_delete_desc": "This will permanently delete {name} and all its data. This action cannot be undone.",
"events_delete_confirm": "Delete Event",
"events_days_ago": "{count} days ago",
"events_today": "Today!",
"events_tomorrow": "Tomorrow",
"events_in_days": "In {count} days",
"events_overview": "Overview",
"events_modules": "Modules",
"events_details": "Event Details",
"events_start_date": "Start Date",
"events_end_date": "End Date",
"events_venue": "Venue",
"events_not_set": "Not set",
"events_all_events": "All Events",
"events_team": "Team",
"events_team_count": "Team ({count})",
"events_team_manage": "Manage",
"events_team_empty": "No team members assigned yet",
"events_more_members": "+{count} more",
"events_mod_tasks": "Tasks",
"events_mod_tasks_desc": "Manage tasks, milestones, and progress",
"events_mod_files": "Files",
"events_mod_files_desc": "Documents, contracts, and media",
"events_mod_schedule": "Schedule",
"events_mod_schedule_desc": "Event timeline and program",
"events_mod_budget": "Budget",
"events_mod_budget_desc": "Income, expenses, and tracking",
"events_mod_guests": "Guests",
"events_mod_guests_desc": "Guest list and registration",
"events_mod_team": "Team",
"events_mod_team_desc": "Team members and shift scheduling",
"events_mod_sponsors": "Sponsors",
"events_mod_sponsors_desc": "Sponsors, partners, and deliverables",
"module_coming_soon": "Coming Soon",
"module_coming_soon_desc": "This module is under development and will be available soon.",
"team_title": "Event Team",
"team_subtitle": "Manage team members and their roles for this event.",
"team_add_member": "Add Member",
"team_role_lead": "Lead",
"team_role_manager": "Manager",
"team_role_member": "Member",
"team_empty": "No team members assigned yet. Add members from your organization.",
"team_remove_confirm": "Remove {name} from this event's team?",
"team_remove_btn": "Remove",
"team_added": "{name} added to team",
"team_removed": "{name} removed from team",
"team_updated": "Role updated",
"team_select_member": "Select a member",
"team_select_role": "Select role",
"team_already_assigned": "Already on team",
"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

@@ -251,5 +251,100 @@
"entity_kanban_column": "veeru", "entity_kanban_column": "veeru",
"entity_member": "liikme", "entity_member": "liikme",
"entity_role": "rolli", "entity_role": "rolli",
"entity_invite": "kutse" "entity_invite": "kutse",
"entity_event": "ürituse",
"nav_events": "Üritused",
"nav_chat": "Vestlus",
"chat_title": "Vestlus",
"chat_subtitle": "Meeskonna sõnumid ja suhtlus",
"events_title": "Üritused",
"events_subtitle": "Korralda ja halda oma üritusi",
"events_new": "Uus üritus",
"events_create": "Loo üritus",
"events_empty_title": "Üritusi pole veel",
"events_empty_desc": "Loo oma esimene üritus alustamiseks",
"events_no_dates": "Kuupäevad määramata",
"events_tab_all": "Kõik üritused",
"events_tab_planning": "Planeerimisel",
"events_tab_active": "Aktiivne",
"events_tab_completed": "Lõpetatud",
"events_tab_archived": "Arhiveeritud",
"events_status_planning": "Planeerimisel",
"events_status_active": "Aktiivne",
"events_status_completed": "Lõpetatud",
"events_status_archived": "Arhiveeritud",
"events_form_name": "Ürituse nimi",
"events_form_name_placeholder": "nt Suvekonverents 2026",
"events_form_description": "Kirjeldus",
"events_form_description_placeholder": "Ürituse lühikirjeldus...",
"events_form_start_date": "Alguskuupäev",
"events_form_end_date": "Lõppkuupäev",
"events_form_venue": "Toimumiskoht",
"events_form_venue_placeholder": "nt Konverentsikeskus",
"events_form_venue_address_placeholder": "Toimumiskoha aadress",
"events_form_color": "Värv",
"events_form_select_color": "Vali värv {color}",
"events_creating": "Loomine...",
"events_saving": "Salvestamine...",
"events_deleting": "Kustutamine...",
"events_updated": "Üritus uuendatud",
"events_created": "Üritus \"{name}\" loodud",
"events_deleted": "Üritus kustutatud",
"events_delete_title": "Kustuta üritus?",
"events_delete_desc": "See kustutab jäädavalt ürituse {name} ja kõik selle andmed. Seda toimingut ei saa tagasi võtta.",
"events_delete_confirm": "Kustuta üritus",
"events_days_ago": "{count} päeva tagasi",
"events_today": "Täna!",
"events_tomorrow": "Homme",
"events_in_days": "{count} päeva pärast",
"events_overview": "Ülevaade",
"events_modules": "Moodulid",
"events_details": "Ürituse andmed",
"events_start_date": "Alguskuupäev",
"events_end_date": "Lõppkuupäev",
"events_venue": "Toimumiskoht",
"events_not_set": "Määramata",
"events_all_events": "Kõik üritused",
"events_team": "Meeskond",
"events_team_count": "Meeskond ({count})",
"events_team_manage": "Halda",
"events_team_empty": "Meeskonnaliikmeid pole veel määratud",
"events_more_members": "+{count} veel",
"events_mod_tasks": "Ülesanded",
"events_mod_tasks_desc": "Halda ülesandeid, verstaposte ja edenemist",
"events_mod_files": "Failid",
"events_mod_files_desc": "Dokumendid, lepingud ja meedia",
"events_mod_schedule": "Ajakava",
"events_mod_schedule_desc": "Ürituse ajakava ja programm",
"events_mod_budget": "Eelarve",
"events_mod_budget_desc": "Tulud, kulud ja jälgimine",
"events_mod_guests": "Külalised",
"events_mod_guests_desc": "Külaliste nimekiri ja registreerimine",
"events_mod_team": "Meeskond",
"events_mod_team_desc": "Meeskonnaliikmed ja vahetuste planeerimine",
"events_mod_sponsors": "Sponsorid",
"events_mod_sponsors_desc": "Sponsorid, partnerid ja kohustused",
"module_coming_soon": "Tulekul",
"module_coming_soon_desc": "See moodul on arendamisel ja saab peagi kättesaadavaks.",
"team_title": "Ürituse meeskond",
"team_subtitle": "Halda meeskonnaliikmeid ja nende rolle selle ürituse jaoks.",
"team_add_member": "Lisa liige",
"team_role_lead": "Juht",
"team_role_manager": "Haldur",
"team_role_member": "Liige",
"team_empty": "Meeskonnaliikmeid pole veel määratud. Lisa liikmeid oma organisatsioonist.",
"team_remove_confirm": "Eemalda {name} selle ürituse meeskonnast?",
"team_remove_btn": "Eemalda",
"team_added": "{name} lisatud meeskonda",
"team_removed": "{name} eemaldatud meeskonnast",
"team_updated": "Roll uuendatud",
"team_select_member": "Vali liige",
"team_select_role": "Vali roll",
"team_already_assigned": "Juba meeskonnas",
"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

@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
// Test the slugify logic (extracted inline since it's not exported)
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 60) || 'event';
}
describe('events API - slugify', () => {
it('converts simple name to slug', () => {
expect(slugify('Summer Conference')).toBe('summer-conference');
});
it('handles special characters', () => {
expect(slugify('Music & Arts Festival 2026!')).toBe('music-arts-festival-2026');
});
it('collapses multiple spaces and dashes', () => {
expect(slugify('My Big Event')).toBe('my-big-event');
});
it('trims leading/trailing dashes', () => {
expect(slugify('--hello--')).toBe('hello');
});
it('truncates to 60 characters', () => {
const longName = 'A'.repeat(100);
expect(slugify(longName).length).toBeLessThanOrEqual(60);
});
it('returns "event" for empty string', () => {
expect(slugify('')).toBe('event');
});
it('handles unicode characters', () => {
const result = slugify('Ürituse Korraldamine');
expect(result).toBe('rituse-korraldamine');
});
it('handles numbers', () => {
expect(slugify('Event 2026 Q1')).toBe('event-2026-q1');
});
});

277
src/lib/api/events.ts Normal file
View File

@@ -0,0 +1,277 @@
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '$lib/supabase/types';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('api.events');
export interface Event {
id: string;
org_id: string;
name: string;
slug: string;
description: string | null;
status: 'planning' | 'active' | 'completed' | 'archived';
start_date: string | null;
end_date: string | null;
venue_name: string | null;
venue_address: string | null;
cover_image_url: string | null;
color: string | null;
created_by: string | null;
created_at: string;
updated_at: string;
}
export interface EventMember {
id: string;
event_id: string;
user_id: string;
role: 'lead' | 'manager' | 'member';
assigned_at: string;
}
export interface EventWithCounts extends Event {
member_count: number;
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 60) || 'event';
}
export async function fetchEvents(
supabase: SupabaseClient<Database>,
orgId: string,
status?: string
): Promise<EventWithCounts[]> {
let query = supabase
.from('events')
.select('*, event_members(count)')
.eq('org_id', orgId)
.order('start_date', { ascending: true, nullsFirst: false });
if (status && status !== 'all') {
query = query.eq('status', status);
}
const { data, error } = await query;
if (error) {
log.error('fetchEvents failed', { error, data: { orgId } });
throw error;
}
const events: EventWithCounts[] = (data ?? []).map((e: any) => ({
...e,
member_count: e.event_members?.[0]?.count ?? 0,
event_members: undefined,
}));
log.debug('fetchEvents ok', { data: { count: events.length } });
return events;
}
export async function fetchEvent(
supabase: SupabaseClient<Database>,
eventId: string
): Promise<Event | null> {
const { data, error } = await supabase
.from('events')
.select('*')
.eq('id', eventId)
.single();
if (error) {
if (error.code === 'PGRST116') return null;
log.error('fetchEvent failed', { error, data: { eventId } });
throw error;
}
return data as unknown as Event;
}
export async function fetchEventBySlug(
supabase: SupabaseClient<Database>,
orgId: string,
eventSlug: string
): Promise<Event | null> {
const { data, error } = await supabase
.from('events')
.select('*')
.eq('org_id', orgId)
.eq('slug', eventSlug)
.single();
if (error) {
if (error.code === 'PGRST116') return null;
log.error('fetchEventBySlug failed', { error, data: { orgId, eventSlug } });
throw error;
}
return data as unknown as Event;
}
export async function createEvent(
supabase: SupabaseClient<Database>,
orgId: string,
userId: string,
params: {
name: string;
description?: string;
start_date?: string;
end_date?: string;
venue_name?: string;
venue_address?: string;
color?: string;
}
): Promise<Event> {
const baseSlug = slugify(params.name);
// Ensure unique slug within org
const { data: existing } = await supabase
.from('events')
.select('slug')
.eq('org_id', orgId)
.like('slug', `${baseSlug}%`);
let slug = baseSlug;
if (existing && existing.length > 0) {
const existingSlugs = new Set(existing.map((e: any) => e.slug));
if (existingSlugs.has(slug)) {
let i = 2;
while (existingSlugs.has(`${baseSlug}-${i}`)) i++;
slug = `${baseSlug}-${i}`;
}
}
const { data, error } = await supabase
.from('events')
.insert({
org_id: orgId,
name: params.name,
slug,
description: params.description ?? null,
start_date: params.start_date ?? null,
end_date: params.end_date ?? null,
venue_name: params.venue_name ?? null,
venue_address: params.venue_address ?? null,
color: params.color ?? null,
created_by: userId,
})
.select()
.single();
if (error) {
log.error('createEvent failed', { error, data: { orgId, name: params.name } });
throw error;
}
log.info('createEvent ok', { data: { id: data.id, name: params.name, slug } });
return data as unknown as Event;
}
export async function updateEvent(
supabase: SupabaseClient<Database>,
eventId: string,
params: Partial<Pick<Event, 'name' | 'description' | 'status' | 'start_date' | 'end_date' | 'venue_name' | 'venue_address' | 'cover_image_url' | 'color'>>
): Promise<Event> {
const { data, error } = await supabase
.from('events')
.update({ ...params, updated_at: new Date().toISOString() })
.eq('id', eventId)
.select()
.single();
if (error) {
log.error('updateEvent failed', { error, data: { eventId } });
throw error;
}
log.info('updateEvent ok', { data: { id: data.id } });
return data as unknown as Event;
}
export async function deleteEvent(
supabase: SupabaseClient<Database>,
eventId: string
): Promise<void> {
const { error } = await supabase
.from('events')
.delete()
.eq('id', eventId);
if (error) {
log.error('deleteEvent failed', { error, data: { eventId } });
throw error;
}
log.info('deleteEvent ok', { data: { eventId } });
}
export async function fetchEventMembers(
supabase: SupabaseClient<Database>,
eventId: string
): Promise<(EventMember & { profile?: { id: string; email: string; full_name: string | null; avatar_url: string | null } })[]> {
const { data: members, error } = await supabase
.from('event_members')
.select('*')
.eq('event_id', eventId)
.order('assigned_at');
if (error) {
log.error('fetchEventMembers failed', { error, data: { eventId } });
throw error;
}
if (!members || members.length === 0) return [];
// Fetch profiles separately (same pattern as org_members)
const userIds = members.map((m: any) => m.user_id);
const { data: profiles } = await supabase
.from('profiles')
.select('id, email, full_name, avatar_url')
.in('id', userIds);
const profileMap = Object.fromEntries((profiles ?? []).map(p => [p.id, p]));
return members.map((m: any) => ({
...m,
profile: profileMap[m.user_id] ?? undefined,
}));
}
export async function addEventMember(
supabase: SupabaseClient<Database>,
eventId: string,
userId: string,
role: 'lead' | 'manager' | 'member' = 'member'
): Promise<EventMember> {
const { data, error } = await supabase
.from('event_members')
.upsert({ event_id: eventId, user_id: userId, role }, { onConflict: 'event_id,user_id' })
.select()
.single();
if (error) {
log.error('addEventMember failed', { error, data: { eventId, userId } });
throw error;
}
return data as unknown as EventMember;
}
export async function removeEventMember(
supabase: SupabaseClient<Database>,
eventId: string,
userId: string
): Promise<void> {
const { error } = await supabase
.from('event_members')
.delete()
.eq('event_id', eventId)
.eq('user_id', userId);
if (error) {
log.error('removeEventMember failed', { error, data: { eventId, userId } });
throw error;
}
}

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,41 +487,28 @@
} }
</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 -->
<header class="flex items-center gap-2 p-1">
<Avatar name={title} size="md" />
<h1 class="flex-1 font-heading text-h1 text-white">{title}</h1>
<Button size="md" onclick={handleAdd}>{m.btn_new()}</Button>
<IconButton title={m.files_toggle_view()} onclick={toggleViewMode}>
<Icon
name={viewMode === "list" ? "grid_view" : "view_list"}
size={24}
/>
</IconButton>
</header>
<!-- Breadcrumb Path --> <!-- Breadcrumb Path -->
<nav class="flex items-center gap-2 text-h3 font-heading"> <nav class="flex items-center gap-1 flex-1 min-w-0 overflow-x-auto">
{#each breadcrumbPath as crumb, i} {#each breadcrumbPath as crumb, i}
{#if i > 0} {#if i > 0}
<span <span
class="material-symbols-rounded text-light/30" class="material-symbols-rounded text-light/20 shrink-0"
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;"
> >
chevron_right chevron_right
</span> </span>
{/if} {/if}
<a <a
href={getFolderUrl(crumb.id)} href={getFolderUrl(crumb.id)}
class="px-3 py-1 rounded-xl transition-colors class="px-2 py-1 rounded-lg text-body-sm font-body whitespace-nowrap transition-colors
{crumb.id === currentFolderId {crumb.id === currentFolderId
? 'text-white' ? 'text-white bg-dark/30'
: 'text-light/60 hover:text-primary'} : 'text-light/50 hover:text-white hover:bg-dark/30'}
{dragOverBreadcrumb === (crumb.id ?? '__root__') {dragOverBreadcrumb === (crumb.id ?? '__root__')
? 'ring-2 ring-primary bg-primary/10' ? 'ring-2 ring-primary bg-primary/10'
: ''}" : ''}"
@@ -554,33 +538,50 @@
resetDragState(); resetDragState();
}} }}
> >
{#if i === 0}
<span class="material-symbols-rounded" style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;">home</span>
{:else}
{crumb.name} {crumb.name}
{/if}
</a> </a>
{/each} {/each}
</nav> </nav>
<Button size="sm" icon="add" onclick={handleAdd}>{m.btn_new()}</Button>
<button
type="button"
class="p-1.5 rounded-lg text-light/40 hover:text-white hover:bg-dark/50 transition-colors"
title={m.files_toggle_view()}
onclick={toggleViewMode}
>
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>{viewMode === "list" ? "grid_view" : "view_list"}</span
>
</button>
</div>
<!-- File List/Grid --> <!-- 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"
@@ -594,25 +595,21 @@
onauxclick={(e) => handleAuxClick(e, item)} onauxclick={(e) => handleAuxClick(e, item)}
oncontextmenu={(e) => oncontextmenu={(e) =>
handleContextMenu(e, item)} handleContextMenu(e, item)}
>
<div
class="w-8 h-8 flex items-center justify-center p-1"
> >
<span <span
class="material-symbols-rounded text-light" class="material-symbols-rounded shrink-0 {item.type === 'folder' ? 'text-amber-400' : item.type === 'kanban' ? 'text-purple-400' : 'text-light/50'}"
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;"
> >
{getDocIcon(item)} {getDocIcon(item)}
</span> </span>
</div>
<span <span
class="font-body text-body text-white truncate flex-1" class="font-body text-body-sm text-white truncate flex-1"
>{item.name}</span >{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
</span>
<span
class="font-body text-[12px] text-light leading-none"
>
{formatDueDate(card.due_date)} {formatDueDate(card.due_date)}
</span> </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
</span>
<span
class="font-body text-[12px] text-light leading-none"
>
{card.checklist_done ?? 0}/{card.checklist_total} {card.checklist_done ?? 0}/{card.checklist_total}
</span> </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,15 +124,14 @@
} }
</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">
<div class="flex flex-col gap-4">
<!-- Avatar Upload --> <!-- Avatar Upload -->
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<span class="font-body text-body-sm text-light">Avatar</span> <span class="font-body text-body-sm text-light/60">Avatar</span>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<Avatar name={orgName || "?"} src={avatarUrl} size="lg" /> <Avatar name={orgName || "?"} src={avatarUrl} size="lg" />
<div class="flex gap-2"> <div class="flex gap-2">
@@ -174,7 +173,7 @@
placeholder="my-org" placeholder="my-org"
/> />
<div> <div>
<Button onclick={saveGeneralSettings} loading={isSaving} <Button size="sm" onclick={saveGeneralSettings} loading={isSaving}
>Save Changes</Button >Save Changes</Button
> >
</div> </div>
@@ -182,13 +181,13 @@
<!-- Danger Zone --> <!-- Danger Zone -->
{#if isOwner} {#if isOwner}
<div class="flex flex-col gap-4"> <div class="bg-dark/30 border border-error/10 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-error">Danger Zone</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. Permanently delete this organization and all its data.
</p> </p>
<div> <div>
<Button variant="danger" onclick={onDelete} <Button variant="danger" size="sm" onclick={onDelete}
>Delete Organization</Button >Delete Organization</Button
> >
</div> </div>
@@ -197,20 +196,16 @@
<!-- Leave Organization (non-owners) --> <!-- 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"> <h4 class="font-heading text-body-sm text-white">Leave Organization</h4>
Leave Organization <p class="font-body text-[11px] text-light/40">
</h4> Leave this organization. You will need to be re-invited to rejoin.
<p class="font-body text-body text-white">
Leave this organization. You will need to be re-invited to
rejoin.
</p> </p>
<div> <div>
<Button variant="secondary" onclick={onLeave} <Button variant="secondary" size="sm" onclick={onLeave}
>Leave {org.name}</Button >Leave {org.name}</Button
> >
</div> </div>
</div> </div>
{/if} {/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"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg> </svg>
</div> </div>
<div class="flex-1"> <div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-light"> <h3 class="font-heading text-body-sm text-white">Google Calendar</h3>
Google Calendar <p class="text-[11px] text-light/40 mt-0.5">
</h3> Sync events between your organization and Google Calendar.
<p class="text-sm text-light/50 mt-1">
Sync events between your organization and Google
Calendar.
</p> </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="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3 bg-green-500/10 rounded-lg"
>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p <p class="text-[11px] font-medium text-green-400">Connected</p>
class="text-sm font-medium text-green-400" <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>
Connected <p class="text-[10px] text-light/30 mt-1">Events sync both ways.</p>
</p>
<p class="text-light font-medium">
{orgCalendar.calendar_name ||
"Google Calendar"}
</p>
<p
class="text-xs text-light/50 truncate"
title={orgCalendar.calendar_id}
>
{orgCalendar.calendar_id}
</p>
<p class="text-xs text-light/40 mt-1">
Events sync both ways — create here or
in Google Calendar.
</p>
<a <a
href="https://calendar.google.com/calendar/u/0/r?cid={encodeURIComponent( href="https://calendar.google.com/calendar/u/0/r?cid={encodeURIComponent(orgCalendar.calendar_id)}"
orgCalendar.calendar_id,
)}"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 text-xs text-blue-400 hover:text-blue-300 mt-2" class="inline-flex items-center gap-1 text-[11px] text-blue-400 hover:text-blue-300 mt-1.5"
> >
<svg <span class="material-symbols-rounded" style="font-size: 14px;">open_in_new</span>
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 Open in Google Calendar
</a> </a>
</div> </div>
<Button <Button variant="danger" size="sm" onclick={disconnectOrgCalendar}>Disconnect</Button>
variant="danger"
size="sm"
onclick={disconnectOrgCalendar}
>Disconnect</Button
>
</div> </div>
</div> </div>
{:else if !serviceAccountEmail} {:else if !serviceAccountEmail}
<div <div class="mt-3 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
class="mt-4 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg" <p class="text-[11px] text-yellow-400 font-medium">Setup required</p>
> <p class="text-[10px] text-light/40 mt-1">
<p class="text-sm text-yellow-400 font-medium"> A server administrator needs to configure the <code class="bg-light/10 px-1 rounded">GOOGLE_SERVICE_ACCOUNT_KEY</code> environment variable.
Setup required
</p>
<p class="text-xs text-light/50 mt-1">
A server administrator needs to configure the <code
class="bg-light/10 px-1 rounded"
>GOOGLE_SERVICE_ACCOUNT_KEY</code
> environment variable before calendars can be connected.
</p> </p>
</div> </div>
{:else} {:else}
<div class="mt-4"> <div class="mt-3">
<Button onclick={() => (showConnectModal = true)} <Button size="sm" onclick={() => (showConnectModal = true)}>Connect Google Calendar</Button>
>Connect Google Calendar</Button
>
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
</div> </div>
</Card>
<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>
>
<svg
class="w-7 h-7 text-white"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z"
/>
</svg>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h3 class="text-lg font-semibold text-light">Discord</h3> <h3 class="font-heading text-body-sm text-white">Discord</h3>
<p class="text-sm text-light/50 mt-1"> <p class="text-[11px] text-light/40 mt-0.5">Get notifications in your Discord server.</p>
Get notifications in your Discord server. <p class="text-[10px] text-light/30 mt-1">Coming soon</p>
</p>
<p class="text-xs text-light/40 mt-2">Coming soon</p>
</div> </div>
</div> </div>
</div> </div>
</Card>
<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>
>
<svg
class="w-7 h-7 text-white"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52a2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521a2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521a2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523a2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"
/>
</svg>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h3 class="text-lg font-semibold text-light">Slack</h3> <h3 class="font-heading text-body-sm text-white">Slack</h3>
<p class="text-sm text-light/50 mt-1"> <p class="text-[11px] text-light/40 mt-0.5">Get notifications in your Slack workspace.</p>
Get notifications in your Slack workspace. <p class="text-[10px] text-light/30 mt-1">Coming soon</p>
</p>
<p class="text-xs text-light/40 mt-2">Coming soon</p>
</div> </div>
</div> </div>
</div> </div>
</Card>
</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,41 +168,24 @@
} }
</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>
<h2 class="font-heading text-body text-white">
{m.settings_members_title({ {m.settings_members_title({
count: String(members.length), count: String(members.length),
})} })}
</h2> </h2>
<Button onclick={() => (showInviteModal = true)}> </div>
<svg <Button size="sm" icon="person_add" onclick={() => (showInviteModal = true)}>
class="w-4 h-4 mr-2"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle
cx="9"
cy="7"
r="4"
/><line x1="19" y1="8" x2="19" y2="14" /><line
x1="22"
y1="11"
x2="16"
y2="11"
/>
</svg>
{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">
@@ -212,70 +194,71 @@
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-light">{invite.email}</p> <p class="text-body-sm text-white">{invite.email}</p>
<p class="text-xs text-light/40"> <p class="text-[11px] 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>
<div class="flex items-center gap-2"> <div class="flex items-center gap-1.5">
<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={() => onclick={() =>
navigator.clipboard.writeText( navigator.clipboard.writeText(
`${window.location.origin}/invite/${invite.token}`, `${window.location.origin}/invite/${invite.token}`,
)} )}
>{m.settings_members_copy_link()}</Button title={m.settings_members_copy_link()}
> >
<Button <span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">content_copy</span>
variant="danger" </button>
size="sm" <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)} onclick={() => cancelInvite(invite.id)}
>Cancel</Button title="Cancel invite"
> >
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">close</span>
</button>
</div> </div>
</div> </div>
{/each} {/each}
</div> </div>
</div> </div>
</Card>
{/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-3 h-3 rounded-full" class="w-2.5 h-2.5 rounded-full shrink-0"
style="background-color: {role.color}" style="background-color: {role.color}"
></div> ></div>
<span class="font-medium text-light" <span class="text-body-sm font-medium text-white">{role.name}</span>
>{role.name}</span
>
{#if role.is_system} {#if role.is_system}
<span <span class="text-[10px] text-light/30 bg-light/5 px-1.5 py-0.5 rounded-md">System</span>
class="text-xs text-light/40 bg-light/10 px-2 py-0.5 rounded"
>System</span
>
{/if} {/if}
{#if role.is_default} {#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-primary bg-primary/10 px-2 py-0.5 rounded"
>Default</span
>
{/if} {/if}
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-1.5">
{#if !role.is_system || role.name !== "Owner"} {#if !role.is_system || role.name !== "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={() => openRoleModal(role)} onclick={() => openRoleModal(role)}
>Edit</Button title="Edit"
> >
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">edit</span>
</button>
{/if} {/if}
{#if !role.is_system} {#if !role.is_system}
<Button <button
variant="danger" type="button"
size="sm" class="p-1.5 text-light/40 hover:text-error hover:bg-error/10 rounded-lg transition-colors"
onclick={() => deleteRole(role)} onclick={() => deleteRole(role)}
>Delete</Button title="Delete"
> >
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">delete</span>
</button>
{/if} {/if}
</div> </div>
</div> </div>
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
{#if role.permissions.includes("*")} {#if role.permissions.includes("*")}
<span <span class="text-[10px] bg-light/5 text-light/50 px-1.5 py-0.5 rounded-md">All Permissions</span>
class="text-xs bg-light/10 text-light/70 px-2 py-1 rounded"
>All Permissions</span
>
{:else} {:else}
{#each role.permissions.slice(0, 6) as perm} {#each role.permissions.slice(0, 6) as perm}
<span <span class="text-[10px] bg-light/5 text-light/40 px-1.5 py-0.5 rounded-md">{perm}</span>
class="text-xs bg-light/10 text-light/50 px-2 py-1 rounded"
>{perm}</span
>
{/each} {/each}
{#if role.permissions.length > 6} {#if role.permissions.length > 6}
<span class="text-xs text-light/40" <span class="text-[10px] text-light/30">+{role.permissions.length - 6} more</span>
>+{role.permissions.length - 6} more</span
>
{/if} {/if}
{/if} {/if}
</div> </div>
</div> </div>
</Card>
{/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">
<Button variant="tertiary" fullWidth size="sm" icon="add" onclick={onAddCard}>
Add card Add card
</Button> </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

@@ -360,6 +360,100 @@ export type Database = {
}, },
] ]
} }
event_members: {
Row: {
assigned_at: string | null
event_id: string
id: string
role: string
user_id: string
}
Insert: {
assigned_at?: string | null
event_id: string
id?: string
role?: string
user_id: string
}
Update: {
assigned_at?: string | null
event_id?: string
id?: string
role?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: "event_members_event_id_fkey"
columns: ["event_id"]
isOneToOne: false
referencedRelation: "events"
referencedColumns: ["id"]
},
]
}
events: {
Row: {
color: string | null
cover_image_url: string | null
created_at: string | null
created_by: string | null
description: string | null
end_date: string | null
id: string
name: string
org_id: string
slug: string
start_date: string | null
status: string
updated_at: string | null
venue_address: string | null
venue_name: string | null
}
Insert: {
color?: string | null
cover_image_url?: string | null
created_at?: string | null
created_by?: string | null
description?: string | null
end_date?: string | null
id?: string
name: string
org_id: string
slug: string
start_date?: string | null
status?: string
updated_at?: string | null
venue_address?: string | null
venue_name?: string | null
}
Update: {
color?: string | null
cover_image_url?: string | null
created_at?: string | null
created_by?: string | null
description?: string | null
end_date?: string | null
id?: string
name?: string
org_id?: string
slug?: string
start_date?: string | null
status?: string
updated_at?: string | null
venue_address?: string | null
venue_name?: string | null
}
Relationships: [
{
foreignKeyName: "events_org_id_fkey"
columns: ["org_id"]
isOneToOne: false
referencedRelation: "organizations"
referencedColumns: ["id"]
},
]
}
kanban_boards: { kanban_boards: {
Row: { Row: {
created_at: string | null created_at: string | null
@@ -1226,3 +1320,5 @@ export type OrgGoogleCalendar = PublicTables['org_google_calendars']['Row']
export type ActivityLog = PublicTables['activity_log']['Row'] export type ActivityLog = PublicTables['activity_log']['Row']
export type UserPreferences = PublicTables['user_preferences']['Row'] export type UserPreferences = PublicTables['user_preferences']['Row']
export type MatrixCredentials = PublicTables['matrix_credentials']['Row'] export type MatrixCredentials = PublicTables['matrix_credentials']['Row']
export type EventRow = PublicTables['events']['Row']
export type EventMemberRow = PublicTables['event_members']['Row']

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"
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> </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">
<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()} {org.name.charAt(0).toUpperCase()}
</div> </div>
<span <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-xs px-2 py-1 bg-light/10 rounded text-light/60 capitalize"
>
{org.role}
</span>
</div> </div>
<h3 <h3 class="font-heading text-body-sm text-white group-hover:text-primary transition-colors">{org.name}</h3>
class="text-lg font-semibold text-light group-hover:text-primary transition-colors" <p class="text-[11px] text-light/30 mt-0.5 font-body">/{org.slug}</p>
>
{org.name}
</h3>
<p class="text-sm text-light/40 mt-1">
/{org.slug}
</p>
</div> </div>
</Card>
</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')
@@ -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) {
@@ -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";
@@ -123,6 +123,11 @@
}, },
] ]
: []), : []),
{
href: `/${data.org.slug}/events`,
label: m.nav_events(),
icon: "celebration",
},
{ {
href: `/${data.org.slug}/chat`, href: `/${data.org.slug}/chat`,
label: "Chat", label: "Chat",
@@ -340,21 +345,7 @@
</aside> </aside>
<!-- Main Content Area --> <!-- Main Content Area -->
<main class="flex-1 bg-night rounded-[32px] overflow-auto relative"> <main class="flex-1 bg-night rounded-[32px] overflow-hidden relative">
{#if $navigating}
{@const target = $navigating.to?.url.pathname ?? ""}
{@const skeletonVariant = target.includes("/kanban")
? "kanban"
: target.includes("/documents")
? "files"
: target.includes("/calendar")
? "calendar"
: target.includes("/settings")
? "settings"
: "default"}
<PageSkeleton variant={skeletonVariant} />
{:else}
{@render children()} {@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,322 +69,175 @@
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> <a
</header> href="/{data.org.slug}/events"
class="flex items-center gap-2 px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors"
>
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>celebration</span
>
{m.nav_events()}
</a>
{/if}
{/snippet}
</PageHeader>
<div class="flex-1 p-6 overflow-auto">
<!-- Stats Grid --> <!-- Stats Grid -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div class="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
{#each statCards as stat} <StatCard
{#if stat.href} label={m.overview_stat_events()}
<a value={stats.eventCount}
href={stat.href} icon="celebration"
class="bg-night rounded-2xl p-5 flex flex-col gap-3 hover:bg-night/80 transition-colors group" href="/{data.org.slug}/events"
> color="text-primary"
<div bg="bg-primary/10"
class="w-10 h-10 rounded-xl {stat.bg} flex items-center justify-center" />
> <StatCard
<span label={m.overview_stat_members()}
class="material-symbols-rounded {stat.color}" value={stats.memberCount}
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;" icon="group"
> href={isAdmin ? `/${data.org.slug}/settings` : null}
{stat.icon} color="text-blue-400"
</span> bg="bg-blue-400/10"
</div> />
<div> <StatCard
<p class="text-2xl font-bold text-white"> label={m.overview_stat_documents()}
{stat.value} value={stats.documentCount}
</p> icon="description"
<p class="text-body-sm text-light/50">{stat.label}</p> href="/{data.org.slug}/documents"
</div> color="text-emerald-400"
</a> bg="bg-emerald-400/10"
{:else} />
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3"> <StatCard
<div label={m.overview_stat_boards()}
class="w-10 h-10 rounded-xl {stat.bg} flex items-center justify-center" value={stats.kanbanCount}
> icon="view_kanban"
<span href="/{data.org.slug}/documents"
class="material-symbols-rounded {stat.color}" color="text-purple-400"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;" bg="bg-purple-400/10"
>
{stat.icon}
</span>
</div>
<div>
<p class="text-2xl font-bold text-white">
{stat.value}
</p>
<p class="text-body-sm text-light/50">{stat.label}</p>
</div>
</div>
{/if}
{/each}
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 flex-1 min-h-0">
<!-- Recent Activity -->
<div
class="lg:col-span-2 bg-night rounded-2xl p-5 flex flex-col gap-4 min-h-0"
>
<h2 class="text-h3 font-heading text-white">
{m.activity_title()}
</h2>
{#if recentActivity.length === 0}
<div
class="flex-1 flex flex-col items-center justify-center text-light/40 py-12"
>
<span
class="material-symbols-rounded mb-3"
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;"
>
history
</span>
<p class="text-body">{m.activity_empty()}</p>
</div>
{:else}
<div class="flex flex-col gap-1 overflow-auto flex-1">
{#each recentActivity as entry}
<div
class="flex items-start gap-3 px-3 py-2.5 rounded-xl hover:bg-dark/50 transition-colors"
>
<span
class="material-symbols-rounded {getActivityColor(
entry.action,
)} mt-0.5 shrink-0"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>
{getActivityIcon(entry.action)}
</span>
<div class="flex-1 min-w-0">
<p
class="text-body-sm text-light leading-relaxed"
>
{getActivityDescription(entry)}
</p>
<p class="text-[11px] text-light/40 mt-0.5">
{formatTimeAgo(entry.created_at)}
</p>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Sidebar: Quick Links + Members -->
<div class="flex flex-col gap-6">
<!-- Quick Links -->
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3">
<h2 class="text-h3 font-heading text-white">
{m.overview_quick_links()}
</h2>
<div class="flex flex-col gap-1">
{#each quickLinks as link}
<a
href={link.href}
class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-light hover:bg-dark/50 hover:text-white transition-colors"
>
<span
class="material-symbols-rounded text-light/50"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>
{link.icon}
</span>
<span class="text-body">{link.label}</span>
</a>
{/each}
</div>
</div>
<!-- Team Members Preview -->
<div class="bg-night rounded-2xl p-5 flex flex-col gap-3">
<div class="flex items-center justify-between">
<h2 class="text-h3 font-heading text-white">
{m.overview_stat_members()}
</h2>
<span class="text-body-sm text-light/40"
>{stats.memberCount}</span
>
</div>
<div class="flex flex-col gap-2">
{#each members.slice(0, 5) as member}
<div class="flex items-center gap-3 px-1 py-1">
<Avatar
name={member.profiles?.full_name ||
member.profiles?.email ||
"?"}
src={member.profiles?.avatar_url}
size="sm"
/> />
<div class="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 class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Left Column: Upcoming Events + Activity -->
<div class="lg:col-span-2 flex flex-col gap-6">
<!-- Upcoming Events -->
<SectionCard title={m.overview_upcoming_events()}>
{#snippet titleRight()}
<a
href="/{data.org.slug}/events"
class="text-[12px] text-primary hover:underline"
>{m.overview_view_all_events()}</a
>
{/snippet}
{#if upcomingEvents.length === 0}
<div class="flex flex-col items-center justify-center text-light/40 py-8">
<span
class="material-symbols-rounded mb-2"
style="font-size: 40px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 40;"
>celebration</span
>
<p class="text-body-sm">{m.overview_upcoming_empty()}</p>
</div> </div>
{:else}
<div class="flex flex-col gap-1">
{#each upcomingEvents as event}
<EventCard
name={event.name}
slug={event.slug}
status={event.status}
startDate={event.start_date}
endDate={event.end_date}
color={event.color}
venueName={event.venue_name}
href="/{data.org.slug}/events/{event.slug}"
compact
/>
{/each} {/each}
{#if stats.memberCount > 5} </div>
{/if}
</SectionCard>
<!-- Recent Activity -->
<SectionCard title={m.activity_title()}>
<ActivityFeed entries={recentActivity} />
</SectionCard>
</div>
<!-- Right Column: Quick Links + Team -->
<div class="flex flex-col gap-6">
<SectionCard title={m.overview_quick_links()}>
<QuickLinkGrid links={quickLinks} />
</SectionCard>
<SectionCard title={m.overview_stat_members()}>
{#snippet titleRight()}
<span class="text-[12px] text-light/30">{stats.memberCount}</span>
{/snippet}
<MemberList
{members}
max={6}
moreHref="/{data.org.slug}/settings"
moreLabel={m.overview_more_members({ count: String(Math.max(0, stats.memberCount - 6)) })}
/>
</SectionCard>
{#if isAdmin}
<a <a
href="/{data.org.slug}/settings" href="/{data.org.slug}/settings"
class="text-body-sm text-primary hover:underline text-center pt-1" class="flex items-center gap-3 bg-dark/30 border border-light/5 hover:border-light/10 rounded-xl p-4 transition-all group"
> >
+{stats.memberCount - 5} more <div class="w-10 h-10 rounded-xl bg-light/5 flex items-center justify-center">
<span
class="material-symbols-rounded text-light/40 group-hover:text-white transition-colors"
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 22;"
>settings</span
>
</div>
<div>
<p class="text-body-sm text-white group-hover:text-primary transition-colors">
{m.nav_settings()}
</p>
<p class="text-[11px] text-light/30">{m.settings_general_title()}</p>
</div>
</a> </a>
{/if} {/if}
</div> </div>
</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

@@ -227,25 +227,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">
@@ -314,8 +306,8 @@
</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 +325,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 +363,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 +388,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 +418,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 +452,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>
<span class="flex-1 font-heading text-light text-base">Messages</span>
<button <button
class="text-light hover:text-primary transition-colors" class="p-1 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
onclick={() => (showStartDMModal = true)} onclick={() => (showStartDMModal = true)}
title="New message" title="New message"
> >
<span class="material-symbols-rounded" style="font-size: 20px;">add</span> <span class="material-symbols-rounded" style="font-size: 18px;">add</span>
</button> </button>
</div> </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

@@ -0,0 +1,29 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { fetchEvents } from '$lib/api/events';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.events');
export const load: PageServerLoad = async ({ params, locals, url }) => {
const { session, user } = await locals.safeGetSession();
if (!session || !user) error(401, 'Unauthorized');
const { data: org } = await locals.supabase
.from('organizations')
.select('id')
.eq('slug', params.orgSlug)
.single();
if (!org) error(404, 'Organization not found');
const statusFilter = url.searchParams.get('status') || 'all';
try {
const events = await fetchEvents(locals.supabase, org.id, statusFilter);
return { events, statusFilter };
} catch (e: any) {
log.error('Failed to load events', { error: e, data: { orgId: org.id } });
return { events: [], statusFilter };
}
};

View File

@@ -0,0 +1,365 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { EventCard, TabBar, Button } from "$lib/components/ui";
import { getContext } from "svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import { toasts } from "$lib/stores/ui";
import * as m from "$lib/paraglide/messages";
interface EventItem {
id: string;
org_id: string;
name: string;
slug: string;
description: string | null;
status: "planning" | "active" | "completed" | "archived";
start_date: string | null;
end_date: string | null;
venue_name: string | null;
color: string | null;
member_count: number;
}
interface Props {
data: {
org: { id: string; name: string; slug: string };
userRole: string;
events: EventItem[];
statusFilter: string;
};
}
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
const isEditor = $derived(
["owner", "admin", "editor"].includes(data.userRole),
);
// Create event modal
let showCreateModal = $state(false);
let newEventName = $state("");
let newEventDescription = $state("");
let newEventStartDate = $state("");
let newEventEndDate = $state("");
let newEventVenue = $state("");
let newEventColor = $state("#00A3E0");
let creating = $state(false);
const statusTabs = $derived([
{ value: "all", label: m.events_tab_all(), icon: "apps" },
{ value: "planning", label: m.events_tab_planning(), icon: "edit_note" },
{ value: "active", label: m.events_tab_active(), icon: "play_circle" },
{ value: "completed", label: m.events_tab_completed(), icon: "check_circle" },
{ value: "archived", label: m.events_tab_archived(), icon: "archive" },
]);
const presetColors = [
"#00A3E0",
"#8B5CF6",
"#EC4899",
"#F59E0B",
"#10B981",
"#EF4444",
"#6366F1",
"#14B8A6",
];
async function handleCreate() {
if (!newEventName.trim()) return;
creating = true;
try {
const { data: created, error } = await (supabase as any)
.from("events")
.insert({
org_id: data.org.id,
name: newEventName.trim(),
slug: newEventName
.trim()
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/[\s_]+/g, "-")
.replace(/-+/g, "-")
.slice(0, 60) || "event",
description: newEventDescription.trim() || null,
start_date: newEventStartDate || null,
end_date: newEventEndDate || null,
venue_name: newEventVenue.trim() || null,
color: newEventColor,
created_by: (await supabase.auth.getUser()).data.user?.id,
})
.select()
.single();
if (error) throw error;
toasts.success(m.events_created({ name: created.name }));
showCreateModal = false;
resetForm();
goto(`/${data.org.slug}/events/${created.slug}`);
} catch (e: any) {
toasts.error(e.message || "Failed to create event");
} finally {
creating = false;
}
}
function resetForm() {
newEventName = "";
newEventDescription = "";
newEventStartDate = "";
newEventEndDate = "";
newEventVenue = "";
newEventColor = "#00A3E0";
}
function switchStatus(status: string) {
const url = new URL($page.url);
if (status === "all") {
url.searchParams.delete("status");
} else {
url.searchParams.set("status", status);
}
goto(url.toString(), { replaceState: true, invalidateAll: true });
}
</script>
<svelte:head>
<title>{m.events_title()} | {data.org.name}</title>
</svelte:head>
<div class="flex flex-col h-full">
<!-- Toolbar: Status Tabs + Create Button -->
<div class="flex items-center justify-between px-6 py-3 border-b border-light/5 shrink-0">
<div class="flex items-center gap-1">
{#each statusTabs as tab}
<button
type="button"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-body-sm font-body transition-colors {data.statusFilter ===
tab.value
? 'bg-primary text-background'
: 'text-light/50 hover:text-white hover:bg-dark/50'}"
onclick={() => switchStatus(tab.value)}
>
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>{tab.icon}</span
>
{tab.label}
</button>
{/each}
</div>
{#if isEditor}
<Button size="sm" icon="add" onclick={() => (showCreateModal = true)}>
{m.events_new()}
</Button>
{/if}
</div>
<!-- Events Grid -->
<div class="flex-1 overflow-auto p-6">
{#if data.events.length === 0}
<div class="flex flex-col items-center justify-center h-full text-light/40">
<span
class="material-symbols-rounded mb-4"
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
>celebration</span
>
<p class="text-h3 font-heading mb-2">{m.events_empty_title()}</p>
<p class="text-body text-light/30">{m.events_empty_desc()}</p>
{#if isEditor}
<div class="mt-4">
<Button icon="add" onclick={() => (showCreateModal = true)}>
{m.events_create()}
</Button>
</div>
{/if}
</div>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{#each data.events as event}
<EventCard
name={event.name}
slug={event.slug}
status={event.status}
startDate={event.start_date}
endDate={event.end_date}
color={event.color}
venueName={event.venue_name}
href="/{data.org.slug}/events/{event.slug}"
/>
{/each}
</div>
{/if}
</div>
</div>
<!-- Create Event Modal -->
{#if showCreateModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div
class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
onkeydown={(e) => e.key === "Escape" && (showCreateModal = false)}
onclick={(e) => e.target === e.currentTarget && (showCreateModal = false)}
role="dialog"
aria-modal="true"
aria-label={m.events_create()}
>
<div
class="bg-night rounded-2xl w-full max-w-lg shadow-2xl border border-light/10"
>
<div class="flex items-center justify-between p-5 border-b border-light/5">
<h2 class="text-h3 font-heading text-white">{m.events_create()}</h2>
<button
type="button"
class="text-light/40 hover:text-white transition-colors"
onclick={() => (showCreateModal = false)}
aria-label={m.btn_close()}
>
<span
class="material-symbols-rounded"
style="font-size: 24px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;"
>close</span
>
</button>
</div>
<form
class="p-5 flex flex-col gap-4"
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
>
<!-- Name -->
<div class="flex flex-col gap-1.5">
<label
for="event-name"
class="text-body-sm text-light/60 font-body"
>{m.events_form_name()}</label
>
<input
id="event-name"
type="text"
bind:value={newEventName}
placeholder={m.events_form_name_placeholder()}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
required
/>
</div>
<!-- Description -->
<div class="flex flex-col gap-1.5">
<label
for="event-desc"
class="text-body-sm text-light/60 font-body"
>{m.events_form_description()}</label
>
<textarea
id="event-desc"
bind:value={newEventDescription}
placeholder={m.events_form_description_placeholder()}
rows="2"
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body text-white placeholder:text-light/30 focus:outline-none focus:border-primary resize-none"
></textarea>
</div>
<!-- Dates -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<label
for="event-start"
class="text-body-sm text-light/60 font-body"
>{m.events_form_start_date()}</label
>
<input
id="event-start"
type="date"
bind:value={newEventStartDate}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body text-white focus:outline-none focus:border-primary"
/>
</div>
<div class="flex flex-col gap-1.5">
<label
for="event-end"
class="text-body-sm text-light/60 font-body"
>{m.events_form_end_date()}</label
>
<input
id="event-end"
type="date"
bind:value={newEventEndDate}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body text-white focus:outline-none focus:border-primary"
/>
</div>
</div>
<!-- Venue -->
<div class="flex flex-col gap-1.5">
<label
for="event-venue"
class="text-body-sm text-light/60 font-body"
>{m.events_form_venue()}</label
>
<input
id="event-venue"
type="text"
bind:value={newEventVenue}
placeholder={m.events_form_venue_placeholder()}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
</div>
<!-- Color -->
<div class="flex flex-col gap-1.5">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="text-body-sm text-light/60 font-body"
>{m.events_form_color()}</label
>
<div class="flex items-center gap-2">
{#each presetColors as color}
<button
type="button"
class="w-7 h-7 rounded-full border-2 transition-all {newEventColor ===
color
? 'border-white scale-110'
: 'border-transparent hover:border-light/30'}"
style="background-color: {color}"
onclick={() => (newEventColor = color)}
aria-label={m.events_form_select_color({ color })}
></button>
{/each}
</div>
</div>
<!-- Actions -->
<div
class="flex items-center justify-end gap-3 pt-2 border-t border-light/5"
>
<button
type="button"
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => {
showCreateModal = false;
resetForm();
}}
>
{m.btn_cancel()}
</button>
<button
type="submit"
disabled={!newEventName.trim() || creating}
class="px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{creating ? m.events_creating() : m.events_create()}
</button>
</div>
</form>
</div>
</div>
{/if}

View File

@@ -0,0 +1,27 @@
import { error } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { fetchEventBySlug, fetchEventMembers } from '$lib/api/events';
import { createLogger } from '$lib/utils/logger';
const log = createLogger('page.event-detail');
export const load: LayoutServerLoad = async ({ params, locals, parent }) => {
const { session, user } = await locals.safeGetSession();
if (!session || !user) error(401, 'Unauthorized');
const parentData = await parent() as { org: { id: string; name: string; slug: string } };
const orgId = parentData.org.id;
try {
const event = await fetchEventBySlug(locals.supabase, orgId, params.eventSlug);
if (!event) error(404, 'Event not found');
const members = await fetchEventMembers(locals.supabase, event.id);
return { event, eventMembers: members };
} catch (e: any) {
if (e?.status === 404) throw e;
log.error('Failed to load event', { error: e, data: { orgId, eventSlug: params.eventSlug } });
error(500, 'Failed to load event');
}
};

View File

@@ -0,0 +1,203 @@
<script lang="ts">
import { page } from "$app/stores";
import { Avatar } from "$lib/components/ui";
import type { Snippet } from "svelte";
import type { Event, EventMember } from "$lib/api/events";
import * as m from "$lib/paraglide/messages";
interface Props {
data: {
org: { id: string; name: string; slug: string };
userRole: string;
event: Event;
eventMembers: (EventMember & {
profile?: {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
};
})[];
};
children: Snippet;
}
let { data, children }: Props = $props();
const basePath = $derived(
`/${data.org.slug}/events/${data.event.slug}`,
);
const modules = $derived([
{
href: basePath,
label: m.events_overview(),
icon: "dashboard",
exact: true,
},
{
href: `${basePath}/tasks`,
label: m.events_mod_tasks(),
icon: "task_alt",
},
{
href: `${basePath}/files`,
label: m.events_mod_files(),
icon: "folder",
},
{
href: `${basePath}/schedule`,
label: m.events_mod_schedule(),
icon: "calendar_today",
},
{
href: `${basePath}/budget`,
label: m.events_mod_budget(),
icon: "account_balance_wallet",
},
{
href: `${basePath}/guests`,
label: m.events_mod_guests(),
icon: "groups",
},
{
href: `${basePath}/team`,
label: m.events_mod_team(),
icon: "badge",
},
{
href: `${basePath}/sponsors`,
label: m.events_mod_sponsors(),
icon: "handshake",
},
]);
function isModuleActive(href: string, exact?: boolean): boolean {
if (exact) return $page.url.pathname === href;
return $page.url.pathname.startsWith(href);
}
function getStatusColor(status: string): string {
const map: Record<string, string> = {
planning: "bg-amber-400",
active: "bg-emerald-400",
completed: "bg-blue-400",
archived: "bg-light/40",
};
return map[status] ?? "bg-light/40";
}
function formatDateCompact(dateStr: string | null): string {
if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
</script>
<div class="flex h-full">
<!-- Event Module Sidebar -->
<aside
class="w-56 shrink-0 bg-dark/30 border-r border-light/5 flex flex-col overflow-hidden"
>
<!-- Event Header -->
<div class="p-4 border-b border-light/5">
<div class="flex items-center gap-2 mb-2">
<div
class="w-2.5 h-2.5 rounded-full shrink-0 {getStatusColor(
data.event.status,
)}"
></div>
<h2
class="text-body font-heading text-white truncate"
title={data.event.name}
>
{data.event.name}
</h2>
</div>
{#if data.event.start_date}
<p class="text-[11px] text-light/40 flex items-center gap-1">
<span
class="material-symbols-rounded"
style="font-size: 12px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 12;"
>calendar_today</span
>
{formatDateCompact(data.event.start_date)}{data.event
.end_date
? ` — ${formatDateCompact(data.event.end_date)}`
: ""}
</p>
{/if}
</div>
<!-- Module Navigation -->
<nav class="flex-1 flex flex-col gap-0.5 p-2 overflow-auto">
{#each modules as mod}
<a
href={mod.href}
class="flex items-center gap-2.5 px-3 py-2 rounded-xl text-body-sm font-body transition-colors {isModuleActive(
mod.href,
mod.exact,
)
? 'bg-primary text-background'
: 'text-light/60 hover:text-white hover:bg-dark/50'}"
>
<span
class="material-symbols-rounded"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>{mod.icon}</span
>
{mod.label}
</a>
{/each}
</nav>
<!-- Event Team Preview -->
<div class="p-3 border-t border-light/5">
<p class="text-[11px] text-light/40 mb-2 px-1">
{m.events_team_count({ count: String(data.eventMembers.length) })}
</p>
<div class="flex flex-wrap gap-1 px-1">
{#each data.eventMembers.slice(0, 8) as member}
<div title={member.profile?.full_name || member.profile?.email || "Member"}>
<Avatar
name={member.profile?.full_name ||
member.profile?.email ||
"?"}
src={member.profile?.avatar_url}
size="xs"
/>
</div>
{/each}
{#if data.eventMembers.length > 8}
<div
class="w-6 h-6 rounded-full bg-dark flex items-center justify-center text-[10px] text-light/50"
>
+{data.eventMembers.length - 8}
</div>
{/if}
</div>
</div>
<!-- Back link -->
<a
href="/{data.org.slug}/events"
class="flex items-center gap-2 px-4 py-3 border-t border-light/5 text-body-sm text-light/40 hover:text-white transition-colors"
>
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>arrow_back</span
>
{m.events_all_events()}
</a>
</aside>
<!-- Module Content -->
<div class="flex-1 overflow-auto">
{#if children}
{@render children()}
{/if}
</div>
</div>

View File

@@ -0,0 +1,549 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { ModuleCard, SectionCard, StatusBadge } from "$lib/components/ui";
import { getContext } from "svelte";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "$lib/supabase/types";
import { toasts } from "$lib/stores/ui";
import type { Event, EventMember } from "$lib/api/events";
import * as m from "$lib/paraglide/messages";
interface Props {
data: {
org: { id: string; name: string; slug: string };
userRole: string;
event: Event;
eventMembers: (EventMember & {
profile?: {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
};
})[];
};
}
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
const isEditor = $derived(
["owner", "admin", "editor"].includes(data.userRole),
);
// Edit mode
let editing = $state(false);
let editName = $state("");
let editDescription = $state("");
let editStatus = $state<string>("planning");
let editStartDate = $state("");
let editEndDate = $state("");
let editVenueName = $state("");
let editVenueAddress = $state("");
let saving = $state(false);
// Sync edit fields when data changes or edit mode opens
$effect(() => {
if (editing) {
editName = data.event.name;
editDescription = data.event.description ?? "";
editStatus = data.event.status;
editStartDate = data.event.start_date ?? "";
editEndDate = data.event.end_date ?? "";
editVenueName = data.event.venue_name ?? "";
editVenueAddress = data.event.venue_address ?? "";
}
});
// Delete confirmation
let showDeleteConfirm = $state(false);
let deleting = $state(false);
const basePath = $derived(
`/${data.org.slug}/events/${data.event.slug}`,
);
const statusOptions = $derived([
{ value: "planning", label: m.events_status_planning(), icon: "edit_note", color: "text-amber-400" },
{ value: "active", label: m.events_status_active(), icon: "play_circle", color: "text-emerald-400" },
{ value: "completed", label: m.events_status_completed(), icon: "check_circle", color: "text-blue-400" },
{ value: "archived", label: m.events_status_archived(), icon: "archive", color: "text-light/40" },
]);
const moduleCards = $derived([
{
href: `${basePath}/tasks`,
label: m.events_mod_tasks(),
icon: "task_alt",
description: m.events_mod_tasks_desc(),
color: "text-emerald-400",
bg: "bg-emerald-400/10",
},
{
href: `${basePath}/files`,
label: m.events_mod_files(),
icon: "folder",
description: m.events_mod_files_desc(),
color: "text-blue-400",
bg: "bg-blue-400/10",
},
{
href: `${basePath}/schedule`,
label: m.events_mod_schedule(),
icon: "calendar_today",
description: m.events_mod_schedule_desc(),
color: "text-purple-400",
bg: "bg-purple-400/10",
},
{
href: `${basePath}/budget`,
label: m.events_mod_budget(),
icon: "account_balance_wallet",
description: m.events_mod_budget_desc(),
color: "text-amber-400",
bg: "bg-amber-400/10",
},
{
href: `${basePath}/guests`,
label: m.events_mod_guests(),
icon: "groups",
description: m.events_mod_guests_desc(),
color: "text-pink-400",
bg: "bg-pink-400/10",
},
{
href: `${basePath}/team`,
label: m.events_mod_team(),
icon: "badge",
description: m.events_mod_team_desc(),
color: "text-teal-400",
bg: "bg-teal-400/10",
},
{
href: `${basePath}/sponsors`,
label: m.events_mod_sponsors(),
icon: "handshake",
description: m.events_mod_sponsors_desc(),
color: "text-orange-400",
bg: "bg-orange-400/10",
},
]);
function formatDate(dateStr: string | null): string {
if (!dateStr) return m.events_not_set();
return new Date(dateStr).toLocaleDateString(undefined, {
weekday: "short",
month: "long",
day: "numeric",
year: "numeric",
});
}
function daysUntilEvent(): string {
if (!data.event.start_date) return "";
const now = new Date();
const start = new Date(data.event.start_date);
const diff = Math.ceil(
(start.getTime() - now.getTime()) / (1000 * 60 * 60 * 24),
);
if (diff < 0) return m.events_days_ago({ count: String(Math.abs(diff)) });
if (diff === 0) return m.events_today();
if (diff === 1) return m.events_tomorrow();
return m.events_in_days({ count: String(diff) });
}
async function handleSave() {
saving = true;
try {
const { error } = await (supabase as any)
.from("events")
.update({
name: editName.trim(),
description: editDescription.trim() || null,
status: editStatus,
start_date: editStartDate || null,
end_date: editEndDate || null,
venue_name: editVenueName.trim() || null,
venue_address: editVenueAddress.trim() || null,
updated_at: new Date().toISOString(),
})
.eq("id", data.event.id);
if (error) throw error;
toasts.success(m.events_updated());
editing = false;
// Refresh the page data
goto(`/${data.org.slug}/events/${data.event.slug}`, {
invalidateAll: true,
});
} catch (e: any) {
toasts.error(e.message || "Failed to update event");
} finally {
saving = false;
}
}
async function handleDelete() {
deleting = true;
try {
const { error } = await (supabase as any)
.from("events")
.delete()
.eq("id", data.event.id);
if (error) throw error;
toasts.success(m.events_deleted());
goto(`/${data.org.slug}/events`);
} catch (e: any) {
toasts.error(e.message || "Failed to delete event");
} finally {
deleting = false;
}
}
</script>
<svelte:head>
<title>{data.event.name} | {data.org.name}</title>
</svelte:head>
<div class="flex flex-col h-full overflow-auto">
<!-- Event Header -->
<header class="px-6 py-5 border-b border-light/5">
<div class="flex items-start justify-between">
<div class="flex-1">
{#if editing}
<input
type="text"
bind:value={editName}
class="text-h1 font-heading text-white bg-transparent border-b border-primary focus:outline-none w-full"
/>
{:else}
<div class="flex items-center gap-3">
<div
class="w-4 h-4 rounded-full shrink-0"
style="background-color: {data.event.color ||
'#00A3E0'}"
></div>
<h1 class="text-h1 font-heading text-white">
{data.event.name}
</h1>
</div>
{/if}
<div
class="flex items-center gap-4 mt-2 text-body-sm text-light/50"
>
{#if editing}
<select
bind:value={editStatus}
class="bg-dark border border-light/10 rounded-lg px-2 py-1 text-body-sm text-white focus:outline-none"
>
{#each statusOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{:else}
<span
class="capitalize flex items-center gap-1 {statusOptions.find(
(s) => s.value === data.event.status,
)?.color ?? 'text-light/40'}"
>
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>{statusOptions.find(
(s) => s.value === data.event.status,
)?.icon ?? "help"}</span
>
{data.event.status}
</span>
{/if}
{#if data.event.start_date && !editing}
<span class="flex items-center gap-1">
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>calendar_today</span
>
{formatDate(data.event.start_date)}
</span>
{@const countdown = daysUntilEvent()}
{#if countdown}
<span class="text-primary font-bold"
>{countdown}</span
>
{/if}
{/if}
{#if data.event.venue_name && !editing}
<span class="flex items-center gap-1">
<span
class="material-symbols-rounded"
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
>location_on</span
>
{data.event.venue_name}
</span>
{/if}
</div>
</div>
{#if isEditor}
<div class="flex items-center gap-2">
{#if editing}
<button
type="button"
class="px-3 py-1.5 text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => (editing = false)}
>
{m.btn_cancel()}
</button>
<button
type="button"
class="px-3 py-1.5 bg-primary text-background rounded-lg text-body-sm hover:bg-primary-hover transition-colors disabled:opacity-50"
disabled={saving}
onclick={handleSave}
>
{saving ? m.events_saving() : m.btn_save()}
</button>
{:else}
<button
type="button"
class="p-2 text-light/40 hover:text-white transition-colors rounded-lg hover:bg-dark/50"
title={m.btn_edit()}
onclick={() => (editing = true)}
>
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>edit</span
>
</button>
<button
type="button"
class="p-2 text-light/40 hover:text-error transition-colors rounded-lg hover:bg-error/10"
title={m.btn_delete()}
onclick={() => (showDeleteConfirm = true)}
>
<span
class="material-symbols-rounded"
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
>delete</span
>
</button>
{/if}
</div>
{/if}
</div>
</header>
<!-- Edit fields (when editing) -->
{#if editing}
<div class="px-6 py-4 border-b border-light/5 flex flex-col gap-3">
<textarea
bind:value={editDescription}
placeholder="Event description..."
rows="2"
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body text-white placeholder:text-light/30 focus:outline-none focus:border-primary resize-none w-full"
></textarea>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<input
type="date"
bind:value={editStartDate}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
placeholder="Start date"
/>
<input
type="date"
bind:value={editEndDate}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
placeholder="End date"
/>
<input
type="text"
bind:value={editVenueName}
placeholder={m.events_form_venue_placeholder()}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
<input
type="text"
bind:value={editVenueAddress}
placeholder={m.events_form_venue_address_placeholder()}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
/>
</div>
</div>
{/if}
<!-- Overview Content -->
<div class="flex-1 p-6 overflow-auto">
<!-- Description -->
{#if data.event.description && !editing}
<p class="text-body text-light/60 mb-6 max-w-2xl">
{data.event.description}
</p>
{/if}
<!-- Module Cards Grid -->
<h2 class="text-h3 font-heading text-white mb-4">{m.events_modules()}</h2>
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3"
>
{#each moduleCards as mod}
<ModuleCard
label={mod.label}
description={mod.description}
icon={mod.icon}
href={mod.href}
color={mod.color}
bg={mod.bg}
/>
{/each}
</div>
<!-- Event Details Section -->
<div class="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Info Card -->
<SectionCard title={m.events_details()}>
<div class="flex flex-col gap-2.5">
<div class="flex items-center gap-3">
<span
class="material-symbols-rounded text-light/30"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>calendar_today</span
>
<div>
<p class="text-[11px] text-light/40">
{m.events_start_date()}
</p>
<p class="text-body-sm text-white">
{formatDate(data.event.start_date)}
</p>
</div>
</div>
<div class="flex items-center gap-3">
<span
class="material-symbols-rounded text-light/30"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>event</span
>
<div>
<p class="text-[11px] text-light/40">{m.events_end_date()}</p>
<p class="text-body-sm text-white">
{formatDate(data.event.end_date)}
</p>
</div>
</div>
{#if data.event.venue_name}
<div class="flex items-center gap-3">
<span
class="material-symbols-rounded text-light/30"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>location_on</span
>
<div>
<p class="text-[11px] text-light/40">{m.events_venue()}</p>
<p class="text-body-sm text-white">
{data.event.venue_name}
</p>
{#if data.event.venue_address}
<p class="text-[11px] text-light/40">
{data.event.venue_address}
</p>
{/if}
</div>
</div>
{/if}
</div>
</SectionCard>
<!-- Team Card -->
<SectionCard title={m.events_team_count({ count: String(data.eventMembers.length) })}>
{#snippet titleRight()}
<a
href="{basePath}/team"
class="text-[12px] text-primary hover:underline"
>{m.events_team_manage()}</a
>
{/snippet}
<div class="flex flex-col gap-2">
{#each data.eventMembers.slice(0, 6) as member}
<div class="flex items-center gap-2.5">
<div class="w-7 h-7 rounded-full bg-primary/20 flex items-center justify-center text-[11px] font-bold text-primary shrink-0">
{(member.profile?.full_name || member.profile?.email || "?").charAt(0).toUpperCase()}
</div>
<div class="flex-1 min-w-0">
<p class="text-body-sm text-white truncate">
{member.profile?.full_name ||
member.profile?.email ||
"Unknown"}
</p>
<p class="text-[11px] text-light/40 capitalize">
{member.role}
</p>
</div>
</div>
{/each}
{#if data.eventMembers.length > 6}
<a
href="{basePath}/team"
class="text-body-sm text-primary hover:underline text-center pt-1"
>
{m.events_more_members({ count: String(data.eventMembers.length - 6) })}
</a>
{/if}
{#if data.eventMembers.length === 0}
<p class="text-body-sm text-light/30 text-center py-4">
{m.events_team_empty()}
</p>
{/if}
</div>
</SectionCard>
</div>
</div>
</div>
<!-- Delete Confirmation -->
{#if showDeleteConfirm}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div
class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
onkeydown={(e) => e.key === "Escape" && (showDeleteConfirm = false)}
onclick={(e) =>
e.target === e.currentTarget && (showDeleteConfirm = false)}
role="dialog"
aria-modal="true"
aria-label={m.events_delete_title()}
>
<div
class="bg-night rounded-2xl w-full max-w-sm shadow-2xl border border-light/10 p-6"
>
<h2 class="text-h3 font-heading text-white mb-2">{m.events_delete_title()}</h2>
<p class="text-body-sm text-light/50 mb-6">
{m.events_delete_desc({ name: data.event.name })}
</p>
<div class="flex items-center justify-end gap-3">
<button
type="button"
class="px-4 py-2 text-body-sm text-light/60 hover:text-white transition-colors"
onclick={() => (showDeleteConfirm = false)}
>
{m.btn_cancel()}
</button>
<button
type="button"
class="px-4 py-2 bg-error text-white rounded-xl text-body-sm hover:bg-error/80 transition-colors disabled:opacity-50"
disabled={deleting}
onclick={handleDelete}
>
{deleting ? m.events_deleting() : m.events_delete_confirm()}
</button>
</div>
</div>
</div>
{/if}

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,490 @@
<script lang="ts">
import { Avatar, Button, Modal, Select } 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, EventMember } 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;
} | null;
}
interface Props {
data: {
org: { id: string; name: string; slug: string };
userRole: string;
members: OrgMember[];
event: Event;
eventMembers: (EventMember & {
profile?: {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
};
})[];
};
}
let { data }: Props = $props();
const supabase = getContext<SupabaseClient<Database>>("supabase");
const isEditor = $derived(
["owner", "admin", "editor"].includes(data.userRole),
);
// Local mutable copy of event members
let teamMembers = $state(data.eventMembers);
// Sync when data changes (e.g. after invalidation)
$effect(() => {
teamMembers = data.eventMembers;
});
// Add member modal
let showAddModal = $state(false);
let selectedUserId = $state("");
let selectedRole = $state<"lead" | "manager" | "member">("member");
let adding = $state(false);
// Remove confirmation
let memberToRemove = $state<(typeof teamMembers)[0] | null>(null);
let removing = $state(false);
// Edit role
let editingMember = $state<(typeof teamMembers)[0] | null>(null);
let editRole = $state<"lead" | "manager" | "member">("member");
let updatingRole = $state(false);
// Org members not yet on the team
const availableMembers = $derived(
data.members.filter(
(om) => !teamMembers.some((tm) => tm.user_id === om.user_id),
),
);
const roleOptions = $derived([
{ value: "lead", label: m.team_role_lead() },
{ value: "manager", label: m.team_role_manager() },
{ value: "member", label: m.team_role_member() },
]);
function getRoleColor(role: string): string {
const map: Record<string, string> = {
lead: "text-amber-400 bg-amber-400/10",
manager: "text-purple-400 bg-purple-400/10",
member: "text-light/50 bg-light/5",
};
return map[role] ?? "text-light/50 bg-light/5";
}
function getMemberName(member: (typeof teamMembers)[0]): string {
return member.profile?.full_name || member.profile?.email || "Unknown";
}
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: selectedRole,
},
{ onConflict: "event_id,user_id" },
)
.select()
.single();
if (error) throw error;
// Find profile from org members
const orgMember = data.members.find(
(m) => m.user_id === selectedUserId,
);
const profile = orgMember?.profiles ?? undefined;
teamMembers = [
...teamMembers,
{ ...inserted, profile },
];
const name = profile?.full_name || profile?.email || "Member";
toasts.success(m.team_added({ name }));
showAddModal = false;
selectedUserId = "";
selectedRole = "member";
} catch (e: any) {
toasts.error(e.message || "Failed to add member");
} finally {
adding = false;
}
}
async function handleRemove() {
if (!memberToRemove) return;
removing = true;
try {
const { error } = await (supabase as any)
.from("event_members")
.delete()
.eq("event_id", data.event.id)
.eq("user_id", memberToRemove.user_id);
if (error) throw error;
const name = getMemberName(memberToRemove);
teamMembers = teamMembers.filter(
(m) => m.user_id !== memberToRemove!.user_id,
);
toasts.success(m.team_removed({ name }));
memberToRemove = null;
} catch (e: any) {
toasts.error(e.message || "Failed to remove member");
} finally {
removing = false;
}
}
async function handleRoleUpdate() {
if (!editingMember) return;
updatingRole = true;
try {
const { error } = await (supabase as any)
.from("event_members")
.update({ role: editRole })
.eq("event_id", data.event.id)
.eq("user_id", editingMember.user_id);
if (error) throw error;
teamMembers = teamMembers.map((m) =>
m.user_id === editingMember!.user_id
? { ...m, role: editRole }
: m,
);
toasts.success(m.team_updated());
editingMember = null;
} catch (e: any) {
toasts.error(e.message || "Failed to update role");
} finally {
updatingRole = false;
}
}
function openEditRole(member: (typeof teamMembers)[0]) {
editingMember = member;
editRole = member.role;
}
</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 p-6">
<div class="max-w-2xl w-full mx-auto flex flex-col gap-4">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h2 class="font-heading text-body text-white">{m.team_title()}</h2>
<p class="text-body-sm text-light/40 mt-0.5">{m.team_subtitle()}</p>
</div>
{#if isEditor}
<Button
size="sm"
icon="person_add"
onclick={() => (showAddModal = true)}
disabled={availableMembers.length === 0}
>
{m.team_add_member()}
</Button>
{/if}
</div>
<!-- Team List -->
{#if teamMembers.length === 0}
<div
class="flex flex-col items-center justify-center py-16 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}
<div
class="bg-dark/30 border border-light/5 rounded-xl overflow-hidden"
>
<div class="divide-y divide-light/5">
{#each teamMembers as member}
<div
class="flex items-center justify-between px-4 py-3 hover:bg-light/5 transition-colors"
>
<div class="flex items-center gap-3">
<Avatar
name={member.profile?.full_name ||
member.profile?.email ||
"?"}
src={member.profile?.avatar_url}
size="sm"
/>
<div>
<p class="text-body-sm text-white">
{member.profile?.full_name ||
member.profile?.email ||
"Unknown"}
</p>
<p class="text-[11px] text-light/40">
{member.profile?.email || ""}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<span
class="px-2 py-0.5 text-[10px] rounded-md capitalize font-body {getRoleColor(
member.role,
)}">{member.role}</span
>
{#if isEditor}
<button
type="button"
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
onclick={() => openEditRole(member)}
title="Change role"
>
<span
class="material-symbols-rounded"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>swap_horiz</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>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>
<!-- Add Member Modal -->
<Modal
isOpen={showAddModal}
onClose={() => (showAddModal = false)}
title={m.team_add_member()}
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label
for="team-member-select"
class="text-body-sm text-light/60 font-body"
>{m.team_select_member()}</label
>
<select
id="team-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>
<div class="flex flex-col gap-1.5">
<label
for="team-role-select"
class="text-body-sm text-light/60 font-body"
>{m.team_select_role()}</label
>
<select
id="team-role-select"
bind:value={selectedRole}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
>
{#each roleOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</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 = "";
selectedRole = "member";
}}
>
{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 Role Modal -->
<Modal
isOpen={!!editingMember}
onClose={() => (editingMember = null)}
title="Change Role"
>
{#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 capitalize">
{editingMember.role}
</p>
</div>
</div>
<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={editRole}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
>
{#each roleOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</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={updatingRole || editRole === editingMember.role}
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={handleRoleUpdate}
>
{updatingRole ? "..." : m.btn_save()}
</button>
</div>
</div>
{/if}
</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>
<!-- 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> </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,32 +274,23 @@
<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]">
<Avatar name="Settings" size="md" />
<h1 class="flex-1 font-heading text-h1 text-white">
{m.settings_title()}
</h1>
<IconButton title="More options">
<Icon name="more_horiz" size={24} />
</IconButton>
</header>
<!-- Pill Tab Navigation -->
<div class="flex flex-wrap gap-4">
{#each tabs as tab} {#each tabs as tab}
<Button <button
variant={activeTab === tab.id ? "primary" : "secondary"} type="button"
size="md" class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-body-sm font-body transition-colors {activeTab === tab.id
? 'bg-primary text-background'
: 'text-light/50 hover:text-white hover:bg-dark/50'}"
onclick={() => (activeTab = tab.id)} onclick={() => (activeTab = tab.id)}
> >
{tab.label} {tab.label}
</Button> </button>
{/each} {/each}
</div> </div>
</div>
<div class="flex-1 overflow-auto p-6">
<!-- General Tab --> <!-- General Tab -->
{#if activeTab === "general"} {#if activeTab === "general"}
@@ -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-4 block" class="material-symbols-rounded text-light/20 mb-3 block"
style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 48;" style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
>label</span >label</span
> >
<p class="text-light/50">{m.settings_tags_empty()}</p> <p class="text-body-sm text-light/40">{m.settings_tags_empty()}</p>
</div> </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-8 h-8 rounded-lg flex items-center justify-center" class="w-7 h-7 rounded-lg flex items-center justify-center shrink-0"
style="background-color: {tag.color || style="background-color: {tag.color || '#00A3E0'}"
'#00A3E0'}"
> >
<span <span
class="material-symbols-rounded text-night" class="material-symbols-rounded text-night"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 600, 'GRAD' 0, 'opsz' 18;" style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 600, 'GRAD' 0, 'opsz' 16;"
>label</span >label</span
> >
</div> </div>
<div> <div>
<p class="text-light font-medium"> <p class="text-body-sm text-white font-medium">
{tag.name} {tag.name}
</p> </p>
<p class="text-xs text-light/40"> <p class="text-[11px] text-light/30">
{tag.color || "#00A3E0"} {tag.color || "#00A3E0"}
</p> </p>
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-1.5">
<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={() => openTagModal(tag)} onclick={() => openTagModal(tag)}
>Edit</Button title="Edit"
> >
<Button <span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">edit</span>
variant="danger" </button>
size="sm" <button
type="button"
class="p-1.5 text-light/40 hover:text-error hover:bg-error/10 rounded-lg transition-colors"
onclick={() => deleteOrgTag(tag)} onclick={() => deleteOrgTag(tag)}
>Delete</Button title="Delete"
> >
<span class="material-symbols-rounded" style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;">delete</span>
</button>
</div> </div>
</div> </div>
</Card>
{/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">
<!-- Back Button -->
<a
href="/"
class="inline-flex items-center gap-2 text-light/60 hover:text-light transition-colors"
>
<svg
class="w-5 h-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
Back to Home
</a>
<!-- Header --> <!-- Header -->
<header class="text-center space-y-4"> <header class="border-b border-light/5">
<h1 class="text-4xl font-bold text-light">Root Style Guide</h1> <div class="max-w-5xl mx-auto px-6 py-4 flex items-center justify-between">
<p class="text-light/60">All UI components and their variants</p> <div class="flex items-center gap-3">
<a href="/" class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors">
<span class="material-symbols-rounded" style="font-size: 20px;">arrow_back</span>
</a>
<span class="font-heading text-body text-white">Style Guide</span>
<span class="text-[11px] text-light/30 font-body">All UI components and their variants</span>
</div>
</div>
</header> </header>
<!-- Colors - Figma Design System --> <div class="max-w-5xl mx-auto px-6 py-8 space-y-10">
<!-- Colors -->
<section class="space-y-4"> <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,84 @@
-- Events: the core project entity within an organization
-- Each event represents a project (conference, festival, meetup, etc.)
CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
slug TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'planning' CHECK (status IN ('planning', 'active', 'completed', 'archived')),
start_date DATE,
end_date DATE,
venue_name TEXT,
venue_address TEXT,
cover_image_url TEXT,
color TEXT,
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(org_id, slug)
);
-- Event members: subset of org members assigned to an event
CREATE TABLE event_members (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('lead', 'manager', 'member')),
assigned_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(event_id, user_id)
);
-- Indexes
CREATE INDEX idx_events_org ON events(org_id);
CREATE INDEX idx_events_status ON events(org_id, status);
CREATE INDEX idx_events_dates ON events(start_date, end_date);
CREATE INDEX idx_event_members_event ON event_members(event_id);
CREATE INDEX idx_event_members_user ON event_members(user_id);
-- RLS
ALTER TABLE events ENABLE ROW LEVEL SECURITY;
ALTER TABLE event_members ENABLE ROW LEVEL SECURITY;
-- Events: org members can view, editors+ can manage
CREATE POLICY "Org members can view events" ON events FOR SELECT
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = events.org_id AND user_id = auth.uid()));
CREATE POLICY "Editors can manage events" ON events FOR ALL
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = events.org_id AND user_id = auth.uid() AND role IN ('owner', 'admin', 'editor')));
-- Event members: org members can view, editors+ can manage
CREATE POLICY "Org members can view event members" ON event_members FOR SELECT
USING (EXISTS (
SELECT 1 FROM events e
JOIN org_members om ON e.org_id = om.org_id
WHERE e.id = event_members.event_id AND om.user_id = auth.uid()
));
CREATE POLICY "Editors can manage event members" ON event_members FOR ALL
USING (EXISTS (
SELECT 1 FROM events e
JOIN org_members om ON e.org_id = om.org_id
WHERE e.id = event_members.event_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
));
-- Enable realtime
ALTER PUBLICATION supabase_realtime ADD TABLE events;
-- Auto-add creator as event lead
CREATE OR REPLACE FUNCTION public.handle_new_event()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.created_by IS NOT NULL THEN
INSERT INTO public.event_members (event_id, user_id, role)
VALUES (NEW.id, NEW.created_by, 'lead')
ON CONFLICT (event_id, user_id) DO NOTHING;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_event_created
AFTER INSERT ON events
FOR EACH ROW EXECUTE FUNCTION public.handle_new_event();