Open-source web player for Rublox games, dual-licensed under AGPL-3.0 + Commercial. Highlights: - Babylon.js 7 + React 18 + Vite 5 stack - Self-contained engine (~46k lines): BlockManager, ModelManager, PlayerController, ScriptSandboxWorker, MultiplayerSync, 30+ GD gamemodes - Configurable backend via VITE_API_BASE and friends — works against staging (dev-api.rublox.pro) out of the box - Standalone mode (VITE_STANDALONE=true) loads a bundled sample game for first-run without any backend - Full docs: README, ARCHITECTURE, CONTRIBUTING, SECURITY, CHANGELOG - Lint + format scaffolding (ESLint + Prettier + EditorConfig) - Legal: LICENSE (AGPL-3.0), LICENSE-COMMERCIAL.md, CLA.md, COPYRIGHT.md - Issue templates: bug_report, feature_request, security_disclosure Removed before public release: - frontend_deploy.py (contained production SSH credentials) - ~27 admin endpoints (kept in private repo) - Hard-coded internal URLs and IPs - All previous git history (clean repo init)
871 lines
37 KiB
JavaScript
871 lines
37 KiB
JavaScript
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||
import { Link } from 'react-router-dom';
|
||
import { jwtDecode } from 'jwt-decode';
|
||
import { io } from 'socket.io-client';
|
||
import * as Kubikon3DApi from '../api/Kubikon3DService';
|
||
import { STORYS_addres } from '../api/API';
|
||
import { formatTimeShort } from '../utils/kubikonTime';
|
||
import EmailConfirmNotice from '../components/EmailConfirmNotice/EmailConfirmNotice';
|
||
|
||
/**
|
||
* KubikonChatPanel — внутриигровой WebSocket-чат для опубликованной игры.
|
||
*
|
||
* Транспорт: Socket.IO, namespace /kubikon3d/chat, комната project_<id>.
|
||
* Если WS не установился (proxy режется, brownfield-сеть) — падаем на REST polling.
|
||
*
|
||
* События WS:
|
||
* ← chat:hello { last_id, online, messages, is_guest }
|
||
* ← chat:message { id, project_id, user_id, username, text, created_at, ... }
|
||
* ← chat:online { count }
|
||
* ← chat:muted { banned_until, reason, is_manual }
|
||
* ← chat:error { message, reason? }
|
||
*
|
||
* Props: projectId, onClose, onRequestAuth(action)
|
||
*/
|
||
const POLL_INTERVAL_MS = 3000;
|
||
const WS_FAILOVER_AFTER_MS = 4000; // если за 4с не подсоединились — переходим на polling
|
||
|
||
/**
|
||
* Если передан `realtimeRoom` (Colyseus.js Room из мультиплеера) — чат идёт
|
||
* через него, без обращения к storys-микросервису. Это режим in-game чата:
|
||
* • видны только сообщения игроков ИЗ ЭТОЙ КОМНАТЫ (не глобальный чат игры)
|
||
* • без истории — новые подключившиеся видят только сообщения с момента
|
||
* подключения
|
||
* • без мьютов / модерации (комната живёт минуты, пока есть игроки)
|
||
*
|
||
* Если `realtimeRoom` НЕ передан — старая логика (Socket.IO к storys).
|
||
*/
|
||
const KubikonChatPanel = ({ projectId, onClose, onRequestAuth, compact = false, realtimeRoom = null, mobileMode = false }) => {
|
||
const [messages, setMessages] = useState([]);
|
||
const [text, setText] = useState('');
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [error, setError] = useState(null);
|
||
const [muteInfo, setMuteInfo] = useState(null); // активный мьют
|
||
const [loaded, setLoaded] = useState(false);
|
||
const [online, setOnline] = useState(0);
|
||
const [transport, setTransport] = useState('connecting'); // 'ws' | 'polling' | 'connecting' | 'realtime'
|
||
const [emailNotice, setEmailNotice] = useState(false); // окно «подтвердите email»
|
||
|
||
const lastIdRef = useRef(0);
|
||
const listRef = useRef(null);
|
||
const socketRef = useRef(null);
|
||
// onRequestAuth от родителя — стабилизируем через ref, чтобы он не попадал
|
||
// в deps useEffect и не вызывал re-mount (=пересоздание WS) каждый рендер.
|
||
const onRequestAuthRef = useRef(onRequestAuth);
|
||
useEffect(() => { onRequestAuthRef.current = onRequestAuth; }, [onRequestAuth]);
|
||
|
||
const userInfo = (() => {
|
||
try {
|
||
const raw = localStorage.getItem('Authorization');
|
||
if (!raw) return null;
|
||
const token = raw.startsWith('Bearer ') ? raw.slice(7).trim() : raw.trim();
|
||
if (!token) return null;
|
||
const p = jwtDecode(token);
|
||
// Гостевой токен — не считается авторизованным пользователем.
|
||
const isGuest = p?.guest === true
|
||
|| (typeof p?.sub === 'string' && p.sub.startsWith('guest_'))
|
||
|| p?.type === 'guest';
|
||
if (isGuest) return null;
|
||
const id = p?.id ?? p?.user_id ?? p?.userId ?? null;
|
||
return {
|
||
id: id != null ? Number(id) || null : null,
|
||
username: p?.firstName || p?.first_name || p?.email || '',
|
||
};
|
||
} catch (e) { return null; }
|
||
})();
|
||
const userId = userInfo?.id || null;
|
||
|
||
// === Realtime-чат (Colyseus room) ===
|
||
// Если родитель передал realtimeRoom — используем его, не идём в storys.
|
||
useEffect(() => {
|
||
if (!realtimeRoom) return;
|
||
setTransport('realtime');
|
||
setLoaded(true);
|
||
setOnline(realtimeRoom.state?.players?.size || 0);
|
||
|
||
const offChat = realtimeRoom.onMessage('chat', (msg) => {
|
||
setMessages(prev => [
|
||
...prev,
|
||
{
|
||
id: Date.now() + Math.random(),
|
||
user_id: msg.userId,
|
||
username: msg.username,
|
||
text: msg.text,
|
||
created_at: new Date(msg.ts).toISOString(),
|
||
transport: 'realtime',
|
||
},
|
||
].slice(-200)); // последние 200, не копим бесконечно
|
||
});
|
||
const offRejected = realtimeRoom.onMessage('chat-rejected', (m) => {
|
||
setError(m.text || 'Сообщение не отправлено');
|
||
});
|
||
|
||
// Online-counter обновляется когда игроки заходят/выходят.
|
||
// Подсчитываем через state.players через простой interval — точнее,
|
||
// подписаться можно через getStateCallbacks, но нам достаточно опроса.
|
||
const t = setInterval(() => {
|
||
setOnline(realtimeRoom.state?.players?.size || 0);
|
||
}, 1000);
|
||
|
||
return () => {
|
||
try { offChat?.(); } catch (e) {}
|
||
try { offRejected?.(); } catch (e) {}
|
||
clearInterval(t);
|
||
};
|
||
}, [realtimeRoom]);
|
||
|
||
// === Socket.IO — fallback для не-мультиплеерных игр ===
|
||
// Если WS не подключится за WS_FAILOVER_AFTER_MS, включится polling-fallback.
|
||
useEffect(() => {
|
||
// Если работаем в realtime-режиме — Socket.IO не нужен
|
||
if (realtimeRoom) return;
|
||
|
||
let alive = true;
|
||
let pollTimer = null;
|
||
let failoverTimer = null;
|
||
|
||
const tokenRaw = localStorage.getItem('Authorization') || '';
|
||
const token = tokenRaw.startsWith('Bearer ') ? tokenRaw.slice(7) : tokenRaw;
|
||
|
||
// socket.io-client особенность: при io(url, opts) — из url берётся ТОЛЬКО
|
||
// origin + namespace (последний сегмент). Префикс пути отбрасывается, и
|
||
// используется ровно opts.path. Поэтому передаём:
|
||
// 1) origin + namespace в URL
|
||
// 2) полный путь /api-storys/socket.io в opts.path
|
||
// Извлекаем origin из STORYS_addres (например https://dev-api.rublox.pro/api-storys)
|
||
let storysOrigin;
|
||
let storysPath;
|
||
try {
|
||
const u = new URL(STORYS_addres);
|
||
storysOrigin = u.origin; // https://dev-api.rublox.pro
|
||
storysPath = (u.pathname || '').replace(/\/$/, '') + '/socket.io';
|
||
} catch (e) {
|
||
// Fallback (на случай dev-сборки без полного URL)
|
||
storysOrigin = STORYS_addres;
|
||
storysPath = '/socket.io';
|
||
}
|
||
|
||
const socket = io(storysOrigin + '/kubikon3d/chat', {
|
||
path: storysPath,
|
||
transports: ['websocket'],
|
||
query: { project: projectId, token },
|
||
forceNew: true,
|
||
reconnection: true,
|
||
reconnectionAttempts: 3,
|
||
reconnectionDelay: 1000,
|
||
});
|
||
socketRef.current = socket;
|
||
|
||
// Если за N секунд WS не подключилось — переходим на polling
|
||
failoverTimer = setTimeout(() => {
|
||
if (!alive) return;
|
||
if (!socket.connected) {
|
||
setTransport('polling');
|
||
startPolling();
|
||
}
|
||
}, WS_FAILOVER_AFTER_MS);
|
||
|
||
socket.on('connect', () => {
|
||
if (!alive) return;
|
||
setTransport('ws');
|
||
clearTimeout(failoverTimer);
|
||
// Polling больше не нужен
|
||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
|
||
});
|
||
|
||
socket.on('chat:hello', (payload) => {
|
||
if (!alive) return;
|
||
const msgs = payload?.messages || [];
|
||
setMessages(msgs);
|
||
lastIdRef.current = payload?.last_id || 0;
|
||
setOnline(payload?.online || 0);
|
||
setLoaded(true);
|
||
});
|
||
|
||
socket.on('chat:message', (msg) => {
|
||
if (!alive) return;
|
||
setMessages(prev => {
|
||
// Защита от дублей (если REST-fallback уже добавил)
|
||
if (prev.some(x => x.id === msg.id)) return prev;
|
||
return [...prev, msg];
|
||
});
|
||
lastIdRef.current = Math.max(lastIdRef.current, msg.id);
|
||
});
|
||
|
||
socket.on('chat:online', (payload) => {
|
||
if (!alive) return;
|
||
setOnline(payload?.count || 0);
|
||
});
|
||
|
||
socket.on('chat:muted', (payload) => {
|
||
if (!alive) return;
|
||
setMuteInfo({
|
||
banned_until: payload?.banned_until,
|
||
last_reason: payload?.reason,
|
||
is_manual: payload?.is_manual,
|
||
});
|
||
});
|
||
|
||
socket.on('chat:error', (payload) => {
|
||
if (!alive) return;
|
||
const m = payload?.message || 'error';
|
||
if (m === 'login_required') onRequestAuthRef.current?.('писать в чат');
|
||
else if (m === 'email_not_confirmed') setEmailNotice(true);
|
||
else if (m === 'too_frequent') setError(payload?.text || 'Слишком быстро.');
|
||
else if (m === 'invalid_text') setError('Сообщение пустое.');
|
||
else setError(payload?.text || m);
|
||
});
|
||
|
||
socket.on('disconnect', () => {
|
||
if (!alive) return;
|
||
setTransport('connecting');
|
||
});
|
||
|
||
socket.on('connect_error', () => {
|
||
// не падаем сразу — failoverTimer переключит на polling
|
||
});
|
||
|
||
// === Polling-fallback ===
|
||
// Для draft-проектов чат недоступен (бэкенд отвечает 403). В этом
|
||
// случае НЕ запускаем интервал — иначе он будет каждые 3с отправлять
|
||
// HTTP-запрос с 403, что блокирует main thread и виден дёрганый
|
||
// куб игрока в GD-уровнях (см. отчёт 2026-05-19).
|
||
const startPolling = async () => {
|
||
if (pollTimer) return;
|
||
let initialFailedAsForbidden = false;
|
||
try {
|
||
const res = await Kubikon3DApi.getChat(projectId);
|
||
if (!alive) return;
|
||
setMessages(res.data?.messages || []);
|
||
lastIdRef.current = res.data?.last_id || 0;
|
||
setLoaded(true);
|
||
} catch (e) {
|
||
// 403 = project_not_published. Дальнейший polling бессмысленен.
|
||
if (e?.response?.status === 403) {
|
||
initialFailedAsForbidden = true;
|
||
setLoaded(true); // не висим в «загрузка...» бесконечно
|
||
}
|
||
}
|
||
if (initialFailedAsForbidden) return;
|
||
pollTimer = setInterval(async () => {
|
||
if (!alive) return;
|
||
try {
|
||
const res = await Kubikon3DApi.getChat(projectId, lastIdRef.current);
|
||
const newMsgs = res.data?.messages || [];
|
||
if (newMsgs.length > 0) {
|
||
setMessages(prev => [...prev, ...newMsgs]);
|
||
lastIdRef.current = res.data.last_id;
|
||
}
|
||
} catch (e) {
|
||
// Если стал 403 в процессе (например, статус сменился на draft) —
|
||
// прекращаем polling, чтобы не флудить пустыми запросами.
|
||
if (e?.response?.status === 403 && pollTimer) {
|
||
clearInterval(pollTimer);
|
||
pollTimer = null;
|
||
}
|
||
}
|
||
}, POLL_INTERVAL_MS);
|
||
};
|
||
|
||
// Параллельно проверяем mute-статус (REST), независимо от транспорта
|
||
if (userId) {
|
||
Kubikon3DApi.getChatMuteStatus(userId)
|
||
.then(r => { if (alive && r.data?.muted) setMuteInfo(r.data.ban); })
|
||
.catch(() => {});
|
||
}
|
||
|
||
return () => {
|
||
alive = false;
|
||
clearTimeout(failoverTimer);
|
||
if (pollTimer) clearInterval(pollTimer);
|
||
try { socket.disconnect(); } catch (e) {}
|
||
socketRef.current = null;
|
||
};
|
||
// ВАЖНО: onRequestAuth НЕ в deps — он берётся из ref. Иначе при каждом
|
||
// рендере родителя useEffect пересобирался бы и пересоздавал WS-соединение.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [projectId, userId]);
|
||
|
||
// Автоскролл вниз: при первой загрузке скроллим всегда. При новых сообщениях
|
||
// — только если пользователь сам не отскроллил вверх (>200px от низа).
|
||
// Используем двойной RAF — фактический scrollHeight доступен только после
|
||
// того как браузер применил layout новых сообщений.
|
||
const wasFirstScrollRef = useRef(false);
|
||
useEffect(() => {
|
||
const el = listRef.current;
|
||
if (!el) return;
|
||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||
const isFirst = !wasFirstScrollRef.current && messages.length > 0;
|
||
const stayDown = isFirst || distanceFromBottom < 200;
|
||
if (stayDown) {
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
if (listRef.current) {
|
||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||
}
|
||
});
|
||
});
|
||
if (isFirst) wasFirstScrollRef.current = true;
|
||
}
|
||
}, [messages, loaded]);
|
||
|
||
const send = async () => {
|
||
if (!userId) { onRequestAuth?.('писать в чат'); return; }
|
||
const t = text.trim();
|
||
if (!t) return;
|
||
setError(null);
|
||
|
||
// === Путь 0: Realtime (Colyseus room) ===
|
||
// Если в этом сеансе есть multiplayer-room, шлём через неё.
|
||
if (realtimeRoom) {
|
||
try {
|
||
realtimeRoom.send('chat', { text: t });
|
||
setText('');
|
||
} catch (e) {
|
||
setError('Ошибка отправки в realtime');
|
||
}
|
||
return;
|
||
}
|
||
|
||
const sock = socketRef.current;
|
||
// === Путь 1: WebSocket ===
|
||
if (sock && sock.connected) {
|
||
sock.emit('chat:send', {
|
||
text: t,
|
||
username: userInfo?.username || '',
|
||
});
|
||
// Сообщение прилетит обратно через chat:message broadcast — не дублируем локально
|
||
setText('');
|
||
return;
|
||
}
|
||
|
||
// === Путь 2: REST-fallback ===
|
||
setSubmitting(true);
|
||
try {
|
||
const res = await Kubikon3DApi.postChatMessage(projectId, {
|
||
user_id: userId,
|
||
username: userInfo?.username || '',
|
||
text: t,
|
||
});
|
||
const m = res.data?.message;
|
||
if (m) {
|
||
setMessages(prev => prev.some(x => x.id === m.id) ? prev : [...prev, m]);
|
||
lastIdRef.current = Math.max(lastIdRef.current, m.id);
|
||
}
|
||
setText('');
|
||
} catch (e) {
|
||
const code = e?.response?.data?.error;
|
||
const data = e?.response?.data || {};
|
||
if (code === 'muted' || code === 'flood_muted') {
|
||
setMuteInfo({
|
||
banned_until: data.banned_until,
|
||
last_reason: data.reason,
|
||
is_manual: data.is_manual,
|
||
});
|
||
setError(data.message || formatMuteMessage(data));
|
||
} else if (code === 'too_frequent') {
|
||
setError(data.message || 'Слишком быстро.');
|
||
} else if (code === 'login_required') {
|
||
onRequestAuth?.('писать в чат');
|
||
} else if (code === 'invalid_text') {
|
||
setError('Сообщение пустое.');
|
||
} else {
|
||
setError(data.message || code || 'Ошибка отправки');
|
||
}
|
||
}
|
||
setSubmitting(false);
|
||
};
|
||
|
||
const onKeyDown = (e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
send();
|
||
}
|
||
};
|
||
|
||
// Compact mode — плавающее окно 320×380 в левом верхнем углу под top-bar.
|
||
// Mobile mode — окно 280×320 в правом верхнем углу. Высота достаточная,
|
||
// чтобы кнопка отправки и поле ввода поместились над клавиатурой.
|
||
const wrapperStyle = compact
|
||
? (mobileMode ? {
|
||
position: 'fixed', top: 8, right: 8,
|
||
width: 280, height: 320, maxWidth: '90vw', maxHeight: '70vh',
|
||
background: CHAT.bgPanel,
|
||
border: `1px solid ${CHAT.border}`,
|
||
borderRadius: 14,
|
||
zIndex: 1100,
|
||
display: 'flex', flexDirection: 'column',
|
||
color: CHAT.text,
|
||
fontFamily: CHAT.font,
|
||
boxShadow: '0 8px 24px rgba(0,0,0,0.45)',
|
||
backdropFilter: 'blur(20px)',
|
||
WebkitBackdropFilter: 'blur(20px)',
|
||
overflow: 'hidden',
|
||
fontSize: 12,
|
||
animation: 'chatSlideIn 280ms cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||
} : {
|
||
position: 'fixed', top: 146, left: 14,
|
||
width: 340, height: 380, maxWidth: '95vw',
|
||
background: CHAT.bgPanel,
|
||
border: `1px solid ${CHAT.border}`,
|
||
borderRadius: 16,
|
||
zIndex: 1100,
|
||
display: 'flex', flexDirection: 'column',
|
||
color: CHAT.text,
|
||
fontFamily: CHAT.font,
|
||
boxShadow: '0 16px 40px rgba(0,0,0,0.55), 0 0 0 1px rgba(51,87,255,0.12)',
|
||
backdropFilter: 'blur(24px)',
|
||
WebkitBackdropFilter: 'blur(24px)',
|
||
overflow: 'hidden',
|
||
animation: 'chatSlideIn 280ms cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||
})
|
||
: {
|
||
position: 'fixed',
|
||
top: 0, left: 0, bottom: 0,
|
||
width: 380, maxWidth: '95vw',
|
||
background: CHAT.bgPanel,
|
||
borderRight: `1px solid ${CHAT.border}`,
|
||
zIndex: 1600,
|
||
display: 'flex', flexDirection: 'column',
|
||
color: CHAT.text,
|
||
fontFamily: CHAT.font,
|
||
boxShadow: '8px 0 32px rgba(0,0,0,0.55)',
|
||
backdropFilter: 'blur(24px)',
|
||
WebkitBackdropFilter: 'blur(24px)',
|
||
overflow: 'hidden',
|
||
};
|
||
|
||
const transportInfo = transport === 'ws'
|
||
? { color: CHAT.success, label: 'Live', icon: '●' }
|
||
: transport === 'polling'
|
||
? { color: CHAT.warning, label: 'Sync', icon: '●' }
|
||
: { color: CHAT.textDim, label: '...', icon: '○' };
|
||
|
||
return (
|
||
<div style={wrapperStyle}>
|
||
<style>{CHAT_KEYFRAMES}</style>
|
||
|
||
{/* === Header с градиентной плашкой === */}
|
||
<div style={{
|
||
padding: '14px 16px',
|
||
borderBottom: `1px solid ${CHAT.border}`,
|
||
display: 'flex', alignItems: 'center', gap: 10,
|
||
background: 'linear-gradient(180deg, rgba(51,87,255,0.16) 0%, rgba(51,87,255,0.03) 100%)',
|
||
position: 'relative',
|
||
}}>
|
||
{/* Тонкая градиентная линия снизу */}
|
||
<div style={{
|
||
position: 'absolute', left: 0, right: 0, bottom: -1, height: 1,
|
||
background: `linear-gradient(90deg, transparent, ${CHAT.accent}, transparent)`,
|
||
opacity: 0.6,
|
||
}} />
|
||
|
||
<div style={{
|
||
width: 32, height: 32, borderRadius: 10,
|
||
background: CHAT.gradientBrand,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 16,
|
||
boxShadow: '0 4px 12px rgba(51,87,255,0.45)',
|
||
}}>💬</div>
|
||
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div style={{
|
||
fontSize: 14, fontWeight: 800, color: CHAT.text,
|
||
letterSpacing: -0.2,
|
||
}}>Чат</div>
|
||
<div style={{
|
||
fontSize: 10, color: CHAT.textMuted, fontWeight: 700,
|
||
textTransform: 'uppercase', letterSpacing: 0.8,
|
||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||
}}>
|
||
<span style={{
|
||
color: transportInfo.color,
|
||
animation: transport === 'ws' ? 'chatPulse 2s ease-in-out infinite' : 'none',
|
||
}}>{transportInfo.icon}</span>
|
||
{transportInfo.label}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Online-pill */}
|
||
<div style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||
background: CHAT.successBg,
|
||
border: `1px solid ${CHAT.success}33`,
|
||
padding: '4px 10px',
|
||
borderRadius: 999,
|
||
fontSize: 11, fontWeight: 800,
|
||
color: CHAT.success,
|
||
}}>
|
||
<span style={{
|
||
width: 6, height: 6, borderRadius: '50%',
|
||
background: CHAT.success,
|
||
animation: 'chatPulse 2s ease-in-out infinite',
|
||
}} />
|
||
{online}
|
||
</div>
|
||
|
||
<button
|
||
onClick={onClose}
|
||
style={iconBtn}
|
||
onMouseEnter={(e) => {
|
||
e.currentTarget.style.background = 'rgba(255,255,255,0.12)';
|
||
e.currentTarget.style.borderColor = CHAT.borderHover;
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
e.currentTarget.style.background = 'rgba(255,255,255,0.04)';
|
||
e.currentTarget.style.borderColor = CHAT.border;
|
||
}}
|
||
>✕</button>
|
||
</div>
|
||
|
||
{/* === Список сообщений === */}
|
||
<div ref={listRef} style={{
|
||
flex: 1, overflowY: 'auto',
|
||
padding: '12px 14px',
|
||
display: 'flex', flexDirection: 'column', gap: 6,
|
||
scrollBehavior: 'smooth',
|
||
}}>
|
||
{!loaded ? (
|
||
<div style={{
|
||
margin: 'auto', textAlign: 'center', color: CHAT.textMuted,
|
||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10,
|
||
}}>
|
||
<div style={{
|
||
width: 22, height: 22,
|
||
border: `3px solid ${CHAT.accentBg}`,
|
||
borderTopColor: CHAT.accent,
|
||
borderRadius: '50%',
|
||
animation: 'chatSpin 0.8s linear infinite',
|
||
}} />
|
||
<div style={{ fontSize: 12, fontWeight: 600 }}>Подключение…</div>
|
||
</div>
|
||
) : messages.filter(m => !m.is_deleted).length === 0 ? (
|
||
<div style={{
|
||
margin: 'auto', padding: '24px 18px',
|
||
textAlign: 'center', color: CHAT.textMuted,
|
||
background: 'rgba(51,87,255,0.06)',
|
||
borderRadius: 14,
|
||
border: `1px dashed ${CHAT.border}`,
|
||
maxWidth: 240,
|
||
}}>
|
||
<div style={{
|
||
fontSize: 32, marginBottom: 8,
|
||
animation: 'chatFloat 3s ease-in-out infinite',
|
||
}}>💭</div>
|
||
<div style={{ fontSize: 13, fontWeight: 700, color: CHAT.text, marginBottom: 4 }}>
|
||
В чате пока тихо
|
||
</div>
|
||
<div style={{ fontSize: 11 }}>Напиши первым!</div>
|
||
</div>
|
||
) : (
|
||
messages.filter(m => !m.is_deleted).map((m, i) => (
|
||
<ChatMessage key={m.id} m={m} myUserId={userId} />
|
||
))
|
||
)}
|
||
</div>
|
||
|
||
{/* === Footer с инпутом === */}
|
||
<div style={{
|
||
borderTop: `1px solid ${CHAT.border}`,
|
||
padding: 12,
|
||
background: 'rgba(15,19,38,0.6)',
|
||
flexShrink: 0,
|
||
}}>
|
||
{muteInfo && (
|
||
<MuteBanner muteInfo={muteInfo} onExpire={() => setMuteInfo(null)} />
|
||
)}
|
||
{error && !muteInfo && (
|
||
<div style={{
|
||
background: CHAT.dangerBg,
|
||
border: `1px solid ${CHAT.danger}55`,
|
||
borderRadius: 10,
|
||
padding: '8px 12px',
|
||
marginBottom: 10,
|
||
fontSize: 12, color: CHAT.danger, fontWeight: 600,
|
||
animation: 'chatFadeIn 220ms ease',
|
||
}}>
|
||
⚠️ {error}
|
||
</div>
|
||
)}
|
||
{!userId ? (
|
||
<div style={{
|
||
padding: '12px 14px', textAlign: 'center',
|
||
background: CHAT.accentBg,
|
||
border: `1px solid ${CHAT.accent}55`,
|
||
borderRadius: 12, fontSize: 12, color: CHAT.text, fontWeight: 600,
|
||
}}>
|
||
🔒 Войди, чтобы общаться в чате
|
||
</div>
|
||
) : (
|
||
<ChatInput
|
||
text={text}
|
||
onChange={(v) => setText(v.slice(0, 300))}
|
||
onSend={send}
|
||
onKeyDown={onKeyDown}
|
||
disabled={submitting || !!muteInfo}
|
||
muteInfo={muteInfo}
|
||
/>
|
||
)}
|
||
</div>
|
||
<EmailConfirmNotice
|
||
open={emailNotice}
|
||
onClose={() => setEmailNotice(false)}
|
||
action="писать в чате"
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// === Стили / палитра / keyframes ===
|
||
const CHAT = {
|
||
bgPanel: 'rgba(20, 24, 45, 0.92)',
|
||
bgInput: 'rgba(15, 19, 38, 0.85)',
|
||
bgBubble: 'rgba(255, 255, 255, 0.05)',
|
||
bgBubbleMine: 'linear-gradient(135deg, rgba(51,87,255,0.32) 0%, rgba(30,45,165,0.28) 100%)',
|
||
border: 'rgba(255, 255, 255, 0.10)',
|
||
borderHover:'rgba(255, 255, 255, 0.18)',
|
||
text: '#f1f5fb',
|
||
textMuted: 'rgba(241, 245, 251, 0.62)',
|
||
textDim: 'rgba(241, 245, 251, 0.42)',
|
||
accent: '#3357ff',
|
||
accentBg: 'rgba(51, 87, 255, 0.18)',
|
||
accentHover:'rgba(51, 87, 255, 0.28)',
|
||
success: '#22d97a',
|
||
successBg: 'rgba(34, 217, 122, 0.18)',
|
||
warning: '#ffc857',
|
||
danger: '#ff6f7a',
|
||
dangerBg: 'rgba(255, 111, 122, 0.16)',
|
||
gradientBrand: 'linear-gradient(135deg, #3357ff 0%, #1e2da5 100%)',
|
||
font: '"Roboto Condensed", system-ui, -apple-system, sans-serif',
|
||
};
|
||
|
||
const CHAT_KEYFRAMES = `
|
||
@keyframes chatFadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
||
@keyframes chatSlideIn { from { opacity: 0; transform: translateX(-12px) scale(0.96); } to { opacity: 1; transform: translateX(0) scale(1); } }
|
||
@keyframes chatPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||
@keyframes chatSpin { to { transform: rotate(360deg); } }
|
||
@keyframes chatFloat { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
|
||
`;
|
||
|
||
/** Цвет аватарки/ника — детерминирован по user_id (HSL). */
|
||
function colorForUser(uid) {
|
||
if (uid == null) return CHAT.accent;
|
||
const h = (uid * 137) % 360;
|
||
return `hsl(${h}, 70%, 62%)`;
|
||
}
|
||
|
||
const ChatInput = ({ text, onChange, onSend, onKeyDown, disabled, muteInfo }) => {
|
||
const [focus, setFocus] = useState(false);
|
||
const [hovered, setHovered] = useState(false);
|
||
const canSend = !disabled && text.trim();
|
||
return (
|
||
<div style={{
|
||
display: 'flex', gap: 8, alignItems: 'stretch',
|
||
}}>
|
||
<input
|
||
type="text"
|
||
value={text}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
onKeyDown={onKeyDown}
|
||
onFocus={() => setFocus(true)}
|
||
onBlur={() => setFocus(false)}
|
||
placeholder={muteInfo ? '🔇 Чат заблокирован' : 'Сообщение… (Enter)'}
|
||
disabled={disabled}
|
||
maxLength={300}
|
||
style={{
|
||
flex: 1,
|
||
background: CHAT.bgInput,
|
||
border: `1.5px solid ${focus ? CHAT.accent : CHAT.border}`,
|
||
borderRadius: 12,
|
||
padding: '10px 14px',
|
||
color: CHAT.text,
|
||
fontSize: 13, fontWeight: 500, fontFamily: CHAT.font,
|
||
outline: 'none',
|
||
opacity: muteInfo ? 0.5 : 1,
|
||
transition: 'all 150ms ease',
|
||
boxShadow: focus ? `0 0 0 3px ${CHAT.accentBg}` : 'none',
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={onSend}
|
||
disabled={!canSend}
|
||
onMouseEnter={() => setHovered(true)}
|
||
onMouseLeave={() => setHovered(false)}
|
||
style={{
|
||
padding: '0 14px', minWidth: 44,
|
||
background: canSend
|
||
? CHAT.gradientBrand
|
||
: 'rgba(255,255,255,0.06)',
|
||
color: canSend ? '#fff' : CHAT.textDim,
|
||
border: `1px solid ${canSend ? 'transparent' : CHAT.border}`,
|
||
borderRadius: 12,
|
||
fontSize: 16, fontWeight: 800,
|
||
cursor: canSend ? 'pointer' : 'not-allowed',
|
||
fontFamily: CHAT.font,
|
||
transition: 'all 200ms ease',
|
||
boxShadow: canSend
|
||
? (hovered
|
||
? '0 8px 20px rgba(51,87,255,0.55)'
|
||
: '0 4px 12px rgba(51,87,255,0.35)')
|
||
: 'none',
|
||
transform: hovered && canSend ? 'translateY(-1px)' : 'none',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
}}
|
||
title="Отправить"
|
||
>
|
||
➤
|
||
</button>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const ChatMessage = ({ m, myUserId }) => {
|
||
const isMine = myUserId === m.user_id;
|
||
const userColor = colorForUser(m.user_id);
|
||
const initial = (m.username || '?').slice(0, 1).toUpperCase();
|
||
|
||
return (
|
||
<div style={{
|
||
display: 'flex', gap: 8, alignItems: 'flex-start',
|
||
flexDirection: isMine ? 'row-reverse' : 'row',
|
||
animation: 'chatFadeIn 220ms ease',
|
||
}}>
|
||
{/* Аватар-кружок с инициалом */}
|
||
<div style={{
|
||
width: 28, height: 28, flexShrink: 0,
|
||
borderRadius: '50%',
|
||
background: isMine ? CHAT.gradientBrand : userColor,
|
||
color: '#fff',
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontSize: 12, fontWeight: 800,
|
||
boxShadow: isMine
|
||
? '0 4px 10px rgba(51,87,255,0.40)'
|
||
: '0 2px 6px rgba(0,0,0,0.35)',
|
||
marginTop: 2,
|
||
}}>
|
||
{initial}
|
||
</div>
|
||
|
||
{/* Пузырь сообщения */}
|
||
<div style={{
|
||
background: isMine ? CHAT.bgBubbleMine : CHAT.bgBubble,
|
||
border: `1px solid ${isMine ? 'rgba(51,87,255,0.35)' : CHAT.border}`,
|
||
borderRadius: 14,
|
||
borderTopRightRadius: isMine ? 4 : 14,
|
||
borderTopLeftRadius: isMine ? 14 : 4,
|
||
padding: '6px 10px 8px',
|
||
maxWidth: '78%',
|
||
minWidth: 0,
|
||
}}>
|
||
<div style={{
|
||
display: 'flex', alignItems: 'baseline', gap: 6,
|
||
flexDirection: isMine ? 'row-reverse' : 'row',
|
||
}}>
|
||
<a
|
||
href={`https://rublox.pro/app/profile/${m.user_id}`}
|
||
style={{
|
||
fontSize: 11, fontWeight: 800, textDecoration: 'none',
|
||
color: isMine ? '#fff' : userColor,
|
||
letterSpacing: 0.1,
|
||
}}
|
||
>
|
||
{isMine ? 'Ты' : (m.username || `#${m.user_id}`)}
|
||
</a>
|
||
<span style={{
|
||
fontSize: 9, color: CHAT.textDim, fontWeight: 600,
|
||
}}>
|
||
{formatTimeShort(m.created_at)}
|
||
</span>
|
||
</div>
|
||
<div style={{
|
||
fontSize: 13, color: CHAT.text,
|
||
whiteSpace: 'pre-wrap', wordBreak: 'break-word',
|
||
marginTop: 2, lineHeight: 1.4,
|
||
}}>
|
||
{m.text}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const MuteBanner = ({ muteInfo, onExpire }) => {
|
||
const [now, setNow] = useState(Date.now());
|
||
useEffect(() => {
|
||
const t = setInterval(() => setNow(Date.now()), 1000);
|
||
return () => clearInterval(t);
|
||
}, []);
|
||
if (!muteInfo?.banned_until) return null;
|
||
const untilMs = new Date(muteInfo.banned_until.replace(' ', 'T')).getTime();
|
||
const remaining = untilMs - now;
|
||
if (remaining <= 0) {
|
||
setTimeout(onExpire, 0);
|
||
return null;
|
||
}
|
||
const fmt = formatRemaining(remaining);
|
||
return (
|
||
<div style={{
|
||
background: CHAT.dangerBg,
|
||
border: `1px solid ${CHAT.danger}55`,
|
||
borderRadius: 12,
|
||
padding: '10px 12px',
|
||
marginBottom: 10,
|
||
fontSize: 12, color: CHAT.danger,
|
||
display: 'flex', alignItems: 'center', gap: 10,
|
||
}}>
|
||
<span style={{ fontSize: 18 }}>🔇</span>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontWeight: 800 }}>
|
||
Мьют чата · <span style={{ fontVariantNumeric: 'tabular-nums' }}>{fmt}</span>
|
||
</div>
|
||
{muteInfo.last_reason && (
|
||
<div style={{
|
||
fontSize: 11, color: CHAT.danger, marginTop: 2, opacity: 0.85,
|
||
}}>
|
||
Причина: {translateReason(muteInfo.last_reason)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
function formatRemaining(ms) {
|
||
const s = Math.ceil(ms / 1000);
|
||
if (s < 60) return `${s}с`;
|
||
const m = Math.floor(s / 60);
|
||
if (m < 60) return `${m}м ${s % 60}с`;
|
||
const h = Math.floor(m / 60);
|
||
return `${h}ч ${m % 60}м`;
|
||
}
|
||
|
||
function formatMuteMessage(data) {
|
||
const r = translateReason(data.reason);
|
||
if (data.banned_until) return `🔇 Чат заблокирован до ${data.banned_until} (${r})`;
|
||
return `🔇 ${r}`;
|
||
}
|
||
|
||
function translateReason(r) {
|
||
if (r === 'profanity') return 'мат';
|
||
if (r === 'links') return 'ссылки/спам';
|
||
if (r === 'flood') return 'флуд';
|
||
if (r === 'caps') return 'CAPS';
|
||
if (r === 'manual') return 'вручную модератором';
|
||
return r || 'нарушение правил';
|
||
}
|
||
|
||
const iconBtn = {
|
||
background: 'rgba(255,255,255,0.04)',
|
||
border: `1px solid ${CHAT.border}`,
|
||
borderRadius: 8,
|
||
color: CHAT.text,
|
||
width: 28, height: 28,
|
||
cursor: 'pointer',
|
||
fontSize: 13, fontWeight: 700,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
fontFamily: CHAT.font,
|
||
transition: 'all 150ms ease',
|
||
};
|
||
|
||
export default KubikonChatPanel;
|