feat: add JD detail page with ranked candidates and weight sliders
Browse files
frontend/src/app/jds/[id]/page.tsx
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useEffect, useState, useCallback, useRef } from "react";
|
| 3 |
+
import { useParams } from "next/navigation";
|
| 4 |
+
import { api, type JD, type MatchResponse, type MatchedCandidate } from "../../../lib/api";
|
| 5 |
+
|
| 6 |
+
const DEFAULT_WEIGHTS = {
|
| 7 |
+
semantic: 0.20, skill: 0.35, yoe: 0.15, company: 0.10, growth: 0.10, education: 0.10,
|
| 8 |
+
};
|
| 9 |
+
|
| 10 |
+
const WEIGHT_LABELS: Record<string, string> = {
|
| 11 |
+
semantic: "Semantic Sim", skill: "Skill Overlap", yoe: "Experience",
|
| 12 |
+
company: "Company Quality", growth: "Career Growth", education: "Education",
|
| 13 |
+
};
|
| 14 |
+
|
| 15 |
+
const SCORE_COLORS = ["#6c63ff", "#22c55e", "#3b82f6", "#f59e0b", "#a78bfa", "#34d399"];
|
| 16 |
+
|
| 17 |
+
export default function JDDetailPage() {
|
| 18 |
+
const params = useParams<{ id: string }>();
|
| 19 |
+
const jdId = params.id;
|
| 20 |
+
|
| 21 |
+
const [jd, setJD] = useState<JD | null>(null);
|
| 22 |
+
const [match, setMatch] = useState<MatchResponse | null>(null);
|
| 23 |
+
const [loading, setLoading] = useState(true);
|
| 24 |
+
const [matching, setMatching] = useState(false);
|
| 25 |
+
const [weights, setWeights] = useState(DEFAULT_WEIGHTS);
|
| 26 |
+
const [reranking, setReranking] = useState(false);
|
| 27 |
+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 28 |
+
|
| 29 |
+
const loadData = useCallback(async () => {
|
| 30 |
+
setLoading(true);
|
| 31 |
+
try {
|
| 32 |
+
const [jdData, matchData] = await Promise.all([
|
| 33 |
+
api.getJD(jdId),
|
| 34 |
+
api.getMatchResults(jdId).catch(() => null),
|
| 35 |
+
]);
|
| 36 |
+
setJD(jdData);
|
| 37 |
+
setMatch(matchData);
|
| 38 |
+
} finally {
|
| 39 |
+
setLoading(false);
|
| 40 |
+
}
|
| 41 |
+
}, [jdId]);
|
| 42 |
+
|
| 43 |
+
useEffect(() => { loadData(); }, [loadData]);
|
| 44 |
+
|
| 45 |
+
const handleTriggerMatch = async () => {
|
| 46 |
+
setMatching(true);
|
| 47 |
+
try {
|
| 48 |
+
const result = await api.triggerMatch(jdId);
|
| 49 |
+
setMatch(result);
|
| 50 |
+
} finally {
|
| 51 |
+
setMatching(false);
|
| 52 |
+
}
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
const handleWeightChange = (key: string, value: number) => {
|
| 56 |
+
const newWeights = { ...weights, [key]: value };
|
| 57 |
+
setWeights(newWeights);
|
| 58 |
+
if (debounceRef.current) clearTimeout(debounceRef.current);
|
| 59 |
+
debounceRef.current = setTimeout(async () => {
|
| 60 |
+
setReranking(true);
|
| 61 |
+
const result = await api.rerank(jdId, newWeights).catch(() => null);
|
| 62 |
+
if (result) setMatch(result);
|
| 63 |
+
setReranking(false);
|
| 64 |
+
}, 400);
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
const scoreColor = (score: number) => {
|
| 68 |
+
if (score >= 0.7) return "var(--green)";
|
| 69 |
+
if (score >= 0.45) return "var(--yellow)";
|
| 70 |
+
return "var(--red)";
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
const rankClass = (rank: number) => {
|
| 74 |
+
if (rank === 1) return "rank-1";
|
| 75 |
+
if (rank === 2) return "rank-2";
|
| 76 |
+
if (rank === 3) return "rank-3";
|
| 77 |
+
return "rank-other";
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
if (loading) {
|
| 81 |
+
return (
|
| 82 |
+
<main className="page"><div className="container" style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: "60vh" }}>
|
| 83 |
+
<div style={{ textAlign: "center" }}>
|
| 84 |
+
<div className="spinner" style={{ width: 40, height: 40, margin: "0 auto 16px" }} />
|
| 85 |
+
<div style={{ color: "var(--text-secondary)" }}>Loading…</div>
|
| 86 |
+
</div>
|
| 87 |
+
</div></main>
|
| 88 |
+
);
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
if (!jd) return <main className="page"><div className="container"><p>JD not found</p></div></main>;
|
| 92 |
+
|
| 93 |
+
const quality = jd.jd_quality;
|
| 94 |
+
const qualityColor = quality?.overall === "good" ? "badge-green" : quality?.overall === "fair" ? "badge-yellow" : "badge-red";
|
| 95 |
+
|
| 96 |
+
return (
|
| 97 |
+
<main className="page">
|
| 98 |
+
<div className="container">
|
| 99 |
+
<div style={{ marginBottom: 24 }}>
|
| 100 |
+
<a href="/" style={{ color: "var(--text-muted)", fontSize: "0.8125rem", display: "inline-flex", alignItems: "center", gap: 6, marginBottom: 16 }}>← Dashboard</a>
|
| 101 |
+
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 16, flexWrap: "wrap" }}>
|
| 102 |
+
<div>
|
| 103 |
+
<h1 className="page-title">{jd.title}</h1>
|
| 104 |
+
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginTop: 8 }}>
|
| 105 |
+
{jd.engineer_type && <span className="badge badge-purple">🔧 {jd.engineer_type}</span>}
|
| 106 |
+
{jd.min_yoe && <span className="badge badge-blue">📅 {jd.min_yoe}+ yrs</span>}
|
| 107 |
+
{jd.location && <span className="badge badge-blue">📍 {jd.location}</span>}
|
| 108 |
+
{quality && <span className={`badge ${qualityColor}`}>{quality.overall === "good" ? "✓" : "⚠"} JD {quality.overall}</span>}
|
| 109 |
+
</div>
|
| 110 |
+
</div>
|
| 111 |
+
<button id="trigger-match-btn" className="btn btn-primary" onClick={handleTriggerMatch} disabled={matching || jd.status !== "ready"}>
|
| 112 |
+
{matching ? <><div className="spinner" style={{ width: 16, height: 16 }} /> Matching…</> : match ? "🔄 Re-run Match" : "⚡ Run Match"}
|
| 113 |
+
</button>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
{quality?.warnings?.length > 0 && (
|
| 118 |
+
<div style={{ marginBottom: 24, padding: "14px 18px", borderRadius: 12, background: "rgba(234,179,8,0.08)", border: "1px solid rgba(234,179,8,0.3)" }}>
|
| 119 |
+
<div style={{ fontWeight: 600, fontSize: "0.875rem", color: "var(--yellow)", marginBottom: 6 }}>⚠ JD Quality Issues</div>
|
| 120 |
+
{quality.warnings.map((w, i) => (
|
| 121 |
+
<div key={i} style={{ fontSize: "0.8125rem", color: "var(--text-secondary)", marginTop: 4 }}>• {w}</div>
|
| 122 |
+
))}
|
| 123 |
+
</div>
|
| 124 |
+
)}
|
| 125 |
+
|
| 126 |
+
{jd.required_skills?.length > 0 && (
|
| 127 |
+
<div style={{ marginBottom: 24, display: "flex", flexWrap: "wrap", gap: 6 }}>
|
| 128 |
+
{jd.required_skills.map((s) => <span key={s} className="chip">{s}</span>)}
|
| 129 |
+
</div>
|
| 130 |
+
)}
|
| 131 |
+
|
| 132 |
+
<div className="grid-2" style={{ gap: 24, alignItems: "start" }}>
|
| 133 |
+
<div>
|
| 134 |
+
{!match && !matching && (
|
| 135 |
+
<div style={{ textAlign: "center", padding: "60px 20px", background: "var(--bg-card)", borderRadius: "var(--radius-xl)", border: "1px dashed var(--border)" }}>
|
| 136 |
+
<div style={{ fontSize: "2.5rem", marginBottom: 12 }}>🎯</div>
|
| 137 |
+
<div style={{ fontWeight: 600, marginBottom: 8 }}>No results yet</div>
|
| 138 |
+
<div style={{ color: "var(--text-secondary)", fontSize: "0.875rem", marginBottom: 20 }}>Click Run Match to start the two-stage pipeline</div>
|
| 139 |
+
<button className="btn btn-primary" onClick={handleTriggerMatch} disabled={jd.status !== "ready"}>
|
| 140 |
+
⚡ Run Match
|
| 141 |
+
</button>
|
| 142 |
+
</div>
|
| 143 |
+
)}
|
| 144 |
+
|
| 145 |
+
{matching && (
|
| 146 |
+
<div style={{ textAlign: "center", padding: "60px 20px", background: "var(--bg-card)", borderRadius: "var(--radius-xl)", border: "1px solid var(--border)" }}>
|
| 147 |
+
<div className="spinner" style={{ width: 48, height: 48, margin: "0 auto 16px" }} />
|
| 148 |
+
<div style={{ fontWeight: 600 }}>Running Stage 1 → Stage 2 pipeline…</div>
|
| 149 |
+
<div style={{ color: "var(--text-secondary)", fontSize: "0.875rem", marginTop: 8 }}>ANN search → feature scoring → cross-encoder reranking</div>
|
| 150 |
+
</div>
|
| 151 |
+
)}
|
| 152 |
+
|
| 153 |
+
{match && (
|
| 154 |
+
<div>
|
| 155 |
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
|
| 156 |
+
<div style={{ fontWeight: 600, fontSize: "1rem" }}>
|
| 157 |
+
Top Matches
|
| 158 |
+
<span style={{ marginLeft: 10, fontWeight: 400, color: "var(--text-secondary)", fontSize: "0.875rem" }}>
|
| 159 |
+
{match.total_matched} candidates ranked
|
| 160 |
+
</span>
|
| 161 |
+
</div>
|
| 162 |
+
{reranking && <div className="spinner" style={{ width: 18, height: 18 }} />}
|
| 163 |
+
</div>
|
| 164 |
+
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
| 165 |
+
{match.results.map((c) => (
|
| 166 |
+
<CandidateCard key={c.candidate_id} candidate={c} jdId={jdId} rankClass={rankClass} scoreColor={scoreColor} />
|
| 167 |
+
))}
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
)}
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
<div style={{ display: "flex", flexDirection: "column", gap: 16, position: "sticky", top: 80 }}>
|
| 174 |
+
<div className="weight-panel">
|
| 175 |
+
<div style={{ fontWeight: 600, fontSize: "0.9rem", marginBottom: 4 }}>🎛 Adjust Scoring Weights</div>
|
| 176 |
+
<div style={{ fontSize: "0.775rem", color: "var(--text-muted)", marginBottom: 16 }}>Drag sliders to re-rank instantly. No re-embedding needed.</div>
|
| 177 |
+
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
| 178 |
+
{Object.entries(weights).map(([key, val], i) => (
|
| 179 |
+
<div key={key} className="weight-row">
|
| 180 |
+
<div className="weight-label-row">
|
| 181 |
+
<span className="weight-key">{WEIGHT_LABELS[key] || key}</span>
|
| 182 |
+
<span className="weight-val">{(val * 100).toFixed(0)}%</span>
|
| 183 |
+
</div>
|
| 184 |
+
<input
|
| 185 |
+
id={`weight-${key}`} type="range" className="slider"
|
| 186 |
+
min={0} max={1} step={0.01} value={val}
|
| 187 |
+
onChange={(e) => handleWeightChange(key, parseFloat(e.target.value))}
|
| 188 |
+
style={{ accentColor: SCORE_COLORS[i % SCORE_COLORS.length] }}
|
| 189 |
+
/>
|
| 190 |
+
</div>
|
| 191 |
+
))}
|
| 192 |
+
</div>
|
| 193 |
+
<button className="btn btn-ghost btn-sm" style={{ marginTop: 16, width: "100%" }}
|
| 194 |
+
onClick={() => { setWeights(DEFAULT_WEIGHTS); api.rerank(jdId, DEFAULT_WEIGHTS).then((r) => r && setMatch(r)); }}>
|
| 195 |
+
↺ Reset to default
|
| 196 |
+
</button>
|
| 197 |
+
</div>
|
| 198 |
+
|
| 199 |
+
{match?.jd_quality && (
|
| 200 |
+
<div className="card" style={{ padding: 20 }}>
|
| 201 |
+
<div style={{ fontWeight: 600, fontSize: "0.9rem", marginBottom: 12 }}>📊 JD Quality Report</div>
|
| 202 |
+
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
| 203 |
+
{[
|
| 204 |
+
{ label: "Vagueness", val: match.jd_quality.vagueness_score, invert: true },
|
| 205 |
+
{ label: "Breadth", val: match.jd_quality.breadth_score, invert: true },
|
| 206 |
+
].map(({ label, val, invert }) => (
|
| 207 |
+
<div key={label} className="score-bar-row">
|
| 208 |
+
<span className="score-bar-label">{label}</span>
|
| 209 |
+
<div className="score-bar-track">
|
| 210 |
+
<div className="score-bar-fill" style={{ width: `${val * 100}%`, background: invert ? (val > 0.6 ? "var(--red)" : val > 0.35 ? "var(--yellow)" : "var(--green)") : "var(--accent)" }} />
|
| 211 |
+
</div>
|
| 212 |
+
<span className="score-bar-pct">{(val * 100).toFixed(0)}%</span>
|
| 213 |
+
</div>
|
| 214 |
+
))}
|
| 215 |
+
<div style={{ fontSize: "0.775rem", color: "var(--text-secondary)", marginTop: 4 }}>
|
| 216 |
+
Skills found: <strong>{match.jd_quality.skill_count}</strong>
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
)}
|
| 221 |
+
</div>
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
</main>
|
| 225 |
+
);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
function CandidateCard({ candidate, jdId, rankClass, scoreColor }: {
|
| 229 |
+
candidate: MatchedCandidate; jdId: string;
|
| 230 |
+
rankClass: (r: number) => string; scoreColor: (s: number) => string;
|
| 231 |
+
}) {
|
| 232 |
+
const comp = candidate.component_scores;
|
| 233 |
+
const bars = [
|
| 234 |
+
{ key: "skill", label: "Skill", val: comp.skill, color: SCORE_COLORS[1] },
|
| 235 |
+
{ key: "semantic", label: "Semantic", val: comp.semantic, color: SCORE_COLORS[0] },
|
| 236 |
+
{ key: "yoe", label: "YOE", val: comp.yoe, color: SCORE_COLORS[2] },
|
| 237 |
+
];
|
| 238 |
+
|
| 239 |
+
return (
|
| 240 |
+
<a href={`/jds/${jdId}/candidates/${candidate.candidate_id}`} id={`cand-${candidate.candidate_id}`}
|
| 241 |
+
style={{ display: "block", textDecoration: "none" }}>
|
| 242 |
+
<div className="candidate-card">
|
| 243 |
+
<div className="candidate-card-top">
|
| 244 |
+
<div className={`rank-badge ${rankClass(candidate.rank)}`}>{candidate.rank}</div>
|
| 245 |
+
<div className="candidate-card-info">
|
| 246 |
+
<div className="candidate-name">{candidate.name || "Anonymous Candidate"}</div>
|
| 247 |
+
<div className="candidate-meta">
|
| 248 |
+
{candidate.years_of_experience != null && <span>📅 {candidate.years_of_experience} yrs</span>}
|
| 249 |
+
{candidate.engineer_type && <span>🔧 {candidate.engineer_type}</span>}
|
| 250 |
+
{candidate.most_recent_company && <span>🏢 {candidate.most_recent_company}</span>}
|
| 251 |
+
</div>
|
| 252 |
+
</div>
|
| 253 |
+
<div className="score-pill">
|
| 254 |
+
<div className="score-pill-value" style={{ color: scoreColor(candidate.final_score) }}>
|
| 255 |
+
{(candidate.final_score * 100).toFixed(0)}
|
| 256 |
+
</div>
|
| 257 |
+
<div className="score-pill-label">score</div>
|
| 258 |
+
</div>
|
| 259 |
+
</div>
|
| 260 |
+
|
| 261 |
+
{candidate.parsed_summary && (
|
| 262 |
+
<div style={{ fontSize: "0.8rem", color: "var(--text-secondary)", lineHeight: 1.5, display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical" as "vertical", overflow: "hidden" }}>
|
| 263 |
+
{candidate.parsed_summary}
|
| 264 |
+
</div>
|
| 265 |
+
)}
|
| 266 |
+
|
| 267 |
+
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
|
| 268 |
+
{candidate.programming_languages.slice(0, 5).map((l) => (
|
| 269 |
+
<span key={l} className="chip">{l}</span>
|
| 270 |
+
))}
|
| 271 |
+
{candidate.programming_languages.length > 5 && (
|
| 272 |
+
<span className="chip">+{candidate.programming_languages.length - 5}</span>
|
| 273 |
+
)}
|
| 274 |
+
</div>
|
| 275 |
+
|
| 276 |
+
<div className="score-bar-wrap">
|
| 277 |
+
{bars.map(({ key, label, val, color }) => (
|
| 278 |
+
<div key={key} className="score-bar-row">
|
| 279 |
+
<span className="score-bar-label">{label}</span>
|
| 280 |
+
<div className="score-bar-track">
|
| 281 |
+
<div className="score-bar-fill" style={{ width: `${val * 100}%`, background: color }} />
|
| 282 |
+
</div>
|
| 283 |
+
<span className="score-bar-pct">{(val * 100).toFixed(0)}%</span>
|
| 284 |
+
</div>
|
| 285 |
+
))}
|
| 286 |
+
</div>
|
| 287 |
+
|
| 288 |
+
{candidate.gaps.length > 0 && (
|
| 289 |
+
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
| 290 |
+
{candidate.gaps.slice(0, 3).map((g, i) => (
|
| 291 |
+
<span key={i} style={{ fontSize: "0.72rem", padding: "2px 8px", borderRadius: 6, background: "var(--red-dim)", color: "var(--red)", border: "1px solid rgba(239,68,68,0.2)" }}>
|
| 292 |
+
⚠ {g.type.replace("_", " ")}
|
| 293 |
+
</span>
|
| 294 |
+
))}
|
| 295 |
+
</div>
|
| 296 |
+
)}
|
| 297 |
+
</div>
|
| 298 |
+
</a>
|
| 299 |
+
);
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
const SCORE_COLORS = ["#6c63ff", "#22c55e", "#3b82f6", "#f59e0b", "#a78bfa", "#34d399"];
|