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 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); | |
| const [rateLimitInfo, setRateLimitInfo] = useState(null); | |
| // Auto-refresh timer for live stream | |
| const pollIntervalRef = useRef(null); | |
| const raceIntervalRef = useRef(null); | |
| const rateLimitTimerRef = useRef(null); | |
| const safeFetch = async (url, options = {}) => { | |
| const res = await fetch(url, options); | |
| if (res.status === 429) { | |
| try { | |
| const data = await res.json(); | |
| setRateLimitInfo({ | |
| message: data.message || 'Превышен лимит запросов к серверу.', | |
| retryAfter: data.retryAfter || 60 | |
| }); | |
| } catch (e) { | |
| setRateLimitInfo({ message: 'Слишком много запросов к серверу.', retryAfter: 60 }); | |
| } | |
| return res; | |
| } else if (res.ok) { | |
| setRateLimitInfo(null); | |
| } | |
| return res; | |
| }; | |
| useEffect(() => { | |
| if (rateLimitInfo && rateLimitInfo.retryAfter > 0) { | |
| if (rateLimitTimerRef.current) clearInterval(rateLimitTimerRef.current); | |
| rateLimitTimerRef.current = setInterval(() => { | |
| setRateLimitInfo(prev => { | |
| if (!prev || prev.retryAfter <= 1) { | |
| clearInterval(rateLimitTimerRef.current); | |
| return null; | |
| } | |
| return { ...prev, retryAfter: prev.retryAfter - 1 }; | |
| }); | |
| }, 1000); | |
| } else if (!rateLimitInfo && rateLimitTimerRef.current) { | |
| clearInterval(rateLimitTimerRef.current); | |
| } | |
| return () => clearInterval(rateLimitTimerRef.current); | |
| }, [rateLimitInfo]); | |
| const fetchAdminStats = async () => { | |
| setLoadingAdminStats(true); | |
| try { | |
| const res = await safeFetch(`${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 safeFetch(`${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 safeFetch(`${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 safeFetch(`${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 safeFetch(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 safeFetch(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 safeFetch(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 safeFetch(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 safeFetch(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 safeFetch(getUrl('/api/stats/moderators', { limit: 100 }), { credentials: 'include' }); | |
| const modData = await modRes.json(); | |
| setModActions(Array.isArray(modData) ? modData : []); | |
| const summaryRes = await safeFetch(getUrl('/api/stats/moderators/summary'), { credentials: 'include' }); | |
| const summaryData = await summaryRes.json(); | |
| setModSummary(Array.isArray(summaryData) ? summaryData : []); | |
| const profilesRes = await safeFetch(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 safeFetch(`${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 ( | |
| <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"> | |
| {rateLimitInfo && ( | |
| <div className="rate-limit-banner" style={{ | |
| background: 'linear-gradient(90deg, rgba(255, 153, 0, 0.15) 0%, rgba(255, 102, 0, 0.15) 100%)', | |
| backdropFilter: 'blur(8px)', | |
| WebkitBackdropFilter: 'blur(8px)', | |
| border: '1px solid rgba(255, 153, 0, 0.3)', | |
| borderRadius: 'var(--radius-md)', | |
| padding: '1rem', | |
| margin: '0 0 1rem 0', | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: '1rem', | |
| color: '#FF9900', | |
| animation: 'fadeIn 0.3s ease-out' | |
| }}> | |
| <ShieldAlert size={24} style={{ animation: 'pulse 2s infinite' }} /> | |
| <div style={{ flex: 1 }}> | |
| <h3 style={{ margin: 0, fontSize: '1rem', fontWeight: 600 }}>Внимание: Лимит запросов</h3> | |
| <p style={{ margin: '0.2rem 0 0', fontSize: '0.85rem', color: 'rgba(255, 255, 255, 0.8)' }}> | |
| {rateLimitInfo.message} | |
| </p> | |
| </div> | |
| <div style={{ | |
| background: 'rgba(255, 153, 0, 0.2)', | |
| padding: '0.5rem 1rem', | |
| borderRadius: 'var(--radius-sm)', | |
| fontWeight: 600, | |
| fontVariantNumeric: 'tabular-nums' | |
| }}> | |
| Разблокировка через: {rateLimitInfo.retryAfter} сек. | |
| </div> | |
| </div> | |
| )} | |
| {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 }}> | |
| Сервер бэкенда в облаке может находиться в спящем режиме. Пробуждение сервера занимает около 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> | |
| ) : ( | |
| <> | |
| {activeTab === 'overview' && ( | |
| <OverviewTab | |
| statsSummary={statsSummary} | |
| selectedStreamId={selectedStreamId} | |
| activityData={activityData} | |
| isRacing={isRacing} | |
| startGraphRace={startGraphRace} | |
| chartDataConfig={chartDataConfig} | |
| chartOptions={chartOptions} | |
| voiceWords={voiceWords} | |
| /> | |
| )} | |
| {activeTab === 'chatters' && ( | |
| <ChattersTab | |
| chatters={chatters} | |
| chattersSearch={chattersSearch} | |
| setChattersSearch={setChattersSearch} | |
| TWITCH_CHANNEL={TWITCH_CHANNEL} | |
| /> | |
| )} | |
| {activeTab === 'words' && ( | |
| <WordsTab | |
| wordType={wordType} | |
| setWordType={setWordType} | |
| voiceWords={voiceWords} | |
| chatWords={chatWords} | |
| /> | |
| )} | |
| {activeTab === 'admin' && ( | |
| <AdminTab | |
| adminSelectedStreamId={adminSelectedStreamId} | |
| setAdminSelectedStreamId={setAdminSelectedStreamId} | |
| streams={streams} | |
| editTitle={editTitle} | |
| setEditTitle={setEditTitle} | |
| editCategory={editCategory} | |
| setEditCategory={setEditCategory} | |
| savingMetadata={savingMetadata} | |
| setSavingMetadata={setSavingMetadata} | |
| deletingStream={deletingStream} | |
| setDeletingStream={setDeletingStream} | |
| syncingVods={syncingVods} | |
| setSyncingVods={setSyncingVods} | |
| cleaningStreams={cleaningStreams} | |
| setCleaningStreams={setCleaningStreams} | |
| loadingAdminStats={loadingAdminStats} | |
| adminStats={adminStats} | |
| fetchAdminStats={fetchAdminStats} | |
| fetchStreams={fetchStreams} | |
| API_BASE={API_BASE} | |
| /> | |
| )} | |
| {activeTab === 'moderator' && ( | |
| <ModeratorTab | |
| twitchConfigured={twitchConfigured} | |
| auth={auth} | |
| handleLogin={handleLogin} | |
| TWITCH_CHANNEL={TWITCH_CHANNEL} | |
| modProfiles={modProfiles} | |
| selectedMod={selectedMod} | |
| setSelectedMod={setSelectedMod} | |
| API_BASE={API_BASE} | |
| /> | |
| )} | |
| </> | |
| )} | |
| </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> | |
| ); | |
| } | |