diff --git a/client/src/api/endpoints.ts b/client/src/api/endpoints.ts index 29f81d5..630b6bb 100644 --- a/client/src/api/endpoints.ts +++ b/client/src/api/endpoints.ts @@ -68,6 +68,18 @@ export function getAdminUsers() { return api.get<{ users: User[] }>('/admin/users'); } +export function getAdminCats() { + return api.get<{ cats: Cat[] }>('/admin/cats'); +} + +export function adminUpdateCaption(id: number, caption: string) { + return api.put(`/admin/cats/${id}/caption`, { caption }); +} + +export function adminSetPoints(id: number, points: number) { + return api.put(`/admin/users/${id}/points`, { points }); +} + export function adminDeleteCat(id: number) { return api.delete(`/admin/cats/${id}`); } diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index ba58118..eb14739 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -2,27 +2,43 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '@/hooks/useAuth'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { getAdminUsers, adminDeleteUser, adminDeleteCat } from '@/api/endpoints'; +import { getAdminUsers, getAdminCats, adminDeleteUser, adminDeleteCat, adminUpdateCaption, adminSetPoints } from '@/api/endpoints'; import { Button } from '@/components/ui/button'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; -import { ArrowLeft, Trash2, Shield } from 'lucide-react'; +import { ArrowLeft, Trash2, Shield, Edit3, Save, X, Search } from 'lucide-react'; import { useToast } from '@/components/Toast'; +type Tab = 'users' | 'cats'; + export default function AdminPage() { const { user } = useAuth(); const navigate = useNavigate(); const { toast } = useToast(); const qc = useQueryClient(); + const [tab, setTab] = useState('users'); + const [searchUser, setSearchUser] = useState(''); - const { data, isLoading } = useQuery({ + const usersQuery = useQuery({ queryKey: ['admin', 'users'], queryFn: () => getAdminUsers().then(r => r.data), enabled: !!user?.is_admin, }); - const deleteUserMutation = useMutation({ + const catsQuery = useQuery({ + queryKey: ['admin', 'cats'], + queryFn: () => getAdminCats().then(r => r.data), + enabled: !!user?.is_admin, + }); + + const deleteUserMut = useMutation({ mutationFn: (id: number) => adminDeleteUser(id), - onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin', 'users'] }); toast('Пользователь удалён'); }, + onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin'] }); toast('Пользователь удалён'); }, + onError: () => toast('Ошибка удаления', 'error'), + }); + + const deleteCatMut = useMutation({ + mutationFn: (id: number) => adminDeleteCat(id), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin'] }); toast('Пост удалён'); }, onError: () => toast('Ошибка удаления', 'error'), }); @@ -38,10 +54,15 @@ export default function AdminPage() { ); } - const users = data?.users ?? []; + const users = usersQuery.data?.users ?? []; + const cats = catsQuery.data?.cats ?? []; + + const filteredUsers = users.filter((u: any) => + u.username.toLowerCase().includes(searchUser.toLowerCase()) + ); return ( -
+
- {isLoading ? ( -
- {Array.from({ length: 4 }).map((_, i) => ( -
-
-
-
-
-
-
- ))} -
- ) : ( -
-
- Пользователи ({users.length}) + {/* Tabs */} +
+ + +
+ + {tab === 'users' && ( + <> +
+ + setSearchUser(e.target.value)} + placeholder="Поиск пользователя..." + className="w-full h-10 pl-9 pr-4 text-[14px] rounded-xl bg-white border input-focus" />
- {users.map((u: any) => ( -
- - {u.username.charAt(0).toUpperCase()} - -
-
- @{u.username} - {u.is_admin ? ( - Админ - ) : null} -
-

🏆 {u.points} баллов

-
- {!u.is_admin && ( - - )} + +
+
+ Пользователи
+ {filteredUsers.length === 0 && ( +
Нет пользователей
+ )} + {filteredUsers.map((u: any) => ( + deleteUserMut.mutate(u.id)} qc={qc} /> + ))} +
+ + )} + + {tab === 'cats' && ( +
+
+ Все посты +
+ {cats.length === 0 && ( +
Нет постов
+ )} + {cats.map((c: any) => ( + deleteCatMut.mutate(c.id)} qc={qc} /> ))}
)}
); +} + +function UserRow({ u, onDelete, qc }: { u: any; onDelete: () => void; qc: any }) { + const [editing, setEditing] = useState(false); + const [points, setPoints] = useState(u.points); + const { toast } = useToast(); + + const savePoints = async () => { + try { + await adminSetPoints(u.id, points); + qc.invalidateQueries({ queryKey: ['admin'] }); + setEditing(false); + toast('Баллы обновлены'); + } catch { toast('Ошибка', 'error'); } + }; + + return ( +
+ + {u.username.charAt(0).toUpperCase()} + +
+
+ @{u.username} + {u.is_admin && Админ} +
+
+ {editing ? ( +
+ setPoints(Math.max(0, parseInt(e.target.value) || 0))} + className="w-20 h-7 px-2 text-[13px] rounded-lg border input-focus" autoFocus /> + + +
+ ) : ( + 🏆 {u.points} баллов + )} +
+
+ {!u.is_admin && ( +
+ + +
+ )} +
+ ); +} + +function CatRow({ c, onDelete, qc }: { c: any; onDelete: () => void; qc: any }) { + const [editing, setEditing] = useState(false); + const [caption, setCaption] = useState(c.caption || ''); + const { toast } = useToast(); + + const saveCaption = async () => { + try { + await adminUpdateCaption(c.id, caption); + qc.invalidateQueries({ queryKey: ['admin'] }); + setEditing(false); + toast('Описание обновлено'); + } catch { toast('Ошибка', 'error'); } + }; + + return ( +
+
+ +
+
+ @{c.username} + {editing ? ( +
+ setCaption(e.target.value)} + className="flex-1 h-7 px-2 text-[13px] rounded-lg border input-focus" autoFocus + onKeyDown={e => { if (e.key === 'Enter') saveCaption(); if (e.key === 'Escape') { setEditing(false); setCaption(c.caption || ''); } }} /> + + +
+ ) : ( +

{c.caption || 'Без подписи'}

+ )} +
+
+ + +
+
+ ); } \ No newline at end of file diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 12831b9..3373bc2 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -4,45 +4,70 @@ import { authMiddleware, adminOnly, AuthRequest } from '../middleware/auth.js'; const router = Router(); -// All routes require admin router.use(authMiddleware, adminOnly); -// Get all users -router.get('/users', (req: AuthRequest, res: Response) => { +// ── Users ── + +router.get('/users', (_req: AuthRequest, res: Response) => { const users = queryAll( 'SELECT id, username, points, is_admin, created_at FROM users ORDER BY points DESC' ); res.json({ users }); }); -// Delete any cat -router.delete('/cats/:id', (req: AuthRequest, res: Response) => { - const cat = queryOne('SELECT id FROM cats WHERE id = ?', [req.params.id]); - if (!cat) { - res.status(404).json({ error: 'Cat not found' }); +router.put('/users/:id/points', (req: AuthRequest, res: Response) => { + const userId = parseInt(req.params.id); + const { points } = req.body; + if (points === undefined || points < 0) { + res.status(400).json({ error: 'Укажите points (>= 0)' }); return; } - execute('DELETE FROM likes WHERE cat_id = ?', [req.params.id]); - execute('DELETE FROM cats WHERE id = ?', [req.params.id]); - res.json({ ok: true }); + const user = queryOne('SELECT id, username, is_admin FROM users WHERE id = ?', [userId]); + if (!user) { res.status(404).json({ error: 'User not found' }); return; } + if (user.is_admin) { res.status(400).json({ error: 'Cannot modify admin' }); return; } + execute('UPDATE users SET points = ? WHERE id = ?', [points, userId]); + res.json({ ok: true, points }); }); -// Ban user (delete their cats and likes) router.delete('/users/:id', (req: AuthRequest, res: Response) => { const userId = parseInt(req.params.id); const user = queryOne('SELECT id, username, is_admin FROM users WHERE id = ?', [userId]); - if (!user) { - res.status(404).json({ error: 'User not found' }); - return; - } - if (user.is_admin) { - res.status(400).json({ error: 'Cannot delete admin' }); - return; - } + if (!user) { res.status(404).json({ error: 'User not found' }); return; } + if (user.is_admin) { res.status(400).json({ error: 'Cannot delete admin' }); return; } execute('DELETE FROM likes WHERE cat_id IN (SELECT id FROM cats WHERE user_id = ?)', [userId]); execute('DELETE FROM cats WHERE user_id = ?', [userId]); execute('DELETE FROM users WHERE id = ?', [userId]); res.json({ ok: true }); }); +// ── Cats ── + +router.get('/cats', (_req: AuthRequest, res: Response) => { + const cats = queryAll( + `SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id, + u.username, u.points AS user_points, + (SELECT COUNT(*) FROM likes WHERE cat_id = c.id) AS likes_count + FROM cats c JOIN users u ON u.id = c.user_id + ORDER BY c.created_at DESC` + ); + res.json({ cats }); +}); + +router.put('/cats/:id/caption', (req: AuthRequest, res: Response) => { + const catId = parseInt(req.params.id); + const { caption } = req.body; + const cat = queryOne('SELECT id FROM cats WHERE id = ?', [catId]); + if (!cat) { res.status(404).json({ error: 'Cat not found' }); return; } + execute('UPDATE cats SET caption = ? WHERE id = ?', [(caption || '').trim(), catId]); + res.json({ ok: true, caption: (caption || '').trim() }); +}); + +router.delete('/cats/:id', (req: AuthRequest, res: Response) => { + const cat = queryOne('SELECT id FROM cats WHERE id = ?', [req.params.id]); + if (!cat) { res.status(404).json({ error: 'Cat not found' }); return; } + execute('DELETE FROM likes WHERE cat_id = ?', [req.params.id]); + execute('DELETE FROM cats WHERE id = ?', [req.params.id]); + res.json({ ok: true }); +}); + export default router; \ No newline at end of file