Initial commit: Flask quiz game 'Уроки французского'

This commit is contained in:
eka
2026-03-01 08:36:02 +05:00
commit 9a9c58afc0
9 changed files with 1769 additions and 0 deletions

29
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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>