diff --git a/admin/src/api/themes.ts b/admin/src/api/themes.ts index aab4eb6..ae9fa36 100644 --- a/admin/src/api/themes.ts +++ b/admin/src/api/themes.ts @@ -30,6 +30,8 @@ export interface ThemeSettings { export interface Theme { id: string name: string + icon?: string | null + description?: string | null isPublic: boolean colors: ThemeColors settings: ThemeSettings @@ -45,6 +47,8 @@ export interface Theme { export interface ThemePreview { id: string name: string + icon?: string | null + description?: string | null isPublic: boolean colors: ThemeColors settings: ThemeSettings @@ -57,6 +61,8 @@ export interface ThemePreview { export interface CreateThemeDto { name: string + icon?: string + description?: string isPublic?: boolean colors: ThemeColors settings: ThemeSettings @@ -64,6 +70,8 @@ export interface CreateThemeDto { export interface UpdateThemeDto { name?: string + icon?: string + description?: string isPublic?: boolean colors?: ThemeColors settings?: ThemeSettings diff --git a/admin/src/components/ThemeEditorDialog.tsx b/admin/src/components/ThemeEditorDialog.tsx index 07a87e6..755171b 100644 --- a/admin/src/components/ThemeEditorDialog.tsx +++ b/admin/src/components/ThemeEditorDialog.tsx @@ -25,6 +25,8 @@ interface ThemeEditorDialogProps { theme: ThemePreview | null onSave: (data: { name: string + icon?: string + description?: string isPublic: boolean colors: ThemeColors settings: ThemeSettings @@ -40,21 +42,107 @@ interface ColorFieldProps { description?: string } +// Extract hex color from rgba string +function extractHexFromRgba(rgba: string): string | null { + const match = rgba.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/) + if (match) { + const r = parseInt(match[1], 10).toString(16).padStart(2, '0') + const g = parseInt(match[2], 10).toString(16).padStart(2, '0') + const b = parseInt(match[3], 10).toString(16).padStart(2, '0') + return `#${r}${g}${b}`.toUpperCase() + } + return null +} + +// Extract first hex color from gradient +function extractHexFromGradient(gradient: string): string | null { + const hexMatch = gradient.match(/#[0-9a-fA-F]{6}|#[0-9a-fA-F]{3}/) + if (hexMatch) { + let hex = hexMatch[0] + // Convert 3-digit hex to 6-digit + if (hex.length === 4) { + hex = `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` + } + return hex.toUpperCase() + } + return null +} + +// Get hex color for color picker +function getHexForPicker(value: string): string { + if (value.startsWith('#')) { + // Ensure 6-digit hex for color picker + if (value.length === 4) { + const r = value[1] + const g = value[2] + const b = value[3] + return `#${r}${r}${g}${g}${b}${b}`.toUpperCase() + } + if (value.length === 7) { + return value.toUpperCase() + } + } + + if (value.includes('rgba') || value.includes('rgb')) { + const hex = extractHexFromRgba(value) + if (hex) return hex + } + + if (value.includes('gradient')) { + const hex = extractHexFromGradient(value) + if (hex) return hex + } + + return '#000000' +} + function ColorField({ label, value, onChange, description }: ColorFieldProps) { - const isGradient = value.includes('gradient') || value.includes('rgba') + const hexForPicker = getHexForPicker(value) + + const handleColorPickerChange = (hex: string) => { + if (value.includes('gradient')) { + // Replace first hex color in gradient, handle both 3 and 6 digit hex + // Match first occurrence of hex color (either 3 or 6 digit) + const hexMatch = value.match(/#[0-9a-fA-F]{3}(?![0-9a-fA-F])|#[0-9a-fA-F]{6}/) + if (hexMatch) { + const updated = value.replace(hexMatch[0], hex) + onChange(updated) + } else { + // If no hex found, just use the hex directly (shouldn't happen normally) + onChange(hex) + } + } else if (value.includes('rgba')) { + // Convert hex to rgba, preserve alpha + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + // Extract alpha from rgba string + const alphaMatch = value.match(/rgba\([^)]+,\s*([^)]+)\)/) + const alpha = alphaMatch ? alphaMatch[1] : '1' + onChange(`rgba(${r}, ${g}, ${b}, ${alpha})`) + } else if (value.match(/^rgb\(/)) { + // Convert hex to rgb + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + onChange(`rgb(${r}, ${g}, ${b})`) + } else { + // Direct hex color + onChange(hex) + } + } return (
- {!isGradient && ( - onChange(e.target.value)} - className="w-10 h-10 rounded border cursor-pointer" - /> - )} + handleColorPickerChange(e.target.value)} + className="w-10 h-10 rounded border cursor-pointer flex-shrink-0" + title="Pick color" + /> onChange(e.target.value)} @@ -77,19 +165,25 @@ export function ThemeEditorDialog({ isSaving = false, }: ThemeEditorDialogProps) { const [name, setName] = useState('') - const [isPublic, setIsPublic] = useState(false) + const [icon, setIcon] = useState('') + const [description, setDescription] = useState('') + const [isPublic, setIsPublic] = useState(true) const [colors, setColors] = useState(DEFAULT_THEME_COLORS) const [settings, setSettings] = useState(DEFAULT_THEME_SETTINGS) useEffect(() => { if (theme) { setName(theme.name) + setIcon(theme.icon || '') + setDescription(theme.description || '') setIsPublic(theme.isPublic) setColors(theme.colors) setSettings(theme.settings) } else { setName('') - setIsPublic(false) + setIcon('') + setDescription('') + setIsPublic(true) setColors(DEFAULT_THEME_COLORS) setSettings(DEFAULT_THEME_SETTINGS) } @@ -102,6 +196,8 @@ export function ThemeEditorDialog({ } onSave({ name: name.trim(), + icon: icon.trim() || undefined, + description: description.trim() || undefined, isPublic, colors, settings, @@ -129,18 +225,39 @@ export function ThemeEditorDialog({
{/* Basic Info */} -
+
+
+
+ + setName(e.target.value)} + placeholder="My Custom Theme" + required + /> +
+
+ + setIcon(e.target.value)} + placeholder="🎨" + maxLength={2} + /> +
+
- + setName(e.target.value)} - placeholder="My Custom Theme" - required + id="description" + value={description} + onChange={(e) => setDescription(e.target.value)} + placeholder="Theme description for users" />
-
+
+ {/* Preview - Always visible above tabs */} +
+ +
+
+

+ Theme Preview +

+

+ This is primary text +

+

+ This is secondary text +

+
+ +
+
+
+ Card Example +
+
+ Card content here +
+
+ +
+
+ Active Card +
+
+ Hover state +
+
+
+ +
+ + +
+
+
+ {/* Tabs for Colors and Settings */} - + Colors Settings - Preview @@ -164,63 +382,73 @@ export function ThemeEditorDialog({ label="Background Primary" value={colors.bgPrimary} onChange={(v) => updateColor('bgPrimary', v)} - description="Main background (gradient or solid)" + description="Основной фон темы. Поддерживает градиенты (linear-gradient) и сплошные цвета (hex, rgb, rgba)" /> updateColor('bgOverlay', v)} - description="Overlay color (rgba recommended)" + description="Цвет наложения поверх основного фона. Рекомендуется использовать rgba для прозрачности" /> updateColor('bgCard', v)} + description="Фон карточек и контейнеров. Обычно используется rgba с прозрачностью для эффекта стекла" /> updateColor('bgCardHover', v)} + description="Фон карточки при наведении. Обычно более светлый вариант Card Background" /> updateColor('textPrimary', v)} + description="Основной цвет текста. Используется для основного содержимого и заголовков" /> updateColor('textSecondary', v)} + description="Вторичный цвет текста. Используется для второстепенного контента, обычно с прозрачностью" /> updateColor('textGlow', v)} + description="Цвет свечения текста. Используется для эффекта textShadow, добавляет объемность" /> updateColor('accentPrimary', v)} + description="Основной акцентный цвет. Используется для кнопок, активных элементов и выделения" /> updateColor('accentSecondary', v)} + description="Вторичный акцентный цвет. Используется для дополнительных акцентов и состояний hover" /> updateColor('accentSuccess', v)} + description="Цвет успеха. Используется для успешных действий, положительных статусов" /> updateColor('borderColor', v)} + description="Цвет границ элементов. Обычно используется rgba с небольшой прозрачностью" /> updateColor('borderGlow', v)} + description="Цвет свечения границ. Используется для активных состояний, создает эффект свечения" />
@@ -234,6 +462,9 @@ export function ThemeEditorDialog({ onChange={(e) => updateSetting('shadowSm', e.target.value)} placeholder="0 1px 2px rgba(0, 0, 0, 0.1)" /> +

+ Малая тень для небольших элементов. Формат: offset-x offset-y blur-radius color +

@@ -242,6 +473,9 @@ export function ThemeEditorDialog({ onChange={(e) => updateSetting('shadowMd', e.target.value)} placeholder="0 4px 6px rgba(0, 0, 0, 0.1)" /> +

+ Средняя тень для карточек и контейнеров. Формат: offset-x offset-y blur-radius color +

@@ -250,6 +484,9 @@ export function ThemeEditorDialog({ onChange={(e) => updateSetting('shadowLg', e.target.value)} placeholder="0 10px 15px rgba(0, 0, 0, 0.2)" /> +

+ Большая тень для модальных окон и важных элементов. Формат: offset-x offset-y blur-radius color +

@@ -258,6 +495,9 @@ export function ThemeEditorDialog({ onChange={(e) => updateSetting('blurAmount', e.target.value)} placeholder="10px" /> +

+ Интенсивность размытия для backdrop-filter. Используется для эффекта стекла (glassmorphism) +

@@ -266,6 +506,9 @@ export function ThemeEditorDialog({ onChange={(e) => updateSetting('borderRadiusSm', e.target.value)} placeholder="4px" /> +

+ Маленький радиус скругления для кнопок и маленьких элементов +

@@ -274,6 +517,9 @@ export function ThemeEditorDialog({ onChange={(e) => updateSetting('borderRadiusMd', e.target.value)} placeholder="8px" /> +

+ Средний радиус скругления для карточек и контейнеров +

@@ -282,6 +528,9 @@ export function ThemeEditorDialog({ onChange={(e) => updateSetting('borderRadiusLg', e.target.value)} placeholder="12px" /> +

+ Большой радиус скругления для модальных окон и крупных элементов +

@@ -290,106 +539,9 @@ export function ThemeEditorDialog({ onChange={(e) => updateSetting('animationSpeed', e.target.value)} placeholder="0.3s" /> -
-
- - - -
-
-

- Theme Preview -

-

- This is primary text +

+ Скорость анимаций переходов. Формат: время (например, 0.3s, 300ms)

-

- This is secondary text -

-
- -
-
-
- Card Example -
-
- Card content here -
-
- -
-
- Active Card -
-
- Hover state -
-
-
- -
- -
diff --git a/admin/src/pages/ThemesPage.tsx b/admin/src/pages/ThemesPage.tsx index 4f5dfea..da612c7 100644 --- a/admin/src/pages/ThemesPage.tsx +++ b/admin/src/pages/ThemesPage.tsx @@ -124,6 +124,8 @@ export default function ThemesPage() { const handleSave = (formData: { name: string + icon?: string + description?: string isPublic: boolean colors: ThemeColors settings: ThemeSettings diff --git a/backend/package.json b/backend/package.json index 9609dee..8e39967 100644 --- a/backend/package.json +++ b/backend/package.json @@ -45,6 +45,9 @@ "rxjs": "^7.8.1", "socket.io": "^4.8.3" }, + "prisma": { + "seed": "ts-node prisma/seed.ts" + }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", "@eslint/js": "^9.18.0", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5cbc4fa..0b06c74 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -187,6 +187,8 @@ enum CodeStatus { model Theme { id String @id @default(uuid()) name String + icon String? + description String? isPublic Boolean @default(false) createdBy String createdAt DateTime @default(now()) diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index ab32d6a..c5776b1 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -220,6 +220,159 @@ async function main() { console.log('Default pack created:', defaultPack); + // Create default themes + const defaultThemes = [ + { + id: 'new-year', + name: 'Новый год', + icon: '🎄', + description: 'Праздничная новогодняя тема с золотым свечением', + isPublic: true, + colors: { + bgPrimary: 'linear-gradient(135deg, #1a4d7a 0%, #2b0d4f 50%, #4a1942 100%)', + bgOverlay: 'rgba(10, 10, 30, 0.4)', + bgCard: 'rgba(255, 255, 255, 0.12)', + bgCardHover: 'rgba(255, 255, 255, 0.18)', + textPrimary: '#ffffff', + textSecondary: 'rgba(255, 255, 255, 0.95)', + textGlow: 'rgba(255, 215, 0, 1)', + accentPrimary: '#ffd700', + accentSecondary: '#ff6b6b', + accentSuccess: '#4ecdc4', + borderColor: 'rgba(255, 215, 0, 0.4)', + borderGlow: 'rgba(255, 215, 0, 0.6)', + }, + settings: { + shadowSm: '0 2px 15px rgba(255, 215, 0, 0.2)', + shadowMd: '0 4px 20px rgba(255, 215, 0, 0.3)', + shadowLg: '0 8px 35px rgba(255, 215, 0, 0.4)', + blurAmount: '10px', + borderRadiusSm: '12px', + borderRadiusMd: '15px', + borderRadiusLg: '20px', + animationSpeed: '0.3s', + }, + }, + { + id: 'family', + name: 'Семейная', + icon: '🏠', + description: 'Светлая и уютная тема для семейной игры', + isPublic: true, + colors: { + bgPrimary: 'linear-gradient(135deg, #56ccf2 0%, #2f80ed 50%, #b2fefa 100%)', + bgOverlay: 'rgba(255, 255, 255, 0.1)', + bgCard: 'rgba(255, 255, 255, 0.25)', + bgCardHover: 'rgba(255, 255, 255, 0.35)', + textPrimary: '#2d3748', + textSecondary: '#4a5568', + textGlow: 'rgba(47, 128, 237, 0.8)', + accentPrimary: '#2f80ed', + accentSecondary: '#eb5757', + accentSuccess: '#27ae60', + borderColor: 'rgba(47, 128, 237, 0.3)', + borderGlow: 'rgba(47, 128, 237, 0.5)', + }, + settings: { + shadowSm: '0 2px 10px rgba(47, 128, 237, 0.15)', + shadowMd: '0 4px 15px rgba(47, 128, 237, 0.2)', + shadowLg: '0 8px 30px rgba(47, 128, 237, 0.25)', + blurAmount: '10px', + borderRadiusSm: '12px', + borderRadiusMd: '15px', + borderRadiusLg: '20px', + animationSpeed: '0.3s', + }, + }, + { + id: 'party', + name: 'Вечеринка', + icon: '🎉', + description: 'Яркая энергичная тема для шумных компаний', + isPublic: true, + colors: { + bgPrimary: 'linear-gradient(135deg, #f093fb 0%, #f5576c 50%, #4facfe 100%)', + bgOverlay: 'rgba(0, 0, 0, 0.2)', + bgCard: 'rgba(255, 255, 255, 0.15)', + bgCardHover: 'rgba(255, 255, 255, 0.25)', + textPrimary: '#ffffff', + textSecondary: 'rgba(255, 255, 255, 0.95)', + textGlow: 'rgba(255, 87, 108, 1)', + accentPrimary: '#f5576c', + accentSecondary: '#f093fb', + accentSuccess: '#4facfe', + borderColor: 'rgba(255, 87, 108, 0.5)', + borderGlow: 'rgba(255, 87, 108, 0.7)', + }, + settings: { + shadowSm: '0 2px 15px rgba(245, 87, 108, 0.3)', + shadowMd: '0 4px 20px rgba(245, 87, 108, 0.4)', + shadowLg: '0 8px 35px rgba(245, 87, 108, 0.5)', + blurAmount: '10px', + borderRadiusSm: '12px', + borderRadiusMd: '15px', + borderRadiusLg: '20px', + animationSpeed: '0.2s', + }, + }, + { + id: 'dark', + name: 'Темная', + icon: '🌙', + description: 'Контрастная тема для ТВ и проектора', + isPublic: true, + colors: { + bgPrimary: 'linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%)', + bgOverlay: 'rgba(0, 0, 0, 0.7)', + bgCard: 'rgba(40, 40, 40, 0.8)', + bgCardHover: 'rgba(60, 60, 60, 0.9)', + textPrimary: '#e0e0e0', + textSecondary: '#b0b0b0', + textGlow: 'rgba(100, 255, 218, 0.8)', + accentPrimary: '#64ffda', + accentSecondary: '#ff5370', + accentSuccess: '#c3e88d', + borderColor: 'rgba(100, 255, 218, 0.3)', + borderGlow: 'rgba(100, 255, 218, 0.5)', + }, + settings: { + shadowSm: '0 2px 10px rgba(0, 0, 0, 0.5)', + shadowMd: '0 4px 15px rgba(0, 0, 0, 0.6)', + shadowLg: '0 8px 30px rgba(0, 0, 0, 0.7)', + blurAmount: '5px', + borderRadiusSm: '12px', + borderRadiusMd: '15px', + borderRadiusLg: '20px', + animationSpeed: '0.3s', + }, + }, + ]; + + for (const theme of defaultThemes) { + await prisma.theme.upsert({ + where: { id: theme.id }, + update: { + name: theme.name, + icon: theme.icon, + description: theme.description, + isPublic: theme.isPublic, + colors: theme.colors as any, + settings: theme.settings as any, + }, + create: { + id: theme.id, + name: theme.name, + icon: theme.icon, + description: theme.description, + isPublic: theme.isPublic, + createdBy: demoUser.id, + colors: theme.colors as any, + settings: theme.settings as any, + }, + }); + console.log(`Theme "${theme.name}" created/updated`); + } + console.log('Seed completed successfully!'); } diff --git a/backend/src/admin/themes/admin-themes.service.ts b/backend/src/admin/themes/admin-themes.service.ts index 314c45f..4452daa 100644 --- a/backend/src/admin/themes/admin-themes.service.ts +++ b/backend/src/admin/themes/admin-themes.service.ts @@ -31,6 +31,8 @@ export class AdminThemesService { select: { id: true, name: true, + icon: true, + description: true, isPublic: true, colors: true, settings: true, @@ -83,7 +85,9 @@ export class AdminThemesService { return this.prisma.theme.create({ data: { name: createThemeDto.name, - isPublic: createThemeDto.isPublic, + icon: createThemeDto.icon, + description: createThemeDto.description, + isPublic: createThemeDto.isPublic ?? true, createdBy, colors: JSON.parse(JSON.stringify(createThemeDto.colors)) as Prisma.InputJsonValue, settings: JSON.parse(JSON.stringify(createThemeDto.settings)) as Prisma.InputJsonValue, @@ -111,6 +115,8 @@ export class AdminThemesService { const updateData: Prisma.ThemeUpdateInput = { ...(updateThemeDto.name !== undefined && { name: updateThemeDto.name }), + ...(updateThemeDto.icon !== undefined && { icon: updateThemeDto.icon }), + ...(updateThemeDto.description !== undefined && { description: updateThemeDto.description }), ...(updateThemeDto.isPublic !== undefined && { isPublic: updateThemeDto.isPublic, }), @@ -163,6 +169,8 @@ export class AdminThemesService { select: { id: true, name: true, + icon: true, + description: true, colors: true, settings: true, }, diff --git a/backend/src/admin/themes/dto/create-theme.dto.ts b/backend/src/admin/themes/dto/create-theme.dto.ts index c5049c4..21abf9e 100644 --- a/backend/src/admin/themes/dto/create-theme.dto.ts +++ b/backend/src/admin/themes/dto/create-theme.dto.ts @@ -75,6 +75,14 @@ export class CreateThemeDto { @IsString() name: string; + @IsString() + @IsOptional() + icon?: string; + + @IsString() + @IsOptional() + description?: string; + @IsBoolean() @IsOptional() isPublic?: boolean; diff --git a/backend/src/themes/themes.controller.ts b/backend/src/themes/themes.controller.ts index f679330..ae6ce76 100644 --- a/backend/src/themes/themes.controller.ts +++ b/backend/src/themes/themes.controller.ts @@ -18,6 +18,8 @@ export class ThemesController { select: { id: true, name: true, + icon: true, + description: true, colors: true, settings: true, }, @@ -32,6 +34,8 @@ export class ThemesController { select: { id: true, name: true, + icon: true, + description: true, isPublic: true, colors: true, settings: true, diff --git a/src/components/NameInputModal.jsx b/src/components/NameInputModal.jsx index 4fb18c5..133fec6 100644 --- a/src/components/NameInputModal.jsx +++ b/src/components/NameInputModal.jsx @@ -1,7 +1,13 @@ import React, { useState, useEffect } from 'react'; import './NameInputModal.css'; -const NameInputModal = ({ isOpen, onSubmit, onCancel }) => { +const NameInputModal = ({ + isOpen, + onSubmit, + onCancel, + title = 'Введите ваше имя', + description = 'Чтобы присоединиться к комнате, введите ваше имя' +}) => { const [name, setName] = useState(''); const [error, setError] = useState(''); @@ -43,7 +49,7 @@ const NameInputModal = ({ isOpen, onSubmit, onCancel }) => {
-

Введите ваше имя

+

{title}

{onCancel && ( {isOpen && ( @@ -33,9 +37,9 @@ const ThemeSwitcher = () => { className={`theme-option ${currentTheme === theme.id ? 'active' : ''}`} onClick={() => handleThemeChange(theme.id)} > - {theme.icon} + {theme.icon || '🎨'} {theme.name} - {theme.description} + {theme.description || ''} ))}
diff --git a/src/context/ThemeContext.jsx b/src/context/ThemeContext.jsx index b7ed892..404675e 100644 --- a/src/context/ThemeContext.jsx +++ b/src/context/ThemeContext.jsx @@ -2,41 +2,6 @@ import React, { createContext, useContext, useState, useEffect } from 'react'; const ThemeContext = createContext(); -// Built-in themes -export const BUILT_IN_THEMES = { - 'new-year': { - id: 'new-year', - name: 'Новый год', - icon: '🎄', - description: 'Праздничная новогодняя тема с золотым свечением', - isBuiltIn: true, - }, - family: { - id: 'family', - name: 'Семейная', - icon: '🏠', - description: 'Светлая и уютная тема для семейной игры', - isBuiltIn: true, - }, - party: { - id: 'party', - name: 'Вечеринка', - icon: '🎉', - description: 'Яркая энергичная тема для шумных компаний', - isBuiltIn: true, - }, - dark: { - id: 'dark', - name: 'Темная', - icon: '🌙', - description: 'Контрастная тема для ТВ и проектора', - isBuiltIn: true, - }, -}; - -// For backwards compatibility -export const themes = BUILT_IN_THEMES; - // Helper to convert camelCase to kebab-case const camelToKebab = (str) => str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase(); @@ -49,105 +14,99 @@ export const useTheme = () => { }; export const ThemeProvider = ({ children }) => { + const [themes, setThemes] = useState([]); + const [loading, setLoading] = useState(true); const [currentTheme, setCurrentTheme] = useState(() => { const saved = localStorage.getItem('app-theme'); - return saved && BUILT_IN_THEMES[saved] ? saved : 'new-year'; + return saved || null; }); - const [customThemes, setCustomThemes] = useState([]); - const [loading, setLoading] = useState(true); - // Load custom themes from API + // Load themes from API useEffect(() => { - const loadCustomThemes = async () => { + const loadThemes = async () => { try { const response = await fetch('/api/themes'); if (response.ok) { const data = await response.json(); - setCustomThemes(data); + setThemes(data); + + // Set default theme if no theme is selected or current theme is not found + setCurrentTheme((prevTheme) => { + if (!prevTheme || !data.find((t) => t.id === prevTheme)) { + const defaultTheme = data.find((t) => t.id === 'new-year') || data[0]; + if (defaultTheme) { + localStorage.setItem('app-theme', defaultTheme.id); + return defaultTheme.id; + } + } + return prevTheme; + }); } } catch (error) { - console.error('Failed to load custom themes:', error); + console.error('Failed to load themes:', error); } finally { setLoading(false); } }; - loadCustomThemes(); + loadThemes(); }, []); - // Apply theme CSS variables - const applyTheme = (themeId) => { + // Apply theme when currentTheme or themes change + useEffect(() => { + if (!currentTheme || themes.length === 0) return; + + const theme = themes.find((t) => t.id === currentTheme); + if (!theme) return; + const root = document.documentElement; - // Check if it's a built-in theme - if (BUILT_IN_THEMES[themeId]) { - root.setAttribute('data-theme', themeId); - // Reset any custom CSS variables - root.style.removeProperty('--text-primary'); - root.style.removeProperty('--text-secondary'); - root.style.removeProperty('--accent-primary'); - root.style.removeProperty('--accent-secondary'); - root.style.removeProperty('--bg-primary'); - root.style.removeProperty('--bg-card'); - root.style.removeProperty('--bg-card-hover'); - root.style.removeProperty('--border-color'); - root.style.removeProperty('--border-glow'); - return; + // Remove data-theme attribute (for built-in CSS themes) + root.removeAttribute('data-theme'); + + // Apply theme colors + if (theme.colors) { + Object.entries(theme.colors).forEach(([key, value]) => { + root.style.setProperty(`--${camelToKebab(key)}`, value); + }); } - // Check if it's a custom theme - const customTheme = customThemes.find((t) => t.id === themeId); - if (customTheme) { - root.removeAttribute('data-theme'); - - // Apply custom theme colors - if (customTheme.colors) { - Object.entries(customTheme.colors).forEach(([key, value]) => { - root.style.setProperty(`--${camelToKebab(key)}`, value); - }); - } - - // Apply custom theme settings - if (customTheme.settings) { - Object.entries(customTheme.settings).forEach(([key, value]) => { - root.style.setProperty(`--${camelToKebab(key)}`, value); - }); - } + // Apply theme settings + if (theme.settings) { + Object.entries(theme.settings).forEach(([key, value]) => { + root.style.setProperty(`--${camelToKebab(key)}`, value); + }); } - }; - // Apply theme when currentTheme changes - useEffect(() => { - applyTheme(currentTheme); localStorage.setItem('app-theme', currentTheme); - }, [currentTheme, customThemes]); + }, [currentTheme, themes]); const changeTheme = (themeId) => { - // Check if it's a built-in or custom theme - if (BUILT_IN_THEMES[themeId] || customThemes.find((t) => t.id === themeId)) { + if (themes.find((t) => t.id === themeId)) { setCurrentTheme(themeId); } }; - // Combine built-in and custom themes for display - const allThemes = { - ...BUILT_IN_THEMES, - ...customThemes.reduce((acc, theme) => { - acc[theme.id] = { - ...theme, - icon: '🎨', - isBuiltIn: false, - }; - return acc; - }, {}), - }; + // Convert themes array to object for easier access + const themesObject = themes.reduce((acc, theme) => { + acc[theme.id] = { + ...theme, + icon: theme.icon || '🎨', + description: theme.description || '', + }; + return acc; + }, {}); + + const currentThemeData = themes.find((t) => t.id === currentTheme) || themes[0] || null; const value = { currentTheme, - currentThemeData: allThemes[currentTheme] || BUILT_IN_THEMES['new-year'], - themes: allThemes, - builtInThemes: BUILT_IN_THEMES, - customThemes, + currentThemeData: currentThemeData ? { + ...currentThemeData, + icon: currentThemeData.icon || '🎨', + description: currentThemeData.description || '', + } : null, + themes: themesObject, changeTheme, loading, }; diff --git a/src/pages/CreateRoom.jsx b/src/pages/CreateRoom.jsx index 7291a7e..e19fbc8 100644 --- a/src/pages/CreateRoom.jsx +++ b/src/pages/CreateRoom.jsx @@ -12,7 +12,6 @@ const CreateRoom = () => { const [questionPacks, setQuestionPacks] = useState([]); const [selectedPackId, setSelectedPackId] = useState(''); - const [hostName, setHostName] = useState(''); const [settings, setSettings] = useState({ maxPlayers: 10, allowSpectators: true, @@ -21,6 +20,7 @@ const CreateRoom = () => { }); const [loading, setLoading] = useState(true); const [isNameModalOpen, setIsNameModalOpen] = useState(false); + const [isHostNameModalOpen, setIsHostNameModalOpen] = useState(false); // Проверка авторизации и показ модального окна для ввода имени useEffect(() => { @@ -67,12 +67,19 @@ const CreateRoom = () => { return; } + // Всегда спрашиваем имя хоста перед созданием комнаты + setIsHostNameModalOpen(true); + }; + + const handleHostNameSubmit = async (name) => { + setIsHostNameModalOpen(false); + try { const room = await createRoom( user.id, selectedPackId || undefined, settings, - hostName.trim() || undefined, + name.trim(), ); navigate(`/room/${room.code}`); } catch (error) { @@ -90,18 +97,6 @@ const CreateRoom = () => {

Создать комнату

-
- - setHostName(e.target.value)} - placeholder="Ведущий" - maxLength={50} - /> - Оставьте пустым для использования «Ведущий» -
-