From 1fcc5ed70d525910687c9b7c046252cf12cd581b Mon Sep 17 00:00:00 2001 From: Primakov Alexandr Alexandrovich Date: Tue, 11 Mar 2025 23:50:50 +0300 Subject: [PATCH 1/4] init Questionnaire --- server/index.js | 1 + server/models/questionnaire.js | 60 + server/routers/questioneer/index.js | 421 ++++ server/routers/questioneer/public/admin.html | 70 + server/routers/questioneer/public/create.html | 129 ++ server/routers/questioneer/public/edit.html | 146 ++ server/routers/questioneer/public/index.html | 42 + server/routers/questioneer/public/poll.html | 45 + .../questioneer/public/static/css/style.css | 1830 +++++++++++++++++ .../questioneer/public/static/js/admin.js | 294 +++ .../questioneer/public/static/js/common.js | 236 +++ .../questioneer/public/static/js/create.js | 343 +++ .../questioneer/public/static/js/edit.js | 332 +++ .../questioneer/public/static/js/index.js | 67 + .../questioneer/public/static/js/poll.js | 1069 ++++++++++ 15 files changed, 5085 insertions(+) create mode 100644 server/models/questionnaire.js create mode 100644 server/routers/questioneer/index.js create mode 100644 server/routers/questioneer/public/admin.html create mode 100644 server/routers/questioneer/public/create.html create mode 100644 server/routers/questioneer/public/edit.html create mode 100644 server/routers/questioneer/public/index.html create mode 100644 server/routers/questioneer/public/poll.html create mode 100644 server/routers/questioneer/public/static/css/style.css create mode 100644 server/routers/questioneer/public/static/js/admin.js create mode 100644 server/routers/questioneer/public/static/js/common.js create mode 100644 server/routers/questioneer/public/static/js/create.js create mode 100644 server/routers/questioneer/public/static/js/edit.js create mode 100644 server/routers/questioneer/public/static/js/index.js create mode 100644 server/routers/questioneer/public/static/js/poll.js diff --git a/server/index.js b/server/index.js index 1e81309..2a179cd 100644 --- a/server/index.js +++ b/server/index.js @@ -90,6 +90,7 @@ app.use("/dhs-testing", require("./routers/dhs-testing")) app.use("/gamehub", require("./routers/gamehub")) app.use("/esc", require("./routers/esc")) app.use('/connectme', require('./routers/connectme')) +app.use('/questioneer', require('./routers/questioneer')) app.use(require("./error")) diff --git a/server/models/questionnaire.js b/server/models/questionnaire.js new file mode 100644 index 0000000..e08928e --- /dev/null +++ b/server/models/questionnaire.js @@ -0,0 +1,60 @@ +const mongoose = require('mongoose'); + +// Типы вопросов +const QUESTION_TYPES = { + SINGLE_CHOICE: 'single_choice', // Один вариант + MULTIPLE_CHOICE: 'multiple_choice', // Несколько вариантов + TEXT: 'text', // Текстовый ответ + RATING: 'rating', // Оценка по шкале + TAG_CLOUD: 'tag_cloud' // Облако тегов +}; + +// Типы отображения +const DISPLAY_TYPES = { + DEFAULT: 'default', + TAG_CLOUD: 'tag_cloud', + VOTING: 'voting', + POLL: 'poll' +}; + +// Схема варианта ответа +const optionSchema = new mongoose.Schema({ + text: { type: String, required: true }, + count: { type: Number, default: 0 } // счетчик голосов +}); + +// Схема вопроса +const questionSchema = new mongoose.Schema({ + text: { type: String, required: true }, + type: { + type: String, + enum: Object.values(QUESTION_TYPES), + required: true + }, + options: [optionSchema], + required: { type: Boolean, default: false } +}); + +// Схема опроса +const questionnaireSchema = new mongoose.Schema({ + title: { type: String, required: true }, + description: { type: String }, + questions: [questionSchema], + displayType: { + type: String, + enum: Object.values(DISPLAY_TYPES), + default: DISPLAY_TYPES.DEFAULT + }, + createdAt: { type: Date, default: Date.now }, + updatedAt: { type: Date, default: Date.now }, + adminLink: { type: String, required: true }, // ссылка для редактирования + publicLink: { type: String, required: true } // ссылка для голосования +}); + +const Questionnaire = mongoose.model('Questionnaire', questionnaireSchema); + +module.exports = { + Questionnaire, + QUESTION_TYPES, + DISPLAY_TYPES +}; \ No newline at end of file diff --git a/server/routers/questioneer/index.js b/server/routers/questioneer/index.js new file mode 100644 index 0000000..c031883 --- /dev/null +++ b/server/routers/questioneer/index.js @@ -0,0 +1,421 @@ +const express = require('express') +const { Router } = require("express") +const router = Router() +const crypto = require('crypto') +const path = require('path') +const { getDB } = require('../../utils/mongo') +const mongoose = require('mongoose') + +// Используем одно определение модели +const Questionnaire = (() => { + // Если модель уже существует, используем её + if (mongoose.models.Questionnaire) { + return mongoose.models.Questionnaire; + } + + // Иначе создаем новую модель + const questionnaireSchema = new mongoose.Schema({ + title: { type: String, required: true }, + description: { type: String }, + questions: [{ + text: { type: String, required: true }, + type: { + type: String, + enum: ['single_choice', 'multiple_choice', 'text', 'rating', 'tag_cloud', 'scale'], + required: true + }, + required: { type: Boolean, default: false }, + options: [{ + text: { type: String, required: true }, + count: { type: Number, default: 0 } + }], + scaleMin: { type: Number }, + scaleMax: { type: Number }, + scaleMinLabel: { type: String }, + scaleMaxLabel: { type: String }, + answers: [{ type: String }], + scaleValues: [{ type: Number }], + tags: [{ + text: { type: String }, + count: { type: Number, default: 1 } + }] + }], + displayType: { + type: String, + enum: ['default', 'tag_cloud', 'voting', 'poll', 'step_by_step'], + default: 'step_by_step' + }, + createdAt: { type: Date, default: Date.now }, + updatedAt: { type: Date, default: Date.now }, + adminLink: { type: String, required: true }, + publicLink: { type: String, required: true } + }); + + return mongoose.model('Questionnaire', questionnaireSchema); +})(); + +// Middleware для парсинга JSON +router.use(express.json()); + +// Обслуживание статичных файлов - проверяем правильность пути +router.use('/static', express.static(path.join(__dirname, 'public', 'static'))); + +// Получить главную страницу +router.get("/", (req, res) => { + res.sendFile(path.join(__dirname, 'public/index.html')) +}) + +// Страница создания нового опроса +router.get("/create", (req, res) => { + res.sendFile(path.join(__dirname, 'public/create.html')) +}) + +// Страница редактирования опроса +router.get("/edit/:adminLink", (req, res) => { + res.sendFile(path.join(__dirname, 'public/edit.html')) +}) + +// Страница администрирования опроса +router.get("/admin/:adminLink", (req, res) => { + res.sendFile(path.join(__dirname, 'public/admin.html')) +}) + +// Страница голосования +router.get("/poll/:publicLink", (req, res) => { + res.sendFile(path.join(__dirname, 'public/poll.html')) +}) + +// API для работы с опросами + +// Создать новый опрос +router.post("/api/questionnaires", async (req, res) => { + try { + // Проверка наличия нужных полей + const { title, questions } = req.body; + + if (!title || !Array.isArray(questions) || questions.length === 0) { + return res.json({ success: false, error: 'Необходимо указать название и хотя бы один вопрос' }); + } + + // Создаем уникальные ссылки + const adminLink = crypto.randomBytes(6).toString('hex'); + const publicLink = crypto.randomBytes(6).toString('hex'); + + // Устанавливаем тип отображения step_by_step, если не указан + if (!req.body.displayType) { + req.body.displayType = 'step_by_step'; + } + + // Создаем новый опросник + const questionnaire = new Questionnaire({ + ...req.body, + adminLink, + publicLink + }); + + await questionnaire.save(); + + res.json({ + success: true, + data: { + adminLink, + publicLink + } + }); + } catch (error) { + console.error('Error creating questionnaire:', error); + res.json({ success: false, error: error.message }); + } +}); + +// Получить все опросы +router.get("/api/questionnaires", async (req, res) => { + try { + const questionnaires = await Questionnaire.find({}, { + title: 1, + description: 1, + createdAt: 1, + updatedAt: 1, + _id: 1, + adminLink: 1, + publicLink: 1 + }).sort({ createdAt: -1 }) + + res.status(200).json({ + success: true, + data: questionnaires + }) + } catch (error) { + console.error('Error fetching questionnaires:', error) + res.status(500).json({ + success: false, + error: 'Failed to fetch questionnaires' + }) + } +}) + +// Получить опрос по ID для админа +router.get("/api/questionnaires/admin/:adminLink", async (req, res) => { + try { + const { adminLink } = req.params + const questionnaire = await Questionnaire.findOne({ adminLink }) + + if (!questionnaire) { + return res.status(404).json({ + success: false, + error: 'Questionnaire not found' + }) + } + + res.status(200).json({ + success: true, + data: questionnaire + }) + } catch (error) { + console.error('Error fetching questionnaire:', error) + res.status(500).json({ + success: false, + error: 'Failed to fetch questionnaire' + }) + } +}) + +// Получить опрос по публичной ссылке (для голосования) +router.get("/api/questionnaires/public/:publicLink", async (req, res) => { + try { + const { publicLink } = req.params + const questionnaire = await Questionnaire.findOne({ publicLink }) + + if (!questionnaire) { + return res.status(404).json({ + success: false, + error: 'Questionnaire not found' + }) + } + + res.status(200).json({ + success: true, + data: questionnaire + }) + } catch (error) { + console.error('Error fetching questionnaire:', error) + res.status(500).json({ + success: false, + error: 'Failed to fetch questionnaire' + }) + } +}) + +// Обновить опрос +router.put("/api/questionnaires/:adminLink", async (req, res) => { + try { + const { adminLink } = req.params + const { title, description, questions, displayType } = req.body + + const updatedQuestionnaire = await Questionnaire.findOneAndUpdate( + { adminLink }, + { + title, + description, + questions, + displayType, + updatedAt: Date.now() + }, + { new: true } + ) + + if (!updatedQuestionnaire) { + return res.status(404).json({ + success: false, + error: 'Questionnaire not found' + }) + } + + res.status(200).json({ + success: true, + data: updatedQuestionnaire + }) + } catch (error) { + console.error('Error updating questionnaire:', error) + res.status(500).json({ + success: false, + error: 'Failed to update questionnaire' + }) + } +}) + +// Удалить опрос +router.delete("/api/questionnaires/:adminLink", async (req, res) => { + try { + const { adminLink } = req.params + + const deletedQuestionnaire = await Questionnaire.findOneAndDelete({ adminLink }) + + if (!deletedQuestionnaire) { + return res.status(404).json({ + success: false, + error: 'Questionnaire not found' + }) + } + + res.status(200).json({ + success: true, + message: 'Questionnaire deleted successfully' + }) + } catch (error) { + console.error('Error deleting questionnaire:', error) + res.status(500).json({ + success: false, + error: 'Failed to delete questionnaire' + }) + } +}) + +// Голосование в опросе +router.post("/api/vote/:publicLink", async (req, res) => { + try { + const { publicLink } = req.params + const { answers } = req.body + + const questionnaire = await Questionnaire.findOne({ publicLink }) + + if (!questionnaire) { + return res.status(404).json({ + success: false, + error: 'Questionnaire not found' + }) + } + + // Обновить счетчики голосов + answers.forEach(answer => { + const { questionIndex, optionIndices, textAnswer, scaleValue, tagTexts } = answer + + // Обработка одиночного и множественного выбора + if (Array.isArray(optionIndices)) { + // Для множественного выбора + optionIndices.forEach(optionIndex => { + if (questionnaire.questions[questionIndex] && + questionnaire.questions[questionIndex].options[optionIndex]) { + questionnaire.questions[questionIndex].options[optionIndex].count += 1 + } + }) + } else if (typeof optionIndices === 'number') { + // Для единичного выбора + if (questionnaire.questions[questionIndex] && + questionnaire.questions[questionIndex].options[optionIndices]) { + questionnaire.questions[questionIndex].options[optionIndices].count += 1 + } + } + + // Сохраняем текстовые ответы + if (textAnswer && questionnaire.questions[questionIndex]) { + if (!questionnaire.questions[questionIndex].answers) { + questionnaire.questions[questionIndex].answers = []; + } + questionnaire.questions[questionIndex].answers.push(textAnswer); + } + + // Сохраняем ответы шкалы оценки + if (scaleValue !== undefined && questionnaire.questions[questionIndex]) { + if (!questionnaire.questions[questionIndex].scaleValues) { + questionnaire.questions[questionIndex].scaleValues = []; + } + questionnaire.questions[questionIndex].scaleValues.push(scaleValue); + } + + // Сохраняем теги + if (Array.isArray(tagTexts) && tagTexts.length > 0 && questionnaire.questions[questionIndex]) { + if (!questionnaire.questions[questionIndex].tags) { + questionnaire.questions[questionIndex].tags = []; + } + + tagTexts.forEach(tagText => { + const existingTag = questionnaire.questions[questionIndex].tags.find(t => t.text === tagText); + if (existingTag) { + existingTag.count += 1; + } else { + questionnaire.questions[questionIndex].tags.push({ text: tagText, count: 1 }); + } + }); + } + }) + + await questionnaire.save() + + res.status(200).json({ + success: true, + message: 'Vote registered successfully' + }) + } catch (error) { + console.error('Error registering vote:', error) + res.status(500).json({ + success: false, + error: 'Failed to register vote' + }) + } +}) + +// Получить результаты опроса по публичной ссылке +router.get("/api/results/:publicLink", async (req, res) => { + try { + const { publicLink } = req.params; + const questionnaire = await Questionnaire.findOne({ publicLink }); + + if (!questionnaire) { + return res.status(404).json({ + success: false, + error: 'Questionnaire not found' + }); + } + + // Формируем результаты для отправки + const results = { + title: questionnaire.title, + description: questionnaire.description, + questions: questionnaire.questions.map(question => { + const result = { + text: question.text, + type: question.type + }; + + // Добавляем варианты ответов, если они есть + if (question.options && question.options.length > 0) { + result.options = question.options; + } + + // Добавляем текстовые ответы, если они есть + if (question.answers && question.answers.length > 0) { + result.answers = question.answers; + } + + // Добавляем результаты шкалы, если они есть + if (question.scaleValues && question.scaleValues.length > 0) { + result.scaleValues = question.scaleValues; + + // Считаем среднее значение + result.scaleAverage = question.scaleValues.reduce((a, b) => a + b, 0) / question.scaleValues.length; + } + + // Добавляем теги, если они есть + if (question.tags && question.tags.length > 0) { + result.tags = question.tags; + } + + return result; + }) + }; + + res.status(200).json({ + success: true, + data: results + }); + } catch (error) { + console.error('Error fetching poll results:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch poll results' + }); + } +}); + +module.exports = router diff --git a/server/routers/questioneer/public/admin.html b/server/routers/questioneer/public/admin.html new file mode 100644 index 0000000..5928fc2 --- /dev/null +++ b/server/routers/questioneer/public/admin.html @@ -0,0 +1,70 @@ + + + + + + Управление опросом + + + + + + +
+

