From 403ea8ac24341fb2f4924da68c7a340c5fbcbe5b Mon Sep 17 00:00:00 2001 From: Dmitry Date: Sun, 11 Jan 2026 01:17:30 +0300 Subject: [PATCH] fixes and cookies --- admin/src/api/themes.ts | 2 + admin/src/components/ThemeEditorDialog.tsx | 24 +++++- .../src/admin/themes/dto/create-theme.dto.ts | 5 ++ backend/src/auth/auth.controller.ts | 38 ++++++++- backend/src/auth/auth.service.ts | 26 ++++++ backend/src/rooms/rooms.service.ts | 7 ++ src/components/Snowflakes.jsx | 57 +++++++++---- src/context/AuthContext.jsx | 77 +++++++++++++++--- src/hooks/useRoom.js | 24 +++++- src/index.css | 42 ++++++++++ src/pages/Home.jsx | 79 +++++++++++++++++-- src/services/api.js | 4 + 12 files changed, 347 insertions(+), 38 deletions(-) diff --git a/admin/src/api/themes.ts b/admin/src/api/themes.ts index fec2e16..1a04837 100644 --- a/admin/src/api/themes.ts +++ b/admin/src/api/themes.ts @@ -34,6 +34,7 @@ export interface ThemeSettings { particleDurationMin?: number particleDurationMax?: number particleInitialDelayMax?: number + particleDensity?: number // Finish Screen Settings finishScreenTitle?: string finishScreenSubtitle?: string @@ -317,6 +318,7 @@ export const DEFAULT_THEME_SETTINGS: ThemeSettings = { particleDurationMin: 7, particleDurationMax: 10, particleInitialDelayMax: 10, + particleDensity: 100, finishScreenTitle: 'Игра завершена!', finishScreenSubtitle: '', finishScreenBgColor: 'rgba(0, 0, 0, 0.5)', diff --git a/admin/src/components/ThemeEditorDialog.tsx b/admin/src/components/ThemeEditorDialog.tsx index 758a18c..1f92965 100644 --- a/admin/src/components/ThemeEditorDialog.tsx +++ b/admin/src/components/ThemeEditorDialog.tsx @@ -615,7 +615,29 @@ export function ThemeEditorDialog({ }} />

- Целевое количество снежинок на экране + Целевое количество снежинок на экране (используется, если плотность не задана) +

+ +
+ + { + const value = parseInt(e.target.value, 10) + if (!isNaN(value) && value > 0) { + updateSetting('particleDensity', value) + } + }} + /> +

+ Плотность частиц (количество на 1Мп площади экрана). Учитывает размер экрана для одинаковой визуальной плотности на разных устройствах

