diff --git a/admin/src/api/themes.ts b/admin/src/api/themes.ts
index 3390e55..e3658af 100644
--- a/admin/src/api/themes.ts
+++ b/admin/src/api/themes.ts
@@ -29,6 +29,11 @@ export interface ThemeSettings {
particleSymbol?: string
particleColor?: string
particleGlow?: string
+ particleTargetCount?: number
+ particleUpdateInterval?: number
+ particleDurationMin?: number
+ particleDurationMax?: number
+ particleInitialDelayMax?: number
}
export interface Theme {
@@ -251,4 +256,9 @@ export const DEFAULT_THEME_SETTINGS: ThemeSettings = {
particleSymbol: '❄',
particleColor: '#ffffff',
particleGlow: 'rgba(255, 255, 255, 0.8)',
+ particleTargetCount: 200,
+ particleUpdateInterval: 1000,
+ particleDurationMin: 7,
+ particleDurationMax: 10,
+ particleInitialDelayMax: 10,
}
diff --git a/admin/src/components/ThemeEditorDialog.tsx b/admin/src/components/ThemeEditorDialog.tsx
index 8862a90..f84670c 100644
--- a/admin/src/components/ThemeEditorDialog.tsx
+++ b/admin/src/components/ThemeEditorDialog.tsx
@@ -218,7 +218,7 @@ export function ThemeEditorDialog({
setColors((prev) => ({ ...prev, [key]: value }))
}
- const updateSetting = (key: keyof ThemeSettings, value: string | boolean) => {
+ const updateSetting = (key: keyof ThemeSettings, value: string | boolean | number) => {
setSettings((prev) => ({ ...prev, [key]: value }))
}
@@ -594,6 +594,120 @@ export function ThemeEditorDialog({
description="Цвет свечения частиц. По умолчанию используется Text Glow цвет"
/>
+
+
Animation Settings (Настройки анимации)
+
+
+
+
{
+ const value = parseInt(e.target.value, 10)
+ if (!isNaN(value) && value > 0) {
+ updateSetting('particleTargetCount', value)
+ }
+ }}
+ />
+
+ Целевое количество снежинок на экране
+
+
+
+
+
{
+ const value = parseInt(e.target.value, 10)
+ if (!isNaN(value) && value > 0) {
+ updateSetting('particleUpdateInterval', value)
+ }
+ }}
+ />
+
+ Интервал обновления частиц в миллисекундах
+
+
+
+
+
{
+ const value = parseFloat(e.target.value)
+ if (!isNaN(value) && value > 0) {
+ updateSetting('particleDurationMin', value)
+ }
+ }}
+ />
+
+ Минимальная длительность анимации в секундах
+
+
+
+
+
{
+ const value = parseFloat(e.target.value)
+ if (!isNaN(value) && value > 0) {
+ updateSetting('particleDurationMax', value)
+ }
+ }}
+ />
+
+ Максимальная длительность анимации в секундах
+
+
+
+
+
{
+ const value = parseFloat(e.target.value)
+ if (!isNaN(value) && value >= 0) {
+ updateSetting('particleInitialDelayMax', value)
+ }
+ }}
+ />
+
+ Максимальная начальная задержка для первой партии частиц в секундах
+
+
+
+
diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts
index dfe6a84..80a3d5f 100644
--- a/backend/prisma/seed.ts
+++ b/backend/prisma/seed.ts
@@ -287,6 +287,11 @@ async function main() {
particleSymbol: '❄',
particleColor: '#ffffff',
particleGlow: 'rgba(255, 215, 0, 0.8)',
+ particleTargetCount: 200,
+ particleUpdateInterval: 1000,
+ particleDurationMin: 7,
+ particleDurationMax: 10,
+ particleInitialDelayMax: 10,
},
},
{
@@ -322,6 +327,11 @@ async function main() {
particleSymbol: '🌸',
particleColor: '#2d3748',
particleGlow: 'rgba(47, 128, 237, 0.6)',
+ particleTargetCount: 200,
+ particleUpdateInterval: 1000,
+ particleDurationMin: 7,
+ particleDurationMax: 10,
+ particleInitialDelayMax: 10,
},
},
{
@@ -357,6 +367,11 @@ async function main() {
particleSymbol: '🎉',
particleColor: '#ffffff',
particleGlow: 'rgba(255, 87, 108, 0.8)',
+ particleTargetCount: 200,
+ particleUpdateInterval: 1000,
+ particleDurationMin: 7,
+ particleDurationMax: 10,
+ particleInitialDelayMax: 10,
},
},
{
@@ -392,6 +407,11 @@ async function main() {
particleSymbol: '✨',
particleColor: '#e0e0e0',
particleGlow: 'rgba(100, 255, 218, 0.6)',
+ particleTargetCount: 200,
+ particleUpdateInterval: 1000,
+ particleDurationMin: 7,
+ particleDurationMax: 10,
+ particleInitialDelayMax: 10,
},
},
];
diff --git a/backend/src/admin/themes/dto/create-theme.dto.ts b/backend/src/admin/themes/dto/create-theme.dto.ts
index 91ba427..d7c8eea 100644
--- a/backend/src/admin/themes/dto/create-theme.dto.ts
+++ b/backend/src/admin/themes/dto/create-theme.dto.ts
@@ -1,6 +1,7 @@
import {
IsString,
IsBoolean,
+ IsNumber,
IsOptional,
IsObject,
ValidateNested,
@@ -85,6 +86,31 @@ export class ThemeSettingsDto {
@IsString()
@IsOptional()
particleGlow?: string;
+
+ @IsNumber()
+ @IsOptional()
+ @Type(() => Number)
+ particleTargetCount?: number;
+
+ @IsNumber()
+ @IsOptional()
+ @Type(() => Number)
+ particleUpdateInterval?: number;
+
+ @IsNumber()
+ @IsOptional()
+ @Type(() => Number)
+ particleDurationMin?: number;
+
+ @IsNumber()
+ @IsOptional()
+ @Type(() => Number)
+ particleDurationMax?: number;
+
+ @IsNumber()
+ @IsOptional()
+ @Type(() => Number)
+ particleInitialDelayMax?: number;
}
export class CreateThemeDto {
diff --git a/src/components/Answer.css b/src/components/Answer.css
index ecaccda..52ba2a9 100644
--- a/src/components/Answer.css
+++ b/src/components/Answer.css
@@ -13,7 +13,7 @@
min-height: 0;
position: relative;
overflow-y: auto;
- max-height: clamp(120px, 20vh, 250px);
+ height: 100%;
width: 100%;
/* Firefox scrollbar */
scrollbar-width: thin;
@@ -27,7 +27,6 @@
align-items: center;
justify-content: space-between;
gap: clamp(8px, 1.5vw, 15px);
- max-height: clamp(100px, 30vh, 200px);
}
}
diff --git a/src/components/GameManagementModal.jsx b/src/components/GameManagementModal.jsx
index 3914586..808dfd8 100644
--- a/src/components/GameManagementModal.jsx
+++ b/src/components/GameManagementModal.jsx
@@ -74,7 +74,8 @@ const GameManagementModal = ({
{ text: '', points: 10 },
])
const [jsonError, setJsonError] = useState('')
- const [showPackImport, setShowPackImport] = useState(false)
+ const [showCreateQuestionModal, setShowCreateQuestionModal] = useState(false)
+ const [showPackImportModal, setShowPackImportModal] = useState(false)
const [selectedPack, setSelectedPack] = useState(null)
const [packQuestions, setPackQuestions] = useState([])
const [selectedQuestionIndices, setSelectedQuestionIndices] = useState(new Set())
@@ -193,10 +194,12 @@ const GameManagementModal = ({
setQuestionText(question.text)
setAnswers([...question.answers])
setJsonError('')
+ setShowCreateQuestionModal(true)
}
const handleCancelEditQuestion = () => {
resetQuestionForm()
+ setShowCreateQuestionModal(false)
}
const handleAnswerChange = (index, field, value) => {
@@ -263,6 +266,7 @@ const GameManagementModal = ({
onUpdateQuestions(updatedQuestions)
resetQuestionForm()
+ setShowCreateQuestionModal(false)
}
const handleDeleteQuestion = (questionId) => {
@@ -395,18 +399,21 @@ const GameManagementModal = ({
const handleExportJson = () => {
try {
// Нормализуем вопросы, удаляя поле question если оно есть
- const normalizedQuestions = questions.map(q => {
- const { question, ...questionWithoutQuestion } = q
- return {
- ...questionWithoutQuestion,
- text: q.text || '',
- answers: q.answers.map(a => ({
- id: a.id,
- text: a.text,
- points: a.points
- }))
- }
- })
+ // Если вопросов нет, экспортируем пустой массив
+ const normalizedQuestions = questions.length > 0
+ ? questions.map(q => {
+ const { question, ...questionWithoutQuestion } = q
+ return {
+ ...questionWithoutQuestion,
+ text: q.text || '',
+ answers: q.answers.map(a => ({
+ id: a.id,
+ text: a.text,
+ points: a.points
+ }))
+ }
+ })
+ : []
const jsonString = JSON.stringify(normalizedQuestions, null, 2)
const blob = new Blob([jsonString], { type: 'application/json' })
const url = URL.createObjectURL(blob)
@@ -423,49 +430,6 @@ const GameManagementModal = ({
}
}
- const handleDownloadTemplate = () => {
- const template = [
- {
- text: 'Назовите самый популярный вид спорта в мире',
- answers: [
- { text: 'Футбол', points: 100 },
- { text: 'Баскетбол', points: 80 },
- { text: 'Теннис', points: 60 },
- { text: 'Хоккей', points: 40 },
- { text: 'Волейбол', points: 20 },
- { text: 'Бокс', points: 10 },
- ]
- },
- {
- text: 'Что люди обычно берут с собой на пляж?',
- answers: [
- { text: 'Полотенце', points: 100 },
- { text: 'Крем от солнца', points: 80 },
- { text: 'Очки', points: 60 },
- { text: 'Зонт', points: 40 },
- { text: 'Книга', points: 20 },
- { text: 'Еда', points: 10 },
- ]
- }
- ]
-
- try {
- const jsonString = JSON.stringify(template, null, 2)
- const blob = new Blob([jsonString], { type: 'application/json' })
- const url = URL.createObjectURL(blob)
- const link = document.createElement('a')
- link.href = url
- link.download = 'template_questions.json'
- document.body.appendChild(link)
- link.click()
- document.body.removeChild(link)
- URL.revokeObjectURL(url)
- setJsonError('')
- } catch (error) {
- setJsonError('Ошибка при скачивании шаблона: ' + error.message)
- }
- }
-
const handleImportJson = () => {
const input = document.createElement('input')
input.type = 'file'
@@ -630,7 +594,7 @@ const GameManagementModal = ({
setSelectedQuestionIndices(new Set())
setSearchQuery('')
- setShowPackImport(false)
+ setShowPackImportModal(false)
setJsonError('')
alert(`Импортировано ${copiedQuestions.length} вопросов`)
}
@@ -1094,11 +1058,22 @@ const GameManagementModal = ({
+ {availablePacks.length > 0 && (
+
+ )}
- {availablePacks.length > 0 && (
-
- )}
{jsonError && (
{jsonError}
)}
- {showPackImport && availablePacks.length > 0 && (
-
-
Импорт вопросов из пака
-
-
- {packQuestions.length > 0 && (
-
- {/* Поиск */}
-
- setSearchQuery(e.target.value)}
- placeholder="🔍 Поиск вопросов..."
- className="pack-search-input"
- />
-
-
-
-
-
Выберите вопросы для импорта:
-
- {filteredPackQuestions.length > 0 && (
- <>
- {areAllVisibleSelected() ? (
-
- ) : (
-
- )}
- >
- )}
-
-
-
-
-
-
- {filteredPackQuestions.length === 0 ? (
-
- {searchQuery ? 'Вопросы не найдены' : 'Нет вопросов в паке'}
-
- ) : (
- filteredPackQuestions.map((q, filteredIdx) => {
- const originalIndex = packQuestions.findIndex(pq => pq === q)
- return (
-
-
handleToggleQuestion(originalIndex)}
- />
-
- {q.text || ''}
-
- {q.answers?.length || 0} ответов
-
-
-
-
- )
- })
- )}
-
-
- )}
-
- {/* Модальное окно просмотра вопроса */}
- {viewingQuestion && (
-
-
e.stopPropagation()}>
-
-
Просмотр вопроса
-
-
-
-
- {viewingQuestion.text || ''}
-
-
- {showAnswers && (
-
- {viewingQuestion.answers?.map((answer, idx) => (
-
- {answer.text}
- {answer.points} очков
-
- ))}
-
- )}
-
-
-
- )}
-
- )}
-
-
-
setQuestionText(e.target.value)}
- placeholder="Введите текст вопроса"
- className="questions-modal-input"
- />
-
-
-
-
-
- {editingQuestion && (
-
- )}
-
-
-
-
+
Вопросы ({questions.length})
{questions.length === 0 ? (
@@ -1430,6 +1190,249 @@ const GameManagementModal = ({
)}
+
+ {/* Модалка создания вопроса */}
+ {showCreateQuestionModal && (
+ {
+ if (e.target === e.currentTarget) {
+ handleCancelEditQuestion()
+ }
+ }}>
+
e.stopPropagation()}>
+
+
{editingQuestion ? 'Редактировать вопрос' : 'Добавить вопрос'}
+
+
+
+ {jsonError && (
+
{jsonError}
+ )}
+
+
+
setQuestionText(e.target.value)}
+ placeholder="Введите текст вопроса"
+ className="questions-modal-input"
+ />
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Модалка импорта из пака */}
+ {showPackImportModal && availablePacks.length > 0 && (
+ {
+ if (e.target === e.currentTarget) {
+ setShowPackImportModal(false)
+ }
+ }}>
+
e.stopPropagation()}>
+
+
Импорт вопросов из пака
+
+
+
+
+
+
+ {packQuestions.length > 0 && (
+
+ {/* Поиск */}
+
+ setSearchQuery(e.target.value)}
+ placeholder="🔍 Поиск вопросов..."
+ className="pack-search-input"
+ />
+
+
+
+
+
Выберите вопросы для импорта:
+
+ {filteredPackQuestions.length > 0 && (
+ <>
+ {areAllVisibleSelected() ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+
+
+
+
+
+
+ {filteredPackQuestions.length === 0 ? (
+
+ {searchQuery ? 'Вопросы не найдены' : 'Нет вопросов в паке'}
+
+ ) : (
+ filteredPackQuestions.map((q, filteredIdx) => {
+ const originalIndex = packQuestions.findIndex(pq => pq === q)
+ return (
+
+
handleToggleQuestion(originalIndex)}
+ />
+
+ {q.text || ''}
+
+ {q.answers?.length || 0} ответов
+
+
+
+
+ )
+ })
+ )}
+
+
+ )}
+
+ {/* Модальное окно просмотра вопроса */}
+ {viewingQuestion && (
+
+
e.stopPropagation()}>
+
+
Просмотр вопроса
+
+
+
+
+ {viewingQuestion.text || ''}
+
+
+ {showAnswers && (
+
+ {viewingQuestion.answers?.map((answer, idx) => (
+
+ {answer.text}
+ {answer.points} очков
+
+ ))}
+
+ )}
+
+
+
+ )}
+
+
+
+ )}
)
}
diff --git a/src/components/Question.css b/src/components/Question.css
index 01a6457..27d2437 100644
--- a/src/components/Question.css
+++ b/src/components/Question.css
@@ -127,7 +127,7 @@
.answers-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
- grid-auto-rows: auto;
+ grid-auto-rows: minmax(auto, clamp(120px, 18vh, 200px));
column-gap: clamp(6px, 1.2vw, 12px);
row-gap: clamp(6px, 0.8vh, 12px);
flex: 1;
@@ -152,3 +152,17 @@
}
}
+/* Для очень высоких экранов (ТВ и т.д.) */
+@media (min-height: 1080px) {
+ .answers-grid {
+ grid-auto-rows: minmax(auto, 180px);
+ }
+}
+
+/* Для телефонов в портретной ориентации */
+@media (max-width: 768px) and (max-height: 900px) {
+ .answers-grid {
+ grid-auto-rows: minmax(auto, 150px);
+ }
+}
+
diff --git a/src/components/QuestionsModal.css b/src/components/QuestionsModal.css
index 40e55dd..44ceeb3 100644
--- a/src/components/QuestionsModal.css
+++ b/src/components/QuestionsModal.css
@@ -855,3 +855,168 @@
}
}
+/* Question Create Modal */
+.question-create-modal-backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.8);
+ backdrop-filter: blur(5px);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 2000;
+ padding: 20px;
+}
+
+.question-create-modal-content {
+ background: rgba(20, 20, 30, 0.95);
+ backdrop-filter: blur(20px);
+ border-radius: 20px;
+ padding: clamp(15px, 3vh, 25px);
+ max-width: 800px;
+ width: 100%;
+ max-height: 90vh;
+ overflow-y: auto;
+ border: 2px solid rgba(255, 215, 0, 0.3);
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
+ position: relative;
+}
+
+.question-create-modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: clamp(15px, 2vh, 20px);
+ padding-bottom: 15px;
+ border-bottom: 2px solid rgba(255, 215, 0, 0.2);
+}
+
+.question-create-modal-header h3 {
+ color: var(--accent-primary);
+ font-size: clamp(1.3rem, 2.5vw, 1.8rem);
+ margin: 0;
+ text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
+}
+
+.question-create-modal-close {
+ background: rgba(255, 107, 107, 0.2);
+ color: #ff6b6b;
+ border: 2px solid rgba(255, 107, 107, 0.5);
+ border-radius: 50%;
+ width: 40px;
+ height: 40px;
+ font-size: 2rem;
+ line-height: 1;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.3s ease;
+ flex-shrink: 0;
+}
+
+.question-create-modal-close:hover {
+ background: rgba(255, 107, 107, 0.4);
+ border-color: #ff6b6b;
+ transform: scale(1.1);
+}
+
+/* Pack Import Modal */
+.pack-import-modal-backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.8);
+ backdrop-filter: blur(5px);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 2000;
+ padding: 20px;
+}
+
+.pack-import-modal-content {
+ background: rgba(20, 20, 30, 0.95);
+ backdrop-filter: blur(20px);
+ border-radius: 20px;
+ padding: clamp(15px, 3vh, 25px);
+ max-width: 900px;
+ width: 100%;
+ max-height: 90vh;
+ overflow-y: auto;
+ border: 2px solid rgba(255, 215, 0, 0.3);
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
+ position: relative;
+}
+
+.pack-import-modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: clamp(15px, 2vh, 20px);
+ padding-bottom: 15px;
+ border-bottom: 2px solid rgba(255, 215, 0, 0.2);
+}
+
+.pack-import-modal-header h3 {
+ color: var(--accent-primary);
+ font-size: clamp(1.3rem, 2.5vw, 1.8rem);
+ margin: 0;
+ text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
+}
+
+.pack-import-modal-close {
+ background: rgba(255, 107, 107, 0.2);
+ color: #ff6b6b;
+ border: 2px solid rgba(255, 107, 107, 0.5);
+ border-radius: 50%;
+ width: 40px;
+ height: 40px;
+ font-size: 2rem;
+ line-height: 1;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.3s ease;
+ flex-shrink: 0;
+}
+
+.pack-import-modal-close:hover {
+ background: rgba(255, 107, 107, 0.4);
+ border-color: #ff6b6b;
+ transform: scale(1.1);
+}
+
+/* Add button style */
+.questions-modal-add-button {
+ flex: 1;
+ padding: 12px 20px;
+ background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
+ color: var(--text-primary);
+ border: none;
+ border-radius: 12px;
+ font-size: 1rem;
+ font-weight: bold;
+ cursor: pointer;
+ transition: all 0.3s ease;
+}
+
+.questions-modal-add-button:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 15px rgba(78, 205, 196, 0.4);
+}
+
+/* Pack import section inside modal */
+.pack-import-modal-content .pack-import-section {
+ margin: 0;
+ padding: 0;
+ border: none;
+ background: transparent;
+}
+
diff --git a/src/components/Snowflakes.jsx b/src/components/Snowflakes.jsx
index d24c48d..5ddbd76 100644
--- a/src/components/Snowflakes.jsx
+++ b/src/components/Snowflakes.jsx
@@ -1,15 +1,27 @@
import { useEffect, useState } from 'react'
import { useTheme } from '../context/ThemeContext'
-const TARGET_COUNT = 30 // Target number of snowflakes
-const UPDATE_INTERVAL = 500 // Check every 500ms
+// Default values for particle animation settings
+const DEFAULT_TARGET_COUNT = 200
+const DEFAULT_UPDATE_INTERVAL = 1000
+const DEFAULT_DURATION_MIN = 7
+const DEFAULT_DURATION_MAX = 10
+const DEFAULT_INITIAL_DELAY_MAX = 10
-function createSnowflake(id, isInitial = false) {
+function createSnowflake(id, options = {}) {
+ const {
+ durationMin = DEFAULT_DURATION_MIN,
+ durationMax = DEFAULT_DURATION_MAX,
+ initialDelayMax = DEFAULT_INITIAL_DELAY_MAX,
+ isInitial = false,
+ } = options
+
+ const durationRange = durationMax - durationMin
return {
id: id || crypto.randomUUID(),
left: Math.random() * 100,
- duration: Math.random() * 3 + 7, // 7-10s
- delay: isInitial ? Math.random() * 2 : 0, // Only delay initial batch
+ duration: Math.random() * durationRange + durationMin,
+ delay: isInitial ? Math.random() * initialDelayMax : 0,
size: Math.random() * 10 + 10, // 10-20px
opacity: Math.random() * 0.5 + 0.5, // 0.5-1
createdAt: Date.now(),
@@ -40,19 +52,50 @@ const Snowflakes = ({ roomParticlesEnabled = null }) => {
return currentThemeData?.settings?.particleSymbol || '❄'
}
+ // Get particle animation settings from theme with defaults
+ const getParticleTargetCount = () => {
+ return currentThemeData?.settings?.particleTargetCount ?? DEFAULT_TARGET_COUNT
+ }
+
+ const getParticleUpdateInterval = () => {
+ return currentThemeData?.settings?.particleUpdateInterval ?? DEFAULT_UPDATE_INTERVAL
+ }
+
+ const getParticleDurationRange = () => {
+ return {
+ min: currentThemeData?.settings?.particleDurationMin ?? DEFAULT_DURATION_MIN,
+ max: currentThemeData?.settings?.particleDurationMax ?? DEFAULT_DURATION_MAX,
+ }
+ }
+
+ const getParticleInitialDelayMax = () => {
+ return currentThemeData?.settings?.particleInitialDelayMax ?? DEFAULT_INITIAL_DELAY_MAX
+ }
+
const particlesEnabled = getParticlesEnabled()
const particleSymbol = getParticleSymbol()
+ const targetCount = getParticleTargetCount()
+ const updateInterval = getParticleUpdateInterval()
+ const durationRange = getParticleDurationRange()
+ const initialDelayMax = getParticleInitialDelayMax()
// Initialize snowflakes only if particles are enabled
- // Also re-initialize when theme changes (particleSymbol might change)
+ // Also re-initialize when theme changes (particle settings might change)
useEffect(() => {
if (!particlesEnabled) {
setSnowflakes([])
return
}
- const initial = Array.from({ length: TARGET_COUNT }, (_, i) => createSnowflake(i, true))
+ const initial = Array.from({ length: targetCount }, (_, i) =>
+ createSnowflake(i, {
+ durationMin: durationRange.min,
+ durationMax: durationRange.max,
+ initialDelayMax,
+ isInitial: true,
+ })
+ )
setSnowflakes(initial)
- }, [particlesEnabled, particleSymbol, currentThemeData])
+ }, [particlesEnabled, particleSymbol, targetCount, durationRange.min, durationRange.max, initialDelayMax, currentThemeData])
// Update cycle - remove old snowflakes and add new ones
useEffect(() => {
@@ -73,16 +116,23 @@ const Snowflakes = ({ roomParticlesEnabled = null }) => {
// Add new snowflakes if below target
const newFlakes = [...filtered]
- while (newFlakes.length < TARGET_COUNT) {
- newFlakes.push(createSnowflake())
+ while (newFlakes.length < targetCount) {
+ newFlakes.push(
+ createSnowflake(null, {
+ durationMin: durationRange.min,
+ durationMax: durationRange.max,
+ initialDelayMax,
+ isInitial: false,
+ })
+ )
}
return newFlakes
})
- }, UPDATE_INTERVAL)
+ }, updateInterval)
return () => clearInterval(interval)
- }, [particlesEnabled])
+ }, [particlesEnabled, targetCount, updateInterval, durationRange.min, durationRange.max, initialDelayMax])
// Don't render if particles are disabled
if (!particlesEnabled) {
diff --git a/src/context/ThemeContext.jsx b/src/context/ThemeContext.jsx
index f897e94..c86b985 100644
--- a/src/context/ThemeContext.jsx
+++ b/src/context/ThemeContext.jsx
@@ -88,8 +88,22 @@ export const ThemeProvider = ({ children }) => {
// Apply theme settings
if (theme.settings) {
Object.entries(theme.settings).forEach(([key, value]) => {
- // Skip boolean values (like particlesEnabled) - they are handled separately
- if (typeof value !== 'boolean' && value !== null && value !== undefined) {
+ // Skip boolean and number values - they are handled separately (numbers for JS, booleans for logic)
+ // Only string values are used as CSS variables (except particle animation numbers)
+ const isParticleNumber = [
+ 'particleTargetCount',
+ 'particleUpdateInterval',
+ 'particleDurationMin',
+ 'particleDurationMax',
+ 'particleInitialDelayMax',
+ ].includes(key);
+
+ if (
+ typeof value === 'string' &&
+ !isParticleNumber &&
+ value !== null &&
+ value !== undefined
+ ) {
root.style.setProperty(`--${camelToKebab(key)}`, String(value));
}
});