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