Управление опросом

+ +
Загрузка опроса...
+ + +
+ + + + + + + \ No newline at end of file diff --git a/server/routers/questioneer/public/create.html b/server/routers/questioneer/public/create.html new file mode 100644 index 0000000..61cddc9 --- /dev/null +++ b/server/routers/questioneer/public/create.html @@ -0,0 +1,129 @@ + + + + + + Создание нового опроса + + + +
+

Создание нового опроса

+ +
+
+
+ + +
+ +
+ + +
+ + + +
+

Вопросы

+
+ + +
+ +
+ Отмена + +
+
+
+
+ + + + + + + + + + + + \ No newline at end of file diff --git a/server/routers/questioneer/public/edit.html b/server/routers/questioneer/public/edit.html new file mode 100644 index 0000000..a67890f --- /dev/null +++ b/server/routers/questioneer/public/edit.html @@ -0,0 +1,146 @@ + + + + + + Редактирование опроса + + + +
+

Редактирование опроса

+ +
Загрузка опроса...
+ + +
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/server/routers/questioneer/public/index.html b/server/routers/questioneer/public/index.html new file mode 100644 index 0000000..ea8b6ef --- /dev/null +++ b/server/routers/questioneer/public/index.html @@ -0,0 +1,42 @@ + + + + + + Анонимные опросы + + + + + + +
+

Сервис анонимных опросов

+ + + +
+

Ваши опросы

+
+

Загрузка опросов...

