From d1b432f8aa4711a8e7e3eed3a61e4f31c7e19cf7 Mon Sep 17 00:00:00 2001 From: tototomate123 Date: Tue, 3 Mar 2026 19:05:54 +0100 Subject: [PATCH] Save finished games and add history views --- app/db.py | 39 ++++- app/models/game.py | 290 +++++++++++++++++++++++++++++++++ app/routes/main.py | 32 +++- app/sockets/socket.py | 172 ++++++++++++++++--- app/sockets/types.py | 1 + app/static/app.css | 244 ++++++++++++++++++++++++++- app/static/js/play/app.js | 17 +- app/templates/base_app.html | 5 + app/templates/game_detail.html | 100 ++++++++++++ app/templates/games.html | 49 ++++++ app/templates/home.html | 3 + 11 files changed, 919 insertions(+), 33 deletions(-) create mode 100644 app/models/game.py create mode 100644 app/templates/game_detail.html create mode 100644 app/templates/games.html diff --git a/app/db.py b/app/db.py index b14ff1a..2a8c773 100644 --- a/app/db.py +++ b/app/db.py @@ -3,10 +3,15 @@ from flask import g DATABASE = 'sqlite.db' +def connect_db(): + db = sqlite3.connect(DATABASE) + db.row_factory = sqlite3.Row + db.execute("PRAGMA foreign_keys=ON;") + return db + def get_db(): if 'db' not in g: - g.db = sqlite3.connect(DATABASE) - g.db.row_factory = sqlite3.Row + g.db = connect_db() return g.db def close_db(e=None): @@ -33,7 +38,13 @@ def init_db(app): id INTEGER PRIMARY KEY AUTOINCREMENT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, final_fen TEXT, - termination TEXT CHECK(termination IN ('checkmate', 'resignation', 'timeout', 'draw', 'other')) + termination TEXT CHECK(termination IN ('checkmate', 'resignation', 'timeout', 'draw', 'other')), + termination_detail TEXT, + winner_color TEXT CHECK(winner_color IN ('white', 'black', 'draw')), + move_history TEXT NOT NULL DEFAULT '[]', + time_mode TEXT, + started_at TIMESTAMP, + ended_at TIMESTAMP ); CREATE TABLE IF NOT EXISTS game_players ( @@ -75,4 +86,26 @@ def init_db(app): if "last_seen_at" not in user_columns: db.execute("ALTER TABLE users ADD COLUMN last_seen_at TIMESTAMP") + game_columns = { + row["name"] + for row in db.execute("PRAGMA table_info(games)").fetchall() + } + if "termination_detail" not in game_columns: + db.execute("ALTER TABLE games ADD COLUMN termination_detail TEXT") + if "winner_color" not in game_columns: + db.execute( + "ALTER TABLE games ADD COLUMN winner_color TEXT " + "CHECK(winner_color IN ('white', 'black', 'draw'))" + ) + if "move_history" not in game_columns: + db.execute( + "ALTER TABLE games ADD COLUMN move_history TEXT NOT NULL DEFAULT '[]'" + ) + if "time_mode" not in game_columns: + db.execute("ALTER TABLE games ADD COLUMN time_mode TEXT") + if "started_at" not in game_columns: + db.execute("ALTER TABLE games ADD COLUMN started_at TIMESTAMP") + if "ended_at" not in game_columns: + db.execute("ALTER TABLE games ADD COLUMN ended_at TIMESTAMP") + db.commit() diff --git a/app/models/game.py b/app/models/game.py new file mode 100644 index 0000000..d17d48f --- /dev/null +++ b/app/models/game.py @@ -0,0 +1,290 @@ +import json +from contextlib import contextmanager +from dataclasses import dataclass +from typing import Iterator, Optional + +from flask import g, has_app_context + +from app.db import connect_db + + +PIECE_IMAGE_BY_SYMBOL = { + "P": "wp.png", + "N": "wn.png", + "B": "wb.png", + "R": "wr.png", + "Q": "wq.png", + "K": "wk.png", + "p": "bp.png", + "n": "bn.png", + "b": "bb.png", + "r": "br.png", + "q": "bq.png", + "k": "bk.png", +} + + +@dataclass +class SavedGameSummary: + id: int + opponent_id: int + opponent_username: str + my_color: str + opponent_color: str + winner_color: Optional[str] + termination: Optional[str] + termination_detail: Optional[str] + time_mode: Optional[str] + move_count: int + started_at: Optional[str] + ended_at: Optional[str] + + @property + def result(self) -> str: + if self.winner_color == "draw" or not self.winner_color: + return "draw" + return "win" if self.winner_color == self.my_color else "loss" + + +@dataclass +class SavedGameDetail(SavedGameSummary): + final_fen: str + move_history: list[str] + + +@contextmanager +def _db_session() -> Iterator: + if has_app_context() and "db" in g: + yield g.db + return + + db = connect_db() + try: + yield db + db.commit() + finally: + db.close() + + +def save_finished_game( + *, + white_player_id: int, + black_player_id: int, + final_fen: str, + termination: str, + termination_detail: str, + winner_color: Optional[str], + move_history: list[str], + time_mode: Optional[str], + started_at: Optional[str], + ended_at: Optional[str], +) -> int: + winner_value = winner_color if winner_color in {"white", "black"} else "draw" + + with _db_session() as db: + cursor = db.execute( + """ + INSERT INTO games ( + final_fen, + termination, + termination_detail, + winner_color, + move_history, + time_mode, + started_at, + ended_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + final_fen, + termination, + termination_detail, + winner_value, + json.dumps(move_history), + time_mode, + started_at, + ended_at, + ), + ) + game_id = int(cursor.lastrowid) + + db.executemany( + """ + INSERT INTO game_players (game_id, player_id, color) + VALUES (?, ?, ?) + """, + [ + (game_id, white_player_id, "white"), + (game_id, black_player_id, "black"), + ], + ) + db.commit() + + return game_id + + +def list_games_for_user(user_id: int) -> list[SavedGameSummary]: + with _db_session() as db: + rows = db.execute( + """ + SELECT + g.id, + g.winner_color, + g.termination, + g.termination_detail, + g.time_mode, + g.started_at, + g.ended_at, + g.move_history, + me.color AS my_color, + opp.color AS opponent_color, + u.id AS opponent_id, + u.username AS opponent_username + FROM games g + JOIN game_players me + ON me.game_id = g.id + AND me.player_id = ? + JOIN game_players opp + ON opp.game_id = g.id + AND opp.player_id != ? + JOIN users u + ON u.id = opp.player_id + ORDER BY COALESCE(g.ended_at, g.created_at) DESC, g.id DESC + """, + (user_id, user_id), + ).fetchall() + + return [_row_to_summary(row) for row in rows] + + +def get_game_for_user(game_id: int, user_id: int) -> Optional[SavedGameDetail]: + with _db_session() as db: + row = db.execute( + """ + SELECT + g.id, + g.final_fen, + g.winner_color, + g.termination, + g.termination_detail, + g.time_mode, + g.started_at, + g.ended_at, + g.move_history, + me.color AS my_color, + opp.color AS opponent_color, + u.id AS opponent_id, + u.username AS opponent_username + FROM games g + JOIN game_players me + ON me.game_id = g.id + AND me.player_id = ? + JOIN game_players opp + ON opp.game_id = g.id + AND opp.player_id != ? + JOIN users u + ON u.id = opp.player_id + WHERE g.id = ? + LIMIT 1 + """, + (user_id, user_id, game_id), + ).fetchone() + + if not row: + return None + + summary = _row_to_summary(row) + return SavedGameDetail( + **summary.__dict__, + final_fen=row["final_fen"] or "", + move_history=_parse_move_history(row["move_history"]), + ) + + +def build_board_rows(fen: str) -> list[list[dict[str, Optional[str]]]]: + board_part = (fen or "").split(" ", 1)[0] + raw_rows = board_part.split("/") + if len(raw_rows) != 8: + raw_rows = ["8"] * 8 + + rows: list[list[dict[str, Optional[str]]]] = [] + for rank_index, raw_row in enumerate(raw_rows): + row: list[dict[str, Optional[str]]] = [] + file_index = 0 + for char in raw_row: + if char.isdigit(): + for _ in range(int(char)): + row.append( + { + "square": f"{'abcdefgh'[file_index]}{8 - rank_index}", + "piece_image": None, + "shade": "light" if (file_index + rank_index) % 2 == 0 else "dark", + } + ) + file_index += 1 + continue + + row.append( + { + "square": f"{'abcdefgh'[file_index]}{8 - rank_index}", + "piece_image": PIECE_IMAGE_BY_SYMBOL.get(char), + "shade": "light" if (file_index + rank_index) % 2 == 0 else "dark", + } + ) + file_index += 1 + + while len(row) < 8: + row.append( + { + "square": f"{'abcdefgh'[len(row)]}{8 - rank_index}", + "piece_image": None, + "shade": "light" if (len(row) + rank_index) % 2 == 0 else "dark", + } + ) + rows.append(row) + + return rows + + +def group_move_pairs(move_history: list[str]) -> list[dict[str, str]]: + pairs = [] + for index in range(0, len(move_history), 2): + pairs.append( + { + "move_no": str((index // 2) + 1), + "white": move_history[index], + "black": move_history[index + 1] if index + 1 < len(move_history) else "", + } + ) + return pairs + + +def _row_to_summary(row) -> SavedGameSummary: + move_history = _parse_move_history(row["move_history"]) + return SavedGameSummary( + id=row["id"], + opponent_id=row["opponent_id"], + opponent_username=row["opponent_username"], + my_color=row["my_color"], + opponent_color=row["opponent_color"], + winner_color=row["winner_color"], + termination=row["termination"], + termination_detail=row["termination_detail"], + time_mode=row["time_mode"], + move_count=len(move_history), + started_at=row["started_at"], + ended_at=row["ended_at"], + ) + + +def _parse_move_history(raw_value: Optional[str]) -> list[str]: + if not raw_value: + return [] + try: + parsed = json.loads(raw_value) + except json.JSONDecodeError: + return [] + if not isinstance(parsed, list): + return [] + return [str(move) for move in parsed] diff --git a/app/routes/main.py b/app/routes/main.py index d89d522..392c191 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -1,5 +1,11 @@ -from flask import Blueprint, render_template, redirect, url_for, request +from flask import Blueprint, render_template, redirect, url_for, request, abort from flask_login import login_required, current_user +from app.models.game import ( + build_board_rows, + get_game_for_user, + group_move_pairs, + list_games_for_user, +) from app.routes.friends import _friends_page_data main_bp = Blueprint("main", __name__) @@ -23,9 +29,31 @@ def play(): return render_template("play.html") +@main_bp.route("/games", methods=["GET"]) +@login_required +def games_history(): + games = list_games_for_user(current_user.id) + return render_template("games.html", games=games) + + +@main_bp.route("/games/", methods=["GET"]) +@login_required +def game_detail(game_id: int): + game = get_game_for_user(game_id, current_user.id) + if not game: + abort(404) + + return render_template( + "game_detail.html", + game=game, + board_rows=build_board_rows(game.final_fen), + move_pairs=group_move_pairs(game.move_history), + ) + + #todo: decide if this should get moved to the friends.py file @main_bp.route("/friends", methods=["GET"]) @login_required def friends(): data = _friends_page_data(request.args.get("q", "")) - return render_template("friends.html", **data) \ No newline at end of file + return render_template("friends.html", **data) diff --git a/app/sockets/socket.py b/app/sockets/socket.py index beeb62a..e13bbd0 100644 --- a/app/sockets/socket.py +++ b/app/sockets/socket.py @@ -2,6 +2,7 @@ import random import string import time from dataclasses import dataclass, field +from datetime import datetime from threading import Lock from typing import Optional @@ -10,6 +11,7 @@ from flask_login import current_user from flask import request from flask_socketio import emit, join_room, leave_room from app import sIO +from app.models.game import save_finished_game from .types import validate_client_event @@ -17,10 +19,12 @@ from .types import validate_client_event class GameRoom: code: str p1_sid: str + p1_user_id: int p1_name: str p1_pref: str time_mode: str p2_sid: Optional[str] = None + p2_user_id: Optional[int] = None p2_name: Optional[str] = None ready: dict[str, bool] = field(default_factory=dict) board: chess.Board = field(default_factory=chess.Board) @@ -32,6 +36,10 @@ class GameRoom: active_since: Optional[float] = None game_active: bool = False completed: bool = False + move_history: list[str] = field(default_factory=list) + started_at: Optional[str] = None + ended_at: Optional[str] = None + saved_game_id: Optional[int] = None games_by_code: dict[str, GameRoom] = {} @@ -66,6 +74,10 @@ def _parse_time_mode(time_mode: str) -> tuple[int, int]: return minutes * 60 * 1000, increment_seconds * 1000 +def _timestamp_now() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + def _room_for_sid(sid: str) -> Optional[GameRoom]: code = code_by_sid.get(sid) if not code: @@ -139,26 +151,82 @@ def _game_over_reason(board: chess.Board) -> str: return "game over" +def _termination_key(reason: str) -> str: + if reason == "checkmate": + return "checkmate" + if reason == "resignation": + return "resignation" + if reason == "timeout": + return "timeout" + if reason in { + "stalemate", + "insufficient material", + "75-move rule", + "fivefold repetition", + "draw agreed", + }: + return "draw" + return "other" + + +def _winner_color_value(room: GameRoom, winner_sid: Optional[str]) -> Optional[str]: + if winner_sid is None: + return "draw" + + winner = room.color_by_sid.get(winner_sid) + if winner == "w": + return "white" + if winner == "b": + return "black" + return None + + +def _result_for_sid(room: GameRoom, sid: str, winner_color: Optional[str]) -> str: + if winner_color == "draw" or winner_color is None: + return "draw" + + player_color = room.color_by_sid.get(sid) + return "win" if player_color == winner_color else "loss" + + +def _save_completed_game(room: GameRoom, reason: str, winner_color: Optional[str]) -> Optional[int]: + if room.saved_game_id is not None: + return room.saved_game_id + if not room.started_at or room.p2_user_id is None: + return None + + room.ended_at = room.ended_at or _timestamp_now() + room.saved_game_id = save_finished_game( + white_player_id=room.p1_user_id if room.color_by_sid.get(room.p1_sid) == "w" else room.p2_user_id, + black_player_id=room.p1_user_id if room.color_by_sid.get(room.p1_sid) == "b" else room.p2_user_id, + final_fen=room.board.fen(), + termination=_termination_key(reason), + termination_detail=reason, + winner_color=winner_color, + move_history=room.move_history, + time_mode=room.time_mode, + started_at=room.started_at, + ended_at=room.ended_at, + ) + return room.saved_game_id + + def _emit_game_over(room: GameRoom, reason: str) -> None: room.completed = True room.game_active = False outcome = room.board.outcome(claim_draw=True) - p1_result = "draw" - p2_result = "draw" - + winner_color = "draw" if outcome and outcome.winner is not None: winner_color = "w" if outcome.winner == chess.WHITE else "b" - if room.color_by_sid.get(room.p1_sid) == winner_color: - p1_result = "win" - p2_result = "loss" - else: - p1_result = "loss" - p2_result = "win" - emit("game_over", {"result": p1_result, "reason": reason}, to=room.p1_sid) + game_id = _save_completed_game(room, reason, "white" if winner_color == "w" else "black" if winner_color == "b" else "draw") + p1_result = _result_for_sid(room, room.p1_sid, winner_color) + + emit("game_over", {"result": p1_result, "reason": reason, "game_id": game_id}, to=room.p1_sid) if room.p2_sid: - emit("game_over", {"result": p2_result, "reason": reason}, to=room.p2_sid) + p2_result = _result_for_sid(room, room.p2_sid, winner_color) + emit("game_over", {"result": p2_result, "reason": reason, "game_id": game_id}, to=room.p2_sid) def _emit_timeout(room: GameRoom, timed_out_color: str) -> None: @@ -166,13 +234,18 @@ def _emit_timeout(room: GameRoom, timed_out_color: str) -> None: room.game_active = False reason = "timeout" - p1_color = room.color_by_sid.get(room.p1_sid) - p1_result = "loss" if p1_color == timed_out_color else "win" - p2_result = "win" if p1_result == "loss" else "loss" + winner_color = "b" if timed_out_color == "w" else "w" + game_id = _save_completed_game( + room, + reason, + "white" if winner_color == "w" else "black", + ) + p1_result = _result_for_sid(room, room.p1_sid, winner_color) - sIO.emit("game_over", {"result": p1_result, "reason": reason}, to=room.p1_sid) + sIO.emit("game_over", {"result": p1_result, "reason": reason, "game_id": game_id}, to=room.p1_sid) if room.p2_sid: - sIO.emit("game_over", {"result": p2_result, "reason": reason}, to=room.p2_sid) + p2_result = _result_for_sid(room, room.p2_sid, winner_color) + sIO.emit("game_over", {"result": p2_result, "reason": reason, "game_id": game_id}, to=room.p2_sid) def _start_game_if_ready(room: GameRoom) -> None: @@ -198,6 +271,10 @@ def _start_game_if_ready(room: GameRoom) -> None: room.active_since = time.monotonic() room.game_active = True room.completed = False + room.move_history = [] + room.started_at = _timestamp_now() + room.ended_at = None + room.saved_game_id = None p1_payload = { "play_as": p1_color, @@ -272,13 +349,54 @@ def on_disconnect(): leave_room(code) code_by_sid.pop(sid, None) - if sid == room.p1_sid: - if room.p2_sid: - emit("game_over", {"result": "win", "reason": "opponent disconnected"}, to=room.p2_sid) + if room.completed: _cleanup_room(code) return + if sid == room.p1_sid: + if room.p2_sid: + if room.started_at and room.color_by_sid and not room.completed: + room.completed = True + room.game_active = False + game_id = _save_completed_game( + room, + "opponent disconnected", + _winner_color_value(room, room.p2_sid), + ) + emit( + "game_over", + { + "result": "win", + "reason": "opponent disconnected", + "game_id": game_id, + }, + to=room.p2_sid, + ) + _cleanup_room(code) + return + + if room.started_at and room.color_by_sid and not room.completed: + room.completed = True + room.game_active = False + game_id = _save_completed_game( + room, + "opponent disconnected", + _winner_color_value(room, room.p1_sid), + ) + _cleanup_room(code) + emit( + "game_over", + { + "result": "win", + "reason": "opponent disconnected", + "game_id": game_id, + }, + to=room.p1_sid, + ) + return + room.p2_sid = None + room.p2_user_id = None room.p2_name = None room.ready.pop(sid, None) room.color_by_sid.pop(sid, None) @@ -287,6 +405,10 @@ def on_disconnect(): room.game_active = False room.completed = False room.active_since = None + room.move_history = [] + room.started_at = None + room.ended_at = None + room.saved_game_id = None if other_sid: emit("p2_connected", {"p2_name": None, "ready": False}, to=other_sid) @@ -312,6 +434,7 @@ def on_create_code_game(payload): room = GameRoom( code=code, p1_sid=sid, + p1_user_id=current_user.id, p1_name=current_user.username, p1_pref=payload["play_as"], time_mode=payload["time_mode"], @@ -355,6 +478,7 @@ def on_join_code_game(payload): _cleanup_room(previous.code) room.p2_sid = sid + room.p2_user_id = current_user.id room.p2_name = current_user.username room.ready.setdefault(room.p1_sid, False) room.ready[sid] = False @@ -442,6 +566,7 @@ def on_move_request(payload): mover_color = room.color_by_sid.get(sid) san = room.board.san(move) + room.move_history.append(san) room.board.push(move) if mover_color == "w": @@ -491,9 +616,11 @@ def on_request_resign(payload): reason = "resignation" other_sid = room.p2_sid if sid == room.p1_sid else room.p1_sid - emit("game_over", {"result": "loss", "reason": reason}, to=sid) + winner_sid = other_sid if other_sid else None + game_id = _save_completed_game(room, reason, _winner_color_value(room, winner_sid)) + emit("game_over", {"result": "loss", "reason": reason, "game_id": game_id}, to=sid) if other_sid: - emit("game_over", {"result": "win", "reason": reason}, to=other_sid) + emit("game_over", {"result": "win", "reason": reason, "game_id": game_id}, to=other_sid) @sIO.on("request_draw") @@ -515,7 +642,8 @@ def on_request_draw(payload): if payload.get("accepted") is True: room.completed = True room.game_active = False - emit("game_over", {"result": "draw", "reason": "draw agreed"}, to=room.code) + game_id = _save_completed_game(room, "draw agreed", "draw") + emit("game_over", {"result": "draw", "reason": "draw agreed", "game_id": game_id}, to=room.code) return other_sid = room.p2_sid if sid == room.p1_sid else room.p1_sid diff --git a/app/sockets/types.py b/app/sockets/types.py index a1f2d49..4979d40 100644 --- a/app/sockets/types.py +++ b/app/sockets/types.py @@ -51,6 +51,7 @@ class RequestDraw(TypedDict): # Client class GameEnd(TypedDict): # Server result: Literal["win", "loss", "draw"] reason: str + game_id: NotRequired[int] #todo: implement later diff --git a/app/static/app.css b/app/static/app.css index e6040da..3301c48 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -506,7 +506,7 @@ h3 { border-radius: 8px; overflow: hidden; background: var(--surface-soft); - max-height: 280px; + max-height: calc(100vh - 500px); overflow-y: auto; } @@ -541,6 +541,211 @@ h3 { color: var(--muted); } +.games-page { + display: grid; + gap: 14px; +} + +.games-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.games-header p { + margin: 6px 0 0; +} + +.games-header-actions { + margin-top: 0; + justify-content: flex-end; +} + +.games-list { + display: grid; + gap: 10px; +} + +.game-card { + display: grid; + gap: 10px; + text-decoration: none; + color: inherit; + border: 1px solid var(--border); + border-radius: 8px; + padding: 14px; + background: var(--surface-soft); +} + +.game-card:hover { + border-color: #b7c0cb; + background: #eef2f6; +} + +.game-card-main { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.game-card-label { + margin: 0; + color: var(--muted); + font-size: 0.85rem; +} + +.game-card-title { + margin: 4px 0 0; + font-size: 1.1rem; + font-weight: 700; +} + +.game-card-meta { + display: flex; + gap: 0; + flex-wrap: wrap; + color: var(--muted); + font-size: 0.95rem; +} + +.game-card-meta span { + display: inline-flex; + align-items: center; +} + +.game-card-meta span + span::before { + content: "|"; + margin: 0 10px; + color: #9aa3af; +} + +.result-badge { + border: 1px solid currentColor; + border-radius: 6px; + padding: 6px 10px; + font-size: 0.85rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.result-win { + background: #ecfdf3; + color: var(--success); +} + +.result-loss { + background: #fef2f2; + color: var(--danger); +} + +.result-draw { + background: #eef2f6; + color: var(--primary); +} + +.empty-state { + text-align: center; + padding: 24px 14px; +} + +.empty-state p { + margin: 8px 0 0; +} + +.game-detail-layout { + display: grid; + grid-template-columns: minmax(280px, 420px) minmax(0, 1fr); + gap: 14px; +} + +.game-summary { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.game-summary-copy { + min-width: 0; +} + +.game-summary-label { + margin: 0; + color: var(--muted); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.game-summary-title { + margin: 4px 0 0; + font-size: 2rem; + line-height: 1; + text-transform: capitalize; +} + +.game-summary-meta { + margin-top: 14px; + border-top: 1px solid var(--border); +} + +.game-meta-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + padding: 10px 0; + border-bottom: 1px solid var(--border); +} + +.game-meta-key { + color: var(--muted); + font-size: 0.9rem; +} + +.game-meta-value { + text-align: right; + font-weight: 600; +} + +.history-board { + margin-top: 14px; + width: min(100%, 420px); + aspect-ratio: 1 / 1; + display: grid; + grid-template-columns: repeat(8, 1fr); + grid-template-rows: repeat(8, minmax(0, 1fr)); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + background: #d2b48c; +} + +.history-square { + display: grid; + place-items: center; + min-width: 0; + min-height: 0; + aspect-ratio: 1 / 1; +} + +.history-square-light { + background: #f6e7cf; +} + +.history-square-dark { + background: #b18155; +} + +.history-square img { + width: 82%; + height: 82%; + object-fit: contain; +} + #chess-canvas { width: min(100%, 640px); aspect-ratio: 1 / 1; @@ -555,4 +760,41 @@ h3 { flex-direction: column; align-items: flex-start; } + + .games-header { + flex-direction: column; + } + + .games-header-actions { + width: 100%; + justify-content: flex-start; + } + + .game-card-main { + flex-direction: column; + } + + .game-summary { + flex-direction: column; + } + + .game-summary-title { + font-size: 1.7rem; + } + + .game-meta-row { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } + + .game-meta-value { + text-align: left; + } +} + +@media (max-width: 900px) { + .game-detail-layout { + grid-template-columns: 1fr; + } } diff --git a/app/static/js/play/app.js b/app/static/js/play/app.js index e20f3b2..61062fe 100644 --- a/app/static/js/play/app.js +++ b/app/static/js/play/app.js @@ -464,11 +464,18 @@ export class PlayApp { onGameOver(data) { this.setStatus(`Game over: ${data.result} (${data.reason})`); - this.showModal( - "Game over", - `${data.result.toUpperCase()} - ${data.reason}`, - [{ label: "OK" }], - ); + const actions = [{ label: "OK" }]; + if (data.game_id) { + actions.unshift({ + label: "View saved game", + className: "btn-primary", + onClick: () => { + window.location.href = `/games/${data.game_id}`; + }, + }); + } + + this.showModal("Game over", `${data.result.toUpperCase()} - ${data.reason}`, actions); } onDrawOffered(data) { diff --git a/app/templates/base_app.html b/app/templates/base_app.html index bebb2ac..e3a2eef 100644 --- a/app/templates/base_app.html +++ b/app/templates/base_app.html @@ -33,6 +33,11 @@ class="{{ 'active' if active_page == 'friends' else '' }}" >Friends + Games
{{ current_user.username }}
diff --git a/app/templates/game_detail.html b/app/templates/game_detail.html new file mode 100644 index 0000000..d30ce4a --- /dev/null +++ b/app/templates/game_detail.html @@ -0,0 +1,100 @@ +{% extends "base_app.html" %} {% set active_page = 'games' %} {% block title +%}Game {{ game.id }}{% endblock %} {% block content %} +
+
+
+

{{ current_user.username }} vs {{ game.opponent_username }}

+

+ {{ game.time_mode or "Untimed" }} - {{ game.termination_detail or + game.termination or "finished" }} - {{ game.ended_at or game.started_at + or "Unknown date" }} +

+
+ +
+ +
+
+
+
+

Result

+

{{ game.result }}

+
+ {{ game.result }} +
+
+
+ You + {{ current_user.username }} ({{ game.my_color }}) +
+
+ Opponent + {{ game.opponent_username }} ({{ game.opponent_color }}) +
+
+ Moves + {{ game.move_count }} +
+
+ +
+ {% for row in board_rows %} {% for square in row %} +
+ {% if square.piece_image %} + + {% endif %} +
+ {% endfor %} {% endfor %} +
+

Final position

+
+ +
+

Moves

+ {% if move_pairs %} +
+ + + + + + + + + + {% for pair in move_pairs %} + + + + + + {% endfor %} + +
#WhiteBlack
{{ pair.move_no }}.{{ pair.white }}{{ pair.black }}
+
+ {% else %} +

No moves were recorded for this game.

+ {% endif %} +
+
+
+{% endblock %} diff --git a/app/templates/games.html b/app/templates/games.html new file mode 100644 index 0000000..2bfe0a0 --- /dev/null +++ b/app/templates/games.html @@ -0,0 +1,49 @@ +{% extends "base_app.html" %} {% set active_page = 'games' %} {% block title +%}Games{% endblock %} {% block content %} +
+
+
+

Past games

+
+ New game +
+ + +
+{% endblock %} diff --git a/app/templates/home.html b/app/templates/home.html index 2800b46..06ad151 100644 --- a/app/templates/home.html +++ b/app/templates/home.html @@ -6,5 +6,8 @@ Open friends + Past games {% endblock %}