1030 lines
34 KiB
JavaScript
1030 lines
34 KiB
JavaScript
import { useEffect, useRef, useState } from 'react';
|
||
import {
|
||
verifyAuth,
|
||
fetchUsers,
|
||
banUser,
|
||
fetchPosts,
|
||
updatePost,
|
||
deletePost,
|
||
removePostImage,
|
||
banPostAuthor,
|
||
fetchReports,
|
||
updateReportStatus,
|
||
publishToChannel,
|
||
fetchAdmins,
|
||
initiateAddAdmin,
|
||
confirmAddAdmin,
|
||
initiateRemoveAdmin,
|
||
confirmRemoveAdmin
|
||
} 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
|
||
} 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);
|
||
|
||
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 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={() => 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>
|
||
</div>
|
||
);
|
||
}
|
||
|