This commit is contained in:
Dmitry 2026-01-10 22:59:53 +03:00
parent 18ca477ef1
commit 6c32a26fc4
10 changed files with 709 additions and 294 deletions

View file

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

View file

@ -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 цвет"
/>
</div>
<div className="space-y-4 pt-4 border-t">
<h4 className="text-md font-medium">Animation Settings (Настройки анимации)</h4>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="particleTargetCount">
Target Count (Целевое количество)
</Label>
<Input
id="particleTargetCount"
type="number"
min="1"
max="1000"
value={settings.particleTargetCount ?? DEFAULT_THEME_SETTINGS.particleTargetCount ?? 200}
onChange={(e) => {
const value = parseInt(e.target.value, 10)
if (!isNaN(value) && value > 0) {
updateSetting('particleTargetCount', value)
}
}}
/>
<p className="text-xs text-muted-foreground">
Целевое количество снежинок на экране
</p>
</div>
<div className="space-y-2">
<Label htmlFor="particleUpdateInterval">
Update Interval (мс)
</Label>
<Input
id="particleUpdateInterval"
type="number"
min="100"
max="10000"
step="100"
value={settings.particleUpdateInterval ?? DEFAULT_THEME_SETTINGS.particleUpdateInterval ?? 1000}
onChange={(e) => {
const value = parseInt(e.target.value, 10)
if (!isNaN(value) && value > 0) {
updateSetting('particleUpdateInterval', value)
}
}}
/>
<p className="text-xs text-muted-foreground">
Интервал обновления частиц в миллисекундах
</p>
</div>
<div className="space-y-2">
<Label htmlFor="particleDurationMin">
Duration Min (сек)
</Label>
<Input
id="particleDurationMin"
type="number"
min="1"
max="30"
step="0.5"
value={settings.particleDurationMin ?? DEFAULT_THEME_SETTINGS.particleDurationMin ?? 7}
onChange={(e) => {
const value = parseFloat(e.target.value)
if (!isNaN(value) && value > 0) {
updateSetting('particleDurationMin', value)
}
}}
/>
<p className="text-xs text-muted-foreground">
Минимальная длительность анимации в секундах
</p>
</div>
<div className="space-y-2">
<Label htmlFor="particleDurationMax">
Duration Max (сек)
</Label>
<Input
id="particleDurationMax"
type="number"
min="1"
max="30"
step="0.5"
value={settings.particleDurationMax ?? DEFAULT_THEME_SETTINGS.particleDurationMax ?? 10}
onChange={(e) => {
const value = parseFloat(e.target.value)
if (!isNaN(value) && value > 0) {
updateSetting('particleDurationMax', value)
}
}}
/>
<p className="text-xs text-muted-foreground">
Максимальная длительность анимации в секундах
</p>
</div>
<div className="space-y-2">
<Label htmlFor="particleInitialDelayMax">
Initial Delay Max (сек)
</Label>
<Input
id="particleInitialDelayMax"
type="number"
min="0"
max="30"
step="0.5"
value={settings.particleInitialDelayMax ?? DEFAULT_THEME_SETTINGS.particleInitialDelayMax ?? 10}
onChange={(e) => {
const value = parseFloat(e.target.value)
if (!isNaN(value) && value >= 0) {
updateSetting('particleInitialDelayMax', value)
}
}}
/>
<p className="text-xs text-muted-foreground">
Максимальная начальная задержка для первой партии частиц в секундах
</p>
</div>
</div>
</div>
</div>
</TabsContent>
</Tabs>

View file

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

View file

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

View file

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

View file

