tfrere commited on
Commit
606f214
·
1 Parent(s): f77579b

refactor leaderboardSection

Browse files
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 "../context/LeaderboardContext";
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;