+
+
+
+ + + + + + \ No newline at end of file diff --git a/server/routers/questioneer/public/poll.html b/server/routers/questioneer/public/poll.html new file mode 100644 index 0000000..0aa8463 --- /dev/null +++ b/server/routers/questioneer/public/poll.html @@ -0,0 +1,45 @@ + + + + + + Участие в опросе + + + +
+
Загрузка опроса...
+ + +
+ + + + + + \ No newline at end of file diff --git a/server/routers/questioneer/public/static/css/style.css b/server/routers/questioneer/public/static/css/style.css new file mode 100644 index 0000000..5dc4bcf --- /dev/null +++ b/server/routers/questioneer/public/static/css/style.css @@ -0,0 +1,1830 @@ +/* Темный стиль */ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + margin: 0; + padding: 0; + color: #e0e0e0; + background-color: #1e1e1e; + line-height: 1.6; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +h1, h2, h3, h4, h5, h6 { + margin-top: 0; + margin-bottom: 0.5rem; + font-weight: 500; + line-height: 1.2; +} + +h1 { + font-size: 2.5rem; + margin-bottom: 1.5rem; + color: #61dafb; +} + +h2 { + font-size: 2rem; + color: #64b5f6; +} + +h3 { + font-size: 1.5rem; + color: #81c784; +} + +p { + margin-top: 0; + margin-bottom: 1rem; +} + +/* Кнопки */ +.btn { + display: inline-block; + font-weight: 400; + text-align: center; + vertical-align: middle; + user-select: none; + padding: 0.5rem 1rem; + font-size: 1rem; + line-height: 1.5; + border-radius: 0.25rem; + text-decoration: none; + cursor: pointer; + background-color: #2196f3; + color: white; + border: 1px solid transparent; + transition: all 0.15s ease-in-out; +} + +.btn:hover { + background-color: #1976d2; +} + +.btn-primary { + background-color: #4caf50; +} + +.btn-primary:hover { + background-color: #388e3c; +} + +.btn-secondary { + background-color: #757575; +} + +.btn-secondary:hover { + background-color: #616161; +} + +.btn-danger { + background-color: #f44336; +} + +.btn-danger:hover { + background-color: #d32f2f; +} + +.btn-small { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; +} + +.btn-icon { + background: none; + border: none; + cursor: pointer; + font-size: 1rem; + padding: 0; + margin: 0 5px; + color: #e0e0e0; +} + +/* Формы */ +.form-container { + background-color: #2d2d2d; + padding: 20px; + border-radius: 5px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + margin-bottom: 20px; +} + +.form-group { + margin-bottom: 15px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + font-weight: 500; + color: #bbdefb; +} + +input[type="text"], +input[type="email"], +input[type="password"], +input[type="number"], +textarea, +select { + display: block; + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 1rem; + line-height: 1.5; + color: #e0e0e0; + background-color: #424242; + background-clip: padding-box; + border: 1px solid #616161; + border-radius: 0.25rem; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + box-sizing: border-box; +} + +input:focus, +textarea:focus, +select:focus { + color: #e0e0e0; + background-color: #424242; + border-color: #64b5f6; + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(33, 150, 243, 0.25); +} + +.form-actions { + margin-top: 20px; + display: flex; + justify-content: space-between; +} + +/* Список опросов */ +.questionnaires-list { + margin-top: 30px; +} + +.questionnaire-item { + background-color: #2d2d2d; + padding: 20px; + border-radius: 5px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + margin-bottom: 20px; +} + +.questionnaire-links { + display: flex; + gap: 10px; + margin-top: 15px; +} + +/* Вопросы */ +.question-item { + background-color: #2d2d2d; + padding: 20px; + border-radius: 5px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + margin-bottom: 20px; +} + +.question-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; +} + +.options-container { + margin-top: 10px; +} + +.options-list { + margin-bottom: 10px; +} + +.option-item { + display: flex; + align-items: center; + margin-bottom: 8px; +} + +/* Стили для варианта ответа */ +.option-item input[type="text"] { + flex: 1; + margin-right: 10px; +} + +.radio-option label, +.checkbox-option label { + margin-left: 10px; + cursor: pointer; +} + +/* Звездный рейтинг */ +.rating-container { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.rating-item { + display: flex; + flex-direction: column; + align-items: center; +} + +.rating-item label { + cursor: pointer; + padding: 8px 12px; + background-color: #424242; + border-radius: 4px; + transition: all 0.2s; +} + +.rating-item input { + display: none; +} + +.rating-item input:checked + label { + background-color: #2196f3; + color: white; +} + +/* Облако тегов */ +.tag-cloud-container { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 10px; +} + +.tag-item { + padding: 8px 15px; + background-color: #424242; + border-radius: 20px; + cursor: pointer; + transition: all 0.2s; +} + +.tag-item:hover { + background-color: #616161; +} + +.tag-item.selected { + background-color: #2196f3; + color: white; +} + +/* Результаты голосования */ +.results-visualization { + margin-top: 15px; +} + +.result-bar-container { + margin-bottom: 15px; +} + +.result-label { + margin-bottom: 5px; + font-weight: 500; + color: #bbdefb; +} + +.result-bar { + height: 20px; + background-color: #424242; + border-radius: 4px; + overflow: hidden; +} + +.result-bar-fill { + height: 100%; + background-color: #2196f3; + border-radius: 4px; + transition: width 0.5s; +} + +.result-percent { + margin-top: 5px; + font-size: 0.875rem; + color: #9e9e9e; +} + +.results-tag-cloud { + display: flex; + flex-wrap: wrap; + gap: 15px; + margin-top: 15px; + justify-content: center; +} + +.result-tag { + padding: 5px 10px; + background-color: #2196f3; + color: white; + border-radius: 20px; + display: inline-block; +} + +/* Обязательные поля */ +.required-mark { + color: #f44336; +} + +/* Состояния загрузки и ошибки */ +#loading { + text-align: center; + padding: 20px; + font-size: 1.2rem; + color: #9e9e9e; +} + +.error { + color: #f44336; + padding: 10px; + background-color: rgba(244, 67, 54, 0.2); + border-radius: 4px; +} + +/* Ссылки */ +.link-group { + margin-bottom: 15px; +} + +.link-input-group { + display: flex; + gap: 10px; +} + +.link-input-group input { + flex: 1; +} + +/* Текстовая область для ввода ответа */ +.textarea-container { + width: 100%; + margin-top: 10px; +} + +.text-answer { + width: 100%; + min-height: 100px; + resize: vertical; +} + +/* Таблица статистики */ +.stats-table { + width: 100%; + border-collapse: collapse; + margin-top: 10px; + color: #e0e0e0; +} + +.stats-table th, +.stats-table td { + padding: 8px; + text-align: left; + border-bottom: 1px solid #424242; +} + +.stats-table th { + background-color: #383838; + font-weight: 500; + color: #bbdefb; +} + +.stats-table .total-row { + font-weight: bold; + background-color: #424242; +} + +/* Стили для question-stats */ +.question-stats { + background-color: #2d2d2d; + padding: 15px; + border-radius: 5px; + margin-bottom: 20px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15); +} + +/* Модальные окна */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s, visibility 0.3s; +} + +.modal-overlay.active { + opacity: 1; + visibility: visible; +} + +.modal { + background-color: #2d2d2d; + border-radius: 5px; + box-shadow: 0 3px 15px rgba(0, 0, 0, 0.3); + width: 90%; + max-width: 500px; + padding: 20px; + position: relative; + transform: translateY(-20px); + transition: transform 0.3s; + max-height: 90vh; + overflow-y: auto; +} + +.modal-overlay.active .modal { + transform: translateY(0); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 1px solid #444; +} + +.modal-header h3 { + margin: 0; + color: #64b5f6; +} + +.modal-close { + background: none; + border: none; + color: #e0e0e0; + font-size: 1.5rem; + cursor: pointer; + padding: 0; + line-height: 1; +} + +.modal-body { + margin-bottom: 20px; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding-top: 10px; + border-top: 1px solid #444; +} + +/* QR код */ +.qr-container { + display: flex; + flex-direction: column; + align-items: center; + padding: 15px; +} + +.qr-code { + margin-bottom: 15px; + background-color: #fff; + padding: 15px; + border-radius: 8px; +} + +.qr-link-container { + display: flex; + width: 100%; + max-width: 500px; + margin-top: 10px; +} + +.qr-link-input { + flex-grow: 1; + padding: 10px; + border: 1px solid #444; + border-radius: 4px 0 0 4px; + background-color: #333; + color: #fff; +} + +.btn-copy-link { + padding: 10px 15px; + background-color: #64b5f6; + color: #fff; + border: none; + border-radius: 0 4px 4px 0; + cursor: pointer; + transition: background-color 0.3s; +} + +.btn-copy-link:hover { + background-color: #90caf9; +} + +.btn-copy-link.copied { + background-color: #4caf50; +} + +/* Шкала оценки */ +.scale-container { + margin-top: 20px; + width: 100%; +} + +.scale-labels { + display: flex; + justify-content: space-between; + margin-bottom: 10px; +} + +.scale-label-min, +.scale-label-max { + font-weight: 500; + color: #bbdefb; +} + +.scale-values { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 5px; +} + +.scale-item { + text-align: center; +} + +.scale-item input { + display: none; +} + +.scale-item label { + display: block; + width: 40px; + height: 40px; + line-height: 40px; + text-align: center; + background-color: #424242; + border-radius: 50%; + cursor: pointer; + transition: all 0.2s; +} + +.scale-item input:checked + label { + background-color: #2196f3; + color: white; +} + +.scale-item label:hover { + background-color: #616161; +} + +/* Пошаговый опрос */ +.step-navigation { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 20px; + padding-top: 15px; + border-top: 1px solid #444; +} + +#question-counter { + font-size: 1rem; + color: #bbdefb; +} + +/* Улучшения для облака тегов */ +.tag-input-container { + width: 100%; +} + +.tag-input { + width: 100%; + padding: 10px; + margin-bottom: 10px; + background-color: #424242; + border: 1px solid #616161; + border-radius: 4px; + color: #e0e0e0; +} + +.tag-items { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 10px; +} + +.tag-item { + position: relative; + padding: 8px 15px; + background-color: #424242; + border-radius: 20px; + cursor: pointer; + transition: all 0.2s; +} + +.tag-item.selected { + background-color: #2196f3; + color: white; +} + +.tag-remove { + margin-left: 5px; + font-size: 1.2rem; + cursor: pointer; +} + +/* Стили для результатов опроса */ +.question-result { + background-color: #2d2d2d; + padding: 15px; + border-radius: 5px; + margin-bottom: 20px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15); +} + +.text-answers { + margin-top: 10px; +} + +.text-answer { + background-color: #424242; + padding: 10px; + border-radius: 4px; + margin-bottom: 10px; +} + +/* Улучшения для радио и чекбоксов */ +.radio-options-container, +.checkbox-options-container { + margin-top: 10px; +} + +.radio-option, +.checkbox-option { + display: flex; + align-items: center; + margin-bottom: 10px; +} + +.radio-option input, +.checkbox-option input { + margin-right: 10px; +} + +.radio-option label, +.checkbox-option label { + cursor: pointer; +} + +/* Стили для вопросов с ошибками */ +.question-item.error { + border-left: 4px solid var(--color-error); + box-shadow: 0 0 10px rgba(220, 53, 69, 0.3); + padding-left: 16px; + transition: all 0.3s ease; +} + +.question-item.error .question-title { + color: var(--color-error); +} + +/* Анимация для ошибок */ +.shake { + animation: shake 0.6s cubic-bezier(.36,.07,.19,.97) both; +} + +@keyframes shake { + 10%, 90% { + transform: translate3d(-1px, 0, 0); + } + + 20%, 80% { + transform: translate3d(2px, 0, 0); + } + + 30%, 50%, 70% { + transform: translate3d(-4px, 0, 0); + } + + 40%, 60% { + transform: translate3d(4px, 0, 0); + } +} + +/* Стили для выхода вопроса */ +.question-item.exit { + opacity: 0; + transform: translateX(-30px); + transition: opacity 0.3s ease, transform 0.3s ease; + pointer-events: none; +} + +/* Стили для плавного входа вопроса */ +.question-item.enter { + opacity: 0; + transform: translateX(30px); +} + +.question-item.active { + opacity: 1; + transform: translateX(0); + transition: opacity 0.5s ease, transform 0.5s ease; +} + +/* Анимации для элементов результатов */ +.results-title { + animation: slideDown 0.8s ease forwards; +} + +.scale-average { + margin-bottom: 15px; + font-size: 1.2em; +} + +.scale-average .highlight { + color: var(--color-primary); + font-size: 1.5em; + animation: pulse 2s infinite; +} + +.result-bar-container { + margin-bottom: 10px; +} + +.result-label { + margin-bottom: 5px; + font-weight: 500; +} + +.result-bar { + height: 20px; + background-color: rgba(0, 0, 0, 0.1); + border-radius: 4px; + overflow: hidden; + position: relative; +} + +.result-bar-fill { + height: 100%; + background-color: var(--color-primary); + transition: width 1s ease-out; + border-radius: 4px; +} + +.result-percent { + margin-top: 3px; + text-align: right; + font-size: 0.9em; + color: var(--color-muted); +} + +.text-answer { + background-color: rgba(0, 0, 0, 0.05); + padding: 10px 15px; + border-radius: 4px; + margin-bottom: 10px; + transition: opacity 0.5s ease, transform 0.5s ease; +} + +.results-tag-cloud { + display: flex; + flex-wrap: wrap; + justify-content: center; + margin: 20px 0; +} + +.result-tag { + background-color: rgba(var(--primary-rgb), 0.1); + color: var(--color-primary); + padding: 8px 15px; + border-radius: 20px; + margin: 5px; + display: inline-block; + transition: all 0.5s ease; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(30px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes pulse { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } + 100% { + transform: scale(1); + } +} + +.question-result { + transition: all 0.5s ease; + padding: 15px; + margin-bottom: 20px; + border-radius: 8px; + background-color: rgba(0, 0, 0, 0.03); +} + +/* Улучшенная анимация для счетчика вопросов */ +.question-counter { + transition: all 0.3s ease; + animation: fadeIn 0.5s; +} + +.question-counter.update { + animation: pulse 0.5s; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Анимации для кнопок навигации */ +.nav-btn:hover { + transform: translateY(-3px); + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; +} + +.nav-btn:active { + transform: translateY(-1px); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); +} + +/* Улучшенные стили для загрузки */ +#loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin: 50px 0; +} + +.loading-spinner { + width: 50px; + height: 50px; + border: 5px solid rgba(0, 0, 0, 0.1); + border-radius: 50%; + border-top: 5px solid var(--color-primary); + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.loading-text { + margin-top: 15px; + animation: pulse 1.5s infinite; +} + +/* Анимации */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideInUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes slideInRight { + from { + transform: translateX(20px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes pulse { + 0% { transform: scale(1); } + 50% { transform: scale(1.05); } + 100% { transform: scale(1); } +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); } + 20%, 40%, 60%, 80% { transform: translateX(5px); } +} + +@keyframes rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Применение анимаций */ +#questionnaire-container { + animation: fadeIn 0.6s ease-out; +} + +.question-item { + animation: slideInUp 0.5s ease-out; + transition: all 0.3s ease; +} + +.btn { + transition: all 0.2s ease-in-out; +} + +.btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); +} + +.btn:active { + transform: translateY(1px); +} + +.btn-primary { + animation: pulse 2s infinite; +} + +.question-item.error { + animation: shake 0.5s ease-in-out; +} + +.radio-option label:hover, +.checkbox-option label:hover { + transform: translateX(3px); + transition: transform 0.2s ease; +} + +.tag-item { + transition: all 0.2s ease; +} + +.tag-item:hover { + transform: scale(1.05); +} + +.tag-item.selected { + animation: pulse 1s; +} + +.scale-item label:hover { + transform: scale(1.1); + transition: transform 0.2s ease; +} + +.scale-item input:checked + label { + animation: pulse 0.5s; +} + +/* Анимированный лоадер */ +#loading { + position: relative; + padding-left: 30px; +} + +#loading:before { + content: ''; + position: absolute; + left: 0; + top: 50%; + margin-top: -10px; + width: 20px; + height: 20px; + border: 3px solid #2196f3; + border-top-color: transparent; + border-radius: 50%; + animation: rotate 1s linear infinite; +} + +/* Анимированные переходы между вопросами */ +.step-navigation button { + transition: all 0.3s ease; +} + +.question-item.active { + animation: slideInRight 0.4s ease-out; +} + +.question-item.exit { + animation: fadeIn 0.4s ease-out reverse; +} + +/* Анимация для результатов */ +.result-bar-fill { + transition: width 1.5s ease-out; + animation: slideInRight 1.5s ease-out; +} + +.results-container { + animation: fadeIn 1s ease-out; +} + +.question-result { + animation: slideInUp 0.5s ease-out; + animation-fill-mode: both; +} + +.question-result:nth-child(1) { animation-delay: 0.1s; } +.question-result:nth-child(2) { animation-delay: 0.2s; } +.question-result:nth-child(3) { animation-delay: 0.3s; } +.question-result:nth-child(4) { animation-delay: 0.4s; } +.question-result:nth-child(5) { animation-delay: 0.5s; } +.question-result:nth-child(6) { animation-delay: 0.6s; } +.question-result:nth-child(7) { animation-delay: 0.7s; } +.question-result:nth-child(8) { animation-delay: 0.8s; } +.question-result:nth-child(9) { animation-delay: 0.9s; } +.question-result:nth-child(10) { animation-delay: 1s; } + +/* Другие улучшения стилей */ +.textarea-container textarea { + transition: height 0.3s ease; +} + +.textarea-container textarea:focus { + height: 120px; +} + +/* Анимация для модальных окон */ +.modal-overlay.active .modal { + animation: slideInUp 0.3s ease-out; +} + +/* Анимация для кнопки добавления вопроса */ +#add-question { + transition: background-color 0.3s ease, transform 0.2s ease; +} + +#add-question:hover { + transform: translateY(-2px); +} + +#add-question:active { + transform: translateY(1px); +} + +/* Анимация иконок */ +.btn-icon svg { + transition: transform 0.3s ease; +} + +.btn-icon:hover svg { + transform: rotate(90deg); +} + +/* Анимация для обратной связи */ +@keyframes success-animation { + 0% { background-color: transparent; } + 30% { background-color: rgba(76, 175, 80, 0.2); } + 100% { background-color: transparent; } +} + +.success-feedback { + animation: success-animation 1.5s ease; +} + +/* Анимированные переключатели */ +input[type="checkbox"], input[type="radio"] { + transition: all 0.2s ease; +} + +/* Дополнительные плавные переходы для всех элементов */ +* { + transition-property: background-color, border-color, color, box-shadow; + transition-duration: 0.2s; + transition-timing-function: ease; +} + +/* Стили для приветствия и благодарности */ +.welcome-animation, +.thank-you-animation, +.already-completed { + max-width: 800px; + margin: 50px auto; + padding: 30px; + text-align: center; + background-color: #2d2d2d; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + transition: all 0.5s ease; +} + +.welcome-icon, +.thank-you-icon, +.completed-icon { + margin-bottom: 20px; +} + +.welcome-icon svg, +.thank-you-icon svg, +.completed-icon svg { + width: 80px; + height: 80px; + color: var(--color-primary, #2196f3); + opacity: 0.8; +} + +.welcome-title, +.thank-you-title, +.completed-title { + font-size: 2rem; + margin-bottom: 15px; + color: var(--color-primary, #2196f3); +} + +.welcome-description, +.thank-you-description, +.completed-description { + font-size: 1.1rem; + line-height: 1.6; + margin-bottom: 30px; + color: #e0e0e0; +} + +.welcome-start-btn, +.view-results-btn { + margin-top: 20px; + font-size: 1.1rem; + padding: 12px 30px; + border-radius: 30px; + animation: pulse 2s infinite; +} + +.start-again-btn { + margin-top: 20px; + margin-left: 10px; + font-size: 1.1rem; + padding: 12px 30px; + border-radius: 30px; +} + +/* Анимации для приветствия и благодарности */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.welcome-icon svg, +.thank-you-icon svg, +.completed-icon svg { + animation: pulse 3s infinite; +} + +/* CSS переменные для цветов */ +:root { + --color-primary: #2196f3; + --color-primary-dark: #1976d2; + --color-primary-light: #64b5f6; + --color-secondary: #757575; + --color-secondary-dark: #616161; + --color-success: #4caf50; + --color-success-dark: #388e3c; + --color-error: #f44336; + --color-error-dark: #d32f2f; + --color-muted: #9e9e9e; + --primary-rgb: 33, 150, 243; + --color-bg-dark: #1e1e1e; + --color-bg-card: #2d2d2d; + --color-bg-input: #424242; +} + +/* Стили для навигационных кнопок */ +.nav-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 10px 20px; + border-radius: 30px; + transition: all 0.3s ease; +} + +.nav-btn svg { + margin: 0 5px; +} + +.nav-btn:hover { + transform: translateY(-3px); + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); +} + +/* Стили для статистики в админке */ +.question-stats { + margin-bottom: 30px; + padding: 20px; + background-color: #2d2d2d; + border-radius: 8px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); +} + +.question-stats h3 { + margin-top: 0; + margin-bottom: 15px; + color: #90caf9; + border-bottom: 1px solid #444; + padding-bottom: 10px; +} + +.no-stats, .no-votes { + padding: 15px; + background-color: #383838; + border-radius: 5px; + text-align: center; + color: #aaa; +} + +.stats-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 15px; +} + +.stats-table th, .stats-table td { + padding: 10px; + text-align: left; + border-bottom: 1px solid #444; +} + +.stats-table th { + background-color: #383838; + color: #90caf9; +} + +.bar-container { + width: 100%; + height: 20px; + background-color: #383838; + border-radius: 3px; + overflow: hidden; +} + +.bar { + height: 100%; + background-color: #64b5f6; + border-radius: 3px; + transition: width 0.5s ease-in-out; +} + +.total-votes { + text-align: right; + font-style: italic; + color: #aaa; + margin-top: 10px; +} + +/* Стили для облака тегов в статистике */ +.tag-cloud-stats { + display: flex; + flex-wrap: wrap; + gap: 10px; + padding: 15px; + background-color: #383838; + border-radius: 5px; +} + +.tag-item { + display: inline-block; + padding: 5px 10px; + background-color: #444; + border-radius: 15px; + margin-right: 8px; + margin-bottom: 8px; + color: #90caf9; + transition: transform 0.2s ease; +} + +.tag-item:hover { + transform: scale(1.05); +} + +/* Стили для шкалы и рейтинга */ +.scale-stats { + padding: 15px; + background-color: #383838; + border-radius: 5px; +} + +.stat-item { + margin-bottom: 10px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.stat-label { + font-weight: bold; + color: #aaa; +} + +.stat-value { + font-size: 1.1em; + color: #90caf9; +} + +/* Навигация */ +.nav-header { + background-color: #212121; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + padding: 15px 0; + margin-bottom: 30px; +} + +.nav-container { + max-width: 1200px; + margin: 0 auto; + padding: 0 20px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.nav-logo { + font-size: 1.3rem; + font-weight: 600; + color: #61dafb; + text-decoration: none; +} + +.nav-menu { + display: flex; + gap: 20px; +} + +.nav-link { + color: #e0e0e0; + text-decoration: none; + padding: 8px 12px; + border-radius: 4px; + transition: background-color 0.2s; +} + +.nav-link:hover { + background-color: #424242; + color: #61dafb; +} + +.nav-link.active { + background-color: #424242; + color: #61dafb; +} + +/* Формы - улучшенные чекбоксы и радиокнопки */ +.form-container { + background-color: #2d2d2d; + padding: 20px; + border-radius: 5px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + margin-bottom: 20px; +} + +.form-group { + margin-bottom: 15px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + font-weight: 500; + color: #bbdefb; +} + +/* Улучшенные стили для чекбоксов и радиокнопок */ +.radio-option, +.checkbox-option { + display: flex; + align-items: center; + margin-bottom: 12px; + position: relative; +} + +.radio-option input[type="radio"], +.checkbox-option input[type="checkbox"] { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; +} + +.radio-option label, +.checkbox-option label { + position: relative; + padding-left: 40px; + cursor: pointer; + display: block; + font-size: 1rem; + user-select: none; +} + +.radio-option label:before, +.checkbox-option label:before { + content: ''; + position: absolute; + left: 0; + top: 0; + width: 24px; + height: 24px; + border: 2px solid #616161; + background-color: #424242; + transition: all 0.3s; +} + +.radio-option label:before { + border-radius: 50%; +} + +.checkbox-option label:before { + border-radius: 4px; +} + +.radio-option input[type="radio"]:checked ~ label:before { + background-color: #2196f3; + border-color: #2196f3; +} + +.checkbox-option input[type="checkbox"]:checked ~ label:before { + background-color: #4caf50; + border-color: #4caf50; +} + +.radio-option label:after { + content: ''; + position: absolute; + width: 10px; + height: 10px; + background: white; + border-radius: 50%; + top: 7px; + left: 7px; + transition: all 0.2s; + opacity: 0; + transform: scale(0); +} + +.checkbox-option label:after { + content: ''; + position: absolute; + left: 9px; + top: 5px; + width: 6px; + height: 12px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg) scale(0); + opacity: 0; + transition: all 0.2s; +} + +.radio-option input[type="radio"]:checked ~ label:after { + opacity: 1; + transform: scale(1); +} + +.checkbox-option input[type="checkbox"]:checked ~ label:after { + opacity: 1; + transform: rotate(45deg) scale(1); +} + +.radio-option:hover label:before, +.checkbox-option:hover label:before { + border-color: #90caf9; +} + +/* Модальные окна с прогресс-баром */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s, visibility 0.3s; +} + +.modal-overlay.active { + opacity: 1; + visibility: visible; +} + +.modal { + background-color: #2d2d2d; + border-radius: 5px; + box-shadow: 0 3px 15px rgba(0, 0, 0, 0.3); + width: 90%; + max-width: 500px; + padding: 20px; + position: relative; + transform: translateY(-20px); + transition: transform 0.3s; + max-height: 90vh; + overflow-y: auto; +} + +.modal-overlay.active .modal { + transform: translateY(0); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 1px solid #444; +} + +.modal-header h3 { + margin: 0; + color: #64b5f6; +} + +.modal-close { + background: none; + border: none; + color: #e0e0e0; + font-size: 1.5rem; + cursor: pointer; + padding: 0; + line-height: 1; +} + +.modal-body { + margin-bottom: 20px; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding-top: 10px; + border-top: 1px solid #444; +} + +/* Прогресс-бар для модальных окон */ +.modal-progress { + position: absolute; + bottom: 0; + left: 0; + height: 5px; + background-color: #2196f3; + width: 0; + transition: width 2s linear; +} + +.modal-progress.active { + width: 100%; +} + +/* Анимации для опроса */ +.question-item { + opacity: 1; + transform: translateY(0); + transition: opacity 0.5s, transform 0.5s; +} + +.question-item.enter { + opacity: 0; + transform: translateY(20px); +} + +.question-item.active { + opacity: 1; + transform: translateY(0); +} + +#question-counter { + transition: opacity 0.3s, transform 0.3s; +} + +#question-counter.update { + opacity: 0; + transform: translateY(-10px); +} + +/* Анимации для благодарности и приветствия */ +.welcome-animation, +.thank-you-animation, +.already-completed { + background-color: #2d2d2d; + padding: 30px; + border-radius: 8px; + text-align: center; + margin: 50px auto; + max-width: 600px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + transition: opacity 0.5s, transform 0.5s; +} + +.welcome-icon, +.thank-you-icon, +.completed-icon { + margin-bottom: 20px; + color: #4caf50; +} + +.welcome-title, +.thank-you-title, +.completed-title { + color: #64b5f6; + margin-bottom: 15px; +} + +.welcome-description, +.thank-you-description, +.completed-description { + color: #e0e0e0; + margin-bottom: 25px; +} + +.welcome-start-btn, +.view-results-btn { + margin: 10px; +} + +/* Анимации для ошибок */ +.shake-animation { + animation: shake 0.5s; +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); } + 20%, 40%, 60%, 80% { transform: translateX(5px); } +} + +/* Улучшенные стили для чекбоксов и радиокнопок */ +.radio-option, +.checkbox-option { + display: flex; + align-items: center; + margin-bottom: 12px; + position: relative; +} + +.radio-option input[type="radio"], +.checkbox-option input[type="checkbox"] { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; +} + +.radio-option label, +.checkbox-option label { + position: relative; + padding-left: 40px; + cursor: pointer; + display: block; + font-size: 1rem; + user-select: none; +} + +.radio-option label:before, +.checkbox-option label:before { + content: ''; + position: absolute; + left: 0; + top: 0; + width: 24px; + height: 24px; + border: 2px solid #616161; + background-color: #424242; + transition: all 0.3s; +} + +.radio-option label:before { + border-radius: 50%; +} + +.checkbox-option label:before { + border-radius: 4px; +} + +.radio-option input[type="radio"]:checked ~ label:before { + background-color: #2196f3; + border-color: #2196f3; +} + +.checkbox-option input[type="checkbox"]:checked ~ label:before { + background-color: #4caf50; + border-color: #4caf50; +} + +.radio-option label:after { + content: ''; + position: absolute; + width: 10px; + height: 10px; + background: white; + border-radius: 50%; + top: 7px; + left: 7px; + transition: all 0.2s; + opacity: 0; + transform: scale(0); +} + +.checkbox-option label:after { + content: ''; + position: absolute; + left: 9px; + top: 5px; + width: 6px; + height: 12px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg) scale(0); + opacity: 0; + transition: all 0.2s; +} + +.radio-option input[type="radio"]:checked ~ label:after { + opacity: 1; + transform: scale(1); +} + +.checkbox-option input[type="checkbox"]:checked ~ label:after { + opacity: 1; + transform: rotate(45deg) scale(1); +} + +.radio-option:hover label:before, +.checkbox-option:hover label:before { + border-color: #90caf9; +} + +/* Стили для текстовых ответов в админке */ +.text-answers-list { + margin-top: 15px; +} + +.text-answer-item { + background-color: #383838; + padding: 12px 15px; + border-radius: 5px; + margin-bottom: 10px; + display: flex; + align-items: flex-start; +} + +.answer-number { + font-weight: bold; + color: #64b5f6; + margin-right: 10px; + flex-shrink: 0; +} + +.answer-text { + flex-grow: 1; + white-space: pre-wrap; + word-break: break-word; +} \ No newline at end of file diff --git a/server/routers/questioneer/public/static/js/admin.js b/server/routers/questioneer/public/static/js/admin.js new file mode 100644 index 0000000..2adf316 --- /dev/null +++ b/server/routers/questioneer/public/static/js/admin.js @@ -0,0 +1,294 @@ +/* global $, window, document, showAlert, showConfirm, showQRCodeModal */ +$(document).ready(function() { + const adminLink = window.location.pathname.split('/').pop(); + let questionnaireData = null; + + // Получаем базовый путь API (для работы и с /questioneer, и с /ms/questioneer) + const getApiPath = () => { + const pathParts = window.location.pathname.split('/'); + // Убираем последние две части пути (admin/:adminLink) + pathParts.pop(); + pathParts.pop(); + return pathParts.join('/') + '/api'; + }; + + // Загрузка данных опроса + const loadQuestionnaire = () => { + $.ajax({ + url: `${getApiPath()}/questionnaires/admin/${adminLink}`, + method: 'GET', + success: function(result) { + if (result.success) { + questionnaireData = result.data; + renderQuestionnaire(); + } else { + $('#loading').text(`Ошибка: ${result.error}`); + } + }, + error: function(error) { + console.error('Error loading questionnaire:', error); + $('#loading').text('Не удалось загрузить опрос. Пожалуйста, попробуйте позже.'); + } + }); + }; + + // Отображение данных опроса + const renderQuestionnaire = () => { + // Заполняем основные данные + $('#questionnaire-title').text(questionnaireData.title); + $('#questionnaire-description').text(questionnaireData.description || 'Нет описания'); + + // Формируем ссылки + const baseUrl = window.location.origin; + const baseQuestionnairePath = window.location.pathname.split('/admin')[0]; + const publicUrl = `${baseUrl}${baseQuestionnairePath}/poll/${questionnaireData.publicLink}`; + const adminUrl = `${baseUrl}${baseQuestionnairePath}/admin/${questionnaireData.adminLink}`; + + $('#public-link').val(publicUrl); + $('#admin-link').val(adminUrl); + + // Отображаем статистику + renderStats(questionnaireData.questions); + + // Показываем контейнер с данными + $('#loading').hide(); + $('#questionnaire-container').show(); + }; + + // Отображение статистики опроса + const renderStats = (questions) => { + const $statsContainer = $('#stats-container'); + $statsContainer.empty(); + + // Проверяем, есть ли ответы + let hasAnyResponses = false; + + // Проверяем наличие ответов для каждого типа вопросов + for (const question of questions) { + if (question.type === 'single' || question.type === 'multiple') { + if (question.options && question.options.some(option => option.votes && option.votes > 0)) { + hasAnyResponses = true; + break; + } + } else if (question.type === 'tagcloud') { + if (question.tags && question.tags.some(tag => tag.count && tag.count > 0)) { + hasAnyResponses = true; + break; + } + } else if (question.type === 'scale' || question.type === 'rating') { + if (question.responses && question.responses.length > 0) { + hasAnyResponses = true; + break; + } + } else if (question.type === 'text') { + if (question.textAnswers && question.textAnswers.length > 0) { + hasAnyResponses = true; + break; + } + } + } + + if (!hasAnyResponses) { + $statsContainer.html('
Пока нет ответов на опрос
'); + return; + } + + // Для каждого вопроса создаем блок статистики + questions.forEach((question, index) => { + const $questionStats = $('
', { class: 'question-stats' }); + const $questionTitle = $('

