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 sqlalchemy import select, func
from app.api.deps import DbSession, CurrentUser 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) 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) @router.patch("/me", response_model=UserPublic)
async def update_me(data: UserUpdate, current_user: CurrentUser, db: DbSession): async def update_me(data: UserUpdate, current_user: CurrentUser, db: DbSession):
if data.nickname is not None: if data.nickname is not None:

View File

@@ -28,6 +28,7 @@ class Settings(BaseSettings):
# Uploads # Uploads
UPLOAD_DIR: str = "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_IMAGE_SIZE: int = 15 * 1024 * 1024 # 15 MB
MAX_VIDEO_SIZE: int = 30 * 1024 * 1024 # 30 MB MAX_VIDEO_SIZE: int = 30 * 1024 * 1024 # 30 MB
ALLOWED_IMAGE_EXTENSIONS: set = {"jpg", "jpeg", "png", "gif", "webp"} 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) const response = await client.post<{ message: string }>('/users/me/password', data)
return response.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 { feedApi } from '@/api'
import type { Activity, ActivityType } from '@/types' import type { Activity, ActivityType } from '@/types'
import { Loader2, ChevronDown, Bell, ExternalLink, AlertTriangle } from 'lucide-react' import { Loader2, ChevronDown, Bell, ExternalLink, AlertTriangle } from 'lucide-react'
import { UserAvatar } from '@/components/ui'
import { import {
formatRelativeTime, formatRelativeTime,
getActivityIcon, getActivityIcon,
@@ -212,19 +213,12 @@ function ActivityItem({ activity }: ActivityItemProps) {
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{/* Avatar */} {/* Avatar */}
<div className="flex-shrink-0"> <div className="flex-shrink-0">
{activity.user.avatar_url ? ( <UserAvatar
<img userId={activity.user.id}
src={activity.user.avatar_url} hasAvatar={!!activity.user.avatar_url}
alt={activity.user.nickname} nickname={activity.user.nickname}
className="w-8 h-8 rounded-full object-cover" size="sm"
/> />
) : (
<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>
)}
</div> </div>
{/* Content */} {/* Content */}

View File

@@ -173,11 +173,11 @@ export function TelegramLink() {
{/* User Profile Card */} {/* 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="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"> <div className="flex items-center gap-4">
{/* Avatar - prefer Telegram avatar */} {/* Avatar - prefer uploaded avatar */}
<div className="relative"> <div className="relative">
{user?.telegram_avatar_url || user?.avatar_url ? ( {user?.avatar_url || user?.telegram_avatar_url ? (
<img <img
src={user.telegram_avatar_url || user.avatar_url || ''} src={user.avatar_url || user.telegram_avatar_url || ''}
alt={user.nickname} alt={user.nickname}
className="w-16 h-16 rounded-full object-cover border-2 border-blue-500/50" 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 { Card, CardHeader, CardTitle, CardContent } from './Card'
export { ToastContainer } from './Toast' export { ToastContainer } from './Toast'
export { ConfirmModal } from './ConfirmModal' 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 type { UserStats } from '@/types'
import { useToast } from '@/store/toast' import { useToast } from '@/store/toast'
import { import {
Button, Input, Card, CardHeader, CardTitle, CardContent Button, Input, Card, CardHeader, CardTitle, CardContent, clearAvatarCache
} from '@/components/ui' } from '@/components/ui'
import { import {
User, Camera, Trophy, Target, CheckCircle, Flame, User, Camera, Trophy, Target, CheckCircle, Flame,
@@ -43,6 +43,8 @@ export function ProfilePage() {
const [showPasswordForm, setShowPasswordForm] = useState(false) const [showPasswordForm, setShowPasswordForm] = useState(false)
const [showCurrentPassword, setShowCurrentPassword] = useState(false) const [showCurrentPassword, setShowCurrentPassword] = useState(false)
const [showNewPassword, setShowNewPassword] = useState(false) const [showNewPassword, setShowNewPassword] = useState(false)
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null)
const [isLoadingAvatar, setIsLoadingAvatar] = useState(true)
// Telegram state // Telegram state
const [telegramLoading, setTelegramLoading] = useState(false) 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 // Обновляем форму никнейма при изменении user
useEffect(() => { useEffect(() => {
if (user?.nickname) { if (user?.nickname) {
@@ -122,6 +150,15 @@ export function ProfilePage() {
try { try {
const updatedUser = await usersApi.uploadAvatar(file) const updatedUser = await usersApi.uploadAvatar(file)
updateUser({ avatar_url: updatedUser.avatar_url }) 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('Аватар обновлен') toast.success('Аватар обновлен')
} catch { } catch {
toast.error('Не удалось загрузить аватар') toast.error('Не удалось загрузить аватар')
@@ -208,7 +245,8 @@ export function ProfilePage() {
} }
const isLinked = !!user?.telegram_id const isLinked = !!user?.telegram_id
const displayAvatar = user?.telegram_avatar_url || user?.avatar_url // Приоритет: загруженная аватарка (blob) > телеграм аватарка
const displayAvatar = avatarBlobUrl || user?.telegram_avatar_url
return ( return (
<div className="max-w-2xl mx-auto space-y-6"> <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="flex items-start gap-6">
{/* Аватар */} {/* Аватар */}
<div className="relative group flex-shrink-0"> <div className="relative group flex-shrink-0">
<button {isLoadingAvatar ? (
onClick={handleAvatarClick} <div className="w-24 h-24 rounded-full bg-gray-700 animate-pulse" />
disabled={isUploadingAvatar} ) : (
className="relative w-24 h-24 rounded-full overflow-hidden bg-gray-700 hover:opacity-80 transition-opacity" <button
> onClick={handleAvatarClick}
{displayAvatar ? ( disabled={isUploadingAvatar}
<img className="relative w-24 h-24 rounded-full overflow-hidden bg-gray-700 hover:opacity-80 transition-opacity"
src={displayAvatar} >
alt={user?.nickname} {displayAvatar ? (
className="w-full h-full object-cover" <img
/> src={displayAvatar}
) : ( alt={user?.nickname}
<div className="w-full h-full flex items-center justify-center"> className="w-full h-full object-cover"
<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" />
) : ( ) : (
<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> <div className="absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
</button> {isUploadingAvatar ? (
<Loader2 className="w-6 h-6 text-white animate-spin" />
) : (
<Camera className="w-6 h-6 text-white" />
)}
</div>
</button>
)}
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@@ -286,8 +328,14 @@ export function ProfilePage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{isLoadingStats ? ( {isLoadingStats ? (
<div className="flex justify-center py-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Loader2 className="w-6 h-6 animate-spin text-primary-500" /> {[...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> </div>
) : stats ? ( ) : stats ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <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 [profile, setProfile] = useState<UserProfilePublic | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
if (!id) return if (!id) return
@@ -32,6 +33,27 @@ export function UserProfilePage() {
loadProfile(userId) loadProfile(userId)
}, [id, currentUser, navigate]) }, [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) => { const loadProfile = async (userId: number) => {
setIsLoading(true) setIsLoading(true)
setError(null) setError(null)
@@ -101,9 +123,9 @@ export function UserProfilePage() {
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
{/* Аватар */} {/* Аватар */}
<div className="w-24 h-24 rounded-full overflow-hidden bg-gray-700 flex-shrink-0"> <div className="w-24 h-24 rounded-full overflow-hidden bg-gray-700 flex-shrink-0">
{profile.avatar_url ? ( {avatarBlobUrl ? (
<img <img
src={profile.avatar_url} src={avatarBlobUrl}
alt={profile.nickname} alt={profile.nickname}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />