Files
cats/client/src/pages/ProfilePage.tsx
2026-06-26 00:55:58 +03:00

305 lines
12 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, 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>
<input type="file" ref={fileInputRef} onChange={onFileChange} accept="image/*" className="hidden" />
<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>
);
}