Тест онбординга v2: микро-правка в плеере для проверки полного цикла PR (clone → install → build → commit → push → merge). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
251 lines
12 KiB
JavaScript
251 lines
12 KiB
JavaScript
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>
|
||
);
|
||
}
|