stuff
This commit is contained in:
parent
18ca477ef1
commit
6c32a26fc4
10 changed files with 709 additions and 294 deletions
|
|
@ -29,6 +29,11 @@ export interface ThemeSettings {
|
||||||
particleSymbol?: string
|
particleSymbol?: string
|
||||||
particleColor?: string
|
particleColor?: string
|
||||||
particleGlow?: string
|
particleGlow?: string
|
||||||
|
particleTargetCount?: number
|
||||||
|
particleUpdateInterval?: number
|
||||||
|
particleDurationMin?: number
|
||||||
|
particleDurationMax?: number
|
||||||
|
particleInitialDelayMax?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Theme {
|
export interface Theme {
|
||||||
|
|
@ -251,4 +256,9 @@ export const DEFAULT_THEME_SETTINGS: ThemeSettings = {
|
||||||
particleSymbol: '❄',
|
particleSymbol: '❄',
|
||||||
particleColor: '#ffffff',
|
particleColor: '#ffffff',
|
||||||
particleGlow: 'rgba(255, 255, 255, 0.8)',
|
particleGlow: 'rgba(255, 255, 255, 0.8)',
|
||||||
|
particleTargetCount: 200,
|
||||||
|
particleUpdateInterval: 1000,
|
||||||
|
particleDurationMin: 7,
|
||||||
|
particleDurationMax: 10,
|
||||||
|
particleInitialDelayMax: 10,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,7 @@ export function ThemeEditorDialog({
|
||||||
setColors((prev) => ({ ...prev, [key]: value }))
|
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 }))
|
setSettings((prev) => ({ ...prev, [key]: value }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -594,6 +594,120 @@ export function ThemeEditorDialog({
|
||||||
description="Цвет свечения частиц. По умолчанию используется Text Glow цвет"
|
description="Цвет свечения частиц. По умолчанию используется Text Glow цвет"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
|
||||||
|
|
@ -287,6 +287,11 @@ async function main() {
|
||||||
particleSymbol: '❄',
|
particleSymbol: '❄',
|
||||||
particleColor: '#ffffff',
|
particleColor: '#ffffff',
|
||||||
particleGlow: 'rgba(255, 215, 0, 0.8)',
|
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: '🌸',
|
particleSymbol: '🌸',
|
||||||
particleColor: '#2d3748',
|
particleColor: '#2d3748',
|
||||||
particleGlow: 'rgba(47, 128, 237, 0.6)',
|
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: '🎉',
|
particleSymbol: '🎉',
|
||||||
particleColor: '#ffffff',
|
particleColor: '#ffffff',
|
||||||
particleGlow: 'rgba(255, 87, 108, 0.8)',
|
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: '✨',
|
particleSymbol: '✨',
|
||||||
particleColor: '#e0e0e0',
|
particleColor: '#e0e0e0',
|
||||||
particleGlow: 'rgba(100, 255, 218, 0.6)',
|
particleGlow: 'rgba(100, 255, 218, 0.6)',
|
||||||
|
particleTargetCount: 200,
|
||||||
|
particleUpdateInterval: 1000,
|
||||||
|
particleDurationMin: 7,
|
||||||
|
particleDurationMax: 10,
|
||||||
|
particleInitialDelayMax: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
IsString,
|
IsString,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsObject,
|
IsObject,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
|
|
@ -85,6 +86,31 @@ export class ThemeSettingsDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
particleGlow?: string;
|
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 {
|
export class CreateThemeDto {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
max-height: clamp(120px, 20vh, 250px);
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
/* Firefox scrollbar */
|
/* Firefox scrollbar */
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
|
|
@ -27,7 +27,6 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: clamp(8px, 1.5vw, 15px);
|
gap: clamp(8px, 1.5vw, 15px);
|
||||||
max-height: clamp(100px, 30vh, 200px);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,8 @@ const GameManagementModal = ({
|
||||||
{ text: '', points: 10 },
|
{ text: '', points: 10 },
|
||||||
])
|
])
|
||||||
const [jsonError, setJsonError] = useState('')
|
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 [selectedPack, setSelectedPack] = useState(null)
|
||||||
const [packQuestions, setPackQuestions] = useState([])
|
const [packQuestions, setPackQuestions] = useState([])
|
||||||
const [selectedQuestionIndices, setSelectedQuestionIndices] = useState(new Set())
|
const [selectedQuestionIndices, setSelectedQuestionIndices] = useState(new Set())
|
||||||
|
|
@ -193,10 +194,12 @@ const GameManagementModal = ({
|
||||||
setQuestionText(question.text)
|
setQuestionText(question.text)
|
||||||
setAnswers([...question.answers])
|
setAnswers([...question.answers])
|
||||||
setJsonError('')
|
setJsonError('')
|
||||||
|
setShowCreateQuestionModal(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancelEditQuestion = () => {
|
const handleCancelEditQuestion = () => {
|
||||||
resetQuestionForm()
|
resetQuestionForm()
|
||||||
|
setShowCreateQuestionModal(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAnswerChange = (index, field, value) => {
|
const handleAnswerChange = (index, field, value) => {
|
||||||
|
|
@ -263,6 +266,7 @@ const GameManagementModal = ({
|
||||||
|
|
||||||
onUpdateQuestions(updatedQuestions)
|
onUpdateQuestions(updatedQuestions)
|
||||||
resetQuestionForm()
|
resetQuestionForm()
|
||||||
|
setShowCreateQuestionModal(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteQuestion = (questionId) => {
|
const handleDeleteQuestion = (questionId) => {
|
||||||
|
|
@ -395,18 +399,21 @@ const GameManagementModal = ({
|
||||||
const handleExportJson = () => {
|
const handleExportJson = () => {
|
||||||
try {
|
try {
|
||||||
// Нормализуем вопросы, удаляя поле question если оно есть
|
// Нормализуем вопросы, удаляя поле question если оно есть
|
||||||
const normalizedQuestions = questions.map(q => {
|
// Если вопросов нет, экспортируем пустой массив
|
||||||
const { question, ...questionWithoutQuestion } = q
|
const normalizedQuestions = questions.length > 0
|
||||||
return {
|
? questions.map(q => {
|
||||||
...questionWithoutQuestion,
|
const { question, ...questionWithoutQuestion } = q
|
||||||
text: q.text || '',
|
return {
|
||||||
answers: q.answers.map(a => ({
|
...questionWithoutQuestion,
|
||||||
id: a.id,
|
text: q.text || '',
|
||||||
text: a.text,
|
answers: q.answers.map(a => ({
|
||||||
points: a.points
|
id: a.id,
|
||||||
}))
|
text: a.text,
|
||||||
}
|
points: a.points
|
||||||
})
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: []
|
||||||
const jsonString = JSON.stringify(normalizedQuestions, null, 2)
|
const jsonString = JSON.stringify(normalizedQuestions, null, 2)
|
||||||
const blob = new Blob([jsonString], { type: 'application/json' })
|
const blob = new Blob([jsonString], { type: 'application/json' })
|
||||||
const url = URL.createObjectURL(blob)
|
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 handleImportJson = () => {
|
||||||
const input = document.createElement('input')
|
const input = document.createElement('input')
|
||||||
input.type = 'file'
|
input.type = 'file'
|
||||||
|
|
@ -630,7 +594,7 @@ const GameManagementModal = ({
|
||||||
|
|
||||||
setSelectedQuestionIndices(new Set())
|
setSelectedQuestionIndices(new Set())
|
||||||
setSearchQuery('')
|
setSearchQuery('')
|
||||||
setShowPackImport(false)
|
setShowPackImportModal(false)
|
||||||
setJsonError('')
|
setJsonError('')
|
||||||
alert(`Импортировано ${copiedQuestions.length} вопросов`)
|
alert(`Импортировано ${copiedQuestions.length} вопросов`)
|
||||||
}
|
}
|
||||||
|
|
@ -1094,11 +1058,22 @@ const GameManagementModal = ({
|
||||||
|
|
||||||
<div className="questions-modal-actions">
|
<div className="questions-modal-actions">
|
||||||
<button
|
<button
|
||||||
className="questions-modal-template-button"
|
className="questions-modal-add-button"
|
||||||
onClick={handleDownloadTemplate}
|
onClick={() => {
|
||||||
|
resetQuestionForm()
|
||||||
|
setShowCreateQuestionModal(true)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
📋 Скачать шаблон
|
➕ Добавить вопрос
|
||||||
</button>
|
</button>
|
||||||
|
{availablePacks.length > 0 && (
|
||||||
|
<button
|
||||||
|
className="questions-modal-pack-import-button"
|
||||||
|
onClick={() => setShowPackImportModal(true)}
|
||||||
|
>
|
||||||
|
📦 Импортировать из пака
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="questions-modal-import-button"
|
className="questions-modal-import-button"
|
||||||
onClick={handleImportJson}
|
onClick={handleImportJson}
|
||||||
|
|
@ -1111,229 +1086,14 @@ const GameManagementModal = ({
|
||||||
>
|
>
|
||||||
📥 Экспорт JSON
|
📥 Экспорт JSON
|
||||||
</button>
|
</button>
|
||||||
{availablePacks.length > 0 && (
|
|
||||||
<button
|
|
||||||
className="questions-modal-pack-import-button"
|
|
||||||
onClick={() => setShowPackImport(!showPackImport)}
|
|
||||||
>
|
|
||||||
📦 {showPackImport ? 'Скрыть импорт' : 'Импорт из пака'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{jsonError && (
|
{jsonError && (
|
||||||
<div className="questions-modal-error">{jsonError}</div>
|
<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">
|
<div className="questions-modal-list">
|
||||||
<h4 className="questions-modal-list-title">
|
<h4 className="questions-modal-list-title">
|
||||||
Вопросы ({questions.length})
|
Вопросы ({questions.length})
|
||||||
</h4>
|
</h4>
|
||||||
{questions.length === 0 ? (
|
{questions.length === 0 ? (
|
||||||
|
|
@ -1430,6 +1190,249 @@ const GameManagementModal = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@
|
||||||
.answers-grid {
|
.answers-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
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);
|
column-gap: clamp(6px, 1.2vw, 12px);
|
||||||
row-gap: clamp(6px, 0.8vh, 12px);
|
row-gap: clamp(6px, 0.8vh, 12px);
|
||||||
flex: 1;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,27 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTheme } from '../context/ThemeContext'
|
import { useTheme } from '../context/ThemeContext'
|
||||||
|
|
||||||
const TARGET_COUNT = 30 // Target number of snowflakes
|
// Default values for particle animation settings
|
||||||
const UPDATE_INTERVAL = 500 // Check every 500ms
|
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 {
|
return {
|
||||||
id: id || crypto.randomUUID(),
|
id: id || crypto.randomUUID(),
|
||||||
left: Math.random() * 100,
|
left: Math.random() * 100,
|
||||||
duration: Math.random() * 3 + 7, // 7-10s
|
duration: Math.random() * durationRange + durationMin,
|
||||||
delay: isInitial ? Math.random() * 2 : 0, // Only delay initial batch
|
delay: isInitial ? Math.random() * initialDelayMax : 0,
|
||||||
size: Math.random() * 10 + 10, // 10-20px
|
size: Math.random() * 10 + 10, // 10-20px
|
||||||
opacity: Math.random() * 0.5 + 0.5, // 0.5-1
|
opacity: Math.random() * 0.5 + 0.5, // 0.5-1
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
|
@ -40,19 +52,50 @@ const Snowflakes = ({ roomParticlesEnabled = null }) => {
|
||||||
return currentThemeData?.settings?.particleSymbol || '❄'
|
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 particlesEnabled = getParticlesEnabled()
|
||||||
const particleSymbol = getParticleSymbol()
|
const particleSymbol = getParticleSymbol()
|
||||||
|
const targetCount = getParticleTargetCount()
|
||||||
|
const updateInterval = getParticleUpdateInterval()
|
||||||
|
const durationRange = getParticleDurationRange()
|
||||||
|
const initialDelayMax = getParticleInitialDelayMax()
|
||||||
|
|
||||||
// Initialize snowflakes only if particles are enabled
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!particlesEnabled) {
|
if (!particlesEnabled) {
|
||||||
setSnowflakes([])
|
setSnowflakes([])
|
||||||
return
|
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)
|
setSnowflakes(initial)
|
||||||
}, [particlesEnabled, particleSymbol, currentThemeData])
|
}, [particlesEnabled, particleSymbol, targetCount, durationRange.min, durationRange.max, initialDelayMax, currentThemeData])
|
||||||
|
|
||||||
// Update cycle - remove old snowflakes and add new ones
|
// Update cycle - remove old snowflakes and add new ones
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -73,16 +116,23 @@ const Snowflakes = ({ roomParticlesEnabled = null }) => {
|
||||||
|
|
||||||
// Add new snowflakes if below target
|
// Add new snowflakes if below target
|
||||||
const newFlakes = [...filtered]
|
const newFlakes = [...filtered]
|
||||||
while (newFlakes.length < TARGET_COUNT) {
|
while (newFlakes.length < targetCount) {
|
||||||
newFlakes.push(createSnowflake())
|
newFlakes.push(
|
||||||
|
createSnowflake(null, {
|
||||||
|
durationMin: durationRange.min,
|
||||||
|
durationMax: durationRange.max,
|
||||||
|
initialDelayMax,
|
||||||
|
isInitial: false,
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return newFlakes
|
return newFlakes
|
||||||
})
|
})
|
||||||
}, UPDATE_INTERVAL)
|
}, updateInterval)
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [particlesEnabled])
|
}, [particlesEnabled, targetCount, updateInterval, durationRange.min, durationRange.max, initialDelayMax])
|
||||||
|
|
||||||
// Don't render if particles are disabled
|
// Don't render if particles are disabled
|
||||||
if (!particlesEnabled) {
|
if (!particlesEnabled) {
|
||||||
|
|
|
||||||
|
|
@ -88,8 +88,22 @@ export const ThemeProvider = ({ children }) => {
|
||||||
// Apply theme settings
|
// Apply theme settings
|
||||||
if (theme.settings) {
|
if (theme.settings) {
|
||||||
Object.entries(theme.settings).forEach(([key, value]) => {
|
Object.entries(theme.settings).forEach(([key, value]) => {
|
||||||
// Skip boolean values (like particlesEnabled) - they are handled separately
|
// Skip boolean and number values - they are handled separately (numbers for JS, booleans for logic)
|
||||||
if (typeof value !== 'boolean' && value !== null && value !== undefined) {
|
// 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));
|
root.style.setProperty(`--${camelToKebab(key)}`, String(value));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue