Баллы +1 за загрузку, убраны из лайков. Админ-панель (admin/admin123)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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('Нравится ❤️');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
points: number;
|
||||
is_admin?: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
|
||||
99
client/src/pages/AdminPage.tsx
Normal file
99
client/src/pages/AdminPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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('Нравится ❤️');
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user