', { text: `${index + 1}. ${question.text}` }); + $questionStats.append($questionTitle); + + // В зависимости от типа вопроса отображаем разную статистику + if (question.type === 'single' || question.type === 'multiple') { + // Для вопросов с выбором вариантов + const totalVotes = question.options.reduce((sum, option) => sum + (option.votes || 0), 0); + + if (totalVotes === 0) { + $questionStats.append($('
', { class: 'no-votes', text: 'Нет голосов' })); + } else { + const $table = $('', { class: 'stats-table' }); + const $thead = $('').append( + $('').append( + $(''); + + question.options.forEach(option => { + const votes = option.votes || 0; + const percent = totalVotes > 0 ? Math.round((votes / totalVotes) * 100) : 0; + + const $tr = $('').append( + $('
', { text: 'Вариант' }), + $('', { text: 'Голоса' }), + $('', { text: '%' }), + $('', { text: 'Визуализация' }) + ) + ); + + const $tbody = $('
', { text: option.text }), + $('', { text: votes }), + $('', { text: `${percent}%` }), + $('').append( + $('
', { class: 'bar-container' }).append( + $('
', { + class: 'bar', + css: { width: `${percent}%` } + }) + ) + ) + ); + + $tbody.append($tr); + }); + + $table.append($thead, $tbody); + $questionStats.append($table); + $questionStats.append($('
', { class: 'total-votes', text: `Всего голосов: ${totalVotes}` })); + } + } else if (question.type === 'tagcloud') { + // Для облака тегов + if (!question.tags || question.tags.length === 0 || !question.tags.some(tag => tag.count > 0)) { + $questionStats.append($('
', { class: 'no-votes', text: 'Нет выбранных тегов' })); + } else { + const $tagCloud = $('
', { class: 'tag-cloud-stats' }); + + // Находим максимальное количество для масштабирования + const maxCount = Math.max(...question.tags.map(tag => tag.count || 0)); + + // Сортируем теги по популярности + const sortedTags = [...question.tags].sort((a, b) => (b.count || 0) - (a.count || 0)); + + sortedTags.forEach(tag => { + if (tag.count && tag.count > 0) { + const fontSize = maxCount > 0 ? 1 + (tag.count / maxCount) * 1.5 : 1; // от 1em до 2.5em + + $tagCloud.append( + $('', { + class: 'tag-item', + text: `${tag.text} (${tag.count})`, + css: { fontSize: `${fontSize}em` } + }) + ); + } + }); + + $questionStats.append($tagCloud); + } + } else if (question.type === 'scale' || question.type === 'rating') { + // Для шкалы и рейтинга + if (!question.responses || question.responses.length === 0) { + $questionStats.append($('
', { class: 'no-votes', text: 'Нет оценок' })); + } else { + const values = question.responses; + const sum = values.reduce((a, b) => a + b, 0); + const avg = sum / values.length; + const min = Math.min(...values); + const max = Math.max(...values); + + const $scaleStats = $('
', { class: 'scale-stats' }); + + $scaleStats.append( + $('
', { class: 'stat-item' }).append( + $('', { class: 'stat-label', text: 'Среднее значение:' }), + $('', { class: 'stat-value', text: avg.toFixed(1) }) + ), + $('
', { class: 'stat-item' }).append( + $('', { class: 'stat-label', text: 'Минимум:' }), + $('', { class: 'stat-value', text: min }) + ), + $('
', { class: 'stat-item' }).append( + $('', { class: 'stat-label', text: 'Максимум:' }), + $('', { class: 'stat-value', text: max }) + ), + $('
', { class: 'stat-item' }).append( + $('', { class: 'stat-label', text: 'Количество оценок:' }), + $('', { class: 'stat-value', text: values.length }) + ) + ); + + $questionStats.append($scaleStats); + } + } else if (question.type === 'text') { + // Для текстовых ответов + if (!question.textAnswers || question.textAnswers.length === 0) { + $questionStats.append($('
', { class: 'no-votes', text: 'Нет текстовых ответов' })); + } else { + const $textAnswers = $('
', { class: 'text-answers-list' }); + + question.textAnswers.forEach((answer, i) => { + $textAnswers.append( + $('
', { class: 'text-answer-item' }).append( + $('
', { class: 'answer-number', text: `#${i + 1}` }), + $('
', { class: 'answer-text', text: answer }) + ) + ); + }); + + $questionStats.append($textAnswers); + } + } + + $statsContainer.append($questionStats); + }); + }; + + // Копирование ссылок + $('#copy-public-link').on('click', function() { + $('#public-link').select(); + document.execCommand('copy'); + showAlert('Ссылка для голосования скопирована в буфер обмена', 'Копирование', null, true); + }); + + $('#copy-admin-link').on('click', function() { + $('#admin-link').select(); + document.execCommand('copy'); + showAlert('Административная ссылка скопирована в буфер обмена', 'Копирование', null, true); + }); + + // Отображение QR-кода + $('#show-qr-code').on('click', function() { + const publicUrl = $('#public-link').val(); + showQRCodeModal(publicUrl, 'QR-код для голосования'); + }); + + // Редактирование опроса + $('#edit-questionnaire').on('click', function() { + const basePath = window.location.pathname.split('/admin')[0]; + window.location.href = `${basePath}/edit/${adminLink}`; + }); + + // Удаление опроса + $('#delete-questionnaire').on('click', function() { + showConfirm('Вы уверены, что хотите удалить опрос? Все ответы будут удалены безвозвратно.', function(confirmed) { + if (confirmed) { + deleteQuestionnaire(); + } + }, 'Удаление опроса'); + }); + + // Функция удаления опроса + const deleteQuestionnaire = () => { + $.ajax({ + url: `${getApiPath()}/questionnaires/admin/${adminLink}`, + method: 'DELETE', + success: function(result) { + if (result.success) { + showAlert('Опрос успешно удален', 'Удаление опроса', function() { + window.location.href = window.location.pathname.split('/admin')[0]; + }, true); + } else { + showAlert(`Ошибка при удалении опроса: ${result.error}`, 'Ошибка'); + } + }, + error: function(error) { + console.error('Error deleting questionnaire:', error); + showAlert('Не удалось удалить опрос. Пожалуйста, попробуйте позже.', 'Ошибка'); + } + }); + }; + + // Инициализация + loadQuestionnaire(); + + // Обновление данных каждые 10 секунд + setInterval(loadQuestionnaire, 10000); +}); \ No newline at end of file diff --git a/server/routers/questioneer/public/static/js/common.js b/server/routers/questioneer/public/static/js/common.js new file mode 100644 index 0000000..7007de8 --- /dev/null +++ b/server/routers/questioneer/public/static/js/common.js @@ -0,0 +1,236 @@ +/* global $, document */ + +// Функция для создания модального окна +function createModal(options) { + // Если модальное окно уже существует, удаляем его + $('.modal-overlay').remove(); + + // Опции по умолчанию + const defaultOptions = { + title: 'Сообщение', + content: '', + closeText: 'Закрыть', + onClose: null, + showCancel: false, + cancelText: 'Отмена', + confirmText: 'Подтвердить', + onConfirm: null, + onCancel: null, + size: 'normal', // 'normal', 'large', 'small' + customClass: '', + autoClose: false, // Автоматическое закрытие по таймеру + autoCloseTime: 2000 // Время до автоматического закрытия (2 секунды) + }; + + // Объединяем пользовательские опции с опциями по умолчанию + const settings = $.extend({}, defaultOptions, options); + + // Создаем структуру модального окна + const $modalOverlay = $('
', { class: 'modal-overlay' }); + const $modal = $('
', { class: `modal ${settings.customClass}` }); + + // Устанавливаем ширину в зависимости от размера + if (settings.size === 'large') { + $modal.css('max-width', '700px'); + } else if (settings.size === 'small') { + $modal.css('max-width', '400px'); + } + + // Создаем заголовок + const $modalHeader = $('
', { class: 'modal-header' }); + const $modalTitle = $('

