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