Sasha
fix: prevent full-screen spinner on background stream polls
78d6a51
Raw
History Blame
99.2 kB
import React, { useState, useEffect, useRef } from 'react';
import {
BarChart2,
Users,
MessageSquare,
ShieldAlert,
Mic,
LogOut,
LogIn,
Activity,
Search,
Settings,
Lock,
Volume2,
RefreshCw,
Clock,
Trash2,
UserCheck
} from 'lucide-react';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
RadialLinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js';
import { Line, Radar } from 'react-chartjs-2';
// Register Chart.js components
ChartJS.register(
CategoryScale,
LinearScale,
RadialLinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
);
// Fallback to local port 3000 in development, or use VITE_API_URL if configured, otherwise same origin
const API_BASE = import.meta.env.VITE_API_URL || (import.meta.env.DEV ? 'http://localhost:3000' : '');
const TWITCH_CHANNEL = 'winx_prinx';
// 3D Galaxy Chatter Orbit Visualization
function ChatterOrbit({ chatters }) {
const canvasRef = useRef(null);
const containerRef = useRef(null);
const particlesRef = useRef(new Map());
const avatarRef = useRef(null);
useEffect(() => {
// Load Avatar via backend proxy (avoids CORS issues with decapi.me)
if (!avatarRef.current) {
const img = new Image();
img.src = `${API_BASE}/api/avatar/${TWITCH_CHANNEL}`;
img.onload = () => { avatarRef.current = img; };
img.onerror = () => console.warn('[ChatterOrbit] Avatar load failed');
}
}, []);
useEffect(() => {
const safeChatters = Array.isArray(chatters) ? chatters : [];
safeChatters.forEach(c => {
const username = c.username || c.display_name || 'unknown';
if (!particlesRef.current.has(username)) {
const isLurker = !c.has_chatted && !(c.message_count > 0);
// All particles go on the ring — tight band around ring radius ±8%
const ringNoise = (Math.random() - 0.5) * 0.16;
const radiusNorm = 1.0 + ringNoise;
particlesRef.current.set(username, {
username,
displayName: c.display_name || username,
angle: Math.random() * Math.PI * 2,
radiusNorm,
speed: (0.0003 + Math.random() * 0.0004) * (Math.random() > 0.5 ? 1 : -1),
isMod: c.is_mod,
isSub: c.is_sub,
isLurker,
messageCount: c.message_count || 0,
isStreamer: username.toLowerCase() === 'winx_prinx',
});
} else {
const p = particlesRef.current.get(username);
const isLurker = !c.has_chatted && !(c.message_count > 0);
p.isLurker = isLurker;
p.messageCount = c.message_count || p.messageCount;
p.isMod = c.is_mod || p.isMod;
p.isSub = c.is_sub || p.isSub;
}
});
}, [chatters]);
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container) return;
const ctx = canvas.getContext('2d');
let animationId;
const HEIGHT = 320;
const resize = () => {
const dpr = window.devicePixelRatio || 1;
const rect = container.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = HEIGHT * dpr;
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${HEIGHT}px`;
ctx.scale(dpr, dpr);
};
resize();
window.addEventListener('resize', resize);
let hoverName = null;
let hoverX = 0;
let hoverY = 0;
let mouseX = -9999;
let mouseY = -9999;
const handleMouseMove = (e) => {
const rect = canvas.getBoundingClientRect();
mouseX = e.clientX - rect.left;
mouseY = e.clientY - rect.top;
};
const handleMouseLeave = () => { mouseX = -9999; mouseY = -9999; };
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseleave', handleMouseLeave);
const animate = () => {
const width = canvas.width / (window.devicePixelRatio || 1);
const height = HEIGHT;
const cx = width / 2;
const cy = height / 2;
// Ring fills most of the canvas
const RX = width * 0.44;
const RY = height * 0.36;
const TILT = RY / RX;
// Solid near-black background
ctx.fillStyle = '#08080f';
ctx.fillRect(0, 0, width, height);
const particles = Array.from(particlesRef.current.values());
hoverName = null;
// Update positions
particles.forEach(p => {
p.angle += p.speed;
const r = p.radiusNorm * RX;
p.x = cx + Math.cos(p.angle) * r;
p.y = cy + Math.sin(p.angle) * r * TILT;
p.depth = (Math.sin(p.angle) * TILT + TILT) / (2 * TILT); // 0=back, 1=front
if (p.isStreamer) {
p.baseSize = 5;
p.color = '#A370F7';
} else if (p.isLurker) {
p.baseSize = 1;
p.color = null;
} else {
p.baseSize = Math.min(4, 1.5 + Math.log10((p.messageCount || 0) + 1) * 1.2);
p.color = p.isMod ? '#00F5D4' : p.isSub ? '#FF007F' : '#c8c8d8';
}
p.drawSize = p.baseSize;
});
// Sort back-to-front
particles.sort((a, b) => a.depth - b.depth);
// Draw particles
particles.forEach(p => {
const alpha = p.isLurker
? 0.15 + p.depth * 0.45
: 0.5 + p.depth * 0.5;
let color;
if (p.isLurker) {
const brightness = Math.round(160 + p.depth * 60);
color = `rgba(${brightness},${brightness},${brightness + 20},${alpha})`;
} else {
color = p.color;
}
const dist = Math.hypot(mouseX - p.x, mouseY - p.y);
if (dist < p.drawSize + 6) {
hoverName = p.displayName;
hoverX = p.x;
hoverY = p.y;
p.drawSize = Math.max(p.drawSize * 2.5, 5);
}
ctx.globalAlpha = p.isLurker ? 1 : alpha;
ctx.shadowBlur = 0;
if (!p.isLurker && (p.isMod || p.isSub || p.isStreamer)) {
ctx.shadowBlur = 6;
ctx.shadowColor = color;
}
ctx.beginPath();
ctx.arc(p.x, p.y, p.drawSize, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
});
ctx.globalAlpha = 1;
ctx.shadowBlur = 0;
// Draw Avatar on top
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, 26, 0, Math.PI * 2);
ctx.closePath();
ctx.shadowColor = '#9147ff';
ctx.shadowBlur = 24;
ctx.strokeStyle = 'rgba(145, 71, 255, 0.9)';
ctx.lineWidth = 2.5;
ctx.stroke();
ctx.shadowBlur = 0;
ctx.clip();
if (avatarRef.current) {
ctx.drawImage(avatarRef.current, cx - 26, cy - 26, 52, 52);
} else {
ctx.fillStyle = '#6441A5';
ctx.fill();
}
ctx.restore();
// Hover tooltip
if (hoverName) {
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
ctx.font = 'bold 11px Inter, sans-serif';
const tw = ctx.measureText(hoverName).width;
const tx = Math.min(hoverX + 12, width - tw - 20);
const ty = hoverY - 30 < 5 ? hoverY + 20 : hoverY - 30;
ctx.fillStyle = 'rgba(18,18,24,0.92)';
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(tx - 4, ty, tw + 16, 22, 5);
else ctx.rect(tx - 4, ty, tw + 16, 22);
ctx.fill();
ctx.strokeStyle = 'rgba(145,71,255,0.5)';
ctx.lineWidth = 1;
ctx.stroke();
ctx.fillStyle = '#efeff1';
ctx.fillText(hoverName, tx + 4, ty + 15);
}
animationId = requestAnimationFrame(animate);
};
animate();
return () => {
cancelAnimationFrame(animationId);
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mouseleave', handleMouseLeave);
window.removeEventListener('resize', resize);
};
}, []);
return (
<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>
);
}
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 (with loading spinner)
useEffect(() => {
if (selectedStreamId && serverStatus === 'online') {
fetchAllStats(true);
}
}, [selectedStreamId, serverStatus]);
// Manage polling intervals when selected stream changes or streams update
useEffect(() => {
if (selectedStreamId && serverStatus === 'online') {
const selectedStream = streams.find(s => s.id === parseInt(selectedStreamId));
const isLive = selectedStream && !selectedStream.end_time;
if (isLive || selectedStreamId === 'all') {
startPolling();
} else {
stopPolling();
}
}
return () => stopPolling();
}, [selectedStreamId, streams, serverStatus]);
const startPolling = () => {
stopPolling();
pollIntervalRef.current = setInterval(() => {
fetchAllStats(false); // poll silently without loading spinner
}, 15000); // refresh every 15s
};
const stopPolling = () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
};
// Optimize polling based on page visibility (pause if tab is backgrounded)
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
stopPolling();
} else if (document.visibilityState === 'visible' && selectedStreamId && serverStatus === 'online') {
fetchAllStats(false);
const selectedStream = streams.find(s => s.id === parseInt(selectedStreamId));
const isLive = selectedStream && !selectedStream.end_time;
if (isLive || selectedStreamId === 'all') {
startPolling();
}
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [selectedStreamId, streams, serverStatus]);
const fetchAuthStatus = async () => {
try {
const res = await fetch(`${API_BASE}/api/auth/status`, { credentials: 'include' });
const data = await res.json();
setTwitchConfigured(data.twitchConfigured !== false);
if (data.loggedIn) {
setAuth({ loggedIn: true, user: data.user });
} else {
setAuth({ loggedIn: false, user: null });
}
} catch (e) {
console.error('Auth check failed:', e);
}
};
const fetchStreams = async () => {
try {
const res = await fetch(`${API_BASE}/api/streams`);
const data = await res.json();
setStreams(data);
} catch (e) {
console.error('Failed to fetch streams:', e);
}
};
const getUrl = (endpoint, params = {}) => {
if (selectedStreamId !== 'all') {
params.stream_id = selectedStreamId;
}
const query = new URLSearchParams(params).toString();
return `${API_BASE}${endpoint}${query ? '?' + query : ''}`;
};
const fetchAllStats = async (showSpinner = true) => {
if (showSpinner) setLoading(true);
try {
// 1. Top Chatters
const chattersRes = await fetch(getUrl('/api/stats/chatters'));
const chattersData = await chattersRes.json();
setChatters(Array.isArray(chattersData) ? chattersData : []);
// 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 }}>
Сервер бэкенда в облаке может находиться в спящем режиме. Пробуждение сервера занимает около 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 isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
const stretchX = isMobile ? 1.4 : 2.8;
const stretchY = 1.0;
const paddingX = isMobile ? 6 : 12;
const paddingY = isMobile ? 4 : 8;
const fontScale = isMobile ? 0.45 : 1.0;
const remToPx = 16;
const centerGroupThreshold = wordType === 'chat' ? 250 : 500;
// Identify center group: words with count within threshold of the max count,
// but ONLY if the top count is >= threshold. Also cap center group size to at most 3 words.
const centerGroupWords = currentWordsList.filter((w, idx) =>
idx === 0 || (maxCount >= centerGroupThreshold && (maxCount - w.word_count) < centerGroupThreshold && idx < 3)
);
const otherWordsList = currentWordsList.filter((w, idx) =>
!(idx === 0 || (maxCount >= centerGroupThreshold && (maxCount - w.word_count) < centerGroupThreshold && idx < 3))
);
const maxOtherCount = otherWordsList.length > 0 ? Math.max(...otherWordsList.map(x => x.word_count)) : 1;
const minOtherCount = otherWordsList.length > 0 ? Math.min(...otherWordsList.map(x => x.word_count)) : 1;
const minCenterCount = Math.min(...centerGroupWords.map(x => x.word_count));
// Function to estimate word width factor based on wide/narrow letters
const getWordWidthFactor = (word) => {
let factor = 0;
const wideChars = /[мжшщыюяwm]/i;
const narrowChars = /[ilj1!|т]/i;
for (const char of word) {
if (wideChars.test(char)) {
factor += 0.75;
} else if (narrowChars.test(char)) {
factor += 0.35;
} else {
factor += 0.55;
}
}
return factor;
};
for (let i = 0; i < currentWordsList.length; i++) {
const w = currentWordsList[i];
const isCenterGroup = i === 0 || (maxCount >= centerGroupThreshold && (maxCount - w.word_count) < centerGroupThreshold && i < 3);
let fontSize;
let fontWeight;
let scaleValue = 0;
// Scale down the entire galaxy if the absolute max frequency is low (e.g. less than 200)
// If maxCount is 20, galaxyScale is ~0.65. If 200+, it's 1.0.
const galaxyScale = Math.min(Math.max(maxCount / 200, 0), 1.0) * 0.35 + 0.65;
if (isCenterGroup) {
// Scale font size within center group: ranges from 4.5rem to 6.2rem
const centerScale = maxCount === minCenterCount ? 1 : (w.word_count - minCenterCount) / (maxCount - minCenterCount);
fontSize = 4.5 + centerScale * 1.7;
fontWeight = '900';
scaleValue = centerScale;
} else {
// Scale font size within other words: ranges from 1.1rem to 3.2rem
const scale = maxOtherCount === minOtherCount ? 1 : (w.word_count - minOtherCount) / (maxOtherCount - minOtherCount);
scaleValue = Math.pow(scale, 1.4);
fontSize = 1.1 + scaleValue * 2.1;
fontWeight = fontSize > 2.0 ? '700' : (fontSize > 1.4 ? '600' : '500');
}
fontSize = fontSize * fontScale * galaxyScale;
// Precise width calculation based on custom width factors
const wordWidth = fontSize * getWordWidthFactor(w.word) * remToPx;
const wordHeight = fontSize * 1.1 * remToPx;
let x = 0;
let y = 0;
// Generate a stable hash for the word to randomize angle and jitter
let hash = 0;
for (let j = 0; j < w.word.length; j++) {
hash = w.word.charCodeAt(j) + ((hash << 5) - hash);
}
hash = Math.abs(hash);
// Search for position starting from r = 0.
// Since center group words are processed first, they cluster tightly around (0,0).
let found = false;
const rStep = 1.1;
for (let attempt = 0; attempt < 1200; attempt++) {
const startAngle = (hash % 100) * 0.06283; // 0 to 2*PI
const angle = startAngle + attempt * 0.15;
const r = (i === 0 && attempt === 0) ? 0 : (45 + rStep * attempt);
x = r * Math.cos(angle) * stretchX;
y = r * Math.sin(angle) * stretchY;
// Add coordinate noise/jitter (except for the absolute top word at the center)
if (r > 0) {
x += Math.sin(hash * 0.5 + attempt) * 6;
y += Math.cos(hash * 0.8 + attempt) * 4;
}
let collision = false;
for (const box of placedBoxes) {
const halfW1 = wordWidth / 2;
const halfH1 = wordHeight / 2;
const halfW2 = box.w / 2;
const halfH2 = box.h / 2;
if (Math.abs(x - box.x) < (halfW1 + halfW2 + paddingX) &&
Math.abs(y - box.y) < (halfH1 + halfH2 + paddingY)) {
collision = true;
break;
}
}
if (!collision) {
found = true;
break;
}
}
placedBoxes.push({ x: x, y: y, w: wordWidth, h: wordHeight });
const color = getWordColor(w.word_count);
// Floating parameters
const duration = 4.5 + (i % 3) + (i % 4) * 0.4; // 4.5s to 8.5s
const delay = -((i * 1.3) % 7); // negative delay to start asynchronously
const amount = 3 + (i % 4); // 3px to 6px float amount
laidOutWords.push({
word: w.word,
count: w.word_count,
x: Math.round(x),
y: Math.round(y),
fontSize: `${fontSize}rem`,
fontWeight: fontWeight,
color: color,
scale: scaleValue,
isCenterGroup: isCenterGroup,
duration: `${duration}s`,
delay: `${delay}s`,
amount: `${amount}px`
});
}
return (
<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>
);
}