theme
This commit is contained in:
parent
8967c9bac2
commit
dafb0183e9
13 changed files with 556 additions and 244 deletions
|
|
@ -30,6 +30,8 @@ export interface ThemeSettings {
|
||||||
export interface Theme {
|
export interface Theme {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
icon?: string | null
|
||||||
|
description?: string | null
|
||||||
isPublic: boolean
|
isPublic: boolean
|
||||||
colors: ThemeColors
|
colors: ThemeColors
|
||||||
settings: ThemeSettings
|
settings: ThemeSettings
|
||||||
|
|
@ -45,6 +47,8 @@ export interface Theme {
|
||||||
export interface ThemePreview {
|
export interface ThemePreview {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
icon?: string | null
|
||||||
|
description?: string | null
|
||||||
isPublic: boolean
|
isPublic: boolean
|
||||||
colors: ThemeColors
|
colors: ThemeColors
|
||||||
settings: ThemeSettings
|
settings: ThemeSettings
|
||||||
|
|
@ -57,6 +61,8 @@ export interface ThemePreview {
|
||||||
|
|
||||||
export interface CreateThemeDto {
|
export interface CreateThemeDto {
|
||||||
name: string
|
name: string
|
||||||
|
icon?: string
|
||||||
|
description?: string
|
||||||
isPublic?: boolean
|
isPublic?: boolean
|
||||||
colors: ThemeColors
|
colors: ThemeColors
|
||||||
settings: ThemeSettings
|
settings: ThemeSettings
|
||||||
|
|
@ -64,6 +70,8 @@ export interface CreateThemeDto {
|
||||||
|
|
||||||
export interface UpdateThemeDto {
|
export interface UpdateThemeDto {
|
||||||
name?: string
|
name?: string
|
||||||
|
icon?: string
|
||||||
|
description?: string
|
||||||
isPublic?: boolean
|
isPublic?: boolean
|
||||||
colors?: ThemeColors
|
colors?: ThemeColors
|
||||||
settings?: ThemeSettings
|
settings?: ThemeSettings
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ interface ThemeEditorDialogProps {
|
||||||
theme: ThemePreview | null
|
theme: ThemePreview | null
|
||||||
onSave: (data: {
|
onSave: (data: {
|
||||||
name: string
|
name: string
|
||||||
|
icon?: string
|
||||||
|
description?: string
|
||||||
isPublic: boolean
|
isPublic: boolean
|
||||||
colors: ThemeColors
|
colors: ThemeColors
|
||||||
settings: ThemeSettings
|
settings: ThemeSettings
|
||||||
|
|
@ -40,21 +42,107 @@ interface ColorFieldProps {
|
||||||
description?: string
|
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) {
|
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 (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-sm">{label}</Label>
|
<Label className="text-sm">{label}</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{!isGradient && (
|
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
value={value.startsWith('#') ? value : '#ffffff'}
|
value={hexForPicker}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => handleColorPickerChange(e.target.value)}
|
||||||
className="w-10 h-10 rounded border cursor-pointer"
|
className="w-10 h-10 rounded border cursor-pointer flex-shrink-0"
|
||||||
|
title="Pick color"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
<Input
|
<Input
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
|
@ -77,19 +165,25 @@ export function ThemeEditorDialog({
|
||||||
isSaving = false,
|
isSaving = false,
|
||||||
}: ThemeEditorDialogProps) {
|
}: ThemeEditorDialogProps) {
|
||||||
const [name, setName] = useState('')
|
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 [colors, setColors] = useState<ThemeColors>(DEFAULT_THEME_COLORS)
|
||||||
const [settings, setSettings] = useState<ThemeSettings>(DEFAULT_THEME_SETTINGS)
|
const [settings, setSettings] = useState<ThemeSettings>(DEFAULT_THEME_SETTINGS)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (theme) {
|
if (theme) {
|
||||||
setName(theme.name)
|
setName(theme.name)
|
||||||
|
setIcon(theme.icon || '')
|
||||||
|
setDescription(theme.description || '')
|
||||||
setIsPublic(theme.isPublic)
|
setIsPublic(theme.isPublic)
|
||||||
setColors(theme.colors)
|
setColors(theme.colors)
|
||||||
setSettings(theme.settings)
|
setSettings(theme.settings)
|
||||||
} else {
|
} else {
|
||||||
setName('')
|
setName('')
|
||||||
setIsPublic(false)
|
setIcon('')
|
||||||
|
setDescription('')
|
||||||
|
setIsPublic(true)
|
||||||
setColors(DEFAULT_THEME_COLORS)
|
setColors(DEFAULT_THEME_COLORS)
|
||||||
setSettings(DEFAULT_THEME_SETTINGS)
|
setSettings(DEFAULT_THEME_SETTINGS)
|
||||||
}
|
}
|
||||||
|
|
@ -102,6 +196,8 @@ export function ThemeEditorDialog({
|
||||||
}
|
}
|
||||||
onSave({
|
onSave({
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
|
icon: icon.trim() || undefined,
|
||||||
|
description: description.trim() || undefined,
|
||||||
isPublic,
|
isPublic,
|
||||||
colors,
|
colors,
|
||||||
settings,
|
settings,
|
||||||
|
|
@ -129,6 +225,7 @@ export function ThemeEditorDialog({
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="space-y-6 py-4">
|
<div className="space-y-6 py-4">
|
||||||
{/* Basic Info */}
|
{/* Basic Info */}
|
||||||
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Theme Name *</Label>
|
<Label htmlFor="name">Theme Name *</Label>
|
||||||
|
|
@ -140,7 +237,27 @@ export function ThemeEditorDialog({
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<Checkbox
|
||||||
id="isPublic"
|
id="isPublic"
|
||||||
checked={isPublic}
|
checked={isPublic}
|
||||||
|
|
@ -150,153 +267,11 @@ export function ThemeEditorDialog({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs for Colors and Settings */}
|
{/* Preview - Always visible above tabs */}
|
||||||
<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">
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Shadow Small</Label>
|
<Label className="text-sm font-medium">Theme Preview</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">
|
|
||||||
<div
|
<div
|
||||||
className="rounded-lg p-6 min-h-[300px]"
|
className="rounded-lg p-6 min-h-[300px] border"
|
||||||
style={{
|
style={{
|
||||||
background: colors.bgPrimary,
|
background: colors.bgPrimary,
|
||||||
}}
|
}}
|
||||||
|
|
@ -392,6 +367,183 @@ export function ThemeEditorDialog({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,8 @@ export default function ThemesPage() {
|
||||||
|
|
||||||
const handleSave = (formData: {
|
const handleSave = (formData: {
|
||||||
name: string
|
name: string
|
||||||
|
icon?: string
|
||||||
|
description?: string
|
||||||
isPublic: boolean
|
isPublic: boolean
|
||||||
colors: ThemeColors
|
colors: ThemeColors
|
||||||
settings: ThemeSettings
|
settings: ThemeSettings
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"socket.io": "^4.8.3"
|
"socket.io": "^4.8.3"
|
||||||
},
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "ts-node prisma/seed.ts"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,8 @@ enum CodeStatus {
|
||||||
model Theme {
|
model Theme {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
name String
|
name String
|
||||||
|
icon String?
|
||||||
|
description String?
|
||||||
isPublic Boolean @default(false)
|
isPublic Boolean @default(false)
|
||||||
createdBy String
|
createdBy String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,159 @@ async function main() {
|
||||||
|
|
||||||
console.log('Default pack created:', defaultPack);
|
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!');
|
console.log('Seed completed successfully!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ export class AdminThemesService {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
icon: true,
|
||||||
|
description: true,
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
colors: true,
|
colors: true,
|
||||||
settings: true,
|
settings: true,
|
||||||
|
|
@ -83,7 +85,9 @@ export class AdminThemesService {
|
||||||
return this.prisma.theme.create({
|
return this.prisma.theme.create({
|
||||||
data: {
|
data: {
|
||||||
name: createThemeDto.name,
|
name: createThemeDto.name,
|
||||||
isPublic: createThemeDto.isPublic,
|
icon: createThemeDto.icon,
|
||||||
|
description: createThemeDto.description,
|
||||||
|
isPublic: createThemeDto.isPublic ?? true,
|
||||||
createdBy,
|
createdBy,
|
||||||
colors: JSON.parse(JSON.stringify(createThemeDto.colors)) as Prisma.InputJsonValue,
|
colors: JSON.parse(JSON.stringify(createThemeDto.colors)) as Prisma.InputJsonValue,
|
||||||
settings: JSON.parse(JSON.stringify(createThemeDto.settings)) as Prisma.InputJsonValue,
|
settings: JSON.parse(JSON.stringify(createThemeDto.settings)) as Prisma.InputJsonValue,
|
||||||
|
|
@ -111,6 +115,8 @@ export class AdminThemesService {
|
||||||
|
|
||||||
const updateData: Prisma.ThemeUpdateInput = {
|
const updateData: Prisma.ThemeUpdateInput = {
|
||||||
...(updateThemeDto.name !== undefined && { name: updateThemeDto.name }),
|
...(updateThemeDto.name !== undefined && { name: updateThemeDto.name }),
|
||||||
|
...(updateThemeDto.icon !== undefined && { icon: updateThemeDto.icon }),
|
||||||
|
...(updateThemeDto.description !== undefined && { description: updateThemeDto.description }),
|
||||||
...(updateThemeDto.isPublic !== undefined && {
|
...(updateThemeDto.isPublic !== undefined && {
|
||||||
isPublic: updateThemeDto.isPublic,
|
isPublic: updateThemeDto.isPublic,
|
||||||
}),
|
}),
|
||||||
|
|
@ -163,6 +169,8 @@ export class AdminThemesService {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
icon: true,
|
||||||
|
description: true,
|
||||||
colors: true,
|
colors: true,
|
||||||
settings: true,
|
settings: true,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,14 @@ export class CreateThemeDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
icon?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
isPublic?: boolean;
|
isPublic?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ export class ThemesController {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
icon: true,
|
||||||
|
description: true,
|
||||||
colors: true,
|
colors: true,
|
||||||
settings: true,
|
settings: true,
|
||||||
},
|
},
|
||||||
|
|
@ -32,6 +34,8 @@ export class ThemesController {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
icon: true,
|
||||||
|
description: true,
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
colors: true,
|
colors: true,
|
||||||
settings: true,
|
settings: true,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import './NameInputModal.css';
|
import './NameInputModal.css';
|
||||||
|
|
||||||
const NameInputModal = ({ isOpen, onSubmit, onCancel }) => {
|
const NameInputModal = ({
|
||||||
|
isOpen,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
title = 'Введите ваше имя',
|
||||||
|
description = 'Чтобы присоединиться к комнате, введите ваше имя'
|
||||||
|
}) => {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [error, setError] = 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-backdrop" onClick={handleBackdropClick}>
|
||||||
<div className="name-input-modal-content">
|
<div className="name-input-modal-content">
|
||||||
<div className="name-input-modal-header">
|
<div className="name-input-modal-header">
|
||||||
<h2 className="name-input-modal-title">Введите ваше имя</h2>
|
<h2 className="name-input-modal-title">{title}</h2>
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
<button className="name-input-modal-close" onClick={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}>
|
<form className="name-input-modal-form" onSubmit={handleSubmit}>
|
||||||
<div className="name-input-modal-body">
|
<div className="name-input-modal-body">
|
||||||
<p className="name-input-modal-description">
|
<p className="name-input-modal-description">
|
||||||
Чтобы присоединиться к комнате, введите ваше имя
|
{description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="name-input-group">
|
<div className="name-input-group">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useTheme } from '../context/ThemeContext';
|
||||||
import './ThemeSwitcher.css';
|
import './ThemeSwitcher.css';
|
||||||
|
|
||||||
const ThemeSwitcher = () => {
|
const ThemeSwitcher = () => {
|
||||||
const { currentTheme, themes, changeTheme } = useTheme();
|
const { currentTheme, currentThemeData, themes, changeTheme, loading } = useTheme();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const handleThemeChange = (themeId) => {
|
const handleThemeChange = (themeId) => {
|
||||||
|
|
@ -11,6 +11,10 @@ const ThemeSwitcher = () => {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (loading || !currentThemeData || Object.keys(themes).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="theme-switcher">
|
<div className="theme-switcher">
|
||||||
<button
|
<button
|
||||||
|
|
@ -18,7 +22,7 @@ const ThemeSwitcher = () => {
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
title="Сменить тему"
|
title="Сменить тему"
|
||||||
>
|
>
|
||||||
{themes[currentTheme].icon}
|
{currentThemeData.icon || '🎨'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
|
|
@ -33,9 +37,9 @@ const ThemeSwitcher = () => {
|
||||||
className={`theme-option ${currentTheme === theme.id ? 'active' : ''}`}
|
className={`theme-option ${currentTheme === theme.id ? 'active' : ''}`}
|
||||||
onClick={() => handleThemeChange(theme.id)}
|
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-name">{theme.name}</span>
|
||||||
<span className="theme-option-description">{theme.description}</span>
|
<span className="theme-option-description">{theme.description || ''}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,41 +2,6 @@ import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
|
||||||
const ThemeContext = createContext();
|
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
|
// Helper to convert camelCase to kebab-case
|
||||||
const camelToKebab = (str) => str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
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 }) => {
|
export const ThemeProvider = ({ children }) => {
|
||||||
|
const [themes, setThemes] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentTheme, setCurrentTheme] = useState(() => {
|
const [currentTheme, setCurrentTheme] = useState(() => {
|
||||||
const saved = localStorage.getItem('app-theme');
|
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(() => {
|
useEffect(() => {
|
||||||
const loadCustomThemes = async () => {
|
const loadThemes = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/themes');
|
const response = await fetch('/api/themes');
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to load custom themes:', error);
|
console.error('Failed to load themes:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadCustomThemes();
|
loadThemes();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Apply theme CSS variables
|
// Apply theme when currentTheme or themes change
|
||||||
const applyTheme = (themeId) => {
|
useEffect(() => {
|
||||||
|
if (!currentTheme || themes.length === 0) return;
|
||||||
|
|
||||||
|
const theme = themes.find((t) => t.id === currentTheme);
|
||||||
|
if (!theme) return;
|
||||||
|
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
|
|
||||||
// Check if it's a built-in theme
|
// Remove data-theme attribute (for built-in CSS themes)
|
||||||
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) {
|
|
||||||
root.removeAttribute('data-theme');
|
root.removeAttribute('data-theme');
|
||||||
|
|
||||||
// Apply custom theme colors
|
// Apply theme colors
|
||||||
if (customTheme.colors) {
|
if (theme.colors) {
|
||||||
Object.entries(customTheme.colors).forEach(([key, value]) => {
|
Object.entries(theme.colors).forEach(([key, value]) => {
|
||||||
root.style.setProperty(`--${camelToKebab(key)}`, value);
|
root.style.setProperty(`--${camelToKebab(key)}`, value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply custom theme settings
|
// Apply theme settings
|
||||||
if (customTheme.settings) {
|
if (theme.settings) {
|
||||||
Object.entries(customTheme.settings).forEach(([key, value]) => {
|
Object.entries(theme.settings).forEach(([key, value]) => {
|
||||||
root.style.setProperty(`--${camelToKebab(key)}`, value);
|
root.style.setProperty(`--${camelToKebab(key)}`, value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Apply theme when currentTheme changes
|
|
||||||
useEffect(() => {
|
|
||||||
applyTheme(currentTheme);
|
|
||||||
localStorage.setItem('app-theme', currentTheme);
|
localStorage.setItem('app-theme', currentTheme);
|
||||||
}, [currentTheme, customThemes]);
|
}, [currentTheme, themes]);
|
||||||
|
|
||||||
const changeTheme = (themeId) => {
|
const changeTheme = (themeId) => {
|
||||||
// Check if it's a built-in or custom theme
|
if (themes.find((t) => t.id === themeId)) {
|
||||||
if (BUILT_IN_THEMES[themeId] || customThemes.find((t) => t.id === themeId)) {
|
|
||||||
setCurrentTheme(themeId);
|
setCurrentTheme(themeId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Combine built-in and custom themes for display
|
// Convert themes array to object for easier access
|
||||||
const allThemes = {
|
const themesObject = themes.reduce((acc, theme) => {
|
||||||
...BUILT_IN_THEMES,
|
|
||||||
...customThemes.reduce((acc, theme) => {
|
|
||||||
acc[theme.id] = {
|
acc[theme.id] = {
|
||||||
...theme,
|
...theme,
|
||||||
icon: '🎨',
|
icon: theme.icon || '🎨',
|
||||||
isBuiltIn: false,
|
description: theme.description || '',
|
||||||
};
|
};
|
||||||
return acc;
|
return acc;
|
||||||
}, {}),
|
}, {});
|
||||||
};
|
|
||||||
|
const currentThemeData = themes.find((t) => t.id === currentTheme) || themes[0] || null;
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
currentTheme,
|
currentTheme,
|
||||||
currentThemeData: allThemes[currentTheme] || BUILT_IN_THEMES['new-year'],
|
currentThemeData: currentThemeData ? {
|
||||||
themes: allThemes,
|
...currentThemeData,
|
||||||
builtInThemes: BUILT_IN_THEMES,
|
icon: currentThemeData.icon || '🎨',
|
||||||
customThemes,
|
description: currentThemeData.description || '',
|
||||||
|
} : null,
|
||||||
|
themes: themesObject,
|
||||||
changeTheme,
|
changeTheme,
|
||||||
loading,
|
loading,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ const CreateRoom = () => {
|
||||||
|
|
||||||
const [questionPacks, setQuestionPacks] = useState([]);
|
const [questionPacks, setQuestionPacks] = useState([]);
|
||||||
const [selectedPackId, setSelectedPackId] = useState('');
|
const [selectedPackId, setSelectedPackId] = useState('');
|
||||||
const [hostName, setHostName] = useState('');
|
|
||||||
const [settings, setSettings] = useState({
|
const [settings, setSettings] = useState({
|
||||||
maxPlayers: 10,
|
maxPlayers: 10,
|
||||||
allowSpectators: true,
|
allowSpectators: true,
|
||||||
|
|
@ -21,6 +20,7 @@ const CreateRoom = () => {
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [isNameModalOpen, setIsNameModalOpen] = useState(false);
|
const [isNameModalOpen, setIsNameModalOpen] = useState(false);
|
||||||
|
const [isHostNameModalOpen, setIsHostNameModalOpen] = useState(false);
|
||||||
|
|
||||||
// Проверка авторизации и показ модального окна для ввода имени
|
// Проверка авторизации и показ модального окна для ввода имени
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -67,12 +67,19 @@ const CreateRoom = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Всегда спрашиваем имя хоста перед созданием комнаты
|
||||||
|
setIsHostNameModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHostNameSubmit = async (name) => {
|
||||||
|
setIsHostNameModalOpen(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const room = await createRoom(
|
const room = await createRoom(
|
||||||
user.id,
|
user.id,
|
||||||
selectedPackId || undefined,
|
selectedPackId || undefined,
|
||||||
settings,
|
settings,
|
||||||
hostName.trim() || undefined,
|
name.trim(),
|
||||||
);
|
);
|
||||||
navigate(`/room/${room.code}`);
|
navigate(`/room/${room.code}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -90,18 +97,6 @@ const CreateRoom = () => {
|
||||||
<div className="create-room-container">
|
<div className="create-room-container">
|
||||||
<h1>Создать комнату</h1>
|
<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">
|
<div className="form-group">
|
||||||
<label>Выберите пак вопросов (можно добавить позже):</label>
|
<label>Выберите пак вопросов (можно добавить позже):</label>
|
||||||
<select
|
<select
|
||||||
|
|
@ -188,6 +183,14 @@ const CreateRoom = () => {
|
||||||
onSubmit={handleNameSubmit}
|
onSubmit={handleNameSubmit}
|
||||||
onCancel={null}
|
onCancel={null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<NameInputModal
|
||||||
|
isOpen={isHostNameModalOpen}
|
||||||
|
onSubmit={handleHostNameSubmit}
|
||||||
|
onCancel={() => setIsHostNameModalOpen(false)}
|
||||||
|
title="Введите ваше имя как ведущего"
|
||||||
|
description="Чтобы создать комнату, введите ваше имя как ведущего"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue