From 1c07d8c5ffdf7110aa2d3f5047885f76a0afbfe4 Mon Sep 17 00:00:00 2001 From: Oronemu Date: Wed, 17 Dec 2025 00:04:14 +0700 Subject: [PATCH] Fix avatars upload --- backend/app/api/v1/users.py | 30 ++++++- backend/app/core/config.py | 1 + frontend/src/api/users.ts | 8 ++ frontend/src/components/ActivityFeed.tsx | 20 ++--- frontend/src/components/TelegramLink.tsx | 6 +- frontend/src/components/ui/UserAvatar.tsx | 87 +++++++++++++++++++ frontend/src/components/ui/index.ts | 1 + frontend/src/pages/ProfilePage.tsx | 100 ++++++++++++++++------ frontend/src/pages/UserProfilePage.tsx | 26 +++++- 9 files changed, 234 insertions(+), 45 deletions(-) create mode 100644 frontend/src/components/ui/UserAvatar.tsx diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index 88b9ffc..852d49a 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, HTTPException, status, UploadFile, File +from fastapi import APIRouter, HTTPException, status, UploadFile, File, Response from sqlalchemy import select, func from app.api.deps import DbSession, CurrentUser @@ -30,6 +30,34 @@ async def get_user(user_id: int, db: DbSession): return UserPublic.model_validate(user) +@router.get("/{user_id}/avatar") +async def get_user_avatar(user_id: int, db: DbSession): + """Stream user avatar from storage""" + result = await db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if not user.avatar_path: + raise HTTPException(status_code=404, detail="User has no avatar") + + # Get file from storage + file_data = await storage_service.get_file(user.avatar_path, "avatars") + if not file_data: + raise HTTPException(status_code=404, detail="Avatar not found in storage") + + content, content_type = file_data + + return Response( + content=content, + media_type=content_type, + headers={ + "Cache-Control": "public, max-age=3600", + } + ) + + @router.patch("/me", response_model=UserPublic) async def update_me(data: UserUpdate, current_user: CurrentUser, db: DbSession): if data.nickname is not None: diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 8d8db03..1147a5a 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -28,6 +28,7 @@ class Settings(BaseSettings): # Uploads UPLOAD_DIR: str = "uploads" + MAX_UPLOAD_SIZE: int = 5 * 1024 * 1024 # 5 MB for avatars MAX_IMAGE_SIZE: int = 15 * 1024 * 1024 # 15 MB MAX_VIDEO_SIZE: int = 30 * 1024 * 1024 # 30 MB ALLOWED_IMAGE_EXTENSIONS: set = {"jpg", "jpeg", "png", "gif", "webp"} diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index b88e534..e068a66 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -39,4 +39,12 @@ export const usersApi = { const response = await client.post<{ message: string }>('/users/me/password', data) return response.data }, + + // Получить аватар пользователя как blob URL + getAvatarUrl: async (userId: number): Promise => { + const response = await client.get(`/users/${userId}/avatar`, { + responseType: 'blob', + }) + return URL.createObjectURL(response.data) + }, } diff --git a/frontend/src/components/ActivityFeed.tsx b/frontend/src/components/ActivityFeed.tsx index 8036382..ddc4ee1 100644 --- a/frontend/src/components/ActivityFeed.tsx +++ b/frontend/src/components/ActivityFeed.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom' import { feedApi } from '@/api' import type { Activity, ActivityType } from '@/types' import { Loader2, ChevronDown, Bell, ExternalLink, AlertTriangle } from 'lucide-react' +import { UserAvatar } from '@/components/ui' import { formatRelativeTime, getActivityIcon, @@ -212,19 +213,12 @@ function ActivityItem({ activity }: ActivityItemProps) {
{/* Avatar */}
- {activity.user.avatar_url ? ( - {activity.user.nickname} - ) : ( -
- - {activity.user.nickname.charAt(0).toUpperCase()} - -
- )} +
{/* Content */} diff --git a/frontend/src/components/TelegramLink.tsx b/frontend/src/components/TelegramLink.tsx index 7ff3337..2062cd8 100644 --- a/frontend/src/components/TelegramLink.tsx +++ b/frontend/src/components/TelegramLink.tsx @@ -173,11 +173,11 @@ export function TelegramLink() { {/* User Profile Card */}
- {/* Avatar - prefer Telegram avatar */} + {/* Avatar - prefer uploaded avatar */}
- {user?.telegram_avatar_url || user?.avatar_url ? ( + {user?.avatar_url || user?.telegram_avatar_url ? ( {user.nickname} diff --git a/frontend/src/components/ui/UserAvatar.tsx b/frontend/src/components/ui/UserAvatar.tsx new file mode 100644 index 0000000..f1e89ae --- /dev/null +++ b/frontend/src/components/ui/UserAvatar.tsx @@ -0,0 +1,87 @@ +import { useState, useEffect } from 'react' +import { usersApi } from '@/api' + +// Глобальный кэш для blob URL аватарок +const avatarCache = new Map() + +interface UserAvatarProps { + userId: number + hasAvatar: boolean // Есть ли у пользователя avatar_url + nickname: string + size?: 'sm' | 'md' | 'lg' + className?: string +} + +const sizeClasses = { + sm: 'w-8 h-8 text-xs', + md: 'w-12 h-12 text-sm', + lg: 'w-24 h-24 text-xl', +} + +export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className = '' }: UserAvatarProps) { + const [blobUrl, setBlobUrl] = useState(null) + const [failed, setFailed] = useState(false) + + useEffect(() => { + if (!hasAvatar) { + setBlobUrl(null) + return + } + + // Проверяем кэш + const cached = avatarCache.get(userId) + if (cached) { + setBlobUrl(cached) + return + } + + // Загружаем аватарку + let cancelled = false + usersApi.getAvatarUrl(userId) + .then(url => { + if (!cancelled) { + avatarCache.set(userId, url) + setBlobUrl(url) + } + }) + .catch(() => { + if (!cancelled) { + setFailed(true) + } + }) + + return () => { + cancelled = true + } + }, [userId, hasAvatar]) + + const sizeClass = sizeClasses[size] + + if (blobUrl && !failed) { + return ( + {nickname} + ) + } + + // Fallback - первая буква никнейма + return ( +
+ + {nickname.charAt(0).toUpperCase()} + +
+ ) +} + +// Функция для очистки кэша конкретного пользователя (после загрузки нового аватара) +export function clearAvatarCache(userId: number) { + const cached = avatarCache.get(userId) + if (cached) { + URL.revokeObjectURL(cached) + avatarCache.delete(userId) + } +} diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 3336266..1896ec0 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -3,3 +3,4 @@ export { Input } from './Input' export { Card, CardHeader, CardTitle, CardContent } from './Card' export { ToastContainer } from './Toast' export { ConfirmModal } from './ConfirmModal' +export { UserAvatar, clearAvatarCache } from './UserAvatar' diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index 3e40039..ef76516 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -7,7 +7,7 @@ import { usersApi, telegramApi, authApi } from '@/api' import type { UserStats } from '@/types' import { useToast } from '@/store/toast' import { - Button, Input, Card, CardHeader, CardTitle, CardContent + Button, Input, Card, CardHeader, CardTitle, CardContent, clearAvatarCache } from '@/components/ui' import { User, Camera, Trophy, Target, CheckCircle, Flame, @@ -43,6 +43,8 @@ export function ProfilePage() { const [showPasswordForm, setShowPasswordForm] = useState(false) const [showCurrentPassword, setShowCurrentPassword] = useState(false) const [showNewPassword, setShowNewPassword] = useState(false) + const [avatarBlobUrl, setAvatarBlobUrl] = useState(null) + const [isLoadingAvatar, setIsLoadingAvatar] = useState(true) // Telegram state const [telegramLoading, setTelegramLoading] = useState(false) @@ -70,6 +72,32 @@ export function ProfilePage() { } }, []) + // Загрузка аватарки через API + useEffect(() => { + if (user?.id && user?.avatar_url) { + loadAvatar(user.id) + } else { + setIsLoadingAvatar(false) + } + return () => { + if (avatarBlobUrl) { + URL.revokeObjectURL(avatarBlobUrl) + } + } + }, [user?.id, user?.avatar_url]) + + const loadAvatar = async (userId: number) => { + setIsLoadingAvatar(true) + try { + const url = await usersApi.getAvatarUrl(userId) + setAvatarBlobUrl(url) + } catch { + setAvatarBlobUrl(null) + } finally { + setIsLoadingAvatar(false) + } + } + // Обновляем форму никнейма при изменении user useEffect(() => { if (user?.nickname) { @@ -122,6 +150,15 @@ export function ProfilePage() { try { const updatedUser = await usersApi.uploadAvatar(file) updateUser({ avatar_url: updatedUser.avatar_url }) + // Перезагружаем аватарку через API + if (user?.id) { + // Очищаем старый blob URL и глобальный кэш + if (avatarBlobUrl) { + URL.revokeObjectURL(avatarBlobUrl) + } + clearAvatarCache(user.id) + await loadAvatar(user.id) + } toast.success('Аватар обновлен') } catch { toast.error('Не удалось загрузить аватар') @@ -208,7 +245,8 @@ export function ProfilePage() { } const isLinked = !!user?.telegram_id - const displayAvatar = user?.telegram_avatar_url || user?.avatar_url + // Приоритет: загруженная аватарка (blob) > телеграм аватарка + const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url return (
@@ -220,30 +258,34 @@ export function ProfilePage() {
{/* Аватар */}
-
- +
+ {isUploadingAvatar ? ( + + ) : ( + + )} +
+ + )} {isLoadingStats ? ( -
- +
+ {[...Array(4)].map((_, i) => ( +
+
+
+
+
+ ))}
) : stats ? (
diff --git a/frontend/src/pages/UserProfilePage.tsx b/frontend/src/pages/UserProfilePage.tsx index fee5219..d43b20e 100644 --- a/frontend/src/pages/UserProfilePage.tsx +++ b/frontend/src/pages/UserProfilePage.tsx @@ -17,6 +17,7 @@ export function UserProfilePage() { const [profile, setProfile] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) + const [avatarBlobUrl, setAvatarBlobUrl] = useState(null) useEffect(() => { if (!id) return @@ -32,6 +33,27 @@ export function UserProfilePage() { loadProfile(userId) }, [id, currentUser, navigate]) + // Загрузка аватарки через API + useEffect(() => { + if (profile?.id && profile?.avatar_url) { + loadAvatar(profile.id) + } + return () => { + if (avatarBlobUrl) { + URL.revokeObjectURL(avatarBlobUrl) + } + } + }, [profile?.id, profile?.avatar_url]) + + const loadAvatar = async (userId: number) => { + try { + const url = await usersApi.getAvatarUrl(userId) + setAvatarBlobUrl(url) + } catch { + setAvatarBlobUrl(null) + } + } + const loadProfile = async (userId: number) => { setIsLoading(true) setError(null) @@ -101,9 +123,9 @@ export function UserProfilePage() {
{/* Аватар */}
- {profile.avatar_url ? ( + {avatarBlobUrl ? ( {profile.nickname}