feat: Phase 1 - Events entity (migration, API, list page, detail layout with module sidebar, overview page)
This commit is contained in:
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}
|
||||
Reference in New Issue
Block a user