', { text: settings.title }); + const $modalClose = $(' +

+
+ `; + + const modal = createModal({ + title: title || 'QR-код для доступа', + content: content, + size: 'large' + }); + + // Добавляем обработчик для кнопки копирования + modal.$modal.find('.btn-copy-link').on('click', function() { + const input = modal.$modal.find('.qr-link-input'); + input.select(); + document.execCommand('copy'); + + // Показываем уведомление о копировании + const $button = $(this); + const originalText = $button.text(); + $button.text('Скопировано!'); + $button.addClass('copied'); + + setTimeout(function() { + $button.text(originalText); + $button.removeClass('copied'); + }, 1500); + }); + + return modal; +} \ No newline at end of file diff --git a/server/routers/questioneer/public/static/js/create.js b/server/routers/questioneer/public/static/js/create.js new file mode 100644 index 0000000..2597e7c --- /dev/null +++ b/server/routers/questioneer/public/static/js/create.js @@ -0,0 +1,343 @@ +/* global $, window, document, alert, showAlert, showConfirm */ +$(document).ready(function() { + const form = $('#create-questionnaire-form'); + const questionsList = $('#questions-list'); + const addQuestionBtn = $('#add-question'); + + let questionCount = 0; + + // Получаем базовый путь API (для работы и с /questioneer, и с /ms/questioneer) + const getApiPath = () => { + const pathParts = window.location.pathname.split('/'); + // Убираем последнюю часть пути (create) + pathParts.pop(); + return pathParts.join('/') + '/api'; + }; + + // Добавление нового вопроса + addQuestionBtn.on('click', function() { + addQuestion(); + }); + + // Обработка отправки формы + form.on('submit', function(e) { + e.preventDefault(); + saveQuestionnaire(); + }); + + // Делегирование событий для динамических элементов + questionsList.on('click', '.delete-question', function() { + // Удаление вопроса + const questionItem = $(this).closest('.question-item'); + showConfirm('Вы уверены, что хотите удалить этот вопрос?', function(confirmed) { + if (confirmed) { + questionItem.remove(); + renumberQuestions(); + // Вызываем функцию обновления атрибутов required + updateRequiredAttributes(); + } + }); + }); + + questionsList.on('click', '.add-option', function() { + // Добавление варианта ответа + const questionIndex = $(this).data('question-index'); + addOption(questionIndex); + }); + + questionsList.on('click', '.delete-option', function() { + // Удаление варианта ответа + $(this).closest('.option-item').remove(); + // Вызываем функцию обновления атрибутов required + updateRequiredAttributes(); + }); + + // Делегирование для изменения типа вопроса + questionsList.on('change', '.question-type-select', function() { + const questionItem = $(this).closest('.question-item'); + const questionIndex = questionItem.data('index'); + const optionsContainer = $(`#options-container-${questionIndex}`); + const scaleContainer = $(`#scale-container-${questionIndex}`); + + // Скрыть/показать варианты ответа в зависимости от типа вопроса + const questionType = $(this).val(); + if (['single_choice', 'multiple_choice', 'tag_cloud'].includes(questionType)) { + optionsContainer.show(); + scaleContainer.hide(); + + // Если нет вариантов, добавляем два + const optionsList = $(`#options-list-${questionIndex}`); + if (optionsList.children().length === 0) { + addOption(questionIndex); + addOption(questionIndex); + } + + // Включаем required для полей ввода вариантов + optionsList.find('input[type="text"]').prop('required', true); + } else if (questionType === 'scale') { + optionsContainer.hide(); + scaleContainer.show(); + // Отключаем required для скрытых полей + $(`#options-list-${questionIndex}`).find('input[type="text"]').prop('required', false); + } else { + optionsContainer.hide(); + scaleContainer.hide(); + // Отключаем required для скрытых полей + $(`#options-list-${questionIndex}`).find('input[type="text"]').prop('required', false); + } + + // Вызываем функцию обновления атрибутов required + updateRequiredAttributes(); + }); + + // Функция для добавления нового вопроса + function addQuestion() { + const template = $('#question-template').html(); + const index = questionCount++; + + // Заменяем плейсхолдеры в шаблоне + let questionHtml = template + .replace(/\{\{index\}\}/g, index) + .replace(/\{\{number\}\}/g, index + 1); + + questionsList.append(questionHtml); + + // Показываем/скрываем контейнер вариантов в зависимости от типа вопроса + const questionType = $(`#question-type-${index}`).val(); + if (!['single_choice', 'multiple_choice', 'tag_cloud'].includes(questionType)) { + $(`#options-container-${index}`).hide(); + // Отключаем required для скрытых полей + $(`#options-list-${index}`).find('input[type="text"]').prop('required', false); + } else { + // Добавляем пару начальных вариантов ответа + addOption(index); + addOption(index); + } + + if (questionType === 'scale') { + $(`#scale-container-${index}`).show(); + } else { + $(`#scale-container-${index}`).hide(); + } + + // Вызываем функцию обновления атрибутов required + updateRequiredAttributes(); + } + + // Функция для добавления варианта ответа + function addOption(questionIndex) { + const optionsList = $(`#options-list-${questionIndex}`); + const template = $('#option-template').html(); + + const optionIndex = optionsList.children().length; + + // Заменяем плейсхолдеры в шаблоне + let optionHtml = template + .replace(/\{\{questionIndex\}\}/g, questionIndex) + .replace(/\{\{optionIndex\}\}/g, optionIndex); + + optionsList.append(optionHtml); + + // Проверяем, видим ли контейнер опций + const optionsContainer = $(`#options-container-${questionIndex}`); + if (optionsContainer.is(':hidden')) { + // Если контейнер скрыт, отключаем required у полей ввода + optionsList.find('input[type="text"]').prop('required', false); + } + + // Вызываем функцию обновления атрибутов required + updateRequiredAttributes(); + } + + // Перенумерация вопросов + function renumberQuestions() { + $('.question-item').each(function(index) { + $(this).find('h3').text(`Вопрос ${index + 1}`); + }); + } + + // Функция для обновления нумерации вопросов + function updateQuestionNumbers() { + $('.question-item').each(function(index) { + $(this).find('h3').text(`Вопрос ${index + 1}`); + }); + } + + // Сохранение опроса + function saveQuestionnaire() { + const questionnaire = { + title: $('#title').val(), + description: $('#description').val(), + displayType: 'step_by_step', // Всегда устанавливаем пошаговый режим + questions: [] + }; + + // Собираем данные о вопросах + $('.question-item').each(function() { + const index = $(this).data('index'); + const questionType = $(`#question-type-${index}`).val(); + + const question = { + text: $(`#question-text-${index}`).val(), + type: questionType, + required: $(`input[name="questions[${index}][required]"]`).is(':checked'), + options: [] + }; + + // Добавляем настройки шкалы если нужно + if (questionType === 'scale') { + question.scaleMin = parseInt($(`#scale-min-${index}`).val()) || 0; + question.scaleMax = parseInt($(`#scale-max-${index}`).val()) || 10; + question.scaleMinLabel = $(`#scale-min-label-${index}`).val() || 'Минимум'; + question.scaleMaxLabel = $(`#scale-max-label-${index}`).val() || 'Максимум'; + } + + // Собираем варианты ответа если это не текстовый вопрос или шкала + if (['single_choice', 'multiple_choice', 'tag_cloud'].includes(questionType)) { + $(`#options-list-${index} .option-item`).each(function() { + const optionText = $(this).find('input[type="text"]').val(); + + if (optionText) { + question.options.push({ + text: optionText, + count: 0 + }); + } + }); + } + + questionnaire.questions.push(question); + }); + + // Отправка на сервер + $.ajax({ + url: `${getApiPath()}/questionnaires`, + method: 'POST', + contentType: 'application/json', + data: JSON.stringify(questionnaire), + success: function(result) { + if (result.success) { + // Перенаправление на страницу администрирования опроса + const basePath = window.location.pathname.split('/create')[0]; + window.location.href = `${basePath}/admin/${result.data.adminLink}`; + } else { + showAlert(`Ошибка при создании опроса: ${result.error}`, 'Ошибка'); + } + }, + error: function(error) { + console.error('Error creating questionnaire:', error); + showAlert('Не удалось создать опрос. Пожалуйста, попробуйте позже.', 'Ошибка'); + } + }); + } + + // Функция для обновления атрибута required в зависимости от видимости полей + function updateRequiredAttributes() { + // Для полей вопросов + $('.question-item').each(function() { + const questionType = $(this).find('.question-type-select').val(); + const textInput = $(this).find('.question-text'); + const optionsContainer = $(this).find('.options-container'); + + // Обновляем required для текстового поля вопроса + if (textInput.is(':visible')) { + textInput.prop('required', true); + } else { + textInput.prop('required', false); + } + + // Обновляем required для полей опций + if (questionType === 'single_choice' || questionType === 'multiple_choice') { + optionsContainer.find('input[type="text"]').each(function() { + if ($(this).is(':visible')) { + $(this).prop('required', true); + } else { + $(this).prop('required', false); + } + }); + } else { + optionsContainer.find('input[type="text"]').prop('required', false); + } + + // Для шкалы оценки + if (questionType === 'scale') { + const minInput = $(this).find('.scale-min'); + const maxInput = $(this).find('.scale-max'); + const minLabelInput = $(this).find('.scale-min-label'); + const maxLabelInput = $(this).find('.scale-max-label'); + + if (minInput.is(':visible')) minInput.prop('required', true); + else minInput.prop('required', false); + + if (maxInput.is(':visible')) maxInput.prop('required', true); + else maxInput.prop('required', false); + + if (minLabelInput.is(':visible')) minLabelInput.prop('required', true); + else minLabelInput.prop('required', false); + + if (maxLabelInput.is(':visible')) maxLabelInput.prop('required', true); + else maxLabelInput.prop('required', false); + } + }); + + // Для основных полей формы + const titleInput = $('#title'); + const descriptionInput = $('#description'); + + if (titleInput.is(':visible')) titleInput.prop('required', true); + else titleInput.prop('required', false); + + if (descriptionInput.is(':visible')) descriptionInput.prop('required', false); // Описание не обязательно + } + + // Инициализация с одним вопросом + addQuestion(); + + // Обработчик отправки формы + $('#create-questionnaire-form').on('submit', function(e) { + // Обновляем атрибуты required перед отправкой + updateRequiredAttributes(); + + // Проверяем валидность формы + if (!this.checkValidity()) { + e.preventDefault(); + e.stopPropagation(); + + // Находим первый невалидный элемент и прокручиваем к нему + const firstInvalid = $(this).find(':invalid').first(); + if (firstInvalid.length) { + $('html, body').animate({ + scrollTop: firstInvalid.offset().top - 100 + }, 500); + + // Добавляем класс ошибки к родительскому элементу вопроса + firstInvalid.closest('.question-item').addClass('error'); + setTimeout(() => { + firstInvalid.closest('.question-item').removeClass('error'); + }, 3000); + } + } + + $(this).addClass('was-validated'); + }); + + // Инициализируем атрибуты required + updateRequiredAttributes(); +}); + +// Обработчик удаления вопроса +$(document).on('click', '.remove-question', function() { + $(this).closest('.question-item').remove(); + updateQuestionNumbers(); + + // Вызываем функцию обновления атрибутов required + updateRequiredAttributes(); +}); + +// Обработчик удаления опции +$(document).on('click', '.remove-option', function() { + $(this).closest('.option-item').remove(); + + // Вызываем функцию обновления атрибутов required + updateRequiredAttributes(); +}); \ No newline at end of file diff --git a/server/routers/questioneer/public/static/js/edit.js b/server/routers/questioneer/public/static/js/edit.js new file mode 100644 index 0000000..90120f6 --- /dev/null +++ b/server/routers/questioneer/public/static/js/edit.js @@ -0,0 +1,332 @@ +/* global $, window, document, showAlert, showConfirm, showQRCodeModal */ +$(document).ready(function() { + const form = $('#edit-questionnaire-form'); + const questionsList = $('#questions-list'); + const addQuestionBtn = $('#add-question'); + const adminLink = window.location.pathname.split('/').pop(); + + let questionCount = 0; + let questionnaireData = null; + + // Получаем базовый путь API + const getApiPath = () => { + const pathParts = window.location.pathname.split('/'); + // Убираем последние две части пути (edit/:adminLink) + pathParts.pop(); + pathParts.pop(); + return pathParts.join('/') + '/api'; + }; + + // Загрузка данных опроса + const loadQuestionnaire = () => { + $.ajax({ + url: `${getApiPath()}/questionnaires/admin/${adminLink}`, + method: 'GET', + success: function(result) { + if (result.success) { + questionnaireData = result.data; + fillFormData(); + $('#loading').hide(); + $('#edit-form-container').show(); + } else { + $('#loading').text(`Ошибка: ${result.error}`); + } + }, + error: function(error) { + console.error('Error loading questionnaire:', error); + $('#loading').text('Не удалось загрузить опрос. Пожалуйста, попробуйте позже.'); + } + }); + }; + + // Заполнение формы данными опроса + const fillFormData = () => { + // Заполняем основные данные + $('#title').val(questionnaireData.title); + $('#description').val(questionnaireData.description || ''); + $('#display-type').val(questionnaireData.displayType); + + // Формируем ссылки + const baseUrl = window.location.origin; + const baseQuestionnairePath = window.location.pathname.split('/edit')[0]; + const publicUrl = `${baseUrl}${baseQuestionnairePath}/poll/${questionnaireData.publicLink}`; + const adminUrl = `${baseUrl}${baseQuestionnairePath}/admin/${questionnaireData.adminLink}`; + + $('#public-link').val(publicUrl); + $('#admin-link').val(adminUrl); + + // Добавляем вопросы + questionsList.empty(); + + if (questionnaireData.questions && questionnaireData.questions.length > 0) { + questionnaireData.questions.forEach((question, index) => { + addQuestion(question); + }); + } else { + // Если нет вопросов, добавляем пустой + addQuestion(); + } + + renumberQuestions(); + }; + + // Добавление нового вопроса + addQuestionBtn.on('click', function() { + addQuestion(); + renumberQuestions(); + }); + + // Обработка отправки формы + form.on('submit', function(e) { + e.preventDefault(); + saveQuestionnaire(); + }); + + // Делегирование событий для динамических элементов + questionsList.on('click', '.delete-question', function() { + // Удаление вопроса + const questionItem = $(this).closest('.question-item'); + questionItem.remove(); + renumberQuestions(); + }); + + questionsList.on('click', '.add-option', function() { + // Добавление варианта ответа + const questionIndex = $(this).data('question-index'); + addOption(questionIndex); + }); + + questionsList.on('click', '.delete-option', function() { + // Удаление варианта ответа + $(this).closest('.option-item').remove(); + }); + + // Делегирование для изменения типа вопроса + questionsList.on('change', '.question-type-select', function() { + const questionItem = $(this).closest('.question-item'); + const questionIndex = questionItem.data('index'); + const optionsContainer = $(`#options-container-${questionIndex}`); + const scaleContainer = $(`#scale-container-${questionIndex}`); + + // Показываем/скрываем контейнеры в зависимости от типа вопроса + const questionType = $(this).val(); + if (['single_choice', 'multiple_choice', 'tag_cloud'].includes(questionType)) { + optionsContainer.show(); + scaleContainer.hide(); + + // Если нет вариантов, добавляем два + const optionsList = $(`#options-list-${questionIndex}`); + if (optionsList.children().length === 0) { + addOption(questionIndex); + addOption(questionIndex); + } + + // Включаем required для полей ввода вариантов + optionsList.find('input[type="text"]').prop('required', true); + } else if (questionType === 'scale') { + optionsContainer.hide(); + scaleContainer.show(); + // Отключаем required для скрытых полей + $(`#options-list-${questionIndex}`).find('input[type="text"]').prop('required', false); + } else { + optionsContainer.hide(); + scaleContainer.hide(); + // Отключаем required для скрытых полей + $(`#options-list-${questionIndex}`).find('input[type="text"]').prop('required', false); + } + }); + + // Копирование ссылок + $('#copy-public-link').on('click', function() { + $('#public-link').select(); + document.execCommand('copy'); + showAlert('Ссылка для голосования скопирована в буфер обмена', 'Копирование'); + }); + + $('#copy-admin-link').on('click', function() { + $('#admin-link').select(); + document.execCommand('copy'); + showAlert('Административная ссылка скопирована в буфер обмена', 'Копирование'); + }); + + // Отображение QR-кода + $('#show-qr-code').on('click', function() { + const publicUrl = $('#public-link').val(); + showQRCodeModal(publicUrl, 'QR-код для голосования'); + }); + + // Возврат к админке + $('#back-to-admin').on('click', function(e) { + e.preventDefault(); + const basePath = window.location.pathname.split('/edit')[0]; + window.location.href = `${basePath}/admin/${adminLink}`; + }); + + // Функция для добавления нового вопроса + function addQuestion(questionData) { + const template = $('#question-template').html(); + const index = questionCount++; + + // Заменяем плейсхолдеры в шаблоне + let questionHtml = template + .replace(/\{\{index\}\}/g, index) + .replace(/\{\{number\}\}/g, index + 1); + + questionsList.append(questionHtml); + + // Если есть данные вопроса - заполняем поля + if (questionData) { + $(`#question-text-${index}`).val(questionData.text); + $(`#question-type-${index}`).val(questionData.type); + + if (questionData.required) { + $(`input[name="questions[${index}][required]"]`).prop('checked', true); + } + + // Добавляем варианты ответа если они есть + if (questionData.options && questionData.options.length > 0) { + questionData.options.forEach(option => { + addOption(index, option.text); + }); + } + + // Заполняем настройки шкалы если нужно + if (questionData.scaleMax) { + $(`#scale-max-${index}`).val(questionData.scaleMax); + } + } + + // Показываем/скрываем контейнеры в зависимости от типа вопроса + const questionType = $(`#question-type-${index}`).val(); + if (['single_choice', 'multiple_choice', 'tag_cloud'].includes(questionType)) { + $(`#options-container-${index}`).show(); + $(`#scale-container-${index}`).hide(); + + // Если нет вариантов и не загружены данные, добавляем два + if (!questionData && $(`#options-list-${index}`).children().length === 0) { + addOption(index); + addOption(index); + } + } else if (questionType === 'scale') { + $(`#options-container-${index}`).hide(); + $(`#scale-container-${index}`).show(); + } else { + $(`#options-container-${index}`).hide(); + $(`#scale-container-${index}`).hide(); + } + } + + // Функция для добавления варианта ответа + function addOption(questionIndex, optionText) { + const optionsList = $(`#options-list-${questionIndex}`); + const template = $('#option-template').html(); + + const optionIndex = optionsList.children().length; + + // Заменяем плейсхолдеры в шаблоне + let optionHtml = template + .replace(/\{\{questionIndex\}\}/g, questionIndex) + .replace(/\{\{optionIndex\}\}/g, optionIndex); + + optionsList.append(optionHtml); + + // Если есть текст варианта - устанавливаем его + if (optionText) { + optionsList.children().last().find('input[type="text"]').val(optionText); + } + + // Проверяем, видим ли контейнер опций + const optionsContainer = $(`#options-container-${questionIndex}`); + if (optionsContainer.is(':hidden')) { + // Если контейнер скрыт, отключаем required у полей ввода + optionsList.find('input[type="text"]').prop('required', false); + } + } + + // Перенумерация вопросов + function renumberQuestions() { + $('.question-item').each(function(index) { + $(this).find('h3').text(`Вопрос ${index + 1}`); + }); + } + + // Сохранение опроса + function saveQuestionnaire() { + const questionnaire = { + title: $('#title').val(), + description: $('#description').val(), + displayType: $('#display-type').val(), + questions: [] + }; + + // Собираем данные о вопросах + $('.question-item').each(function() { + const index = $(this).data('index'); + const questionType = $(`#question-type-${index}`).val(); + + const question = { + text: $(`#question-text-${index}`).val(), + type: questionType, + required: $(`input[name="questions[${index}][required]"]`).is(':checked'), + options: [] + }; + + // Добавляем настройки шкалы если нужно + if (questionType === 'scale') { + question.scaleMax = parseInt($(`#scale-max-${index}`).val()); + } + + // Собираем варианты ответа если это не текстовый вопрос или оценка + if (['single_choice', 'multiple_choice', 'tag_cloud'].includes(questionType)) { + $(`#options-list-${index} .option-item`).each(function() { + const optionText = $(this).find('input[type="text"]').val(); + + if (optionText) { + // Сохраняем количество голосов из старых данных + let count = 0; + const optionIndex = $(this).data('index'); + + if (questionnaireData && + questionnaireData.questions[index] && + questionnaireData.questions[index].options && + questionnaireData.questions[index].options[optionIndex]) { + count = questionnaireData.questions[index].options[optionIndex].count || 0; + } + + question.options.push({ + text: optionText, + count: count + }); + } + }); + } + + questionnaire.questions.push(question); + }); + + // Отправка на сервер + $.ajax({ + url: `${getApiPath()}/questionnaires/${adminLink}`, + method: 'PUT', + contentType: 'application/json', + data: JSON.stringify(questionnaire), + success: function(result) { + if (result.success) { + showAlert('Опрос успешно обновлен', 'Успешно', function() { + const basePath = window.location.pathname.split('/edit')[0]; + window.location.href = `${basePath}/admin/${adminLink}`; + }); + } else { + showAlert(`Ошибка при обновлении опроса: ${result.error}`, 'Ошибка'); + } + }, + error: function(error) { + console.error('Error updating questionnaire:', error); + showAlert('Не удалось обновить опрос. Пожалуйста, попробуйте позже.', 'Ошибка'); + } + }); + } + + // Инициализация + loadQuestionnaire(); +}); \ No newline at end of file diff --git a/server/routers/questioneer/public/static/js/index.js b/server/routers/questioneer/public/static/js/index.js new file mode 100644 index 0000000..68f970d --- /dev/null +++ b/server/routers/questioneer/public/static/js/index.js @@ -0,0 +1,67 @@ +/* global $, window, document */ +$(document).ready(function() { + // Функция для получения базового пути API + const getApiPath = () => { + // Извлекаем базовый путь из URL страницы + const pathParts = window.location.pathname.split('/'); + // Если последний сегмент пустой (из-за /) - удаляем его + if (pathParts[pathParts.length - 1] === '') { + pathParts.pop(); + } + + // Путь до корня приложения + return pathParts.join('/') + '/api'; + }; + + // Функция для загрузки списка опросов + const loadQuestionnaires = () => { + $.ajax({ + url: getApiPath() + '/questionnaires', + method: 'GET', + success: function(result) { + if (result.success) { + renderQuestionnaires(result.data); + } else { + $('#questionnaires-container').html(`

