feat: Phase 1 - Events entity (migration, API, list page, detail layout with module sidebar, overview page)

This commit is contained in:
AlacrisDevs
2026-02-07 10:04:37 +02:00
parent 4f21c89103
commit 556955f349
10 changed files with 1833 additions and 3 deletions

View File

@@ -123,6 +123,11 @@
},
]
: []),
{
href: `/${data.org.slug}/events`,
label: "Events",
icon: "celebration",
},
{
href: `/${data.org.slug}/chat`,
label: "Chat",
@@ -349,9 +354,11 @@
? "files"
: target.includes("/calendar")
? "calendar"
: target.includes("/settings")
? "settings"
: "default"}
: target.includes("/events")
? "default"
: target.includes("/settings")
? "settings"
: "default"}
<PageSkeleton variant={skeletonVariant} />
{:else}
{@render children()}

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

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

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