2025-11-10 21:22:58 +00:00
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
2025-11-10 20:13:22 +00:00
|
|
|
|
import {
|
2025-12-08 23:42:32 +00:00
|
|
|
|
getCurrentUser,
|
|
|
|
|
|
sendVerificationCode,
|
|
|
|
|
|
registerWithCode,
|
|
|
|
|
|
login,
|
|
|
|
|
|
loginTelegram,
|
|
|
|
|
|
logout,
|
2025-11-10 20:13:22 +00:00
|
|
|
|
fetchUsers,
|
|
|
|
|
|
banUser,
|
|
|
|
|
|
fetchPosts,
|
|
|
|
|
|
updatePost,
|
|
|
|
|
|
deletePost,
|
|
|
|
|
|
removePostImage,
|
|
|
|
|
|
banPostAuthor,
|
|
|
|
|
|
fetchReports,
|
|
|
|
|
|
updateReportStatus,
|
2025-11-11 00:40:29 +00:00
|
|
|
|
publishToChannel,
|
|
|
|
|
|
fetchAdmins,
|
|
|
|
|
|
initiateAddAdmin,
|
|
|
|
|
|
confirmAddAdmin,
|
|
|
|
|
|
initiateRemoveAdmin,
|
2025-12-04 21:45:02 +00:00
|
|
|
|
confirmRemoveAdmin,
|
|
|
|
|
|
getPostComments,
|
2025-12-08 23:42:32 +00:00
|
|
|
|
deleteComment,
|
2025-12-09 01:03:25 +00:00
|
|
|
|
getApiUrl,
|
|
|
|
|
|
getModerationConfig
|
2025-11-10 20:13:22 +00:00
|
|
|
|
} from './utils/api';
|
|
|
|
|
|
import { io } from 'socket.io-client';
|
|
|
|
|
|
import {
|
|
|
|
|
|
Loader2,
|
|
|
|
|
|
Users,
|
|
|
|
|
|
Image as ImageIcon,
|
|
|
|
|
|
ShieldCheck,
|
|
|
|
|
|
SendHorizontal,
|
|
|
|
|
|
MessageSquare,
|
|
|
|
|
|
RefreshCw,
|
|
|
|
|
|
Trash2,
|
|
|
|
|
|
Edit,
|
2025-11-11 00:40:29 +00:00
|
|
|
|
Ban,
|
|
|
|
|
|
UserPlus,
|
|
|
|
|
|
UserMinus,
|
2025-12-04 21:45:02 +00:00
|
|
|
|
Crown,
|
|
|
|
|
|
X
|
2025-11-10 20:13:22 +00:00
|
|
|
|
} from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
|
|
const TABS = [
|
|
|
|
|
|
{ id: 'users', title: 'Пользователи', icon: Users },
|
|
|
|
|
|
{ id: 'posts', title: 'Посты', icon: ImageIcon },
|
|
|
|
|
|
{ id: 'reports', title: 'Репорты', icon: ShieldCheck },
|
2025-11-11 00:40:29 +00:00
|
|
|
|
{ id: 'admins', title: 'Админы', icon: Crown },
|
2025-11-10 20:13:22 +00:00
|
|
|
|
{ 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);
|
|
|
|
|
|
|
2025-11-11 00:40:29 +00:00
|
|
|
|
// 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('');
|
|
|
|
|
|
|
2025-11-10 20:13:22 +00:00
|
|
|
|
// Chat
|
|
|
|
|
|
const [chatState, setChatState] = useState(initialChatState);
|
|
|
|
|
|
const [chatInput, setChatInput] = useState('');
|
|
|
|
|
|
const chatSocketRef = useRef(null);
|
|
|
|
|
|
const chatListRef = useRef(null);
|
2025-12-09 00:29:13 +00:00
|
|
|
|
const telegramWidgetRef = useRef(null);
|
2025-11-10 20:13:22 +00:00
|
|
|
|
|
2025-12-04 21:45:02 +00:00
|
|
|
|
// Comments modal
|
|
|
|
|
|
const [commentsModal, setCommentsModal] = useState(null); // { postId, comments: [] }
|
|
|
|
|
|
const [commentsLoading, setCommentsLoading] = useState(false);
|
2025-12-08 23:42:32 +00:00
|
|
|
|
|
2025-12-09 01:03:25 +00:00
|
|
|
|
// Конфигурация (bot username)
|
2025-12-15 01:16:04 +00:00
|
|
|
|
const [moderationBotUsername, setModerationBotUsername] = useState('rbachbot');
|
2025-12-09 01:03:25 +00:00
|
|
|
|
|
2025-12-08 23:42:32 +00:00
|
|
|
|
// Форма авторизации
|
|
|
|
|
|
const [authForm, setAuthForm] = useState({
|
|
|
|
|
|
step: 'login', // 'login', 'register-step1', 'register-step2'
|
|
|
|
|
|
email: '',
|
|
|
|
|
|
password: '',
|
|
|
|
|
|
code: '',
|
|
|
|
|
|
username: '',
|
|
|
|
|
|
showPassword: false
|
|
|
|
|
|
});
|
|
|
|
|
|
const [authLoading, setAuthLoading] = useState(false);
|
2025-12-04 21:45:02 +00:00
|
|
|
|
|
2025-12-09 01:03:25 +00:00
|
|
|
|
// Загрузить конфигурацию бота при монтировании
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const loadConfig = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const config = await getModerationConfig();
|
|
|
|
|
|
if (config.botUsername) {
|
|
|
|
|
|
setModerationBotUsername(config.botUsername);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.warn('Не удалось загрузить конфигурацию бота:', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
loadConfig();
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2025-11-10 20:13:22 +00:00
|
|
|
|
useEffect(() => {
|
2025-11-10 21:36:23 +00:00
|
|
|
|
let cancelled = false;
|
|
|
|
|
|
|
2025-12-08 23:42:32 +00:00
|
|
|
|
|
2025-11-10 20:13:22 +00:00
|
|
|
|
const init = async () => {
|
|
|
|
|
|
try {
|
2025-11-10 21:36:23 +00:00
|
|
|
|
const telegramApp = window.Telegram?.WebApp;
|
|
|
|
|
|
|
2025-12-08 23:42:32 +00:00
|
|
|
|
// Если это Telegram WebApp - попробовать авторизацию через Telegram
|
|
|
|
|
|
if (telegramApp && telegramApp.initData) {
|
|
|
|
|
|
telegramApp.disableVerticalSwipes?.();
|
|
|
|
|
|
telegramApp.expand?.();
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await loginTelegram();
|
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
|
|
2025-12-09 00:51:07 +00:00
|
|
|
|
if (result && result.user) {
|
|
|
|
|
|
setUser(result.user);
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Telegram авторизация не удалась:', err);
|
|
|
|
|
|
// В MiniApp при ошибке показываем ошибку, а не форму входа
|
|
|
|
|
|
setError(err?.response?.data?.error || 'Ошибка авторизации. Убедитесь, что у вас есть права модератора.');
|
2025-12-08 23:42:32 +00:00
|
|
|
|
setLoading(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-11-10 20:13:22 +00:00
|
|
|
|
}
|
2025-11-10 21:22:58 +00:00
|
|
|
|
|
2025-12-09 00:29:13 +00:00
|
|
|
|
// Инициализация виджета будет в отдельном useEffect после монтирования компонента
|
2025-11-10 23:22:34 +00:00
|
|
|
|
|
2025-12-08 23:42:32 +00:00
|
|
|
|
// Проверить 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');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-11-10 21:36:23 +00:00
|
|
|
|
|
2025-12-08 23:42:32 +00:00
|
|
|
|
// Нет токена - показать форму входа
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
setError('login_required');
|
|
|
|
|
|
|
2025-11-10 20:13:22 +00:00
|
|
|
|
} catch (err) {
|
2025-11-10 21:36:23 +00:00
|
|
|
|
if (cancelled) return;
|
2025-11-10 21:22:58 +00:00
|
|
|
|
console.error('Ошибка инициализации модератора:', err);
|
2025-12-08 23:42:32 +00:00
|
|
|
|
setError('login_required');
|
|
|
|
|
|
setLoading(false);
|
2025-11-10 21:22:58 +00:00
|
|
|
|
}
|
2025-11-10 20:13:22 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
init();
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
2025-11-10 21:36:23 +00:00
|
|
|
|
cancelled = true;
|
2025-12-08 23:42:32 +00:00
|
|
|
|
if (window.onTelegramAuth) {
|
|
|
|
|
|
delete window.onTelegramAuth;
|
|
|
|
|
|
}
|
2025-11-10 20:13:22 +00:00
|
|
|
|
};
|
2025-11-10 21:36:23 +00:00
|
|
|
|
}, []);
|
2025-11-10 20:13:22 +00:00
|
|
|
|
|
2025-12-09 00:29:13 +00:00
|
|
|
|
// Отдельный useEffect для инициализации виджета после монтирования
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const telegramApp = window.Telegram?.WebApp;
|
|
|
|
|
|
const showLoginForm = error === 'login_required' || (!user && !loading);
|
|
|
|
|
|
|
|
|
|
|
|
// Инициализировать виджет только если нет WebApp initData и показана форма входа
|
2025-12-09 00:51:07 +00:00
|
|
|
|
if (!telegramApp?.initData && showLoginForm && telegramWidgetRef.current) {
|
2025-12-09 00:29:13 +00:00
|
|
|
|
// Глобальная функция для обработки авторизации через виджет
|
|
|
|
|
|
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();
|
|
|
|
|
|
|
2025-12-14 14:28:59 +00:00
|
|
|
|
console.log('[Telegram Widget] Результат авторизации:', result);
|
|
|
|
|
|
|
2025-12-09 00:29:13 +00:00
|
|
|
|
if (result.accessToken) {
|
|
|
|
|
|
localStorage.setItem('moderation_jwt_token', result.accessToken);
|
2025-12-14 14:28:59 +00:00
|
|
|
|
console.log('[Telegram Widget] Токен сохранен');
|
2025-12-09 00:29:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (result?.user) {
|
|
|
|
|
|
setUser(result.user);
|
|
|
|
|
|
setError(null);
|
2025-12-14 14:28:59 +00:00
|
|
|
|
console.log('[Telegram Widget] Пользователь установлен:', result.user.username);
|
|
|
|
|
|
|
|
|
|
|
|
// Перенаправить на главную страницу после успешной авторизации
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
window.location.href = '/';
|
|
|
|
|
|
}, 500);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
throw new Error('Пользователь не получен от сервера');
|
2025-12-09 00:29:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Ошибка авторизации через виджет:', err);
|
|
|
|
|
|
setError(err.message || 'Ошибка авторизации через Telegram');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setAuthLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const initWidget = () => {
|
|
|
|
|
|
// Проверить не загружен ли уже виджет
|
|
|
|
|
|
if (document.querySelector('script[src*="telegram-widget"]')) {
|
2025-12-09 00:51:07 +00:00
|
|
|
|
console.log('[Telegram Widget] Виджет уже загружен');
|
2025-12-09 00:29:13 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const widgetContainer = telegramWidgetRef.current;
|
|
|
|
|
|
if (!widgetContainer) {
|
2025-12-09 00:51:07 +00:00
|
|
|
|
console.warn('[Telegram Widget] Контейнер не найден');
|
2025-12-09 00:29:13 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-09 00:51:07 +00:00
|
|
|
|
console.log('[Telegram Widget] Инициализация виджета...');
|
|
|
|
|
|
|
|
|
|
|
|
// Очистить контейнер перед добавлением скрипта
|
|
|
|
|
|
widgetContainer.innerHTML = '';
|
|
|
|
|
|
|
2025-12-09 00:29:13 +00:00
|
|
|
|
const script = document.createElement('script');
|
|
|
|
|
|
script.async = true;
|
|
|
|
|
|
script.src = 'https://telegram.org/js/telegram-widget.js?22';
|
2025-12-09 01:03:25 +00:00
|
|
|
|
script.setAttribute('data-telegram-login', moderationBotUsername);
|
2025-12-09 00:29:13 +00:00
|
|
|
|
script.setAttribute('data-size', 'large');
|
|
|
|
|
|
script.setAttribute('data-request-access', 'write');
|
|
|
|
|
|
script.setAttribute('data-onauth', 'onTelegramAuth');
|
2025-12-09 00:51:07 +00:00
|
|
|
|
script.setAttribute('data-radius', '10');
|
2025-12-15 00:47:02 +00:00
|
|
|
|
// Добавить auth-url для валидации домена
|
2025-12-15 01:16:04 +00:00
|
|
|
|
// 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);
|
2025-12-09 00:51:07 +00:00
|
|
|
|
|
|
|
|
|
|
script.onload = () => {
|
|
|
|
|
|
console.log('[Telegram Widget] Скрипт загружен');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
script.onerror = () => {
|
|
|
|
|
|
console.error('[Telegram Widget] Ошибка загрузки скрипта');
|
|
|
|
|
|
};
|
2025-12-09 00:29:13 +00:00
|
|
|
|
|
|
|
|
|
|
widgetContainer.appendChild(script);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Подождать немного чтобы контейнер был готов
|
2025-12-09 00:51:07 +00:00
|
|
|
|
const timer = setTimeout(initWidget, 100);
|
2025-12-09 00:29:13 +00:00
|
|
|
|
return () => {
|
|
|
|
|
|
clearTimeout(timer);
|
|
|
|
|
|
if (window.onTelegramAuth) {
|
|
|
|
|
|
delete window.onTelegramAuth;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (window.onTelegramAuth) {
|
|
|
|
|
|
delete window.onTelegramAuth;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2025-12-09 01:03:25 +00:00
|
|
|
|
}, [error, user, loading, moderationBotUsername]);
|
2025-12-09 00:29:13 +00:00
|
|
|
|
|
2025-11-10 20:13:22 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (tab === 'users') {
|
|
|
|
|
|
loadUsers();
|
|
|
|
|
|
} else if (tab === 'posts') {
|
|
|
|
|
|
loadPosts();
|
|
|
|
|
|
} else if (tab === 'reports') {
|
|
|
|
|
|
loadReports();
|
2025-11-11 00:40:29 +00:00
|
|
|
|
} else if (tab === 'admins') {
|
|
|
|
|
|
loadAdmins();
|
2025-11-11 00:54:39 +00:00
|
|
|
|
} else if (tab === 'publish') {
|
|
|
|
|
|
// Загрузить список админов для проверки прав публикации
|
|
|
|
|
|
loadAdmins();
|
2025-11-10 20:13:22 +00:00
|
|
|
|
} 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-11 00:40:29 +00:00
|
|
|
|
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 || 'Ошибка подтверждения');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-10 20:13:22 +00:00
|
|
|
|
const initChat = () => {
|
2025-11-21 01:14:56 +00:00
|
|
|
|
if (!user) {
|
|
|
|
|
|
console.error('[Chat] Нет user, отмена инициализации');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (chatSocketRef.current) {
|
|
|
|
|
|
console.warn('[Chat] Socket уже существует');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-11-11 00:54:39 +00:00
|
|
|
|
|
2025-12-08 23:42:32 +00:00
|
|
|
|
// Использовать тот же API URL что и в api.js
|
|
|
|
|
|
const getApiUrl = () => {
|
|
|
|
|
|
if (import.meta.env.VITE_API_URL) {
|
|
|
|
|
|
return import.meta.env.VITE_API_URL;
|
|
|
|
|
|
}
|
2025-12-09 00:29:13 +00:00
|
|
|
|
// В production используем относительный путь (HTTPS)
|
2025-12-08 23:42:32 +00:00
|
|
|
|
if (import.meta.env.PROD) {
|
|
|
|
|
|
return '/api';
|
|
|
|
|
|
}
|
2025-12-09 00:29:13 +00:00
|
|
|
|
// В development используем localhost (только для dev)
|
2025-12-08 23:42:32 +00:00
|
|
|
|
return 'http://localhost:3001/api';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const API_URL = getApiUrl();
|
2025-12-01 14:26:18 +00:00
|
|
|
|
|
|
|
|
|
|
// Для WebSocket убираем "/api" из base URL, т.к. socket.io слушает на корне
|
2025-12-09 00:29:13 +00:00
|
|
|
|
// В 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://');
|
|
|
|
|
|
}
|
2025-11-11 00:54:39 +00:00
|
|
|
|
|
2025-11-21 01:14:56 +00:00
|
|
|
|
console.log('[Chat] Инициализация чата');
|
2025-12-01 14:26:18 +00:00
|
|
|
|
console.log('[Chat] WS base URL:', socketBase);
|
2025-11-21 01:14:56 +00:00
|
|
|
|
console.log('[Chat] User данные:', {
|
|
|
|
|
|
username: user.username,
|
|
|
|
|
|
telegramId: user.telegramId,
|
|
|
|
|
|
hasUsername: !!user.username,
|
|
|
|
|
|
hasTelegramId: !!user.telegramId
|
|
|
|
|
|
});
|
2025-12-15 01:16:04 +00:00
|
|
|
|
// 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');
|
2025-11-20 21:32:48 +00:00
|
|
|
|
|
2025-12-15 01:16:04 +00:00
|
|
|
|
const socket = io(socketUrl, {
|
|
|
|
|
|
namespace: '/mod-chat',
|
2025-11-11 00:54:39 +00:00
|
|
|
|
transports: ['websocket', 'polling'],
|
|
|
|
|
|
reconnection: true,
|
|
|
|
|
|
reconnectionDelay: 1000,
|
2025-11-20 21:32:48 +00:00
|
|
|
|
reconnectionAttempts: 5,
|
|
|
|
|
|
timeout: 10000
|
2025-11-10 20:13:22 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
socket.on('connect', () => {
|
2025-11-21 01:14:56 +00:00
|
|
|
|
console.log('[Chat] ✅ WebSocket подключен, ID:', socket.id);
|
|
|
|
|
|
console.log('[Chat] Отправка auth с данными:', {
|
|
|
|
|
|
username: user.username,
|
|
|
|
|
|
telegramId: user.telegramId
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-10 20:13:22 +00:00
|
|
|
|
socket.emit('auth', {
|
|
|
|
|
|
username: user.username,
|
|
|
|
|
|
telegramId: user.telegramId
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
socket.on('ready', () => {
|
2025-11-20 21:32:48 +00:00
|
|
|
|
console.log('Авторизация успешна!');
|
2025-11-10 20:13:22 +00:00
|
|
|
|
setChatState((prev) => ({ ...prev, connected: true }));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
socket.on('unauthorized', () => {
|
2025-11-20 21:32:48 +00:00
|
|
|
|
console.error('Unauthorized в чате');
|
2025-11-10 20:13:22 +00:00
|
|
|
|
setChatState((prev) => ({ ...prev, connected: false }));
|
|
|
|
|
|
socket.disconnect();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
socket.on('message', (message) => {
|
2025-11-20 21:32:48 +00:00
|
|
|
|
console.log('Получено сообщение:', message);
|
2025-11-10 20:13:22 +00:00
|
|
|
|
setChatState((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
messages: [...prev.messages, message]
|
|
|
|
|
|
}));
|
2025-11-20 21:32:48 +00:00
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
if (chatListRef.current) {
|
|
|
|
|
|
chatListRef.current.scrollTo({
|
|
|
|
|
|
top: chatListRef.current.scrollHeight,
|
|
|
|
|
|
behavior: 'smooth'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 100);
|
2025-11-10 20:13:22 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
socket.on('online', (online) => {
|
2025-11-20 21:32:48 +00:00
|
|
|
|
console.log('Обновление списка онлайн:', online);
|
2025-11-10 20:13:22 +00:00
|
|
|
|
setChatState((prev) => ({ ...prev, online }));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-20 21:32:48 +00:00
|
|
|
|
socket.on('disconnect', (reason) => {
|
|
|
|
|
|
console.log('WebSocket отключен:', reason);
|
2025-11-10 20:13:22 +00:00
|
|
|
|
setChatState((prev) => ({ ...prev, connected: false }));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-20 21:32:48 +00:00
|
|
|
|
socket.on('connect_error', (error) => {
|
|
|
|
|
|
console.error('Ошибка подключения WebSocket:', error);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-10 20:13:22 +00:00
|
|
|
|
chatSocketRef.current = socket;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSendChat = () => {
|
2025-11-20 21:32:48 +00:00
|
|
|
|
if (!chatSocketRef.current || !chatState.connected) {
|
|
|
|
|
|
console.warn('Чат не подключен');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-11-10 20:13:22 +00:00
|
|
|
|
const text = chatInput.trim();
|
|
|
|
|
|
if (!text) return;
|
2025-11-20 21:32:48 +00:00
|
|
|
|
console.log('Отправка сообщения:', text);
|
2025-11-10 20:13:22 +00:00
|
|
|
|
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();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-07 03:14:27 +00:00
|
|
|
|
const handleToggleArt = async (post) => {
|
|
|
|
|
|
const newIsArt = !post.isArt;
|
|
|
|
|
|
await updatePost(post.id, { isArt: newIsArt });
|
|
|
|
|
|
loadPosts();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-04 21:45:02 +00:00
|
|
|
|
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('Ошибка удаления комментария');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-10 20:13:22 +00:00
|
|
|
|
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 }));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-08 23:42:32 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-15 01:01:39 +00:00
|
|
|
|
if (authForm.password.length < 8) {
|
|
|
|
|
|
setError('Пароль должен содержать минимум 8 символов');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (authForm.password.length > 24) {
|
|
|
|
|
|
setError('Пароль должен содержать максимум 24 символа');
|
2025-12-08 23:42:32 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-10 20:13:22 +00:00
|
|
|
|
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>
|
2025-12-04 17:44:05 +00:00
|
|
|
|
{u.referralsCount > 0 && <span className="badge badge-info">Рефералов: {u.referralsCount}</span>}
|
2025-11-10 20:13:22 +00:00
|
|
|
|
{u.banned && <span className="badge badge-danger">Бан до {formatDate(u.bannedUntil)}</span>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="list-item-actions">
|
2025-11-11 00:40:29 +00:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2025-11-10 20:13:22 +00:00
|
|
|
|
{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">
|
2025-11-20 21:32:48 +00:00
|
|
|
|
{post.images.map((img, idx) => {
|
|
|
|
|
|
// Преобразовать относительный путь в абсолютный
|
2025-12-08 23:42:32 +00:00
|
|
|
|
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';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-20 21:32:48 +00:00
|
|
|
|
const imageUrl = img.startsWith('http')
|
|
|
|
|
|
? img
|
2025-12-08 23:42:32 +00:00
|
|
|
|
: `${getImageBaseUrl()}${img}`;
|
2025-11-20 21:32:48 +00:00
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2025-11-10 20:13:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="list-item-actions">
|
2025-12-04 21:45:02 +00:00
|
|
|
|
<button className="btn" onClick={() => handleOpenComments(post.id)}>
|
|
|
|
|
|
<MessageSquare size={16} />
|
|
|
|
|
|
Комментарии ({post.commentsCount || 0})
|
|
|
|
|
|
</button>
|
2025-11-10 20:13:22 +00:00
|
|
|
|
<button className="btn" onClick={() => handlePostEdit(post)}>
|
|
|
|
|
|
<Edit size={16} />
|
|
|
|
|
|
Редактировать
|
|
|
|
|
|
</button>
|
2025-12-07 03:14:27 +00:00
|
|
|
|
<button
|
|
|
|
|
|
className={`btn ${post.isArt ? 'active' : ''}`}
|
|
|
|
|
|
onClick={() => handleToggleArt(post)}
|
|
|
|
|
|
style={post.isArt ? { backgroundColor: '#4CAF50', color: 'white' } : {}}
|
|
|
|
|
|
>
|
|
|
|
|
|
🎨 Арт
|
|
|
|
|
|
</button>
|
2025-11-10 20:13:22 +00:00
|
|
|
|
<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">
|
2025-11-20 21:32:48 +00:00
|
|
|
|
<div style={{ marginBottom: '12px', padding: '12px', backgroundColor: 'var(--bg-secondary)', borderRadius: '8px' }}>
|
|
|
|
|
|
<strong>Причина жалобы:</strong>
|
|
|
|
|
|
<p style={{ marginTop: '4px' }}>{report.reason || 'Причина не указана'}</p>
|
|
|
|
|
|
</div>
|
2025-11-10 20:13:22 +00:00
|
|
|
|
{report.post && (
|
2025-11-20 21:32:48 +00:00
|
|
|
|
<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) => {
|
2025-12-08 23:42:32 +00:00
|
|
|
|
const getImageBaseUrl = () => {
|
|
|
|
|
|
if (import.meta.env.VITE_API_URL) {
|
|
|
|
|
|
return import.meta.env.VITE_API_URL.replace('/api', '');
|
|
|
|
|
|
}
|
2025-12-09 00:29:13 +00:00
|
|
|
|
// В production используем текущий origin (HTTPS через nginx)
|
2025-12-08 23:42:32 +00:00
|
|
|
|
if (import.meta.env.PROD) {
|
|
|
|
|
|
return window.location.origin;
|
|
|
|
|
|
}
|
2025-12-09 00:29:13 +00:00
|
|
|
|
// Только для development
|
2025-12-08 23:42:32 +00:00
|
|
|
|
return 'http://localhost:3000';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-20 21:32:48 +00:00
|
|
|
|
const imageUrl = img.startsWith('http')
|
|
|
|
|
|
? img
|
2025-12-08 23:42:32 +00:00
|
|
|
|
: `${getImageBaseUrl()}${img}`;
|
2025-11-20 21:32:48 +00:00
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
)}
|
2025-11-10 20:13:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="list-item-actions">
|
2025-11-20 21:32:48 +00:00
|
|
|
|
{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} />
|
2025-12-01 14:26:18 +00:00
|
|
|
|
Забанить автора (срок)
|
2025-11-20 21:32:48 +00:00
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2025-11-10 20:13:22 +00:00
|
|
|
|
<button className="btn" onClick={() => handleReportStatus(report.id, 'resolved')}>
|
2025-12-01 14:26:18 +00:00
|
|
|
|
Закрыть как решённый
|
2025-11-10 20:13:22 +00:00
|
|
|
|
</button>
|
2025-11-20 21:32:48 +00:00
|
|
|
|
<button className="btn" onClick={() => handleReportStatus(report.id, 'dismissed')}>
|
2025-12-01 14:26:18 +00:00
|
|
|
|
Пропустить
|
2025-11-10 20:13:22 +00:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
2025-11-20 21:32:48 +00:00
|
|
|
|
{reportsData.reports.length === 0 && (
|
|
|
|
|
|
<div style={{ textAlign: 'center', padding: '40px', color: 'var(--text-secondary)' }}>
|
|
|
|
|
|
Нет активных репортов
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-11-10 20:13:22 +00:00
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-11-11 00:54:39 +00:00
|
|
|
|
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' }}>
|
|
|
|
|
|
Вам не присвоен номер админа. Обратитесь к владельцу.
|
2025-11-10 20:13:22 +00:00
|
|
|
|
</div>
|
2025-11-11 00:54:39 +00:00
|
|
|
|
)}
|
2025-11-10 20:13:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-11-11 00:54:39 +00:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2025-11-10 20:13:22 +00:00
|
|
|
|
</div>
|
2025-11-11 00:54:39 +00:00
|
|
|
|
);
|
|
|
|
|
|
};
|
2025-11-10 20:13:22 +00:00
|
|
|
|
|
2025-11-11 00:40:29 +00:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-11-10 20:13:22 +00:00
|
|
|
|
const renderContent = () => {
|
|
|
|
|
|
switch (tab) {
|
|
|
|
|
|
case 'users':
|
|
|
|
|
|
return renderUsers();
|
|
|
|
|
|
case 'posts':
|
|
|
|
|
|
return renderPosts();
|
|
|
|
|
|
case 'reports':
|
|
|
|
|
|
return renderReports();
|
2025-11-11 00:40:29 +00:00
|
|
|
|
case 'admins':
|
|
|
|
|
|
return renderAdmins();
|
2025-11-10 20:13:22 +00:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-09 00:51:07 +00:00
|
|
|
|
// Показать форму входа ТОЛЬКО если это НЕ MiniApp
|
|
|
|
|
|
const telegramApp = window.Telegram?.WebApp;
|
|
|
|
|
|
const isMiniApp = telegramApp && telegramApp.initData;
|
|
|
|
|
|
|
|
|
|
|
|
if (error === 'login_required' || (!user && !loading && !isMiniApp)) {
|
2025-12-08 23:42:32 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="fullscreen-center">
|
|
|
|
|
|
<div className="login-card">
|
|
|
|
|
|
<h1 style={{ marginTop: 0, marginBottom: '24px' }}>Вход в модерацию</h1>
|
|
|
|
|
|
|
2025-12-09 00:51:07 +00:00
|
|
|
|
{/* Telegram авторизация в MiniApp - не показываем, авторизация автоматическая */}
|
2025-12-08 23:42:32 +00:00
|
|
|
|
|
|
|
|
|
|
{/* Telegram Login Widget для обычного браузера */}
|
2025-12-09 00:51:07 +00:00
|
|
|
|
{!isMiniApp && (
|
2025-12-08 23:42:32 +00:00
|
|
|
|
<>
|
|
|
|
|
|
<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 }))}
|
2025-12-15 01:01:39 +00:00
|
|
|
|
placeholder="От 8 до 24 символов"
|
2025-12-08 23:42:32 +00:00
|
|
|
|
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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-09 00:51:07 +00:00
|
|
|
|
// Показать ошибку (особенно важно для MiniApp)
|
2025-12-08 23:42:32 +00:00
|
|
|
|
if (error && error !== 'login_required') {
|
2025-12-09 00:51:07 +00:00
|
|
|
|
const telegramApp = window.Telegram?.WebApp;
|
|
|
|
|
|
const isMiniApp = telegramApp && telegramApp.initData;
|
|
|
|
|
|
|
2025-11-10 20:13:22 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="fullscreen-center">
|
|
|
|
|
|
<ShieldCheck size={48} />
|
2025-12-09 00:51:07 +00:00
|
|
|
|
<p style={{ marginTop: '16px', textAlign: 'center', maxWidth: '400px' }}>{error}</p>
|
|
|
|
|
|
{!isMiniApp && error.includes('доступ') && (
|
2025-12-08 23:42:32 +00:00
|
|
|
|
<button
|
|
|
|
|
|
className="btn"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
localStorage.removeItem('moderation_token');
|
2025-12-09 00:51:07 +00:00
|
|
|
|
localStorage.removeItem('moderation_jwt_token');
|
2025-12-08 23:42:32 +00:00
|
|
|
|
setError('login_required');
|
|
|
|
|
|
}}
|
|
|
|
|
|
style={{ marginTop: '16px' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
Войти заново
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2025-12-09 00:51:07 +00:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2025-11-10 20:13:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="app-container">
|
|
|
|
|
|
<header className="app-header">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h1>Nakama Moderation</h1>
|
|
|
|
|
|
<span className="subtitle">@{user.username}</span>
|
|
|
|
|
|
</div>
|
2025-12-08 23:42:32 +00:00
|
|
|
|
<button
|
|
|
|
|
|
className="btn"
|
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
|
await logout();
|
|
|
|
|
|
window.location.reload();
|
|
|
|
|
|
}}
|
|
|
|
|
|
style={{ fontSize: '14px' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
Выйти
|
|
|
|
|
|
</button>
|
2025-11-10 20:13:22 +00:00
|
|
|
|
</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>
|
2025-12-04 21:45:02 +00:00
|
|
|
|
|
|
|
|
|
|
{/* Модалка комментариев */}
|
|
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2025-11-10 20:13:22 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|