-
Notifications
You must be signed in to change notification settings - Fork 18
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
14 changed files
with
1,311 additions
and
150 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
name: Update Leaderboard Every Friday | ||
|
||
on: | ||
schedule: | ||
- cron: "0 19 * * 5" | ||
|
||
jobs: | ||
update-leaderboard: | ||
runs-on: ubuntu-latest | ||
|
||
steps: | ||
- name: Trigger API to Update Leaderboard | ||
run: | | ||
response=$(curl -X POST -s -w "%{http_code}" "https://pointblank.club/api/hustle") | ||
if [ "${response: -3}" != "200" ]; then | ||
echo "API call failed with status: ${response: -3}" | ||
exit 1 | ||
fi |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -37,4 +37,7 @@ next-env.d.ts | |
|
||
# env | ||
.env | ||
.env.local | ||
.env.local | ||
|
||
# include package-lock.json | ||
!/package-lock.json |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,144 @@ | ||
import { NextResponse } from "next/server"; | ||
import axios from "axios"; | ||
// import { JSDOM } from "jsdom"; | ||
import puppeteer from "puppeteer"; | ||
|
||
import { | ||
getFirestore, | ||
doc, | ||
setDoc, | ||
getDoc, | ||
} from "firebase/firestore"; | ||
import { app } from "@/Firebase"; | ||
|
||
interface ContestRanking { | ||
rank: number; | ||
name: string; | ||
score: number; | ||
} | ||
|
||
interface LeaderboardUser { | ||
name: string; | ||
score: number; | ||
consistency: number; | ||
rank?: number; | ||
} | ||
|
||
interface LeaderboardData { | ||
rankings?: LeaderboardUser[]; | ||
updatedAt?: Date; | ||
lastContestCode?: string; | ||
} | ||
|
||
export async function POST() { | ||
try { | ||
const db = getFirestore(app); | ||
|
||
const API_URL = | ||
process.env.VJUDGE_CONTEST_API || | ||
"https://vjudge.net/contest/data?draw=2&start=0&length=20&sortDir=desc&sortCol=0&category=mine&running=3&title=&owner=Pbhustle&_=1733642420751"; | ||
|
||
const { data } = await axios.get(API_URL); | ||
const ccode = data.data[0][0]; | ||
|
||
const url = `https://vjudge.net/contest/${ccode}#rank`; | ||
|
||
const leaderboardRef = doc(db, "hustle", "leaderboard"); | ||
const leaderboardDoc = await getDoc(leaderboardRef); | ||
|
||
const existingData = leaderboardDoc.data() as LeaderboardData | undefined; | ||
const lastContestCode = existingData?.lastContestCode; | ||
|
||
if (lastContestCode === ccode) { | ||
console.log("This contest has already been processed. Skipping update."); | ||
return NextResponse.json({ | ||
message: "Leaderboard is already up-to-date.", | ||
}); | ||
} | ||
|
||
const browser = await puppeteer.launch(); | ||
const page = await browser.newPage(); | ||
|
||
await page.goto(url, { waitUntil: "networkidle2" }); | ||
|
||
await page.waitForSelector("#contest-rank-table tbody"); | ||
|
||
const latest: ContestRanking[] = await page.evaluate(() => { | ||
const rows = Array.from( | ||
document.querySelectorAll("#contest-rank-table tbody tr") | ||
); | ||
return rows.slice(0).map((row) => { | ||
const cells = row.querySelectorAll("td"); | ||
return { | ||
rank: parseInt(cells[0]?.textContent?.trim() || "0"), | ||
name: cells[1]?.textContent?.trim() || "", | ||
score: parseInt(cells[2]?.textContent?.trim() || "0"), | ||
}; | ||
}); | ||
}); | ||
|
||
await browser.close(); | ||
|
||
const latestRef = doc(db, "hustle", "latest"); | ||
await setDoc(latestRef, { | ||
results: latest, | ||
updateTime: new Date(), | ||
}); | ||
|
||
let rankings: LeaderboardUser[] = existingData?.rankings || []; | ||
|
||
latest.forEach(({ name, score }) => { | ||
const existingUser = rankings.find((user) => user.name === name); | ||
if (existingUser) { | ||
existingUser.score += score; | ||
existingUser.consistency += 1; | ||
} else { | ||
rankings.push({ name, score, consistency: 1 }); | ||
} | ||
}); | ||
|
||
rankings.sort((a, b) => { | ||
if (b.score !== a.score) return b.score - a.score; | ||
if (b.consistency !== a.consistency) return b.consistency - a.consistency; | ||
return (a.rank || 0) - (b.rank || 0); | ||
}); | ||
|
||
rankings.forEach((user, index) => { | ||
user.rank = index + 1; | ||
}); | ||
|
||
await setDoc(leaderboardRef, { | ||
rankings, | ||
updatedAt: new Date(), | ||
lastContestCode: ccode, | ||
}); | ||
|
||
return NextResponse.json({ | ||
message: "Leaderboard updated successfully", | ||
rankings, | ||
}); | ||
} catch (error) { | ||
console.error("Error updating leaderboard:", error); | ||
return NextResponse.json({ error: "Failed to update leaderboard" }); | ||
} | ||
} | ||
|
||
export async function GET() { | ||
try { | ||
const db = getFirestore(); | ||
|
||
const latestDoc = await getDoc(doc(db, "hustle", "latest")); | ||
const leaderboardDoc = await getDoc(doc(db, "hustle", "leaderboard")); | ||
|
||
return NextResponse.json({ | ||
message: "Fetched hustle data successfully", | ||
data: { | ||
latest: latestDoc.data(), | ||
leaderboard: leaderboardDoc.data(), | ||
}, | ||
}); | ||
} catch (error) { | ||
console.error("Error fetching hustle data:", error); | ||
return NextResponse.json({ error: "Failed to fetch hustle data" }); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,209 @@ | ||
"use client"; | ||
|
||
import React, { useState, useEffect } from "react"; | ||
import { motion } from "framer-motion"; | ||
import { Trophy, RefreshCw, TableProperties } from "lucide-react"; | ||
import { auth } from "../../../Firebase"; // Firebase setup | ||
import { onAuthStateChanged } from "firebase/auth"; | ||
|
||
interface Result { | ||
rank: number; | ||
name: string; | ||
score: number; | ||
consistency?: number[]; // For Rankings only | ||
} | ||
|
||
export default function ResultsTable() { | ||
const [tab, setTab] = useState<"latest" | "rankings">("latest"); | ||
const [isAdmin, setIsAdmin] = useState(false); | ||
const [latestResults, setLatestResults] = useState<Result[]>([]); | ||
const [rankings, setRankings] = useState<Result[]>([]); | ||
const [loading, setLoading] = useState(true); | ||
const [isAdminLoggedIn, setIsAdminLoggedIn] = useState(false); | ||
|
||
useEffect(() => { | ||
const fetchData = async () => { | ||
try { | ||
const response = await fetch("/api/hustle"); | ||
const data = await response.json(); | ||
|
||
if (response.ok && data?.data) { | ||
const allDocs = data.data; | ||
const latestData: Result[] = allDocs.latest.results || []; | ||
const rankingsData: Result[] = allDocs.leaderboard.rankings || []; | ||
|
||
setLatestResults(latestData); | ||
setRankings(rankingsData); | ||
} else { | ||
console.error("Error in response:", data.error); | ||
} | ||
|
||
setLoading(false); | ||
} catch (error) { | ||
console.error("Failed to fetch results:", error); | ||
setLoading(false); | ||
} | ||
}; | ||
|
||
const checkAdmin = async (uid: string) => { | ||
try { | ||
const response = await fetch(`/api/admin?uid=${uid}`); | ||
const { isAdmin } = await response.json(); | ||
setIsAdmin(isAdmin); | ||
setIsAdminLoggedIn(true); | ||
} catch (error) { | ||
console.error("Error checking admin status:", error); | ||
setIsAdmin(false); | ||
setIsAdminLoggedIn(false); | ||
} | ||
}; | ||
|
||
const unsubscribe = onAuthStateChanged(auth, (user) => { | ||
if (user) { | ||
checkAdmin(user.uid); | ||
} else { | ||
setIsAdmin(false); | ||
setIsAdminLoggedIn(false); | ||
} | ||
}); | ||
|
||
fetchData(); | ||
|
||
return () => unsubscribe(); | ||
}, []); | ||
|
||
const handleGetResults = async () => { | ||
try { | ||
setLoading(true); | ||
|
||
const res = await fetch("/api/hustle", { | ||
method: "POST", // Specify the request method | ||
headers: { | ||
"Content-Type": "application/json", | ||
}, | ||
body: JSON.stringify({ key: "value" }), | ||
}); | ||
|
||
if (res.ok) { | ||
alert("Scraper executed successfully!"); | ||
} else { | ||
alert("Failed to update results."); | ||
} | ||
setLoading(false); | ||
} catch (error) { | ||
console.error("Error triggering scraper:", error); | ||
setLoading(false); | ||
} | ||
}; | ||
|
||
// const renderConsistency = (consistency?: number[]) => { | ||
// if (!consistency) return "N/A"; | ||
|
||
// return consistency.map((value, index) => ( | ||
// <span | ||
// key={index} | ||
// className="mx-1" | ||
// style={{ color: value === 1 ? "#00C853" : "red" }} | ||
// > | ||
// {value === 1 ? "✓" : "✗"} | ||
// </span> | ||
// )); | ||
// }; | ||
|
||
const renderTable = (data: Result[], showConsistency: boolean) => ( | ||
<div className="overflow-x-auto shadow-xl rounded-xl border border-gray-700"> | ||
<table className="w-full text-sm text-left text-gray-300"> | ||
<thead className="text-xs uppercase bg-gray-800 text-[#00FF66]"> | ||
<tr> | ||
<th className="px-6 py-4">Rank</th> | ||
<th className="px-6 py-4">Name</th> | ||
<th className="px-6 py-4">Score</th> | ||
</tr> | ||
</thead> | ||
<tbody> | ||
{data.map((result, index) => ( | ||
<tr | ||
key={index} | ||
className="border-b bg-gray-900 border-gray-700 hover:bg-gray-800 transition-colors" | ||
> | ||
<td className="px-6 py-4 font-medium whitespace-nowrap text-[#00FF66]"> | ||
{result.rank} | ||
</td> | ||
<td className="px-6 py-4">{result.name}</td> | ||
<td className="px-6 py-4 font-semibold">{result.score}</td> | ||
</tr> | ||
))} | ||
</tbody> | ||
</table> | ||
</div> | ||
); | ||
|
||
return ( | ||
<div className="min-h-screen bg-gray-900 text-white p-6"> | ||
<div className="max-w-5xl mx-auto pt-14"> | ||
<motion.header | ||
initial={{ opacity: 0, y: -20 }} | ||
animate={{ opacity: 1, y: 0 }} | ||
className="text-center mb-12" | ||
> | ||
<h1 className="text-5xl font-bold mb-6 text-[#00FF66] flex items-center justify-center gap-4"> | ||
<Trophy className="w-12 h-12" /> PB HUSTLE | ||
</h1> | ||
<p className="text-gray-300 max-w-2xl mx-auto text-lg"> | ||
Track latest results and overall rankings in real-time | ||
</p> | ||
</motion.header> | ||
|
||
|
||
<div className="flex justify-center space-x-6 mb-8"> | ||
{(["latest", "rankings"] as const).map((tabName) => ( | ||
<button | ||
key={tabName} | ||
onClick={() => setTab(tabName)} | ||
className={` | ||
px-6 py-3 rounded-full flex items-center gap-3 transition-all text-lg font-semibold | ||
${ | ||
tab === tabName | ||
? "bg-[#00FF66] text-gray-900 shadow-lg" | ||
: "bg-gray-800 text-gray-300 hover:bg-gray-700" | ||
} | ||
`} | ||
> | ||
<TableProperties className="w-6 h-6" /> | ||
{tabName === "latest" ? "Latest Results" : "Rankings"} | ||
</button> | ||
))} | ||
</div> | ||
{isAdmin && isAdminLoggedIn && tab === "latest" && ( | ||
<div className="flex justify-center mb-4"> | ||
<motion.button | ||
whileHover={{ scale: 1.05 }} | ||
whileTap={{ scale: 0.95 }} | ||
onClick={handleGetResults} | ||
disabled={loading} | ||
className=" | ||
bg-[#00FF66] text-gray-900 px-8 py-3 rounded-full | ||
flex items-center gap-3 shadow-lg hover:bg-opacity-90 text-lg font-semibold | ||
disabled:opacity-50 disabled:cursor-not-allowed | ||
" | ||
> | ||
<RefreshCw className="w-6 h-6" /> | ||
{loading ? "Updating..." : "GET LATEST RESULTS"} | ||
</motion.button> | ||
</div> | ||
)} | ||
|
||
{loading ? ( | ||
<div className="flex justify-center items-center h-64"> | ||
<div className="animate-spin rounded-full h-16 w-16 border-t-4 border-[#00FF66]"></div> | ||
</div> | ||
) : ( | ||
renderTable( | ||
tab === "latest" ? latestResults : rankings, | ||
tab === "rankings" | ||
) | ||
)} | ||
</div> | ||
</div> | ||
); | ||
} |
Oops, something went wrong.