WIP: claude works hard
This commit is contained in:
@@ -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
|
||||
|
||||
143
analyzer/dope_card.py
Normal file
143
analyzer/dope_card.py
Normal file
@@ -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 "")
|
||||
@@ -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))
|
||||
|
||||
26
app.py
26
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/<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
|
||||
|
||||
3
babel.cfg
Normal file
3
babel.cfg
Normal file
@@ -0,0 +1,3 @@
|
||||
[python: **.py]
|
||||
[jinja2: **/templates/**.html]
|
||||
extensions=jinja2.ext.autoescape,jinja2.ext.with_
|
||||
@@ -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("/<int:analysis_id>/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("/<int:analysis_id>/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("/<int:analysis_id>/groups/<int:group_index>/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)
|
||||
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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("/<int:session_id>")
|
||||
@@ -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("/<int:session_id>/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("/<int:session_id>/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("/<int:session_id>/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("/<int:session_id>/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))
|
||||
|
||||
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
@@ -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")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
Flask>=3.0
|
||||
Flask-Babel>=3.0
|
||||
python-dotenv>=1.0
|
||||
Flask-SQLAlchemy>=3.1
|
||||
Flask-Migrate>=4.0
|
||||
|
||||
@@ -6,51 +6,51 @@
|
||||
<div>
|
||||
<div style="font-size:0.82rem;color:#888;margin-bottom:.3rem;">
|
||||
{% if analysis.session_id %}
|
||||
<a href="{{ url_for('sessions.detail', session_id=analysis.session_id) }}">Session</a> ›
|
||||
<a href="{{ url_for('sessions.detail', session_id=analysis.session_id) }}">{{ _('Session') }}</a> ›
|
||||
{% else %}
|
||||
<a href="{{ url_for('dashboard.index') }}">Dashboard</a> ›
|
||||
<a href="{{ url_for('dashboard.index') }}">{{ _('Dashboard') }}</a> ›
|
||||
{% endif %}
|
||||
Analysis
|
||||
{{ _('Analysis') }}
|
||||
</div>
|
||||
<h1 style="margin:0;">{{ analysis.title }}</h1>
|
||||
<div style="font-size:0.85rem;color:#888;margin-top:.3rem;">
|
||||
{{ 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)') }}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:.75rem;align-items:center;flex-wrap:wrap;">
|
||||
{% if has_pdf %}
|
||||
<a href="{{ url_for('analyses.download_pdf', analysis_id=analysis.id) }}"
|
||||
style="background:#1f77b4;color:#fff;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;text-decoration:none;">
|
||||
⇓ Download PDF report
|
||||
⇓ {{ _('Download PDF report') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if current_user.is_authenticated and current_user.id == analysis.user_id %}
|
||||
<form method="post" action="{{ url_for('analyses.delete', analysis_id=analysis.id) }}"
|
||||
onsubmit="return confirm('Delete this analysis? The CSV and PDF will be permanently removed.');">
|
||||
onsubmit="return confirm('{{ _('Delete this analysis? The CSV and PDF will be permanently removed.') | e }}');">
|
||||
<button type="submit"
|
||||
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
|
||||
Delete
|
||||
{{ _('Delete') }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('analyze') }}" style="font-size:0.9rem;color:#666;">← New analysis</a>
|
||||
<a href="{{ url_for('analyze') }}" style="font-size:0.9rem;color:#666;">← {{ _('New analysis') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Overall Statistics</h2>
|
||||
<h2>{{ _('Overall Statistics') }}</h2>
|
||||
<table style="max-width:480px;">
|
||||
<thead>
|
||||
<tr><th>Metric</th><th>Value</th></tr>
|
||||
<tr><th>{{ _('Metric') }}</th><th>{{ _('Value') }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Total shots</td><td>{{ overall.count }}</td></tr>
|
||||
<tr><td>Min speed</td><td>{{ "%.4f"|format(overall.min_speed) }}</td></tr>
|
||||
<tr><td>Max speed</td><td>{{ "%.4f"|format(overall.max_speed) }}</td></tr>
|
||||
<tr><td>Mean speed</td><td>{{ "%.4f"|format(overall.mean_speed) }}</td></tr>
|
||||
<tr><td>{{ _('Total shots') }}</td><td>{{ overall.count }}</td></tr>
|
||||
<tr><td>{{ _('Min speed') }}</td><td>{{ "%.4f"|format(overall.min_speed) }}</td></tr>
|
||||
<tr><td>{{ _('Max speed') }}</td><td>{{ "%.4f"|format(overall.max_speed) }}</td></tr>
|
||||
<tr><td>{{ _('Mean speed') }}</td><td>{{ "%.4f"|format(overall.mean_speed) }}</td></tr>
|
||||
<tr>
|
||||
<td>Std dev (speed)</td>
|
||||
<td>{{ _('Std dev (speed)') }}</td>
|
||||
<td>
|
||||
{% if overall.std_speed is not none %}{{ "%.4f"|format(overall.std_speed) }}{% else %}–{% endif %}
|
||||
</td>
|
||||
@@ -61,24 +61,24 @@
|
||||
<img class="chart-img" src="data:image/png;base64,{{ overview_chart }}"
|
||||
alt="Avg speed and std dev per group" style="max-width:600px;margin:1rem 0 1.5rem;">
|
||||
|
||||
<h2>Groups — {{ groups_display|length }} group(s) detected</h2>
|
||||
<h2>{{ _('Groups') }} — {{ groups_display|length }} {{ _('group(s) detected') }}</h2>
|
||||
|
||||
{% for stat, chart_b64 in groups_display %}
|
||||
<div class="group-section">
|
||||
<h3>Group {{ stat.group_index }}</h3>
|
||||
<h3>{{ _('Group %(n)s', n=stat.group_index) }}</h3>
|
||||
<div class="group-meta">
|
||||
{{ stat.time_start }} – {{ stat.time_end }} | {{ stat.count }} shot(s)
|
||||
{{ stat.time_start }} – {{ stat.time_end }} | {{ stat.count }} {{ _('shot(s)') }}
|
||||
</div>
|
||||
<table style="max-width:480px;">
|
||||
<thead>
|
||||
<tr><th>Metric</th><th>Value</th></tr>
|
||||
<tr><th>{{ _('Metric') }}</th><th>{{ _('Value') }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Min speed</td><td>{{ "%.4f"|format(stat.min_speed) }}</td></tr>
|
||||
<tr><td>Max speed</td><td>{{ "%.4f"|format(stat.max_speed) }}</td></tr>
|
||||
<tr><td>Mean speed</td><td>{{ "%.4f"|format(stat.mean_speed) }}</td></tr>
|
||||
<tr><td>{{ _('Min speed') }}</td><td>{{ "%.4f"|format(stat.min_speed) }}</td></tr>
|
||||
<tr><td>{{ _('Max speed') }}</td><td>{{ "%.4f"|format(stat.max_speed) }}</td></tr>
|
||||
<tr><td>{{ _('Mean speed') }}</td><td>{{ "%.4f"|format(stat.mean_speed) }}</td></tr>
|
||||
<tr>
|
||||
<td>Std dev (speed)</td>
|
||||
<td>{{ _('Std dev (speed)') }}</td>
|
||||
<td>
|
||||
{% if stat.std_speed is not none %}{{ "%.4f"|format(stat.std_speed) }}{% else %}–{% endif %}
|
||||
</td>
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Login — Ballistic Analyzer{% endblock %}
|
||||
{% block title %}{{ _('Sign in') }} — Ballistic Analyzer{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Sign in</h1>
|
||||
<h1>{{ _('Sign in') }}</h1>
|
||||
|
||||
<form method="post" action="{{ url_for('auth.login') }}" style="max-width:360px;margin-bottom:1.5rem;">
|
||||
<div style="margin-bottom:1rem;">
|
||||
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">Email</label>
|
||||
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">{{ _('Email') }}</label>
|
||||
<input type="email" name="email" value="{{ prefill_email or '' }}" required autocomplete="email"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
|
||||
</div>
|
||||
<div style="margin-bottom:1.25rem;">
|
||||
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">Password</label>
|
||||
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">{{ _('Password') }}</label>
|
||||
<input type="password" name="password" required autocomplete="current-password"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
|
||||
</div>
|
||||
<button type="submit"
|
||||
style="width:100%;background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:0.65rem;font-size:0.95rem;cursor:pointer;">
|
||||
Sign in
|
||||
{{ _('Sign in') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -24,18 +24,18 @@
|
||||
<form method="post" action="{{ url_for('auth.resend_confirmation') }}" style="margin-bottom:1.5rem;">
|
||||
<input type="hidden" name="email" value="{{ resend_email }}">
|
||||
<button type="submit" class="btn-link" style="color:#1f77b4;font-size:0.88rem;">
|
||||
Resend confirmation email
|
||||
{{ _('Resend confirmation email') }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<p style="font-size:0.9rem;color:#555;margin-bottom:1.5rem;">
|
||||
Don't have an account? <a href="{{ url_for('auth.register') }}">Create one</a>
|
||||
{{ _("Don't have an account?") }} <a href="{{ url_for('auth.register') }}">{{ _('Create one') }}</a>
|
||||
</p>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1.5rem;max-width:360px;">
|
||||
<hr style="flex:1;border:none;border-top:1px solid #ddd;">
|
||||
<span style="font-size:0.8rem;color:#999;">or continue with</span>
|
||||
<span style="font-size:0.8rem;color:#999;">{{ _('or continue with') }}</span>
|
||||
<hr style="flex:1;border:none;border-top:1px solid #ddd;">
|
||||
</div>
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
<path d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z" fill="#FBBC05"/>
|
||||
<path d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z" fill="#EA4335"/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
{{ _('Continue with Google') }}
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('auth.login_github') }}"
|
||||
@@ -56,7 +56,7 @@
|
||||
<svg width="17" height="17" viewBox="0 0 98 96" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/>
|
||||
</svg>
|
||||
Continue with GitHub
|
||||
{{ _('Continue with GitHub') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 %}
|
||||
<h1>Profile</h1>
|
||||
<h1>{{ _('Profile') }}</h1>
|
||||
|
||||
{# ---- Avatar + display name ---- #}
|
||||
<h2>Account</h2>
|
||||
<h2>{{ _('Account') }}</h2>
|
||||
<form method="post" action="{{ url_for('auth.profile') }}"
|
||||
enctype="multipart/form-data"
|
||||
style="max-width:480px;">
|
||||
@@ -22,15 +22,15 @@
|
||||
{% endif %}
|
||||
<div>
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">
|
||||
Profile picture
|
||||
{{ _('Profile picture') }}
|
||||
</label>
|
||||
<input type="file" name="avatar" accept="image/*" style="font-size:0.9rem;">
|
||||
<div style="font-size:0.78rem;color:#aaa;margin-top:.2rem;">JPEG/PNG, max 1200 px, auto-resized.</div>
|
||||
<div style="font-size:0.78rem;color:#aaa;margin-top:.2rem;">{{ _('JPEG/PNG, max 1200 px, auto-resized.') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:1rem;">
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">Display name</label>
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">{{ _('Display name') }}</label>
|
||||
<input type="text" name="display_name"
|
||||
value="{{ current_user.display_name or '' }}"
|
||||
required
|
||||
@@ -38,13 +38,13 @@
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:1rem;">
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">Bio</label>
|
||||
<textarea name="bio" rows="4" placeholder="Tell others a bit about yourself…"
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">{{ _('Bio') }}</label>
|
||||
<textarea name="bio" rows="4" placeholder="{{ _('Tell others a bit about yourself…') }}"
|
||||
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;resize:vertical;">{{ current_user.bio or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:1rem;">
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">Email</label>
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">{{ _('Email') }}</label>
|
||||
<input type="text" value="{{ current_user.email }}" disabled
|
||||
style="width:100%;padding:.55rem .75rem;border:1px solid #e0e0e0;border-radius:4px;font-size:0.95rem;background:#f5f5f5;color:#888;">
|
||||
<div style="font-size:0.78rem;color:#aaa;margin-top:.2rem;">
|
||||
@@ -57,49 +57,49 @@
|
||||
<input type="checkbox" name="show_equipment_public"
|
||||
{% if current_user.show_equipment_public %}checked{% endif %}
|
||||
style="width:1rem;height:1rem;">
|
||||
Show my equipment on my public profile
|
||||
{{ _('Show my equipment on my public profile') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:1.5rem;flex-wrap:wrap;">
|
||||
<button type="submit"
|
||||
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.6rem 1.5rem;font-size:.95rem;cursor:pointer;">
|
||||
Save changes
|
||||
{{ _('Save changes') }}
|
||||
</button>
|
||||
<a href="{{ url_for('public_profile', user_id=current_user.id) }}"
|
||||
style="font-size:0.9rem;color:#1f77b4;" target="_blank">
|
||||
View my public profile →
|
||||
{{ _('View my public profile →') }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# ---- Change password (local accounts only) ---- #}
|
||||
{% if current_user.provider == 'local' %}
|
||||
<h2>Change password</h2>
|
||||
<h2>{{ _('Change password') }}</h2>
|
||||
<form method="post" action="{{ url_for('auth.profile') }}"
|
||||
style="max-width:480px;">
|
||||
<input type="hidden" name="action" value="change_password">
|
||||
|
||||
<div style="margin-bottom:1rem;">
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">Current password</label>
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">{{ _('Current password') }}</label>
|
||||
<input type="password" name="current_password" required
|
||||
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:.95rem;">
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
|
||||
<div>
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">New password</label>
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">{{ _('New password') }}</label>
|
||||
<input type="password" name="new_password" required minlength="8"
|
||||
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:.95rem;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">Confirm</label>
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">{{ _('Confirm') }}</label>
|
||||
<input type="password" name="confirm_password" required minlength="8"
|
||||
style="width:100%;padding:.55rem .75rem;border:1px solid #ccc;border-radius:4px;font-size:.95rem;">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit"
|
||||
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.6rem 1.5rem;font-size:.95rem;cursor:pointer;">
|
||||
Change password
|
||||
{{ _('Change password') }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div>
|
||||
<h1 style="margin:0 0 .25rem;">{{ profile_user.display_name or profile_user.email.split('@')[0] }}</h1>
|
||||
<div style="font-size:0.85rem;color:#888;">
|
||||
Member since {{ profile_user.created_at.strftime('%B %Y') }}
|
||||
{{ _('Member since %(date)s', date=profile_user.created_at.strftime('%B %Y')) }}
|
||||
</div>
|
||||
{% if profile_user.bio %}
|
||||
<p style="margin-top:.75rem;color:#444;white-space:pre-wrap;max-width:600px;">{{ profile_user.bio }}</p>
|
||||
@@ -24,12 +24,12 @@
|
||||
</div>
|
||||
|
||||
{# ---- Public Sessions ---- #}
|
||||
<h2>Sessions{% if public_sessions %} ({{ public_sessions|length }}){% endif %}</h2>
|
||||
<h2>{{ _('Sessions') }}{% if public_sessions %} ({{ public_sessions|length }}){% endif %}</h2>
|
||||
|
||||
{% if public_sessions %}
|
||||
<table style="margin-bottom:1.5rem;">
|
||||
<thead>
|
||||
<tr><th>Session</th><th>Location</th><th>Distance</th></tr>
|
||||
<tr><th>{{ _('Session') }}</th><th>{{ _('Location') }}</th><th>{{ _('Distance') }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in public_sessions %}
|
||||
@@ -46,16 +46,16 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p style="color:#888;margin-bottom:1.5rem;">No public sessions yet.</p>
|
||||
<p style="color:#888;margin-bottom:1.5rem;">{{ _('No public sessions yet.') }}</p>
|
||||
{% endif %}
|
||||
|
||||
{# ---- Equipment (optional) ---- #}
|
||||
{% if equipment is not none %}
|
||||
<h2>Equipment</h2>
|
||||
<h2>{{ _('Equipment') }}</h2>
|
||||
{% if equipment %}
|
||||
<table style="margin-bottom:1.5rem;">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>Category</th><th>Brand / Model</th><th>Caliber</th></tr>
|
||||
<tr><th>{{ _('Name') }}</th><th>{{ _('Category') }}</th><th>{{ _('Brand / Model') }}</th><th>{{ _('Caliber') }}</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in equipment %}
|
||||
@@ -73,7 +73,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p style="color:#888;margin-bottom:1.5rem;">No equipment listed.</p>
|
||||
<p style="color:#888;margin-bottom:1.5rem;">{{ _('No equipment listed.') }}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Create account — Ballistic Analyzer{% endblock %}
|
||||
{% block title %}{{ _('Create account') }} — Ballistic Analyzer{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Create account</h1>
|
||||
<h1>{{ _('Create account') }}</h1>
|
||||
|
||||
<form method="post" action="{{ url_for('auth.register') }}" style="max-width:360px;">
|
||||
<div style="margin-bottom:1rem;">
|
||||
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">Email</label>
|
||||
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">{{ _('Email') }}</label>
|
||||
<input type="email" name="email" value="{{ prefill_email or '' }}" required autocomplete="email"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
|
||||
</div>
|
||||
<div style="margin-bottom:1rem;">
|
||||
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">Password
|
||||
<span style="font-weight:400;color:#888;">(min. 8 characters)</span>
|
||||
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">{{ _('Password') }}
|
||||
<span style="font-weight:400;color:#888;">{{ _('(min. 8 characters)') }}</span>
|
||||
</label>
|
||||
<input type="password" name="password" required autocomplete="new-password" minlength="8"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
|
||||
</div>
|
||||
<div style="margin-bottom:1.5rem;">
|
||||
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">Confirm password</label>
|
||||
<label style="display:block;font-size:0.88rem;font-weight:600;color:#444;margin-bottom:0.3rem;">{{ _('Confirm password') }}</label>
|
||||
<input type="password" name="confirm_password" required autocomplete="new-password"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
|
||||
</div>
|
||||
<button type="submit"
|
||||
style="width:100%;background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:0.65rem;font-size:0.95rem;cursor:pointer;">
|
||||
Create account
|
||||
{{ _('Create account') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p style="font-size:0.9rem;color:#555;margin-top:1.25rem;">
|
||||
Already have an account? <a href="{{ url_for('auth.login') }}">Sign in</a>
|
||||
{{ _('Already have an account?') }} <a href="{{ url_for('auth.login') }}">{{ _('Sign in') }}</a>
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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 @@
|
||||
<body>
|
||||
|
||||
{% block body %}
|
||||
<nav class="nav">
|
||||
<nav class="nav" id="mainNav">
|
||||
<a href="/" class="nav-brand">The Shooter's Network</a>
|
||||
|
||||
<div class="nav-links">
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('analyze') }}">New Analysis</a>
|
||||
<a href="{{ url_for('equipment.index') }}">Equipment</a>
|
||||
<a href="{{ url_for('sessions.index') }}">Sessions</a>
|
||||
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
|
||||
<a href="{{ url_for('analyze') }}">{{ _('New Analysis') }}</a>
|
||||
<a href="{{ url_for('equipment.index') }}">{{ _('Equipment') }}</a>
|
||||
<a href="{{ url_for('sessions.index') }}">{{ _('Sessions') }}</a>
|
||||
<a href="{{ url_for('dashboard.index') }}">{{ _('Dashboard') }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="nav-right">
|
||||
{# Language switcher #}
|
||||
<div class="nav-dropdown" id="langDropdown">
|
||||
<button class="nav-user-btn" onclick="toggleLangDropdown(event)" style="padding:.2rem .55rem;gap:.35rem;font-size:1.1rem;">
|
||||
{% if current_lang == 'fr' %}🇫🇷{% elif current_lang == 'de' %}🇩🇪{% else %}🇬🇧{% endif %}<span class="caret">▼</span>
|
||||
</button>
|
||||
<div class="nav-dd-menu" style="min-width:130px;">
|
||||
<a href="{{ url_for('set_lang', lang='en') }}">🇬🇧 English</a>
|
||||
<a href="{{ url_for('set_lang', lang='fr') }}">🇫🇷 Français</a>
|
||||
<a href="{{ url_for('set_lang', lang='de') }}">🇩🇪 Deutsch</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="nav-dropdown" id="userDropdown">
|
||||
<button class="nav-user-btn" onclick="toggleDropdown(event)">
|
||||
@@ -215,21 +278,46 @@
|
||||
<span class="caret">▼</span>
|
||||
</button>
|
||||
<div class="nav-dd-menu">
|
||||
<a href="{{ url_for('auth.profile') }}">👤 Profile</a>
|
||||
<a href="{{ url_for('auth.profile') }}">👤 {{ _('Profile') }}</a>
|
||||
<hr>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}">
|
||||
<button type="submit">→ Logout</button>
|
||||
<button type="submit">→ {{ _('Logout') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.login') }}" style="color:#c8cfe0;font-size:0.9rem;text-decoration:none;">Login</a>
|
||||
<a href="{{ url_for('auth.login') }}" style="color:#c8cfe0;font-size:0.9rem;text-decoration:none;">{{ _('Login') }}</a>
|
||||
<a href="{{ url_for('auth.register') }}"
|
||||
style="background:#1f77b4;color:#fff;padding:0.35rem 0.9rem;border-radius:4px;font-size:0.88rem;text-decoration:none;">
|
||||
Join free
|
||||
{{ _('Join free') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Hamburger — only visible on mobile #}
|
||||
<button class="nav-hamburger" onclick="toggleMobileNav(event)" aria-label="Menu">☰</button>
|
||||
|
||||
{# Mobile menu panel #}
|
||||
<div class="nav-mobile-menu">
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('analyze') }}">{{ _('New Analysis') }}</a>
|
||||
<a href="{{ url_for('equipment.index') }}">{{ _('Equipment') }}</a>
|
||||
<a href="{{ url_for('sessions.index') }}">{{ _('Sessions') }}</a>
|
||||
<a href="{{ url_for('dashboard.index') }}">{{ _('Dashboard') }}</a>
|
||||
<a href="{{ url_for('auth.profile') }}">{{ _('Profile') }}</a>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}" style="padding:0;border:none;">
|
||||
<button type="submit">{{ _('Logout') }}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.login') }}">{{ _('Login') }}</a>
|
||||
<a href="{{ url_for('auth.register') }}">{{ _('Join free') }}</a>
|
||||
{% endif %}
|
||||
<div style="padding:.5rem 0;border-top:1px solid rgba(255,255,255,.1);margin-top:.25rem;display:flex;gap:1rem;">
|
||||
<a href="{{ url_for('set_lang', lang='en') }}" style="font-size:1.1rem;text-decoration:none;color:#fff;">🇬🇧</a>
|
||||
<a href="{{ url_for('set_lang', lang='fr') }}" style="font-size:1.1rem;text-decoration:none;color:#fff;">🇫🇷</a>
|
||||
<a href="{{ url_for('set_lang', lang='de') }}" style="font-size:1.1rem;text-decoration:none;color:#fff;">🇩🇪</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
@@ -237,9 +325,21 @@
|
||||
e.stopPropagation();
|
||||
document.getElementById('userDropdown').classList.toggle('open');
|
||||
}
|
||||
function toggleLangDropdown(e) {
|
||||
e.stopPropagation();
|
||||
document.getElementById('langDropdown').classList.toggle('open');
|
||||
}
|
||||
function toggleMobileNav(e) {
|
||||
e.stopPropagation();
|
||||
document.getElementById('mainNav').classList.toggle('open');
|
||||
}
|
||||
document.addEventListener('click', function() {
|
||||
var d = document.getElementById('userDropdown');
|
||||
if (d) d.classList.remove('open');
|
||||
var l = document.getElementById('langDropdown');
|
||||
if (l) l.classList.remove('open');
|
||||
var n = document.getElementById('mainNav');
|
||||
if (n) n.classList.remove('open');
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard — The Shooter's Network{% endblock %}
|
||||
{% block title %}{{ _('Dashboard') }} — The Shooter's Network{% endblock %}
|
||||
{% block content %}
|
||||
<h1>Dashboard</h1>
|
||||
<h1>{{ _('Dashboard') }}</h1>
|
||||
<p style="color:#555;margin-bottom:2rem;">
|
||||
Welcome back, <strong>{{ current_user.display_name or current_user.email }}</strong>.
|
||||
{{ _('Welcome back, %(name)s.', name=(current_user.display_name or current_user.email)) }}
|
||||
</p>
|
||||
|
||||
<div style="display:flex;gap:1.5rem;margin-bottom:2.5rem;flex-wrap:wrap;">
|
||||
<a href="{{ url_for('sessions.new') }}"
|
||||
style="background:#1a1a2e;color:#fff;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;">
|
||||
+ New session
|
||||
{{ _('+ New session') }}
|
||||
</a>
|
||||
<a href="{{ url_for('equipment.new') }}"
|
||||
style="background:#f0f4ff;color:#1a1a2e;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;border:1px solid #c0d0f0;">
|
||||
+ Add equipment
|
||||
{{ _('+ Add equipment') }}
|
||||
</a>
|
||||
<a href="{{ url_for('analyze') }}"
|
||||
style="background:#f0f4ff;color:#1a1a2e;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;border:1px solid #c0d0f0;">
|
||||
New analysis
|
||||
{{ _('New analysis') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h2>Recent Analyses</h2>
|
||||
<h2>{{ _('Recent Analyses') }}</h2>
|
||||
|
||||
{% if analyses %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Date</th>
|
||||
<th>Shots</th>
|
||||
<th>Groups</th>
|
||||
<th>Visibility</th>
|
||||
<th>{{ _('Title') }}</th>
|
||||
<th>{{ _('Date') }}</th>
|
||||
<th>{{ _('Shots') }}</th>
|
||||
<th>{{ _('Groups') }}</th>
|
||||
<th>{{ _('Visibility') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -42,7 +42,7 @@
|
||||
<td>{{ a.shot_count }}</td>
|
||||
<td>{{ a.group_count }}</td>
|
||||
<td style="color:{% if a.is_public %}#27ae60{% else %}#888{% endif %};font-size:0.88rem;">
|
||||
{{ 'Public' if a.is_public else 'Private' }}
|
||||
{{ _('Public') if a.is_public else _('Private') }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -50,18 +50,18 @@
|
||||
</table>
|
||||
{% else %}
|
||||
<p style="color:#888;margin-top:1rem;">
|
||||
No analyses yet. <a href="{{ url_for('analyze') }}">Upload a CSV file</a> to get started — it will be saved here automatically.
|
||||
{{ _('No analyses yet.') }} <a href="{{ url_for('analyze') }}">{{ _('Upload a CSV file') }}</a> {{ _('to get started — it will be saved here automatically.') }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div style="display:flex;gap:2rem;margin-top:2.5rem;flex-wrap:wrap;">
|
||||
<div style="flex:1;min-width:180px;padding:1.25rem;border:1px solid #e0e0e0;border-radius:6px;">
|
||||
<div style="font-size:0.8rem;color:#888;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.4rem;">Equipment</div>
|
||||
<a href="{{ url_for('equipment.index') }}">Manage your rifles, scopes & gear →</a>
|
||||
<div style="font-size:0.8rem;color:#888;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.4rem;">{{ _('Equipment') }}</div>
|
||||
<a href="{{ url_for('equipment.index') }}">{{ _('Manage your rifles, scopes & gear →') }}</a>
|
||||
</div>
|
||||
<div style="flex:1;min-width:180px;padding:1.25rem;border:1px solid #e0e0e0;border-radius:6px;">
|
||||
<div style="font-size:0.8rem;color:#888;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.4rem;">Sessions</div>
|
||||
<a href="{{ url_for('sessions.index') }}">View your shooting sessions →</a>
|
||||
<div style="font-size:0.8rem;color:#888;text-transform:uppercase;letter-spacing:.05em;margin-bottom:.4rem;">{{ _('Sessions') }}</div>
|
||||
<a href="{{ url_for('sessions.index') }}">{{ _('View your shooting sessions →') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% set editing = item is not none %}
|
||||
{% block title %}{{ 'Edit' if editing else 'Add' }} Equipment — The Shooter's Network{% endblock %}
|
||||
{% block title %}{{ _('Edit') if editing else _('Add equipment') }} — The Shooter's Network{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ 'Edit' if editing else 'Add equipment' }}</h1>
|
||||
<h1>{{ _('Edit') if editing else _('Add equipment') }}</h1>
|
||||
|
||||
{% set f = prefill or item %}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
style="max-width:520px;">
|
||||
|
||||
<div style="margin-bottom:1rem;">
|
||||
<label class="field-label">Category *</label>
|
||||
<label class="field-label">{{ _('Category *') }}</label>
|
||||
<select name="category" required style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;background:#fff;">
|
||||
{% for key, label in categories %}
|
||||
<option value="{{ key }}" {% if (f and f.category == key) or (not f and key == 'rifle') %}selected{% endif %}>{{ label }}</option>
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:1rem;">
|
||||
<label class="field-label">Name *</label>
|
||||
<label class="field-label">{{ _('Name *') }}</label>
|
||||
<input type="text" name="name" value="{{ f.name if f else '' }}" required
|
||||
placeholder="e.g. Tikka T3x, Glock 17"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
|
||||
@@ -29,13 +29,13 @@
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
|
||||
<div>
|
||||
<label class="field-label">Brand</label>
|
||||
<label class="field-label">{{ _('Brand') }}</label>
|
||||
<input type="text" name="brand" value="{{ f.brand if f else '' }}"
|
||||
placeholder="e.g. Tikka, Leupold"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">Model</label>
|
||||
<label class="field-label">{{ _('Model') }}</label>
|
||||
<input type="text" name="model" value="{{ f.model if f else '' }}"
|
||||
placeholder="e.g. T3x, VX-3HD"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
|
||||
@@ -45,7 +45,7 @@
|
||||
<div id="rifle-fields" style="margin-bottom:1rem;">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;">
|
||||
<div>
|
||||
<label class="field-label">Caliber</label>
|
||||
<label class="field-label">{{ _('Caliber') }}</label>
|
||||
<input type="text" name="caliber" value="{{ f.caliber if f else '' }}"
|
||||
placeholder="e.g. .308 Win, 6.5 CM"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
|
||||
@@ -56,13 +56,13 @@
|
||||
<div id="scope-fields" style="display:none;margin-bottom:1rem;">
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;">
|
||||
<div>
|
||||
<label class="field-label">Magnification</label>
|
||||
<label class="field-label">{{ _('Magnification') }}</label>
|
||||
<input type="text" name="magnification" value="{{ f.magnification if f else '' }}"
|
||||
placeholder="e.g. 3-15x50"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">Reticle</label>
|
||||
<label class="field-label">{{ _('Reticle') }}</label>
|
||||
<select name="reticle" style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;background:#fff;">
|
||||
<option value="">—</option>
|
||||
<option value="FFP" {% if f and f.reticle == 'FFP' %}selected{% endif %}>FFP (First Focal Plane)</option>
|
||||
@@ -70,7 +70,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label">Unit</label>
|
||||
<label class="field-label">{{ _('Unit') }}</label>
|
||||
<select name="unit" style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;background:#fff;">
|
||||
<option value="">—</option>
|
||||
<option value="MOA" {% if f and f.unit == 'MOA' %}selected{% endif %}>MOA</option>
|
||||
@@ -81,24 +81,24 @@
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:1rem;">
|
||||
<label class="field-label">Serial number</label>
|
||||
<label class="field-label">{{ _('Serial number') }}</label>
|
||||
<input type="text" name="serial_number" value="{{ f.serial_number if f else '' }}"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:1rem;">
|
||||
<label class="field-label">Notes</label>
|
||||
<label class="field-label">{{ _('Notes') }}</label>
|
||||
<textarea name="notes" rows="3"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;resize:vertical;">{{ f.notes if f else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:1.5rem;">
|
||||
<label class="field-label">Photo</label>
|
||||
<label class="field-label">{{ _('Photo') }}</label>
|
||||
{% if editing and item.photo_url %}
|
||||
<div style="margin-bottom:0.5rem;">
|
||||
<img src="{{ item.photo_url }}" alt="Current photo"
|
||||
style="height:80px;border-radius:4px;object-fit:cover;">
|
||||
<span style="font-size:0.82rem;color:#888;margin-left:0.5rem;">Upload a new one to replace it.</span>
|
||||
<span style="font-size:0.82rem;color:#888;margin-left:0.5rem;">{{ _('Upload a new one to replace it.') }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<input type="file" name="photo" accept="image/*"
|
||||
@@ -108,10 +108,10 @@
|
||||
<div style="display:flex;gap:1rem;align-items:center;">
|
||||
<button type="submit"
|
||||
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:0.6rem 1.5rem;font-size:0.95rem;cursor:pointer;">
|
||||
{{ 'Save changes' if editing else 'Add equipment' }}
|
||||
{{ _('Save changes') if editing else _('Add equipment') }}
|
||||
</button>
|
||||
<a href="{{ url_for('equipment.detail', item_id=item.id) if editing else url_for('equipment.index') }}"
|
||||
style="font-size:0.9rem;color:#666;">Cancel</a>
|
||||
style="font-size:0.9rem;color:#666;">{{ _('Cancel') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Equipment — The Shooter's Network{% endblock %}
|
||||
{% block title %}{{ _('Equipment') }} — The Shooter's Network{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
|
||||
<h1 style="margin:0;">My Equipment</h1>
|
||||
<h1 style="margin:0;">{{ _('My Equipment') }}</h1>
|
||||
<a href="{{ url_for('equipment.new') }}"
|
||||
style="background:#1a1a2e;color:#fff;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;">
|
||||
+ Add item
|
||||
{{ _('+ Add item') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -39,11 +39,11 @@
|
||||
<div style="font-size:0.82rem;color:#888;margin-bottom:0.6rem;">{{ item.caliber }}</div>
|
||||
{% endif %}
|
||||
<div style="display:flex;gap:0.75rem;margin-top:0.5rem;">
|
||||
<a href="{{ url_for('equipment.detail', item_id=item.id) }}" style="font-size:0.85rem;">View</a>
|
||||
<a href="{{ url_for('equipment.edit', item_id=item.id) }}" style="font-size:0.85rem;">Edit</a>
|
||||
<a href="{{ url_for('equipment.detail', item_id=item.id) }}" style="font-size:0.85rem;">{{ _('View') }}</a>
|
||||
<a href="{{ url_for('equipment.edit', item_id=item.id) }}" style="font-size:0.85rem;">{{ _('Edit') }}</a>
|
||||
<form method="post" action="{{ url_for('equipment.delete', item_id=item.id) }}" style="display:inline;"
|
||||
onsubmit="return confirm('Delete {{ item.name }}?');">
|
||||
<button type="submit" class="btn-link" style="font-size:0.85rem;color:#e74c3c;">Delete</button>
|
||||
onsubmit="return confirm('{{ _('Delete %(name)s?', name=item.name) | e }}');">
|
||||
<button type="submit" class="btn-link" style="font-size:0.85rem;color:#e74c3c;">{{ _('Delete') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,8 +55,8 @@
|
||||
{% else %}
|
||||
<div style="text-align:center;padding:3rem 0;color:#888;">
|
||||
<div style="font-size:3rem;margin-bottom:1rem;">🔫</div>
|
||||
<p style="margin-bottom:1rem;">No equipment yet.</p>
|
||||
<a href="{{ url_for('equipment.new') }}">Add your first item</a>
|
||||
<p style="margin-bottom:1rem;">{{ _('No equipment yet.') }}</p>
|
||||
<a href="{{ url_for('equipment.new') }}">{{ _('Add your first item') }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,13 +6,24 @@
|
||||
<a href="/" class="nav-brand">The Shooter's Network</a>
|
||||
<div class="nav-links">
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('analyze') }}">New Analysis</a>
|
||||
<a href="{{ url_for('equipment.index') }}">Equipment</a>
|
||||
<a href="{{ url_for('sessions.index') }}">Sessions</a>
|
||||
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
|
||||
<a href="{{ url_for('analyze') }}">{{ _('New Analysis') }}</a>
|
||||
<a href="{{ url_for('equipment.index') }}">{{ _('Equipment') }}</a>
|
||||
<a href="{{ url_for('sessions.index') }}">{{ _('Sessions') }}</a>
|
||||
<a href="{{ url_for('dashboard.index') }}">{{ _('Dashboard') }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
{# Language switcher #}
|
||||
<div class="nav-dropdown" id="langDropdown">
|
||||
<button class="nav-user-btn" onclick="toggleLangDropdown(event)" style="padding:.2rem .55rem;gap:.35rem;font-size:1.1rem;">
|
||||
{% if current_lang == 'fr' %}🇫🇷{% elif current_lang == 'de' %}🇩🇪{% else %}🇬🇧{% endif %}<span class="caret">▼</span>
|
||||
</button>
|
||||
<div class="nav-dd-menu" style="min-width:130px;">
|
||||
<a href="{{ url_for('set_lang', lang='en') }}">🇬🇧 English</a>
|
||||
<a href="{{ url_for('set_lang', lang='fr') }}">🇫🇷 Français</a>
|
||||
<a href="{{ url_for('set_lang', lang='de') }}">🇩🇪 Deutsch</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if current_user.is_authenticated %}
|
||||
<div class="nav-dropdown" id="userDropdown">
|
||||
<button class="nav-user-btn" onclick="toggleDropdown(event)">
|
||||
@@ -23,18 +34,18 @@
|
||||
<span class="caret">▼</span>
|
||||
</button>
|
||||
<div class="nav-dd-menu">
|
||||
<a href="{{ url_for('auth.profile') }}">👤 Profile</a>
|
||||
<a href="{{ url_for('auth.profile') }}">👤 {{ _('Profile') }}</a>
|
||||
<hr>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}">
|
||||
<button type="submit">→ Logout</button>
|
||||
<button type="submit">→ {{ _('Logout') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.login') }}" style="color:#c8cfe0;font-size:0.9rem;text-decoration:none;">Login</a>
|
||||
<a href="{{ url_for('auth.login') }}" style="color:#c8cfe0;font-size:0.9rem;text-decoration:none;">{{ _('Login') }}</a>
|
||||
<a href="{{ url_for('auth.register') }}"
|
||||
style="background:#1f77b4;color:#fff;padding:0.35rem 0.9rem;border-radius:4px;font-size:0.88rem;text-decoration:none;">
|
||||
Join free
|
||||
{{ _('Join free') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -44,9 +55,15 @@ function toggleDropdown(e) {
|
||||
e.stopPropagation();
|
||||
document.getElementById('userDropdown').classList.toggle('open');
|
||||
}
|
||||
function toggleLangDropdown(e) {
|
||||
e.stopPropagation();
|
||||
document.getElementById('langDropdown').classList.toggle('open');
|
||||
}
|
||||
document.addEventListener('click', function() {
|
||||
var d = document.getElementById('userDropdown');
|
||||
if (d) d.classList.remove('open');
|
||||
var l = document.getElementById('langDropdown');
|
||||
if (l) l.classList.remove('open');
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -63,20 +80,20 @@ document.addEventListener('click', function() {
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('analyze') }}"
|
||||
style="background:#1f77b4;color:#fff;padding:0.75rem 1.75rem;border-radius:6px;font-size:1rem;font-weight:600;text-decoration:none;">
|
||||
New Analysis
|
||||
{{ _('New Analysis') }}
|
||||
</a>
|
||||
<a href="{{ url_for('sessions.new') }}"
|
||||
style="background:transparent;color:#fff;padding:0.75rem 1.75rem;border-radius:6px;font-size:1rem;font-weight:600;text-decoration:none;border:1px solid #4a5568;">
|
||||
Log a Session
|
||||
{{ _('Log a Session') }}
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.register') }}"
|
||||
style="background:#1f77b4;color:#fff;padding:0.75rem 1.75rem;border-radius:6px;font-size:1rem;font-weight:600;text-decoration:none;">
|
||||
Get started — free
|
||||
{{ _('Get started — free') }}
|
||||
</a>
|
||||
<a href="{{ url_for('analyze') }}"
|
||||
style="background:transparent;color:#fff;padding:0.75rem 1.75rem;border-radius:6px;font-size:1rem;font-weight:600;text-decoration:none;border:1px solid #4a5568;">
|
||||
Try without account
|
||||
{{ _('Try without account') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -87,18 +104,18 @@ document.addEventListener('click', function() {
|
||||
<div style="max-width:900px;margin:0 auto;display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:1.25rem;">
|
||||
<div style="background:#fff;padding:1.5rem;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.07);">
|
||||
<div style="font-size:1.6rem;margin-bottom:0.5rem;">📊</div>
|
||||
<h3 style="margin:0 0 .35rem;color:#1a1a2e;font-size:1rem;">Ballistic Analysis</h3>
|
||||
<p style="color:#666;font-size:0.88rem;margin:0;">Upload CSV files from your chronograph and get instant shot-group statistics, velocity charts, and PDF reports.</p>
|
||||
<h3 style="margin:0 0 .35rem;color:#1a1a2e;font-size:1rem;">{{ _('Ballistic Analysis') }}</h3>
|
||||
<p style="color:#666;font-size:0.88rem;margin:0;">{{ _('Upload CSV files from your chronograph and get instant shot-group statistics, velocity charts, and PDF reports.') }}</p>
|
||||
</div>
|
||||
<div style="background:#fff;padding:1.5rem;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.07);">
|
||||
<div style="font-size:1.6rem;margin-bottom:0.5rem;">🎯</div>
|
||||
<h3 style="margin:0 0 .35rem;color:#1a1a2e;font-size:1rem;">Session Tracking</h3>
|
||||
<p style="color:#666;font-size:0.88rem;margin:0;">Log every range visit with location, weather, rifle, ammo, and distance. All your data in one place.</p>
|
||||
<h3 style="margin:0 0 .35rem;color:#1a1a2e;font-size:1rem;">{{ _('Session Tracking') }}</h3>
|
||||
<p style="color:#666;font-size:0.88rem;margin:0;">{{ _('Log every range visit with location, weather, rifle, ammo, and distance. All your data in one place.') }}</p>
|
||||
</div>
|
||||
<div style="background:#fff;padding:1.5rem;border-radius:8px;box-shadow:0 1px 4px rgba(0,0,0,.07);">
|
||||
<div style="font-size:1.6rem;margin-bottom:0.5rem;">🤝</div>
|
||||
<h3 style="margin:0 0 .35rem;color:#1a1a2e;font-size:1rem;">Community Feed</h3>
|
||||
<p style="color:#666;font-size:0.88rem;margin:0;">Share your public sessions and see what other shooters are achieving on the range.</p>
|
||||
<h3 style="margin:0 0 .35rem;color:#1a1a2e;font-size:1rem;">{{ _('Community Feed') }}</h3>
|
||||
<p style="color:#666;font-size:0.88rem;margin:0;">{{ _('Share your public sessions and see what other shooters are achieving on the range.') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -118,7 +135,7 @@ document.addEventListener('click', function() {
|
||||
<section style="padding:2.5rem 1.5rem 3rem;">
|
||||
<div style="max-width:960px;margin:0 auto;">
|
||||
<h2 style="font-size:1.3rem;color:#1a1a2e;margin-bottom:1.25rem;border-bottom:2px solid #e0e0e0;padding-bottom:.4rem;">
|
||||
Latest sessions
|
||||
{{ _('Latest sessions') }}
|
||||
</h2>
|
||||
|
||||
{% if public_sessions %}
|
||||
@@ -149,7 +166,7 @@ document.addEventListener('click', function() {
|
||||
</div>
|
||||
{% else %}
|
||||
<p style="color:#aaa;text-align:center;padding:3rem 0;">
|
||||
No public sessions yet. Be the first to share one!
|
||||
{{ _('No public sessions yet. Be the first to share one!') }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
|
||||
<h1 style="margin:0;">Analysis Results</h1>
|
||||
<h1 style="margin:0;">{{ _('Analysis Results') }}</h1>
|
||||
<div style="display:flex;gap:0.75rem;align-items:center;flex-wrap:wrap;">
|
||||
<a href="/">← Upload another file</a>
|
||||
<a href="/">{{ _('← Upload another file') }}</a>
|
||||
{% if saved_analysis_id %}
|
||||
<a href="{{ url_for('analyses.detail', analysis_id=saved_analysis_id) }}"
|
||||
style="font-size:0.9rem;color:#1f77b4;">View saved report →</a>
|
||||
style="font-size:0.9rem;color:#1f77b4;">{{ _('View saved report →') }}</a>
|
||||
{% endif %}
|
||||
<a href="data:application/pdf;base64,{{ pdf_b64 }}"
|
||||
download="ballistic_report.pdf"
|
||||
style="background:#1f77b4;color:#fff;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;text-decoration:none;">
|
||||
⇓ Download PDF report
|
||||
{{ _('⬙ Download PDF report') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Overall Statistics</h2>
|
||||
<h2>{{ _('Overall Statistics') }}</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Metric</th>
|
||||
<th>Value</th>
|
||||
<th>{{ _('Metric') }}</th>
|
||||
<th>{{ _('Value') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Total shots</td><td>{{ overall.count }}</td></tr>
|
||||
<tr><td>Min speed</td><td>{{ "%.4f"|format(overall.min_speed) }}</td></tr>
|
||||
<tr><td>Max speed</td><td>{{ "%.4f"|format(overall.max_speed) }}</td></tr>
|
||||
<tr><td>Mean speed</td><td>{{ "%.4f"|format(overall.mean_speed) }}</td></tr>
|
||||
<tr><td>{{ _('Total shots') }}</td><td>{{ overall.count }}</td></tr>
|
||||
<tr><td>{{ _('Min speed') }}</td><td>{{ "%.4f"|format(overall.min_speed) }}</td></tr>
|
||||
<tr><td>{{ _('Max speed') }}</td><td>{{ "%.4f"|format(overall.max_speed) }}</td></tr>
|
||||
<tr><td>{{ _('Mean speed') }}</td><td>{{ "%.4f"|format(overall.mean_speed) }}</td></tr>
|
||||
<tr>
|
||||
<td>Std dev (speed)</td>
|
||||
<td>{{ _('Std dev (speed)') }}</td>
|
||||
<td>
|
||||
{% if overall.std_speed is not none %}
|
||||
{{ "%.4f"|format(overall.std_speed) }}
|
||||
@@ -45,27 +45,27 @@
|
||||
<img class="chart-img" src="data:image/png;base64,{{ overview_chart }}"
|
||||
alt="Avg speed and std dev per group" style="max-width:600px;margin:1rem 0 1.5rem;">
|
||||
|
||||
<h2>Groups — {{ groups_display|length }} group(s) detected</h2>
|
||||
<h2>{{ _('Groups') }} — {{ groups_display|length }} {{ _('group(s) detected') }}</h2>
|
||||
|
||||
{% for stat, chart_b64 in groups_display %}
|
||||
<div class="group-section">
|
||||
<h3>Group {{ stat.group_index }}</h3>
|
||||
<h3>{{ _('Group %(n)s', n=stat.group_index) }}</h3>
|
||||
<div class="group-meta">
|
||||
{{ stat.time_start }} – {{ stat.time_end }} | {{ stat.count }} shot(s)
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Metric</th>
|
||||
<th>Value</th>
|
||||
<th>{{ _('Metric') }}</th>
|
||||
<th>{{ _('Value') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Min speed</td><td>{{ "%.4f"|format(stat.min_speed) }}</td></tr>
|
||||
<tr><td>Max speed</td><td>{{ "%.4f"|format(stat.max_speed) }}</td></tr>
|
||||
<tr><td>Mean speed</td><td>{{ "%.4f"|format(stat.mean_speed) }}</td></tr>
|
||||
<tr><td>{{ _('Min speed') }}</td><td>{{ "%.4f"|format(stat.min_speed) }}</td></tr>
|
||||
<tr><td>{{ _('Max speed') }}</td><td>{{ "%.4f"|format(stat.max_speed) }}</td></tr>
|
||||
<tr><td>{{ _('Mean speed') }}</td><td>{{ "%.4f"|format(stat.mean_speed) }}</td></tr>
|
||||
<tr>
|
||||
<td>Std dev (speed)</td>
|
||||
<td>{{ _('Std dev (speed)') }}</td>
|
||||
<td>
|
||||
{% if stat.std_speed is not none %}
|
||||
{{ "%.4f"|format(stat.std_speed) }}
|
||||
|
||||
@@ -8,7 +8,14 @@
|
||||
<a href="{{ url_for('sessions.detail', session_id=session.id) }}">{{ session.label }}</a> ›
|
||||
Annotate
|
||||
</div>
|
||||
<h1 style="margin:0;">{{ photo.caption or 'Photo annotation' }}</h1>
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem;">
|
||||
<h1 style="margin:0;">{{ photo.caption or 'Photo annotation' }}</h1>
|
||||
<a href="{{ url_for('sessions.detail', session_id=session.id) }}"
|
||||
style="background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;border-radius:4px;
|
||||
padding:.45rem 1rem;font-size:0.88rem;text-decoration:none;white-space:nowrap;">
|
||||
✕ Close
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:1.5rem;align-items:flex-start;">
|
||||
@@ -49,8 +56,8 @@
|
||||
|
||||
<hr style="border:none;border-top:1px solid #e8e8e8;margin-bottom:1rem;">
|
||||
|
||||
{# Shooting distance (always visible) #}
|
||||
<div style="margin-bottom:1rem;">
|
||||
{# Shooting distance (always visible, pre-filled from session) #}
|
||||
<div style="margin-bottom:.75rem;">
|
||||
<label style="display:block;font-size:.82rem;font-weight:600;color:#444;margin-bottom:.3rem;">Shooting distance</label>
|
||||
<div style="display:flex;gap:.4rem;">
|
||||
<input type="number" id="shoot-dist" min="1" step="1" placeholder="100"
|
||||
@@ -63,6 +70,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Clean barrel checkbox #}
|
||||
<div style="margin-bottom:1rem;">
|
||||
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:0.88rem;color:#444;">
|
||||
<input type="checkbox" id="clean-barrel" style="width:1rem;height:1rem;">
|
||||
Clean barrel (first shot)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<hr style="border:none;border-top:1px solid #e8e8e8;margin-bottom:1rem;">
|
||||
|
||||
{# Step 0: Reference line #}
|
||||
@@ -117,7 +132,7 @@
|
||||
<div id="panel-3" class="step-panel" style="display:none;">
|
||||
<div id="results-box" style="margin-bottom:1rem;"></div>
|
||||
<div style="display:flex;gap:.5rem;flex-wrap:wrap;">
|
||||
<button class="btn-primary" id="btn-save" onclick="saveAnnotations()">Save</button>
|
||||
<button class="btn-primary" id="btn-save" onclick="saveAnnotations()">Save & close</button>
|
||||
<button class="btn-ghost" onclick="goStep(2)">← Edit</button>
|
||||
</div>
|
||||
<div id="save-status" style="font-size:0.82rem;margin-top:.5rem;"></div>
|
||||
@@ -145,9 +160,11 @@
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const PHOTO_URL = {{ photo.photo_url | tojson }};
|
||||
const SAVE_URL = {{ url_for('sessions.annotate_photo', session_id=session.id, photo_id=photo.id) | tojson }};
|
||||
const EXISTING = {{ (photo.annotations or {}) | tojson }};
|
||||
const PHOTO_URL = {{ photo.photo_url | tojson }};
|
||||
const SAVE_URL = {{ url_for('sessions.annotate_photo', session_id=session.id, photo_id=photo.id) | tojson }};
|
||||
const SESSION_URL = {{ url_for('sessions.detail', session_id=session.id) | tojson }};
|
||||
const SESSION_DIST_M = {{ (session.distance_m or 'null') }};
|
||||
const EXISTING = {{ (photo.annotations or {}) | tojson }};
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
let step = 0;
|
||||
@@ -408,6 +425,8 @@ function renderResults() {
|
||||
<span class="stat-val">${stats.shot_count}</span></div>
|
||||
<div class="stat-row"><span class="stat-label">@ distance</span>
|
||||
<span class="stat-val">${stats.shooting_distance_m.toFixed(0)} m</span></div>
|
||||
<div class="stat-row"><span class="stat-label">Clean barrel</span>
|
||||
<span class="stat-val">${document.getElementById('clean-barrel').checked ? 'Yes' : 'No'}</span></div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -554,6 +573,7 @@ async function saveAnnotations() {
|
||||
poa: toFrac(poa),
|
||||
pois: pois.map(toFrac),
|
||||
shooting_distance_m: stats.shooting_distance_m,
|
||||
clean_barrel: document.getElementById('clean-barrel').checked,
|
||||
stats: stats,
|
||||
};
|
||||
|
||||
@@ -564,8 +584,7 @@ async function saveAnnotations() {
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (resp.ok) {
|
||||
status.style.color = '#27ae60';
|
||||
status.textContent = 'Saved!';
|
||||
window.location.href = SESSION_URL;
|
||||
} else {
|
||||
throw new Error('Server error');
|
||||
}
|
||||
@@ -578,6 +597,12 @@ async function saveAnnotations() {
|
||||
|
||||
// ── Load existing annotations ──────────────────────────────────────────────
|
||||
function loadExisting() {
|
||||
// Always pre-fill shooting distance from session if available
|
||||
if (SESSION_DIST_M) {
|
||||
document.getElementById('shoot-dist').value = SESSION_DIST_M;
|
||||
document.getElementById('shoot-unit').value = 'm';
|
||||
}
|
||||
|
||||
if (!EXISTING || !EXISTING.ref) return;
|
||||
const W = img.naturalWidth, H = img.naturalHeight;
|
||||
function fromFrac(f) { return { x: f.x * W, y: f.y * H }; }
|
||||
@@ -597,6 +622,9 @@ function loadExisting() {
|
||||
document.getElementById('shoot-dist').value = EXISTING.shooting_distance_m.toFixed(0);
|
||||
document.getElementById('shoot-unit').value = 'm';
|
||||
}
|
||||
if (EXISTING.clean_barrel) {
|
||||
document.getElementById('clean-barrel').checked = true;
|
||||
}
|
||||
if (EXISTING.stats) {
|
||||
stats = EXISTING.stats;
|
||||
renderResults();
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{% if is_owner %}<a href="{{ url_for('sessions.index') }}">Sessions</a> › {% endif %}
|
||||
{{ session.session_date.strftime('%d %b %Y') }}
|
||||
{% if session.is_public %}
|
||||
<span style="background:#e8f5e9;color:#27ae60;font-size:0.75rem;padding:.1rem .45rem;border-radius:3px;margin-left:.4rem;">Public</span>
|
||||
<span style="background:#e8f5e9;color:#27ae60;font-size:0.75rem;padding:.1rem .45rem;border-radius:3px;margin-left:.4rem;">{{ _('Public') }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h1 style="margin:0;">{{ session.label }}</h1>
|
||||
@@ -20,13 +20,13 @@
|
||||
<div style="display:flex;gap:.75rem;">
|
||||
<a href="{{ url_for('sessions.edit', session_id=session.id) }}"
|
||||
style="background:#f0f4ff;color:#1a1a2e;padding:0.5rem 1.1rem;border-radius:4px;font-size:0.9rem;text-decoration:none;">
|
||||
Edit
|
||||
{{ _('Edit') }}
|
||||
</a>
|
||||
<form method="post" action="{{ url_for('sessions.delete', session_id=session.id) }}"
|
||||
onsubmit="return confirm('Delete this session? This cannot be undone.');">
|
||||
onsubmit="return confirm('{{ _('Delete this session? This cannot be undone.') | e }}');">
|
||||
<button type="submit"
|
||||
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:4px;padding:0.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
|
||||
Delete
|
||||
{{ _('Delete') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
{% if session.location_name or session.distance_m %}
|
||||
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
|
||||
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">Location</div>
|
||||
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">{{ _('Location') }}</div>
|
||||
{% if session.location_name %}<div style="font-weight:600;">{{ session.location_name }}</div>{% endif %}
|
||||
{% if session.distance_m %}<div style="color:#555;font-size:0.9rem;">{{ session.distance_m }} m</div>{% endif %}
|
||||
</div>
|
||||
@@ -46,18 +46,18 @@
|
||||
|
||||
{% if session.weather_cond or session.weather_temp_c is not none or session.weather_wind_kph is not none %}
|
||||
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
|
||||
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">Weather</div>
|
||||
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">{{ _('Weather') }}</div>
|
||||
{% if session.weather_cond %}<div style="font-weight:600;">{{ session.weather_cond.replace('_',' ').title() }}</div>{% endif %}
|
||||
<div style="color:#555;font-size:0.9rem;">
|
||||
{% if session.weather_temp_c is not none %}{{ session.weather_temp_c }}°C{% endif %}
|
||||
{% if session.weather_wind_kph is not none %} {{ session.weather_wind_kph }} km/h wind{% endif %}
|
||||
{% if session.weather_wind_kph is not none %} {{ session.weather_wind_kph }} {{ _('km/h wind') }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if session.rifle %}
|
||||
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
|
||||
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">Rifle / Handgun</div>
|
||||
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">{{ _('Rifle / Handgun') }}</div>
|
||||
<div style="font-weight:600;">{{ session.rifle.name }}</div>
|
||||
{% if session.rifle.caliber %}<div style="color:#555;font-size:0.9rem;">{{ session.rifle.caliber }}</div>{% endif %}
|
||||
</div>
|
||||
@@ -65,14 +65,14 @@
|
||||
|
||||
{% if session.scope %}
|
||||
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
|
||||
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">Scope</div>
|
||||
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">{{ _('Scope') }}</div>
|
||||
<div style="font-weight:600;">{{ session.scope.name }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if session.ammo_brand or session.ammo_weight_gr is not none %}
|
||||
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
|
||||
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">Ammo</div>
|
||||
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">{{ _('Ammunition') }}</div>
|
||||
{% if session.ammo_brand %}<div style="font-weight:600;">{{ session.ammo_brand }}</div>{% endif %}
|
||||
<div style="color:#555;font-size:0.9rem;">
|
||||
{% if session.ammo_weight_gr is not none %}{{ session.ammo_weight_gr }} gr{% endif %}
|
||||
@@ -81,16 +81,247 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if session.shooting_position %}
|
||||
<div style="background:#f8f9fb;border-radius:6px;padding:1rem;">
|
||||
<div style="font-size:0.75rem;text-transform:uppercase;letter-spacing:.05em;color:#888;margin-bottom:.4rem;">{{ _('Position') }}</div>
|
||||
<div style="font-weight:600;">{{ session.shooting_position.replace('_',' ').title() }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{% if session.notes %}
|
||||
<h2>Notes</h2>
|
||||
<h2>{{ _('Notes') }}</h2>
|
||||
<p style="color:#555;white-space:pre-wrap;">{{ session.notes }}</p>
|
||||
{% endif %}
|
||||
|
||||
{# ---- PRS Stages ---- #}
|
||||
{% if session.session_type == 'prs' %}
|
||||
<h2>{{ _('PRS Stages') }}</h2>
|
||||
|
||||
{% set stages = session.prs_stages or [] %}
|
||||
|
||||
{# Stage table (read + edit combined) #}
|
||||
<div style="overflow-x:auto;margin-bottom:1rem;">
|
||||
<table id="prs-table" style="min-width:900px;font-size:0.88rem;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:36px;">N°</th>
|
||||
<th>{{ _('Name') }}</th>
|
||||
<th style="width:80px;">Dist. (m)</th>
|
||||
<th style="width:75px;">{{ _('Time (s)') }}</th>
|
||||
<th style="width:110px;">Position</th>
|
||||
<th style="width:110px;">{{ _('Elevation Dope') }}</th>
|
||||
<th style="width:110px;">{{ _('Windage Dope') }}</th>
|
||||
<th style="width:80px;">{{ _('Hits/Poss.') }}</th>
|
||||
<th>{{ _('Notes') }}</th>
|
||||
{% if is_owner %}<th style="width:36px;"></th>{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="prs-tbody">
|
||||
{% for st in stages %}
|
||||
<tr data-idx="{{ loop.index0 }}">
|
||||
<td style="text-align:center;font-weight:600;">{{ st.num or loop.index }}</td>
|
||||
<td>{{ st.name or '' }}</td>
|
||||
<td style="text-align:center;">{{ st.distance_m or '' }}</td>
|
||||
<td style="text-align:center;">{{ st.time_s or '' }}</td>
|
||||
<td>{{ st.position or '' }}</td>
|
||||
<td style="text-align:center;">{{ st.dope_elevation or '' }}</td>
|
||||
<td style="text-align:center;">{{ st.dope_windage or '' }}</td>
|
||||
<td style="text-align:center;">
|
||||
{% if st.hits is not none %}{{ st.hits }}{% endif %}{% if st.possible %}/{{ st.possible }}{% endif %}
|
||||
</td>
|
||||
<td>{{ st.notes or '' }}</td>
|
||||
{% if is_owner %}<td></td>{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if is_owner %}
|
||||
<div style="display:flex;gap:.75rem;flex-wrap:wrap;margin-bottom:1.5rem;">
|
||||
<button onclick="enterEditMode()" id="btn-edit-stages"
|
||||
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
|
||||
{{ _('✏ Edit stages') }}
|
||||
</button>
|
||||
<a href="{{ url_for('sessions.dope_card', session_id=session.id) }}" target="_blank"
|
||||
style="background:#27ae60;color:#fff;padding:.5rem 1.1rem;border-radius:4px;font-size:0.9rem;text-decoration:none;">
|
||||
{{ _('📄 Generate dope card (PDF)') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# Hidden form used by JS to save stages #}
|
||||
<form method="post" action="{{ url_for('sessions.save_stages', session_id=session.id) }}" id="stages-form" style="display:none;">
|
||||
<input type="hidden" name="stages_json" id="stages-json-input">
|
||||
</form>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var PRS_POS = [
|
||||
["prone", "Couché"],
|
||||
["standing", "Debout"],
|
||||
["kneeling", "Agenouillé"],
|
||||
["sitting", "Assis"],
|
||||
["barricade", "Barricade"],
|
||||
["rooftop", "Toit"],
|
||||
["unknown", "Variable"],
|
||||
];
|
||||
|
||||
var stages = {{ (session.prs_stages or []) | tojson }};
|
||||
|
||||
function posLabel(slug) {
|
||||
var m = PRS_POS.find(function(p) { return p[0] === slug; });
|
||||
return m ? m[1] : slug;
|
||||
}
|
||||
|
||||
function posSelect(val) {
|
||||
var s = document.createElement('select');
|
||||
s.style.cssText = 'width:100%;font-size:0.85rem;padding:.2rem;border:1px solid #ccc;border-radius:3px;';
|
||||
PRS_POS.forEach(function(p) {
|
||||
var o = document.createElement('option');
|
||||
o.value = p[0]; o.textContent = p[1];
|
||||
if (p[0] === val) o.selected = true;
|
||||
s.appendChild(o);
|
||||
});
|
||||
return s;
|
||||
}
|
||||
|
||||
function inp(val, type, placeholder) {
|
||||
var i = document.createElement('input');
|
||||
i.type = type || 'text';
|
||||
i.value = val == null ? '' : val;
|
||||
if (placeholder) i.placeholder = placeholder;
|
||||
i.style.cssText = 'width:100%;font-size:0.85rem;padding:.2rem .4rem;border:1px solid #ccc;border-radius:3px;box-sizing:border-box;';
|
||||
return i;
|
||||
}
|
||||
|
||||
function collectStages() {
|
||||
var rows = document.querySelectorAll('#prs-tbody tr');
|
||||
var result = [];
|
||||
rows.forEach(function(tr, i) {
|
||||
var cells = tr.querySelectorAll('td');
|
||||
if (!cells.length) return;
|
||||
function val(idx) {
|
||||
var el = cells[idx];
|
||||
if (!el) return '';
|
||||
var input = el.querySelector('input,select,textarea');
|
||||
return input ? (input.value || '') : (el.textContent.trim() || '');
|
||||
}
|
||||
result.push({
|
||||
num: parseInt(val(0)) || i + 1,
|
||||
name: val(1),
|
||||
distance_m: parseInt(val(2)) || null,
|
||||
time_s: parseInt(val(3)) || null,
|
||||
position: val(4),
|
||||
dope_elevation: val(5),
|
||||
dope_windage: val(6),
|
||||
hits: val(7).split('/')[0] !== '' ? parseInt(val(7).split('/')[0]) : null,
|
||||
possible: val(7).split('/')[1] !== undefined ? (parseInt(val(7).split('/')[1]) || null) : null,
|
||||
notes: val(8),
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function addRow(st) {
|
||||
var tbody = document.getElementById('prs-tbody');
|
||||
var idx = tbody.rows.length;
|
||||
st = st || { num: idx + 1 };
|
||||
var tr = document.createElement('tr');
|
||||
tr.dataset.idx = idx;
|
||||
|
||||
var hitsVal = '';
|
||||
if (st.hits != null) hitsVal = st.hits;
|
||||
if (st.possible != null) hitsVal = (hitsVal !== '' ? hitsVal : '') + '/' + st.possible;
|
||||
|
||||
[
|
||||
inp(st.num, 'number'),
|
||||
inp(st.name),
|
||||
inp(st.distance_m, 'number'),
|
||||
inp(st.time_s, 'number'),
|
||||
posSelect(st.position || ''),
|
||||
inp(st.dope_elevation, 'text', 'ex : 3.5 MOA'),
|
||||
inp(st.dope_windage, 'text', 'ex : 0.5 MOA D'),
|
||||
inp(hitsVal, 'text', 'x/y'),
|
||||
inp(st.notes),
|
||||
].forEach(function(el) {
|
||||
var td = document.createElement('td');
|
||||
td.appendChild(el);
|
||||
tr.appendChild(td);
|
||||
});
|
||||
|
||||
// Remove button
|
||||
var tdDel = document.createElement('td');
|
||||
tdDel.style.textAlign = 'center';
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = '✕';
|
||||
btn.style.cssText = 'background:none;border:none;color:#c0392b;cursor:pointer;font-size:1rem;padding:0 .3rem;';
|
||||
btn.onclick = function() { tr.remove(); renumber(); };
|
||||
tdDel.appendChild(btn);
|
||||
tr.appendChild(tdDel);
|
||||
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
function renumber() {
|
||||
document.querySelectorAll('#prs-tbody tr').forEach(function(tr, i) {
|
||||
var first = tr.querySelector('td:first-child input');
|
||||
if (first && first.type === 'number') first.value = i + 1;
|
||||
});
|
||||
}
|
||||
|
||||
window.enterEditMode = function () {
|
||||
// Replace read-only rows with editable rows
|
||||
var tbody = document.getElementById('prs-tbody');
|
||||
tbody.innerHTML = '';
|
||||
stages.forEach(function(st) { addRow(st); });
|
||||
if (!stages.length) addRow(null);
|
||||
|
||||
document.getElementById('btn-edit-stages').style.display = 'none';
|
||||
|
||||
var STR_ADD_STAGE = '{{ _("+ Add stage") | e }}';
|
||||
var STR_SAVE = '{{ _("💾 Save") | e }}';
|
||||
var STR_CANCEL = '{{ _("Cancel") | e }}';
|
||||
|
||||
// Add / Save / Cancel buttons
|
||||
var bar = document.createElement('div');
|
||||
bar.id = 'edit-bar';
|
||||
bar.style.cssText = 'display:flex;gap:.6rem;flex-wrap:wrap;margin:.75rem 0;';
|
||||
bar.innerHTML =
|
||||
'<button type="button" onclick="addRow(null);renumber();" ' +
|
||||
'style="background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;border-radius:4px;padding:.4rem .9rem;font-size:0.88rem;cursor:pointer;">' +
|
||||
STR_ADD_STAGE + '</button>' +
|
||||
'<button type="button" onclick="saveStages()" ' +
|
||||
'style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.4rem .9rem;font-size:0.88rem;cursor:pointer;">' +
|
||||
STR_SAVE + '</button>' +
|
||||
'<button type="button" onclick="cancelEdit()" ' +
|
||||
'style="background:none;color:#666;border:none;font-size:0.88rem;cursor:pointer;padding:.4rem .5rem;">' + STR_CANCEL + '</button>';
|
||||
document.getElementById('prs-table').after(bar);
|
||||
};
|
||||
|
||||
window.saveStages = function () {
|
||||
var data = collectStages();
|
||||
document.getElementById('stages-json-input').value = JSON.stringify(data);
|
||||
document.getElementById('stages-form').submit();
|
||||
};
|
||||
|
||||
window.cancelEdit = function () {
|
||||
location.reload();
|
||||
};
|
||||
|
||||
window.addRow = addRow;
|
||||
window.renumber = renumber;
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}{# end prs #}
|
||||
|
||||
{# ---- Photos ---- #}
|
||||
{% if session.photos or is_owner %}
|
||||
<h2>Photos</h2>
|
||||
<h2>{{ _('Photos') }}</h2>
|
||||
{% if session.photos %}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
|
||||
{% for photo in session.photos %}
|
||||
@@ -105,7 +336,7 @@
|
||||
{% if is_owner %}
|
||||
<form method="post"
|
||||
action="{{ url_for('sessions.delete_photo', session_id=session.id, photo_id=photo.id) }}"
|
||||
onsubmit="return confirm('Delete this photo?');"
|
||||
onsubmit="return confirm('{{ _('Delete this photo?') | e }}');"
|
||||
style="position:absolute;top:4px;right:4px;">
|
||||
<button type="submit"
|
||||
style="background:rgba(0,0,0,.5);color:#fff;border:none;border-radius:3px;padding:.2rem .45rem;font-size:0.8rem;cursor:pointer;line-height:1.2;">
|
||||
@@ -116,8 +347,32 @@
|
||||
</div>
|
||||
{% if photo.annotations and photo.annotations.stats %}
|
||||
{% set s = photo.annotations.stats %}
|
||||
<div style="font-size:0.78rem;background:#f0f4ff;color:#1a1a2e;padding:.2rem .45rem;border-radius:3px;margin-top:.3rem;font-weight:600;">
|
||||
{{ s.shot_count }} shots · {{ '%.2f'|format(s.group_size_moa) }} MOA ES
|
||||
{% set a = photo.annotations %}
|
||||
<div style="margin-top:.5rem;background:#f8f9fb;border:1px solid #e0e0e0;border-radius:6px;padding:.6rem .75rem;font-size:0.8rem;min-width:180px;">
|
||||
<div style="font-weight:700;color:#1a1a2e;margin-bottom:.35rem;font-size:0.82rem;">
|
||||
{{ s.shot_count }} shot{{ 's' if s.shot_count != 1 }}
|
||||
· {{ s.shooting_distance_m | int }} m
|
||||
{% if a.clean_barrel %}<span style="background:#e8f5e9;color:#27ae60;border-radius:3px;padding:.05rem .3rem;margin-left:.3rem;">{{ _('clean barrel') }}</span>{% endif %}
|
||||
</div>
|
||||
<table style="width:100%;border-collapse:collapse;margin:0;">
|
||||
<tr>
|
||||
<td style="color:#666;padding:.15rem 0;border:none;">{{ _('Group ES') }}</td>
|
||||
<td style="font-weight:600;text-align:right;padding:.15rem 0;border:none;">{{ '%.2f'|format(s.group_size_moa) }} MOA</td>
|
||||
<td style="color:#888;text-align:right;padding:.15rem 0 .15rem .4rem;border:none;">{{ '%.1f'|format(s.group_size_mm) }} mm</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:#666;padding:.15rem 0;border:none;">{{ _('Mean Radius') }}</td>
|
||||
<td style="font-weight:600;text-align:right;padding:.15rem 0;border:none;">{{ '%.2f'|format(s.mean_radius_moa) }} MOA</td>
|
||||
<td style="color:#888;text-align:right;padding:.15rem 0 .15rem .4rem;border:none;">{{ '%.1f'|format(s.mean_radius_mm) }} mm</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="color:#666;padding:.15rem 0;border:none;">{{ _('Centre') }}</td>
|
||||
<td colspan="2" style="text-align:right;padding:.15rem 0;border:none;">
|
||||
{{ '%.2f'|format(s.center_dist_moa) }} MOA
|
||||
<span style="color:#888;">({{ '%.1f'|format(s.center_x_mm | abs) }} mm {{ 'R' if s.center_x_mm > 0 else 'L' if s.center_x_mm < 0 else '' }}, {{ '%.1f'|format(s.center_y_mm | abs) }} mm {{ _('low') if s.center_y_mm > 0 else _('high') if s.center_y_mm < 0 else '' }})</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if photo.caption %}
|
||||
@@ -145,7 +400,7 @@
|
||||
background:#1a1a2e;color:#fff;border:1px solid #1a1a2e;
|
||||
{% endif %}">
|
||||
{% if photo.annotations and photo.annotations.stats %}✓{% else %}▶{% endif %}
|
||||
Measure group
|
||||
{{ _('Measure group') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -159,17 +414,17 @@
|
||||
enctype="multipart/form-data"
|
||||
style="display:flex;flex-wrap:wrap;gap:.75rem;align-items:flex-end;margin-bottom:2rem;">
|
||||
<div>
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.25rem;">Add photo</label>
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.25rem;">{{ _('Add photo') }}</label>
|
||||
<input type="file" name="photo" accept="image/*" required style="font-size:0.9rem;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.25rem;">Caption (optional)</label>
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.25rem;">{{ _('Caption (optional)') }}</label>
|
||||
<input type="text" name="caption" placeholder="e.g. 300 m target"
|
||||
style="padding:.45rem .7rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
|
||||
</div>
|
||||
<button type="submit"
|
||||
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
|
||||
Upload
|
||||
{{ _('Upload') }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
@@ -178,25 +433,161 @@
|
||||
{# ---- Analyses ---- #}
|
||||
<h2>Analyses{% if analyses %} ({{ analyses|length }}){% endif %}</h2>
|
||||
|
||||
{% if analyses %}
|
||||
<table style="margin-bottom:1.5rem;">
|
||||
<thead>
|
||||
<tr><th>Title</th><th>Date</th><th>Shots</th><th>Groups</th><th>Mean speed</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in analyses %}
|
||||
<tr style="cursor:pointer;" onclick="location.href='{{ url_for('analyses.detail', analysis_id=a.id) }}'">
|
||||
<td><a href="{{ url_for('analyses.detail', analysis_id=a.id) }}" style="color:inherit;text-decoration:none;">{{ a.title }}</a></td>
|
||||
<td style="color:#666;font-size:0.88rem;">{{ a.created_at.strftime('%d %b %Y') }}</td>
|
||||
<td>{{ a.shot_count }}</td>
|
||||
<td>{{ a.group_count }}</td>
|
||||
<td>{{ "%.2f"|format(a.overall_stats.mean_speed) }} m/s</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if analyses_display %}
|
||||
{% for a, groups_display, overview_chart in analyses_display %}
|
||||
<details open style="border:1px solid #e0e0e0;border-radius:8px;margin-bottom:1.25rem;overflow:hidden;">
|
||||
<summary style="display:flex;align-items:center;gap:.75rem;padding:.85rem 1.25rem;
|
||||
background:#f8f9fb;cursor:pointer;list-style:none;flex-wrap:wrap;">
|
||||
<span style="font-weight:700;font-size:1rem;color:#1a1a2e;flex:1;">{{ a.title }}</span>
|
||||
<span style="font-size:0.82rem;color:#666;">{{ a.shot_count }} {{ _('shots') }} · {{ a.group_count }} {{ _('group') if a.group_count == 1 else _('groups') }}</span>
|
||||
<span style="font-size:0.82rem;color:#888;">{{ "%.2f"|format(a.overall_stats.mean_speed) }} {{ _('m/s mean') }}</span>
|
||||
<span style="font-size:0.78rem;color:#aaa;">{{ a.created_at.strftime('%d %b %Y') }}</span>
|
||||
</summary>
|
||||
|
||||
<div style="padding:1.25rem 1.5rem;">
|
||||
|
||||
{# --- Owner action bar: rename / standalone link / PDF / delete --- #}
|
||||
{% if is_owner %}
|
||||
<div style="display:flex;flex-wrap:wrap;gap:.6rem;align-items:center;margin-bottom:1.25rem;">
|
||||
|
||||
{# Rename inline form #}
|
||||
<details style="display:inline;">
|
||||
<summary style="display:inline-block;padding:.3rem .75rem;background:#f0f4ff;color:#1a1a2e;
|
||||
border:1px solid #c8d4f0;border-radius:4px;font-size:0.82rem;cursor:pointer;list-style:none;">
|
||||
{{ _('✏ Rename') }}
|
||||
</summary>
|
||||
<form method="post" action="{{ url_for('analyses.rename', analysis_id=a.id) }}"
|
||||
style="display:flex;gap:.5rem;align-items:center;margin-top:.5rem;">
|
||||
<input type="text" name="title" value="{{ a.title }}" required
|
||||
style="padding:.4rem .65rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;min-width:220px;">
|
||||
<button type="submit"
|
||||
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.4rem .9rem;font-size:0.85rem;cursor:pointer;">
|
||||
{{ _('Save') }}
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<a href="{{ url_for('analyses.detail', analysis_id=a.id) }}"
|
||||
style="padding:.3rem .75rem;background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;
|
||||
border-radius:4px;font-size:0.82rem;text-decoration:none;">
|
||||
{{ _('Full view') }}
|
||||
</a>
|
||||
|
||||
{% if a.pdf_path %}
|
||||
<a href="{{ url_for('analyses.download_pdf', analysis_id=a.id) }}"
|
||||
style="padding:.3rem .75rem;background:#f0f4ff;color:#1a1a2e;border:1px solid #c8d4f0;
|
||||
border-radius:4px;font-size:0.82rem;text-decoration:none;">
|
||||
↧ PDF
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('analyses.delete', analysis_id=a.id) }}"
|
||||
onsubmit="return confirm('{{ _('Delete this analysis? This cannot be undone.') | e }}');"
|
||||
style="display:inline;">
|
||||
<button type="submit"
|
||||
style="background:#fff0f0;color:#c0392b;border:1px solid #f5c6c6;border-radius:4px;
|
||||
padding:.3rem .75rem;font-size:0.82rem;cursor:pointer;">
|
||||
{{ _('Delete') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# --- Overview chart --- #}
|
||||
{% if overview_chart %}
|
||||
<img src="data:image/png;base64,{{ overview_chart }}" class="chart-img" alt="Overview chart"
|
||||
style="margin-bottom:1.25rem;">
|
||||
{% endif %}
|
||||
|
||||
{# --- Per-group cards --- #}
|
||||
{% if groups_display %}
|
||||
{% for gs, chart in groups_display %}
|
||||
<div class="group-section" style="margin-bottom:1rem;">
|
||||
<div class="group-meta">
|
||||
<strong>{{ _('Group %(n)s', n=loop.index) }}</strong>
|
||||
· {{ gs.count }} {{ _('shots') }}
|
||||
· {{ _('mean') }} {{ "%.2f"|format(gs.mean_speed) }} m/s
|
||||
{% if gs.std_speed is not none %} · {{ _('SD') }} {{ "%.2f"|format(gs.std_speed) }}{% endif %}
|
||||
· {{ _('ES') }} {{ "%.2f"|format(gs.max_speed - gs.min_speed) }}
|
||||
</div>
|
||||
<img src="data:image/png;base64,{{ chart }}" class="chart-img" alt="Group {{ loop.index }} chart">
|
||||
|
||||
{% if gs.note %}
|
||||
<div style="margin-top:.75rem;padding:.5rem .75rem;background:#fffbea;border-left:3px solid #f0c040;
|
||||
border-radius:0 4px 4px 0;font-size:0.88rem;color:#555;white-space:pre-wrap;">{{ gs.note }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if is_owner %}
|
||||
<details style="margin-top:.75rem;">
|
||||
<summary style="font-size:0.82rem;color:#888;cursor:pointer;list-style:none;display:inline;">
|
||||
✎ {{ _('Note') }}
|
||||
</summary>
|
||||
<form method="post"
|
||||
action="{{ url_for('analyses.save_group_note', analysis_id=a.id, group_index=loop.index0) }}"
|
||||
style="margin-top:.4rem;display:flex;flex-direction:column;gap:.4rem;">
|
||||
<textarea name="note" rows="2"
|
||||
style="padding:.45rem .65rem;border:1px solid #ccc;border-radius:4px;font-size:0.88rem;resize:vertical;width:100%;max-width:520px;">{{ gs.note or '' }}</textarea>
|
||||
<div>
|
||||
<button type="submit"
|
||||
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.35rem .85rem;font-size:0.82rem;cursor:pointer;">
|
||||
{{ _('Save note') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% elif groups_display is none %}
|
||||
<p style="color:#e74c3c;font-size:0.9rem;">{{ _('CSV file missing — cannot display charts.') }}</p>
|
||||
{% endif %}
|
||||
|
||||
{# --- Re-group panel (owner only) --- #}
|
||||
{% if is_owner %}
|
||||
<details style="margin-top:1rem;border-top:1px solid #e8e8e8;padding-top:.9rem;">
|
||||
<summary style="font-size:0.85rem;color:#888;cursor:pointer;list-style:none;">
|
||||
⚙ {{ _('Re-group settings') }}
|
||||
</summary>
|
||||
<form method="post" action="{{ url_for('analyses.regroup', analysis_id=a.id) }}"
|
||||
style="margin-top:.75rem;display:flex;flex-direction:column;gap:.75rem;max-width:480px;">
|
||||
<div>
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">
|
||||
{{ _('Outlier factor:') }}
|
||||
<span id="factor_val_{{ a.id }}">{{ a.grouping_outlier_factor or 5 }}</span>
|
||||
</label>
|
||||
<input type="range" name="outlier_factor" min="1" max="20" step="0.5"
|
||||
value="{{ a.grouping_outlier_factor or 5 }}"
|
||||
style="width:100%;"
|
||||
oninput="document.getElementById('factor_val_{{ a.id }}').textContent=this.value">
|
||||
<div style="display:flex;justify-content:space-between;font-size:0.75rem;color:#aaa;">
|
||||
<span>{{ _('1 (fine)') }}</span><span>{{ _('20 (coarse)') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.3rem;">
|
||||
{{ _('Manual split indices (JSON array, e.g. [5, 12])') }}
|
||||
</label>
|
||||
<input type="text" name="manual_splits"
|
||||
value="{{ (a.grouping_manual_splits | tojson) if a.grouping_manual_splits else '' }}"
|
||||
placeholder="e.g. [5, 12]"
|
||||
style="width:100%;padding:.45rem .7rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;">
|
||||
<div style="font-size:0.75rem;color:#aaa;margin-top:.2rem;">{{ _('Shot positions (0-based) where a new group should always begin.') }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit"
|
||||
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.45rem 1.1rem;font-size:0.88rem;cursor:pointer;">
|
||||
{{ _('Apply') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p style="color:#888;margin-bottom:1.5rem;">No analyses yet.</p>
|
||||
<p style="color:#888;margin-bottom:1.5rem;">{{ _('No analyses yet.') }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if is_owner %}
|
||||
@@ -205,12 +596,12 @@
|
||||
enctype="multipart/form-data"
|
||||
style="display:flex;flex-wrap:wrap;gap:.75rem;align-items:flex-end;margin-bottom:2rem;">
|
||||
<div>
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.25rem;">Upload chronograph CSV</label>
|
||||
<label style="display:block;font-size:.85rem;font-weight:600;color:#444;margin-bottom:.25rem;">{{ _('Upload chronograph CSV') }}</label>
|
||||
<input type="file" name="csv_file" accept=".csv,text/csv" required style="font-size:0.9rem;">
|
||||
</div>
|
||||
<button type="submit"
|
||||
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:.5rem 1.1rem;font-size:0.9rem;cursor:pointer;">
|
||||
Analyse & link
|
||||
{{ _('Analyse & link') }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,44 +1,93 @@
|
||||
{% extends "base.html" %}
|
||||
{% set editing = session is not none %}
|
||||
{% block title %}{{ 'Edit session' if editing else 'New session' }} — The Shooter's Network{% endblock %}
|
||||
|
||||
{# Effective type: prefill form > existing session > URL param #}
|
||||
{% set eff_type = (prefill.session_type if prefill else None) or (session.session_type if session else None) or selected_type or '' %}
|
||||
|
||||
{# Find display name for this type #}
|
||||
{% set type_name = '' %}
|
||||
{% for slug, name, _ in (session_types or []) %}{% if slug == eff_type %}{% set type_name = name %}{% endif %}{% endfor %}
|
||||
|
||||
{% block title %}{{ _('Edit session') if editing else _('New session') }} — The Shooter's Network{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{{ 'Edit session' if editing else 'Log a session' }}</h1>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:.4rem;">
|
||||
{% if not editing %}
|
||||
<a href="{{ url_for('sessions.new') }}" style="font-size:0.85rem;color:#888;text-decoration:none;">{{ _('← Change type') }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h1 style="margin-bottom:1.5rem;">
|
||||
{{ _('Edit session') if editing else _('New session') }}
|
||||
{% if type_name %}
|
||||
<span style="font-size:0.95rem;font-weight:400;color:#666;margin-left:.6rem;">— {{ type_name }}</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
|
||||
{% set f = prefill or session %}
|
||||
|
||||
<form method="post"
|
||||
action="{{ url_for('sessions.edit', session_id=session.id) if editing else url_for('sessions.new') }}"
|
||||
style="max-width:580px;">
|
||||
style="max-width:600px;">
|
||||
|
||||
<h2>Basic info</h2>
|
||||
<input type="hidden" name="session_type" id="session_type_hidden" value="{{ eff_type }}">
|
||||
|
||||
{# In edit mode: allow changing type via a small selector #}
|
||||
{% if editing %}
|
||||
<div style="margin-bottom:1.5rem;padding:.75rem 1rem;background:#f8f9fb;border-radius:6px;display:flex;align-items:center;gap:1rem;">
|
||||
<label style="font-size:.85rem;font-weight:600;color:#444;white-space:nowrap;">{{ _('Session type:') }}</label>
|
||||
<select id="type_sel" onchange="document.getElementById('session_type_hidden').value=this.value;applyType(this.value);"
|
||||
style="padding:.4rem .7rem;border:1px solid #ccc;border-radius:4px;font-size:0.9rem;background:#fff;">
|
||||
{% for slug, name, _ in (session_types or []) %}
|
||||
<option value="{{ slug }}" {% if slug == eff_type %}selected{% endif %}>{{ name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Section: basic information ── #}
|
||||
<h2>{{ _('Basic information') }}</h2>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
|
||||
<div>
|
||||
<label class="fl">Date *</label>
|
||||
<label class="fl">{{ _('Date *') }}</label>
|
||||
<input type="date" name="session_date" required
|
||||
value="{{ (f.session_date.isoformat() if f.session_date else '') if f else (today or '') }}"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
|
||||
</div>
|
||||
<div>
|
||||
<label class="fl">Distance (m)</label>
|
||||
<input type="number" name="distance_m" min="1" max="5000"
|
||||
value="{{ f.distance_m if f and f.distance_m else '' }}"
|
||||
|
||||
{# Distance: shown for long_range and pistol_25m (hidden for prs — per stage) #}
|
||||
<div id="field_distance_wrap">
|
||||
<label class="fl">{{ _('Distance (m)') }}</label>
|
||||
<input type="number" name="distance_m" min="1" max="5000" id="field_distance"
|
||||
value="{{ f.distance_m if f and f.distance_m else (prefill_distance or '') }}"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:1rem;">
|
||||
<label class="fl">Location</label>
|
||||
<label class="fl">{{ _('Location') }}</label>
|
||||
<input type="text" name="location_name" value="{{ f.location_name if f else '' }}"
|
||||
placeholder="e.g. Range name, city"
|
||||
placeholder="ex : Nom du stand, commune"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
|
||||
</div>
|
||||
|
||||
<h2>Weather</h2>
|
||||
{# ── Section: shooting position (long_range and pistol_25m) ── #}
|
||||
<div id="section_position">
|
||||
<div style="margin-bottom:1rem;">
|
||||
<label class="fl">{{ _('Shooting position') }}</label>
|
||||
<select name="shooting_position" id="field_position"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.93rem;background:#fff;">
|
||||
{# Options injected by JS based on type #}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Section: weather ── #}
|
||||
<h2>{{ _('Weather') }}</h2>
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin-bottom:1rem;">
|
||||
<div>
|
||||
<label class="fl">Condition</label>
|
||||
<label class="fl">{{ _('Conditions') }}</label>
|
||||
<select name="weather_cond"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.93rem;background:#fff;">
|
||||
{% for val, label in weather_conditions %}
|
||||
@@ -47,27 +96,27 @@
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="fl">Temp (°C)</label>
|
||||
<label class="fl">{{ _('Temp. (°C)') }}</label>
|
||||
<input type="number" name="weather_temp_c" step="0.1"
|
||||
value="{{ f.weather_temp_c if f and f.weather_temp_c is not none else '' }}"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
|
||||
</div>
|
||||
<div>
|
||||
<label class="fl">Wind (km/h)</label>
|
||||
<label class="fl">{{ _('Wind (km/h)') }}</label>
|
||||
<input type="number" name="weather_wind_kph" step="0.1" min="0"
|
||||
value="{{ f.weather_wind_kph if f and f.weather_wind_kph is not none else '' }}"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Equipment & Ammo</h2>
|
||||
|
||||
{# ── Section: equipment & ammunition ── #}
|
||||
<h2>{{ _('Equipment & Ammunition') }}</h2>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-bottom:1rem;">
|
||||
<div>
|
||||
<label class="fl">Rifle / Handgun</label>
|
||||
<label class="fl">{{ _('Rifle / Handgun') }}</label>
|
||||
<select name="rifle_id"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.93rem;background:#fff;">
|
||||
<option value="">— none —</option>
|
||||
<option value="">{{ _('— none —') }}</option>
|
||||
{% for r in rifles %}
|
||||
<option value="{{ r.id }}" {% if f and f.rifle_id == r.id %}selected{% endif %}>
|
||||
{{ r.name }}{% if r.caliber %} ({{ r.caliber }}){% endif %}
|
||||
@@ -76,15 +125,15 @@
|
||||
</select>
|
||||
{% if not rifles %}
|
||||
<div style="font-size:0.78rem;color:#aaa;margin-top:.25rem;">
|
||||
<a href="{{ url_for('equipment.new') }}">Add a rifle first</a>
|
||||
<a href="{{ url_for('equipment.new') }}">{{ _('Add a rifle first') }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<label class="fl">Scope</label>
|
||||
<label class="fl">{{ _('Scope') }}</label>
|
||||
<select name="scope_id"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.93rem;background:#fff;">
|
||||
<option value="">— none —</option>
|
||||
<option value="">{{ _('— none —') }}</option>
|
||||
{% for sc in scopes %}
|
||||
<option value="{{ sc.id }}" {% if f and f.scope_id == sc.id %}selected{% endif %}>{{ sc.name }}</option>
|
||||
{% endfor %}
|
||||
@@ -94,50 +143,97 @@
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:1rem;margin-bottom:1rem;">
|
||||
<div>
|
||||
<label class="fl">Ammo brand</label>
|
||||
<label class="fl">{{ _('Ammo brand') }}</label>
|
||||
<input type="text" name="ammo_brand" value="{{ f.ammo_brand if f else '' }}"
|
||||
placeholder="e.g. Lapua, Federal"
|
||||
placeholder="ex : Lapua, RWS"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
|
||||
</div>
|
||||
<div>
|
||||
<label class="fl">Bullet weight (gr)</label>
|
||||
<label class="fl">{{ _('Bullet weight (gr)') }}</label>
|
||||
<input type="number" name="ammo_weight_gr" step="0.1" min="0"
|
||||
value="{{ f.ammo_weight_gr if f and f.ammo_weight_gr is not none else '' }}"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
|
||||
</div>
|
||||
<div>
|
||||
<label class="fl">Lot number</label>
|
||||
<label class="fl">{{ _('Lot number') }}</label>
|
||||
<input type="text" name="ammo_lot" value="{{ f.ammo_lot if f else '' }}"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Notes & Visibility</h2>
|
||||
|
||||
{# ── Section: notes & visibility ── #}
|
||||
<h2>{{ _('Notes & Visibility') }}</h2>
|
||||
<div style="margin-bottom:1rem;">
|
||||
<label class="fl">Notes</label>
|
||||
<label class="fl">{{ _('Notes') }}</label>
|
||||
<textarea name="notes" rows="4"
|
||||
style="width:100%;padding:0.55rem 0.75rem;border:1px solid #ccc;border-radius:4px;font-size:0.95rem;resize:vertical;">{{ f.notes if f else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:1.5rem;">
|
||||
<label style="display:flex;align-items:center;gap:0.6rem;cursor:pointer;font-size:0.95rem;">
|
||||
<input type="checkbox" name="is_public" value="1"
|
||||
{% if f and f.is_public %}checked{% endif %}
|
||||
style="width:16px;height:16px;">
|
||||
Make this session public (visible in the community feed)
|
||||
{{ _('Make this session public (visible in the community feed)') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:1rem;align-items:center;">
|
||||
<button type="submit"
|
||||
style="background:#1a1a2e;color:#fff;border:none;border-radius:4px;padding:0.6rem 1.5rem;font-size:0.95rem;cursor:pointer;">
|
||||
{{ 'Save changes' if editing else 'Log session' }}
|
||||
{{ _('Save') if editing else _('Create session') }}
|
||||
</button>
|
||||
<a href="{{ url_for('sessions.detail', session_id=session.id) if editing else url_for('sessions.index') }}"
|
||||
style="font-size:0.9rem;color:#666;">Cancel</a>
|
||||
style="font-size:0.9rem;color:#666;">{{ _('Cancel') }}</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>.fl { display:block; font-size:.88rem; font-weight:600; color:#444; margin-bottom:.3rem; }</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var LR_POS = {{ (long_range_positions | tojson) if long_range_positions else '[]' }};
|
||||
var P25_POS = {{ (pistol_25m_positions | tojson) if pistol_25m_positions else '[]' }};
|
||||
|
||||
function buildOptions(sel, opts, currentVal) {
|
||||
sel.innerHTML = '';
|
||||
opts.forEach(function(o) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = o[0]; opt.textContent = o[1];
|
||||
if (o[0] === currentVal) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
var currentPosition = {{ ((f.shooting_position if f else None) or '') | tojson }};
|
||||
|
||||
function applyType(t) {
|
||||
var distWrap = document.getElementById('field_distance_wrap');
|
||||
var posSection = document.getElementById('section_position');
|
||||
var posSelect = document.getElementById('field_position');
|
||||
|
||||
if (t === 'prs') {
|
||||
if (distWrap) distWrap.style.display = 'none';
|
||||
if (posSection) posSection.style.display = 'none';
|
||||
} else if (t === 'pistol_25m') {
|
||||
if (distWrap) distWrap.style.display = '';
|
||||
if (posSection) posSection.style.display = '';
|
||||
buildOptions(posSelect, P25_POS, currentPosition);
|
||||
var distField = document.getElementById('field_distance');
|
||||
if (distField && !distField.value) distField.value = '25';
|
||||
} else {
|
||||
// long_range
|
||||
if (distWrap) distWrap.style.display = '';
|
||||
if (posSection) posSection.style.display = '';
|
||||
buildOptions(posSelect, LR_POS, currentPosition);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
applyType({{ eff_type | tojson }});
|
||||
});
|
||||
|
||||
// Expose for the type selector in edit mode
|
||||
window.applyType = applyType;
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Sessions — The Shooter's Network{% endblock %}
|
||||
{% block title %}{{ _('Sessions') }} — The Shooter's Network{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:1.5rem;">
|
||||
<h1 style="margin:0;">My Sessions</h1>
|
||||
<h1 style="margin:0;">{{ _('My Sessions') }}</h1>
|
||||
<a href="{{ url_for('sessions.new') }}"
|
||||
style="background:#1a1a2e;color:#fff;padding:0.5rem 1.2rem;border-radius:4px;font-size:0.92rem;text-decoration:none;">
|
||||
+ New session
|
||||
{{ _('+ New session') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -13,9 +13,10 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Session</th>
|
||||
<th>Location</th>
|
||||
<th>Visibility</th>
|
||||
<th>{{ _('Session') }}</th>
|
||||
<th>{{ _('Type') }}</th>
|
||||
<th>{{ _('Location') }}</th>
|
||||
<th>{{ _('Visibility') }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -23,15 +24,24 @@
|
||||
{% for s in sessions %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('sessions.detail', session_id=s.id) }}">{{ s.session_date.strftime('%d %b %Y') }}</a></td>
|
||||
<td style="font-size:0.82rem;">
|
||||
{% if s.session_type == 'long_range' %}
|
||||
<span style="background:#f0f4ff;color:#1a1a2e;padding:.15rem .5rem;border-radius:3px;">{{ _('Long Range') }}</span>
|
||||
{% elif s.session_type == 'prs' %}
|
||||
<span style="background:#fff3e0;color:#e65100;padding:.15rem .5rem;border-radius:3px;">PRS</span>
|
||||
{% elif s.session_type == 'pistol_25m' %}
|
||||
<span style="background:#f3e5f5;color:#6a1b9a;padding:.15rem .5rem;border-radius:3px;">{{ _('25m Pistol') }}</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td style="color:#666;font-size:0.88rem;">{{ s.location_name or '—' }}</td>
|
||||
<td style="font-size:0.85rem;color:{% if s.is_public %}#27ae60{% else %}#aaa{% endif %};">
|
||||
{{ 'Public' if s.is_public else 'Private' }}
|
||||
{{ _('Public') if s.is_public else _('Private') }}
|
||||
</td>
|
||||
<td style="white-space:nowrap;">
|
||||
<a href="{{ url_for('sessions.edit', session_id=s.id) }}" style="font-size:0.85rem;margin-right:.75rem;">Edit</a>
|
||||
<a href="{{ url_for('sessions.edit', session_id=s.id) }}" style="font-size:0.85rem;margin-right:.75rem;">{{ _('Edit') }}</a>
|
||||
<form method="post" action="{{ url_for('sessions.delete', session_id=s.id) }}" style="display:inline;"
|
||||
onsubmit="return confirm('Delete this session?');">
|
||||
<button type="submit" class="btn-link" style="font-size:0.85rem;color:#e74c3c;">Delete</button>
|
||||
onsubmit="return confirm('{{ _('Delete this session?') | e }}');">
|
||||
<button type="submit" class="btn-link" style="font-size:0.85rem;color:#e74c3c;">{{ _('Delete') }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -41,8 +51,8 @@
|
||||
{% else %}
|
||||
<div style="text-align:center;padding:3rem 0;color:#888;">
|
||||
<div style="font-size:3rem;margin-bottom:1rem;">🎯</div>
|
||||
<p style="margin-bottom:1rem;">No sessions recorded yet.</p>
|
||||
<a href="{{ url_for('sessions.new') }}">Log your first session</a>
|
||||
<p style="margin-bottom:1rem;">{{ _('No sessions recorded yet.') }}</p>
|
||||
<a href="{{ url_for('sessions.new') }}">{{ _('Log your first session') }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
40
templates/sessions/type_picker.html
Normal file
40
templates/sessions/type_picker.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('New session') }} — The Shooter's Network{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:1rem;margin-bottom:.5rem;">
|
||||
<h1 style="margin:0;">{{ _('New session') }}</h1>
|
||||
<a href="{{ url_for('sessions.index') }}" style="font-size:0.9rem;color:#666;">{{ _('Cancel') }}</a>
|
||||
</div>
|
||||
<p style="color:#666;margin-bottom:2rem;font-size:0.95rem;">{{ _('Choose the session type.') }}</p>
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:1.5rem;">
|
||||
|
||||
{% for slug, name, desc in session_types %}
|
||||
<a href="{{ url_for('sessions.new', type=slug) }}"
|
||||
style="display:flex;flex-direction:column;padding:1.5rem 1.75rem;border:2px solid #e0e0e0;border-radius:10px;
|
||||
text-decoration:none;color:inherit;background:#fff;
|
||||
transition:border-color .15s,box-shadow .15s,transform .1s;"
|
||||
onmouseover="this.style.borderColor='#1a1a2e';this.style.boxShadow='0 4px 14px rgba(0,0,0,.1)';this.style.transform='translateY(-2px)'"
|
||||
onmouseout="this.style.borderColor='#e0e0e0';this.style.boxShadow='none';this.style.transform='none'">
|
||||
|
||||
{% if slug == 'long_range' %}
|
||||
<div style="font-size:2rem;margin-bottom:.6rem;">🎯</div>
|
||||
{% elif slug == 'prs' %}
|
||||
<div style="font-size:2rem;margin-bottom:.6rem;">🏔️</div>
|
||||
{% else %}
|
||||
<div style="font-size:2rem;margin-bottom:.6rem;">🔫</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="font-weight:700;font-size:1.1rem;color:#1a1a2e;margin-bottom:.4rem;">{{ _(name) }}</div>
|
||||
<div style="font-size:0.85rem;color:#777;line-height:1.5;flex:1;">{{ _(desc) }}</div>
|
||||
|
||||
{% if slug == 'prs' %}
|
||||
<div style="margin-top:1rem;font-size:0.78rem;color:#1f77b4;font-weight:600;">
|
||||
{{ _('Stage management, PDF dope card ↗') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,13 +1,13 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>New Analysis</h1>
|
||||
<h1>{{ _('New Analysis') }}</h1>
|
||||
|
||||
{% if error %}
|
||||
<div class="error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<p style="margin-bottom:1.5rem; color:#555;">
|
||||
Upload a CSV file to analyse shot groups. The file must contain the following columns:
|
||||
{{ _('Upload a CSV file to analyse shot groups. The file must contain the following columns:') }}
|
||||
<strong>index</strong>, <strong>speed</strong>, <strong>standard deviation</strong>,
|
||||
<strong>energy</strong>, <strong>power factor</strong>, <strong>time of the day</strong>.
|
||||
</p>
|
||||
@@ -24,7 +24,7 @@
|
||||
type="submit"
|
||||
style="background:#1f77b4;color:#fff;border:none;border-radius:4px;padding:0.55rem 1.4rem;font-size:0.95rem;cursor:pointer;"
|
||||
>
|
||||
Analyze
|
||||
{{ _('Analyze') }}
|
||||
</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
BIN
translations/de/LC_MESSAGES/messages.mo
Normal file
BIN
translations/de/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
653
translations/de/LC_MESSAGES/messages.po
Normal file
653
translations/de/LC_MESSAGES/messages.po
Normal file
@@ -0,0 +1,653 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: de\n"
|
||||
|
||||
msgid "New Analysis"
|
||||
msgstr "Neue Analyse"
|
||||
|
||||
msgid "Equipment"
|
||||
msgstr "Ausrüstung"
|
||||
|
||||
msgid "Sessions"
|
||||
msgstr "Sitzungen"
|
||||
|
||||
msgid "Dashboard"
|
||||
msgstr "Dashboard"
|
||||
|
||||
msgid "Profile"
|
||||
msgstr "Profil"
|
||||
|
||||
msgid "Logout"
|
||||
msgstr "Abmelden"
|
||||
|
||||
msgid "Login"
|
||||
msgstr "Anmelden"
|
||||
|
||||
msgid "Join free"
|
||||
msgstr "Kostenlos registrieren"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Speichern"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "Abbrechen"
|
||||
|
||||
msgid "Edit"
|
||||
msgstr "Bearbeiten"
|
||||
|
||||
msgid "Delete"
|
||||
msgstr "Löschen"
|
||||
|
||||
msgid "Apply"
|
||||
msgstr "Anwenden"
|
||||
|
||||
msgid "Upload"
|
||||
msgstr "Hochladen"
|
||||
|
||||
msgid "Notes"
|
||||
msgstr "Notizen"
|
||||
|
||||
msgid "Public"
|
||||
msgstr "Öffentlich"
|
||||
|
||||
msgid "Private"
|
||||
msgstr "Privat"
|
||||
|
||||
msgid "Date"
|
||||
msgstr "Datum"
|
||||
|
||||
msgid "Location"
|
||||
msgstr "Ort"
|
||||
|
||||
msgid "Name"
|
||||
msgstr "Name"
|
||||
|
||||
msgid "Category"
|
||||
msgstr "Kategorie"
|
||||
|
||||
msgid "Caliber"
|
||||
msgstr "Kaliber"
|
||||
|
||||
msgid "Distance"
|
||||
msgstr "Distanz"
|
||||
|
||||
msgid "Position"
|
||||
msgstr "Position"
|
||||
|
||||
msgid "Log a Session"
|
||||
msgstr "Sitzung erfassen"
|
||||
|
||||
msgid "Get started — free"
|
||||
msgstr "Kostenlos starten"
|
||||
|
||||
msgid "Try without account"
|
||||
msgstr "Ohne Konto ausprobieren"
|
||||
|
||||
msgid "Ballistic Analysis"
|
||||
msgstr "Ballistische Analyse"
|
||||
|
||||
msgid "Session Tracking"
|
||||
msgstr "Sitzungsverfolgung"
|
||||
|
||||
msgid "Community Feed"
|
||||
msgstr "Community-Feed"
|
||||
|
||||
msgid "Upload CSV files from your chronograph and get instant shot-group statistics, velocity charts, and PDF reports."
|
||||
msgstr "Laden Sie CSV-Dateien von Ihrem Chronograph hoch und erhalten Sie sofort Schussgruppenstatistiken, Geschwindigkeitsdiagramme und PDF-Berichte."
|
||||
|
||||
msgid "Log every range visit with location, weather, rifle, ammo, and distance. All your data in one place."
|
||||
msgstr "Erfassen Sie jeden Schießstandbesuch mit Ort, Wetter, Gewehr, Munition und Distanz. Alle Ihre Daten an einem Ort."
|
||||
|
||||
msgid "Share your public sessions and see what other shooters are achieving on the range."
|
||||
msgstr "Teilen Sie Ihre öffentlichen Sitzungen und sehen Sie, was andere Schützen auf dem Schießstand erreichen."
|
||||
|
||||
msgid "Latest sessions"
|
||||
msgstr "Neueste Sitzungen"
|
||||
|
||||
msgid "No public sessions yet. Be the first to share one!"
|
||||
msgstr "Noch keine öffentlichen Sitzungen. Seien Sie der Erste, der eine teilt!"
|
||||
|
||||
msgid "Analyze"
|
||||
msgstr "Analysieren"
|
||||
|
||||
msgid "Upload a CSV file to analyse shot groups. The file must contain the following columns:"
|
||||
msgstr "Laden Sie eine CSV-Datei hoch, um Schussgruppen zu analysieren. Die Datei muss folgende Spalten enthalten:"
|
||||
|
||||
msgid "Analysis Results"
|
||||
msgstr "Analyseergebnisse"
|
||||
|
||||
msgid "← Upload another file"
|
||||
msgstr "← Weitere Datei hochladen"
|
||||
|
||||
msgid "View saved report →"
|
||||
msgstr "Gespeicherten Bericht ansehen →"
|
||||
|
||||
msgid "⬙ Download PDF report"
|
||||
msgstr "⬙ PDF-Bericht herunterladen"
|
||||
|
||||
msgid "Overall Statistics"
|
||||
msgstr "Gesamtstatistiken"
|
||||
|
||||
msgid "Metric"
|
||||
msgstr "Messgröße"
|
||||
|
||||
msgid "Value"
|
||||
msgstr "Wert"
|
||||
|
||||
msgid "Total shots"
|
||||
msgstr "Schüsse gesamt"
|
||||
|
||||
msgid "Min speed"
|
||||
msgstr "Min. Geschwindigkeit"
|
||||
|
||||
msgid "Max speed"
|
||||
msgstr "Max. Geschwindigkeit"
|
||||
|
||||
msgid "Mean speed"
|
||||
msgstr "Mittlere Geschwindigkeit"
|
||||
|
||||
msgid "Std dev (speed)"
|
||||
msgstr "Standardabweichung (Geschwindigkeit)"
|
||||
|
||||
msgid "Groups"
|
||||
msgstr "Gruppen"
|
||||
|
||||
msgid "group(s) detected"
|
||||
msgstr "Gruppe(n) erkannt"
|
||||
|
||||
msgid "Group %(n)s"
|
||||
msgstr "Gruppe %(n)s"
|
||||
|
||||
msgid "shot(s)"
|
||||
msgstr "Schuss/Schüsse"
|
||||
|
||||
msgid "group(s)"
|
||||
msgstr "Gruppe(n)"
|
||||
|
||||
msgid "Analysis"
|
||||
msgstr "Analyse"
|
||||
|
||||
msgid "Welcome back, %(name)s."
|
||||
msgstr "Willkommen zurück, %(name)s."
|
||||
|
||||
msgid "+ New session"
|
||||
msgstr "+ Neue Sitzung"
|
||||
|
||||
msgid "+ Add equipment"
|
||||
msgstr "+ Ausrüstung hinzufügen"
|
||||
|
||||
msgid "New analysis"
|
||||
msgstr "Neue Analyse"
|
||||
|
||||
msgid "Recent Analyses"
|
||||
msgstr "Neueste Analysen"
|
||||
|
||||
msgid "Title"
|
||||
msgstr "Titel"
|
||||
|
||||
msgid "Shots"
|
||||
msgstr "Schüsse"
|
||||
|
||||
msgid "Visibility"
|
||||
msgstr "Sichtbarkeit"
|
||||
|
||||
msgid "No analyses yet."
|
||||
msgstr "Noch keine Analysen."
|
||||
|
||||
msgid "Upload a CSV file"
|
||||
msgstr "CSV-Datei hochladen"
|
||||
|
||||
msgid "to get started — it will be saved here automatically."
|
||||
msgstr "um zu beginnen – sie wird hier automatisch gespeichert."
|
||||
|
||||
msgid "Manage your rifles, scopes & gear →"
|
||||
msgstr "Gewehre, Optiken und Ausrüstung verwalten →"
|
||||
|
||||
msgid "View your shooting sessions →"
|
||||
msgstr "Schießsitzungen anzeigen →"
|
||||
|
||||
msgid "Sign in"
|
||||
msgstr "Anmelden"
|
||||
|
||||
msgid "Password"
|
||||
msgstr "Passwort"
|
||||
|
||||
msgid "Email"
|
||||
msgstr "E-Mail"
|
||||
|
||||
msgid "Resend confirmation email"
|
||||
msgstr "Bestätigungsmail erneut senden"
|
||||
|
||||
msgid "Don't have an account?"
|
||||
msgstr "Kein Konto?"
|
||||
|
||||
msgid "Create one"
|
||||
msgstr "Jetzt erstellen"
|
||||
|
||||
msgid "or continue with"
|
||||
msgstr "oder weiter mit"
|
||||
|
||||
msgid "Continue with Google"
|
||||
msgstr "Mit Google fortfahren"
|
||||
|
||||
msgid "Continue with GitHub"
|
||||
msgstr "Mit GitHub fortfahren"
|
||||
|
||||
msgid "Create account"
|
||||
msgstr "Konto erstellen"
|
||||
|
||||
msgid "(min. 8 characters)"
|
||||
msgstr "(min. 8 Zeichen)"
|
||||
|
||||
msgid "Confirm password"
|
||||
msgstr "Passwort bestätigen"
|
||||
|
||||
msgid "Already have an account?"
|
||||
msgstr "Bereits ein Konto?"
|
||||
|
||||
msgid "Account"
|
||||
msgstr "Konto"
|
||||
|
||||
msgid "Profile picture"
|
||||
msgstr "Profilbild"
|
||||
|
||||
msgid "JPEG/PNG, max 1200 px, auto-resized."
|
||||
msgstr "JPEG/PNG, max 1200 px, wird automatisch angepasst."
|
||||
|
||||
msgid "Display name"
|
||||
msgstr "Anzeigename"
|
||||
|
||||
msgid "Bio"
|
||||
msgstr "Bio"
|
||||
|
||||
msgid "Tell others a bit about yourself…"
|
||||
msgstr "Erzählen Sie etwas über sich…"
|
||||
|
||||
msgid "Show my equipment on my public profile"
|
||||
msgstr "Meine Ausrüstung im öffentlichen Profil anzeigen"
|
||||
|
||||
msgid "Save changes"
|
||||
msgstr "Änderungen speichern"
|
||||
|
||||
msgid "View my public profile →"
|
||||
msgstr "Mein öffentliches Profil ansehen →"
|
||||
|
||||
msgid "Change password"
|
||||
msgstr "Passwort ändern"
|
||||
|
||||
msgid "Current password"
|
||||
msgstr "Aktuelles Passwort"
|
||||
|
||||
msgid "New password"
|
||||
msgstr "Neues Passwort"
|
||||
|
||||
msgid "Confirm"
|
||||
msgstr "Bestätigen"
|
||||
|
||||
msgid "Member since %(date)s"
|
||||
msgstr "Mitglied seit %(date)s"
|
||||
|
||||
msgid "Session"
|
||||
msgstr "Sitzung"
|
||||
|
||||
msgid "Brand / Model"
|
||||
msgstr "Marke / Modell"
|
||||
|
||||
msgid "No public sessions yet."
|
||||
msgstr "Noch keine öffentlichen Sitzungen."
|
||||
|
||||
msgid "No equipment listed."
|
||||
msgstr "Keine Ausrüstung angegeben."
|
||||
|
||||
msgid "My Equipment"
|
||||
msgstr "Meine Ausrüstung"
|
||||
|
||||
msgid "+ Add item"
|
||||
msgstr "+ Artikel hinzufügen"
|
||||
|
||||
msgid "View"
|
||||
msgstr "Ansehen"
|
||||
|
||||
msgid "Delete %(name)s?"
|
||||
msgstr "%(name)s löschen?"
|
||||
|
||||
msgid "No equipment yet."
|
||||
msgstr "Noch keine Ausrüstung."
|
||||
|
||||
msgid "Add your first item"
|
||||
msgstr "Ersten Artikel hinzufügen"
|
||||
|
||||
msgid "Add equipment"
|
||||
msgstr "Ausrüstung hinzufügen"
|
||||
|
||||
msgid "Category *"
|
||||
msgstr "Kategorie *"
|
||||
|
||||
msgid "Name *"
|
||||
msgstr "Name *"
|
||||
|
||||
msgid "Brand"
|
||||
msgstr "Marke"
|
||||
|
||||
msgid "Model"
|
||||
msgstr "Modell"
|
||||
|
||||
msgid "Magnification"
|
||||
msgstr "Vergrößerung"
|
||||
|
||||
msgid "Reticle"
|
||||
msgstr "Absehen"
|
||||
|
||||
msgid "Unit"
|
||||
msgstr "Einheit"
|
||||
|
||||
msgid "Serial number"
|
||||
msgstr "Seriennummer"
|
||||
|
||||
msgid "Photo"
|
||||
msgstr "Foto"
|
||||
|
||||
msgid "Upload a new one to replace it."
|
||||
msgstr "Laden Sie eine neue hoch, um sie zu ersetzen."
|
||||
|
||||
msgid "My Sessions"
|
||||
msgstr "Meine Sitzungen"
|
||||
|
||||
msgid "Type"
|
||||
msgstr "Typ"
|
||||
|
||||
msgid "Long Range"
|
||||
msgstr "Long Range"
|
||||
|
||||
msgid "25m Pistol"
|
||||
msgstr "Pistole 25m"
|
||||
|
||||
msgid "Delete this session?"
|
||||
msgstr "Diese Sitzung löschen?"
|
||||
|
||||
msgid "No sessions recorded yet."
|
||||
msgstr "Noch keine Sitzungen aufgezeichnet."
|
||||
|
||||
msgid "Log your first session"
|
||||
msgstr "Erste Sitzung erfassen"
|
||||
|
||||
msgid "New session"
|
||||
msgstr "Neue Sitzung"
|
||||
|
||||
msgid "Choose the session type."
|
||||
msgstr "Wählen Sie den Sitzungstyp."
|
||||
|
||||
msgid "Long Range Practice"
|
||||
msgstr "Long Range Practice"
|
||||
|
||||
msgid "Long range precision shooting (100m+)"
|
||||
msgstr "Präzisionsschießen auf lange Distanz (100m+)"
|
||||
|
||||
msgid "PRS"
|
||||
msgstr "PRS"
|
||||
|
||||
msgid "Precision Rifle Series — training & competition"
|
||||
msgstr "Precision Rifle Series — Training & Wettkampf"
|
||||
|
||||
msgid "25m precision pistol shooting"
|
||||
msgstr "Präzisionsschießen Pistole 25m"
|
||||
|
||||
msgid "Stage management, PDF dope card ↗"
|
||||
msgstr "Stage-Verwaltung, PDF-Dopekarte ↗"
|
||||
|
||||
msgid "← Change type"
|
||||
msgstr "← Typ ändern"
|
||||
|
||||
msgid "Edit session"
|
||||
msgstr "Sitzung bearbeiten"
|
||||
|
||||
msgid "Basic information"
|
||||
msgstr "Grundinformationen"
|
||||
|
||||
msgid "Date *"
|
||||
msgstr "Datum *"
|
||||
|
||||
msgid "Distance (m)"
|
||||
msgstr "Distanz (m)"
|
||||
|
||||
msgid "Shooting position"
|
||||
msgstr "Schießposition"
|
||||
|
||||
msgid "Weather"
|
||||
msgstr "Wetter"
|
||||
|
||||
msgid "Conditions"
|
||||
msgstr "Bedingungen"
|
||||
|
||||
msgid "Temp. (°C)"
|
||||
msgstr "Temp. (°C)"
|
||||
|
||||
msgid "Wind (km/h)"
|
||||
msgstr "Wind (km/h)"
|
||||
|
||||
msgid "Equipment & Ammunition"
|
||||
msgstr "Ausrüstung & Munition"
|
||||
|
||||
msgid "Rifle / Handgun"
|
||||
msgstr "Gewehr / Pistole"
|
||||
|
||||
msgid "— none —"
|
||||
msgstr "— keine —"
|
||||
|
||||
msgid "Add a rifle first"
|
||||
msgstr "Zuerst ein Gewehr hinzufügen"
|
||||
|
||||
msgid "Scope"
|
||||
msgstr "Optik"
|
||||
|
||||
msgid "Ammo brand"
|
||||
msgstr "Munitionsmarke"
|
||||
|
||||
msgid "Bullet weight (gr)"
|
||||
msgstr "Geschossgewicht (gr)"
|
||||
|
||||
msgid "Lot number"
|
||||
msgstr "Losnummer"
|
||||
|
||||
msgid "Notes & Visibility"
|
||||
msgstr "Notizen & Sichtbarkeit"
|
||||
|
||||
msgid "Make this session public (visible in the community feed)"
|
||||
msgstr "Diese Sitzung öffentlich machen (im Community-Feed sichtbar)"
|
||||
|
||||
msgid "Create session"
|
||||
msgstr "Sitzung erstellen"
|
||||
|
||||
msgid "Session type:"
|
||||
msgstr "Sitzungstyp:"
|
||||
|
||||
msgid "Delete this session? This cannot be undone."
|
||||
msgstr "Diese Sitzung löschen? Dies kann nicht rückgängig gemacht werden."
|
||||
|
||||
msgid "km/h wind"
|
||||
msgstr "km/h Wind"
|
||||
|
||||
msgid "Ammunition"
|
||||
msgstr "Munition"
|
||||
|
||||
msgid "PRS Stages"
|
||||
msgstr "PRS-Stages"
|
||||
|
||||
msgid "Time (s)"
|
||||
msgstr "Zeit (s)"
|
||||
|
||||
msgid "Elevation Dope"
|
||||
msgstr "Erhöhungs-Dope"
|
||||
|
||||
msgid "Windage Dope"
|
||||
msgstr "Seiten-Dope"
|
||||
|
||||
msgid "Hits/Poss."
|
||||
msgstr "Treffer/Mögl."
|
||||
|
||||
msgid "✏ Edit stages"
|
||||
msgstr "✏ Stages bearbeiten"
|
||||
|
||||
msgid "📄 Generate dope card (PDF)"
|
||||
msgstr "📄 Dopekarte erstellen (PDF)"
|
||||
|
||||
msgid "+ Add stage"
|
||||
msgstr "+ Stage hinzufügen"
|
||||
|
||||
msgid "💾 Save"
|
||||
msgstr "💾 Speichern"
|
||||
|
||||
msgid "Photos"
|
||||
msgstr "Fotos"
|
||||
|
||||
msgid "Add photo"
|
||||
msgstr "Foto hinzufügen"
|
||||
|
||||
msgid "Caption (optional)"
|
||||
msgstr "Beschriftung (optional)"
|
||||
|
||||
msgid "Measure group"
|
||||
msgstr "Gruppe messen"
|
||||
|
||||
msgid "Delete this photo?"
|
||||
msgstr "Dieses Foto löschen?"
|
||||
|
||||
msgid "clean barrel"
|
||||
msgstr "sauberer Lauf"
|
||||
|
||||
msgid "Group ES"
|
||||
msgstr "Gruppen-ES"
|
||||
|
||||
msgid "Mean Radius"
|
||||
msgstr "Mittlerer Radius"
|
||||
|
||||
msgid "Centre"
|
||||
msgstr "Mittelpunkt"
|
||||
|
||||
msgid "high"
|
||||
msgstr "oben"
|
||||
|
||||
msgid "low"
|
||||
msgstr "unten"
|
||||
|
||||
msgid "Analyses"
|
||||
msgstr "Analysen"
|
||||
|
||||
msgid "shots"
|
||||
msgstr "Schüsse"
|
||||
|
||||
msgid "group"
|
||||
msgstr "Gruppe"
|
||||
|
||||
msgid "groups"
|
||||
msgstr "Gruppen"
|
||||
|
||||
msgid "m/s mean"
|
||||
msgstr "m/s Mittel"
|
||||
|
||||
msgid "✏ Rename"
|
||||
msgstr "✏ Umbenennen"
|
||||
|
||||
msgid "Full view"
|
||||
msgstr "Vollansicht"
|
||||
|
||||
msgid "Delete this analysis? This cannot be undone."
|
||||
msgstr "Diese Analyse löschen? Dies kann nicht rückgängig gemacht werden."
|
||||
|
||||
msgid "mean"
|
||||
msgstr "Mittel"
|
||||
|
||||
msgid "SD"
|
||||
msgstr "SA"
|
||||
|
||||
msgid "ES"
|
||||
msgstr "ES"
|
||||
|
||||
msgid "Note"
|
||||
msgstr "Notiz"
|
||||
|
||||
msgid "Save note"
|
||||
msgstr "Notiz speichern"
|
||||
|
||||
msgid "CSV file missing — cannot display charts."
|
||||
msgstr "CSV-Datei fehlt — Diagramme können nicht angezeigt werden."
|
||||
|
||||
msgid "Re-group settings"
|
||||
msgstr "Gruppierungseinstellungen"
|
||||
|
||||
msgid "Outlier factor:"
|
||||
msgstr "Ausreißer-Faktor:"
|
||||
|
||||
msgid "1 (fine)"
|
||||
msgstr "1 (fein)"
|
||||
|
||||
msgid "20 (coarse)"
|
||||
msgstr "20 (grob)"
|
||||
|
||||
msgid "Manual split indices (JSON array, e.g. [5, 12])"
|
||||
msgstr "Manuelle Teilungsindizes (JSON-Array, z.B. [5, 12])"
|
||||
|
||||
msgid "Shot positions (0-based) where a new group should always begin."
|
||||
msgstr "Schussposition (0-basiert), bei der eine neue Gruppe beginnen soll."
|
||||
|
||||
msgid "Upload chronograph CSV"
|
||||
msgstr "Chronograph-CSV hochladen"
|
||||
|
||||
msgid "Analyse & link"
|
||||
msgstr "Analysieren & verknüpfen"
|
||||
|
||||
msgid "Invalid email or password."
|
||||
msgstr "Ungültige E-Mail-Adresse oder ungültiges Passwort."
|
||||
|
||||
msgid "Account created! Welcome."
|
||||
msgstr "Konto erstellt! Willkommen."
|
||||
|
||||
msgid "Profile updated."
|
||||
msgstr "Profil aktualisiert."
|
||||
|
||||
msgid "Password changed."
|
||||
msgstr "Passwort geändert."
|
||||
|
||||
msgid "'%(name)s' added."
|
||||
msgstr "'%(name)s' hinzugefügt."
|
||||
|
||||
msgid "'%(name)s' updated."
|
||||
msgstr "'%(name)s' aktualisiert."
|
||||
|
||||
msgid "'%(name)s' deleted."
|
||||
msgstr "'%(name)s' gelöscht."
|
||||
|
||||
msgid "Name is required."
|
||||
msgstr "Name ist erforderlich."
|
||||
|
||||
msgid "Invalid category."
|
||||
msgstr "Ungültige Kategorie."
|
||||
|
||||
msgid "Session saved."
|
||||
msgstr "Sitzung gespeichert."
|
||||
|
||||
msgid "Session deleted."
|
||||
msgstr "Sitzung gelöscht."
|
||||
|
||||
msgid "Stages saved."
|
||||
msgstr "Stages gespeichert."
|
||||
|
||||
msgid "Photo deleted."
|
||||
msgstr "Foto gelöscht."
|
||||
|
||||
msgid "Analysis deleted."
|
||||
msgstr "Analyse gelöscht."
|
||||
|
||||
msgid "Title updated."
|
||||
msgstr "Titel aktualisiert."
|
||||
|
||||
msgid "Regrouped."
|
||||
msgstr "Neu gruppiert."
|
||||
|
||||
msgid "Note saved."
|
||||
msgstr "Notiz gespeichert."
|
||||
|
||||
msgid "Please log in to access this page."
|
||||
msgstr "Bitte melden Sie sich an, um auf diese Seite zuzugreifen."
|
||||
BIN
translations/en/LC_MESSAGES/messages.mo
Normal file
BIN
translations/en/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
653
translations/en/LC_MESSAGES/messages.po
Normal file
653
translations/en/LC_MESSAGES/messages.po
Normal file
@@ -0,0 +1,653 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: en\n"
|
||||
|
||||
msgid "New Analysis"
|
||||
msgstr ""
|
||||
|
||||
msgid "Equipment"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sessions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Dashboard"
|
||||
msgstr ""
|
||||
|
||||
msgid "Profile"
|
||||
msgstr ""
|
||||
|
||||
msgid "Logout"
|
||||
msgstr ""
|
||||
|
||||
msgid "Login"
|
||||
msgstr ""
|
||||
|
||||
msgid "Join free"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete"
|
||||
msgstr ""
|
||||
|
||||
msgid "Apply"
|
||||
msgstr ""
|
||||
|
||||
msgid "Upload"
|
||||
msgstr ""
|
||||
|
||||
msgid "Notes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Public"
|
||||
msgstr ""
|
||||
|
||||
msgid "Private"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date"
|
||||
msgstr ""
|
||||
|
||||
msgid "Location"
|
||||
msgstr ""
|
||||
|
||||
msgid "Name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Category"
|
||||
msgstr ""
|
||||
|
||||
msgid "Caliber"
|
||||
msgstr ""
|
||||
|
||||
msgid "Distance"
|
||||
msgstr ""
|
||||
|
||||
msgid "Position"
|
||||
msgstr ""
|
||||
|
||||
msgid "Log a Session"
|
||||
msgstr ""
|
||||
|
||||
msgid "Get started — free"
|
||||
msgstr ""
|
||||
|
||||
msgid "Try without account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Ballistic Analysis"
|
||||
msgstr ""
|
||||
|
||||
msgid "Session Tracking"
|
||||
msgstr ""
|
||||
|
||||
msgid "Community Feed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Upload CSV files from your chronograph and get instant shot-group statistics, velocity charts, and PDF reports."
|
||||
msgstr ""
|
||||
|
||||
msgid "Log every range visit with location, weather, rifle, ammo, and distance. All your data in one place."
|
||||
msgstr ""
|
||||
|
||||
msgid "Share your public sessions and see what other shooters are achieving on the range."
|
||||
msgstr ""
|
||||
|
||||
msgid "Latest sessions"
|
||||
msgstr ""
|
||||
|
||||
msgid "No public sessions yet. Be the first to share one!"
|
||||
msgstr ""
|
||||
|
||||
msgid "Analyze"
|
||||
msgstr ""
|
||||
|
||||
msgid "Upload a CSV file to analyse shot groups. The file must contain the following columns:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Analysis Results"
|
||||
msgstr ""
|
||||
|
||||
msgid "← Upload another file"
|
||||
msgstr ""
|
||||
|
||||
msgid "View saved report →"
|
||||
msgstr ""
|
||||
|
||||
msgid "⬙ Download PDF report"
|
||||
msgstr ""
|
||||
|
||||
msgid "Overall Statistics"
|
||||
msgstr ""
|
||||
|
||||
msgid "Metric"
|
||||
msgstr ""
|
||||
|
||||
msgid "Value"
|
||||
msgstr ""
|
||||
|
||||
msgid "Total shots"
|
||||
msgstr ""
|
||||
|
||||
msgid "Min speed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Max speed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Mean speed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Std dev (speed)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "group(s) detected"
|
||||
msgstr ""
|
||||
|
||||
msgid "Group %(n)s"
|
||||
msgstr ""
|
||||
|
||||
msgid "shot(s)"
|
||||
msgstr ""
|
||||
|
||||
msgid "group(s)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Analysis"
|
||||
msgstr ""
|
||||
|
||||
msgid "Welcome back, %(name)s."
|
||||
msgstr ""
|
||||
|
||||
msgid "+ New session"
|
||||
msgstr ""
|
||||
|
||||
msgid "+ Add equipment"
|
||||
msgstr ""
|
||||
|
||||
msgid "New analysis"
|
||||
msgstr ""
|
||||
|
||||
msgid "Recent Analyses"
|
||||
msgstr ""
|
||||
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
msgid "Shots"
|
||||
msgstr ""
|
||||
|
||||
msgid "Visibility"
|
||||
msgstr ""
|
||||
|
||||
msgid "No analyses yet."
|
||||
msgstr ""
|
||||
|
||||
msgid "Upload a CSV file"
|
||||
msgstr ""
|
||||
|
||||
msgid "to get started — it will be saved here automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Manage your rifles, scopes & gear →"
|
||||
msgstr ""
|
||||
|
||||
msgid "View your shooting sessions →"
|
||||
msgstr ""
|
||||
|
||||
msgid "Sign in"
|
||||
msgstr ""
|
||||
|
||||
msgid "Password"
|
||||
msgstr ""
|
||||
|
||||
msgid "Email"
|
||||
msgstr ""
|
||||
|
||||
msgid "Resend confirmation email"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't have an account?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Create one"
|
||||
msgstr ""
|
||||
|
||||
msgid "or continue with"
|
||||
msgstr ""
|
||||
|
||||
msgid "Continue with Google"
|
||||
msgstr ""
|
||||
|
||||
msgid "Continue with GitHub"
|
||||
msgstr ""
|
||||
|
||||
msgid "Create account"
|
||||
msgstr ""
|
||||
|
||||
msgid "(min. 8 characters)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm password"
|
||||
msgstr ""
|
||||
|
||||
msgid "Already have an account?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Profile picture"
|
||||
msgstr ""
|
||||
|
||||
msgid "JPEG/PNG, max 1200 px, auto-resized."
|
||||
msgstr ""
|
||||
|
||||
msgid "Display name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Bio"
|
||||
msgstr ""
|
||||
|
||||
msgid "Tell others a bit about yourself…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Show my equipment on my public profile"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save changes"
|
||||
msgstr ""
|
||||
|
||||
msgid "View my public profile →"
|
||||
msgstr ""
|
||||
|
||||
msgid "Change password"
|
||||
msgstr ""
|
||||
|
||||
msgid "Current password"
|
||||
msgstr ""
|
||||
|
||||
msgid "New password"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm"
|
||||
msgstr ""
|
||||
|
||||
msgid "Member since %(date)s"
|
||||
msgstr ""
|
||||
|
||||
msgid "Session"
|
||||
msgstr ""
|
||||
|
||||
msgid "Brand / Model"
|
||||
msgstr ""
|
||||
|
||||
msgid "No public sessions yet."
|
||||
msgstr ""
|
||||
|
||||
msgid "No equipment listed."
|
||||
msgstr ""
|
||||
|
||||
msgid "My Equipment"
|
||||
msgstr ""
|
||||
|
||||
msgid "+ Add item"
|
||||
msgstr ""
|
||||
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete %(name)s?"
|
||||
msgstr ""
|
||||
|
||||
msgid "No equipment yet."
|
||||
msgstr ""
|
||||
|
||||
msgid "Add your first item"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add equipment"
|
||||
msgstr ""
|
||||
|
||||
msgid "Category *"
|
||||
msgstr ""
|
||||
|
||||
msgid "Name *"
|
||||
msgstr ""
|
||||
|
||||
msgid "Brand"
|
||||
msgstr ""
|
||||
|
||||
msgid "Model"
|
||||
msgstr ""
|
||||
|
||||
msgid "Magnification"
|
||||
msgstr ""
|
||||
|
||||
msgid "Reticle"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Serial number"
|
||||
msgstr ""
|
||||
|
||||
msgid "Photo"
|
||||
msgstr ""
|
||||
|
||||
msgid "Upload a new one to replace it."
|
||||
msgstr ""
|
||||
|
||||
msgid "My Sessions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Type"
|
||||
msgstr ""
|
||||
|
||||
msgid "Long Range"
|
||||
msgstr ""
|
||||
|
||||
msgid "25m Pistol"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete this session?"
|
||||
msgstr ""
|
||||
|
||||
msgid "No sessions recorded yet."
|
||||
msgstr ""
|
||||
|
||||
msgid "Log your first session"
|
||||
msgstr ""
|
||||
|
||||
msgid "New session"
|
||||
msgstr ""
|
||||
|
||||
msgid "Choose the session type."
|
||||
msgstr ""
|
||||
|
||||
msgid "Long Range Practice"
|
||||
msgstr ""
|
||||
|
||||
msgid "Long range precision shooting (100m+)"
|
||||
msgstr ""
|
||||
|
||||
msgid "PRS"
|
||||
msgstr ""
|
||||
|
||||
msgid "Precision Rifle Series — training & competition"
|
||||
msgstr ""
|
||||
|
||||
msgid "25m precision pistol shooting"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stage management, PDF dope card ↗"
|
||||
msgstr ""
|
||||
|
||||
msgid "← Change type"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit session"
|
||||
msgstr ""
|
||||
|
||||
msgid "Basic information"
|
||||
msgstr ""
|
||||
|
||||
msgid "Date *"
|
||||
msgstr ""
|
||||
|
||||
msgid "Distance (m)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Shooting position"
|
||||
msgstr ""
|
||||
|
||||
msgid "Weather"
|
||||
msgstr ""
|
||||
|
||||
msgid "Conditions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Temp. (°C)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wind (km/h)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Equipment & Ammunition"
|
||||
msgstr ""
|
||||
|
||||
msgid "Rifle / Handgun"
|
||||
msgstr ""
|
||||
|
||||
msgid "— none —"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add a rifle first"
|
||||
msgstr ""
|
||||
|
||||
msgid "Scope"
|
||||
msgstr ""
|
||||
|
||||
msgid "Ammo brand"
|
||||
msgstr ""
|
||||
|
||||
msgid "Bullet weight (gr)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Lot number"
|
||||
msgstr ""
|
||||
|
||||
msgid "Notes & Visibility"
|
||||
msgstr ""
|
||||
|
||||
msgid "Make this session public (visible in the community feed)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Create session"
|
||||
msgstr ""
|
||||
|
||||
msgid "Session type:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete this session? This cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
msgid "km/h wind"
|
||||
msgstr ""
|
||||
|
||||
msgid "Ammunition"
|
||||
msgstr ""
|
||||
|
||||
msgid "PRS Stages"
|
||||
msgstr ""
|
||||
|
||||
msgid "Time (s)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Elevation Dope"
|
||||
msgstr ""
|
||||
|
||||
msgid "Windage Dope"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hits/Poss."
|
||||
msgstr ""
|
||||
|
||||
msgid "✏ Edit stages"
|
||||
msgstr ""
|
||||
|
||||
msgid "📄 Generate dope card (PDF)"
|
||||
msgstr ""
|
||||
|
||||
msgid "+ Add stage"
|
||||
msgstr ""
|
||||
|
||||
msgid "💾 Save"
|
||||
msgstr ""
|
||||
|
||||
msgid "Photos"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add photo"
|
||||
msgstr ""
|
||||
|
||||
msgid "Caption (optional)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Measure group"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete this photo?"
|
||||
msgstr ""
|
||||
|
||||
msgid "clean barrel"
|
||||
msgstr ""
|
||||
|
||||
msgid "Group ES"
|
||||
msgstr ""
|
||||
|
||||
msgid "Mean Radius"
|
||||
msgstr ""
|
||||
|
||||
msgid "Centre"
|
||||
msgstr ""
|
||||
|
||||
msgid "high"
|
||||
msgstr ""
|
||||
|
||||
msgid "low"
|
||||
msgstr ""
|
||||
|
||||
msgid "Analyses"
|
||||
msgstr ""
|
||||
|
||||
msgid "shots"
|
||||
msgstr ""
|
||||
|
||||
msgid "group"
|
||||
msgstr ""
|
||||
|
||||
msgid "groups"
|
||||
msgstr ""
|
||||
|
||||
msgid "m/s mean"
|
||||
msgstr ""
|
||||
|
||||
msgid "✏ Rename"
|
||||
msgstr ""
|
||||
|
||||
msgid "Full view"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete this analysis? This cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
msgid "mean"
|
||||
msgstr ""
|
||||
|
||||
msgid "SD"
|
||||
msgstr ""
|
||||
|
||||
msgid "ES"
|
||||
msgstr ""
|
||||
|
||||
msgid "Note"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save note"
|
||||
msgstr ""
|
||||
|
||||
msgid "CSV file missing — cannot display charts."
|
||||
msgstr ""
|
||||
|
||||
msgid "Re-group settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Outlier factor:"
|
||||
msgstr ""
|
||||
|
||||
msgid "1 (fine)"
|
||||
msgstr ""
|
||||
|
||||
msgid "20 (coarse)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Manual split indices (JSON array, e.g. [5, 12])"
|
||||
msgstr ""
|
||||
|
||||
msgid "Shot positions (0-based) where a new group should always begin."
|
||||
msgstr ""
|
||||
|
||||
msgid "Upload chronograph CSV"
|
||||
msgstr ""
|
||||
|
||||
msgid "Analyse & link"
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid email or password."
|
||||
msgstr ""
|
||||
|
||||
msgid "Account created! Welcome."
|
||||
msgstr ""
|
||||
|
||||
msgid "Profile updated."
|
||||
msgstr ""
|
||||
|
||||
msgid "Password changed."
|
||||
msgstr ""
|
||||
|
||||
msgid "'%(name)s' added."
|
||||
msgstr ""
|
||||
|
||||
msgid "'%(name)s' updated."
|
||||
msgstr ""
|
||||
|
||||
msgid "'%(name)s' deleted."
|
||||
msgstr ""
|
||||
|
||||
msgid "Name is required."
|
||||
msgstr ""
|
||||
|
||||
msgid "Invalid category."
|
||||
msgstr ""
|
||||
|
||||
msgid "Session saved."
|
||||
msgstr ""
|
||||
|
||||
msgid "Session deleted."
|
||||
msgstr ""
|
||||
|
||||
msgid "Stages saved."
|
||||
msgstr ""
|
||||
|
||||
msgid "Photo deleted."
|
||||
msgstr ""
|
||||
|
||||
msgid "Analysis deleted."
|
||||
msgstr ""
|
||||
|
||||
msgid "Title updated."
|
||||
msgstr ""
|
||||
|
||||
msgid "Regrouped."
|
||||
msgstr ""
|
||||
|
||||
msgid "Note saved."
|
||||
msgstr ""
|
||||
|
||||
msgid "Please log in to access this page."
|
||||
msgstr ""
|
||||
BIN
translations/fr/LC_MESSAGES/messages.mo
Normal file
BIN
translations/fr/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
653
translations/fr/LC_MESSAGES/messages.po
Normal file
653
translations/fr/LC_MESSAGES/messages.po
Normal file
@@ -0,0 +1,653 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: fr\n"
|
||||
|
||||
msgid "New Analysis"
|
||||
msgstr "Nouvelle analyse"
|
||||
|
||||
msgid "Equipment"
|
||||
msgstr "Équipement"
|
||||
|
||||
msgid "Sessions"
|
||||
msgstr "Séances"
|
||||
|
||||
msgid "Dashboard"
|
||||
msgstr "Tableau de bord"
|
||||
|
||||
msgid "Profile"
|
||||
msgstr "Profil"
|
||||
|
||||
msgid "Logout"
|
||||
msgstr "Déconnexion"
|
||||
|
||||
msgid "Login"
|
||||
msgstr "Connexion"
|
||||
|
||||
msgid "Join free"
|
||||
msgstr "Inscription gratuite"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Enregistrer"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "Annuler"
|
||||
|
||||
msgid "Edit"
|
||||
msgstr "Modifier"
|
||||
|
||||
msgid "Delete"
|
||||
msgstr "Supprimer"
|
||||
|
||||
msgid "Apply"
|
||||
msgstr "Appliquer"
|
||||
|
||||
msgid "Upload"
|
||||
msgstr "Envoyer"
|
||||
|
||||
msgid "Notes"
|
||||
msgstr "Notes"
|
||||
|
||||
msgid "Public"
|
||||
msgstr "Public"
|
||||
|
||||
msgid "Private"
|
||||
msgstr "Privé"
|
||||
|
||||
msgid "Date"
|
||||
msgstr "Date"
|
||||
|
||||
msgid "Location"
|
||||
msgstr "Lieu"
|
||||
|
||||
msgid "Name"
|
||||
msgstr "Nom"
|
||||
|
||||
msgid "Category"
|
||||
msgstr "Catégorie"
|
||||
|
||||
msgid "Caliber"
|
||||
msgstr "Calibre"
|
||||
|
||||
msgid "Distance"
|
||||
msgstr "Distance"
|
||||
|
||||
msgid "Position"
|
||||
msgstr "Position"
|
||||
|
||||
msgid "Log a Session"
|
||||
msgstr "Enregistrer une séance"
|
||||
|
||||
msgid "Get started — free"
|
||||
msgstr "Commencer — gratuit"
|
||||
|
||||
msgid "Try without account"
|
||||
msgstr "Essayer sans compte"
|
||||
|
||||
msgid "Ballistic Analysis"
|
||||
msgstr "Analyse balistique"
|
||||
|
||||
msgid "Session Tracking"
|
||||
msgstr "Suivi des séances"
|
||||
|
||||
msgid "Community Feed"
|
||||
msgstr "Fil communautaire"
|
||||
|
||||
msgid "Upload CSV files from your chronograph and get instant shot-group statistics, velocity charts, and PDF reports."
|
||||
msgstr "Importez des fichiers CSV de votre chrono et obtenez instantanément des statistiques de groupes, des graphiques de vélocité et des rapports PDF."
|
||||
|
||||
msgid "Log every range visit with location, weather, rifle, ammo, and distance. All your data in one place."
|
||||
msgstr "Enregistrez chaque séance avec lieu, météo, arme, munitions et distance. Toutes vos données au même endroit."
|
||||
|
||||
msgid "Share your public sessions and see what other shooters are achieving on the range."
|
||||
msgstr "Partagez vos séances publiques et voyez ce que les autres tireurs accomplissent sur le stand."
|
||||
|
||||
msgid "Latest sessions"
|
||||
msgstr "Dernières séances"
|
||||
|
||||
msgid "No public sessions yet. Be the first to share one!"
|
||||
msgstr "Aucune séance publique pour l'instant. Soyez le premier à en partager une !"
|
||||
|
||||
msgid "Analyze"
|
||||
msgstr "Analyser"
|
||||
|
||||
msgid "Upload a CSV file to analyse shot groups. The file must contain the following columns:"
|
||||
msgstr "Importez un fichier CSV pour analyser les groupes de tirs. Le fichier doit contenir les colonnes suivantes :"
|
||||
|
||||
msgid "Analysis Results"
|
||||
msgstr "Résultats de l'analyse"
|
||||
|
||||
msgid "← Upload another file"
|
||||
msgstr "← Importer un autre fichier"
|
||||
|
||||
msgid "View saved report →"
|
||||
msgstr "Voir le rapport sauvegardé →"
|
||||
|
||||
msgid "⬙ Download PDF report"
|
||||
msgstr "⬙ Télécharger le rapport PDF"
|
||||
|
||||
msgid "Overall Statistics"
|
||||
msgstr "Statistiques globales"
|
||||
|
||||
msgid "Metric"
|
||||
msgstr "Indicateur"
|
||||
|
||||
msgid "Value"
|
||||
msgstr "Valeur"
|
||||
|
||||
msgid "Total shots"
|
||||
msgstr "Tirs totaux"
|
||||
|
||||
msgid "Min speed"
|
||||
msgstr "Vitesse min."
|
||||
|
||||
msgid "Max speed"
|
||||
msgstr "Vitesse max."
|
||||
|
||||
msgid "Mean speed"
|
||||
msgstr "Vitesse moyenne"
|
||||
|
||||
msgid "Std dev (speed)"
|
||||
msgstr "Écart-type (vitesse)"
|
||||
|
||||
msgid "Groups"
|
||||
msgstr "Groupes"
|
||||
|
||||
msgid "group(s) detected"
|
||||
msgstr "groupe(s) détecté(s)"
|
||||
|
||||
msgid "Group %(n)s"
|
||||
msgstr "Groupe %(n)s"
|
||||
|
||||
msgid "shot(s)"
|
||||
msgstr "tir(s)"
|
||||
|
||||
msgid "group(s)"
|
||||
msgstr "groupe(s)"
|
||||
|
||||
msgid "Analysis"
|
||||
msgstr "Analyse"
|
||||
|
||||
msgid "Welcome back, %(name)s."
|
||||
msgstr "Bienvenue, %(name)s."
|
||||
|
||||
msgid "+ New session"
|
||||
msgstr "+ Nouvelle séance"
|
||||
|
||||
msgid "+ Add equipment"
|
||||
msgstr "+ Ajouter du matériel"
|
||||
|
||||
msgid "New analysis"
|
||||
msgstr "Nouvelle analyse"
|
||||
|
||||
msgid "Recent Analyses"
|
||||
msgstr "Analyses récentes"
|
||||
|
||||
msgid "Title"
|
||||
msgstr "Titre"
|
||||
|
||||
msgid "Shots"
|
||||
msgstr "Tirs"
|
||||
|
||||
msgid "Visibility"
|
||||
msgstr "Visibilité"
|
||||
|
||||
msgid "No analyses yet."
|
||||
msgstr "Aucune analyse pour l'instant."
|
||||
|
||||
msgid "Upload a CSV file"
|
||||
msgstr "Importez un fichier CSV"
|
||||
|
||||
msgid "to get started — it will be saved here automatically."
|
||||
msgstr "pour commencer — il sera automatiquement sauvegardé ici."
|
||||
|
||||
msgid "Manage your rifles, scopes & gear →"
|
||||
msgstr "Gérez vos armes, optiques et équipement →"
|
||||
|
||||
msgid "View your shooting sessions →"
|
||||
msgstr "Voir vos séances de tir →"
|
||||
|
||||
msgid "Sign in"
|
||||
msgstr "Connexion"
|
||||
|
||||
msgid "Password"
|
||||
msgstr "Mot de passe"
|
||||
|
||||
msgid "Email"
|
||||
msgstr "E-mail"
|
||||
|
||||
msgid "Resend confirmation email"
|
||||
msgstr "Renvoyer l'e-mail de confirmation"
|
||||
|
||||
msgid "Don't have an account?"
|
||||
msgstr "Pas de compte ?"
|
||||
|
||||
msgid "Create one"
|
||||
msgstr "Créez-en un"
|
||||
|
||||
msgid "or continue with"
|
||||
msgstr "ou continuer avec"
|
||||
|
||||
msgid "Continue with Google"
|
||||
msgstr "Continuer avec Google"
|
||||
|
||||
msgid "Continue with GitHub"
|
||||
msgstr "Continuer avec GitHub"
|
||||
|
||||
msgid "Create account"
|
||||
msgstr "Créer un compte"
|
||||
|
||||
msgid "(min. 8 characters)"
|
||||
msgstr "(min. 8 caractères)"
|
||||
|
||||
msgid "Confirm password"
|
||||
msgstr "Confirmer le mot de passe"
|
||||
|
||||
msgid "Already have an account?"
|
||||
msgstr "Vous avez déjà un compte ?"
|
||||
|
||||
msgid "Account"
|
||||
msgstr "Compte"
|
||||
|
||||
msgid "Profile picture"
|
||||
msgstr "Photo de profil"
|
||||
|
||||
msgid "JPEG/PNG, max 1200 px, auto-resized."
|
||||
msgstr "JPEG/PNG, max 1200 px, redimensionné automatiquement."
|
||||
|
||||
msgid "Display name"
|
||||
msgstr "Nom d'affichage"
|
||||
|
||||
msgid "Bio"
|
||||
msgstr "Bio"
|
||||
|
||||
msgid "Tell others a bit about yourself…"
|
||||
msgstr "Parlez un peu de vous…"
|
||||
|
||||
msgid "Show my equipment on my public profile"
|
||||
msgstr "Afficher mon équipement sur mon profil public"
|
||||
|
||||
msgid "Save changes"
|
||||
msgstr "Enregistrer les modifications"
|
||||
|
||||
msgid "View my public profile →"
|
||||
msgstr "Voir mon profil public →"
|
||||
|
||||
msgid "Change password"
|
||||
msgstr "Changer le mot de passe"
|
||||
|
||||
msgid "Current password"
|
||||
msgstr "Mot de passe actuel"
|
||||
|
||||
msgid "New password"
|
||||
msgstr "Nouveau mot de passe"
|
||||
|
||||
msgid "Confirm"
|
||||
msgstr "Confirmer"
|
||||
|
||||
msgid "Member since %(date)s"
|
||||
msgstr "Membre depuis %(date)s"
|
||||
|
||||
msgid "Session"
|
||||
msgstr "Séance"
|
||||
|
||||
msgid "Brand / Model"
|
||||
msgstr "Marque / Modèle"
|
||||
|
||||
msgid "No public sessions yet."
|
||||
msgstr "Aucune séance publique pour l'instant."
|
||||
|
||||
msgid "No equipment listed."
|
||||
msgstr "Aucun équipement renseigné."
|
||||
|
||||
msgid "My Equipment"
|
||||
msgstr "Mon équipement"
|
||||
|
||||
msgid "+ Add item"
|
||||
msgstr "+ Ajouter un article"
|
||||
|
||||
msgid "View"
|
||||
msgstr "Voir"
|
||||
|
||||
msgid "Delete %(name)s?"
|
||||
msgstr "Supprimer %(name)s ?"
|
||||
|
||||
msgid "No equipment yet."
|
||||
msgstr "Aucun équipement pour l'instant."
|
||||
|
||||
msgid "Add your first item"
|
||||
msgstr "Ajoutez votre premier article"
|
||||
|
||||
msgid "Add equipment"
|
||||
msgstr "Ajouter du matériel"
|
||||
|
||||
msgid "Category *"
|
||||
msgstr "Catégorie *"
|
||||
|
||||
msgid "Name *"
|
||||
msgstr "Nom *"
|
||||
|
||||
msgid "Brand"
|
||||
msgstr "Marque"
|
||||
|
||||
msgid "Model"
|
||||
msgstr "Modèle"
|
||||
|
||||
msgid "Magnification"
|
||||
msgstr "Grossissement"
|
||||
|
||||
msgid "Reticle"
|
||||
msgstr "Réticule"
|
||||
|
||||
msgid "Unit"
|
||||
msgstr "Unité"
|
||||
|
||||
msgid "Serial number"
|
||||
msgstr "Numéro de série"
|
||||
|
||||
msgid "Photo"
|
||||
msgstr "Photo"
|
||||
|
||||
msgid "Upload a new one to replace it."
|
||||
msgstr "Importez-en une nouvelle pour la remplacer."
|
||||
|
||||
msgid "My Sessions"
|
||||
msgstr "Mes séances"
|
||||
|
||||
msgid "Type"
|
||||
msgstr "Type"
|
||||
|
||||
msgid "Long Range"
|
||||
msgstr "Long Range"
|
||||
|
||||
msgid "25m Pistol"
|
||||
msgstr "Pistolet 25m"
|
||||
|
||||
msgid "Delete this session?"
|
||||
msgstr "Supprimer cette séance ?"
|
||||
|
||||
msgid "No sessions recorded yet."
|
||||
msgstr "Aucune séance enregistrée pour l'instant."
|
||||
|
||||
msgid "Log your first session"
|
||||
msgstr "Enregistrez votre première séance"
|
||||
|
||||
msgid "New session"
|
||||
msgstr "Nouvelle séance"
|
||||
|
||||
msgid "Choose the session type."
|
||||
msgstr "Choisissez le type de séance."
|
||||
|
||||
msgid "Long Range Practice"
|
||||
msgstr "Long Range Practice"
|
||||
|
||||
msgid "Long range precision shooting (100m+)"
|
||||
msgstr "Tir de précision longue distance (100 m+)"
|
||||
|
||||
msgid "PRS"
|
||||
msgstr "PRS"
|
||||
|
||||
msgid "Precision Rifle Series — training & competition"
|
||||
msgstr "Precision Rifle Series — entraînement & compétition"
|
||||
|
||||
msgid "25m precision pistol shooting"
|
||||
msgstr "Tir de précision pistolet à 25 m"
|
||||
|
||||
msgid "Stage management, PDF dope card ↗"
|
||||
msgstr "Gestion des stages, fiche de tir PDF ↗"
|
||||
|
||||
msgid "← Change type"
|
||||
msgstr "← Changer le type"
|
||||
|
||||
msgid "Edit session"
|
||||
msgstr "Modifier la séance"
|
||||
|
||||
msgid "Basic information"
|
||||
msgstr "Informations de base"
|
||||
|
||||
msgid "Date *"
|
||||
msgstr "Date *"
|
||||
|
||||
msgid "Distance (m)"
|
||||
msgstr "Distance (m)"
|
||||
|
||||
msgid "Shooting position"
|
||||
msgstr "Position de tir"
|
||||
|
||||
msgid "Weather"
|
||||
msgstr "Météo"
|
||||
|
||||
msgid "Conditions"
|
||||
msgstr "Conditions"
|
||||
|
||||
msgid "Temp. (°C)"
|
||||
msgstr "Temp. (°C)"
|
||||
|
||||
msgid "Wind (km/h)"
|
||||
msgstr "Vent (km/h)"
|
||||
|
||||
msgid "Equipment & Ammunition"
|
||||
msgstr "Équipement & Munitions"
|
||||
|
||||
msgid "Rifle / Handgun"
|
||||
msgstr "Arme"
|
||||
|
||||
msgid "— none —"
|
||||
msgstr "— aucune —"
|
||||
|
||||
msgid "Add a rifle first"
|
||||
msgstr "Ajouter une arme d'abord"
|
||||
|
||||
msgid "Scope"
|
||||
msgstr "Optique"
|
||||
|
||||
msgid "Ammo brand"
|
||||
msgstr "Marque munitions"
|
||||
|
||||
msgid "Bullet weight (gr)"
|
||||
msgstr "Poids ogive (gr)"
|
||||
|
||||
msgid "Lot number"
|
||||
msgstr "N° de lot"
|
||||
|
||||
msgid "Notes & Visibility"
|
||||
msgstr "Notes & Visibilité"
|
||||
|
||||
msgid "Make this session public (visible in the community feed)"
|
||||
msgstr "Rendre cette séance publique (visible dans le fil communautaire)"
|
||||
|
||||
msgid "Create session"
|
||||
msgstr "Créer la séance"
|
||||
|
||||
msgid "Session type:"
|
||||
msgstr "Type de séance :"
|
||||
|
||||
msgid "Delete this session? This cannot be undone."
|
||||
msgstr "Supprimer cette séance ? Cette action est irréversible."
|
||||
|
||||
msgid "km/h wind"
|
||||
msgstr "km/h de vent"
|
||||
|
||||
msgid "Ammunition"
|
||||
msgstr "Munitions"
|
||||
|
||||
msgid "PRS Stages"
|
||||
msgstr "Stages PRS"
|
||||
|
||||
msgid "Time (s)"
|
||||
msgstr "Temps (s)"
|
||||
|
||||
msgid "Elevation Dope"
|
||||
msgstr "Dope Élév."
|
||||
|
||||
msgid "Windage Dope"
|
||||
msgstr "Dope Dérive"
|
||||
|
||||
msgid "Hits/Poss."
|
||||
msgstr "Coups/Poss."
|
||||
|
||||
msgid "✏ Edit stages"
|
||||
msgstr "✏ Modifier les stages"
|
||||
|
||||
msgid "📄 Generate dope card (PDF)"
|
||||
msgstr "📄 Générer la fiche de tir (PDF)"
|
||||
|
||||
msgid "+ Add stage"
|
||||
msgstr "+ Ajouter un stage"
|
||||
|
||||
msgid "💾 Save"
|
||||
msgstr "💾 Enregistrer"
|
||||
|
||||
msgid "Photos"
|
||||
msgstr "Photos"
|
||||
|
||||
msgid "Add photo"
|
||||
msgstr "Ajouter une photo"
|
||||
|
||||
msgid "Caption (optional)"
|
||||
msgstr "Légende (optionnel)"
|
||||
|
||||
msgid "Measure group"
|
||||
msgstr "Mesurer le groupe"
|
||||
|
||||
msgid "Delete this photo?"
|
||||
msgstr "Supprimer cette photo ?"
|
||||
|
||||
msgid "clean barrel"
|
||||
msgstr "canon propre"
|
||||
|
||||
msgid "Group ES"
|
||||
msgstr "ES du groupe"
|
||||
|
||||
msgid "Mean Radius"
|
||||
msgstr "Rayon moyen"
|
||||
|
||||
msgid "Centre"
|
||||
msgstr "Centre"
|
||||
|
||||
msgid "high"
|
||||
msgstr "haut"
|
||||
|
||||
msgid "low"
|
||||
msgstr "bas"
|
||||
|
||||
msgid "Analyses"
|
||||
msgstr "Analyses"
|
||||
|
||||
msgid "shots"
|
||||
msgstr "tirs"
|
||||
|
||||
msgid "group"
|
||||
msgstr "groupe"
|
||||
|
||||
msgid "groups"
|
||||
msgstr "groupes"
|
||||
|
||||
msgid "m/s mean"
|
||||
msgstr "m/s moyen"
|
||||
|
||||
msgid "✏ Rename"
|
||||
msgstr "✏ Renommer"
|
||||
|
||||
msgid "Full view"
|
||||
msgstr "Vue complète"
|
||||
|
||||
msgid "Delete this analysis? This cannot be undone."
|
||||
msgstr "Supprimer cette analyse ? Cette action est irréversible."
|
||||
|
||||
msgid "mean"
|
||||
msgstr "moyen"
|
||||
|
||||
msgid "SD"
|
||||
msgstr "ET"
|
||||
|
||||
msgid "ES"
|
||||
msgstr "ES"
|
||||
|
||||
msgid "Note"
|
||||
msgstr "Note"
|
||||
|
||||
msgid "Save note"
|
||||
msgstr "Enregistrer la note"
|
||||
|
||||
msgid "CSV file missing — cannot display charts."
|
||||
msgstr "Fichier CSV manquant — impossible d'afficher les graphiques."
|
||||
|
||||
msgid "Re-group settings"
|
||||
msgstr "Paramètres de regroupement"
|
||||
|
||||
msgid "Outlier factor:"
|
||||
msgstr "Facteur d'anomalie :"
|
||||
|
||||
msgid "1 (fine)"
|
||||
msgstr "1 (fin)"
|
||||
|
||||
msgid "20 (coarse)"
|
||||
msgstr "20 (grossier)"
|
||||
|
||||
msgid "Manual split indices (JSON array, e.g. [5, 12])"
|
||||
msgstr "Indices de coupure manuels (tableau JSON, ex. [5, 12])"
|
||||
|
||||
msgid "Shot positions (0-based) where a new group should always begin."
|
||||
msgstr "Positions de tir (base 0) où un nouveau groupe doit toujours commencer."
|
||||
|
||||
msgid "Upload chronograph CSV"
|
||||
msgstr "Importer le CSV du chronographe"
|
||||
|
||||
msgid "Analyse & link"
|
||||
msgstr "Analyser & lier"
|
||||
|
||||
msgid "Invalid email or password."
|
||||
msgstr "Identifiant ou mot de passe incorrect."
|
||||
|
||||
msgid "Account created! Welcome."
|
||||
msgstr "Compte créé ! Bienvenue."
|
||||
|
||||
msgid "Profile updated."
|
||||
msgstr "Profil mis à jour."
|
||||
|
||||
msgid "Password changed."
|
||||
msgstr "Mot de passe modifié."
|
||||
|
||||
msgid "'%(name)s' added."
|
||||
msgstr "'%(name)s' ajouté."
|
||||
|
||||
msgid "'%(name)s' updated."
|
||||
msgstr "'%(name)s' mis à jour."
|
||||
|
||||
msgid "'%(name)s' deleted."
|
||||
msgstr "'%(name)s' supprimé."
|
||||
|
||||
msgid "Name is required."
|
||||
msgstr "Le nom est obligatoire."
|
||||
|
||||
msgid "Invalid category."
|
||||
msgstr "Catégorie invalide."
|
||||
|
||||
msgid "Session saved."
|
||||
msgstr "Séance sauvegardée."
|
||||
|
||||
msgid "Session deleted."
|
||||
msgstr "Séance supprimée."
|
||||
|
||||
msgid "Stages saved."
|
||||
msgstr "Stages sauvegardés."
|
||||
|
||||
msgid "Photo deleted."
|
||||
msgstr "Photo supprimée."
|
||||
|
||||
msgid "Analysis deleted."
|
||||
msgstr "Analyse supprimée."
|
||||
|
||||
msgid "Title updated."
|
||||
msgstr "Titre mis à jour."
|
||||
|
||||
msgid "Regrouped."
|
||||
msgstr "Regroupé."
|
||||
|
||||
msgid "Note saved."
|
||||
msgstr "Note sauvegardée."
|
||||
|
||||
msgid "Please log in to access this page."
|
||||
msgstr "Veuillez vous connecter pour accéder à cette page."
|
||||
Reference in New Issue
Block a user