From d1b5a6b480babf7c6321a7ba0392c7427a518f35 Mon Sep 17 00:00:00 2001 From: tototomate123 Date: Thu, 26 Feb 2026 10:53:34 +0100 Subject: [PATCH 1/3] Refactor shared layouts and remove unused CSS --- app/static/app.css | 170 ++++++++++++++++++++++++--------- app/static/style.css | 100 +++++++++++++++++-- app/templates/base_app.html | 53 ++++++++++ app/templates/base_public.html | 24 +++++ app/templates/home.html | 50 ++-------- app/templates/index.html | 34 +++---- app/templates/login.html | 21 ++-- app/templates/play.html | 79 +++++---------- app/templates/register.html | 21 ++-- 9 files changed, 367 insertions(+), 185 deletions(-) create mode 100644 app/templates/base_app.html create mode 100644 app/templates/base_public.html diff --git a/app/static/app.css b/app/static/app.css index 5544f45..4646ad0 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -8,6 +8,7 @@ --primary: #1f2937; --primary-strong: #0f172a; --success: #16a34a; + --danger: #b91c1c; } * { @@ -26,15 +27,19 @@ body { } .topbar { - height: 64px; border-bottom: 1px solid var(--border); background: var(--surface); +} + +.topbar-inner { + max-width: 1000px; + margin: 0 auto; + min-height: 64px; display: flex; align-items: center; justify-content: space-between; + gap: 12px; padding: 0 20px; - position: sticky; - top: 0; } .brand { @@ -58,12 +63,7 @@ body { font-weight: 600; } -.topnav a:hover { - color: var(--text); - background: var(--surface-soft); - border-color: var(--border); -} - +.topnav a:hover, .topnav a.active { color: var(--text); background: var(--surface-soft); @@ -76,18 +76,41 @@ body { padding: 7px 10px; font-size: 0.9rem; color: var(--muted); + border-radius: 4px; } .page-wrap { - max-width: 1080px; + max-width: 1000px; margin: 0 auto; padding: 24px 20px 30px; } +h1, +h2, +h3 { + margin: 0; +} + h1 { - margin: 8px 0 10px; - font-size: clamp(1.6rem, 3vw, 2.25rem); - line-height: 1.15; + margin-bottom: 12px; + font-size: clamp(1.6rem, 3vw, 2.1rem); +} + +h2 { + margin-bottom: 10px; + font-size: 1.25rem; +} + +h3 { + margin: 14px 0 0; + font-size: 1rem; +} + +.panel { + border: 1px solid var(--border); + background: var(--surface); + border-radius: 8px; + padding: 16px; } .cta-row { @@ -124,10 +147,8 @@ h1 { } .queue-layout { - margin-top: 14px; display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; + grid-template-columns: 1fr; } .chip-row { @@ -153,45 +174,103 @@ h1 { background: #eef2f6; } -.friend-list { - margin: 0; - padding: 0; - list-style: none; +.search-form { + display: flex; + gap: 10px; + margin-bottom: 12px; } -.friend-list li { +.search-form input { + flex: 1; + min-width: 0; + border: 1px solid var(--border); + border-radius: 4px; + padding: 9px 12px; + font: inherit; +} + +.friends-section, +.friends-search-panel { + margin-top: 14px; +} + +.friends-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.friend-card { + border: 1px solid var(--border); + border-radius: 6px; + background: var(--surface-soft); + padding: 10px; display: flex; align-items: center; - gap: 8px; - padding: 7px 0; - border-top: 1px solid var(--border); + justify-content: space-between; + gap: 10px; +} + +.friend-main { + min-width: 0; +} + +.friend-username { + font-weight: 600; +} + +.friend-status { + font-size: 0.9rem; color: var(--muted); } -.friend-list li:first-child { - border-top: 0; +.status-online { + color: var(--success); } -.dot { - width: 8px; - height: 8px; - border-radius: 4px; - background: var(--success); +.status-offline { + color: var(--muted); } -@media (max-width: 900px) { - .panel-grid { - grid-template-columns: 1fr; - } +.friend-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} - .queue-layout { - grid-template-columns: 1fr; - } +.friend-note, +.muted { + color: var(--muted); +} + +.flash-stack { + margin-bottom: 14px; + display: grid; + gap: 8px; +} + +.flash { + border: 1px solid var(--border); + background: var(--surface); + border-radius: 6px; + padding: 10px 12px; + color: var(--text); +} + +.flash.success { + border-color: #b7dfc3; + background: #ecfdf3; +} + +.flash.error { + border-color: #f0c2c2; + background: #fef2f2; + color: var(--danger); } @media (max-width: 700px) { - .topbar { - height: auto; + .topbar-inner { + min-height: auto; flex-wrap: wrap; gap: 8px; padding: 10px 14px; @@ -211,9 +290,12 @@ h1 { padding: 16px 14px 24px; } - .hero, - .stack-head, - .panel { - padding: 14px; + .search-form { + flex-direction: column; + } + + .friend-card { + flex-direction: column; + align-items: flex-start; } } diff --git a/app/static/style.css b/app/static/style.css index 4fed022..ee4892f 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -1,9 +1,93 @@ -.link-button { - border: 1px solid black; - padding: 10px; - text-decoration: none; - border-radius: 5px; - background-color: lightgray; - color: black; - margin: 5px; +:root { + --bg: #f4f6f8; + --surface: #ffffff; + --border: #dde2e8; + --text: #111827; + --muted: #5f6b7a; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Segoe UI", sans-serif; + color: var(--text); + background: var(--bg); +} + +.public-wrap { + max-width: 1000px; + margin: 0 auto; + padding: 24px 20px; +} + +.public-card { + border: 1px solid var(--border); + background: var(--surface); + border-radius: 8px; + padding: 18px; +} + +h1 { + margin: 0 0 10px; +} + +.muted { + color: var(--muted); +} + +.public-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.auth-form { + display: grid; + gap: 8px; + margin-bottom: 10px; +} + +.auth-form input { + border: 1px solid var(--border); + border-radius: 4px; + padding: 9px 10px; + font: inherit; +} + +.link-button { + display: inline-block; + border: 1px solid #111827; + padding: 8px 12px; + text-decoration: none; + border-radius: 4px; + color: #111827; + background: #f3f4f6; + cursor: pointer; + font: inherit; +} + +.flash-stack { + margin-bottom: 10px; + display: grid; + gap: 8px; +} + +.flash { + border: 1px solid var(--border); + background: var(--surface); + border-radius: 6px; + padding: 10px 12px; +} + +.flash.error { + border-color: #f0c2c2; + background: #fef2f2; +} + +.flash.success { + border-color: #b7dfc3; + background: #ecfdf3; } diff --git a/app/templates/base_app.html b/app/templates/base_app.html new file mode 100644 index 0000000..4e26ad0 --- /dev/null +++ b/app/templates/base_app.html @@ -0,0 +1,53 @@ + + + + + + {% block title %}Chess{% endblock %} + + + + + + +
+
+
+ Chess + +
{{ current_user.username }}
+
+
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} {% if + messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} {% endwith %} {% block content %}{% endblock %} +
+
+ + diff --git a/app/templates/base_public.html b/app/templates/base_public.html new file mode 100644 index 0000000..dd16f9b --- /dev/null +++ b/app/templates/base_public.html @@ -0,0 +1,24 @@ + + + + + + {% block title %}Chess{% endblock %} + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} {% if + messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} {% endwith %} {% block content %}{% endblock %} +
+ + diff --git a/app/templates/home.html b/app/templates/home.html index 4648daa..2800b46 100644 --- a/app/templates/home.html +++ b/app/templates/home.html @@ -1,40 +1,10 @@ - - - - - - Home - - - - - - -
-
- Chess - -
{{ current_user.username }}
-
- -
-

