Save finished games and add history views

This commit is contained in:
2026-03-03 19:05:54 +01:00
parent 7887986a5a
commit d1b432f8aa
11 changed files with 919 additions and 33 deletions
+36 -3
View File
@@ -3,10 +3,15 @@ from flask import g
DATABASE = 'sqlite.db' 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(): def get_db():
if 'db' not in g: if 'db' not in g:
g.db = sqlite3.connect(DATABASE) g.db = connect_db()
g.db.row_factory = sqlite3.Row
return g.db return g.db
def close_db(e=None): def close_db(e=None):
@@ -33,7 +38,13 @@ def init_db(app):
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
final_fen TEXT, 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 ( CREATE TABLE IF NOT EXISTS game_players (
@@ -75,4 +86,26 @@ def init_db(app):
if "last_seen_at" not in user_columns: if "last_seen_at" not in user_columns:
db.execute("ALTER TABLE users ADD COLUMN last_seen_at TIMESTAMP") 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() db.commit()
+290
View File
@@ -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]
+30 -2
View File
@@ -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 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 from app.routes.friends import _friends_page_data
main_bp = Blueprint("main", __name__) main_bp = Blueprint("main", __name__)
@@ -23,9 +29,31 @@ def play():
return render_template("play.html") 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/<int:game_id>", 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 #todo: decide if this should get moved to the friends.py file
@main_bp.route("/friends", methods=["GET"]) @main_bp.route("/friends", methods=["GET"])
@login_required @login_required
def friends(): def friends():
data = _friends_page_data(request.args.get("q", "")) data = _friends_page_data(request.args.get("q", ""))
return render_template("friends.html", **data) return render_template("friends.html", **data)
+150 -22
View File
@@ -2,6 +2,7 @@ import random
import string import string
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime
from threading import Lock from threading import Lock
from typing import Optional from typing import Optional
@@ -10,6 +11,7 @@ from flask_login import current_user
from flask import request from flask import request
from flask_socketio import emit, join_room, leave_room from flask_socketio import emit, join_room, leave_room
from app import sIO from app import sIO
from app.models.game import save_finished_game
from .types import validate_client_event from .types import validate_client_event
@@ -17,10 +19,12 @@ from .types import validate_client_event
class GameRoom: class GameRoom:
code: str code: str
p1_sid: str p1_sid: str
p1_user_id: int
p1_name: str p1_name: str
p1_pref: str p1_pref: str
time_mode: str time_mode: str
p2_sid: Optional[str] = None p2_sid: Optional[str] = None
p2_user_id: Optional[int] = None
p2_name: Optional[str] = None p2_name: Optional[str] = None
ready: dict[str, bool] = field(default_factory=dict) ready: dict[str, bool] = field(default_factory=dict)
board: chess.Board = field(default_factory=chess.Board) board: chess.Board = field(default_factory=chess.Board)
@@ -32,6 +36,10 @@ class GameRoom:
active_since: Optional[float] = None active_since: Optional[float] = None
game_active: bool = False game_active: bool = False
completed: 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] = {} 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 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]: def _room_for_sid(sid: str) -> Optional[GameRoom]:
code = code_by_sid.get(sid) code = code_by_sid.get(sid)
if not code: if not code:
@@ -139,26 +151,82 @@ def _game_over_reason(board: chess.Board) -> str:
return "game over" 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: def _emit_game_over(room: GameRoom, reason: str) -> None:
room.completed = True room.completed = True
room.game_active = False room.game_active = False
outcome = room.board.outcome(claim_draw=True) outcome = room.board.outcome(claim_draw=True)
p1_result = "draw" winner_color = "draw"
p2_result = "draw"
if outcome and outcome.winner is not None: if outcome and outcome.winner is not None:
winner_color = "w" if outcome.winner == chess.WHITE else "b" 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: 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: 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 room.game_active = False
reason = "timeout" reason = "timeout"
p1_color = room.color_by_sid.get(room.p1_sid) winner_color = "b" if timed_out_color == "w" else "w"
p1_result = "loss" if p1_color == timed_out_color else "win" game_id = _save_completed_game(
p2_result = "win" if p1_result == "loss" else "loss" 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: 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: 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.active_since = time.monotonic()
room.game_active = True room.game_active = True
room.completed = False room.completed = False
room.move_history = []
room.started_at = _timestamp_now()
room.ended_at = None
room.saved_game_id = None
p1_payload = { p1_payload = {
"play_as": p1_color, "play_as": p1_color,
@@ -272,13 +349,54 @@ def on_disconnect():
leave_room(code) leave_room(code)
code_by_sid.pop(sid, None) code_by_sid.pop(sid, None)
if sid == room.p1_sid: if room.completed:
if room.p2_sid:
emit("game_over", {"result": "win", "reason": "opponent disconnected"}, to=room.p2_sid)
_cleanup_room(code) _cleanup_room(code)
return 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_sid = None
room.p2_user_id = None
room.p2_name = None room.p2_name = None
room.ready.pop(sid, None) room.ready.pop(sid, None)
room.color_by_sid.pop(sid, None) room.color_by_sid.pop(sid, None)
@@ -287,6 +405,10 @@ def on_disconnect():
room.game_active = False room.game_active = False
room.completed = False room.completed = False
room.active_since = None room.active_since = None
room.move_history = []
room.started_at = None
room.ended_at = None
room.saved_game_id = None
if other_sid: if other_sid:
emit("p2_connected", {"p2_name": None, "ready": False}, to=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( room = GameRoom(
code=code, code=code,
p1_sid=sid, p1_sid=sid,
p1_user_id=current_user.id,
p1_name=current_user.username, p1_name=current_user.username,
p1_pref=payload["play_as"], p1_pref=payload["play_as"],
time_mode=payload["time_mode"], time_mode=payload["time_mode"],
@@ -355,6 +478,7 @@ def on_join_code_game(payload):
_cleanup_room(previous.code) _cleanup_room(previous.code)
room.p2_sid = sid room.p2_sid = sid
room.p2_user_id = current_user.id
room.p2_name = current_user.username room.p2_name = current_user.username
room.ready.setdefault(room.p1_sid, False) room.ready.setdefault(room.p1_sid, False)
room.ready[sid] = False room.ready[sid] = False
@@ -442,6 +566,7 @@ def on_move_request(payload):
mover_color = room.color_by_sid.get(sid) mover_color = room.color_by_sid.get(sid)
san = room.board.san(move) san = room.board.san(move)
room.move_history.append(san)
room.board.push(move) room.board.push(move)
if mover_color == "w": if mover_color == "w":
@@ -491,9 +616,11 @@ def on_request_resign(payload):
reason = "resignation" reason = "resignation"
other_sid = room.p2_sid if sid == room.p1_sid else room.p1_sid 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: 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") @sIO.on("request_draw")
@@ -515,7 +642,8 @@ def on_request_draw(payload):
if payload.get("accepted") is True: if payload.get("accepted") is True:
room.completed = True room.completed = True
room.game_active = False 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 return
other_sid = room.p2_sid if sid == room.p1_sid else room.p1_sid other_sid = room.p2_sid if sid == room.p1_sid else room.p1_sid
+1
View File
@@ -51,6 +51,7 @@ class RequestDraw(TypedDict): # Client
class GameEnd(TypedDict): # Server class GameEnd(TypedDict): # Server
result: Literal["win", "loss", "draw"] result: Literal["win", "loss", "draw"]
reason: str reason: str
game_id: NotRequired[int]
#todo: implement later #todo: implement later
+243 -1
View File
@@ -506,7 +506,7 @@ h3 {
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
background: var(--surface-soft); background: var(--surface-soft);
max-height: 280px; max-height: calc(100vh - 500px);
overflow-y: auto; overflow-y: auto;
} }
@@ -541,6 +541,211 @@ h3 {
color: var(--muted); 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 { #chess-canvas {
width: min(100%, 640px); width: min(100%, 640px);
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
@@ -555,4 +760,41 @@ h3 {
flex-direction: column; flex-direction: column;
align-items: flex-start; 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;
}
} }
+12 -5
View File
@@ -464,11 +464,18 @@ export class PlayApp {
onGameOver(data) { onGameOver(data) {
this.setStatus(`Game over: ${data.result} (${data.reason})`); this.setStatus(`Game over: ${data.result} (${data.reason})`);
this.showModal( const actions = [{ label: "OK" }];
"Game over", if (data.game_id) {
`${data.result.toUpperCase()} - ${data.reason}`, actions.unshift({
[{ label: "OK" }], 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) { onDrawOffered(data) {
+5
View File
@@ -33,6 +33,11 @@
class="{{ 'active' if active_page == 'friends' else '' }}" class="{{ 'active' if active_page == 'friends' else '' }}"
>Friends</a >Friends</a
> >
<a
href="{{ url_for('main.games_history') }}"
class="{{ 'active' if active_page == 'games' else '' }}"
>Games</a
>
</nav> </nav>
<div class="profile-pill">{{ current_user.username }}</div> <div class="profile-pill">{{ current_user.username }}</div>
</div> </div>
+100
View File
@@ -0,0 +1,100 @@
{% extends "base_app.html" %} {% set active_page = 'games' %} {% block title
%}Game {{ game.id }}{% endblock %} {% block content %}
<section class="games-page">
<div class="games-header">
<div>
<h1>{{ current_user.username }} vs {{ game.opponent_username }}</h1>
<p class="muted">
{{ game.time_mode or "Untimed" }} - {{ game.termination_detail or
game.termination or "finished" }} - {{ game.ended_at or game.started_at
or "Unknown date" }}
</p>
</div>
<div class="cta-row games-header-actions">
<a href="{{ url_for('main.games_history') }}" class="btn btn-secondary"
>Back to games</a
>
<a href="{{ url_for('main.play') }}" class="btn btn-primary"
>Play again</a
>
</div>
</div>
<section class="game-detail-layout">
<article class="panel">
<div class="game-summary">
<div class="game-summary-copy">
<p class="game-summary-label">Result</p>
<h2 class="game-summary-title">{{ game.result }}</h2>
</div>
<span class="result-badge result-{{ game.result }}"
>{{ game.result }}</span
>
</div>
<div class="game-summary-meta">
<div class="game-meta-row">
<span class="game-meta-key">You</span>
<span class="game-meta-value"
>{{ current_user.username }} ({{ game.my_color }})</span
>
</div>
<div class="game-meta-row">
<span class="game-meta-key">Opponent</span>
<span class="game-meta-value"
>{{ game.opponent_username }} ({{ game.opponent_color }})</span
>
</div>
<div class="game-meta-row">
<span class="game-meta-key">Moves</span>
<span class="game-meta-value">{{ game.move_count }}</span>
</div>
</div>
<div class="history-board" aria-label="Final board position">
{% for row in board_rows %} {% for square in row %}
<div
class="history-square history-square-{{ square.shade }}"
title="{{ square.square }}"
>
{% if square.piece_image %}
<img
src="{{ url_for('static', filename='board_pieces/' ~ square.piece_image) }}"
alt=""
/>
{% endif %}
</div>
{% endfor %} {% endfor %}
</div>
<p class="muted">Final position</p>
</article>
<article class="panel">
<h2>Moves</h2>
{% if move_pairs %}
<div class="move-list-wrap">
<table class="move-table">
<thead>
<tr>
<th>#</th>
<th>White</th>
<th>Black</th>
</tr>
</thead>
<tbody>
{% for pair in move_pairs %}
<tr>
<td>{{ pair.move_no }}.</td>
<td>{{ pair.white }}</td>
<td>{{ pair.black }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="muted">No moves were recorded for this game.</p>
{% endif %}
</article>
</section>
</section>
{% endblock %}
+49
View File
@@ -0,0 +1,49 @@
{% extends "base_app.html" %} {% set active_page = 'games' %} {% block title
%}Games{% endblock %} {% block content %}
<section class="games-page">
<div class="games-header">
<div>
<h1>Past games</h1>
</div>
<a href="{{ url_for('main.play') }}" class="btn btn-primary">New game</a>
</div>
<article class="panel">
{% if games %}
<div class="games-list">
{% for game in games %}
<a
class="game-card"
href="{{ url_for('main.game_detail', game_id=game.id) }}"
>
<div class="game-card-main">
<div>
<p class="game-card-label">Opponent</p>
<p class="game-card-title">{{ game.opponent_username }}</p>
</div>
<span class="result-badge result-{{ game.result }}"
>{{ game.result }}</span
>
</div>
<div class="game-card-meta">
<span>You played {{ game.my_color }}</span>
<span>{{ game.time_mode or "Untimed" }}</span>
<span>{{ game.move_count }} moves</span>
<span
>{{ game.termination_detail or game.termination or "finished"
}}</span
>
<span>{{ game.ended_at or game.started_at or "Unknown date" }}</span>
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<h2>No saved games yet</h2>
<p class="muted">Finished matches will appear here automatically.</p>
</div>
{% endif %}
</article>
</section>
{% endblock %}
+3
View File
@@ -6,5 +6,8 @@
<a href="{{ url_for('main.friends') }}" class="btn btn-secondary" <a href="{{ url_for('main.friends') }}" class="btn btn-secondary"
>Open friends</a >Open friends</a
> >
<a href="{{ url_for('main.games_history') }}" class="btn btn-secondary"
>Past games</a
>
</div> </div>
{% endblock %} {% endblock %}