This commit is contained in:
simoncreates
2026-03-03 20:09:49 +01:00
11 changed files with 854 additions and 21 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]
+29 -1
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,6 +29,28 @@ 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
+1
View File
@@ -285,6 +285,7 @@ def on_disconnect():
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)
# initialize a new chessboard???
room.board = chess.Board() room.board = chess.Board()
room.ready[room.p1_sid] = False room.ready[room.p1_sid] = False
room.game_active = False room.game_active = False
+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
+274 -1
View File
@@ -79,6 +79,26 @@ body {
border-radius: 4px; border-radius: 4px;
} }
.quick-join-form {
display: flex;
align-items: center;
gap: 8px;
}
.quick-join-form input {
width: 116px;
border: 1px solid var(--border);
border-radius: 4px;
padding: 9px 10px;
font: inherit;
text-transform: uppercase;
background: var(--surface);
}
.home-join {
margin-top: 10px;
}
.page-wrap { .page-wrap {
max-width: 1000px; max-width: 1000px;
margin: 0 auto; margin: 0 auto;
@@ -329,6 +349,17 @@ h3 {
text-align: center; text-align: center;
} }
.quick-join-form {
flex: 1;
min-width: 0;
}
.quick-join-form input {
flex: 1;
min-width: 0;
width: auto;
}
.page-wrap { .page-wrap {
padding: 16px 14px 24px; padding: 16px 14px 24px;
} }
@@ -506,7 +537,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 +572,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 +791,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;
}
} }
+49 -15
View File
@@ -87,6 +87,7 @@ export class PlayApp {
this.displayBoardSize = 640; this.displayBoardSize = 640;
this.squareSize = 80; this.squareSize = 80;
this.dpr = Math.max(1, window.devicePixelRatio || 1); this.dpr = Math.max(1, window.devicePixelRatio || 1);
this.urlJoinConsumed = false;
this.loadPieceImages(); this.loadPieceImages();
this.installControls(); this.installControls();
@@ -127,6 +128,7 @@ export class PlayApp {
installSocket() { installSocket() {
this.socket = createWSClient({ this.socket = createWSClient({
onServerReady: () => this.consumeJoinCodeFromUrl(),
onGameStarted: handleGameStarted, onGameStarted: handleGameStarted,
onP2Connected: handleP2Connected, onP2Connected: handleP2Connected,
onGameCreated: handleCodeGameCreated, onGameCreated: handleCodeGameCreated,
@@ -170,16 +172,7 @@ export class PlayApp {
}); });
this.joinBtn.addEventListener("click", () => { this.joinBtn.addEventListener("click", () => {
const code = this.joinInput.value.trim().toUpperCase(); this.joinCode(this.joinInput.value);
if (!code) {
this.showModal("Join game", "Enter a game code first.", [
{ label: "OK" },
]);
return;
}
this.gameCode = code;
this.socket.emit("join_code_game", { code });
this.setLobbyStatus("Joining game...");
}); });
this.copyCodeBtn.addEventListener("click", async () => { this.copyCodeBtn.addEventListener("click", async () => {
@@ -358,6 +351,40 @@ export class PlayApp {
}); });
} }
joinCode(rawCode) {
const code = String(rawCode || "").trim().toUpperCase();
if (!code) {
this.showModal("Join game", "Enter a game code first.", [
{ label: "OK" },
]);
return false;
}
this.gameCode = code;
this.joinInput.value = code;
this.socket.emit("join_code_game", { code });
this.setLobbyStatus("Joining game...");
return true;
}
consumeJoinCodeFromUrl() {
if (this.urlJoinConsumed) {
return;
}
const url = new URL(window.location.href);
const code = url.searchParams.get("code");
if (!code) {
return;
}
this.urlJoinConsumed = true;
url.searchParams.delete("code");
const nextUrl = `${url.pathname}${url.search}${url.hash}`;
window.history.replaceState({}, "", nextUrl);
this.joinCode(code);
}
onCodeCreated(data) { onCodeCreated(data) {
this.gameCode = data.code; this.gameCode = data.code;
this.codeEl.textContent = data.code; this.codeEl.textContent = data.code;
@@ -464,11 +491,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 %}
+19
View File
@@ -6,5 +6,24 @@
<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 class="home-join">
<form
class="quick-join-form"
action="{{ url_for('main.play') }}"
method="get"
>
<input
type="text"
name="code"
maxlength="6"
placeholder="Game code"
aria-label="Join by code"
/>
<button class="btn btn-secondary" type="submit">Join</button>
</form>
</div> </div>
{% endblock %} {% endblock %}