1208 lines
32 KiB
Svelte
1208 lines
32 KiB
Svelte
<script lang="ts">
|
|
import { enhance } from "$app/forms";
|
|
import { invalidateAll } from "$app/navigation";
|
|
import {
|
|
Button,
|
|
Badge,
|
|
Avatar,
|
|
StatCard,
|
|
TabBar,
|
|
Input,
|
|
Select,
|
|
Modal,
|
|
} from "$lib/components/ui";
|
|
|
|
let { data } = $props();
|
|
|
|
let activeTab = $state("overview");
|
|
let orgSearch = $state("");
|
|
let userSearch = $state("");
|
|
let eventSearch = $state("");
|
|
|
|
// Modal state
|
|
let confirmModal = $state<{
|
|
type: string;
|
|
id: string;
|
|
name: string;
|
|
} | null>(null);
|
|
let editOrgModal = $state<any>(null);
|
|
let editUserModal = $state<any>(null);
|
|
let editEventModal = $state<any>(null);
|
|
let actionLoading = $state(false);
|
|
|
|
const filteredOrgs = $derived(
|
|
orgSearch
|
|
? data.organizations.filter(
|
|
(o: any) =>
|
|
o.name
|
|
?.toLowerCase()
|
|
.includes(orgSearch.toLowerCase()) ||
|
|
o.slug?.toLowerCase().includes(orgSearch.toLowerCase()),
|
|
)
|
|
: data.organizations,
|
|
);
|
|
|
|
const filteredUsers = $derived(
|
|
userSearch
|
|
? data.profiles.filter(
|
|
(p: any) =>
|
|
p.email
|
|
?.toLowerCase()
|
|
.includes(userSearch.toLowerCase()) ||
|
|
p.full_name
|
|
?.toLowerCase()
|
|
.includes(userSearch.toLowerCase()),
|
|
)
|
|
: data.profiles,
|
|
);
|
|
|
|
const filteredEvents = $derived(
|
|
eventSearch
|
|
? data.events.filter(
|
|
(e: any) =>
|
|
e.name
|
|
?.toLowerCase()
|
|
.includes(eventSearch.toLowerCase()) ||
|
|
e.slug
|
|
?.toLowerCase()
|
|
.includes(eventSearch.toLowerCase()),
|
|
)
|
|
: data.events,
|
|
);
|
|
|
|
function formatDate(dateStr: string | null) {
|
|
if (!dateStr) return "-";
|
|
return new Date(dateStr).toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
});
|
|
}
|
|
|
|
function formatDateInput(dateStr: string | null) {
|
|
if (!dateStr) return "";
|
|
return new Date(dateStr).toISOString().split("T")[0];
|
|
}
|
|
|
|
function timeAgo(dateStr: string | null) {
|
|
if (!dateStr) return "-";
|
|
const diff = Date.now() - new Date(dateStr).getTime();
|
|
const mins = Math.floor(diff / 60000);
|
|
if (mins < 60) return `${mins}m ago`;
|
|
const hours = Math.floor(mins / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
if (days < 30) return `${days}d ago`;
|
|
return formatDate(dateStr);
|
|
}
|
|
|
|
const statusColors: 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",
|
|
draft: "text-light/40 bg-light/5",
|
|
};
|
|
|
|
const eventStatuses = ["planning", "active", "completed", "archived"];
|
|
|
|
const orgMap = $derived(
|
|
Object.fromEntries(data.organizations.map((o: any) => [o.id, o])),
|
|
);
|
|
|
|
// Count orgs per user
|
|
const userOrgCounts = $derived(() => {
|
|
const counts: Record<string, number> = {};
|
|
for (const m of data.orgMembers) {
|
|
counts[m.user_id] = (counts[m.user_id] || 0) + 1;
|
|
}
|
|
return counts;
|
|
});
|
|
|
|
function enhanceAction() {
|
|
return ({ result }: any) => {
|
|
actionLoading = false;
|
|
if (result.type === "success") {
|
|
confirmModal = null;
|
|
editOrgModal = null;
|
|
editUserModal = null;
|
|
editEventModal = null;
|
|
invalidateAll();
|
|
}
|
|
};
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Platform Admin | root</title>
|
|
</svelte:head>
|
|
|
|
<div class="min-h-screen bg-background">
|
|
<!-- Header -->
|
|
<header class="border-b border-light/5 bg-dark/30">
|
|
<div
|
|
class="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
<a
|
|
href="/"
|
|
class="p-1.5 text-light/40 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
|
|
>
|
|
<span
|
|
class="material-symbols-rounded"
|
|
style="font-size: 20px;">arrow_back</span
|
|
>
|
|
</a>
|
|
<div class="flex items-center gap-2">
|
|
<span
|
|
class="material-symbols-rounded text-primary"
|
|
style="font-size: 24px; font-variation-settings: 'FILL' 1;"
|
|
>admin_panel_settings</span
|
|
>
|
|
<span class="font-heading text-body text-white"
|
|
>Platform Admin</span
|
|
>
|
|
</div>
|
|
<Badge variant="error" size="sm">Admin Only</Badge>
|
|
</div>
|
|
<div class="flex items-center gap-2 text-light/40 text-body-sm">
|
|
<span class="material-symbols-rounded" style="font-size: 16px;"
|
|
>schedule</span
|
|
>
|
|
{new Date().toLocaleDateString("en-US", {
|
|
weekday: "long",
|
|
month: "long",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
})}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="max-w-7xl mx-auto px-6 py-6 space-y-6">
|
|
<!-- Stats Overview -->
|
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3">
|
|
<StatCard
|
|
label="Total Users"
|
|
value={data.stats.totalUsers}
|
|
icon="group"
|
|
/>
|
|
<StatCard
|
|
label="Organizations"
|
|
value={data.stats.totalOrgs}
|
|
icon="business"
|
|
/>
|
|
<StatCard
|
|
label="Total Events"
|
|
value={data.stats.totalEvents}
|
|
icon="event"
|
|
/>
|
|
<StatCard
|
|
label="New Users (7d)"
|
|
value={data.stats.newUsersLast7d}
|
|
icon="person_add"
|
|
/>
|
|
<StatCard
|
|
label="New Users (30d)"
|
|
value={data.stats.newUsersLast30d}
|
|
icon="trending_up"
|
|
/>
|
|
<StatCard
|
|
label="Active Events"
|
|
value={data.stats.activeEvents}
|
|
icon="play_circle"
|
|
/>
|
|
<StatCard
|
|
label="Planning"
|
|
value={data.stats.planningEvents}
|
|
icon="edit_calendar"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Tab Navigation -->
|
|
<TabBar
|
|
tabs={[
|
|
{ value: "overview", label: "Overview", icon: "dashboard" },
|
|
{
|
|
value: "organizations",
|
|
label: "Organizations",
|
|
icon: "business",
|
|
},
|
|
{ value: "users", label: "Users", icon: "group" },
|
|
{ value: "events", label: "Events", icon: "event" },
|
|
]}
|
|
active={activeTab}
|
|
onchange={(v) => (activeTab = v)}
|
|
/>
|
|
|
|
<!-- Tab Content -->
|
|
{#if activeTab === "overview"}
|
|
<div class="grid lg:grid-cols-2 gap-6">
|
|
<!-- Recent Organizations -->
|
|
<div class="bg-dark/30 border border-light/5 rounded-xl p-5">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="font-heading text-body text-white">
|
|
Recent Organizations
|
|
</h3>
|
|
<button
|
|
type="button"
|
|
class="text-[11px] text-primary hover:text-primary/80 transition-colors"
|
|
onclick={() => (activeTab = "organizations")}
|
|
>View all →</button
|
|
>
|
|
</div>
|
|
<div class="space-y-2">
|
|
{#each data.organizations.slice(0, 5) as org}
|
|
<div
|
|
class="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-dark/50 transition-colors"
|
|
>
|
|
<div class="flex items-center gap-3 min-w-0">
|
|
<Avatar
|
|
name={org.name ?? "Org"}
|
|
size="sm"
|
|
/>
|
|
<div class="min-w-0">
|
|
<p
|
|
class="text-body-sm text-white truncate"
|
|
>
|
|
{org.name}
|
|
</p>
|
|
<p class="text-[10px] text-light/30">
|
|
/{org.slug}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="flex items-center gap-3 shrink-0 text-[10px] text-light/40"
|
|
>
|
|
<span>{org.memberCount} members</span>
|
|
<span>{org.eventCount} events</span>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
{#if data.organizations.length === 0}
|
|
<p
|
|
class="text-body-sm text-light/30 text-center py-4"
|
|
>
|
|
No organizations yet
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Users -->
|
|
<div class="bg-dark/30 border border-light/5 rounded-xl p-5">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="font-heading text-body text-white">
|
|
Recent Users
|
|
</h3>
|
|
<button
|
|
type="button"
|
|
class="text-[11px] text-primary hover:text-primary/80 transition-colors"
|
|
onclick={() => (activeTab = "users")}
|
|
>View all →</button
|
|
>
|
|
</div>
|
|
<div class="space-y-2">
|
|
{#each data.profiles.slice(0, 5) as profile}
|
|
<div
|
|
class="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-dark/50 transition-colors"
|
|
>
|
|
<div class="flex items-center gap-3 min-w-0">
|
|
<Avatar
|
|
name={profile.full_name ??
|
|
profile.email}
|
|
size="sm"
|
|
src={profile.avatar_url}
|
|
/>
|
|
<div class="min-w-0">
|
|
<p
|
|
class="text-body-sm text-white truncate"
|
|
>
|
|
{profile.full_name ?? "No name"}
|
|
</p>
|
|
<p class="text-[10px] text-light/30">
|
|
{profile.email}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2 shrink-0">
|
|
{#if profile.is_platform_admin}
|
|
<Badge variant="error" size="sm"
|
|
>Admin</Badge
|
|
>
|
|
{/if}
|
|
<span class="text-[10px] text-light/30"
|
|
>{timeAgo(profile.created_at)}</span
|
|
>
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
{#if data.profiles.length === 0}
|
|
<p
|
|
class="text-body-sm text-light/30 text-center py-4"
|
|
>
|
|
No users yet
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Events -->
|
|
<div
|
|
class="bg-dark/30 border border-light/5 rounded-xl p-5 lg:col-span-2"
|
|
>
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="font-heading text-body text-white">
|
|
Recent Events
|
|
</h3>
|
|
<button
|
|
type="button"
|
|
class="text-[11px] text-primary hover:text-primary/80 transition-colors"
|
|
onclick={() => (activeTab = "events")}
|
|
>View all →</button
|
|
>
|
|
</div>
|
|
{#if data.events.length > 0}
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-left">
|
|
<thead>
|
|
<tr class="border-b border-light/5">
|
|
<th
|
|
class="text-[10px] text-light/40 font-body pb-2 pr-4"
|
|
>Event</th
|
|
>
|
|
<th
|
|
class="text-[10px] text-light/40 font-body pb-2 pr-4"
|
|
>Organization</th
|
|
>
|
|
<th
|
|
class="text-[10px] text-light/40 font-body pb-2 pr-4"
|
|
>Status</th
|
|
>
|
|
<th
|
|
class="text-[10px] text-light/40 font-body pb-2 pr-4"
|
|
>Dates</th
|
|
>
|
|
<th
|
|
class="text-[10px] text-light/40 font-body pb-2"
|
|
>Created</th
|
|
>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each data.events.slice(0, 8) as event}
|
|
<tr
|
|
class="border-b border-light/5 last:border-0 hover:bg-dark/30"
|
|
>
|
|
<td class="py-2.5 pr-4">
|
|
<p
|
|
class="text-body-sm text-white"
|
|
>
|
|
{event.name}
|
|
</p>
|
|
<p
|
|
class="text-[10px] text-light/30"
|
|
>
|
|
/{event.slug}
|
|
</p>
|
|
</td>
|
|
<td
|
|
class="py-2.5 pr-4 text-body-sm text-light/50"
|
|
>
|
|
{orgMap[event.org_id]?.name ??
|
|
"-"}
|
|
</td>
|
|
<td class="py-2.5 pr-4">
|
|
<span
|
|
class="text-[10px] px-2 py-0.5 rounded-full capitalize {statusColors[
|
|
event.status
|
|
] ??
|
|
'text-light/40 bg-light/5'}"
|
|
>
|
|
{event.status}
|
|
</span>
|
|
</td>
|
|
<td
|
|
class="py-2.5 pr-4 text-[11px] text-light/40"
|
|
>
|
|
{formatDate(event.start_date)} -
|
|
{formatDate(event.end_date)}
|
|
</td>
|
|
<td
|
|
class="py-2.5 text-[11px] text-light/30"
|
|
>{timeAgo(event.created_at)}</td
|
|
>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{:else}
|
|
<p class="text-body-sm text-light/30 text-center py-4">
|
|
No events yet
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Event Status Legend -->
|
|
<div
|
|
class="bg-dark/30 border border-light/5 rounded-xl p-5 lg:col-span-2"
|
|
>
|
|
<h3 class="font-heading text-body text-white mb-3">
|
|
Event Status Guide
|
|
</h3>
|
|
<div class="grid md:grid-cols-4 gap-4">
|
|
<div class="flex items-start gap-3">
|
|
<span
|
|
class="text-[10px] px-2 py-0.5 rounded-full shrink-0 text-amber-400 bg-amber-400/10"
|
|
>planning</span
|
|
>
|
|
<p class="text-[11px] text-light/40">
|
|
Event is being prepared. Team is setting up
|
|
departments, tasks, and logistics.
|
|
</p>
|
|
</div>
|
|
<div class="flex items-start gap-3">
|
|
<span
|
|
class="text-[10px] px-2 py-0.5 rounded-full shrink-0 text-emerald-400 bg-emerald-400/10"
|
|
>active</span
|
|
>
|
|
<p class="text-[11px] text-light/40">
|
|
Event is currently live/happening. All systems
|
|
go.
|
|
</p>
|
|
</div>
|
|
<div class="flex items-start gap-3">
|
|
<span
|
|
class="text-[10px] px-2 py-0.5 rounded-full shrink-0 text-blue-400 bg-blue-400/10"
|
|
>completed</span
|
|
>
|
|
<p class="text-[11px] text-light/40">
|
|
Event has finished. Data is preserved for
|
|
review.
|
|
</p>
|
|
</div>
|
|
<div class="flex items-start gap-3">
|
|
<span
|
|
class="text-[10px] px-2 py-0.5 rounded-full shrink-0 text-light/40 bg-light/5"
|
|
>archived</span
|
|
>
|
|
<p class="text-[11px] text-light/40">
|
|
Event is hidden from normal views. Can be
|
|
restored.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{:else if activeTab === "organizations"}
|
|
<div class="space-y-4">
|
|
<div class="max-w-sm">
|
|
<Input
|
|
placeholder="Search organizations..."
|
|
icon="search"
|
|
bind:value={orgSearch}
|
|
/>
|
|
</div>
|
|
<div
|
|
class="bg-dark/30 border border-light/5 rounded-xl overflow-hidden"
|
|
>
|
|
<table class="w-full text-left">
|
|
<thead>
|
|
<tr class="border-b border-light/5 bg-dark/20">
|
|
<th
|
|
class="text-[10px] text-light/40 font-body py-3 px-4"
|
|
>Organization</th
|
|
>
|
|
<th
|
|
class="text-[10px] text-light/40 font-body py-3 px-4"
|
|
>Slug</th
|
|
>
|
|
<th
|
|
class="text-[10px] text-light/40 font-body py-3 px-4"
|
|
>Members</th
|
|
>
|
|
<th
|
|
class="text-[10px] text-light/40 font-body py-3 px-4"
|
|
>Events</th
|
|
>
|
|
<th
|
|
class="text-[10px] text-light/40 font-body py-3 px-4"
|
|
>Created</th
|
|
>
|
|
<th
|
|
class="text-[10px] text-light/40 font-body py-3 px-4 text-right"
|
|
>Actions</th
|
|
>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each filteredOrgs as org}
|
|
<tr
|
|
class="border-b border-light/5 last:border-0 hover:bg-dark/30 transition-colors"
|
|
>
|
|
<td class="py-3 px-4">
|
|
<div class="flex items-center gap-3">
|
|
<Avatar
|
|
name={org.name ?? "Org"}
|
|
size="sm"
|
|
/>
|
|
<span
|
|
class="text-body-sm text-white"
|
|
>{org.name}</span
|
|
>
|
|
</div>
|
|
</td>
|
|
<td
|
|
class="py-3 px-4 text-body-sm text-light/40"
|
|
>/{org.slug}</td
|
|
>
|
|
<td class="py-3 px-4">
|
|
<Badge variant="default" size="sm"
|
|
>{org.memberCount}</Badge
|
|
>
|
|
</td>
|
|
<td class="py-3 px-4">
|
|
<Badge variant="primary" size="sm"
|
|
>{org.eventCount}</Badge
|
|
>
|
|
</td>
|
|
<td
|
|
class="py-3 px-4 text-[11px] text-light/30"
|
|
>{formatDate(org.created_at)}</td
|
|
>
|
|
<td class="py-3 px-4">
|
|
<div
|
|
class="flex items-center justify-end gap-1"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="p-1.5 text-light/30 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
|
|
title="Edit organization"
|
|
onclick={() =>
|
|
(editOrgModal = {
|
|
id: org.id,
|
|
name: org.name,
|
|
slug: org.slug,
|
|
})}
|
|
>
|
|
<span
|
|
class="material-symbols-rounded"
|
|
style="font-size: 16px;"
|
|
>edit</span
|
|
>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="p-1.5 text-light/30 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-colors"
|
|
title="Delete organization"
|
|
onclick={() =>
|
|
(confirmModal = {
|
|
type: "deleteOrg",
|
|
id: org.id,
|
|
name: org.name,
|
|
})}
|
|
>
|
|
<span
|
|
class="material-symbols-rounded"
|
|
style="font-size: 16px;"
|
|
>delete</span
|
|
>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
{#if filteredOrgs.length === 0}
|
|
<tr>
|
|
<td
|
|
colspan="6"
|
|
class="py-8 text-center text-body-sm text-light/30"
|
|
>
|
|
{orgSearch
|
|
? "No organizations match your search"
|
|
: "No organizations yet"}
|
|
</td>
|
|
</tr>
|
|
{/if}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p class="text-[10px] text-light/30">
|
|
{filteredOrgs.length} of {data.organizations.length} organizations
|
|
</p>
|
|
</div>
|
|
{:else if activeTab === "users"}
|
|
<div class="space-y-4">
|
|
<div class="max-w-sm">
|
|
<Input
|
|
placeholder="Search users..."
|
|
icon="search"
|
|
bind:value={userSearch}
|
|
/>
|
|
</div>
|
|
<div
|
|
class="bg-dark/30 border border-light/5 rounded-xl overflow-hidden"
|
|
>
|
|
<table class="w-full text-left">
|
|
<thead>
|
|
<tr class="border-b border-light/5 bg-dark/20">
|
|
<th
|
|
class="text-[10px] text-light/40 font-body py-3 px-4"
|
|
>User</th
|
|
>
|
|
<th
|
|
class="text-[10px] text-light/40 font-body py-3 px-4"
|
|
>Email</th
|
|
>
|
|
<th
|
|
class="text-[10px] text-light/40 font-body py-3 px-4"
|
|
>Organizations</th
|
|
>
|
|
<th
|
|
class="text-[10px] text-light/40 font-body py-3 px-4"
|
|
>Role</th
|
|
>
|
|
<th
|
|
class="text-[10px] text-light/40 font-body py-3 px-4"
|
|
>Joined</th
|
|
>
|
|
<th
|
|
class="text-[10px] text-light/40 font-body py-3 px-4 text-right"
|
|
>Actions</th
|
|
>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each filteredUsers as profile}
|
|
<tr
|
|
class="border-b border-light/5 last:border-0 hover:bg-dark/30 transition-colors"
|
|
>
|
|
<td class="py-3 px-4">
|
|
<div class="flex items-center gap-3">
|
|
<Avatar
|
|
name={profile.full_name ??
|
|
profile.email}
|
|
size="sm"
|
|
src={profile.avatar_url}
|
|
/>
|
|
<span
|
|
class="text-body-sm text-white"
|
|
>{profile.full_name ??
|
|
"No name"}</span
|
|
>
|
|
</div>
|
|
</td>
|
|
<td
|
|
class="py-3 px-4 text-body-sm text-light/40"
|
|
>{profile.email}</td
|
|
>
|
|
<td class="py-3 px-4">
|
|
<Badge variant="default" size="sm"
|
|
>{userOrgCounts()[profile.id] ??
|
|
0}</Badge
|
|
>
|
|
</td>
|
|
<td class="py-3 px-4">
|
|
{#if profile.is_platform_admin}
|
|
<Badge variant="error" size="sm"
|
|
>Platform Admin</Badge
|
|
>
|
|
{:else}
|
|
<Badge variant="default" size="sm"
|
|
>User</Badge
|
|
>
|
|
{/if}
|
|
</td>
|
|
<td
|
|
class="py-3 px-4 text-[11px] text-light/30"
|
|
>{formatDate(profile.created_at)}</td
|
|
>
|
|
<td class="py-3 px-4">
|
|
<div
|
|
class="flex items-center justify-end gap-1"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="p-1.5 text-light/30 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
|
|
title="Edit user"
|
|
onclick={() =>
|
|
(editUserModal = {
|
|
id: profile.id,
|
|
full_name:
|
|
profile.full_name ??
|
|
"",
|
|
email: profile.email,
|
|
is_platform_admin:
|
|
profile.is_platform_admin,
|
|
})}
|
|
>
|
|
<span
|
|
class="material-symbols-rounded"
|
|
style="font-size: 16px;"
|
|
>edit</span
|
|
>
|
|
</button>
|
|
{#if profile.is_platform_admin && profile.id !== data.currentUserId}
|
|
<form
|
|
method="POST"
|
|
action="?/toggleAdmin"
|
|
use:enhance={enhanceAction}
|
|
>
|
|
<input
|
|
type="hidden"
|
|
name="id"
|
|
value={profile.id}
|
|
/>
|
|
<input
|
|
type="hidden"
|
|
name="is_admin"
|
|
value="false"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
class="p-1.5 text-amber-400 hover:text-amber-300 hover:bg-amber-400/10 rounded-lg transition-colors"
|
|
title="Remove admin"
|
|
>
|
|
<span
|
|
class="material-symbols-rounded"
|
|
style="font-size: 16px; font-variation-settings: 'FILL' 1;"
|
|
>shield</span
|
|
>
|
|
</button>
|
|
</form>
|
|
{:else if !profile.is_platform_admin}
|
|
<form
|
|
method="POST"
|
|
action="?/toggleAdmin"
|
|
use:enhance={enhanceAction}
|
|
>
|
|
<input
|
|
type="hidden"
|
|
name="id"
|
|
value={profile.id}
|
|
/>
|
|
<input
|
|
type="hidden"
|
|
name="is_admin"
|
|
value="true"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
class="p-1.5 text-light/30 hover:text-amber-400 hover:bg-amber-400/10 rounded-lg transition-colors"
|
|
title="Make admin"
|
|
>
|
|
<span
|
|
class="material-symbols-rounded"
|
|
style="font-size: 16px;"
|
|
>shield</span
|
|
>
|
|
</button>
|
|
</form>
|
|
{/if}
|
|
{#if profile.id !== data.currentUserId}
|
|
<button
|
|
type="button"
|
|
class="p-1.5 text-light/30 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-colors"
|
|
title="Delete user"
|
|
onclick={() =>
|
|
(confirmModal = {
|
|
type: "deleteUser",
|
|
id: profile.id,
|
|
name:
|
|
profile.full_name ??
|
|
profile.email,
|
|
})}
|
|
>
|
|
<span
|
|
class="material-symbols-rounded"
|
|
style="font-size: 16px;"
|
|
>delete</span
|
|
>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
{#if filteredUsers.length === 0}
|
|
<tr>
|
|
<td
|
|
colspan="6"
|
|
class="py-8 text-center text-body-sm text-light/30"
|
|
>
|
|
{userSearch
|
|
? "No users match your search"
|
|
: "No users yet"}
|
|
</td>
|
|
</tr>
|
|
{/if}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p class="text-[10px] text-light/30">
|
|
{filteredUsers.length} of {data.profiles.length} users
|
|
</p>
|
|
</div>
|
|
{:else if activeTab === "events"}
|
|
<div class="space-y-4">
|
|
<div class="max-w-sm">
|
|
<Input
|
|
placeholder="Search events..."
|
|
icon="search"
|
|
bind:value={eventSearch}
|
|
/>
|
|
</div>
|
|
<div
|
|
class="bg-dark/30 border border-light/5 rounded-xl overflow-hidden"
|
|
>
|
|
<table class="w-full text-left">
|
|
<thead>
|
|
<tr class="border-b border-light/5 bg-dark/20">
|
|
<th
|
|
class="text-[10px] text-light/40 font-body py-3 px-4"
|
|
>Event</th
|
|
>
|
|
<th
|
|
class="text-[10px] text-light/40 font-body py-3 px-4"
|
|
>Organization</th
|
|
>
|
|
<th
|
|
class="text-[10px] text-light/40 font-body py-3 px-4"
|
|
>Status</th
|
|
>
|
|
<th
|
|
class="text-[10px] text-light/40 font-body py-3 px-4"
|
|
>Start</th
|
|
>
|
|
<th
|
|
class="text-[10px] text-light/40 font-body py-3 px-4"
|
|
>End</th
|
|
>
|
|
<th
|
|
class="text-[10px] text-light/40 font-body py-3 px-4"
|
|
>Created</th
|
|
>
|
|
<th
|
|
class="text-[10px] text-light/40 font-body py-3 px-4 text-right"
|
|
>Actions</th
|
|
>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each filteredEvents as event}
|
|
<tr
|
|
class="border-b border-light/5 last:border-0 hover:bg-dark/30 transition-colors"
|
|
>
|
|
<td class="py-3 px-4">
|
|
<div>
|
|
<p class="text-body-sm text-white">
|
|
{event.name}
|
|
</p>
|
|
<p
|
|
class="text-[10px] text-light/30"
|
|
>
|
|
/{event.slug}
|
|
</p>
|
|
</div>
|
|
</td>
|
|
<td
|
|
class="py-3 px-4 text-body-sm text-light/40"
|
|
>
|
|
{orgMap[event.org_id]?.name ?? "-"}
|
|
</td>
|
|
<td class="py-3 px-4">
|
|
<form
|
|
method="POST"
|
|
action="?/updateEventStatus"
|
|
use:enhance={enhanceAction}
|
|
class="inline"
|
|
>
|
|
<input
|
|
type="hidden"
|
|
name="id"
|
|
value={event.id}
|
|
/>
|
|
<select
|
|
name="status"
|
|
class="text-[10px] px-2 py-0.5 rounded-full capitalize cursor-pointer border-0 appearance-none bg-transparent {statusColors[
|
|
event.status
|
|
] ??
|
|
'text-light/40 bg-light/5'}"
|
|
onchange={(e) =>
|
|
e.currentTarget.form?.requestSubmit()}
|
|
>
|
|
{#each eventStatuses as s}
|
|
<option
|
|
value={s}
|
|
selected={event.status ===
|
|
s}
|
|
class="bg-surface text-white"
|
|
>{s}</option
|
|
>
|
|
{/each}
|
|
</select>
|
|
</form>
|
|
</td>
|
|
<td
|
|
class="py-3 px-4 text-[11px] text-light/40"
|
|
>{formatDate(event.start_date)}</td
|
|
>
|
|
<td
|
|
class="py-3 px-4 text-[11px] text-light/40"
|
|
>{formatDate(event.end_date)}</td
|
|
>
|
|
<td
|
|
class="py-3 px-4 text-[11px] text-light/30"
|
|
>{timeAgo(event.created_at)}</td
|
|
>
|
|
<td class="py-3 px-4">
|
|
<div
|
|
class="flex items-center justify-end gap-1"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="p-1.5 text-light/30 hover:text-white hover:bg-dark/50 rounded-lg transition-colors"
|
|
title="Edit event"
|
|
onclick={() =>
|
|
(editEventModal = {
|
|
id: event.id,
|
|
name: event.name,
|
|
slug: event.slug,
|
|
status: event.status,
|
|
start_date:
|
|
formatDateInput(
|
|
event.start_date,
|
|
),
|
|
end_date:
|
|
formatDateInput(
|
|
event.end_date,
|
|
),
|
|
})}
|
|
>
|
|
<span
|
|
class="material-symbols-rounded"
|
|
style="font-size: 16px;"
|
|
>edit</span
|
|
>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="p-1.5 text-light/30 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-colors"
|
|
title="Delete event"
|
|
onclick={() =>
|
|
(confirmModal = {
|
|
type: "deleteEvent",
|
|
id: event.id,
|
|
name: event.name,
|
|
})}
|
|
>
|
|
<span
|
|
class="material-symbols-rounded"
|
|
style="font-size: 16px;"
|
|
>delete</span
|
|
>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
{#if filteredEvents.length === 0}
|
|
<tr>
|
|
<td
|
|
colspan="7"
|
|
class="py-8 text-center text-body-sm text-light/30"
|
|
>
|
|
{eventSearch
|
|
? "No events match your search"
|
|
: "No events yet"}
|
|
</td>
|
|
</tr>
|
|
{/if}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p class="text-[10px] text-light/30">
|
|
{filteredEvents.length} of {data.events.length} events
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Delete Confirmation Modal -->
|
|
<Modal
|
|
isOpen={!!confirmModal}
|
|
onClose={() => (confirmModal = null)}
|
|
title="Confirm Delete"
|
|
>
|
|
{#if confirmModal}
|
|
<p class="text-light/70 text-body-sm mb-2">
|
|
Are you sure you want to delete <strong class="text-white"
|
|
>{confirmModal.name}</strong
|
|
>?
|
|
</p>
|
|
<p class="text-red-400/70 text-[11px] mb-4">
|
|
This action cannot be undone. All associated data will be
|
|
permanently removed.
|
|
</p>
|
|
<form
|
|
method="POST"
|
|
action="?/{confirmModal.type}"
|
|
use:enhance={() => {
|
|
actionLoading = true;
|
|
return enhanceAction();
|
|
}}
|
|
>
|
|
<input type="hidden" name="id" value={confirmModal.id} />
|
|
<div class="flex gap-3 justify-end">
|
|
<Button
|
|
variant="secondary"
|
|
onclick={() => (confirmModal = null)}>Cancel</Button
|
|
>
|
|
<Button variant="danger" type="submit" loading={actionLoading}
|
|
>Delete</Button
|
|
>
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
</Modal>
|
|
|
|
<!-- Edit Organization Modal -->
|
|
<Modal
|
|
isOpen={!!editOrgModal}
|
|
onClose={() => (editOrgModal = null)}
|
|
title="Edit Organization"
|
|
>
|
|
{#if editOrgModal}
|
|
<form
|
|
method="POST"
|
|
action="?/updateOrg"
|
|
use:enhance={() => {
|
|
actionLoading = true;
|
|
return enhanceAction();
|
|
}}
|
|
class="space-y-4"
|
|
>
|
|
<input type="hidden" name="id" value={editOrgModal.id} />
|
|
<Input label="Name" name="name" bind:value={editOrgModal.name} />
|
|
<Input label="Slug" name="slug" bind:value={editOrgModal.slug} />
|
|
<div class="flex gap-3 justify-end">
|
|
<Button
|
|
variant="secondary"
|
|
onclick={() => (editOrgModal = null)}>Cancel</Button
|
|
>
|
|
<Button type="submit" loading={actionLoading}>Save</Button>
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
</Modal>
|
|
|
|
<!-- Edit User Modal -->
|
|
<Modal
|
|
isOpen={!!editUserModal}
|
|
onClose={() => (editUserModal = null)}
|
|
title="Edit User"
|
|
>
|
|
{#if editUserModal}
|
|
<form
|
|
method="POST"
|
|
action="?/updateUser"
|
|
use:enhance={() => {
|
|
actionLoading = true;
|
|
return enhanceAction();
|
|
}}
|
|
class="space-y-4"
|
|
>
|
|
<input type="hidden" name="id" value={editUserModal.id} />
|
|
<Input
|
|
label="Full Name"
|
|
name="full_name"
|
|
bind:value={editUserModal.full_name}
|
|
/>
|
|
<Input
|
|
label="Email"
|
|
name="email"
|
|
bind:value={editUserModal.email}
|
|
/>
|
|
<div class="flex items-center gap-3 py-2">
|
|
<label
|
|
class="flex items-center gap-2 text-body-sm text-light/60 cursor-pointer"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
bind:checked={editUserModal.is_platform_admin}
|
|
class="accent-primary"
|
|
/>
|
|
Platform Admin
|
|
</label>
|
|
</div>
|
|
<div class="flex gap-3 justify-end">
|
|
<Button
|
|
variant="secondary"
|
|
onclick={() => (editUserModal = null)}>Cancel</Button
|
|
>
|
|
<Button type="submit" loading={actionLoading}>Save</Button>
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
</Modal>
|
|
|
|
<!-- Edit Event Modal -->
|
|
<Modal
|
|
isOpen={!!editEventModal}
|
|
onClose={() => (editEventModal = null)}
|
|
title="Edit Event"
|
|
>
|
|
{#if editEventModal}
|
|
<form
|
|
method="POST"
|
|
action="?/updateEvent"
|
|
use:enhance={() => {
|
|
actionLoading = true;
|
|
return enhanceAction();
|
|
}}
|
|
class="space-y-4"
|
|
>
|
|
<input type="hidden" name="id" value={editEventModal.id} />
|
|
<Input label="Name" name="name" bind:value={editEventModal.name} />
|
|
<Input label="Slug" name="slug" bind:value={editEventModal.slug} />
|
|
<Select
|
|
variant="compact"
|
|
label="Status"
|
|
name="status"
|
|
bind:value={editEventModal.status}
|
|
placeholder=""
|
|
options={eventStatuses.map((s) => ({
|
|
value: s,
|
|
label: s.charAt(0).toUpperCase() + s.slice(1),
|
|
}))}
|
|
/>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<Input
|
|
variant="compact"
|
|
type="date"
|
|
label="Start Date"
|
|
name="start_date"
|
|
bind:value={editEventModal.start_date}
|
|
/>
|
|
<Input
|
|
variant="compact"
|
|
type="date"
|
|
label="End Date"
|
|
name="end_date"
|
|
bind:value={editEventModal.end_date}
|
|
/>
|
|
</div>
|
|
<div class="flex gap-3 justify-end">
|
|
<Button
|
|
variant="secondary"
|
|
onclick={() => (editEventModal = null)}>Cancel</Button
|
|
>
|
|
<Button type="submit" loading={actionLoading}>Save</Button>
|
|
</div>
|
|
</form>
|
|
{/if}
|
|
</Modal>
|