feat: add event module pages (placeholders + full Team module) - 6 placeholder 'coming soon' pages: tasks, files, schedule, budget, guests, sponsors - Full Team module: add/remove members, change roles, role badges - Uses existing event_members DB table and API layer - i18n keys added for EN and ET (module placeholders + team) - svelte-check: 0 errors, vitest: 112/112 passed

This commit is contained in:
AlacrisDevs
2026-02-07 11:39:51 +02:00
parent 4999836a57
commit edc5f8af85
9 changed files with 716 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import * as m from "$lib/paraglide/messages";
interface Props {
data: {
org: { id: string; name: string; slug: string };
event: { name: string; slug: string };
};
}
let { data }: Props = $props();
</script>
<svelte:head>
<title>{m.events_mod_budget()} | {data.event.name} | {data.org.name}</title>
</svelte:head>
<div class="flex flex-col items-center justify-center h-full text-light/40 p-6">
<span
class="material-symbols-rounded mb-4"
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
>account_balance_wallet</span
>
<h2 class="text-h3 font-heading text-white mb-2">{m.events_mod_budget()}</h2>
<p class="text-body-sm text-light/30 text-center max-w-sm">
{m.events_mod_budget_desc()}
</p>
<span
class="mt-4 text-[11px] text-light/20 bg-light/5 px-3 py-1 rounded-full"
>{m.module_coming_soon()}</span
>
</div>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import * as m from "$lib/paraglide/messages";
interface Props {
data: {
org: { id: string; name: string; slug: string };
event: { name: string; slug: string };
};
}
let { data }: Props = $props();
</script>
<svelte:head>
<title>{m.events_mod_files()} | {data.event.name} | {data.org.name}</title>
</svelte:head>
<div class="flex flex-col items-center justify-center h-full text-light/40 p-6">
<span
class="material-symbols-rounded mb-4"
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
>folder</span
>
<h2 class="text-h3 font-heading text-white mb-2">{m.events_mod_files()}</h2>
<p class="text-body-sm text-light/30 text-center max-w-sm">
{m.events_mod_files_desc()}
</p>
<span
class="mt-4 text-[11px] text-light/20 bg-light/5 px-3 py-1 rounded-full"
>{m.module_coming_soon()}</span
>
</div>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import * as m from "$lib/paraglide/messages";
interface Props {
data: {
org: { id: string; name: string; slug: string };
event: { name: string; slug: string };
};
}
let { data }: Props = $props();
</script>
<svelte:head>
<title>{m.events_mod_guests()} | {data.event.name} | {data.org.name}</title>
</svelte:head>
<div class="flex flex-col items-center justify-center h-full text-light/40 p-6">
<span
class="material-symbols-rounded mb-4"
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
>groups</span
>
<h2 class="text-h3 font-heading text-white mb-2">{m.events_mod_guests()}</h2>
<p class="text-body-sm text-light/30 text-center max-w-sm">
{m.events_mod_guests_desc()}
</p>
<span
class="mt-4 text-[11px] text-light/20 bg-light/5 px-3 py-1 rounded-full"
>{m.module_coming_soon()}</span
>
</div>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import * as m from "$lib/paraglide/messages";
interface Props {
data: {
org: { id: string; name: string; slug: string };
event: { name: string; slug: string };
};
}
let { data }: Props = $props();
</script>
<svelte:head>
<title>{m.events_mod_schedule()} | {data.event.name} | {data.org.name}</title>
</svelte:head>
<div class="flex flex-col items-center justify-center h-full text-light/40 p-6">
<span
class="material-symbols-rounded mb-4"
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
>calendar_today</span
>
<h2 class="text-h3 font-heading text-white mb-2">{m.events_mod_schedule()}</h2>
<p class="text-body-sm text-light/30 text-center max-w-sm">
{m.events_mod_schedule_desc()}
</p>
<span
class="mt-4 text-[11px] text-light/20 bg-light/5 px-3 py-1 rounded-full"
>{m.module_coming_soon()}</span
>
</div>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import * as m from "$lib/paraglide/messages";
interface Props {
data: {
org: { id: string; name: string; slug: string };
event: { name: string; slug: string };
};
}
let { data }: Props = $props();
</script>
<svelte:head>
<title>{m.events_mod_sponsors()} | {data.event.name} | {data.org.name}</title>
</svelte:head>
<div class="flex flex-col items-center justify-center h-full text-light/40 p-6">
<span
class="material-symbols-rounded mb-4"
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
>handshake</span
>
<h2 class="text-h3 font-heading text-white mb-2">{m.events_mod_sponsors()}</h2>
<p class="text-body-sm text-light/30 text-center max-w-sm">
{m.events_mod_sponsors_desc()}
</p>
<span
class="mt-4 text-[11px] text-light/20 bg-light/5 px-3 py-1 rounded-full"
>{m.module_coming_soon()}</span
>
</div>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import * as m from "$lib/paraglide/messages";
interface Props {
data: {
org: { id: string; name: string; slug: string };
event: { name: string; slug: string };
};
}
let { data }: Props = $props();
</script>
<svelte:head>
<title>{m.events_mod_tasks()} | {data.event.name} | {data.org.name}</title>
</svelte:head>
<div class="flex flex-col items-center justify-center h-full text-light/40 p-6">
<span
class="material-symbols-rounded mb-4"
style="font-size: 64px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
>task_alt</span
>
<h2 class="text-h3 font-heading text-white mb-2">{m.events_mod_tasks()}</h2>
<p class="text-body-sm text-light/30 text-center max-w-sm">
{m.events_mod_tasks_desc()}
</p>
<span
class="mt-4 text-[11px] text-light/20 bg-light/5 px-3 py-1 rounded-full"
>{m.module_coming_soon()}</span
>
</div>

