Mega push vol 7 mvp lesgoooo

This commit is contained in:
AlacrisDevs
2026-02-07 21:47:47 +02:00
parent dcee479839
commit d22847f555
75 changed files with 7685 additions and 892 deletions

View File

@@ -0,0 +1,104 @@
import { error, redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
// Cast helper for columns not yet in generated types
function db(supabase: any) {
return supabase as any;
}
export const load: PageServerLoad = async ({ locals }) => {
const { session, user } = await locals.safeGetSession();
if (!session || !user) {
redirect(303, '/login');
}
// Check platform admin status
const { data: profile } = await db(locals.supabase)
.from('profiles')
.select('is_platform_admin')
.eq('id', user.id)
.single();
if (!profile?.is_platform_admin) {
error(403, 'Access denied. Platform admin only.');
}
// Fetch all platform data in parallel
const [
orgsResult,
profilesResult,
eventsResult,
orgMembersResult,
] = await Promise.all([
db(locals.supabase)
.from('organizations')
.select('*')
.order('created_at', { ascending: false }),
db(locals.supabase)
.from('profiles')
.select('id, email, full_name, avatar_url, is_platform_admin, created_at')
.order('created_at', { ascending: false }),
db(locals.supabase)
.from('events')
.select('id, name, slug, status, start_date, end_date, org_id, created_at')
.order('created_at', { ascending: false }),
db(locals.supabase)
.from('org_members')
.select('id, user_id, org_id, role')
.order('created_at', { ascending: false }),
]);
const organizations = orgsResult.data ?? [];
const profiles = profilesResult.data ?? [];
const events = eventsResult.data ?? [];
const orgMembers = orgMembersResult.data ?? [];
// Compute stats
const now = new Date();
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const newUsersLast30d = profiles.filter(
(p: any) => p.created_at && new Date(p.created_at) > thirtyDaysAgo
).length;
const newUsersLast7d = profiles.filter(
(p: any) => p.created_at && new Date(p.created_at) > sevenDaysAgo
).length;
const activeEvents = events.filter((e: any) => e.status === 'active').length;
const planningEvents = events.filter((e: any) => e.status === 'planning').length;
// Org member counts
const orgMemberCounts: Record<string, number> = {};
for (const m of orgMembers) {
orgMemberCounts[m.org_id] = (orgMemberCounts[m.org_id] || 0) + 1;
}
// Org event counts
const orgEventCounts: Record<string, number> = {};
for (const e of events) {
if (e.org_id) {
orgEventCounts[e.org_id] = (orgEventCounts[e.org_id] || 0) + 1;
}
}
return {
organizations: organizations.map((o: any) => ({
...o,
memberCount: orgMemberCounts[o.id] || 0,
eventCount: orgEventCounts[o.id] || 0,
})),
profiles,
events,
stats: {
totalUsers: profiles.length,
totalOrgs: organizations.length,
totalEvents: events.length,
totalMemberships: orgMembers.length,
newUsersLast30d,
newUsersLast7d,
activeEvents,
planningEvents,
},
};
};

View File

@@ -0,0 +1,406 @@
<script lang="ts">
import { Button, Badge, Avatar, Card, StatCard, TabBar, Input } from "$lib/components/ui";
let { data } = $props();
let activeTab = $state("overview");
let orgSearch = $state("");
let userSearch = $state("");
let eventSearch = $state("");
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 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",
};
// Find org name by id
const orgMap = $derived(
Object.fromEntries(data.organizations.map((o: any) => [o.id, o])),
);
</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-8 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="Memberships" value={data.stats.totalMemberships} icon="badge" />
<StatCard label="New (7d)" value={data.stats.newUsersLast7d} icon="person_add" />
<StatCard label="New (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>
</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>
</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>
</tr>
{/each}
{#if filteredOrgs.length === 0}
<tr>
<td colspan="5" 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">Role</th>
<th class="text-[10px] text-light/40 font-body py-3 px-4">Joined</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">
{#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>
</tr>
{/each}
{#if filteredUsers.length === 0}
<tr>
<td colspan="4" 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>
</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">
<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-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>
</tr>
{/each}
{#if filteredEvents.length === 0}
<tr>
<td colspan="6" 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>