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" />
<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" />
<title>Catstagram</title>
<title>Котограм</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<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" />

View File

@@ -10,6 +10,15 @@ export interface Cat {
username: string;
user_points: 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 {
@@ -87,3 +96,16 @@ export function adminDeleteCat(id: number) {
export function adminDeleteUser(id: number) {
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"
>
<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>
</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 { useLikeStatus, useLikeCat, useUnlikeCat } from '@/hooks/useLikes';
import { useComments, useAddComment, useDeleteComment } from '@/hooks/useComments';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/components/Toast';
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 {
catId: number;
@@ -18,15 +19,35 @@ export default function CatModal({ catId, onClose }: CatModalProps) {
const likeMutation = useLikeCat(catId);
const unlikeMutation = useUnlikeCat(catId);
const deleteMutation = useDeleteCat();
const { data: commentsData } = useComments(catId);
const addCommentMutation = useAddComment(catId);
const deleteCommentMutation = useDeleteComment(catId);
const { toast } = useToast();
const [liked, setLiked] = useState(false);
const [likeCount, setLikeCount] = useState(0);
const [imageLoaded, setImageLoaded] = useState(false);
const [commentText, setCommentText] = useState('');
const commentsEndRef = useRef<HTMLDivElement>(null);
useEffect(() => { if (likeData) setLiked(likeData.liked); }, [likeData]);
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(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', h);
@@ -34,6 +55,7 @@ export default function CatModal({ catId, onClose }: CatModalProps) {
}, [onClose]);
const cat = data?.cat;
const comments = commentsData?.comments ?? [];
const isOwner = cat && user && cat.user_id === user.id;
const toggleLike = async () => {
@@ -56,6 +78,21 @@ export default function CatModal({ catId, onClose }: CatModalProps) {
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) {
return (
<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
src={cat.image_url}
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)}
/>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
<div className="flex items-start gap-3">
<Avatar className="h-8 w-8 shrink-0 mt-0.5">
<AvatarFallback className="text-[10px] avatar-colored bg-[var(--accent)]">
{cat.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<p className="text-sm leading-[1.5]">
<span className="font-semibold mr-1">@{cat.username}</span>
{cat.caption || 'Без подписи'}
</p>
</div>
<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">
<Avatar className="h-7 w-7 shrink-0 mt-0.5">
<AvatarFallback className="text-[9px] avatar-colored bg-[var(--accent)]">
{cat.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<p className="text-sm leading-[1.5]">
<span className="font-semibold mr-1">@{cat.username}</span>
{cat.caption}
</p>
</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 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">
<button
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)]">
<Trophy className="h-3.5 w-3.5" strokeWidth={1.5} />
<span className="font-semibold text-[var(--fg-secondary)]">{cat.user_points}</span>
<span>баллов</span>
</span>
</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>

View File

@@ -70,7 +70,7 @@ export default function FeedSidebar() {
)}
</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>
);
}

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)]">
<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)]">
Catstagram
Котограм
</Link>
<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 USER_KEY = 'catstagram_user';
const TOKEN_KEY = 'kotogram_token';
const USER_KEY = 'kotogram_user';
export interface User {
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 { useCat, useDeleteCat } from '@/hooks/useCats';
import { useLikeStatus, useLikeCat, useUnlikeCat } from '@/hooks/useLikes';
import { useComments, useAddComment, useDeleteComment } from '@/hooks/useComments';
import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/components/Toast';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
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() {
const { id } = useParams<{ id: string }>();
@@ -21,10 +22,16 @@ export default function CatPage() {
const likeMutation = useLikeCat(catId);
const unlikeMutation = useUnlikeCat(catId);
const deleteMutation = useDeleteCat();
const { data: commentsData } = useComments(catId);
const addCommentMutation = useAddComment(catId);
const deleteCommentMutation = useDeleteComment(catId);
const [liked, setLiked] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [commentText, setCommentText] = useState('');
const commentsEndRef = useRef<HTMLDivElement>(null);
const cat = data?.cat;
const comments = commentsData?.comments ?? [];
const isOwner = cat && user && cat.user_id === user.id;
const isLiked = likeData?.liked ?? false;
@@ -50,6 +57,21 @@ export default function CatPage() {
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) {
return (
<div className="mx-auto max-w-[520px] px-5 py-8">
@@ -141,6 +163,61 @@ export default function CatPage() {
</span>
</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">
<ArrowLeft className="h-3.5 w-3.5" strokeWidth={1.5} /> В ленту
</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">
<Cat className="h-7 w-7 text-white" strokeWidth={1.5} />
</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>
</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">
<Cat className="h-7 w-7 text-white" strokeWidth={1.5} />
</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>
</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"}