Compare commits

..

2 Commits

Author SHA1 Message Date
11f7b59471 Fix telegram avatar 2025-12-17 01:06:03 +07:00
1c07d8c5ff Fix avatars upload 2025-12-17 00:04:14 +07:00
11 changed files with 250 additions and 58 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

@@ -36,7 +36,10 @@ class GPTService:
{games_text} {games_text}
ВАЖНО: Челленджи должны быть СПЕЦИФИЧНЫМИ для каждой игры! ВАЖНО:
- ВСЕ ТЕКСТЫ (title, description, proof_hint) ОБЯЗАТЕЛЬНО ПИШИ НА РУССКОМ ЯЗЫКЕ!
- Используй интернет для поиска актуальной информации об играх
- Челленджи должны быть СПЕЦИФИЧНЫМИ для каждой игры!
- Используй РЕАЛЬНЫЕ названия локаций, боссов, персонажей, миссий, уровней из игры - Используй РЕАЛЬНЫЕ названия локаций, боссов, персонажей, миссий, уровней из игры
- Основывайся на том, какие челленджи РЕАЛЬНО делают игроки в этой игре - Основывайся на том, какие челленджи РЕАЛЬНО делают игроки в этой игре
- НЕ генерируй абстрактные челленджи типа "пройди уровень" или "убей 10 врагов" - НЕ генерируй абстрактные челленджи типа "пройди уровень" или "убей 10 врагов"
@@ -44,7 +47,7 @@ class GPTService:
Требования по сложности ДЛЯ КАЖДОЙ ИГРЫ: Требования по сложности ДЛЯ КАЖДОЙ ИГРЫ:
- 2 лёгких (15-30 мин): простые задачи - 2 лёгких (15-30 мин): простые задачи
- 2 средних (1-2 часа): требуют навыка - 2 средних (1-2 часа): требуют навыка
- 2 сложных (3+ часа): серьёзный челлендж - 2 сложных (3-12 часов): серьёзный челлендж
Формат ответа — JSON с объектом где ключи это ТОЧНЫЕ названия игр, как они указаны в запросе: Формат ответа — JSON с объектом где ключи это ТОЧНЫЕ названия игр, как они указаны в запросе:
{{ {{
@@ -59,10 +62,10 @@ class GPTService:
}} }}
points: easy=20-40, medium=45-75, hard=90-150 points: easy=20-40, medium=45-75, hard=90-150
Ответь ТОЛЬКО JSON.""" Ответь ТОЛЬКО JSON. ОПИСАНИЕ И НАЗВАНИЕ ЧЕЛЛЕНДЖА ТОЛЬКО НА РУССКОМ ЯЗЫКЕ!"""
response = await self.client.chat.completions.create( response = await self.client.chat.completions.create(
model="gpt-5-mini", model="gpt-5",
messages=[{"role": "user", "content": prompt}], messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"}, response_format={"type": "json_object"},
) )

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,22 +173,22 @@ 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 - Telegram avatar */}
<div className="relative"> <div className="relative">
{user?.telegram_avatar_url || user?.avatar_url ? ( {user?.telegram_avatar_url ? (
<img <img
src={user.telegram_avatar_url || user.avatar_url || ''} src={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-12 h-12 rounded-full object-cover border-2 border-blue-500/50"
/> />
) : ( ) : (
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center border-2 border-blue-500/50"> <div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center border-2 border-blue-500/50">
<User className="w-8 h-8 text-white" /> <User className="w-6 h-6 text-white" />
</div> </div>
)} )}
{/* Link indicator */} {/* Link indicator */}
<div className="absolute -bottom-1 -right-1 w-6 h-6 bg-green-500 rounded-full flex items-center justify-center border-2 border-gray-800"> <div className="absolute -bottom-1 -right-1 w-5 h-5 bg-green-500 rounded-full flex items-center justify-center border-2 border-gray-800">
<Link2 className="w-3 h-3 text-white" /> <Link2 className="w-2.5 h-2.5 text-white" />
</div> </div>
</div> </div>

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,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">

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"
/> />

View File

@@ -44,10 +44,10 @@ http {
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Timeout for file uploads # Timeout for long GPT requests (15 min)
proxy_read_timeout 300; proxy_read_timeout 900;
proxy_connect_timeout 300; proxy_connect_timeout 900;
proxy_send_timeout 300; proxy_send_timeout 900;
} }
# Static files (uploads) # Static files (uploads)