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([]); const [statsSummary, setStatsSummary] = useState({ messages: 0, uniqueChatters: 0, mostActiveChatter: '—', voiceWordsCount: 0, modActionsCount: 0 }); // 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 (with loading spinner) useEffect(() => { if (selectedStreamId && serverStatus === 'online') { fetchAllStats(true); } }, [selectedStreamId, serverStatus]); // Manage polling intervals when selected stream changes or streams update useEffect(() => { if (selectedStreamId && serverStatus === 'online') { 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 : []); // 1.5. Overall stream stats summary (true totals) try { const statsSummaryRes = await fetch(getUrl('/api/stats/summary')); const statsSummaryData = await statsSummaryRes.json(); if (statsSummaryData && !statsSummaryData.error) { setStatsSummary(statsSummaryData); } } catch (err) { console.error('Error fetching stream stats summary:', err); } // 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' }); }; // 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 */}
{/* Navigation Tabs */} {/* Refresh + User Profile */}
{!twitchConfigured ? ( Локальный режим ) : auth.loggedIn ? ( <>
{auth.user.displayName}
{auth.user?.role === 'streamer' ? 'Стример' : auth.user?.role === 'admin' ? 'Админ' : auth.user?.role === 'moderator' ? 'Модератор' : 'Зритель'}
) : ( )}
{/* Main Dashboard Panel */}
{serverStatus !== 'online' ? (
{serverStatus === 'connecting' ? (

Подключение к серверу аналитики...

Сервер бэкенда в облаке может находиться в спящем режиме. Пробуждение сервера занимает около 30–50 секунд. Пожалуйста, подождите.

) : (
⚠️

Бэкенд недоступен

Не удалось установить соединение с сервером. Возможно, сервер временно отключен, обновляется или ваш провайдер блокирует подключение.

)}
) : 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 && ( )}
{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' && (

Орбита зрителей

Лидеры чата по активности

setChattersSearch(e.target.value)} />
{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_vip && !c.is_mod ? VIP : null} {c.is_sub && !c.is_mod && !c.is_vip ? Саб : null}
{c.message_count.toLocaleString()}
)}
)} {/* WORDS TAB */} {activeTab === 'words' && (

Частотный словарь

{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 */}

Управление стримами

{(() => { 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 */}

Редактирование названия / категории

setEditTitle(e.target.value)} />
setEditCategory(e.target.value)} />
{/* VOD Actions (Only if VOD is present) */} {activeStream.twitch_vod_id && (

Действия импорта VOD:

)} {/* Delete Button */}
); })()}
{/* Right: Global actions and Stats */}
{/* Global Actions Panel */}

Глобальные действия

{/* Twitch VOD Sync */}

Синхронизация Twitch VOD

Запросить последние 20 архивов из Twitch API.

{/* Database Cleanup */}

Очистить пустые стримы

Удалить сессии с 0 сообщениями и 0 слов.

{/* 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 для проверки прав.

) : 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 */}
); }