Skip to content
Draft
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
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,16 @@

### TODO

- [ ] Handle the color of text inside the problems(make it easy to read)
- [x] Handle the color of text inside the problems(make it easy to read)
- [ ] Add img preview for each team to show in popup(use api from domjudge,also update the python script for add img for teams,also we need script to just update the img in teams)
- [ ] Add static version(use domjudge shdaow and maybe use nextjs)
- [ ] Responsive design(if we deploy in backend service and share with others)
- [ ] Add font for persian and english
- [ ] Theme system (Light/Dark/Custom)
- [ ] Add preview
- [ ] Add preview of result
- [ ] Add document for functions in scorboard component
- [ ] Refactor the scoreboard component
- [ ] Docker!
- [ ] New design ?! remove the header add the popup for hover in problems to show the lable of that also make the gap between items

## Lets use it!

Expand All @@ -52,6 +52,8 @@ node cors-proxy.js &
npm run dev
```

> Keep in mind if you want to share the port of NAT use --host after bun dev or bun preview

### env file :

copy The .env.example to .env:
Expand Down Expand Up @@ -96,3 +98,17 @@ google-chrome-stable --user-data-dir="./" --disable-web-security
```

For the firefox : About:config and the security.fileuri.strict_origin_policy. Sometimes also the network.http.refere.XOriginPolicy. Im not sure:))



### Doc
The resolver it self dosen't any really hard method ,Just get the data from domjudge with api and sort the teams
For each resolv the of any submit update the teams and rendere the new array of teams with nice animation ,And yse that's the magic
These days im trying to seprate the functions of scoreboard component ,Because a little bit feel heavy




so until here:
I working on popup img show in page but i have problem with unmounting the page and showing some notif!

