Fix mobile scroll jump, add comments, rename to Котограм
This commit is contained in:
@@ -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" />
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
34
client/src/hooks/useComments.ts
Normal file
34
client/src/hooks/useComments.ts
Normal 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] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,14 +26,15 @@ 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.use('/api/comments', commentRoutes);
|
||||||
|
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' });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Catstagram server running on http://localhost:${PORT}`);
|
console.log(`Котограм server running on http://localhost:${PORT}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
74
server/src/routes/comments.ts
Normal file
74
server/src/routes/comments.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user