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 OverviewTab from './components/OverviewTab'; import ChattersTab from './components/ChattersTab'; import WordsTab from './components/WordsTab'; import AdminTab from './components/AdminTab'; import ModeratorTab from './components/ModeratorTab'; // 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'; // ChatterOrbit component has been extracted to components/ChatterOrbit.jsx 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, auth.loggedIn, twitchConfigured]); // 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(Array.isArray(data) ? 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 ? (

Загрузка аналитики...

) : ( <> {activeTab === 'overview' && ( )} {activeTab === 'chatters' && ( )} {activeTab === 'words' && ( )} {activeTab === 'admin' && ( )} {activeTab === 'moderator' && ( )} )}
{/* Footer */}
); }