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,
|
|
|
|
|
|
publishToChannel
|
|
|
|
|
|
} from './utils/api';
|
|
|
|
|
|
import { io } from 'socket.io-client';
|
|
|
|
|
|
import {
|
|
|
|
|
|
Loader2,
|
|
|
|
|
|
Users,
|
|
|
|
|
|
Image as ImageIcon,
|
|
|
|
|
|
ShieldCheck,
|
|
|
|
|
|
SendHorizontal,
|
|
|
|
|
|
MessageSquare,
|
|
|
|
|
|
RefreshCw,
|
|
|
|
|
|
Trash2,
|
|
|
|
|
|
Edit,
|
|
|
|
|
|
Ban
|
|
|
|
|
|
} from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
|
|
const TABS = [
|
|
|
|
|
|
{ id: 'users', title: 'Пользователи', icon: Users },
|
|
|
|
|
|
{ id: 'posts', title: 'Посты', icon: ImageIcon },
|
|
|
|
|
|
{ id: 'reports', title: 'Репорты', icon: ShieldCheck },
|
|
|
|
|
|
{ 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);
|
|
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
|
|
|
|
|
|
const waitForInitData = async () => {
|
|
|
|
|
|
const start = Date.now();
|
|
|
|
|
|
const timeout = 5000;
|
|
|
|
|
|
|
|
|
|
|
|
while (Date.now() - start < timeout) {
|
|
|
|
|
|
const app = window.Telegram?.WebApp;
|
|
|
|
|
|
if (app?.initData && app.initData.length > 0) {
|
|
|
|
|
|
return app;
|
|
|
|
|
|
}
|
|
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
|
|
}
|
|
|
|
|
|
throw new Error('Telegram initData не передан (timeout)');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
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 21:36:23 +00:00
|
|
|
|
telegramApp.disableVerticalSwipes?.();
|
|
|
|
|
|
telegramApp.ready?.();
|
|
|
|
|
|
telegramApp.expand?.();
|
2025-11-10 21:22:58 +00:00
|
|
|
|
|
2025-11-10 21:36:23 +00:00
|
|
|
|
const app = await waitForInitData();
|
|
|
|
|
|
if (cancelled) return;
|
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);
|
|
|
|
|
|
const telegramApp = window.Telegram?.WebApp;
|
|
|
|
|
|
telegramApp?.MainButton?.setText?.('Закрыть');
|
|
|
|
|
|
telegramApp?.MainButton?.show?.();
|
|
|
|
|
|
telegramApp?.MainButton?.onClick?.(() => telegramApp.close());
|
|
|
|
|
|
}
|
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;
|
|
|
|
|
|
window.Telegram?.WebApp?.MainButton?.hide?.();
|
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();
|
|
|
|
|
|
} 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 initChat = () => {
|
|
|
|
|
|
if (!user || chatSocketRef.current) return;
|
|
|
|
|
|
const socket = io('/mod-chat', {
|
|
|
|
|
|
transports: ['websocket', 'polling']
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
socket.on('connect', () => {
|
|
|
|
|
|
socket.emit('auth', {
|
|
|
|
|
|
username: user.username,
|
|
|
|
|
|
telegramId: user.telegramId
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
socket.on('ready', () => {
|
|
|
|
|
|
setChatState((prev) => ({ ...prev, connected: true }));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
socket.on('unauthorized', () => {
|
|
|
|
|
|
setChatState((prev) => ({ ...prev, connected: false }));
|
|
|
|
|
|
socket.disconnect();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
socket.on('message', (message) => {
|
|
|
|
|
|
setChatState((prev) => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
messages: [...prev.messages, message]
|
|
|
|
|
|
}));
|
|
|
|
|
|
if (chatListRef.current) {
|
|
|
|
|
|
chatListRef.current.scrollTo({
|
|
|
|
|
|
top: chatListRef.current.scrollHeight,
|
|
|
|
|
|
behavior: 'smooth'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
socket.on('online', (online) => {
|
|
|
|
|
|
setChatState((prev) => ({ ...prev, online }));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
socket.on('disconnect', () => {
|
|
|
|
|
|
setChatState((prev) => ({ ...prev, connected: false }));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
chatSocketRef.current = socket;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSendChat = () => {
|
|
|
|
|
|
if (!chatSocketRef.current || !chatState.connected) return;
|
|
|
|
|
|
const text = chatInput.trim();
|
|
|
|
|
|
if (!text) return;
|
|
|
|
|
|
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">
|
|
|
|
|
|
{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) => (
|
|
|
|
|
|
<div key={idx} className="image-thumb">
|
|
|
|
|
|
<img src={img} alt="" />
|
|
|
|
|
|
<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">
|
|
|
|
|
|
<p>{report.reason || 'Причина не указана'}</p>
|
|
|
|
|
|
{report.post && (
|
|
|
|
|
|
<div className="report-post">
|
|
|
|
|
|
<strong>Пост:</strong> {report.post.content || 'Без текста'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="list-item-actions">
|
|
|
|
|
|
<button className="btn" onClick={() => handleReportStatus(report.id, 'resolved')}>
|
|
|
|
|
|
Решено
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button className="btn warn" onClick={() => handleReportStatus(report.id, 'dismissed')}>
|
|
|
|
|
|
Отклонить
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</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 = () => (
|
|
|
|
|
|
<div className="card">
|
|
|
|
|
|
<div className="section-header">
|
|
|
|
|
|
<h2>Публикация в @reichenbfurry</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="publish-form">
|
|
|
|
|
|
<label>
|
|
|
|
|
|
Описание
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
value={publishState.description}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setPublishState((prev) => ({ ...prev, description: e.target.value }))
|
|
|
|
|
|
}
|
|
|
|
|
|
maxLength={1024}
|
|
|
|
|
|
placeholder="Текст поста"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
Теги (через пробел или запятую)
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
value={publishState.tags}
|
|
|
|
|
|
onChange={(e) => setPublishState((prev) => ({ ...prev, tags: e.target.value }))}
|
|
|
|
|
|
placeholder="#furry #art"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label>
|
|
|
|
|
|
Номер администратора (#a1 - #a10)
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={publishState.slot}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
setPublishState((prev) => ({ ...prev, slot: parseInt(e.target.value, 10) }))
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{slotOptions.map((option) => (
|
|
|
|
|
|
<option key={option} value={option}>
|
|
|
|
|
|
#{`a${option}`}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label>
|
2025-11-10 20:28:24 +00:00
|
|
|
|
Медиа (до 10, фото или видео)
|
|
|
|
|
|
<input type="file" accept="image/*,video/*" multiple onChange={handleFileChange} />
|
2025-11-10 20:13:22 +00:00
|
|
|
|
</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} onClick={handlePublish}>
|
|
|
|
|
|
{publishing ? <Loader2 className="spin" size={18} /> : <SendHorizontal size={18} />}
|
|
|
|
|
|
Опубликовать
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const renderContent = () => {
|
|
|
|
|
|
switch (tab) {
|
|
|
|
|
|
case 'users':
|
|
|
|
|
|
return renderUsers();
|
|
|
|
|
|
case 'posts':
|
|
|
|
|
|
return renderPosts();
|
|
|
|
|
|
case 'reports':
|
|
|
|
|
|
return renderReports();
|
|
|
|
|
|
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>
|
|
|
|
|
|
<div className="header-actions">
|
|
|
|
|
|
<button className="btn" onClick={() => window.Telegram?.WebApp?.close()}>
|
|
|
|
|
|
Закрыть
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|