Spaces:
Running
Running
refactor leaderboardSection
Browse files- client/src/components/LeaderboardSection.jsx +0 -559
- client/src/components/LeaderboardSection/components/EmptyState.jsx +55 -0
- client/src/components/LeaderboardSection/components/LanguageAccordion.jsx +167 -0
- client/src/components/{LeaderboardCard.jsx → LeaderboardSection/components/LeaderboardCard.jsx} +1 -1
- client/src/components/LeaderboardSection/components/LeaderboardGrid.jsx +56 -0
- client/src/components/LeaderboardSection/components/SectionHeader.jsx +93 -0
- client/src/components/LeaderboardSection/hooks/useLanguageStats.js +84 -0
- client/src/components/LeaderboardSection/index.jsx +102 -0
client/src/components/LeaderboardSection.jsx
DELETED
@@ -1,559 +0,0 @@
|
|
1 |
-
import React, { useState, useMemo } from "react";
|
2 |
-
import {
|
3 |
-
Typography,
|
4 |
-
Grid,
|
5 |
-
Box,
|
6 |
-
Button,
|
7 |
-
Collapse,
|
8 |
-
Stack,
|
9 |
-
Accordion,
|
10 |
-
AccordionSummary,
|
11 |
-
AccordionDetails,
|
12 |
-
} from "@mui/material";
|
13 |
-
import { alpha } from "@mui/material/styles";
|
14 |
-
import LeaderboardCard from "./LeaderboardCard";
|
15 |
-
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
16 |
-
import { useLeaderboard } from "../context/LeaderboardContext";
|
17 |
-
import SearchOffIcon from "@mui/icons-material/SearchOff";
|
18 |
-
|
19 |
-
const ITEMS_PER_PAGE = 4;
|
20 |
-
|
21 |
-
const LeaderboardSection = ({
|
22 |
-
title,
|
23 |
-
leaderboards,
|
24 |
-
filteredLeaderboards,
|
25 |
-
id,
|
26 |
-
}) => {
|
27 |
-
const {
|
28 |
-
expandedSections,
|
29 |
-
setExpandedSections,
|
30 |
-
selectedLanguage,
|
31 |
-
setSelectedLanguage,
|
32 |
-
searchQuery,
|
33 |
-
selectedCategory,
|
34 |
-
} = useLeaderboard();
|
35 |
-
const isExpanded = expandedSections.has(id);
|
36 |
-
|
37 |
-
// Définition des familles de langues
|
38 |
-
const LANGUAGE_FAMILIES = {
|
39 |
-
"European Languages": {
|
40 |
-
Main: [
|
41 |
-
"English",
|
42 |
-
"French",
|
43 |
-
"Spanish",
|
44 |
-
"German",
|
45 |
-
"Italian",
|
46 |
-
"Portuguese",
|
47 |
-
"Dutch",
|
48 |
-
"Romanian",
|
49 |
-
"Polish",
|
50 |
-
"Greek",
|
51 |
-
"Swedish",
|
52 |
-
"Danish",
|
53 |
-
"Norwegian",
|
54 |
-
"Finnish",
|
55 |
-
"Hungarian",
|
56 |
-
"Czech",
|
57 |
-
"Slovak",
|
58 |
-
"Croatian",
|
59 |
-
"Serbian",
|
60 |
-
"Bulgarian",
|
61 |
-
"Ukrainian",
|
62 |
-
"Russian",
|
63 |
-
"Lithuanian",
|
64 |
-
"Latvian",
|
65 |
-
"Estonian",
|
66 |
-
"Basque",
|
67 |
-
"Catalan",
|
68 |
-
"Albanian",
|
69 |
-
"Slovenian",
|
70 |
-
"Icelandic",
|
71 |
-
"Galician",
|
72 |
-
"Armenian",
|
73 |
-
],
|
74 |
-
},
|
75 |
-
"Asian Languages": {
|
76 |
-
Main: [
|
77 |
-
"Chinese",
|
78 |
-
"Japanese",
|
79 |
-
"Korean",
|
80 |
-
"Vietnamese",
|
81 |
-
"Thai",
|
82 |
-
"Indonesian",
|
83 |
-
"Malay",
|
84 |
-
"Tagalog",
|
85 |
-
"Mandarin",
|
86 |
-
"Cantonese",
|
87 |
-
"日本語",
|
88 |
-
"Taiwanese",
|
89 |
-
"Filipino",
|
90 |
-
"Singlish",
|
91 |
-
],
|
92 |
-
},
|
93 |
-
"Indian Languages": {
|
94 |
-
Main: [
|
95 |
-
"Hindi",
|
96 |
-
"Bengali",
|
97 |
-
"Tamil",
|
98 |
-
"Telugu",
|
99 |
-
"Marathi",
|
100 |
-
"Gujarati",
|
101 |
-
"Kannada",
|
102 |
-
"Malayalam",
|
103 |
-
"Nepali",
|
104 |
-
"Urdu",
|
105 |
-
"Indic",
|
106 |
-
],
|
107 |
-
},
|
108 |
-
"Middle Eastern & African": {
|
109 |
-
Main: [
|
110 |
-
"Arabic",
|
111 |
-
"Hebrew",
|
112 |
-
"Turkish",
|
113 |
-
"Persian",
|
114 |
-
"Darija",
|
115 |
-
"Swahili",
|
116 |
-
"Amharic",
|
117 |
-
"Hausa",
|
118 |
-
"Yoruba",
|
119 |
-
],
|
120 |
-
},
|
121 |
-
"Other Languages": {
|
122 |
-
Main: [], // Pour les langues non listées
|
123 |
-
},
|
124 |
-
};
|
125 |
-
|
126 |
-
// Helper pour trouver la famille d'une langue
|
127 |
-
const findLanguageFamily = (language) => {
|
128 |
-
for (const [familyName, subFamilies] of Object.entries(LANGUAGE_FAMILIES)) {
|
129 |
-
for (const [subFamilyName, languages] of Object.entries(subFamilies)) {
|
130 |
-
if (languages.includes(language)) {
|
131 |
-
return { family: familyName, subFamily: subFamilyName };
|
132 |
-
}
|
133 |
-
}
|
134 |
-
}
|
135 |
-
// Si la langue n'est pas trouvée, on la met dans Other Languages
|
136 |
-
console.log(`Language not categorized: ${language}`);
|
137 |
-
return { family: "Other Languages", subFamily: "Main" };
|
138 |
-
};
|
139 |
-
|
140 |
-
// Extraire la liste des langues si c'est la section Language Specific
|
141 |
-
const languages = useMemo(() => {
|
142 |
-
if (id !== "language") return null;
|
143 |
-
const langSet = new Set();
|
144 |
-
|
145 |
-
// Compter toutes les langues uniques
|
146 |
-
leaderboards.forEach((board) => {
|
147 |
-
board.tags?.forEach((tag) => {
|
148 |
-
if (tag.startsWith("language:")) {
|
149 |
-
const language = tag.split(":")[1];
|
150 |
-
const capitalizedLang =
|
151 |
-
language.charAt(0).toUpperCase() + language.slice(1);
|
152 |
-
langSet.add(capitalizedLang);
|
153 |
-
}
|
154 |
-
});
|
155 |
-
});
|
156 |
-
|
157 |
-
// Créer un tableau de paires [langue, nombre de leaderboards, famille]
|
158 |
-
const langArray = Array.from(langSet).map((lang) => {
|
159 |
-
const count = leaderboards.filter((board) =>
|
160 |
-
board.tags?.some(
|
161 |
-
(tag) => tag.toLowerCase() === `language:${lang.toLowerCase()}`
|
162 |
-
)
|
163 |
-
).length;
|
164 |
-
const { family, subFamily } = findLanguageFamily(lang);
|
165 |
-
return [lang, count, family, subFamily];
|
166 |
-
});
|
167 |
-
|
168 |
-
// Trier d'abord par famille, puis par nombre de leaderboards
|
169 |
-
return langArray
|
170 |
-
.sort((a, b) => {
|
171 |
-
// Si les familles sont différentes
|
172 |
-
if (a[2] !== b[2]) {
|
173 |
-
if (a[2] === "Other Languages") return 1; // Other Languages toujours à la fin
|
174 |
-
if (b[2] === "Other Languages") return -1;
|
175 |
-
return a[2].localeCompare(b[2]);
|
176 |
-
}
|
177 |
-
// Si même famille, trier par nombre de leaderboards
|
178 |
-
return b[1] - a[1];
|
179 |
-
})
|
180 |
-
.map(([lang]) => lang);
|
181 |
-
}, [id, leaderboards]);
|
182 |
-
|
183 |
-
// Calculer le nombre de leaderboards par langue
|
184 |
-
// On utilise les leaderboards filtrés pour avoir les bonnes statistiques
|
185 |
-
const languageStats = useMemo(() => {
|
186 |
-
if (!languages) return null;
|
187 |
-
const stats = new Map();
|
188 |
-
|
189 |
-
// Compter les leaderboards pour chaque langue en tenant compte des filtres
|
190 |
-
languages.forEach((lang) => {
|
191 |
-
const count = filteredLeaderboards.filter((board) =>
|
192 |
-
board.tags?.some(
|
193 |
-
(tag) => tag.toLowerCase() === `language:${lang.toLowerCase()}`
|
194 |
-
)
|
195 |
-
).length;
|
196 |
-
stats.set(lang, count);
|
197 |
-
});
|
198 |
-
|
199 |
-
return stats;
|
200 |
-
}, [languages, filteredLeaderboards]);
|
201 |
-
|
202 |
-
// Filtrer pour n'avoir que les leaderboards approuvés
|
203 |
-
const approvedLeaderboards = filteredLeaderboards.filter(
|
204 |
-
(leaderboard) => leaderboard.approval_status === "approved"
|
205 |
-
);
|
206 |
-
|
207 |
-
// On ne retourne null que si on n'a pas de leaderboards bruts
|
208 |
-
if (!leaderboards) return null;
|
209 |
-
|
210 |
-
// On affiche toujours les 3 premiers
|
211 |
-
const displayedLeaderboards = approvedLeaderboards.slice(0, ITEMS_PER_PAGE);
|
212 |
-
// Le reste sera dans le Collapse
|
213 |
-
const remainingLeaderboards = approvedLeaderboards.slice(ITEMS_PER_PAGE);
|
214 |
-
|
215 |
-
// Calculate how many skeletons we need
|
216 |
-
const skeletonsNeeded = Math.max(0, 4 - approvedLeaderboards.length);
|
217 |
-
|
218 |
-
// On affiche le bouton seulement si aucune catégorie n'est sélectionnée
|
219 |
-
const showExpandButton = !selectedCategory;
|
220 |
-
|
221 |
-
// Le bouton est actif seulement s'il y a plus de 4 leaderboards
|
222 |
-
const isExpandButtonEnabled = approvedLeaderboards.length > ITEMS_PER_PAGE;
|
223 |
-
|
224 |
-
const toggleExpanded = () => {
|
225 |
-
setExpandedSections((prev) => {
|
226 |
-
const newSet = new Set(prev);
|
227 |
-
if (isExpanded) {
|
228 |
-
newSet.delete(id);
|
229 |
-
} else {
|
230 |
-
newSet.add(id);
|
231 |
-
}
|
232 |
-
return newSet;
|
233 |
-
});
|
234 |
-
};
|
235 |
-
|
236 |
-
return (
|
237 |
-
<Box sx={{ mb: 6 }}>
|
238 |
-
<Box
|
239 |
-
sx={{
|
240 |
-
display: "flex",
|
241 |
-
alignItems: "center",
|
242 |
-
justifyContent: "space-between",
|
243 |
-
mb: languageStats ? 2 : 4,
|
244 |
-
}}
|
245 |
-
>
|
246 |
-
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
247 |
-
<Typography
|
248 |
-
variant="h4"
|
249 |
-
sx={{
|
250 |
-
color: "text.primary",
|
251 |
-
fontWeight: 600,
|
252 |
-
fontSize: { xs: "1.5rem", md: "2rem" },
|
253 |
-
}}
|
254 |
-
>
|
255 |
-
{title}
|
256 |
-
</Typography>
|
257 |
-
<Box
|
258 |
-
sx={(theme) => ({
|
259 |
-
width: "4px",
|
260 |
-
height: "4px",
|
261 |
-
borderRadius: "100%",
|
262 |
-
backgroundColor: alpha(
|
263 |
-
theme.palette.text.primary,
|
264 |
-
theme.palette.mode === "dark" ? 0.2 : 0.15
|
265 |
-
),
|
266 |
-
})}
|
267 |
-
/>
|
268 |
-
<Typography
|
269 |
-
variant="h4"
|
270 |
-
sx={{
|
271 |
-
color: "text.secondary",
|
272 |
-
fontWeight: 400,
|
273 |
-
fontSize: { xs: "1.25rem", md: "1.5rem" },
|
274 |
-
opacity: 0.6,
|
275 |
-
}}
|
276 |
-
>
|
277 |
-
{approvedLeaderboards.length}
|
278 |
-
</Typography>
|
279 |
-
</Box>
|
280 |
-
{showExpandButton && (
|
281 |
-
<Button
|
282 |
-
onClick={toggleExpanded}
|
283 |
-
size="small"
|
284 |
-
disabled={!isExpandButtonEnabled}
|
285 |
-
sx={{
|
286 |
-
color: "text.secondary",
|
287 |
-
fontSize: "0.875rem",
|
288 |
-
textTransform: "none",
|
289 |
-
opacity: isExpandButtonEnabled ? 1 : 0.5,
|
290 |
-
"&:hover": {
|
291 |
-
backgroundColor: (theme) =>
|
292 |
-
isExpandButtonEnabled
|
293 |
-
? alpha(
|
294 |
-
theme.palette.text.primary,
|
295 |
-
theme.palette.mode === "dark" ? 0.1 : 0.06
|
296 |
-
)
|
297 |
-
: "transparent",
|
298 |
-
},
|
299 |
-
}}
|
300 |
-
endIcon={
|
301 |
-
<ExpandMoreIcon
|
302 |
-
sx={{
|
303 |
-
transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)",
|
304 |
-
transition: "transform 300ms",
|
305 |
-
}}
|
306 |
-
/>
|
307 |
-
}
|
308 |
-
>
|
309 |
-
{isExpanded ? "Show less" : "Show more"}
|
310 |
-
</Button>
|
311 |
-
)}
|
312 |
-
</Box>
|
313 |
-
|
314 |
-
{languages && selectedCategory === "language" && (
|
315 |
-
<Accordion
|
316 |
-
defaultExpanded={false}
|
317 |
-
elevation={0}
|
318 |
-
disableGutters
|
319 |
-
sx={{
|
320 |
-
mb: approvedLeaderboards.length > 0 ? 4 : 2,
|
321 |
-
"&:before": {
|
322 |
-
display: "none",
|
323 |
-
},
|
324 |
-
backgroundColor: "transparent",
|
325 |
-
}}
|
326 |
-
>
|
327 |
-
<AccordionSummary
|
328 |
-
expandIcon={
|
329 |
-
<ExpandMoreIcon sx={{ fontSize: 20, color: "text.secondary" }} />
|
330 |
-
}
|
331 |
-
sx={{
|
332 |
-
p: 0,
|
333 |
-
minHeight: 32,
|
334 |
-
height: 32,
|
335 |
-
"&.MuiAccordionSummary-root": {
|
336 |
-
"&.Mui-expanded": {
|
337 |
-
minHeight: 32,
|
338 |
-
height: 32,
|
339 |
-
},
|
340 |
-
},
|
341 |
-
"& .MuiAccordionSummary-content": {
|
342 |
-
m: 0,
|
343 |
-
"&.Mui-expanded": {
|
344 |
-
m: 0,
|
345 |
-
},
|
346 |
-
},
|
347 |
-
}}
|
348 |
-
>
|
349 |
-
<Typography
|
350 |
-
variant="body2"
|
351 |
-
color="text.secondary"
|
352 |
-
sx={{ fontWeight: 500 }}
|
353 |
-
>
|
354 |
-
Filter by language
|
355 |
-
</Typography>
|
356 |
-
</AccordionSummary>
|
357 |
-
<AccordionDetails sx={{ padding: 0 }}>
|
358 |
-
{/* Grouper les langues par famille */}
|
359 |
-
{Object.entries(LANGUAGE_FAMILIES).map(([family, subFamilies]) => {
|
360 |
-
const familyLanguages = languages.filter((lang) => {
|
361 |
-
const { family: langFamily } = findLanguageFamily(lang);
|
362 |
-
return langFamily === family;
|
363 |
-
});
|
364 |
-
|
365 |
-
// Toujours afficher toutes les familles qui ont des langues dans la liste complète
|
366 |
-
if (familyLanguages.length === 0 && family !== "Other Languages")
|
367 |
-
return null;
|
368 |
-
|
369 |
-
// Calculer le nombre total de leaderboards dans cette famille
|
370 |
-
const familyTotal = familyLanguages.reduce(
|
371 |
-
(sum, lang) => sum + (languageStats?.get(lang) || 0),
|
372 |
-
0
|
373 |
-
);
|
374 |
-
|
375 |
-
return (
|
376 |
-
<Box key={family} sx={{ mb: 3 }}>
|
377 |
-
<Typography
|
378 |
-
variant="subtitle2"
|
379 |
-
sx={{
|
380 |
-
color: "text.secondary",
|
381 |
-
mb: 1,
|
382 |
-
fontWeight: 500,
|
383 |
-
opacity: familyTotal === 0 ? 0.5 : 0.8,
|
384 |
-
}}
|
385 |
-
>
|
386 |
-
{family}{" "}
|
387 |
-
{familyLanguages.length > 0 ? `(${familyTotal})` : ""}
|
388 |
-
</Typography>
|
389 |
-
<Box
|
390 |
-
sx={{
|
391 |
-
display: "flex",
|
392 |
-
flexWrap: "wrap",
|
393 |
-
gap: 1,
|
394 |
-
mx: -0.5,
|
395 |
-
}}
|
396 |
-
>
|
397 |
-
{familyLanguages.map((lang) => {
|
398 |
-
const isActive = selectedLanguage === lang;
|
399 |
-
const count = languageStats?.get(lang) || 0;
|
400 |
-
const isDisabled = count === 0 && !isActive;
|
401 |
-
|
402 |
-
return (
|
403 |
-
<Button
|
404 |
-
key={lang}
|
405 |
-
onClick={() =>
|
406 |
-
setSelectedLanguage(isActive ? null : lang)
|
407 |
-
}
|
408 |
-
variant={isActive ? "contained" : "outlined"}
|
409 |
-
size="small"
|
410 |
-
disabled={isDisabled}
|
411 |
-
sx={{
|
412 |
-
textTransform: "none",
|
413 |
-
m: 0.125,
|
414 |
-
opacity: isDisabled ? 0.5 : 1,
|
415 |
-
backgroundColor: (theme) =>
|
416 |
-
isActive
|
417 |
-
? undefined
|
418 |
-
: theme.palette.mode === "dark"
|
419 |
-
? "background.paper"
|
420 |
-
: "white",
|
421 |
-
"&:hover": {
|
422 |
-
backgroundColor: (theme) =>
|
423 |
-
isActive
|
424 |
-
? undefined
|
425 |
-
: theme.palette.mode === "dark"
|
426 |
-
? "background.paper"
|
427 |
-
: "white",
|
428 |
-
opacity: isDisabled ? 0.5 : 0.8,
|
429 |
-
},
|
430 |
-
"& .MuiTouchRipple-root": {
|
431 |
-
transition: "none",
|
432 |
-
},
|
433 |
-
transition: "none",
|
434 |
-
}}
|
435 |
-
>
|
436 |
-
{lang}
|
437 |
-
<Box
|
438 |
-
component="span"
|
439 |
-
sx={{
|
440 |
-
display: "inline-flex",
|
441 |
-
alignItems: "center",
|
442 |
-
gap: 0.75,
|
443 |
-
color: isActive ? "inherit" : "text.secondary",
|
444 |
-
ml: 0.75,
|
445 |
-
}}
|
446 |
-
>
|
447 |
-
<Box
|
448 |
-
component="span"
|
449 |
-
sx={(theme) => ({
|
450 |
-
width: "4px",
|
451 |
-
height: "4px",
|
452 |
-
borderRadius: "100%",
|
453 |
-
backgroundColor: alpha(
|
454 |
-
theme.palette.text.primary,
|
455 |
-
theme.palette.mode === "dark" ? 0.2 : 0.15
|
456 |
-
),
|
457 |
-
})}
|
458 |
-
/>
|
459 |
-
{count}
|
460 |
-
</Box>
|
461 |
-
</Button>
|
462 |
-
);
|
463 |
-
})}
|
464 |
-
</Box>
|
465 |
-
</Box>
|
466 |
-
);
|
467 |
-
})}
|
468 |
-
</AccordionDetails>
|
469 |
-
</Accordion>
|
470 |
-
)}
|
471 |
-
|
472 |
-
{approvedLeaderboards.length === 0 ? (
|
473 |
-
<Box
|
474 |
-
sx={{
|
475 |
-
display: "flex",
|
476 |
-
flexDirection: "column",
|
477 |
-
alignItems: "center",
|
478 |
-
gap: 2,
|
479 |
-
py: 7,
|
480 |
-
bgcolor: (theme) =>
|
481 |
-
theme.palette.mode === "dark"
|
482 |
-
? "background.paper"
|
483 |
-
: "background.default",
|
484 |
-
borderRadius: 2,
|
485 |
-
}}
|
486 |
-
>
|
487 |
-
<SearchOffIcon
|
488 |
-
sx={{
|
489 |
-
fontSize: 64,
|
490 |
-
color: "text.secondary",
|
491 |
-
opacity: 0.5,
|
492 |
-
}}
|
493 |
-
/>
|
494 |
-
<Typography variant="h5" color="text.secondary" align="center">
|
495 |
-
{searchQuery ? (
|
496 |
-
<>
|
497 |
-
No {title.toLowerCase()} leaderboard matches{" "}
|
498 |
-
<Box
|
499 |
-
component="span"
|
500 |
-
sx={{
|
501 |
-
bgcolor: "primary.main",
|
502 |
-
color: "primary.contrastText",
|
503 |
-
px: 1,
|
504 |
-
borderRadius: 1,
|
505 |
-
}}
|
506 |
-
>
|
507 |
-
{searchQuery}
|
508 |
-
</Box>
|
509 |
-
</>
|
510 |
-
) : (
|
511 |
-
`No ${title.toLowerCase()} leaderboard matches your criteria`
|
512 |
-
)}
|
513 |
-
</Typography>
|
514 |
-
<Typography variant="body1" color="text.secondary" align="center">
|
515 |
-
Try adjusting your search filters
|
516 |
-
</Typography>
|
517 |
-
</Box>
|
518 |
-
) : (
|
519 |
-
<>
|
520 |
-
<Grid container spacing={3}>
|
521 |
-
{displayedLeaderboards.map((leaderboard, index) => (
|
522 |
-
<Grid item xs={12} sm={6} md={3} key={index}>
|
523 |
-
<LeaderboardCard leaderboard={leaderboard} />
|
524 |
-
</Grid>
|
525 |
-
))}
|
526 |
-
{/* Add skeletons if needed */}
|
527 |
-
{Array.from({ length: skeletonsNeeded }).map((_, index) => (
|
528 |
-
<Grid item xs={12} sm={6} md={3} key={`skeleton-${index}`}>
|
529 |
-
<Box
|
530 |
-
sx={{
|
531 |
-
height: "180px",
|
532 |
-
borderRadius: 2,
|
533 |
-
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.15),
|
534 |
-
opacity: 1,
|
535 |
-
transition: "opacity 0.3s ease-in-out",
|
536 |
-
"&:hover": {
|
537 |
-
opacity: 0.8,
|
538 |
-
},
|
539 |
-
}}
|
540 |
-
/>
|
541 |
-
</Grid>
|
542 |
-
))}
|
543 |
-
</Grid>
|
544 |
-
<Collapse in={isExpanded} timeout={300} unmountOnExit>
|
545 |
-
<Grid container spacing={3} sx={{ mt: 0 }}>
|
546 |
-
{remainingLeaderboards.map((leaderboard, index) => (
|
547 |
-
<Grid item xs={12} sm={6} md={3} key={index + ITEMS_PER_PAGE}>
|
548 |
-
<LeaderboardCard leaderboard={leaderboard} />
|
549 |
-
</Grid>
|
550 |
-
))}
|
551 |
-
</Grid>
|
552 |
-
</Collapse>
|
553 |
-
</>
|
554 |
-
)}
|
555 |
-
</Box>
|
556 |
-
);
|
557 |
-
};
|
558 |
-
|
559 |
-
export default LeaderboardSection;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
client/src/components/LeaderboardSection/components/EmptyState.jsx
ADDED
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from "react";
|
2 |
+
import { Box, Typography } from "@mui/material";
|
3 |
+
import SearchOffIcon from "@mui/icons-material/SearchOff";
|
4 |
+
|
5 |
+
const EmptyState = ({ title, searchQuery }) => {
|
6 |
+
return (
|
7 |
+
<Box
|
8 |
+
sx={{
|
9 |
+
display: "flex",
|
10 |
+
flexDirection: "column",
|
11 |
+
alignItems: "center",
|
12 |
+
gap: 2,
|
13 |
+
py: 7,
|
14 |
+
bgcolor: (theme) =>
|
15 |
+
theme.palette.mode === "dark"
|
16 |
+
? "background.paper"
|
17 |
+
: "background.default",
|
18 |
+
borderRadius: 2,
|
19 |
+
}}
|
20 |
+
>
|
21 |
+
<SearchOffIcon
|
22 |
+
sx={{
|
23 |
+
fontSize: 64,
|
24 |
+
color: "text.secondary",
|
25 |
+
opacity: 0.5,
|
26 |
+
}}
|
27 |
+
/>
|
28 |
+
<Typography variant="h5" color="text.secondary" align="center">
|
29 |
+
{searchQuery ? (
|
30 |
+
<>
|
31 |
+
No {title.toLowerCase()} leaderboard matches{" "}
|
32 |
+
<Box
|
33 |
+
component="span"
|
34 |
+
sx={{
|
35 |
+
bgcolor: "primary.main",
|
36 |
+
color: "primary.contrastText",
|
37 |
+
px: 1,
|
38 |
+
borderRadius: 1,
|
39 |
+
}}
|
40 |
+
>
|
41 |
+
{searchQuery}
|
42 |
+
</Box>
|
43 |
+
</>
|
44 |
+
) : (
|
45 |
+
`No ${title.toLowerCase()} leaderboard matches your criteria`
|
46 |
+
)}
|
47 |
+
</Typography>
|
48 |
+
<Typography variant="body1" color="text.secondary" align="center">
|
49 |
+
Try adjusting your search filters
|
50 |
+
</Typography>
|
51 |
+
</Box>
|
52 |
+
);
|
53 |
+
};
|
54 |
+
|
55 |
+
export default EmptyState;
|
client/src/components/LeaderboardSection/components/LanguageAccordion.jsx
ADDED
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from "react";
|
2 |
+
import {
|
3 |
+
Accordion,
|
4 |
+
AccordionSummary,
|
5 |
+
AccordionDetails,
|
6 |
+
Typography,
|
7 |
+
Box,
|
8 |
+
Button,
|
9 |
+
} from "@mui/material";
|
10 |
+
import { alpha } from "@mui/material/styles";
|
11 |
+
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
12 |
+
|
13 |
+
const LanguageAccordion = ({
|
14 |
+
languages,
|
15 |
+
languageStats,
|
16 |
+
selectedLanguage,
|
17 |
+
onLanguageSelect,
|
18 |
+
LANGUAGE_FAMILIES,
|
19 |
+
findLanguageFamily,
|
20 |
+
}) => {
|
21 |
+
return (
|
22 |
+
<Accordion
|
23 |
+
defaultExpanded={false}
|
24 |
+
elevation={0}
|
25 |
+
disableGutters
|
26 |
+
sx={{
|
27 |
+
mb: 4,
|
28 |
+
"&:before": {
|
29 |
+
display: "none",
|
30 |
+
},
|
31 |
+
backgroundColor: "transparent",
|
32 |
+
}}
|
33 |
+
>
|
34 |
+
<AccordionSummary
|
35 |
+
expandIcon={
|
36 |
+
<ExpandMoreIcon sx={{ fontSize: 20, color: "text.secondary" }} />
|
37 |
+
}
|
38 |
+
sx={{
|
39 |
+
p: 0,
|
40 |
+
minHeight: 32,
|
41 |
+
height: 32,
|
42 |
+
"&.MuiAccordionSummary-root": {
|
43 |
+
"&.Mui-expanded": {
|
44 |
+
minHeight: 32,
|
45 |
+
height: 32,
|
46 |
+
},
|
47 |
+
},
|
48 |
+
"& .MuiAccordionSummary-content": {
|
49 |
+
m: 0,
|
50 |
+
"&.Mui-expanded": {
|
51 |
+
m: 0,
|
52 |
+
},
|
53 |
+
},
|
54 |
+
}}
|
55 |
+
>
|
56 |
+
<Typography
|
57 |
+
variant="body2"
|
58 |
+
color="text.secondary"
|
59 |
+
sx={{ fontWeight: 500 }}
|
60 |
+
>
|
61 |
+
Filter by language
|
62 |
+
</Typography>
|
63 |
+
</AccordionSummary>
|
64 |
+
<AccordionDetails sx={{ padding: 0 }}>
|
65 |
+
{Object.entries(LANGUAGE_FAMILIES).map(([family, subFamilies]) => {
|
66 |
+
const familyLanguages = languages.filter((lang) => {
|
67 |
+
const { family: langFamily } = findLanguageFamily(lang);
|
68 |
+
return langFamily === family;
|
69 |
+
});
|
70 |
+
|
71 |
+
if (familyLanguages.length === 0 && family !== "Other Languages")
|
72 |
+
return null;
|
73 |
+
|
74 |
+
const familyTotal = familyLanguages.reduce(
|
75 |
+
(sum, lang) => sum + (languageStats?.get(lang) || 0),
|
76 |
+
0
|
77 |
+
);
|
78 |
+
|
79 |
+
if (familyTotal === 0) return null;
|
80 |
+
|
81 |
+
return (
|
82 |
+
<Box key={family} sx={{ mb: 3 }}>
|
83 |
+
<Typography
|
84 |
+
variant="body2"
|
85 |
+
color="text.secondary"
|
86 |
+
sx={{ mb: 1, opacity: 0.7 }}
|
87 |
+
>
|
88 |
+
{family}
|
89 |
+
</Typography>
|
90 |
+
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
|
91 |
+
{familyLanguages.map((lang) => {
|
92 |
+
const count = languageStats?.get(lang) || 0;
|
93 |
+
if (count === 0) return null;
|
94 |
+
|
95 |
+
const isActive = selectedLanguage === lang;
|
96 |
+
const isDisabled = count === 0;
|
97 |
+
|
98 |
+
return (
|
99 |
+
<Button
|
100 |
+
key={lang}
|
101 |
+
onClick={() => onLanguageSelect(isActive ? null : lang)}
|
102 |
+
variant={isActive ? "contained" : "outlined"}
|
103 |
+
size="small"
|
104 |
+
disabled={isDisabled}
|
105 |
+
sx={{
|
106 |
+
textTransform: "none",
|
107 |
+
m: 0.125,
|
108 |
+
opacity: isDisabled ? 0.5 : 1,
|
109 |
+
backgroundColor: (theme) =>
|
110 |
+
isActive
|
111 |
+
? undefined
|
112 |
+
: theme.palette.mode === "dark"
|
113 |
+
? "background.paper"
|
114 |
+
: "white",
|
115 |
+
"&:hover": {
|
116 |
+
backgroundColor: (theme) =>
|
117 |
+
isActive
|
118 |
+
? undefined
|
119 |
+
: theme.palette.mode === "dark"
|
120 |
+
? "background.paper"
|
121 |
+
: "white",
|
122 |
+
opacity: isDisabled ? 0.5 : 0.8,
|
123 |
+
},
|
124 |
+
"& .MuiTouchRipple-root": {
|
125 |
+
transition: "none",
|
126 |
+
},
|
127 |
+
transition: "none",
|
128 |
+
}}
|
129 |
+
>
|
130 |
+
{lang}
|
131 |
+
<Box
|
132 |
+
component="span"
|
133 |
+
sx={{
|
134 |
+
display: "inline-flex",
|
135 |
+
alignItems: "center",
|
136 |
+
gap: 0.75,
|
137 |
+
color: isActive ? "inherit" : "text.secondary",
|
138 |
+
ml: 0.75,
|
139 |
+
}}
|
140 |
+
>
|
141 |
+
<Box
|
142 |
+
component="span"
|
143 |
+
sx={(theme) => ({
|
144 |
+
width: "4px",
|
145 |
+
height: "4px",
|
146 |
+
borderRadius: "100%",
|
147 |
+
backgroundColor: alpha(
|
148 |
+
theme.palette.text.primary,
|
149 |
+
theme.palette.mode === "dark" ? 0.2 : 0.15
|
150 |
+
),
|
151 |
+
})}
|
152 |
+
/>
|
153 |
+
{count}
|
154 |
+
</Box>
|
155 |
+
</Button>
|
156 |
+
);
|
157 |
+
})}
|
158 |
+
</Box>
|
159 |
+
</Box>
|
160 |
+
);
|
161 |
+
})}
|
162 |
+
</AccordionDetails>
|
163 |
+
</Accordion>
|
164 |
+
);
|
165 |
+
};
|
166 |
+
|
167 |
+
export default LanguageAccordion;
|
client/src/components/{LeaderboardCard.jsx → LeaderboardSection/components/LeaderboardCard.jsx}
RENAMED
@@ -2,7 +2,7 @@ import React from "react";
|
|
2 |
import { Card, CardContent, Typography, Box, Stack } from "@mui/material";
|
3 |
import FavoriteBorderIcon from "@mui/icons-material/FavoriteBorder";
|
4 |
import { alpha } from "@mui/material/styles";
|
5 |
-
import { useLeaderboard } from "
|
6 |
|
7 |
const LeaderboardCard = ({ leaderboard }) => {
|
8 |
const {
|
|
|
2 |
import { Card, CardContent, Typography, Box, Stack } from "@mui/material";
|
3 |
import FavoriteBorderIcon from "@mui/icons-material/FavoriteBorder";
|
4 |
import { alpha } from "@mui/material/styles";
|
5 |
+
import { useLeaderboard } from "../../../context/LeaderboardContext";
|
6 |
|
7 |
const LeaderboardCard = ({ leaderboard }) => {
|
8 |
const {
|
client/src/components/LeaderboardSection/components/LeaderboardGrid.jsx
ADDED
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from "react";
|
2 |
+
import { Grid, Box, Collapse } from "@mui/material";
|
3 |
+
import { alpha } from "@mui/material/styles";
|
4 |
+
import LeaderboardCard from "./LeaderboardCard";
|
5 |
+
|
6 |
+
const ITEMS_PER_PAGE = 4;
|
7 |
+
|
8 |
+
const LeaderboardSkeleton = () => (
|
9 |
+
<Box
|
10 |
+
sx={{
|
11 |
+
height: "180px",
|
12 |
+
borderRadius: 2,
|
13 |
+
bgcolor: (theme) => alpha(theme.palette.primary.main, 0.15),
|
14 |
+
opacity: 1,
|
15 |
+
transition: "opacity 0.3s ease-in-out",
|
16 |
+
"&:hover": {
|
17 |
+
opacity: 0.8,
|
18 |
+
},
|
19 |
+
}}
|
20 |
+
/>
|
21 |
+
);
|
22 |
+
|
23 |
+
const LeaderboardGrid = ({
|
24 |
+
displayedLeaderboards,
|
25 |
+
remainingLeaderboards,
|
26 |
+
isExpanded,
|
27 |
+
skeletonsNeeded,
|
28 |
+
}) => {
|
29 |
+
return (
|
30 |
+
<>
|
31 |
+
<Grid container spacing={3}>
|
32 |
+
{displayedLeaderboards.map((leaderboard, index) => (
|
33 |
+
<Grid item xs={12} sm={6} md={3} key={index}>
|
34 |
+
<LeaderboardCard leaderboard={leaderboard} />
|
35 |
+
</Grid>
|
36 |
+
))}
|
37 |
+
{Array.from({ length: skeletonsNeeded }).map((_, index) => (
|
38 |
+
<Grid item xs={12} sm={6} md={3} key={`skeleton-${index}`}>
|
39 |
+
<LeaderboardSkeleton />
|
40 |
+
</Grid>
|
41 |
+
))}
|
42 |
+
</Grid>
|
43 |
+
<Collapse in={isExpanded} timeout={300} unmountOnExit>
|
44 |
+
<Grid container spacing={3} sx={{ mt: 0 }}>
|
45 |
+
{remainingLeaderboards.map((leaderboard, index) => (
|
46 |
+
<Grid item xs={12} sm={6} md={3} key={index + ITEMS_PER_PAGE}>
|
47 |
+
<LeaderboardCard leaderboard={leaderboard} />
|
48 |
+
</Grid>
|
49 |
+
))}
|
50 |
+
</Grid>
|
51 |
+
</Collapse>
|
52 |
+
</>
|
53 |
+
);
|
54 |
+
};
|
55 |
+
|
56 |
+
export default LeaderboardGrid;
|
client/src/components/LeaderboardSection/components/SectionHeader.jsx
ADDED
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from "react";
|
2 |
+
import { Typography, Box, Button } from "@mui/material";
|
3 |
+
import { alpha } from "@mui/material/styles";
|
4 |
+
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
5 |
+
|
6 |
+
const SectionHeader = ({
|
7 |
+
title,
|
8 |
+
count,
|
9 |
+
isExpanded,
|
10 |
+
onToggleExpand,
|
11 |
+
showExpandButton,
|
12 |
+
isExpandButtonEnabled,
|
13 |
+
}) => {
|
14 |
+
return (
|
15 |
+
<Box
|
16 |
+
sx={{
|
17 |
+
display: "flex",
|
18 |
+
alignItems: "center",
|
19 |
+
justifyContent: "space-between",
|
20 |
+
mb: 4,
|
21 |
+
}}
|
22 |
+
>
|
23 |
+
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
24 |
+
<Typography
|
25 |
+
variant="h4"
|
26 |
+
sx={{
|
27 |
+
color: "text.primary",
|
28 |
+
fontWeight: 600,
|
29 |
+
fontSize: { xs: "1.5rem", md: "2rem" },
|
30 |
+
}}
|
31 |
+
>
|
32 |
+
{title}
|
33 |
+
</Typography>
|
34 |
+
<Box
|
35 |
+
sx={(theme) => ({
|
36 |
+
width: "4px",
|
37 |
+
height: "4px",
|
38 |
+
borderRadius: "100%",
|
39 |
+
backgroundColor: alpha(
|
40 |
+
theme.palette.text.primary,
|
41 |
+
theme.palette.mode === "dark" ? 0.2 : 0.15
|
42 |
+
),
|
43 |
+
})}
|
44 |
+
/>
|
45 |
+
<Typography
|
46 |
+
variant="h4"
|
47 |
+
sx={{
|
48 |
+
color: "text.secondary",
|
49 |
+
fontWeight: 400,
|
50 |
+
fontSize: { xs: "1.25rem", md: "1.5rem" },
|
51 |
+
opacity: 0.6,
|
52 |
+
}}
|
53 |
+
>
|
54 |
+
{count}
|
55 |
+
</Typography>
|
56 |
+
</Box>
|
57 |
+
{showExpandButton && (
|
58 |
+
<Button
|
59 |
+
onClick={onToggleExpand}
|
60 |
+
size="small"
|
61 |
+
disabled={!isExpandButtonEnabled}
|
62 |
+
sx={{
|
63 |
+
color: "text.secondary",
|
64 |
+
fontSize: "0.875rem",
|
65 |
+
textTransform: "none",
|
66 |
+
opacity: isExpandButtonEnabled ? 1 : 0.5,
|
67 |
+
"&:hover": {
|
68 |
+
backgroundColor: (theme) =>
|
69 |
+
isExpandButtonEnabled
|
70 |
+
? alpha(
|
71 |
+
theme.palette.text.primary,
|
72 |
+
theme.palette.mode === "dark" ? 0.1 : 0.06
|
73 |
+
)
|
74 |
+
: "transparent",
|
75 |
+
},
|
76 |
+
}}
|
77 |
+
endIcon={
|
78 |
+
<ExpandMoreIcon
|
79 |
+
sx={{
|
80 |
+
transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)",
|
81 |
+
transition: "transform 300ms",
|
82 |
+
}}
|
83 |
+
/>
|
84 |
+
}
|
85 |
+
>
|
86 |
+
{isExpanded ? "Show less" : "Show more"}
|
87 |
+
</Button>
|
88 |
+
)}
|
89 |
+
</Box>
|
90 |
+
);
|
91 |
+
};
|
92 |
+
|
93 |
+
export default SectionHeader;
|
client/src/components/LeaderboardSection/hooks/useLanguageStats.js
ADDED
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useMemo } from "react";
|
2 |
+
|
3 |
+
const LANGUAGE_FAMILIES = {
|
4 |
+
"Romance Languages": [
|
5 |
+
"french",
|
6 |
+
"spanish",
|
7 |
+
"italian",
|
8 |
+
"portuguese",
|
9 |
+
"romanian",
|
10 |
+
],
|
11 |
+
"Germanic Languages": [
|
12 |
+
"english",
|
13 |
+
"german",
|
14 |
+
"dutch",
|
15 |
+
"swedish",
|
16 |
+
"danish",
|
17 |
+
"norwegian",
|
18 |
+
],
|
19 |
+
"Asian Languages": ["chinese", "japanese", "korean", "vietnamese", "thai"],
|
20 |
+
"Other Languages": [], // Will catch any language not in other families
|
21 |
+
};
|
22 |
+
|
23 |
+
const findLanguageFamily = (language) => {
|
24 |
+
for (const [family, languages] of Object.entries(LANGUAGE_FAMILIES)) {
|
25 |
+
if (languages.includes(language.toLowerCase())) {
|
26 |
+
return { family, subFamily: language };
|
27 |
+
}
|
28 |
+
}
|
29 |
+
return { family: "Other Languages", subFamily: language };
|
30 |
+
};
|
31 |
+
|
32 |
+
export const useLanguageStats = (leaderboards, filteredLeaderboards) => {
|
33 |
+
const languages = useMemo(() => {
|
34 |
+
if (!leaderboards) return null;
|
35 |
+
|
36 |
+
const langMap = new Map();
|
37 |
+
const langFamilyMap = new Map();
|
38 |
+
|
39 |
+
leaderboards.forEach((board) => {
|
40 |
+
board.tags?.forEach((tag) => {
|
41 |
+
if (tag.toLowerCase().startsWith("language:")) {
|
42 |
+
const lang = tag.split(":")[1].toLowerCase();
|
43 |
+
langMap.set(lang, (langMap.get(lang) || 0) + 1);
|
44 |
+
const { family } = findLanguageFamily(lang);
|
45 |
+
langFamilyMap.set(family, (langFamilyMap.get(family) || 0) + 1);
|
46 |
+
}
|
47 |
+
});
|
48 |
+
});
|
49 |
+
|
50 |
+
const langArray = Array.from(langMap.entries()).map(([lang, count]) => {
|
51 |
+
const { family } = findLanguageFamily(lang);
|
52 |
+
return [lang, count, family];
|
53 |
+
});
|
54 |
+
|
55 |
+
return langArray
|
56 |
+
.sort((a, b) => {
|
57 |
+
if (a[2] !== b[2]) {
|
58 |
+
if (a[2] === "Other Languages") return 1;
|
59 |
+
if (b[2] === "Other Languages") return -1;
|
60 |
+
return a[2].localeCompare(b[2]);
|
61 |
+
}
|
62 |
+
return b[1] - a[1];
|
63 |
+
})
|
64 |
+
.map(([lang]) => lang);
|
65 |
+
}, [leaderboards]);
|
66 |
+
|
67 |
+
const languageStats = useMemo(() => {
|
68 |
+
if (!languages) return null;
|
69 |
+
const stats = new Map();
|
70 |
+
|
71 |
+
languages.forEach((lang) => {
|
72 |
+
const count = filteredLeaderboards.filter((board) =>
|
73 |
+
board.tags?.some(
|
74 |
+
(tag) => tag.toLowerCase() === `language:${lang.toLowerCase()}`
|
75 |
+
)
|
76 |
+
).length;
|
77 |
+
stats.set(lang, count);
|
78 |
+
});
|
79 |
+
|
80 |
+
return stats;
|
81 |
+
}, [languages, filteredLeaderboards]);
|
82 |
+
|
83 |
+
return { languages, languageStats, LANGUAGE_FAMILIES, findLanguageFamily };
|
84 |
+
};
|
client/src/components/LeaderboardSection/index.jsx
ADDED
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from "react";
|
2 |
+
import { Box } from "@mui/material";
|
3 |
+
import { useLeaderboard } from "../../context/LeaderboardContext";
|
4 |
+
import SectionHeader from "./components/SectionHeader";
|
5 |
+
import LanguageAccordion from "./components/LanguageAccordion";
|
6 |
+
import LeaderboardGrid from "./components/LeaderboardGrid";
|
7 |
+
import EmptyState from "./components/EmptyState";
|
8 |
+
import { useLanguageStats } from "./hooks/useLanguageStats";
|
9 |
+
|
10 |
+
const ITEMS_PER_PAGE = 4;
|
11 |
+
|
12 |
+
const LeaderboardSection = ({
|
13 |
+
title,
|
14 |
+
leaderboards,
|
15 |
+
filteredLeaderboards,
|
16 |
+
id,
|
17 |
+
}) => {
|
18 |
+
const {
|
19 |
+
expandedSections,
|
20 |
+
setExpandedSections,
|
21 |
+
selectedLanguage,
|
22 |
+
setSelectedLanguage,
|
23 |
+
searchQuery,
|
24 |
+
selectedCategory,
|
25 |
+
} = useLeaderboard();
|
26 |
+
|
27 |
+
const isExpanded = expandedSections.has(id);
|
28 |
+
|
29 |
+
const { languages, languageStats, LANGUAGE_FAMILIES, findLanguageFamily } =
|
30 |
+
useLanguageStats(leaderboards, filteredLeaderboards);
|
31 |
+
|
32 |
+
// Filtrer pour n'avoir que les leaderboards approuvés
|
33 |
+
const approvedLeaderboards = filteredLeaderboards.filter(
|
34 |
+
(leaderboard) => leaderboard.approval_status === "approved"
|
35 |
+
);
|
36 |
+
|
37 |
+
// On ne retourne null que si on n'a pas de leaderboards bruts
|
38 |
+
if (!leaderboards) return null;
|
39 |
+
|
40 |
+
// On affiche toujours les 3 premiers
|
41 |
+
const displayedLeaderboards = approvedLeaderboards.slice(0, ITEMS_PER_PAGE);
|
42 |
+
// Le reste sera dans le Collapse
|
43 |
+
const remainingLeaderboards = approvedLeaderboards.slice(ITEMS_PER_PAGE);
|
44 |
+
|
45 |
+
// Calculate how many skeletons we need
|
46 |
+
const skeletonsNeeded = Math.max(0, 4 - approvedLeaderboards.length);
|
47 |
+
|
48 |
+
// On affiche le bouton seulement si aucune catégorie n'est sélectionnée
|
49 |
+
const showExpandButton = !selectedCategory;
|
50 |
+
|
51 |
+
// Le bouton est actif seulement s'il y a plus de 4 leaderboards
|
52 |
+
const isExpandButtonEnabled = approvedLeaderboards.length > ITEMS_PER_PAGE;
|
53 |
+
|
54 |
+
const toggleExpanded = () => {
|
55 |
+
setExpandedSections((prev) => {
|
56 |
+
const newSet = new Set(prev);
|
57 |
+
if (isExpanded) {
|
58 |
+
newSet.delete(id);
|
59 |
+
} else {
|
60 |
+
newSet.add(id);
|
61 |
+
}
|
62 |
+
return newSet;
|
63 |
+
});
|
64 |
+
};
|
65 |
+
|
66 |
+
return (
|
67 |
+
<Box sx={{ mb: 6 }}>
|
68 |
+
<SectionHeader
|
69 |
+
title={title}
|
70 |
+
count={approvedLeaderboards.length}
|
71 |
+
isExpanded={isExpanded}
|
72 |
+
onToggleExpand={toggleExpanded}
|
73 |
+
showExpandButton={showExpandButton}
|
74 |
+
isExpandButtonEnabled={isExpandButtonEnabled}
|
75 |
+
/>
|
76 |
+
|
77 |
+
{languages && selectedCategory === "language" && (
|
78 |
+
<LanguageAccordion
|
79 |
+
languages={languages}
|
80 |
+
languageStats={languageStats}
|
81 |
+
selectedLanguage={selectedLanguage}
|
82 |
+
onLanguageSelect={setSelectedLanguage}
|
83 |
+
LANGUAGE_FAMILIES={LANGUAGE_FAMILIES}
|
84 |
+
findLanguageFamily={findLanguageFamily}
|
85 |
+
/>
|
86 |
+
)}
|
87 |
+
|
88 |
+
{approvedLeaderboards.length === 0 ? (
|
89 |
+
<EmptyState title={title} searchQuery={searchQuery} />
|
90 |
+
) : (
|
91 |
+
<LeaderboardGrid
|
92 |
+
displayedLeaderboards={displayedLeaderboards}
|
93 |
+
remainingLeaderboards={remainingLeaderboards}
|
94 |
+
isExpanded={isExpanded}
|
95 |
+
skeletonsNeeded={skeletonsNeeded}
|
96 |
+
/>
|
97 |
+
)}
|
98 |
+
</Box>
|
99 |
+
);
|
100 |
+
};
|
101 |
+
|
102 |
+
export default LeaderboardSection;
|