fixes and cookies

This commit is contained in:
Dmitry 2026-01-11 01:17:30 +03:00
parent 35c5fb8cd8
commit 403ea8ac24
12 changed files with 347 additions and 38 deletions

View file

@ -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)',

View file

@ -615,7 +615,29 @@ export function ThemeEditorDialog({
}}
/>
<p className="text-xs text-muted-foreground">
Целевое количество снежинок на экране
Целевое количество снежинок на экране (используется, если плотность не задана)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="particleDensity">
Density (Плотность, частиц/Мп)
</Label>
<Input
id="particleDensity"
type="number"
min="1"
max="500"
step="1"
value={settings.particleDensity ?? DEFAULT_THEME_SETTINGS.particleDensity ?? 100}
onChange={(e) => {
const value = parseInt(e.target.value, 10)
if (!isNaN(value) && value > 0) {
updateSetting('particleDensity', value)
}
}}
/>
<p className="text-xs text-muted-foreground">
Плотность частиц (количество на 1Мп площади экрана). Учитывает размер экрана для одинаковой визуальной плотности на разных устройствах
</p>
</div>
<div className="space-y-2">

View file

@ -112,6 +112,11 @@ export class ThemeSettingsDto {
@Type(() => Number)
particleInitialDelayMax?: number;
@IsNumber()
@IsOptional()
@Type(() => Number)
particleDensity?: number;
// Finish Screen Settings
@IsString()
@IsOptional()

View file

@ -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');
}
}
}

View file

@ -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,
},
});
}
}

View file

@ -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);

View file

@ -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) {

View file

@ -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,
};

View file

@ -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);
}

View file

@ -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;

View file

@ -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 = () => {
</div>
<div className="home-container">
<h1>100 к 1</h1>
<p className="welcome-text">
{user ? `Привет, ${user.name}!` : 'Добро пожаловать!'}
</p>
<div className="welcome-text">
{user ? (
<>
Привет,{' '}
{editingName ? (
<input
type="text"
value={nameValue}
onChange={handleNameChange}
onBlur={handleNameBlur}
onKeyPress={handleNameKeyPress}
className="name-input-inline"
autoFocus
maxLength={50}
disabled={isUpdating}
/>
) : (
<span
className="name-editable"
onClick={handleNameClick}
title="Нажмите, чтобы изменить имя"
>
{user.name}
</span>
)}
!
</>
) : (
'Добро пожаловать!'
)}
</div>
<div className="menu-buttons">
<button onClick={handleCreateRoom} className="menu-button primary">

View file

@ -12,6 +12,10 @@ export const authApi = {
createAnonymous: (name) => api.post('/auth/anonymous', { name }),
register: (email, password, name) => api.post('/auth/register', { email, password, name }),
login: (email, password) => api.post('/auth/login', { email, password }),
updateName: (token, name) =>
api.patch('/auth/user', { name }, {
headers: { Authorization: `Bearer ${token}` }
}),
};
// Rooms endpoints