Spaces:
Sleeping
Sleeping
| 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 | |
| ); | |
| // 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/avatar/winx_prinx'; | |
| 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 ( | |
| <div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', width: '100%', position: 'relative' }}> | |
| <canvas ref={canvasRef} style={{ borderRadius: '8px', display: 'block', width: '100%' }} /> | |
| <div style={{ position: 'absolute', bottom: '10px', right: '12px', fontSize: '11px', color: 'rgba(180,180,200,0.7)', display: 'flex', gap: '14px' }}> | |
| <span><span style={{ display: 'inline-block', width: '6px', height: '6px', background: 'rgba(200,200,220,0.5)', borderRadius: '50%', marginRight: '4px'}}></span>Зрители</span> | |
| <span><span style={{ display: 'inline-block', width: '6px', height: '6px', background: '#c8c8d8', borderRadius: '50%', marginRight: '4px'}}></span>Чатеры</span> | |
| <span><span style={{ display: 'inline-block', width: '6px', height: '6px', background: '#FF007F', borderRadius: '50%', marginRight: '4px'}}></span>Сабы</span> | |
| <span><span style={{ display: 'inline-block', width: '6px', height: '6px', background: '#00F5D4', borderRadius: '50%', marginRight: '4px'}}></span>Модеры</span> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // 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'; | |
| 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 ( | |
| <div className="app-container"> | |
| {/* Header */} | |
| <header className="app-header"> | |
| <div className="brand-section"> | |
| <BarChart2 size={28} className="brand-logo" /> | |
| <h1 className="brand-name">winx_prinx</h1> | |
| <span className="channel-tag">Analytics</span> | |
| {/* Live stream badge */} | |
| {activeStream ? ( | |
| <div className="status-indicator" style={{ marginLeft: '1rem' }}> | |
| <span className="status-dot live"></span> | |
| <span style={{ color: '#ff0000', fontSize: '0.75rem' }}>В ЭФИРЕ</span> | |
| </div> | |
| ) : ( | |
| <div className="status-indicator" style={{ marginLeft: '1rem' }}> | |
| <span className="status-dot offline"></span> | |
| <span style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem' }}>ОФФЛАЙН</span> | |
| </div> | |
| )} | |
| {/* Stream Selector — compact, in header */} | |
| <div className="header-stream-selector"> | |
| <select | |
| className="select-input select-input--compact" | |
| value={selectedStreamId} | |
| onChange={(e) => setSelectedStreamId(e.target.value)} | |
| > | |
| <option value="all">За всё время</option> | |
| {streams.map(s => ( | |
| <option key={s.id} value={s.id}> | |
| {s.title} ({formatDate(s.start_time)}{!s.end_time ? ' · Live' : ''}) | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| </div> | |
| <div className="header-controls"> | |
| {/* Navigation Tabs */} | |
| <nav className="nav-tabs"> | |
| <button | |
| className={`tab-btn ${activeTab === 'overview' ? 'active' : ''}`} | |
| onClick={() => setActiveTab('overview')} | |
| > | |
| <Activity size={16} /> Обзор | |
| </button> | |
| <button | |
| className={`tab-btn ${activeTab === 'chatters' ? 'active' : ''}`} | |
| onClick={() => setActiveTab('chatters')} | |
| > | |
| <Users size={16} /> Зрители | |
| </button> | |
| <button | |
| className={`tab-btn ${activeTab === 'words' ? 'active' : ''}`} | |
| onClick={() => setActiveTab('words')} | |
| > | |
| <Mic size={16} /> Словарь частот | |
| </button> | |
| <button | |
| className={`tab-btn ${activeTab === 'moderator' ? 'active' : ''}`} | |
| onClick={() => setActiveTab('moderator')} | |
| > | |
| <ShieldAlert size={16} /> Модерация | |
| </button> | |
| {(auth.user?.role === 'streamer' || auth.user?.role === 'admin' || !twitchConfigured) && ( | |
| <button | |
| className={`tab-btn ${activeTab === 'admin' ? 'active' : ''}`} | |
| onClick={() => setActiveTab('admin')} | |
| > | |
| <Settings size={16} /> Админка | |
| </button> | |
| )} | |
| </nav> | |
| {/* Refresh + User Profile */} | |
| <div className="user-profile"> | |
| <button | |
| className="btn btn-refresh" | |
| onClick={() => { fetchStreams(); fetchAllStats(); }} | |
| disabled={loading} | |
| title="Обновить данные" | |
| > | |
| <RefreshCw size={14} className={loading ? 'spin' : ''} /> | |
| Обновить | |
| </button> | |
| {!twitchConfigured ? ( | |
| <span className="badge badge-mod" style={{ padding: '0.5rem 1rem', fontSize: '0.85rem', background: '#232329', border: '1px solid #2F2F35', color: '#00F5D4' }}> | |
| Локальный режим | |
| </span> | |
| ) : auth.loggedIn ? ( | |
| <> | |
| <div className="user-info"> | |
| <div className="user-name">{auth.user.displayName}</div> | |
| <div className="user-role"> | |
| {auth.user?.role === 'streamer' ? 'Стример' : auth.user?.role === 'admin' ? 'Админ' : auth.user?.role === 'moderator' ? 'Модератор' : 'Зритель'} | |
| </div> | |
| </div> | |
| <button className="btn btn-secondary" onClick={handleLogout}> | |
| <LogOut size={16} /> Выйти | |
| </button> | |
| </> | |
| ) : ( | |
| <button className="btn" onClick={handleLogin}> | |
| <LogIn size={16} /> Войти через Twitch | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </header> | |
| {/* Main Dashboard Panel */} | |
| <main className="dashboard-content"> | |
| {serverStatus !== 'online' ? ( | |
| <div className="connection-overlay-container" style={{ | |
| display: 'flex', | |
| flexDirection: 'column', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| minHeight: '60vh', | |
| padding: '2rem', | |
| textAlign: 'center', | |
| background: 'rgba(20, 20, 27, 0.4)', | |
| backdropFilter: 'blur(8px)', | |
| WebkitBackdropFilter: 'blur(8px)', | |
| borderRadius: 'var(--radius-lg)', | |
| border: '1px solid var(--color-border)', | |
| margin: '2rem auto', | |
| maxWidth: '600px', | |
| boxShadow: '0 8px 32px 0 rgba(0, 0, 0, 0.37)', | |
| animation: 'fadeIn 0.4s ease-out' | |
| }}> | |
| {serverStatus === 'connecting' ? ( | |
| <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1.5rem' }}> | |
| <div className="spinner" style={{ | |
| width: '50px', | |
| height: '50px', | |
| borderRadius: '50%', | |
| border: '3px solid rgba(145, 70, 255, 0.1)', | |
| borderTopColor: 'var(--color-brand)', | |
| animation: 'spin 1s linear infinite' | |
| }}></div> | |
| <h2 style={{ fontSize: '1.4rem', fontWeight: 600, color: 'var(--color-text-main)', margin: 0 }}> | |
| Подключение к серверу аналитики... | |
| </h2> | |
| <p style={{ color: 'var(--color-text-muted)', fontSize: '0.88rem', margin: 0, lineHeight: 1.6 }}> | |
| Сервер бэкенда на Render автоматически засыпает после 15 минут неактивности. Пробуждение сервера занимает около 30–50 секунд. Пожалуйста, подождите. | |
| </p> | |
| </div> | |
| ) : ( | |
| <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1.5rem' }}> | |
| <div style={{ | |
| width: '60px', | |
| height: '60px', | |
| borderRadius: '50%', | |
| backgroundColor: 'rgba(255, 73, 73, 0.1)', | |
| border: '1px solid rgba(255, 73, 73, 0.2)', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| color: '#FF4949', | |
| fontSize: '1.8rem' | |
| }}>⚠️</div> | |
| <h2 style={{ fontSize: '1.4rem', fontWeight: 600, color: 'var(--color-text-main)', margin: 0 }}> | |
| Бэкенд недоступен | |
| </h2> | |
| <p style={{ color: 'var(--color-text-muted)', fontSize: '0.88rem', margin: 0, lineHeight: 1.6 }}> | |
| Не удалось установить соединение с сервером. Возможно, сервер временно отключен, обновляется или ваш провайдер блокирует подключение. | |
| </p> | |
| <button | |
| className="btn btn-refresh" | |
| onClick={() => { | |
| 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' | |
| }} | |
| > | |
| <RefreshCw size={14} style={{ marginRight: '8px' }} /> | |
| Повторить попытку | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| ) : loading ? ( | |
| <div className="status-msg"> | |
| <RefreshCw size={48} className="status-msg-icon spin" /> | |
| <p>Загрузка аналитики...</p> | |
| </div> | |
| ) : ( | |
| <> | |
| {/* OVERVIEW TAB */} | |
| {activeTab === 'overview' && ( | |
| <div className="tab-content"> | |
| {/* Stats row */} | |
| <div className="grid-4col" style={{ marginBottom: '2rem' }}> | |
| <div className="stat-card" style={{ '--accent-color': 'var(--color-brand)' }}> | |
| <div className="stat-card-inner"> | |
| <div style={{ minWidth: 0, flex: 1, marginRight: '0.5rem' }}> | |
| <div className="stat-label">Сообщений в чате</div> | |
| <div className="stat-value">{statsSummary.messages.toLocaleString()}</div> | |
| </div> | |
| <div className="stat-icon-wrapper"><MessageSquare size={22} style={{ color: 'var(--color-brand)' }} /></div> | |
| </div> | |
| </div> | |
| <div className="stat-card" style={{ '--accent-color': 'var(--color-text-accent)' }}> | |
| <div className="stat-card-inner"> | |
| <div style={{ minWidth: 0, flex: 1, marginRight: '0.5rem' }}> | |
| <div className="stat-label">Уникальных зрителей</div> | |
| <div className="stat-value">{statsSummary.uniqueChatters.toLocaleString()}</div> | |
| </div> | |
| <div className="stat-icon-wrapper"><Users size={22} style={{ color: 'var(--color-text-accent)' }} /></div> | |
| </div> | |
| </div> | |
| <div className="stat-card" style={{ '--accent-color': '#ff007f' }}> | |
| <div className="stat-card-inner"> | |
| <div style={{ minWidth: 0, flex: 1, marginRight: '0.5rem' }}> | |
| <div className="stat-label">Самый активный чатер</div> | |
| <div | |
| className="stat-value" | |
| title={statsSummary.mostActiveChatter || ''} | |
| style={{ | |
| fontSize: (statsSummary.mostActiveChatter || '').length > 15 ? '1.05rem' : '1.25rem', | |
| letterSpacing: '-0.02em', | |
| overflow: 'hidden', | |
| textOverflow: 'ellipsis', | |
| whiteSpace: 'nowrap' | |
| }} | |
| > | |
| {statsSummary.mostActiveChatter || '—'} | |
| </div> | |
| </div> | |
| <div className="stat-icon-wrapper"><Users size={22} style={{ color: '#ff007f' }} /></div> | |
| </div> | |
| </div> | |
| <div className="stat-card" style={{ '--accent-color': '#00f5d4' }}> | |
| <div className="stat-card-inner"> | |
| <div style={{ minWidth: 0, flex: 1, marginRight: '0.5rem' }}> | |
| <div className="stat-label">Распознано слов (голос)</div> | |
| <div className="stat-value">{statsSummary.voiceWordsCount.toLocaleString()}</div> | |
| </div> | |
| <div className="stat-icon-wrapper"><Mic size={22} style={{ color: '#00f5d4' }} /></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="grid-2col" style={{ gridTemplateColumns: '2fr 1fr' }}> | |
| {/* Activity graph */} | |
| <div className="panel"> | |
| <div className="panel-header"> | |
| <h3 className="panel-title"><Activity size={20} /> Активность чата</h3> | |
| <div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}> | |
| <span style={{ fontSize: '0.8rem', color: 'var(--color-text-muted)' }}>выберите конкретный стрим для графика</span> | |
| {activityData.length > 0 && ( | |
| <button | |
| className="btn btn-secondary" | |
| style={{ fontSize: '0.75rem', padding: '0.3rem 0.6rem' }} | |
| onClick={startGraphRace} | |
| disabled={isRacing} | |
| > | |
| <Activity size={12} className={isRacing ? "pulse" : ""} /> {isRacing ? 'Гонка идет...' : 'Запустить гонку'} | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| {selectedStreamId === 'all' ? ( | |
| <div className="status-msg" style={{ height: '300px' }}> | |
| <BarChart2 size={36} className="status-msg-icon" /> | |
| <p>Для просмотра временного графика выберите конкретный стрим в верхнем меню</p> | |
| </div> | |
| ) : activityData.length === 0 ? ( | |
| <div className="status-msg" style={{ height: '300px' }}> | |
| <MessageSquare size={36} className="status-msg-icon" /> | |
| <p>В этом стриме пока не было сообщений</p> | |
| </div> | |
| ) : ( | |
| <div className="chart-container"> | |
| <Line data={chartDataConfig} options={chartOptions} /> | |
| </div> | |
| )} | |
| </div> | |
| {/* Quick Words list */} | |
| <div className="panel"> | |
| <div className="panel-header"> | |
| <h3 className="panel-title"> | |
| <Mic size={18} style={{ color: 'var(--color-text-accent)' }} /> Голос стримера (Топ) | |
| </h3> | |
| </div> | |
| {voiceWords.length === 0 ? ( | |
| <div className="status-msg" style={{ height: '300px', padding: '1rem' }}> | |
| <Mic size={28} className="status-msg-icon" /> | |
| <p>Слова пока не распознаны. Запустите local_worker на ПК.</p> | |
| </div> | |
| ) : ( | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', maxHeight: '320px', overflowY: 'auto', paddingRight: '4px' }}> | |
| {voiceWords.slice(0, 7).map((w, idx) => ( | |
| <div key={idx} style={{ display: 'flex', justifyContent: 'space-between', padding: '0.5rem 0.75rem', backgroundColor: 'var(--color-bg-base)', border: '1px solid var(--color-border)', borderRadius: '6px', alignItems: 'center' }}> | |
| <span style={{ fontWeight: 600, color: 'var(--color-text-main)' }}>#{idx+1} {w.word}</span> | |
| <span className="badge badge-streamer">{w.word_count} раз</span> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* CHATTERS TAB */} | |
| {activeTab === 'chatters' && ( | |
| <div className="grid-2col tab-content" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(340px, 1fr))', gap: '1.5rem', alignItems: 'start' }}> | |
| <div className="panel"> | |
| <div className="panel-header"> | |
| <h3 className="panel-title"><Users size={20} /> Орбита зрителей</h3> | |
| </div> | |
| <div style={{ display: 'flex', justifyContent: 'center', padding: '1rem 0' }}> | |
| <ChatterOrbit chatters={chatters} /> | |
| </div> | |
| </div> | |
| <div className="panel"> | |
| <div className="panel-header"> | |
| <h3 className="panel-title"><Users size={20} /> Лидеры чата по активности</h3> | |
| <div className="search-container"> | |
| <Search size={16} className="search-icon" /> | |
| <input | |
| type="text" | |
| className="search-input" | |
| placeholder="Поиск зрителя..." | |
| value={chattersSearch} | |
| onChange={(e) => setChattersSearch(e.target.value)} | |
| /> | |
| </div> | |
| </div> | |
| {chatters.length === 0 ? ( | |
| <div className="status-msg"> | |
| <Users size={40} className="status-msg-icon" /> | |
| <p>Нет данных о зрителях для этого периода</p> | |
| </div> | |
| ) : ( | |
| <div className="table-wrapper"> | |
| <table className="data-table"> | |
| <thead> | |
| <tr> | |
| <th>Место</th> | |
| <th>Никнейм</th> | |
| <th>Роли</th> | |
| <th>Сообщений</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {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) => ( | |
| <tr key={idx} className="chatter-row"> | |
| <td style={{ fontWeight: 700, width: '80px', color: idx < 3 ? 'var(--color-brand)' : 'var(--color-text-muted)' }}> | |
| #{idx + 1} | |
| </td> | |
| <td style={{ fontWeight: 600 }}>{c.display_name || c.username || '—'}</td> | |
| <td> | |
| <div style={{ display: 'flex', gap: '0.4rem' }}> | |
| {(c.username || '').toLowerCase() === TWITCH_CHANNEL && ( | |
| <span className="badge badge-streamer">Стример</span> | |
| )} | |
| {c.is_mod ? <span className="badge badge-mod">Мод</span> : null} | |
| {c.is_sub ? <span className="badge badge-sub">Саб</span> : null} | |
| </div> | |
| </td> | |
| <td style={{ fontWeight: 700, color: 'var(--color-text-accent)' }}> | |
| {c.message_count.toLocaleString()} | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {/* WORDS TAB */} | |
| {activeTab === 'words' && ( | |
| <div className="panel tab-content"> | |
| <div className="panel-header"> | |
| <h3 className="panel-title"><Mic size={20} /> Частотный словарь</h3> | |
| <div className="nav-tabs" style={{ background: 'var(--color-bg-base)', padding: '0.2rem', borderRadius: '8px', border: '1px solid var(--color-border)' }}> | |
| <button | |
| className={`tab-btn ${wordType === 'voice' ? 'active' : ''}`} | |
| onClick={() => setWordType('voice')} | |
| style={{ fontSize: '0.8rem', padding: '0.4rem 0.8rem' }} | |
| > | |
| <Volume2 size={14} /> Из Голоса (Whisper) | |
| </button> | |
| <button | |
| className={`tab-btn ${wordType === 'chat' ? 'active' : ''}`} | |
| onClick={() => setWordType('chat')} | |
| style={{ fontSize: '0.8rem', padding: '0.4rem 0.8rem' }} | |
| > | |
| <MessageSquare size={14} /> Из Чата (Текстом) | |
| </button> | |
| </div> | |
| </div> | |
| <p style={{ color: 'var(--color-text-muted)', fontSize: '0.85rem', marginBottom: '1.5rem', marginTop: '-0.75rem' }}> | |
| {wordType === 'voice' | |
| ? 'Слова, которые стример произнес в микрофон (распознанные через Whisper на локальном ПК).' | |
| : 'Слова, которые зрители написали в текстовый чат Twitch.'} | |
| </p> | |
| {(() => { | |
| const currentWordsList = wordType === 'voice' | |
| ? (Array.isArray(voiceWords) ? voiceWords : []) | |
| : (Array.isArray(chatWords) ? chatWords : []); | |
| if (currentWordsList.length === 0) { | |
| return ( | |
| <div className="status-msg"> | |
| <Mic size={40} className="status-msg-icon" /> | |
| <p>Нет собранных слов для выбранного стрима</p> | |
| </div> | |
| ); | |
| } | |
| 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 stretchX = 2.8; | |
| const stretchY = 1.0; | |
| const paddingX = 12; | |
| const paddingY = 8; | |
| const remToPx = 16; | |
| // Identify center group: words with count within 500 of the max count, | |
| // but ONLY if the top count is >= 500. Also cap center group size to at most 3 words. | |
| const centerGroupWords = currentWordsList.filter((w, idx) => | |
| idx === 0 || (maxCount >= 500 && (maxCount - w.word_count) < 500 && idx < 3) | |
| ); | |
| const otherWordsList = currentWordsList.filter((w, idx) => | |
| !(idx === 0 || (maxCount >= 500 && (maxCount - w.word_count) < 500 && 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 >= 500 && (maxCount - w.word_count) < 500 && i < 3); | |
| let fontSize; | |
| let fontWeight; | |
| let scaleValue = 0; | |
| 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'); | |
| } | |
| // 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 ( | |
| <div className="word-galaxy-container"> | |
| {laidOutWords.map((w, idx) => ( | |
| <div | |
| key={idx} | |
| className="word-galaxy-tag" | |
| style={{ | |
| '--x': `${w.x}px`, | |
| '--y': `${w.y}px`, | |
| '--float-duration': w.duration, | |
| '--float-delay': w.delay, | |
| '--float-amount': w.amount, | |
| fontSize: w.fontSize, | |
| fontWeight: w.fontWeight, | |
| color: w.color, | |
| opacity: 0.95, // High opacity so purple colors are bright and vivid | |
| zIndex: w.isCenterGroup ? 25 : Math.round(10 + w.scale * 15) | |
| }} | |
| > | |
| {w.word} | |
| <div className="word-tooltip"> | |
| {w.count} {getRussianPlural(w.count)} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| ); | |
| })()} | |
| </div> | |
| )} | |
| {/* ADMIN TAB */} | |
| {activeTab === 'admin' && ( | |
| <div className="tab-content"> | |
| <h2 style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}> | |
| <Settings size={24} style={{ color: 'var(--color-brand)' }} /> Панель администратора | |
| </h2> | |
| <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem', marginBottom: '2rem' }}> | |
| {/* Left: Stream management */} | |
| <div className="panel" style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}> | |
| <div className="panel-header"> | |
| <h3 className="panel-title">Управление стримами</h3> | |
| </div> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> | |
| <label style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--color-text-muted)' }}>Выбрать стрим для редактирования или удаления:</label> | |
| <select | |
| className="dark-input" | |
| value={adminSelectedStreamId} | |
| onChange={(e) => setAdminSelectedStreamId(e.target.value)} | |
| > | |
| <option value="">-- Выберите стрим --</option> | |
| {streams.map((stream) => ( | |
| <option key={stream.id} value={stream.id}> | |
| {stream.title || 'Без названия'} ({new Date(stream.start_time).toLocaleDateString('ru-RU')} {new Date(stream.start_time).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}) | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| {(() => { | |
| const activeStream = streams.find(s => s.id === parseInt(adminSelectedStreamId)); | |
| if (!activeStream) return ( | |
| <div style={{ padding: '1.5rem', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: '0.85rem' }}> | |
| Выберите стрим в выпадающем списке выше для выполнения действий. | |
| </div> | |
| ); | |
| 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 ( | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}> | |
| <div style={{ fontSize: '0.8rem', padding: '0.75rem', background: 'rgba(255,255,255,0.02)', borderRadius: '6px', border: '1px solid var(--color-border)' }}> | |
| <p style={{ margin: '0 0 0.4rem 0' }}><strong>ID сессии:</strong> {activeStream.id}</p> | |
| <p style={{ margin: '0 0 0.4rem 0' }}><strong>Статус импорта VOD:</strong> <span style={{ color: statusColor, fontWeight: 'bold' }}>{statusLabel}</span></p> | |
| <p style={{ margin: '0 0 0.4rem 0' }}><strong>Twitch VOD ID:</strong> {activeStream.twitch_vod_id || 'Отсутствует'}</p> | |
| <p style={{ margin: 0 }}><strong>Начало:</strong> {new Date(activeStream.start_time).toLocaleString('ru-RU')}</p> | |
| </div> | |
| {/* Edit Metadata */} | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', padding: '1rem', background: 'rgba(255,255,255,0.01)', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.03)' }}> | |
| <h4 style={{ margin: 0, fontSize: '0.9rem', fontWeight: 600 }}>Редактирование названия / категории</h4> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}> | |
| <label style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Название стрима:</label> | |
| <input | |
| type="text" | |
| className="dark-input" | |
| value={editTitle} | |
| onChange={(e) => setEditTitle(e.target.value)} | |
| /> | |
| </div> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}> | |
| <label style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Категория (игра):</label> | |
| <input | |
| type="text" | |
| className="dark-input" | |
| value={editCategory} | |
| onChange={(e) => setEditCategory(e.target.value)} | |
| /> | |
| </div> | |
| <button | |
| className="btn btn-secondary" | |
| style={{ width: '100%', padding: '0.5rem', marginTop: '0.25rem' }} | |
| disabled={savingMetadata || !editTitle.trim()} | |
| onClick={async () => { | |
| setSavingMetadata(true); | |
| try { | |
| const res = await fetch(`${API_BASE}/api/streams/${activeStream.id}`, { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ title: editTitle, category: editCategory }), | |
| credentials: 'include' | |
| }); | |
| const data = await res.json(); | |
| if (data.success) { | |
| alert('Метаданные стрима успешно обновлены!'); | |
| fetchStreams(); | |
| } else { | |
| alert(`Ошибка: ${data.error}`); | |
| } | |
| } catch (e) { | |
| alert('Ошибка при обновлении метаданных.'); | |
| } finally { | |
| setSavingMetadata(false); | |
| } | |
| }} | |
| > | |
| {savingMetadata ? 'Сохранение...' : 'Сохранить изменения'} | |
| </button> | |
| </div> | |
| {/* VOD Actions (Only if VOD is present) */} | |
| {activeStream.twitch_vod_id && ( | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}> | |
| <h4 style={{ margin: 0, fontSize: '0.9rem', fontWeight: 600 }}>Действия импорта VOD:</h4> | |
| <div style={{ display: 'flex', gap: '0.5rem' }}> | |
| <button | |
| className="btn btn-secondary" | |
| style={{ flex: 1, fontSize: '0.75rem', padding: '0.5rem' }} | |
| onClick={async () => { | |
| 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 | |
| </button> | |
| <button | |
| className="btn btn-secondary" | |
| style={{ flex: 1, fontSize: '0.75rem', padding: '0.5rem' }} | |
| onClick={async () => { | |
| 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 | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Delete Button */} | |
| <div style={{ borderTop: '1px solid var(--color-border)', paddingTop: '1rem', marginTop: '0.5rem' }}> | |
| <button | |
| className="btn btn-danger" | |
| style={{ | |
| width: '100%', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| gap: '0.4rem', | |
| backgroundColor: 'rgba(231, 76, 60, 0.15)', | |
| color: '#e74c3c', | |
| border: '1px solid rgba(231, 76, 60, 0.3)', | |
| borderRadius: '4px', | |
| cursor: 'pointer', | |
| padding: '0.6rem' | |
| }} | |
| disabled={deletingStream} | |
| onClick={async () => { | |
| 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); | |
| } | |
| }} | |
| > | |
| <Trash2 size={16} /> Удалить стрим полностью | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| })()} | |
| </div> | |
| {/* Right: Global actions and Stats */} | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}> | |
| {/* Global Actions Panel */} | |
| <div className="panel"> | |
| <div className="panel-header"> | |
| <h3 className="panel-title">Глобальные действия</h3> | |
| </div> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> | |
| {/* Twitch VOD Sync */} | |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingBottom: '0.75rem', borderBottom: '1px solid var(--color-border)' }}> | |
| <div> | |
| <h4 style={{ margin: 0, fontSize: '0.85rem', fontWeight: 600 }}>Синхронизация Twitch VOD</h4> | |
| <p style={{ margin: 0, fontSize: '0.7rem', color: 'var(--color-text-muted)' }}>Запросить последние 20 архивов из Twitch API.</p> | |
| </div> | |
| <button | |
| className="btn btn-secondary" | |
| style={{ fontSize: '0.75rem', padding: '0.4rem 0.8rem' }} | |
| disabled={syncingVods} | |
| onClick={async () => { | |
| 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); | |
| } | |
| }} | |
| > | |
| <RefreshCw size={12} className={syncingVods ? "spin" : ""} /> {syncingVods ? 'Синхронизация...' : 'Синхронизировать VOD'} | |
| </button> | |
| </div> | |
| {/* Database Cleanup */} | |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> | |
| <div> | |
| <h4 style={{ margin: 0, fontSize: '0.85rem', fontWeight: 600 }}>Очистить пустые стримы</h4> | |
| <p style={{ margin: 0, fontSize: '0.7rem', color: 'var(--color-text-muted)' }}>Удалить сессии с 0 сообщениями и 0 слов.</p> | |
| </div> | |
| <button | |
| className="btn btn-secondary" | |
| style={{ fontSize: '0.75rem', padding: '0.4rem 0.8rem' }} | |
| disabled={cleaningStreams} | |
| onClick={async () => { | |
| 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); | |
| } | |
| }} | |
| > | |
| Очистить пустые стримы | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| {/* System Statistics Panel */} | |
| <div className="panel"> | |
| <div className="panel-header"> | |
| <h3 className="panel-title">Системная статистика</h3> | |
| </div> | |
| {loadingAdminStats || !adminStats ? ( | |
| <div style={{ padding: '1rem', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: '0.85rem' }}> | |
| Загрузка статистики... | |
| </div> | |
| ) : ( | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', fontSize: '0.8rem' }}> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: '0.4rem', borderBottom: '1px solid rgba(255,255,255,0.03)' }}> | |
| <span style={{ color: 'var(--color-text-muted)' }}>Режим базы данных:</span> | |
| <strong style={{ color: 'var(--color-brand)' }}>{adminStats.dbMode.toUpperCase()}</strong> | |
| </div> | |
| {adminStats.dbMode === 'sqlite' && ( | |
| <div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: '0.4rem', borderBottom: '1px solid rgba(255,255,255,0.03)' }}> | |
| <span style={{ color: 'var(--color-text-muted)' }}>Размер файла SQLite:</span> | |
| <strong>{adminStats.dbSizeMb} МБ</strong> | |
| </div> | |
| )} | |
| <div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: '0.4rem', borderBottom: '1px solid rgba(255,255,255,0.03)' }}> | |
| <span style={{ color: 'var(--color-text-muted)' }}>Всего стримов в базе:</span> | |
| <strong>{adminStats.totalStreams}</strong> | |
| </div> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: '0.4rem', borderBottom: '1px solid rgba(255,255,255,0.03)' }}> | |
| <span style={{ color: 'var(--color-text-muted)' }}>Всего сообщений чата:</span> | |
| <strong>{adminStats.totalMessages.toLocaleString()}</strong> | |
| </div> | |
| <div style={{ display: 'flex', justifyContent: 'space-between' }}> | |
| <span style={{ color: 'var(--color-text-muted)' }}>Всего голосовых слов:</span> | |
| <strong>{adminStats.totalVoiceWords.toLocaleString()}</strong> | |
| </div> | |
| <button | |
| className="btn btn-secondary" | |
| style={{ width: '100%', fontSize: '0.75rem', padding: '0.4rem', marginTop: '0.5rem' }} | |
| onClick={fetchAdminStats} | |
| > | |
| Обновить статистику | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* MODERATOR TAB */} | |
| {activeTab === 'moderator' && ( | |
| <div className="tab-content"> | |
| {twitchConfigured && !auth.loggedIn ? ( | |
| <div className="login-overlay"> | |
| <div className="login-card"> | |
| <Lock size={48} style={{ color: 'var(--color-brand)', marginBottom: '1.5rem' }} /> | |
| <h2 className="login-title">Доступ Ограничен</h2> | |
| <p className="login-description"> | |
| Лог действий модераторов и статистика банов/удалений доступны только стримеру и официальным модераторам канала **winx_prinx**. | |
| Пожалуйста, авторизуйтесь через Twitch для проверки прав. | |
| </p> | |
| <button className="btn" onClick={handleLogin}> | |
| <LogIn size={16} /> Войти через Twitch | |
| </button> | |
| </div> | |
| </div> | |
| ) : twitchConfigured && (auth.user?.role !== 'streamer' && auth.user?.role !== 'moderator' && auth.user?.role !== 'admin') ? ( | |
| <div className="status-msg"> | |
| <ShieldAlert size={48} className="status-msg-icon" /> | |
| <h2>Недостаточно прав</h2> | |
| <p>Вы успешно вошли как <strong>{auth.user?.displayName}</strong>, но вы не являетесь модератором канала {TWITCH_CHANNEL}.</p> | |
| </div> | |
| ) : ( | |
| <div> | |
| {modProfiles.length <= 2 && ( | |
| <div style={{ background: 'rgba(145, 70, 255, 0.1)', border: '1px solid rgba(145, 70, 255, 0.3)', borderRadius: '8px', padding: '1rem', marginBottom: '1.5rem', display: 'flex', gap: '1rem', alignItems: 'center' }}> | |
| <ShieldAlert size={24} style={{ color: 'var(--color-brand)' }} /> | |
| <div> | |
| <h4 style={{ margin: '0 0 0.25rem 0', color: 'var(--color-text-main)', fontSize: '0.95rem' }}>Данные накапливаются</h4> | |
| <p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--color-text-muted)' }}>Информация о модераторах появляется по мере их активности в выбранном стриме.</p> | |
| </div> | |
| </div> | |
| )} | |
| {/* TOP 3 PODIUM */} | |
| <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '1.5rem', marginBottom: '2rem' }}> | |
| {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 ( | |
| <div key={idx} className="panel" style={glowStyle}> | |
| <div style={{ position: 'absolute', top: '-10px', right: '-10px', fontSize: '5rem', opacity: 0.08, color: trophyColor, fontWeight: 900 }}> | |
| #{idx + 1} | |
| </div> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}> | |
| <div style={{ | |
| width: '44px', | |
| height: '44px', | |
| borderRadius: '50%', | |
| background: 'var(--color-bg-base)', | |
| border: `2px solid ${trophyColor}`, | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| fontWeight: 700, | |
| fontSize: '1.1rem', | |
| color: trophyColor | |
| }}> | |
| {idx === 0 ? '👑' : idx === 1 ? '🥈' : '🥉'} | |
| </div> | |
| <div> | |
| <h4 style={{ margin: 0, fontSize: '1rem', fontWeight: 700 }}>{mod.moderator}</h4> | |
| <span style={{ fontSize: '0.7rem', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}> | |
| {idx === 0 ? 'Глава патруля' : idx === 1 ? 'Старший мод' : 'Защитник чата'} | |
| </span> | |
| </div> | |
| </div> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '1.25rem', fontSize: '0.8rem' }}> | |
| <div> | |
| <div style={{ color: 'var(--color-text-muted)' }}>Действий</div> | |
| <div style={{ fontSize: '1.1rem', fontWeight: 700, color: 'var(--color-text-accent)' }}>{mod.total_actions}</div> | |
| </div> | |
| <div> | |
| <div style={{ color: 'var(--color-text-muted)' }}>Ср. Реакция</div> | |
| <div style={{ fontSize: '1.1rem', fontWeight: 700, color: '#00F5D4' }}> | |
| {mod.reaction_time_avg ? `${mod.reaction_time_avg}с` : '—'} | |
| </div> | |
| </div> | |
| <div> | |
| <div style={{ color: 'var(--color-text-muted)' }}>КПД Активности</div> | |
| <div style={{ fontSize: '1.1rem', fontWeight: 700, color: '#A370F7' }}>{mod.scores.activity}%</div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| <div className="grid-2col" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem', alignItems: 'start' }}> | |
| {/* Left: Mod List */} | |
| <div className="panel"> | |
| <div className="panel-header"> | |
| <h3 className="panel-title"><ShieldAlert size={20} /> Рейтинг модераторов</h3> | |
| </div> | |
| {modProfiles.length === 0 ? ( | |
| <div className="status-msg"> | |
| <ShieldAlert size={40} className="status-msg-icon" /> | |
| <p>Модераторы еще не совершали действий в этом периоде</p> | |
| </div> | |
| ) : ( | |
| <div className="table-wrapper"> | |
| <table className="data-table"> | |
| <thead> | |
| <tr> | |
| <th>Ранг</th> | |
| <th>Никнейм</th> | |
| <th>Действий</th> | |
| <th>Ср. Реакция</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {modProfiles.map((mod, idx) => ( | |
| <tr | |
| key={idx} | |
| onClick={() => 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' | |
| }} | |
| > | |
| <td>#{idx + 1}</td> | |
| <td style={{ fontWeight: 600 }}>{mod.moderator}</td> | |
| <td>{mod.total_actions}</td> | |
| <td style={{ color: '#00F5D4', fontWeight: 600 }}>{mod.reaction_time_avg ? `${mod.reaction_time_avg}с` : '—'}</td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| )} | |
| </div> | |
| {/* Right: Mod Dossier */} | |
| <div className="panel"> | |
| <div className="panel-header"> | |
| <h3 className="panel-title"><UserCheck size={20} /> Личное досье модератора</h3> | |
| </div> | |
| {!selectedMod ? ( | |
| <div className="status-msg"> | |
| <UserCheck size={40} className="status-msg-icon" /> | |
| <p>Выберите модератора слева для просмотра досье</p> | |
| </div> | |
| ) : ( | |
| <div> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.25rem' }}> | |
| <div> | |
| <h3 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 700, color: 'var(--color-brand)' }}>{selectedMod.moderator}</h3> | |
| <p style={{ margin: 0, fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Анализ стиля модерирования и характеристик</p> | |
| </div> | |
| </div> | |
| {/* Radar chart */} | |
| <div style={{ height: '280px', display: 'flex', justifyContent: 'center', marginBottom: '1.25rem' }}> | |
| <Radar | |
| data={{ | |
| labels: ['Скорость', 'Активность', 'Жесткость', 'Внимательность'], | |
| datasets: [{ | |
| data: [ | |
| selectedMod.scores.speed, | |
| selectedMod.scores.activity, | |
| selectedMod.scores.harshness, | |
| selectedMod.scores.watchfulness | |
| ], | |
| backgroundColor: 'rgba(163, 112, 247, 0.2)', | |
| borderColor: '#A370F7', | |
| borderWidth: 2, | |
| pointBackgroundColor: '#A370F7', | |
| pointBorderColor: '#FFF', | |
| pointHoverBackgroundColor: '#FFF', | |
| pointHoverBorderColor: '#A370F7' | |
| }] | |
| }} | |
| options={{ | |
| plugins: { legend: { display: false } }, | |
| scales: { | |
| r: { | |
| angleLines: { color: '#2F2F35' }, | |
| grid: { color: '#2F2F35' }, | |
| pointLabels: { color: '#ADADB8', font: { size: 10, weight: 'bold' } }, | |
| ticks: { display: false }, | |
| min: 0, | |
| max: 100 | |
| } | |
| } | |
| }} | |
| /> | |
| </div> | |
| {/* Actions distribution */} | |
| <div style={{ marginBottom: '1rem' }}> | |
| <h4 style={{ margin: '0 0 0.5rem 0', fontSize: '0.85rem', fontWeight: 600 }}>Распределение наказаний:</h4> | |
| <div style={{ height: '16px', display: 'flex', borderRadius: '4px', overflow: 'hidden', background: 'var(--color-bg-base)' }}> | |
| {selectedMod.bans_count > 0 && ( | |
| <div | |
| style={{ width: `${(selectedMod.bans_count / selectedMod.total_actions) * 100}%`, background: '#FF0055', height: '100%' }} | |
| title={`Баны: ${selectedMod.bans_count}`} | |
| /> | |
| )} | |
| {selectedMod.timeouts_count > 0 && ( | |
| <div | |
| style={{ width: `${(selectedMod.timeouts_count / selectedMod.total_actions) * 100}%`, background: '#FFAA00', height: '100%' }} | |
| title={`Муты/Таймауты: ${selectedMod.timeouts_count}`} | |
| /> | |
| )} | |
| {selectedMod.deletions_count > 0 && ( | |
| <div | |
| style={{ width: `${(selectedMod.deletions_count / selectedMod.total_actions) * 100}%`, background: '#00AAFF', height: '100%' }} | |
| title={`Удаления сообщений: ${selectedMod.deletions_count}`} | |
| /> | |
| )} | |
| {selectedMod.unbans_count > 0 && ( | |
| <div | |
| style={{ width: `${(selectedMod.unbans_count / selectedMod.total_actions) * 100}%`, background: '#00FF88', height: '100%' }} | |
| title={`Разбаны: ${selectedMod.unbans_count}`} | |
| /> | |
| )} | |
| </div> | |
| {/* Legend */} | |
| <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem 1.25rem', marginTop: '0.5rem', fontSize: '0.7rem', color: 'var(--color-text-muted)' }}> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}> | |
| <span style={{ display: 'inline-block', width: '6px', height: '6px', borderRadius: '50%', background: '#FF0055' }}></span> | |
| Баны ({selectedMod.bans_count}) | |
| </div> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}> | |
| <span style={{ display: 'inline-block', width: '6px', height: '6px', borderRadius: '50%', background: '#FFAA00' }}></span> | |
| Муты ({selectedMod.timeouts_count}) | |
| </div> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}> | |
| <span style={{ display: 'inline-block', width: '6px', height: '6px', borderRadius: '50%', background: '#00AAFF' }}></span> | |
| Удаления ({selectedMod.deletions_count}) | |
| </div> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}> | |
| <span style={{ display: 'inline-block', width: '6px', height: '6px', borderRadius: '50%', background: '#00FF88' }}></span> | |
| Разбаны ({selectedMod.unbans_count}) | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </> | |
| )} | |
| </main> | |
| {/* Footer */} | |
| <footer style={{ backgroundColor: 'var(--color-bg-card)', borderTop: '1px solid var(--color-border)', padding: '1.5rem', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: '0.8rem' }}> | |
| <p>Дашборд Аналитики Twitch для канала winx_prinx © {new Date().getFullYear()}</p> | |
| </footer> | |
| </div> | |
| ); | |
| } | |