This commit is contained in:
Dmitry 2026-01-10 02:27:21 +03:00
parent 8967c9bac2
commit dafb0183e9
13 changed files with 556 additions and 244 deletions

View file

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

View file

@ -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 (
<div className="space-y-1">
<Label className="text-sm">{label}</Label>
<div className="flex items-center gap-2">
{!isGradient && (
<input
type="color"
value={value.startsWith('#') ? value : '#ffffff'}
onChange={(e) => onChange(e.target.value)}
className="w-10 h-10 rounded border cursor-pointer"
value={hexForPicker}
onChange={(e) => handleColorPickerChange(e.target.value)}
className="w-10 h-10 rounded border cursor-pointer flex-shrink-0"
title="Pick color"
/>
)}
<Input
value={value}
onChange={(e) => 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<ThemeColors>(DEFAULT_THEME_COLORS)
const [settings, setSettings] = useState<ThemeSettings>(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,6 +225,7 @@ export function ThemeEditorDialog({
<form onSubmit={handleSubmit}>
<div className="space-y-6 py-4">
{/* Basic Info */}
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Theme Name *</Label>
@ -140,7 +237,27 @@ export function ThemeEditorDialog({
required
/>
</div>
<div className="flex items-center space-x-2 pt-8">
<div className="space-y-2">
<Label htmlFor="icon">Icon (emoji)</Label>
<Input
id="icon"
value={icon}
onChange={(e) => setIcon(e.target.value)}
placeholder="🎨"
maxLength={2}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Theme description for users"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="isPublic"
checked={isPublic}
@ -150,153 +267,11 @@ export function ThemeEditorDialog({
</div>
</div>
{/* Tabs for Colors and Settings */}
<Tabs defaultValue="colors">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="colors">Colors</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="preview">Preview</TabsTrigger>
</TabsList>
<TabsContent value="colors" className="space-y-4 pt-4">
<div className="grid grid-cols-2 gap-4">
<ColorField
label="Background Primary"
value={colors.bgPrimary}
onChange={(v) => updateColor('bgPrimary', v)}
description="Main background (gradient or solid)"
/>
<ColorField
label="Background Overlay"
value={colors.bgOverlay}
onChange={(v) => updateColor('bgOverlay', v)}
description="Overlay color (rgba recommended)"
/>
<ColorField
label="Card Background"
value={colors.bgCard}
onChange={(v) => updateColor('bgCard', v)}
/>
<ColorField
label="Card Hover"
value={colors.bgCardHover}
onChange={(v) => updateColor('bgCardHover', v)}
/>
<ColorField
label="Text Primary"
value={colors.textPrimary}
onChange={(v) => updateColor('textPrimary', v)}
/>
<ColorField
label="Text Secondary"
value={colors.textSecondary}
onChange={(v) => updateColor('textSecondary', v)}
/>
<ColorField
label="Text Glow"
value={colors.textGlow}
onChange={(v) => updateColor('textGlow', v)}
/>
<ColorField
label="Accent Primary"
value={colors.accentPrimary}
onChange={(v) => updateColor('accentPrimary', v)}
/>
<ColorField
label="Accent Secondary"
value={colors.accentSecondary}
onChange={(v) => updateColor('accentSecondary', v)}
/>
<ColorField
label="Accent Success"
value={colors.accentSuccess}
onChange={(v) => updateColor('accentSuccess', v)}
/>
<ColorField
label="Border Color"
value={colors.borderColor}
onChange={(v) => updateColor('borderColor', v)}
/>
<ColorField
label="Border Glow"
value={colors.borderGlow}
onChange={(v) => updateColor('borderGlow', v)}
/>
</div>
</TabsContent>
<TabsContent value="settings" className="space-y-4 pt-4">
<div className="grid grid-cols-2 gap-4">
{/* Preview - Always visible above tabs */}
<div className="space-y-2">
<Label>Shadow Small</Label>
<Input
value={settings.shadowSm}
onChange={(e) => updateSetting('shadowSm', e.target.value)}
placeholder="0 1px 2px rgba(0, 0, 0, 0.1)"
/>
</div>
<div className="space-y-2">
<Label>Shadow Medium</Label>
<Input
value={settings.shadowMd}
onChange={(e) => updateSetting('shadowMd', e.target.value)}
placeholder="0 4px 6px rgba(0, 0, 0, 0.1)"
/>
</div>
<div className="space-y-2">
<Label>Shadow Large</Label>
<Input
value={settings.shadowLg}
onChange={(e) => updateSetting('shadowLg', e.target.value)}
placeholder="0 10px 15px rgba(0, 0, 0, 0.2)"
/>
</div>
<div className="space-y-2">
<Label>Blur Amount</Label>
<Input
value={settings.blurAmount}
onChange={(e) => updateSetting('blurAmount', e.target.value)}
placeholder="10px"
/>
</div>
<div className="space-y-2">
<Label>Border Radius Small</Label>
<Input
value={settings.borderRadiusSm}
onChange={(e) => updateSetting('borderRadiusSm', e.target.value)}
placeholder="4px"
/>
</div>
<div className="space-y-2">
<Label>Border Radius Medium</Label>
<Input
value={settings.borderRadiusMd}
onChange={(e) => updateSetting('borderRadiusMd', e.target.value)}
placeholder="8px"
/>
</div>
<div className="space-y-2">
<Label>Border Radius Large</Label>
<Input
value={settings.borderRadiusLg}
onChange={(e) => updateSetting('borderRadiusLg', e.target.value)}
placeholder="12px"
/>
</div>
<div className="space-y-2">
<Label>Animation Speed</Label>
<Input
value={settings.animationSpeed}
onChange={(e) => updateSetting('animationSpeed', e.target.value)}
placeholder="0.3s"
/>
</div>
</div>
</TabsContent>
<TabsContent value="preview" className="pt-4">
<Label className="text-sm font-medium">Theme Preview</Label>
<div
className="rounded-lg p-6 min-h-[300px]"
className="rounded-lg p-6 min-h-[300px] border"
style={{
background: colors.bgPrimary,
}}
@ -392,6 +367,183 @@ export function ThemeEditorDialog({
</button>
</div>
</div>
</div>
{/* Tabs for Colors and Settings */}
<Tabs defaultValue="colors">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="colors">Colors</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="colors" className="space-y-4 pt-4">
<div className="grid grid-cols-2 gap-4">
<ColorField
label="Background Primary"
value={colors.bgPrimary}
onChange={(v) => updateColor('bgPrimary', v)}
description="Основной фон темы. Поддерживает градиенты (linear-gradient) и сплошные цвета (hex, rgb, rgba)"
/>
<ColorField
label="Background Overlay"
value={colors.bgOverlay}
onChange={(v) => updateColor('bgOverlay', v)}
description="Цвет наложения поверх основного фона. Рекомендуется использовать rgba для прозрачности"
/>
<ColorField
label="Card Background"
value={colors.bgCard}
onChange={(v) => updateColor('bgCard', v)}
description="Фон карточек и контейнеров. Обычно используется rgba с прозрачностью для эффекта стекла"
/>
<ColorField
label="Card Hover"
value={colors.bgCardHover}
onChange={(v) => updateColor('bgCardHover', v)}
description="Фон карточки при наведении. Обычно более светлый вариант Card Background"
/>
<ColorField
label="Text Primary"
value={colors.textPrimary}
onChange={(v) => updateColor('textPrimary', v)}
description="Основной цвет текста. Используется для основного содержимого и заголовков"
/>
<ColorField
label="Text Secondary"
value={colors.textSecondary}
onChange={(v) => updateColor('textSecondary', v)}
description="Вторичный цвет текста. Используется для второстепенного контента, обычно с прозрачностью"
/>
<ColorField
label="Text Glow"
value={colors.textGlow}
onChange={(v) => updateColor('textGlow', v)}
description="Цвет свечения текста. Используется для эффекта textShadow, добавляет объемность"
/>
<ColorField
label="Accent Primary"
value={colors.accentPrimary}
onChange={(v) => updateColor('accentPrimary', v)}
description="Основной акцентный цвет. Используется для кнопок, активных элементов и выделения"
/>
<ColorField
label="Accent Secondary"
value={colors.accentSecondary}
onChange={(v) => updateColor('accentSecondary', v)}
description="Вторичный акцентный цвет. Используется для дополнительных акцентов и состояний hover"
/>
<ColorField
label="Accent Success"
value={colors.accentSuccess}
onChange={(v) => updateColor('accentSuccess', v)}
description="Цвет успеха. Используется для успешных действий, положительных статусов"
/>
<ColorField
label="Border Color"
value={colors.borderColor}
onChange={(v) => updateColor('borderColor', v)}
description="Цвет границ элементов. Обычно используется rgba с небольшой прозрачностью"
/>
<ColorField
label="Border Glow"
value={colors.borderGlow}
onChange={(v) => updateColor('borderGlow', v)}
description="Цвет свечения границ. Используется для активных состояний, создает эффект свечения"
/>
</div>
</TabsContent>
<TabsContent value="settings" className="space-y-4 pt-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Shadow Small</Label>
<Input
value={settings.shadowSm}
onChange={(e) => updateSetting('shadowSm', e.target.value)}
placeholder="0 1px 2px rgba(0, 0, 0, 0.1)"
/>
<p className="text-xs text-muted-foreground">
Малая тень для небольших элементов. Формат: offset-x offset-y blur-radius color
</p>
</div>
<div className="space-y-2">
<Label>Shadow Medium</Label>
<Input
value={settings.shadowMd}
onChange={(e) => updateSetting('shadowMd', e.target.value)}
placeholder="0 4px 6px rgba(0, 0, 0, 0.1)"
/>
<p className="text-xs text-muted-foreground">
Средняя тень для карточек и контейнеров. Формат: offset-x offset-y blur-radius color
</p>
</div>
<div className="space-y-2">
<Label>Shadow Large</Label>
<Input
value={settings.shadowLg}
onChange={(e) => updateSetting('shadowLg', e.target.value)}
placeholder="0 10px 15px rgba(0, 0, 0, 0.2)"
/>
<p className="text-xs text-muted-foreground">
Большая тень для модальных окон и важных элементов. Формат: offset-x offset-y blur-radius color
</p>
</div>
<div className="space-y-2">
<Label>Blur Amount</Label>
<Input
value={settings.blurAmount}
onChange={(e) => updateSetting('blurAmount', e.target.value)}
placeholder="10px"
/>
<p className="text-xs text-muted-foreground">
Интенсивность размытия для backdrop-filter. Используется для эффекта стекла (glassmorphism)
</p>
</div>
<div className="space-y-2">
<Label>Border Radius Small</Label>
<Input
value={settings.borderRadiusSm}
onChange={(e) => updateSetting('borderRadiusSm', e.target.value)}
placeholder="4px"
/>
<p className="text-xs text-muted-foreground">
Маленький радиус скругления для кнопок и маленьких элементов
</p>
</div>
<div className="space-y-2">
<Label>Border Radius Medium</Label>
<Input
value={settings.borderRadiusMd}
onChange={(e) => updateSetting('borderRadiusMd', e.target.value)}
placeholder="8px"
/>
<p className="text-xs text-muted-foreground">
Средний радиус скругления для карточек и контейнеров
</p>
</div>
<div className="space-y-2">
<Label>Border Radius Large</Label>
<Input
value={settings.borderRadiusLg}
onChange={(e) => updateSetting('borderRadiusLg', e.target.value)}
placeholder="12px"
/>
<p className="text-xs text-muted-foreground">
Большой радиус скругления для модальных окон и крупных элементов
</p>
</div>
<div className="space-y-2">
<Label>Animation Speed</Label>
<Input
value={settings.animationSpeed}
onChange={(e) => updateSetting('animationSpeed', e.target.value)}
placeholder="0.3s"
/>
<p className="text-xs text-muted-foreground">
Скорость анимаций переходов. Формат: время (например, 0.3s, 300ms)
</p>
</div>
</div>
</TabsContent>
</Tabs>
</div>

View file

@ -124,6 +124,8 @@ export default function ThemesPage() {
const handleSave = (formData: {
name: string
icon?: string
description?: string
isPublic: boolean
colors: ThemeColors
settings: ThemeSettings

View file

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

View file

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

View file

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

View file

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

View file

@ -75,6 +75,14 @@ export class CreateThemeDto {
@IsString()
name: string;
@IsString()
@IsOptional()
icon?: string;
@IsString()
@IsOptional()
description?: string;
@IsBoolean()
@IsOptional()
isPublic?: boolean;

View file

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

View file

@ -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 }) => {
<div className="name-input-modal-backdrop" onClick={handleBackdropClick}>
<div className="name-input-modal-content">
<div className="name-input-modal-header">
<h2 className="name-input-modal-title">Введите ваше имя</h2>
<h2 className="name-input-modal-title">{title}</h2>
{onCancel && (
<button className="name-input-modal-close" onClick={onCancel}>
×
@ -54,7 +60,7 @@ const NameInputModal = ({ isOpen, onSubmit, onCancel }) => {
<form className="name-input-modal-form" onSubmit={handleSubmit}>
<div className="name-input-modal-body">
<p className="name-input-modal-description">
Чтобы присоединиться к комнате, введите ваше имя
{description}
</p>
<div className="name-input-group">

View file

@ -3,7 +3,7 @@ import { useTheme } from '../context/ThemeContext';
import './ThemeSwitcher.css';
const ThemeSwitcher = () => {
const { currentTheme, themes, changeTheme } = useTheme();
const { currentTheme, currentThemeData, themes, changeTheme, loading } = useTheme();
const [isOpen, setIsOpen] = useState(false);
const handleThemeChange = (themeId) => {
@ -11,6 +11,10 @@ const ThemeSwitcher = () => {
setIsOpen(false);
};
if (loading || !currentThemeData || Object.keys(themes).length === 0) {
return null;
}
return (
<div className="theme-switcher">
<button
@ -18,7 +22,7 @@ const ThemeSwitcher = () => {
onClick={() => setIsOpen(!isOpen)}
title="Сменить тему"
>
{themes[currentTheme].icon}
{currentThemeData.icon || '🎨'}
</button>
{isOpen && (
@ -33,9 +37,9 @@ const ThemeSwitcher = () => {
className={`theme-option ${currentTheme === theme.id ? 'active' : ''}`}
onClick={() => handleThemeChange(theme.id)}
>
<span className="theme-option-icon">{theme.icon}</span>
<span className="theme-option-icon">{theme.icon || '🎨'}</span>
<span className="theme-option-name">{theme.name}</span>
<span className="theme-option-description">{theme.description}</span>
<span className="theme-option-description">{theme.description || ''}</span>
</button>
))}
</div>

View file

@ -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;
}
// Check if it's a custom theme
const customTheme = customThemes.find((t) => t.id === themeId);
if (customTheme) {
// Remove data-theme attribute (for built-in CSS themes)
root.removeAttribute('data-theme');
// Apply custom theme colors
if (customTheme.colors) {
Object.entries(customTheme.colors).forEach(([key, value]) => {
// Apply theme colors
if (theme.colors) {
Object.entries(theme.colors).forEach(([key, value]) => {
root.style.setProperty(`--${camelToKebab(key)}`, value);
});
}
// Apply custom theme settings
if (customTheme.settings) {
Object.entries(customTheme.settings).forEach(([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) => {
// Convert themes array to object for easier access
const themesObject = themes.reduce((acc, theme) => {
acc[theme.id] = {
...theme,
icon: '🎨',
isBuiltIn: false,
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,
};

View file

@ -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 = () => {
<div className="create-room-container">
<h1>Создать комнату</h1>
<div className="form-group">
<label>Ваше имя как ведущего:</label>
<input
type="text"
value={hostName}
onChange={(e) => setHostName(e.target.value)}
placeholder="Ведущий"
maxLength={50}
/>
<small className="form-hint">Оставьте пустым для использования «Ведущий»</small>
</div>
<div className="form-group">
<label>Выберите пак вопросов (можно добавить позже):</label>
<select
@ -188,6 +183,14 @@ const CreateRoom = () => {
onSubmit={handleNameSubmit}
onCancel={null}
/>
<NameInputModal
isOpen={isHostNameModalOpen}
onSubmit={handleHostNameSubmit}
onCancel={() => setIsHostNameModalOpen(false)}
title="Введите ваше имя как ведущего"
description="Чтобы создать комнату, введите ваше имя как ведущего"
/>
</div>
);
};