uploadLimiter now only applies to POST /api/cats (uploads), not GET reads — previously users were getting 429 after ~10 page navigations. Also remove duplicate file input in ProfilePage and cancel stale rAF on route transition cleanup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
import { useState, useRef, useCallback } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||
import { useAuth } from '@/hooks/useAuth';
|
||
import { useUserCats } from '@/hooks/useCats';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import { getUser, uploadAvatar, setAvatarPreset } from '@/api/endpoints';
|
||
import CatModal from '@/components/CatModal';
|
||
import UserAvatar from '@/components/UserAvatar';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Skeleton } from '@/components/ui/skeleton';
|
||
import { ArrowLeft, Plus, LogOut, Shield, Heart, Image, Trophy, Camera, X, Grid3x3 } from 'lucide-react';
|
||
import { PRESETS } from '@/lib/avatars';
|
||
import { useToast } from '@/components/Toast';
|
||
|
||
export default function ProfilePage() {
|
||
const { id: paramId } = useParams<{ id?: string }>();
|
||
const navigate = useNavigate();
|
||
const { user: me, logout, updateUser } = useAuth();
|
||
const [modalCatId, setModalCatId] = useState<number | null>(null);
|
||
const [showAvatarPicker, setShowAvatarPicker] = useState(false);
|
||
const { toast } = useToast();
|
||
|
||
const profileUserId = paramId ? parseInt(paramId) : me?.id;
|
||
const isMe = !paramId || (me && profileUserId === me.id);
|
||
|
||
const { data: userData } = useQuery({
|
||
queryKey: ['user', profileUserId],
|
||
queryFn: () => getUser(profileUserId!).then(r => r.data.user),
|
||
enabled: !isMe && !!profileUserId,
|
||
});
|
||
|
||
const { data, isLoading } = useUserCats(profileUserId ?? 0);
|
||
const profileUser = isMe ? me : userData;
|
||
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const onFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file || !isMe) return;
|
||
const fd = new FormData();
|
||
fd.append('avatar', file);
|
||
try {
|
||
const res = await uploadAvatar(fd);
|
||
updateUser(res.data.user);
|
||
toast('Аватар обновлён');
|
||
} catch { toast('Ошибка', 'error'); }
|
||
e.target.value = '';
|
||
}, [isMe, updateUser, toast]);
|
||
|
||
const handlePreset = async (emoji: string) => {
|
||
if (!isMe) return;
|
||
try {
|
||
const res = await setAvatarPreset(emoji);
|
||
updateUser(res.data.user);
|
||
setShowAvatarPicker(false);
|
||
toast('Аватар обновлён');
|
||
} catch { toast('Ошибка', 'error'); }
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="mx-auto max-w-[600px] px-5 py-8">
|
||
<div className="flex items-center gap-5 mb-8">
|
||
<Skeleton className="h-24 w-24 rounded-full" />
|
||
<div className="space-y-2.5 flex-1">
|
||
<Skeleton className="h-5 w-36 rounded-full" />
|
||
<Skeleton className="h-4 w-48 rounded-full" />
|
||
<Skeleton className="h-4 w-24 rounded-full" />
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-1">
|
||
{Array.from({ length: 9 }).map((_, i) => (
|
||
<Skeleton key={i} className="aspect-square rounded-none first:rounded-tl-xl" />
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!profileUser && !isMe) {
|
||
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-3 h-9 px-4">
|
||
В ленту
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const cats = data?.cats ?? [];
|
||
const totalLikes = cats.reduce((sum, c) => sum + c.likes_count, 0);
|
||
|
||
return (
|
||
<div className="mx-auto max-w-[600px] animate-fade-up">
|
||
{!isMe && (
|
||
<div className="px-5 pt-5">
|
||
<button
|
||
onClick={() => navigate(-1)}
|
||
className="mb-4 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>
|
||
)}
|
||
|
||
{/* Шапка профиля */}
|
||
<div className="px-5 pt-6 pb-5">
|
||
<div className="flex items-start gap-6">
|
||
{/* Аватар */}
|
||
<div className="relative group shrink-0">
|
||
<UserAvatar
|
||
avatarUrl={profileUser?.avatar_url}
|
||
username={profileUser?.username || '?'}
|
||
className="h-[88px] w-[88px] ring-2 ring-[var(--border-light)] ring-offset-2 ring-offset-[var(--bg)]"
|
||
fallbackClassName="text-3xl font-extrabold avatar-colored bg-[var(--accent)]"
|
||
/>
|
||
{isMe && (
|
||
<button
|
||
onClick={() => setShowAvatarPicker(true)}
|
||
className="absolute inset-0 rounded-full bg-black/0 group-hover:bg-black/35 flex items-center justify-center transition-all"
|
||
>
|
||
<Camera className="h-6 w-6 text-white opacity-0 group-hover:opacity-100 transition-opacity drop-shadow" strokeWidth={1.5} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Инфо */}
|
||
<div className="flex-1 min-w-0 pt-1">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<h1 className="text-xl font-extrabold truncate tracking-tight">
|
||
@{profileUser?.username}
|
||
</h1>
|
||
{isMe && me?.is_admin && (
|
||
<Link
|
||
to="/admin"
|
||
className="btn-ghost h-7 w-7 text-[var(--accent)] shrink-0"
|
||
title="Админ-панель"
|
||
>
|
||
<Shield className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||
</Link>
|
||
)}
|
||
{isMe && (
|
||
<button
|
||
onClick={logout}
|
||
className="btn-ghost h-7 w-7 text-[var(--fg-tertiary)] hover:text-[var(--danger)] ml-auto shrink-0"
|
||
title="Выйти"
|
||
>
|
||
<LogOut className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Статистика */}
|
||
<div className="flex items-center gap-5 mb-3">
|
||
<Stat value={cats.length} label="публикаций" />
|
||
<div className="w-px h-6 bg-[var(--border-light)]" />
|
||
<Stat value={totalLikes} label="лайков" icon={<Heart className="h-3 w-3 text-[var(--accent)]" strokeWidth={1.5} />} />
|
||
<div className="w-px h-6 bg-[var(--border-light)]" />
|
||
<Stat value={profileUser?.points ?? 0} label="баллов" icon={<Trophy className="h-3 w-3 text-amber-500" strokeWidth={1.5} />} />
|
||
</div>
|
||
|
||
{isMe && (
|
||
<Link to="/upload">
|
||
<Button
|
||
size="sm" className="gap-1.5"
|
||
>
|
||
<Plus className="h-3.5 w-3.5" strokeWidth={2.5} />
|
||
Новая публикация
|
||
</Button>
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Разделитель с иконкой сетки */}
|
||
<div className="flex items-center gap-3 px-5 py-2 border-t border-[var(--border-light)]">
|
||
<Grid3x3 className="h-4 w-4 text-[var(--fg-secondary)]" strokeWidth={1.5} />
|
||
<span className="text-[11px] font-bold uppercase tracking-widest text-[var(--fg-tertiary)]">
|
||
Публикации
|
||
</span>
|
||
</div>
|
||
|
||
{/* Сетка фото */}
|
||
{cats.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center py-16 text-center px-5 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-sm font-semibold mb-1">Пока нет публикаций</p>
|
||
<p className="text-xs text-[var(--fg-tertiary)] mb-5">
|
||
{isMe ? 'Поделитесь первым фото' : 'У пользователя пока нет котов'}
|
||
</p>
|
||
{isMe && (
|
||
<Link to="/upload">
|
||
<Button size="sm">
|
||
Загрузить кота
|
||
</Button>
|
||
</Link>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-3 gap-0.5">
|
||
{cats.map((cat, i) => (
|
||
<button
|
||
key={cat.id}
|
||
onClick={() => setModalCatId(cat.id)}
|
||
className={`profile-grid-item ${
|
||
i === 0 ? 'rounded-none' :
|
||
i === cats.length - 1 && cats.length % 3 === 1 ? 'rounded-none' : 'rounded-none'
|
||
}`}
|
||
>
|
||
<img
|
||
src={cat.image_url}
|
||
alt={cat.caption || 'Фото кота'}
|
||
loading="lazy"
|
||
/>
|
||
<div className="profile-grid-overlay">
|
||
<span className="text-white text-sm font-bold opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1.5 drop-shadow-lg">
|
||
<Heart className="h-4 w-4 fill-white" strokeWidth={0} />
|
||
{cat.likes_count}
|
||
</span>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{modalCatId && <CatModal catId={modalCatId} onClose={() => setModalCatId(null)} />}
|
||
|
||
{/* Пикер аватара */}
|
||
{showAvatarPicker && createPortal(
|
||
<div
|
||
className="fixed inset-0 z-[70] modal-backdrop flex items-center justify-center animate-fade-in"
|
||
onClick={() => setShowAvatarPicker(false)}
|
||
>
|
||
<div
|
||
className="bg-[var(--surface)] rounded-2xl w-full max-w-sm p-5 shadow-xl animate-scale-in mx-4"
|
||
onClick={e => e.stopPropagation()}
|
||
>
|
||
<div className="flex items-center justify-between mb-5">
|
||
<h2 className="text-base font-bold">Выбрать аватар</h2>
|
||
<button onClick={() => setShowAvatarPicker(false)} className="btn-ghost h-8 w-8 text-[var(--fg-tertiary)]">
|
||
<X className="h-5 w-5" strokeWidth={1.5} />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-5 gap-2 mb-5">
|
||
{PRESETS.map(emoji => (
|
||
<button
|
||
key={emoji}
|
||
onClick={() => handlePreset(emoji)}
|
||
className={`h-12 w-12 rounded-xl flex items-center justify-center text-2xl transition-all hover:scale-110 ${
|
||
me?.avatar_url === `preset:${emoji}`
|
||
? 'ring-2 ring-[var(--accent)] bg-[var(--accent-light)] scale-105'
|
||
: 'bg-[var(--border-light)] hover:bg-[var(--accent-light)]'
|
||
}`}
|
||
>
|
||
{emoji}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="divider mb-4" />
|
||
|
||
<div>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className="w-full rounded-xl h-10 gap-2"
|
||
>
|
||
<Camera className="h-4 w-4" strokeWidth={1.5} /> Загрузить фото
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body
|
||
)}
|
||
|
||
<input type="file" ref={fileInputRef} onChange={onFileChange} accept="image/*" className="hidden" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Stat({
|
||
value, label, icon,
|
||
}: {
|
||
value: number; label: string; icon?: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="flex flex-col items-start">
|
||
<span className="flex items-center gap-1 stat-value text-base">
|
||
{icon}
|
||
{value}
|
||
</span>
|
||
<span className="text-[10px] text-[var(--fg-tertiary)] uppercase tracking-wider leading-tight">{label}</span>
|
||
</div>
|
||
);
|
||
}
|