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