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;
|
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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)]">
|
||||||
|
|||||||
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;
|
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
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 { 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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"}
|
||||||
@@ -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']);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
26
server/src/routes/users.ts
Normal file
26
server/src/routes/users.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user