Spaces:
Running
Running
add multi selection and update language specific list
Browse files- client/src/App.jsx +2 -41
- client/src/components/LeaderboardFilters/LeaderboardFilters.jsx +224 -63
- client/src/components/LeaderboardSection/components/LanguageAccordion.jsx +113 -142
- client/src/components/LeaderboardSection/components/LanguageList.jsx +188 -0
- client/src/components/LeaderboardSection/components/LeaderboardGrid.jsx +14 -20
- client/src/components/LeaderboardSection/hooks/useLanguageStats.js +108 -36
- client/src/components/LeaderboardSection/index.jsx +32 -12
- client/src/components/Logo/Logo.jsx +18 -20
- client/src/components/common/FilterTag.jsx +83 -0
- client/src/context/LeaderboardContext.jsx +133 -123
- client/src/hooks/useUrlState.js +75 -0
- client/src/pages/LeaderboardPage/LeaderboardPage.jsx +55 -13
- client/src/utils/tagFilters.js +23 -0
client/src/App.jsx
CHANGED
@@ -1,11 +1,5 @@
|
|
1 |
-
import React
|
2 |
-
import {
|
3 |
-
HashRouter as Router,
|
4 |
-
Routes,
|
5 |
-
Route,
|
6 |
-
useSearchParams,
|
7 |
-
useLocation,
|
8 |
-
} from "react-router-dom";
|
9 |
import { ThemeProvider } from "@mui/material/styles";
|
10 |
import { Box, CssBaseline } from "@mui/material";
|
11 |
import Navigation from "./components/Navigation/Navigation";
|
@@ -15,38 +9,6 @@ import Footer from "./components/Footer/Footer";
|
|
15 |
import getTheme from "./config/theme";
|
16 |
import { useThemeMode } from "./hooks/useThemeMode";
|
17 |
|
18 |
-
function UrlHandler() {
|
19 |
-
const location = useLocation();
|
20 |
-
const [searchParams] = useSearchParams();
|
21 |
-
|
22 |
-
useEffect(() => {
|
23 |
-
const isHFSpace = window.location !== window.parent.location;
|
24 |
-
if (!isHFSpace) return;
|
25 |
-
|
26 |
-
const queryString = window.location.search;
|
27 |
-
const hash = window.location.hash;
|
28 |
-
|
29 |
-
window.parent.postMessage(
|
30 |
-
{
|
31 |
-
queryString,
|
32 |
-
hash,
|
33 |
-
},
|
34 |
-
"https://huggingface.co"
|
35 |
-
);
|
36 |
-
}, [location, searchParams]);
|
37 |
-
|
38 |
-
useEffect(() => {
|
39 |
-
const handleHashChange = (event) => {
|
40 |
-
console.log("hash change event", event);
|
41 |
-
};
|
42 |
-
|
43 |
-
window.addEventListener("hashchange", handleHashChange);
|
44 |
-
return () => window.removeEventListener("hashchange", handleHashChange);
|
45 |
-
}, []);
|
46 |
-
|
47 |
-
return null;
|
48 |
-
}
|
49 |
-
|
50 |
function App() {
|
51 |
const { mode, toggleTheme } = useThemeMode();
|
52 |
const theme = getTheme(mode);
|
@@ -64,7 +26,6 @@ function App() {
|
|
64 |
<ThemeProvider theme={theme}>
|
65 |
<CssBaseline />
|
66 |
<Router>
|
67 |
-
<UrlHandler />
|
68 |
<Box
|
69 |
sx={{
|
70 |
minHeight: "100vh",
|
|
|
1 |
+
import React from "react";
|
2 |
+
import { HashRouter as Router, Routes, Route } from "react-router-dom";
|
|
|
|
|
|
|
|
|
|
|
|
|
3 |
import { ThemeProvider } from "@mui/material/styles";
|
4 |
import { Box, CssBaseline } from "@mui/material";
|
5 |
import Navigation from "./components/Navigation/Navigation";
|
|
|
9 |
import getTheme from "./config/theme";
|
10 |
import { useThemeMode } from "./hooks/useThemeMode";
|
11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
12 |
function App() {
|
13 |
const { mode, toggleTheme } = useThemeMode();
|
14 |
const theme = getTheme(mode);
|
|
|
26 |
<ThemeProvider theme={theme}>
|
27 |
<CssBaseline />
|
28 |
<Router>
|
|
|
29 |
<Box
|
30 |
sx={{
|
31 |
minHeight: "100vh",
|
client/src/components/LeaderboardFilters/LeaderboardFilters.jsx
CHANGED
@@ -5,6 +5,32 @@ import { useDebounce } from "../../hooks/useDebounce";
|
|
5 |
import { alpha, lighten, darken } from "@mui/material/styles";
|
6 |
import SearchBar from "./SearchBar";
|
7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
8 |
// Helper function to get the category group of a section
|
9 |
const getSectionGroup = (id) => {
|
10 |
const groups = {
|
@@ -65,8 +91,8 @@ const LeaderboardFilters = ({ allSections = [] }) => {
|
|
65 |
arenaOnly,
|
66 |
setArenaOnly,
|
67 |
filterLeaderboards,
|
68 |
-
|
69 |
-
|
70 |
searchQuery,
|
71 |
} = useLeaderboard();
|
72 |
|
@@ -86,70 +112,208 @@ const LeaderboardFilters = ({ allSections = [] }) => {
|
|
86 |
|
87 |
// Update total arena count
|
88 |
React.useEffect(() => {
|
89 |
-
const
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
97 |
const arenaCount = boardsToCount.filter((board) =>
|
98 |
board.tags?.includes("judge:humans")
|
99 |
).length;
|
100 |
setTotalArenaCount(arenaCount);
|
101 |
-
}, [
|
102 |
|
103 |
-
// Calculer le nombre total en fonction
|
104 |
const totalCount = useMemo(() => {
|
105 |
if (!allSections) return 0;
|
106 |
|
107 |
-
if (
|
108 |
-
|
109 |
-
(section) => section.id === selectedCategory
|
110 |
-
);
|
111 |
-
return categorySection?.data?.length || 0;
|
112 |
}
|
113 |
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
120 |
});
|
121 |
-
}
|
122 |
-
|
123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
124 |
|
125 |
// Calculer le nombre filtré en prenant en compte tous les filtres
|
126 |
const currentFilteredCount = useMemo(() => {
|
127 |
if (!allSections) return 0;
|
128 |
|
129 |
-
if (arenaOnly && !searchQuery &&
|
130 |
return totalArenaCount;
|
131 |
}
|
132 |
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
|
|
140 |
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
uniqueFilteredIds.add(board.id);
|
147 |
-
}
|
148 |
-
});
|
149 |
-
});
|
150 |
-
return uniqueFilteredIds.size;
|
151 |
}, [
|
152 |
-
|
153 |
allSections,
|
154 |
filterLeaderboards,
|
155 |
arenaOnly,
|
@@ -184,6 +348,7 @@ const LeaderboardFilters = ({ allSections = [] }) => {
|
|
184 |
const prevGroup =
|
185 |
index > 0 ? getSectionGroup(allSections[index - 1].id) : null;
|
186 |
const needsSpacing = currentGroup !== prevGroup;
|
|
|
187 |
|
188 |
return (
|
189 |
<React.Fragment key={id}>
|
@@ -192,22 +357,19 @@ const LeaderboardFilters = ({ allSections = [] }) => {
|
|
192 |
)}
|
193 |
<Button
|
194 |
onClick={() => {
|
195 |
-
if (
|
196 |
-
|
197 |
}
|
198 |
}}
|
199 |
-
variant={
|
200 |
size="small"
|
201 |
-
disabled={count === 0 &&
|
202 |
sx={{
|
203 |
textTransform: "none",
|
204 |
-
cursor:
|
205 |
-
count === 0 && selectedCategory !== id
|
206 |
-
? "default"
|
207 |
-
: "pointer",
|
208 |
mb: 1,
|
209 |
backgroundColor: (theme) => {
|
210 |
-
if (
|
211 |
return groupColors[currentGroup]?.main;
|
212 |
}
|
213 |
return theme.palette.mode === "dark"
|
@@ -215,7 +377,7 @@ const LeaderboardFilters = ({ allSections = [] }) => {
|
|
215 |
: groupColors[currentGroup]?.light;
|
216 |
},
|
217 |
color: (theme) => {
|
218 |
-
if (
|
219 |
return "white";
|
220 |
}
|
221 |
return groupColors[currentGroup]?.main;
|
@@ -225,7 +387,7 @@ const LeaderboardFilters = ({ allSections = [] }) => {
|
|
225 |
},
|
226 |
"&:hover": {
|
227 |
backgroundColor: (theme) => {
|
228 |
-
if (
|
229 |
return groupColors[currentGroup]?.main;
|
230 |
}
|
231 |
return groupColors[currentGroup]?.light;
|
@@ -245,7 +407,7 @@ const LeaderboardFilters = ({ allSections = [] }) => {
|
|
245 |
alignItems: "center",
|
246 |
gap: 0.75,
|
247 |
color: (theme) => {
|
248 |
-
if (
|
249 |
return "white";
|
250 |
}
|
251 |
// Assombrir la couleur principale du groupe
|
@@ -262,13 +424,12 @@ const LeaderboardFilters = ({ allSections = [] }) => {
|
|
262 |
width: "4px",
|
263 |
height: "4px",
|
264 |
borderRadius: "100%",
|
265 |
-
backgroundColor:
|
266 |
-
|
267 |
-
|
268 |
-
|
269 |
-
|
270 |
-
|
271 |
-
),
|
272 |
})}
|
273 |
/>
|
274 |
{count}
|
|
|
5 |
import { alpha, lighten, darken } from "@mui/material/styles";
|
6 |
import SearchBar from "./SearchBar";
|
7 |
|
8 |
+
// Constantes pour les tags de catégorisation
|
9 |
+
const CATEGORIZATION_TAGS = [
|
10 |
+
"modality:agent",
|
11 |
+
"modality:artefacts",
|
12 |
+
"modality:text",
|
13 |
+
"eval:code",
|
14 |
+
"eval:math",
|
15 |
+
"eval:reasoning",
|
16 |
+
"eval:hallucination",
|
17 |
+
"modality:video",
|
18 |
+
"modality:image",
|
19 |
+
"modality:3d",
|
20 |
+
"modality:audio",
|
21 |
+
"domain:financial",
|
22 |
+
"domain:medical",
|
23 |
+
"domain:legal",
|
24 |
+
"domain:biology",
|
25 |
+
"domain:translation",
|
26 |
+
"domain:chemistry",
|
27 |
+
"domain:physics",
|
28 |
+
"domain:commercial",
|
29 |
+
"eval:safety",
|
30 |
+
"eval:performance",
|
31 |
+
"eval:rag",
|
32 |
+
];
|
33 |
+
|
34 |
// Helper function to get the category group of a section
|
35 |
const getSectionGroup = (id) => {
|
36 |
const groups = {
|
|
|
91 |
arenaOnly,
|
92 |
setArenaOnly,
|
93 |
filterLeaderboards,
|
94 |
+
selectedCategories,
|
95 |
+
setSelectedCategories,
|
96 |
searchQuery,
|
97 |
} = useLeaderboard();
|
98 |
|
|
|
112 |
|
113 |
// Update total arena count
|
114 |
React.useEffect(() => {
|
115 |
+
const allLeaderboards = Array.from(
|
116 |
+
new Set(
|
117 |
+
allSections.reduce((acc, section) => {
|
118 |
+
return [...acc, ...(section.data || [])];
|
119 |
+
}, [])
|
120 |
+
)
|
121 |
+
);
|
122 |
|
123 |
+
// Filtrer d'abord par catégories si nécessaire
|
124 |
+
let boardsToCount = allLeaderboards;
|
125 |
+
if (selectedCategories.size > 0) {
|
126 |
+
boardsToCount = boardsToCount.filter((board) => {
|
127 |
+
if (board.approval_status !== "approved") return false;
|
128 |
+
const tags = board.tags || [];
|
129 |
+
return Array.from(selectedCategories).every((category) => {
|
130 |
+
switch (category) {
|
131 |
+
case "agentic":
|
132 |
+
return tags.includes("modality:agent");
|
133 |
+
case "text":
|
134 |
+
return tags.includes("modality:text");
|
135 |
+
case "image":
|
136 |
+
return tags.includes("modality:image");
|
137 |
+
case "video":
|
138 |
+
return tags.includes("modality:video");
|
139 |
+
case "code":
|
140 |
+
return tags.includes("eval:code");
|
141 |
+
case "math":
|
142 |
+
return tags.includes("eval:math");
|
143 |
+
case "reasoning":
|
144 |
+
return tags.includes("eval:reasoning");
|
145 |
+
case "hallucination":
|
146 |
+
return tags.includes("eval:hallucination");
|
147 |
+
case "rag":
|
148 |
+
return tags.includes("eval:rag");
|
149 |
+
case "language":
|
150 |
+
return tags.some((tag) => tag.startsWith("language:"));
|
151 |
+
case "vision":
|
152 |
+
return tags.some(
|
153 |
+
(tag) => tag === "modality:video" || tag === "modality:image"
|
154 |
+
);
|
155 |
+
case "threeD":
|
156 |
+
return tags.includes("modality:3d");
|
157 |
+
case "audio":
|
158 |
+
return tags.includes("modality:audio");
|
159 |
+
case "financial":
|
160 |
+
return tags.includes("domain:financial");
|
161 |
+
case "medical":
|
162 |
+
return tags.includes("domain:medical");
|
163 |
+
case "legal":
|
164 |
+
return tags.includes("domain:legal");
|
165 |
+
case "biology":
|
166 |
+
return tags.includes("domain:biology");
|
167 |
+
case "commercial":
|
168 |
+
return tags.includes("domain:commercial");
|
169 |
+
case "translation":
|
170 |
+
return tags.includes("domain:translation");
|
171 |
+
case "chemistry":
|
172 |
+
return tags.includes("domain:chemistry");
|
173 |
+
case "safety":
|
174 |
+
return tags.includes("eval:safety");
|
175 |
+
case "performance":
|
176 |
+
return tags.includes("eval:performance");
|
177 |
+
case "uncategorized":
|
178 |
+
return !tags.some(
|
179 |
+
(tag) =>
|
180 |
+
CATEGORIZATION_TAGS.includes(tag) ||
|
181 |
+
tag.startsWith("language:")
|
182 |
+
);
|
183 |
+
default:
|
184 |
+
return false;
|
185 |
+
}
|
186 |
+
});
|
187 |
+
});
|
188 |
+
}
|
189 |
+
|
190 |
+
// Ensuite compter les leaderboards arena
|
191 |
const arenaCount = boardsToCount.filter((board) =>
|
192 |
board.tags?.includes("judge:humans")
|
193 |
).length;
|
194 |
setTotalArenaCount(arenaCount);
|
195 |
+
}, [selectedCategories, allSections]);
|
196 |
|
197 |
+
// Calculer le nombre total en fonction des catégories sélectionnées
|
198 |
const totalCount = useMemo(() => {
|
199 |
if (!allSections) return 0;
|
200 |
|
201 |
+
if (arenaOnly && selectedCategories.size === 0) {
|
202 |
+
return totalArenaCount;
|
|
|
|
|
|
|
203 |
}
|
204 |
|
205 |
+
// Récupérer tous les leaderboards uniques de toutes les sections
|
206 |
+
const allLeaderboards = Array.from(
|
207 |
+
new Set(
|
208 |
+
allSections.reduce((acc, section) => {
|
209 |
+
return [...acc, ...(section.data || [])];
|
210 |
+
}, [])
|
211 |
+
)
|
212 |
+
);
|
213 |
+
|
214 |
+
// Si des catégories sont sélectionnées, filtrer les leaderboards qui correspondent à TOUTES les catégories
|
215 |
+
let filteredBoards = allLeaderboards;
|
216 |
+
if (selectedCategories.size > 0) {
|
217 |
+
filteredBoards = filteredBoards.filter((board) => {
|
218 |
+
if (board.approval_status !== "approved") return false;
|
219 |
+
const tags = board.tags || [];
|
220 |
+
return Array.from(selectedCategories).every((category) => {
|
221 |
+
switch (category) {
|
222 |
+
case "agentic":
|
223 |
+
return tags.includes("modality:agent");
|
224 |
+
case "text":
|
225 |
+
return tags.includes("modality:text");
|
226 |
+
case "image":
|
227 |
+
return tags.includes("modality:image");
|
228 |
+
case "video":
|
229 |
+
return tags.includes("modality:video");
|
230 |
+
case "code":
|
231 |
+
return tags.includes("eval:code");
|
232 |
+
case "math":
|
233 |
+
return tags.includes("eval:math");
|
234 |
+
case "reasoning":
|
235 |
+
return tags.includes("eval:reasoning");
|
236 |
+
case "hallucination":
|
237 |
+
return tags.includes("eval:hallucination");
|
238 |
+
case "rag":
|
239 |
+
return tags.includes("eval:rag");
|
240 |
+
case "language":
|
241 |
+
return tags.some((tag) => tag.startsWith("language:"));
|
242 |
+
case "vision":
|
243 |
+
return tags.some(
|
244 |
+
(tag) => tag === "modality:video" || tag === "modality:image"
|
245 |
+
);
|
246 |
+
case "threeD":
|
247 |
+
return tags.includes("modality:3d");
|
248 |
+
case "audio":
|
249 |
+
return tags.includes("modality:audio");
|
250 |
+
case "financial":
|
251 |
+
return tags.includes("domain:financial");
|
252 |
+
case "medical":
|
253 |
+
return tags.includes("domain:medical");
|
254 |
+
case "legal":
|
255 |
+
return tags.includes("domain:legal");
|
256 |
+
case "biology":
|
257 |
+
return tags.includes("domain:biology");
|
258 |
+
case "commercial":
|
259 |
+
return tags.includes("domain:commercial");
|
260 |
+
case "translation":
|
261 |
+
return tags.includes("domain:translation");
|
262 |
+
case "chemistry":
|
263 |
+
return tags.includes("domain:chemistry");
|
264 |
+
case "safety":
|
265 |
+
return tags.includes("eval:safety");
|
266 |
+
case "performance":
|
267 |
+
return tags.includes("eval:performance");
|
268 |
+
case "uncategorized":
|
269 |
+
return !tags.some(
|
270 |
+
(tag) =>
|
271 |
+
CATEGORIZATION_TAGS.includes(tag) ||
|
272 |
+
tag.startsWith("language:")
|
273 |
+
);
|
274 |
+
default:
|
275 |
+
return false;
|
276 |
+
}
|
277 |
+
});
|
278 |
});
|
279 |
+
}
|
280 |
+
|
281 |
+
// Appliquer le filtre Arena Only si nécessaire
|
282 |
+
if (arenaOnly) {
|
283 |
+
filteredBoards = filteredBoards.filter((board) =>
|
284 |
+
board.tags?.includes("judge:humans")
|
285 |
+
);
|
286 |
+
}
|
287 |
+
|
288 |
+
return filteredBoards.filter(
|
289 |
+
(board) => board.approval_status === "approved"
|
290 |
+
).length;
|
291 |
+
}, [selectedCategories, allSections, arenaOnly, totalArenaCount]);
|
292 |
|
293 |
// Calculer le nombre filtré en prenant en compte tous les filtres
|
294 |
const currentFilteredCount = useMemo(() => {
|
295 |
if (!allSections) return 0;
|
296 |
|
297 |
+
if (arenaOnly && !searchQuery && selectedCategories.size === 0) {
|
298 |
return totalArenaCount;
|
299 |
}
|
300 |
|
301 |
+
// Récupérer tous les leaderboards uniques de toutes les sections
|
302 |
+
const allLeaderboards = Array.from(
|
303 |
+
new Set(
|
304 |
+
allSections.reduce((acc, section) => {
|
305 |
+
return [...acc, ...(section.data || [])];
|
306 |
+
}, [])
|
307 |
+
)
|
308 |
+
);
|
309 |
|
310 |
+
// Appliquer les filtres sur l'ensemble des leaderboards
|
311 |
+
const filteredBoards = filterLeaderboards(allLeaderboards);
|
312 |
+
return filteredBoards.filter(
|
313 |
+
(board) => board.approval_status === "approved"
|
314 |
+
).length;
|
|
|
|
|
|
|
|
|
|
|
315 |
}, [
|
316 |
+
selectedCategories,
|
317 |
allSections,
|
318 |
filterLeaderboards,
|
319 |
arenaOnly,
|
|
|
348 |
const prevGroup =
|
349 |
index > 0 ? getSectionGroup(allSections[index - 1].id) : null;
|
350 |
const needsSpacing = currentGroup !== prevGroup;
|
351 |
+
const isSelected = selectedCategories.has(id);
|
352 |
|
353 |
return (
|
354 |
<React.Fragment key={id}>
|
|
|
357 |
)}
|
358 |
<Button
|
359 |
onClick={() => {
|
360 |
+
if (isSelected || count > 0) {
|
361 |
+
setSelectedCategories(id);
|
362 |
}
|
363 |
}}
|
364 |
+
variant={isSelected ? "contained" : "outlined"}
|
365 |
size="small"
|
366 |
+
disabled={count === 0 && !isSelected}
|
367 |
sx={{
|
368 |
textTransform: "none",
|
369 |
+
cursor: count === 0 && !isSelected ? "default" : "pointer",
|
|
|
|
|
|
|
370 |
mb: 1,
|
371 |
backgroundColor: (theme) => {
|
372 |
+
if (isSelected) {
|
373 |
return groupColors[currentGroup]?.main;
|
374 |
}
|
375 |
return theme.palette.mode === "dark"
|
|
|
377 |
: groupColors[currentGroup]?.light;
|
378 |
},
|
379 |
color: (theme) => {
|
380 |
+
if (isSelected) {
|
381 |
return "white";
|
382 |
}
|
383 |
return groupColors[currentGroup]?.main;
|
|
|
387 |
},
|
388 |
"&:hover": {
|
389 |
backgroundColor: (theme) => {
|
390 |
+
if (isSelected) {
|
391 |
return groupColors[currentGroup]?.main;
|
392 |
}
|
393 |
return groupColors[currentGroup]?.light;
|
|
|
407 |
alignItems: "center",
|
408 |
gap: 0.75,
|
409 |
color: (theme) => {
|
410 |
+
if (isSelected) {
|
411 |
return "white";
|
412 |
}
|
413 |
// Assombrir la couleur principale du groupe
|
|
|
424 |
width: "4px",
|
425 |
height: "4px",
|
426 |
borderRadius: "100%",
|
427 |
+
backgroundColor: isSelected
|
428 |
+
? "rgba(255, 255, 255, 0.5)"
|
429 |
+
: alpha(
|
430 |
+
groupColors[currentGroup]?.main,
|
431 |
+
theme.palette.mode === "dark" ? 0.4 : 0.3
|
432 |
+
),
|
|
|
433 |
})}
|
434 |
/>
|
435 |
{count}
|
client/src/components/LeaderboardSection/components/LanguageAccordion.jsx
CHANGED
@@ -1,16 +1,13 @@
|
|
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
|
|
|
|
|
|
|
|
|
|
|
14 |
languages,
|
15 |
languageStats,
|
16 |
selectedLanguage,
|
@@ -18,150 +15,124 @@ const LanguageAccordion = ({
|
|
18 |
LANGUAGE_FAMILIES,
|
19 |
findLanguageFamily,
|
20 |
}) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
21 |
return (
|
22 |
-
<
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
backgroundColor: "transparent",
|
32 |
-
}}
|
33 |
-
>
|
34 |
-
<AccordionSummary
|
35 |
-
expandIcon={
|
36 |
-
<ExpandMoreIcon sx={{ fontSize: 20, color: "text.secondary" }} />
|
37 |
-
}
|
38 |
sx={{
|
39 |
-
|
40 |
-
|
41 |
-
|
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 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
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 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
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 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
),
|
151 |
-
})}
|
152 |
-
/>
|
153 |
-
{count}
|
154 |
-
</Box>
|
155 |
-
</Button>
|
156 |
-
);
|
157 |
-
})}
|
158 |
-
</Box>
|
159 |
</Box>
|
160 |
-
)
|
161 |
-
|
162 |
-
</
|
163 |
-
</
|
164 |
);
|
165 |
};
|
166 |
|
167 |
-
export default
|
|
|
1 |
import React from "react";
|
2 |
+
import { Typography, Box, Button } from "@mui/material";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
3 |
import { alpha } from "@mui/material/styles";
|
|
|
4 |
|
5 |
+
const TAG_COLOR = {
|
6 |
+
main: "#F44336",
|
7 |
+
light: "#FFEBEE",
|
8 |
+
};
|
9 |
+
|
10 |
+
const LanguageList = ({
|
11 |
languages,
|
12 |
languageStats,
|
13 |
selectedLanguage,
|
|
|
15 |
LANGUAGE_FAMILIES,
|
16 |
findLanguageFamily,
|
17 |
}) => {
|
18 |
+
// Group languages by family
|
19 |
+
const groupedLanguages = languages.reduce((acc, lang) => {
|
20 |
+
const { family } = findLanguageFamily(lang);
|
21 |
+
if (!acc[family]) {
|
22 |
+
acc[family] = [];
|
23 |
+
}
|
24 |
+
acc[family].push(lang);
|
25 |
+
return acc;
|
26 |
+
}, {});
|
27 |
+
|
28 |
+
const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);
|
29 |
+
|
30 |
return (
|
31 |
+
<Box sx={{ mb: 4 }}>
|
32 |
+
<Typography
|
33 |
+
variant="body2"
|
34 |
+
color="text.secondary"
|
35 |
+
sx={{ fontWeight: 500, mb: 2 }}
|
36 |
+
>
|
37 |
+
Filter by language
|
38 |
+
</Typography>
|
39 |
+
<Box
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
40 |
sx={{
|
41 |
+
display: "flex",
|
42 |
+
flexWrap: "wrap",
|
43 |
+
gap: 3,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
44 |
}}
|
45 |
>
|
46 |
+
{Object.entries(groupedLanguages).map(
|
47 |
+
([family, familyLanguages], familyIndex) => (
|
48 |
+
<Box
|
49 |
+
key={family}
|
50 |
+
sx={{
|
51 |
+
display: "flex",
|
52 |
+
flexWrap: "wrap",
|
53 |
+
gap: 1,
|
54 |
+
}}
|
55 |
+
>
|
56 |
+
{familyLanguages.map((lang) => {
|
57 |
+
const isActive = selectedLanguage === lang;
|
58 |
+
const count = languageStats?.get(lang) || 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
59 |
|
60 |
+
return (
|
61 |
+
<Button
|
62 |
+
key={lang}
|
63 |
+
onClick={() => onLanguageSelect(isActive ? null : lang)}
|
64 |
+
variant={isActive ? "contained" : "outlined"}
|
65 |
+
size="small"
|
66 |
+
sx={{
|
67 |
+
textTransform: "none",
|
68 |
+
cursor: "pointer",
|
69 |
+
mb: 0.75,
|
70 |
+
backgroundColor: (theme) => {
|
71 |
+
if (isActive) {
|
72 |
+
return TAG_COLOR.main;
|
73 |
+
}
|
74 |
+
return theme.palette.mode === "dark"
|
75 |
+
? "background.paper"
|
76 |
+
: TAG_COLOR.light;
|
77 |
+
},
|
78 |
+
color: (theme) => {
|
79 |
+
if (isActive) {
|
80 |
+
return "white";
|
81 |
+
}
|
82 |
+
return TAG_COLOR.main;
|
83 |
+
},
|
84 |
+
borderColor: TAG_COLOR.main,
|
85 |
+
"&:hover": {
|
86 |
+
backgroundColor: (theme) => {
|
87 |
+
if (isActive) {
|
88 |
+
return TAG_COLOR.main;
|
89 |
+
}
|
90 |
+
return TAG_COLOR.light;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
91 |
},
|
92 |
+
opacity: 0.8,
|
93 |
+
},
|
94 |
+
"& .MuiTouchRipple-root": {
|
95 |
transition: "none",
|
96 |
+
},
|
97 |
+
transition: "none",
|
98 |
+
}}
|
99 |
+
>
|
100 |
+
{capitalize(lang)}
|
101 |
+
<Box
|
102 |
+
component="span"
|
103 |
+
sx={{
|
104 |
+
display: "inline-flex",
|
105 |
+
alignItems: "center",
|
106 |
+
gap: 0.75,
|
107 |
+
color: isActive ? "white" : "inherit",
|
108 |
+
ml: 0.75,
|
109 |
}}
|
110 |
>
|
|
|
111 |
<Box
|
112 |
component="span"
|
113 |
+
sx={(theme) => ({
|
114 |
+
width: "4px",
|
115 |
+
height: "4px",
|
116 |
+
borderRadius: "100%",
|
117 |
+
backgroundColor: isActive
|
118 |
+
? "rgba(255, 255, 255, 0.5)"
|
119 |
+
: alpha(
|
120 |
+
TAG_COLOR.main,
|
121 |
+
theme.palette.mode === "dark" ? 0.4 : 0.3
|
122 |
+
),
|
123 |
+
})}
|
124 |
+
/>
|
125 |
+
{count}
|
126 |
+
</Box>
|
127 |
+
</Button>
|
128 |
+
);
|
129 |
+
})}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
130 |
</Box>
|
131 |
+
)
|
132 |
+
)}
|
133 |
+
</Box>
|
134 |
+
</Box>
|
135 |
);
|
136 |
};
|
137 |
|
138 |
+
export default LanguageList;
|
client/src/components/LeaderboardSection/components/LanguageList.jsx
ADDED
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React, { useMemo } from "react";
|
2 |
+
import {
|
3 |
+
Typography,
|
4 |
+
Box,
|
5 |
+
Button,
|
6 |
+
Accordion,
|
7 |
+
AccordionSummary,
|
8 |
+
AccordionDetails,
|
9 |
+
} from "@mui/material";
|
10 |
+
import { alpha } from "@mui/material/styles";
|
11 |
+
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
12 |
+
import { useLeaderboard } from "../../../context/LeaderboardContext";
|
13 |
+
|
14 |
+
const TAG_COLOR = {
|
15 |
+
main: "#F44336",
|
16 |
+
light: "#FFEBEE",
|
17 |
+
};
|
18 |
+
|
19 |
+
const LanguageList = ({
|
20 |
+
languages,
|
21 |
+
languageStats,
|
22 |
+
selectedLanguage,
|
23 |
+
onLanguageSelect,
|
24 |
+
LANGUAGE_FAMILIES,
|
25 |
+
findLanguageFamily,
|
26 |
+
}) => {
|
27 |
+
const { isLanguageExpanded, setIsLanguageExpanded } = useLeaderboard();
|
28 |
+
|
29 |
+
// Store initial language stats in a memo to keep them constant
|
30 |
+
const initialLanguageStats = useMemo(() => new Map(languageStats), []);
|
31 |
+
|
32 |
+
// Group languages by family
|
33 |
+
const groupedLanguages = languages.reduce((acc, lang) => {
|
34 |
+
const { family } = findLanguageFamily(lang);
|
35 |
+
if (!acc[family]) {
|
36 |
+
acc[family] = [];
|
37 |
+
}
|
38 |
+
acc[family].push(lang);
|
39 |
+
return acc;
|
40 |
+
}, {});
|
41 |
+
|
42 |
+
const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1);
|
43 |
+
|
44 |
+
const handleAccordionChange = (event, expanded) => {
|
45 |
+
setIsLanguageExpanded(expanded);
|
46 |
+
};
|
47 |
+
|
48 |
+
return (
|
49 |
+
<Box sx={{ mb: 4 }}>
|
50 |
+
<Accordion
|
51 |
+
expanded={isLanguageExpanded}
|
52 |
+
onChange={handleAccordionChange}
|
53 |
+
disableGutters
|
54 |
+
elevation={0}
|
55 |
+
sx={{
|
56 |
+
"&:before": {
|
57 |
+
display: "none",
|
58 |
+
},
|
59 |
+
bgcolor: "transparent",
|
60 |
+
}}
|
61 |
+
>
|
62 |
+
<AccordionSummary
|
63 |
+
expandIcon={<ExpandMoreIcon />}
|
64 |
+
aria-controls="language-filter-content"
|
65 |
+
id="language-filter-header"
|
66 |
+
sx={{
|
67 |
+
p: 0,
|
68 |
+
minHeight: "unset",
|
69 |
+
"& .MuiAccordionSummary-content": {
|
70 |
+
m: 0,
|
71 |
+
alignItems: "center",
|
72 |
+
},
|
73 |
+
"& .MuiAccordionSummary-expandIconWrapper": {
|
74 |
+
ml: 1,
|
75 |
+
},
|
76 |
+
}}
|
77 |
+
>
|
78 |
+
<Typography
|
79 |
+
variant="body2"
|
80 |
+
color="text.secondary"
|
81 |
+
sx={{ fontWeight: 500 }}
|
82 |
+
>
|
83 |
+
Filter by language
|
84 |
+
</Typography>
|
85 |
+
</AccordionSummary>
|
86 |
+
<AccordionDetails sx={{ p: 0, pt: 2 }}>
|
87 |
+
<Box
|
88 |
+
sx={{
|
89 |
+
display: "flex",
|
90 |
+
flexWrap: "wrap",
|
91 |
+
gap: 3,
|
92 |
+
}}
|
93 |
+
>
|
94 |
+
{Object.entries(groupedLanguages).map(
|
95 |
+
([family, familyLanguages], familyIndex) => (
|
96 |
+
<Box
|
97 |
+
key={family}
|
98 |
+
sx={{
|
99 |
+
display: "flex",
|
100 |
+
flexWrap: "wrap",
|
101 |
+
gap: 1,
|
102 |
+
}}
|
103 |
+
>
|
104 |
+
{familyLanguages.map((lang) => {
|
105 |
+
const isActive = selectedLanguage === lang;
|
106 |
+
const count = initialLanguageStats.get(lang) || 0;
|
107 |
+
|
108 |
+
return (
|
109 |
+
<Button
|
110 |
+
key={lang}
|
111 |
+
onClick={() => onLanguageSelect(isActive ? null : lang)}
|
112 |
+
variant={isActive ? "contained" : "outlined"}
|
113 |
+
size="small"
|
114 |
+
sx={{
|
115 |
+
textTransform: "none",
|
116 |
+
cursor: "pointer",
|
117 |
+
mb: 0.75,
|
118 |
+
backgroundColor: (theme) => {
|
119 |
+
if (isActive) {
|
120 |
+
return TAG_COLOR.main;
|
121 |
+
}
|
122 |
+
return theme.palette.mode === "dark"
|
123 |
+
? "background.paper"
|
124 |
+
: TAG_COLOR.light;
|
125 |
+
},
|
126 |
+
color: (theme) => {
|
127 |
+
if (isActive) {
|
128 |
+
return "white";
|
129 |
+
}
|
130 |
+
return TAG_COLOR.main;
|
131 |
+
},
|
132 |
+
borderColor: TAG_COLOR.main,
|
133 |
+
"&:hover": {
|
134 |
+
backgroundColor: (theme) => {
|
135 |
+
if (isActive) {
|
136 |
+
return TAG_COLOR.main;
|
137 |
+
}
|
138 |
+
return TAG_COLOR.light;
|
139 |
+
},
|
140 |
+
opacity: 0.8,
|
141 |
+
},
|
142 |
+
"& .MuiTouchRipple-root": {
|
143 |
+
transition: "none",
|
144 |
+
},
|
145 |
+
transition: "none",
|
146 |
+
}}
|
147 |
+
>
|
148 |
+
{capitalize(lang)}
|
149 |
+
<Box
|
150 |
+
component="span"
|
151 |
+
sx={{
|
152 |
+
display: "inline-flex",
|
153 |
+
alignItems: "center",
|
154 |
+
gap: 0.75,
|
155 |
+
color: isActive ? "white" : "inherit",
|
156 |
+
ml: 0.75,
|
157 |
+
}}
|
158 |
+
>
|
159 |
+
<Box
|
160 |
+
component="span"
|
161 |
+
sx={(theme) => ({
|
162 |
+
width: "4px",
|
163 |
+
height: "4px",
|
164 |
+
borderRadius: "100%",
|
165 |
+
backgroundColor: isActive
|
166 |
+
? "rgba(255, 255, 255, 0.5)"
|
167 |
+
: alpha(
|
168 |
+
TAG_COLOR.main,
|
169 |
+
theme.palette.mode === "dark" ? 0.4 : 0.3
|
170 |
+
),
|
171 |
+
})}
|
172 |
+
/>
|
173 |
+
{count}
|
174 |
+
</Box>
|
175 |
+
</Button>
|
176 |
+
);
|
177 |
+
})}
|
178 |
+
</Box>
|
179 |
+
)
|
180 |
+
)}
|
181 |
+
</Box>
|
182 |
+
</AccordionDetails>
|
183 |
+
</Accordion>
|
184 |
+
</Box>
|
185 |
+
);
|
186 |
+
};
|
187 |
+
|
188 |
+
export default LanguageList;
|
client/src/components/LeaderboardSection/components/LeaderboardGrid.jsx
CHANGED
@@ -1,5 +1,5 @@
|
|
1 |
import React from "react";
|
2 |
-
import { Grid, Box
|
3 |
import { alpha } from "@mui/material/styles";
|
4 |
import LeaderboardCard from "./LeaderboardCard";
|
5 |
|
@@ -25,31 +25,25 @@ const LeaderboardGrid = ({
|
|
25 |
remainingLeaderboards,
|
26 |
isExpanded,
|
27 |
skeletonsNeeded,
|
|
|
28 |
}) => {
|
|
|
|
|
|
|
29 |
return (
|
30 |
-
|
31 |
-
|
32 |
-
{
|
33 |
-
<
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
<Grid item xs={12} sm={6} md={3} key={`skeleton-${index}`}>
|
39 |
<LeaderboardSkeleton />
|
40 |
</Grid>
|
41 |
))}
|
42 |
-
|
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 |
|
|
|
1 |
import React from "react";
|
2 |
+
import { Grid, Box } from "@mui/material";
|
3 |
import { alpha } from "@mui/material/styles";
|
4 |
import LeaderboardCard from "./LeaderboardCard";
|
5 |
|
|
|
25 |
remainingLeaderboards,
|
26 |
isExpanded,
|
27 |
skeletonsNeeded,
|
28 |
+
id,
|
29 |
}) => {
|
30 |
+
// On n'affiche que les leaderboards qui doivent être affichés selon l'état d'expansion
|
31 |
+
const leaderboardsToShow = displayedLeaderboards;
|
32 |
+
|
33 |
return (
|
34 |
+
<Grid container spacing={3}>
|
35 |
+
{leaderboardsToShow.map((leaderboard, index) => (
|
36 |
+
<Grid item xs={12} sm={6} md={3} key={index}>
|
37 |
+
<LeaderboardCard leaderboard={leaderboard} />
|
38 |
+
</Grid>
|
39 |
+
))}
|
40 |
+
{skeletonsNeeded > 0 &&
|
41 |
+
Array.from({ length: skeletonsNeeded }).map((_, index) => (
|
42 |
<Grid item xs={12} sm={6} md={3} key={`skeleton-${index}`}>
|
43 |
<LeaderboardSkeleton />
|
44 |
</Grid>
|
45 |
))}
|
46 |
+
</Grid>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
47 |
);
|
48 |
};
|
49 |
|
client/src/components/LeaderboardSection/hooks/useLanguageStats.js
CHANGED
@@ -1,28 +1,94 @@
|
|
1 |
-
import { useMemo } from "react";
|
2 |
|
3 |
const LANGUAGE_FAMILIES = {
|
4 |
-
"
|
5 |
-
"
|
6 |
-
|
7 |
-
|
8 |
-
|
9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
],
|
11 |
-
|
12 |
-
"
|
13 |
-
"
|
14 |
-
"
|
15 |
-
"
|
16 |
-
|
17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
25 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
26 |
return { family, subFamily: language };
|
27 |
}
|
28 |
}
|
@@ -30,9 +96,10 @@ const findLanguageFamily = (language) => {
|
|
30 |
};
|
31 |
|
32 |
export const useLanguageStats = (leaderboards, filteredLeaderboards) => {
|
33 |
-
const
|
34 |
-
|
35 |
|
|
|
36 |
const langMap = new Map();
|
37 |
const langFamilyMap = new Map();
|
38 |
|
@@ -52,33 +119,38 @@ export const useLanguageStats = (leaderboards, filteredLeaderboards) => {
|
|
52 |
return [lang, count, family];
|
53 |
});
|
54 |
|
55 |
-
|
56 |
.sort((a, b) => {
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
|
|
|
|
|
|
|
|
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 |
-
|
72 |
-
|
|
|
|
|
73 |
board.tags?.some(
|
74 |
(tag) => tag.toLowerCase() === `language:${lang.toLowerCase()}`
|
75 |
)
|
76 |
).length;
|
77 |
-
|
78 |
});
|
|
|
79 |
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
|
|
|
|
84 |
};
|
|
|
1 |
+
import { useMemo, useRef } from "react";
|
2 |
|
3 |
const LANGUAGE_FAMILIES = {
|
4 |
+
"Indo-European": {
|
5 |
+
Germanic: ["english", "german", "dutch", "danish", "swedish", "icelandic"],
|
6 |
+
Romance: [
|
7 |
+
"french",
|
8 |
+
"spanish",
|
9 |
+
"italian",
|
10 |
+
"portuguese",
|
11 |
+
"romanian",
|
12 |
+
"catalan",
|
13 |
+
"galician",
|
14 |
+
],
|
15 |
+
Slavic: [
|
16 |
+
"russian",
|
17 |
+
"polish",
|
18 |
+
"czech",
|
19 |
+
"slovak",
|
20 |
+
"ukrainian",
|
21 |
+
"bulgarian",
|
22 |
+
"slovenian",
|
23 |
+
"serbian",
|
24 |
+
"croatian",
|
25 |
+
],
|
26 |
+
Baltic: ["lithuanian", "latvian"],
|
27 |
+
"Indo-Iranian": [
|
28 |
+
"persian",
|
29 |
+
"hindi",
|
30 |
+
"bengali",
|
31 |
+
"gujarati",
|
32 |
+
"nepali",
|
33 |
+
"marathi",
|
34 |
+
],
|
35 |
+
Greek: ["greek"],
|
36 |
+
Armenian: ["armenian"],
|
37 |
+
},
|
38 |
+
"Sino-Tibetan": ["chinese", "mandarin", "taiwanese"],
|
39 |
+
Afroasiatic: [
|
40 |
+
"arabic",
|
41 |
+
"hebrew",
|
42 |
+
"darija", // Variante de l'arabe
|
43 |
],
|
44 |
+
Austronesian: [
|
45 |
+
"indonesian",
|
46 |
+
"malay",
|
47 |
+
"filipino",
|
48 |
+
"singlish", // Créole basé sur l'anglais mais avec influence malaise
|
49 |
+
],
|
50 |
+
"Niger-Congo": ["swahili", "yoruba"],
|
51 |
+
Dravidian: ["tamil", "telugu", "kannada", "malayalam"],
|
52 |
+
Austroasiatic: ["vietnamese"],
|
53 |
+
"Kra-Dai": ["thai"],
|
54 |
+
Japonic: [
|
55 |
+
"japanese",
|
56 |
+
"日本語", // Japonais en caractères japonais
|
57 |
+
],
|
58 |
+
Koreanic: ["korean"],
|
59 |
+
Uralic: ["hungarian", "finnish", "estonian"],
|
60 |
+
"Language Isolate": [
|
61 |
+
"basque", // Langue isolée, pas de famille connue
|
62 |
],
|
|
|
63 |
"Other Languages": [], // Will catch any language not in other families
|
64 |
};
|
65 |
|
66 |
+
const FAMILY_ORDER = [
|
67 |
+
"Indo-European", // Plus grande famille en Europe et Asie du Sud
|
68 |
+
"Sino-Tibetan", // Deuxième plus grande famille en Asie
|
69 |
+
"Afroasiatic", // Principale famille en Afrique du Nord et Moyen-Orient
|
70 |
+
"Austronesian", // Principale famille en Asie du Sud-Est insulaire
|
71 |
+
"Niger-Congo", // Plus grande famille en Afrique subsaharienne
|
72 |
+
"Dravidian", // Principale famille en Inde du Sud
|
73 |
+
"Austroasiatic", // Importante en Asie du Sud-Est continentale
|
74 |
+
"Kra-Dai", // Famille du thaï
|
75 |
+
"Japonic", // Japonais
|
76 |
+
"Koreanic", // Coréen
|
77 |
+
"Uralic", // Famille finno-ougrienne
|
78 |
+
"Language Isolate", // Langues isolées
|
79 |
+
"Other Languages", // Autres langues non classifiées
|
80 |
+
];
|
81 |
+
|
82 |
const findLanguageFamily = (language) => {
|
83 |
+
for (const [family, content] of Object.entries(LANGUAGE_FAMILIES)) {
|
84 |
+
if (family === "Indo-European") {
|
85 |
+
// Cas spécial pour l'Indo-Européen qui a des sous-familles
|
86 |
+
for (const [subFamily, languages] of Object.entries(content)) {
|
87 |
+
if (languages.includes(language.toLowerCase())) {
|
88 |
+
return { family, subFamily };
|
89 |
+
}
|
90 |
+
}
|
91 |
+
} else if (content.includes(language.toLowerCase())) {
|
92 |
return { family, subFamily: language };
|
93 |
}
|
94 |
}
|
|
|
96 |
};
|
97 |
|
98 |
export const useLanguageStats = (leaderboards, filteredLeaderboards) => {
|
99 |
+
const statsRef = useRef(null);
|
100 |
+
const languagesRef = useRef(null);
|
101 |
|
102 |
+
if (statsRef.current === null && leaderboards) {
|
103 |
const langMap = new Map();
|
104 |
const langFamilyMap = new Map();
|
105 |
|
|
|
119 |
return [lang, count, family];
|
120 |
});
|
121 |
|
122 |
+
languagesRef.current = langArray
|
123 |
.sort((a, b) => {
|
124 |
+
const familyA = a[2];
|
125 |
+
const familyB = b[2];
|
126 |
+
|
127 |
+
const orderA = FAMILY_ORDER.indexOf(familyA);
|
128 |
+
const orderB = FAMILY_ORDER.indexOf(familyB);
|
129 |
+
|
130 |
+
if (orderA !== orderB) {
|
131 |
+
return orderA - orderB;
|
132 |
}
|
133 |
+
|
134 |
return b[1] - a[1];
|
135 |
})
|
136 |
.map(([lang]) => lang);
|
|
|
|
|
|
|
|
|
|
|
137 |
|
138 |
+
// Calculer les stats une seule fois
|
139 |
+
statsRef.current = new Map();
|
140 |
+
languagesRef.current.forEach((lang) => {
|
141 |
+
const count = leaderboards.filter((board) =>
|
142 |
board.tags?.some(
|
143 |
(tag) => tag.toLowerCase() === `language:${lang.toLowerCase()}`
|
144 |
)
|
145 |
).length;
|
146 |
+
statsRef.current.set(lang, count);
|
147 |
});
|
148 |
+
}
|
149 |
|
150 |
+
return {
|
151 |
+
languages: languagesRef.current,
|
152 |
+
languageStats: statsRef.current,
|
153 |
+
LANGUAGE_FAMILIES,
|
154 |
+
findLanguageFamily,
|
155 |
+
};
|
156 |
};
|
client/src/components/LeaderboardSection/index.jsx
CHANGED
@@ -2,7 +2,7 @@ import React from "react";
|
|
2 |
import { Box } from "@mui/material";
|
3 |
import { useLeaderboard } from "../../context/LeaderboardContext";
|
4 |
import SectionHeader from "./components/SectionHeader";
|
5 |
-
import
|
6 |
import LeaderboardGrid from "./components/LeaderboardGrid";
|
7 |
import EmptyState from "./components/EmptyState";
|
8 |
import { useLanguageStats } from "./hooks/useLanguageStats";
|
@@ -21,7 +21,7 @@ const LeaderboardSection = ({
|
|
21 |
selectedLanguage,
|
22 |
setSelectedLanguage,
|
23 |
searchQuery,
|
24 |
-
|
25 |
} = useLeaderboard();
|
26 |
|
27 |
const isExpanded = expandedSections.has(id);
|
@@ -37,16 +37,28 @@ const LeaderboardSection = ({
|
|
37 |
// On ne retourne null que si on n'a pas de leaderboards bruts
|
38 |
if (!leaderboards) return null;
|
39 |
|
40 |
-
//
|
41 |
-
const
|
42 |
-
|
43 |
-
|
|
|
44 |
|
45 |
-
//
|
46 |
-
const
|
|
|
|
|
47 |
|
48 |
-
|
49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
50 |
|
51 |
// Le bouton est actif seulement s'il y a plus de 4 leaderboards
|
52 |
const isExpandButtonEnabled = approvedLeaderboards.length > ITEMS_PER_PAGE;
|
@@ -63,6 +75,13 @@ const LeaderboardSection = ({
|
|
63 |
});
|
64 |
};
|
65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
66 |
return (
|
67 |
<Box sx={{ mb: 6 }}>
|
68 |
<SectionHeader
|
@@ -74,8 +93,8 @@ const LeaderboardSection = ({
|
|
74 |
isExpandButtonEnabled={isExpandButtonEnabled}
|
75 |
/>
|
76 |
|
77 |
-
{
|
78 |
-
<
|
79 |
languages={languages}
|
80 |
languageStats={languageStats}
|
81 |
selectedLanguage={selectedLanguage}
|
@@ -93,6 +112,7 @@ const LeaderboardSection = ({
|
|
93 |
remainingLeaderboards={remainingLeaderboards}
|
94 |
isExpanded={isExpanded}
|
95 |
skeletonsNeeded={skeletonsNeeded}
|
|
|
96 |
/>
|
97 |
)}
|
98 |
</Box>
|
|
|
2 |
import { Box } from "@mui/material";
|
3 |
import { useLeaderboard } from "../../context/LeaderboardContext";
|
4 |
import SectionHeader from "./components/SectionHeader";
|
5 |
+
import LanguageList from "./components/LanguageList";
|
6 |
import LeaderboardGrid from "./components/LeaderboardGrid";
|
7 |
import EmptyState from "./components/EmptyState";
|
8 |
import { useLanguageStats } from "./hooks/useLanguageStats";
|
|
|
21 |
selectedLanguage,
|
22 |
setSelectedLanguage,
|
23 |
searchQuery,
|
24 |
+
selectedCategories,
|
25 |
} = useLeaderboard();
|
26 |
|
27 |
const isExpanded = expandedSections.has(id);
|
|
|
37 |
// On ne retourne null que si on n'a pas de leaderboards bruts
|
38 |
if (!leaderboards) return null;
|
39 |
|
40 |
+
// Déterminer si on doit paginer ou montrer tous les leaderboards
|
41 |
+
const shouldShowAll =
|
42 |
+
(selectedCategories.size === 1 && selectedCategories.has(id)) || // Une seule catégorie sélectionnée ET c'est celle-ci
|
43 |
+
(selectedCategories.size > 1 && id === "combined") || // Plusieurs catégories ET c'est la section combinée
|
44 |
+
isExpanded; // Section dépliée (quelle que soit la sélection)
|
45 |
|
46 |
+
// Si on doit tout montrer, on ne divise pas la liste
|
47 |
+
const displayedLeaderboards = shouldShowAll
|
48 |
+
? approvedLeaderboards
|
49 |
+
: approvedLeaderboards.slice(0, ITEMS_PER_PAGE);
|
50 |
|
51 |
+
const remainingLeaderboards = shouldShowAll
|
52 |
+
? []
|
53 |
+
: approvedLeaderboards.slice(ITEMS_PER_PAGE);
|
54 |
+
|
55 |
+
// Calculate how many skeletons we need (seulement si on ne montre pas tout)
|
56 |
+
const skeletonsNeeded = shouldShowAll
|
57 |
+
? 0
|
58 |
+
: Math.max(0, 4 - approvedLeaderboards.length);
|
59 |
+
|
60 |
+
// On affiche le bouton expand seulement quand on n'a pas de sélection
|
61 |
+
const showExpandButton = selectedCategories.size === 0;
|
62 |
|
63 |
// Le bouton est actif seulement s'il y a plus de 4 leaderboards
|
64 |
const isExpandButtonEnabled = approvedLeaderboards.length > ITEMS_PER_PAGE;
|
|
|
75 |
});
|
76 |
};
|
77 |
|
78 |
+
// Déterminer si on doit afficher la liste des langues
|
79 |
+
const showLanguageList =
|
80 |
+
languages &&
|
81 |
+
selectedCategories.size > 0 &&
|
82 |
+
(id === "language" ||
|
83 |
+
(selectedCategories.size > 1 && selectedCategories.has("language")));
|
84 |
+
|
85 |
return (
|
86 |
<Box sx={{ mb: 6 }}>
|
87 |
<SectionHeader
|
|
|
93 |
isExpandButtonEnabled={isExpandButtonEnabled}
|
94 |
/>
|
95 |
|
96 |
+
{showLanguageList && (
|
97 |
+
<LanguageList
|
98 |
languages={languages}
|
99 |
languageStats={languageStats}
|
100 |
selectedLanguage={selectedLanguage}
|
|
|
112 |
remainingLeaderboards={remainingLeaderboards}
|
113 |
isExpanded={isExpanded}
|
114 |
skeletonsNeeded={skeletonsNeeded}
|
115 |
+
id={id}
|
116 |
/>
|
117 |
)}
|
118 |
</Box>
|
client/src/components/Logo/Logo.jsx
CHANGED
@@ -1,34 +1,32 @@
|
|
1 |
import React from "react";
|
2 |
-
import {
|
3 |
import { Box } from "@mui/material";
|
4 |
import logoText from "../../assets/logo-text.svg";
|
5 |
import gradient from "../../assets/gradient.svg";
|
|
|
6 |
import { useLeaderboard } from "../../context/LeaderboardContext";
|
7 |
|
8 |
const Logo = () => {
|
9 |
-
const {
|
10 |
-
|
11 |
-
|
12 |
-
setSelectedCategory,
|
13 |
-
setSelectedLanguage,
|
14 |
-
setExpandedSections,
|
15 |
-
} = useLeaderboard();
|
16 |
|
17 |
-
const
|
18 |
e.preventDefault();
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
setSelectedLanguage(null);
|
23 |
-
setExpandedSections(new Set());
|
24 |
-
window.history.pushState({}, "", window.location.pathname);
|
25 |
};
|
26 |
|
27 |
return (
|
28 |
-
<
|
29 |
-
|
30 |
-
onClick={
|
31 |
-
|
|
|
|
|
|
|
|
|
32 |
>
|
33 |
<Box
|
34 |
component="img"
|
@@ -59,7 +57,7 @@ const Logo = () => {
|
|
59 |
opacity: (theme) => (theme.palette.mode === "dark" ? 0.8 : 1),
|
60 |
}}
|
61 |
/>
|
62 |
-
</
|
63 |
);
|
64 |
};
|
65 |
|
|
|
1 |
import React from "react";
|
2 |
+
import { useNavigate } from "react-router-dom";
|
3 |
import { Box } from "@mui/material";
|
4 |
import logoText from "../../assets/logo-text.svg";
|
5 |
import gradient from "../../assets/gradient.svg";
|
6 |
+
import { useUrlState } from "../../hooks/useUrlState";
|
7 |
import { useLeaderboard } from "../../context/LeaderboardContext";
|
8 |
|
9 |
const Logo = () => {
|
10 |
+
const { resetParams } = useUrlState();
|
11 |
+
const { resetState } = useLeaderboard();
|
12 |
+
const navigate = useNavigate();
|
|
|
|
|
|
|
|
|
13 |
|
14 |
+
const handleClick = (e) => {
|
15 |
e.preventDefault();
|
16 |
+
resetState();
|
17 |
+
resetParams();
|
18 |
+
navigate("/");
|
|
|
|
|
|
|
19 |
};
|
20 |
|
21 |
return (
|
22 |
+
<Box
|
23 |
+
component="a"
|
24 |
+
onClick={handleClick}
|
25 |
+
sx={{
|
26 |
+
textDecoration: "none",
|
27 |
+
position: "relative",
|
28 |
+
cursor: "pointer",
|
29 |
+
}}
|
30 |
>
|
31 |
<Box
|
32 |
component="img"
|
|
|
57 |
opacity: (theme) => (theme.palette.mode === "dark" ? 0.8 : 1),
|
58 |
}}
|
59 |
/>
|
60 |
+
</Box>
|
61 |
);
|
62 |
};
|
63 |
|
client/src/components/common/FilterTag.jsx
ADDED
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import React from "react";
|
2 |
+
import { Button, Box } from "@mui/material";
|
3 |
+
import { alpha } from "@mui/material/styles";
|
4 |
+
|
5 |
+
const TAG_COLOR = {
|
6 |
+
main: "#F44336",
|
7 |
+
light: "#FFEBEE",
|
8 |
+
};
|
9 |
+
|
10 |
+
const FilterTag = ({ label, count, isActive, onClick }) => {
|
11 |
+
return (
|
12 |
+
<Button
|
13 |
+
onClick={onClick}
|
14 |
+
variant={isActive ? "contained" : "outlined"}
|
15 |
+
size="small"
|
16 |
+
sx={{
|
17 |
+
textTransform: "none",
|
18 |
+
cursor: "pointer",
|
19 |
+
mb: 0.75,
|
20 |
+
backgroundColor: (theme) => {
|
21 |
+
if (isActive) {
|
22 |
+
return TAG_COLOR.main;
|
23 |
+
}
|
24 |
+
return theme.palette.mode === "dark"
|
25 |
+
? "background.paper"
|
26 |
+
: TAG_COLOR.light;
|
27 |
+
},
|
28 |
+
color: (theme) => {
|
29 |
+
if (isActive) {
|
30 |
+
return "white";
|
31 |
+
}
|
32 |
+
return TAG_COLOR.main;
|
33 |
+
},
|
34 |
+
borderColor: TAG_COLOR.main,
|
35 |
+
"&:hover": {
|
36 |
+
backgroundColor: (theme) => {
|
37 |
+
if (isActive) {
|
38 |
+
return TAG_COLOR.main;
|
39 |
+
}
|
40 |
+
return TAG_COLOR.light;
|
41 |
+
},
|
42 |
+
opacity: 0.8,
|
43 |
+
},
|
44 |
+
"& .MuiTouchRipple-root": {
|
45 |
+
transition: "none",
|
46 |
+
},
|
47 |
+
transition: "none",
|
48 |
+
}}
|
49 |
+
>
|
50 |
+
{label}
|
51 |
+
{count !== undefined && (
|
52 |
+
<Box
|
53 |
+
component="span"
|
54 |
+
sx={{
|
55 |
+
display: "inline-flex",
|
56 |
+
alignItems: "center",
|
57 |
+
gap: 0.75,
|
58 |
+
color: isActive ? "white" : "inherit",
|
59 |
+
ml: 0.75,
|
60 |
+
}}
|
61 |
+
>
|
62 |
+
<Box
|
63 |
+
component="span"
|
64 |
+
sx={(theme) => ({
|
65 |
+
width: "4px",
|
66 |
+
height: "4px",
|
67 |
+
borderRadius: "100%",
|
68 |
+
backgroundColor: isActive
|
69 |
+
? "rgba(255, 255, 255, 0.5)"
|
70 |
+
: alpha(
|
71 |
+
TAG_COLOR.main,
|
72 |
+
theme.palette.mode === "dark" ? 0.4 : 0.3
|
73 |
+
),
|
74 |
+
})}
|
75 |
+
/>
|
76 |
+
{count}
|
77 |
+
</Box>
|
78 |
+
)}
|
79 |
+
</Button>
|
80 |
+
);
|
81 |
+
};
|
82 |
+
|
83 |
+
export default FilterTag;
|
client/src/context/LeaderboardContext.jsx
CHANGED
@@ -6,33 +6,11 @@ import React, {
|
|
6 |
useMemo,
|
7 |
useEffect,
|
8 |
} from "react";
|
|
|
|
|
9 |
|
10 |
const LeaderboardContext = createContext();
|
11 |
|
12 |
-
// Helper pour mettre à jour l'URL
|
13 |
-
const updateURL = (params) => {
|
14 |
-
const url = new URL(window.location);
|
15 |
-
Object.entries(params).forEach(([key, value]) => {
|
16 |
-
if (value) {
|
17 |
-
url.searchParams.set(key, value);
|
18 |
-
} else {
|
19 |
-
url.searchParams.delete(key);
|
20 |
-
}
|
21 |
-
});
|
22 |
-
window.history.pushState({}, "", url);
|
23 |
-
};
|
24 |
-
|
25 |
-
// Helper pour lire les paramètres de l'URL
|
26 |
-
const getURLParams = () => {
|
27 |
-
const params = new URLSearchParams(window.location.search);
|
28 |
-
return {
|
29 |
-
category: params.get("category"),
|
30 |
-
search: params.get("search"),
|
31 |
-
arena: params.get("arena") === "true",
|
32 |
-
language: params.get("language"),
|
33 |
-
};
|
34 |
-
};
|
35 |
-
|
36 |
// Constantes pour les tags de catégorisation
|
37 |
const CATEGORIZATION_TAGS = [
|
38 |
"modality:agent",
|
@@ -76,62 +54,70 @@ const isUncategorized = (board) => {
|
|
76 |
};
|
77 |
|
78 |
export const LeaderboardProvider = ({ children }) => {
|
79 |
-
|
80 |
-
const initialParams = getURLParams();
|
81 |
|
82 |
const [leaderboards, setLeaderboards] = useState([]);
|
83 |
-
const [searchQuery, setSearchQuery] = useState(
|
84 |
-
const [arenaOnly, setArenaOnly] = useState(
|
85 |
-
const [
|
86 |
-
|
87 |
);
|
88 |
const [selectedLanguage, setSelectedLanguage] = useState(
|
89 |
-
|
90 |
);
|
91 |
const [expandedSections, setExpandedSections] = useState(
|
92 |
-
new Set(
|
|
|
|
|
|
|
93 |
);
|
94 |
|
95 |
// Mettre à jour l'URL quand les filtres changent
|
96 |
useEffect(() => {
|
97 |
-
|
98 |
-
|
99 |
search: searchQuery,
|
100 |
arena: arenaOnly ? "true" : null,
|
101 |
language: selectedLanguage,
|
|
|
102 |
});
|
103 |
-
}, [
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
setSelectedCategory(params.category);
|
112 |
-
setSelectedLanguage(params.language);
|
113 |
-
setExpandedSections(new Set(params.category ? [params.category] : []));
|
114 |
-
};
|
115 |
-
|
116 |
-
window.addEventListener("popstate", handleURLChange);
|
117 |
-
return () => window.removeEventListener("popstate", handleURLChange);
|
118 |
-
}, []);
|
119 |
|
120 |
-
// Wrapper pour
|
121 |
const handleCategorySelection = useCallback((categoryId) => {
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
130 |
}
|
131 |
-
|
132 |
-
setExpandedSections(new Set([categoryId]));
|
133 |
-
return categoryId;
|
134 |
});
|
|
|
|
|
|
|
|
|
|
|
135 |
}, []);
|
136 |
|
137 |
// Wrapper pour la sélection de langue
|
@@ -182,7 +168,7 @@ export const LeaderboardProvider = ({ children }) => {
|
|
182 |
[searchQuery, arenaOnly]
|
183 |
);
|
184 |
|
185 |
-
// Filter leaderboards based on all criteria including
|
186 |
const filterLeaderboards = useCallback(
|
187 |
(boards) => {
|
188 |
if (!boards) return [];
|
@@ -199,68 +185,71 @@ export const LeaderboardProvider = ({ children }) => {
|
|
199 |
);
|
200 |
}
|
201 |
|
202 |
-
// Filter by selected
|
203 |
-
if (
|
204 |
filtered = filtered.filter((board) => {
|
205 |
const { tags = [] } = board;
|
206 |
-
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
218 |
-
|
219 |
-
|
220 |
-
|
221 |
-
|
222 |
-
|
223 |
-
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
|
235 |
-
|
236 |
-
|
237 |
-
|
238 |
-
|
239 |
-
|
240 |
-
|
241 |
-
|
242 |
-
|
243 |
-
|
244 |
-
|
245 |
-
|
246 |
-
|
247 |
-
|
248 |
-
|
249 |
-
|
250 |
-
|
251 |
-
|
252 |
-
|
253 |
-
|
254 |
-
|
255 |
-
|
256 |
-
|
257 |
-
|
|
|
|
|
|
|
258 |
});
|
259 |
}
|
260 |
|
261 |
return filtered;
|
262 |
},
|
263 |
-
[
|
264 |
);
|
265 |
|
266 |
// Fonction pour obtenir les leaderboards bruts d'une section
|
@@ -584,8 +573,8 @@ export const LeaderboardProvider = ({ children }) => {
|
|
584 |
|
585 |
// Get filtered count
|
586 |
const filteredCount = useMemo(() => {
|
587 |
-
return
|
588 |
-
}, [
|
589 |
|
590 |
// Function to get highlighted parts of text
|
591 |
const getHighlightedText = useCallback((text, searchTerm) => {
|
@@ -619,9 +608,27 @@ export const LeaderboardProvider = ({ children }) => {
|
|
619 |
};
|
620 |
}, []);
|
621 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
622 |
const value = {
|
623 |
leaderboards,
|
624 |
-
setLeaderboards,
|
625 |
searchQuery,
|
626 |
setSearchQuery,
|
627 |
arenaOnly,
|
@@ -633,14 +640,17 @@ export const LeaderboardProvider = ({ children }) => {
|
|
633 |
sections,
|
634 |
allSections,
|
635 |
getHighlightedText,
|
636 |
-
|
637 |
-
|
638 |
selectedLanguage,
|
639 |
setSelectedLanguage: handleLanguageSelection,
|
640 |
expandedSections,
|
641 |
setExpandedSections,
|
642 |
getLanguageStats,
|
643 |
getSectionLeaderboards,
|
|
|
|
|
|
|
644 |
};
|
645 |
|
646 |
return (
|
|
|
6 |
useMemo,
|
7 |
useEffect,
|
8 |
} from "react";
|
9 |
+
import { normalizeTags } from "../utils/tagFilters";
|
10 |
+
import { useUrlState } from "../hooks/useUrlState";
|
11 |
|
12 |
const LeaderboardContext = createContext();
|
13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
14 |
// Constantes pour les tags de catégorisation
|
15 |
const CATEGORIZATION_TAGS = [
|
16 |
"modality:agent",
|
|
|
54 |
};
|
55 |
|
56 |
export const LeaderboardProvider = ({ children }) => {
|
57 |
+
const { params, updateParams } = useUrlState();
|
|
|
58 |
|
59 |
const [leaderboards, setLeaderboards] = useState([]);
|
60 |
+
const [searchQuery, setSearchQuery] = useState(params.search || "");
|
61 |
+
const [arenaOnly, setArenaOnly] = useState(params.arena === "true");
|
62 |
+
const [selectedCategories, setSelectedCategories] = useState(
|
63 |
+
new Set(params.categories || [])
|
64 |
);
|
65 |
const [selectedLanguage, setSelectedLanguage] = useState(
|
66 |
+
params.language || null
|
67 |
);
|
68 |
const [expandedSections, setExpandedSections] = useState(
|
69 |
+
new Set(params.categories || [])
|
70 |
+
);
|
71 |
+
const [isLanguageExpanded, setIsLanguageExpanded] = useState(
|
72 |
+
params.languageExpanded === "true"
|
73 |
);
|
74 |
|
75 |
// Mettre à jour l'URL quand les filtres changent
|
76 |
useEffect(() => {
|
77 |
+
updateParams({
|
78 |
+
categories: Array.from(selectedCategories),
|
79 |
search: searchQuery,
|
80 |
arena: arenaOnly ? "true" : null,
|
81 |
language: selectedLanguage,
|
82 |
+
languageExpanded: isLanguageExpanded ? "true" : null,
|
83 |
});
|
84 |
+
}, [
|
85 |
+
selectedCategories,
|
86 |
+
searchQuery,
|
87 |
+
arenaOnly,
|
88 |
+
selectedLanguage,
|
89 |
+
isLanguageExpanded,
|
90 |
+
updateParams,
|
91 |
+
]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
92 |
|
93 |
+
// Wrapper pour setSelectedCategories qui gère aussi l'expansion des sections
|
94 |
const handleCategorySelection = useCallback((categoryId) => {
|
95 |
+
setSelectedCategories((prev) => {
|
96 |
+
const newCategories = new Set(prev);
|
97 |
+
if (newCategories.has(categoryId)) {
|
98 |
+
newCategories.delete(categoryId);
|
99 |
+
// Si on désélectionne, on replie la section
|
100 |
+
setExpandedSections((prev) => {
|
101 |
+
const newExpanded = new Set(prev);
|
102 |
+
newExpanded.delete(categoryId);
|
103 |
+
return newExpanded;
|
104 |
+
});
|
105 |
+
} else {
|
106 |
+
newCategories.add(categoryId);
|
107 |
+
// Si on sélectionne, on déploie la section
|
108 |
+
setExpandedSections((prev) => {
|
109 |
+
const newExpanded = new Set(prev);
|
110 |
+
newExpanded.add(categoryId);
|
111 |
+
return newExpanded;
|
112 |
+
});
|
113 |
}
|
114 |
+
return newCategories;
|
|
|
|
|
115 |
});
|
116 |
+
|
117 |
+
// On réinitialise la langue sélectionnée seulement si on désélectionne "language"
|
118 |
+
if (categoryId === "language") {
|
119 |
+
setSelectedLanguage(null);
|
120 |
+
}
|
121 |
}, []);
|
122 |
|
123 |
// Wrapper pour la sélection de langue
|
|
|
168 |
[searchQuery, arenaOnly]
|
169 |
);
|
170 |
|
171 |
+
// Filter leaderboards based on all criteria including categories and language selection
|
172 |
const filterLeaderboards = useCallback(
|
173 |
(boards) => {
|
174 |
if (!boards) return [];
|
|
|
185 |
);
|
186 |
}
|
187 |
|
188 |
+
// Filter by selected categories if any
|
189 |
+
if (selectedCategories.size > 0) {
|
190 |
filtered = filtered.filter((board) => {
|
191 |
const { tags = [] } = board;
|
192 |
+
// Un leaderboard est inclus s'il correspond à TOUTES les catégories sélectionnées
|
193 |
+
return Array.from(selectedCategories).every((category) => {
|
194 |
+
switch (category) {
|
195 |
+
case "agentic":
|
196 |
+
return tags.includes("modality:agent");
|
197 |
+
case "text":
|
198 |
+
return tags.includes("modality:text");
|
199 |
+
case "image":
|
200 |
+
return tags.includes("modality:image");
|
201 |
+
case "video":
|
202 |
+
return tags.includes("modality:video");
|
203 |
+
case "code":
|
204 |
+
return tags.includes("eval:code");
|
205 |
+
case "math":
|
206 |
+
return tags.includes("eval:math");
|
207 |
+
case "reasoning":
|
208 |
+
return tags.includes("eval:reasoning");
|
209 |
+
case "hallucination":
|
210 |
+
return tags.includes("eval:hallucination");
|
211 |
+
case "rag":
|
212 |
+
return tags.includes("eval:rag");
|
213 |
+
case "language":
|
214 |
+
return tags.some((tag) => tag.startsWith("language:"));
|
215 |
+
case "vision":
|
216 |
+
return tags.some(
|
217 |
+
(tag) => tag === "modality:video" || tag === "modality:image"
|
218 |
+
);
|
219 |
+
case "threeD":
|
220 |
+
return tags.includes("modality:3d");
|
221 |
+
case "audio":
|
222 |
+
return tags.includes("modality:audio");
|
223 |
+
case "financial":
|
224 |
+
return tags.includes("domain:financial");
|
225 |
+
case "medical":
|
226 |
+
return tags.includes("domain:medical");
|
227 |
+
case "legal":
|
228 |
+
return tags.includes("domain:legal");
|
229 |
+
case "biology":
|
230 |
+
return tags.includes("domain:biology");
|
231 |
+
case "commercial":
|
232 |
+
return tags.includes("domain:commercial");
|
233 |
+
case "translation":
|
234 |
+
return tags.includes("domain:translation");
|
235 |
+
case "chemistry":
|
236 |
+
return tags.includes("domain:chemistry");
|
237 |
+
case "safety":
|
238 |
+
return tags.includes("eval:safety");
|
239 |
+
case "performance":
|
240 |
+
return tags.includes("eval:performance");
|
241 |
+
case "uncategorized":
|
242 |
+
return isUncategorized(board);
|
243 |
+
default:
|
244 |
+
return false;
|
245 |
+
}
|
246 |
+
});
|
247 |
});
|
248 |
}
|
249 |
|
250 |
return filtered;
|
251 |
},
|
252 |
+
[searchQuery, arenaOnly, selectedCategories, selectedLanguage]
|
253 |
);
|
254 |
|
255 |
// Fonction pour obtenir les leaderboards bruts d'une section
|
|
|
573 |
|
574 |
// Get filtered count
|
575 |
const filteredCount = useMemo(() => {
|
576 |
+
return filterLeaderboards(leaderboards).length;
|
577 |
+
}, [filterLeaderboards, leaderboards]);
|
578 |
|
579 |
// Function to get highlighted parts of text
|
580 |
const getHighlightedText = useCallback((text, searchTerm) => {
|
|
|
608 |
};
|
609 |
}, []);
|
610 |
|
611 |
+
// Wrapper pour setLeaderboards qui normalise les tags
|
612 |
+
const setNormalizedLeaderboards = useCallback((boards) => {
|
613 |
+
const normalizedBoards = boards.map((board) => ({
|
614 |
+
...board,
|
615 |
+
tags: normalizeTags(board.tags),
|
616 |
+
}));
|
617 |
+
setLeaderboards(normalizedBoards);
|
618 |
+
}, []);
|
619 |
+
|
620 |
+
const resetState = useCallback(() => {
|
621 |
+
setSearchQuery("");
|
622 |
+
setArenaOnly(false);
|
623 |
+
setSelectedCategories(new Set());
|
624 |
+
setSelectedLanguage(null);
|
625 |
+
setExpandedSections(new Set());
|
626 |
+
setIsLanguageExpanded(false);
|
627 |
+
}, []);
|
628 |
+
|
629 |
const value = {
|
630 |
leaderboards,
|
631 |
+
setLeaderboards: setNormalizedLeaderboards,
|
632 |
searchQuery,
|
633 |
setSearchQuery,
|
634 |
arenaOnly,
|
|
|
640 |
sections,
|
641 |
allSections,
|
642 |
getHighlightedText,
|
643 |
+
selectedCategories,
|
644 |
+
setSelectedCategories: handleCategorySelection,
|
645 |
selectedLanguage,
|
646 |
setSelectedLanguage: handleLanguageSelection,
|
647 |
expandedSections,
|
648 |
setExpandedSections,
|
649 |
getLanguageStats,
|
650 |
getSectionLeaderboards,
|
651 |
+
resetState,
|
652 |
+
isLanguageExpanded,
|
653 |
+
setIsLanguageExpanded,
|
654 |
};
|
655 |
|
656 |
return (
|
client/src/hooks/useUrlState.js
ADDED
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { useSearchParams, useLocation } from "react-router-dom";
|
2 |
+
import { useCallback, useEffect } from "react";
|
3 |
+
|
4 |
+
/**
|
5 |
+
* Hook personnalisé pour gérer l'état de l'URL
|
6 |
+
* @returns {Object} Méthodes et données pour gérer l'état de l'URL
|
7 |
+
*/
|
8 |
+
export const useUrlState = () => {
|
9 |
+
const [searchParams, setSearchParams] = useSearchParams();
|
10 |
+
const location = useLocation();
|
11 |
+
|
12 |
+
// Convertit les searchParams en objet
|
13 |
+
const getParams = useCallback(() => {
|
14 |
+
const params = {};
|
15 |
+
for (const [key, value] of searchParams.entries()) {
|
16 |
+
if (key === "categories") {
|
17 |
+
params[key] = value.split(",").filter(Boolean);
|
18 |
+
} else if (key === "arena") {
|
19 |
+
params[key] = value === "true";
|
20 |
+
} else {
|
21 |
+
params[key] = value;
|
22 |
+
}
|
23 |
+
}
|
24 |
+
return params;
|
25 |
+
}, [searchParams]);
|
26 |
+
|
27 |
+
// Met à jour les paramètres d'URL
|
28 |
+
const updateParams = useCallback(
|
29 |
+
(newParams) => {
|
30 |
+
const updatedParams = new URLSearchParams(searchParams);
|
31 |
+
|
32 |
+
Object.entries(newParams).forEach(([key, value]) => {
|
33 |
+
if (value && (Array.isArray(value) ? value.length > 0 : true)) {
|
34 |
+
if (Array.isArray(value)) {
|
35 |
+
updatedParams.set(key, value.join(","));
|
36 |
+
} else {
|
37 |
+
updatedParams.set(key, value.toString());
|
38 |
+
}
|
39 |
+
} else {
|
40 |
+
updatedParams.delete(key);
|
41 |
+
}
|
42 |
+
});
|
43 |
+
|
44 |
+
setSearchParams(updatedParams);
|
45 |
+
},
|
46 |
+
[searchParams, setSearchParams]
|
47 |
+
);
|
48 |
+
|
49 |
+
// Réinitialise tous les paramètres d'URL
|
50 |
+
const resetParams = useCallback(() => {
|
51 |
+
setSearchParams(new URLSearchParams());
|
52 |
+
}, [setSearchParams]);
|
53 |
+
|
54 |
+
// Gestion de l'intégration avec Hugging Face
|
55 |
+
useEffect(() => {
|
56 |
+
const isHFSpace = window.location !== window.parent.location;
|
57 |
+
if (!isHFSpace) return;
|
58 |
+
|
59 |
+
window.parent.postMessage(
|
60 |
+
{
|
61 |
+
queryString: location.search,
|
62 |
+
hash: location.hash,
|
63 |
+
},
|
64 |
+
"https://huggingface.co"
|
65 |
+
);
|
66 |
+
}, [location]);
|
67 |
+
|
68 |
+
return {
|
69 |
+
params: getParams(),
|
70 |
+
updateParams,
|
71 |
+
resetParams,
|
72 |
+
searchParams,
|
73 |
+
location,
|
74 |
+
};
|
75 |
+
};
|
client/src/pages/LeaderboardPage/LeaderboardPage.jsx
CHANGED
@@ -20,7 +20,7 @@ const LeaderboardPageContent = () => {
|
|
20 |
allSections,
|
21 |
searchQuery,
|
22 |
arenaOnly,
|
23 |
-
|
24 |
} = useLeaderboard();
|
25 |
|
26 |
useEffect(() => {
|
@@ -101,7 +101,7 @@ const LeaderboardPageContent = () => {
|
|
101 |
<LeaderboardFilters allSections={allSections} />
|
102 |
|
103 |
{/* Message global "No results" seulement si on n'a pas de catégorie sélectionnée */}
|
104 |
-
{!hasLeaderboards && isFiltering &&
|
105 |
<Box
|
106 |
sx={{
|
107 |
display: "flex",
|
@@ -146,23 +146,65 @@ const LeaderboardPageContent = () => {
|
|
146 |
)}
|
147 |
|
148 |
{/* On affiche toujours la section sélectionnée, sinon on affiche les sections avec des résultats */}
|
149 |
-
{
|
150 |
-
?
|
151 |
-
|
152 |
-
|
153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
154 |
return (
|
155 |
-
<Box key=
|
156 |
<LeaderboardSection
|
157 |
-
id=
|
158 |
-
title={
|
159 |
-
leaderboards={
|
160 |
filteredLeaderboards={filteredLeaderboards}
|
161 |
/>
|
162 |
</Box>
|
163 |
);
|
164 |
-
})
|
165 |
-
:
|
|
|
166 |
sections.map(({ id, title, data }) => {
|
167 |
const filteredLeaderboards = filterLeaderboards(data);
|
168 |
if (filteredLeaderboards.length === 0) return null;
|
|
|
20 |
allSections,
|
21 |
searchQuery,
|
22 |
arenaOnly,
|
23 |
+
selectedCategories,
|
24 |
} = useLeaderboard();
|
25 |
|
26 |
useEffect(() => {
|
|
|
101 |
<LeaderboardFilters allSections={allSections} />
|
102 |
|
103 |
{/* Message global "No results" seulement si on n'a pas de catégorie sélectionnée */}
|
104 |
+
{!hasLeaderboards && isFiltering && selectedCategories.size === 0 && (
|
105 |
<Box
|
106 |
sx={{
|
107 |
display: "flex",
|
|
|
146 |
)}
|
147 |
|
148 |
{/* On affiche toujours la section sélectionnée, sinon on affiche les sections avec des résultats */}
|
149 |
+
{selectedCategories.size > 0
|
150 |
+
? // Si des catégories sont sélectionnées
|
151 |
+
selectedCategories.size === 1
|
152 |
+
? // Si une seule catégorie est sélectionnée, on affiche sa section
|
153 |
+
sections
|
154 |
+
.filter(({ id }) => selectedCategories.has(id))
|
155 |
+
.map(({ id, title, data }) => {
|
156 |
+
const filteredLeaderboards = filterLeaderboards(data);
|
157 |
+
return (
|
158 |
+
<Box key={id} id={id}>
|
159 |
+
<LeaderboardSection
|
160 |
+
id={id}
|
161 |
+
title={title}
|
162 |
+
leaderboards={data}
|
163 |
+
filteredLeaderboards={filteredLeaderboards}
|
164 |
+
/>
|
165 |
+
</Box>
|
166 |
+
);
|
167 |
+
})
|
168 |
+
: // Si plusieurs catégories sont sélectionnées, on les agrège
|
169 |
+
(() => {
|
170 |
+
// Agréger les données de toutes les sections sélectionnées
|
171 |
+
const selectedSections = sections.filter(({ id }) =>
|
172 |
+
selectedCategories.has(id)
|
173 |
+
);
|
174 |
+
|
175 |
+
// Créer un titre combiné
|
176 |
+
const combinedTitle = selectedSections
|
177 |
+
.map(({ title }) => title)
|
178 |
+
.join(" + ");
|
179 |
+
|
180 |
+
// Agréger les leaderboards
|
181 |
+
const combinedData = selectedSections.reduce(
|
182 |
+
(acc, { data }) => [...acc, ...data],
|
183 |
+
[]
|
184 |
+
);
|
185 |
+
|
186 |
+
// Filtrer les doublons par ID
|
187 |
+
const uniqueData = Array.from(
|
188 |
+
new Map(
|
189 |
+
combinedData.map((item) => [item.id, item])
|
190 |
+
).values()
|
191 |
+
);
|
192 |
+
|
193 |
+
const filteredLeaderboards = filterLeaderboards(uniqueData);
|
194 |
+
|
195 |
return (
|
196 |
+
<Box key="combined">
|
197 |
<LeaderboardSection
|
198 |
+
id="combined"
|
199 |
+
title={combinedTitle}
|
200 |
+
leaderboards={uniqueData}
|
201 |
filteredLeaderboards={filteredLeaderboards}
|
202 |
/>
|
203 |
</Box>
|
204 |
);
|
205 |
+
})()
|
206 |
+
: // Si aucune catégorie n'est sélectionnée, on affiche toutes les sections avec des résultats
|
207 |
+
(hasLeaderboards || !isFiltering) &&
|
208 |
sections.map(({ id, title, data }) => {
|
209 |
const filteredLeaderboards = filterLeaderboards(data);
|
210 |
if (filteredLeaderboards.length === 0) return null;
|
client/src/utils/tagFilters.js
ADDED
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Map of tags that should be normalized
|
3 |
+
* Key: original tag
|
4 |
+
* Value: normalized tag
|
5 |
+
*/
|
6 |
+
const TAG_NORMALIZATIONS = {
|
7 |
+
"language:日本語": "language:japanese",
|
8 |
+
};
|
9 |
+
|
10 |
+
/**
|
11 |
+
* Normalize tags according to predefined rules
|
12 |
+
* @param {string[]} tags - Array of tags to normalize
|
13 |
+
* @returns {string[]} - Array of normalized tags without duplicates
|
14 |
+
*/
|
15 |
+
export const normalizeTags = (tags) => {
|
16 |
+
if (!tags || !Array.isArray(tags)) return [];
|
17 |
+
|
18 |
+
// First, normalize all tags
|
19 |
+
const normalizedTags = tags.map((tag) => TAG_NORMALIZATIONS[tag] || tag);
|
20 |
+
|
21 |
+
// Then remove duplicates using Set
|
22 |
+
return [...new Set(normalizedTags)];
|
23 |
+
};
|