diff --git a/backend/src/auth/jwt/jwt.strategy.ts b/backend/src/auth/jwt/jwt.strategy.ts index 7c74989..d11b349 100644 --- a/backend/src/auth/jwt/jwt.strategy.ts +++ b/backend/src/auth/jwt/jwt.strategy.ts @@ -29,6 +29,7 @@ export interface AuthenticatedRequestUser { * pending token is `isTwoFactorEnabled: true, tfa: 'pending'`. */ isTwoFactorEnabled: boolean; + avatar: string | null; } const cookieExtractor = (req: Request): string | null => { @@ -68,6 +69,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { username: user.username, tfa: payload.tfa, isTwoFactorEnabled: user.isTwoFactorEnabled, + avatar: user.avatar, }; } } diff --git a/frontend/src/app/components/Avatar.tsx b/frontend/src/app/components/Avatar.tsx new file mode 100644 index 0000000..03c8186 --- /dev/null +++ b/frontend/src/app/components/Avatar.tsx @@ -0,0 +1,36 @@ +import { useState } from "react"; + +const isImageUrl = (avatar?: string | null): boolean => +!!avatar && /^(https?:\/\/|\/)/.test(avatar); + +interface AvatarProps { + src?: string | null; + alt?: string; + className?: string; +} + +export default function Avatar({ + src, + alt = "avatar", + className = "w-10 h-10 text-xl", +}: AvatarProps) { + const [failed, setFailed] = useState(false); + const showImage = isImageUrl(src) && !failed; + return ( +
+ {showImage ? ( + {alt} setFailed(true)} + /> + ) : ( + {(!isImageUrl(src) && src) || "😊"} + )} +
+ ); +} diff --git a/frontend/src/app/components/Layout.tsx b/frontend/src/app/components/Layout.tsx index 14ec988..2ecbada 100644 --- a/frontend/src/app/components/Layout.tsx +++ b/frontend/src/app/components/Layout.tsx @@ -3,6 +3,7 @@ import { Link, Outlet, useLocation, useNavigate } from "react-router-dom"; import { useState, useEffect, useRef } from "react"; import { socket } from "../../socket/socket"; import { useGame } from "../context/GameContext"; +import Avatar from "./Avatar"; interface LayoutProps { username: string; @@ -128,7 +129,7 @@ return ( className="flex items-center gap-2 px-4 py-2 rounded-full bg-white/60 hover:bg-white text-gray-700 hover:text-amber-600 border border-gray-200 transition-all shadow-sm hover:shadow-md" > - Classement + Ranking {/* Bouton Profil (avec avatar dynamique) */} @@ -137,13 +138,7 @@ return ( className="group flex items-center gap-3 px-4 py-2 rounded-full bg-gradient-to-r from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 transition-all shadow-md hover:shadow-lg" > {username} -
- {userAvatar ? ( - {username} - ) : ( - 😊 - )} -
+ {/* Bouton Déconnexion */} @@ -201,7 +196,7 @@ return (
- Chat Global + Global Chat {onlineCount} online @@ -225,22 +220,18 @@ return ( {/* Messages (Zone de texte) */}
{Array.isArray(messages) && messages.length === 0 ? ( -

Aucun message pour l'instant...

+

No message for now...

) : ( Array.isArray(messages) && messages.map((msg, index) => { const authorName = msg.author?.username || msg.user || "Inconnu"; - return (
{/* Avatar */} -
- {msg.avatar || "😊"} -
- -
+ +
{/* Pseudo Cliquable */} setNewMessage(e.target.value)} diff --git a/frontend/src/app/components/LoginPage.tsx b/frontend/src/app/components/LoginPage.tsx index c07b857..e07850e 100644 --- a/frontend/src/app/components/LoginPage.tsx +++ b/frontend/src/app/components/LoginPage.tsx @@ -44,7 +44,7 @@ export default function LoginPage({ onLogin, force2FA = false }: LoginPageProps) if (response.ok) { if (isSignUp) { setIsSignUp(false); - setErrorMsg("Compte créé avec succès ! Connectez-vous."); + setErrorMsg("Account created! Please log in."); return; } @@ -55,11 +55,11 @@ export default function LoginPage({ onLogin, force2FA = false }: LoginPageProps) if (onLogin) onLogin(); } else { - setErrorMsg(data.message || "Erreur lors de la connexion"); + setErrorMsg(data.message || "Error while connecting"); } } catch (error) { console.error("Network Error:", error); - setErrorMsg("Impossible de contacter le serveur NestJS."); + setErrorMsg("Impossible to reach NestJS server."); } }; @@ -83,8 +83,8 @@ export default function LoginPage({ onLogin, force2FA = false }: LoginPageProps)

{is2FARequired - ? "Vérification en deux étapes" - : (isSignUp ? "Créez votre compte pour commencer" : "Bienvenue ! Connectez-vous pour continuer")} + ? "Two steps verification" + : (isSignUp ? "Create your account to start" : "Welcome ! Log in to continue")}

@@ -93,7 +93,7 @@ export default function LoginPage({ onLogin, force2FA = false }: LoginPageProps) {/* Banner d'erreur dynamique */} {errorMsg && ( -
+
{errorMsg}
@@ -103,7 +103,7 @@ export default function LoginPage({ onLogin, force2FA = false }: LoginPageProps) {is2FARequired ? (
- +
{isSignUp && (
- +
setUsername(e.target.value)} - placeholder="Votre pseudo" + placeholder="Your username" className="w-full pl-12 pr-4 py-3 rounded-xl bg-gray-50 border-2 border-gray-100 focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-200 transition-all" required /> @@ -144,7 +144,7 @@ export default function LoginPage({ onLogin, force2FA = false }: LoginPageProps) type="email" value={email} onChange={(e) => setEmail(e.target.value)} - placeholder="votre@email.com" + placeholder="your@email.com" className="w-full pl-12 pr-4 py-3 rounded-xl bg-gray-50 border-2 border-gray-100 focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-200 transition-all" required /> @@ -152,7 +152,7 @@ export default function LoginPage({ onLogin, force2FA = false }: LoginPageProps)
- +
)} @@ -180,7 +180,7 @@ export default function LoginPage({ onLogin, force2FA = false }: LoginPageProps) type="submit" className="w-full py-4 rounded-xl bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white font-bold shadow-lg hover:shadow-xl hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 transition-all transform hover:-translate-y-0.5 mt-2" > - {is2FARequired ? "Vérifier le code" : (isSignUp ? "Créer mon compte" : "Se connecter")} + {is2FARequired ? "Verify the code" : (isSignUp ? "Create account" : "Log in")} @@ -191,7 +191,7 @@ export default function LoginPage({ onLogin, force2FA = false }: LoginPageProps)
- ou continuer avec + or continue with
@@ -201,14 +201,14 @@ export default function LoginPage({ onLogin, force2FA = false }: LoginPageProps)
42
- Se connecter avec l'intra 42 + Log in with 42 intra - Se connecter avec GitHub + Log in with GitHub
@@ -221,9 +221,9 @@ export default function LoginPage({ onLogin, force2FA = false }: LoginPageProps) className="text-gray-500 hover:text-gray-700 font-medium text-sm transition-colors" > {isSignUp ? ( - <>Vous avez déjà un compte ? Se connecter + <>Already have an account ? Log in ) : ( - <>Pas encore de compte ? S'inscrire + <>No account yet ? Sign in )}
diff --git a/frontend/src/app/components/ProfilePage.tsx b/frontend/src/app/components/ProfilePage.tsx index 3e2a2b0..a07ae5b 100644 --- a/frontend/src/app/components/ProfilePage.tsx +++ b/frontend/src/app/components/ProfilePage.tsx @@ -5,6 +5,7 @@ import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } f import { socket } from "../../socket/socket"; import { statsSocket } from "../../socket/socket"; import { useParams } from "react-router-dom"; +import Avatar from "./Avatar"; interface ProfilePageProps { username: string; @@ -133,7 +134,13 @@ export default function ProfilePage({ useEffect(() => { fetchStats(); fetchMatchHistory(); - }, [displayUsername]); + if (isMyProfile) { + fetch(`/api/auth/me`, { credentials: "include" }) + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { if (data?.avatar) setAvatar(data.avatar); }) + .catch(() => {}); + } + }, [displayUsername]); // Temps réel : écoute les mises à jour de stats poussées par le serveur useEffect(() => { @@ -283,12 +290,12 @@ export default function ProfilePage({ {twoFAModal === "enable" ? : }

- {twoFAModal === "enable" ? "Activer le 2FA" : "Désactiver le 2FA"} + {twoFAModal === "enable" ? "Activate the 2FA" : "Deactivate the 2FA"}

{twoFAModal === "enable" - ? "Scannez ce QR Code avec Google Authenticator ou Authy." - : "Saisissez un code de votre application pour confirmer."} + ? "Scan the QR Code with Google Authenticator" + : "Write the code from your app to confirm"}

@@ -297,14 +304,14 @@ export default function ProfilePage({ {qrCodeUrl ? ( QR Code 2FA ) : ( -
Chargement...
+
Loading...
)}
)}
- + - Confirmer l'activation + Confirm activation ) : ( )}
@@ -354,9 +361,8 @@ export default function ProfilePage({
-
-
- {avatar} +
+
{isMyProfile && ( @@ -419,7 +425,7 @@ export default function ProfilePage({ {/* Progression du niveau */}
- Niveau {stats?.level ?? 1} + Level {stats?.level ?? 1} {stats?.currentLevelXp ?? 0} / 100 XP
@@ -490,7 +496,7 @@ export default function ProfilePage({ onClick={fetchStats} className="px-5 py-2 rounded-xl bg-gray-900 hover:bg-gray-800 text-white font-semibold transition-all" > - Filtrer + Filter {(startDate || endDate) && (
@@ -586,7 +592,7 @@ export default function ProfilePage({

- Badges + Achievements

{(stats?.badges ?? []).map((badge) => ( @@ -671,14 +677,14 @@ export default function ProfilePage({

- Historique des parties + Game History

{historyLoading ? ( -

Chargement...

+

Loading...

) : matchHistory.length === 0 ? (

- Aucune partie 1v1 pour l'instant — lance-toi ! + No 1v1 game yet, launch a game! !

) : (
@@ -740,10 +746,10 @@ export default function ProfilePage({ }`} > {match.result === "win" - ? "Victoire" + ? "Victory" : match.result === "loss" - ? "Défaite" - : "Égalité"} + ? "Defeat" + : "Draw"}

@@ -752,6 +758,5 @@ export default function ProfilePage({ )}
-
); } diff --git a/frontend/src/app/pages/Leaderboard.tsx b/frontend/src/app/pages/Leaderboard.tsx index d739830..672fb06 100644 --- a/frontend/src/app/pages/Leaderboard.tsx +++ b/frontend/src/app/pages/Leaderboard.tsx @@ -24,125 +24,128 @@ const RANK_STYLES: Record = { }; export default function LeaderboardPage({ userId, onBack }: LeaderboardPageProps) { - const [entries, setEntries] = useState([]); - const [myRank, setMyRank] = useState(null); - const [loading, setLoading] = useState(true); + const [entries, setEntries] = useState([]); + const [myRank, setMyRank] = useState(null); + const [loading, setLoading] = useState(true); - useEffect(() => { - const load = async () => { - setLoading(true); - try { - const [leaderboardRes, rankRes] = await Promise.all([ - fetch(`/api/stats/leaderboard?limit=20`, { credentials: "include" }), - fetch(`/api/stats/${userId}/rank`, { credentials: "include" }), - ]); + useEffect(() => { + const load = async () => { + setLoading(true); + try { + const [leaderboardRes, rankRes] = await Promise.all([ + fetch(`/api/stats/leaderboard?limit=20`, { credentials: "include" }), + fetch(`/api/stats/${userId}/rank`, { credentials: "include" }), + ]); + if (leaderboardRes.ok) { + setEntries(await leaderboardRes.json()); + } + if (rankRes.ok) { + const data = await rankRes.json(); + setMyRank(data.rank); + } + } catch (err) { + console.error("Erreur lors du chargement du classement :", err); + } finally { + setLoading(false); + } + }; + load(); + }, [userId]); + const isMe = (entry: LeaderboardEntry) => entry.userId === userId; + const isImageUrl = (avatar: string | null): boolean => + !!avatar && /^(https?:\/\/|\/)/.test(avatar); + return ( +
+
+
+ + {myRank !== null && ( +
+ Your rank: #{myRank} +
+ )} +
+
+
+ +
+

+ Ranking +

+

Best players, ranked by XP

+
- if (leaderboardRes.ok) { - setEntries(await leaderboardRes.json()); - } - if (rankRes.ok) { - const data = await rankRes.json(); - setMyRank(data.rank); - } - } catch (err) { - console.error("Erreur lors du chargement du classement :", err); - } finally { - setLoading(false); - } - }; - - load(); - }, [userId]); - - const isMe = (entry: LeaderboardEntry) => entry.userId === userId; - - return ( -
-
-
- - - {myRank !== null && ( -
- Ton rang : #{myRank} -
- )} -
- -
-
- -
-

- Classement -

-

Les meilleurs joueurs, classés par XP

-
- -
- {loading ? ( -

Chargement...

- ) : entries.length === 0 ? ( -

Aucun joueur classé pour l'instant.

- ) : ( -
- {entries.map((entry) => ( -
-
+ {loading ? ( +

Loading...

+ ) : entries.length === 0 ? ( +

No players ranked yet.

+ ) : ( +
+ {entries.map((entry) => ( +
+
- {entry.rank <= 3 ? ( - entry.rank === 1 ? ( - + {entry.rank <= 3 ? ( + entry.rank === 1 ? ( + ) : ( - - ) - ) : ( - entry.rank - )} -
- -
- {entry.avatar || "😊"} -
- -
-

- {entry.username} - {isMe(entry) && (toi)} -

-

- Niveau {entry.level} · {entry.gamesPlayed} partie(s) · {entry.wins} victoire(s) -

-
- -
-

- {entry.xp} -

-

XP

-
-
- ))} -
- )} -
-
-
- ); + + ) + ) : ( + entry.rank + )} +
+
+ {isImageUrl(entry.avatar) ? ( + {entry.username} { e.currentTarget.style.display = "none"; }} + /> + ) : ( + entry.avatar || "😊" + )} +
+
+

+ {entry.username} + {isMe(entry) && (you)} +

+

+ Level {entry.level} · {entry.gamesPlayed} games · {entry.wins} wins +

+
+
+

+ {entry.xp} +

+

XP

+
+
+ ))} +
+ )} +
+
+
+ ); }