Ошибка: ${result.error}

`); + } + }, + error: function(error) { + console.error('Error loading questionnaires:', error); + $('#questionnaires-container').html('

Не удалось загрузить опросы. Пожалуйста, попробуйте позже.

'); + } + }); + }; + + // Функция для отображения списка опросов + const renderQuestionnaires = (questionnaires) => { + if (!questionnaires || questionnaires.length === 0) { + $('#questionnaires-container').html('

У вас еще нет созданных опросов.

'); + return; + } + + // Получаем базовый путь (для работы и с /questioneer, и с /ms/questioneer) + const basePath = window.location.pathname.endsWith('/') + ? window.location.pathname + : window.location.pathname + '/'; + + const questionnairesHTML = questionnaires.map(q => ` +
+

${q.title}

+

${q.description || 'Нет описания'}

+

Создан: ${new Date(q.createdAt).toLocaleString()}

+ +
+ `).join(''); + + $('#questionnaires-container').html(questionnairesHTML); + }; + + // Инициализация страницы + loadQuestionnaires(); + + // Обновление данных каждые 30 секунд + setInterval(loadQuestionnaires, 30000); +}); \ No newline at end of file diff --git a/server/routers/questioneer/public/static/js/poll.js b/server/routers/questioneer/public/static/js/poll.js new file mode 100644 index 0000000..d7e78f1 --- /dev/null +++ b/server/routers/questioneer/public/static/js/poll.js @@ -0,0 +1,1069 @@ +/* global $, window, document, showAlert */ +$(document).ready(function() { + const publicLink = window.location.pathname.split('/').pop(); + let questionnaireData = null; + + // Элементы DOM + const loadingEl = $('#loading'); + const containerEl = $('#questionnaire-container'); + const titleEl = $('#questionnaire-title'); + const descriptionEl = $('#questionnaire-description'); + const questionsContainerEl = $('#questions-container'); + const formEl = $('#poll-form'); + const resultsContainerEl = $('#results-container'); + const pollResultsContainerEl = $('#poll-results-container'); + + // Элементы навигации для пошаговых опросов + const navigationControlsEl = $('#navigation-controls'); + const prevButtonEl = $('#prev-question'); + const nextButtonEl = $('#next-question'); + const questionCounterEl = $('#question-counter'); + const submitButtonEl = $('#submit-button'); + + // Для пошаговых опросов + let currentQuestionIndex = 0; + + // Проверка доступности localStorage + const isLocalStorageAvailable = () => { + try { + const testKey = 'test'; + window.localStorage.setItem(testKey, testKey); + window.localStorage.removeItem(testKey); + return true; + } catch (e) { + return false; + } + }; + + // Ключ для localStorage + const getLocalStorageKey = () => `questionnaire_${publicLink}_completed`; + + // Проверка на повторное прохождение опроса + const checkIfAlreadyCompleted = () => { + if (!isLocalStorageAvailable()) return false; + return window.localStorage.getItem(getLocalStorageKey()) === 'true'; + }; + + // Сохранение информации о прохождении опроса + const markAsCompleted = () => { + if (!isLocalStorageAvailable()) return; + window.localStorage.setItem(getLocalStorageKey(), 'true'); + }; + + // Получаем базовый путь API + const getApiPath = () => { + const pathParts = window.location.pathname.split('/'); + // Убираем последние две части пути (poll/:publicLink) + pathParts.pop(); + pathParts.pop(); + return pathParts.join('/') + '/api'; + }; + + // Загрузка данных опроса + const loadQuestionnaire = () => { + $.ajax({ + url: `${getApiPath()}/questionnaires/public/${publicLink}`, + method: 'GET', + success: function(result) { + if (result.success) { + questionnaireData = result.data; + + // Проверяем, проходил ли пользователь уже этот опрос + if (checkIfAlreadyCompleted()) { + showAlreadyCompletedMessage(); + } else { + showWelcomeAnimation(); + } + } else { + loadingEl.text(`Ошибка: ${result.error}`); + } + }, + error: function(error) { + console.error('Error loading questionnaire:', error); + loadingEl.text('Не удалось загрузить опрос. Пожалуйста, попробуйте позже.'); + } + }); + }; + + // Показываем анимацию приветствия + const showWelcomeAnimation = () => { + // Скрываем индикатор загрузки + loadingEl.hide(); + + // Создаем элемент приветствия + const $welcomeEl = $('
', { + id: 'welcome-animation', + class: 'welcome-animation', + css: { + opacity: 0, + transform: 'translateY(20px)' + } + }); + + const $welcomeIcon = $('
', { + class: 'welcome-icon', + html: '' + }); + + const $welcomeTitle = $('

', { + text: `Добро пожаловать в опрос "${questionnaireData.title}"`, + class: 'welcome-title' + }); + + const $welcomeDescription = $('

', { + text: questionnaireData.description || 'Пожалуйста, ответьте на следующие вопросы:', + class: 'welcome-description' + }); + + const $startButton = $(' + Вопрос 1 из ${questionnaireData.questions.length} + +

+ `); + + // Обработчики для навигации + $('#prev-question').on('click', prevQuestion); + $('#next-question').on('click', nextQuestion); + + // Показываем только первый вопрос + currentQuestionIndex = 0; + renderCurrentQuestion(); + + // Показываем контейнер с данными + containerEl.show(); + }; + + // Отображение текущего вопроса в пошаговом режиме + const renderCurrentQuestion = () => { + // Очищаем контейнер вопросов + questionsContainerEl.empty(); + + // Получаем текущий вопрос + const currentQuestion = questionnaireData.questions[currentQuestionIndex]; + + // Отображаем вопрос + renderQuestion(questionsContainerEl, currentQuestion, currentQuestionIndex); + + // Добавляем классы для анимации + const $currentQuestionEl = questionsContainerEl.find(`.question-item[data-index="${currentQuestionIndex}"]`); + $currentQuestionEl.addClass('enter'); + + // Запускаем анимацию появления после небольшой задержки + setTimeout(() => { + $currentQuestionEl.removeClass('enter').addClass('active'); + }, 50); + + // Обновляем счетчик вопросов + questionCounterEl.addClass('update'); + questionCounterEl.text(`Вопрос ${currentQuestionIndex + 1} из ${questionnaireData.questions.length}`); + + // Снимаем класс анимации счетчика + setTimeout(() => { + questionCounterEl.removeClass('update'); + }, 500); + + // Управляем состоянием кнопок навигации + prevButtonEl.prop('disabled', currentQuestionIndex === 0); + nextButtonEl.text( + currentQuestionIndex === questionnaireData.questions.length - 1 + ? 'Отправить' + : 'Далее' + ); + }; + + // Переход к предыдущему вопросу + const prevQuestion = function() { + if (currentQuestionIndex > 0) { + currentQuestionIndex--; + renderCurrentQuestion(); + } + }; + + // Переход к следующему вопросу или завершение опроса + const nextQuestion = function() { + const currentQuestion = questionnaireData.questions[currentQuestionIndex]; + + // Проверяем, что на текущий вопрос есть ответ, если он обязательный + if (currentQuestion.required && !checkQuestionAnswer(currentQuestion, currentQuestionIndex)) { + // Добавляем класс ошибки к текущему вопросу + const $currentQuestionEl = questionsContainerEl.find(`.question-item[data-index="${currentQuestionIndex}"]`); + $currentQuestionEl.addClass('error shake'); + + // Удаляем класс ошибки после окончания анимации + setTimeout(() => { + $currentQuestionEl.removeClass('shake'); + }, 500); + + return; + } + + // Если есть еще вопросы, переходим к следующему + if (currentQuestionIndex < questionnaireData.questions.length - 1) { + // Анимируем текущий вопрос перед переходом к следующему + questionsContainerEl.find(`.question-item[data-index="${currentQuestionIndex}"]`).addClass('exit'); + + // Увеличиваем индекс текущего вопроса + currentQuestionIndex++; + + // Рендерим новый вопрос после небольшой задержки + setTimeout(() => { + renderCurrentQuestion(); + }, 200); + } else { + // Анимируем контейнер перед отправкой формы + questionsContainerEl.find('.question-item').addClass('exit'); + + // Последний вопрос, отправляем форму + setTimeout(() => { + // Отключаем атрибут required у невидимых полей перед отправкой + $('input[required]:not(:visible), textarea[required]:not(:visible)').prop('required', false); + + submitForm(); + }, 300); + } + }; + + // Проверка наличия ответа на вопрос + const checkQuestionAnswer = (question, questionIndex) => { + switch (question.type) { + case 'single_choice': + return $(`.question-item[data-index="${questionIndex}"] input[name="question_${questionIndex}"]:checked`).length > 0; + + case 'multiple_choice': + return $(`.question-item[data-index="${questionIndex}"] input[type="checkbox"]:checked:not(.multiple-choice-validation)`).length > 0; + + case 'text': + return $(`.question-item[data-index="${questionIndex}"] textarea`).val().trim() !== ''; + + case 'scale': + return $(`.question-item[data-index="${questionIndex}"] input[name="question_${questionIndex}"]:checked`).length > 0; + + case 'tag_cloud': + return $(`.question-item[data-index="${questionIndex}"] .tag-item.selected`).length > 0; + + default: + return true; + } + }; + + // Отображение всех вопросов (стандартный режим) + const renderQuestions = () => { + questionsContainerEl.empty(); + + $.each(questionnaireData.questions, function(questionIndex, question) { + renderQuestion(questionsContainerEl, question, questionIndex); + }); + }; + + // Отображение одного вопроса + const renderQuestion = (container, question, questionIndex) => { + const $questionEl = $('
', { + class: 'question-item', + 'data-index': questionIndex + }); + + const $questionTitle = $('