diff --git a/backend/src/admin/themes/dto/create-theme.dto.ts b/backend/src/admin/themes/dto/create-theme.dto.ts index 70cbcc6..aede48a 100644 --- a/backend/src/admin/themes/dto/create-theme.dto.ts +++ b/backend/src/admin/themes/dto/create-theme.dto.ts @@ -112,6 +112,11 @@ export class ThemeSettingsDto { @Type(() => Number) particleInitialDelayMax?: number; + @IsNumber() + @IsOptional() + @Type(() => Number) + particleDensity?: number; + // Finish Screen Settings @IsString() @IsOptional() diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index b118b13..d18e55a 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -1,9 +1,13 @@ -import { Controller, Post, Body } from '@nestjs/common'; +import { Controller, Post, Body, Patch, Headers, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; import { AuthService } from './auth.service'; @Controller('auth') export class AuthController { - constructor(private authService: AuthService) {} + constructor( + private authService: AuthService, + private jwtService: JwtService, + ) {} @Post('anonymous') async createAnonymous(@Body('name') name?: string) { @@ -19,4 +23,34 @@ export class AuthController { async login(@Body() dto: { email: string; password: string }) { return this.authService.login(dto.email, dto.password); } + + @Patch('user') + async updateUserName( + @Headers('authorization') authorization: string, + @Body('name') name: string, + ) { + if (!authorization) { + throw new UnauthorizedException('Authorization header is required'); + } + + const token = authorization.replace('Bearer ', ''); + + try { + const payload = this.jwtService.verify(token); + const userId = payload.sub; + + if (!name || name.trim().length === 0) { + throw new Error('Name cannot be empty'); + } + + if (name.trim().length > 50) { + throw new Error('Name is too long (max 50 characters)'); + } + + const user = await this.authService.updateUserName(userId, name.trim()); + return { user }; + } catch (error) { + throw new UnauthorizedException('Invalid token'); + } + } } diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 7e53a93..ba2afb0 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -46,4 +46,30 @@ export class AuthService { async validateUser(userId: string) { return this.prisma.user.findUnique({ where: { id: userId } }); } + + async updateUserName(userId: string, name: string) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + }); + + if (!user) { + throw new Error('User not found'); + } + + return this.prisma.user.update({ + where: { id: userId }, + data: { name }, + select: { + id: true, + email: true, + name: true, + role: true, + telegramId: true, + createdAt: true, + gamesPlayed: true, + gamesWon: true, + totalPoints: true, + }, + }); + } } diff --git a/backend/src/rooms/rooms.service.ts b/backend/src/rooms/rooms.service.ts index b695f25..868b741 100644 --- a/backend/src/rooms/rooms.service.ts +++ b/backend/src/rooms/rooms.service.ts @@ -153,6 +153,10 @@ export class RoomsService { // Отправляем событие roomUpdate всем клиентам в комнате if (updatedRoom) { + // Небольшая задержка, чтобы дать время новому игроку присоединиться к WebSocket комнате + // WebSocket joinRoom может выполняться параллельно с REST API joinRoom + await new Promise(resolve => setTimeout(resolve, 50)); + this.roomEventsService.emitRoomUpdate(updatedRoom.code, updatedRoom); // Также отправляем gameStateUpdated через broadcastFullState await this.gameGateway.broadcastFullState(updatedRoom.code); @@ -243,6 +247,9 @@ export class RoomsService { // Отправляем событие roomUpdate всем клиентам в комнате if (updatedRoom) { + // Небольшая задержка, чтобы дать время новому игроку присоединиться к WebSocket комнате + await new Promise(resolve => setTimeout(resolve, 50)); + this.roomEventsService.emitRoomUpdate(updatedRoom.code, updatedRoom); // Также отправляем gameStateUpdated через broadcastFullState await this.gameGateway.broadcastFullState(updatedRoom.code); diff --git a/src/components/Snowflakes.jsx b/src/components/Snowflakes.jsx index 5ddbd76..5e185ad 100644 --- a/src/components/Snowflakes.jsx +++ b/src/components/Snowflakes.jsx @@ -6,14 +6,11 @@ const DEFAULT_TARGET_COUNT = 200 const DEFAULT_UPDATE_INTERVAL = 1000 const DEFAULT_DURATION_MIN = 7 const DEFAULT_DURATION_MAX = 10 -const DEFAULT_INITIAL_DELAY_MAX = 10 function createSnowflake(id, options = {}) { const { durationMin = DEFAULT_DURATION_MIN, durationMax = DEFAULT_DURATION_MAX, - initialDelayMax = DEFAULT_INITIAL_DELAY_MAX, - isInitial = false, } = options const durationRange = durationMax - durationMin @@ -21,7 +18,7 @@ function createSnowflake(id, options = {}) { id: id || crypto.randomUUID(), left: Math.random() * 100, duration: Math.random() * durationRange + durationMin, - delay: isInitial ? Math.random() * initialDelayMax : 0, + delay: 0, // Частицы появляются сразу size: Math.random() * 10 + 10, // 10-20px opacity: Math.random() * 0.5 + 0.5, // 0.5-1 createdAt: Date.now(), @@ -31,6 +28,10 @@ function createSnowflake(id, options = {}) { const Snowflakes = ({ roomParticlesEnabled = null }) => { const { currentThemeData } = useTheme() const [snowflakes, setSnowflakes] = useState([]) + const [windowSize, setWindowSize] = useState({ + width: typeof window !== 'undefined' ? window.innerWidth : 1920, + height: typeof window !== 'undefined' ? window.innerHeight : 1080, + }) // Determine if particles should be enabled // Priority: room override (if explicitly set to true/false) > theme setting > default (true) @@ -52,9 +53,29 @@ const Snowflakes = ({ roomParticlesEnabled = null }) => { return currentThemeData?.settings?.particleSymbol || '❄' } + // Get particle density from theme settings + const getParticleDensity = () => { + return currentThemeData?.settings?.particleDensity + } + + // Calculate target count based on density and screen size, or use fixed target count + const calculateTargetCount = () => { + const density = getParticleDensity() + + // If density is set, calculate based on screen area (particles per 1Mp) + if (density !== undefined && density > 0) { + const area = windowSize.width * windowSize.height + const count = Math.round((density * area) / 1000000) + return Math.max(1, count) // Ensure at least 1 particle + } + + // Fallback to fixed target count + return currentThemeData?.settings?.particleTargetCount ?? DEFAULT_TARGET_COUNT + } + // Get particle animation settings from theme with defaults const getParticleTargetCount = () => { - return currentThemeData?.settings?.particleTargetCount ?? DEFAULT_TARGET_COUNT + return calculateTargetCount() } const getParticleUpdateInterval = () => { @@ -68,19 +89,27 @@ const Snowflakes = ({ roomParticlesEnabled = null }) => { } } - const getParticleInitialDelayMax = () => { - return currentThemeData?.settings?.particleInitialDelayMax ?? DEFAULT_INITIAL_DELAY_MAX - } + // Handle window resize to recalculate particle count + useEffect(() => { + const handleResize = () => { + setWindowSize({ + width: window.innerWidth, + height: window.innerHeight, + }) + } + + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, []) const particlesEnabled = getParticlesEnabled() const particleSymbol = getParticleSymbol() const targetCount = getParticleTargetCount() const updateInterval = getParticleUpdateInterval() const durationRange = getParticleDurationRange() - const initialDelayMax = getParticleInitialDelayMax() // Initialize snowflakes only if particles are enabled - // Also re-initialize when theme changes (particle settings might change) + // Also re-initialize when theme changes or window size changes (particle settings might change) useEffect(() => { if (!particlesEnabled) { setSnowflakes([]) @@ -90,12 +119,10 @@ const Snowflakes = ({ roomParticlesEnabled = null }) => { createSnowflake(i, { durationMin: durationRange.min, durationMax: durationRange.max, - initialDelayMax, - isInitial: true, }) ) setSnowflakes(initial) - }, [particlesEnabled, particleSymbol, targetCount, durationRange.min, durationRange.max, initialDelayMax, currentThemeData]) + }, [particlesEnabled, particleSymbol, targetCount, durationRange.min, durationRange.max]) // Update cycle - remove old snowflakes and add new ones useEffect(() => { @@ -121,8 +148,6 @@ const Snowflakes = ({ roomParticlesEnabled = null }) => { createSnowflake(null, { durationMin: durationRange.min, durationMax: durationRange.max, - initialDelayMax, - isInitial: false, }) ) } @@ -132,7 +157,7 @@ const Snowflakes = ({ roomParticlesEnabled = null }) => { }, updateInterval) return () => clearInterval(interval) - }, [particlesEnabled, targetCount, updateInterval, durationRange.min, durationRange.max, initialDelayMax]) + }, [particlesEnabled, targetCount, updateInterval, durationRange.min, durationRange.max]) // Don't render if particles are disabled if (!particlesEnabled) { diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx index ef55547..1568fa4 100644 --- a/src/context/AuthContext.jsx +++ b/src/context/AuthContext.jsx @@ -1,5 +1,6 @@ import React, { createContext, useContext, useState, useEffect } from 'react'; import { authApi } from '../services/api'; +import { getCookie, setCookie, deleteCookie } from '../utils/cookies'; const AuthContext = createContext(); @@ -17,12 +18,37 @@ export const AuthProvider = ({ children }) => { const [loading, setLoading] = useState(true); useEffect(() => { - const storedUser = localStorage.getItem('user'); - const storedToken = localStorage.getItem('token'); + // Миграция с localStorage на куки (обратная совместимость) + const storedUserLS = localStorage.getItem('user'); + const storedTokenLS = localStorage.getItem('token'); + + if (storedUserLS && storedTokenLS) { + // Мигрируем в куки + const userData = JSON.parse(storedUserLS); + setCookie('player-id', userData.id); + setCookie('player-name', userData.name); + setCookie('player-token', storedTokenLS); + + setUser(userData); + setToken(storedTokenLS); + + // Очищаем localStorage (но оставляем app-theme и room-password-*) + localStorage.removeItem('user'); + localStorage.removeItem('token'); + } else { + // Загружаем из куков + const storedUserId = getCookie('player-id'); + const storedUserName = getCookie('player-name'); + const storedToken = getCookie('player-token'); - if (storedUser && storedToken) { - setUser(JSON.parse(storedUser)); - setToken(storedToken); + if (storedUserId && storedUserName && storedToken) { + const userData = { + id: storedUserId, + name: storedUserName, + }; + setUser(userData); + setToken(storedToken); + } } setLoading(false); @@ -36,8 +62,9 @@ export const AuthProvider = ({ children }) => { setUser(newUser); setToken(newToken); - localStorage.setItem('user', JSON.stringify(newUser)); - localStorage.setItem('token', newToken); + setCookie('player-id', newUser.id); + setCookie('player-name', newUser.name); + setCookie('player-token', newToken); return { user: newUser, token: newToken }; } catch (error) { @@ -54,8 +81,9 @@ export const AuthProvider = ({ children }) => { setUser(newUser); setToken(newToken); - localStorage.setItem('user', JSON.stringify(newUser)); - localStorage.setItem('token', newToken); + setCookie('player-id', newUser.id); + setCookie('player-name', newUser.name); + setCookie('player-token', newToken); return { user: newUser, token: newToken }; } catch (error) { @@ -72,8 +100,9 @@ export const AuthProvider = ({ children }) => { setUser(newUser); setToken(newToken); - localStorage.setItem('user', JSON.stringify(newUser)); - localStorage.setItem('token', newToken); + setCookie('player-id', newUser.id); + setCookie('player-name', newUser.name); + setCookie('player-token', newToken); return { user: newUser, token: newToken }; } catch (error) { @@ -85,8 +114,29 @@ export const AuthProvider = ({ children }) => { const logout = () => { setUser(null); setToken(null); - localStorage.removeItem('user'); - localStorage.removeItem('token'); + deleteCookie('player-id'); + deleteCookie('player-name'); + deleteCookie('player-token'); + }; + + const updateUserName = async (name) => { + if (!token || !user) { + throw new Error('Not authenticated'); + } + + try { + const response = await authApi.updateName(token, name); + const { user: updatedUser } = response.data; + + setUser(updatedUser); + setCookie('player-id', updatedUser.id); + setCookie('player-name', updatedUser.name); + + return updatedUser; + } catch (error) { + console.error('Update name error:', error); + throw error; + } }; const value = { @@ -97,6 +147,7 @@ export const AuthProvider = ({ children }) => { register, login, logout, + updateUserName, isAuthenticated: !!user, }; diff --git a/src/hooks/useRoom.js b/src/hooks/useRoom.js index 16488f0..afcf926 100644 --- a/src/hooks/useRoom.js +++ b/src/hooks/useRoom.js @@ -20,12 +20,27 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => { const fetchRoom = async () => { try { setLoading(true); - const response = await roomsApi.getByCode(roomCode, password, user?.id); + + // Проверяем, есть ли сохраненный пароль для этой комнаты + let roomPassword = password; + if (!roomPassword) { + const savedPassword = localStorage.getItem(`room-password-${roomCode}`); + if (savedPassword) { + roomPassword = savedPassword; + } + } + + const response = await roomsApi.getByCode(roomCode, roomPassword, user?.id); setRoom(response.data); setParticipants(response.data.participants || []); setError(null); setRequiresPassword(false); + // Сохраняем пароль, если вход успешен + if (roomPassword) { + localStorage.setItem(`room-password-${roomCode}`, roomPassword); + } + // ✅ Подключаться к WebSocket только после успешной загрузки комнаты socketService.connect(); socketService.joinRoom(roomCode, user?.id); @@ -34,6 +49,8 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => { if (err.response?.status === 401) { setRequiresPassword(true); setError('Room password required'); + // Удаляем неверный сохраненный пароль + localStorage.removeItem(`room-password-${roomCode}`); // ❌ НЕ подключаться к WebSocket, если требуется пароль } else { setError(err.response?.data?.message || err.message); @@ -186,6 +203,9 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => { setError(null); setRequiresPassword(false); + // Сохраняем пароль в localStorage при успешном входе + localStorage.setItem(`room-password-${roomCode}`, roomPassword); + // ✅ После успешной загрузки комнаты с паролем подключаемся к WebSocket socketService.connect(); socketService.joinRoom(roomCode, user?.id); @@ -195,6 +215,8 @@ export const useRoom = (roomCode, onGameStarted = null, password = null) => { if (err.response?.status === 401) { setRequiresPassword(true); setError('Incorrect password'); + // Удаляем неверный пароль + localStorage.removeItem(`room-password-${roomCode}`); } else { setError(err.response?.data?.message || err.message); } diff --git a/src/index.css b/src/index.css index c4cc511..880fd79 100644 --- a/src/index.css +++ b/src/index.css @@ -186,6 +186,48 @@ body { font-size: 1.2rem; } +.name-editable { + color: gold; + cursor: pointer; + position: relative; + padding: 0 0.25rem; + border-radius: 4px; + transition: all 0.3s ease; + display: inline-block; +} + +.name-editable:hover { + background: rgba(255, 215, 0, 0.1); + text-shadow: 0 0 8px rgba(255, 215, 0, 0.5); +} + +.name-input-inline { + background: rgba(255, 215, 0, 0.15); + border: 2px solid rgba(255, 215, 0, 0.5); + border-radius: 6px; + padding: 0.2rem 0.5rem; + color: gold; + font-size: 1.2rem; + font-weight: inherit; + font-family: inherit; + text-align: center; + min-width: 100px; + max-width: 300px; + outline: none; + transition: all 0.3s ease; +} + +.name-input-inline:focus { + border-color: gold; + background: rgba(255, 215, 0, 0.2); + box-shadow: 0 0 10px rgba(255, 215, 0, 0.4); +} + +.name-input-inline:disabled { + opacity: 0.6; + cursor: not-allowed; +} + .menu-buttons { display: flex; flex-direction: column; diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx index 66a67ac..2a08498 100644 --- a/src/pages/Home.jsx +++ b/src/pages/Home.jsx @@ -1,11 +1,14 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import ThemeSwitcher from '../components/ThemeSwitcher'; const Home = () => { const navigate = useNavigate(); - const { user, loginAnonymous, isAuthenticated } = useAuth(); + const { user, loginAnonymous, isAuthenticated, updateUserName } = useAuth(); + const [editingName, setEditingName] = useState(false); + const [nameValue, setNameValue] = useState(''); + const [isUpdating, setIsUpdating] = useState(false); useEffect(() => { const initAuth = async () => { @@ -21,6 +24,44 @@ const Home = () => { initAuth(); }, [isAuthenticated, loginAnonymous]); + useEffect(() => { + if (user) { + setNameValue(user.name || ''); + } + }, [user]); + + const handleNameClick = () => { + setEditingName(true); + }; + + const handleNameChange = (e) => { + setNameValue(e.target.value); + }; + + const handleNameBlur = async () => { + setEditingName(false); + + if (nameValue.trim() && nameValue.trim() !== user?.name) { + setIsUpdating(true); + try { + await updateUserName(nameValue.trim()); + } catch (error) { + console.error('Failed to update name:', error); + setNameValue(user?.name || ''); + } finally { + setIsUpdating(false); + } + } else { + setNameValue(user?.name || ''); + } + }; + + const handleNameKeyPress = (e) => { + if (e.key === 'Enter') { + e.target.blur(); + } + }; + const handleCreateRoom = () => { navigate('/create-room'); }; @@ -36,9 +77,37 @@ const Home = () => {

100 к 1

-

- {user ? `Привет, ${user.name}!` : 'Добро пожаловать!'} -

+
+ {user ? ( + <> + Привет,{' '} + {editingName ? ( + + ) : ( + + {user.name} + + )} + ! + + ) : ( + 'Добро пожаловать!' + )} +