nakama/moderation/frontend/src/App.jsx

1832 lines
65 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = () => (
<div className="card">
<div className="section-header">
<h2>Пользователи</h2>
<button className="icon-btn" onClick={loadUsers} disabled={usersLoading}>
<RefreshCw size={18} />
</button>
</div>
<div className="filter-chips">
{FILTERS.map((filter) => (
<button
key={filter.id}
className={classNames('chip', userFilter === filter.id && 'chip-active')}
onClick={() => setUserFilter(filter.id)}
>
{filter.label}
</button>
))}
</div>
{usersLoading ? (
<div className="loader">
<Loader2 className="spin" size={32} />
</div>
) : (
<div className="list">
{usersData.users.map((u) => (
<div key={u.id} className="list-item">
<div className="list-item-main">
<div className="list-item-title">@{u.username}</div>
<div className="list-item-subtitle">
{u.firstName} {u.lastName || ''}
</div>
<div className="list-item-meta">
<span>Роль: {u.role}</span>
<span>Активность: {formatDate(u.lastActiveAt)}</span>
{u.referralsCount > 0 && <span className="badge badge-info">Рефералов: {u.referralsCount}</span>}
{u.banned && <span className="badge badge-danger">Бан до {formatDate(u.bannedUntil)}</span>}
</div>
</div>
<div className="list-item-actions">
{user.username === 'glpshchn00' && !u.isAdmin && (
<button
className="btn"
onClick={() => {
const num = prompt('Введите номер админа (1-10):', '1');
if (num && !isNaN(num) && num >= 1 && num <= 10) {
handleInitiateAddAdmin(u, parseInt(num));
}
}}
>
<UserPlus size={16} /> Назначить
</button>
)}
{u.banned ? (
<button className="btn" onClick={() => handleBanUser(u.id, false)}>
Разблокировать
</button>
) : (
<button className="btn danger" onClick={() => handleBanUser(u.id, true)}>
Забанить
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
);
const renderPosts = () => (
<div className="card">
<div className="section-header">
<h2>Посты</h2>
<button className="icon-btn" onClick={loadPosts} disabled={postsLoading}>
<RefreshCw size={18} />
</button>
</div>
{postsLoading ? (
<div className="loader">
<Loader2 className="spin" size={32} />
</div>
) : (
<div className="list">
{postsData.posts.map((post) => (
<div key={post.id} className="list-item">
<div className="list-item-main">
<div className="list-item-title">
Автор: @{post.author?.username || 'Удалён'} {formatDate(post.createdAt)}
</div>
<div className="post-content-preview">{post.content || 'Без текста'}</div>
<div className="list-item-meta">
<span>Лайки: {post.likesCount}</span>
<span>Комментарии: {post.commentsCount}</span>
{post.isNSFW && <span className="badge badge-warning">NSFW</span>}
</div>
{post.images?.length ? (
<div className="image-grid">
{post.images.map((img, idx) => {
// Преобразовать относительный путь в абсолютный
const getImageBaseUrl = () => {
if (import.meta.env.VITE_API_URL) {
return import.meta.env.VITE_API_URL.replace('/api', '');
}
if (import.meta.env.PROD) {
return window.location.origin;
}
return 'http://localhost:3000';
};
const imageUrl = img.startsWith('http')
? img
: `${getImageBaseUrl()}${img}`;
return (
<div key={idx} className="image-thumb">
<img src={imageUrl} alt="" onError={(e) => {
e.target.style.display = 'none';
console.error('Failed to load image:', imageUrl);
}} />
<button className="image-remove" onClick={() => handleRemoveImage(post.id, idx)}>
<Trash2 size={16} />
</button>
</div>
);
})}
</div>
) : null}
</div>
<div className="list-item-actions">
<button className="btn" onClick={() => handleOpenComments(post.id)}>
<MessageSquare size={16} />
Комментарии ({post.commentsCount || 0})
</button>
<button className="btn" onClick={() => handlePostEdit(post)}>
<Edit size={16} />
Редактировать
</button>
<button
className={`btn ${post.isArt ? 'active' : ''}`}
onClick={() => handleToggleArt(post)}
style={post.isArt ? { backgroundColor: '#4CAF50', color: 'white' } : {}}
>
🎨 Арт
</button>
<button className="btn danger" onClick={() => handlePostDelete(post.id)}>
<Trash2 size={16} />
Удалить
</button>
<button className="btn warn" onClick={() => handleBanAuthor(post.id)}>
<Ban size={16} />
Бан автора
</button>
</div>
</div>
))}
</div>
)}
</div>
);
const renderReports = () => (
<div className="card">
<div className="section-header">
<h2>Репорты</h2>
<button className="icon-btn" onClick={loadReports} disabled={reportsLoading}>
<RefreshCw size={18} />
</button>
</div>
{reportsLoading ? (
<div className="loader">
<Loader2 className="spin" size={32} />
</div>
) : (
<div className="list">
{reportsData.reports.map((report) => (
<div key={report.id} className="list-item">
<div className="list-item-main">
<div className="list-item-title">
Репорт от @{report.reporter?.username || 'Unknown'} {formatDate(report.createdAt)}
</div>
<div className="list-item-subtitle">Статус: {report.status}</div>
<div className="report-content">
<div style={{ marginBottom: '12px', padding: '12px', backgroundColor: 'var(--bg-secondary)', borderRadius: '8px' }}>
<strong>Причина жалобы:</strong>
<p style={{ marginTop: '4px' }}>{report.reason || 'Причина не указана'}</p>
</div>
{report.post && (
<div className="report-post" style={{ padding: '12px', backgroundColor: 'var(--bg-secondary)', borderRadius: '8px', marginBottom: '12px' }}>
<div style={{ marginBottom: '8px' }}>
<strong>Пост от @{report.post.author?.username || 'Удалён'}</strong>
</div>
<div style={{ marginBottom: '8px' }}>
{report.post.content || 'Без текста'}
</div>
{report.post.images?.length > 0 && (
<div className="image-grid" style={{ marginTop: '8px' }}>
{report.post.images.slice(0, 3).map((img, idx) => {
const getImageBaseUrl = () => {
if (import.meta.env.VITE_API_URL) {
return import.meta.env.VITE_API_URL.replace('/api', '');
}
// В production используем текущий origin (HTTPS через nginx)
if (import.meta.env.PROD) {
return window.location.origin;
}
// Только для development
return 'http://localhost:3000';
};
const imageUrl = img.startsWith('http')
? img
: `${getImageBaseUrl()}${img}`;
return (
<img
key={idx}
src={imageUrl}
alt=""
style={{
width: '80px',
height: '80px',
objectFit: 'cover',
borderRadius: '4px',
marginRight: '4px'
}}
onError={(e) => {
e.target.style.display = 'none';
console.error('Failed to load image:', imageUrl);
}}
/>
);
})}
{report.post.images.length > 3 && (
<span style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
+{report.post.images.length - 3} ещё
</span>
)}
</div>
)}
</div>
)}
</div>
</div>
<div className="list-item-actions">
{report.post && (
<>
<button className="btn danger" onClick={() => handlePostDelete(report.post.id)}>
<Trash2 size={16} />
Удалить пост
</button>
{report.post.author && (
<button className="btn warn" onClick={() => handleBanAuthor(report.post.id)}>
<Ban size={16} />
Забанить автора (срок)
</button>
)}
</>
)}
<button className="btn" onClick={() => handleReportStatus(report.id, 'resolved')}>
Закрыть как решённый
</button>
<button className="btn" onClick={() => handleReportStatus(report.id, 'dismissed')}>
Пропустить
</button>
</div>
</div>
))}
{reportsData.reports.length === 0 && (
<div style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
Нет активных репортов
</div>
)}
</div>
)}
</div>
);
const renderChat = () => (
<div className="card chat-card">
<div className="section-header">
<h2>Лайвчат админов</h2>
{chatState.connected ? (
<span className="badge badge-success">В сети</span>
) : (
<span className="badge badge-warning">Подключение...</span>
)}
</div>
<div className="chat-online">
Онлайн:{' '}
{chatState.online.length
? chatState.online.map((admin) => `@${admin.username}`).join(', ')
: '—'}
</div>
<div className="chat-list" ref={chatListRef}>
{chatState.messages.map((message) => (
<div
key={message.id}
className={classNames(
'chat-message',
message.username === user.username && 'chat-message-own'
)}
>
<div className="chat-message-author">@{message.username}</div>
<div className="chat-message-text">{message.text}</div>
<div className="chat-message-time">{formatDate(message.createdAt)}</div>
</div>
))}
</div>
<div className="chat-input">
<input
type="text"
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
placeholder="Сообщение для админов..."
/>
<button className="btn" onClick={handleSendChat} disabled={!chatState.connected}>
<SendHorizontal size={18} />
</button>
</div>
</div>
);
const renderPublish = () => {
// Найти админа текущего пользователя
const currentAdmin = adminsData.admins.find((admin) => admin.telegramId === user.telegramId);
const canPublish = currentAdmin && currentAdmin.adminNumber >= 1 && currentAdmin.adminNumber <= 10;
return (
<div className="card">
<div className="section-header">
<h2>Публикация в @reichenbfurry</h2>
</div>
{!canPublish && (
<div style={{ padding: '16px', backgroundColor: 'var(--bg-secondary)', borderRadius: '8px', marginBottom: '16px', color: 'var(--text-secondary)' }}>
Публиковать в канал могут только админы с номерами от 1 до 10.
{currentAdmin ? (
<div style={{ marginTop: '8px' }}>
Ваш номер: <strong>#{currentAdmin.adminNumber}</strong> (доступ запрещён)
</div>
) : (
<div style={{ marginTop: '8px' }}>
Вам не присвоен номер админа. Обратитесь к владельцу.
</div>
)}
</div>
)}
<div className="publish-form">
<label>
Описание
<textarea
value={publishState.description}
onChange={(e) =>
setPublishState((prev) => ({ ...prev, description: e.target.value }))
}
maxLength={1024}
placeholder="Текст поста"
disabled={!canPublish}
/>
</label>
<label>
Теги (через пробел или запятую)
<input
type="text"
value={publishState.tags}
onChange={(e) => setPublishState((prev) => ({ ...prev, tags: e.target.value }))}
placeholder="#furry #art"
disabled={!canPublish}
/>
</label>
{currentAdmin && (
<div style={{ padding: '12px', backgroundColor: 'var(--bg-secondary)', borderRadius: '8px', marginBottom: '8px' }}>
Ваш номер админа: <strong>#{currentAdmin.adminNumber}</strong>
<div style={{ fontSize: '12px', color: 'var(--text-secondary)', marginTop: '4px' }}>
Автоматически будет добавлен тег #a{currentAdmin.adminNumber}
</div>
</div>
)}
<label>
Медиа (до 10, фото или видео)
<input
type="file"
accept="image/*,video/*"
multiple
onChange={handleFileChange}
disabled={!canPublish}
/>
</label>
{publishState.files.length > 0 && (
<div className="file-list">
{publishState.files.map((file, index) => (
<div key={index} className="file-item">
{file.name} ({Math.round(file.size / 1024)} KB)
</div>
))}
</div>
)}
<button
className="btn primary"
disabled={publishing || !canPublish}
onClick={handlePublish}
>
{publishing ? <Loader2 className="spin" size={18} /> : <SendHorizontal size={18} />}
Опубликовать
</button>
</div>
</div>
);
};
const renderAdmins = () => (
<div className="card">
<div className="section-header">
<h2>Админы модерации</h2>
<button className="icon-btn" onClick={loadAdmins} disabled={adminsLoading}>
<RefreshCw size={18} />
</button>
</div>
{adminsLoading ? (
<div className="loader">
<Loader2 className="spin" size={32} />
</div>
) : (
<div className="list">
{adminsData.admins.map((admin) => (
<div key={admin.id} className="list-item">
<div className="list-item-main">
<div className="list-item-title">
<Crown size={16} color="gold" /> @{admin.username} Номер {admin.adminNumber}
</div>
<div className="list-item-subtitle">
{admin.firstName} {admin.lastName || ''}
</div>
<div className="list-item-meta">
<span>Добавил: {admin.addedBy}</span>
<span>Дата: {formatDate(admin.createdAt)}</span>
</div>
</div>
{user.username === 'glpshchn00' && (
<div className="list-item-actions">
<button
className="btn danger"
onClick={() => handleInitiateRemoveAdmin(admin)}
>
<UserMinus size={16} /> Снять
</button>
</div>
)}
</div>
))}
{adminsData.admins.length === 0 && (
<div style={{ textAlign: 'center', padding: '20px', color: 'var(--text-secondary)' }}>
Нет админов
</div>
)}
</div>
)}
{/* Модальное окно подтверждения */}
{adminModal && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
background: 'var(--background)',
padding: '20px',
borderRadius: '12px',
maxWidth: '400px',
width: '90%'
}}>
<h3 style={{ marginTop: 0 }}>
{adminModal.action === 'add' ? 'Подтверждение добавления админа' : 'Подтверждение удаления админа'}
</h3>
<p>
{adminModal.action === 'add'
? `Код отправлен пользователю @${adminModal.user.username}. Введите код для подтверждения:`
: `Код отправлен админу @${adminModal.admin.username}. Введите код для подтверждения:`
}
</p>
<input
type="text"
placeholder="6-значный код"
value={confirmCode}
onChange={(e) => 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}
/>
<div style={{ display: 'flex', gap: '8px' }}>
<button
className="btn"
onClick={adminModal.action === 'add' ? handleConfirmAddAdmin : handleConfirmRemoveAdmin}
disabled={confirmCode.length !== 6}
>
Подтвердить
</button>
<button
className="btn"
onClick={() => {
setAdminModal(null);
setConfirmCode('');
}}
>
Отмена
</button>
</div>
</div>
</div>
)}
</div>
);
const renderContent = () => {
switch (tab) {
case 'users':
return renderUsers();
case 'posts':
return renderPosts();
case 'reports':
return renderReports();
case 'admins':
return renderAdmins();
case 'chat':
return renderChat();
case 'publish':
return renderPublish();
default:
return null;
}
};
if (loading) {
return (
<div className="fullscreen-center">
<Loader2 className="spin" size={48} />
<p>Загрузка модераторского интерфейса...</p>
</div>
);
}
// Показать форму входа ТОЛЬКО если это НЕ MiniApp
const telegramApp = window.Telegram?.WebApp;
const isMiniApp = telegramApp && telegramApp.initData;
if (error === 'login_required' || (!user && !loading && !isMiniApp)) {
return (
<div className="fullscreen-center">
<div className="login-card">
<h1 style={{ marginTop: 0, marginBottom: '24px' }}>Вход в модерацию</h1>
{/* Telegram авторизация в MiniApp - не показываем, авторизация автоматическая */}
{/* Telegram Login Widget для обычного браузера */}
{!isMiniApp && (
<>
<div
id="telegram-login-widget"
ref={telegramWidgetRef}
style={{
marginBottom: '24px',
display: 'flex',
justifyContent: 'center',
minHeight: '48px'
}}
/>
<div style={{
textAlign: 'center',
marginBottom: '24px',
padding: '16px',
background: 'rgba(255, 255, 255, 0.05)',
borderRadius: '8px'
}}>
<p style={{ margin: 0, fontSize: '14px', color: 'var(--text-secondary)' }}>
или
</p>
</div>
</>
)}
{/* Авторизация по email/паролю */}
{authForm.step === 'login' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%', maxWidth: '400px' }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<span>Email</span>
<input
type="email"
value={authForm.email}
onChange={(e) => setAuthForm((prev) => ({ ...prev, email: e.target.value }))}
placeholder="email@example.com"
style={{
padding: '12px',
borderRadius: '8px',
border: '1px solid rgba(255, 255, 255, 0.12)',
background: 'rgba(255, 255, 255, 0.04)',
color: 'var(--text-primary)',
fontSize: '14px'
}}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<span>Пароль</span>
<input
type={authForm.showPassword ? 'text' : 'password'}
value={authForm.password}
onChange={(e) => setAuthForm((prev) => ({ ...prev, password: e.target.value }))}
placeholder="Введите пароль"
style={{
padding: '12px',
borderRadius: '8px',
border: '1px solid rgba(255, 255, 255, 0.12)',
background: 'rgba(255, 255, 255, 0.04)',
color: 'var(--text-primary)',
fontSize: '14px'
}}
/>
</label>
<div style={{ display: 'flex', gap: '12px' }}>
<button
className="btn primary"
onClick={handleLogin}
disabled={authLoading}
style={{ flex: 1, justifyContent: 'center' }}
>
{authLoading ? <Loader2 className="spin" size={18} /> : 'Войти'}
</button>
</div>
<button
className="btn"
onClick={() => setAuthForm((prev) => ({ ...prev, step: 'register-step1' }))}
style={{ fontSize: '14px', padding: '8px' }}
>
Зарегистрироваться
</button>
</div>
)}
{/* Регистрация шаг 1 - отправка кода */}
{authForm.step === 'register-step1' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%', maxWidth: '400px' }}>
<p style={{ color: 'var(--text-secondary)', fontSize: '14px', marginBottom: '8px' }}>
Для регистрации необходимо, чтобы администратор добавил ваш email в систему
</p>
<label style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<span>Email</span>
<input
type="email"
value={authForm.email}
onChange={(e) => setAuthForm((prev) => ({ ...prev, email: e.target.value }))}
placeholder="email@example.com"
style={{
padding: '12px',
borderRadius: '8px',
border: '1px solid rgba(255, 255, 255, 0.12)',
background: 'rgba(255, 255, 255, 0.04)',
color: 'var(--text-primary)',
fontSize: '14px'
}}
/>
</label>
<button
className="btn primary"
onClick={handleSendCode}
disabled={authLoading}
style={{ width: '100%', justifyContent: 'center' }}
>
{authLoading ? <Loader2 className="spin" size={18} /> : 'Отправить код на email'}
</button>
<button
className="btn"
onClick={() => setAuthForm((prev) => ({ ...prev, step: 'login' }))}
style={{ fontSize: '14px', padding: '8px' }}
>
Вернуться к входу
</button>
</div>
)}
{/* Регистрация шаг 2 - ввод кода и пароля */}
{authForm.step === 'register-step2' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px', width: '100%', maxWidth: '400px' }}>
<p style={{ color: 'var(--text-secondary)', fontSize: '14px', marginBottom: '8px' }}>
Код отправлен на {authForm.email}
</p>
<label style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<span>Код подтверждения</span>
<input
type="text"
value={authForm.code}
onChange={(e) => setAuthForm((prev) => ({ ...prev, code: e.target.value.replace(/\D/g, '').slice(0, 6) }))}
placeholder="000000"
maxLength={6}
style={{
padding: '12px',
borderRadius: '8px',
border: '1px solid rgba(255, 255, 255, 0.12)',
background: 'rgba(255, 255, 255, 0.04)',
color: 'var(--text-primary)',
fontSize: '18px',
textAlign: 'center',
letterSpacing: '8px'
}}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<span>Имя пользователя</span>
<input
type="text"
value={authForm.username}
onChange={(e) => setAuthForm((prev) => ({ ...prev, username: e.target.value }))}
placeholder="username"
style={{
padding: '12px',
borderRadius: '8px',
border: '1px solid rgba(255, 255, 255, 0.12)',
background: 'rgba(255, 255, 255, 0.04)',
color: 'var(--text-primary)',
fontSize: '14px'
}}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<span>Пароль</span>
<input
type={authForm.showPassword ? 'text' : 'password'}
value={authForm.password}
onChange={(e) => setAuthForm((prev) => ({ ...prev, password: e.target.value }))}
placeholder="От 8 до 24 символов"
style={{
padding: '12px',
borderRadius: '8px',
border: '1px solid rgba(255, 255, 255, 0.12)',
background: 'rgba(255, 255, 255, 0.04)',
color: 'var(--text-primary)',
fontSize: '14px'
}}
/>
</label>
<button
className="btn primary"
onClick={handleRegister}
disabled={authLoading || authForm.code.length !== 6 || !authForm.password || !authForm.username}
style={{ width: '100%', justifyContent: 'center' }}
>
{authLoading ? <Loader2 className="spin" size={18} /> : 'Зарегистрироваться'}
</button>
<button
className="btn"
onClick={() => setAuthForm((prev) => ({ ...prev, step: 'register-step1' }))}
style={{ fontSize: '14px', padding: '8px' }}
>
Изменить email
</button>
</div>
)}
{error && error !== 'login_required' && (
<div style={{
marginTop: '16px',
padding: '12px',
background: 'rgba(255, 59, 48, 0.1)',
borderRadius: '8px',
color: '#ff453a',
fontSize: '14px'
}}>
{error}
</div>
)}
</div>
</div>
);
}
// Показать ошибку (особенно важно для MiniApp)
if (error && error !== 'login_required') {
const telegramApp = window.Telegram?.WebApp;
const isMiniApp = telegramApp && telegramApp.initData;
return (
<div className="fullscreen-center">
<ShieldCheck size={48} />
<p style={{ marginTop: '16px', textAlign: 'center', maxWidth: '400px' }}>{error}</p>
{!isMiniApp && error.includes('доступ') && (
<button
className="btn"
onClick={() => {
localStorage.removeItem('moderation_token');
localStorage.removeItem('moderation_jwt_token');
setError('login_required');
}}
style={{ marginTop: '16px' }}
>
Войти заново
</button>
)}
{isMiniApp && (
<button
className="btn"
onClick={async () => {
setLoading(true);
setError(null);
try {
const result = await loginTelegram();
setUser(result.user);
setError(null);
} catch (err) {
setError(err?.response?.data?.error || 'Ошибка авторизации');
} finally {
setLoading(false);
}
}}
style={{ marginTop: '16px' }}
>
Попробовать снова
</button>
)}
</div>
);
}
return (
<div className="app-container">
<header className="app-header">
<div>
<h1>Nakama Moderation</h1>
<span className="subtitle">@{user.username}</span>
</div>
<button
className="btn"
onClick={async () => {
await logout();
window.location.reload();
}}
style={{ fontSize: '14px' }}
>
Выйти
</button>
</header>
<nav className="tabbar">
{TABS.map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
className={classNames('tab-btn', tab === item.id && 'tab-btn-active')}
onClick={() => setTab(item.id)}
>
<Icon size={18} />
<span>{item.title}</span>
</button>
);
})}
</nav>
<main className="content">{renderContent()}</main>
{/* Модалка комментариев */}
{commentsModal && (
<div
className="modal-overlay"
onClick={() => setCommentsModal(null)}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.5)',
zIndex: 10000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px'
}}
>
<div
className="modal-content"
onClick={(e) => e.stopPropagation()}
style={{
background: 'var(--bg-primary)',
borderRadius: '12px',
maxWidth: '600px',
width: '100%',
maxHeight: '80vh',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}
>
<div style={{
padding: '16px',
borderBottom: '1px solid var(--divider-color)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}>
<h2 style={{ margin: 0, fontSize: '18px', fontWeight: 600 }}>Комментарии</h2>
<button
onClick={() => setCommentsModal(null)}
style={{
width: '32px',
height: '32px',
borderRadius: '50%',
background: 'var(--bg-secondary)',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<X size={20} />
</button>
</div>
<div style={{
padding: '16px',
overflowY: 'auto',
flex: 1
}}>
{commentsLoading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>
<Loader2 className="spin" size={32} />
</div>
) : commentsModal.comments.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
Нет комментариев
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
{commentsModal.comments.map((comment) => (
<div
key={comment._id || comment.id}
style={{
padding: '12px',
background: 'var(--bg-secondary)',
borderRadius: '8px',
display: 'flex',
gap: '12px'
}}
>
<div style={{ flex: 1 }}>
<div style={{
display: 'flex',
gap: '8px',
marginBottom: '4px',
alignItems: 'center'
}}>
<strong style={{ fontSize: '14px' }}>
{comment.author?.firstName || comment.author?.username || 'Пользователь'}
</strong>
<span style={{ fontSize: '12px', color: 'var(--text-secondary)' }}>
{formatDate(comment.createdAt)}
</span>
</div>
<p style={{
margin: 0,
fontSize: '14px',
lineHeight: '1.5',
whiteSpace: 'pre-wrap'
}}>
{comment.content}
</p>
</div>
<button
onClick={() => handleDeleteComment(comment._id || comment.id)}
style={{
width: '32px',
height: '32px',
borderRadius: '50%',
background: '#FF3B30',
color: 'white',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0
}}
title="Удалить комментарий"
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}