Skip to content

Commit eb4c004

Browse files
authored
Merge pull request #47 from mrepol742/master
feat(dashboard): Add admin panel and role support
2 parents 03c933e + 6741578 commit eb4c004

11 files changed

Lines changed: 535 additions & 35 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import Dashboard from "@/app/components/admin/Dashbord";
2+
import { createClient } from "@/app/lib/supabase/server";
3+
import { redirect } from "next/navigation";
4+
5+
export default async function AdminPage() {
6+
const supabase = await createClient();
7+
8+
const {
9+
data: { user },
10+
} = await supabase.auth.getUser();
11+
12+
if (!user) {
13+
redirect("/login");
14+
}
15+
16+
const { data: profile } = await supabase
17+
.from("profiles")
18+
.select("role")
19+
.eq("id", user.id)
20+
.single();
21+
22+
if (!profile || profile.role !== "admin") {
23+
redirect("/dashbord");
24+
}
25+
26+
return <Dashboard user={user} />;
27+
}

app/(user)/dashboard/layout.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export default async function Layout({
1717

1818
const { data: profile } = await supabase
1919
.from("profiles")
20-
.select("wakatime_api_key, email")
20+
.select("wakatime_api_key, email, role")
2121
.eq("id", user.id)
2222
.single();
2323

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

3131
return (
32-
<DashboardLayout email={email} name={name}>
32+
<DashboardLayout email={email} name={name} role={profile.role}>
3333
{children}
3434
</DashboardLayout>
3535
);

app/components/admin/Dashbord.tsx

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
"use client";
2+
3+
import { createClient } from "@/app/lib/supabase/client";
4+
import { Database } from "@/app/supabase-types";
5+
import { User } from "@supabase/supabase-js";
6+
import { useEffect, useState } from "react";
7+
import TopInsights from "./Widgets/TopInsights";
8+
import FeatureInsights from "./Widgets/FeatureInsights";
9+
import RankingInsights, {
10+
AICoderStat,
11+
CoderStats,
12+
} from "./Widgets/RankingInsights";
13+
import UserLists from "./Widgets/UserLists";
14+
15+
const supabase = createClient();
16+
17+
type UserStat = Database["public"]["Views"]["top_user_stats"]["Row"];
18+
type CategoryStat = {
19+
name: string;
20+
users: Set<string>;
21+
totalSeconds: number;
22+
};
23+
24+
export default function Dashboard({ user }: { user: User }) {
25+
const [loading, setLoading] = useState(false);
26+
const [users, setUsers] = useState<UserStat[]>([]);
27+
const [totalThreads, setTotalThreads] = useState(0);
28+
const [totalMessages, setTotalMessages] = useState(0);
29+
const [totalLeaderboards, setTotalLeaderboards] = useState(0);
30+
const [totalFlexes, setTotalFlexes] = useState(0);
31+
const categoryMap: Record<string, CategoryStat> = {};
32+
33+
useEffect(() => {
34+
async function fetchUsers() {
35+
setLoading(true);
36+
const [
37+
{ data: topUserStats },
38+
{ count: threads },
39+
{ count: messages },
40+
{ count: leaderboard },
41+
{ count: userFlexes },
42+
] = await Promise.all([
43+
supabase.from("top_user_stats").select("*"),
44+
supabase
45+
.from("conversations")
46+
.select("*", { count: "exact", head: true }),
47+
supabase.from("messages").select("*", { count: "exact", head: true }),
48+
supabase
49+
.from("leaderboards")
50+
.select("*", { count: "exact", head: true }),
51+
supabase
52+
.from("user_flexes")
53+
.select("*", { count: "exact", head: true }),
54+
]);
55+
56+
setUsers(topUserStats || []);
57+
setTotalThreads(threads || 0);
58+
setTotalMessages(messages || 0);
59+
setTotalLeaderboards(leaderboard || 0);
60+
setTotalFlexes(userFlexes || 0);
61+
setLoading(false);
62+
}
63+
64+
fetchUsers();
65+
66+
const interval = setInterval(fetchUsers, 5000);
67+
return () => clearInterval(interval);
68+
}, [user.id]);
69+
70+
/*
71+
* total users and coding time
72+
*/
73+
const totalUsers = users.length;
74+
const totalSeconds = users.reduce(
75+
(sum, u) => sum + (u.total_seconds || 0),
76+
0,
77+
);
78+
const sortedUsers = [...users].sort(
79+
(a, b) => (b.total_seconds || 0) - (a.total_seconds || 0),
80+
);
81+
82+
/*
83+
* get the top and least coders
84+
*/
85+
const top3 = sortedUsers.slice(0, 3);
86+
const bottom3 = [...sortedUsers].reverse().slice(0, 3);
87+
88+
/*
89+
* category stats
90+
*/
91+
users.forEach((u) => {
92+
const categories = (u.categories || []) as {
93+
name: string;
94+
total_seconds: number;
95+
}[];
96+
97+
categories.forEach((c) => {
98+
if (!categoryMap[c.name]) {
99+
categoryMap[c.name] = {
100+
name: c.name,
101+
users: new Set(),
102+
totalSeconds: 0,
103+
};
104+
}
105+
106+
categoryMap[c.name].users.add(u.email || u.user_id || "unknown");
107+
categoryMap[c.name].totalSeconds += c.total_seconds || 0;
108+
});
109+
});
110+
111+
const categoryStats = Object.values(categoryMap).map((c) => ({
112+
name: c.name,
113+
userCount: c.users.size,
114+
hours: Math.floor(c.totalSeconds / 3600),
115+
}));
116+
117+
/*
118+
* vibe coders
119+
*/
120+
const aiCoders = users
121+
.map((u) => {
122+
const categories = (u.categories || []) as {
123+
name: string;
124+
total_seconds: number;
125+
}[];
126+
127+
const aiTotalSeconds = categories
128+
.filter((c) => c.name.toLowerCase().includes("ai"))
129+
.reduce((sum, c) => sum + (c.total_seconds || 0), 0);
130+
131+
return {
132+
...u,
133+
aiTotalSeconds,
134+
};
135+
})
136+
.filter((u) => u.aiTotalSeconds > 0)
137+
.sort((a, b) => b.aiTotalSeconds - a.aiTotalSeconds)
138+
.slice(0, 6);
139+
140+
return (
141+
<div className="p-6 md:p-8 space-y-6">
142+
{/* Header */}
143+
<div className="flex flex-row justify-between items-center w-full">
144+
<div>
145+
<h1 className="text-3xl font-bold text-indigo-400">Admin Panel</h1>
146+
</div>
147+
</div>
148+
149+
<TopInsights
150+
totalUsers={totalUsers}
151+
totalSeconds={totalSeconds}
152+
totalThreads={totalThreads}
153+
totalMessages={totalMessages}
154+
/>
155+
156+
<FeatureInsights
157+
totalLeaderboards={totalLeaderboards}
158+
totalUsers={totalUsers}
159+
totalFlexes={totalFlexes}
160+
/>
161+
162+
<RankingInsights
163+
top3={top3 as CoderStats[]}
164+
bottom3={bottom3 as CoderStats[]}
165+
categoryStats={categoryStats}
166+
aiCoders={aiCoders as AICoderStat[]}
167+
/>
168+
169+
<UserLists users={users as UserStat[]} loading={loading} />
170+
</div>
171+
);
172+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
export default function FeatureInsights({
2+
totalLeaderboards,
3+
totalUsers,
4+
totalFlexes,
5+
}: {
6+
totalLeaderboards: number;
7+
totalUsers: number;
8+
totalFlexes: number;
9+
}) {
10+
return (
11+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
12+
<div className="p-4 rounded-2xl bg-zinc-900 border border-zinc-800">
13+
<p className="text-sm text-gray-400 mb-2">Leaderboard Stats</p>
14+
<div className="space-y-1 text-sm">
15+
<div className="flex justify-between">
16+
<span>Total</span>
17+
<span>{totalLeaderboards}</span>
18+
</div>
19+
<div className="flex justify-between">
20+
<span className="truncate">Avg Users/Leaderboard</span>
21+
<span className="truncate">
22+
{totalLeaderboards > 0
23+
? Math.floor(totalUsers / totalLeaderboards)
24+
: 0}{" "}
25+
users
26+
</span>
27+
</div>
28+
</div>
29+
</div>
30+
31+
<div className="p-4 rounded-2xl bg-zinc-900 border border-zinc-800">
32+
<p className="text-sm text-gray-400 mb-2">Flex Stats</p>
33+
<div className="space-y-1 text-sm">
34+
<div className="flex justify-between">
35+
<span>Total</span>
36+
<span>{totalFlexes}</span>
37+
</div>
38+
<div className="flex justify-between">
39+
<span className="truncate">Avg Users/Flex</span>
40+
<span className="truncate">
41+
{totalFlexes > 0 ? Math.floor(totalUsers / totalFlexes) : 0} users
42+
</span>
43+
</div>
44+
</div>
45+
</div>
46+
</div>
47+
);
48+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
export interface CoderStats {
2+
email: string;
3+
total_seconds: number;
4+
}
5+
6+
export interface CategoryStat {
7+
name: string;
8+
userCount: number;
9+
hours: number;
10+
}
11+
12+
export interface AICoderStat {
13+
email: string;
14+
aiTotalSeconds: number;
15+
}
16+
17+
export default function RankingInsights({
18+
top3,
19+
bottom3,
20+
categoryStats,
21+
aiCoders,
22+
}: {
23+
top3: CoderStats[];
24+
bottom3: CoderStats[];
25+
categoryStats: CategoryStat[];
26+
aiCoders: AICoderStat[];
27+
}) {
28+
return (
29+
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
30+
<div className="p-4 rounded-2xl bg-zinc-900 border border-zinc-800">
31+
<p className="text-sm text-gray-400 mb-2">Top Coders</p>
32+
<div className="space-y-1">
33+
{top3.map((u, i) => (
34+
<div key={i} className="flex justify-between text-sm">
35+
<span className="truncate">
36+
#{i + 1} {u.email}
37+
</span>
38+
<span className="whitespace-nowrap truncate">
39+
{Math.floor((u.total_seconds || 0) / 3600)} hrs
40+
</span>
41+
</div>
42+
))}
43+
</div>
44+
</div>
45+
46+
<div className="p-4 rounded-2xl bg-zinc-900 border border-zinc-800">
47+
<p className="text-sm text-gray-400 mb-2">Least Coders</p>
48+
<div className="space-y-1">
49+
{bottom3.map((u, i) => (
50+
<div key={i} className="flex justify-between text-sm">
51+
<span className="truncate">
52+
#{i + 1} {u.email}
53+
</span>
54+
<span className="whitespace-nowrap truncate">
55+
{Math.floor((u.total_seconds || 0) / 3600)} hrs
56+
</span>
57+
</div>
58+
))}
59+
</div>
60+
</div>
61+
62+
<div className="p-4 rounded-2xl bg-zinc-900 border border-zinc-800">
63+
<p className="text-sm text-gray-400 mb-2">Category Stats</p>
64+
65+
<div className="space-y-1 text-sm">
66+
{categoryStats.map((c, i) => (
67+
<div key={i} className="flex justify-between">
68+
<span>{c.name}</span>
69+
<span>
70+
{c.userCount} users • {c.hours} hrs
71+
</span>
72+
</div>
73+
))}
74+
</div>
75+
</div>
76+
77+
<div className="p-4 rounded-2xl bg-zinc-900 border border-zinc-800">
78+
<p className="text-sm text-gray-400 mb-2">Vibe Coders</p>
79+
80+
<div className="space-y-1 text-sm">
81+
{aiCoders.map((c, i) => (
82+
<div key={i} className="flex justify-between">
83+
<span>{c.email}</span>
84+
<span>{Math.floor(c.aiTotalSeconds / 3600)} hrs</span>
85+
</div>
86+
))}
87+
</div>
88+
</div>
89+
</div>
90+
);
91+
}

0 commit comments

Comments
 (0)