winx_prinx-api / client /src /components /AdminTab.jsx
Sasha
refactor: modularize client App.jsx tab components and update Netlify CSP
fdc7871
Raw
History Blame Contribute Delete
20.9 kB
import React from 'react';
import { Settings, Trash2, RefreshCw } from 'lucide-react';
export default function AdminTab({
adminSelectedStreamId,
setAdminSelectedStreamId,
streams,
editTitle,
setEditTitle,
editCategory,
setEditCategory,
savingMetadata,
setSavingMetadata,
deletingStream,
setDeletingStream,
syncingVods,
setSyncingVods,
cleaningStreams,
setCleaningStreams,
loadingAdminStats,
adminStats,
fetchAdminStats,
fetchStreams,
API_BASE
}) {
return (
<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 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>
);
}