Баллы +1 за загрузку, убраны из лайков. Админ-панель (admin/admin123)
This commit is contained in:
@@ -10,6 +10,7 @@ import FeedPage from '@/pages/FeedPage';
|
|||||||
import UploadPage from '@/pages/UploadPage';
|
import UploadPage from '@/pages/UploadPage';
|
||||||
import CatPage from '@/pages/CatPage';
|
import CatPage from '@/pages/CatPage';
|
||||||
import ProfilePage from '@/pages/ProfilePage';
|
import ProfilePage from '@/pages/ProfilePage';
|
||||||
|
import AdminPage from '@/pages/AdminPage';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -82,6 +83,14 @@ function AnimatedRoutes() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AdminPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="/" element={<Navigate to="/feed" replace />} />
|
<Route path="/" element={<Navigate to="/feed" replace />} />
|
||||||
<Route path="*" element={<Navigate to="/feed" replace />} />
|
<Route path="*" element={<Navigate to="/feed" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -52,13 +52,26 @@ export function deleteCat(id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function likeCat(catId: 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) {
|
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) {
|
export function getLikeStatus(catId: number) {
|
||||||
return api.get<{ liked: boolean }>(`/likes/${catId}/status`);
|
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) {
|
export default function CatCard({ cat, onOpen }: CatCardProps) {
|
||||||
const { user, updateUser } = useAuth();
|
const { user } = useAuth();
|
||||||
const [liked, setLiked] = useState(false);
|
const [liked, setLiked] = useState(false);
|
||||||
const [likeCount, setLikeCount] = useState(cat.likes_count);
|
const [likeCount, setLikeCount] = useState(cat.likes_count);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -25,11 +25,8 @@ export default function CatCard({ cat, onOpen }: CatCardProps) {
|
|||||||
if (liked) {
|
if (liked) {
|
||||||
setLiked(false);
|
setLiked(false);
|
||||||
setLikeCount((c) => c - 1);
|
setLikeCount((c) => c - 1);
|
||||||
try {
|
try { await unlikeMutation.mutateAsync(); }
|
||||||
const res = await unlikeMutation.mutateAsync();
|
catch { setLiked(true); setLikeCount((c) => c + 1); }
|
||||||
if (res.ownerPoints !== undefined && cat.user_id === user.id)
|
|
||||||
updateUser({ ...user, points: res.ownerPoints });
|
|
||||||
} catch { setLiked(true); setLikeCount((c) => c + 1); }
|
|
||||||
} else {
|
} else {
|
||||||
setLiked(true);
|
setLiked(true);
|
||||||
setLikeCount((c) => c + 1);
|
setLikeCount((c) => c + 1);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface CatModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CatModal({ catId, onClose }: CatModalProps) {
|
export default function CatModal({ catId, onClose }: CatModalProps) {
|
||||||
const { user, updateUser } = useAuth();
|
const { user } = useAuth();
|
||||||
const { data, isLoading } = useCat(catId);
|
const { data, isLoading } = useCat(catId);
|
||||||
const { data: likeData, refetch: refetchLike } = useLikeStatus(catId);
|
const { data: likeData, refetch: refetchLike } = useLikeStatus(catId);
|
||||||
const likeMutation = useLikeCat(catId);
|
const likeMutation = useLikeCat(catId);
|
||||||
@@ -40,11 +40,8 @@ export default function CatModal({ catId, onClose }: CatModalProps) {
|
|||||||
if (isOwner || !user || !cat) return;
|
if (isOwner || !user || !cat) return;
|
||||||
if (liked) {
|
if (liked) {
|
||||||
setLiked(false); setLikeCount((c) => c - 1);
|
setLiked(false); setLikeCount((c) => c - 1);
|
||||||
try {
|
try { await unlikeMutation.mutateAsync(); refetchLike(); }
|
||||||
const res = await unlikeMutation.mutateAsync();
|
catch { setLiked(true); setLikeCount((c) => c + 1); }
|
||||||
if (res.ownerPoints !== undefined && cat.user_id === user.id) updateUser({ ...user, points: res.ownerPoints });
|
|
||||||
refetchLike();
|
|
||||||
} catch { setLiked(true); setLikeCount((c) => c + 1); }
|
|
||||||
} else {
|
} else {
|
||||||
setLiked(true); setLikeCount((c) => c + 1);
|
setLiked(true); setLikeCount((c) => c + 1);
|
||||||
toast('Нравится ❤️');
|
toast('Нравится ❤️');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
|
import { Shield } from 'lucide-react';
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -19,12 +20,17 @@ export default function Navbar() {
|
|||||||
<NavLink to="/feed" active={location.pathname === '/feed'} emoji="🏠" label="Лента" />
|
<NavLink to="/feed" active={location.pathname === '/feed'} emoji="🏠" label="Лента" />
|
||||||
<NavLink to="/upload" active={location.pathname === '/upload'} emoji="➕" label="Добавить" short />
|
<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">
|
<Avatar className="h-8 w-8 ring-1 ring-[var(--border)] hover:ring-[var(--primary)] transition-all">
|
||||||
<AvatarFallback className="text-[11px] avatar-initials">
|
<AvatarFallback className="text-[11px] avatar-initials">
|
||||||
{user.username.charAt(0).toUpperCase()}
|
{user.username.charAt(0).toUpperCase()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,34 +13,26 @@ export function useLikeStatus(catId: number) {
|
|||||||
|
|
||||||
export function useLikeCat(catId: number) {
|
export function useLikeCat(catId: number) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { updateUser, user } = useAuth();
|
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => likeCat(catId).then((r) => r.data),
|
mutationFn: () => likeCat(catId).then((r) => r.data),
|
||||||
onSuccess: (data) => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['likeStatus', catId] });
|
qc.invalidateQueries({ queryKey: ['likeStatus', catId] });
|
||||||
qc.invalidateQueries({ queryKey: ['cat', catId] });
|
qc.invalidateQueries({ queryKey: ['cat', catId] });
|
||||||
qc.invalidateQueries({ queryKey: ['cats'] });
|
qc.invalidateQueries({ queryKey: ['cats'] });
|
||||||
if (user && data.ownerPoints !== undefined) {
|
|
||||||
updateUser({ ...user, points: data.ownerPoints });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUnlikeCat(catId: number) {
|
export function useUnlikeCat(catId: number) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { updateUser, user } = useAuth();
|
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => unlikeCat(catId).then((r) => r.data),
|
mutationFn: () => unlikeCat(catId).then((r) => r.data),
|
||||||
onSuccess: (data) => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['likeStatus', catId] });
|
qc.invalidateQueries({ queryKey: ['likeStatus', catId] });
|
||||||
qc.invalidateQueries({ queryKey: ['cat', catId] });
|
qc.invalidateQueries({ queryKey: ['cat', catId] });
|
||||||
qc.invalidateQueries({ queryKey: ['cats'] });
|
qc.invalidateQueries({ queryKey: ['cats'] });
|
||||||
if (user && data.ownerPoints !== undefined) {
|
|
||||||
updateUser({ ...user, points: data.ownerPoints });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ export interface User {
|
|||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
points: number;
|
points: number;
|
||||||
|
is_admin?: boolean;
|
||||||
created_at?: string;
|
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 { id } = useParams<{ id: string }>();
|
||||||
const catId = parseInt(id!);
|
const catId = parseInt(id!);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, updateUser } = useAuth();
|
const { user } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const { data, isLoading, isError } = useCat(catId);
|
const { data, isLoading, isError } = useCat(catId);
|
||||||
@@ -34,11 +34,8 @@ export default function CatPage() {
|
|||||||
if (isOwner || !user || !cat) return;
|
if (isOwner || !user || !cat) return;
|
||||||
if (isLiked) {
|
if (isLiked) {
|
||||||
setLiked(false);
|
setLiked(false);
|
||||||
try {
|
try { await unlikeMutation.mutateAsync(); refetchLike(); }
|
||||||
const res = await unlikeMutation.mutateAsync();
|
catch { setLiked(true); }
|
||||||
if (res.ownerPoints !== undefined && cat.user_id === user.id) updateUser({ ...user, points: res.ownerPoints });
|
|
||||||
refetchLike();
|
|
||||||
} catch { setLiked(true); }
|
|
||||||
} else {
|
} else {
|
||||||
setLiked(true);
|
setLiked(true);
|
||||||
toast('Нравится ❤️');
|
toast('Нравится ❤️');
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import CatModal from '@/components/CatModal';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
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() {
|
export default function ProfilePage() {
|
||||||
const { id: paramId } = useParams<{ id?: string }>();
|
const { id: paramId } = useParams<{ id?: string }>();
|
||||||
@@ -75,11 +75,18 @@ export default function ProfilePage() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<h1 className="text-[20px] font-bold truncate">@{profileUser?.username}</h1>
|
<h1 className="text-[20px] font-bold truncate">@{profileUser?.username}</h1>
|
||||||
{isMe && (
|
<div className="flex items-center gap-1 ml-auto">
|
||||||
<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="Выйти">
|
{isMe && me?.is_admin && (
|
||||||
<LogOut className="h-4 w-4" strokeWidth={1.5} />
|
<Link to="/admin" className="btn-icon h-8 w-8 text-[var(--primary)] hover:bg-[var(--primary-light)]" title="Админ-панель">
|
||||||
</button>
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center gap-6 text-[14px] mb-3">
|
<div className="flex items-center gap-6 text-[14px] mb-3">
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function UploadPage() {
|
|||||||
try {
|
try {
|
||||||
const result = await uploadMutation.mutateAsync(fd);
|
const result = await uploadMutation.mutateAsync(fd);
|
||||||
if (user) updateUser({ ...user, points: (user.points || 0) + 10 });
|
if (user) updateUser({ ...user, points: (user.points || 0) + 10 });
|
||||||
toast('+10 баллов 🎉');
|
toast('+1 балл 🎉');
|
||||||
navigate(`/cat/${result.cat.id}`);
|
navigate(`/cat/${result.cat.id}`);
|
||||||
} catch { toast('Ошибка загрузки', 'error'); }
|
} 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 justify-between pt-3 border-t">
|
||||||
<div className="flex items-center gap-2 text-[13px] text-[var(--fg-secondary)]">
|
<div className="flex items-center gap-2 text-[13px] text-[var(--fg-secondary)]">
|
||||||
<span>🏆</span>
|
<span>🏆</span>
|
||||||
<span className="font-medium">+10 баллов</span>
|
<span className="font-medium">+1 балл</span>
|
||||||
<span className="text-[var(--fg-tertiary)]">за публикацию</span>
|
<span className="text-[var(--fg-tertiary)]">за публикацию</span>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleUpload} disabled={uploadMutation.isPending}
|
<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"}
|
||||||
@@ -2,6 +2,7 @@ import initSqlJs, { Database as SqlJsDatabase } from 'sql.js';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const dbPath = path.join(__dirname, '..', 'data.db');
|
const dbPath = path.join(__dirname, '..', 'data.db');
|
||||||
@@ -26,10 +27,22 @@ export async function initDb(): Promise<SqlJsDatabase> {
|
|||||||
username TEXT UNIQUE NOT NULL,
|
username TEXT UNIQUE NOT NULL,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
points INTEGER DEFAULT 0,
|
points INTEGER DEFAULT 0,
|
||||||
|
is_admin INTEGER DEFAULT 0,
|
||||||
created_at TEXT DEFAULT (datetime('now'))
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Add is_admin column if missing (existing DB migration)
|
||||||
|
try { db.run('ALTER TABLE users ADD COLUMN is_admin INTEGER DEFAULT 0'); } catch {}
|
||||||
|
|
||||||
|
// Auto-create admin if not exists
|
||||||
|
const admin = queryOne('SELECT id FROM users WHERE username = ?', ['admin']);
|
||||||
|
if (!admin) {
|
||||||
|
const hash = bcrypt.hashSync('admin123', 10);
|
||||||
|
execute('INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 1)', ['admin', hash]);
|
||||||
|
console.log('Admin user created: admin / admin123');
|
||||||
|
}
|
||||||
|
|
||||||
db.run(`
|
db.run(`
|
||||||
CREATE TABLE IF NOT EXISTS cats (
|
CREATE TABLE IF NOT EXISTS cats (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { initDb } from './db.js';
|
|||||||
import authRoutes from './routes/auth.js';
|
import authRoutes from './routes/auth.js';
|
||||||
import catRoutes from './routes/cats.js';
|
import catRoutes from './routes/cats.js';
|
||||||
import likeRoutes from './routes/likes.js';
|
import likeRoutes from './routes/likes.js';
|
||||||
|
import adminRoutes from './routes/admin.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ async function main() {
|
|||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/cats', catRoutes);
|
app.use('/api/cats', catRoutes);
|
||||||
app.use('/api/likes', likeRoutes);
|
app.use('/api/likes', likeRoutes);
|
||||||
|
app.use('/api/admin', adminRoutes);
|
||||||
|
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({ status: 'ok' });
|
res.json({ status: 'ok' });
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { Request, Response, NextFunction } from 'express';
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { queryOne } from '../db.js';
|
||||||
|
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'catstagram-dev-secret-key-change-in-production';
|
const JWT_SECRET = process.env.JWT_SECRET || 'catstagram-dev-secret-key-change-in-production';
|
||||||
|
|
||||||
export interface AuthRequest extends Request {
|
export interface AuthRequest extends Request {
|
||||||
userId?: number;
|
userId?: number;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateToken(userId: number, username: string): string {
|
export function generateToken(userId: number, username: string, isAdmin: boolean): string {
|
||||||
return jwt.sign({ userId, username }, JWT_SECRET, { expiresIn: '7d' });
|
return jwt.sign({ userId, username, isAdmin }, JWT_SECRET, { expiresIn: '7d' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction): void {
|
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction): void {
|
||||||
@@ -21,11 +23,20 @@ export function authMiddleware(req: AuthRequest, res: Response, next: NextFuncti
|
|||||||
|
|
||||||
const token = header.split(' ')[1];
|
const token = header.split(' ')[1];
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, JWT_SECRET) as { userId: number; username: string };
|
const decoded = jwt.verify(token, JWT_SECRET) as { userId: number; username: string; isAdmin: boolean };
|
||||||
req.userId = decoded.userId;
|
req.userId = decoded.userId;
|
||||||
req.username = decoded.username;
|
req.username = decoded.username;
|
||||||
|
req.isAdmin = decoded.isAdmin;
|
||||||
next();
|
next();
|
||||||
} catch {
|
} catch {
|
||||||
res.status(401).json({ error: 'Invalid token' });
|
res.status(401).json({ error: 'Invalid token' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function adminOnly(req: AuthRequest, res: Response, next: NextFunction): void {
|
||||||
|
if (!req.isAdmin) {
|
||||||
|
res.status(403).json({ error: 'Доступ только администратору' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
48
server/src/routes/admin.ts
Normal file
48
server/src/routes/admin.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Router, Response } from 'express';
|
||||||
|
import { queryAll, queryOne, execute } from '../db';
|
||||||
|
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) => {
|
||||||
|
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' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
execute('DELETE FROM likes WHERE cat_id = ?', [req.params.id]);
|
||||||
|
execute('DELETE FROM cats WHERE id = ?', [req.params.id]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -27,32 +27,32 @@ router.post('/register', (req: Request, res: Response) => {
|
|||||||
const password_hash = bcrypt.hashSync(password, 10);
|
const password_hash = bcrypt.hashSync(password, 10);
|
||||||
execute('INSERT INTO users (username, password_hash) VALUES (?, ?)', [username, password_hash]);
|
execute('INSERT INTO users (username, password_hash) VALUES (?, ?)', [username, password_hash]);
|
||||||
|
|
||||||
const user = queryOne('SELECT id, username, points FROM users WHERE username = ?', [username]);
|
const user = queryOne('SELECT id, username, points, is_admin FROM users WHERE username = ?', [username]);
|
||||||
|
|
||||||
const token = generateToken(user.id, user.username);
|
const token = generateToken(user.id, user.username, !!user.is_admin);
|
||||||
res.status(201).json({ token, user });
|
res.status(201).json({ token, user: { id: user.id, username: user.username, points: user.points, is_admin: !!user.is_admin } });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/login', (req: Request, res: Response) => {
|
router.post('/login', (req: Request, res: Response) => {
|
||||||
const { username, password } = req.body;
|
const { username, password } = req.body;
|
||||||
|
|
||||||
const user = queryOne('SELECT id, username, password_hash, points FROM users WHERE username = ?', [username]);
|
const user = queryOne('SELECT id, username, password_hash, points, is_admin FROM users WHERE username = ?', [username]);
|
||||||
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
|
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
|
||||||
res.status(401).json({ error: 'Неверное имя пользователя или пароль' });
|
res.status(401).json({ error: 'Неверное имя пользователя или пароль' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = generateToken(user.id, user.username);
|
const token = generateToken(user.id, user.username, !!user.is_admin);
|
||||||
res.json({ token, user: { id: user.id, username: user.username, points: user.points } });
|
res.json({ token, user: { id: user.id, username: user.username, points: user.points, is_admin: !!user.is_admin } });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/me', authMiddleware, (req: AuthRequest, res: Response) => {
|
router.get('/me', authMiddleware, (req: AuthRequest, res: Response) => {
|
||||||
const user = queryOne('SELECT id, username, points, created_at FROM users WHERE id = ?', [req.userId]);
|
const user = queryOne('SELECT id, username, points, is_admin, created_at FROM users WHERE id = ?', [req.userId]);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
res.status(404).json({ error: 'User not found' });
|
res.status(404).json({ error: 'User not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.json({ user });
|
res.json({ user: { id: user.id, username: user.username, points: user.points, is_admin: !!user.is_admin, created_at: user.created_at } });
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -99,7 +99,7 @@ router.post('/', authMiddleware, upload.single('image'), (req: AuthRequest, res:
|
|||||||
[req.userId, image_url, caption]
|
[req.userId, image_url, caption]
|
||||||
);
|
);
|
||||||
|
|
||||||
execute('UPDATE users SET points = points + 10 WHERE id = ?', [req.userId]);
|
execute('UPDATE users SET points = points + 1 WHERE id = ?', [req.userId]);
|
||||||
|
|
||||||
const cat = queryOne(
|
const cat = queryOne(
|
||||||
`SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id,
|
`SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id,
|
||||||
@@ -120,7 +120,7 @@ router.delete('/:id', authMiddleware, (req: AuthRequest, res: Response) => {
|
|||||||
res.status(404).json({ error: 'Cat not found' });
|
res.status(404).json({ error: 'Cat not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (cat.user_id !== req.userId) {
|
if (cat.user_id !== req.userId && !req.isAdmin) {
|
||||||
res.status(403).json({ error: 'Not your cat' });
|
res.status(403).json({ error: 'Not your cat' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,10 @@ router.post('/:catId', authMiddleware, (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
execute('INSERT INTO likes (cat_id, user_id) VALUES (?, ?)', [catId, userId]);
|
execute('INSERT INTO likes (cat_id, user_id) VALUES (?, ?)', [catId, userId]);
|
||||||
execute('UPDATE users SET points = points + 1 WHERE id = ?', [cat.user_id]);
|
|
||||||
|
|
||||||
const countResult = queryOne('SELECT COUNT(*) as count FROM likes WHERE cat_id = ?', [catId]);
|
const countResult = queryOne('SELECT COUNT(*) as count FROM likes WHERE cat_id = ?', [catId]);
|
||||||
const owner = queryOne('SELECT points FROM users WHERE id = ?', [cat.user_id]);
|
|
||||||
|
|
||||||
res.json({ liked: true, likesCount: countResult?.count || 0, ownerPoints: owner?.points || 0 });
|
res.json({ liked: true, likesCount: countResult?.count || 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/:catId', authMiddleware, (req: AuthRequest, res: Response) => {
|
router.delete('/:catId', authMiddleware, (req: AuthRequest, res: Response) => {
|
||||||
@@ -51,12 +49,10 @@ router.delete('/:catId', authMiddleware, (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
execute('DELETE FROM likes WHERE id = ?', [existing.id]);
|
execute('DELETE FROM likes WHERE id = ?', [existing.id]);
|
||||||
execute('UPDATE users SET points = MAX(0, points - 1) WHERE id = ?', [cat.user_id]);
|
|
||||||
|
|
||||||
const countResult = queryOne('SELECT COUNT(*) as count FROM likes WHERE cat_id = ?', [catId]);
|
const countResult = queryOne('SELECT COUNT(*) as count FROM likes WHERE cat_id = ?', [catId]);
|
||||||
const owner = queryOne('SELECT points FROM users WHERE id = ?', [cat.user_id]);
|
|
||||||
|
|
||||||
res.json({ liked: false, likesCount: countResult?.count || 0, ownerPoints: owner?.points || 0 });
|
res.json({ liked: false, likesCount: countResult?.count || 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/:catId/status', authMiddleware, (req: AuthRequest, res: Response) => {
|
router.get('/:catId/status', authMiddleware, (req: AuthRequest, res: Response) => {
|
||||||
@@ -67,4 +63,4 @@ router.get('/:catId/status', authMiddleware, (req: AuthRequest, res: Response) =
|
|||||||
res.json({ liked: !!liked });
|
res.json({ liked: !!liked });
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
Reference in New Issue
Block a user