player/src/components/KubikonLeaderboard/KubikonLeaderboard.jsx
Вика 124504488c
Some checks failed
CI / Lint + Format (pull_request) Failing after 34s
CI / Build (pull_request) Failing after 37s
CI / Secret scan (pull_request) Failing after 34s
CI / PR size check (pull_request) Failing after 31s
chore: лидерборд — «Загрузка…» → «Загружаю…»
Тест онбординга v2: микро-правка в плеере для проверки полного цикла
PR (clone → install → build → commit → push → merge).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:18:45 +03:00

251 lines
12 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import * as Kubikon3DApi from '../../api/Kubikon3DService';
/**
* Форматирует время в мс → "1:23.45" (мин:сек.сотые) или "23.45" если меньше минуты.
*/
export function formatTimeMs(ms) {
if (ms == null || !Number.isFinite(ms)) return '—';
const totalSec = ms / 1000;
const mm = Math.floor(totalSec / 60);
const sec = totalSec - mm * 60;
if (mm > 0) {
return mm + ':' + sec.toFixed(2).padStart(5, '0');
}
return sec.toFixed(2);
}
const RANK_BADGES = ['🥇', '🥈', '🥉', '4⃣', '5⃣'];
/**
* KubikonLeaderboard — таблица топ-N рекордов прохождения игры.
*
* Props:
* projectId — id проекта Кубикон 3D
* limit — топ N (default 5)
* currentTimeMs — текущее время игрока (показывается отдельной строкой)
* currentUserId — id текущего игрока (выделить его строку)
* onClose — если задан, показывается крестик в углу для скрытия
* compact — если true, компактный режим (для оверлея в игре)
* refreshKey — менять чтобы перезагрузить (например при submit)
* style, className — для оборачивания
*/
export default function KubikonLeaderboard({
projectId, limit = 5, currentTimeMs = null, currentUserId = null,
onClose = null, compact = false, refreshKey = 0,
clickable = true, onLoaded = null,
style = {}, className = '',
}) {
const navigate = useNavigate();
const [records, setRecords] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// onLoaded храним в ref чтобы не плодить fetchData при каждом рендере
// родителя (если он не оборачивает onLoaded в useCallback). Без этого
// компонент попадал в бесконечный цикл fetch.
const onLoadedRef = useRef(onLoaded);
useEffect(() => { onLoadedRef.current = onLoaded; }, [onLoaded]);
const fetchData = useCallback(async () => {
if (!projectId) return;
setLoading(true);
setError(null);
try {
const r = await Kubikon3DApi.getLeaderboard(projectId, limit);
const list = r.data?.records || [];
setRecords(list);
const cb = onLoadedRef.current;
if (cb) try { cb(list); } catch (e) {}
} catch (e) {
setError(e?.message || 'Ошибка загрузки');
} finally {
setLoading(false);
}
}, [projectId, limit]);
useEffect(() => { fetchData(); }, [fetchData, refreshKey]);
const goProfile = (uid) => {
if (!clickable) return;
if (!uid) return;
// Профили живут на rublox.pro, не в плеере. Внешний редирект.
window.location.assign(`https://rublox.pro/app/profile/${uid}`);
};
// Палитра — берём «золотистую» гамму с лёгким бирюзовым акцентом (соответствует Кубикону)
const C = {
bg: compact ? 'rgba(10, 14, 26, 0.92)' : 'rgba(20, 24, 45, 0.96)',
bgRow: 'rgba(15, 19, 38, 0.55)',
bgRowAlt: 'rgba(15, 19, 38, 0.30)',
bgRowMe: 'rgba(255, 215, 0, 0.16)',
border: 'rgba(255, 215, 0, 0.45)',
borderSoft:'rgba(255, 255, 255, 0.10)',
gold: '#ffd700',
goldDim: '#caa01c',
text: '#f1f5fb',
muted: 'rgba(241, 245, 251, 0.55)',
accent: '#3357ff',
success: '#22d97a',
};
return (
<div
className={className}
style={{
background: C.bg,
border: `2px solid ${C.border}`,
borderRadius: compact ? 14 : 18,
padding: compact ? 12 : 16,
color: C.text,
fontFamily: '"Roboto Condensed", system-ui, sans-serif',
backdropFilter: 'blur(8px)',
boxShadow: '0 8px 32px rgba(0,0,0,0.45), 0 0 0 1px rgba(255,215,0,0.10) inset',
minWidth: compact ? 240 : 320,
maxWidth: compact ? 320 : 480,
...style,
}}
>
{/* Заголовок */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 10, gap: 8,
}}>
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
fontSize: compact ? 16 : 18, fontWeight: 800,
letterSpacing: 0.5,
color: C.gold,
textShadow: '0 0 12px rgba(255,215,0,0.35)',
}}>
<span style={{ fontSize: compact ? 22 : 26 }}>🏆</span>
Топ-{limit}
</div>
{onClose && (
<button
onClick={onClose}
title="Скрыть таблицу (Tab)"
style={{
background: 'transparent', border: 'none',
color: C.muted, cursor: 'pointer', fontSize: 18,
padding: '2px 8px', borderRadius: 6,
}}
onMouseEnter={e => { e.currentTarget.style.color = C.text; }}
onMouseLeave={e => { e.currentTarget.style.color = C.muted; }}
></button>
)}
</div>
{/* Тело */}
{loading ? (
<div style={{ color: C.muted, fontSize: 14, textAlign: 'center', padding: '12px 0' }}>
Загружаю
</div>
) : error ? (
<div style={{ color: '#ff6f7a', fontSize: 13, textAlign: 'center', padding: '12px 0' }}>
{error}
</div>
) : records.length === 0 ? (
<div style={{
color: C.muted, fontSize: 13, textAlign: 'center',
padding: '14px 6px',
background: C.bgRowAlt, borderRadius: 10,
border: `1px dashed ${C.borderSoft}`,
}}>
Пока нет рекордов.<br/>
<span style={{ color: C.gold }}>Стань первым!</span>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{records.map((r, idx) => {
const isMe = currentUserId && r.user_id === currentUserId;
return (
<div
key={r.id}
onClick={() => goProfile(r.user_id)}
style={{
display: 'grid',
gridTemplateColumns: '32px 1fr auto',
alignItems: 'center',
gap: 8,
padding: '8px 10px',
borderRadius: 10,
background: isMe ? C.bgRowMe :
idx % 2 === 0 ? C.bgRow : C.bgRowAlt,
border: isMe ? `1px solid ${C.gold}` : `1px solid ${C.borderSoft}`,
cursor: clickable ? 'pointer' : 'default',
transition: 'transform 0.12s ease, background 0.12s ease',
}}
onMouseEnter={e => {
if (!clickable) return;
e.currentTarget.style.transform = 'translateX(2px)';
e.currentTarget.style.background = isMe ? 'rgba(255,215,0,0.24)' : 'rgba(51,87,255,0.18)';
}}
onMouseLeave={e => {
if (!clickable) return;
e.currentTarget.style.transform = 'translateX(0)';
e.currentTarget.style.background = isMe ? C.bgRowMe :
idx % 2 === 0 ? C.bgRow : C.bgRowAlt;
}}
>
<div style={{
fontSize: idx < 3 ? 22 : 16,
fontWeight: 700,
color: idx < 3 ? C.gold : C.muted,
textAlign: 'center',
}}>
{RANK_BADGES[idx] || (idx + 1)}
</div>
<div style={{
fontWeight: 600, fontSize: 14,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
color: isMe ? C.gold : C.text,
}}>
{r.username || `id ${r.user_id}`}
{isMe && <span style={{ marginLeft: 6, fontSize: 11, opacity: 0.7 }}>(ты)</span>}
</div>
<div style={{
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
fontSize: 14, fontWeight: 700,
color: idx === 0 ? C.gold : C.text,
minWidth: 60, textAlign: 'right',
}}>
{formatTimeMs(r.time_ms)}
</div>
</div>
);
})}
</div>
)}
{/* Текущее время игрока (если идёт прохождение) */}
{currentTimeMs != null && currentTimeMs > 0 && (
<div style={{
marginTop: 10,
padding: '8px 10px',
borderRadius: 10,
background: 'linear-gradient(135deg, rgba(34, 217, 122, 0.18), rgba(51, 87, 255, 0.14))',
border: `1px solid ${C.success}`,
display: 'grid',
gridTemplateColumns: 'auto 1fr auto',
alignItems: 'center',
gap: 8,
}}>
<span style={{ fontSize: 18 }}></span>
<span style={{ fontSize: 13, color: C.success, fontWeight: 700 }}>
Твоё время
</span>
<span style={{
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
fontSize: 16, fontWeight: 800,
color: C.success,
}}>
{formatTimeMs(currentTimeMs)}
</span>
</div>
)}
</div>
);
}