import React, { useState, useEffect, useRef } from 'react';
import {
BarChart2,
Users,
MessageSquare,
ShieldAlert,
Mic,
LogOut,
LogIn,
Activity,
Search,
Settings,
Lock,
Volume2,
RefreshCw,
Clock,
Trash2,
UserCheck
} from 'lucide-react';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
RadialLinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js';
import { Line, Radar } from 'react-chartjs-2';
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
RadialLinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
);
// Fallback to local port 3000 in development, or use VITE_API_URL if configured, otherwise same origin
const API_BASE = import.meta.env.VITE_API_URL || (import.meta.env.DEV ? 'http://localhost:3000' : '');
const TWITCH_CHANNEL = 'winx_prinx';
// 3D Galaxy Chatter Orbit Visualization
function ChatterOrbit({ chatters }) {
const canvasRef = useRef(null);
const containerRef = useRef(null);
const particlesRef = useRef(new Map());
const avatarRef = useRef(null);
useEffect(() => {
// Load Avatar via backend proxy (avoids CORS issues with decapi.me)
if (!avatarRef.current) {
const img = new Image();
img.src = `${API_BASE}/api/avatar/${TWITCH_CHANNEL}`;
img.onload = () => { avatarRef.current = img; };
img.onerror = () => console.warn('[ChatterOrbit] Avatar load failed');
}
}, []);
useEffect(() => {
const safeChatters = Array.isArray(chatters) ? chatters : [];
safeChatters.forEach(c => {
const username = c.username || c.display_name || 'unknown';
if (!particlesRef.current.has(username)) {
const isLurker = !c.has_chatted && !(c.message_count > 0);
// All particles go on the ring — tight band around ring radius ±8%
const ringNoise = (Math.random() - 0.5) * 0.16;
const radiusNorm = 1.0 + ringNoise;
particlesRef.current.set(username, {
username,
displayName: c.display_name || username,
angle: Math.random() * Math.PI * 2,
radiusNorm,
speed: (0.0003 + Math.random() * 0.0004) * (Math.random() > 0.5 ? 1 : -1),
isMod: c.is_mod,
isSub: c.is_sub,
isLurker,
messageCount: c.message_count || 0,
isStreamer: username.toLowerCase() === 'winx_prinx',
});
} else {
const p = particlesRef.current.get(username);
const isLurker = !c.has_chatted && !(c.message_count > 0);
p.isLurker = isLurker;
p.messageCount = c.message_count || p.messageCount;
p.isMod = c.is_mod || p.isMod;
p.isSub = c.is_sub || p.isSub;
}
});
}, [chatters]);
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext('2d');
let animationId;
const HEIGHT = 320;
const resize = () => {
const dpr = window.devicePixelRatio || 1;
const rect = container.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = HEIGHT * dpr;
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${HEIGHT}px`;
ctx.scale(dpr, dpr);
};
resize();
window.addEventListener('resize', resize);
let hoverName = null;
let hoverX = 0;
let hoverY = 0;
let mouseX = -9999;
let mouseY = -9999;
const handleMouseMove = (e) => {
const rect = canvas.getBoundingClientRect();
mouseX = e.clientX - rect.left;
mouseY = e.clientY - rect.top;
};
const handleMouseLeave = () => { mouseX = -9999; mouseY = -9999; };
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseleave', handleMouseLeave);
const animate = () => {
const width = canvas.width / (window.devicePixelRatio || 1);
const height = HEIGHT;
const cx = width / 2;
const cy = height / 2;
// Ring fills most of the canvas
const RX = width * 0.44;
const RY = height * 0.36;
const TILT = RY / RX;
// Solid near-black background
ctx.fillStyle = '#08080f';
ctx.fillRect(0, 0, width, height);
const particles = Array.from(particlesRef.current.values());
hoverName = null;
// Update positions
particles.forEach(p => {
p.angle += p.speed;
const r = p.radiusNorm * RX;
p.x = cx + Math.cos(p.angle) * r;
p.y = cy + Math.sin(p.angle) * r * TILT;
p.depth = (Math.sin(p.angle) * TILT + TILT) / (2 * TILT); // 0=back, 1=front
if (p.isStreamer) {
p.baseSize = 5;
p.color = '#A370F7';
} else if (p.isLurker) {
p.baseSize = 1;
p.color = null;
} else {
p.baseSize = Math.min(4, 1.5 + Math.log10((p.messageCount || 0) + 1) * 1.2);
p.color = p.isMod ? '#00F5D4' : p.isSub ? '#FF007F' : '#c8c8d8';
}
p.drawSize = p.baseSize;
});
// Sort back-to-front
particles.sort((a, b) => a.depth - b.depth);
// Draw particles
particles.forEach(p => {
const alpha = p.isLurker
? 0.15 + p.depth * 0.45
: 0.5 + p.depth * 0.5;
let color;
if (p.isLurker) {
const brightness = Math.round(160 + p.depth * 60);
color = `rgba(${brightness},${brightness},${brightness + 20},${alpha})`;
} else {
color = p.color;
}
const dist = Math.hypot(mouseX - p.x, mouseY - p.y);
if (dist < p.drawSize + 6) {
hoverName = p.displayName;
hoverX = p.x;
hoverY = p.y;
p.drawSize = Math.max(p.drawSize * 2.5, 5);
}
ctx.globalAlpha = p.isLurker ? 1 : alpha;
ctx.shadowBlur = 0;
if (!p.isLurker && (p.isMod || p.isSub || p.isStreamer)) {
ctx.shadowBlur = 6;
ctx.shadowColor = color;
}
ctx.beginPath();
ctx.arc(p.x, p.y, p.drawSize, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
});
ctx.globalAlpha = 1;
ctx.shadowBlur = 0;
// Draw Avatar on top
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, 26, 0, Math.PI * 2);
ctx.closePath();
ctx.shadowColor = '#9147ff';
ctx.shadowBlur = 24;
ctx.strokeStyle = 'rgba(145, 71, 255, 0.9)';
ctx.lineWidth = 2.5;
ctx.stroke();
ctx.shadowBlur = 0;
ctx.clip();
if (avatarRef.current) {
ctx.drawImage(avatarRef.current, cx - 26, cy - 26, 52, 52);
} else {
ctx.fillStyle = '#6441A5';
ctx.fill();
}
ctx.restore();
// Hover tooltip
if (hoverName) {
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
ctx.font = 'bold 11px Inter, sans-serif';
const tw = ctx.measureText(hoverName).width;
const tx = Math.min(hoverX + 12, width - tw - 20);
const ty = hoverY - 30 < 5 ? hoverY + 20 : hoverY - 30;
ctx.fillStyle = 'rgba(18,18,24,0.92)';
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(tx - 4, ty, tw + 16, 22, 5);
else ctx.rect(tx - 4, ty, tw + 16, 22);
ctx.fill();
ctx.strokeStyle = 'rgba(145,71,255,0.5)';
ctx.lineWidth = 1;
ctx.stroke();
ctx.fillStyle = '#efeff1';
ctx.fillText(hoverName, tx + 4, ty + 15);
}
animationId = requestAnimationFrame(animate);
};
animate();
return () => {
cancelAnimationFrame(animationId);
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mouseleave', handleMouseLeave);
window.removeEventListener('resize', resize);
};
}, []);
return (
Зрители
Чатеры
Сабы
Модеры
);
}
export default function App() {
const [activeTab, setActiveTab] = useState('overview');
const [streams, setStreams] = useState([]);
const [selectedStreamId, setSelectedStreamId] = useState('all');
const [auth, setAuth] = useState({ loggedIn: false, user: null });
const [twitchConfigured, setTwitchConfigured] = useState(true);
const [loading, setLoading] = useState(true);
const [serverStatus, setServerStatus] = useState('connecting'); // 'connecting' | 'online' | 'offline'
// Statistics State
const [chatters, setChatters] = useState([]);
const [chattersSearch, setChattersSearch] = useState('');
const [voiceWords, setVoiceWords] = useState([]);
const [chatWords, setChatWords] = useState([]);
const [wordType, setWordType] = useState('voice'); // 'voice' or 'chat'
const [activityData, setActivityData] = useState([]);
const [modActions, setModActions] = useState([]);
const [modSummary, setModSummary] = useState([]);
// RPG Mod Profiles
const [modProfiles, setModProfiles] = useState([]);
const [selectedMod, setSelectedMod] = useState(null);
const [syncingVods, setSyncingVods] = useState(false);
const [isRacing, setIsRacing] = useState(false);
// Admin State
const [adminStats, setAdminStats] = useState(null);
const [loadingAdminStats, setLoadingAdminStats] = useState(false);
const [cleaningStreams, setCleaningStreams] = useState(false);
const [adminSelectedStreamId, setAdminSelectedStreamId] = useState('');
const [editTitle, setEditTitle] = useState('');
const [editCategory, setEditCategory] = useState('');
const [savingMetadata, setSavingMetadata] = useState(false);
const [deletingStream, setDeletingStream] = useState(false);
// Auto-refresh timer for live stream
const pollIntervalRef = useRef(null);
const raceIntervalRef = useRef(null);
const fetchAdminStats = async () => {
setLoadingAdminStats(true);
try {
const res = await fetch(`${API_BASE}/api/admin/stats`, { credentials: 'include' });
if (res.status === 200) {
const data = await res.json();
if (data.success) {
setAdminStats(data.stats);
}
}
} catch (e) {
console.error('Error fetching admin stats:', e);
} finally {
setLoadingAdminStats(false);
}
};
// Fetch admin stats when switching to admin tab
useEffect(() => {
if (activeTab === 'admin') {
fetchAdminStats();
if (streams && streams.length > 0 && !adminSelectedStreamId) {
setAdminSelectedStreamId(streams[0].id.toString());
}
}
}, [activeTab, streams]);
// Sync edit fields when admin selection changes
useEffect(() => {
if (adminSelectedStreamId) {
const stream = streams.find(s => s.id === parseInt(adminSelectedStreamId));
if (stream) {
setEditTitle(stream.title || '');
setEditCategory(stream.category || '');
} else {
setEditTitle('');
setEditCategory('');
}
} else {
setEditTitle('');
setEditCategory('');
}
}, [adminSelectedStreamId, streams]);
const checkServerHealth = async () => {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 6000);
const res = await fetch(`${API_BASE}/api/health`, { signal: controller.signal });
clearTimeout(timeoutId);
if (res.ok) {
setServerStatus('online');
return true;
}
} catch (e) {
console.warn('Backend connection healthcheck failed:', e);
}
return false;
};
const pingServer = async () => {
const ok = await checkServerHealth();
if (ok) {
fetchAuthStatus();
fetchStreams();
} else {
setServerStatus('offline');
}
};
// Check Auth on Mount & Server connection status loop
useEffect(() => {
let active = true;
let timer = null;
const runPingLoop = async () => {
if (!active) return;
const ok = await checkServerHealth();
if (active) {
if (ok) {
fetchAuthStatus();
fetchStreams();
timer = setTimeout(runPingLoop, 30000);
} else {
setServerStatus('offline');
timer = setTimeout(runPingLoop, 6000);
}
}
};
runPingLoop();
return () => {
active = false;
if (timer) clearTimeout(timer);
};
}, []);
// Fetch stats when selected stream changes
useEffect(() => {
if (selectedStreamId && serverStatus === 'online') {
fetchAllStats();
// Setup polling if the selected stream is live (end_time is null)
const selectedStream = streams.find(s => s.id === parseInt(selectedStreamId));
const isLive = selectedStream && !selectedStream.end_time;
if (isLive || selectedStreamId === 'all') {
startPolling();
} else {
stopPolling();
}
}
return () => stopPolling();
}, [selectedStreamId, streams, serverStatus]);
const startPolling = () => {
stopPolling();
pollIntervalRef.current = setInterval(() => {
fetchAllStats(false); // poll silently without loading spinner
}, 15000); // refresh every 15s
};
const stopPolling = () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
};
// Optimize polling based on page visibility (pause if tab is backgrounded)
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
stopPolling();
} else if (document.visibilityState === 'visible' && selectedStreamId && serverStatus === 'online') {
fetchAllStats(false);
const selectedStream = streams.find(s => s.id === parseInt(selectedStreamId));
const isLive = selectedStream && !selectedStream.end_time;
if (isLive || selectedStreamId === 'all') {
startPolling();
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [selectedStreamId, streams, serverStatus]);
const fetchAuthStatus = async () => {
try {
const res = await fetch(`${API_BASE}/api/auth/status`, { credentials: 'include' });
const data = await res.json();
setTwitchConfigured(data.twitchConfigured !== false);
if (data.loggedIn) {
setAuth({ loggedIn: true, user: data.user });
} else {
setAuth({ loggedIn: false, user: null });
}
} catch (e) {
console.error('Auth check failed:', e);
}
};
const fetchStreams = async () => {
try {
const res = await fetch(`${API_BASE}/api/streams`);
const data = await res.json();
setStreams(data);
} catch (e) {
console.error('Failed to fetch streams:', e);
}
};
const getUrl = (endpoint, params = {}) => {
if (selectedStreamId !== 'all') {
params.stream_id = selectedStreamId;
}
const query = new URLSearchParams(params).toString();
return `${API_BASE}${endpoint}${query ? '?' + query : ''}`;
};
const fetchAllStats = async (showSpinner = true) => {
if (showSpinner) setLoading(true);
try {
// 1. Top Chatters
const chattersRes = await fetch(getUrl('/api/stats/chatters'));
const chattersData = await chattersRes.json();
setChatters(Array.isArray(chattersData) ? chattersData : []);
// 2. Spoken voice words
const voiceRes = await fetch(getUrl('/api/stats/words', { type: 'voice', limit: 60 }));
const voiceData = await voiceRes.json();
setVoiceWords(Array.isArray(voiceData) ? voiceData : []);
// 3. Written chat words by streamer
const chatWordsRes = await fetch(getUrl('/api/stats/words', { type: 'chat', limit: 60 }));
const chatWordsData = await chatWordsRes.json();
setChatWords(Array.isArray(chatWordsData) ? chatWordsData : []);
// 4. Activity chart (only if stream is selected)
if (selectedStreamId !== 'all') {
const activityRes = await fetch(getUrl('/api/stats/activity'));
const activityData = await activityRes.json();
setActivityData(Array.isArray(activityData) ? activityData : []);
} else {
setActivityData([]);
}
// 5. Fetch moderator actions if authorized
if (!twitchConfigured || (auth.loggedIn && (auth.user?.role === 'streamer' || auth.user?.role === 'moderator' || auth.user?.role === 'admin'))) {
const modRes = await fetch(getUrl('/api/stats/moderators', { limit: 100 }), { credentials: 'include' });
const modData = await modRes.json();
setModActions(Array.isArray(modData) ? modData : []);
const summaryRes = await fetch(getUrl('/api/stats/moderators/summary'), { credentials: 'include' });
const summaryData = await summaryRes.json();
setModSummary(Array.isArray(summaryData) ? summaryData : []);
const profilesRes = await fetch(getUrl('/api/stats/moderators/profiles'), { credentials: 'include' });
const profilesData = await profilesRes.json();
setModProfiles(Array.isArray(profilesData) ? profilesData : []);
if (profilesData && profilesData.length > 0) {
setSelectedMod(prev => prev ? profilesData.find(p => p.moderator === prev.moderator) || profilesData[0] : profilesData[0]);
}
}
} catch (e) {
console.error('Error fetching statistics:', e);
} finally {
if (showSpinner) setLoading(false);
}
};
const startGraphRace = () => {
if (activityData.length === 0) return;
if (raceIntervalRef.current) clearInterval(raceIntervalRef.current);
const originalData = [...activityData];
setActivityData([]);
setIsRacing(true);
let currentIndex = 0;
raceIntervalRef.current = setInterval(() => {
if (currentIndex >= originalData.length) {
clearInterval(raceIntervalRef.current);
setIsRacing(false);
return;
}
setActivityData(prev => [...prev, originalData[currentIndex]]);
currentIndex++;
}, 150);
};
const handleLogin = () => {
window.location.href = `${API_BASE}/api/auth/twitch`;
};
const handleLogout = async () => {
try {
await fetch(`${API_BASE}/api/auth/logout`, { credentials: 'include' });
setAuth({ loggedIn: false, user: null });
setModActions([]);
setModSummary([]);
setActiveTab('overview');
} catch (e) {
console.error('Logout failed:', e);
}
};
const activeStream = streams.find(s => !s.end_time);
// Helper to format dates
const formatDate = (isoString) => {
if (!isoString) return '';
const date = new Date(isoString);
return date.toLocaleString('ru-RU', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
};
// KPI Metrics Calculation
const totalMessages = chatters.reduce((acc, curr) => acc + curr.message_count, 0);
const totalUniqueChatters = chatters.length;
const topChatter = chatters[0]?.display_name || '—';
const totalVoiceWords = voiceWords.reduce((acc, curr) => acc + curr.word_count, 0);
const totalModActionsCount = modActions.length;
const statsSummary = {
messages: totalMessages,
uniqueChatters: totalUniqueChatters,
mostActiveChatter: topChatter,
voiceWordsCount: totalVoiceWords,
modActionsCount: totalModActionsCount
};
// Chart Setup
const chartLabels = activityData.map(d => {
const date = new Date(d.timestamp);
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
});
const chartDataConfig = {
labels: chartLabels.length > 0 ? chartLabels : ['Нет данных'],
datasets: [
{
fill: true,
label: 'Сообщений в 5 мин',
data: activityData.map(d => d.message_count),
borderColor: '#9146FF',
backgroundColor: 'rgba(145, 70, 255, 0.1)',
tension: 0.4,
pointBackgroundColor: '#9146FF',
pointBorderColor: '#fff',
pointHoverBackgroundColor: '#fff',
pointHoverBorderColor: '#9146FF',
},
],
};
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: '#18181B',
borderColor: '#2F2F35',
borderWidth: 1,
titleColor: '#EFeff1',
bodyColor: '#ADADB8',
padding: 10,
displayColors: false,
}
},
scales: {
x: {
grid: {
color: '#2F2F35',
},
ticks: {
color: '#ADADB8',
font: { size: 10 }
}
},
y: {
grid: {
color: '#2F2F35',
},
ticks: {
color: '#ADADB8',
font: { size: 10 }
}
}
}
};
return (
{/* Header */}
winx_prinx
Analytics
{/* Live stream badge */}
{activeStream ? (
В ЭФИРЕ
) : (
ОФФЛАЙН
)}
{/* Stream Selector — compact, in header */}
setSelectedStreamId(e.target.value)}
>
За всё время
{streams.map(s => (
{s.title} ({formatDate(s.start_time)}{!s.end_time ? ' · Live' : ''})
))}
{/* Navigation Tabs */}
setActiveTab('overview')}
>
Обзор
setActiveTab('chatters')}
>
Зрители
setActiveTab('words')}
>
Словарь частот
setActiveTab('moderator')}
>
Модерация
{(auth.user?.role === 'streamer' || auth.user?.role === 'admin' || !twitchConfigured) && (
setActiveTab('admin')}
>
Админка
)}
{/* Refresh + User Profile */}
{ fetchStreams(); fetchAllStats(); }}
disabled={loading}
title="Обновить данные"
>
Обновить
{!twitchConfigured ? (
Локальный режим
) : auth.loggedIn ? (
<>
{auth.user.displayName}
{auth.user?.role === 'streamer' ? 'Стример' : auth.user?.role === 'admin' ? 'Админ' : auth.user?.role === 'moderator' ? 'Модератор' : 'Зритель'}
Выйти
>
) : (
Войти через Twitch
)}
{/* Main Dashboard Panel */}
{serverStatus !== 'online' ? (
{serverStatus === 'connecting' ? (
Подключение к серверу аналитики...
Сервер бэкенда в облаке может находиться в спящем режиме. Пробуждение сервера занимает около 30–50 секунд. Пожалуйста, подождите.
) : (
⚠️
Бэкенд недоступен
Не удалось установить соединение с сервером. Возможно, сервер временно отключен, обновляется или ваш провайдер блокирует подключение.
{
setServerStatus('connecting');
pingServer();
}}
style={{
padding: '0.6rem 1.5rem',
fontSize: '0.85rem',
backgroundColor: 'var(--color-brand)',
color: '#fff',
border: 'none',
borderRadius: 'var(--radius-md)',
cursor: 'pointer',
fontWeight: 600,
transition: 'background-color 0.2s',
marginTop: '0.5rem'
}}
>
Повторить попытку
)}
) : loading ? (
) : (
<>
{/* OVERVIEW TAB */}
{activeTab === 'overview' && (
{/* Stats row */}
Сообщений в чате
{statsSummary.messages.toLocaleString()}
Уникальных зрителей
{statsSummary.uniqueChatters.toLocaleString()}
Самый активный чатер
15 ? '1.05rem' : '1.25rem',
letterSpacing: '-0.02em',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{statsSummary.mostActiveChatter || '—'}
Распознано слов (голос)
{statsSummary.voiceWordsCount.toLocaleString()}
{/* Activity graph */}
Активность чата
выберите конкретный стрим для графика
{activityData.length > 0 && (
{isRacing ? 'Гонка идет...' : 'Запустить гонку'}
)}
{selectedStreamId === 'all' ? (
Для просмотра временного графика выберите конкретный стрим в верхнем меню
) : activityData.length === 0 ? (
В этом стриме пока не было сообщений
) : (
)}
{/* Quick Words list */}
Голос стримера (Топ)
{voiceWords.length === 0 ? (
Слова пока не распознаны. Запустите local_worker на ПК.
) : (
{voiceWords.slice(0, 7).map((w, idx) => (
#{idx+1} {w.word}
{w.word_count} раз
))}
)}
)}
{/* CHATTERS TAB */}
{activeTab === 'chatters' && (
{chatters.length === 0 ? (
Нет данных о зрителях для этого периода
) : (
Место
Никнейм
Роли
Сообщений
{chatters
.filter(c => {
const disp = c.display_name || c.username || '';
const user = c.username || '';
return disp.toLowerCase().includes(chattersSearch.toLowerCase()) ||
user.toLowerCase().includes(chattersSearch.toLowerCase());
})
.map((c, idx) => (
#{idx + 1}
{c.display_name || c.username || '—'}
{(c.username || '').toLowerCase() === TWITCH_CHANNEL && (
Стример
)}
{c.is_mod ? Мод : null}
{c.is_sub ? Саб : null}
{c.message_count.toLocaleString()}
))}
)}
)}
{/* WORDS TAB */}
{activeTab === 'words' && (
Частотный словарь
setWordType('voice')}
style={{ fontSize: '0.8rem', padding: '0.4rem 0.8rem' }}
>
Из Голоса (Whisper)
setWordType('chat')}
style={{ fontSize: '0.8rem', padding: '0.4rem 0.8rem' }}
>
Из Чата (Текстом)
{wordType === 'voice'
? 'Слова, которые стример произнес в микрофон (распознанные через Whisper на локальном ПК).'
: 'Слова, которые зрители написали в текстовый чат Twitch.'}
{(() => {
const currentWordsList = wordType === 'voice'
? (Array.isArray(voiceWords) ? voiceWords : [])
: (Array.isArray(chatWords) ? chatWords : []);
if (currentWordsList.length === 0) {
return (
Нет собранных слов для выбранного стрима
);
}
const counts = currentWordsList.map(x => x.word_count);
const maxCount = Math.max(...counts, 1);
const minCount = Math.min(...counts, 1);
// Helper for Russian pluralization
const getRussianPlural = (count) => {
const lastDigit = count % 10;
const lastTwoDigits = count % 100;
if (lastTwoDigits >= 11 && lastTwoDigits <= 19) {
return 'раз';
}
if (lastDigit === 1) {
return 'раз';
}
if (lastDigit >= 2 && lastDigit <= 4) {
return 'раза';
}
return 'раз';
};
// Color assignment based on word frequency: purple gradient
const getWordColor = (count) => {
const scale = maxCount === minCount ? 1 : (count - minCount) / (maxCount - minCount);
// Scale saturation from 35% (desaturated/white-lavender) to 100% (saturated purple)
const sat = Math.round(35 + scale * 65);
// Scale lightness from 92% (light/white-ish) to 60% (vivid deep purple)
const light = Math.round(92 - scale * 32);
return `hsl(265, ${sat}%, ${light}%)`;
};
// Layout calculations with collision avoidance on stretched elliptical spiral
const placedBoxes = [];
const laidOutWords = [];
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
const stretchX = isMobile ? 1.4 : 2.8;
const stretchY = 1.0;
const paddingX = isMobile ? 6 : 12;
const paddingY = isMobile ? 4 : 8;
const fontScale = isMobile ? 0.45 : 1.0;
const remToPx = 16;
const centerGroupThreshold = wordType === 'chat' ? 250 : 500;
// Identify center group: words with count within threshold of the max count,
// but ONLY if the top count is >= threshold. Also cap center group size to at most 3 words.
const centerGroupWords = currentWordsList.filter((w, idx) =>
idx === 0 || (maxCount >= centerGroupThreshold && (maxCount - w.word_count) < centerGroupThreshold && idx < 3)
);
const otherWordsList = currentWordsList.filter((w, idx) =>
!(idx === 0 || (maxCount >= centerGroupThreshold && (maxCount - w.word_count) < centerGroupThreshold && idx < 3))
);
const maxOtherCount = otherWordsList.length > 0 ? Math.max(...otherWordsList.map(x => x.word_count)) : 1;
const minOtherCount = otherWordsList.length > 0 ? Math.min(...otherWordsList.map(x => x.word_count)) : 1;
const minCenterCount = Math.min(...centerGroupWords.map(x => x.word_count));
// Function to estimate word width factor based on wide/narrow letters
const getWordWidthFactor = (word) => {
let factor = 0;
const wideChars = /[мжшщыюяwm]/i;
const narrowChars = /[ilj1!|т]/i;
for (const char of word) {
if (wideChars.test(char)) {
factor += 0.75;
} else if (narrowChars.test(char)) {
factor += 0.35;
} else {
factor += 0.55;
}
}
return factor;
};
for (let i = 0; i < currentWordsList.length; i++) {
const w = currentWordsList[i];
const isCenterGroup = i === 0 || (maxCount >= centerGroupThreshold && (maxCount - w.word_count) < centerGroupThreshold && i < 3);
let fontSize;
let fontWeight;
let scaleValue = 0;
// Scale down the entire galaxy if the absolute max frequency is low (e.g. less than 200)
// If maxCount is 20, galaxyScale is ~0.65. If 200+, it's 1.0.
const galaxyScale = Math.min(Math.max(maxCount / 200, 0), 1.0) * 0.35 + 0.65;
if (isCenterGroup) {
// Scale font size within center group: ranges from 4.5rem to 6.2rem
const centerScale = maxCount === minCenterCount ? 1 : (w.word_count - minCenterCount) / (maxCount - minCenterCount);
fontSize = 4.5 + centerScale * 1.7;
fontWeight = '900';
scaleValue = centerScale;
} else {
// Scale font size within other words: ranges from 1.1rem to 3.2rem
const scale = maxOtherCount === minOtherCount ? 1 : (w.word_count - minOtherCount) / (maxOtherCount - minOtherCount);
scaleValue = Math.pow(scale, 1.4);
fontSize = 1.1 + scaleValue * 2.1;
fontWeight = fontSize > 2.0 ? '700' : (fontSize > 1.4 ? '600' : '500');
}
fontSize = fontSize * fontScale * galaxyScale;
// Precise width calculation based on custom width factors
const wordWidth = fontSize * getWordWidthFactor(w.word) * remToPx;
const wordHeight = fontSize * 1.1 * remToPx;
let x = 0;
let y = 0;
// Generate a stable hash for the word to randomize angle and jitter
let hash = 0;
for (let j = 0; j < w.word.length; j++) {
hash = w.word.charCodeAt(j) + ((hash << 5) - hash);
}
hash = Math.abs(hash);
// Search for position starting from r = 0.
// Since center group words are processed first, they cluster tightly around (0,0).
let found = false;
const rStep = 1.1;
for (let attempt = 0; attempt < 1200; attempt++) {
const startAngle = (hash % 100) * 0.06283; // 0 to 2*PI
const angle = startAngle + attempt * 0.15;
const r = (i === 0 && attempt === 0) ? 0 : (45 + rStep * attempt);
x = r * Math.cos(angle) * stretchX;
y = r * Math.sin(angle) * stretchY;
// Add coordinate noise/jitter (except for the absolute top word at the center)
if (r > 0) {
x += Math.sin(hash * 0.5 + attempt) * 6;
y += Math.cos(hash * 0.8 + attempt) * 4;
}
let collision = false;
for (const box of placedBoxes) {
const halfW1 = wordWidth / 2;
const halfH1 = wordHeight / 2;
const halfW2 = box.w / 2;
const halfH2 = box.h / 2;
if (Math.abs(x - box.x) < (halfW1 + halfW2 + paddingX) &&
Math.abs(y - box.y) < (halfH1 + halfH2 + paddingY)) {
collision = true;
break;
}
}
if (!collision) {
found = true;
break;
}
}
placedBoxes.push({ x: x, y: y, w: wordWidth, h: wordHeight });
const color = getWordColor(w.word_count);
// Floating parameters
const duration = 4.5 + (i % 3) + (i % 4) * 0.4; // 4.5s to 8.5s
const delay = -((i * 1.3) % 7); // negative delay to start asynchronously
const amount = 3 + (i % 4); // 3px to 6px float amount
laidOutWords.push({
word: w.word,
count: w.word_count,
x: Math.round(x),
y: Math.round(y),
fontSize: `${fontSize}rem`,
fontWeight: fontWeight,
color: color,
scale: scaleValue,
isCenterGroup: isCenterGroup,
duration: `${duration}s`,
delay: `${delay}s`,
amount: `${amount}px`
});
}
return (
{laidOutWords.map((w, idx) => (
{w.word}
{w.count} {getRussianPlural(w.count)}
))}
);
})()}
)}
{/* ADMIN TAB */}
{activeTab === 'admin' && (
Панель администратора
{/* Left: Stream management */}
Управление стримами
Выбрать стрим для редактирования или удаления:
setAdminSelectedStreamId(e.target.value)}
>
-- Выберите стрим --
{streams.map((stream) => (
{stream.title || 'Без названия'} ({new Date(stream.start_time).toLocaleDateString('ru-RU')} {new Date(stream.start_time).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })})
))}
{(() => {
const activeStream = streams.find(s => s.id === parseInt(adminSelectedStreamId));
if (!activeStream) return (
Выберите стрим в выпадающем списке выше для выполнения действий.
);
const isLive = !activeStream.end_time;
const statusLabel =
activeStream.backfill_status === 'completed' ? 'Импортирован полностью' :
activeStream.backfill_status === 'pending' ? 'В очереди воркера (ожидание)' :
activeStream.backfill_status === 'live' ? 'В эфире (live)' : activeStream.backfill_status;
const statusColor =
activeStream.backfill_status === 'completed' ? 'var(--color-success, #00f2fe)' :
activeStream.backfill_status === 'pending' ? 'var(--color-warning, #f1c40f)' : 'var(--color-primary)';
return (
ID сессии: {activeStream.id}
Статус импорта VOD: {statusLabel}
Twitch VOD ID: {activeStream.twitch_vod_id || 'Отсутствует'}
Начало: {new Date(activeStream.start_time).toLocaleString('ru-RU')}
{/* Edit Metadata */}
{/* VOD Actions (Only if VOD is present) */}
{activeStream.twitch_vod_id && (
Действия импорта VOD:
{
if (!confirm(`Запустить заполнение пропусков для стрима "${activeStream.title}"? Воркер докачает пропущенный чат и Whisper-речь.`)) return;
try {
const res = await fetch(`${API_BASE}/api/streams/reset-backfill`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ streamId: activeStream.id, mode: 'gap_fill' }),
credentials: 'include'
});
const data = await res.json();
if (data.success) {
alert('Статус сброшен на "ожидание". Воркер скоро начнет дозаполнение!');
fetchStreams();
} else {
alert(`Ошибка: ${data.error}`);
}
} catch (e) {
alert('Ошибка при запуске дозаполнения VOD.');
}
}}
>
Заполнить пропуски VOD
{
if (!confirm(`Внимание: это полностью удалит сохраненные сообщения чата и слова Whisper для стрима "${activeStream.title}" и заново запустит весь импорт VOD с 0-й секунды. Вы уверены?`)) return;
try {
const res = await fetch(`${API_BASE}/api/streams/reset-backfill`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ streamId: activeStream.id, mode: 'full_rebuild' }),
credentials: 'include'
});
const data = await res.json();
if (data.success) {
alert('Данные очищены. Стрим поставлен на полный переимпорт воркером!');
fetchStreams();
} else {
alert(`Ошибка: ${data.error}`);
}
} catch (e) {
alert('Ошибка при запуске переимпорта.');
}
}}
>
Полный переимпорт VOD
)}
{/* Delete Button */}
{
if (!confirm(`ВНИМАНИЕ: Вы уверены, что хотите полностью УДАЛИТЬ стрим "${activeStream.title}"? Это действие сотрет всю связанную статистику (чат, слова Whisper, модерацию) из базы данных навсегда и безвозвратно!`)) return;
if (!confirm(`ПОСЛЕДНЕЕ ПРЕДУПРЕЖДЕНИЕ: Вы действительно хотите стереть стрим ID ${activeStream.id} из базы? Восстановление невозможно.`)) return;
setDeletingStream(true);
try {
const res = await fetch(`${API_BASE}/api/streams/${activeStream.id}`, {
method: 'DELETE',
credentials: 'include'
});
const data = await res.json();
if (data.success) {
alert('Стрим успешно удален из базы данных!');
setAdminSelectedStreamId('');
fetchStreams();
} else {
alert(`Ошибка: ${data.error}`);
}
} catch (e) {
alert('Ошибка при удалении стрима.');
} finally {
setDeletingStream(false);
}
}}
>
Удалить стрим полностью
);
})()}
{/* Right: Global actions and Stats */}
{/* Global Actions Panel */}
Глобальные действия
{/* Twitch VOD Sync */}
Синхронизация Twitch VOD
Запросить последние 20 архивов из Twitch API.
{
setSyncingVods(true);
try {
const res = await fetch(`${API_BASE}/api/streams/sync-vods`, { method: 'POST', credentials: 'include' });
const data = await res.json();
if (data.success) {
alert(`Успешно импортировано ${data.count} стримов!`);
fetchStreams();
} else {
alert(`Ошибка: ${data.error}`);
}
} catch (e) {
alert('Ошибка при синхронизации VOD.');
} finally {
setSyncingVods(false);
}
}}
>
{syncingVods ? 'Синхронизация...' : 'Синхронизировать VOD'}
{/* Database Cleanup */}
Очистить пустые стримы
Удалить сессии с 0 сообщениями и 0 слов.
{
if (!confirm('Вы действительно хотите удалить все пустые стримы (в которых нет ни сообщений в чате, ни распознанных слов)? Это очистит тестовый мусор из списка.')) return;
setCleaningStreams(true);
try {
const res = await fetch(`${API_BASE}/api/admin/cleanup`, { method: 'POST', credentials: 'include' });
const data = await res.json();
if (data.success) {
alert(`Успешно очищено. Удалено пустых стримов: ${data.count}`);
fetchStreams();
fetchAdminStats();
} else {
alert(`Ошибка: ${data.error}`);
}
} catch (e) {
alert('Ошибка при очистке стримов.');
} finally {
setCleaningStreams(false);
}
}}
>
Очистить пустые стримы
{/* System Statistics Panel */}
Системная статистика
{loadingAdminStats || !adminStats ? (
Загрузка статистики...
) : (
Режим базы данных:
{adminStats.dbMode.toUpperCase()}
{adminStats.dbMode === 'sqlite' && (
Размер файла SQLite:
{adminStats.dbSizeMb} МБ
)}
Всего стримов в базе:
{adminStats.totalStreams}
Всего сообщений чата:
{adminStats.totalMessages.toLocaleString()}
Всего голосовых слов:
{adminStats.totalVoiceWords.toLocaleString()}
Обновить статистику
)}
)}
{/* MODERATOR TAB */}
{activeTab === 'moderator' && (
{twitchConfigured && !auth.loggedIn ? (
Доступ Ограничен
Лог действий модераторов и статистика банов/удалений доступны только стримеру и официальным модераторам канала **winx_prinx**.
Пожалуйста, авторизуйтесь через Twitch для проверки прав.
Войти через Twitch
) : twitchConfigured && (auth.user?.role !== 'streamer' && auth.user?.role !== 'moderator' && auth.user?.role !== 'admin') ? (
Недостаточно прав
Вы успешно вошли как {auth.user?.displayName} , но вы не являетесь модератором канала {TWITCH_CHANNEL}.
) : (
{modProfiles.length <= 2 && (
Данные накапливаются
Информация о модераторах появляется по мере их активности в выбранном стриме.
)}
{/* TOP 3 PODIUM */}
{modProfiles.slice(0, 3).map((mod, idx) => {
const trophyColor = idx === 0 ? '#FFD700' : idx === 1 ? '#C0C0C0' : '#CD7F32';
const glowStyle = {
border: `1px solid ${trophyColor}40`,
boxShadow: `0 0 15px ${trophyColor}12`,
position: 'relative',
overflow: 'hidden'
};
return (
#{idx + 1}
{idx === 0 ? '👑' : idx === 1 ? '🥈' : '🥉'}
{mod.moderator}
{idx === 0 ? 'Глава патруля' : idx === 1 ? 'Старший мод' : 'Защитник чата'}
Действий
{mod.total_actions}
Ср. Реакция
{mod.reaction_time_avg ? `${mod.reaction_time_avg}с` : '—'}
КПД Активности
{mod.scores.activity}%
);
})}
{/* Left: Mod List */}
Рейтинг модераторов
{modProfiles.length === 0 ? (
Модераторы еще не совершали действий в этом периоде
) : (
Ранг
Никнейм
Действий
Ср. Реакция
{modProfiles.map((mod, idx) => (
setSelectedMod(mod)}
style={{
cursor: 'pointer',
background: selectedMod && selectedMod.moderator === mod.moderator ? 'rgba(163, 112, 247, 0.08)' : 'transparent',
borderLeft: selectedMod && selectedMod.moderator === mod.moderator ? '3px solid var(--color-brand)' : 'none'
}}
>
#{idx + 1}
{mod.moderator}
{mod.total_actions}
{mod.reaction_time_avg ? `${mod.reaction_time_avg}с` : '—'}
))}
)}
{/* Right: Mod Dossier */}
Личное досье модератора
{!selectedMod ? (
Выберите модератора слева для просмотра досье
) : (
{selectedMod.moderator}
Анализ стиля модерирования и характеристик
{/* Radar chart */}
{/* Actions distribution */}
Распределение наказаний:
{selectedMod.bans_count > 0 && (
)}
{selectedMod.timeouts_count > 0 && (
)}
{selectedMod.deletions_count > 0 && (
)}
{selectedMod.unbans_count > 0 && (
)}
{/* Legend */}
Баны ({selectedMod.bans_count})
Муты ({selectedMod.timeouts_count})
Удаления ({selectedMod.deletions_count})
Разбаны ({selectedMod.unbans_count})
)}
)}
)}
>
)}
{/* Footer */}
);
}