Fix avatars upload
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
87
frontend/src/components/ui/UserAvatar.tsx
Normal file
87
frontend/src/components/ui/UserAvatar.tsx
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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,6 +258,9 @@ 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">
|
||||||
|
{isLoadingAvatar ? (
|
||||||
|
<div className="w-24 h-24 rounded-full bg-gray-700 animate-pulse" />
|
||||||
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={handleAvatarClick}
|
onClick={handleAvatarClick}
|
||||||
disabled={isUploadingAvatar}
|
disabled={isUploadingAvatar}
|
||||||
@@ -244,6 +285,7 @@ export function ProfilePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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">
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user