Fix avatars upload

This commit is contained in:
2025-12-17 00:04:14 +07:00
parent 895e296f44
commit 1c07d8c5ff
9 changed files with 234 additions and 45 deletions

View File

@@ -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:

View File

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

View File

@@ -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<string> => {
const response = await client.get(`/users/${userId}/avatar`, {
responseType: 'blob',
})
return URL.createObjectURL(response.data)
},
}

View File

@@ -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) {
<div className="flex items-start gap-3">
{/* Avatar */}
<div className="flex-shrink-0">
{activity.user.avatar_url ? (
<img
src={activity.user.avatar_url}
alt={activity.user.nickname}
className="w-8 h-8 rounded-full object-cover"
/>
) : (
<div className="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center">
<span className="text-xs text-gray-400 font-medium">
{activity.user.nickname.charAt(0).toUpperCase()}
</span>
</div>
)}
<UserAvatar
userId={activity.user.id}
hasAvatar={!!activity.user.avatar_url}
nickname={activity.user.nickname}
size="sm"
/>
</div>
{/* Content */}

View File

@@ -173,11 +173,11 @@ export function TelegramLink() {
{/* User Profile Card */}
<div className="p-4 bg-gradient-to-br from-gray-700/50 to-gray-800/50 rounded-xl border border-gray-600/50">
<div className="flex items-center gap-4">
{/* Avatar - prefer Telegram avatar */}
{/* Avatar - prefer uploaded avatar */}
<div className="relative">
{user?.telegram_avatar_url || user?.avatar_url ? (
{user?.avatar_url || user?.telegram_avatar_url ? (
<img
src={user.telegram_avatar_url || user.avatar_url || ''}
src={user.avatar_url || user.telegram_avatar_url || ''}
alt={user.nickname}
className="w-16 h-16 rounded-full object-cover border-2 border-blue-500/50"
/>

View File

@@ -0,0 +1,87 @@
import { useState, useEffect } from 'react'
import { usersApi } from '@/api'
// Глобальный кэш для blob URL аватарок
const avatarCache = new Map<number, string>()
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<string | null>(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 (
<img
src={blobUrl}
alt={nickname}
className={`rounded-full object-cover ${sizeClass} ${className}`}
/>
)
}
// Fallback - первая буква никнейма
return (
<div className={`rounded-full bg-gray-700 flex items-center justify-center ${sizeClass} ${className}`}>
<span className="text-gray-400 font-medium">
{nickname.charAt(0).toUpperCase()}
</span>
</div>
)
}
// Функция для очистки кэша конкретного пользователя (после загрузки нового аватара)
export function clearAvatarCache(userId: number) {
const cached = avatarCache.get(userId)
if (cached) {
URL.revokeObjectURL(cached)
avatarCache.delete(userId)
}
}

View File

@@ -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'

View File

@@ -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<string | null>(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 (
<div className="max-w-2xl mx-auto space-y-6">
@@ -220,30 +258,34 @@ export function ProfilePage() {
<div className="flex items-start gap-6">
{/* Аватар */}
<div className="relative group flex-shrink-0">
<button
onClick={handleAvatarClick}
disabled={isUploadingAvatar}
className="relative w-24 h-24 rounded-full overflow-hidden bg-gray-700 hover:opacity-80 transition-opacity"
>
{displayAvatar ? (
<img
src={displayAvatar}
alt={user?.nickname}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<User className="w-12 h-12 text-gray-500" />
</div>
)}
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
{isUploadingAvatar ? (
<Loader2 className="w-6 h-6 text-white animate-spin" />
{isLoadingAvatar ? (
<div className="w-24 h-24 rounded-full bg-gray-700 animate-pulse" />
) : (
<button
onClick={handleAvatarClick}
disabled={isUploadingAvatar}
className="relative w-24 h-24 rounded-full overflow-hidden bg-gray-700 hover:opacity-80 transition-opacity"
>
{displayAvatar ? (
<img
src={displayAvatar}
alt={user?.nickname}
className="w-full h-full object-cover"
/>
) : (
<Camera className="w-6 h-6 text-white" />
<div className="w-full h-full flex items-center justify-center">
<User className="w-12 h-12 text-gray-500" />
</div>
)}
</div>
</button>
<div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
{isUploadingAvatar ? (
<Loader2 className="w-6 h-6 text-white animate-spin" />
) : (
<Camera className="w-6 h-6 text-white" />
)}
</div>
</button>
)}
<input
ref={fileInputRef}
type="file"
@@ -286,8 +328,14 @@ export function ProfilePage() {
</CardHeader>
<CardContent>
{isLoadingStats ? (
<div className="flex justify-center py-4">
<Loader2 className="w-6 h-6 animate-spin text-primary-500" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-gray-900 rounded-lg p-4 text-center">
<div className="w-6 h-6 bg-gray-700 rounded mx-auto mb-2 animate-pulse" />
<div className="h-8 w-12 bg-gray-700 rounded mx-auto mb-2 animate-pulse" />
<div className="h-4 w-16 bg-gray-700 rounded mx-auto animate-pulse" />
</div>
))}
</div>
) : stats ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">

View File

@@ -17,6 +17,7 @@ export function UserProfilePage() {
const [profile, setProfile] = useState<UserProfilePublic | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(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() {
<div className="flex items-center gap-6">
{/* Аватар */}
<div className="w-24 h-24 rounded-full overflow-hidden bg-gray-700 flex-shrink-0">
{profile.avatar_url ? (
{avatarBlobUrl ? (
<img
src={profile.avatar_url}
src={avatarBlobUrl}
alt={profile.nickname}
className="w-full h-full object-cover"
/>