Админ: управление caption, баллами, список постов

This commit is contained in:
2026-05-29 12:43:37 +03:00
parent 12701ffbc2
commit cfc6001d62
3 changed files with 228 additions and 63 deletions

View File

@@ -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}`);
}

View File

@@ -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<Tab>('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 (
<div className="mx-auto max-w-[660px] px-4 py-6 animate-fade-in-up">
<div className="mx-auto max-w-[700px] px-4 py-6 animate-fade-in-up">
<button onClick={() => navigate(-1)}
className="mb-5 flex items-center gap-1.5 text-[14px] text-[var(--fg-secondary)] hover:text-[var(--fg)] transition-colors group">
<ArrowLeft className="h-4 w-4 stroke-[1.5] group-hover:-translate-x-0.5 transition-transform" />
@@ -53,47 +74,154 @@ export default function AdminPage() {
<h1 className="text-[22px] font-bold">Админ-панель</h1>
</div>
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="card p-4 flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-[var(--secondary)] animate-skeleton" />
<div className="space-y-2 flex-1">
<div className="h-4 w-32 rounded-full bg-[var(--secondary)] animate-skeleton" />
<div className="h-3 w-20 rounded-full bg-[var(--secondary)] animate-skeleton" />
</div>
</div>
))}
</div>
) : (
<div className="card divide-y">
<div className="px-4 py-2.5 text-[12px] font-bold text-[var(--fg-secondary)] uppercase tracking-widest flex items-center justify-between">
<span>Пользователи ({users.length})</span>
{/* Tabs */}
<div className="flex gap-1 mb-5">
<button onClick={() => setTab('users')}
className={`px-4 py-2 rounded-lg text-[14px] font-medium transition-all ${tab === 'users' ? 'bg-[var(--primary-soft)] text-[var(--primary)]' : 'text-[var(--fg-secondary)] hover:bg-[var(--secondary)]'}`}>
Пользователи ({users.length})
</button>
<button onClick={() => setTab('cats')}
className={`px-4 py-2 rounded-lg text-[14px] font-medium transition-all ${tab === 'cats' ? 'bg-[var(--primary-soft)] text-[var(--primary)]' : 'text-[var(--fg-secondary)] hover:bg-[var(--secondary)]'}`}>
Посты ({cats.length})
</button>
</div>
{tab === 'users' && (
<>
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--fg-tertiary)]" strokeWidth={1.5} />
<input value={searchUser} onChange={e => setSearchUser(e.target.value)}
placeholder="Поиск пользователя..."
className="w-full h-10 pl-9 pr-4 text-[14px] rounded-xl bg-white border input-focus" />
</div>
{users.map((u: any) => (
<div key={u.id} className="flex items-center gap-3 px-4 py-3">
<Avatar className="h-10 w-10 ring-1 ring-[var(--border)]">
<AvatarFallback className="text-sm avatar-initials">{u.username.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[15px] font-semibold truncate">@{u.username}</span>
{u.is_admin ? (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-[var(--primary-light)] text-[var(--primary)] font-semibold">Админ</span>
) : null}
</div>
<p className="text-[12px] text-[var(--fg-secondary)]">🏆 {u.points} баллов</p>
</div>
{!u.is_admin && (
<button onClick={() => { if (confirm('Удалить пользователя и все его фото?')) deleteUserMutation.mutate(u.id); }}
className="btn-icon h-8 w-8 text-[var(--fg-tertiary)] hover:text-[var(--danger)] hover:bg-[var(--danger-light)]">
<Trash2 className="h-3.5 w-3.5" strokeWidth={1.5} />
</button>
)}
<div className="card divide-y">
<div className="px-4 py-2.5 text-[12px] font-bold text-[var(--fg-secondary)] uppercase tracking-widest">
<span>Пользователи</span>
</div>
{filteredUsers.length === 0 && (
<div className="px-4 py-8 text-center text-[14px] text-[var(--fg-tertiary)]">Нет пользователей</div>
)}
{filteredUsers.map((u: any) => (
<UserRow key={u.id} u={u} onDelete={() => deleteUserMut.mutate(u.id)} qc={qc} />
))}
</div>
</>
)}
{tab === 'cats' && (
<div className="card divide-y">
<div className="px-4 py-2.5 text-[12px] font-bold text-[var(--fg-secondary)] uppercase tracking-widest">
<span>Все посты</span>
</div>
{cats.length === 0 && (
<div className="px-4 py-8 text-center text-[14px] text-[var(--fg-tertiary)]">Нет постов</div>
)}
{cats.map((c: any) => (
<CatRow key={c.id} c={c} onDelete={() => deleteCatMut.mutate(c.id)} qc={qc} />
))}
</div>
)}
</div>
);
}
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 (
<div className="flex items-center gap-3 px-4 py-3">
<Avatar className="h-10 w-10 ring-1 ring-[var(--border)]">
<AvatarFallback className="text-sm avatar-initials">{u.username.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[15px] font-semibold truncate">@{u.username}</span>
{u.is_admin && <span className="text-[10px] px-2 py-0.5 rounded-full bg-[var(--primary-light)] text-[var(--primary)] font-semibold">Админ</span>}
</div>
<div className="flex items-center gap-2 text-[12px] text-[var(--fg-secondary)]">
{editing ? (
<div className="flex items-center gap-1.5">
<input type="number" value={points} min={0} onChange={e => setPoints(Math.max(0, parseInt(e.target.value) || 0))}
className="w-20 h-7 px-2 text-[13px] rounded-lg border input-focus" autoFocus />
<button onClick={savePoints} className="btn-icon h-7 w-7 text-green-600 hover:bg-green-50"><Save className="h-3.5 w-3.5" /></button>
<button onClick={() => { setEditing(false); setPoints(u.points); }} className="btn-icon h-7 w-7 text-[var(--fg-tertiary)] hover:bg-[var(--secondary)]"><X className="h-3.5 w-3.5" /></button>
</div>
) : (
<span>🏆 {u.points} баллов</span>
)}
</div>
</div>
{!u.is_admin && (
<div className="flex items-center gap-1">
<button onClick={() => { setEditing(true); setPoints(u.points); }}
className="btn-icon h-8 w-8 text-[var(--fg-tertiary)] hover:text-[var(--primary)] hover:bg-[var(--primary-light)]">
<Edit3 className="h-3.5 w-3.5" strokeWidth={1.5} />
</button>
<button onClick={() => { if (confirm('Удалить пользователя и все его фото?')) onDelete(); }}
className="btn-icon h-8 w-8 text-[var(--fg-tertiary)] hover:text-[var(--danger)] hover:bg-[var(--danger-light)]">
<Trash2 className="h-3.5 w-3.5" strokeWidth={1.5} />
</button>
</div>
)}
</div>
);
}
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 (
<div className="flex items-center gap-3 px-4 py-3">
<div className="h-10 w-10 rounded-lg bg-[var(--secondary)] overflow-hidden shrink-0">
<img src={c.image_url} alt="" className="h-full w-full object-cover" />
</div>
<div className="flex-1 min-w-0">
<span className="text-[14px] font-semibold">@{c.username}</span>
{editing ? (
<div className="flex items-center gap-1.5 mt-0.5">
<input type="text" value={caption} onChange={e => 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 || ''); } }} />
<button onClick={saveCaption} className="btn-icon h-7 w-7 text-green-600 hover:bg-green-50"><Save className="h-3.5 w-3.5" /></button>
<button onClick={() => { setEditing(false); setCaption(c.caption || ''); }} className="btn-icon h-7 w-7 text-[var(--fg-tertiary)] hover:bg-[var(--secondary)]"><X className="h-3.5 w-3.5" /></button>
</div>
) : (
<p className="text-[12px] text-[var(--fg-secondary)] truncate">{c.caption || 'Без подписи'}</p>
)}
</div>
<div className="flex items-center gap-1">
<button onClick={() => { setEditing(true); setCaption(c.caption || ''); }}
className="btn-icon h-8 w-8 text-[var(--fg-tertiary)] hover:text-[var(--primary)] hover:bg-[var(--primary-light)]">
<Edit3 className="h-3.5 w-3.5" strokeWidth={1.5} />
</button>
<button onClick={() => { if (confirm('Удалить этот пост?')) onDelete(); }}
className="btn-icon h-8 w-8 text-[var(--fg-tertiary)] hover:text-[var(--danger)] hover:bg-[var(--danger-light)]">
<Trash2 className="h-3.5 w-3.5" strokeWidth={1.5} />
</button>
</div>
</div>
);
}

View File

@@ -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;