diff --git a/public/default-avatar.png b/public/default-avatar.png new file mode 100644 index 0000000..4515dbd Binary files /dev/null and b/public/default-avatar.png differ diff --git a/src/App.jsx b/src/App.jsx index 006629b..2f5af89 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,7 @@ import {BrowserRouter as Router, Routes, Route, Navigate, useParams, Outlet} from 'react-router-dom'; import LoginPage from './pages/LoginPage'; import BattlePage from './pages/BattlePage'; +import LeaderboardPage from './pages/LeaderboardPage'; import WorkspacePage from './pages/WorkspacePage'; import './App.css'; import {ProfilePage} from "./pages/ProfilePage.jsx"; @@ -44,6 +45,7 @@ function App() { }/> }/> }/> + }/> }> }/> }/> diff --git a/src/context/UserContext.jsx b/src/context/UserContext.jsx index 32889af..767a5a4 100644 --- a/src/context/UserContext.jsx +++ b/src/context/UserContext.jsx @@ -1,24 +1,28 @@ -import { createContext, useContext, useState } from 'react'; -import {api} from "../api/axiosConfig.js"; +import { createContext, useContext, useState, useEffect } from 'react'; +import { api } from '../api/axiosConfig'; const UserContext = createContext(); export function UserProvider({ children }) { - const [user, setUser] = useState(null); - - const login = (userData) => { - setUser(userData); - }; + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); - const logout = () => { - api.post("auth/logout"); - setUser(null); - }; + useEffect(() => { + api.get('/auth/me') + .then(res => setUser(res.data)) + .catch(() => setUser(null)) + .finally(() => setLoading(false)); + }, []); + + const login = (userData) => setUser(userData); + const logout = () => setUser(null); + + if (loading) return null; return ( - - {children} - + + {children} + ); } diff --git a/src/pages/LeaderboardPage.css b/src/pages/LeaderboardPage.css new file mode 100644 index 0000000..a5eda3a --- /dev/null +++ b/src/pages/LeaderboardPage.css @@ -0,0 +1,150 @@ +.lb-main { + flex-grow: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 40px 20px 60px; + background: radial-gradient(circle at center, #1a1a2e 0%, var(--bg-main) 100%); + color: var(--text-primary); +} + +.lb-title { + margin-bottom: 6px; + font-family: var(--font-ui); + font-size: 2.5rem; + text-shadow: 0 4px 10px rgba(0, 0, 0, 0.5); +} + +.lb-subtitle { + margin-bottom: 40px; + color: var(--text-secondary); + font-size: 1.1rem; +} + +.lb-loading, +.lb-empty { + margin-top: 40px; + color: var(--text-secondary); + font-size: 1.1rem; +} + +.lb-board { + width: 100%; + max-width: 640px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.lb-header-row { + display: grid; + grid-template-columns: 80px 1fr 100px; + align-items: center; + padding: 4px 20px; + color: var(--text-secondary); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 1px; +} + +.lb-row { + display: grid; + grid-template-columns: 80px 1fr 100px; + align-items: center; + padding: 12px 20px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + transition: transform 0.12s ease, background 0.12s ease; +} + +.lb-row:hover { + background: rgba(255, 255, 255, 0.07); + transform: translateX(2px); +} + +.lb-rank { + font-family: var(--font-ui); + font-size: 1.2rem; + font-weight: 700; +} + +.lb-user { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.lb-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + object-fit: cover; + background: rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.15); + flex-shrink: 0; +} + +.lb-nickname { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lb-you-badge { + padding: 2px 8px; + background: var(--accent-color, #646cff); + color: #fff; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + border-radius: 6px; + flex-shrink: 0; +} + +.lb-rating { + text-align: right; + font-family: var(--font-ui); + font-size: 1.15rem; + font-weight: 700; + color: var(--accent-color, #646cff); +} + +/* Подсветка своей строки */ +.lb-row--me { + background: rgba(100, 108, 255, 0.15); + border-color: var(--accent-color, #646cff); + box-shadow: 0 0 16px rgba(100, 108, 255, 0.25); +} + +/* Топ-3 — кубковые акценты */ +.lb-row--top1 { + background: linear-gradient(90deg, rgba(255, 215, 0, 0.18) 0%, rgba(255, 255, 255, 0.04) 60%); + border-color: rgba(255, 215, 0, 0.5); +} + +.lb-row--top2 { + background: linear-gradient(90deg, rgba(192, 192, 192, 0.18) 0%, rgba(255, 255, 255, 0.04) 60%); + border-color: rgba(192, 192, 192, 0.5); +} + +.lb-row--top3 { + background: linear-gradient(90deg, rgba(205, 127, 50, 0.18) 0%, rgba(255, 255, 255, 0.04) 60%); + border-color: rgba(205, 127, 50, 0.5); +} + +.lb-row--top1 .lb-rank, +.lb-row--top2 .lb-rank, +.lb-row--top3 .lb-rank { + font-size: 1.5rem; +} + +.lb-divider { + text-align: center; + color: var(--text-secondary); + font-size: 1.5rem; + letter-spacing: 4px; + padding: 4px 0; + user-select: none; +} \ No newline at end of file diff --git a/src/pages/LeaderboardPage.jsx b/src/pages/LeaderboardPage.jsx new file mode 100644 index 0000000..82cf9c2 --- /dev/null +++ b/src/pages/LeaderboardPage.jsx @@ -0,0 +1,132 @@ +import defaultAvatar from '../../public/default-avatar.png'; +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useUser } from '../context/UserContext'; +import { api } from '../api/axiosConfig'; +import Header from './components/layout/Header'; +import Footer from './components/layout/Footer'; +import SettingsModal from './components/ui/SettingsModal'; +import toast from 'react-hot-toast'; +import './LeaderboardPage.css'; + +const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }; + +function LeaderboardRow({ entry }) { + const isTop3 = entry.rank <= 3; + return ( +
+
+ {MEDALS[entry.rank] || `#${entry.rank}`} +
+
+ {entry.nickname} { + if (e.currentTarget.dataset.fallback) return; + e.currentTarget.dataset.fallback = '1'; + e.currentTarget.src = defaultAvatar; + }} + /> + {entry.nickname} + {entry.isCurrentUser && вы} +
+
{entry.rating}
+
+ ); +} + +export default function LeaderboardPage() { + const navigate = useNavigate(); + const { logout } = useUser(); + const [showSettings, setShowSettings] = useState(false); + + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await api.get('/leaderboard'); + if (!cancelled) setData(res.data); + } catch (err) { + if (!cancelled) toast.error('Не удалось загрузить рейтинг'); + console.error('[Leaderboard] load error:', err); + } finally { + if (!cancelled) setIsLoading(false); + } + })(); + return () => { cancelled = true; }; + }, []); + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + + const topRanks = new Set((data?.top ?? []).map((e) => e.rank)); + const bottom = (data?.bottom ?? []).filter((e) => !topRanks.has(e.rank)); + + const meRank = data?.currentUser?.rank; + const meAlreadyShown = + topRanks.has(meRank) || bottom.some((e) => e.rank === meRank); + const showMeSeparately = data?.currentUser && !meAlreadyShown; + + const lastTopRank = data?.top?.[data.top.length - 1]?.rank ?? 0; + const firstBottomRank = bottom[0]?.rank ?? null; + const hasGap = firstBottomRank != null && firstBottomRank > lastTopRank + 1; + + return ( +
+
setShowSettings(true)} /> + +
+

Зал славы

+

Лучшие бойцы CodZilla

+ + {isLoading ? ( +
Загрузка рейтинга...
+ ) : !data || data.totalUsers === 0 ? ( +
Пока никого нет в рейтинге
+ ) : ( +
+
+
Место
+
Игрок
+
Рейтинг
+
+ + {data.top.map((e) => ( + + ))} + + {showMeSeparately && ( + <> +
+ + + )} + + {hasGap && !showMeSeparately && ( +
+ )} + + {bottom.map((e) => ( + + ))} +
+ )} +
+ +
+ ); +} \ No newline at end of file