google-labs-jules[bot] commited on
Commit
eaf45d5
·
1 Parent(s): ff8c4cc

Add Flask web application for Sudoku game

Browse files

- Implements backend API for game logic (new game, fill, check, solve, hint)
- Manages game state using Flask sessions.
- Develops frontend with HTML, CSS, and JavaScript for user interaction, board rendering, timer, and undo functionality.
- Configures the application for production using Gunicorn and WhiteNoise.
- Updates Dockerfile and requirements for web app deployment.
- Sets up FLASK_SECRET_KEY to be read from environment variables.

Dockerfile CHANGED
@@ -8,34 +8,22 @@ WORKDIR /app
8
  COPY requirements.txt .
9
 
10
  # 4. Install any needed packages specified in requirements.txt
11
- # Using --no-cache-dir to reduce image size
12
- # Using --default-timeout to prevent timeouts on slow networks if any package download is slow
13
  RUN pip install --no-cache-dir --upgrade pip && \
14
  pip install --no-cache-dir --default-timeout=100 -r requirements.txt
15
 
16
  # 5. Copy the rest of the application code into the container at /app
17
- # This includes your sudoku_bot directory and main.py (if it's in the root or sudoku_bot folder)
18
- # Assuming your Procfile specifies `python sudoku_bot/main.py`
19
- # and main.py is inside sudoku_bot directory.
20
- # If main.py is in the root, you might adjust the CMD or Procfile.
21
  COPY . .
22
- # If your sudoku_bot folder is the main source, you could also do:
23
- # COPY sudoku_bot ./sudoku_bot
24
- # COPY requirements.txt .
25
- # COPY Procfile . # If you have other files in root needed in the image
26
 
27
- # 6. Make port 8080 available to the world outside this container (optional, not strictly needed for a worker)
28
- # For a worker process like a Telegram bot, exposing a port isn't strictly necessary
29
- # unless you have a health check endpoint or something similar.
30
- # EXPOSE 8080
31
 
32
- # 7. Define environment variable for the bot token (BEST PRACTICE: set this in Hugging Face Space secrets)
33
- # We expect BOT_TOKEN to be set in the Hugging Face Space environment
34
  ENV BOT_TOKEN=""
 
 
 
35
 
36
- # 8. Run main.py when the container launches
37
- # This command will be overridden by the Procfile if you use one with Hugging Face.
38
- # However, it's good practice to have a default command.
39
- # If you are using a Procfile (`worker: python sudoku_bot/main.py`),
40
- # Hugging Face will use that. This CMD can be a fallback or for local Docker runs.
41
- CMD ["python", "sudoku_bot/main.py"]
 
8
  COPY requirements.txt .
9
 
10
  # 4. Install any needed packages specified in requirements.txt
 
 
11
  RUN pip install --no-cache-dir --upgrade pip && \
12
  pip install --no-cache-dir --default-timeout=100 -r requirements.txt
13
 
14
  # 5. Copy the rest of the application code into the container at /app
 
 
 
 
15
  COPY . .
 
 
 
 
16
 
17
+ # 6. Make port available
18
+ ENV PORT ${PORT:-7860}
19
+ EXPOSE $PORT
 
20
 
21
+ # 7. Define environment variables (set these in HF Space secrets or your deployment environment)
 
22
  ENV BOT_TOKEN=""
23
+ ENV FLASK_SECRET_KEY="a_very_secure_default_secret_key_that_you_SHOULD_override"
24
+ # It's best practice for FLASK_SECRET_KEY to be set in the environment, not defaulted here.
25
+ # The default above is just a placeholder to avoid crashes if not set.
26
 
27
+ # 8. Run Gunicorn to serve the Flask app
28
+ # The module is web_app.app and the Flask instance is named 'app'
29
+ CMD ["gunicorn", "--workers", "2", "--bind", "0.0.0.0:$PORT", "web_app.app:app"]
 
 
 
requirements.txt CHANGED
@@ -1,2 +1,5 @@
1
  python-telegram-bot
2
  numpy
 
 
 
 
1
  python-telegram-bot
2
  numpy
