Initial commit: Flask quiz game 'Уроки французского'
This commit is contained in:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
venv/
|
||||
env/
|
||||
.env
|
||||
.venv
|
||||
|
||||
# Flask
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
164
AGENTS.md
Normal file
164
AGENTS.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# AGENTS.md - Guidelines for Agentic Coding
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a simple Flask web application - a "Jeopardy-style" quiz game about the Russian short story "Уроки французского" by Valentin Rasputin. The project uses:
|
||||
- **Backend**: Python 3.13 with Flask
|
||||
- **Frontend**: HTML, CSS, JavaScript (vanilla)
|
||||
- **Data Storage**: JSON file (`data/questions.json`)
|
||||
- **State**: Browser localStorage (client-side)
|
||||
|
||||
## Build/Test Commands
|
||||
|
||||
### Running the Application
|
||||
|
||||
```bash
|
||||
# Activate virtual environment
|
||||
source venv/bin/activate
|
||||
|
||||
# Run Flask app (default port 5000)
|
||||
python app.py
|
||||
|
||||
# Run on specific port
|
||||
python -c "from app import app; app.run(port=5002)"
|
||||
```
|
||||
|
||||
### No Formal Tests
|
||||
|
||||
This project does not have a formal test suite. For manual testing:
|
||||
- Use `curl` to test API endpoints
|
||||
- Test in browser at http://localhost:5000 (or configured port)
|
||||
|
||||
### Linting/Type Checking
|
||||
|
||||
No formal linter is configured. The codebase uses basic Python and JavaScript.
|
||||
|
||||
## Code Style Guidelines
|
||||
|
||||
### Python (app.py)
|
||||
|
||||
**Imports**
|
||||
- Standard library first, then third-party
|
||||
- Use absolute imports
|
||||
- Example:
|
||||
```python
|
||||
import json
|
||||
from flask import Flask, render_template, request, redirect, url_for, make_response
|
||||
```
|
||||
|
||||
**Formatting**
|
||||
- Use 4 spaces for indentation
|
||||
- Maximum line length: 100 characters
|
||||
- Blank lines: 2 between top-level definitions, 1 between function definitions
|
||||
|
||||
**Naming Conventions**
|
||||
- `snake_case` for variables, functions
|
||||
- `PascalCase` for classes
|
||||
- `UPPER_SNAKE_CASE` for constants
|
||||
|
||||
**Error Handling**
|
||||
- Use try/except for file operations (JSON loading/saving)
|
||||
- Return proper HTTP error codes (400, 404, 500)
|
||||
- Log errors to console
|
||||
|
||||
**Flask-Specific**
|
||||
- Use `app.secret_key` for sessions
|
||||
- Always use `ensure_ascii=False` with JSON for Cyrillic support
|
||||
- Use `context_processor` for global template variables
|
||||
|
||||
### JavaScript (templates/*.html)
|
||||
|
||||
**General**
|
||||
- Use vanilla JavaScript (no frameworks)
|
||||
- Prefer `var` over `let/const` for compatibility
|
||||
- Use `function` keyword instead of arrow functions where possible
|
||||
|
||||
**Event Handling**
|
||||
- Use `onclick` directly in HTML or `element.onclick = function()`
|
||||
- Avoid `addEventListener` for simplicity
|
||||
|
||||
**DOM Manipulation**
|
||||
- Use `document.getElementById` and `document.querySelector`
|
||||
- Template literals for dynamic content: `` `string ${variable}` ``
|
||||
- Use `JSON.parse()` and `JSON.stringify()` for localStorage
|
||||
|
||||
**Naming**
|
||||
- camelCase for variables and functions
|
||||
- Descriptive names (e.g., `currentTeam`, `selectAnswer`)
|
||||
|
||||
### HTML/CSS (templates/*.html)
|
||||
|
||||
**Template Syntax (Jinja2)**
|
||||
- Use `{% for %}` loops with `enumerate()` for indexed iteration
|
||||
- Pass data via `{{ variable }}` syntax
|
||||
- Use `|tojson` filter for JavaScript data
|
||||
|
||||
**CSS**
|
||||
- Use CSS custom properties (variables) for colors
|
||||
- Follow BEM-like naming for classes
|
||||
- Keep responsive design in mind
|
||||
|
||||
**Structure**
|
||||
- Inline CSS in `<style>` tags (simple project)
|
||||
- Inline JS in `<script>` tags at end of body
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
/home/eof/dev/roma/sigra/
|
||||
├── app.py # Flask application
|
||||
├── data/
|
||||
│ └── questions.json # Game questions data
|
||||
├── templates/
|
||||
│ ├── index.html # Main game page
|
||||
│ ├── admin.html # Admin panel (edit questions)
|
||||
│ ├── edit.html # Question editor
|
||||
│ └── login.html # Admin login
|
||||
├── static/ # Static assets (empty currently)
|
||||
├── venv/ # Virtual environment
|
||||
├── SPEC.md # Project specification
|
||||
└── AGENTS.md # This file
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Adding a New Question
|
||||
1. Edit `data/questions.json` manually or via admin panel at `/admin`
|
||||
2. Follow existing structure:
|
||||
```json
|
||||
{
|
||||
"cost": 600,
|
||||
"question": "Question text?",
|
||||
"options": ["Option 1", "Option 2", "Option 3", "Option 4"],
|
||||
"answer": 0 // 0-3 index of correct answer
|
||||
}
|
||||
```
|
||||
|
||||
### Adding a New Route
|
||||
1. Add route in `app.py` using `@app.route()`
|
||||
2. Return `render_template()` or `jsonify()`
|
||||
3. For API routes, use proper HTTP methods
|
||||
|
||||
### Modifying Game Logic
|
||||
- Game state stored in browser's localStorage
|
||||
- Key: `francuzskiy_game`
|
||||
- Structure: `{ score: 0, answered: [], userAnswers: {}, teams: [] }`
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Admin panel is password-protected (password in `app.py`)
|
||||
- No database - data is in JSON file
|
||||
- No SQL injection risk (no database)
|
||||
- XSS: Be careful with `innerHTML` - user input in questions is trusted
|
||||
|
||||
## Dependencies
|
||||
|
||||
```
|
||||
Flask==3.1.3
|
||||
Jinja2==3.1.6
|
||||
Werkzeug==3.1.6
|
||||
```
|
||||
|
||||
## Contact
|
||||
|
||||
For questions about this codebase, refer to SPEC.md or the original requirements.
|
||||
90
SPEC.md
Normal file
90
SPEC.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Своя игра: Уроки французского
|
||||
|
||||
## 1. Project Overview
|
||||
|
||||
Веб-игра по типу "Своя игра" (Jeopardy) на тему рассказа Валентина Распутина "Уроки французского". Игроки выбирают вопросы разной сложности, отвечают на них и набирают очки.
|
||||
|
||||
## 2. UI/UX Specification
|
||||
|
||||
### Layout Structure
|
||||
|
||||
- **Header**: Название игры, текущий счёт
|
||||
- **Game Board**: Сетка 5x6 (5 тем × 6 вопросов)
|
||||
- **Question Modal**: Всплывающее окно с вопросом
|
||||
- **Score Panel**: Отображение очков игрока
|
||||
|
||||
### Responsive Breakpoints
|
||||
- Desktop: 1200px+
|
||||
- Tablet: 768px - 1199px
|
||||
- Mobile: < 768px
|
||||
|
||||
### Visual Design
|
||||
|
||||
**Color Palette**:
|
||||
- Background: #1a1a2e (тёмно-синий)
|
||||
- Primary: #e94560 (красный акцент)
|
||||
- Secondary: #16213e (тёмно-синий вторичный)
|
||||
- Surface: #0f3460 (карточки)
|
||||
- Text: #eaeaea (светлый текст)
|
||||
- Gold: #ffd700 (для очков)
|
||||
- Success: #4ade80 (правильный ответ)
|
||||
- Error: #f87171 (неправильный ответ)
|
||||
|
||||
**Typography**:
|
||||
- Headings: "Playfair Display", serif
|
||||
- Body: "Source Sans Pro", sans-serif
|
||||
- Sizes: H1 2.5rem, H2 1.5rem, Body 1rem
|
||||
|
||||
**Visual Effects**:
|
||||
- Box-shadow на карточках
|
||||
- Hover эффект scale(1.05) на кнопках
|
||||
- Анимация появления модального окна
|
||||
- Fade transition для переходов
|
||||
|
||||
### Components
|
||||
|
||||
1. **Category Card** - Заголовок темы
|
||||
2. **Question Cell** - Ячейка с вопросом (отображает стоимость)
|
||||
3. **Question Modal** - Модальное окно с вопросом и вариантами ответов
|
||||
4. **Score Display** - Показ очков
|
||||
5. **Final Score Screen** - Итоговый экран
|
||||
|
||||
## 3. Functionality Specification
|
||||
|
||||
### Темы и вопросы (5 тем × 6 вопросов = 30 вопросов)
|
||||
|
||||
**Темы**:
|
||||
1. Автор и произведение (100-500)
|
||||
2. Персонажи (100-500)
|
||||
3. События (100-500)
|
||||
4. Цитаты (100-500)
|
||||
5. География и детали (100-500)
|
||||
|
||||
**Сложность**:
|
||||
- 100 - самый лёгкий
|
||||
- 500 - самый сложный
|
||||
|
||||
### Core Features
|
||||
1. Выбор вопроса из сетки
|
||||
2. Отображение вопроса с вариантами ответов (4 варианта)
|
||||
3. Проверка правильности ответа
|
||||
4. Начисление/списание очков
|
||||
5. Отслеживание отыгранных вопросов
|
||||
6. Финальный экран с результатом
|
||||
|
||||
### User Flow
|
||||
1. Стартовый экран → Начать игру
|
||||
2. Игровое поле → Выбор вопроса
|
||||
3. Модальное окно → Выбор ответа
|
||||
4. Результат → Возврат на поле
|
||||
5. Все вопросы отыграны → Финальный счёт
|
||||
|
||||
## 4. Acceptance Criteria
|
||||
|
||||
- [ ] Все 30 вопросов отображаются корректно
|
||||
- [ ] Вопросы открываются по клику
|
||||
- [ ] Правильный ответ показывает +очки, неправильный -очки
|
||||
- [ ] Отыгранные вопросы затемняются
|
||||
- [ ] Финальный экран показывает итоговый счёт
|
||||
- [ ] Кнопка "Играть снова" сбрасывает игру
|
||||
- [ ] Адаптивный дизайн работает на мобильных
|
||||
73
app.py
Normal file
73
app.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import json
|
||||
from flask import Flask, render_template, request, redirect, url_for, make_response
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = 'your_secret_key_here'
|
||||
|
||||
ADMIN_PASSWORD = 'RaMaZaNoV2013'
|
||||
|
||||
def load_questions():
|
||||
with open('data/questions.json', 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
def save_questions(data):
|
||||
with open('data/questions.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=4)
|
||||
|
||||
@app.context_processor
|
||||
def inject_enumerate():
|
||||
return dict(enumerate=enumerate)
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
questions = load_questions()
|
||||
return render_template('index.html', questions=questions)
|
||||
|
||||
def check_auth():
|
||||
password = request.cookies.get('admin_password')
|
||||
return password == ADMIN_PASSWORD
|
||||
|
||||
@app.route('/admin')
|
||||
def admin():
|
||||
if not check_auth():
|
||||
return render_template('login.html')
|
||||
questions = load_questions()
|
||||
return render_template('admin.html', questions=questions)
|
||||
|
||||
@app.route('/admin/login', methods=['POST'])
|
||||
def login():
|
||||
password = request.form.get('password')
|
||||
if password == ADMIN_PASSWORD:
|
||||
resp = make_response(redirect(url_for('admin')))
|
||||
resp.set_cookie('admin_password', password)
|
||||
return resp
|
||||
return render_template('login.html', error='Неверный пароль')
|
||||
|
||||
@app.route('/admin/logout')
|
||||
def logout():
|
||||
resp = make_response(redirect(url_for('admin')))
|
||||
resp.set_cookie('admin_password', '', expires=0)
|
||||
return resp
|
||||
|
||||
@app.route('/admin/edit/<int:cat>/<int:q>', methods=['GET', 'POST'])
|
||||
def edit_question(cat, q):
|
||||
if not check_auth():
|
||||
return redirect(url_for('admin'))
|
||||
questions = load_questions()
|
||||
if request.method == 'POST':
|
||||
questions['categories'][cat]['questions'][q]['question'] = request.form['question']
|
||||
questions['categories'][cat]['questions'][q]['options'] = [
|
||||
request.form['option1'],
|
||||
request.form['option2'],
|
||||
request.form['option3'],
|
||||
request.form['option4']
|
||||
]
|
||||
questions['categories'][cat]['questions'][q]['answer'] = int(request.form['answer'])
|
||||
questions['categories'][cat]['questions'][q]['cost'] = int(request.form['cost'])
|
||||
save_questions(questions)
|
||||
return redirect(url_for('admin'))
|
||||
question = questions['categories'][cat]['questions'][q]
|
||||
return render_template('edit.html', question=question, cat=cat, q=q)
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, port=5002)
|
||||
359
data/questions.json
Normal file
359
data/questions.json
Normal file
@@ -0,0 +1,359 @@
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"name": "Автор и произведение",
|
||||
"questions": [
|
||||
{
|
||||
"cost": 100,
|
||||
"question": "В каком году было впервые опубликовано произведение «Уроки французского»?",
|
||||
"options": [
|
||||
"1973",
|
||||
"1978",
|
||||
"1980",
|
||||
"1985"
|
||||
],
|
||||
"answer": 0
|
||||
},
|
||||
{
|
||||
"cost": 200,
|
||||
"question": "К какому жанру относится это произведение?",
|
||||
"options": [
|
||||
"Повесть",
|
||||
"Рассказ",
|
||||
"Роман",
|
||||
"Очерк"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"cost": 300,
|
||||
"question": "Какую должность занимала учительница французского языка?",
|
||||
"options": [
|
||||
"Директор школы",
|
||||
"Завуч",
|
||||
"Учительница начальных классов",
|
||||
"Учительница французского языка"
|
||||
],
|
||||
"answer": 3
|
||||
},
|
||||
{
|
||||
"cost": 400,
|
||||
"question": "Сколько лет было главному герою в начале произведения?",
|
||||
"options": [
|
||||
"8 лет",
|
||||
"10 лет",
|
||||
"11 лет",
|
||||
"12 лет"
|
||||
],
|
||||
"answer": 2
|
||||
},
|
||||
{
|
||||
"cost": 500,
|
||||
"question": "Какое звание имеет автор рассказа?",
|
||||
"options": [
|
||||
"Народный писатель России",
|
||||
"Заслуженный деятель искусств",
|
||||
"Академик РАН",
|
||||
"Профессор"
|
||||
],
|
||||
"answer": 3
|
||||
},
|
||||
{
|
||||
"cost": 600,
|
||||
"question": "В каком году родился Валентин Распутин?",
|
||||
"options": [
|
||||
"1937",
|
||||
"1945",
|
||||
"1952",
|
||||
"1960"
|
||||
],
|
||||
"answer": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Персонажи",
|
||||
"questions": [
|
||||
{
|
||||
"cost": 100,
|
||||
"question": "Как зовут главного героя рассказа?",
|
||||
"options": [
|
||||
"Витя",
|
||||
"Игорь",
|
||||
"Миша",
|
||||
"Саша"
|
||||
],
|
||||
"answer": 0
|
||||
},
|
||||
{
|
||||
"cost": 200,
|
||||
"question": "Как зовут учительницу французского языка?",
|
||||
"options": [
|
||||
"Лидия Михайловна",
|
||||
"Елизавета Павловна",
|
||||
"Екатерина Сергеевна",
|
||||
"Валентина Петровна"
|
||||
],
|
||||
"answer": 0
|
||||
},
|
||||
{
|
||||
"cost": 300,
|
||||
"question": "Кем приходится директор школы главному герою?",
|
||||
"options": [
|
||||
"Дядей",
|
||||
"Отцом",
|
||||
"Дедушкой",
|
||||
"Он не был ему родственником"
|
||||
],
|
||||
"answer": 3
|
||||
},
|
||||
{
|
||||
"cost": 400,
|
||||
"question": "Как зовут одноклассника, который помогал Вите с уроками?",
|
||||
"options": [
|
||||
"Федя",
|
||||
"Ваня",
|
||||
"Серёжа",
|
||||
"Петя"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"cost": 500,
|
||||
"question": "Кто научил Витю играть в «чику»?",
|
||||
"options": [
|
||||
"Учительница",
|
||||
"Одноклассники",
|
||||
"Старшие мальчишки",
|
||||
"Отец"
|
||||
],
|
||||
"answer": 2
|
||||
},
|
||||
{
|
||||
"cost": 600,
|
||||
"question": "Как звали маму Вити?",
|
||||
"options": [
|
||||
"Анна",
|
||||
"Марья",
|
||||
"Валентина",
|
||||
"Её имя не упоминается"
|
||||
],
|
||||
"answer": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "События",
|
||||
"questions": [
|
||||
{
|
||||
"cost": 100,
|
||||
"question": "Почему Витя остался на второй год?",
|
||||
"options": [
|
||||
"Он не оставался на второй год",
|
||||
"Ленился",
|
||||
"Переехал в другую школу",
|
||||
"Не сдал экзамен"
|
||||
],
|
||||
"answer": 0
|
||||
},
|
||||
{
|
||||
"cost": 200,
|
||||
"question": "Что случилось с Витиными деньгами, которые он украл у отца?",
|
||||
"options": [
|
||||
"Он ничего не крал у отца",
|
||||
"Он их проиграл",
|
||||
"Они потерялись",
|
||||
"Отец их нашёл"
|
||||
],
|
||||
"answer": 0
|
||||
},
|
||||
{
|
||||
"cost": 300,
|
||||
"question": "Зачем Витя пошёл в школу в выходной день?",
|
||||
"options": [
|
||||
"Чтобы вернуть деньги бабушке которую обманул",
|
||||
"К учительнице",
|
||||
"Забыл тетрадь",
|
||||
"Встретить друзей"
|
||||
],
|
||||
"answer": 0
|
||||
},
|
||||
{
|
||||
"cost": 400,
|
||||
"question": "Что делала учительница, чтобы помочь Вите?",
|
||||
"options": [
|
||||
"Давала деньги",
|
||||
"Проводила дополнительные уроки",
|
||||
"Отправляла ему посылки с продуктами",
|
||||
"Всё верно"
|
||||
],
|
||||
"answer": 2
|
||||
},
|
||||
{
|
||||
"cost": 500,
|
||||
"question": "Что Витя делал с деньгами которыми выиграл в \"Чике\"?",
|
||||
"options": [
|
||||
"Покупал продукты",
|
||||
"Помогал бедным детям",
|
||||
"Покупал игрушки",
|
||||
"Покупал на них повозку чтобы ездить к маме"
|
||||
],
|
||||
"answer": 0
|
||||
},
|
||||
{
|
||||
"cost": 600,
|
||||
"question": "Сколько денег украл Витя у отца?",
|
||||
"options": [
|
||||
"50 рублей",
|
||||
"Он не крал у него денег",
|
||||
"150 рублей",
|
||||
"200 рублей"
|
||||
],
|
||||
"answer": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Цитаты и детали",
|
||||
"questions": [
|
||||
{
|
||||
"cost": 100,
|
||||
"question": "На каком языке говорят персонажи рассказа, кроме уроков?",
|
||||
"options": [
|
||||
"Английском",
|
||||
"Немецком",
|
||||
"Русском",
|
||||
"Французском"
|
||||
],
|
||||
"answer": 2
|
||||
},
|
||||
{
|
||||
"cost": 200,
|
||||
"question": "Что означает слово «чика» в рассказе?",
|
||||
"options": [
|
||||
"Конфеты",
|
||||
"Игра",
|
||||
"Школа",
|
||||
"Деньги"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"cost": 300,
|
||||
"question": "Какой предмет был самым сложным для Вити?",
|
||||
"options": [
|
||||
"Математика",
|
||||
"Русский язык",
|
||||
"Французский язык",
|
||||
"География"
|
||||
],
|
||||
"answer": 2
|
||||
},
|
||||
{
|
||||
"cost": 400,
|
||||
"question": "Что учительница написала на доске в первый урок?",
|
||||
"options": [
|
||||
"Алфавит",
|
||||
"Стихотворение",
|
||||
"Предложение",
|
||||
"Числа"
|
||||
],
|
||||
"answer": 2
|
||||
},
|
||||
{
|
||||
"cost": 500,
|
||||
"question": "Какой момент стал переломным в отношениях Вити и учительницы?",
|
||||
"options": [
|
||||
"Экзамен",
|
||||
"Разговор о деньгах",
|
||||
"Игра в чику",
|
||||
"Письмо родителям"
|
||||
],
|
||||
"answer": 2
|
||||
},
|
||||
{
|
||||
"cost": 600,
|
||||
"question": "Что означала фраза \"незадачливый щелбан\"?",
|
||||
"options": [
|
||||
"Удар по голове",
|
||||
"Пощечина",
|
||||
"Неудачный бросок",
|
||||
"Укус"
|
||||
],
|
||||
"answer": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "География и детали",
|
||||
"questions": [
|
||||
{
|
||||
"cost": 100,
|
||||
"question": "В каком году происходит действие рассказа?",
|
||||
"options": [
|
||||
"1940-е",
|
||||
"1950-е",
|
||||
"1960-е",
|
||||
"1970-е"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"cost": 200,
|
||||
"question": "Где жил главный герой после переезда?",
|
||||
"options": [
|
||||
"В городе",
|
||||
"В районном центре",
|
||||
"В деревне",
|
||||
"В посёлке"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"cost": 300,
|
||||
"question": "Сколько времени Витя не ходил в школу после переезда?",
|
||||
"options": [
|
||||
"Несколько дней",
|
||||
"Несколько недель",
|
||||
"Месяц",
|
||||
"Полгода"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"cost": 400,
|
||||
"question": "Какую еду Витя мог себе позволить купить на деньги?",
|
||||
"options": [
|
||||
"Хлеб и молоко",
|
||||
"Молоко и конфеты",
|
||||
"Конфеты и булку",
|
||||
"Всё верно"
|
||||
],
|
||||
"answer": 3
|
||||
},
|
||||
{
|
||||
"cost": 500,
|
||||
"question": "Как звали подружку учительницы, которая приезжала к ней?",
|
||||
"options": [
|
||||
"Мария Ивановна",
|
||||
"У неё не было подруги",
|
||||
"Надежда Сергеевна",
|
||||
"Людмила Петровна"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"cost": 600,
|
||||
"question": "Как звали хозяина магазина где Витя покупал продукты?",
|
||||
"options": [
|
||||
"Иван Петрович",
|
||||
"Егор Семёнович",
|
||||
"Михаил Иванович",
|
||||
"Его имя не называется"
|
||||
],
|
||||
"answer": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
40
templates/admin.html
Normal file
40
templates/admin.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Админка - Уроки французского</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; background: #1a1a2e; color: #eaeaea; padding: 20px; }
|
||||
h1, h2 { color: #e94560; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
|
||||
th, td { padding: 10px; border: 1px solid #0f3460; text-align: left; }
|
||||
th { background: #0f3460; }
|
||||
.btn { background: #e94560; color: white; padding: 8px 16px; text-decoration: none; border-radius: 4px; display: inline-block; }
|
||||
.btn:hover { background: #c73e54; }
|
||||
.back { margin-top: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Админка - Редактирование вопросов</h1>
|
||||
<a href="/admin/logout" class="btn" style="float: right;">Выйти</a>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Категория</th>
|
||||
<th>Вопрос</th>
|
||||
<th>Стоимость</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
{% for cat_idx, category in enumerate(questions.categories) %}
|
||||
{% for q_idx, q in enumerate(category.questions) %}
|
||||
<tr>
|
||||
<td>{{ category.name }}</td>
|
||||
<td>{{ q.question }}</td>
|
||||
<td>{{ q.cost }}</td>
|
||||
<td><a href="/admin/edit/{{ cat_idx }}/{{ q_idx }}" class="btn">Редактировать</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
<a href="/" class="btn back">Назад к игре</a>
|
||||
</body>
|
||||
</html>
|
||||
55
templates/edit.html
Normal file
55
templates/edit.html
Normal file
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Редактирование вопроса</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; background: #1a1a2e; color: #eaeaea; padding: 20px; }
|
||||
h1 { color: #e94560; }
|
||||
.form-group { margin: 15px 0; }
|
||||
label { display: block; margin-bottom: 5px; }
|
||||
input, textarea { width: 100%; padding: 8px; background: #0f3460; color: #eaeaea; border: 1px solid #e94560; border-radius: 4px; }
|
||||
.btn { background: #e94560; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; }
|
||||
.btn:hover { background: #c73e54; }
|
||||
.options { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Редактирование вопроса</h1>
|
||||
<form method="POST">
|
||||
<div class="form-group">
|
||||
<label>Вопрос:</label>
|
||||
<textarea name="question" rows="3" required>{{ question.question }}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Стоимость:</label>
|
||||
<input type="number" name="cost" value="{{ question.cost }}" required>
|
||||
</div>
|
||||
<div class="form-group options">
|
||||
<div>
|
||||
<label>Вариант 1:</label>
|
||||
<input type="text" name="option1" value="{{ question.options[0] }}" required>
|
||||
</div>
|
||||
<div>
|
||||
<label>Вариант 2:</label>
|
||||
<input type="text" name="option2" value="{{ question.options[1] }}" required>
|
||||
</div>
|
||||
<div>
|
||||
<label>Вариант 3:</label>
|
||||
<input type="text" name="option3" value="{{ question.options[2] }}" required>
|
||||
</div>
|
||||
<div>
|
||||
<label>Вариант 4:</label>
|
||||
<input type="text" name="option4" value="{{ question.options[3] }}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Правильный ответ (индекс 0-3):</label>
|
||||
<input type="number" name="answer" value="{{ question.answer }}" min="0" max="3" required>
|
||||
<p>Текущий правильный: {{ question.options[question.answer] }}</p>
|
||||
</div>
|
||||
<button type="submit" class="btn">Сохранить</button>
|
||||
</form>
|
||||
<a href="/admin" class="btn" style="margin-top: 10px;">Назад</a>
|
||||
</body>
|
||||
</html>
|
||||
931
templates/index.html
Normal file
931
templates/index.html
Normal file
@@ -0,0 +1,931 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Уроки французского - Своя игра</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=Source+Sans+Pro:wght@400;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #1a1a2e;
|
||||
--bg-secondary: #16213e;
|
||||
--surface: #0f3460;
|
||||
--primary: #e94560;
|
||||
--text: #eaeaea;
|
||||
--gold: #ffd700;
|
||||
--success: #4ade80;
|
||||
--error: #f87171;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Source Sans Pro', sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
background-image:
|
||||
radial-gradient(ellipse at top, #1a1a2e 0%, #0f0f1a 100%),
|
||||
repeating-linear-gradient(45deg, transparent, transparent 10px, rgba(233, 69, 96, 0.03) 10px, rgba(233, 69, 96, 0.03) 20px);
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
background: linear-gradient(180deg, var(--bg-secondary) 0%, transparent 100%);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-size: 2.5rem;
|
||||
color: var(--primary);
|
||||
text-shadow: 0 0 30px rgba(233, 69, 96, 0.5);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: rgba(234, 234, 234, 0.7);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.score-panel {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin: 1.5rem 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.score-item {
|
||||
background: var(--surface);
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 50px;
|
||||
font-size: 1.2rem;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(233, 69, 96, 0.3);
|
||||
}
|
||||
|
||||
.score-value {
|
||||
color: var(--gold);
|
||||
font-weight: 600;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.restart-btn {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 25px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.restart-btn:hover {
|
||||
background: #c73e54;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.team-setup {
|
||||
text-align: center;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.team-setup select {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 4px;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.team-setup select option {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.team-names {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.team-names input {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.team-names input {
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 4px;
|
||||
margin: 0 0.5rem;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
background: var(--success);
|
||||
color: var(--bg-primary);
|
||||
border: none;
|
||||
padding: 0.5rem 1.5rem;
|
||||
border-radius: 25px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.start-btn:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.score-panel {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.score-item {
|
||||
background: var(--surface);
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 50px;
|
||||
font-size: 1.2rem;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(233, 69, 96, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.team-active {
|
||||
border-color: var(--gold);
|
||||
box-shadow: 0 0 15px rgba(255, 215, 0, 0.5);
|
||||
}
|
||||
|
||||
.team-selector {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.team-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
border: 2px solid var(--primary);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.team-btn.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.team-btn:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.game-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.game-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
column-gap: 0.5rem;
|
||||
row-gap: 0.5rem;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.category-header {
|
||||
background: linear-gradient(135deg, var(--primary) 0%, #c73e54 100%);
|
||||
color: white;
|
||||
padding: 0.5rem;
|
||||
text-align: center;
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
border-radius: 8px 8px 0 0;
|
||||
min-height: 80px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 15px rgba(233, 69, 96, 0.4);
|
||||
}
|
||||
|
||||
.question-cell {
|
||||
background: var(--surface);
|
||||
aspect-ratio: 1.4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--gold);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid rgba(233, 69, 96, 0.2);
|
||||
border-radius: 4px;
|
||||
min-height: 50px;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.question-cell:hover:not(.answered) {
|
||||
transform: scale(1.05);
|
||||
background: var(--bg-secondary);
|
||||
box-shadow: 0 0 25px rgba(233, 69, 96, 0.4);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.question-cell.answered {
|
||||
background: rgba(15, 52, 96, 0.5);
|
||||
color: rgba(234, 234, 234, 0.3);
|
||||
cursor: not-allowed;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-overlay.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: linear-gradient(145deg, var(--bg-secondary) 0%, var(--surface) 100%);
|
||||
border-radius: 20px;
|
||||
padding: 2.5rem;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5), 0 0 40px rgba(233, 69, 96, 0.2);
|
||||
border: 1px solid rgba(233, 69, 96, 0.3);
|
||||
animation: slideUp 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(30px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
font-family: 'Playfair Display', serif;
|
||||
color: var(--primary);
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-cost {
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
right: 20px;
|
||||
background: var(--gold);
|
||||
color: var(--bg-primary);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 4px 15px rgba(255, 215, 0, 0.4);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.question-text {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.options {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.option {
|
||||
background: var(--bg-primary);
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.option:hover {
|
||||
border-color: var(--primary);
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.option.correct {
|
||||
background: var(--success);
|
||||
color: var(--bg-primary);
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.option.wrong {
|
||||
background: var(--error);
|
||||
color: var(--bg-primary);
|
||||
border-color: var(--error);
|
||||
}
|
||||
|
||||
.result-message {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
border-radius: 10px;
|
||||
margin-top: 1.5rem;
|
||||
font-weight: 600;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.result-message.show {
|
||||
display: block;
|
||||
animation: popIn 0.3s ease;
|
||||
}
|
||||
|
||||
.result-message.success {
|
||||
background: rgba(74, 222, 128, 0.2);
|
||||
color: var(--success);
|
||||
border: 1px solid var(--success);
|
||||
}
|
||||
|
||||
.result-message.error {
|
||||
background: rgba(248, 113, 113, 0.2);
|
||||
color: var(--error);
|
||||
border: 1px solid var(--error);
|
||||
}
|
||||
|
||||
@keyframes popIn {
|
||||
from { transform: scale(0.8); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: none;
|
||||
margin: 1.5rem auto 0;
|
||||
padding: 0.75rem 2rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.close-btn.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 20px rgba(233, 69, 96, 0.4);
|
||||
}
|
||||
|
||||
.final-screen {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-primary);
|
||||
z-index: 2000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.final-screen.active {
|
||||
display: flex;
|
||||
animation: fadeIn 0.5s ease;
|
||||
}
|
||||
|
||||
.final-screen h2 {
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-size: 3rem;
|
||||
color: var(--gold);
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 0 0 30px rgba(255, 215, 0, 0.5);
|
||||
}
|
||||
|
||||
.podium {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
gap: 20px;
|
||||
margin: 2rem 0;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.podium-place {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.podium-team {
|
||||
background: var(--surface);
|
||||
padding: 1rem;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
border: 2px solid var(--primary);
|
||||
}
|
||||
|
||||
.podium-team-name {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.podium-team-score {
|
||||
font-size: 1.5rem;
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.podium-block {
|
||||
background: linear-gradient(180deg, var(--primary) 0%, #c73e54 100%);
|
||||
width: 100%;
|
||||
border-radius: 8px 8px 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.podium-1 .podium-block {
|
||||
height: 150px;
|
||||
background: linear-gradient(180deg, #ffd700 0%, #daa520 100%);
|
||||
}
|
||||
|
||||
.podium-2 .podium-block {
|
||||
height: 100px;
|
||||
background: linear-gradient(180deg, #c0c0c0 0%, #a8a8a8 100%);
|
||||
}
|
||||
|
||||
.podium-3 .podium-block {
|
||||
height: 70px;
|
||||
background: linear-gradient(180deg, #cd7f32 0%, #b87333 100%);
|
||||
}
|
||||
|
||||
.podium-1 { order: 2; }
|
||||
.podium-2 { order: 1; }
|
||||
.podium-3 { order: 3; }
|
||||
|
||||
.all-teams {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.team-result {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--surface);
|
||||
padding: 15px 20px;
|
||||
border-radius: 10px;
|
||||
border: 2px solid var(--primary);
|
||||
}
|
||||
|
||||
.team-place {
|
||||
font-size: 1.5rem;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.team-name {
|
||||
flex: 1;
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.team-points {
|
||||
font-size: 1.2rem;
|
||||
color: var(--gold);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.-gradient(180deg, var(--primary) 0%, #c73e54 100%);
|
||||
width: 100%;
|
||||
border-radius: 8px 8px 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.podium-1 .podium-block {
|
||||
height: 150px;
|
||||
background: linear-gradient(180deg, #ffd700 0%, #daa520 100%);
|
||||
}
|
||||
|
||||
.podium-2 .podium-block {
|
||||
height: 100px;
|
||||
background: linear-gradient(180deg, #c0c0c0 0%, #a8a8a8 100%);
|
||||
}
|
||||
|
||||
.podium-3 .podium-block {
|
||||
height: 70px;
|
||||
background: linear-gradient(180deg, #cd7f32 0%, #b87333 100%);
|
||||
}
|
||||
|
||||
.podium-1 .podium-block {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.podium-2 .podium-block {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.podium-3 .podium-block {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.play-again-btn {
|
||||
padding: 1rem 3rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.play-again-btn:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 10px 30px rgba(233, 69, 96, 0.5);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
h1 { font-size: 1.8rem; }
|
||||
.category-header { font-size: 0.85rem; min-height: 60px; padding: 0.5rem; }
|
||||
.question-cell { font-size: 1.1rem; min-height: 50px; }
|
||||
.modal { padding: 1.5rem; }
|
||||
.question-text { font-size: 1rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Своя игра</h1>
|
||||
<p class="subtitle">Рассказ В.Г. Распутина «Уроки французского»</p>
|
||||
<div class="team-setup" id="teamSetup">
|
||||
<label>Количество команд:
|
||||
<select id="teamCount" onchange="setupTeams()">
|
||||
<option value="1">1 команда</option>
|
||||
<option value="2">2 команды</option>
|
||||
<option value="3">3 команды</option>
|
||||
</select>
|
||||
</label>
|
||||
<div id="teamNames"></div>
|
||||
<button class="start-btn" id="startBtn" onclick="startGame()">Начать игру</button>
|
||||
</div>
|
||||
<div class="score-panel" id="scorePanel">
|
||||
<div class="team-selector" id="teamSelector"></div>
|
||||
<div class="score-item">Вопросов: <span class="score-value" id="answered">0</span>/<span id="totalQuestions">30</span></div>
|
||||
<button class="restart-btn" onclick="if(confirm('Вы точно хотите начать заново?')) restartGame()">Начать заново</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="game-container">
|
||||
<div class="game-board" id="gameBoard">
|
||||
{% for cat_idx, category in enumerate(questions.categories) %}
|
||||
<div>
|
||||
<div class="category-header">{{ category.name }}</div>
|
||||
{% for q_idx, q in enumerate(category.questions) %}
|
||||
<div class="question-cell"
|
||||
data-cat="{{ cat_idx }}"
|
||||
data-q="{{ q_idx }}">
|
||||
{{ q.cost }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="modalOverlay">
|
||||
<div class="modal">
|
||||
<div class="modal-body">
|
||||
<span class="modal-cost" id="modalCost">100</span>
|
||||
<h2>Вопрос</h2>
|
||||
<p class="question-text" id="questionText"></p>
|
||||
<div class="options" id="options"></div>
|
||||
<div class="result-message" id="resultMessage"></div>
|
||||
<button class="close-btn" id="closeBtn">Продолжить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="final-screen" id="finalScreen">
|
||||
<h2>Игра окончена!</h2>
|
||||
<div class="podium" id="podium"></div>
|
||||
<button class="play-again-btn" onclick="location.reload()">Играть снова</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var allQuestions = {{ questions | tojson }};
|
||||
|
||||
var STORAGE_KEY = 'francuzskiy_game';
|
||||
var currentTeam = 0;
|
||||
var teams = [];
|
||||
|
||||
setupTeams();
|
||||
|
||||
function setupTeams() {
|
||||
var count = parseInt(document.getElementById('teamCount').value);
|
||||
var container = document.getElementById('teamNames');
|
||||
container.innerHTML = '';
|
||||
for (var i = 0; i < count; i++) {
|
||||
var input = document.createElement('input');
|
||||
input.placeholder = 'Команда ' + (i + 1);
|
||||
input.value = 'Команда ' + (i + 1);
|
||||
input.id = 'teamName' + i;
|
||||
container.appendChild(input);
|
||||
}
|
||||
}
|
||||
|
||||
function startGame() {
|
||||
document.getElementById('startBtn').style.display = 'none';
|
||||
|
||||
var count = parseInt(document.getElementById('teamCount').value);
|
||||
teams = [];
|
||||
for (var i = 0; i < count; i++) {
|
||||
teams.push({
|
||||
name: document.getElementById('teamName' + i).value || 'Команда ' + (i + 1),
|
||||
score: 0
|
||||
});
|
||||
}
|
||||
|
||||
var totalQuestions = 30;
|
||||
document.getElementById('totalQuestions').textContent = totalQuestions;
|
||||
|
||||
currentTeam = 0;
|
||||
updateScoreDisplay();
|
||||
|
||||
initGame();
|
||||
}
|
||||
|
||||
function selectTeam(index) {
|
||||
currentTeam = index;
|
||||
var btns = document.querySelectorAll('.team-btn');
|
||||
for (var i = 0; i < btns.length; i++) {
|
||||
btns[i].classList.toggle('active', i === index);
|
||||
}
|
||||
}
|
||||
|
||||
function updateScoreDisplay() {
|
||||
var container = document.getElementById('teamSelector');
|
||||
var html = '';
|
||||
for (var i = 0; i < teams.length; i++) {
|
||||
html += '<button class="team-btn' + (i === currentTeam ? ' active' : '') + '" onclick="selectTeam(' + i + ')">' + teams[i].name + ': <span id="score' + i + '">' + teams[i].score + '</span></button>';
|
||||
}
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function loadGameState() {
|
||||
var saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
return JSON.parse(saved);
|
||||
}
|
||||
return { score: 0, answered: [], userAnswers: {} };
|
||||
}
|
||||
|
||||
function saveGameState(state) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
function initGame() {
|
||||
const state = loadGameState();
|
||||
|
||||
let answeredCount = 0;
|
||||
state.answered.forEach(function(key) {
|
||||
const [cat, q] = key.split('_');
|
||||
const cell = document.querySelector('.question-cell[data-cat="' + cat + '"][data-q="' + q + '"]');
|
||||
if (cell) {
|
||||
cell.classList.add('answered');
|
||||
answeredCount++;
|
||||
}
|
||||
});
|
||||
document.getElementById('answered').textContent = answeredCount;
|
||||
}
|
||||
|
||||
const modalOverlay = document.getElementById('modalOverlay');
|
||||
const questionText = document.getElementById('questionText');
|
||||
const optionsContainer = document.getElementById('options');
|
||||
const modalCost = document.getElementById('modalCost');
|
||||
const resultMessage = document.getElementById('resultMessage');
|
||||
const answeredEl = document.getElementById('answered');
|
||||
const finalScreen = document.getElementById('finalScreen');
|
||||
const finalScore = document.getElementById('finalScore');
|
||||
|
||||
let currentCat = 0;
|
||||
let currentQ = 0;
|
||||
let correctAnswer = 0;
|
||||
let currentCost = 0;
|
||||
let canAnswer = true;
|
||||
|
||||
initGame();
|
||||
|
||||
var cells = document.querySelectorAll('.question-cell');
|
||||
for (var i = 0; i < cells.length; i++) {
|
||||
cells[i].onclick = function() {
|
||||
var cat = parseInt(this.dataset.cat, 10);
|
||||
var q = parseInt(this.dataset.q, 10);
|
||||
var questionData = allQuestions.categories[cat].questions[q];
|
||||
var isAnswered = this.classList.contains('answered');
|
||||
|
||||
openQuestionModal(cat, q, questionData.question, questionData.options, questionData.answer, questionData.cost, this, isAnswered);
|
||||
};
|
||||
}
|
||||
|
||||
function openQuestionModal(catIndex, qIndex, question, options, answer, cost, cell, viewOnly) {
|
||||
currentCat = catIndex;
|
||||
currentQ = qIndex;
|
||||
correctAnswer = answer;
|
||||
currentCost = cost;
|
||||
|
||||
questionText.textContent = question;
|
||||
modalCost.textContent = cost;
|
||||
|
||||
optionsContainer.innerHTML = '';
|
||||
var userAnswerData = null;
|
||||
if (viewOnly) {
|
||||
var state = loadGameState();
|
||||
var key = catIndex + '_' + qIndex;
|
||||
userAnswerData = state.userAnswers[key];
|
||||
}
|
||||
|
||||
options.forEach(function(opt, i) {
|
||||
var btn = document.createElement('div');
|
||||
btn.className = 'option';
|
||||
btn.textContent = opt;
|
||||
|
||||
if (viewOnly) {
|
||||
btn.style.pointerEvents = 'none';
|
||||
if (i === answer) {
|
||||
btn.classList.add('correct');
|
||||
}
|
||||
if (userAnswerData && i === userAnswerData.answer && i !== answer) {
|
||||
btn.classList.add('wrong');
|
||||
}
|
||||
} else {
|
||||
btn.onclick = function() { selectAnswer(i, cell); };
|
||||
}
|
||||
optionsContainer.appendChild(btn);
|
||||
});
|
||||
|
||||
resultMessage.className = 'result-message';
|
||||
resultMessage.textContent = '';
|
||||
if (viewOnly) {
|
||||
resultMessage.textContent = 'Этот вопрос уже отыгран';
|
||||
resultMessage.className = 'result-message show';
|
||||
}
|
||||
var closeBtnEl = document.getElementById('closeBtn');
|
||||
closeBtnEl.className = 'close-btn show';
|
||||
canAnswer = !viewOnly;
|
||||
|
||||
modalOverlay.classList.add('active');
|
||||
}
|
||||
|
||||
function selectAnswer(index, cell) {
|
||||
if (!canAnswer) return;
|
||||
canAnswer = false;
|
||||
|
||||
const options = document.querySelectorAll('.option');
|
||||
options.forEach((opt, i) => {
|
||||
opt.style.pointerEvents = 'none';
|
||||
if (i === correctAnswer) opt.classList.add('correct');
|
||||
});
|
||||
|
||||
const isCorrect = index === correctAnswer;
|
||||
|
||||
if (!isCorrect) {
|
||||
options[index].classList.add('wrong');
|
||||
}
|
||||
|
||||
const state = loadGameState();
|
||||
const key = `${currentCat}_${currentQ}`;
|
||||
|
||||
if (!state.answered.includes(key)) {
|
||||
state.answered.push(key);
|
||||
}
|
||||
state.userAnswers[key] = { team: currentTeam, answer: index };
|
||||
|
||||
if (isCorrect) {
|
||||
teams[currentTeam].score += currentCost;
|
||||
resultMessage.textContent = 'Правильно! ' + teams[currentTeam].name + ' получает +' + currentCost + ' очков';
|
||||
} else {
|
||||
teams[currentTeam].score -= currentCost;
|
||||
resultMessage.textContent = 'Неправильно! ' + teams[currentTeam].name + ' теряет -' + currentCost + ' очков';
|
||||
}
|
||||
|
||||
saveGameState(state);
|
||||
|
||||
updateScoreDisplay();
|
||||
answeredEl.textContent = state.answered.length;
|
||||
|
||||
if (teams.length > 1) {
|
||||
currentTeam = (currentTeam + 1) % teams.length;
|
||||
selectTeam(currentTeam);
|
||||
}
|
||||
|
||||
resultMessage.className = 'result-message show ' + (isCorrect ? 'success' : 'error');
|
||||
|
||||
cell.classList.add('answered');
|
||||
|
||||
var closeBtnEl = document.getElementById('closeBtn');
|
||||
closeBtnEl.classList.add('show');
|
||||
|
||||
setTimeout(function() { checkGameEnd(); }, 500);
|
||||
}
|
||||
|
||||
document.getElementById('closeBtn').onclick = function() {
|
||||
modalOverlay.classList.remove('active');
|
||||
};
|
||||
|
||||
function checkGameEnd() {
|
||||
var totalQuestions = parseInt(document.getElementById('totalQuestions').textContent);
|
||||
var state = loadGameState();
|
||||
if (state.answered.length >= totalQuestions) {
|
||||
var sortedTeams = teams.slice().sort(function(a, b) { return b.score - a.score; });
|
||||
|
||||
var podiumHtml = '<div class="all-teams">';
|
||||
for (var i = 0; i < sortedTeams.length; i++) {
|
||||
var place = i + 1;
|
||||
var medal = '';
|
||||
if (place === 1) medal = '🥇';
|
||||
else if (place === 2) medal = '🥈';
|
||||
else if (place === 3) medal = '🥉';
|
||||
else medal = place + '.';
|
||||
|
||||
podiumHtml += '<div class="team-result">';
|
||||
podiumHtml += '<span class="team-place">' + medal + '</span>';
|
||||
podiumHtml += '<span class="team-name">' + sortedTeams[i].name + '</span>';
|
||||
podiumHtml += '<span class="team-points">' + sortedTeams[i].score + ' очков</span>';
|
||||
podiumHtml += '</div>';
|
||||
}
|
||||
podiumHtml += '</div>';
|
||||
|
||||
document.getElementById('podium').innerHTML = podiumHtml;
|
||||
|
||||
setTimeout(function() {
|
||||
finalScreen.classList.add('active');
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelector('.play-again-btn').onclick = function() {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
location.reload();
|
||||
};
|
||||
|
||||
function restartGame() {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
location.reload();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
28
templates/login.html
Normal file
28
templates/login.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Вход в админку</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; background: #1a1a2e; color: #eaeaea; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
|
||||
.login-form { background: #0f3460; padding: 30px; border-radius: 10px; text-align: center; }
|
||||
h1 { color: #e94560; margin-bottom: 20px; }
|
||||
input { width: 100%; padding: 10px; margin: 10px 0; background: #1a1a2e; color: #eaeaea; border: 1px solid #e94560; border-radius: 4px; }
|
||||
.btn { background: #e94560; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; width: 100%; }
|
||||
.btn:hover { background: #c73e54; }
|
||||
.error { color: #f87171; margin-top: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-form">
|
||||
<h1>Вход в админку</h1>
|
||||
<form method="POST" action="/admin/login">
|
||||
<input type="password" name="password" placeholder="Введите пароль" required>
|
||||
<button type="submit" class="btn">Войти</button>
|
||||
</form>
|
||||
{% if error %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user