Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions app/(user)/dashboard/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Dashboard from "@/app/components/admin/Dashbord";
import { createClient } from "@/app/lib/supabase/server";
import { redirect } from "next/navigation";

export default async function AdminPage() {
const supabase = await createClient();

const {
data: { user },
} = await supabase.auth.getUser();

if (!user) {
redirect("/login");
}

const { data: profile } = await supabase
.from("profiles")
.select("role")
.eq("id", user.id)
.single();

if (!profile || profile.role !== "admin") {
redirect("/dashbord");
}

return <Dashboard user={user} />;
}
4 changes: 2 additions & 2 deletions app/(user)/dashboard/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default async function Layout({

const { data: profile } = await supabase
.from("profiles")
.select("wakatime_api_key, email")
.select("wakatime_api_key, email, role")
.eq("id", user.id)
.single();

Expand All @@ -29,7 +29,7 @@ export default async function Layout({
const name = user?.user_metadata?.name || email.split("@")[0];

return (
<DashboardLayout email={email} name={name}>
<DashboardLayout email={email} name={name} role={profile.role}>
{children}
</DashboardLayout>
);
Expand Down
172 changes: 172 additions & 0 deletions app/components/admin/Dashbord.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
"use client";

import { createClient } from "@/app/lib/supabase/client";
import { Database } from "@/app/supabase-types";
import { User } from "@supabase/supabase-js";
import { useEffect, useState } from "react";
import TopInsights from "./Widgets/TopInsights";
import FeatureInsights from "./Widgets/FeatureInsights";
import RankingInsights, {
AICoderStat,
CoderStats,
} from "./Widgets/RankingInsights";
import UserLists from "./Widgets/UserLists";

const supabase = createClient();

type UserStat = Database["public"]["Views"]["top_user_stats"]["Row"];
type CategoryStat = {
name: string;
users: Set<string>;
totalSeconds: number;
};

export default function Dashboard({ user }: { user: User }) {
const [loading, setLoading] = useState(false);
const [users, setUsers] = useState<UserStat[]>([]);
const [totalThreads, setTotalThreads] = useState(0);
const [totalMessages, setTotalMessages] = useState(0);
const [totalLeaderboards, setTotalLeaderboards] = useState(0);
const [totalFlexes, setTotalFlexes] = useState(0);
const categoryMap: Record<string, CategoryStat> = {};

useEffect(() => {
async function fetchUsers() {
setLoading(true);
const [
{ data: topUserStats },
{ count: threads },
{ count: messages },
{ count: leaderboard },
{ count: userFlexes },
] = await Promise.all([
supabase.from("top_user_stats").select("*"),
supabase
.from("conversations")
.select("*", { count: "exact", head: true }),
supabase.from("messages").select("*", { count: "exact", head: true }),
supabase
.from("leaderboards")
.select("*", { count: "exact", head: true }),
supabase
.from("user_flexes")
.select("*", { count: "exact", head: true }),
]);

setUsers(topUserStats || []);
setTotalThreads(threads || 0);
setTotalMessages(messages || 0);
setTotalLeaderboards(leaderboard || 0);
setTotalFlexes(userFlexes || 0);
setLoading(false);
}

fetchUsers();

const interval = setInterval(fetchUsers, 5000);
return () => clearInterval(interval);
}, [user.id]);

/*
* total users and coding time
*/
const totalUsers = users.length;
const totalSeconds = users.reduce(
(sum, u) => sum + (u.total_seconds || 0),
0,
);
const sortedUsers = [...users].sort(
(a, b) => (b.total_seconds || 0) - (a.total_seconds || 0),
);

/*
* get the top and least coders
*/
const top3 = sortedUsers.slice(0, 3);
const bottom3 = [...sortedUsers].reverse().slice(0, 3);

/*
* category stats
*/
users.forEach((u) => {
const categories = (u.categories || []) as {
name: string;
total_seconds: number;
}[];

categories.forEach((c) => {
if (!categoryMap[c.name]) {
categoryMap[c.name] = {
name: c.name,
users: new Set(),
totalSeconds: 0,
};
}

categoryMap[c.name].users.add(u.email || u.user_id || "unknown");
categoryMap[c.name].totalSeconds += c.total_seconds || 0;
});
});

const categoryStats = Object.values(categoryMap).map((c) => ({
name: c.name,
userCount: c.users.size,
hours: Math.floor(c.totalSeconds / 3600),
}));

/*
* vibe coders
*/
const aiCoders = users
.map((u) => {
const categories = (u.categories || []) as {
name: string;
total_seconds: number;
}[];

const aiTotalSeconds = categories
.filter((c) => c.name.toLowerCase().includes("ai"))
.reduce((sum, c) => sum + (c.total_seconds || 0), 0);

return {
...u,
aiTotalSeconds,
};
})
.filter((u) => u.aiTotalSeconds > 0)
.sort((a, b) => b.aiTotalSeconds - a.aiTotalSeconds)
.slice(0, 6);

return (
<div className="p-6 md:p-8 space-y-6">
{/* Header */}
<div className="flex flex-row justify-between items-center w-full">
<div>
<h1 className="text-3xl font-bold text-indigo-400">Admin Panel</h1>
</div>
</div>

<TopInsights
totalUsers={totalUsers}
totalSeconds={totalSeconds}
totalThreads={totalThreads}
totalMessages={totalMessages}
/>

<FeatureInsights
totalLeaderboards={totalLeaderboards}
totalUsers={totalUsers}
totalFlexes={totalFlexes}
/>

<RankingInsights
top3={top3 as CoderStats[]}
bottom3={bottom3 as CoderStats[]}
categoryStats={categoryStats}
aiCoders={aiCoders as AICoderStat[]}
/>

<UserLists users={users as UserStat[]} loading={loading} />
</div>
);
}
48 changes: 48 additions & 0 deletions app/components/admin/Widgets/FeatureInsights.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
export default function FeatureInsights({
totalLeaderboards,
totalUsers,
totalFlexes,
}: {
totalLeaderboards: number;
totalUsers: number;
totalFlexes: number;
}) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="p-4 rounded-2xl bg-zinc-900 border border-zinc-800">
<p className="text-sm text-gray-400 mb-2">Leaderboard Stats</p>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span>Total</span>
<span>{totalLeaderboards}</span>
</div>
<div className="flex justify-between">
<span className="truncate">Avg Users/Leaderboard</span>
<span className="truncate">
{totalLeaderboards > 0
? Math.floor(totalUsers / totalLeaderboards)
: 0}{" "}
users
</span>
</div>
</div>
</div>

<div className="p-4 rounded-2xl bg-zinc-900 border border-zinc-800">
<p className="text-sm text-gray-400 mb-2">Flex Stats</p>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span>Total</span>
<span>{totalFlexes}</span>
</div>
<div className="flex justify-between">
<span className="truncate">Avg Users/Flex</span>
<span className="truncate">
{totalFlexes > 0 ? Math.floor(totalUsers / totalFlexes) : 0} users
</span>
</div>
</div>
</div>
</div>
);
}
91 changes: 91 additions & 0 deletions app/components/admin/Widgets/RankingInsights.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
export interface CoderStats {
email: string;
total_seconds: number;
}

export interface CategoryStat {
name: string;
userCount: number;
hours: number;
}

export interface AICoderStat {
email: string;
aiTotalSeconds: number;
}

export default function RankingInsights({
top3,
bottom3,
categoryStats,
aiCoders,
}: {
top3: CoderStats[];
bottom3: CoderStats[];
categoryStats: CategoryStat[];
aiCoders: AICoderStat[];
}) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<div className="p-4 rounded-2xl bg-zinc-900 border border-zinc-800">
<p className="text-sm text-gray-400 mb-2">Top Coders</p>
<div className="space-y-1">
{top3.map((u, i) => (
<div key={i} className="flex justify-between text-sm">
<span className="truncate">
#{i + 1} {u.email}
</span>
<span className="whitespace-nowrap truncate">
{Math.floor((u.total_seconds || 0) / 3600)} hrs
</span>
</div>
))}
</div>
</div>

<div className="p-4 rounded-2xl bg-zinc-900 border border-zinc-800">
<p className="text-sm text-gray-400 mb-2">Least Coders</p>
<div className="space-y-1">
{bottom3.map((u, i) => (
<div key={i} className="flex justify-between text-sm">
<span className="truncate">
#{i + 1} {u.email}
</span>
<span className="whitespace-nowrap truncate">
{Math.floor((u.total_seconds || 0) / 3600)} hrs
</span>
</div>
))}
</div>
</div>

<div className="p-4 rounded-2xl bg-zinc-900 border border-zinc-800">
<p className="text-sm text-gray-400 mb-2">Category Stats</p>

<div className="space-y-1 text-sm">
{categoryStats.map((c, i) => (
<div key={i} className="flex justify-between">
<span>{c.name}</span>
<span>
{c.userCount} users • {c.hours} hrs
</span>
</div>
))}
</div>
</div>

<div className="p-4 rounded-2xl bg-zinc-900 border border-zinc-800">
<p className="text-sm text-gray-400 mb-2">Vibe Coders</p>

<div className="space-y-1 text-sm">
{aiCoders.map((c, i) => (
<div key={i} className="flex justify-between">
<span>{c.email}</span>
<span>{Math.floor(c.aiTotalSeconds / 3600)} hrs</span>
</div>
))}
</div>
</div>
</div>
);
}
Loading