View File

@@ -0,0 +1,490 @@
<script lang="ts">
import { Avatar, Button, Modal, Select } 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 OrgMember {
id: string;
user_id: string;
role: string;
profiles: {
id: string;
email: string;
full_name: string | null;
avatar_url: string | null;
} | null;
}
interface Props {
data: {
org: { id: string; name: string; slug: string };
userRole: string;
members: OrgMember[];
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),
);
// Local mutable copy of event members
let teamMembers = $state(data.eventMembers);
// Sync when data changes (e.g. after invalidation)
$effect(() => {
teamMembers = data.eventMembers;
});
// Add member modal
let showAddModal = $state(false);
let selectedUserId = $state("");
let selectedRole = $state<"lead" | "manager" | "member">("member");
let adding = $state(false);
// Remove confirmation
let memberToRemove = $state<(typeof teamMembers)[0] | null>(null);
let removing = $state(false);
// Edit role
let editingMember = $state<(typeof teamMembers)[0] | null>(null);
let editRole = $state<"lead" | "manager" | "member">("member");
let updatingRole = $state(false);
// Org members not yet on the team
const availableMembers = $derived(
data.members.filter(
(om) => !teamMembers.some((tm) => tm.user_id === om.user_id),
),
);
const roleOptions = $derived([
{ value: "lead", label: m.team_role_lead() },
{ value: "manager", label: m.team_role_manager() },
{ value: "member", label: m.team_role_member() },
]);
function getRoleColor(role: string): string {
const map: Record<string, string> = {
lead: "text-amber-400 bg-amber-400/10",
manager: "text-purple-400 bg-purple-400/10",
member: "text-light/50 bg-light/5",
};
return map[role] ?? "text-light/50 bg-light/5";
}
function getMemberName(member: (typeof teamMembers)[0]): string {
return member.profile?.full_name || member.profile?.email || "Unknown";
}
async function handleAdd() {
if (!selectedUserId) return;
adding = true;
try {
const { data: inserted, error } = await (supabase as any)
.from("event_members")
.upsert(
{
event_id: data.event.id,
user_id: selectedUserId,
role: selectedRole,
},
{ onConflict: "event_id,user_id" },
)
.select()
.single();
if (error) throw error;
// Find profile from org members
const orgMember = data.members.find(
(m) => m.user_id === selectedUserId,
);
const profile = orgMember?.profiles ?? undefined;
teamMembers = [
...teamMembers,
{ ...inserted, profile },
];
const name = profile?.full_name || profile?.email || "Member";
toasts.success(m.team_added({ name }));
showAddModal = false;
selectedUserId = "";
selectedRole = "member";
} catch (e: any) {
toasts.error(e.message || "Failed to add member");
} finally {
adding = false;
}
}
async function handleRemove() {
if (!memberToRemove) return;
removing = true;
try {
const { error } = await (supabase as any)
.from("event_members")
.delete()
.eq("event_id", data.event.id)
.eq("user_id", memberToRemove.user_id);
if (error) throw error;
const name = getMemberName(memberToRemove);
teamMembers = teamMembers.filter(
(m) => m.user_id !== memberToRemove!.user_id,
);
toasts.success(m.team_removed({ name }));
memberToRemove = null;
} catch (e: any) {
toasts.error(e.message || "Failed to remove member");
} finally {
removing = false;
}
}
async function handleRoleUpdate() {
if (!editingMember) return;
updatingRole = true;
try {
const { error } = await (supabase as any)
.from("event_members")
.update({ role: editRole })
.eq("event_id", data.event.id)
.eq("user_id", editingMember.user_id);
if (error) throw error;
teamMembers = teamMembers.map((m) =>
m.user_id === editingMember!.user_id
? { ...m, role: editRole }
: m,
);
toasts.success(m.team_updated());
editingMember = null;
} catch (e: any) {
toasts.error(e.message || "Failed to update role");
} finally {
updatingRole = false;
}
}
function openEditRole(member: (typeof teamMembers)[0]) {
editingMember = member;
editRole = member.role;
}
</script>
<svelte:head>
<title>{m.events_mod_team()} | {data.event.name} | {data.org.name}</title>
</svelte:head>
<div class="flex flex-col h-full overflow-auto p-6">
<div class="max-w-2xl w-full mx-auto flex flex-col gap-4">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h2 class="font-heading text-body text-white">{m.team_title()}</h2>
<p class="text-body-sm text-light/40 mt-0.5">{m.team_subtitle()}</p>
</div>
{#if isEditor}
<Button
size="sm"
icon="person_add"
onclick={() => (showAddModal = true)}
disabled={availableMembers.length === 0}
>
{m.team_add_member()}
</Button>
{/if}
</div>
<!-- Team List -->
{#if teamMembers.length === 0}
<div
class="flex flex-col items-center justify-center py-16 text-light/40"
>
<span
class="material-symbols-rounded mb-4"
style="font-size: 56px; font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 48;"
>badge</span
>
<p class="text-body-sm text-light/30 text-center max-w-sm">
{m.team_empty()}
</p>
{#if isEditor && availableMembers.length > 0}
<div class="mt-4">
<Button
size="sm"
icon="person_add"
onclick={() => (showAddModal = true)}
>
{m.team_add_member()}
</Button>
</div>
{/if}
</div>
{:else}
<div
class="bg-dark/30 border border-light/5 rounded-xl overflow-hidden"
>
<div class="divide-y divide-light/5">
{#each teamMembers as member}
<div
class="flex items-center justify-between px-4 py-3 hover:bg-light/5 transition-colors"
>
<div class="flex items-center gap-3">
<Avatar
name={member.profile?.full_name ||
member.profile?.email ||
"?"}
src={member.profile?.avatar_url}
size="sm"
/>
<div>
<p class="text-body-sm text-white">
{member.profile?.full_name ||
member.profile?.email ||
"Unknown"}
</p>
<p class="text-[11px] text-light/40">
{member.profile?.email || ""}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<span
class="px-2 py-0.5 text-[10px] rounded-md capitalize font-body {getRoleColor(
member.role,
)}">{member.role}</span
>
{#if isEditor}
<button
type="button"
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
onclick={() => openEditRole(member)}
title="Change role"
>
<span
class="material-symbols-rounded"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>swap_horiz</span
>
</button>
<button
type="button"
class="p-1.5 text-light/40 hover:text-error hover:bg-error/10 rounded-lg transition-colors"
onclick={() =>
(memberToRemove = member)}
title={m.team_remove_btn()}
>
<span
class="material-symbols-rounded"
style="font-size: 18px; font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 18;"
>person_remove</span
>
</button>
{/if}
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>
</div>
<!-- Add Member Modal -->
<Modal
isOpen={showAddModal}
onClose={() => (showAddModal = false)}
title={m.team_add_member()}
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label
for="team-member-select"
class="text-body-sm text-light/60 font-body"
>{m.team_select_member()}</label
>
<select
id="team-member-select"
bind:value={selectedUserId}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
>
<option value="" disabled>{m.team_select_member()}</option>
{#each availableMembers as om}
<option value={om.user_id}>
{om.profiles?.full_name || om.profiles?.email || om.user_id}
</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-1.5">
<label
for="team-role-select"
class="text-body-sm text-light/60 font-body"
>{m.team_select_role()}</label
>
<select
id="team-role-select"
bind:value={selectedRole}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
>
{#each roleOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<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={() => {
showAddModal = false;
selectedUserId = "";
selectedRole = "member";
}}
>
{m.btn_cancel()}
</button>
<button
type="button"
disabled={!selectedUserId || adding}
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"
onclick={handleAdd}
>
{adding ? "..." : m.team_add_member()}
</button>
</div>
</div>
</Modal>
<!-- Edit Role Modal -->
<Modal
isOpen={!!editingMember}
onClose={() => (editingMember = null)}
title="Change Role"
>
{#if editingMember}
<div class="flex flex-col gap-4">
<div class="flex items-center gap-3 p-3 bg-dark/30 rounded-xl">
<Avatar
name={editingMember.profile?.full_name ||
editingMember.profile?.email ||
"?"}
src={editingMember.profile?.avatar_url}
size="sm"
/>
<div>
<p class="text-body-sm text-white">
{editingMember.profile?.full_name ||
editingMember.profile?.email ||
"Unknown"}
</p>
<p class="text-[11px] text-light/40 capitalize">
{editingMember.role}
</p>
</div>
</div>
<div class="flex flex-col gap-1.5">
<label
for="edit-role-select"
class="text-body-sm text-light/60 font-body"
>{m.team_select_role()}</label
>
<select
id="edit-role-select"
bind:value={editRole}
class="bg-dark border border-light/10 rounded-xl px-3 py-2 text-body-sm text-white focus:outline-none focus:border-primary"
>
{#each roleOptions as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<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={() => (editingMember = null)}
>
{m.btn_cancel()}
</button>
<button
type="button"
disabled={updatingRole || editRole === editingMember.role}
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"
onclick={handleRoleUpdate}
>
{updatingRole ? "..." : m.btn_save()}
</button>
</div>
</div>
{/if}
</Modal>
<!-- Remove Confirmation -->
{#if memberToRemove}
<!-- 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" && (memberToRemove = null)}
onclick={(e) => e.target === e.currentTarget && (memberToRemove = null)}
role="dialog"
aria-modal="true"
aria-label={m.team_remove_btn()}
>
<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.team_remove_btn()}
</h2>
<p class="text-body-sm text-light/50 mb-6">
{m.team_remove_confirm({ name: getMemberName(memberToRemove) })}
</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={() => (memberToRemove = null)}
>
{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={removing}
onclick={handleRemove}
>
{removing ? "..." : m.team_remove_btn()}
</button>
</div>
</div>
</div>
{/if}