From 54b8cc991e0bb48c202599763e9473198d6014e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Colangelo?= Date: Thu, 19 Mar 2026 16:42:37 +0100 Subject: [PATCH] WIP: claude works hard --- Dockerfile | 1 + analyzer/dope_card.py | 143 ++++ analyzer/grouper.py | 34 +- app.py | 26 +- babel.cfg | 3 + blueprints/analyses.py | 121 +++- blueprints/auth.py | 55 +- blueprints/equipment.py | 13 +- blueprints/sessions.py | 167 ++++- entrypoint.sh | 2 + extensions.py | 4 +- ..._prs_stages_and_drop_competition_fields.py | 36 + ...b3d_session_type_and_analysis_grouping_.py | 49 ++ models.py | 5 + requirements.txt | 1 + templates/analyses/detail.html | 48 +- templates/auth/login.html | 20 +- templates/auth/profile.html | 34 +- templates/auth/public_profile.html | 14 +- templates/auth/register.html | 16 +- templates/base.html | 118 +++- templates/dashboard/index.html | 36 +- templates/equipment/form.html | 32 +- templates/equipment/list.html | 18 +- templates/index.html | 57 +- templates/results.html | 40 +- templates/sessions/annotate_photo.html | 46 +- templates/sessions/detail.html | 469 +++++++++++-- templates/sessions/form.html | 162 ++++- templates/sessions/list.html | 34 +- templates/sessions/type_picker.html | 40 ++ templates/upload.html | 6 +- translations/de/LC_MESSAGES/messages.mo | Bin 0 -> 12116 bytes translations/de/LC_MESSAGES/messages.po | 653 ++++++++++++++++++ translations/en/LC_MESSAGES/messages.mo | Bin 0 -> 445 bytes translations/en/LC_MESSAGES/messages.po | 653 ++++++++++++++++++ translations/fr/LC_MESSAGES/messages.mo | Bin 0 -> 12161 bytes translations/fr/LC_MESSAGES/messages.po | 653 ++++++++++++++++++ 38 files changed, 3492 insertions(+), 317 deletions(-) create mode 100644 analyzer/dope_card.py create mode 100644 babel.cfg create mode 100644 migrations/versions/bf96ceb7f076_prs_stages_and_drop_competition_fields.py create mode 100644 migrations/versions/edf627601b3d_session_type_and_analysis_grouping_.py create mode 100644 templates/sessions/type_picker.html create mode 100644 translations/de/LC_MESSAGES/messages.mo create mode 100644 translations/de/LC_MESSAGES/messages.po create mode 100644 translations/en/LC_MESSAGES/messages.mo create mode 100644 translations/en/LC_MESSAGES/messages.po create mode 100644 translations/fr/LC_MESSAGES/messages.mo create mode 100644 translations/fr/LC_MESSAGES/messages.po diff --git a/Dockerfile b/Dockerfile index 1725c2b..a8b8140 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,7 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . +RUN pybabel compile -d translations 2>/dev/null || true RUN chmod +x entrypoint.sh ENV FLASK_APP=app diff --git a/analyzer/dope_card.py b/analyzer/dope_card.py new file mode 100644 index 0000000..a23e555 --- /dev/null +++ b/analyzer/dope_card.py @@ -0,0 +1,143 @@ +"""Generate a printable PRS dope card as PDF (A4 portrait).""" + +from fpdf import FPDF + + +# Column widths in mm +_W = { + "num": 10, + "name": 28, + "dist": 20, + "time": 18, + "pos": 26, + "dope_e": 30, + "dope_w": 30, + "hits": 24, + "notes": 0, # fills remaining width +} +_ROW_H = 8 +_HEAD_H = 9 +_DARK = (26, 26, 46) # #1a1a2e +_LIGHT = (240, 244, 255) # #f0f4ff +_GRID = (200, 210, 230) + + +def _notes_w(epw: float) -> float: + fixed = sum(v for k, v in _W.items() if k != "notes") + return max(0, epw - fixed) + + +def generate_dope_card(session, stages: list) -> bytes: + pdf = FPDF(orientation="P", unit="mm", format="A4") + pdf.set_auto_page_break(auto=True, margin=15) + pdf.add_page() + pdf.set_margins(10, 12, 10) + + epw = pdf.w - pdf.l_margin - pdf.r_margin + nw = _notes_w(epw) + + # ── Header ────────────────────────────────────────────────────────────── + pdf.set_font("Helvetica", "B", 18) + pdf.set_text_color(*_DARK) + pdf.cell(0, 10, "FICHE DE TIR — PRS", new_x="LMARGIN", new_y="NEXT", align="C") + + pdf.set_font("Helvetica", "", 9) + pdf.set_text_color(80, 80, 80) + parts = [session.session_date.strftime("%d/%m/%Y")] + if session.location_name: + parts.append(session.location_name) + if session.rifle: + parts.append(session.rifle.name) + if session.rifle.caliber: + parts.append(session.rifle.caliber) + if session.ammo_brand: + parts.append(session.ammo_brand) + if session.ammo_weight_gr: + parts.append(f"{session.ammo_weight_gr} gr") + pdf.cell(0, 5, " | ".join(parts), new_x="LMARGIN", new_y="NEXT", align="C") + pdf.ln(4) + + # ── Column headers ─────────────────────────────────────────────────────── + pdf.set_fill_color(*_DARK) + pdf.set_text_color(255, 255, 255) + pdf.set_font("Helvetica", "B", 8) + + headers = [ + ("N°", _W["num"]), + ("Nom", _W["name"]), + ("Dist.(m)", _W["dist"]), + ("Temps(s)", _W["time"]), + ("Position", _W["pos"]), + ("Dope Élév.", _W["dope_e"]), + ("Dope Dérive", _W["dope_w"]), + ("Coups/Poss.", _W["hits"]), + ("Notes", nw), + ] + for label, w in headers: + pdf.cell(w, _HEAD_H, label, border=0, fill=True, align="C") + pdf.ln() + + # ── Stage rows ─────────────────────────────────────────────────────────── + pdf.set_text_color(30, 30, 30) + pdf.set_font("Helvetica", "", 9) + + for i, st in enumerate(stages): + fill = i % 2 == 0 + pdf.set_fill_color(*(_LIGHT if fill else (255, 255, 255))) + pdf.set_draw_color(*_GRID) + + hits_str = "" + if st.get("hits") is not None: + hits_str = str(st["hits"]) + if st.get("possible"): + hits_str += f"/{st['possible']}" + elif st.get("possible"): + hits_str = f"—/{st['possible']}" + + row = [ + (str(st.get("num", i + 1)), _W["num"], "C"), + (st.get("name") or "", _W["name"], "L"), + (str(st.get("distance_m") or ""), _W["dist"], "C"), + (str(st.get("time_s") or ""), _W["time"], "C"), + (_pos_label(st.get("position", "")), _W["pos"], "L"), + (st.get("dope_elevation") or "", _W["dope_e"], "C"), + (st.get("dope_windage") or "", _W["dope_w"], "C"), + (hits_str, _W["hits"], "C"), + (st.get("notes") or "", nw, "L"), + ] + for val, w, align in row: + pdf.cell(w, _ROW_H, val, border="B", fill=fill, align=align) + pdf.ln() + + # ── Blank rows for hand-written stages ────────────────────────────────── + spare = max(0, 10 - len(stages)) + for i in range(min(spare, 5)): + fill = (len(stages) + i) % 2 == 0 + pdf.set_fill_color(*(_LIGHT if fill else (255, 255, 255))) + for _, w, _ in row: # reuse last row widths + pdf.cell(w, _ROW_H, "", border="B", fill=fill) + pdf.ln() + + # ── Footer ─────────────────────────────────────────────────────────────── + pdf.ln(4) + pdf.set_font("Helvetica", "I", 7) + pdf.set_text_color(160, 160, 160) + pdf.cell(0, 5, "The Shooter's Network — fiche générée automatiquement", + new_x="LMARGIN", new_y="NEXT", align="C") + + return bytes(pdf.output()) + + +_POSITION_LABELS = { + "prone": "Couché", + "standing": "Debout", + "kneeling": "Agenouillé", + "sitting": "Assis", + "barricade": "Barricade", + "rooftop": "Toit", + "unknown": "Variable", +} + + +def _pos_label(slug: str) -> str: + return _POSITION_LABELS.get(slug, slug.replace("_", " ").title() if slug else "") diff --git a/analyzer/grouper.py b/analyzer/grouper.py index 4bb8798..e51c9ca 100644 --- a/analyzer/grouper.py +++ b/analyzer/grouper.py @@ -4,7 +4,15 @@ import pandas as pd OUTLIER_FACTOR = 5 -def detect_groups(df: pd.DataFrame) -> list: +def detect_groups(df: pd.DataFrame, outlier_factor: float = OUTLIER_FACTOR, + manual_splits: list | None = None) -> list: + """Split shots into groups. + + Auto-detection: consecutive shots with a time gap > median_gap * outlier_factor + start a new group. manual_splits is an optional list of shot indices (0-based + positions in df) where a split should be forced, regardless of timing. + Both mechanisms are merged and deduplicated. + """ if len(df) <= 1: return [df] @@ -16,23 +24,25 @@ def detect_groups(df: pd.DataFrame) -> list: median_gap = diffs.median() - if median_gap == timedelta(0): - return [df] + # Auto-detect splits based on time gaps + auto_splits: set[int] = set() + if median_gap != timedelta(0): + threshold = outlier_factor * median_gap + for idx, gap in diffs.items(): + if gap > threshold: + pos = df.index.get_loc(idx) + auto_splits.add(pos) - threshold = OUTLIER_FACTOR * median_gap + # Merge with manual splits (filter to valid range) + extra = set(manual_splits) if manual_splits else set() + all_splits = sorted(auto_splits | extra) - split_positions = [] - for idx, gap in diffs.items(): - if gap > threshold: - pos = df.index.get_loc(idx) - split_positions.append(pos) - - if not split_positions: + if not all_splits: return [df] groups = [] prev = 0 - for pos in split_positions: + for pos in all_splits: group = df.iloc[prev:pos] if len(group) > 0: groups.append(group.reset_index(drop=True)) diff --git a/app.py b/app.py index 9e6017a..9c0a838 100644 --- a/app.py +++ b/app.py @@ -1,12 +1,21 @@ import base64 import io -from flask import Flask, request, render_template +from flask import Flask, redirect, request, render_template, session as flask_session from flask_login import current_user from sqlalchemy import select from config import Config -from extensions import db, jwt, login_manager, migrate, oauth +from extensions import babel, db, jwt, login_manager, migrate, oauth + +SUPPORTED_LANGS = ["fr", "en", "de"] + + +def _select_locale(): + lang = flask_session.get("lang") + if lang in SUPPORTED_LANGS: + return lang + return request.accept_languages.best_match(SUPPORTED_LANGS) or "en" def create_app(config_class=Config): @@ -17,6 +26,13 @@ def create_app(config_class=Config): migrate.init_app(app, db) login_manager.init_app(app) jwt.init_app(app) + babel.init_app(app, locale_selector=_select_locale) + + @app.context_processor + def inject_locale(): + from flask_babel import get_locale + return {"current_lang": str(get_locale())} + @jwt.unauthorized_loader def unauthorized_callback(reason): @@ -96,6 +112,12 @@ def create_app(config_class=Config): public_sessions=public_sessions, equipment=equipment) + @app.route("/set-lang/") + def set_lang(lang: str): + if lang in SUPPORTED_LANGS: + flask_session["lang"] = lang + return redirect(request.referrer or "/") + @app.route("/") def index(): from models import ShootingSession diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 0000000..f0234b3 --- /dev/null +++ b/babel.cfg @@ -0,0 +1,3 @@ +[python: **.py] +[jinja2: **/templates/**.html] +extensions=jinja2.ext.autoescape,jinja2.ext.with_ diff --git a/blueprints/analyses.py b/blueprints/analyses.py index 9c0d89b..ca601b5 100644 --- a/blueprints/analyses.py +++ b/blueprints/analyses.py @@ -1,10 +1,12 @@ import io +import json from pathlib import Path from flask import ( - Blueprint, abort, current_app, flash, redirect, + Blueprint, abort, current_app, flash, redirect, request, render_template, send_from_directory, url_for, ) +from flask_babel import _ from flask_login import current_user, login_required from extensions import db @@ -83,7 +85,122 @@ def delete(analysis_id: int): db.session.delete(a) db.session.commit() - flash("Analysis deleted.", "success") + flash(_("Analysis deleted."), "success") + return redirect(back) + + +@analyses_bp.route("//rename", methods=["POST"]) +@login_required +def rename(analysis_id: int): + a = db.session.get(Analysis, analysis_id) + if a is None: + abort(404) + if a.user_id != current_user.id: + abort(403) + title = request.form.get("title", "").strip() + if title: + a.title = title[:255] + db.session.commit() + flash(_("Title updated."), "success") + back = (url_for("sessions.detail", session_id=a.session_id) + if a.session_id else url_for("analyses.detail", analysis_id=a.id)) + return redirect(back) + + +@analyses_bp.route("//regroup", methods=["POST"]) +@login_required +def regroup(analysis_id: int): + a = db.session.get(Analysis, analysis_id) + if a is None: + abort(404) + if a.user_id != current_user.id: + abort(403) + + try: + outlier_factor = float(request.form.get("outlier_factor", 5)) + outlier_factor = max(1.0, min(20.0, outlier_factor)) + except (TypeError, ValueError): + outlier_factor = 5.0 + + manual_splits_raw = request.form.get("manual_splits", "").strip() + manual_splits = None + if manual_splits_raw: + try: + parsed = json.loads(manual_splits_raw) + if isinstance(parsed, list): + manual_splits = [int(x) for x in parsed] + except (json.JSONDecodeError, ValueError, TypeError): + pass + + storage_root = current_app.config["STORAGE_ROOT"] + csv_path = Path(storage_root) / a.csv_path + if not csv_path.exists(): + abort(410) + + from analyzer.parser import parse_csv + from analyzer.grouper import detect_groups + from analyzer.stats import compute_overall_stats, compute_group_stats + from analyzer.charts import render_group_charts, render_overview_chart + from analyzer.pdf_report import generate_pdf + from storage import _to_python + + csv_bytes = csv_path.read_bytes() + df = parse_csv(io.BytesIO(csv_bytes)) + groups = detect_groups(df, outlier_factor=outlier_factor, manual_splits=manual_splits) + overall = compute_overall_stats(df) + group_stats = compute_group_stats(groups) + + # Preserve existing notes + old_stats = a.group_stats or [] + for i, gs in enumerate(group_stats): + if i < len(old_stats) and old_stats[i].get("note"): + gs["note"] = old_stats[i]["note"] + + charts = render_group_charts(groups, y_min=overall["min_speed"], y_max=overall["max_speed"]) + overview_chart = render_overview_chart(group_stats) + pdf_bytes = generate_pdf(overall, group_stats, charts, overview_chart) + + if a.pdf_path: + pdf_path = Path(storage_root) / a.pdf_path + try: + pdf_path.write_bytes(pdf_bytes) + except Exception: + pass + + a.grouping_outlier_factor = outlier_factor + a.grouping_manual_splits = manual_splits + a.group_stats = _to_python(group_stats) + a.overall_stats = _to_python(overall) + a.shot_count = int(overall.get("count", 0)) + a.group_count = len(group_stats) + db.session.commit() + + flash(_("Regrouped."), "success") + back = (url_for("sessions.detail", session_id=a.session_id) + if a.session_id else url_for("analyses.detail", analysis_id=a.id)) + return redirect(back) + + +@analyses_bp.route("//groups//note", methods=["POST"]) +@login_required +def save_group_note(analysis_id: int, group_index: int): + a = db.session.get(Analysis, analysis_id) + if a is None: + abort(404) + if a.user_id != current_user.id: + abort(403) + + note = request.form.get("note", "").strip() + group_stats = list(a.group_stats or []) + if 0 <= group_index < len(group_stats): + group_stats[group_index] = dict(group_stats[group_index]) + group_stats[group_index]["note"] = note or None + a.group_stats = group_stats + db.session.commit() + flash(_("Note saved."), "success") + + back = (url_for("sessions.detail", session_id=a.session_id) + if a.session_id else url_for("analyses.detail", analysis_id=a.id)) return redirect(back) diff --git a/blueprints/auth.py b/blueprints/auth.py index 927ac55..2b48652 100644 --- a/blueprints/auth.py +++ b/blueprints/auth.py @@ -14,6 +14,7 @@ from flask import ( send_from_directory, url_for, ) +from flask_babel import _ from flask_login import current_user, login_required, login_user, logout_user from sqlalchemy.exc import IntegrityError @@ -96,11 +97,11 @@ def login(): ) if user is None or not user.check_password(password): - flash("Invalid email or password.", "error") + flash(_("Invalid email or password."), "error") return render_template("auth/login.html", prefill_email=email) if current_app.config["EMAIL_CONFIRMATION_REQUIRED"] and not user.email_confirmed: - flash("Please confirm your email address before logging in.", "error") + flash(_("Please confirm your email address before logging in."), "error") return render_template("auth/login.html", prefill_email=email, show_resend=True, resend_email=email) @@ -125,20 +126,22 @@ def register(): # Validate error = None if not email or "@" not in email or "." not in email.split("@")[-1]: - error = "Please enter a valid email address." + error = _("Please enter a valid email address.") elif len(password) < 8: - error = "Password must be at least 8 characters." + error = _("Password must be at least 8 characters.") elif password != confirm: - error = "Passwords do not match." + error = _("Passwords do not match.") else: existing = db.session.scalar(db.select(User).filter_by(email=email)) if existing: if existing.provider == "local": - error = "An account with this email already exists." + error = _("An account with this email already exists.") else: - error = ( - f"This email is linked to a {existing.provider.title()} account. " - f"Please log in with {existing.provider.title()}." + error = _( + "This email is linked to a %(provider)s account. " + "Please log in with %(provider2)s.", + provider=existing.provider.title(), + provider2=existing.provider.title(), ) if error: @@ -165,7 +168,7 @@ def register(): return render_template("auth/confirm_pending.html", email=email) login_user(user) - flash("Account created! Welcome.", "success") + flash(_("Account created! Welcome."), "success") return redirect(url_for("dashboard.index")) return render_template("auth/register.html") @@ -177,14 +180,14 @@ def confirm_email(token: str): db.select(User).filter_by(email_confirm_token=token) ) if user is None: - flash("Invalid or expired confirmation link.", "error") + flash(_("Invalid or expired confirmation link."), "error") return redirect(url_for("auth.login")) user.email_confirmed = True user.email_confirm_token = None db.session.commit() login_user(user) - flash("Email confirmed! Welcome.", "success") + flash(_("Email confirmed! Welcome."), "success") return redirect(url_for("dashboard.index")) @@ -200,7 +203,7 @@ def resend_confirmation(): db.session.commit() _dispatch_confirmation(user) # Vague message to prevent email enumeration - flash("If that account exists and is unconfirmed, a new link has been sent.", "message") + flash(_("If that account exists and is unconfirmed, a new link has been sent."), "message") return redirect(url_for("auth.login")) @@ -220,13 +223,13 @@ def callback_google(): try: token = oauth.google.authorize_access_token() except Exception: - flash("Google login failed. Please try again.", "error") + flash(_("Google login failed. Please try again."), "error") return redirect(url_for("auth.login")) info = token.get("userinfo") or {} email = info.get("email") if not email: - flash("Could not retrieve your email from Google.", "error") + flash(_("Could not retrieve your email from Google."), "error") return redirect(url_for("auth.login")) user = _upsert_oauth_user( @@ -237,7 +240,7 @@ def callback_google(): avatar_url=info.get("picture"), ) if user is None: - flash("This email is already registered with a different login method.", "error") + flash(_("This email is already registered with a different login method."), "error") return redirect(url_for("auth.login")) login_user(user) @@ -260,7 +263,7 @@ def callback_github(): try: token = oauth.github.authorize_access_token() except Exception: - flash("GitHub login failed. Please try again.", "error") + flash(_("GitHub login failed. Please try again."), "error") return redirect(url_for("auth.login")) resp = oauth.github.get("user", token=token) @@ -276,7 +279,7 @@ def callback_github(): ) if not email: - flash("Could not retrieve a verified email from GitHub.", "error") + flash(_("Could not retrieve a verified email from GitHub."), "error") return redirect(url_for("auth.login")) user = _upsert_oauth_user( @@ -287,7 +290,7 @@ def callback_github(): avatar_url=info.get("avatar_url"), ) if user is None: - flash("This email is already registered with a different login method.", "error") + flash(_("This email is already registered with a different login method."), "error") return redirect(url_for("auth.login")) login_user(user) @@ -307,7 +310,7 @@ def profile(): if action == "update_profile": display_name = request.form.get("display_name", "").strip() if not display_name: - flash("Display name cannot be empty.", "error") + flash(_("Display name cannot be empty."), "error") else: current_user.display_name = display_name current_user.bio = request.form.get("bio", "").strip() or None @@ -325,25 +328,25 @@ def profile(): db.session.rollback() return render_template("auth/profile.html") db.session.commit() - flash("Profile updated.", "success") + flash(_("Profile updated."), "success") elif action == "change_password": if current_user.provider != "local": - flash("Password change is only available for local accounts.", "error") + flash(_("Password change is only available for local accounts."), "error") else: current_pw = request.form.get("current_password", "") new_pw = request.form.get("new_password", "") confirm_pw = request.form.get("confirm_password", "") if not current_user.check_password(current_pw): - flash("Current password is incorrect.", "error") + flash(_("Current password is incorrect."), "error") elif len(new_pw) < 8: - flash("New password must be at least 8 characters.", "error") + flash(_("New password must be at least 8 characters."), "error") elif new_pw != confirm_pw: - flash("Passwords do not match.", "error") + flash(_("Passwords do not match."), "error") else: current_user.set_password(new_pw) db.session.commit() - flash("Password changed.", "success") + flash(_("Password changed."), "success") return redirect(url_for("auth.profile")) diff --git a/blueprints/equipment.py b/blueprints/equipment.py index b942041..508823e 100644 --- a/blueprints/equipment.py +++ b/blueprints/equipment.py @@ -5,6 +5,7 @@ from flask import ( request, send_from_directory, url_for, ) from flask import Blueprint +from flask_babel import _ from flask_login import current_user, login_required from sqlalchemy import select @@ -41,9 +42,9 @@ def _apply_form(item: EquipmentItem) -> str | None: name = request.form.get("name", "").strip() category = request.form.get("category", "").strip() if not name: - return "Name is required." + return _("Name is required.") if category not in CATEGORY_KEYS: - return "Invalid category." + return _("Invalid category.") item.name = name item.category = category item.brand = request.form.get("brand", "").strip() or None @@ -93,7 +94,7 @@ def new(): db.session.flush() _handle_photo(item, is_new=True) db.session.commit() - flash(f"'{item.name}' added.", "success") + flash(_("'%(name)s' added.", name=item.name), "success") return redirect(url_for("equipment.detail", item_id=item.id)) return render_template("equipment/form.html", item=None, categories=CATEGORIES) @@ -117,7 +118,7 @@ def edit(item_id: int): categories=CATEGORIES, prefill=request.form) _handle_photo(item, is_new=False) db.session.commit() - flash(f"'{item.name}' updated.", "success") + flash(_("'%(name)s' updated.", name=item.name), "success") return redirect(url_for("equipment.detail", item_id=item.id)) return render_template("equipment/form.html", item=item, categories=CATEGORIES) @@ -131,7 +132,7 @@ def delete(item_id: int): _remove_photo_file(item.photo_path) db.session.delete(item) db.session.commit() - flash(f"'{name}' deleted.", "success") + flash(_("'%(name)s' deleted.", name=name), "success") return redirect(url_for("equipment.index")) @@ -140,7 +141,7 @@ def delete(item_id: int): def rotate_photo_view(item_id: int): item = _own_item(item_id) if not item.photo_path: - flash("No photo to rotate.", "error") + flash(_("No photo to rotate."), "error") return redirect(url_for("equipment.detail", item_id=item_id)) try: degrees = int(request.form.get("degrees", 0)) diff --git a/blueprints/sessions.py b/blueprints/sessions.py index 80a90b7..9ca4cac 100644 --- a/blueprints/sessions.py +++ b/blueprints/sessions.py @@ -6,6 +6,7 @@ from flask import ( Blueprint, abort, current_app, flash, jsonify, redirect, render_template, request, send_from_directory, url_for, ) +from flask_babel import _ from flask_login import current_user, login_required from sqlalchemy import select @@ -15,6 +16,42 @@ from storage import rotate_photo, save_session_photo sessions_bp = Blueprint("sessions", __name__, url_prefix="/sessions") +# (slug, display name, short description) +SESSION_TYPES = [ + ("long_range", "Long Range Practice", "Long range precision shooting (100m+)"), + ("prs", "PRS", "Precision Rifle Series — training & competition"), + ("pistol_25m", "25m Pistol", "25m precision pistol shooting"), +] + +LONG_RANGE_POSITIONS = [ + ("", "— select —"), + ("prone", "Prone"), + ("bench", "Bench rest"), + ("standing", "Standing"), + ("kneeling", "Kneeling"), +] + +PISTOL_25M_POSITIONS = [ + ("", "— select —"), + ("debout", "Standing"), + ("debout_appui", "Standing with support"), + ("assis", "Sitting"), + ("assis_appui", "Sitting with support"), +] + +PRS_STAGE_POSITIONS = [ + ("prone", "Prone"), + ("standing", "Standing"), + ("kneeling", "Kneeling"), + ("sitting", "Sitting"), + ("barricade", "Barricade"), + ("rooftop", "Rooftop"), + ("unknown", "Variable"), +] + +# Kept as reference but not used in UI +_FIXED_DISTANCES = {"pistol_25m": 25} + WEATHER_CONDITIONS = [ ("", "— select —"), ("sunny", "Sunny"), @@ -80,6 +117,8 @@ def _apply_form(s: ShootingSession) -> str | None: s.ammo_weight_gr = _float_or_none(request.form.get("ammo_weight_gr")) s.ammo_lot = request.form.get("ammo_lot", "").strip() or None s.notes = request.form.get("notes", "").strip() or None + s.session_type = request.form.get("session_type") or None + s.shooting_position = request.form.get("shooting_position") or None return None @@ -134,14 +173,28 @@ def new(): return render_template("sessions/form.html", session=None, rifles=_user_rifles(), scopes=_user_scopes(), weather_conditions=WEATHER_CONDITIONS, + session_types=SESSION_TYPES, + long_range_positions=LONG_RANGE_POSITIONS, + pistol_25m_positions=PISTOL_25M_POSITIONS, prefill=request.form) db.session.commit() - flash("Session created.", "success") + flash(_("Session saved."), "success") return redirect(url_for("sessions.detail", session_id=s.id)) - return render_template("sessions/form.html", session=None, - rifles=_user_rifles(), scopes=_user_scopes(), - weather_conditions=WEATHER_CONDITIONS, - today=date.today().isoformat()) + + selected_type = request.args.get("type") + if selected_type: + prefill_distance = _FIXED_DISTANCES.get(selected_type) + return render_template("sessions/form.html", session=None, + rifles=_user_rifles(), scopes=_user_scopes(), + weather_conditions=WEATHER_CONDITIONS, + session_types=SESSION_TYPES, + long_range_positions=LONG_RANGE_POSITIONS, + pistol_25m_positions=PISTOL_25M_POSITIONS, + selected_type=selected_type, + prefill_distance=prefill_distance, + today=date.today().isoformat()) + # Step 1: show type picker + return render_template("sessions/type_picker.html", session_types=SESSION_TYPES) @sessions_bp.route("/") @@ -157,8 +210,43 @@ def detail(session_id: int): .where(Analysis.session_id == session_id) .order_by(Analysis.created_at) ).all() + + from analyzer.parser import parse_csv + from analyzer.grouper import detect_groups, OUTLIER_FACTOR + from analyzer.stats import compute_overall_stats, compute_group_stats + from analyzer.charts import render_group_charts, render_overview_chart + + storage_root = current_app.config["STORAGE_ROOT"] + analyses_display = [] + for a in analyses: + csv_path = Path(storage_root) / a.csv_path + if csv_path.exists(): + try: + df = parse_csv(io.BytesIO(csv_path.read_bytes())) + factor = a.grouping_outlier_factor if a.grouping_outlier_factor is not None else OUTLIER_FACTOR + splits = a.grouping_manual_splits or None + groups = detect_groups(df, outlier_factor=factor, manual_splits=splits) + overall = compute_overall_stats(df) + group_stats = compute_group_stats(groups) + # Merge stored notes into freshly computed stats + stored = a.group_stats or [] + for i, gs in enumerate(group_stats): + if i < len(stored) and stored[i].get("note"): + gs["note"] = stored[i]["note"] + charts = render_group_charts(groups, + y_min=overall["min_speed"], + y_max=overall["max_speed"]) + overview_chart = render_overview_chart(group_stats) + groups_display = list(zip(group_stats, charts)) + analyses_display.append((a, groups_display, overview_chart)) + except Exception: + analyses_display.append((a, None, None)) + else: + analyses_display.append((a, None, None)) + return render_template("sessions/detail.html", session=s, - analyses=analyses, is_owner=is_owner) + analyses=analyses, analyses_display=analyses_display, + is_owner=is_owner) @sessions_bp.route("//edit", methods=["GET", "POST"]) @@ -172,15 +260,21 @@ def edit(session_id: int): return render_template("sessions/form.html", session=s, rifles=_user_rifles(), scopes=_user_scopes(), weather_conditions=WEATHER_CONDITIONS, + session_types=SESSION_TYPES, + long_range_positions=LONG_RANGE_POSITIONS, + pistol_25m_positions=PISTOL_25M_POSITIONS, prefill=request.form) for analysis in s.analyses: analysis.is_public = s.is_public db.session.commit() - flash("Session updated.", "success") + flash(_("Session saved."), "success") return redirect(url_for("sessions.detail", session_id=s.id)) return render_template("sessions/form.html", session=s, rifles=_user_rifles(), scopes=_user_scopes(), - weather_conditions=WEATHER_CONDITIONS) + weather_conditions=WEATHER_CONDITIONS, + session_types=SESSION_TYPES, + long_range_positions=LONG_RANGE_POSITIONS, + pistol_25m_positions=PISTOL_25M_POSITIONS) @sessions_bp.route("//delete", methods=["POST"]) @@ -191,10 +285,55 @@ def delete(session_id: int): _remove_file(photo.photo_path) db.session.delete(s) db.session.commit() - flash("Session deleted.", "success") + flash(_("Session deleted."), "success") return redirect(url_for("sessions.index")) +# --------------------------------------------------------------------------- +# PRS stages +# --------------------------------------------------------------------------- + +@sessions_bp.route("//stages", methods=["POST"]) +@login_required +def save_stages(session_id: int): + import json + s = _own_session(session_id) + raw = request.form.get("stages_json", "[]") + try: + stages = json.loads(raw) + if not isinstance(stages, list): + stages = [] + except (json.JSONDecodeError, ValueError): + stages = [] + s.prs_stages = stages + db.session.commit() + flash(_("Stages saved."), "success") + return redirect(url_for("sessions.detail", session_id=session_id)) + + +@sessions_bp.route("//dope-card") +def dope_card(session_id: int): + s = db.session.get(ShootingSession, session_id) + if s is None: + abort(404) + is_owner = current_user.is_authenticated and s.user_id == current_user.id + if not s.is_public and not is_owner: + abort(403) + if s.session_type != "prs": + abort(400) + + from analyzer.dope_card import generate_dope_card + from flask import make_response + + stages = s.prs_stages or [] + pdf_bytes = generate_dope_card(s, stages) + resp = make_response(pdf_bytes) + resp.headers["Content-Type"] = "application/pdf" + fname = f"dope_card_{s.session_date.isoformat()}.pdf" + resp.headers["Content-Disposition"] = f'inline; filename="{fname}"' + return resp + + # --------------------------------------------------------------------------- # CSV upload # --------------------------------------------------------------------------- @@ -206,7 +345,7 @@ def upload_csv(session_id: int): csv_file = request.files.get("csv_file") if not csv_file or not csv_file.filename: - flash("No CSV file selected.", "error") + flash(_("No CSV file selected."), "error") return redirect(url_for("sessions.detail", session_id=session_id)) from analyzer.parser import parse_csv @@ -240,7 +379,7 @@ def upload_csv(session_id: int): session_id=session_id, is_public=s.is_public if s else False, ) - flash("CSV analysed and linked to this session.", "success") + flash(_("CSV analysed and linked to this session."), "success") return redirect(url_for("sessions.detail", session_id=session_id)) @@ -255,7 +394,7 @@ def upload_photo(session_id: int): photo_file = request.files.get("photo") if not photo_file or not photo_file.filename: - flash("No photo selected.", "error") + flash(_("No photo selected."), "error") return redirect(url_for("sessions.detail", session_id=session_id)) try: @@ -268,7 +407,7 @@ def upload_photo(session_id: int): photo = SessionPhoto(session_id=session_id, photo_path=photo_path, caption=caption) db.session.add(photo) db.session.commit() - flash("Photo added.", "success") + flash(_("Photo added."), "success") return redirect(url_for("sessions.detail", session_id=session_id)) @@ -282,7 +421,7 @@ def delete_photo(session_id: int, photo_id: int): _remove_file(photo.photo_path) db.session.delete(photo) db.session.commit() - flash("Photo deleted.", "success") + flash(_("Photo deleted."), "success") return redirect(url_for("sessions.detail", session_id=session_id)) diff --git a/entrypoint.sh b/entrypoint.sh index 1d1f2ba..1c84e92 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -3,6 +3,8 @@ set -e mkdir -p /app/storage/csvs /app/storage/pdfs /app/storage/equipment_photos +pybabel compile -d translations 2>/dev/null || true + flask db upgrade exec python -m gunicorn --bind 0.0.0.0:5000 --workers 2 "app:create_app()" diff --git a/extensions.py b/extensions.py index b7ce766..a67819b 100644 --- a/extensions.py +++ b/extensions.py @@ -1,4 +1,5 @@ from authlib.integrations.flask_client import OAuth +from flask_babel import Babel, lazy_gettext as _l from flask_jwt_extended import JWTManager from flask_login import LoginManager from flask_migrate import Migrate @@ -9,6 +10,7 @@ login_manager = LoginManager() migrate = Migrate() oauth = OAuth() jwt = JWTManager() +babel = Babel() login_manager.login_view = "auth.login" -login_manager.login_message = "Please log in to access this page." +login_manager.login_message = _l("Please log in to access this page.") diff --git a/migrations/versions/bf96ceb7f076_prs_stages_and_drop_competition_fields.py b/migrations/versions/bf96ceb7f076_prs_stages_and_drop_competition_fields.py new file mode 100644 index 0000000..69ea61b --- /dev/null +++ b/migrations/versions/bf96ceb7f076_prs_stages_and_drop_competition_fields.py @@ -0,0 +1,36 @@ +"""prs_stages and drop competition fields + +Revision ID: bf96ceb7f076 +Revises: edf627601b3d +Create Date: 2026-03-18 15:41:28.882592 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'bf96ceb7f076' +down_revision = 'edf627601b3d' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('shooting_sessions', schema=None) as batch_op: + batch_op.add_column(sa.Column('prs_stages', sa.JSON(), nullable=True)) + batch_op.drop_column('competition_stage') + batch_op.drop_column('competition_division') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('shooting_sessions', schema=None) as batch_op: + batch_op.add_column(sa.Column('competition_division', sa.VARCHAR(length=100), autoincrement=False, nullable=True)) + batch_op.add_column(sa.Column('competition_stage', sa.VARCHAR(length=255), autoincrement=False, nullable=True)) + batch_op.drop_column('prs_stages') + + # ### end Alembic commands ### diff --git a/migrations/versions/edf627601b3d_session_type_and_analysis_grouping_.py b/migrations/versions/edf627601b3d_session_type_and_analysis_grouping_.py new file mode 100644 index 0000000..cef9a79 --- /dev/null +++ b/migrations/versions/edf627601b3d_session_type_and_analysis_grouping_.py @@ -0,0 +1,49 @@ +"""session_type and analysis_grouping_fields + +Revision ID: edf627601b3d +Revises: d46dc696b3c3 +Create Date: 2026-03-18 14:12:25.811674 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'edf627601b3d' +down_revision = 'd46dc696b3c3' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('analyses', schema=None) as batch_op: + batch_op.add_column(sa.Column('grouping_outlier_factor', sa.Double(), nullable=True)) + batch_op.add_column(sa.Column('grouping_manual_splits', sa.JSON(), nullable=True)) + + with op.batch_alter_table('shooting_sessions', schema=None) as batch_op: + batch_op.add_column(sa.Column('session_type', sa.String(length=50), nullable=True)) + batch_op.add_column(sa.Column('competition_stage', sa.String(length=255), nullable=True)) + batch_op.add_column(sa.Column('competition_division', sa.String(length=100), nullable=True)) + batch_op.add_column(sa.Column('shooting_position', sa.String(length=100), nullable=True)) + + # Backfill existing sessions as long_range precision + op.execute("UPDATE shooting_sessions SET session_type = 'long_range' WHERE session_type IS NULL") + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('shooting_sessions', schema=None) as batch_op: + batch_op.drop_column('shooting_position') + batch_op.drop_column('competition_division') + batch_op.drop_column('competition_stage') + batch_op.drop_column('session_type') + + with op.batch_alter_table('analyses', schema=None) as batch_op: + batch_op.drop_column('grouping_manual_splits') + batch_op.drop_column('grouping_outlier_factor') + + # ### end Alembic commands ### diff --git a/models.py b/models.py index da5ecd1..93f21a8 100644 --- a/models.py +++ b/models.py @@ -118,6 +118,9 @@ class ShootingSession(db.Model): ammo_weight_gr: Mapped[float | None] = mapped_column(Numeric(7, 2)) ammo_lot: Mapped[str | None] = mapped_column(String(80)) notes: Mapped[str | None] = mapped_column(Text) + session_type: Mapped[str | None] = mapped_column(String(50)) + shooting_position: Mapped[str | None] = mapped_column(String(100)) + prs_stages: Mapped[list | None] = mapped_column(db.JSON) is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) @@ -151,6 +154,8 @@ class Analysis(db.Model): group_stats: Mapped[list] = mapped_column(db.JSON, nullable=False) shot_count: Mapped[int] = mapped_column(Integer, nullable=False) group_count: Mapped[int] = mapped_column(Integer, nullable=False) + grouping_outlier_factor: Mapped[float | None] = mapped_column(Double) + grouping_manual_splits: Mapped[list | None] = mapped_column(db.JSON) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) user: Mapped["User"] = relationship("User", back_populates="analyses") diff --git a/requirements.txt b/requirements.txt index 90458f7..f8bbf9d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ Flask>=3.0 +Flask-Babel>=3.0 python-dotenv>=1.0 Flask-SQLAlchemy>=3.1 Flask-Migrate>=4.0 diff --git a/templates/analyses/detail.html b/templates/analyses/detail.html index d7c7c48..746a6c3 100644 --- a/templates/analyses/detail.html +++ b/templates/analyses/detail.html @@ -6,51 +6,51 @@
{% if analysis.session_id %} - Session › + {{ _('Session') }} › {% else %} - Dashboard › + {{ _('Dashboard') }} › {% endif %} - Analysis + {{ _('Analysis') }}

