Mega push vol 7 mvp lesgoooo
This commit is contained in:
104
src/routes/admin/+page.server.ts
Normal file
104
src/routes/admin/+page.server.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
406
src/routes/admin/+page.svelte
Normal file
406
src/routes/admin/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user