feat: UI redesign — polished layout, modal, nav, profile

- CSS: refined shadows with border ring, dark bg #0A0A0A, larger card
  radius (16/22px), modal-backdrop blur, scrollbar styling, new
  dropzone + profile-grid CSS classes
- Navbar: paw emoji logo, labels under icons, accent gradient bar,
  active-state ring on avatar
- Login / Register: auth card with decorative background blobs,
  form wrapped in card, cleaner spacing
- CatModal: two-column layout on desktop (photo left, info right),
  modal-backdrop blur, comment input with user avatar, like label
- ProfilePage: 88px avatar with camera overlay, inline stat bar with
  icons, grid section divider, Stat helper component
- CatCard: points badge in header (amber), caption text secondary
  color, ring on avatar
- FeedSidebar: color-coded rank badges, hover rows, star icon
- UploadPage: new dropzone-idle/dropzone-active CSS classes,
  filename chip on preview, UserAvatar in caption row, HEIC/AVIF in
  accepted formats hint, 500-char counter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 00:30:41 +03:00
parent 94fff09a41
commit 91ab44c6fb
9 changed files with 900 additions and 475 deletions

View File

@@ -9,7 +9,7 @@ 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 } from 'lucide-react';
import { ArrowLeft, Plus, LogOut, Shield, Heart, Image, Trophy, Camera, X, Grid3x3 } from 'lucide-react';
import { PRESETS } from '@/lib/avatars';
import { useToast } from '@/components/Toast';
@@ -31,7 +31,6 @@ export default function ProfilePage() {
});
const { data, isLoading } = useUserCats(profileUserId ?? 0);
const profileUser = isMe ? me : userData;
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -60,16 +59,19 @@ export default function ProfilePage() {
if (isLoading) {
return (
<div className="mx-auto max-w-[540px] px-5 py-8">
<div className="flex items-center gap-5 mb-10">
<Skeleton className="h-20 w-20 rounded-full" />
<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-6 w-32 rounded-full" />
<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.5">
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="aspect-square rounded-xl" />)}
<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>
);
@@ -82,7 +84,9 @@ export default function ProfilePage() {
<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>
<Button variant="outline" onClick={() => navigate('/feed')} className="rounded-xl text-xs mt-3 h-9 px-4">
В ленту
</Button>
</div>
);
}
@@ -91,104 +95,135 @@ export default function ProfilePage() {
const totalLikes = cats.reduce((sum, c) => sum + c.likes_count, 0);
return (
<div className="mx-auto max-w-[540px] px-5 py-6 animate-fade-up">
<div className="mx-auto max-w-[600px] animate-fade-up">
{!isMe && (
<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="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="flex items-center gap-5 mb-8">
<div className="relative group">
<UserAvatar
avatarUrl={profileUser?.avatar_url}
username={profileUser?.username || '?'}
className="h-[72px] w-[72px]"
fallbackClassName="text-2xl font-800 avatar-colored bg-[var(--accent)]"
/>
{isMe && (
<button
onClick={() => setShowAvatarPicker(true)}
className="absolute inset-0 rounded-full bg-black/0 group-hover:bg-black/30 flex items-center justify-center transition-colors"
>
<Camera className="h-6 w-6 text-white opacity-0 group-hover:opacity-100 transition-opacity" 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">
<div className="flex items-center gap-2 mb-3">
<h1 className="text-lg font-800 truncate">@{profileUser?.username}</h1>
<div className="flex items-center gap-1 ml-auto">
{/* Инфо */}
<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-8 w-8 text-[var(--accent)]" title="Админ-панель">
<Shield className="h-4 w-4" strokeWidth={1.5} />
<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-8 w-8 text-[var(--fg-tertiary)] hover:text-[var(--danger)]" title="Выйти">
<LogOut className="h-4 w-4" strokeWidth={1.5} />
<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>
<div className="flex items-center gap-5">
<div className="text-center">
<div className="stat-value text-base">{cats.length}</div>
<div className="text-[10px] text-[var(--fg-tertiary)] uppercase tracking-wider">фото</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>
<div className="text-center">
<div className="stat-value text-base flex items-center justify-center gap-0.5">
<Heart className="h-3.5 w-3.5 text-[var(--fg-tertiary)]" strokeWidth={1.5} />
{totalLikes}
</div>
<div className="text-[10px] text-[var(--fg-tertiary)] uppercase tracking-wider">лайков</div>
</div>
<div className="text-center">
<div className="stat-value text-base flex items-center justify-center gap-0.5">
<Trophy className="h-3.5 w-3.5 text-[var(--fg-tertiary)]" strokeWidth={1.5} />
{profileUser?.points ?? 0}
</div>
<div className="text-[10px] text-[var(--fg-tertiary)] uppercase tracking-wider">баллов</div>
</div>
</div>
{isMe && (
<div className="mt-3">
{isMe && (
<Link to="/upload">
<Button size="sm" className="rounded-xl text-xs h-8 gap-1.5 bg-[var(--accent)] hover:bg-[var(--accent-hover)] text-white px-4">
<Plus className="h-3.5 w-3.5" strokeWidth={2} /> Новая публикация
<Button
size="sm"
className="rounded-xl text-xs h-8 gap-1.5 bg-[var(--accent)] hover:bg-[var(--accent-hover)] text-white px-4 shadow-sm"
>
<Plus className="h-3.5 w-3.5" strokeWidth={2.5} />
Новая публикация
</Button>
</Link>
</div>
)}
)}
</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 animate-fade-up">
<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>
<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" className="rounded-xl text-xs bg-[var(--accent)] hover:bg-[var(--accent-hover)] text-white">Загрузить кота</Button>
<Button size="sm" className="rounded-xl text-xs bg-[var(--accent)] hover:bg-[var(--accent-hover)] text-white">
Загрузить кота
</Button>
</Link>
)}
</div>
) : (
<div className="grid grid-cols-3 gap-1.5">
{cats.map(cat => (
<button key={cat.id} onClick={() => setModalCatId(cat.id)}
className="group relative aspect-square overflow-hidden bg-[var(--bg)] rounded-xl">
<img src={cat.image_url} alt={cat.caption || 'Фото кота'}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" loading="lazy" />
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<span className="text-white text-xs font-semibold opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 drop-shadow-lg">
<Heart className="h-3.5 w-3.5 fill-white" strokeWidth={0} /> {cat.likes_count}
<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>
@@ -198,11 +233,18 @@ export default function ProfilePage() {
{modalCatId && <CatModal catId={modalCatId} onClose={() => setModalCatId(null)} />}
{/* Пикер аватара */}
{showAvatarPicker && createPortal(
<div className="fixed inset-0 z-[70] bg-black/30 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="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-800">Выбрать аватар</h2>
<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>
@@ -213,8 +255,10 @@ export default function ProfilePage() {
<button
key={emoji}
onClick={() => handlePreset(emoji)}
className={`h-12 w-12 rounded-xl flex items-center justify-center text-2xl transition-all hover:bg-[var(--accent-light)] hover:scale-110 ${
me?.avatar_url === `preset:${emoji}` ? 'ring-2 ring-[var(--accent)] bg-[var(--accent-light)]' : 'bg-[var(--border-light)]'
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}
@@ -242,4 +286,20 @@ export default function ProfilePage() {
<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>
);
}