Welcome back, {{current_user.username}}

- -
-
- - +{% extends "base_app.html" %} {% set active_page = 'home' %} {% block title +%}Home{% endblock %} {% block content %} +

Welcome back, {{ current_user.username }}

+
+ Start a game + Open friends +
+{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html index 88f5d51..d50e2bf 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,20 +1,14 @@ - - - - - - Chess - - - -

Chess Game

-

main page

- - Login - - Register - - +{% extends "base_public.html" %} {% block title %}Chess{% endblock %} {% block +content %} +
+

Play Chess

+

+ Play games and challenge your friends. You just log in or create a new + account below. +

+
+ Login + Register +
+
+{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html index 8548872..629df57 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -1,9 +1,12 @@ -

Login

- -
- - - -
- -Register +{% extends "base_public.html" %} {% block title %}Login{% endblock %} {% block +content %} +
+

Login

+
+ + + +
+ Register +
+{% endblock %} diff --git a/app/templates/play.html b/app/templates/play.html index 3e608b0..f4d27f1 100644 --- a/app/templates/play.html +++ b/app/templates/play.html @@ -1,56 +1,25 @@ - - - - - - Play - - - - - - -
-
- Chess - -
{{ current_user.username }}
-
- - -
-
-
-

Game settings

-

Choose a time setup

-
- - - - -
-

You play as

-
- - - -
-
- - -
-
-
-
+{% extends "base_app.html" %} {% set active_page = 'play' %} {% block title +%}Play{% endblock %} {% block content %} +
+
+

Play

+

Game settings

+

Choose a time setup

+
+ + + +
- - +

You play as

+
+ + + +
+
+ +
+
+
+{% endblock %} diff --git a/app/templates/register.html b/app/templates/register.html index 5f85a62..c35a0c8 100644 --- a/app/templates/register.html +++ b/app/templates/register.html @@ -1,9 +1,12 @@ -

Register

- -
- - - -
- -Back to Login +{% extends "base_public.html" %} {% block title %}Register{% endblock %} {% +block content %} +
+

Register

+
+ + + +
+ Back to Login +
+{% endblock %} From 60442b4335d4f5705a1fabe49fdf1fa978c967b1 Mon Sep 17 00:00:00 2001 From: tototomate123 Date: Thu, 26 Feb 2026 10:53:37 +0100 Subject: [PATCH 2/3] Move friends flows to server-rendered Flask/Jinja routes --- app/__init__.py | 4 +- app/routes/friends.py | 327 +++++++++++++++++++++++++++++++++++++ app/routes/main.py | 18 +- app/templates/friends.html | 195 +++++++++++++++++----- 4 files changed, 487 insertions(+), 57 deletions(-) create mode 100644 app/routes/friends.py diff --git a/app/__init__.py b/app/__init__.py index 32bd689..a20d23c 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -26,9 +26,11 @@ def create_app(): from .routes.auth import auth_bp from .routes.main import main_bp + from .routes.friends import friends_bp - app.register_blueprint(auth_bp) app.register_blueprint(main_bp) + app.register_blueprint(auth_bp) + app.register_blueprint(friends_bp) init_db(app) diff --git a/app/routes/friends.py b/app/routes/friends.py new file mode 100644 index 0000000..27e9858 --- /dev/null +++ b/app/routes/friends.py @@ -0,0 +1,327 @@ +from flask import Blueprint, flash, jsonify, redirect, request, url_for +from flask_login import current_user, login_required + +from app.db import get_db + + +friends_bp = Blueprint("friends", __name__) + + +def _get_friendship_row(user_a_id: int, user_b_id: int): + db = get_db() + return db.execute( + """ + SELECT requester_id, addressee_id, status + FROM friendships + WHERE (requester_id = ? AND addressee_id = ?) + OR (requester_id = ? AND addressee_id = ?) + LIMIT 1 + """, + (user_a_id, user_b_id, user_b_id, user_a_id), + ).fetchone() + + +def _send_friend_request_or_accept(addressee_id: int): + if addressee_id == current_user.id: + return {"error": "cannot send a request to yourself"}, 400 + + db = get_db() + user_exists = db.execute( + "SELECT id FROM users WHERE id = ?", + (addressee_id,), + ).fetchone() + if not user_exists: + return {"error": "user not found"}, 404 + + friendship = _get_friendship_row(current_user.id, addressee_id) + if friendship: + if friendship["status"] == "accepted": + return {"error": "already friends"}, 409 + if friendship["status"] == "blocked": + return {"error": "cannot send request"}, 403 + if friendship["status"] == "pending": + if friendship["addressee_id"] == current_user.id: + db.execute( + """ + UPDATE friendships + SET status = 'accepted' + WHERE requester_id = ? AND addressee_id = ? + """, + (addressee_id, current_user.id), + ) + db.commit() + return {"status": "accepted"}, 200 + return {"error": "request already sent"}, 409 + + db.execute( + """ + INSERT INTO friendships (requester_id, addressee_id, status) + VALUES (?, ?, 'pending') + """, + (current_user.id, addressee_id), + ) + db.commit() + return {"status": "pending"}, 201 + + +def _accept_friend_request(requester_id: int): + db = get_db() + updated = db.execute( + """ + UPDATE friendships + SET status = 'accepted' + WHERE requester_id = ? + AND addressee_id = ? + AND status = 'pending' + """, + (requester_id, current_user.id), + ).rowcount + + if updated == 0: + return {"error": "request not found"}, 404 + + db.commit() + return {"status": "accepted"}, 200 + + +def _decline_friend_request(requester_id: int): + db = get_db() + deleted = db.execute( + """ + DELETE FROM friendships + WHERE requester_id = ? + AND addressee_id = ? + AND status = 'pending' + """, + (requester_id, current_user.id), + ).rowcount + + if deleted == 0: + return {"error": "request not found"}, 404 + + db.commit() + return {"status": "declined"}, 200 + + +def _cancel_outgoing_friend_request(addressee_id: int): + db = get_db() + deleted = db.execute( + """ + DELETE FROM friendships + WHERE requester_id = ? + AND addressee_id = ? + AND status = 'pending' + """, + (current_user.id, addressee_id), + ).rowcount + + if deleted == 0: + return {"error": "request not found"}, 404 + + db.commit() + return {"status": "canceled"}, 200 + + +def _friends_page_data(search_query: str = ""): + db = get_db() + + friends = db.execute( + """ + SELECT DISTINCT u.id, u.username, + CASE + WHEN u.last_seen_at IS NOT NULL + AND u.last_seen_at >= datetime('now', '-35 seconds') + THEN 1 ELSE 0 + END AS is_online + FROM friendships f + JOIN users u + ON ( + (f.requester_id = ? AND f.addressee_id = u.id) + OR + (f.addressee_id = ? AND f.requester_id = u.id) + ) + WHERE f.status = 'accepted' + ORDER BY u.username COLLATE NOCASE ASC + """, + (current_user.id, current_user.id), + ).fetchall() + + incoming = db.execute( + """ + SELECT f.requester_id AS id, u.username + FROM friendships f + JOIN users u ON u.id = f.requester_id + WHERE f.addressee_id = ? + AND f.status = 'pending' + ORDER BY u.username COLLATE NOCASE ASC + """, + (current_user.id,), + ).fetchall() + + outgoing = db.execute( + """ + SELECT f.addressee_id AS id, u.username + FROM friendships f + JOIN users u ON u.id = f.addressee_id + WHERE f.requester_id = ? + AND f.status = 'pending' + ORDER BY u.username COLLATE NOCASE ASC + """, + (current_user.id,), + ).fetchall() + + search_results = [] + normalized_query = search_query.strip() + if len(normalized_query) >= 2: + like_query = f"%{normalized_query}%" + rows = db.execute( + """ + SELECT u.id, u.username + FROM users u + WHERE u.id != ? + AND u.username LIKE ? + ORDER BY u.username COLLATE NOCASE ASC + LIMIT 20 + """, + (current_user.id, like_query), + ).fetchall() + + for row in rows: + relation = "none" + friendship = _get_friendship_row(current_user.id, row["id"]) + if friendship: + if friendship["status"] == "accepted": + relation = "accepted" + elif friendship["status"] == "pending": + relation = ( + "incoming" + if friendship["addressee_id"] == current_user.id + else "outgoing" + ) + else: + relation = friendship["status"] + + search_results.append( + { + "id": row["id"], + "username": row["username"], + "relation": relation, + } + ) + + return { + "friends": friends, + "incoming_requests": incoming, + "outgoing_requests": outgoing, + "search_results": search_results, + "search_query": normalized_query, + } + + +@friends_bp.route("/friends/request", methods=["POST"]) +@login_required +def request_page_action(): + try: + addressee_id = int(request.form.get("addressee_id", "")) + except ValueError: + flash("Invalid user id", "error") + return redirect(url_for("main.friends", q=request.form.get("q", ""))) + + payload, status = _send_friend_request_or_accept(addressee_id) + if status in (200, 201): + flash("Friend request updated", "success") + else: + flash(payload["error"], "error") + + return redirect(url_for("main.friends", q=request.form.get("q", ""))) + + +@friends_bp.route("/friends/requests//accept", methods=["POST"]) +@login_required +def accept_page_action(requester_id: int): + payload, status = _accept_friend_request(requester_id) + if status == 200: + flash("Friend request accepted", "success") + else: + flash(payload["error"], "error") + + return redirect(url_for("main.friends", q=request.form.get("q", ""))) + + +@friends_bp.route("/friends/requests//decline", methods=["POST"]) +@login_required +def decline_page_action(requester_id: int): + payload, status = _decline_friend_request(requester_id) + if status == 200: + flash("Friend request declined", "success") + else: + flash(payload["error"], "error") + + return redirect(url_for("main.friends", q=request.form.get("q", ""))) + + +@friends_bp.route("/friends/requests//cancel", methods=["POST"]) +@login_required +def cancel_page_action(addressee_id: int): + payload, status = _cancel_outgoing_friend_request(addressee_id) + if status == 200: + flash("Outgoing request canceled", "success") + else: + flash(payload["error"], "error") + + return redirect(url_for("main.friends", q=request.form.get("q", ""))) + + +@friends_bp.route("/api/friends", methods=["GET"]) +@login_required +def list_friends(): + data = _friends_page_data("") + return jsonify({"friends": [dict(row) for row in data["friends"]]}) + + +@friends_bp.route("/api/friends/search", methods=["GET"]) +@login_required +def search_people(): + data = _friends_page_data(request.args.get("q", "")) + return jsonify({"results": data["search_results"]}) + + +@friends_bp.route("/api/friends/requests/incoming", methods=["GET"]) +@login_required +def incoming_friend_requests(): + data = _friends_page_data("") + return jsonify({"requests": [dict(row) for row in data["incoming_requests"]]}) + + +@friends_bp.route("/api/friends/requests/outgoing", methods=["GET"]) +@login_required +def outgoing_friend_requests(): + data = _friends_page_data("") + return jsonify({"requests": [dict(row) for row in data["outgoing_requests"]]}) + + +@friends_bp.route("/api/friends/requests", methods=["POST"]) +@login_required +def send_friend_request(): + payload = request.get_json(silent=True) or {} + addressee_id = payload.get("addressee_id") + + if not isinstance(addressee_id, int): + return jsonify({"error": "addressee_id must be an integer"}), 400 + + response_payload, status = _send_friend_request_or_accept(addressee_id) + return jsonify(response_payload), status + + +@friends_bp.route("/api/friends/requests//accept", methods=["POST"]) +@login_required +def accept_friend_request(requester_id: int): + payload, status = _accept_friend_request(requester_id) + return jsonify(payload), status + + +@friends_bp.route("/api/friends/requests//decline", methods=["POST"]) +@login_required +def decline_friend_request(requester_id: int): + payload, status = _decline_friend_request(requester_id) + return jsonify(payload), status diff --git a/app/routes/main.py b/app/routes/main.py index 2675480..f4a832f 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -1,16 +1,10 @@ -from flask import Blueprint, render_template, redirect, url_for +from flask import Blueprint, render_template, redirect, url_for, request from flask_login import login_required, current_user +from app.routes.friends import _friends_page_data main_bp = Blueprint("main", __name__) -""" def login_required(view): - @wraps(view) - def wrapped_view(**kwargs): - if "user_id" not in session: - return redirect(url_for("auth.login")) - return view(**kwargs) - return wrapped_view - """ + @main_bp.route("/", methods=["GET", "POST"]) def index(): @@ -18,10 +12,10 @@ def index(): return redirect(url_for("main.home")) return render_template("index.html") + @main_bp.route("/home", methods=["GET", "POST"]) @login_required def home(): - print(f"Current user: {current_user.username}") return render_template("home.html") @@ -31,7 +25,9 @@ def play(): return render_template("play.html") +#todo: decide if this should get moved to the friends.py file @main_bp.route("/friends", methods=["GET"]) @login_required def friends(): - return render_template("friends.html") + data = _friends_page_data(request.args.get("q", "")) + return render_template("friends.html", **data) \ No newline at end of file diff --git a/app/templates/friends.html b/app/templates/friends.html index 15d359b..95e80a9 100644 --- a/app/templates/friends.html +++ b/app/templates/friends.html @@ -1,48 +1,153 @@ - - - - - - Friends - - - Friends + +
+

Find people

+
+ - - - -
-
- Chess - -
{{ current_user.username }}
-
+ + -

Friends

- -
-
-

Online now

-
    -
  • name1
  • -
  • friend2
  • -
  • bro has a lot of friends
  • -
-
- -
-

Incoming invites

-

No pending invitations -

-
-
- + {% if search_query %} +
+ {% if search_results %} {% for person in search_results %} +
+
+
{{ person.username }}
+
{{ person.relation|capitalize }}
+
+
+ {% if person.relation == 'none' %} +
+ + + +
+ {% elif person.relation == 'incoming' %} +
+ + +
+
+ + +
+ {% elif person.relation == 'outgoing' %} +
+ + +
+ {% else %} + No action available + {% endif %} +
- - + {% endfor %} {% else %} +

No users found for "{{ search_query }}".

+ {% endif %} +
+ {% endif %} +
+ +
+ +

Your friends

+ {% if friends %} +
+ {% for friend in friends %} +
+
+
{{ friend.username }}
+
+ {{ 'Online' if friend.is_online else 'Offline' }} +
+
+ + +
+ {% endfor %} +
+ {% else %} +

No friends yet.

+ {% endif %} +
+ +
+

Requests

+ +

Incoming

+ {% if incoming_requests %} +
+ {% for req in incoming_requests %} +
+
+
{{ req.username }}
+
Pending
+
+
+
+ +
+
+ +
+
+
+ {% endfor %} +
+ {% else %} +

No incoming requests.

+ {% endif %} + +

Outgoing

+ {% if outgoing_requests %} +
+ {% for req in outgoing_requests %} +
+
+
{{ req.username }}
+
Pending
+
+
+ +
+
+ {% endfor %} +
+ {% else %} +

No outgoing requests.

+ {% endif %} +
+{% endblock %} From 5eeef54e46e5604e8b301850e69d648e68cd4777 Mon Sep 17 00:00:00 2001 From: tototomate123 Date: Thu, 26 Feb 2026 10:53:56 +0100 Subject: [PATCH 3/3] Add global presence heartbeat and online tracking --- app/__init__.py | 2 ++ app/db.py | 11 ++++++++++- app/routes/presence.py | 24 ++++++++++++++++++++++++ app/static/js/presence.js | 18 ++++++++++++++++++ app/templates/base_app.html | 1 + app/templates/base_public.html | 1 + 6 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 app/routes/presence.py create mode 100644 app/static/js/presence.js diff --git a/app/__init__.py b/app/__init__.py index a20d23c..3961366 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -27,10 +27,12 @@ def create_app(): from .routes.auth import auth_bp from .routes.main import main_bp from .routes.friends import friends_bp + from .routes.presence import presence_bp app.register_blueprint(main_bp) app.register_blueprint(auth_bp) app.register_blueprint(friends_bp) + app.register_blueprint(presence_bp) init_db(app) diff --git a/app/db.py b/app/db.py index d0f9ae3..b14ff1a 100644 --- a/app/db.py +++ b/app/db.py @@ -66,4 +66,13 @@ def init_db(app): ); """ ) - db.commit() \ No newline at end of file + + # Lightweight migration support for existing databases. + user_columns = { + row["name"] + for row in db.execute("PRAGMA table_info(users)").fetchall() + } + if "last_seen_at" not in user_columns: + db.execute("ALTER TABLE users ADD COLUMN last_seen_at TIMESTAMP") + + db.commit() diff --git a/app/routes/presence.py b/app/routes/presence.py new file mode 100644 index 0000000..26f7210 --- /dev/null +++ b/app/routes/presence.py @@ -0,0 +1,24 @@ +from flask import Blueprint, jsonify +from flask_login import login_required, current_user +from app.db import get_db + +presence_bp = Blueprint("presence", __name__) + +def _mark_current_user_online() -> None: + db = get_db() + db.execute( + "UPDATE users SET last_seen_at = CURRENT_TIMESTAMP WHERE id = ?", + (current_user.id,), + ) + db.commit() + + +@presence_bp.route("/api/presence/ping", methods=["POST"]) +@login_required +def presence_ping(): + if not current_user.is_authenticated: # should not happen due to @login_required, but just in case + return ("", 204) + + _mark_current_user_online() + return jsonify({"status": "ok"}) + \ No newline at end of file diff --git a/app/static/js/presence.js b/app/static/js/presence.js new file mode 100644 index 0000000..e036a69 --- /dev/null +++ b/app/static/js/presence.js @@ -0,0 +1,18 @@ +(function startPresenceHeartbeat() { + const ping = function () { + fetch("/api/presence/ping", { + method: "POST", + credentials: "same-origin", + keepalive: true, + headers: { + "Content-Type": "application/json", + }, + body: "{}", + }).catch(function () { + // ignore network issues; next interval will retry + }); + }; + + ping(); + setInterval(ping, 15000); +})(); diff --git a/app/templates/base_app.html b/app/templates/base_app.html index 4e26ad0..6dae00f 100644 --- a/app/templates/base_app.html +++ b/app/templates/base_app.html @@ -49,5 +49,6 @@ {% endif %} {% endwith %} {% block content %}{% endblock %}
+ diff --git a/app/templates/base_public.html b/app/templates/base_public.html index dd16f9b..9d6a689 100644 --- a/app/templates/base_public.html +++ b/app/templates/base_public.html @@ -20,5 +20,6 @@ {% endif %} {% endwith %} {% block content %}{% endblock %} +