43 changes: 23 additions & 20 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,39 +5,42 @@
"type": "module",
"scripts": {
"start": "bunx concurrently 'bun run cors' 'bun run dev'",
"dev": "vite",
"dev": "vite --host 0.0.0.0",
"cors": "bun run cors-proxy.js",
"prettier" : "bunx prettier ./src --write",
"prettier": "bunx prettier ./src --write",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.7",
"axios": "^1.9.0",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.16",
"axios": "^1.13.2",
"cors-anywhere": "^0.4.4",
"framer-motion": "^12.12.1",
"framer-motion": "^12.23.28",
"path": "^0.12.7",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwindcss": "^4.1.7"
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-toastify": "^11.0.5",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@types/node": "^24.9.1",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21",
"@eslint/js": "^9.39.2",
"@tanstack/eslint-plugin-query": "^5.91.2",
"@types/node": "^24.10.4",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.23",
"concurrently": "^9.2.1",
"eslint": "^9.25.0",
"eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"postcss": "^8.5.3",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
"typescript-eslint": "^8.51.0",
"vite": "^6.4.1"
},
"trustedDependencies": [
"@tailwindcss/oxide"
Expand Down
Binary file modified public/home.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Scoreboard from "@/components/scoreboard";
function App() {
const api_cid = import.meta.env.VITE_API_CID;
return (
<main className="min-h-screen scroll-smooth">
<main className="min-h-screen bg-background scroll-smooth">
<img
src="/home.png"
className="w-auto mx-auto h-[20rem] p-4 object-contain"
Expand Down
2 changes: 1 addition & 1 deletion src/api/axiosInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ axiosInstance.interceptors.request.use(
if (API_USERNAME && API_PASSWORD) {
const credentials = btoa(`${API_USERNAME}:${API_PASSWORD}`);
config.headers = {
...config.headers,
...config.headers || {},
Authorization: `Basic ${credentials}`,
};
}
Expand Down
12 changes: 11 additions & 1 deletion src/components/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,17 @@ export async function fetchJudgeTypes(cid: string): Promise<any> {
return data;
}

/** 🧩 Example usage in your React component or wherever */
/**
*/
export async function fetchTeamImg(teamId: string): Promise<any> {
const { data } = await axiosInstance.get<any>(
`/teams/${teamId}/photo`,
{ responseType: "blob" }
)

return data;
}

export async function loadContestData(cid: string) {
const contestApi = await fetchContest(cid);
const beforeApi = await fetchBefore(cid);
Expand Down
113 changes: 46 additions & 67 deletions src/components/scoreboard.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,14 @@
import { useEffect, useState } from "react";
import TeamImg from "./teamImg"
import { AnimatePresence, motion } from "framer-motion";
import { loadContestData } from "@/components/data.ts";
import type { ScoreboardData, ScoreboardRow, ProblemInfo } from "../types";
import { loadContestData } from "./data.ts";
import type { ScoreboardData, ScoreboardRow, ProblemInfo ,ScoreboardProps } from "../types";
import {colorBasedBg, firstSubmit} from "./utils.ts"

interface ScoreboardProps {
contestId: string | number;
}

export function sortAndRankTeams(teams: ScoreboardRow[]): ScoreboardRow[] {
const firstSolves: Record<
string,
{ time: number; team_id: string | number }
> = {};

// Clear first_to_solve flags and find earliest solves
teams.forEach((team) => {
team.problems.forEach((problem) => {
problem.first_to_solve = false;
if (problem.solved) {
const prev = firstSolves[problem.problem_id];
if (!prev || problem.time < prev.time) {
firstSolves[problem.problem_id] = {
time: problem.time,
team_id: team.team_id,
};
}
}
});
});

// Mark FTS
teams.forEach((team) => {
team.problems.forEach((problem) => {
if (
problem.solved &&
firstSolves[problem.problem_id]?.team_id === team.team_id
) {
problem.first_to_solve = true;
}
});
});
function sortAndRankTeams(teams: ScoreboardRow[]): ScoreboardRow[] {

firstSubmit(teams)

// Sort and assign ranks
teams.sort((a, b) => {
if (b.score.num_solved !== a.score.num_solved)
Expand Down Expand Up @@ -101,6 +69,7 @@ export default function Scoreboard({ contestId }: ScoreboardProps) {
const [judges, setJudges] = useState([]);
const [subs, setSubs] = useState([]);
const api_penalty = import.meta.env.VITE_API_PENALTY;
const [teamPopup , setTeamPopup] = useState({state : false , id : ""})

useEffect(() => {
loadContestData(contestId)
Expand Down Expand Up @@ -189,21 +158,24 @@ export default function Scoreboard({ contestId }: ScoreboardProps) {
useEffect(() => {
const handleKeyDown = (event: any) => {
if (event.key === "Escape") {
console.log("Escape key pressed");
setrCoolDown(false); // Call your function when Escape is pressed
document.querySelector(".active")?.classList.remove("active");
if (teamPopup.state) {
setTeamPopup({state : false , id :""})
} else {
setrCoolDown(false); // Call your function when Escape is pressed
document.querySelector(".active")?.classList.remove("active");
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [setrCoolDown]);
}, [setrCoolDown,teamPopup]);

const getCellColor = (pr: ProblemInfo, team_id: string) => {
if (pr.num_pending > 0) return "bg-yellow-300";
if (pr.solved) return "bg-green-300";
if (pr.num_judged > 0) return "bg-red-400";
if (pr.num_pending > 0) return "bg-yellow";
if (pr.solved) return "bg-green";
if (pr.num_judged > 0) return "bg-red";

// Handle special case: team had a pending submission in the previous state,
// but now the judged result has no penalty (e.g., compile error, too late submission, etc.).
Expand Down Expand Up @@ -247,9 +219,6 @@ export default function Scoreboard({ contestId }: ScoreboardProps) {
const pendings = document.getElementsByClassName("cursor-pointer");
if (!pendings) return;
const lastPend = pendings[pendings.length - 1];
lastPend?.scrollIntoView({
behavior: "smooth",
});
lastPend?.click();
}
}
Expand All @@ -259,14 +228,14 @@ export default function Scoreboard({ contestId }: ScoreboardProps) {
};
}, []);

return (
<div className="p-4 w-full">
return (<>
<div className="p-4 w-full backdrop-blur-sm">
<h1 className="title-font text-indigo-700 text-4xl">
{contestData?.name}
</h1>
<div className="overflow-auto shadow-lg shadow-slate-200 rounded-lg">
<div className="overflow-auto shadow-xl rounded-2xl">
<div className="min-w-[1024px]">
<div className="grid shadow-sm grid-cols-[50px_25rem_10rem_1fr_5rem] min-h-10 place-items-stretch gap-2 px-2 text-lg font-bold bg-gray-100 text-slate-700">
<div className="grid grid-cols-[50px_25rem_10rem_1fr_5rem] min-h-10 place-items-stretch gap-2 px-2 text-lg font-bold bg-card-foreground/10 text-primary">
<div className="text-start grid place-items-center">RANK</div>
<div className="text-start flex justify-center items-center">
TEAM
Expand All @@ -283,18 +252,18 @@ export default function Scoreboard({ contestId }: ScoreboardProps) {
style={{
gridTemplateColumns: `repeat(${problems?.length || 4}, 1fr)`,
}}
className="grid text-white gap-3 justify-center min-w-[30px]"
className="grid text-white gap-3 justify-center min-w-[30px]"
>
{problems?.map((problem: any, i: number) => (
<div
key={problem.id}
className="relative flex justify-center items-center"
>
<div
className="w-full h-full text-slate-900 text-xl font-bold text-center rounded-lg grid place-items-center"
className="w-full h-full text-xl font-bold text-center rounded-2xl grid place-items-center"
style={{ backgroundColor: problem.rgb || "#888" }}
>
{problem.label || String.fromCharCode(65 + i)}
<span style={{color : colorBasedBg(problem.rgb)}} className="">{problem.label || String.fromCharCode(65 + i)}</span>
</div>
</div>
))}
Expand All @@ -311,32 +280,37 @@ export default function Scoreboard({ contestId }: ScoreboardProps) {
transition={{
layout: {
duration: 1.1,
ease: [0.25, 0.8, 0.25, 1], // ease-in-out
//ease: [0.25, 0.8, 0.25, 1], // ease-in-out
ease: [0.05, 0.8, 0.35, 1],
},
opacity: {
duration: 0.3,
},
}}
key={team.team_id}
initial={{ opacity: 0, y: -10 }}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
exit={{ opacity: 0, y: -5 }}
style={{ zIndex: result.length - index }}
className={`team-${team.team_id} bg-white border-b-2 border-gray-100 gap-2
[&:has(&>*.active)]:z-50 relative grid px-2 py-1 justify-center grid-cols-[50px_25rem_10rem_1fr_5rem]`}
className={`team-${team.team_id} bg-card has-[.active]:bg-card/10 card text-foreground not-last:border-b border-border/30 gap-2
relative grid px-2 py-2 justify-center grid-cols-[50px_25rem_10rem_1fr_5rem]`}
>
<div className="font-bod grid place-items-center text-2xl">
{team.rank}
</div>
<div className="flex flex-col justify-center">
<span className="font-bold text-3xl">
<div
onClick={() => {
setTeamPopup({state : true ,id : team.team_id })
}}
className="flex flex-col justify-center cursor-pointer hover:scale-105 transition-transform">
<span className="font-medium text-3xl">
{getNameTeam(team.team_id)}
</span>
<span className="text-xl min-h-[1rem] text-gray-500 font-bold">
<span className="text-xl min-h-[1rem] ">
{getUniTeam(team.team_id)}
</span>
</div>
<div className="text-center text-xl self-center font-bold">
<div className="text-center text-xl self-center font-medium">
<span className="text-teal-900">
{team.score.num_solved}
</span>{" "}
Expand Down Expand Up @@ -366,7 +340,7 @@ export default function Scoreboard({ contestId }: ScoreboardProps) {
});
}}
id={`team-${team.team_id}-${pr.problem_id}`}
className={`relative grid py-3 place-items-center rounded-lg text-center cell font-bold text-2xl ${pr.num_pending > 0 ? "cursor-pointer" : ""} transition-all rounded ${getCellColor(pr, team.team_id)} p-1 ${pr.first_to_solve ? "bg-green-800 text-white" : ""}`}
className={`relative grid py-3 place-items-center rounded-2xl text-center cell font-bold text-2xl ${pr.num_pending > 0 ? "cursor-pointer" : ""} transition-all ${getCellColor(pr, team.team_id)} p-1 ${pr.first_to_solve ? "bg-green-800 text-white" : ""}`}
>
{pr.solved ? `${pr.time}` : ""}
{pr.first_to_solve && (
Expand Down Expand Up @@ -399,5 +373,10 @@ export default function Scoreboard({ contestId }: ScoreboardProps) {
</div>
</div>
</div>
);
<TeamImg teamPopup={teamPopup} setDefault={() => {
console.log("test")
setTeamPopup({state: false , id : ""})
}} />
</> );

}
34 changes: 34 additions & 0 deletions src/components/teamImg.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { fetchTeamImg } from "./data"
import {
useQuery,
useMutation,
useQueryClient,
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'

const TeamImg = ({teamPopup , setDefault} : {teamPopup : {state : boolean ,id : string} , setDefault :() => void}) => {
if (!teamPopup.state || !teamPopup.id) return null;

const query = useQuery({ queryKey: [teamPopup.id], queryFn: () => fetchTeamImg(teamPopup.id) })

if (query.error) return setDefault();

if (query.data){
console.log(query.data)
const src = URL.createObjectURL(query.data)
return (<>
<div
className="fixed inset-0 bg-black-/60 z-100 top-50 left-50 popup w-[80rem] h-max object-contain rounded-2xl overflow-hidden">
{
query.isPending && <div>Pending...!</div> ||
query.isError && <img src="/home.png" alt="team" /> ||
query.data && <img src={src} alt="fallback" />
}
</div>
</>)
}
}


export default TeamImg
Loading