{{ analysis.title }}

{{ analysis.created_at.strftime('%d %b %Y') }} -  ·  {{ analysis.shot_count }} shot(s) -  ·  {{ analysis.group_count }} group(s) +  ·  {{ analysis.shot_count }} {{ _('shot(s)') }} +  ·  {{ analysis.group_count }} {{ _('group(s)') }}
{% if has_pdf %} - ⇓ Download PDF report + ⇓ {{ _('Download PDF report') }} {% endif %} {% if current_user.is_authenticated and current_user.id == analysis.user_id %}
+ onsubmit="return confirm('{{ _('Delete this analysis? The CSV and PDF will be permanently removed.') | e }}');">
{% endif %} - ← New analysis + ← {{ _('New analysis') }}
-

Overall Statistics

+

{{ _('Overall Statistics') }}

- + - - - - + + + + - + @@ -61,24 +61,24 @@ Avg speed and std dev per group -

Groups — {{ groups_display|length }} group(s) detected

+

{{ _('Groups') }} — {{ groups_display|length }} {{ _('group(s) detected') }}

{% for stat, chart_b64 in groups_display %}
-

Group {{ stat.group_index }}

+

{{ _('Group %(n)s', n=stat.group_index) }}

- {{ stat.time_start }} – {{ stat.time_end }}  |  {{ stat.count }} shot(s) + {{ stat.time_start }} – {{ stat.time_end }}  |  {{ stat.count }} {{ _('shot(s)') }}
MetricValue
{{ _('Metric') }}{{ _('Value') }}
Total shots{{ overall.count }}
Min speed{{ "%.4f"|format(overall.min_speed) }}
Max speed{{ "%.4f"|format(overall.max_speed) }}
Mean speed{{ "%.4f"|format(overall.mean_speed) }}
{{ _('Total shots') }}{{ overall.count }}
{{ _('Min speed') }}{{ "%.4f"|format(overall.min_speed) }}
{{ _('Max speed') }}{{ "%.4f"|format(overall.max_speed) }}
{{ _('Mean speed') }}{{ "%.4f"|format(overall.mean_speed) }}
Std dev (speed){{ _('Std dev (speed)') }} {% if overall.std_speed is not none %}{{ "%.4f"|format(overall.std_speed) }}{% else %}–{% endif %}
- + - - - + + + - + diff --git a/templates/auth/login.html b/templates/auth/login.html index 0ab8556..cc714bc 100644 --- a/templates/auth/login.html +++ b/templates/auth/login.html @@ -1,22 +1,22 @@ {% extends "base.html" %} -{% block title %}Login — Ballistic Analyzer{% endblock %} +{% block title %}{{ _('Sign in') }} — Ballistic Analyzer{% endblock %} {% block content %} -

