feat: Phase 1 - Events entity (migration, API, list page, detail layout with module sidebar, overview page)
This commit is contained in:
48
src/lib/api/events.test.ts
Normal file
48
src/lib/api/events.test.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
// Test the slugify logic (extracted inline since it's not exported)
|
||||||
|
function slugify(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\w\s-]/g, '')
|
||||||
|
.replace(/[\s_]+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
.slice(0, 60) || 'event';
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('events API - slugify', () => {
|
||||||
|
it('converts simple name to slug', () => {
|
||||||
|
expect(slugify('Summer Conference')).toBe('summer-conference');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles special characters', () => {
|
||||||
|
expect(slugify('Music & Arts Festival 2026!')).toBe('music-arts-festival-2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses multiple spaces and dashes', () => {
|
||||||
|
expect(slugify('My Big Event')).toBe('my-big-event');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims leading/trailing dashes', () => {
|
||||||
|
expect(slugify('--hello--')).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates to 60 characters', () => {
|
||||||
|
const longName = 'A'.repeat(100);
|
||||||
|
expect(slugify(longName).length).toBeLessThanOrEqual(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "event" for empty string', () => {
|
||||||
|
expect(slugify('')).toBe('event');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles unicode characters', () => {
|
||||||
|
const result = slugify('Ürituse Korraldamine');
|
||||||
|
expect(result).toBe('rituse-korraldamine');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles numbers', () => {
|
||||||
|
expect(slugify('Event 2026 Q1')).toBe('event-2026-q1');
|
||||||
|
});
|
||||||
|
});
|
||||||
277
src/lib/api/events.ts
Normal file
277
src/lib/api/events.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']
|
||||||
|
|||||||
@@ -123,6 +123,11 @@
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
{
|
||||||
|
href: `/${data.org.slug}/events`,
|
||||||
|
label: "Events",
|
||||||
|
icon: "celebration",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: `/${data.org.slug}/chat`,
|
href: `/${data.org.slug}/chat`,
|
||||||
label: "Chat",
|
label: "Chat",
|
||||||
@@ -349,9 +354,11 @@
|
|||||||
? "files"
|
? "files"
|
||||||
: target.includes("/calendar")
|
: target.includes("/calendar")
|
||||||
? "calendar"
|
? "calendar"
|
||||||
: target.includes("/settings")
|
: target.includes("/events")
|
||||||
? "settings"
|
? "default"
|
||||||
: "default"}
|
: target.includes("/settings")
|
||||||
|
? "settings"
|
||||||
|
: "default"}
|
||||||
<PageSkeleton variant={skeletonVariant} />
|
<PageSkeleton variant={skeletonVariant} />
|
||||||
{:else}
|
{:else}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
29
src/routes/[orgSlug]/events/+page.server.ts
Normal file
29
src/routes/[orgSlug]/events/+page.server.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { fetchEvents } from '$lib/api/events';
|
||||||
|
import { createLogger } from '$lib/utils/logger';
|
||||||
|
|
||||||
|
const log = createLogger('page.events');
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, locals, url }) => {
|
||||||
|
const { session, user } = await locals.safeGetSession();
|
||||||
|
if (!session || !user) error(401, 'Unauthorized');
|
||||||
|
|
||||||
|
const { data: org } = await locals.supabase
|
||||||
|
.from('organizations')
|
||||||
|
.select('id')
|
||||||
|
.eq('slug', params.orgSlug)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!org) error(404, 'Organization not found');
|
||||||
|
|
||||||
|
const statusFilter = url.searchParams.get('status') || 'all';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const events = await fetchEvents(locals.supabase, org.id, statusFilter);
|
||||||
|
return { events, statusFilter };
|
||||||
|
} catch (e: any) {
|
||||||
|
log.error('Failed to load events', { error: e, data: { orgId: org.id } });
|
||||||
|
return { events: [], statusFilter };
|
||||||
|
}
|
||||||
|
};
|
||||||
499
src/routes/[orgSlug]/events/+page.svelte
Normal file
499
src/routes/[orgSlug]/events/+page.svelte
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
import { Avatar } 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";
|
||||||
|
|
||||||
|
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 = [
|
||||||
|
{ value: "all", label: "All Events", icon: "apps" },
|
||||||
|
{ value: "planning", label: "Planning", icon: "edit_note" },
|
||||||
|
{ value: "active", label: "Active", icon: "play_circle" },
|
||||||
|
{ value: "completed", label: "Completed", icon: "check_circle" },
|
||||||
|
{ value: "archived", label: "Archived", icon: "archive" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const presetColors = [
|
||||||
|
"#00A3E0",
|
||||||
|
"#8B5CF6",
|
||||||
|
"#EC4899",
|
||||||
|
"#F59E0B",
|
||||||
|
"#10B981",
|
||||||
|
"#EF4444",
|
||||||
|
"#6366F1",
|
||||||
|
"#14B8A6",
|
||||||
|
];
|
||||||
|
|
||||||
|
function getStatusColor(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
planning: "text-amber-400 bg-amber-400/10",
|
||||||
|
active: "text-emerald-400 bg-emerald-400/10",
|
||||||
|
completed: "text-blue-400 bg-blue-400/10",
|
||||||
|
archived: "text-light/40 bg-light/5",
|
||||||
|
};
|
||||||
|
return map[status] ?? "text-light/40 bg-light/5";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusIcon(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
planning: "edit_note",
|
||||||
|
active: "play_circle",
|
||||||
|
completed: "check_circle",
|
||||||
|
archived: "archive",
|
||||||
|
};
|
||||||
|
return map[status] ?? "help";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return "";
|
||||||
|
return new Date(dateStr).toLocaleDateString(undefined, {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateRange(
|
||||||
|
start: string | null,
|
||||||
|
end: string | null,
|
||||||
|
): string {
|
||||||
|
if (!start && !end) return "No dates set";
|
||||||
|
if (start && !end) return formatDate(start);
|
||||||
|
if (!start && end) return `Until ${formatDate(end)}`;
|
||||||
|
return `${formatDate(start)} — ${formatDate(end)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(`Event "${created.name}" created`);
|
||||||
|
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>Events | {data.org.name}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<!-- Header -->
|
||||||
|
<header
|
||||||
|
class="flex items-center justify-between px-6 py-5 border-b border-light/5"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-h1 font-heading text-white">Events</h1>
|
||||||
|
<p class="text-body-sm text-light/50 mt-1">
|
||||||
|
Organize and manage your events
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{#if isEditor}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors"
|
||||||
|
onclick={() => (showCreateModal = true)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded"
|
||||||
|
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
||||||
|
>add</span
|
||||||
|
>
|
||||||
|
New Event
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Status Tabs -->
|
||||||
|
<div class="flex items-center gap-1 px-6 py-3 border-b border-light/5">
|
||||||
|
{#each statusTabs as tab}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-body-sm font-body transition-colors {data.statusFilter ===
|
||||||
|
tab.value
|
||||||
|
? 'bg-primary text-background'
|
||||||
|
: 'text-light/60 hover:text-white hover:bg-dark/50'}"
|
||||||
|
onclick={() => switchStatus(tab.value)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded"
|
||||||
|
style="font-size: 16px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 16;"
|
||||||
|
>{tab.icon}</span
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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">No events yet</p>
|
||||||
|
<p class="text-body text-light/30">
|
||||||
|
Create your first event to get started
|
||||||
|
</p>
|
||||||
|
{#if isEditor}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-4 flex items-center gap-2 px-4 py-2 bg-primary text-background rounded-xl font-body text-body-sm hover:bg-primary-hover transition-colors"
|
||||||
|
onclick={() => (showCreateModal = true)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded"
|
||||||
|
style="font-size: 20px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;"
|
||||||
|
>add</span
|
||||||
|
>
|
||||||
|
Create Event
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
|
{#each data.events as event}
|
||||||
|
<a
|
||||||
|
href="/{data.org.slug}/events/{event.slug}"
|
||||||
|
class="group bg-dark/30 hover:bg-dark/60 border border-light/5 hover:border-light/10 rounded-2xl p-5 flex flex-col gap-3 transition-all"
|
||||||
|
>
|
||||||
|
<!-- Color bar + Status -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
class="w-3 h-3 rounded-full"
|
||||||
|
style="background-color: {event.color ||
|
||||||
|
'#00A3E0'}"
|
||||||
|
></div>
|
||||||
|
<h3
|
||||||
|
class="text-body font-heading text-white group-hover:text-primary transition-colors truncate"
|
||||||
|
>
|
||||||
|
{event.name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="text-[11px] font-body px-2 py-0.5 rounded-full capitalize {getStatusColor(
|
||||||
|
event.status,
|
||||||
|
)}"
|
||||||
|
>
|
||||||
|
{event.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
{#if event.description}
|
||||||
|
<p
|
||||||
|
class="text-body-sm text-light/50 line-clamp-2"
|
||||||
|
>
|
||||||
|
{event.description}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Meta row -->
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-4 text-[12px] text-light/40 mt-auto pt-2"
|
||||||
|
>
|
||||||
|
<!-- Date -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded"
|
||||||
|
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
|
||||||
|
>calendar_today</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
>{formatDateRange(
|
||||||
|
event.start_date,
|
||||||
|
event.end_date,
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Venue -->
|
||||||
|
{#if event.venue_name}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded"
|
||||||
|
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
|
||||||
|
>location_on</span
|
||||||
|
>
|
||||||
|
<span class="truncate max-w-[120px]"
|
||||||
|
>{event.venue_name}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Members -->
|
||||||
|
<div class="flex items-center gap-1 ml-auto">
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded"
|
||||||
|
style="font-size: 14px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 14;"
|
||||||
|
>group</span
|
||||||
|
>
|
||||||
|
<span>{event.member_count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Event Modal -->
|
||||||
|
{#if showCreateModal}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<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="Create Event"
|
||||||
|
>
|
||||||
|
<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">Create Event</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-light/40 hover:text-white transition-colors"
|
||||||
|
onclick={() => (showCreateModal = false)}
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
>Event Name</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="event-name"
|
||||||
|
type="text"
|
||||||
|
bind:value={newEventName}
|
||||||
|
placeholder="e.g., Summer Conference 2026"
|
||||||
|
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"
|
||||||
|
>Description</label
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
id="event-desc"
|
||||||
|
bind:value={newEventDescription}
|
||||||
|
placeholder="Brief description of the event..."
|
||||||
|
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"
|
||||||
|
>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"
|
||||||
|
>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"
|
||||||
|
>Venue</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="event-venue"
|
||||||
|
type="text"
|
||||||
|
bind:value={newEventVenue}
|
||||||
|
placeholder="e.g., Convention Center"
|
||||||
|
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">
|
||||||
|
<label class="text-body-sm text-light/60 font-body"
|
||||||
|
>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)}
|
||||||
|
></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();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
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 ? "Creating..." : "Create Event"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
27
src/routes/[orgSlug]/events/[eventSlug]/+layout.server.ts
Normal file
27
src/routes/[orgSlug]/events/[eventSlug]/+layout.server.ts
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
200
src/routes/[orgSlug]/events/[eventSlug]/+layout.svelte
Normal file
200
src/routes/[orgSlug]/events/[eventSlug]/+layout.svelte
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
<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";
|
||||||
|
|
||||||
|
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: "Overview",
|
||||||
|
icon: "dashboard",
|
||||||
|
exact: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: `${basePath}/tasks`,
|
||||||
|
label: "Tasks",
|
||||||
|
icon: "task_alt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: `${basePath}/files`,
|
||||||
|
label: "Files",
|
||||||
|
icon: "folder",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: `${basePath}/schedule`,
|
||||||
|
label: "Schedule",
|
||||||
|
icon: "calendar_today",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: `${basePath}/budget`,
|
||||||
|
label: "Budget",
|
||||||
|
icon: "account_balance_wallet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: `${basePath}/guests`,
|
||||||
|
label: "Guests",
|
||||||
|
icon: "groups",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: `${basePath}/team`,
|
||||||
|
label: "Team",
|
||||||
|
icon: "badge",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: `${basePath}/sponsors`,
|
||||||
|
label: "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">
|
||||||
|
Team ({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
|
||||||
|
>
|
||||||
|
All Events
|
||||||
|
</a>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Module Content -->
|
||||||
|
<div class="flex-1 overflow-auto">
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
563
src/routes/[orgSlug]/events/[eventSlug]/+page.svelte
Normal file
563
src/routes/[orgSlug]/events/[eventSlug]/+page.svelte
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { Avatar } 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";
|
||||||
|
|
||||||
|
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(data.event.name);
|
||||||
|
let editDescription = $state(data.event.description ?? "");
|
||||||
|
let editStatus = $state(data.event.status);
|
||||||
|
let editStartDate = $state(data.event.start_date ?? "");
|
||||||
|
let editEndDate = $state(data.event.end_date ?? "");
|
||||||
|
let editVenueName = $state(data.event.venue_name ?? "");
|
||||||
|
let editVenueAddress = $state(data.event.venue_address ?? "");
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
// Delete confirmation
|
||||||
|
let showDeleteConfirm = $state(false);
|
||||||
|
let deleting = $state(false);
|
||||||
|
|
||||||
|
const basePath = $derived(
|
||||||
|
`/${data.org.slug}/events/${data.event.slug}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: "planning", label: "Planning", icon: "edit_note", color: "text-amber-400" },
|
||||||
|
{ value: "active", label: "Active", icon: "play_circle", color: "text-emerald-400" },
|
||||||
|
{ value: "completed", label: "Completed", icon: "check_circle", color: "text-blue-400" },
|
||||||
|
{ value: "archived", label: "Archived", icon: "archive", color: "text-light/40" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const moduleCards = $derived([
|
||||||
|
{
|
||||||
|
href: `${basePath}/tasks`,
|
||||||
|
label: "Tasks",
|
||||||
|
icon: "task_alt",
|
||||||
|
description: "Manage tasks, milestones, and progress",
|
||||||
|
color: "text-emerald-400",
|
||||||
|
bg: "bg-emerald-400/10",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: `${basePath}/files`,
|
||||||
|
label: "Files",
|
||||||
|
icon: "folder",
|
||||||
|
description: "Documents, contracts, and media",
|
||||||
|
color: "text-blue-400",
|
||||||
|
bg: "bg-blue-400/10",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: `${basePath}/schedule`,
|
||||||
|
label: "Schedule",
|
||||||
|
icon: "calendar_today",
|
||||||
|
description: "Event timeline and program",
|
||||||
|
color: "text-purple-400",
|
||||||
|
bg: "bg-purple-400/10",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: `${basePath}/budget`,
|
||||||
|
label: "Budget",
|
||||||
|
icon: "account_balance_wallet",
|
||||||
|
description: "Income, expenses, and tracking",
|
||||||
|
color: "text-amber-400",
|
||||||
|
bg: "bg-amber-400/10",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: `${basePath}/guests`,
|
||||||
|
label: "Guests",
|
||||||
|
icon: "groups",
|
||||||
|
description: "Guest list and registration",
|
||||||
|
color: "text-pink-400",
|
||||||
|
bg: "bg-pink-400/10",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: `${basePath}/team`,
|
||||||
|
label: "Team",
|
||||||
|
icon: "badge",
|
||||||
|
description: "Team members and shift scheduling",
|
||||||
|
color: "text-teal-400",
|
||||||
|
bg: "bg-teal-400/10",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: `${basePath}/sponsors`,
|
||||||
|
label: "Sponsors",
|
||||||
|
icon: "handshake",
|
||||||
|
description: "Sponsors, partners, and deliverables",
|
||||||
|
color: "text-orange-400",
|
||||||
|
bg: "bg-orange-400/10",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | null): string {
|
||||||
|
if (!dateStr) return "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 `${Math.abs(diff)} days ago`;
|
||||||
|
if (diff === 0) return "Today!";
|
||||||
|
if (diff === 1) return "Tomorrow";
|
||||||
|
return `In ${diff} days`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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("Event 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("Event 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)}
|
||||||
|
>
|
||||||
|
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 ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="p-2 text-light/40 hover:text-white transition-colors rounded-lg hover:bg-dark/50"
|
||||||
|
title="Edit event"
|
||||||
|
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="Delete event"
|
||||||
|
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="Venue name"
|
||||||
|
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white placeholder:text-light/30 focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={editVenueAddress}
|
||||||
|
placeholder="Venue address"
|
||||||
|
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">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}
|
||||||
|
<a
|
||||||
|
href={mod.href}
|
||||||
|
class="group bg-dark/30 hover:bg-dark/60 border border-light/5 hover:border-light/10 rounded-xl p-4 flex flex-col gap-2 transition-all"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 rounded-xl {mod.bg} flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="material-symbols-rounded {mod.color}"
|
||||||
|
style="font-size: 22px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 22;"
|
||||||
|
>{mod.icon}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<h3
|
||||||
|
class="text-body font-heading text-white group-hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{mod.label}
|
||||||
|
</h3>
|
||||||
|
<p class="text-[12px] text-light/40">{mod.description}</p>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event Details Section -->
|
||||||
|
<div class="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- Info Card -->
|
||||||
|
<div class="bg-dark/30 border border-light/5 rounded-xl p-5">
|
||||||
|
<h3 class="text-body font-heading text-white mb-3">
|
||||||
|
Event Details
|
||||||
|
</h3>
|
||||||
|
<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">
|
||||||
|
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">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">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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Team Card -->
|
||||||
|
<div class="bg-dark/30 border border-light/5 rounded-xl p-5">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="text-body font-heading text-white">
|
||||||
|
Team ({data.eventMembers.length})
|
||||||
|
</h3>
|
||||||
|
<a
|
||||||
|
href="{basePath}/team"
|
||||||
|
class="text-[12px] text-primary hover:underline"
|
||||||
|
>Manage</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#each data.eventMembers.slice(0, 6) as member}
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<Avatar
|
||||||
|
name={member.profile?.full_name ||
|
||||||
|
member.profile?.email ||
|
||||||
|
"?"}
|
||||||
|
src={member.profile?.avatar_url}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
+{data.eventMembers.length - 6} more
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{#if data.eventMembers.length === 0}
|
||||||
|
<p class="text-body-sm text-light/30 text-center py-4">
|
||||||
|
No team members assigned yet
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation -->
|
||||||
|
{#if showDeleteConfirm}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<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="Delete Event"
|
||||||
|
>
|
||||||
|
<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">Delete Event?</h2>
|
||||||
|
<p class="text-body-sm text-light/50 mb-6">
|
||||||
|
This will permanently delete <strong class="text-white"
|
||||||
|
>{data.event.name}</strong
|
||||||
|
>
|
||||||
|
and all its data. This action cannot be undone.
|
||||||
|
</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)}
|
||||||
|
>
|
||||||
|
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 ? "Deleting..." : "Delete Event"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
84
supabase/migrations/022_events.sql
Normal file
84
supabase/migrations/022_events.sql
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
-- Events: the core project entity within an organization
|
||||||
|
-- Each event represents a project (conference, festival, meetup, etc.)
|
||||||
|
|
||||||
|
CREATE TABLE events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'planning' CHECK (status IN ('planning', 'active', 'completed', 'archived')),
|
||||||
|
start_date DATE,
|
||||||
|
end_date DATE,
|
||||||
|
venue_name TEXT,
|
||||||
|
venue_address TEXT,
|
||||||
|
cover_image_url TEXT,
|
||||||
|
color TEXT,
|
||||||
|
created_by UUID REFERENCES auth.users(id),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
UNIQUE(org_id, slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Event members: subset of org members assigned to an event
|
||||||
|
CREATE TABLE event_members (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('lead', 'manager', 'member')),
|
||||||
|
assigned_at TIMESTAMPTZ DEFAULT now(),
|
||||||
|
UNIQUE(event_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX idx_events_org ON events(org_id);
|
||||||
|
CREATE INDEX idx_events_status ON events(org_id, status);
|
||||||
|
CREATE INDEX idx_events_dates ON events(start_date, end_date);
|
||||||
|
CREATE INDEX idx_event_members_event ON event_members(event_id);
|
||||||
|
CREATE INDEX idx_event_members_user ON event_members(user_id);
|
||||||
|
|
||||||
|
-- RLS
|
||||||
|
ALTER TABLE events ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE event_members ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Events: org members can view, editors+ can manage
|
||||||
|
CREATE POLICY "Org members can view events" ON events FOR SELECT
|
||||||
|
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = events.org_id AND user_id = auth.uid()));
|
||||||
|
|
||||||
|
CREATE POLICY "Editors can manage events" ON events FOR ALL
|
||||||
|
USING (EXISTS (SELECT 1 FROM org_members WHERE org_id = events.org_id AND user_id = auth.uid() AND role IN ('owner', 'admin', 'editor')));
|
||||||
|
|
||||||
|
-- Event members: org members can view, editors+ can manage
|
||||||
|
CREATE POLICY "Org members can view event members" ON event_members FOR SELECT
|
||||||
|
USING (EXISTS (
|
||||||
|
SELECT 1 FROM events e
|
||||||
|
JOIN org_members om ON e.org_id = om.org_id
|
||||||
|
WHERE e.id = event_members.event_id AND om.user_id = auth.uid()
|
||||||
|
));
|
||||||
|
|
||||||
|
CREATE POLICY "Editors can manage event members" ON event_members FOR ALL
|
||||||
|
USING (EXISTS (
|
||||||
|
SELECT 1 FROM events e
|
||||||
|
JOIN org_members om ON e.org_id = om.org_id
|
||||||
|
WHERE e.id = event_members.event_id AND om.user_id = auth.uid() AND om.role IN ('owner', 'admin', 'editor')
|
||||||
|
));
|
||||||
|
|
||||||
|
-- Enable realtime
|
||||||
|
ALTER PUBLICATION supabase_realtime ADD TABLE events;
|
||||||
|
|
||||||
|
-- Auto-add creator as event lead
|
||||||
|
CREATE OR REPLACE FUNCTION public.handle_new_event()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.created_by IS NOT NULL THEN
|
||||||
|
INSERT INTO public.event_members (event_id, user_id, role)
|
||||||
|
VALUES (NEW.id, NEW.created_by, 'lead')
|
||||||
|
ON CONFLICT (event_id, user_id) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
CREATE TRIGGER on_event_created
|
||||||
|
AFTER INSERT ON events
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION public.handle_new_event();
|
||||||
Reference in New Issue
Block a user