import React, { useState, useEffect } from "react"; import { Box, Typography, Paper, Button, Alert, List, ListItem, CircularProgress, Chip, Divider, IconButton, Stack, Link, useTheme, useMediaQuery, } from "@mui/material"; import AccessTimeIcon from "@mui/icons-material/AccessTime"; import PersonIcon from "@mui/icons-material/Person"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import HowToVoteIcon from "@mui/icons-material/HowToVote"; import { useAuth } from "../../hooks/useAuth"; import PageHeader from "../../components/shared/PageHeader"; import AuthContainer from "../../components/shared/AuthContainer"; import { alpha } from "@mui/material/styles"; import CheckIcon from "@mui/icons-material/Check"; const NoModelsToVote = () => ( <Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", py: 8, textAlign: "center", }} > <HowToVoteIcon sx={{ fontSize: 100, color: "grey.300", mb: 3, }} /> <Typography variant="h4" component="h2" sx={{ fontWeight: "bold", color: "grey.700", mb: 2, }} > No Models to Vote </Typography> <Typography variant="body1" sx={{ color: "grey.600", maxWidth: 450, mx: "auto", }} > There are currently no models waiting for votes. <br /> Check back later! </Typography> </Box> ); const LOCAL_STORAGE_KEY = "pending_votes"; function VoteModelPage() { const { isAuthenticated, user, loading: authLoading } = useAuth(); const [pendingModels, setPendingModels] = useState([]); const [loadingModels, setLoadingModels] = useState(true); const [error, setError] = useState(null); const [userVotes, setUserVotes] = useState(new Set()); const [loadingVotes, setLoadingVotes] = useState({}); const [localVotes, setLocalVotes] = useState(new Set()); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); // Create a unique identifier for a model const getModelUniqueId = (model) => { return `${model.name}_${model.precision}_${model.revision}`; }; const formatWaitTime = (submissionTime) => { if (!submissionTime) return "N/A"; const now = new Date(); const submitted = new Date(submissionTime); const diffInHours = Math.floor((now - submitted) / (1000 * 60 * 60)); // Less than 24 hours: show in hours if (diffInHours < 24) { return `${diffInHours}h`; } // Less than 7 days: show in days const diffInDays = Math.floor(diffInHours / 24); if (diffInDays < 7) { return `${diffInDays}d`; } // More than 7 days: show in weeks const diffInWeeks = Math.floor(diffInDays / 7); return `${diffInWeeks}w`; }; const getConfigVotes = (votesData, model) => { // Créer l'identifiant unique du modèle const modelUniqueId = getModelUniqueId(model); // Compter les votes du serveur let serverVotes = 0; for (const [key, config] of Object.entries(votesData.votes_by_config)) { if ( config.precision === model.precision && config.revision === model.revision ) { serverVotes = config.count; break; } } // Ajouter les votes en attente du localStorage const pendingVote = localVotes.has(modelUniqueId) ? 1 : 0; return serverVotes + pendingVote; }; const sortModels = (models) => { // Trier d'abord par nombre de votes décroissant, puis par soumission de l'utilisateur return [...models].sort((a, b) => { // Comparer d'abord le nombre de votes if (b.votes !== a.votes) { return b.votes - a.votes; } // Si l'utilisateur est connecté, mettre ses modèles en priorité if (user) { const aIsUserModel = a.submitter === user.username; const bIsUserModel = b.submitter === user.username; if (aIsUserModel && !bIsUserModel) return -1; if (!aIsUserModel && bIsUserModel) return 1; } // Si égalité, trier par date de soumission (le plus récent d'abord) return new Date(b.submission_time) - new Date(a.submission_time); }); }; // Add this function to handle localStorage const updateLocalVotes = (modelUniqueId, action = "add") => { const storedVotes = JSON.parse( localStorage.getItem(LOCAL_STORAGE_KEY) || "[]" ); if (action === "add") { if (!storedVotes.includes(modelUniqueId)) { storedVotes.push(modelUniqueId); } } else { const index = storedVotes.indexOf(modelUniqueId); if (index > -1) { storedVotes.splice(index, 1); } } localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(storedVotes)); setLocalVotes(new Set(storedVotes)); }; useEffect(() => { const fetchData = async () => { try { // Ne pas afficher le loading si on a déjà des données if (pendingModels.length === 0) { setLoadingModels(true); } setError(null); // Charger d'abord les votes en attente du localStorage const storedVotes = JSON.parse( localStorage.getItem(LOCAL_STORAGE_KEY) || "[]" ); const localVotesSet = new Set(storedVotes); // Préparer toutes les requêtes en parallèle const [pendingModelsResponse, userVotesResponse] = await Promise.all([ fetch("/api/models/pending"), isAuthenticated && user ? fetch(`/api/votes/user/${user.username}`) : Promise.resolve(null), ]); if (!pendingModelsResponse.ok) { throw new Error("Failed to fetch pending models"); } const modelsData = await pendingModelsResponse.json(); const votedModels = new Set(); // Traiter les votes de l'utilisateur si connecté if (userVotesResponse && userVotesResponse.ok) { const votesData = await userVotesResponse.json(); const userVotes = Array.isArray(votesData) ? votesData : []; userVotes.forEach((vote) => { const uniqueId = `${vote.model}_${vote.precision || "unknown"}_${ vote.revision || "main" }`; votedModels.add(uniqueId); if (localVotesSet.has(uniqueId)) { localVotesSet.delete(uniqueId); updateLocalVotes(uniqueId, "remove"); } }); } // Préparer et exécuter toutes les requêtes de votes en une seule fois const modelVotesResponses = await Promise.all( modelsData.map((model) => { const [provider, modelName] = model.name.split("/"); return fetch(`/api/votes/model/${provider}/${modelName}`) .then((response) => response.ok ? response.json() : { total_votes: 0, votes_by_config: {} } ) .catch(() => ({ total_votes: 0, votes_by_config: {} })); }) ); // Construire les modèles avec toutes les données const modelsWithVotes = modelsData.map((model, index) => { const votesData = modelVotesResponses[index]; const modelUniqueId = getModelUniqueId(model); const isVotedByUser = votedModels.has(modelUniqueId) || localVotesSet.has(modelUniqueId); return { ...model, votes: getConfigVotes( { ...votesData, votes_by_config: votesData.votes_by_config || {}, }, model ), votes_by_config: votesData.votes_by_config || {}, wait_time: formatWaitTime(model.submission_time), hasVoted: isVotedByUser, }; }); // Mettre à jour tous les états en une seule fois const sortedModels = sortModels(modelsWithVotes); // Batch updates const updates = () => { setPendingModels(sortedModels); setUserVotes(votedModels); setLocalVotes(localVotesSet); setLoadingModels(false); }; updates(); } catch (err) { console.error("Error fetching data:", err); setError(err.message); setLoadingModels(false); } }; fetchData(); }, [isAuthenticated, user]); // Modify the handleVote function const handleVote = async (model) => { if (!isAuthenticated) return; const modelUniqueId = getModelUniqueId(model); try { setError(null); setLoadingVotes((prev) => ({ ...prev, [modelUniqueId]: true })); // Add to localStorage immediately updateLocalVotes(modelUniqueId, "add"); // Encode model name for URL const encodedModelName = encodeURIComponent(model.name); const response = await fetch( `/api/votes/${encodedModelName}?vote_type=up&user_id=${user.username}`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ precision: model.precision, revision: model.revision, }), } ); if (!response.ok) { // If the request fails, remove from localStorage updateLocalVotes(modelUniqueId, "remove"); throw new Error("Failed to submit vote"); } // Refresh votes for this model with cache bypass const [provider, modelName] = model.name.split("/"); const timestamp = Date.now(); const votesResponse = await fetch( `/api/votes/model/${provider}/${modelName}?nocache=${timestamp}` ); if (!votesResponse.ok) { throw new Error("Failed to fetch updated votes"); } const votesData = await votesResponse.json(); console.log(`Updated votes for ${model.name}:`, votesData); // Debug log // Update model and resort the list setPendingModels((models) => { const updatedModels = models.map((m) => getModelUniqueId(m) === getModelUniqueId(model) ? { ...m, votes: getConfigVotes(votesData, m), votes_by_config: votesData.votes_by_config || {}, hasVoted: true, } : m ); const sortedModels = sortModels(updatedModels); console.log("Updated and sorted models:", sortedModels); // Debug log return sortedModels; }); // Update user votes with unique ID setUserVotes((prev) => new Set([...prev, getModelUniqueId(model)])); } catch (err) { console.error("Error voting:", err); setError(err.message); } finally { // Clear loading state for this model setLoadingVotes((prev) => ({ ...prev, [modelUniqueId]: false, })); } }; // Modify the rendering logic to consider both server and local votes // Inside the map function where you render models const isVoted = (model) => { const modelUniqueId = getModelUniqueId(model); return userVotes.has(modelUniqueId) || localVotes.has(modelUniqueId); }; if (authLoading || (loadingModels && pendingModels.length === 0)) { return ( <Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "100vh", }} > <CircularProgress /> </Box> ); } return ( <Box sx={{ width: "100%", maxWidth: 1200, margin: "0 auto", py: 4, px: 0, }} > <PageHeader title="Vote for the Next Models" subtitle={ <> Help us <span style={{ fontWeight: 600 }}>prioritize</span> which models to evaluate next </> } /> {error && ( <Alert severity="error" sx={{ mb: 2 }}> {error} </Alert> )} {/* Auth Status */} {/* <Box sx={{ mb: 3 }}> {isAuthenticated ? ( <Paper elevation={0} sx={{ p: 2, border: "1px solid", borderColor: "grey.300" }} > <Stack direction="row" spacing={2} alignItems="center" justifyContent="space-between" > <Stack direction="row" spacing={1} alignItems="center"> <Typography variant="body1"> Connected as <strong>{user?.username}</strong> </Typography> <Chip label="Ready to vote" color="success" size="small" variant="outlined" /> </Stack> <LogoutButton /> </Stack> </Paper> ) : ( <Paper elevation={0} sx={{ p: 3, border: "1px solid", borderColor: "grey.300", display: "flex", flexDirection: "column", alignItems: "center", gap: 2, }} > <Typography variant="h6" align="center"> Login to Vote </Typography> <Typography variant="body2" color="text.secondary" align="center"> You need to be logged in with your Hugging Face account to vote for models </Typography> <AuthBlock /> </Paper> )} </Box> */} <AuthContainer actionText="vote for models" /> {/* Models List */} <Paper elevation={0} sx={{ border: "1px solid", borderColor: "grey.300", borderRadius: 1, overflow: "hidden", minHeight: 400, }} > {/* Header - Always visible */} <Box sx={{ px: 3, py: 2, borderBottom: "1px solid", borderColor: (theme) => theme.palette.mode === "dark" ? alpha(theme.palette.divider, 0.1) : "grey.200", bgcolor: (theme) => theme.palette.mode === "dark" ? alpha(theme.palette.background.paper, 0.5) : "grey.50", }} > <Typography variant="h6" sx={{ fontWeight: 600, color: "text.primary" }} > Models Pending Evaluation </Typography> </Box> {/* Table Header */} <Box sx={{ px: 3, py: 1.5, borderBottom: "1px solid", borderColor: "divider", bgcolor: "background.paper", display: { xs: "none", sm: "grid" }, gridTemplateColumns: "1fr 200px 160px", gap: 3, alignItems: "center", }} > <Box> <Typography variant="subtitle2" color="text.secondary"> Model </Typography> </Box> <Box sx={{ textAlign: "right" }}> <Typography variant="subtitle2" color="text.secondary"> Votes </Typography> </Box> <Box sx={{ textAlign: "right" }}> <Typography variant="subtitle2" color="text.secondary"> Priority </Typography> </Box> </Box> {/* Content */} {loadingModels ? ( <Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "200px", width: "100%", bgcolor: "background.paper", }} > <CircularProgress /> </Box> ) : pendingModels.length === 0 && !loadingModels ? ( <NoModelsToVote /> ) : ( <List sx={{ p: 0, bgcolor: "background.paper" }}> {pendingModels.map((model, index) => { const isTopThree = index < 3; return ( <React.Fragment key={getModelUniqueId(model)}> {index > 0 && <Divider />} <ListItem sx={{ py: 2.5, px: 3, display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 200px 160px" }, gap: { xs: 2, sm: 3 }, alignItems: "start", position: "relative", "&:hover": { bgcolor: "action.hover", }, }} > {/* Left side - Model info */} <Box> <Stack spacing={1}> {/* Model name and link */} <Stack direction={{ xs: "column", sm: "row" }} spacing={1} alignItems={{ xs: "stretch", sm: "center" }} > <Stack direction="row" spacing={1} alignItems="center" sx={{ flexGrow: 1 }} > <Link href={`https://huggingface.co/${model.name}`} target="_blank" rel="noopener noreferrer" sx={{ textDecoration: "none", color: "primary.main", fontWeight: 500, "&:hover": { textDecoration: "underline", }, fontSize: { xs: "0.9rem", sm: "inherit" }, wordBreak: "break-word", }} > {model.name} </Link> <IconButton size="small" href={`https://huggingface.co/${model.name}`} target="_blank" rel="noopener noreferrer" sx={{ ml: 0.5, p: 0.5, color: "action.active", "&:hover": { color: "primary.main", }, }} > <OpenInNewIcon sx={{ fontSize: "1rem" }} /> </IconButton> </Stack> <Stack direction="row" spacing={1} sx={{ width: { xs: "100%", sm: "auto" }, justifyContent: { xs: "flex-start", sm: "flex-end", }, flexWrap: "wrap", gap: 1, }} > <Chip label={model.precision} size="small" variant="outlined" sx={{ borderColor: "grey.300", bgcolor: "grey.50", "& .MuiChip-label": { fontSize: "0.75rem", fontWeight: 600, color: "text.secondary", }, }} /> <Chip label={`rev: ${model.revision.slice(0, 7)}`} size="small" variant="outlined" sx={{ borderColor: "grey.300", bgcolor: "grey.50", "& .MuiChip-label": { fontSize: "0.75rem", fontWeight: 600, color: "text.secondary", }, }} /> </Stack> </Stack> {/* Metadata row */} <Stack direction={{ xs: "column", sm: "row" }} spacing={{ xs: 1, sm: 2 }} alignItems={{ xs: "flex-start", sm: "center" }} > <Stack direction="row" spacing={0.5} alignItems="center" > <AccessTimeIcon sx={{ fontSize: "0.875rem", color: "text.secondary", }} /> <Typography variant="body2" color="text.secondary"> {model.wait_time} </Typography> </Stack> <Stack direction="row" spacing={0.5} alignItems="center" > <PersonIcon sx={{ fontSize: "0.875rem", color: "text.secondary", }} /> <Typography variant="body2" color="text.secondary"> {model.submitter} </Typography> </Stack> </Stack> </Stack> </Box> {/* Vote Column */} <Box sx={{ textAlign: { xs: "left", sm: "right" }, mt: { xs: 2, sm: 0 }, }} > <Stack direction={{ xs: "row", sm: "row" }} spacing={2.5} justifyContent={{ xs: "space-between", sm: "flex-end" }} alignItems="center" > <Stack alignItems={{ xs: "flex-start", sm: "center" }} sx={{ minWidth: { xs: "auto", sm: "90px" }, }} > <Typography variant="h4" component="div" sx={{ fontWeight: 700, lineHeight: 1, fontSize: { xs: "1.75rem", sm: "2rem" }, display: "flex", alignItems: "center", justifyContent: "center", }} > <Typography component="span" sx={{ fontSize: { xs: "1.25rem", sm: "1.5rem" }, fontWeight: 600, color: "primary.main", lineHeight: 1, mr: 0.5, mt: "-2px", }} > + </Typography> <Typography component="span" sx={{ color: model.votes === 0 ? "text.primary" : "primary.main", fontWeight: 700, lineHeight: 1, }} > {model.votes > 999 ? "999" : model.votes} </Typography> </Typography> <Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 500, mt: 0.5, textTransform: "uppercase", letterSpacing: "0.05em", fontSize: "0.75rem", }} > votes </Typography> </Stack> <Button variant={isVoted(model) ? "contained" : "outlined"} size={isMobile ? "medium" : "large"} onClick={() => handleVote(model)} disabled={ !isAuthenticated || isVoted(model) || loadingVotes[getModelUniqueId(model)] } color="primary" sx={{ minWidth: { xs: "80px", sm: "100px" }, height: { xs: "36px", sm: "40px" }, textTransform: "none", fontWeight: 600, fontSize: { xs: "0.875rem", sm: "0.95rem" }, ...(isVoted(model) ? { bgcolor: "primary.main", "&:hover": { bgcolor: "primary.dark", }, "&.Mui-disabled": { bgcolor: "primary.main", color: "white", opacity: 0.7, }, } : { borderWidth: 2, "&:hover": { borderWidth: 2, }, }), }} > {loadingVotes[getModelUniqueId(model)] ? ( <CircularProgress size={20} color="inherit" /> ) : isVoted(model) ? ( <Stack direction="row" spacing={0.5} alignItems="center" > <CheckIcon sx={{ fontSize: "1.2rem" }} /> <span>Voted</span> </Stack> ) : ( "Vote" )} </Button> </Stack> </Box> {/* Priority Column */} <Box sx={{ textAlign: { xs: "left", sm: "right" }, mt: { xs: 2, sm: 0 }, display: { xs: "none", sm: "block" }, }} > <Chip label={ <Stack direction="row" spacing={0.5} alignItems="center" > {isTopThree && ( <Typography variant="body2" sx={{ fontWeight: 600, color: isTopThree ? "primary.main" : "text.primary", letterSpacing: "0.02em", }} > HIGH </Typography> )} <Typography variant="body2" sx={{ fontWeight: 600, color: isTopThree ? "primary.main" : "text.secondary", letterSpacing: "0.02em", }} > #{index + 1} </Typography> </Stack> } size="medium" variant={isTopThree ? "filled" : "outlined"} sx={{ height: 36, minWidth: "100px", bgcolor: isTopThree ? (theme) => alpha(theme.palette.primary.main, 0.1) : "transparent", borderColor: isTopThree ? "primary.main" : "grey.300", borderWidth: 2, "& .MuiChip-label": { px: 2, fontSize: "0.95rem", }, }} /> </Box> </ListItem> </React.Fragment> ); })} </List> )} </Paper> </Box> ); } export default VoteModelPage;