Add i18n support for French and German with browser language detection
CI / test-and-package (push) Successful in 35s
CI / test-and-package (push) Successful in 35s
This commit is contained in:
@@ -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,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)
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user