Add i18n support for French and German with browser language detection
CI / test-and-package (push) Successful in 35s

This commit is contained in:
agentbox
2026-08-01 06:00:29 +00:00
parent 218e2b6fb0
commit b46691fee3
9 changed files with 519 additions and 78 deletions
+3
View File
@@ -7,6 +7,7 @@ from flask import Flask
from models import init_db from models import init_db
from auth import auth_bp from auth import auth_bp
from meals import meals_bp from meals import meals_bp
from i18n import init_i18n
def create_app() -> Flask: def create_app() -> Flask:
@@ -19,6 +20,8 @@ def create_app() -> Flask:
with app.app_context(): with app.app_context():
init_db() init_db()
init_i18n(app)
return app return app
+12 -11
View File
@@ -12,6 +12,7 @@ from models import (
get_all_households, get_all_households,
count_household_users, count_household_users,
) )
from i18n import t
auth_bp = Blueprint("auth", __name__) auth_bp = Blueprint("auth", __name__)
@@ -23,12 +24,12 @@ def login():
password = request.form.get("password", "") password = request.form.get("password", "")
if not username or not password: if not username or not password:
flash("Username and password are required.", "error") flash(t("Username and password are required."), "error")
return render_template("login.html") return render_template("login.html")
user = get_user_by_username(username) user = get_user_by_username(username)
if user is None or not check_password_hash(user["password_hash"], password): if user is None or not check_password_hash(user["password_hash"], password):
flash("Invalid username or password.", "error") flash(t("Invalid username or password."), "error")
return render_template("login.html") return render_template("login.html")
household_id = user["household_id"] household_id = user["household_id"]
@@ -59,32 +60,32 @@ def register():
errors = [] errors = []
if not username or len(username) < 2: if not username or len(username) < 2:
errors.append("Username must be at least 2 characters.") errors.append(t("Username must be at least 2 characters."))
if not password or len(password) < 4: if not password or len(password) < 4:
errors.append("Password must be at least 4 characters.") errors.append(t("Password must be at least 4 characters."))
if password != confirm: if password != confirm:
errors.append("Passwords do not match.") errors.append(t("Passwords do not match."))
if get_user_by_username(username): if get_user_by_username(username):
errors.append("Username already taken.") errors.append(t("Username already taken."))
target_household_id = None target_household_id = None
if household_action == "create": if household_action == "create":
if not new_household_name: if not new_household_name:
errors.append("Household name is required.") errors.append(t("Household name is required."))
elif get_household_by_name(new_household_name): elif get_household_by_name(new_household_name):
errors.append("A household with that name already exists.") errors.append(t("A household with that name already exists."))
else: else:
household = create_household(new_household_name) household = create_household(new_household_name)
target_household_id = household["id"] target_household_id = household["id"]
is_admin = True # First user in a new household is admin is_admin = True # First user in a new household is admin
else: else:
if not household_id: if not household_id:
errors.append("Please select a household.") errors.append(t("Please select a household."))
else: else:
hh = get_household_by_id(int(household_id)) hh = get_household_by_id(int(household_id))
if hh is None: if hh is None:
errors.append("Selected household does not exist.") errors.append(t("Selected household does not exist."))
else: else:
target_household_id = hh["id"] target_household_id = hh["id"]
is_admin = False is_admin = False
@@ -104,7 +105,7 @@ def register():
session["household_id"] = user["household_id"] session["household_id"] = user["household_id"]
session["household_name"] = get_household_by_id(target_household_id)["name"] session["household_name"] = get_household_by_id(target_household_id)["name"]
flash("Account created! Welcome to your household.", "success") flash(t("Account created! Welcome to your household."), "success")
return redirect(url_for("meals.dashboard")) return redirect(url_for("meals.dashboard"))
return render_template("register.html", households=households) return render_template("register.html", households=households)
+401
View File
@@ -0,0 +1,401 @@
"""Lightweight i18n — French and German translations, browser-language detection."""
from __future__ import annotations
from flask import request, session, g
# ── Supported languages ───────────────────────────────────────────────────────
LANGUAGES = {
"en": "English",
"fr": "Français",
"de": "Deutsch",
}
FALLBACK = "en"
# ── Translations ──────────────────────────────────────────────────────────────
# Keys are English strings; values are dicts of language → translated string.
# Only non-English translations are stored; English is the key itself.
TRANSLATIONS: dict[str, dict[str, str]] = {
# ── base.html ──
"Meal Tracker": {
"fr": "Suivi des Repas",
"de": "Mahlzeiten-Tracker",
},
"admin": {
"fr": "administrateur",
"de": "Administrator",
},
"Logout": {
"fr": "Déconnexion",
"de": "Abmelden",
},
# ── login.html ──
"Log In": {
"fr": "Se connecter",
"de": "Anmelden",
},
"Username": {
"fr": "Nom d'utilisateur",
"de": "Benutzername",
},
"Password": {
"fr": "Mot de passe",
"de": "Passwort",
},
"Don't have an account?": {
"fr": "Pas de compte ?",
"de": "Kein Konto?",
},
"Register": {
"fr": "S'inscrire",
"de": "Registrieren",
},
# ── register.html ──
"Create Account": {
"fr": "Créer un compte",
"de": "Konto erstellen",
},
"Confirm Password": {
"fr": "Confirmer le mot de passe",
"de": "Passwort bestätigen",
},
"Household": {
"fr": "Foyer",
"de": "Haushalt",
},
"Join an existing household": {
"fr": "Rejoindre un foyer existant",
"de": "Einem bestehenden Haushalt beitreten",
},
"— Select —": {
"fr": "— Sélectionner —",
"de": "— Auswählen —",
},
"No households yet. Create one below!": {
"fr": "Pas encore de foyer. Créez-en un ci-dessous !",
"de": "Noch keine Haushalte. Erstellen Sie unten einen!",
},
"Create a new household": {
"fr": "Créer un nouveau foyer",
"de": "Neuen Haushalt erstellen",
},
"Household name (e.g. The Smiths)": {
"fr": "Nom du foyer (ex. Les Dupont)",
"de": "Haushaltsname (z.B. Familie Müller)",
},
"You'll be the admin of this household.": {
"fr": "Vous serez l'administrateur de ce foyer.",
"de": "Sie werden der Administrator dieses Haushalts sein.",
},
"Already have an account?": {
"fr": "Vous avez déjà un compte ?",
"de": "Haben Sie bereits ein Konto?",
},
"Log in": {
"fr": "Se connecter",
"de": "Anmelden",
},
# ── dashboard.html ──
"Today's Meals": {
"fr": "Repas du jour",
"de": "Heutige Mahlzeiten",
},
"History — {date}": {
"fr": "Historique — {date}",
"de": "Verlauf — {date}",
},
"◀ Prev": {
"fr": "◀ Préc.",
"de": "◀ Zurück",
},
"Next ▶": {
"fr": "Suiv. ▶",
"de": "Weiter ▶",
},
"Today": {
"fr": "Aujourd'hui",
"de": "Heute",
},
"No registered users yet. Ask household members to register!": {
"fr": "Pas encore d'utilisateurs. Invitez les membres du foyer à s'inscrire !",
"de": "Noch keine registrierten Benutzer. Bitten Sie Haushaltsmitglieder, sich zu registrieren!",
},
"User": {
"fr": "Utilisateur",
"de": "Benutzer",
},
"Lunch": {
"fr": "Déjeuner",
"de": "Mittagessen",
},
"Dinner": {
"fr": "Dîner",
"de": "Abendessen",
},
"you": {
"fr": "vous",
"de": "du",
},
"Home": {
"fr": "Présent",
"de": "Zuhause",
},
"Out": {
"fr": "Absent",
"de": "Auswärts",
},
"": {
"fr": "",
"de": "",
},
"Remove": {
"fr": "Retirer",
"de": "Entfernen",
},
"Changed mind at {time}": {
"fr": "A changé d'avis à {time}",
"de": "Meinung geändert um {time}",
},
"⚙ Account Settings": {
"fr": "⚙ Paramètres du compte",
"de": "⚙ Kontoeinstellungen",
},
"Delete My Account": {
"fr": "Supprimer mon compte",
"de": "Mein Konto löschen",
},
"Enter your password to confirm": {
"fr": "Entrez votre mot de passe pour confirmer",
"de": "Geben Sie Ihr Passwort zur Bestätigung ein",
},
"Delete Household": {
"fr": "Supprimer le foyer",
"de": "Haushalt löschen",
},
"This will remove all users and all data in this household.": {
"fr": "Cela supprimera tous les utilisateurs et toutes les données de ce foyer.",
"de": "Dies entfernt alle Benutzer und alle Daten in diesem Haushalt.",
},
# ── JS confirm strings ──
"Delete YOUR account? This cannot be undone.": {
"fr": "Supprimer VOTRE compte ? Cette action est irréversible.",
"de": "IHR Konto löschen? Dies kann nicht rückgängig gemacht werden.",
},
"Remove {name} from this household?": {
"fr": "Retirer {name} de ce foyer ?",
"de": "{name} aus diesem Haushalt entfernen?",
},
"Delete the ENTIRE household and ALL members? This cannot be undone.": {
"fr": "Supprimer le foyer ENTIER et TOUS ses membres ? Cette action est irréversible.",
"de": "Den GESAMTEN Haushalt und ALLE Mitglieder löschen? Dies kann nicht rückgängig gemacht werden.",
},
# ── Page titles ──
"Login — Meal Tracker": {
"fr": "Connexion — Suivi des Repas",
"de": "Anmeldung — Mahlzeiten-Tracker",
},
"Register — Meal Tracker": {
"fr": "Inscription — Suivi des Repas",
"de": "Registrierung — Mahlzeiten-Tracker",
},
"Dashboard — Meal Tracker": {
"fr": "Tableau de bord — Suivi des Repas",
"de": "Übersicht — Mahlzeiten-Tracker",
},
# ── Flash / error messages ──
"Username and password are required.": {
"fr": "Le nom d'utilisateur et le mot de passe sont requis.",
"de": "Benutzername und Passwort sind erforderlich.",
},
"Invalid username or password.": {
"fr": "Nom d'utilisateur ou mot de passe invalide.",
"de": "Ungültiger Benutzername oder Passwort.",
},
"Username must be at least 2 characters.": {
"fr": "Le nom d'utilisateur doit comporter au moins 2 caractères.",
"de": "Der Benutzername muss mindestens 2 Zeichen lang sein.",
},
"Password must be at least 4 characters.": {
"fr": "Le mot de passe doit comporter au moins 4 caractères.",
"de": "Das Passwort muss mindestens 4 Zeichen lang sein.",
},
"Passwords do not match.": {
"fr": "Les mots de passe ne correspondent pas.",
"de": "Die Passwörter stimmen nicht überein.",
},
"Username already taken.": {
"fr": "Ce nom d'utilisateur est déjà pris.",
"de": "Dieser Benutzername ist bereits vergeben.",
},
"Household name is required.": {
"fr": "Le nom du foyer est requis.",
"de": "Der Haushaltsname ist erforderlich.",
},
"A household with that name already exists.": {
"fr": "Un foyer avec ce nom existe déjà.",
"de": "Ein Haushalt mit diesem Namen existiert bereits.",
},
"Please select a household.": {
"fr": "Veuillez sélectionner un foyer.",
"de": "Bitte wählen Sie einen Haushalt aus.",
},
"Selected household does not exist.": {
"fr": "Le foyer sélectionné n'existe pas.",
"de": "Der ausgewählte Haushalt existiert nicht.",
},
"Account created! Welcome to your household.": {
"fr": "Compte créé ! Bienvenue dans votre foyer.",
"de": "Konto erstellt! Willkommen in Ihrem Haushalt.",
},
"Invalid meal type.": {
"fr": "Type de repas invalide.",
"de": "Ungültige Mahlzeitart.",
},
"Invalid status.": {
"fr": "Statut invalide.",
"de": "Ungültiger Status.",
},
"Invalid date.": {
"fr": "Date invalide.",
"de": "Ungültiges Datum.",
},
"Cannot change responses for past dates.": {
"fr": "Impossible de modifier les réponses pour les dates passées.",
"de": "Antworten für vergangene Daten können nicht geändert werden.",
},
"Meal period not found.": {
"fr": "Période de repas introuvable.",
"de": "Mahlzeitenzeitraum nicht gefunden.",
},
"Invalid date format. Use YYYY-MM-DD.": {
"fr": "Format de date invalide. Utilisez AAAA-MM-JJ.",
"de": "Ungültiges Datumsformat. Verwenden Sie JJJJ-MM-TT.",
},
"Incorrect password. Account not deleted.": {
"fr": "Mot de passe incorrect. Compte non supprimé.",
"de": "Falsches Passwort. Konto nicht gelöscht.",
},
"Your account and household have been deleted.": {
"fr": "Votre compte et votre foyer ont été supprimés.",
"de": "Ihr Konto und Ihr Haushalt wurden gelöscht.",
},
"Your account has been deleted.": {
"fr": "Votre compte a été supprimé.",
"de": "Ihr Konto wurde gelöscht.",
},
"Only admins can remove users.": {
"fr": "Seuls les administrateurs peuvent retirer des utilisateurs.",
"de": "Nur Administratoren können Benutzer entfernen.",
},
"User not found in your household.": {
"fr": "Utilisateur introuvable dans votre foyer.",
"de": "Benutzer nicht in Ihrem Haushalt gefunden.",
},
"Cannot remove the admin. Transfer admin role first or delete the household.": {
"fr": "Impossible de retirer l'administrateur. Transférez d'abord le rôle admin ou supprimez le foyer.",
"de": "Der Administrator kann nicht entfernt werden. Übertragen Sie zuerst die Admin-Rolle oder löschen Sie den Haushalt.",
},
"User '{name}' has been removed.": {
"fr": "L'utilisateur '{name}' a été retiré.",
"de": "Benutzer '{name}' wurde entfernt.",
},
"Only admins can delete the household.": {
"fr": "Seuls les administrateurs peuvent supprimer le foyer.",
"de": "Nur Administratoren können den Haushalt löschen.",
},
"Incorrect password. Household not deleted.": {
"fr": "Mot de passe incorrect. Foyer non supprimé.",
"de": "Falsches Passwort. Haushalt nicht gelöscht.",
},
"Household and all members have been deleted.": {
"fr": "Le foyer et tous ses membres ont été supprimés.",
"de": "Haushalt und alle Mitglieder wurden gelöscht.",
},
}
# ── Helpers ───────────────────────────────────────────────────────────────────
def parse_accept_language(header: str | None) -> str:
"""Parse Accept-Language header and return the best supported language code."""
if not header:
return FALLBACK
best = FALLBACK
best_quality = 0.0
for part in header.split(","):
chunk = part.strip().split(";")
tag = chunk[0].strip().split("-")[0].lower() # "fr-FR" → "fr"
quality = 1.0
if len(chunk) > 1 and chunk[1].strip().startswith("q="):
try:
quality = float(chunk[1].strip()[2:])
except ValueError:
pass
if tag in LANGUAGES and quality > best_quality:
best_quality = quality
best = tag
return best
def get_lang() -> str:
"""Return current language code (from session, header, or fallback)."""
# Session override (user manually selected a language)
lang = session.get("lang")
if lang and lang in LANGUAGES:
return lang
# Parse Accept-Language header
lang = parse_accept_language(request.headers.get("Accept-Language", ""))
session["lang"] = lang
return lang
def t(key: str, **kwargs) -> str:
"""Translate a key into the current language.
Usage in templates: {{ t('Log In') }}
With placeholders: {{ t("User '{name}' has been removed.", name=username) }}
"""
lang = g.get("lang") or session.get("lang") or FALLBACK
if lang == FALLBACK:
result = key
else:
result = TRANSLATIONS.get(key, {}).get(lang, key)
if kwargs:
result = result.format(**kwargs)
return result
def init_i18n(app):
"""Register before_request handler and template globals on a Flask app."""
@app.before_request
def _set_lang():
# Allow ?lang= override in query string
qs_lang = request.args.get("lang")
if qs_lang and qs_lang in LANGUAGES:
session["lang"] = qs_lang
g.lang = get_lang()
@app.context_processor
def _inject_i18n():
return {
"t": t,
"lang": g.get("lang", FALLBACK),
"lang_name": LANGUAGES.get(g.get("lang", FALLBACK), "English"),
}
+17 -16
View File
@@ -23,6 +23,7 @@ from models import (
delete_household, delete_household,
count_household_users, count_household_users,
) )
from i18n import t
meals_bp = Blueprint("meals", __name__) meals_bp = Blueprint("meals", __name__)
@@ -60,7 +61,7 @@ def dashboard():
try: try:
today = datetime.strptime(date_str, "%Y-%m-%d").date() today = datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError: except ValueError:
flash("Invalid date format. Use YYYY-MM-DD.", "error") flash(t("Invalid date format. Use YYYY-MM-DD."), "error")
if household_id is None: if household_id is None:
session.clear() session.clear()
@@ -87,27 +88,27 @@ def respond():
date_str = request.form.get("date", date.today().isoformat()) date_str = request.form.get("date", date.today().isoformat())
if meal_type not in ("lunch", "dinner"): if meal_type not in ("lunch", "dinner"):
flash("Invalid meal type.", "error") flash(t("Invalid meal type."), "error")
return redirect(url_for("meals.dashboard")) return redirect(url_for("meals.dashboard"))
if status not in ("yes", "no"): if status not in ("yes", "no"):
flash("Invalid status.", "error") flash(t("Invalid status."), "error")
return redirect(url_for("meals.dashboard")) return redirect(url_for("meals.dashboard"))
try: try:
target_date = datetime.strptime(date_str, "%Y-%m-%d").date() target_date = datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError: except ValueError:
flash("Invalid date.", "error") flash(t("Invalid date."), "error")
return redirect(url_for("meals.dashboard")) return redirect(url_for("meals.dashboard"))
if target_date < date.today(): if target_date < date.today():
flash("Cannot change responses for past dates.", "error") flash(t("Cannot change responses for past dates."), "error")
return redirect(url_for("meals.dashboard")) return redirect(url_for("meals.dashboard"))
periods = ensure_meal_periods(target_date) periods = ensure_meal_periods(target_date)
period = next((p for p in periods if p["meal_type"] == meal_type), None) period = next((p for p in periods if p["meal_type"] == meal_type), None)
if period is None: if period is None:
flash("Meal period not found.", "error") flash(t("Meal period not found."), "error")
return redirect(url_for("meals.dashboard")) return redirect(url_for("meals.dashboard"))
upsert_response(session["user_id"], period["id"], status) upsert_response(session["user_id"], period["id"], status)
@@ -147,7 +148,7 @@ def delete_account():
user = get_user_by_id(session["user_id"]) user = get_user_by_id(session["user_id"])
if not check_password_hash(user["password_hash"], password): if not check_password_hash(user["password_hash"], password):
flash("Incorrect password. Account not deleted.", "error") flash(t("Incorrect password. Account not deleted."), "error")
return redirect(url_for("meals.dashboard")) return redirect(url_for("meals.dashboard"))
household_id = session["household_id"] household_id = session["household_id"]
@@ -157,10 +158,10 @@ def delete_account():
# If admin is the last user, delete the household too # If admin is the last user, delete the household too
if is_admin and user_count == 1: if is_admin and user_count == 1:
delete_household(household_id) delete_household(household_id)
flash("Your account and household have been deleted.", "success") flash(t("Your account and household have been deleted."), "success")
else: else:
delete_user(session["user_id"]) delete_user(session["user_id"])
flash("Your account has been deleted.", "success") flash(t("Your account has been deleted."), "success")
# Clear auth keys but keep the session so flash survives the redirect # Clear auth keys but keep the session so flash survives the redirect
for key in ("user_id", "username", "is_admin", "household_id", "household_name"): for key in ("user_id", "username", "is_admin", "household_id", "household_name"):
@@ -173,20 +174,20 @@ def delete_account():
def admin_remove_user(user_id): def admin_remove_user(user_id):
"""Admin removes a user from the household.""" """Admin removes a user from the household."""
if not session["is_admin"]: if not session["is_admin"]:
flash("Only admins can remove users.", "error") flash(t("Only admins can remove users."), "error")
return redirect(url_for("meals.dashboard")) return redirect(url_for("meals.dashboard"))
user = get_user_by_id(user_id) user = get_user_by_id(user_id)
if user is None or user["household_id"] != session["household_id"]: if user is None or user["household_id"] != session["household_id"]:
flash("User not found in your household.", "error") flash(t("User not found in your household."), "error")
return redirect(url_for("meals.dashboard")) return redirect(url_for("meals.dashboard"))
if user["is_admin"]: if user["is_admin"]:
flash("Cannot remove the admin. Transfer admin role first or delete the household.", "error") flash(t("Cannot remove the admin. Transfer admin role first or delete the household."), "error")
return redirect(url_for("meals.dashboard")) return redirect(url_for("meals.dashboard"))
delete_user(user_id) delete_user(user_id)
flash(f"User '{user['username']}' has been removed.", "success") flash(t("User '{name}' has been removed.", name=user["username"]), "success")
return redirect(url_for("meals.dashboard")) return redirect(url_for("meals.dashboard"))
@@ -195,18 +196,18 @@ def admin_remove_user(user_id):
def admin_delete_household(): def admin_delete_household():
"""Admin deletes the entire household.""" """Admin deletes the entire household."""
if not session["is_admin"]: if not session["is_admin"]:
flash("Only admins can delete the household.", "error") flash(t("Only admins can delete the household."), "error")
return redirect(url_for("meals.dashboard")) return redirect(url_for("meals.dashboard"))
password = request.form.get("password", "") password = request.form.get("password", "")
user = get_user_by_id(session["user_id"]) user = get_user_by_id(session["user_id"])
if not check_password_hash(user["password_hash"], password): if not check_password_hash(user["password_hash"], password):
flash("Incorrect password. Household not deleted.", "error") flash(t("Incorrect password. Household not deleted."), "error")
return redirect(url_for("meals.dashboard")) return redirect(url_for("meals.dashboard"))
delete_household(session["household_id"]) delete_household(session["household_id"])
flash("Household and all members have been deleted.", "success") flash(t("Household and all members have been deleted."), "success")
for key in ("user_id", "username", "is_admin", "household_id", "household_name"): for key in ("user_id", "username", "is_admin", "household_id", "household_name"):
session.pop(key, None) session.pop(key, None)
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
+22
View File
@@ -232,3 +232,25 @@ body {
.manage-form h4 { margin-bottom: 8px; font-size: 0.9rem; } .manage-form h4 { margin-bottom: 8px; font-size: 0.9rem; }
.manage-form .hint { margin-bottom: 8px; } .manage-form .hint { margin-bottom: 8px; }
.manage-form input[type="password"] { width: 100%; padding: 6px 10px; border: 1px solid var(--border); border-radius: 4px; margin-bottom: 8px; } .manage-form input[type="password"] { width: 100%; padding: 6px 10px; border: 1px solid var(--border); border-radius: 4px; margin-bottom: 8px; }
/* ── Language footer ── */
.lang-footer {
text-align: center;
padding: 16px;
margin-top: 32px;
border-top: 1px solid var(--border);
}
.lang-link {
display: inline-block;
padding: 4px 10px;
margin: 0 4px;
font-size: 0.8rem;
font-weight: 500;
color: var(--muted);
text-decoration: none;
border-radius: 12px;
transition: all 0.15s;
}
.lang-link:hover { background: #eee; color: var(--text); }
.lang-active { background: var(--primary); color: #fff; }
.lang-active:hover { background: #0d47a1; color: #fff; }
+13 -5
View File
@@ -1,19 +1,19 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="{{ lang }}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Meal Tracker{% endblock %}</title> <title>{% block title %}{{ t('Meal Tracker') }}{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head> </head>
<body> <body>
<nav class="navbar"> <nav class="navbar">
<span class="brand">🍽 Meal Tracker</span> <span class="brand">🍽 {{ t('Meal Tracker') }}</span>
{% if session.user_id %} {% if session.user_id %}
<div class="nav-right"> <div class="nav-right">
<span class="nav-household">{{ session.household_name }}</span> <span class="nav-household">{{ session.household_name }}</span>
<span class="nav-user">{{ session.username }}{% if session.is_admin %} (admin){% endif %}</span> <span class="nav-user">{{ session.username }}{% if session.is_admin %} ({{ t('admin') }}){% endif %}</span>
<a class="btn btn-sm" href="{{ url_for('auth.logout') }}">Logout</a> <a class="btn btn-sm" href="{{ url_for('auth.logout') }}">{{ t('Logout') }}</a>
</div> </div>
{% endif %} {% endif %}
</nav> </nav>
@@ -31,5 +31,13 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<footer class="lang-footer">
{% for code, name in {'en': 'English', 'fr': 'Français', 'de': 'Deutsch'}.items() %}
<a href="?lang={{ code }}"
class="lang-link{% if lang == code %} lang-active{% endif %}"
aria-label="{{ name }}">{{ code.upper() }}</a>
{% endfor %}
</footer>
</body> </body>
</html> </html>
+31 -26
View File
@@ -1,31 +1,31 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Dashboard — Meal Tracker{% endblock %} {% block title %}{{ t('Dashboard — Meal Tracker') }}{% endblock %}
{% block content %} {% block content %}
<div class="dashboard-header"> <div class="dashboard-header">
<h2> <h2>
{% if history_mode %}History — {{ viewing_date.isoformat() }}{% else %}Today's Meals{% endif %} {% if history_mode %}{{ t('History — {date}', date=viewing_date.isoformat()) }}{% else %}{{ t("Today's Meals") }}{% endif %}
</h2> </h2>
<div class="date-nav"> <div class="date-nav">
<a class="btn btn-sm" href="?date={{ prev_date.isoformat() }}">◀ Prev</a> <a class="btn btn-sm" href="?date={{ prev_date.isoformat() }}">{{ t('◀ Prev') }}</a>
<span class="date-display">{{ viewing_date.isoformat() }}</span> <span class="date-display">{{ viewing_date.isoformat() }}</span>
<a class="btn btn-sm" href="?date={{ next_date.isoformat() }}">Next ▶</a> <a class="btn btn-sm" href="?date={{ next_date.isoformat() }}">{{ t('Next ▶') }}</a>
{% if viewing_date != today %} {% if viewing_date != today %}
<a class="btn btn-sm" href="{{ url_for('meals.dashboard') }}">Today</a> <a class="btn btn-sm" href="{{ url_for('meals.dashboard') }}">{{ t('Today') }}</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% if not dashboard.users %} {% if not dashboard.users %}
<p class="empty-state">No registered users yet. Ask household members to register!</p> <p class="empty-state">{{ t('No registered users yet. Ask household members to register!') }}</p>
{% else %} {% else %}
<div class="table-wrapper"> <div class="table-wrapper">
<table class="meal-table"> <table class="meal-table">
<thead> <thead>
<tr> <tr>
<th>User</th> <th>{{ t('User') }}</th>
{% for period in dashboard.periods %} {% for period in dashboard.periods %}
<th class="meal-col">{{ period.meal_type|capitalize }}</th> <th class="meal-col">{{ t(period.meal_type|capitalize) }}</th>
{% endfor %} {% endfor %}
{% if is_admin %} {% if is_admin %}
<th class="action-col"></th> <th class="action-col"></th>
@@ -38,8 +38,8 @@
<tr> <tr>
<td class="user-cell"> <td class="user-cell">
{{ user.username }} {{ user.username }}
{% if user.is_admin %}<span class="admin-tag">admin</span>{% endif %} {% if user.is_admin %}<span class="admin-tag">{{ t('admin') }}</span>{% endif %}
{% if is_me %}<span class="you-tag">you</span>{% endif %} {% if is_me %}<span class="you-tag">{{ t('you') }}</span>{% endif %}
</td> </td>
{% for period in dashboard.periods %} {% for period in dashboard.periods %}
{% set resp = user.responses[period.meal_type] %} {% set resp = user.responses[period.meal_type] %}
@@ -51,25 +51,26 @@
<div class="btn-row"> <div class="btn-row">
<button name="status" value="yes" <button name="status" value="yes"
class="chip chip-yes {% if resp.status == 'yes' %}active{% endif %}" class="chip chip-yes {% if resp.status == 'yes' %}active{% endif %}"
>Home</button> >{{ t('Home') }}</button>
<button name="status" value="no" <button name="status" value="no"
class="chip chip-no {% if resp.status == 'no' %}active{% endif %}" class="chip chip-no {% if resp.status == 'no' %}active{% endif %}"
>Out</button> >{{ t('Out') }}</button>
</div> </div>
</form> </form>
{% if resp.changed_at %} {% if resp.changed_at %}
<div class="changed-badge" title="Changed mind at {{ resp.changed_at }}">↻ {{ resp.changed_at[11:16] }}</div> <div class="changed-badge" title="{{ t('Changed mind at {time}', time=resp.changed_at) }}">↻ {{ resp.changed_at[11:16] }}</div>
{% endif %} {% endif %}
{% else %} {% else %}
<span class="badge badge-{{ resp.status }}">{{ 'Home' if resp.status == 'yes' else ('Out' if resp.status == 'no' else '—') }}</span> <span class="badge badge-{{ resp.status }}">{{ t('Home') if resp.status == 'yes' else (t('Out') if resp.status == 'no' else t('—')) }}</span>
{% endif %} {% endif %}
</td> </td>
{% endfor %} {% endfor %}
{% if is_admin and not is_me and not user.is_admin %} {% if is_admin and not is_me and not user.is_admin %}
<td class="action-cell"> <td class="action-cell">
<form method="POST" action="{{ url_for('meals.admin_remove_user', user_id=user.id) }}" <form method="POST" action="{{ url_for('meals.admin_remove_user', user_id=user.id) }}"
onsubmit="return confirm('Remove {{ user.username }} from this household?');"> onsubmit="return confirm(this.dataset.confirmMsg);"
<button class="btn btn-sm btn-danger">Remove</button> data-confirm-msg="{{ t('Remove {name} from this household?', name=user.username) }}">
<button class="btn btn-sm btn-danger">{{ t('Remove') }}</button>
</form> </form>
</td> </td>
{% elif is_admin %} {% elif is_admin %}
@@ -84,22 +85,26 @@
<div class="manage-section"> <div class="manage-section">
<details class="manage-details"> <details class="manage-details">
<summary>⚙ Account Settings</summary> <summary>{{ t('⚙ Account Settings') }}</summary>
<div class="manage-forms"> <div class="manage-forms">
<form method="POST" action="{{ url_for('meals.delete_account') }}" <form method="POST" action="{{ url_for('meals.delete_account') }}"
onsubmit="return confirm('Delete YOUR account? This cannot be undone.');" class="manage-form"> onsubmit="return confirm(this.dataset.confirmMsg);"
<h4>Delete My Account</h4> data-confirm-msg="{{ t('Delete YOUR account? This cannot be undone.') }}"
<input type="password" name="password" placeholder="Enter your password to confirm" required> class="manage-form">
<button class="btn btn-danger">Delete My Account</button> <h4>{{ t('Delete My Account') }}</h4>
<input type="password" name="password" placeholder="{{ t('Enter your password to confirm') }}" required>
<button class="btn btn-danger">{{ t('Delete My Account') }}</button>
</form> </form>
{% if is_admin %} {% if is_admin %}
<form method="POST" action="{{ url_for('meals.admin_delete_household') }}" <form method="POST" action="{{ url_for('meals.admin_delete_household') }}"
onsubmit="return confirm('Delete the ENTIRE household and ALL members? This cannot be undone.');" class="manage-form"> onsubmit="return confirm(this.dataset.confirmMsg);"
<h4>Delete Household</h4> data-confirm-msg="{{ t('Delete the ENTIRE household and ALL members? This cannot be undone.') }}"
<p class="hint">This will remove all users and all data in this household.</p> class="manage-form">
<input type="password" name="password" placeholder="Enter your password to confirm" required> <h4>{{ t('Delete Household') }}</h4>
<button class="btn btn-danger">Delete Household</button> <p class="hint">{{ t('This will remove all users and all data in this household.') }}</p>
<input type="password" name="password" placeholder="{{ t('Enter your password to confirm') }}" required>
<button class="btn btn-danger">{{ t('Delete Household') }}</button>
</form> </form>
{% endif %} {% endif %}
</div> </div>
+6 -6
View File
@@ -1,19 +1,19 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Login — Meal Tracker{% endblock %} {% block title %}{{ t('Login — Meal Tracker') }}{% endblock %}
{% block content %} {% block content %}
<div class="form-card"> <div class="form-card">
<h2>Log In</h2> <h2>{{ t('Log In') }}</h2>
<form method="POST"> <form method="POST">
<label for="username">Username</label> <label for="username">{{ t('Username') }}</label>
<input type="text" id="username" name="username" required autofocus> <input type="text" id="username" name="username" required autofocus>
<label for="password">Password</label> <label for="password">{{ t('Password') }}</label>
<input type="password" id="password" name="password" required> <input type="password" id="password" name="password" required>
<button type="submit" class="btn btn-primary">Log In</button> <button type="submit" class="btn btn-primary">{{ t('Log In') }}</button>
</form> </form>
<p class="form-footer"> <p class="form-footer">
Don't have an account? <a href="{{ url_for('auth.register') }}">Register</a> {{ t("Don't have an account?") }} <a href="{{ url_for('auth.register') }}">{{ t('Register') }}</a>
</p> </p>
</div> </div>
{% endblock %} {% endblock %}
+14 -14
View File
@@ -1,54 +1,54 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Register — Meal Tracker{% endblock %} {% block title %}{{ t('Register — Meal Tracker') }}{% endblock %}
{% block content %} {% block content %}
<div class="form-card register-card"> <div class="form-card register-card">
<h2>Create Account</h2> <h2>{{ t('Create Account') }}</h2>
<form method="POST"> <form method="POST">
<label for="username">Username</label> <label for="username">{{ t('Username') }}</label>
<input type="text" id="username" name="username" required autofocus> <input type="text" id="username" name="username" required autofocus>
<label for="password">Password</label> <label for="password">{{ t('Password') }}</label>
<input type="password" id="password" name="password" required> <input type="password" id="password" name="password" required>
<label for="confirm">Confirm Password</label> <label for="confirm">{{ t('Confirm Password') }}</label>
<input type="password" id="confirm" name="confirm" required> <input type="password" id="confirm" name="confirm" required>
<fieldset class="household-section"> <fieldset class="household-section">
<legend>Household</legend> <legend>{{ t('Household') }}</legend>
<label class="radio-label"> <label class="radio-label">
<input type="radio" name="household_action" value="join" id="radio-join" checked> <input type="radio" name="household_action" value="join" id="radio-join" checked>
Join an existing household {{ t('Join an existing household') }}
</label> </label>
<div id="join-block" class="household-block"> <div id="join-block" class="household-block">
<select name="household_id" class="select-input"> <select name="household_id" class="select-input">
<option value="">— Select —</option> <option value="">{{ t('— Select —') }}</option>
{% for h in households %} {% for h in households %}
<option value="{{ h.id }}">{{ h.name }}</option> <option value="{{ h.id }}">{{ h.name }}</option>
{% endfor %} {% endfor %}
</select> </select>
{% if not households %} {% if not households %}
<p class="hint">No households yet. Create one below!</p> <p class="hint">{{ t('No households yet. Create one below!') }}</p>
{% endif %} {% endif %}
</div> </div>
<label class="radio-label"> <label class="radio-label">
<input type="radio" name="household_action" value="create" id="radio-create" <input type="radio" name="household_action" value="create" id="radio-create"
{% if not households %}checked{% endif %}> {% if not households %}checked{% endif %}>
Create a new household {{ t('Create a new household') }}
</label> </label>
<div id="create-block" class="household-block"> <div id="create-block" class="household-block">
<input type="text" name="new_household_name" <input type="text" name="new_household_name"
placeholder="Household name (e.g. The Smiths)" placeholder="{{ t('Household name (e.g. The Smiths)') }}"
class="text-input"> class="text-input">
<p class="hint">You'll be the admin of this household.</p> <p class="hint">{{ t("You'll be the admin of this household.") }}</p>
</div> </div>
</fieldset> </fieldset>
<button type="submit" class="btn btn-primary">Register</button> <button type="submit" class="btn btn-primary">{{ t('Register') }}</button>
</form> </form>
<p class="form-footer"> <p class="form-footer">
Already have an account? <a href="{{ url_for('auth.login') }}">Log in</a> {{ t('Already have an account?') }} <a href="{{ url_for('auth.login') }}">{{ t('Log in') }}</a>
</p> </p>
</div> </div>