Sign in

+

{{ _('Sign in') }}

- +
- +
@@ -24,18 +24,18 @@ {% endif %}

- Don't have an account? Create one + {{ _("Don't have an account?") }} {{ _('Create one') }}


- or continue with + {{ _('or continue with') }}
@@ -48,7 +48,7 @@ - Continue with Google + {{ _('Continue with Google') }} - Continue with GitHub + {{ _('Continue with GitHub') }} {% endblock %} diff --git a/templates/auth/profile.html b/templates/auth/profile.html index 70f2e55..3a84024 100644 --- a/templates/auth/profile.html +++ b/templates/auth/profile.html @@ -1,10 +1,10 @@ {% extends "base.html" %} -{% block title %}Profile — The Shooter's Network{% endblock %} +{% block title %}{{ _('Profile') }} — The Shooter's Network{% endblock %} {% block content %} -

Profile

+

{{ _('Profile') }}

{# ---- Avatar + display name ---- #} -

Account

+

{{ _('Account') }}

@@ -22,15 +22,15 @@ {% endif %}
-
JPEG/PNG, max 1200 px, auto-resized.
+
{{ _('JPEG/PNG, max 1200 px, auto-resized.') }}
- +
- -
- +
@@ -57,49 +57,49 @@ - Show my equipment on my public profile + {{ _('Show my equipment on my public profile') }}
{# ---- Change password (local accounts only) ---- #} {% if current_user.provider == 'local' %} -

Change password

+

{{ _('Change password') }}

- +
- +
- +
{% endif %} diff --git a/templates/auth/public_profile.html b/templates/auth/public_profile.html index 882d5e8..bbc6c38 100644 --- a/templates/auth/public_profile.html +++ b/templates/auth/public_profile.html @@ -15,7 +15,7 @@

{{ profile_user.display_name or profile_user.email.split('@')[0] }}

- Member since {{ profile_user.created_at.strftime('%B %Y') }} + {{ _('Member since %(date)s', date=profile_user.created_at.strftime('%B %Y')) }}
{% if profile_user.bio %}

{{ profile_user.bio }}

@@ -24,12 +24,12 @@
{# ---- Public Sessions ---- #} -

Sessions{% if public_sessions %} ({{ public_sessions|length }}){% endif %}

+

{{ _('Sessions') }}{% if public_sessions %} ({{ public_sessions|length }}){% endif %}

{% if public_sessions %}
MetricValue
{{ _('Metric') }}{{ _('Value') }}
Min speed{{ "%.4f"|format(stat.min_speed) }}
Max speed{{ "%.4f"|format(stat.max_speed) }}
Mean speed{{ "%.4f"|format(stat.mean_speed) }}
{{ _('Min speed') }}{{ "%.4f"|format(stat.min_speed) }}
{{ _('Max speed') }}{{ "%.4f"|format(stat.max_speed) }}
{{ _('Mean speed') }}{{ "%.4f"|format(stat.mean_speed) }}
Std dev (speed){{ _('Std dev (speed)') }} {% if stat.std_speed is not none %}{{ "%.4f"|format(stat.std_speed) }}{% else %}–{% endif %}
- + {% for s in public_sessions %} @@ -46,16 +46,16 @@
SessionLocationDistance
{{ _('Session') }}{{ _('Location') }}{{ _('Distance') }}
{% else %} -

No public sessions yet.

+

{{ _('No public sessions yet.') }}

{% endif %} {# ---- Equipment (optional) ---- #} {% if equipment is not none %} -

Equipment

+

{{ _('Equipment') }}

{% if equipment %} - + {% for item in equipment %} @@ -73,7 +73,7 @@
NameCategoryBrand / ModelCaliber
{{ _('Name') }}{{ _('Category') }}{{ _('Brand / Model') }}{{ _('Caliber') }}
{% else %} -

No equipment listed.

+

{{ _('No equipment listed.') }}

{% endif %} {% endif %} diff --git a/templates/auth/register.html b/templates/auth/register.html index 59847c4..8319646 100644 --- a/templates/auth/register.html +++ b/templates/auth/register.html @@ -1,33 +1,33 @@ {% extends "base.html" %} -{% block title %}Create account — Ballistic Analyzer{% endblock %} +{% block title %}{{ _('Create account') }} — Ballistic Analyzer{% endblock %} {% block content %} -

Create account

+

{{ _('Create account') }}

- +
-
- +

- Already have an account? Sign in + {{ _('Already have an account?') }} {{ _('Sign in') }}

{% endblock %} diff --git a/templates/base.html b/templates/base.html index 2250165..bae3aa7 100644 --- a/templates/base.html +++ b/templates/base.html @@ -23,6 +23,7 @@ padding: 0 1.5rem; height: 52px; gap: 1rem; + position: relative; } .nav-brand { font-weight: 700; @@ -57,6 +58,57 @@ gap: 0.75rem; flex-shrink: 0; } + /* ── Hamburger (hidden on desktop) ── */ + .nav-hamburger { + display: none; + background: none; + border: none; + color: #fff; + font-size: 1.5rem; + line-height: 1; + cursor: pointer; + padding: 0.25rem 0.4rem; + } + /* ── Mobile menu panel ── */ + .nav-mobile-menu { + display: none; + position: absolute; + top: 52px; + left: 0; right: 0; + background: #1a1a2e; + border-top: 1px solid rgba(255,255,255,.12); + padding: 0.75rem 1.5rem 1rem; + z-index: 200; + flex-direction: column; + gap: 0; + } + .nav-mobile-menu a, + .nav-mobile-menu button { + display: block; + width: 100%; + text-align: left; + color: #c8cfe0; + text-decoration: none; + font-size: 0.95rem; + padding: 0.6rem 0; + border-bottom: 1px solid rgba(255,255,255,.07); + background: none; + border-left: none; + border-right: none; + border-top: none; + cursor: pointer; + font-family: inherit; + } + .nav-mobile-menu a:last-child, + .nav-mobile-menu button:last-child { border-bottom: none; } + .nav-mobile-menu a:hover, + .nav-mobile-menu button:hover { color: #fff; } + .nav.open .nav-mobile-menu { display: flex; } + @media (max-width: 640px) { + .nav-links { display: none; } + .nav-right { display: none; } + .nav-hamburger { display: block; } + } /* ── User dropdown ── */ .nav-dropdown { position: relative; } .nav-user-btn { @@ -189,19 +241,30 @@ {% block body %} -