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:
@@ -324,6 +324,23 @@
|
|||||||
"events_mod_team_desc": "Team members and shift scheduling",
|
"events_mod_team_desc": "Team members and shift scheduling",
|
||||||
"events_mod_sponsors": "Sponsors",
|
"events_mod_sponsors": "Sponsors",
|
||||||
"events_mod_sponsors_desc": "Sponsors, partners, and deliverables",
|
"events_mod_sponsors_desc": "Sponsors, partners, and deliverables",
|
||||||
|
"module_coming_soon": "Coming Soon",
|
||||||
|
"module_coming_soon_desc": "This module is under development and will be available soon.",
|
||||||
|
"team_title": "Event Team",
|
||||||
|
"team_subtitle": "Manage team members and their roles for this event.",
|
||||||
|
"team_add_member": "Add Member",
|
||||||
|
"team_role_lead": "Lead",
|
||||||
|
"team_role_manager": "Manager",
|
||||||
|
"team_role_member": "Member",
|
||||||
|
"team_empty": "No team members assigned yet. Add members from your organization.",
|
||||||
|
"team_remove_confirm": "Remove {name} from this event's team?",
|
||||||
|
"team_remove_btn": "Remove",
|
||||||
|
"team_added": "{name} added to team",
|
||||||
|
"team_removed": "{name} removed from team",
|
||||||
|
"team_updated": "Role updated",
|
||||||
|
"team_select_member": "Select a member",
|
||||||
|
"team_select_role": "Select role",
|
||||||
|
"team_already_assigned": "Already on team",
|
||||||
"overview_subtitle": "Welcome back. Here's what's happening.",
|
"overview_subtitle": "Welcome back. Here's what's happening.",
|
||||||
"overview_stat_events": "Events",
|
"overview_stat_events": "Events",
|
||||||
"overview_upcoming_events": "Upcoming Events",
|
"overview_upcoming_events": "Upcoming Events",
|
||||||
|
|||||||
@@ -324,6 +324,23 @@
|
|||||||
"events_mod_team_desc": "Meeskonnaliikmed ja vahetuste planeerimine",
|
"events_mod_team_desc": "Meeskonnaliikmed ja vahetuste planeerimine",
|
||||||
"events_mod_sponsors": "Sponsorid",
|
"events_mod_sponsors": "Sponsorid",
|
||||||
"events_mod_sponsors_desc": "Sponsorid, partnerid ja kohustused",
|
"events_mod_sponsors_desc": "Sponsorid, partnerid ja kohustused",
|
||||||
|
"module_coming_soon": "Tulekul",
|
||||||
|
"module_coming_soon_desc": "See moodul on arendamisel ja saab peagi kättesaadavaks.",
|
||||||
|
"team_title": "Ürituse meeskond",
|
||||||
|
"team_subtitle": "Halda meeskonnaliikmeid ja nende rolle selle ürituse jaoks.",
|
||||||
|
"team_add_member": "Lisa liige",
|
||||||
|
"team_role_lead": "Juht",
|
||||||
|
"team_role_manager": "Haldur",
|
||||||
|
"team_role_member": "Liige",
|
||||||
|
"team_empty": "Meeskonnaliikmeid pole veel määratud. Lisa liikmeid oma organisatsioonist.",
|
||||||
|
"team_remove_confirm": "Eemalda {name} selle ürituse meeskonnast?",
|
||||||
|
"team_remove_btn": "Eemalda",
|
||||||
|
"team_added": "{name} lisatud meeskonda",
|
||||||
|
"team_removed": "{name} eemaldatud meeskonnast",
|
||||||
|
"team_updated": "Roll uuendatud",
|
||||||
|
"team_select_member": "Vali liige",
|
||||||
|
"team_select_role": "Vali roll",
|
||||||
|
"team_already_assigned": "Juba meeskonnas",
|
||||||
"overview_subtitle": "Tere tagasi. Siin on ülevaade toimuvast.",
|
"overview_subtitle": "Tere tagasi. Siin on ülevaade toimuvast.",
|
||||||
"overview_stat_events": "Üritused",
|
"overview_stat_events": "Üritused",
|
||||||
"overview_upcoming_events": "Tulevased üritused",
|
"overview_upcoming_events": "Tulevased üritused",
|
||||||
|
|||||||
32
src/routes/[orgSlug]/events/[eventSlug]/budget/+page.svelte
Normal file
32
src/routes/[orgSlug]/events/[eventSlug]/budget/+page.svelte
Normal 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>
|
||||||
32
src/routes/[orgSlug]/events/[eventSlug]/files/+page.svelte
Normal file
32
src/routes/[orgSlug]/events/[eventSlug]/files/+page.svelte
Normal 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>
|
||||||
32
src/routes/[orgSlug]/events/[eventSlug]/guests/+page.svelte
Normal file
32
src/routes/[orgSlug]/events/[eventSlug]/guests/+page.svelte
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
32
src/routes/[orgSlug]/events/[eventSlug]/tasks/+page.svelte
Normal file
32
src/routes/[orgSlug]/events/[eventSlug]/tasks/+page.svelte
Normal 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>
|
||||||
490
src/routes/[orgSlug]/events/[eventSlug]/team/+page.svelte
Normal file
490
src/routes/[orgSlug]/events/[eventSlug]/team/+page.svelte
Normal 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}
|
||||||
Reference in New Issue
Block a user