Баллы +1 за загрузку, убраны из лайков. Админ-панель (admin/admin123)

This commit is contained in:
2026-05-29 12:13:42 +03:00
parent f8f220469c
commit 12701ffbc2
19 changed files with 250 additions and 62 deletions

View File

@@ -10,6 +10,7 @@ import FeedPage from '@/pages/FeedPage';
import UploadPage from '@/pages/UploadPage';
import CatPage from '@/pages/CatPage';
import ProfilePage from '@/pages/ProfilePage';
import AdminPage from '@/pages/AdminPage';
import { useEffect, useRef } from 'react';
const queryClient = new QueryClient({
@@ -82,6 +83,14 @@ function AnimatedRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<ProtectedRoute>
<AdminPage />
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/feed" replace />} />
<Route path="*" element={<Navigate to="/feed" replace />} />
</Routes>

View File

@@ -52,13 +52,26 @@ export function deleteCat(id: number) {
}
export function likeCat(catId: number) {
return api.post<{ liked: boolean; likesCount: number; ownerPoints: number }>(`/likes/${catId}`);
return api.post<{ liked: boolean; likesCount: number }>(`/likes/${catId}`);
}
export function unlikeCat(catId: number) {
return api.delete<{ liked: boolean; likesCount: number; ownerPoints: number }>(`/likes/${catId}`);
return api.delete<{ liked: boolean; likesCount: number }>(`/likes/${catId}`);
}
export function getLikeStatus(catId: number) {
return api.get<{ liked: boolean }>(`/likes/${catId}/status`);
}
// Admin
export function getAdminUsers() {
return api.get<{ users: User[] }>('/admin/users');
}
export function adminDeleteCat(id: number) {
return api.delete(`/admin/cats/${id}`);
}
export function adminDeleteUser(id: number) {
return api.delete(`/admin/users/${id}`);
}

View File

@@ -12,7 +12,7 @@ interface CatCardProps {
}
export default function CatCard({ cat, onOpen }: CatCardProps) {
const { user, updateUser } = useAuth();
const { user } = useAuth();
const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(cat.likes_count);
const { toast } = useToast();
@@ -25,11 +25,8 @@ export default function CatCard({ cat, onOpen }: CatCardProps) {
if (liked) {
setLiked(false);
setLikeCount((c) => c - 1);
try {
const res = await unlikeMutation.mutateAsync();
if (res.ownerPoints !== undefined && cat.user_id === user.id)
updateUser({ ...user, points: res.ownerPoints });
} catch { setLiked(true); setLikeCount((c) => c + 1); }
try { await unlikeMutation.mutateAsync(); }
catch { setLiked(true); setLikeCount((c) => c + 1); }
} else {
setLiked(true);
setLikeCount((c) => c + 1);

View File

@@ -12,7 +12,7 @@ interface CatModalProps {
}
export default function CatModal({ catId, onClose }: CatModalProps) {
const { user, updateUser } = useAuth();
const { user } = useAuth();
const { data, isLoading } = useCat(catId);
const { data: likeData, refetch: refetchLike } = useLikeStatus(catId);
const likeMutation = useLikeCat(catId);
@@ -40,11 +40,8 @@ export default function CatModal({ catId, onClose }: CatModalProps) {
if (isOwner || !user || !cat) return;
if (liked) {
setLiked(false); setLikeCount((c) => c - 1);
try {
const res = await unlikeMutation.mutateAsync();
if (res.ownerPoints !== undefined && cat.user_id === user.id) updateUser({ ...user, points: res.ownerPoints });
refetchLike();
} catch { setLiked(true); setLikeCount((c) => c + 1); }
try { await unlikeMutation.mutateAsync(); refetchLike(); }
catch { setLiked(true); setLikeCount((c) => c + 1); }
} else {
setLiked(true); setLikeCount((c) => c + 1);
toast('Нравится ❤️');

View File

@@ -1,6 +1,7 @@
import { Link, useLocation } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Shield } from 'lucide-react';
export default function Navbar() {
const { user } = useAuth();
@@ -19,12 +20,17 @@ export default function Navbar() {
<NavLink to="/feed" active={location.pathname === '/feed'} emoji="🏠" label="Лента" />
<NavLink to="/upload" active={location.pathname === '/upload'} emoji="" label="Добавить" short />
<Link to="/profile" className="ml-2">
<Link to="/profile" className="ml-2 relative">
<Avatar className="h-8 w-8 ring-1 ring-[var(--border)] hover:ring-[var(--primary)] transition-all">
<AvatarFallback className="text-[11px] avatar-initials">
{user.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
{user.is_admin && (
<span className="absolute -top-1 -right-1 h-3.5 w-3.5 rounded-full bg-[var(--primary)] flex items-center justify-center">
<Shield className="h-2 w-2 text-white" strokeWidth={3} />
</span>
)}
</Link>
</div>
</div>

View File

@@ -13,34 +13,26 @@ export function useLikeStatus(catId: number) {
export function useLikeCat(catId: number) {
const qc = useQueryClient();
const { updateUser, user } = useAuth();
return useMutation({
mutationFn: () => likeCat(catId).then((r) => r.data),
onSuccess: (data) => {
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['likeStatus', catId] });
qc.invalidateQueries({ queryKey: ['cat', catId] });
qc.invalidateQueries({ queryKey: ['cats'] });
if (user && data.ownerPoints !== undefined) {
updateUser({ ...user, points: data.ownerPoints });
}
},
});
}
export function useUnlikeCat(catId: number) {
const qc = useQueryClient();
const { updateUser, user } = useAuth();
return useMutation({
mutationFn: () => unlikeCat(catId).then((r) => r.data),
onSuccess: (data) => {
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['likeStatus', catId] });
qc.invalidateQueries({ queryKey: ['cat', catId] });
qc.invalidateQueries({ queryKey: ['cats'] });
if (user && data.ownerPoints !== undefined) {
updateUser({ ...user, points: data.ownerPoints });
}
},
});
}
}

View File

@@ -5,6 +5,7 @@ export interface User {
id: number;
username: string;
points: number;
is_admin?: boolean;
created_at?: string;
}

View File

@@ -0,0 +1,99 @@
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 { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { ArrowLeft, Trash2, Shield } from 'lucide-react';
import { useToast } from '@/components/Toast';
export default function AdminPage() {
const { user } = useAuth();
const navigate = useNavigate();
const { toast } = useToast();
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['admin', 'users'],
queryFn: () => getAdminUsers().then(r => r.data),
enabled: !!user?.is_admin,
});
const deleteUserMutation = useMutation({
mutationFn: (id: number) => adminDeleteUser(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin', 'users'] }); toast('Пользователь удалён'); },
onError: () => toast('Ошибка удаления', 'error'),
});
if (!user?.is_admin) {
return (
<div className="flex flex-col items-center justify-center py-24">
<div className="mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-[var(--secondary)]">
<Shield className="h-8 w-8 text-[var(--fg-tertiary)]" strokeWidth={1.5} />
</div>
<p className="text-[17px] font-semibold mb-1.5">Доступ запрещён</p>
<Button variant="outline" onClick={() => navigate('/feed')} className="rounded-full text-[13px] mt-2">Вернуться в ленту</Button>
</div>
);
}
const users = data?.users ?? [];
return (
<div className="mx-auto max-w-[660px] 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" />
Назад
</button>
<div className="flex items-center gap-3 mb-6">
<Shield className="h-6 w-6 text-[var(--primary)]" strokeWidth={1.5} />
<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>
</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>
))}
</div>
)}
</div>
);
}

View File

@@ -13,7 +13,7 @@ export default function CatPage() {
const { id } = useParams<{ id: string }>();
const catId = parseInt(id!);
const navigate = useNavigate();
const { user, updateUser } = useAuth();
const { user } = useAuth();
const { toast } = useToast();
const { data, isLoading, isError } = useCat(catId);
@@ -34,11 +34,8 @@ export default function CatPage() {
if (isOwner || !user || !cat) return;
if (isLiked) {
setLiked(false);
try {
const res = await unlikeMutation.mutateAsync();
if (res.ownerPoints !== undefined && cat.user_id === user.id) updateUser({ ...user, points: res.ownerPoints });
refetchLike();
} catch { setLiked(true); }
try { await unlikeMutation.mutateAsync(); refetchLike(); }
catch { setLiked(true); }
} else {
setLiked(true);
toast('Нравится ❤️');

View File

@@ -6,7 +6,7 @@ import CatModal from '@/components/CatModal';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { ArrowLeft, Plus, LogOut, Heart } from 'lucide-react';
import { ArrowLeft, Plus, LogOut, Heart, Shield } from 'lucide-react';
export default function ProfilePage() {
const { id: paramId } = useParams<{ id?: string }>();
@@ -75,11 +75,18 @@ export default function ProfilePage() {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<h1 className="text-[20px] font-bold truncate">@{profileUser?.username}</h1>
{isMe && (
<button onClick={logout} className="btn-icon h-8 w-8 text-[var(--fg-secondary)] hover:text-[var(--danger)] hover:bg-[var(--danger-light)] ml-auto" title="Выйти">
<LogOut className="h-4 w-4" strokeWidth={1.5} />
</button>
)}
<div className="flex items-center gap-1 ml-auto">
{isMe && me?.is_admin && (
<Link to="/admin" className="btn-icon h-8 w-8 text-[var(--primary)] hover:bg-[var(--primary-light)]" title="Админ-панель">
<Shield className="h-4 w-4" strokeWidth={1.5} />
</Link>
)}
{isMe && (
<button onClick={logout} className="btn-icon h-8 w-8 text-[var(--fg-secondary)] hover:text-[var(--danger)] hover:bg-[var(--danger-light)]" title="Выйти">
<LogOut className="h-4 w-4" strokeWidth={1.5} />
</button>
)}
</div>
</div>
<div className="flex items-center gap-6 text-[14px] mb-3">

View File

@@ -36,7 +36,7 @@ export default function UploadPage() {
try {
const result = await uploadMutation.mutateAsync(fd);
if (user) updateUser({ ...user, points: (user.points || 0) + 10 });
toast('+10 баллов 🎉');
toast('+1 балл 🎉');
navigate(`/cat/${result.cat.id}`);
} catch { toast('Ошибка загрузки', 'error'); }
};
@@ -98,7 +98,7 @@ export default function UploadPage() {
<div className="flex items-center justify-between pt-3 border-t">
<div className="flex items-center gap-2 text-[13px] text-[var(--fg-secondary)]">
<span>🏆</span>
<span className="font-medium">+10 баллов</span>
<span className="font-medium">+1 балл</span>
<span className="text-[var(--fg-tertiary)]">за публикацию</span>
</div>
<Button onClick={handleUpload} disabled={uploadMutation.isPending}

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/endpoints.ts","./src/components/catcard.tsx","./src/components/catmodal.tsx","./src/components/feedsidebar.tsx","./src/components/navbar.tsx","./src/components/protectedroute.tsx","./src/components/storycircle.tsx","./src/components/toast.tsx","./src/components/ui/avatar.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/skeleton.tsx","./src/hooks/useauth.tsx","./src/hooks/usecats.ts","./src/hooks/uselikes.ts","./src/lib/auth.ts","./src/lib/utils.ts","./src/pages/catpage.tsx","./src/pages/feedpage.tsx","./src/pages/loginpage.tsx","./src/pages/profilepage.tsx","./src/pages/registerpage.tsx","./src/pages/uploadpage.tsx"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/endpoints.ts","./src/components/catcard.tsx","./src/components/catmodal.tsx","./src/components/feedsidebar.tsx","./src/components/navbar.tsx","./src/components/protectedroute.tsx","./src/components/storycircle.tsx","./src/components/toast.tsx","./src/components/ui/avatar.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/skeleton.tsx","./src/hooks/useauth.tsx","./src/hooks/usecats.ts","./src/hooks/uselikes.ts","./src/lib/auth.ts","./src/lib/utils.ts","./src/pages/adminpage.tsx","./src/pages/catpage.tsx","./src/pages/feedpage.tsx","./src/pages/loginpage.tsx","./src/pages/profilepage.tsx","./src/pages/registerpage.tsx","./src/pages/uploadpage.tsx"],"version":"5.9.3"}