Fix likes display, add avatar system (presets+upload), improve desktop layout, fix profile viewing
This commit is contained in:
@@ -9,6 +9,7 @@ export interface Cat {
|
||||
user_id: number;
|
||||
username: string;
|
||||
user_points: number;
|
||||
user_avatar_url: string | null;
|
||||
likes_count: number;
|
||||
comments_count: number;
|
||||
}
|
||||
@@ -72,6 +73,10 @@ export function getLikeStatus(catId: number) {
|
||||
return api.get<{ liked: boolean }>(`/likes/${catId}/status`);
|
||||
}
|
||||
|
||||
export function getBatchLikeStatus(catIds: number[]) {
|
||||
return api.post<{ likedIds: number[] }>('/likes/batch/status', { catIds });
|
||||
}
|
||||
|
||||
// Admin
|
||||
export function getAdminUsers() {
|
||||
return api.get<{ users: User[] }>('/admin/users');
|
||||
@@ -109,3 +114,19 @@ export function addComment(catId: number, text: string) {
|
||||
export function deleteComment(commentId: number) {
|
||||
return api.delete(`/comments/${commentId}`);
|
||||
}
|
||||
|
||||
// Users
|
||||
export function getUser(id: number) {
|
||||
return api.get<{ user: User }>(`/users/${id}`);
|
||||
}
|
||||
|
||||
// Avatar
|
||||
export function uploadAvatar(formData: FormData) {
|
||||
return api.put<{ user: User }>('/auth/avatar', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
}
|
||||
|
||||
export function setAvatarPreset(preset: string) {
|
||||
return api.put<{ user: User }>('/auth/avatar/preset', { preset });
|
||||
}
|
||||
|
||||
@@ -3,17 +3,18 @@ import { Cat } from '@/api/endpoints';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useLikeCat, useUnlikeCat } from '@/hooks/useLikes';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { Heart, MessageCircle, Trophy } from 'lucide-react';
|
||||
|
||||
interface CatCardProps {
|
||||
cat: Cat;
|
||||
onOpen: (id: number) => void;
|
||||
likedIds?: Set<number>;
|
||||
}
|
||||
|
||||
export default function CatCard({ cat, onOpen }: CatCardProps) {
|
||||
export default function CatCard({ cat, onOpen, likedIds }: CatCardProps) {
|
||||
const { user } = useAuth();
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [liked, setLiked] = useState(() => likedIds?.has(cat.id) ?? false);
|
||||
const [likeCount, setLikeCount] = useState(cat.likes_count);
|
||||
const { toast } = useToast();
|
||||
const likeMutation = useLikeCat(cat.id);
|
||||
@@ -43,11 +44,7 @@ export default function CatCard({ cat, onOpen }: CatCardProps) {
|
||||
<article className="animate-fade-up">
|
||||
<div className="card overflow-hidden cursor-pointer card-interactive" onClick={() => onOpen(cat.id)}>
|
||||
<div className="flex items-center gap-3 px-5 pt-5 pb-3">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarFallback className="text-xs avatar-colored bg-[var(--accent)]">
|
||||
{cat.username.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<UserAvatar avatarUrl={cat.user_avatar_url} username={cat.username} className="h-9 w-9" fallbackClassName="text-xs avatar-colored bg-[var(--accent)]" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold truncate">@{cat.username}</h3>
|
||||
<time className="text-[11px] text-[var(--fg-tertiary)]">{timeAgo}</time>
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { X, Heart, Trash2, Trophy, Send } from 'lucide-react';
|
||||
|
||||
interface CatModalProps {
|
||||
@@ -116,11 +116,7 @@ export default function CatModal({ catId, onClose }: CatModalProps) {
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-4 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarFallback className="text-xs avatar-colored bg-[var(--accent)]">
|
||||
{cat.username.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<UserAvatar avatarUrl={cat.user_avatar_url} username={cat.username} className="h-9 w-9" fallbackClassName="text-xs avatar-colored bg-[var(--accent)]" />
|
||||
<div>
|
||||
<span className="text-sm font-semibold">@{cat.username}</span>
|
||||
<p className="text-[11px] text-[var(--fg-tertiary)]">{dateStr}</p>
|
||||
@@ -155,11 +151,7 @@ export default function CatModal({ catId, onClose }: CatModalProps) {
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3 min-h-0">
|
||||
{cat.caption && (
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar className="h-7 w-7 shrink-0 mt-0.5">
|
||||
<AvatarFallback className="text-[9px] avatar-colored bg-[var(--accent)]">
|
||||
{cat.username.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<UserAvatar avatarUrl={cat.user_avatar_url} username={cat.username} className="h-7 w-7" fallbackClassName="text-[9px] avatar-colored bg-[var(--accent)]" />
|
||||
<p className="text-sm leading-[1.5]">
|
||||
<span className="font-semibold mr-1">@{cat.username}</span>
|
||||
{cat.caption}
|
||||
@@ -171,11 +163,7 @@ export default function CatModal({ catId, onClose }: CatModalProps) {
|
||||
<div className="space-y-3 pt-1">
|
||||
{comments.map(c => (
|
||||
<div key={c.id} className="flex items-start gap-2.5 group">
|
||||
<Avatar className="h-7 w-7 shrink-0 mt-0.5">
|
||||
<AvatarFallback className="text-[9px] avatar-colored bg-[var(--fg-tertiary)]">
|
||||
{c.username.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<UserAvatar avatarUrl={undefined} username={c.username} className="h-7 w-7" fallbackClassName="text-[9px] avatar-colored bg-[var(--fg-tertiary)]" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm leading-[1.5]">
|
||||
<span className="font-semibold mr-1">@{c.username}</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useCats } from '@/hooks/useCats';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Trophy, Crown, Medal } from 'lucide-react';
|
||||
|
||||
@@ -15,11 +15,11 @@ export default function FeedSidebar() {
|
||||
const { user } = useAuth();
|
||||
const { data } = useCats(1);
|
||||
|
||||
const usersMap = new Map<number, { username: string; points: number }>();
|
||||
const usersMap = new Map<number, { username: string; points: number; avatar_url: string | null }>();
|
||||
if (data?.cats) {
|
||||
for (const cat of data.cats) {
|
||||
if (!usersMap.has(cat.user_id))
|
||||
usersMap.set(cat.user_id, { username: cat.username, points: cat.user_points });
|
||||
usersMap.set(cat.user_id, { username: cat.username, points: cat.user_points, avatar_url: cat.user_avatar_url });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +31,7 @@ export default function FeedSidebar() {
|
||||
<div className="space-y-4">
|
||||
{user && (
|
||||
<Link to="/profile" className="card p-4 flex items-center gap-3.5 card-interactive group block">
|
||||
<Avatar className="h-11 w-11">
|
||||
<AvatarFallback className="text-sm font-bold avatar-colored bg-[var(--accent)]">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<UserAvatar avatarUrl={user.avatar_url} username={user.username} className="h-11 w-11" fallbackClassName="text-sm font-bold avatar-colored bg-[var(--accent)]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold truncate group-hover:text-[var(--accent)] transition-colors">{user.username}</p>
|
||||
<p className="text-xs text-[var(--fg-tertiary)] flex items-center gap-1">
|
||||
@@ -57,11 +53,7 @@ export default function FeedSidebar() {
|
||||
{topUsers.map(([id, u], i) => (
|
||||
<Link key={id} to={`/profile/${id}`} className="flex items-center gap-2.5 group py-0.5">
|
||||
<RankBadge rank={i} />
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback className="text-[8px] font-bold avatar-colored bg-[var(--fg-tertiary)]">
|
||||
{u.username.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<UserAvatar avatarUrl={u.avatar_url} username={u.username} className="h-6 w-6" fallbackClassName="text-[8px] font-bold avatar-colored bg-[var(--fg-tertiary)]" />
|
||||
<span className="text-sm flex-1 truncate font-medium group-hover:text-[var(--accent)] transition-colors">{u.username}</span>
|
||||
<span className="stat-value text-xs text-[var(--fg-secondary)]">{u.points}</span>
|
||||
</Link>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Home, PlusCircle, User, Shield } from 'lucide-react';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { Home, PlusCircle, Shield } from 'lucide-react';
|
||||
|
||||
export default function Navbar() {
|
||||
const { user } = useAuth();
|
||||
@@ -11,7 +11,7 @@ export default function Navbar() {
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-50 nav-glass border-b border-[var(--border-light)]">
|
||||
<div className="mx-auto flex h-14 max-w-[540px] items-center justify-between px-5">
|
||||
<div className="mx-auto flex h-14 max-w-[900px] items-center justify-between px-5">
|
||||
<Link to="/feed" className="text-[15px] font-800 tracking-tight text-[var(--fg)]">
|
||||
Котограм
|
||||
</Link>
|
||||
@@ -24,11 +24,7 @@ export default function Navbar() {
|
||||
<div className={`flex items-center justify-center h-8 w-8 rounded-full transition-all ${
|
||||
location.pathname.startsWith('/profile') ? 'ring-2 ring-[var(--accent)] ring-offset-2 ring-offset-[var(--bg)]' : ''
|
||||
}`}>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="text-[11px] avatar-colored bg-[var(--accent)]">
|
||||
{user.username.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<UserAvatar avatarUrl={user.avatar_url} username={user.username} className="h-8 w-8" fallbackClassName="text-[11px] avatar-colored bg-[var(--accent)]" />
|
||||
</div>
|
||||
{user.is_admin && (
|
||||
<span className="absolute -top-0.5 -right-0.5 h-3.5 w-3.5 rounded-full bg-[var(--accent)] flex items-center justify-center ring-2 ring-[var(--bg)]">
|
||||
|
||||
39
client/src/components/UserAvatar.tsx
Normal file
39
client/src/components/UserAvatar.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getPresetEmoji } from '@/lib/avatars';
|
||||
|
||||
interface UserAvatarProps {
|
||||
avatarUrl: string | null | undefined;
|
||||
username: string;
|
||||
className?: string;
|
||||
fallbackClassName?: string;
|
||||
}
|
||||
|
||||
export default function UserAvatar({ avatarUrl, username, className, fallbackClassName }: UserAvatarProps) {
|
||||
const presetEmoji = getPresetEmoji(avatarUrl);
|
||||
const initial = username.charAt(0).toUpperCase();
|
||||
|
||||
if (presetEmoji) {
|
||||
return (
|
||||
<Avatar className={className}>
|
||||
<AvatarFallback className={fallbackClassName ?? 'avatar-colored bg-[var(--accent)] text-base leading-none'}>
|
||||
{presetEmoji}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
if (avatarUrl) {
|
||||
return (
|
||||
<Avatar className={className}>
|
||||
<AvatarImage src={avatarUrl} alt={username} />
|
||||
<AvatarFallback className={fallbackClassName ?? 'avatar-colored bg-[var(--accent)]'}>{initial}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar className={className}>
|
||||
<AvatarFallback className={fallbackClassName ?? 'avatar-colored bg-[var(--accent)]'}>{initial}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export interface User {
|
||||
username: string;
|
||||
points: number;
|
||||
is_admin?: boolean;
|
||||
avatar_url?: string | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
|
||||
16
client/src/lib/avatars.ts
Normal file
16
client/src/lib/avatars.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
const PRESETS = ['🐱', '🐶', '🦊', '🐻', '🐼', '🦁', '🐯', '🐮', '🐷', '🐸', '🦉', '🦋', '🐙', '🦄', '🐰', '🐹', '🐨', '🦎', '🐧', '🦈'] as const;
|
||||
export type AvatarPreset = typeof PRESETS[number];
|
||||
export { PRESETS };
|
||||
|
||||
export function isPresetAvatar(avatarUrl: string | null | undefined): avatarUrl is `preset:${AvatarPreset}` {
|
||||
return !!avatarUrl && avatarUrl.startsWith('preset:');
|
||||
}
|
||||
|
||||
export function getPresetEmoji(avatarUrl: string | null | undefined): string | null {
|
||||
if (isPresetAvatar(avatarUrl)) return avatarUrl.slice(7);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isCustomAvatar(avatarUrl: string | null | undefined): boolean {
|
||||
return !!avatarUrl && !avatarUrl.startsWith('preset:');
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import { useComments, useAddComment, useDeleteComment } from '@/hooks/useComment
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useToast } from '@/components/Toast';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
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() {
|
||||
@@ -33,13 +33,12 @@ export default function CatPage() {
|
||||
const cat = data?.cat;
|
||||
const comments = commentsData?.comments ?? [];
|
||||
const isOwner = cat && user && cat.user_id === user.id;
|
||||
const isLiked = likeData?.liked ?? false;
|
||||
|
||||
useEffect(() => { setLiked(isLiked); }, [isLiked]);
|
||||
useEffect(() => { if (likeData?.liked !== undefined) setLiked(likeData.liked); }, [likeData?.liked]);
|
||||
|
||||
const toggleLike = async () => {
|
||||
if (isOwner || !user || !cat) return;
|
||||
if (isLiked) {
|
||||
if (liked) {
|
||||
setLiked(false);
|
||||
try { await unlikeMutation.mutateAsync(); refetchLike(); }
|
||||
catch { setLiked(true); }
|
||||
@@ -74,7 +73,7 @@ export default function CatPage() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-[520px] px-5 py-8">
|
||||
<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">
|
||||
@@ -102,7 +101,7 @@ export default function CatPage() {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[520px] px-5 py-6 animate-fade-up">
|
||||
<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} /> Назад
|
||||
@@ -123,11 +122,7 @@ export default function CatPage() {
|
||||
<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">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback className="text-sm avatar-colored bg-[var(--accent)]">
|
||||
{cat.username.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<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>
|
||||
@@ -170,11 +165,7 @@ export default function CatPage() {
|
||||
</p>
|
||||
{comments.map(c => (
|
||||
<div key={c.id} className="flex items-start gap-2.5 group">
|
||||
<Avatar className="h-7 w-7 shrink-0 mt-0.5">
|
||||
<AvatarFallback className="text-[9px] avatar-colored bg-[var(--fg-tertiary)]">
|
||||
{c.username.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<UserAvatar avatarUrl={undefined} username={c.username} className="h-7 w-7" fallbackClassName="text-[9px] avatar-colored bg-[var(--fg-tertiary)]" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm leading-[1.5]">
|
||||
<span className="font-semibold mr-1">@{c.username}</span>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useCats } from '@/hooks/useCats';
|
||||
import CatCard from '@/components/CatCard';
|
||||
import FeedSidebar from '@/components/FeedSidebar';
|
||||
import CatModal from '@/components/CatModal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getBatchLikeStatus } from '@/api/endpoints';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { ChevronLeft, ChevronRight, RefreshCw, Cat } from 'lucide-react';
|
||||
|
||||
function FeedSkeleton() {
|
||||
@@ -32,12 +34,29 @@ function FeedSkeleton() {
|
||||
export default function FeedPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [modalCatId, setModalCatId] = useState<number | null>(null);
|
||||
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
|
||||
const { data, isLoading, isError, refetch } = useCats(page);
|
||||
const { user } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.cats?.length || !user) return;
|
||||
const ids = data.cats.map(c => c.id);
|
||||
getBatchLikeStatus(ids)
|
||||
.then(r => setLikedIds(new Set(r.data.likedIds)))
|
||||
.catch(() => {});
|
||||
}, [data?.cats, user]);
|
||||
|
||||
const handleUnlike = (catId: number) => {
|
||||
setLikedIds(prev => { const n = new Set(prev); n.delete(catId); return n; });
|
||||
};
|
||||
const handleLike = (catId: number) => {
|
||||
setLikedIds(prev => { const n = new Set(prev); n.add(catId); return n; });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-[540px] px-5 py-5">
|
||||
<div className="mx-auto max-w-[900px] px-5 py-5">
|
||||
<div className="flex gap-8">
|
||||
<main className="flex-1 min-w-0">
|
||||
<main className="flex-1 min-w-0 max-w-[540px]">
|
||||
{isLoading && <FeedSkeleton />}
|
||||
|
||||
{isError && (
|
||||
@@ -71,7 +90,7 @@ export default function FeedPage() {
|
||||
<>
|
||||
<div className="space-y-5">
|
||||
{data.cats.map(cat => (
|
||||
<CatCard key={cat.id} cat={cat} onOpen={setModalCatId} />
|
||||
<CatCard key={cat.id} cat={cat} onOpen={setModalCatId} likedIds={likedIds} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -103,7 +122,7 @@ export default function FeedPage() {
|
||||
)}
|
||||
</main>
|
||||
|
||||
<aside className="hidden lg:block w-[220px] shrink-0">
|
||||
<aside className="hidden lg:block w-[260px] shrink-0">
|
||||
<div className="sticky top-20"><FeedSidebar /></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,61 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
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 { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { ArrowLeft, Plus, LogOut, Shield, Heart, Image, Trophy } from 'lucide-react';
|
||||
import { ArrowLeft, Plus, LogOut, Shield, Heart, Image, Trophy, Camera, X } 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 } = useAuth();
|
||||
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
|
||||
: data?.cats?.[0]
|
||||
? { id: data.cats[0].user_id, username: data.cats[0].username, points: data.cats[0].user_points }
|
||||
: null;
|
||||
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 (
|
||||
@@ -55,7 +87,6 @@ export default function ProfilePage() {
|
||||
}
|
||||
|
||||
const cats = data?.cats ?? [];
|
||||
const initials = profileUser?.username?.charAt(0).toUpperCase() || '?';
|
||||
const totalLikes = cats.reduce((sum, c) => sum + c.likes_count, 0);
|
||||
|
||||
return (
|
||||
@@ -67,11 +98,23 @@ export default function ProfilePage() {
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-5 mb-8">
|
||||
<Avatar className="h-[72px] w-[72px] shrink-0">
|
||||
<AvatarFallback className="text-2xl font-800 avatar-colored bg-[var(--accent)]">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<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="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h1 className="text-lg font-800 truncate">@{profileUser?.username}</h1>
|
||||
@@ -153,6 +196,48 @@ export default function ProfilePage() {
|
||||
)}
|
||||
|
||||
{modalCatId && <CatModal catId={modalCatId} onClose={() => setModalCatId(null)} />}
|
||||
|
||||
{showAvatarPicker && (
|
||||
<div className="fixed inset-0 z-[70] bg-black/30 flex items-center justify-center animate-fade-in" onClick={() => setShowAvatarPicker(false)}>
|
||||
<div className="bg-white 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>
|
||||
<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: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)]'
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<input type="file" ref={fileInputRef} onChange={onFileChange} accept="image/*" className="hidden" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/endpoints.ts","./src/components/catcard.tsx","./src/components/catmodal.tsx","./src/components/feedsidebar.tsx","./src/components/navbar.tsx","./src/components/protectedroute.tsx","./src/components/toast.tsx","./src/components/ui/avatar.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/skeleton.tsx","./src/hooks/useauth.tsx","./src/hooks/usecats.ts","./src/hooks/usecomments.ts","./src/hooks/uselikes.ts","./src/lib/auth.ts","./src/lib/utils.ts","./src/pages/adminpage.tsx","./src/pages/catpage.tsx","./src/pages/feedpage.tsx","./src/pages/loginpage.tsx","./src/pages/profilepage.tsx","./src/pages/registerpage.tsx","./src/pages/uploadpage.tsx"],"version":"5.9.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/api/client.ts","./src/api/endpoints.ts","./src/components/catcard.tsx","./src/components/catmodal.tsx","./src/components/feedsidebar.tsx","./src/components/navbar.tsx","./src/components/protectedroute.tsx","./src/components/toast.tsx","./src/components/useravatar.tsx","./src/components/ui/avatar.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/skeleton.tsx","./src/hooks/useauth.tsx","./src/hooks/usecats.ts","./src/hooks/usecomments.ts","./src/hooks/uselikes.ts","./src/lib/auth.ts","./src/lib/avatars.ts","./src/lib/utils.ts","./src/pages/adminpage.tsx","./src/pages/catpage.tsx","./src/pages/feedpage.tsx","./src/pages/loginpage.tsx","./src/pages/profilepage.tsx","./src/pages/registerpage.tsx","./src/pages/uploadpage.tsx"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user