', { + text: question.text, + class: 'question-title' + }); + + if (question.required) { + const $requiredMark = $('', { + class: 'required-mark', + text: ' *' + }); + $questionTitle.append($requiredMark); + } + + $questionEl.append($questionTitle); + + // Отображение вариантов ответа в зависимости от типа вопроса + switch (question.type) { + case 'single_choice': + renderSingleChoice($questionEl, question, questionIndex); + break; + case 'multiple_choice': + renderMultipleChoice($questionEl, question, questionIndex); + break; + case 'text': + renderTextAnswer($questionEl, question, questionIndex); + break; + case 'scale': + renderScale($questionEl, question, questionIndex); + break; + case 'rating': // Обратная совместимость для рейтинга + renderScale($questionEl, { ...question, scaleMax: 5 }, questionIndex); + break; + case 'tag_cloud': + renderTagCloud($questionEl, question, questionIndex); + break; + } + + container.append($questionEl); + }; + + // Отображение вопроса с одиночным выбором + const renderSingleChoice = ($container, question, questionIndex) => { + const $optionsContainer = $('
', { + class: 'radio-options-container' + }); + + $.each(question.options, function(optionIndex, option) { + const $optionContainer = $('
', { + class: 'radio-option' + }); + + const $optionInput = $('', { + type: 'radio', + id: `question_${questionIndex}_option_${optionIndex}`, + name: `question_${questionIndex}`, + value: optionIndex, + required: question.required + }); + + const $optionLabel = $('