From f82988f2770ba8928658a6686c685ee0276c312d Mon Sep 17 00:00:00 2001 From: Melvin Jones Repol Date: Wed, 25 Mar 2026 17:10:28 +0800 Subject: [PATCH 1/2] feat(dashboard): Add admin panel and role support - Add server-side admin page that checks Supabase auth and redirects non-admin users - Implement Admin Dashboard and widgets under app/components/admin (TopInsights, FeatureInsights, RankingInsights, UserLists) with polling for live stats - Pass role from dashboard layout to DashboardLayout and update Navbar Sidebar to accept role and conditionally show admin nav - Add role field to supabase-types and include migration to add role column with a check constraint on profiles - Minor formatting/whitespace fixes in Stats and Navbar components --- app/(user)/dashboard/admin/page.tsx | 27 +++ app/(user)/dashboard/layout.tsx | 4 +- app/components/admin/Dashbord.tsx | 172 ++++++++++++++++++ .../admin/Widgets/FeatureInsights.tsx | 48 +++++ .../admin/Widgets/RankingInsights.tsx | 91 +++++++++ app/components/admin/Widgets/TopInsights.tsx | 49 +++++ app/components/admin/Widgets/UserLists.tsx | 51 ++++++ app/components/dashboard/Navbar.tsx | 87 ++++++--- app/components/dashboard/Stats.tsx | 10 +- app/supabase-types.ts | 3 + .../20260325072343_add_role_to_profiles.sql | 6 + 11 files changed, 513 insertions(+), 35 deletions(-) create mode 100644 app/(user)/dashboard/admin/page.tsx create mode 100644 app/components/admin/Dashbord.tsx create mode 100644 app/components/admin/Widgets/FeatureInsights.tsx create mode 100644 app/components/admin/Widgets/RankingInsights.tsx create mode 100644 app/components/admin/Widgets/TopInsights.tsx create mode 100644 app/components/admin/Widgets/UserLists.tsx create mode 100644 supabase/migrations/20260325072343_add_role_to_profiles.sql diff --git a/app/(user)/dashboard/admin/page.tsx b/app/(user)/dashboard/admin/page.tsx new file mode 100644 index 0000000..b6d0f67 --- /dev/null +++ b/app/(user)/dashboard/admin/page.tsx @@ -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 ; +} diff --git a/app/(user)/dashboard/layout.tsx b/app/(user)/dashboard/layout.tsx index 70021fa..8cfd42a 100644 --- a/app/(user)/dashboard/layout.tsx +++ b/app/(user)/dashboard/layout.tsx @@ -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(); @@ -29,7 +29,7 @@ export default async function Layout({ const name = user?.user_metadata?.name || email.split("@")[0]; return ( - + {children} ); diff --git a/app/components/admin/Dashbord.tsx b/app/components/admin/Dashbord.tsx new file mode 100644 index 0000000..ec7ea7a --- /dev/null +++ b/app/components/admin/Dashbord.tsx @@ -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; + totalSeconds: number; +}; + +export default function Dashboard({ user }: { user: User }) { + const [loading, setLoading] = useState(false); + const [users, setUsers] = useState([]); + const [totalThreads, setTotalThreads] = useState(0); + const [totalMessages, setTotalMessages] = useState(0); + const [totalLeaderboards, setTotalLeaderboards] = useState(0); + const [totalFlexes, setTotalFlexes] = useState(0); + const categoryMap: Record = {}; + + 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 ( +
+ {/* Header */} +
+
+

Admin Panel

+
+
+ + + + + + + + +
+ ); +} diff --git a/app/components/admin/Widgets/FeatureInsights.tsx b/app/components/admin/Widgets/FeatureInsights.tsx new file mode 100644 index 0000000..7472fa2 --- /dev/null +++ b/app/components/admin/Widgets/FeatureInsights.tsx @@ -0,0 +1,48 @@ +export default function FeatureInsights({ + totalLeaderboards, + totalUsers, + totalFlexes, +}: { + totalLeaderboards: number; + totalUsers: number; + totalFlexes: number; +}) { + return ( +
+
+

Leaderboard Stats

+
+
+ Total + {totalLeaderboards} +
+
+ Avg Users/Leaderboard + + {totalLeaderboards > 0 + ? Math.floor(totalUsers / totalLeaderboards) + : 0}{" "} + users + +
+
+
+ +
+

Flex Stats

+
+
+ Total + {totalFlexes} +
+
+ Avg Users/Flex + + {totalFlexes > 0 ? Math.floor(totalUsers / totalFlexes) : 0} users + +
+
+
+
+ ); +} diff --git a/app/components/admin/Widgets/RankingInsights.tsx b/app/components/admin/Widgets/RankingInsights.tsx new file mode 100644 index 0000000..faf91ca --- /dev/null +++ b/app/components/admin/Widgets/RankingInsights.tsx @@ -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 ( +
+
+

Top Coders

+
+ {top3.map((u, i) => ( +
+ + #{i + 1} {u.email} + + + {Math.floor((u.total_seconds || 0) / 3600)} hrs + +
+ ))} +
+
+ +
+

Least Coders

+
+ {bottom3.map((u, i) => ( +
+ + #{i + 1} {u.email} + + + {Math.floor((u.total_seconds || 0) / 3600)} hrs + +
+ ))} +
+
+ +
+

Category Stats

+ +
+ {categoryStats.map((c, i) => ( +
+ {c.name} + + {c.userCount} users • {c.hours} hrs + +
+ ))} +
+
+ +
+

Vibe Coders

+ +
+ {aiCoders.map((c, i) => ( +
+ {c.email} + {Math.floor(c.aiTotalSeconds / 3600)} hrs +
+ ))} +
+
+
+ ); +} diff --git a/app/components/admin/Widgets/TopInsights.tsx b/app/components/admin/Widgets/TopInsights.tsx new file mode 100644 index 0000000..93e1330 --- /dev/null +++ b/app/components/admin/Widgets/TopInsights.tsx @@ -0,0 +1,49 @@ +export default function TopInsights({ + totalUsers, + totalSeconds, + totalThreads, + totalMessages, +}: { + totalUsers: number; + totalSeconds: number; + totalThreads: number; + totalMessages: number; +}) { + return ( +
+
+

Total Users

+

{totalUsers}

+

+ (Average: {Math.floor(totalUsers / 30)} users/day) +

+
+ +
+

Total Coding Time

+

+ {Math.floor(totalSeconds / 3600)} hrs +

+

+ (Average: {Math.floor(totalSeconds / totalUsers / 3600)} hrs/user) +

+
+ +
+

Total Threads

+

{totalThreads}

+

+ (Average: {Math.floor(totalThreads / 30)} threads/day) +

+
+ +
+

Total Messages

+

{totalMessages}

+

+ (Average: {Math.floor(totalMessages / totalThreads)} msgs/thread) +

+
+
+ ); +} diff --git a/app/components/admin/Widgets/UserLists.tsx b/app/components/admin/Widgets/UserLists.tsx new file mode 100644 index 0000000..57ae82b --- /dev/null +++ b/app/components/admin/Widgets/UserLists.tsx @@ -0,0 +1,51 @@ +import { Database } from "@/app/supabase-types"; + +type UserStat = Database["public"]["Views"]["top_user_stats"]["Row"]; + +export default function UserLists({ + users, + loading, +}: { + users: UserStat[]; + loading: boolean; +}) { + return ( +
+ + + + + + + + + + + {users.map((u, i) => ( + + + + + + + ))} + + {!loading && users.length === 0 && ( + + + + )} + +
UserEmail + Total Time (hrs) + Action
{u.user_id || "N/A"}{u.email || "N/A"} + {Math.floor((u.total_seconds || 0) / 3600)} +
+ No users found +
+
+ ); +} diff --git a/app/components/dashboard/Navbar.tsx b/app/components/dashboard/Navbar.tsx index 8141bf1..d8d7b45 100644 --- a/app/components/dashboard/Navbar.tsx +++ b/app/components/dashboard/Navbar.tsx @@ -12,6 +12,7 @@ import { faChevronRight, faMessage, faCrown, + faDashboard, } from "@fortawesome/free-solid-svg-icons"; import type { IconDefinition } from "@fortawesome/free-solid-svg-icons"; @@ -22,18 +23,39 @@ const SidebarContext = createContext({ isMobile: false, }); -function Sidebar() { +function Sidebar({ role }: { role: string }) { const pathname = usePathname(); const { collapsed, mobileHidden, setMobileHidden, isMobile } = useContext(SidebarContext); - const navItems: { href: string; label: string; icon: IconDefinition }[] = [ - { href: "/dashboard", label: "Dashboard", icon: faChartLine }, - { href: "/dashboard/chat", label: "Chat", icon: faMessage }, - { href: "/dashboard/flex", label: "Flex", icon: faCrown }, - { href: "/dashboard/leaderboards", label: "Leaderboards", icon: faTrophy }, + const navItems: { + href: string; + label: string; + icon: IconDefinition; + role: string; + }[] = [ + { + href: "/dashboard/admin", + label: "Admin", + icon: faDashboard, + role: "admin", + }, + { href: "/dashboard", label: "Dashboard", icon: faChartLine, role: "user" }, + { href: "/dashboard/chat", label: "Chat", icon: faMessage, role: "user" }, + { href: "/dashboard/flex", label: "Flex", icon: faCrown, role: "user" }, + { + href: "/dashboard/leaderboards", + label: "Leaderboards", + icon: faTrophy, + role: "user", + }, ]; + const getAuthorization = (itemRole: string, userRole: string) => { + if (itemRole === "admin" && userRole !== "admin") return false; + return true; + }; + return (