Files
cats/client/src/pages/CatPage.tsx
heagbokat 81c9bba56f feat: redesign UI with pastel theme, dark mode and smooth animations
- Add light/dark/system theme switcher with localStorage persistence
  and automatic prefers-color-scheme detection
- New pastel Instagram-inspired palette: rose accent (#C9445D light,
  #E8687E dark), clean #FAFAFA / #000000 backgrounds
- Replace all hardcoded bg-white / red-* colors with CSS variables
  so every page and modal respects the active theme
- Smooth card hover: translateY elevation + image zoom (no layout shift)
- Micro-animations on action buttons with spring cubic-bezier
- Editorial navbar: 3px accent top bar, uppercase wordmark
- Theme toggle button (sun/moon/monitor) added to navbar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 23:57:33 +03:00

221 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { Skeleton } from '@/components/ui/skeleton';
import UserAvatar from '@/components/UserAvatar';
import { ArrowLeft, Heart, Trash2, Trophy, Image, Send, X } from 'lucide-react';
export default function CatPage() {
const { id } = useParams<{ id: string }>();
const catId = parseInt(id!);
const navigate = useNavigate();
const { user } = useAuth();
const { toast } = useToast();
const { data, isLoading, isError } = useCat(catId);
const { data: likeData, refetch: refetchLike } = useLikeStatus(catId);
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;
useEffect(() => { if (likeData?.liked !== undefined) setLiked(likeData.liked); }, [likeData?.liked]);
const toggleLike = async () => {
if (isOwner || !user || !cat) return;
if (liked) {
setLiked(false);
try { await unlikeMutation.mutateAsync(); refetchLike(); }
catch { setLiked(true); }
} else {
setLiked(true);
toast('❤️');
try { await likeMutation.mutateAsync(); refetchLike(); }
catch { setLiked(false); }
}
};
const handleDelete = async () => {
if (!isOwner || !cat) return;
try { await deleteMutation.mutateAsync(cat.id); toast('Удалено'); navigate('/feed'); }
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-[540px] px-5 py-8">
<Skeleton className="mb-4 h-4 w-20 rounded-lg" />
<Skeleton className="aspect-[4/3] w-full rounded-2xl" />
<div className="mt-5 space-y-3">
<Skeleton className="h-4 w-32 rounded-lg" />
<Skeleton className="h-3 w-48 rounded-lg" />
</div>
</div>
);
}
if (isError || !cat) {
return (
<div className="flex flex-col items-center justify-center py-24 animate-fade-up">
<div className="mb-4 h-14 w-14 rounded-2xl flex items-center justify-center bg-[var(--border-light)]">
<Image className="h-7 w-7 text-[var(--fg-tertiary)]" strokeWidth={1.5} />
</div>
<p className="text-base font-semibold mb-1">Кот не найден</p>
<Button variant="outline" onClick={() => navigate('/feed')} className="rounded-xl text-xs mt-2 h-9 px-4">В ленту</Button>
</div>
);
}
const dateStr = new Date(cat.created_at).toLocaleDateString('ru-RU', {
year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
return (
<div className="mx-auto max-w-[540px] px-5 py-6 animate-fade-up">
<button onClick={() => navigate(-1)}
className="mb-5 flex items-center gap-1.5 text-xs text-[var(--fg-tertiary)] hover:text-[var(--fg)] transition-colors">
<ArrowLeft className="h-4 w-4" strokeWidth={1.5} /> Назад
</button>
<div className="card overflow-hidden">
<div className="bg-[var(--bg)] relative">
{!imageLoaded && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-6 w-6 rounded-full border-2 border-[var(--border)] border-t-[var(--accent)] animate-spin" />
</div>
)}
<img src={cat.image_url} alt={cat.caption || 'Фото кота'}
className={`w-full max-h-[60vh] object-contain mx-auto transition-opacity duration-300 ${imageLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setImageLoaded(true)} />
</div>
<div className="p-5 space-y-4">
<div className="flex items-start justify-between">
<Link to={`/profile/${cat.user_id}`} className="flex items-center gap-3 group">
<UserAvatar avatarUrl={cat.user_avatar_url} username={cat.username} className="h-10 w-10" fallbackClassName="text-sm avatar-colored bg-[var(--accent)]" />
<div>
<span className="text-sm font-semibold group-hover:text-[var(--accent)] transition-colors">@{cat.username}</span>
<p className="text-[11px] text-[var(--fg-tertiary)]">{dateStr}</p>
</div>
</Link>
{isOwner && (
<button onClick={handleDelete} className="btn-ghost h-9 w-9 text-[var(--fg-tertiary)] hover:text-[var(--danger)] hover:bg-[var(--danger-light)]">
<Trash2 className="h-4 w-4" strokeWidth={1.5} />
</button>
)}
</div>
{cat.caption && (
<p className="text-sm leading-[1.5]">
<Link to={`/profile/${cat.user_id}`} className="font-semibold mr-1 hover:text-[var(--accent)] transition-colors">@{cat.username}</Link>
{cat.caption}
</p>
)}
<div className="divider" />
<div className="flex items-center justify-between">
<button onClick={toggleLike} disabled={isOwner || !user}
className={`flex items-center gap-2 text-sm font-medium transition-all ${
isOwner ? 'text-[var(--fg-tertiary)] cursor-not-allowed' : liked ? 'text-red-500' : 'text-[var(--fg-secondary)] hover:text-red-400'
}`}>
<Heart className={`h-5 w-5 transition-all ${liked ? 'fill-red-500 stroke-red-500 animate-pop' : 'stroke-[1.5]'}`} />
{cat.likes_count > 0 && <span className="stat-value">{cat.likes_count}</span>}
</button>
<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>
</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">
<Link to={`/profile/${c.user_id}`}>
<UserAvatar avatarUrl={undefined} username={c.username} className="h-7 w-7" fallbackClassName="text-[9px] avatar-colored bg-[var(--fg-tertiary)]" />
</Link>
<div className="flex-1 min-w-0">
<p className="text-sm leading-[1.5]">
<Link to={`/profile/${c.user_id}`} className="font-semibold mr-1 hover:text-[var(--accent)] transition-colors">@{c.username}</Link>
{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-[var(--danger)] 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-[var(--surface)] 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>
</div>
</div>
</div>
);
}