1832 lines
65 KiB
JavaScript
1832 lines
65 KiB
JavaScript
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>
|
||
);
|
||
}
|
||
|