@ -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 = ({
<div className="questions-modal-actions">
<button
className="questions-modal-template-button"
onClick={handleDownloadTemplate}
className="questions-modal-add-button"
onClick={() => {
resetQuestionForm()
setShowCreateQuestionModal(true)
}}
>
📋 Скачать шаблон
Добавить вопрос
</button>
{availablePacks.length > 0 && (
<button
className="questions-modal-pack-import-button"
onClick={() => setShowPackImportModal(true)}
>
📦 Импортировать из пака
</button>
)}
<button
className="questions-modal-import-button"
onClick={handleImportJson}
@ -1111,229 +1086,14 @@ const GameManagementModal = ({
>
📥 Экспорт JSON
</button>
{availablePacks.length > 0 && (
<button
className="questions-modal-pack-import-button"
onClick={() => setShowPackImport(!showPackImport)}
>
📦 {showPackImport ? 'Скрыть импорт' : 'Импорт из пака'}
</button>
)}
</div>
{jsonError && (
<div className="questions-modal-error">{jsonError}</div>
)}
{showPackImport && availablePacks.length > 0 && (
<div className="pack-import-section">
<h4>Импорт вопросов из пака</h4>
<select
value={selectedPack || ''}
onChange={(e) => handleSelectPack(e.target.value)}
className="pack-import-select"
>
<option value="">-- Выберите пак --</option>
{availablePacks.map(pack => (
<option key={pack.id} value={pack.id}>
{pack.name} ({pack.questionCount} вопросов)
</option>
))}
</select>
{packQuestions.length > 0 && (
<div className="pack-questions-list">
{/* Поиск */}
<div className="pack-search-container">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="🔍 Поиск вопросов..."
className="pack-search-input"
/>
</div>
<div className="pack-questions-header">
<div className="pack-questions-header-left">
<span>Выберите вопросы для импорта:</span>
<div className="pack-select-all-buttons">
{filteredPackQuestions.length > 0 && (
<>
{areAllVisibleSelected() ? (
<button
onClick={handleDeselectAll}
className="pack-select-all-button"
>
Снять выбор
</button>
) : (
<button
onClick={handleSelectAll}
className="pack-select-all-button"
>
Выбрать все ({filteredPackQuestions.length})
</button>
)}
</>
)}
</div>
</div>
<button
onClick={handleImportSelected}
disabled={selectedQuestionIndices.size === 0}
className="pack-import-confirm-button"
>
Импортировать ({selectedQuestionIndices.size})
</button>
</div>
<div className="pack-questions-items">
{filteredPackQuestions.length === 0 ? (
<div className="pack-no-results">
{searchQuery ? 'Вопросы не найдены' : 'Нет вопросов в паке'}
</div>
) : (
filteredPackQuestions.map((q, filteredIdx) => {
const originalIndex = packQuestions.findIndex(pq => pq === q)
return (
<div key={originalIndex} className="pack-question-item">
<input
type="checkbox"
checked={selectedQuestionIndices.has(originalIndex)}
onChange={() => handleToggleQuestion(originalIndex)}
/>
<div className="pack-question-content">
<strong>{q.text || ''}</strong>
<span className="pack-question-info">
{q.answers?.length || 0} ответов
</span>
</div>
<button
onClick={() => handleViewQuestion(q)}
className="pack-view-question-button"
title="Просмотр вопроса"
>
👁
</button>
</div>
)
})
)}
</div>
</div>
)}
{/* Модальное окно просмотра вопроса */}
{viewingQuestion && (
<div className="pack-question-viewer-backdrop" onClick={handleCloseViewer}>
<div className="pack-question-viewer" onClick={(e) => e.stopPropagation()}>
<div className="pack-question-viewer-header">
<h4>Просмотр вопроса</h4>
<button
className="pack-question-viewer-close"
onClick={handleCloseViewer}
>
×
</button>
</div>
<div className="pack-question-viewer-content">
<div className="pack-question-viewer-text">
{viewingQuestion.text || ''}
</div>
<button
className="pack-show-answers-button"
onClick={() => setShowAnswers(!showAnswers)}
>
{showAnswers ? '🙈 Скрыть ответы' : '👁 Показать ответы'}
</button>
{showAnswers && (
<div className="pack-question-answers">
{viewingQuestion.answers?.map((answer, idx) => (
<div key={idx} className="pack-answer-item">
<span className="pack-answer-text">{answer.text}</span>
<span className="pack-answer-points">{answer.points} очков</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
)}
<div className="questions-modal-form">
<input
type="text"
value={questionText}
onChange={(e) => setQuestionText(e.target.value)}
placeholder="Введите текст вопроса"
className="questions-modal-input"
/>
<div className="questions-modal-answers">
<div className="questions-modal-answers-header">
<span>Ответы:</span>
<button
className="questions-modal-add-answer-button"
onClick={handleAddAnswer}
type="button"
>
+ Добавить ответ
</button>
</div>
{answers.map((answer, index) => (
<div key={index} className="questions-modal-answer-row">
<input
type="text"
value={answer.text}
onChange={(e) => handleAnswerChange(index, 'text', e.target.value)}
placeholder={`Ответ ${index + 1}`}
className="questions-modal-answer-input"
/>
<input
type="number"
value={answer.points}
onChange={(e) => handleAnswerChange(index, 'points', e.target.value)}
className="questions-modal-points-input"
min="0"
/>
{answers.length > 1 && (
<button
className="questions-modal-remove-answer-button"
onClick={() => handleRemoveAnswer(index)}
type="button"
>
×
</button>
)}
</div>
))}
</div>
<div className="questions-modal-form-buttons">
<button
className="questions-modal-save-button"
onClick={handleSaveQuestion}
>
{editingQuestion ? 'Сохранить изменения' : 'Добавить вопрос'}
</button>
{editingQuestion && (
<button
className="questions-modal-cancel-button"
onClick={handleCancelEditQuestion}
>
Отмена
</button>
)}
</div>
</div>
<div className="questions-modal-list">
<h4 className="questions-modal-list-title">
<h4 className="questions-modal-list-title">
Вопросы ({questions.length})
</h4>
{questions.length === 0 ? (
@ -1430,6 +1190,249 @@ const GameManagementModal = ({
)}
</div>
</div>
{/* Модалка создания вопроса */}
{showCreateQuestionModal && (
<div className="question-create-modal-backdrop" onClick={(e) => {
if (e.target === e.currentTarget) {
handleCancelEditQuestion()
}
}}>
<div className="question-create-modal-content" onClick={(e) => e.stopPropagation()}>
<div className="question-create-modal-header">
<h3>{editingQuestion ? 'Редактировать вопрос' : 'Добавить вопрос'}</h3>
<button className="question-create-modal-close" onClick={handleCancelEditQuestion}>×</button>
</div>
{jsonError && (
<div className="questions-modal-error">{jsonError}</div>
)}
<div className="questions-modal-form">
<input
type="text"
value={questionText}
onChange={(e) => setQuestionText(e.target.value)}
placeholder="Введите текст вопроса"
className="questions-modal-input"
/>
<div className="questions-modal-answers">
<div className="questions-modal-answers-header">
<span>Ответы:</span>
<button
className="questions-modal-add-answer-button"
onClick={handleAddAnswer}
type="button"
>
+ Добавить ответ
</button>
</div>
{answers.map((answer, index) => (
<div key={index} className="questions-modal-answer-row">
<input
type="text"
value={answer.text}
onChange={(e) => handleAnswerChange(index, 'text', e.target.value)}
placeholder={`Ответ ${index + 1}`}
className="questions-modal-answer-input"
/>
<input
type="number"
value={answer.points}
onChange={(e) => handleAnswerChange(index, 'points', e.target.value)}
className="questions-modal-points-input"
min="0"
/>
{answers.length > 1 && (
<button
className="questions-modal-remove-answer-button"
onClick={() => handleRemoveAnswer(index)}
type="button"
>
×
</button>
)}
</div>
))}
</div>
<div className="questions-modal-form-buttons">
<button
className="questions-modal-save-button"
onClick={handleSaveQuestion}
>
{editingQuestion ? 'Сохранить изменения' : 'Добавить вопрос'}
</button>
<button
className="questions-modal-cancel-button"
onClick={handleCancelEditQuestion}
>
Отмена
</button>
</div>
</div>
</div>
</div>
)}
{/* Модалка импорта из пака */}
{showPackImportModal && availablePacks.length > 0 && (
<div className="pack-import-modal-backdrop" onClick={(e) => {
if (e.target === e.currentTarget) {
setShowPackImportModal(false)
}
}}>
<div className="pack-import-modal-content" onClick={(e) => e.stopPropagation()}>
<div className="pack-import-modal-header">
<h3>Импорт вопросов из пака</h3>
<button
className="pack-import-modal-close"
onClick={() => setShowPackImportModal(false)}
>
×
</button>
</div>
<div className="pack-import-section">
<select
value={selectedPack || ''}
onChange={(e) => handleSelectPack(e.target.value)}
className="pack-import-select"
>
<option value="">-- Выберите пак --</option>
{availablePacks.map(pack => (
<option key={pack.id} value={pack.id}>
{pack.name} ({pack.questionCount} вопросов)
</option>
))}
</select>
{packQuestions.length > 0 && (
<div className="pack-questions-list">
{/* Поиск */}
<div className="pack-search-container">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="🔍 Поиск вопросов..."
className="pack-search-input"
/>
</div>
<div className="pack-questions-header">
<div className="pack-questions-header-left">
<span>Выберите вопросы для импорта:</span>
<div className="pack-select-all-buttons">
{filteredPackQuestions.length > 0 && (
<>
{areAllVisibleSelected() ? (
<button
onClick={handleDeselectAll}
className="pack-select-all-button"
>
Снять выбор
</button>
) : (
<button
onClick={handleSelectAll}
className="pack-select-all-button"
>
Выбрать все ({filteredPackQuestions.length})
</button>
)}
</>
)}
</div>
</div>
<button
onClick={handleImportSelected}
disabled={selectedQuestionIndices.size === 0}
className="pack-import-confirm-button"
>
Импортировать ({selectedQuestionIndices.size})
</button>
</div>
<div className="pack-questions-items">
{filteredPackQuestions.length === 0 ? (
<div className="pack-no-results">
{searchQuery ? 'Вопросы не найдены' : 'Нет вопросов в паке'}
</div>
) : (
filteredPackQuestions.map((q, filteredIdx) => {
const originalIndex = packQuestions.findIndex(pq => pq === q)
return (
<div key={originalIndex} className="pack-question-item">
<input
type="checkbox"
checked={selectedQuestionIndices.has(originalIndex)}
onChange={() => handleToggleQuestion(originalIndex)}
/>
<div className="pack-question-content">
<strong>{q.text || ''}</strong>
<span className="pack-question-info">
{q.answers?.length || 0} ответов
</span>
</div>
<button
onClick={() => handleViewQuestion(q)}
className="pack-view-question-button"
title="Просмотр вопроса"
>
👁
</button>
</div>
)
})
)}
</div>
</div>
)}
{/* Модальное окно просмотра вопроса */}
{viewingQuestion && (
<div className="pack-question-viewer-backdrop" onClick={handleCloseViewer}>
<div className="pack-question-viewer" onClick={(e) => e.stopPropagation()}>
<div className="pack-question-viewer-header">
<h4>Просмотр вопроса</h4>
<button
className="pack-question-viewer-close"
onClick={handleCloseViewer}
>
×
</button>
</div>
<div className="pack-question-viewer-content">
<div className="pack-question-viewer-text">
{viewingQuestion.text || ''}
</div>
<button
className="pack-show-answers-button"
onClick={() => setShowAnswers(!showAnswers)}
>
{showAnswers ? '🙈 Скрыть ответы' : '👁 Показать ответы'}
</button>
{showAnswers && (
<div className="pack-question-answers">
{viewingQuestion.answers?.map((answer, idx) => (
<div key={idx} className="pack-answer-item">
<span className="pack-answer-text">{answer.text}</span>
<span className="pack-answer-points">{answer.points} очков</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
)
}

View file

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

View file

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

View file

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

View file

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