nakama/moderation/frontend/src/App.jsx

1212 lines
41 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

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

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

import { useEffect, useRef, useState } from 'react';
import {
verifyAuth,
fetchUsers,
banUser,
fetchPosts,
updatePost,
deletePost,
removePostImage,
banPostAuthor,
fetchReports,
updateReportStatus,
publishToChannel,
fetchAdmins,
initiateAddAdmin,
confirmAddAdmin,
initiateRemoveAdmin,
confirmRemoveAdmin,
getPostComments,
deleteComment
} 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);
// Comments modal
const [commentsModal, setCommentsModal] = useState(null); // { postId, comments: [] }
const [commentsLoading, setCommentsLoading] = useState(false);
useEffect(() => {
let cancelled = false;
const init = async () => {
try {
const telegramApp = window.Telegram?.WebApp;
if (!telegramApp) {
throw new Error('Откройте модераторский интерфейс из Telegram (бот @rbachbot).');
}
if (!telegramApp.initData) {
throw new Error('Telegram не передал initData. Откройте приложение из официального клиента.');
}
telegramApp.disableVerticalSwipes?.();
telegramApp.expand?.();
const userData = await verifyAuth();
if (cancelled) return;
setUser(userData);
setError(null);
} catch (err) {
if (cancelled) return;
console.error('Ошибка инициализации модератора:', err);
const message =
err?.response?.data?.error ||
err?.message ||
'Нет доступа. Убедитесь, что вы добавлены как администратор.';
setError(message);
} finally {
if (!cancelled) {
setLoading(false);
// Убрана кнопка "Закрыть"
}
}
};
init();
return () => {
cancelled = true;
};
}, []);
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;
}
const API_URL = import.meta.env.VITE_API_URL || (
import.meta.env.PROD ? '/api' : 'http://localhost:3000/api'
);
// Для WebSocket убираем "/api" из base URL, т.к. socket.io слушает на корне
const socketBase = API_URL.replace(/\/?api\/?$/, '');
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
});
console.log('[Chat] Подключение к:', `${socketBase}/mod-chat`);
const socket = io(`${socketBase}/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 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 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 imageUrl = img.startsWith('http')
? img
: `${import.meta.env.VITE_API_URL || (import.meta.env.PROD ? window.location.origin : 'http://localhost:3000')}${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 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 imageUrl = img.startsWith('http')
? img
: `${import.meta.env.VITE_API_URL || (import.meta.env.PROD ? window.location.origin : 'http://localhost:3000')}${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>
);
}
if (error) {
return (
<div className="fullscreen-center">
<ShieldCheck size={48} />
<p>{error}</p>
</div>
);
}
return (
<div className="app-container">
<header className="app-header">
<div>
<h1>Nakama Moderation</h1>
<span className="subtitle">@{user.username}</span>
</div>
</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>
);
}