This commit is contained in:
2025-12-17 19:50:55 +07:00
parent debdd66458
commit 7e7cdbcd76
10 changed files with 225 additions and 77 deletions

View File

@@ -10,6 +10,7 @@ from app.core.config import settings
from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType from app.models import Marathon, MarathonStatus, GameProposalMode, Game, GameStatus, Challenge, Activity, ActivityType
from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic from app.schemas import GameCreate, GameUpdate, GameResponse, MessageResponse, UserPublic
from app.services.storage import storage_service from app.services.storage import storage_service
from app.services.telegram_notifier import telegram_notifier
router = APIRouter(tags=["games"]) router = APIRouter(tags=["games"])
@@ -268,6 +269,13 @@ async def approve_game(game_id: int, current_user: CurrentUser, db: DbSession):
if game.status != GameStatus.PENDING.value: if game.status != GameStatus.PENDING.value:
raise HTTPException(status_code=400, detail="Game is not pending") raise HTTPException(status_code=400, detail="Game is not pending")
# Get marathon title for notification
marathon_result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
marathon = marathon_result.scalar_one()
# Save proposer id before status change
proposer_id = game.proposed_by_id
game.status = GameStatus.APPROVED.value game.status = GameStatus.APPROVED.value
game.approved_by_id = current_user.id game.approved_by_id = current_user.id
@@ -283,6 +291,12 @@ async def approve_game(game_id: int, current_user: CurrentUser, db: DbSession):
await db.commit() await db.commit()
await db.refresh(game) await db.refresh(game)
# Notify proposer (if not self-approving)
if proposer_id and proposer_id != current_user.id:
await telegram_notifier.notify_game_approved(
db, proposer_id, marathon.title, game.title
)
# Need to reload relationships # Need to reload relationships
game = await get_game_or_404(db, game_id) game = await get_game_or_404(db, game_id)
challenges_count = await db.scalar( challenges_count = await db.scalar(
@@ -302,6 +316,14 @@ async def reject_game(game_id: int, current_user: CurrentUser, db: DbSession):
if game.status != GameStatus.PENDING.value: if game.status != GameStatus.PENDING.value:
raise HTTPException(status_code=400, detail="Game is not pending") raise HTTPException(status_code=400, detail="Game is not pending")
# Get marathon title for notification
marathon_result = await db.execute(select(Marathon).where(Marathon.id == game.marathon_id))
marathon = marathon_result.scalar_one()
# Save proposer id and game title before changes
proposer_id = game.proposed_by_id
game_title = game.title
game.status = GameStatus.REJECTED.value game.status = GameStatus.REJECTED.value
# Log activity # Log activity
@@ -316,6 +338,12 @@ async def reject_game(game_id: int, current_user: CurrentUser, db: DbSession):
await db.commit() await db.commit()
await db.refresh(game) await db.refresh(game)
# Notify proposer
if proposer_id and proposer_id != current_user.id:
await telegram_notifier.notify_game_rejected(
db, proposer_id, marathon.title, game_title
)
# Need to reload relationships # Need to reload relationships
game = await get_game_or_404(db, game_id) game = await get_game_or_404(db, game_id)
challenges_count = await db.scalar( challenges_count = await db.scalar(

View File

@@ -1,5 +1,6 @@
from datetime import timedelta from datetime import timedelta
import secrets import secrets
import string
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from sqlalchemy import select, func from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -10,7 +11,7 @@ from app.api.deps import (
get_participant, get_participant,
) )
from app.models import ( from app.models import (
Marathon, Participant, MarathonStatus, Game, GameStatus, Marathon, Participant, MarathonStatus, Game, GameStatus, Challenge,
Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole, Assignment, AssignmentStatus, Activity, ActivityType, ParticipantRole,
) )
from app.schemas import ( from app.schemas import (
@@ -40,7 +41,7 @@ async def get_marathon_by_code(invite_code: str, db: DbSession):
select(Marathon, func.count(Participant.id).label("participants_count")) select(Marathon, func.count(Participant.id).label("participants_count"))
.outerjoin(Participant) .outerjoin(Participant)
.options(selectinload(Marathon.creator)) .options(selectinload(Marathon.creator))
.where(Marathon.invite_code == invite_code) .where(func.upper(Marathon.invite_code) == invite_code.upper())
.group_by(Marathon.id) .group_by(Marathon.id)
) )
row = result.first() row = result.first()
@@ -62,7 +63,9 @@ async def get_marathon_by_code(invite_code: str, db: DbSession):
def generate_invite_code() -> str: def generate_invite_code() -> str:
return secrets.token_urlsafe(8) """Generate a clean 8-character uppercase alphanumeric code."""
alphabet = string.ascii_uppercase + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(8))
async def get_marathon_or_404(db, marathon_id: int) -> Marathon: async def get_marathon_or_404(db, marathon_id: int) -> Marathon:
@@ -272,15 +275,33 @@ async def start_marathon(marathon_id: int, current_user: CurrentUser, db: DbSess
if marathon.status != MarathonStatus.PREPARING.value: if marathon.status != MarathonStatus.PREPARING.value:
raise HTTPException(status_code=400, detail="Marathon is not in preparing state") raise HTTPException(status_code=400, detail="Marathon is not in preparing state")
# Check if there are approved games with challenges # Check if there are approved games
games_count = await db.scalar( games_result = await db.execute(
select(func.count()).select_from(Game).where( select(Game).where(
Game.marathon_id == marathon_id, Game.marathon_id == marathon_id,
Game.status == GameStatus.APPROVED.value, Game.status == GameStatus.APPROVED.value,
) )
) )
if games_count == 0: approved_games = games_result.scalars().all()
raise HTTPException(status_code=400, detail="Add and approve at least one game before starting")
if len(approved_games) == 0:
raise HTTPException(status_code=400, detail="Добавьте и одобрите хотя бы одну игру")
# Check that all approved games have at least one challenge
games_without_challenges = []
for game in approved_games:
challenge_count = await db.scalar(
select(func.count()).select_from(Challenge).where(Challenge.game_id == game.id)
)
if challenge_count == 0:
games_without_challenges.append(game.title)
if games_without_challenges:
games_list = ", ".join(games_without_challenges)
raise HTTPException(
status_code=400,
detail=f"У следующих игр нет челленджей: {games_list}"
)
marathon.status = MarathonStatus.ACTIVE.value marathon.status = MarathonStatus.ACTIVE.value
@@ -332,7 +353,7 @@ async def finish_marathon(marathon_id: int, current_user: CurrentUser, db: DbSes
@router.post("/join", response_model=MarathonResponse) @router.post("/join", response_model=MarathonResponse)
async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession): async def join_marathon(data: JoinMarathon, current_user: CurrentUser, db: DbSession):
result = await db.execute( result = await db.execute(
select(Marathon).where(Marathon.invite_code == data.invite_code) select(Marathon).where(func.upper(Marathon.invite_code) == data.invite_code.upper())
) )
marathon = result.scalar_one_or_none() marathon = result.scalar_one_or_none()

View File

@@ -244,6 +244,38 @@ class TelegramNotifier:
) )
return await self.notify_user(db, user_id, message) return await self.notify_user(db, user_id, message)
async def notify_game_approved(
self,
db: AsyncSession,
user_id: int,
marathon_title: str,
game_title: str
) -> bool:
"""Notify user that their proposed game was approved."""
message = (
f"✅ <b>Твоя игра одобрена!</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Игра: {game_title}\n\n"
f"Теперь она доступна для всех участников."
)
return await self.notify_user(db, user_id, message)
async def notify_game_rejected(
self,
db: AsyncSession,
user_id: int,
marathon_title: str,
game_title: str
) -> bool:
"""Notify user that their proposed game was rejected."""
message = (
f"❌ <b>Твоя игра отклонена</b>\n\n"
f"Марафон: {marathon_title}\n"
f"Игра: {game_title}\n\n"
f"Ты можешь предложить другую игру."
)
return await self.notify_user(db, user_id, message)
# Global instance # Global instance
telegram_notifier = TelegramNotifier() telegram_notifier = TelegramNotifier()

View File

@@ -41,8 +41,9 @@ export const usersApi = {
}, },
// Получить аватар пользователя как blob URL // Получить аватар пользователя как blob URL
getAvatarUrl: async (userId: number): Promise<string> => { getAvatarUrl: async (userId: number, bustCache = false): Promise<string> => {
const response = await client.get(`/users/${userId}/avatar`, { const cacheBuster = bustCache ? `?t=${Date.now()}` : ''
const response = await client.get(`/users/${userId}/avatar${cacheBuster}`, {
responseType: 'blob', responseType: 'blob',
}) })
return URL.createObjectURL(response.data) return URL.createObjectURL(response.data)

View File

@@ -125,8 +125,8 @@ export function TelegramLink() {
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
className={`p-2 rounded-lg transition-colors ${ className={`p-2 rounded-lg transition-colors ${
isLinked isLinked
? 'text-blue-400 hover:text-blue-300 hover:bg-gray-700' ? 'text-blue-400 hover:text-blue-300 hover:bg-dark-700'
: 'text-gray-400 hover:text-white hover:bg-gray-700' : 'text-gray-400 hover:text-white hover:bg-dark-700'
}`} }`}
title={isLinked ? 'Telegram привязан' : 'Привязать Telegram'} title={isLinked ? 'Telegram привязан' : 'Привязать Telegram'}
> >
@@ -134,17 +134,17 @@ export function TelegramLink() {
</button> </button>
{isOpen && ( {isOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-gray-800 rounded-xl max-w-md w-full p-6 relative"> <div className="glass rounded-xl max-w-md w-full p-6 relative border border-dark-600">
<button <button
onClick={handleClose} onClick={handleClose}
className="absolute top-4 right-4 text-gray-400 hover:text-white" className="absolute top-4 right-4 text-gray-400 hover:text-white transition-colors"
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-blue-500/20 rounded-full flex items-center justify-center"> <div className="w-12 h-12 bg-blue-500/10 rounded-full flex items-center justify-center border border-blue-500/30">
<MessageCircle className="w-6 h-6 text-blue-400" /> <MessageCircle className="w-6 h-6 text-blue-400" />
</div> </div>
<div> <div>
@@ -171,7 +171,7 @@ 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-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Avatar - Telegram avatar */} {/* Avatar - Telegram avatar */}
<div className="relative"> <div className="relative">
@@ -182,12 +182,12 @@ export function TelegramLink() {
className="w-12 h-12 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-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"> <div className="w-12 h-12 rounded-full bg-gradient-to-br from-blue-500 to-accent-500 flex items-center justify-center border-2 border-blue-500/50">
<User className="w-6 h-6 text-white" /> <User className="w-6 h-6 text-white" />
</div> </div>
)} )}
{/* Link indicator */} {/* Link indicator */}
<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"> <div className="absolute -bottom-1 -right-1 w-5 h-5 bg-green-500 rounded-full flex items-center justify-center border-2 border-dark-800">
<Link2 className="w-2.5 h-2.5 text-white" /> <Link2 className="w-2.5 h-2.5 text-white" />
</div> </div>
</div> </div>
@@ -205,7 +205,7 @@ export function TelegramLink() {
</div> </div>
{/* Notifications Info */} {/* Notifications Info */}
<div className="p-4 bg-gray-700/30 rounded-lg"> <div className="p-4 bg-dark-700/30 rounded-lg border border-dark-600/50">
<p className="text-sm text-gray-300 font-medium mb-3">Уведомления включены:</p> <p className="text-sm text-gray-300 font-medium mb-3">Уведомления включены:</p>
<div className="grid grid-cols-1 gap-2"> <div className="grid grid-cols-1 gap-2">
<div className="flex items-center gap-2 text-sm text-gray-400"> <div className="flex items-center gap-2 text-sm text-gray-400">
@@ -254,7 +254,7 @@ export function TelegramLink() {
<button <button
onClick={handleOpenBot} onClick={handleOpenBot}
className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2" className="w-full py-3 px-4 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 border border-blue-500/50 hover:border-blue-500 rounded-lg font-medium transition-all duration-200 flex items-center justify-center gap-2"
> >
<ExternalLink className="w-5 h-5" /> <ExternalLink className="w-5 h-5" />
Открыть Telegram снова Открыть Telegram снова
@@ -268,13 +268,13 @@ export function TelegramLink() {
<button <button
onClick={handleOpenBot} onClick={handleOpenBot}
className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2" className="w-full py-3 px-4 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 border border-blue-500/50 hover:border-blue-500 rounded-lg font-medium transition-all duration-200 flex items-center justify-center gap-2"
> >
<ExternalLink className="w-5 h-5" /> <ExternalLink className="w-5 h-5" />
Открыть Telegram Открыть Telegram
</button> </button>
<p className="text-sm text-gray-500 text-center"> <p className="text-sm text-gray-400 text-center">
Ссылка действительна 10 минут Ссылка действительна 10 минут
</p> </p>
</> </>
@@ -304,7 +304,7 @@ export function TelegramLink() {
<button <button
onClick={handleGenerateLink} onClick={handleGenerateLink}
disabled={loading} disabled={loading}
className="w-full py-3 px-4 bg-blue-500 hover:bg-blue-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50 flex items-center justify-center gap-2" className="w-full py-3 px-4 bg-blue-500/20 hover:bg-blue-500/30 text-blue-400 border border-blue-500/50 hover:border-blue-500 rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
> >
{loading ? ( {loading ? (
<Loader2 className="w-5 h-5 animate-spin" /> <Loader2 className="w-5 h-5 animate-spin" />

View File

@@ -2,7 +2,7 @@ import { useEffect } from 'react'
import { AlertTriangle, Info, Trash2, X } from 'lucide-react' import { AlertTriangle, Info, Trash2, X } from 'lucide-react'
import { clsx } from 'clsx' import { clsx } from 'clsx'
import { useConfirmStore, type ConfirmVariant } from '@/store/confirm' import { useConfirmStore, type ConfirmVariant } from '@/store/confirm'
import { Button } from './Button' import { NeonButton } from './NeonButton'
const icons: Record<ConfirmVariant, React.ReactNode> = { const icons: Record<ConfirmVariant, React.ReactNode> = {
danger: <Trash2 className="w-6 h-6" />, danger: <Trash2 className="w-6 h-6" />,
@@ -11,15 +11,15 @@ const icons: Record<ConfirmVariant, React.ReactNode> = {
} }
const iconStyles: Record<ConfirmVariant, string> = { const iconStyles: Record<ConfirmVariant, string> = {
danger: 'bg-red-500/20 text-red-500', danger: 'bg-red-500/10 text-red-400 border border-red-500/30',
warning: 'bg-yellow-500/20 text-yellow-500', warning: 'bg-yellow-500/10 text-yellow-400 border border-yellow-500/30',
info: 'bg-blue-500/20 text-blue-500', info: 'bg-neon-500/10 text-neon-400 border border-neon-500/30',
} }
const buttonVariants: Record<ConfirmVariant, 'danger' | 'primary' | 'secondary'> = { const confirmButtonStyles: Record<ConfirmVariant, string> = {
danger: 'danger', danger: 'border-red-500/50 text-red-400 hover:bg-red-500/10 hover:border-red-500',
warning: 'primary', warning: 'border-yellow-500/50 text-yellow-400 hover:bg-yellow-500/10 hover:border-yellow-500',
info: 'primary', info: '', // Will use NeonButton default
} }
export function ConfirmModal() { export function ConfirmModal() {
@@ -62,7 +62,7 @@ export function ConfirmModal() {
/> />
{/* Modal */} {/* Modal */}
<div className="relative bg-gray-800 rounded-xl shadow-2xl max-w-md w-full animate-in zoom-in-95 fade-in duration-200 border border-gray-700"> <div className="relative glass rounded-xl shadow-2xl max-w-md w-full animate-in zoom-in-95 fade-in duration-200 border border-dark-600">
{/* Close button */} {/* Close button */}
<button <button
onClick={handleCancel} onClick={handleCancel}
@@ -89,20 +89,31 @@ export function ConfirmModal() {
{/* Actions */} {/* Actions */}
<div className="flex gap-3"> <div className="flex gap-3">
<Button <NeonButton
variant="secondary" variant="secondary"
className="flex-1" className="flex-1"
onClick={handleCancel} onClick={handleCancel}
> >
{options.cancelText || 'Отмена'} {options.cancelText || 'Отмена'}
</Button> </NeonButton>
<Button {variant === 'info' ? (
variant={buttonVariants[variant]} <NeonButton
className="flex-1" className="flex-1"
onClick={handleConfirm} onClick={handleConfirm}
> >
{options.confirmText || 'Подтвердить'} {options.confirmText || 'Подтвердить'}
</Button> </NeonButton>
) : (
<button
className={clsx(
'flex-1 px-4 py-2.5 rounded-lg font-semibold transition-all duration-200 border-2 bg-transparent',
confirmButtonStyles[variant]
)}
onClick={handleConfirm}
>
{options.confirmText || 'Подтвердить'}
</button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -91,10 +91,14 @@ export function StatsCard({
className className
)} )}
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm text-gray-400 mb-1">{label}</p> <p className="text-sm text-gray-400 mb-1">{label}</p>
<p className={clsx('text-2xl font-bold truncate', valueColorClasses[color])}> <p className={clsx(
'font-bold',
typeof value === 'number' ? 'text-2xl' : 'text-lg',
valueColorClasses[color]
)}>
{value} {value}
</p> </p>
{trend && ( {trend && (
@@ -111,7 +115,7 @@ export function StatsCard({
{icon && ( {icon && (
<div <div
className={clsx( className={clsx(
'w-12 h-12 rounded-lg flex items-center justify-center flex-shrink-0', 'w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0',
iconColorClasses[color] iconColorClasses[color]
)} )}
> >

View File

@@ -3,6 +3,8 @@ import { usersApi } from '@/api'
// Глобальный кэш для blob URL аватарок // Глобальный кэш для blob URL аватарок
const avatarCache = new Map<number, string>() const avatarCache = new Map<number, string>()
// Пользователи, для которых нужно сбросить HTTP-кэш при следующем запросе
const needsCacheBust = new Set<number>()
interface UserAvatarProps { interface UserAvatarProps {
userId: number userId: number
@@ -10,6 +12,7 @@ interface UserAvatarProps {
nickname: string nickname: string
size?: 'sm' | 'md' | 'lg' size?: 'sm' | 'md' | 'lg'
className?: string className?: string
version?: number // Для принудительного обновления при смене аватара
} }
const sizeClasses = { const sizeClasses = {
@@ -18,7 +21,7 @@ const sizeClasses = {
lg: 'w-24 h-24 text-xl', lg: 'w-24 h-24 text-xl',
} }
export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className = '' }: UserAvatarProps) { export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className = '', version = 0 }: UserAvatarProps) {
const [blobUrl, setBlobUrl] = useState<string | null>(null) const [blobUrl, setBlobUrl] = useState<string | null>(null)
const [failed, setFailed] = useState(false) const [failed, setFailed] = useState(false)
@@ -28,16 +31,31 @@ export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className
return return
} }
// Проверяем кэш // Если version > 0, значит аватар обновился - сбрасываем кэш
const cached = avatarCache.get(userId) const shouldBustCache = version > 0 || needsCacheBust.has(userId)
if (cached) {
setBlobUrl(cached) // Проверяем кэш только если не нужен bust
return if (!shouldBustCache) {
const cached = avatarCache.get(userId)
if (cached) {
setBlobUrl(cached)
return
}
}
// Очищаем старый кэш если bust
if (shouldBustCache) {
const cached = avatarCache.get(userId)
if (cached) {
URL.revokeObjectURL(cached)
avatarCache.delete(userId)
}
needsCacheBust.delete(userId)
} }
// Загружаем аватарку // Загружаем аватарку
let cancelled = false let cancelled = false
usersApi.getAvatarUrl(userId) usersApi.getAvatarUrl(userId, shouldBustCache)
.then(url => { .then(url => {
if (!cancelled) { if (!cancelled) {
avatarCache.set(userId, url) avatarCache.set(userId, url)
@@ -53,7 +71,7 @@ export function UserAvatar({ userId, hasAvatar, nickname, size = 'md', className
return () => { return () => {
cancelled = true cancelled = true
} }
}, [userId, hasAvatar]) }, [userId, hasAvatar, version])
const sizeClass = sizeClasses[size] const sizeClass = sizeClasses[size]
@@ -84,4 +102,6 @@ export function clearAvatarCache(userId: number) {
URL.revokeObjectURL(cached) URL.revokeObjectURL(cached)
avatarCache.delete(userId) avatarCache.delete(userId)
} }
// Помечаем, что при следующем запросе нужно сбросить HTTP-кэш браузера
needsCacheBust.add(userId)
} }

View File

@@ -33,7 +33,7 @@ type NicknameForm = z.infer<typeof nicknameSchema>
type PasswordForm = z.infer<typeof passwordSchema> type PasswordForm = z.infer<typeof passwordSchema>
export function ProfilePage() { export function ProfilePage() {
const { user, updateUser } = useAuthStore() const { user, updateUser, avatarVersion, bumpAvatarVersion } = useAuthStore()
const toast = useToast() const toast = useToast()
// State // State
@@ -72,31 +72,57 @@ export function ProfilePage() {
} }
}, []) }, [])
// Ref для отслеживания текущего blob URL
const avatarBlobRef = useRef<string | null>(null)
// Load avatar via API // Load avatar via API
useEffect(() => { useEffect(() => {
if (user?.id && user?.avatar_url) { if (!user?.id || !user?.avatar_url) {
loadAvatar(user.id)
} else {
setIsLoadingAvatar(false) setIsLoadingAvatar(false)
return
} }
let cancelled = false
const bustCache = avatarVersion > 0
setIsLoadingAvatar(true)
usersApi.getAvatarUrl(user.id, bustCache)
.then(url => {
if (cancelled) {
URL.revokeObjectURL(url)
return
}
// Очищаем старый blob URL
if (avatarBlobRef.current) {
URL.revokeObjectURL(avatarBlobRef.current)
}
avatarBlobRef.current = url
setAvatarBlobUrl(url)
})
.catch(() => {
if (!cancelled) {
setAvatarBlobUrl(null)
}
})
.finally(() => {
if (!cancelled) {
setIsLoadingAvatar(false)
}
})
return () => { return () => {
if (avatarBlobUrl) { cancelled = true
URL.revokeObjectURL(avatarBlobUrl) }
}, [user?.id, user?.avatar_url, avatarVersion])
// Cleanup blob URL on unmount
useEffect(() => {
return () => {
if (avatarBlobRef.current) {
URL.revokeObjectURL(avatarBlobRef.current)
} }
} }
}, [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)
}
}
// Update nickname form when user changes // Update nickname form when user changes
useEffect(() => { useEffect(() => {
@@ -150,12 +176,10 @@ export function ProfilePage() {
const updatedUser = await usersApi.uploadAvatar(file) const updatedUser = await usersApi.uploadAvatar(file)
updateUser({ avatar_url: updatedUser.avatar_url }) updateUser({ avatar_url: updatedUser.avatar_url })
if (user?.id) { if (user?.id) {
if (avatarBlobUrl) {
URL.revokeObjectURL(avatarBlobUrl)
}
clearAvatarCache(user.id) clearAvatarCache(user.id)
await loadAvatar(user.id)
} }
// Bump version - это вызовет перезагрузку через useEffect
bumpAvatarVersion()
toast.success('Аватар обновлен') toast.success('Аватар обновлен')
} catch { } catch {
toast.error('Не удалось загрузить аватар') toast.error('Не удалось загрузить аватар')

View File

@@ -10,6 +10,7 @@ interface AuthState {
isLoading: boolean isLoading: boolean
error: string | null error: string | null
pendingInviteCode: string | null pendingInviteCode: string | null
avatarVersion: number
login: (data: LoginData) => Promise<void> login: (data: LoginData) => Promise<void>
register: (data: RegisterData) => Promise<void> register: (data: RegisterData) => Promise<void>
@@ -18,6 +19,7 @@ interface AuthState {
setPendingInviteCode: (code: string | null) => void setPendingInviteCode: (code: string | null) => void
consumePendingInviteCode: () => string | null consumePendingInviteCode: () => string | null
updateUser: (updates: Partial<User>) => void updateUser: (updates: Partial<User>) => void
bumpAvatarVersion: () => void
} }
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
@@ -29,6 +31,7 @@ export const useAuthStore = create<AuthState>()(
isLoading: false, isLoading: false,
error: null, error: null,
pendingInviteCode: null, pendingInviteCode: null,
avatarVersion: 0,
login: async (data) => { login: async (data) => {
set({ isLoading: true, error: null }) set({ isLoading: true, error: null })
@@ -97,6 +100,10 @@ export const useAuthStore = create<AuthState>()(
set({ user: { ...currentUser, ...updates } }) set({ user: { ...currentUser, ...updates } })
} }
}, },
bumpAvatarVersion: () => {
set({ avatarVersion: get().avatarVersion + 1 })
},
}), }),
{ {
name: 'auth-storage', name: 'auth-storage',