feat(promptmasters): add leaderboard functionality for different time periods

This commit is contained in:
Fatih Kadir Akın
2026-01-09 17:06:32 +03:00
parent 1f8e68db85
commit f74ef09b74
3 changed files with 271 additions and 222 deletions
+18 -221
View File
@@ -1,225 +1,22 @@
"use client";
import { getTranslations } from "next-intl/server";
import { PromptmastersContent } from "@/components/promptmasters/promptmasters-content";
import { useState, useEffect, useMemo } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Trophy, Medal, Award, BarChart3, TrendingUp } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export default async function PromptmastersPage() {
const t = await getTranslations("promptmasters");
type SortMode = "total" | "perPrompt";
const translations = {
title: t("title"),
description: t("description"),
allTime: t("allTime"),
thisMonth: t("thisMonth"),
thisWeek: t("thisWeek"),
noData: t("noData"),
prompts: t("prompts"),
upvotes: t("upvotes"),
perPrompt: t("perPrompt"),
sortByTotal: t("sortByTotal"),
sortByRatio: t("sortByRatio"),
};
interface LeaderboardUser {
id: string;
name: string | null;
username: string;
avatar: string | null;
totalUpvotes: number;
promptCount: number;
}
interface LeaderboardData {
period: string;
leaderboard: LeaderboardUser[];
}
function LeaderboardSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 p-3">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1">
<Skeleton className="h-4 w-32 mb-1" />
<Skeleton className="h-3 w-20" />
</div>
<Skeleton className="h-6 w-16" />
</div>
))}
</div>
);
}
function RankBadge({ rank }: { rank: number }) {
if (rank === 1) {
return <Trophy className="h-6 w-6 text-yellow-500" />;
} else if (rank === 2) {
return <Medal className="h-6 w-6 text-gray-400" />;
} else if (rank === 3) {
return <Award className="h-6 w-6 text-amber-600" />;
}
return (
<span className="w-6 h-6 flex items-center justify-center text-sm font-medium text-muted-foreground">
{rank}
</span>
);
}
function LeaderboardList({ users, sortMode }: { users: LeaderboardUser[]; sortMode: SortMode }) {
const t = useTranslations("promptmasters");
const sortedUsers = useMemo(() => {
return [...users].sort((a, b) => {
if (sortMode === "perPrompt") {
const aRatio = a.promptCount > 0 ? a.totalUpvotes / a.promptCount : 0;
const bRatio = b.promptCount > 0 ? b.totalUpvotes / b.promptCount : 0;
return bRatio - aRatio;
}
return b.totalUpvotes - a.totalUpvotes;
});
}, [users, sortMode]);
if (users.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground">
{t("noData")}
</div>
);
}
return (
<div className="divide-y">
{sortedUsers.map((user, index) => (
<Link
key={user.id}
href={`/@${user.username}`}
prefetch={false}
className="flex items-center gap-4 p-3 hover:bg-muted/50 transition-colors"
>
<div className="w-8 flex justify-center">
<RankBadge rank={index + 1} />
</div>
<Avatar className="h-10 w-10">
<AvatarImage src={user.avatar || undefined} alt={user.name || user.username} />
<AvatarFallback>
{(user.name || user.username).slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{user.name || user.username}</p>
<p className="text-sm text-muted-foreground">@{user.username}</p>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="text-center">
<p className="font-semibold">{user.promptCount}</p>
<p className="text-xs text-muted-foreground">{t("prompts")}</p>
</div>
<div className="text-center">
<p className="font-semibold text-primary">
{sortMode === "perPrompt"
? (user.promptCount > 0 ? (user.totalUpvotes / user.promptCount).toFixed(1) : "0")
: user.totalUpvotes}
</p>
<p className="text-xs text-muted-foreground">
{sortMode === "perPrompt" ? t("perPrompt") : t("upvotes")}
</p>
</div>
</div>
</Link>
))}
</div>
);
}
export default function PromptmastersPage() {
const t = useTranslations("promptmasters");
const [allTime, setAllTime] = useState<LeaderboardData | null>(null);
const [monthly, setMonthly] = useState<LeaderboardData | null>(null);
const [weekly, setWeekly] = useState<LeaderboardData | null>(null);
const [loading, setLoading] = useState(true);
const [sortMode, setSortMode] = useState<SortMode>("total");
useEffect(() => {
async function fetchLeaderboards() {
try {
const [allRes, monthRes, weekRes] = await Promise.all([
fetch("/api/leaderboard?period=all"),
fetch("/api/leaderboard?period=month"),
fetch("/api/leaderboard?period=week"),
]);
if (allRes.ok) setAllTime(await allRes.json());
if (monthRes.ok) setMonthly(await monthRes.json());
if (weekRes.ok) setWeekly(await weekRes.json());
} catch (error) {
console.error("Failed to fetch leaderboards:", error);
} finally {
setLoading(false);
}
}
fetchLeaderboards();
}, []);
return (
<div className="container py-8">
<div className="max-w-3xl mx-auto">
<div className="text-center mb-8">
<div className="flex items-center justify-center gap-2 mb-2">
<Trophy className="h-8 w-8 text-yellow-500" />
<h1 className="text-3xl font-bold">{t("title")}</h1>
</div>
<p className="text-muted-foreground">{t("description")}</p>
</div>
<Tabs defaultValue="all" className="w-full">
<div className="flex items-center gap-2 mb-6">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="all">{t("allTime")}</TabsTrigger>
<TabsTrigger value="month">{t("thisMonth")}</TabsTrigger>
<TabsTrigger value="week">{t("thisWeek")}</TabsTrigger>
</TabsList>
<div className="flex-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setSortMode(sortMode === "total" ? "perPrompt" : "total")}
className="shrink-0"
>
{sortMode === "total" ? (
<BarChart3 className="h-4 w-4" />
) : (
<TrendingUp className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{sortMode === "total" ? t("sortByTotal") : t("sortByRatio")}
</TooltipContent>
</Tooltip>
</div>
<TabsContent value="all" className="mt-0">
{loading ? (
<LeaderboardSkeleton />
) : (
<LeaderboardList users={allTime?.leaderboard || []} sortMode={sortMode} />
)}
</TabsContent>
<TabsContent value="month" className="mt-0">
{loading ? (
<LeaderboardSkeleton />
) : (
<LeaderboardList users={monthly?.leaderboard || []} sortMode={sortMode} />
)}
</TabsContent>
<TabsContent value="week" className="mt-0">
{loading ? (
<LeaderboardSkeleton />
) : (
<LeaderboardList users={weekly?.leaderboard || []} sortMode={sortMode} />
)}
</TabsContent>
</Tabs>
</div>
</div>
);
return <PromptmastersContent translations={translations} />;
}
@@ -0,0 +1,247 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import Link from "next/link";
import { Trophy, Medal, Award, BarChart3, TrendingUp } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
type SortMode = "total" | "perPrompt";
interface LeaderboardUser {
id: string;
name: string | null;
username: string;
avatar: string | null;
totalUpvotes: number;
promptCount: number;
}
interface LeaderboardData {
period: string;
leaderboard: LeaderboardUser[];
}
interface Translations {
title: string;
description: string;
allTime: string;
thisMonth: string;
thisWeek: string;
noData: string;
prompts: string;
upvotes: string;
perPrompt: string;
sortByTotal: string;
sortByRatio: string;
}
interface PromptmastersContentProps {
translations: Translations;
}
function LeaderboardSkeleton() {
return (
<div className="space-y-3">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 p-3">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1">
<Skeleton className="h-4 w-32 mb-1" />
<Skeleton className="h-3 w-20" />
</div>
<Skeleton className="h-6 w-16" />
</div>
))}
</div>
);
}
function RankBadge({ rank }: { rank: number }) {
if (rank === 1) {
return <Trophy className="h-6 w-6 text-yellow-500" />;
} else if (rank === 2) {
return <Medal className="h-6 w-6 text-gray-400" />;
} else if (rank === 3) {
return <Award className="h-6 w-6 text-amber-600" />;
}
return (
<span className="w-6 h-6 flex items-center justify-center text-sm font-medium text-muted-foreground">
{rank}
</span>
);
}
function LeaderboardList({
users,
sortMode,
translations
}: {
users: LeaderboardUser[];
sortMode: SortMode;
translations: Translations;
}) {
const sortedUsers = useMemo(() => {
return [...users].sort((a, b) => {
if (sortMode === "perPrompt") {
const aRatio = a.promptCount > 0 ? a.totalUpvotes / a.promptCount : 0;
const bRatio = b.promptCount > 0 ? b.totalUpvotes / b.promptCount : 0;
return bRatio - aRatio;
}
return b.totalUpvotes - a.totalUpvotes;
});
}, [users, sortMode]);
if (users.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground">
{translations.noData}
</div>
);
}
return (
<div className="divide-y">
{sortedUsers.map((user, index) => (
<Link
key={user.id}
href={`/@${user.username}`}
prefetch={false}
className="flex items-center gap-4 p-3 hover:bg-muted/50 transition-colors"
>
<div className="w-8 flex justify-center">
<RankBadge rank={index + 1} />
</div>
<Avatar className="h-10 w-10">
<AvatarImage src={user.avatar || undefined} alt={user.name || user.username} />
<AvatarFallback>
{(user.name || user.username).slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{user.name || user.username}</p>
<p className="text-sm text-muted-foreground">@{user.username}</p>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="text-center">
<p className="font-semibold">{user.promptCount}</p>
<p className="text-xs text-muted-foreground">{translations.prompts}</p>
</div>
<div className="text-center">
<p className="font-semibold text-primary">
{sortMode === "perPrompt"
? (user.promptCount > 0 ? (user.totalUpvotes / user.promptCount).toFixed(1) : "0")
: user.totalUpvotes}
</p>
<p className="text-xs text-muted-foreground">
{sortMode === "perPrompt" ? translations.perPrompt : translations.upvotes}
</p>
</div>
</div>
</Link>
))}
</div>
);
}
export function PromptmastersContent({ translations }: PromptmastersContentProps) {
const [allTime, setAllTime] = useState<LeaderboardData | null>(null);
const [monthly, setMonthly] = useState<LeaderboardData | null>(null);
const [weekly, setWeekly] = useState<LeaderboardData | null>(null);
const [loading, setLoading] = useState(true);
const [sortMode, setSortMode] = useState<SortMode>("total");
useEffect(() => {
async function fetchLeaderboards() {
try {
const [allRes, monthRes, weekRes] = await Promise.all([
fetch("/api/leaderboard?period=all"),
fetch("/api/leaderboard?period=month"),
fetch("/api/leaderboard?period=week"),
]);
if (allRes.ok) setAllTime(await allRes.json());
if (monthRes.ok) setMonthly(await monthRes.json());
if (weekRes.ok) setWeekly(await weekRes.json());
} catch (error) {
console.error("Failed to fetch leaderboards:", error);
} finally {
setLoading(false);
}
}
fetchLeaderboards();
}, []);
return (
<div className="container py-8">
<div className="max-w-3xl mx-auto">
<div className="text-center mb-8">
<div className="flex items-center justify-center gap-2 mb-2">
<Trophy className="h-8 w-8 text-yellow-500" />
<h1 className="text-3xl font-bold">{translations.title}</h1>
</div>
<p className="text-muted-foreground">{translations.description}</p>
</div>
<Tabs defaultValue="all" className="w-full">
<div className="flex items-center gap-2 mb-6">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="all">{translations.allTime}</TabsTrigger>
<TabsTrigger value="month">{translations.thisMonth}</TabsTrigger>
<TabsTrigger value="week">{translations.thisWeek}</TabsTrigger>
</TabsList>
<div className="flex-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setSortMode(sortMode === "total" ? "perPrompt" : "total")}
className="shrink-0"
>
{sortMode === "total" ? (
<BarChart3 className="h-4 w-4" />
) : (
<TrendingUp className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{sortMode === "total" ? translations.sortByTotal : translations.sortByRatio}
</TooltipContent>
</Tooltip>
</div>
<TabsContent value="all" className="mt-0">
{loading ? (
<LeaderboardSkeleton />
) : (
<LeaderboardList users={allTime?.leaderboard || []} sortMode={sortMode} translations={translations} />
)}
</TabsContent>
<TabsContent value="month" className="mt-0">
{loading ? (
<LeaderboardSkeleton />
) : (
<LeaderboardList users={monthly?.leaderboard || []} sortMode={sortMode} translations={translations} />
)}
</TabsContent>
<TabsContent value="week" className="mt-0">
{loading ? (
<LeaderboardSkeleton />
) : (
<LeaderboardList users={weekly?.leaderboard || []} sortMode={sortMode} translations={translations} />
)}
</TabsContent>
</Tabs>
</div>
</div>
);
}
+6 -1
View File
@@ -21,8 +21,13 @@ export function FilterProvider({ children }: { children: ReactNode }) {
export function useFilterContext() {
const context = useContext(FilterContext);
// Return safe defaults if no provider exists (e.g., during SSR errors)
// This prevents crashes when the component tree errors before reaching FilterProvider
if (!context) {
throw new Error("useFilterContext must be used within FilterProvider");
return {
isFilterPending: false,
setFilterPending: () => {},
};
}
return context;
}