Fix likes display, add avatar system (presets+upload), improve desktop layout, fix profile viewing

This commit is contained in:
2026-05-29 14:21:40 +03:00
parent 3664b60f10
commit 025e52e6b1
19 changed files with 346 additions and 94 deletions

View File

@@ -9,6 +9,7 @@ export interface Cat {
user_id: number; user_id: number;
username: string; username: string;
user_points: number; user_points: number;
user_avatar_url: string | null;
likes_count: number; likes_count: number;
comments_count: number; comments_count: number;
} }
@@ -72,6 +73,10 @@ export function getLikeStatus(catId: number) {
return api.get<{ liked: boolean }>(`/likes/${catId}/status`); return api.get<{ liked: boolean }>(`/likes/${catId}/status`);
} }
export function getBatchLikeStatus(catIds: number[]) {
return api.post<{ likedIds: number[] }>('/likes/batch/status', { catIds });
}
// Admin // Admin
export function getAdminUsers() { export function getAdminUsers() {
return api.get<{ users: User[] }>('/admin/users'); return api.get<{ users: User[] }>('/admin/users');
@@ -109,3 +114,19 @@ export function addComment(catId: number, text: string) {
export function deleteComment(commentId: number) { export function deleteComment(commentId: number) {
return api.delete(`/comments/${commentId}`); 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 });
}

View File

@@ -3,17 +3,18 @@ import { Cat } from '@/api/endpoints';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useLikeCat, useUnlikeCat } from '@/hooks/useLikes'; import { useLikeCat, useUnlikeCat } from '@/hooks/useLikes';
import { useToast } from '@/components/Toast'; import { useToast } from '@/components/Toast';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import UserAvatar from '@/components/UserAvatar';
import { Heart, MessageCircle, Trophy } from 'lucide-react'; import { Heart, MessageCircle, Trophy } from 'lucide-react';
interface CatCardProps { interface CatCardProps {
cat: Cat; cat: Cat;
onOpen: (id: number) => void; 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 { user } = useAuth();
const [liked, setLiked] = useState(false); const [liked, setLiked] = useState(() => likedIds?.has(cat.id) ?? false);
const [likeCount, setLikeCount] = useState(cat.likes_count); const [likeCount, setLikeCount] = useState(cat.likes_count);
const { toast } = useToast(); const { toast } = useToast();
const likeMutation = useLikeCat(cat.id); const likeMutation = useLikeCat(cat.id);
@@ -43,11 +44,7 @@ export default function CatCard({ cat, onOpen }: CatCardProps) {
<article className="animate-fade-up"> <article className="animate-fade-up">
<div className="card overflow-hidden cursor-pointer card-interactive" onClick={() => onOpen(cat.id)}> <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"> <div className="flex items-center gap-3 px-5 pt-5 pb-3">
<Avatar className="h-9 w-9"> <UserAvatar avatarUrl={cat.user_avatar_url} username={cat.username} className="h-9 w-9" fallbackClassName="text-xs avatar-colored bg-[var(--accent)]" />
<AvatarFallback className="text-xs avatar-colored bg-[var(--accent)]">
{cat.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold truncate">@{cat.username}</h3> <h3 className="text-sm font-semibold truncate">@{cat.username}</h3>
<time className="text-[11px] text-[var(--fg-tertiary)]">{timeAgo}</time> <time className="text-[11px] text-[var(--fg-tertiary)]">{timeAgo}</time>

View File

@@ -4,7 +4,7 @@ import { useLikeStatus, useLikeCat, useUnlikeCat } from '@/hooks/useLikes';
import { useComments, useAddComment, useDeleteComment } from '@/hooks/useComments'; import { useComments, useAddComment, useDeleteComment } from '@/hooks/useComments';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/components/Toast'; 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'; import { X, Heart, Trash2, Trophy, Send } from 'lucide-react';
interface CatModalProps { 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 justify-between px-5 py-4 shrink-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Avatar className="h-9 w-9"> <UserAvatar avatarUrl={cat.user_avatar_url} username={cat.username} className="h-9 w-9" fallbackClassName="text-xs avatar-colored bg-[var(--accent)]" />
<AvatarFallback className="text-xs avatar-colored bg-[var(--accent)]">
{cat.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div> <div>
<span className="text-sm font-semibold">@{cat.username}</span> <span className="text-sm font-semibold">@{cat.username}</span>
<p className="text-[11px] text-[var(--fg-tertiary)]">{dateStr}</p> <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"> <div className="flex-1 overflow-y-auto px-5 py-4 space-y-3 min-h-0">
{cat.caption && ( {cat.caption && (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Avatar className="h-7 w-7 shrink-0 mt-0.5"> <UserAvatar avatarUrl={cat.user_avatar_url} username={cat.username} className="h-7 w-7" fallbackClassName="text-[9px] avatar-colored bg-[var(--accent)]" />
<AvatarFallback className="text-[9px] avatar-colored bg-[var(--accent)]">
{cat.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<p className="text-sm leading-[1.5]"> <p className="text-sm leading-[1.5]">
<span className="font-semibold mr-1">@{cat.username}</span> <span className="font-semibold mr-1">@{cat.username}</span>
{cat.caption} {cat.caption}
@@ -171,11 +163,7 @@ export default function CatModal({ catId, onClose }: CatModalProps) {
<div className="space-y-3 pt-1"> <div className="space-y-3 pt-1">
{comments.map(c => ( {comments.map(c => (
<div key={c.id} className="flex items-start gap-2.5 group"> <div key={c.id} className="flex items-start gap-2.5 group">
<Avatar className="h-7 w-7 shrink-0 mt-0.5"> <UserAvatar avatarUrl={undefined} username={c.username} className="h-7 w-7" fallbackClassName="text-[9px] avatar-colored bg-[var(--fg-tertiary)]" />
<AvatarFallback className="text-[9px] avatar-colored bg-[var(--fg-tertiary)]">
{c.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm leading-[1.5]"> <p className="text-sm leading-[1.5]">
<span className="font-semibold mr-1">@{c.username}</span> <span className="font-semibold mr-1">@{c.username}</span>

View File

@@ -1,6 +1,6 @@
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useCats } from '@/hooks/useCats'; import { useCats } from '@/hooks/useCats';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import UserAvatar from '@/components/UserAvatar';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Trophy, Crown, Medal } from 'lucide-react'; import { Trophy, Crown, Medal } from 'lucide-react';
@@ -15,11 +15,11 @@ export default function FeedSidebar() {
const { user } = useAuth(); const { user } = useAuth();
const { data } = useCats(1); 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) { if (data?.cats) {
for (const cat of data.cats) { for (const cat of data.cats) {
if (!usersMap.has(cat.user_id)) 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"> <div className="space-y-4">
{user && ( {user && (
<Link to="/profile" className="card p-4 flex items-center gap-3.5 card-interactive group block"> <Link to="/profile" className="card p-4 flex items-center gap-3.5 card-interactive group block">
<Avatar className="h-11 w-11"> <UserAvatar avatarUrl={user.avatar_url} username={user.username} className="h-11 w-11" fallbackClassName="text-sm font-bold avatar-colored bg-[var(--accent)]" />
<AvatarFallback className="text-sm font-bold avatar-colored bg-[var(--accent)]">
{user.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1"> <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-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"> <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) => ( {topUsers.map(([id, u], i) => (
<Link key={id} to={`/profile/${id}`} className="flex items-center gap-2.5 group py-0.5"> <Link key={id} to={`/profile/${id}`} className="flex items-center gap-2.5 group py-0.5">
<RankBadge rank={i} /> <RankBadge rank={i} />
<Avatar className="h-6 w-6"> <UserAvatar avatarUrl={u.avatar_url} username={u.username} className="h-6 w-6" fallbackClassName="text-[8px] font-bold avatar-colored bg-[var(--fg-tertiary)]" />
<AvatarFallback className="text-[8px] font-bold avatar-colored bg-[var(--fg-tertiary)]">
{u.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-sm flex-1 truncate font-medium group-hover:text-[var(--accent)] transition-colors">{u.username}</span> <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> <span className="stat-value text-xs text-[var(--fg-secondary)]">{u.points}</span>
</Link> </Link>

View File

@@ -1,7 +1,7 @@
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import UserAvatar from '@/components/UserAvatar';
import { Home, PlusCircle, User, Shield } from 'lucide-react'; import { Home, PlusCircle, Shield } from 'lucide-react';
export default function Navbar() { export default function Navbar() {
const { user } = useAuth(); const { user } = useAuth();
@@ -11,7 +11,7 @@ export default function Navbar() {
return ( return (
<nav className="sticky top-0 z-50 nav-glass border-b border-[var(--border-light)]"> <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 to="/feed" className="text-[15px] font-800 tracking-tight text-[var(--fg)]">
Котограм Котограм
</Link> </Link>
@@ -24,11 +24,7 @@ export default function Navbar() {
<div className={`flex items-center justify-center h-8 w-8 rounded-full transition-all ${ <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)]' : '' location.pathname.startsWith('/profile') ? 'ring-2 ring-[var(--accent)] ring-offset-2 ring-offset-[var(--bg)]' : ''
}`}> }`}>
<Avatar className="h-8 w-8"> <UserAvatar avatarUrl={user.avatar_url} username={user.username} className="h-8 w-8" fallbackClassName="text-[11px] avatar-colored bg-[var(--accent)]" />
<AvatarFallback className="text-[11px] avatar-colored bg-[var(--accent)]">
{user.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</div> </div>
{user.is_admin && ( {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)]"> <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)]">

View 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>
);
}

View File

@@ -6,6 +6,7 @@ export interface User {
username: string; username: string;
points: number; points: number;
is_admin?: boolean; is_admin?: boolean;
avatar_url?: string | null;
created_at?: string; created_at?: string;
} }

16
client/src/lib/avatars.ts Normal file
View 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:');
}

View File

@@ -6,8 +6,8 @@ import { useComments, useAddComment, useDeleteComment } from '@/hooks/useComment
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useToast } from '@/components/Toast'; import { useToast } from '@/components/Toast';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import UserAvatar from '@/components/UserAvatar';
import { ArrowLeft, Heart, Trash2, Trophy, Image, Send, X } from 'lucide-react'; import { ArrowLeft, Heart, Trash2, Trophy, Image, Send, X } from 'lucide-react';
export default function CatPage() { export default function CatPage() {
@@ -33,13 +33,12 @@ export default function CatPage() {
const cat = data?.cat; const cat = data?.cat;
const comments = commentsData?.comments ?? []; const comments = commentsData?.comments ?? [];
const isOwner = cat && user && cat.user_id === user.id; 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 () => { const toggleLike = async () => {
if (isOwner || !user || !cat) return; if (isOwner || !user || !cat) return;
if (isLiked) { if (liked) {
setLiked(false); setLiked(false);
try { await unlikeMutation.mutateAsync(); refetchLike(); } try { await unlikeMutation.mutateAsync(); refetchLike(); }
catch { setLiked(true); } catch { setLiked(true); }
@@ -74,7 +73,7 @@ export default function CatPage() {
if (isLoading) { if (isLoading) {
return ( 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="mb-4 h-4 w-20 rounded-lg" />
<Skeleton className="aspect-[4/3] w-full rounded-2xl" /> <Skeleton className="aspect-[4/3] w-full rounded-2xl" />
<div className="mt-5 space-y-3"> <div className="mt-5 space-y-3">
@@ -102,7 +101,7 @@ export default function CatPage() {
}); });
return ( 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)} <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"> 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} /> Назад <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="p-5 space-y-4">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<Link to={`/profile/${cat.user_id}`} className="flex items-center gap-3 group"> <Link to={`/profile/${cat.user_id}`} className="flex items-center gap-3 group">
<Avatar className="h-10 w-10"> <UserAvatar avatarUrl={cat.user_avatar_url} username={cat.username} className="h-10 w-10" fallbackClassName="text-sm avatar-colored bg-[var(--accent)]" />
<AvatarFallback className="text-sm avatar-colored bg-[var(--accent)]">
{cat.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div> <div>
<span className="text-sm font-semibold group-hover:text-[var(--accent)] transition-colors">@{cat.username}</span> <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> <p className="text-[11px] text-[var(--fg-tertiary)]">{dateStr}</p>
@@ -170,11 +165,7 @@ export default function CatPage() {
</p> </p>
{comments.map(c => ( {comments.map(c => (
<div key={c.id} className="flex items-start gap-2.5 group"> <div key={c.id} className="flex items-start gap-2.5 group">
<Avatar className="h-7 w-7 shrink-0 mt-0.5"> <UserAvatar avatarUrl={undefined} username={c.username} className="h-7 w-7" fallbackClassName="text-[9px] avatar-colored bg-[var(--fg-tertiary)]" />
<AvatarFallback className="text-[9px] avatar-colored bg-[var(--fg-tertiary)]">
{c.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm leading-[1.5]"> <p className="text-sm leading-[1.5]">
<span className="font-semibold mr-1">@{c.username}</span> <span className="font-semibold mr-1">@{c.username}</span>

View File

@@ -1,9 +1,11 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useCats } from '@/hooks/useCats'; import { useCats } from '@/hooks/useCats';
import CatCard from '@/components/CatCard'; import CatCard from '@/components/CatCard';
import FeedSidebar from '@/components/FeedSidebar'; import FeedSidebar from '@/components/FeedSidebar';
import CatModal from '@/components/CatModal'; import CatModal from '@/components/CatModal';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { getBatchLikeStatus } from '@/api/endpoints';
import { useAuth } from '@/hooks/useAuth';
import { ChevronLeft, ChevronRight, RefreshCw, Cat } from 'lucide-react'; import { ChevronLeft, ChevronRight, RefreshCw, Cat } from 'lucide-react';
function FeedSkeleton() { function FeedSkeleton() {
@@ -32,12 +34,29 @@ function FeedSkeleton() {
export default function FeedPage() { export default function FeedPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [modalCatId, setModalCatId] = useState<number | null>(null); const [modalCatId, setModalCatId] = useState<number | null>(null);
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
const { data, isLoading, isError, refetch } = useCats(page); 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 ( 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"> <div className="flex gap-8">
<main className="flex-1 min-w-0"> <main className="flex-1 min-w-0 max-w-[540px]">
{isLoading && <FeedSkeleton />} {isLoading && <FeedSkeleton />}
{isError && ( {isError && (
@@ -71,7 +90,7 @@ export default function FeedPage() {
<> <>
<div className="space-y-5"> <div className="space-y-5">
{data.cats.map(cat => ( {data.cats.map(cat => (
<CatCard key={cat.id} cat={cat} onOpen={setModalCatId} /> <CatCard key={cat.id} cat={cat} onOpen={setModalCatId} likedIds={likedIds} />
))} ))}
</div> </div>
@@ -103,7 +122,7 @@ export default function FeedPage() {
)} )}
</main> </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> <div className="sticky top-20"><FeedSidebar /></div>
</aside> </aside>
</div> </div>

View File

@@ -1,29 +1,61 @@
import { useState } from 'react'; import { useState, useRef, useCallback } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom'; import { useParams, Link, useNavigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useUserCats } from '@/hooks/useCats'; import { useUserCats } from '@/hooks/useCats';
import { useQuery } from '@tanstack/react-query';
import { getUser, uploadAvatar, setAvatarPreset } from '@/api/endpoints';
import CatModal from '@/components/CatModal'; import CatModal from '@/components/CatModal';
import UserAvatar from '@/components/UserAvatar';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { ArrowLeft, Plus, LogOut, Shield, Heart, Image, Trophy, Camera, X } from 'lucide-react';
import { ArrowLeft, Plus, LogOut, Shield, Heart, Image, Trophy } from 'lucide-react'; import { PRESETS } from '@/lib/avatars';
import { useToast } from '@/components/Toast';
export default function ProfilePage() { export default function ProfilePage() {
const { id: paramId } = useParams<{ id?: string }>(); const { id: paramId } = useParams<{ id?: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { user: me, logout } = useAuth(); const { user: me, logout, updateUser } = useAuth();
const [modalCatId, setModalCatId] = useState<number | null>(null); const [modalCatId, setModalCatId] = useState<number | null>(null);
const [showAvatarPicker, setShowAvatarPicker] = useState(false);
const { toast } = useToast();
const profileUserId = paramId ? parseInt(paramId) : me?.id; const profileUserId = paramId ? parseInt(paramId) : me?.id;
const isMe = !paramId || (me && profileUserId === 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 { data, isLoading } = useUserCats(profileUserId ?? 0);
const profileUser = isMe const profileUser = isMe ? me : userData;
? me
: data?.cats?.[0] const fileInputRef = useRef<HTMLInputElement>(null);
? { id: data.cats[0].user_id, username: data.cats[0].username, points: data.cats[0].user_points } const onFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
: null; 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) { if (isLoading) {
return ( return (
@@ -55,7 +87,6 @@ export default function ProfilePage() {
} }
const cats = data?.cats ?? []; const cats = data?.cats ?? [];
const initials = profileUser?.username?.charAt(0).toUpperCase() || '?';
const totalLikes = cats.reduce((sum, c) => sum + c.likes_count, 0); const totalLikes = cats.reduce((sum, c) => sum + c.likes_count, 0);
return ( return (
@@ -67,11 +98,23 @@ export default function ProfilePage() {
)} )}
<div className="flex items-center gap-5 mb-8"> <div className="flex items-center gap-5 mb-8">
<Avatar className="h-[72px] w-[72px] shrink-0"> <div className="relative group">
<AvatarFallback className="text-2xl font-800 avatar-colored bg-[var(--accent)]"> <UserAvatar
{initials} avatarUrl={profileUser?.avatar_url}
</AvatarFallback> username={profileUser?.username || '?'}
</Avatar> 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-1 min-w-0">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<h1 className="text-lg font-800 truncate">@{profileUser?.username}</h1> <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)} />} {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> </div>
); );
} }

View File

@@ -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"}

View File

@@ -34,6 +34,7 @@ export async function initDb(): Promise<SqlJsDatabase> {
// Add is_admin column if missing (existing DB migration) // Add is_admin column if missing (existing DB migration)
try { db.run('ALTER TABLE users ADD COLUMN is_admin INTEGER DEFAULT 0'); } catch {} try { db.run('ALTER TABLE users ADD COLUMN is_admin INTEGER DEFAULT 0'); } catch {}
try { db.run('ALTER TABLE users ADD COLUMN avatar_url TEXT DEFAULT NULL'); } catch {}
// Auto-create admin if not exists // Auto-create admin if not exists
const admin = queryOne('SELECT id FROM users WHERE username = ?', ['admin']); const admin = queryOne('SELECT id FROM users WHERE username = ?', ['admin']);

View File

@@ -9,6 +9,7 @@ import catRoutes from './routes/cats.js';
import likeRoutes from './routes/likes.js'; import likeRoutes from './routes/likes.js';
import adminRoutes from './routes/admin.js'; import adminRoutes from './routes/admin.js';
import commentRoutes from './routes/comments.js'; import commentRoutes from './routes/comments.js';
import userRoutes from './routes/users.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -27,6 +28,7 @@ async function main() {
app.use('/api/cats', catRoutes); app.use('/api/cats', catRoutes);
app.use('/api/likes', likeRoutes); app.use('/api/likes', likeRoutes);
app.use('/api/comments', commentRoutes); app.use('/api/comments', commentRoutes);
app.use('/api/users', userRoutes);
app.use('/api/admin', adminRoutes); app.use('/api/admin', adminRoutes);
app.get('/api/health', (_req, res) => { app.get('/api/health', (_req, res) => {

View File

@@ -10,7 +10,7 @@ router.use(authMiddleware, adminOnly);
router.get('/users', (_req: AuthRequest, res: Response) => { router.get('/users', (_req: AuthRequest, res: Response) => {
const users = queryAll( const users = queryAll(
'SELECT id, username, points, is_admin, created_at FROM users ORDER BY points DESC' 'SELECT id, username, points, is_admin, avatar_url, created_at FROM users ORDER BY points DESC'
); );
res.json({ users }); res.json({ users });
}); });

View File

@@ -1,8 +1,43 @@
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import multer from 'multer';
import path from 'path';
import { fileURLToPath } from 'url';
import { queryOne, execute } from '../db'; import { queryOne, execute } from '../db';
import { generateToken, authMiddleware, AuthRequest } from '../middleware/auth.js'; import { generateToken, authMiddleware, AuthRequest } from '../middleware/auth.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const uploadsDir = path.join(__dirname, '..', '..', 'uploads');
const avatarStorage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadsDir),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `avatar-${Date.now()}-${Math.random().toString(36).slice(2, 6)}${ext}`);
},
});
const avatarUpload = multer({
storage: avatarStorage,
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
const ext = path.extname(file.originalname).toLowerCase();
cb(null, allowed.includes(ext));
},
});
function userToResponse(u: any) {
return {
id: u.id,
username: u.username,
points: u.points,
is_admin: !!u.is_admin,
avatar_url: u.avatar_url || null,
created_at: u.created_at || undefined,
};
}
const router = Router(); const router = Router();
router.post('/register', (req: Request, res: Response) => { router.post('/register', (req: Request, res: Response) => {
@@ -27,32 +62,58 @@ router.post('/register', (req: Request, res: Response) => {
const password_hash = bcrypt.hashSync(password, 10); const password_hash = bcrypt.hashSync(password, 10);
execute('INSERT INTO users (username, password_hash) VALUES (?, ?)', [username, password_hash]); execute('INSERT INTO users (username, password_hash) VALUES (?, ?)', [username, password_hash]);
const user = queryOne('SELECT id, username, points, is_admin FROM users WHERE username = ?', [username]); const user = queryOne('SELECT id, username, points, is_admin, avatar_url FROM users WHERE username = ?', [username]);
const token = generateToken(user.id, user.username, !!user.is_admin); const token = generateToken(user.id, user.username, !!user.is_admin);
res.status(201).json({ token, user: { id: user.id, username: user.username, points: user.points, is_admin: !!user.is_admin } }); res.status(201).json({ token, user: userToResponse(user) });
}); });
router.post('/login', (req: Request, res: Response) => { router.post('/login', (req: Request, res: Response) => {
const { username, password } = req.body; const { username, password } = req.body;
const user = queryOne('SELECT id, username, password_hash, points, is_admin FROM users WHERE username = ?', [username]); const user = queryOne('SELECT id, username, password_hash, points, is_admin, avatar_url FROM users WHERE username = ?', [username]);
if (!user || !bcrypt.compareSync(password, user.password_hash)) { if (!user || !bcrypt.compareSync(password, user.password_hash)) {
res.status(401).json({ error: 'Неверное имя пользователя или пароль' }); res.status(401).json({ error: 'Неверное имя пользователя или пароль' });
return; return;
} }
const token = generateToken(user.id, user.username, !!user.is_admin); const token = generateToken(user.id, user.username, !!user.is_admin);
res.json({ token, user: { id: user.id, username: user.username, points: user.points, is_admin: !!user.is_admin } }); res.json({ token, user: userToResponse(user) });
}); });
router.get('/me', authMiddleware, (req: AuthRequest, res: Response) => { router.get('/me', authMiddleware, (req: AuthRequest, res: Response) => {
const user = queryOne('SELECT id, username, points, is_admin, created_at FROM users WHERE id = ?', [req.userId]); const user = queryOne('SELECT id, username, points, is_admin, avatar_url, created_at FROM users WHERE id = ?', [req.userId]);
if (!user) { if (!user) {
res.status(404).json({ error: 'User not found' }); res.status(404).json({ error: 'User not found' });
return; return;
} }
res.json({ user: { id: user.id, username: user.username, points: user.points, is_admin: !!user.is_admin, created_at: user.created_at } }); res.json({ user: userToResponse(user) });
});
router.put('/avatar', authMiddleware, avatarUpload.single('avatar'), (req: AuthRequest, res: Response) => {
if (!req.file) {
res.status(400).json({ error: 'Нужно выбрать изображение' });
return;
}
const avatar_url = `/uploads/${req.file.filename}`;
execute('UPDATE users SET avatar_url = ? WHERE id = ?', [avatar_url, req.userId]);
const user = queryOne('SELECT id, username, points, is_admin, avatar_url, created_at FROM users WHERE id = ?', [req.userId]);
res.json({ user: userToResponse(user) });
});
router.put('/avatar/preset', authMiddleware, (req: AuthRequest, res: Response) => {
const { preset } = req.body;
if (!preset) {
res.status(400).json({ error: 'Укажите пресет аватара' });
return;
}
const avatar_url = `preset:${preset}`;
execute('UPDATE users SET avatar_url = ? WHERE id = ?', [avatar_url, req.userId]);
const user = queryOne('SELECT id, username, points, is_admin, avatar_url, created_at FROM users WHERE id = ?', [req.userId]);
res.json({ user: userToResponse(user) });
}); });
export default router; export default router;

View File

@@ -35,7 +35,7 @@ router.get('/', (req: AuthRequest, res: Response) => {
const cats = queryAll( const cats = queryAll(
`SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id, `SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id,
u.username, u.points AS user_points, u.username, u.points AS user_points, u.avatar_url AS user_avatar_url,
(SELECT COUNT(*) FROM likes WHERE cat_id = c.id) AS likes_count, (SELECT COUNT(*) FROM likes WHERE cat_id = c.id) AS likes_count,
(SELECT COUNT(*) FROM comments WHERE cat_id = c.id) AS comments_count (SELECT COUNT(*) FROM comments WHERE cat_id = c.id) AS comments_count
FROM cats c FROM cats c
@@ -55,7 +55,7 @@ router.get('/user/:userId', (req: AuthRequest, res: Response) => {
const userId = parseInt(req.params.userId); const userId = parseInt(req.params.userId);
const cats = queryAll( const cats = queryAll(
`SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id, `SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id,
u.username, u.points AS user_points, u.username, u.points AS user_points, u.avatar_url AS user_avatar_url,
(SELECT COUNT(*) FROM likes WHERE cat_id = c.id) AS likes_count, (SELECT COUNT(*) FROM likes WHERE cat_id = c.id) AS likes_count,
(SELECT COUNT(*) FROM comments WHERE cat_id = c.id) AS comments_count (SELECT COUNT(*) FROM comments WHERE cat_id = c.id) AS comments_count
FROM cats c FROM cats c
@@ -71,7 +71,7 @@ router.get('/user/:userId', (req: AuthRequest, res: Response) => {
router.get('/:id', (req: AuthRequest, res: Response) => { router.get('/:id', (req: AuthRequest, res: Response) => {
const cat = queryOne( const cat = queryOne(
`SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id, `SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id,
u.username, u.points AS user_points, u.username, u.points AS user_points, u.avatar_url AS user_avatar_url,
(SELECT COUNT(*) FROM likes WHERE cat_id = c.id) AS likes_count, (SELECT COUNT(*) FROM likes WHERE cat_id = c.id) AS likes_count,
(SELECT COUNT(*) FROM comments WHERE cat_id = c.id) AS comments_count (SELECT COUNT(*) FROM comments WHERE cat_id = c.id) AS comments_count
FROM cats c FROM cats c
@@ -106,7 +106,7 @@ router.post('/', authMiddleware, upload.single('image'), (req: AuthRequest, res:
const cat = queryOne( const cat = queryOne(
`SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id, `SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id,
u.username, u.points AS user_points, u.username, u.points AS user_points, u.avatar_url AS user_avatar_url,
0 AS likes_count, 0 AS likes_count,
0 AS comments_count 0 AS comments_count
FROM cats c FROM cats c

View File

@@ -1,5 +1,5 @@
import { Router, Response } from 'express'; import { Router, Response } from 'express';
import { queryOne, execute } from '../db'; import { queryOne, queryAll, execute } from '../db';
import { authMiddleware, AuthRequest } from '../middleware/auth.js'; import { authMiddleware, AuthRequest } from '../middleware/auth.js';
const router = Router(); const router = Router();
@@ -63,4 +63,21 @@ router.get('/:catId/status', authMiddleware, (req: AuthRequest, res: Response) =
res.json({ liked: !!liked }); res.json({ liked: !!liked });
}); });
router.post('/batch/status', authMiddleware, (req: AuthRequest, res: Response) => {
const userId = req.userId!;
const { catIds } = req.body as { catIds: number[] };
if (!Array.isArray(catIds) || catIds.length === 0) {
res.json({ likedIds: [] });
return;
}
const likes = queryAll(
`SELECT cat_id FROM likes WHERE user_id = ? AND cat_id IN (${catIds.map(() => '?').join(',')})`,
[userId, ...catIds]
);
res.json({ likedIds: likes.map((l: any) => l.cat_id) });
});
export default router; export default router;

View File

@@ -0,0 +1,26 @@
import { Router, Response } from 'express';
import { queryOne, queryAll } from '../db';
import { AuthRequest } from '../middleware/auth.js';
const router = Router();
router.get('/:id', (req: AuthRequest, res: Response) => {
const userId = parseInt(req.params.id);
const user = queryOne('SELECT id, username, points, is_admin, avatar_url, created_at FROM users WHERE id = ?', [userId]);
if (!user) {
res.status(404).json({ error: 'Пользователь не найден' });
return;
}
res.json({
user: {
id: user.id,
username: user.username,
points: user.points,
is_admin: !!user.is_admin,
avatar_url: user.avatar_url || null,
created_at: user.created_at,
},
});
});
export default router;