Sasha
fix: resolve infinite recursion in safeFetch
ae9e59e
Raw
History Blame Contribute Delete
29.1 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 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>
);
}