Fix mobile scroll jump, add comments, rename to Котограм

This commit is contained in:
2026-05-29 13:58:42 +03:00
parent 4bf9ea22dd
commit 3664b60f10
16 changed files with 352 additions and 34 deletions

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🐱</text></svg>" /> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🐱</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Catstagram</title> <title>Котограм</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&display=swap" rel="stylesheet" />

View File

@@ -10,6 +10,15 @@ export interface Cat {
username: string; username: string;
user_points: number; user_points: number;
likes_count: number; likes_count: number;
comments_count: number;
}
export interface Comment {
id: number;
text: string;
created_at: string;
user_id: number;
username: string;
} }
export interface AuthResponse { export interface AuthResponse {
@@ -87,3 +96,16 @@ export function adminDeleteCat(id: number) {
export function adminDeleteUser(id: number) { export function adminDeleteUser(id: number) {
return api.delete(`/admin/users/${id}`); return api.delete(`/admin/users/${id}`);
} }
// Comments
export function getComments(catId: number) {
return api.get<{ comments: Comment[] }>(`/comments/${catId}`);
}
export function addComment(catId: number, text: string) {
return api.post<{ comment: Comment }>(`/comments/${catId}`, { text });
}
export function deleteComment(commentId: number) {
return api.delete(`/comments/${commentId}`);
}

View File

@@ -90,6 +90,7 @@ export default function CatCard({ cat, onOpen }: CatCardProps) {
className="flex items-center gap-1 text-sm text-[var(--fg-tertiary)] hover:text-[var(--fg-secondary)] transition-colors" className="flex items-center gap-1 text-sm text-[var(--fg-tertiary)] hover:text-[var(--fg-secondary)] transition-colors"
> >
<MessageCircle className="h-[18px] w-[18px]" strokeWidth={1.5} /> <MessageCircle className="h-[18px] w-[18px]" strokeWidth={1.5} />
{(cat.comments_count ?? 0) > 0 && <span className="stat-value text-xs">{cat.comments_count}</span>}
</button> </button>
</div> </div>

View File

@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, useRef } from 'react';
import { useCat, useDeleteCat } from '@/hooks/useCats'; import { useCat, useDeleteCat } from '@/hooks/useCats';
import { useLikeStatus, useLikeCat, useUnlikeCat } from '@/hooks/useLikes'; import { useLikeStatus, useLikeCat, useUnlikeCat } from '@/hooks/useLikes';
import { useComments, useAddComment, useDeleteComment } from '@/hooks/useComments';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/components/Toast'; import { useToast } from '@/components/Toast';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { X, Heart, Trash2, Trophy } from 'lucide-react'; import { X, Heart, Trash2, Trophy, Send } from 'lucide-react';
interface CatModalProps { interface CatModalProps {
catId: number; catId: number;
@@ -18,15 +19,35 @@ export default function CatModal({ catId, onClose }: CatModalProps) {
const likeMutation = useLikeCat(catId); const likeMutation = useLikeCat(catId);
const unlikeMutation = useUnlikeCat(catId); const unlikeMutation = useUnlikeCat(catId);
const deleteMutation = useDeleteCat(); const deleteMutation = useDeleteCat();
const { data: commentsData } = useComments(catId);
const addCommentMutation = useAddComment(catId);
const deleteCommentMutation = useDeleteComment(catId);
const { toast } = useToast(); const { toast } = useToast();
const [liked, setLiked] = useState(false); const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(0); const [likeCount, setLikeCount] = useState(0);
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
const [commentText, setCommentText] = useState('');
const commentsEndRef = useRef<HTMLDivElement>(null);
useEffect(() => { if (likeData) setLiked(likeData.liked); }, [likeData]); useEffect(() => { if (likeData) setLiked(likeData.liked); }, [likeData]);
useEffect(() => { if (data?.cat) setLikeCount(data.cat.likes_count); }, [data]); useEffect(() => { if (data?.cat) setLikeCount(data.cat.likes_count); }, [data]);
useEffect(() => { document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = ''; }; }, []); useEffect(() => {
const scrollY = window.scrollY;
document.body.style.position = 'fixed';
document.body.style.top = `-${scrollY}px`;
document.body.style.left = '0';
document.body.style.right = '0';
document.body.style.overflow = 'hidden';
return () => {
document.body.style.position = '';
document.body.style.top = '';
document.body.style.left = '';
document.body.style.right = '';
document.body.style.overflow = '';
window.scrollTo(0, scrollY);
};
}, []);
useEffect(() => { useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', h); window.addEventListener('keydown', h);
@@ -34,6 +55,7 @@ export default function CatModal({ catId, onClose }: CatModalProps) {
}, [onClose]); }, [onClose]);
const cat = data?.cat; const cat = data?.cat;
const comments = commentsData?.comments ?? [];
const isOwner = cat && user && cat.user_id === user.id; const isOwner = cat && user && cat.user_id === user.id;
const toggleLike = async () => { const toggleLike = async () => {
@@ -56,6 +78,21 @@ export default function CatModal({ catId, onClose }: CatModalProps) {
catch { toast('Ошибка удаления', 'error'); } catch { toast('Ошибка удаления', 'error'); }
}; };
const handleComment = async () => {
const text = commentText.trim();
if (!text || !user) return;
try {
await addCommentMutation.mutateAsync(text);
setCommentText('');
setTimeout(() => commentsEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100);
} catch { toast('Ошибка', 'error'); }
};
const handleDeleteComment = async (commentId: number) => {
try { await deleteCommentMutation.mutateAsync(commentId); }
catch { toast('Ошибка', 'error'); }
};
if (isLoading || !cat) { if (isLoading || !cat) {
return ( return (
<div className="fixed inset-0 z-[60] bg-black/30 flex items-center justify-center animate-fade-in" onClick={onClose}> <div className="fixed inset-0 z-[60] bg-black/30 flex items-center justify-center animate-fade-in" onClick={onClose}>
@@ -110,26 +147,60 @@ export default function CatModal({ catId, onClose }: CatModalProps) {
<img <img
src={cat.image_url} src={cat.image_url}
alt={cat.caption || 'Фото кота'} alt={cat.caption || 'Фото кота'}
className={`w-full max-h-[55vh] object-contain transition-opacity duration-300 ${imageLoaded ? 'opacity-100' : 'opacity-0'}`} className={`w-full max-h-[45vh] object-contain transition-opacity duration-300 ${imageLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setImageLoaded(true)} onLoad={() => setImageLoaded(true)}
/> />
</div> </div>
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3"> <div className="flex-1 overflow-y-auto px-5 py-4 space-y-3 min-h-0">
{cat.caption && (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Avatar className="h-8 w-8 shrink-0 mt-0.5"> <Avatar className="h-7 w-7 shrink-0 mt-0.5">
<AvatarFallback className="text-[10px] avatar-colored bg-[var(--accent)]"> <AvatarFallback className="text-[9px] avatar-colored bg-[var(--accent)]">
{cat.username.charAt(0).toUpperCase()} {cat.username.charAt(0).toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<p className="text-sm leading-[1.5]"> <p className="text-sm leading-[1.5]">
<span className="font-semibold mr-1">@{cat.username}</span> <span className="font-semibold mr-1">@{cat.username}</span>
{cat.caption || 'Без подписи'} {cat.caption}
</p> </p>
</div> </div>
)}
{comments.length > 0 && (
<div className="space-y-3 pt-1">
{comments.map(c => (
<div key={c.id} className="flex items-start gap-2.5 group">
<Avatar className="h-7 w-7 shrink-0 mt-0.5">
<AvatarFallback className="text-[9px] avatar-colored bg-[var(--fg-tertiary)]">
{c.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm leading-[1.5]">
<span className="font-semibold mr-1">@{c.username}</span>
{c.text}
</p>
<p className="text-[10px] text-[var(--fg-tertiary)] mt-0.5">
{new Date(c.created_at).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
</p>
</div>
{(user && (c.user_id === user.id || user.is_admin)) && (
<button
onClick={() => handleDeleteComment(c.id)}
className="btn-ghost h-6 w-6 text-[var(--fg-tertiary)] hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
>
<X className="h-3 w-3" strokeWidth={2} />
</button>
)}
</div>
))}
<div ref={commentsEndRef} />
</div>
)}
</div> </div>
<div className="border-t px-5 py-4 shrink-0"> <div className="border-t px-5 py-3 shrink-0 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<button <button
onClick={toggleLike} onClick={toggleLike}
@@ -144,9 +215,31 @@ export default function CatModal({ catId, onClose }: CatModalProps) {
<span className="flex items-center gap-1.5 text-xs text-[var(--fg-tertiary)]"> <span className="flex items-center gap-1.5 text-xs text-[var(--fg-tertiary)]">
<Trophy className="h-3.5 w-3.5" strokeWidth={1.5} /> <Trophy className="h-3.5 w-3.5" strokeWidth={1.5} />
<span className="font-semibold text-[var(--fg-secondary)]">{cat.user_points}</span> <span className="font-semibold text-[var(--fg-secondary)]">{cat.user_points}</span>
<span>баллов</span>
</span> </span>
</div> </div>
{user && (
<form
onSubmit={e => { e.preventDefault(); handleComment(); }}
className="flex items-center gap-2"
>
<input
type="text"
value={commentText}
onChange={e => setCommentText(e.target.value)}
placeholder="Комментарий..."
maxLength={500}
className="flex-1 h-9 px-3 text-sm rounded-xl border border-[var(--border)] bg-white placeholder:text-[var(--fg-tertiary)] focus-ring"
/>
<button
type="submit"
disabled={!commentText.trim() || addCommentMutation.isPending}
className="btn-ghost h-9 w-9 text-[var(--accent)] hover:bg-[var(--accent-light)] disabled:opacity-30 disabled:cursor-not-allowed"
>
<Send className="h-4 w-4" strokeWidth={1.5} />
</button>
</form>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -70,7 +70,7 @@ export default function FeedSidebar() {
)} )}
</div> </div>
<p className="px-1 text-[10px] text-[var(--fg-tertiary)] tracking-wide">Catstagram · сообщество котоводов</p> <p className="px-1 text-[10px] text-[var(--fg-tertiary)] tracking-wide">Котограм · сообщество котоводов</p>
</div> </div>
); );
} }

View File

@@ -13,7 +13,7 @@ export default function Navbar() {
<nav className="sticky top-0 z-50 nav-glass border-b border-[var(--border-light)]"> <nav className="sticky top-0 z-50 nav-glass border-b border-[var(--border-light)]">
<div className="mx-auto flex h-14 max-w-[540px] items-center justify-between px-5"> <div className="mx-auto flex h-14 max-w-[540px] items-center justify-between px-5">
<Link to="/feed" className="text-[15px] font-800 tracking-tight text-[var(--fg)]"> <Link to="/feed" className="text-[15px] font-800 tracking-tight text-[var(--fg)]">
Catstagram Котограм
</Link> </Link>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">

View File

@@ -0,0 +1,34 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getComments, addComment, deleteComment } from '@/api/endpoints';
export function useComments(catId: number) {
return useQuery({
queryKey: ['comments', catId],
queryFn: () => getComments(catId).then(r => r.data),
enabled: !!catId,
});
}
export function useAddComment(catId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (text: string) => addComment(catId, text).then(r => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['comments', catId] });
qc.invalidateQueries({ queryKey: ['cats'] });
qc.invalidateQueries({ queryKey: ['cat', catId] });
},
});
}
export function useDeleteComment(catId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: (commentId: number) => deleteComment(commentId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['comments', catId] });
qc.invalidateQueries({ queryKey: ['cats'] });
qc.invalidateQueries({ queryKey: ['cat', catId] });
},
});
}

View File

@@ -1,5 +1,5 @@
const TOKEN_KEY = 'catstagram_token'; const TOKEN_KEY = 'kotogram_token';
const USER_KEY = 'catstagram_user'; const USER_KEY = 'kotogram_user';
export interface User { export interface User {
id: number; id: number;

View File

@@ -1,13 +1,14 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom'; import { useParams, useNavigate, Link } from 'react-router-dom';
import { useCat, useDeleteCat } from '@/hooks/useCats'; import { useCat, useDeleteCat } from '@/hooks/useCats';
import { useLikeStatus, useLikeCat, useUnlikeCat } from '@/hooks/useLikes'; import { useLikeStatus, useLikeCat, useUnlikeCat } from '@/hooks/useLikes';
import { useComments, useAddComment, useDeleteComment } from '@/hooks/useComments';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/components/Toast'; import { useToast } from '@/components/Toast';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { ArrowLeft, Heart, Trash2, Trophy, Image } from 'lucide-react'; import { ArrowLeft, Heart, Trash2, Trophy, Image, Send, X } from 'lucide-react';
export default function CatPage() { export default function CatPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -21,10 +22,16 @@ export default function CatPage() {
const likeMutation = useLikeCat(catId); const likeMutation = useLikeCat(catId);
const unlikeMutation = useUnlikeCat(catId); const unlikeMutation = useUnlikeCat(catId);
const deleteMutation = useDeleteCat(); const deleteMutation = useDeleteCat();
const { data: commentsData } = useComments(catId);
const addCommentMutation = useAddComment(catId);
const deleteCommentMutation = useDeleteComment(catId);
const [liked, setLiked] = useState(false); const [liked, setLiked] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
const [commentText, setCommentText] = useState('');
const commentsEndRef = useRef<HTMLDivElement>(null);
const cat = data?.cat; const cat = data?.cat;
const comments = commentsData?.comments ?? [];
const isOwner = cat && user && cat.user_id === user.id; const isOwner = cat && user && cat.user_id === user.id;
const isLiked = likeData?.liked ?? false; const isLiked = likeData?.liked ?? false;
@@ -50,6 +57,21 @@ export default function CatPage() {
catch { toast('Ошибка удаления', 'error'); } catch { toast('Ошибка удаления', 'error'); }
}; };
const handleComment = async () => {
const text = commentText.trim();
if (!text || !user) return;
try {
await addCommentMutation.mutateAsync(text);
setCommentText('');
setTimeout(() => commentsEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 100);
} catch { toast('Ошибка', 'error'); }
};
const handleDeleteComment = async (commentId: number) => {
try { await deleteCommentMutation.mutateAsync(commentId); }
catch { toast('Ошибка', 'error'); }
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="mx-auto max-w-[520px] px-5 py-8"> <div className="mx-auto max-w-[520px] px-5 py-8">
@@ -141,6 +163,61 @@ export default function CatPage() {
</span> </span>
</div> </div>
{comments.length > 0 && (
<div className="space-y-3">
<p className="text-[11px] font-bold text-[var(--fg-tertiary)] uppercase tracking-widest">
Комментарии · {comments.length}
</p>
{comments.map(c => (
<div key={c.id} className="flex items-start gap-2.5 group">
<Avatar className="h-7 w-7 shrink-0 mt-0.5">
<AvatarFallback className="text-[9px] avatar-colored bg-[var(--fg-tertiary)]">
{c.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm leading-[1.5]">
<span className="font-semibold mr-1">@{c.username}</span>
{c.text}
</p>
<p className="text-[10px] text-[var(--fg-tertiary)] mt-0.5">
{new Date(c.created_at).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}
</p>
</div>
{(user && (c.user_id === user.id || user.is_admin)) && (
<button
onClick={() => handleDeleteComment(c.id)}
className="btn-ghost h-6 w-6 text-[var(--fg-tertiary)] hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
>
<X className="h-3 w-3" strokeWidth={2} />
</button>
)}
</div>
))}
<div ref={commentsEndRef} />
</div>
)}
{user && (
<form onSubmit={e => { e.preventDefault(); handleComment(); }} className="flex items-center gap-2">
<input
type="text"
value={commentText}
onChange={e => setCommentText(e.target.value)}
placeholder="Написать комментарий..."
maxLength={500}
className="flex-1 h-9 px-3 text-sm rounded-xl border border-[var(--border)] bg-white placeholder:text-[var(--fg-tertiary)] focus-ring"
/>
<button
type="submit"
disabled={!commentText.trim() || addCommentMutation.isPending}
className="btn-ghost h-9 w-9 text-[var(--accent)] hover:bg-[var(--accent-light)] disabled:opacity-30 disabled:cursor-not-allowed"
>
<Send className="h-4 w-4" strokeWidth={1.5} />
</button>
</form>
)}
<Button variant="outline" size="sm" onClick={() => navigate('/feed')} className="rounded-xl text-xs h-8 px-4 gap-1"> <Button variant="outline" size="sm" onClick={() => navigate('/feed')} className="rounded-xl text-xs h-8 px-4 gap-1">
<ArrowLeft className="h-3.5 w-3.5" strokeWidth={1.5} /> В ленту <ArrowLeft className="h-3.5 w-3.5" strokeWidth={1.5} /> В ленту
</Button> </Button>

View File

@@ -30,7 +30,7 @@ export default function LoginPage() {
<div className="mx-auto mb-5 h-14 w-14 rounded-2xl flex items-center justify-center bg-[var(--accent)] shadow-sm"> <div className="mx-auto mb-5 h-14 w-14 rounded-2xl flex items-center justify-center bg-[var(--accent)] shadow-sm">
<Cat className="h-7 w-7 text-white" strokeWidth={1.5} /> <Cat className="h-7 w-7 text-white" strokeWidth={1.5} />
</div> </div>
<h1 className="text-2xl font-800 tracking-tight">Catstagram</h1> <h1 className="text-2xl font-800 tracking-tight">Котограм</h1>
<p className="text-sm text-[var(--fg-tertiary)] mt-1">Войдите, чтобы смотреть котов</p> <p className="text-sm text-[var(--fg-tertiary)] mt-1">Войдите, чтобы смотреть котов</p>
</div> </div>

View File

@@ -32,7 +32,7 @@ export default function RegisterPage() {
<div className="mx-auto mb-5 h-14 w-14 rounded-2xl flex items-center justify-center bg-[var(--accent)] shadow-sm"> <div className="mx-auto mb-5 h-14 w-14 rounded-2xl flex items-center justify-center bg-[var(--accent)] shadow-sm">
<Cat className="h-7 w-7 text-white" strokeWidth={1.5} /> <Cat className="h-7 w-7 text-white" strokeWidth={1.5} />
</div> </div>
<h1 className="text-2xl font-800 tracking-tight">Catstagram</h1> <h1 className="text-2xl font-800 tracking-tight">Котограм</h1>
<p className="text-sm text-[var(--fg-tertiary)] mt-1">Присоединяйтесь к сообществу</p> <p className="text-sm text-[var(--fg-tertiary)] mt-1">Присоединяйтесь к сообществу</p>
</div> </div>

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/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"} {"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/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/usecomments.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"}

View File

@@ -62,6 +62,16 @@ export async function initDb(): Promise<SqlJsDatabase> {
) )
`); `);
db.run(`
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cat_id INTEGER NOT NULL REFERENCES cats(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
text TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)
`);
saveDb(); saveDb();
return db; return db;
} }

View File

@@ -8,6 +8,7 @@ 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'; import adminRoutes from './routes/admin.js';
import commentRoutes from './routes/comments.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -25,6 +26,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/comments', commentRoutes);
app.use('/api/admin', adminRoutes); app.use('/api/admin', adminRoutes);
app.get('/api/health', (_req, res) => { app.get('/api/health', (_req, res) => {
@@ -32,7 +34,7 @@ app.use('/api/admin', adminRoutes);
}); });
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Catstagram server running on http://localhost:${PORT}`); console.log(`Котограм server running on http://localhost:${PORT}`);
}); });
} }

View File

@@ -36,7 +36,8 @@ router.get('/', (req: AuthRequest, res: Response) => {
const cats = queryAll( const cats = queryAll(
`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,
u.username, u.points AS user_points, u.username, u.points AS user_points,
(SELECT COUNT(*) FROM likes WHERE cat_id = c.id) AS likes_count (SELECT COUNT(*) FROM likes WHERE cat_id = c.id) AS likes_count,
(SELECT COUNT(*) FROM comments WHERE cat_id = c.id) AS comments_count
FROM cats c FROM cats c
JOIN users u ON u.id = c.user_id JOIN users u ON u.id = c.user_id
ORDER BY c.created_at DESC ORDER BY c.created_at DESC
@@ -55,7 +56,8 @@ router.get('/user/:userId', (req: AuthRequest, res: Response) => {
const cats = queryAll( const cats = queryAll(
`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,
u.username, u.points AS user_points, u.username, u.points AS user_points,
(SELECT COUNT(*) FROM likes WHERE cat_id = c.id) AS likes_count (SELECT COUNT(*) FROM likes WHERE cat_id = c.id) AS likes_count,
(SELECT COUNT(*) FROM comments WHERE cat_id = c.id) AS comments_count
FROM cats c FROM cats c
JOIN users u ON u.id = c.user_id JOIN users u ON u.id = c.user_id
WHERE c.user_id = ? WHERE c.user_id = ?
@@ -70,7 +72,8 @@ router.get('/:id', (req: AuthRequest, res: Response) => {
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,
u.username, u.points AS user_points, u.username, u.points AS user_points,
(SELECT COUNT(*) FROM likes WHERE cat_id = c.id) AS likes_count (SELECT COUNT(*) FROM likes WHERE cat_id = c.id) AS likes_count,
(SELECT COUNT(*) FROM comments WHERE cat_id = c.id) AS comments_count
FROM cats c FROM cats c
JOIN users u ON u.id = c.user_id JOIN users u ON u.id = c.user_id
WHERE c.id = ?`, WHERE c.id = ?`,
@@ -104,7 +107,8 @@ router.post('/', authMiddleware, upload.single('image'), (req: AuthRequest, res:
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,
u.username, u.points AS user_points, u.username, u.points AS user_points,
0 AS likes_count 0 AS likes_count,
0 AS comments_count
FROM cats c FROM cats c
JOIN users u ON u.id = c.user_id JOIN users u ON u.id = c.user_id
WHERE c.id = (SELECT MAX(id) FROM cats WHERE user_id = ?)`, WHERE c.id = (SELECT MAX(id) FROM cats WHERE user_id = ?)`,
@@ -125,6 +129,7 @@ router.delete('/:id', authMiddleware, (req: AuthRequest, res: Response) => {
return; return;
} }
execute('DELETE FROM comments WHERE cat_id = ?', [req.params.id]);
execute('DELETE FROM likes WHERE cat_id = ?', [req.params.id]); execute('DELETE FROM likes WHERE cat_id = ?', [req.params.id]);
execute('DELETE FROM cats WHERE id = ?', [req.params.id]); execute('DELETE FROM cats WHERE id = ?', [req.params.id]);
res.json({ ok: true }); res.json({ ok: true });

View File

@@ -0,0 +1,74 @@
import { Router, Response } from 'express';
import { queryAll, queryOne, execute } from '../db';
import { authMiddleware, AuthRequest } from '../middleware/auth.js';
const router = Router();
router.get('/:catId', (req: AuthRequest, res: Response) => {
const catId = parseInt(req.params.catId);
const comments = queryAll(
`SELECT c.id, c.text, c.created_at, c.user_id, u.username
FROM comments c
JOIN users u ON u.id = c.user_id
WHERE c.cat_id = ?
ORDER BY c.created_at ASC`,
[catId]
);
res.json({ comments });
});
router.post('/:catId', authMiddleware, (req: AuthRequest, res: Response) => {
const catId = parseInt(req.params.catId);
const userId = req.userId!;
const text = (req.body.text || '').trim();
if (!text) {
res.status(400).json({ error: 'Комментарий не может быть пустым' });
return;
}
if (text.length > 500) {
res.status(400).json({ error: 'Комментарий слишком длинный' });
return;
}
const cat = queryOne('SELECT id FROM cats WHERE id = ?', [catId]);
if (!cat) {
res.status(404).json({ error: 'Пост не найден' });
return;
}
const result = execute(
'INSERT INTO comments (cat_id, user_id, text) VALUES (?, ?, ?)',
[catId, userId, text]
);
const comment = queryOne(
`SELECT c.id, c.text, c.created_at, c.user_id, u.username
FROM comments c
JOIN users u ON u.id = c.user_id
WHERE c.id = ?`,
[result.lastInsertRowid]
);
res.status(201).json({ comment });
});
router.delete('/:commentId', authMiddleware, (req: AuthRequest, res: Response) => {
const commentId = parseInt(req.params.commentId);
const comment = queryOne('SELECT user_id FROM comments WHERE id = ?', [commentId]);
if (!comment) {
res.status(404).json({ error: 'Комментарий не найден' });
return;
}
if (comment.user_id !== req.userId && !req.isAdmin) {
res.status(403).json({ error: 'Нельзя удалить чужой комментарий' });
return;
}
execute('DELETE FROM comments WHERE id = ?', [commentId]);
res.json({ ok: true });
});
export default router;