nakama/moderation/frontend/src/App.jsx

676 lines
20 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,
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;
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);
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>
);
}