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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user