import { useEffect, useRef, useState } from 'react'; import { getCurrentUser, sendVerificationCode, registerWithCode, login, loginTelegram, logout, fetchUsers, banUser, fetchPosts, updatePost, deletePost, removePostImage, banPostAuthor, fetchReports, updateReportStatus, publishToChannel, fetchAdmins, initiateAddAdmin, confirmAddAdmin, initiateRemoveAdmin, confirmRemoveAdmin, getPostComments, deleteComment, getApiUrl, getModerationConfig } from './utils/api'; import { io } from 'socket.io-client'; import { Loader2, Users, Image as ImageIcon, ShieldCheck, SendHorizontal, MessageSquare, RefreshCw, Trash2, Edit, Ban, UserPlus, UserMinus, Crown, X } from 'lucide-react'; const TABS = [ { id: 'users', title: 'Пользователи', icon: Users }, { id: 'posts', title: 'Посты', icon: ImageIcon }, { id: 'reports', title: 'Репорты', icon: ShieldCheck }, { id: 'admins', title: 'Админы', icon: Crown }, { id: 'chat', title: 'Чат', icon: MessageSquare }, { id: 'publish', title: 'Публикация', icon: SendHorizontal } ]; const FILTERS = [ { id: 'active', label: 'Активные < 7д' }, { id: 'inactive', label: 'Неактивные' }, { id: 'banned', label: 'Бан' } ]; const slotOptions = Array.from({ length: 10 }, (_, i) => i + 1); const initialChatState = { messages: [], online: [], connected: false }; function formatDate(dateString) { if (!dateString) return '—'; const date = new Date(dateString); return date.toLocaleString('ru-RU', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }); } function classNames(...args) { return args.filter(Boolean).join(' '); } export default function App() { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [tab, setTab] = useState('users'); // Users const [userFilter, setUserFilter] = useState('active'); const [usersData, setUsersData] = useState({ users: [], total: 0, totalPages: 1 }); const [usersLoading, setUsersLoading] = useState(false); // Posts const [postsData, setPostsData] = useState({ posts: [], totalPages: 1 }); const [postsLoading, setPostsLoading] = useState(false); // Reports const [reportsData, setReportsData] = useState({ reports: [], totalPages: 1 }); const [reportsLoading, setReportsLoading] = useState(false); // Publish const [publishState, setPublishState] = useState({ description: '', tags: '', slot: 1, files: [] }); const [publishing, setPublishing] = useState(false); // Admins const [adminsData, setAdminsData] = useState({ admins: [] }); const [adminsLoading, setAdminsLoading] = useState(false); const [adminModal, setAdminModal] = useState(null); // { action: 'add'|'remove', user/admin, adminNumber } const [confirmCode, setConfirmCode] = useState(''); // Chat const [chatState, setChatState] = useState(initialChatState); const [chatInput, setChatInput] = useState(''); const chatSocketRef = useRef(null); const chatListRef = useRef(null); const telegramWidgetRef = useRef(null); // Comments modal const [commentsModal, setCommentsModal] = useState(null); // { postId, comments: [] } const [commentsLoading, setCommentsLoading] = useState(false); // Конфигурация (bot username) const [moderationBotUsername, setModerationBotUsername] = useState('rbachbot'); // Форма авторизации const [authForm, setAuthForm] = useState({ step: 'login', // 'login', 'register-step1', 'register-step2' email: '', password: '', code: '', username: '', showPassword: false }); const [authLoading, setAuthLoading] = useState(false); // Загрузить конфигурацию бота при монтировании useEffect(() => { const loadConfig = async () => { try { const config = await getModerationConfig(); if (config.botUsername) { setModerationBotUsername(config.botUsername); } } catch (err) { console.warn('Не удалось загрузить конфигурацию бота:', err); } }; loadConfig(); }, []); useEffect(() => { let cancelled = false; const init = async () => { try { const telegramApp = window.Telegram?.WebApp; // Если это Telegram WebApp - попробовать авторизацию через Telegram if (telegramApp && telegramApp.initData) { telegramApp.disableVerticalSwipes?.(); telegramApp.expand?.(); try { const result = await loginTelegram(); if (cancelled) return; if (result && result.user) { setUser(result.user); setError(null); setLoading(false); return; } } catch (err) { console.error('Telegram авторизация не удалась:', err); // В MiniApp при ошибке показываем ошибку, а не форму входа setError(err?.response?.data?.error || 'Ошибка авторизации. Убедитесь, что у вас есть права модератора.'); setLoading(false); return; } } // Инициализация виджета будет в отдельном useEffect после монтирования компонента // Проверить JWT токен const jwtToken = localStorage.getItem('moderation_jwt_token'); if (jwtToken) { try { const userData = await getCurrentUser(); if (cancelled) return; setUser(userData); setError(null); setLoading(false); return; } catch (err) { // Если токен невалиден - очистить localStorage.removeItem('moderation_jwt_token'); localStorage.removeItem('moderation_token'); } } // Нет токена - показать форму входа setLoading(false); setError('login_required'); } catch (err) { if (cancelled) return; console.error('Ошибка инициализации модератора:', err); setError('login_required'); setLoading(false); } }; init(); return () => { cancelled = true; if (window.onTelegramAuth) { delete window.onTelegramAuth; } }; }, []); // Отдельный useEffect для инициализации виджета после монтирования useEffect(() => { const telegramApp = window.Telegram?.WebApp; const showLoginForm = error === 'login_required' || (!user && !loading); // Инициализировать виджет только если нет WebApp initData и показана форма входа if (!telegramApp?.initData && showLoginForm && telegramWidgetRef.current) { // Глобальная функция для обработки авторизации через виджет window.onTelegramAuth = async (userData) => { console.log('Telegram Login Widget данные:', userData); try { setAuthLoading(true); // Отправить данные виджета на сервер для создания сессии const API_URL = getApiUrl(); // В production используем относительный путь (HTTPS через nginx) const fullApiUrl = API_URL.startsWith('http') ? API_URL : `${window.location.origin}${API_URL}`; const response = await fetch(`${fullApiUrl}/moderation-auth/telegram-widget`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(userData) }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.error || 'Ошибка авторизации'); } const result = await response.json(); console.log('[Telegram Widget] Результат авторизации:', result); if (result.accessToken) { localStorage.setItem('moderation_jwt_token', result.accessToken); console.log('[Telegram Widget] Токен сохранен'); } if (result?.user) { setUser(result.user); setError(null); console.log('[Telegram Widget] Пользователь установлен:', result.user.username); // Перенаправить на главную страницу после успешной авторизации setTimeout(() => { window.location.href = '/'; }, 500); } else { throw new Error('Пользователь не получен от сервера'); } } catch (err) { console.error('Ошибка авторизации через виджет:', err); setError(err.message || 'Ошибка авторизации через Telegram'); } finally { setAuthLoading(false); } }; const initWidget = () => { // Проверить не загружен ли уже виджет if (document.querySelector('script[src*="telegram-widget"]')) { console.log('[Telegram Widget] Виджет уже загружен'); return; } const widgetContainer = telegramWidgetRef.current; if (!widgetContainer) { console.warn('[Telegram Widget] Контейнер не найден'); return; } console.log('[Telegram Widget] Инициализация виджета...'); // Очистить контейнер перед добавлением скрипта widgetContainer.innerHTML = ''; const script = document.createElement('script'); script.async = true; script.src = 'https://telegram.org/js/telegram-widget.js?22'; script.setAttribute('data-telegram-login', moderationBotUsername); script.setAttribute('data-size', 'large'); script.setAttribute('data-request-access', 'write'); script.setAttribute('data-onauth', 'onTelegramAuth'); script.setAttribute('data-radius', '10'); // Добавить auth-url для валидации домена // Telegram требует, чтобы auth-url был на том же домене, где размещен виджет const authUrl = `${window.location.origin}/api/moderation-auth/telegram-widget`; script.setAttribute('data-auth-url', authUrl); console.log('[Telegram Widget] Bot username:', moderationBotUsername); console.log('[Telegram Widget] Auth URL:', authUrl); console.log('[Telegram Widget] Current origin:', window.location.origin); script.onload = () => { console.log('[Telegram Widget] Скрипт загружен'); }; script.onerror = () => { console.error('[Telegram Widget] Ошибка загрузки скрипта'); }; widgetContainer.appendChild(script); }; // Подождать немного чтобы контейнер был готов const timer = setTimeout(initWidget, 100); return () => { clearTimeout(timer); if (window.onTelegramAuth) { delete window.onTelegramAuth; } }; } return () => { if (window.onTelegramAuth) { delete window.onTelegramAuth; } }; }, [error, user, loading, moderationBotUsername]); useEffect(() => { if (tab === 'users') { loadUsers(); } else if (tab === 'posts') { loadPosts(); } else if (tab === 'reports') { loadReports(); } else if (tab === 'admins') { loadAdmins(); } else if (tab === 'publish') { // Загрузить список админов для проверки прав публикации loadAdmins(); } else if (tab === 'chat' && user) { initChat(); } return () => { if (tab !== 'chat' && chatSocketRef.current) { chatSocketRef.current.disconnect(); chatSocketRef.current = null; setChatState(initialChatState); } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [tab, user, userFilter]); const loadUsers = async () => { setUsersLoading(true); try { const data = await fetchUsers({ filter: userFilter }); setUsersData(data); } catch (err) { console.error(err); } finally { setUsersLoading(false); } }; const loadPosts = async () => { setPostsLoading(true); try { const data = await fetchPosts(); setPostsData(data); } catch (err) { console.error(err); } finally { setPostsLoading(false); } }; const loadReports = async () => { setReportsLoading(true); try { const data = await fetchReports(); setReportsData(data); } catch (err) { console.error(err); } finally { setReportsLoading(false); } }; const loadAdmins = async () => { setAdminsLoading(true); try { const data = await fetchAdmins(); setAdminsData(data); } catch (err) { console.error(err); } finally { setAdminsLoading(false); } }; const handleInitiateAddAdmin = async (targetUser, adminNumber) => { try { const result = await initiateAddAdmin(targetUser.id, adminNumber); alert(`Код отправлен ${result.username}. Попросите пользователя ввести код.`); setAdminModal({ action: 'add', user: targetUser, adminNumber }); } catch (err) { alert(err.response?.data?.error || 'Ошибка отправки кода'); } }; const handleConfirmAddAdmin = async () => { if (!adminModal || !confirmCode) return; try { await confirmAddAdmin(adminModal.user.id, confirmCode); alert(`Админ ${adminModal.user.username} добавлен!`); setAdminModal(null); setConfirmCode(''); loadAdmins(); loadUsers(); } catch (err) { alert(err.response?.data?.error || 'Ошибка подтверждения'); } }; const handleInitiateRemoveAdmin = async (admin) => { try { const result = await initiateRemoveAdmin(admin.id); alert(`Код отправлен ${result.username}. Попросите админа ввести код.`); setAdminModal({ action: 'remove', admin }); } catch (err) { alert(err.response?.data?.error || 'Ошибка отправки кода'); } }; const handleConfirmRemoveAdmin = async () => { if (!adminModal || !confirmCode) return; try { await confirmRemoveAdmin(adminModal.admin.id, confirmCode); alert(`Админ ${adminModal.admin.username} удалён!`); setAdminModal(null); setConfirmCode(''); loadAdmins(); } catch (err) { alert(err.response?.data?.error || 'Ошибка подтверждения'); } }; const initChat = () => { if (!user) { console.error('[Chat] Нет user, отмена инициализации'); return; } if (chatSocketRef.current) { console.warn('[Chat] Socket уже существует'); return; } // Использовать тот же API URL что и в api.js const getApiUrl = () => { if (import.meta.env.VITE_API_URL) { return import.meta.env.VITE_API_URL; } // В production используем относительный путь (HTTPS) if (import.meta.env.PROD) { return '/api'; } // В development используем localhost (только для dev) return 'http://localhost:3001/api'; }; const API_URL = getApiUrl(); // Для WebSocket убираем "/api" из base URL, т.к. socket.io слушает на корне // В production используем текущий origin через wss:// (HTTPS) let socketBase = API_URL.replace(/\/?api\/?$/, ''); if (!socketBase || socketBase === '/api') { // В production используем текущий origin (HTTPS) socketBase = window.location.origin; } // Заменить http:// на https:// для безопасности (если не localhost) if (socketBase.startsWith('http://') && !socketBase.includes('localhost')) { socketBase = socketBase.replace('http://', 'https://'); } console.log('[Chat] Инициализация чата'); console.log('[Chat] WS base URL:', socketBase); console.log('[Chat] User данные:', { username: user.username, telegramId: user.telegramId, hasUsername: !!user.username, hasTelegramId: !!user.telegramId }); // Socket.IO подключается к /socket.io, но использует namespace /mod-chat // В production Socket.IO слушает на /socket.io, namespace указывается отдельно const socketUrl = socketBase.endsWith('/socket.io') ? socketBase : `${socketBase}/socket.io`; console.log('[Chat] Подключение к Socket.IO:', socketUrl); console.log('[Chat] Использование namespace: /mod-chat'); const socket = io(socketUrl, { namespace: '/mod-chat', transports: ['websocket', 'polling'], reconnection: true, reconnectionDelay: 1000, reconnectionAttempts: 5, timeout: 10000 }); socket.on('connect', () => { console.log('[Chat] ✅ WebSocket подключен, ID:', socket.id); console.log('[Chat] Отправка auth с данными:', { username: user.username, telegramId: user.telegramId }); socket.emit('auth', { username: user.username, telegramId: user.telegramId }); }); socket.on('ready', () => { console.log('Авторизация успешна!'); setChatState((prev) => ({ ...prev, connected: true })); }); socket.on('unauthorized', () => { console.error('Unauthorized в чате'); setChatState((prev) => ({ ...prev, connected: false })); socket.disconnect(); }); socket.on('message', (message) => { console.log('Получено сообщение:', message); setChatState((prev) => ({ ...prev, messages: [...prev.messages, message] })); setTimeout(() => { if (chatListRef.current) { chatListRef.current.scrollTo({ top: chatListRef.current.scrollHeight, behavior: 'smooth' }); } }, 100); }); socket.on('online', (online) => { console.log('Обновление списка онлайн:', online); setChatState((prev) => ({ ...prev, online })); }); socket.on('disconnect', (reason) => { console.log('WebSocket отключен:', reason); setChatState((prev) => ({ ...prev, connected: false })); }); socket.on('connect_error', (error) => { console.error('Ошибка подключения WebSocket:', error); }); chatSocketRef.current = socket; }; const handleSendChat = () => { if (!chatSocketRef.current || !chatState.connected) { console.warn('Чат не подключен'); return; } const text = chatInput.trim(); if (!text) return; console.log('Отправка сообщения:', text); chatSocketRef.current.emit('message', { text }); setChatInput(''); }; const handleBanUser = async (id, banned) => { const days = banned ? parseInt(prompt('Введите срок бана в днях', '7'), 10) : 0; await banUser(id, { banned, days }); loadUsers(); }; const handlePostEdit = async (post) => { const newContent = prompt('Новый текст поста', post.content || ''); if (newContent === null) return; await updatePost(post.id, { content: newContent }); loadPosts(); }; const handlePostDelete = async (postId) => { if (!window.confirm('Удалить пост?')) return; await deletePost(postId); loadPosts(); }; const handleRemoveImage = async (postId, index) => { await removePostImage(postId, index); loadPosts(); }; const handleBanAuthor = async (postId) => { const days = parseInt(prompt('Срок бана автора (в днях)', '7'), 10); await banPostAuthor(postId, { days }); loadPosts(); loadUsers(); }; const handleToggleArt = async (post) => { const newIsArt = !post.isArt; await updatePost(post.id, { isArt: newIsArt }); loadPosts(); }; const handleOpenComments = async (postId) => { setCommentsLoading(true); try { const post = await getPostComments(postId); setCommentsModal({ postId, comments: post.comments || [], postContent: post.content }); } catch (error) { console.error('Ошибка загрузки комментариев:', error); alert('Ошибка загрузки комментариев'); } finally { setCommentsLoading(false); } }; const handleDeleteComment = async (commentId) => { if (!commentsModal) return; if (!window.confirm('Удалить этот комментарий?')) return; try { await deleteComment(commentsModal.postId, commentId); // Обновить список комментариев const post = await getPostComments(commentsModal.postId); setCommentsModal({ ...commentsModal, comments: post.comments || [] }); } catch (error) { console.error('Ошибка удаления комментария:', error); alert('Ошибка удаления комментария'); } }; const handleReportStatus = async (reportId, status) => { await updateReportStatus(reportId, { status }); loadReports(); }; const handlePublish = async () => { if (!publishState.files.length) { alert('Добавьте изображения'); return; } setPublishing(true); try { const formData = new FormData(); publishState.files.forEach((file) => formData.append('images', file)); formData.append('description', publishState.description); formData.append('tags', JSON.stringify( publishState.tags .split(/[,\s]+/) .map((tag) => tag.trim()) .filter(Boolean) )); formData.append('slot', publishState.slot); await publishToChannel(formData); setPublishState({ description: '', tags: '', slot: 1, files: [] }); alert('Опубликовано в канал @reichenbfurry'); } catch (err) { console.error(err); alert('Не удалось опубликовать пост'); } finally { setPublishing(false); } }; const handleFileChange = (event) => { const files = Array.from(event.target.files || []).slice(0, 10); setPublishState((prev) => ({ ...prev, files })); }; const handleSendCode = async () => { if (!authForm.email || !authForm.email.includes('@')) { setError('Введите корректный email'); return; } setAuthLoading(true); try { await sendVerificationCode(authForm.email); setAuthForm(prev => ({ ...prev, step: 'register-step2' })); setError(null); } catch (err) { const message = err?.response?.data?.error || err?.message || 'Ошибка отправки кода'; setError(message); } finally { setAuthLoading(false); } }; const handleRegister = async () => { if (!authForm.code || !authForm.password || !authForm.username) { setError('Заполните все поля'); return; } if (authForm.password.length < 8) { setError('Пароль должен содержать минимум 8 символов'); return; } if (authForm.password.length > 24) { setError('Пароль должен содержать максимум 24 символа'); return; } setAuthLoading(true); try { const result = await registerWithCode( authForm.email, authForm.code, authForm.password, authForm.username ); setUser(result.user); setError(null); setAuthForm({ step: 'login', email: '', password: '', code: '', username: '', showPassword: false }); } catch (err) { const message = err?.response?.data?.error || err?.message || 'Ошибка регистрации'; setError(message); } finally { setAuthLoading(false); } }; const handleLogin = async () => { if (!authForm.email || !authForm.password) { setError('Введите email и пароль'); return; } setAuthLoading(true); try { const result = await login(authForm.email, authForm.password); setUser(result.user); setError(null); setAuthForm({ step: 'login', email: '', password: '', code: '', username: '', showPassword: false }); } catch (err) { const message = err?.response?.data?.error || err?.message || 'Ошибка авторизации'; setError(message); } finally { setAuthLoading(false); } }; const handleTelegramLogin = async () => { const telegramApp = window.Telegram?.WebApp; if (!telegramApp || !telegramApp.initData) { setError('Откройте через Telegram бота для авторизации'); return; } setAuthLoading(true); try { const result = await loginTelegram(); setUser(result.user); setError(null); } catch (err) { const message = err?.response?.data?.error || err?.message || 'Ошибка авторизации через Telegram'; setError(message); } finally { setAuthLoading(false); } }; const renderUsers = () => (
{report.reason || 'Причина не указана'}
{adminModal.action === 'add' ? `Код отправлен пользователю @${adminModal.user.username}. Введите код для подтверждения:` : `Код отправлен админу @${adminModal.admin.username}. Введите код для подтверждения:` }
setConfirmCode(e.target.value)} style={{ width: '100%', padding: '12px', fontSize: '16px', border: '1px solid var(--border)', borderRadius: '8px', background: 'var(--background-secondary)', color: 'var(--text-primary)', marginBottom: '16px' }} maxLength={6} />Загрузка модераторского интерфейса...
или
Для регистрации необходимо, чтобы администратор добавил ваш email в систему
Код отправлен на {authForm.email}
{error}
{!isMiniApp && error.includes('доступ') && ( )} {isMiniApp && ( )}{comment.content}