3
+ Flask
4
+ gunicorn
5
+ whitenoise
sudoku_bot/game_logic/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # This file makes Python treat the `game_logic` directory as a package.
web_app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # This file makes Python treat the `web_app` directory as a package.
web_app/app.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from flask import Flask, jsonify, request, session, render_template
3
+ import sys
4
+
5
+ # Add the parent directory to the Python path to access sudoku_bot
6
+ sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
7
+
8
+ from sudoku_bot.game_logic.sudoku import generate_puzzle, check_win as check_sudoku_win, solve_sudoku, is_valid_move, SIDE
9
+
10
+ from whitenoise import WhiteNoise
11
+
12
+ app = Flask(__name__, template_folder='templates', static_folder='static')
13
+ # It's crucial to set a secret key for session management
14
+ # Read from environment variable for production, with a fallback for local development
15
+ app.secret_key = os.environ.get('FLASK_SECRET_KEY', 'dev_secret_key_123!@#')
16
+ # IMPORTANT: The fallback key is for local development ONLY.
17
+ # Set FLASK_SECRET_KEY in your production environment (e.g., Hugging Face Space secrets).
18
+
19
+ # Serve static files efficiently in production using WhiteNoise
20
+ # WhiteNoise will automatically find your static files if static_folder is set correctly
21
+ app.wsgi_app = WhiteNoise(app.wsgi_app, root=os.path.join(os.path.dirname(__file__), 'static'))
22
+ # Add prefix for static files if they are not at root (e.g. /static/css/style.css)
23
+ # app.wsgi_app = WhiteNoise(app.wsgi_app, root=os.path.join(os.path.dirname(__file__), 'static'), prefix='static/')
24
+ # However, Flask's default static_url_path is '/static', so WhiteNoise should work correctly
25
+ # with the default static serving if files are in 'web_app/static' and accessed via url_for.
26
+ # The most robust way for WhiteNoise to pick up Flask's static files is to let it wrap the app
27
+ # and it will use Flask's static_folder and static_url_path.
28
+ # Let's simplify the WhiteNoise setup assuming default Flask static handling.
29
+ # app.wsgi_app = WhiteNoise(app.wsgi_app)
30
+ # WhiteNoise should automatically use app.static_folder.
31
+ # If static_folder is 'static' (relative to app.py location), it's usually fine.
32
+ # The Flask app's static_folder is 'static', relative to the app's root path (where app.py is).
33
+ # So, web_app/static/ should be found by WhiteNoise.
34
+ # The following configuration is standard and should work:
35
+ # It tells WhiteNoise to serve files from the app's static_folder
36
+ # (which is 'static' relative to app.py, so web_app/static/)
37
+ # at the URL specified by app.static_url_path (default is '/static').
38
+ app.wsgi_app = WhiteNoise(app.wsgi_app, root=os.path.join(os.path.dirname(__file__), 'static'))
39
+ # No, this is not quite right. WhiteNoise's `root` is for *all* files if you are not using add_files.
40
+ # The recommended way for Flask is simpler:
41
+ # app.wsgi_app = WhiteNoise(app.wsgi_app)
42
+ # app.wsgi_app.add_files(os.path.join(os.path.dirname(__file__), 'static'), prefix='static/')
43
+ # Flask's default static_url_path is 'static'.
44
+ # `static_folder` is 'static' relative to `app.py`.
45
+ # The most straightforward way:
46
+ app.wsgi_app = WhiteNoise(app.wsgi_app)
47
+ app.wsgi_app.add_files(os.path.join(os.path.dirname(__file__), 'static'), prefix=app.static_url_path)
48
+
49
+
50
+ # --- Helper Functions ---
51
+ def get_user_game():
52
+ """Retrieves game state from session."""
53
+ return {
54
+ 'puzzle_board': session.get('puzzle_board'),
55
+ 'solution_board': session.get('solution_board'),
56
+ 'current_board': session.get('current_board'),
57
+ 'difficulty': session.get('difficulty')
58
+ }
59
+
60
+ def set_user_game(puzzle, solution, current, difficulty):
61
+ """Saves game state to session."""
62
+ session['puzzle_board'] = puzzle
63
+ session['solution_board'] = solution
64
+ session['current_board'] = current
65
+ session['difficulty'] = difficulty
66
+ session['game_active'] = True
67
+
68
+ def clear_user_game():
69
+ """Clears game state from session."""
70
+ session.pop('puzzle_board', None)
71
+ session.pop('solution_board', None)
72
+ session.pop('current_board', None)
73
+ session.pop('difficulty', None)
74
+ session.pop('game_active', False)
75
+
76
+ # --- API Routes ---
77
+
78
+ @app.route('/api/new_game', methods=['POST'])
79
+ def new_game_api():
80
+ data = request.get_json()
81
+ difficulty = data.get('difficulty', 'easy').lower()
82
+ if difficulty not in ['easy', 'medium', 'hard']:
83
+ difficulty = 'easy'
84
+
85
+ try:
86
+ puzzle, solution = generate_puzzle(difficulty)
87
+ current_board_copy = [row[:] for row in puzzle] # User will modify this
88
+ set_user_game(puzzle, solution, current_board_copy, difficulty)
89
+
90
+ return jsonify({
91
+ 'message': f'New game started with difficulty: {difficulty}',
92
+ 'puzzle_board': puzzle,
93
+ 'current_board': current_board_copy, # Send initial state
94
+ 'difficulty': difficulty
95
+ }), 200
96
+ except Exception as e:
97
+ print(f"Error generating puzzle: {e}") # Log error
98
+ return jsonify({'error': 'Could not start new game. ' + str(e)}), 500
99
+
100
+ @app.route('/api/fill_cell', methods=['POST'])
101
+ def fill_cell_api():
102
+ if not session.get('game_active'):
103
+ return jsonify({'error': 'No active game. Start a new game first.'}), 400
104
+
105
+ data = request.get_json()
106
+ row = data.get('row')
107
+ col = data.get('col')
108
+ num = data.get('num')
109
+
110
+ game_state = get_user_game()
111
+ current_board = game_state.get('current_board')
112
+ puzzle_board = game_state.get('puzzle_board')
113
+
114
+ if current_board is None or puzzle_board is None:
115
+ return jsonify({'error': 'Game state not found in session.'}), 500
116
+
117
+ if not (isinstance(row, int) and isinstance(col, int) and isinstance(num, int) and \
118
+ 0 <= row < SIDE and 0 <= col < SIDE and 0 <= num <= SIDE): # num can be 0 to clear
119
+ return jsonify({'error': 'Invalid input. Row/col must be 0-8, num must be 0-9 (0 to clear).'}), 400
120
+
121
+ # Check if the cell is part of the original puzzle
122
+ if puzzle_board[row][col] != 0:
123
+ return jsonify({'error': 'Cannot change pre-filled numbers of the puzzle.'}), 400
124
+
125
+ current_board[row][col] = num
126
+ session['current_board'] = current_board # Update session
127
+
128
+ return jsonify({
129
+ 'message': f'Cell ({row+1}, {col+1}) updated to {num if num != 0 else "empty"}.',
130
+ 'current_board': current_board
131
+ }), 200
132
+
133
+ @app.route('/api/check_game', methods=['GET'])
134
+ def check_game_api():
135
+ if not session.get('game_active'):
136
+ return jsonify({'error': 'No active game.'}), 400
137
+
138
+ game_state = get_user_game()
139
+ current_board = game_state.get('current_board')
140
+ solution_board = game_state.get('solution_board')
141
+
142
+ if current_board is None or solution_board is None:
143
+ return jsonify({'error': 'Game state not found in session.'}), 500
144
+
145
+ is_filled = all(all(cell != 0 for cell in row) for row in current_board)
146
+ is_correct = check_sudoku_win(current_board, solution_board)
147
+
148
+ if is_correct:
149
+ return jsonify({
150
+ 'is_solved': True,
151
+ 'is_filled': True,
152
+ 'message': 'تبریک! شما سودوکو را حل کردید!'
153
+ }), 200
154
+ else:
155
+ return jsonify({
156
+ 'is_solved': False,
157
+ 'is_filled': is_filled,
158
+ 'message': 'جدول فعلی (هنوز) یک راه‌حل صحیح نیست.' if is_filled else 'جدول هنوز کامل نشده یا دارای خطا است.'
159
+ }), 200
160
+
161
+
162
+ @app.route('/api/solve_game', methods=['GET'])
163
+ def solve_game_api():
164
+ if not session.get('game_active'):
165
+ return jsonify({'error': 'No active game.'}), 400
166
+
167
+ game_state = get_user_game()
168
+ solution_board = game_state.get('solution_board')
169
+
170
+ if solution_board is None:
171
+ return jsonify({'error': 'Solution not found in session.'}), 500
172
+
173
+ # Update current board in session to the solution
174
+ session['current_board'] = [row[:] for row in solution_board]
175
+
176
+ return jsonify({
177
+ 'message': 'Showing solution.',
178
+ 'current_board': solution_board # Send the solved board
179
+ }), 200
180
+
181
+ @app.route('/api/hint', methods=['GET'])
182
+ def hint_api():
183
+ if not session.get('game_active'):
184
+ return jsonify({'error': 'No active game.'}), 400
185
+
186
+ game_state = get_user_game()
187
+ current_board = game_state.get('current_board')
188
+ solution_board = game_state.get('solution_board')
189
+
190
+ if current_board is None or solution_board is None:
191
+ return jsonify({'error': 'Game state not found in session.'}), 500
192
+
193
+ empty_cells = []
194
+ for r_idx in range(SIDE):
195
+ for c_idx in range(SIDE):
196
+ if current_board[r_idx][c_idx] == 0:
197
+ empty_cells.append({'row': r_idx, 'col': c_idx})
198
+
199
+ if not empty_cells:
200
+ return jsonify({'message': 'Board is already full! No hints available.', 'hint': None, 'current_board': current_board}), 200
201
+
202
+ import random
203
+ hint_cell_info = random.choice(empty_cells)
204
+ row, col = hint_cell_info['row'], hint_cell_info['col']
205
+ hint_value = solution_board[row][col]
206
+
207
+ current_board[row][col] = hint_value
208
+ session['current_board'] = current_board
209
+
210
+ return jsonify({
211
+ 'message': f'راهنمایی: مقدار خانه ({row+1}, {col+1}) عدد {hint_value} است.',
212
+ 'hint': {'row': row, 'col': col, 'value': hint_value},
213
+ 'current_board': current_board
214
+ }), 200
215
+
216
+
217
+ # --- Route for serving the main HTML page ---
218
+ @app.route('/')
219
+ def index():
220
+ return render_template('index.html') # Will be created later
221
+
222
+ if __name__ == '__main__':
223
+ app.run(debug=True, port=5001)
web_app/static/css/style.css ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* web_app/static/css/style.css */
2
+ body {
3
+ font-family: 'Tahoma', sans-serif; /* یک فونت فارسی خوانا */
4
+ display: flex;
5
+ justify-content: center;
6
+ align-items: flex-start; /* تغییر به flex-start برای جلوگیری از کشیدگی عمودی اولیه */
7
+ min-height: 100vh;
8
+ margin: 0;
9
+ background-color: #f0f0f0;
10
+ direction: rtl;
11
+ padding-top: 20px; /* کمی فاصله از بالا */
12
+ }
13
+
14
+ .container {
15
+ background-color: #fff;
16
+ padding: 20px;
17
+ border-radius: 8px;
18
+ box-shadow: 0 0 15px rgba(0,0,0,0.1);
19
+ text-align: center;
20
+ width: auto; /* اجازه می‌دهد محتوا عرض را تعیین کند */
21
+ max-width: 500px; /* حداکثر عرض برای جلوگیری از کشیدگی زیاد در صفحات بزرگ */
22
+ }
23
+
24
+ h1 {
25
+ color: #333;
26
+ margin-bottom: 20px;
27
+ }
28
+
29
+ .game-controls {
30
+ margin-bottom: 20px;
31
+ display: flex;
32
+ justify-content: center;
33
+ align-items: center;
34
+ gap: 15px; /* فاصله بین انتخابگر سطح و دکمه */
35
+ flex-wrap: wrap; /* اجازه شکستن در صفحات کوچکتر */
36
+ }
37
+
38
+ .difficulty-selector {
39
+ display: flex;
40
+ align-items: center;
41
+ gap: 5px;
42
+ }
43
+
44
+ #sudoku-board-container {
45
+ margin-bottom: 20px;
46
+ display: grid;
47
+ grid-template-columns: repeat(9, 1fr);
48
+ grid-template-rows: repeat(9, 1fr);
49
+ width: 360px; /* اندازه ثابت برای جدول */
50
+ height: 360px; /* ارتفاع ثابت برای جدول */
51
+ border: 3px solid #333; /* خط بیرونی ضخیم تر */
52
+ margin-left: auto;
53
+ margin-right: auto;
54
+ box-sizing: border-box;
55
+ }
56
+
57
+ .sudoku-cell {
58
+ box-sizing: border-box;
59
+ border: 1px solid #ccc;
60
+ display: flex;
61
+ justify-content: center;
62
+ align-items: center;
63
+ font-size: 1.4em;
64
+ cursor: pointer;
65
+ background-color: #fff;
66
+ transition: background-color 0.2s;
67
+ }
68
+
69
+ .sudoku-cell input {
70
+ width: 100%; /* ورودی تمام خانه را بگیرد */
71
+ height: 100%;
72
+ text-align: center;
73
+ border: none;
74
+ outline: none;
75
+ font-size: inherit; /* ارث‌بری اندازه فونت از والد */
76
+ padding: 0;
77
+ margin: 0;
78
+ background-color: transparent; /* ورودی شفاف */
79
+ cursor: pointer;
80
+ color: #007bff; /* رنگ آبی برای اعداد وارد شده توسط کاربر */
81
+ }
82
+
83
+ .sudoku-cell.pre-filled input {
84
+ font-weight: bold;
85
+ color: #000; /* رنگ مشکی و ضخیم برای اعداد ثابت */
86
+ cursor: not-allowed;
87
+ }
88
+
89
+ .sudoku-cell.selected {
90
+ background-color: #e0f0ff; /* رنگ پس‌زمینه برای خانه انتخاب شده */
91
+ outline: 2px solid #007bff; /* یک حاشیه برای تاکید بیشتر */
92
+ z-index: 1;
93
+ }
94
+
95
+ /* خطوط ضخیم‌تر برای جدا کردن بلوک‌های 3x3 */
96
+ /* ردیف ها */
97
+ #sudoku-board-container .sudoku-cell:nth-child(n+19):nth-child(-n+27), /* مرز پایین ردیف 3 */
98
+ #sudoku-board-container .sudoku-cell:nth-child(n+46):nth-child(-n+54) /* مرز پایین ردیف 6 */
99
+ {
100
+ border-bottom: 2px solid #333;
101
+ }
102
+
103
+ /* ستون ها */
104
+ #sudoku-board-container .sudoku-cell:nth-child(3n) {
105
+ border-left: 2px solid #333; /* مرز چپ ستون 3 و 6 (چون rtl هستیم، چپ می شود) */
106
+ }
107
+ /* اصلاح برای اینکه خط بیرونی جدول را دوباره نکشد */
108
+ #sudoku-board-container .sudoku-cell:nth-child(9n) {
109
+ border-left: 1px solid #ccc; /* برگرداندن به خط عادی برای آخرین ستون در هر ردیف */
110
+ }
111
+ /* خطوط برای اولین ستون در حالت rtl */
112
+ #sudoku-board-container .sudoku-cell:nth-child(9n-8) {
113
+ border-right: none; /* حذف خط راست پیش فرض */
114
+ }
115
+
116
+
117
+ .ingame-controls {
118
+ margin-top: 15px;
119
+ margin-bottom: 15px;
120
+ }
121
+
122
+ #numbers-panel {
123
+ display: flex;
124
+ justify-content: center;
125
+ gap: 5px;
126
+ margin-bottom: 15px;
127
+ flex-wrap: wrap;
128
+ }
129
+
130
+ #numbers-panel button {
131
+ width: 40px; /* کمی بزرگتر */
132
+ height: 40px;
133
+ font-size: 1.3em;
134
+ border-radius: 5px; /* کمی گردتر */
135
+ }
136
+
137
+ .action-buttons {
138
+ display: flex;
139
+ justify-content: center;
140
+ gap: 10px;
141
+ flex-wrap: wrap;
142
+ }
143
+
144
+ .action-buttons button, .game-controls button, #numbers-panel button {
145
+ padding: 8px 12px;
146
+ font-size: 1em;
147
+ cursor: pointer;
148
+ border: 1px solid #ccc;
149
+ border-radius: 4px;
150
+ background-color: #f9f9f9;
151
+ transition: background-color 0.2s, box-shadow 0.2s;
152
+ box-shadow: 0 2px 2px rgba(0,0,0,0.05);
153
+ }
154
+
155
+ .action-buttons button:hover, .game-controls button:hover, #numbers-panel button:hover {
156
+ background-color: #e9e9e9;
157
+ box-shadow: 0 2px 5px rgba(0,0,0,0.1);
158
+ }
159
+ .action-buttons button:active, .game-controls button:active, #numbers-panel button:active {
160
+ background-color: #ddd;
161
+ box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);
162
+ }
163
+
164
+
165
+ .action-buttons button:disabled, #numbers-panel button:disabled {
166
+ background-color: #e9ecef;
167
+ cursor: not-allowed;
168
+ color: #6c757d;
169
+ box-shadow: none;
170
+ }
171
+ #new-game-btn { /* دکمه بازی جدید را کمی متمایز کنیم */
172
+ background-color: #28a745;
173
+ color: white;
174
+ border-color: #28a745;
175
+ }
176
+ #new-game-btn:hover {
177
+ background-color: #218838;
178
+ border-color: #1e7e34;
179
+ }
180
+
181
+
182
+ #timer-section {
183
+ font-size: 1.1em;
184
+ margin-bottom: 15px;
185
+ color: #555;
186
+ }
187
+
188
+ #message-area {
189
+ margin-top: 15px;
190
+ padding: 10px 15px;
191
+ min-height: 24px;
192
+ border-radius: 4px;
193
+ font-weight: 500; /* کمی خواناتر */
194
+ text-align: right; /* برای پیام‌های فارسی */
195
+ visibility: hidden; /* در ابتدا مخفی */
196
+ opacity: 0;
197
+ transition: opacity 0.3s ease-in-out, visibility 0.3s ease-in-out;
198
+ }
199
+ #message-area.visible {
200
+ visibility: visible;
201
+ opacity: 1;
202
+ }
203
+
204
+
205
+ .message-success {
206
+ background-color: #d4edda;
207
+ color: #155724;
208
+ border: 1px solid #c3e6cb;
209
+ }
210
+
211
+ .message-error {
212
+ background-color: #f8d7da;
213
+ color: #721c24;
214
+ border: 1px solid #f5c6cb;
215
+ }
216
+
217
+ .message-info {
218
+ background-color: #e2e3e5;
219
+ color: #383d41;
220
+ border: 1px solid #d6d8db;
221
+ }
222
+
223
+ .sudoku-cell.error input {
224
+ color: red !important;
225
+ font-weight: bold;
226
+ animation: shake 0.5s;
227
+ }
228
+
229
+ @keyframes shake {
230
+ 0%, 100% {transform: translateX(0);}
231
+ 25% {transform: translateX(-3px);}
232
+ 75% {transform: translateX(3px);}
233
+ }
234
+
235
+ .sudoku-cell.hint-cell {
236
+ background-color: #d1eaff !important;
237
+ animation: pulse 0.8s ease-in-out;
238
+ }
239
+
240
+ @keyframes pulse {
241
+ 0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(0, 123, 255, 0.7); }
242
+ 50% { transform: scale(1.05); box-shadow: 0 0 5px 10px rgba(0, 123, 255, 0); }
243
+ 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(0, 123, 255, 0); }
244
+ }
245
+
246
+ /* Responsive adjustments */
247
+ @media (max-width: 540px) { /* نقطه شکست را کمی تغییر دادم */
248
+ .container {
249
+ width: 98%; /* کمی بیشتر فضا بدهیم */
250
+ padding: 10px;
251
+ }
252
+ #sudoku-board-container {
253
+ width: 100%;
254
+ max-width: 360px;
255
+ height: auto;
256
+ aspect-ratio: 1 / 1;
257
+ }
258
+ .sudoku-cell {
259
+ font-size: max(3.5vw, 1em); /* اندازه فونت واکنش‌گرا با حداقل سایز */
260
+ }
261
+ .game-controls, .ingame-controls, .action-buttons, #numbers-panel {
262
+ gap: 8px; /* فاصله کمتر در موبایل */
263
+ }
264
+ .action-buttons button, .game-controls button, #numbers-panel button {
265
+ padding: 6px 10px;
266
+ font-size: 0.9em;
267
+ }
268
+ #numbers-panel button {
269
+ width: 35px;
270
+ height: 35px;
271
+ font-size: 1.1em;
272
+ }
273
+ }
web_app/static/js/main.js ADDED
@@ -0,0 +1,401 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // web_app/static/js/main.js
2
+
3
+ document.addEventListener('DOMContentLoaded', () => {
4
+ const SIDE = 9;
5
+ let currentBoard = []; // The board user interacts with
6
+ let initialPuzzle = []; // The original puzzle, to identify pre-filled cells
7
+ // let solutionBoard = []; // Not strictly needed to store globally on client if backend handles it
8
+ let selectedCell = null; // { row, col, element }
9
+ let undoStack = []; // For undo functionality
10
+ let timerInterval = null;
11
+ let timerSeconds = 0;
12
+
13
+ // DOM Elements
14
+ const boardContainer = document.getElementById('sudoku-board-container');
15
+ const numbersPanel = document.getElementById('numbers-panel');
16
+ const newGameBtn = document.getElementById('new-game-btn');
17
+ const difficultySelect = document.getElementById('difficulty');
18
+ const checkBtn = document.getElementById('check-btn');
19
+ const solveBtn = document.getElementById('solve-btn');
20
+ const hintBtn = document.getElementById('hint-btn');
21
+ const undoBtn = document.getElementById('undo-btn');
22
+ const messageArea = document.getElementById('message-area');
23
+ const timerDisplay = document.getElementById('timer');
24
+
25
+ // --- Helper Functions ---
26
+ async function fetchAPI(url, method = 'GET', body = null) {
27
+ const options = {
28
+ method: method,
29
+ headers: {
30
+ 'Content-Type': 'application/json',
31
+ 'Accept': 'application/json'
32
+ }
33
+ };
34
+ if (body) {
35
+ options.body = JSON.stringify(body);
36
+ }
37
+ try {
38
+ const response = await fetch(url, options);
39
+ const responseData = await response.json();
40
+ if (!response.ok) {
41
+ throw new Error(responseData.error || `HTTP error! status: ${response.status}`);
42
+ }
43
+ return responseData;
44
+ } catch (error) {
45
+ console.error('API Error:', error);
46
+ showMessage(`خطا در ارتباط با سرور: ${error.message}`, 'error');
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function showMessage(message, type = 'info') {
52
+ messageArea.textContent = message;
53
+ messageArea.className = 'message-area visible';
54
+ messageArea.classList.add(`message-${type}`);
55
+ }
56
+
57
+ function clearMessage() {
58
+ messageArea.textContent = '';
59
+ messageArea.className = 'message-area';
60
+ }
61
+
62
+ function toggleActionButtons(disabled) {
63
+ checkBtn.disabled = disabled;
64
+ solveBtn.disabled = disabled;
65
+ hintBtn.disabled = disabled;
66
+ undoBtn.disabled = disabled || undoStack.length === 0;
67
+ }
68
+
69
+ // --- Timer Functions ---
70
+ function startTimer() {
71
+ stopTimer();
72
+ timerSeconds = 0;
73
+ updateTimerDisplay();
74
+ timerInterval = setInterval(() => {
75
+ timerSeconds++;
76
+ updateTimerDisplay();
77
+ }, 1000);
78
+ }
79
+
80
+ function stopTimer() {
81
+ clearInterval(timerInterval);
82
+ timerInterval = null;
83
+ }
84
+
85
+ function updateTimerDisplay() {
86
+ const minutes = Math.floor(timerSeconds / 60);
87
+ const seconds = timerSeconds % 60;
88
+ timerDisplay.textContent = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
89
+ }
90
+
91
+ // --- Undo Functionality ---
92
+ function pushToUndoStack(row, col, oldValue, newValue) {
93
+ undoStack.push({ row, col, oldValue, newValue });
94
+ undoBtn.disabled = false;
95
+ }
96
+
97
+ function handleUndo() {
98
+ if (undoStack.length === 0) return;
99
+ const lastAction = undoStack.pop();
100
+
101
+ currentBoard[lastAction.row][lastAction.col] = lastAction.oldValue;
102
+ updateCellOnBoard(lastAction.row, lastAction.col, lastAction.oldValue);
103
+
104
+ if (selectedCell && selectedCell.row === lastAction.row && selectedCell.col === lastAction.col) {
105
+ selectedCell.element.querySelector('input').value = lastAction.oldValue !== 0 ? lastAction.oldValue : '';
106
+ }
107
+ // showMessage(`حرکت قبلی بازگردانده شد.`, 'info');
108
+ if (undoStack.length === 0) {
109
+ undoBtn.disabled = true;
110
+ }
111
+ }
112
+
113
+ // --- Board Rendering and Interaction ---
114
+ function renderBoard(boardData, initialPuzzleData) {
115
+ boardContainer.innerHTML = '';
116
+ currentBoard = JSON.parse(JSON.stringify(boardData));
117
+ initialPuzzle = JSON.parse(JSON.stringify(initialPuzzleData));
118
+
119
+ for (let r = 0; r < SIDE; r++) {
120
+ for (let c = 0; c < SIDE; c++) {
121
+ const cell = document.createElement('div');
122
+ cell.classList.add('sudoku-cell');
123
+ cell.dataset.row = r;
124
+ cell.dataset.col = c;
125
+
126
+ const input = document.createElement('input');
127
+ input.type = 'text';
128
+ input.maxLength = 1;
129
+
130
+ const isPreFilled = initialPuzzle[r][c] !== 0;
131
+ if (isPreFilled) {
132
+ input.value = initialPuzzle[r][c];
133
+ input.readOnly = true;
134
+ cell.classList.add('pre-filled');
135
+ } else {
136
+ input.value = boardData[r][c] !== 0 ? boardData[r][c] : '';
137
+ }
138
+
139
+ input.addEventListener('focus', (e) => handleCellFocus(e, cell, r, c));
140
+ input.addEventListener('input', (e) => handleCellInput(e, r, c));
141
+ input.addEventListener('keydown', (e) => handleCellKeyDown(e, r, c));
142
+ // Click is handled by focus generally, but explicit click can also select
143
+ cell.addEventListener('click', (e) => handleCellFocus(e, cell, r, c));
144
+
145
+
146
+ cell.appendChild(input);
147
+ boardContainer.appendChild(cell);
148
+ }
149
+ }
150
+ }
151
+
152
+ function updateCellOnBoard(row, col, value, isHint = false) {
153
+ const cellElement = boardContainer.querySelector(`.sudoku-cell[data-row='${row}'][data-col='${col}']`);
154
+ if (cellElement) {
155
+ const inputElement = cellElement.querySelector('input');
156
+ inputElement.value = value !== 0 ? value : '';
157
+
158
+ document.querySelectorAll('.hint-cell').forEach(hc => hc.classList.remove('hint-cell'));
159
+ if (isHint) {
160
+ cellElement.classList.add('hint-cell');
161
+ // Auto-remove highlight after animation completes (CSS animation is ~1s)
162
+ setTimeout(() => cellElement.classList.remove('hint-cell'), 1500);
163
+ }
164
+ // Clear any error styling on this cell if a new value is set
165
+ cellElement.classList.remove('error');
166
+ }
167
+ }
168
+
169
+ function handleCellFocus(event, cellElement, row, col) {
170
+ event.stopPropagation();
171
+ if (selectedCell && selectedCell.element) {
172
+ selectedCell.element.classList.remove('selected');
173
+ }
174
+
175
+ const isPreFilled = initialPuzzle[row][col] !== 0;
176
+ if (isPreFilled) {
177
+ selectedCell = null;
178
+ cellElement.querySelector('input').blur();
179
+ return;
180
+ }
181
+
182
+ selectedCell = { row, col, element: cellElement };
183
+ cellElement.classList.add('selected');
184
+ // input already focused by click or tab
185
+ }
186
+
187
+ function handleCellInput(event, row, col) {
188
+ const value = event.target.value;
189
+ let num = parseInt(value);
190
+
191
+ if (value === '') {
192
+ num = 0;
193
+ } else if (isNaN(num) || num < 1 || num > 9) {
194
+ event.target.value = currentBoard[row][col] !== 0 ? currentBoard[row][col] : '';
195
+ showMessage('لطفاً یک عدد بین ۱ تا ۹ وارد کنید.', 'error');
196
+ return;
197
+ }
198
+
199
+ const oldValue = currentBoard[row][col];
200
+ if (oldValue !== num) {
201
+ fillCellWithValue(row, col, num, oldValue);
202
+ }
203
+ }
204
+
205
+ function handleCellKeyDown(event, row, col) {
206
+ const inputElement = event.target;
207
+ if (event.key === "Backspace" || event.key === "Delete") {
208
+ event.preventDefault();
209
+ if (currentBoard[row][col] !== 0 && initialPuzzle[row][col] === 0) { // Can only clear non-pre-filled
210
+ fillCellWithValue(row, col, 0, currentBoard[row][col]);
211
+ }
212
+ } else if (event.key >= '1' && event.key <= '9') {
213
+ // Let input event handle this, but ensure focus remains for direct typing
214
+ inputElement.value = ''; // Clear previous to allow new single digit input
215
+ } else if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Tab", "Enter"].includes(event.key)) {
216
+ // Prevent other characters if not navigation or number
217
+ if (event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) {
218
+ event.preventDefault();
219
+ }
220
+ }
221
+ // Add arrow key navigation later if desired
222
+ }
223
+
224
+
225
+ function renderNumbersPanel() {
226
+ numbersPanel.innerHTML = '';
227
+ for (let i = 1; i <= 9; i++) {
228
+ const btn = document.createElement('button');
229
+ btn.textContent = i;
230
+ btn.addEventListener('click', () => handleNumberPanelClick(i));
231
+ numbersPanel.appendChild(btn);
232
+ }
233
+ const clearBtn = document.createElement('button');
234
+ clearBtn.textContent = 'پاک';
235
+ clearBtn.title = "پاک کردن خانه انتخاب شده";
236
+ clearBtn.addEventListener('click', () => handleNumberPanelClick(0));
237
+ numbersPanel.appendChild(clearBtn);
238
+ }
239
+
240
+ function handleNumberPanelClick(num) {
241
+ if (!selectedCell || (initialPuzzle[selectedCell.row][selectedCell.col] !== 0) ) {
242
+ showMessage('لطفاً ابتدا یک خانه خالی را از جدول انتخاب کنید.', 'info');
243
+ return;
244
+ }
245
+ const { row, col } = selectedCell;
246
+ const oldValue = currentBoard[row][col];
247
+
248
+ if (oldValue !== num) {
249
+ fillCellWithValue(row, col, num, oldValue);
250
+ }
251
+ // Keep focus on the cell after number panel click for convenience
252
+ selectedCell.element.querySelector('input').focus();
253
+ }
254
+
255
+ async function fillCellWithValue(row, col, num, oldValue) {
256
+ currentBoard[row][col] = num; // Optimistic UI update
257
+ updateCellOnBoard(row, col, num);
258
+ if (selectedCell && selectedCell.row === row && selectedCell.col === col) {
259
+ selectedCell.element.querySelector('input').value = num !== 0 ? num : '';
260
+ }
261
+
262
+ pushToUndoStack(row, col, oldValue, num);
263
+
264
+ const response = await fetchAPI('/api/fill_cell', 'POST', { row, col, num });
265
+ if (response) {
266
+ if (response.error) {
267
+ currentBoard[row][col] = oldValue; // Revert
268
+ updateCellOnBoard(row, col, oldValue);
269
+ if (selectedCell && selectedCell.row === row && selectedCell.col === col) {
270
+ selectedCell.element.querySelector('input').value = oldValue !== 0 ? oldValue : '';
271
+ }
272
+ if(undoStack.length > 0 && undoStack[undoStack.length-1].row === row && undoStack[undoStack.length-1].col === col && undoStack[undoStack.length-1].newValue === num) {
273
+ undoStack.pop();
274
+ if(undoStack.length === 0) undoBtn.disabled = true;
275
+ }
276
+ showMessage(response.error, 'error');
277
+ } else {
278
+ currentBoard = response.current_board; // Ensure client board is in sync with server's perspective
279
+ clearMessage();
280
+ }
281
+ } else { // Network or other major error
282
+ currentBoard[row][col] = oldValue; // Revert
283
+ updateCellOnBoard(row, col, oldValue);
284
+ if (selectedCell && selectedCell.row === row && selectedCell.col === col) {
285
+ selectedCell.element.querySelector('input').value = oldValue !== 0 ? oldValue : '';
286
+ }
287
+ if(undoStack.length > 0 && undoStack[undoStack.length-1].row === row && undoStack[undoStack.length-1].col === col && undoStack[undoStack.length-1].newValue === num) {
288
+ undoStack.pop();
289
+ if(undoStack.length === 0) undoBtn.disabled = true;
290
+ }
291
+ }
292
+ }
293
+
294
+ // --- Game Actions ---
295
+ async function handleNewGame() {
296
+ stopTimer();
297
+ const difficulty = difficultySelect.value;
298
+ showMessage('در حال ایجاد بازی جدید...', 'info');
299
+ toggleActionButtons(true); // Disable buttons during load
300
+ newGameBtn.disabled = true; // Disable new game btn itself too
301
+ undoStack = [];
302
+ undoBtn.disabled = true;
303
+ selectedCell = null;
304
+
305
+ const data = await fetchAPI('/api/new_game', 'POST', { difficulty });
306
+ newGameBtn.disabled = false; // Re-enable new game btn
307
+ if (data && data.puzzle_board) {
308
+ renderBoard(data.current_board, data.puzzle_board);
309
+ showMessage(`بازی جدید با سطح ${difficultySelect.options[difficultySelect.selectedIndex].text} شروع شد!`, 'success');
310
+ startTimer();
311
+ toggleActionButtons(false);
312
+ } else {
313
+ showMessage('خطا در شروع بازی جدید. لطفاً دوباره تلاش کنید.', 'error');
314
+ toggleActionButtons(false); // Re-enable if failed, except undo
315
+ undoBtn.disabled = true;
316
+ }
317
+ }
318
+
319
+ async function handleCheckGame() {
320
+ showMessage('در حال بررسی جدول...', 'info');
321
+ const data = await fetchAPI('/api/check_game', 'GET');
322
+ if (data) {
323
+ if (data.is_solved) {
324
+ showMessage(data.message || 'تبریک! شما سودوکو را حل کردید!', 'success');
325
+ stopTimer();
326
+ toggleActionButtons(true);
327
+ } else {
328
+ showMessage(data.message || 'جدول هنوز کامل یا صحیح نیست.', 'error');
329
+ // Future: Could add logic to highlight incorrect cells if backend provides them
330
+ }
331
+ }
332
+ }
333
+
334
+ async function handleSolveGame() {
335
+ if (!confirm('آیا مطمئن هستید که می‌خواهید راه‌حل نمایش داده شود؟ بازی فعلی شما تمام می‌شود.')) {
336
+ return;
337
+ }
338
+ showMessage('در حال دریافت راه‌حل...', 'info');
339
+ const data = await fetchAPI('/api/solve_game', 'GET');
340
+ if (data && data.current_board) {
341
+ renderBoard(data.current_board, initialPuzzle);
342
+ showMessage('راه‌حل نمایش داده شد.', 'success');
343
+ stopTimer();
344
+ toggleActionButtons(true);
345
+ undoStack = []; // Clear undo stack as game is over/solved
346
+ } else {
347
+ showMessage('خطا در دریافت راه‌حل.', 'error');
348
+ }
349
+ }
350
+
351
+ async function handleHint() {
352
+ showMessage('در حال دریافت راهنمایی...', 'info');
353
+ const data = await fetchAPI('/api/hint', 'GET');
354
+ if (data) {
355
+ if (data.hint) {
356
+ const { row, col, value } = data.hint;
357
+ const oldValue = currentBoard[row][col];
358
+ currentBoard[row][col] = value; // Update client model
359
+ updateCellOnBoard(row, col, value, true);
360
+ pushToUndoStack(row, col, oldValue, value);
361
+ showMessage(data.message, 'info');
362
+
363
+ // Check if board is now solved after hint
364
+ if(data.current_board){ // Server sends updated board after hint
365
+ currentBoard = data.current_board; // Sync with server
366
+ const checkData = await fetchAPI('/api/check_game', 'GET');
367
+ if (checkData && checkData.is_solved) {
368
+ showMessage(checkData.message || 'تبریک! با این راهنمایی، سودوکو حل شد!', 'success');
369
+ stopTimer();
370
+ toggleActionButtons(true);
371
+ }
372
+ }
373
+ } else {
374
+ showMessage(data.message || 'راهنمایی‌ای موجود نیست.', 'info');
375
+ }
376
+ }
377
+ }
378
+
379
+ // --- Initialization ---
380
+ function init() {
381
+ newGameBtn.addEventListener('click', handleNewGame);
382
+ checkBtn.addEventListener('click', handleCheckGame);
383
+ solveBtn.addEventListener('click', handleSolveGame);
384
+ hintBtn.addEventListener('click', handleHint);
385
+ undoBtn.addEventListener('click', handleUndo);
386
+
387
+ document.addEventListener('click', (event) => {
388
+ if (!boardContainer.contains(event.target) && !numbersPanel.contains(event.target) && selectedCell) {
389
+ if(selectedCell.element && !selectedCell.element.contains(event.target)) {
390
+ selectedCell.element.classList.remove('selected');
391
+ selectedCell = null;
392
+ }
393
+ }
394
+ });
395
+
396
+ renderNumbersPanel();
397
+ handleNewGame();
398
+ }
399
+
400
+ init();
401
+ });
web_app/templates/index.html ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="fa" dir="rtl">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>بازی سودوکو</title>
7
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
8
+ </head>
9
+ <body>
10
+ <div class="container">
11
+ <h1>سودوکو</h1>
12
+
13
+ <div class="game-controls">
14
+ <div class="difficulty-selector">
15
+ <label for="difficulty">سطح سختی:</label>
16
+ <select id="difficulty">
17
+ <option value="easy" selected>آسان</option>
18
+ <option value="medium">متوسط</option>
19
+ <option value="hard">سخت</option>
20
+ </select>
21
+ </div>
22
+ <button id="new-game-btn">بازی جدید</button>
23
+ </div>
24
+
25
+ <div id="sudoku-board-container">
26
+ <!-- جدول سودوکو توسط جاوااسکریپت در اینجا ساخته می‌شود -->
27
+ </div>
28
+
29
+ <div class="ingame-controls">
30
+ <div id="numbers-panel">
31
+ <!-- دکمه‌های اعداد ۱-۹ و پاک‌کن توسط جاوااسکریپت -->
32
+ </div>
33
+ <div class="action-buttons">
34
+ <button id="check-btn" disabled>بررسی</button>
35
+ <button id="solve-btn" disabled>حل کن</button>
36
+ <button id="hint-btn" disabled>راهنمایی</button>
37
+ <button id="undo-btn" disabled>واگرد</button>
38
+ </div>
39
+ </div>
40
+
41
+ <div id="timer-section">
42
+ زمان: <span id="timer">00:00</span>
43
+ </div>
44
+
45
+ <div id="message-area">
46
+ <!-- پیام‌ها به کاربر در اینجا نمایش داده می‌شوند -->
47
+ </div>
48
+ </div>
49
+
50
+ <script src="{{ url_for('static', filename='js/main.js') }}"></script>
51
+ </body>
52
+ </html>