nakama/moderation/frontend/src/App.jsx

1005 lines
33 KiB
React
Raw Normal View History

2025-11-10 21:22:58 +00:00
import { useEffect, useRef, useState } from 'react';
2025-11-10 20:13:22 +00:00
import {
verifyAuth,
fetchUsers,
banUser,
fetchPosts,
updatePost,
deletePost,
removePostImage,
banPostAuthor,
fetchReports,
updateReportStatus,
2025-11-11 00:40:29 +00:00
publishToChannel,
fetchAdmins,
initiateAddAdmin,
confirmAddAdmin,
initiateRemoveAdmin,
confirmRemoveAdmin
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,
Crown
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);
useEffect(() => {
2025-11-10 21:36:23 +00:00
let cancelled = false;
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;
if (!telegramApp) {
throw new Error('Откройте модераторский интерфейс из Telegram (бот @rbachbot).');
2025-11-10 20:13:22 +00:00
}
2025-11-10 21:22:58 +00:00
2025-11-10 23:22:34 +00:00
if (!telegramApp.initData) {
throw new Error('Telegram не передал initData. Откройте приложение из официального клиента.');
}
2025-11-10 21:36:23 +00:00
telegramApp.disableVerticalSwipes?.();
telegramApp.expand?.();
2025-11-10 21:22:58 +00:00
2025-11-10 22:37:25 +00:00
const userData = await verifyAuth();
2025-11-10 21:36:23 +00:00
if (cancelled) return;
2025-11-10 21:56:36 +00:00
setUser(userData);
2025-11-10 21:36:23 +00:00
setError(null);
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-11-10 22:37:25 +00:00
const message =
err?.response?.data?.error ||
err?.message ||
'Нет доступа. Убедитесь, что вы добавлены как администратор.';
setError(message);
2025-11-10 21:22:58 +00:00
} finally {
2025-11-10 21:36:23 +00:00
if (!cancelled) {
setLoading(false);
2025-11-11 00:33:22 +00:00
// Убрана кнопка "Закрыть"
2025-11-10 21:36:23 +00:00
}
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-11-10 20:13:22 +00:00
};
2025-11-10 21:36:23 +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 = () => {
if (!user || chatSocketRef.current) return;
2025-11-11 00:54:39 +00:00
const API_URL = import.meta.env.VITE_API_URL || (
import.meta.env.PROD ? window.location.origin : 'http://localhost:3000'
);
2025-11-20 21:32:48 +00:00
console.log('Инициализация чата, подключение к:', API_URL);
2025-11-11 00:54:39 +00:00
const socket = io(`${API_URL}/mod-chat`, {
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-20 21:32:48 +00:00
console.log('WebSocket подключен, отправка auth...');
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();
};
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.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) => {
// Преобразовать относительный путь в абсолютный
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>
);
})}
2025-11-10 20:13:22 +00:00
</div>
) : null}
</div>
<div className="list-item-actions">
<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">
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) => {
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>
)}
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} />
Забанить автора
</button>
)}
</>
)}
2025-11-10 20:13:22 +00:00
<button className="btn" onClick={() => handleReportStatus(report.id, 'resolved')}>
Решено
</button>
2025-11-20 21:32:48 +00:00
<button className="btn" onClick={() => handleReportStatus(report.id, 'dismissed')}>
Отклонить репорт
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>
);
}
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>
</div>
);
}