From edd4580a5a91e0ae1b913dad6fe1e5a99a50410e Mon Sep 17 00:00:00 2001 From: Adriano Date: Sat, 7 Feb 2026 01:10:13 +0100 Subject: [PATCH] feat: FASE 2 - Client Base (layout, login, navbar, tema, i18n) Implementazione completa del frontend Flask: - Layout base.html con TailwindCSS CDN, dark/light theme, flash messages - Navbar responsive role-based (Maker, MeasurementTec, Metrologist, Admin) - Login page professionale con form + API integration - Profilo utente: nome, lingua, tema, badge ruoli - Sistema tema dark/light: CSS variables + Alpine.js store + localStorage - i18n completo IT/EN: Flask-Babel (.po) + alpinejs-i18n (JSON) - API Client riscritto: error handling normalizzato, no crash su 4xx/5xx - CSRF protection con Flask-WTF su tutti i form - Logo aziendale dinamico da system_settings - Asset SVG: tmflow-logo.svg + tmflow-icon.svg (favicon) Co-Authored-By: Claude Opus 4.6 --- client/app.py | 12 + client/blueprints/auth.py | 136 ++++++- client/requirements.txt | 1 + client/services/api_client.py | 128 ++++-- client/static/css/themes.css | 374 ++++++++++++++++++ client/static/img/tmflow-icon.svg | 27 ++ client/static/img/tmflow-logo.svg | 41 ++ client/static/js/alpine-init.js | 126 ++++++ client/static/js/locales/en.json | 76 ++++ client/static/js/locales/it.json | 76 ++++ client/templates/auth/login.html | 95 +++++ client/templates/auth/profile.html | 203 ++++++++++ client/templates/base.html | 167 ++++++++ client/templates/components/navbar.html | 324 +++++++++++++++ client/translations/babel.cfg | 2 + .../translations/en/LC_MESSAGES/messages.po | 247 ++++++++++++ .../translations/it/LC_MESSAGES/messages.po | 247 ++++++++++++ 17 files changed, 2230 insertions(+), 52 deletions(-) create mode 100644 client/static/css/themes.css create mode 100644 client/static/img/tmflow-icon.svg create mode 100644 client/static/img/tmflow-logo.svg create mode 100644 client/static/js/alpine-init.js create mode 100644 client/static/js/locales/en.json create mode 100644 client/static/js/locales/it.json create mode 100644 client/templates/auth/login.html create mode 100644 client/templates/auth/profile.html create mode 100644 client/templates/base.html create mode 100644 client/templates/components/navbar.html create mode 100644 client/translations/en/LC_MESSAGES/messages.po create mode 100644 client/translations/it/LC_MESSAGES/messages.po diff --git a/client/app.py b/client/app.py index 570e4ab..48ceb01 100644 --- a/client/app.py +++ b/client/app.py @@ -3,6 +3,7 @@ import os from flask import Flask, redirect, url_for, session, request from flask_babel import Babel +from flask_wtf.csrf import CSRFProtect from config import Config @@ -23,6 +24,9 @@ def create_app() -> Flask: app = Flask(__name__) app.config.from_object(Config) + # Initialize CSRF protection + csrf = CSRFProtect(app) + # Initialize Flask-Babel Babel(app, locale_selector=get_locale) @@ -44,6 +48,13 @@ def create_app() -> Flask: return redirect(url_for("measure.select_recipe")) return redirect(url_for("auth.login")) + @app.route("/set-language/") + def set_language(lang): + """Set user's preferred language and store in session.""" + if lang in Config.LANGUAGES: + session["language"] = lang + return redirect(request.referrer or url_for("auth.login")) + @app.context_processor def inject_globals(): """Inject global variables into all templates.""" @@ -52,6 +63,7 @@ def create_app() -> Flask: "current_theme": session.get("theme", "light"), "current_language": get_locale(), "languages": Config.LANGUAGES, + "company_logo": session.get("company_logo"), } return app diff --git a/client/blueprints/auth.py b/client/blueprints/auth.py index 3374092..0e023d7 100644 --- a/client/blueprints/auth.py +++ b/client/blueprints/auth.py @@ -1,31 +1,141 @@ """Authentication blueprint - login, logout, profile.""" -from flask import Blueprint, render_template, request, redirect, url_for, session, flash +from functools import wraps -auth_bp = Blueprint("auth", __name__) +from flask import Blueprint, flash, redirect, render_template, request, session, url_for +from flask_babel import gettext as _ + +from services.api_client import api_client + +auth_bp = Blueprint("auth", __name__, url_prefix="/auth") + + +def login_required(f): + """Decorator to require login for protected routes.""" + @wraps(f) + def decorated(*args, **kwargs): + if not session.get("api_key"): + flash(_("Effettua il login per continuare"), "warning") + return redirect(url_for("auth.login")) + return f(*args, **kwargs) + return decorated @auth_bp.route("/login", methods=["GET", "POST"]) def login(): - """Login page.""" + """Login page and form handler.""" + # Se già loggato, redirect a home + if session.get("api_key"): + try: + return redirect(url_for("measure.select_recipe")) + except Exception: + return redirect(url_for("auth.profile")) + if request.method == "POST": - # TODO: Implement API call to server /api/auth/login - pass + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + + if not username or not password: + flash(_("Inserisci username e password"), "error") + return render_template("auth/login.html") + + try: + response = api_client.post("/api/auth/login", data={"username": username, "password": password}) + + if response.get("error"): + flash(response.get("detail", _("Credenziali non valide")), "error") + return render_template("auth/login.html") + + # Salva in sessione + session["api_key"] = response["api_key"] + session["user"] = response["user"] + session["user_id"] = response["user"]["id"] + + # Applica preferenze utente + user = response["user"] + if user.get("language_pref"): + session["language"] = user["language_pref"] + if user.get("theme_pref"): + session["theme"] = user["theme_pref"] + + # Carica logo aziendale dalle impostazioni di sistema + settings_resp = api_client.get("/api/settings") + if not settings_resp.get("error"): + logo_path = settings_resp.get("company_logo_path") + if logo_path: + session["company_logo"] = logo_path + + flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success") + + # Redirect dopo login + try: + return redirect(url_for("measure.select_recipe")) + except Exception: + return redirect(url_for("auth.profile")) + + except Exception as e: + flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error") + return render_template("auth/login.html") + return render_template("auth/login.html") -@auth_bp.route("/logout") +@auth_bp.route("/logout", methods=["GET", "POST"]) def logout(): - """Logout - clear session.""" + """Clear session and redirect to login.""" + # Invalida il token sul server + if session.get("api_key"): + try: + api_client.post("/api/auth/logout") + except Exception: + pass # Ignora errori durante logout + session.clear() + flash(_("Logout effettuato"), "info") return redirect(url_for("auth.login")) @auth_bp.route("/profile", methods=["GET", "POST"]) +@login_required def profile(): - """User profile - change display name, language, theme.""" - if "user" not in session: - return redirect(url_for("auth.login")) + """User profile page - change display name, language, theme.""" if request.method == "POST": - # TODO: Implement API call to server /api/auth/me PUT - pass - return render_template("auth/profile.html") + display_name = request.form.get("display_name", "").strip() + language_pref = request.form.get("language_pref", "").strip() + theme_pref = request.form.get("theme_pref", "").strip() + + try: + data = {} + if display_name: + data["display_name"] = display_name + if language_pref: + data["language_pref"] = language_pref + if theme_pref: + data["theme_pref"] = theme_pref + + response = api_client.put("/api/auth/me", data=data) + + if response.get("error"): + flash(response.get("detail", _("Errore durante l'aggiornamento del profilo")), "error") + else: + # Aggiorna sessione + session["user"] = response + if language_pref: + session["language"] = language_pref + if theme_pref: + session["theme"] = theme_pref + + flash(_("Profilo aggiornato con successo"), "success") + return redirect(url_for("auth.profile")) + + except Exception as e: + flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error") + + # GET - carica dati profilo + user_data = api_client.get("/api/auth/me") + if user_data.get("error"): + flash(_("Errore nel caricamento del profilo: %(error)s", error=user_data.get("detail", "")), "error") + user_data = session.get("user", {}) + else: + session["user"] = user_data + + return render_template("auth/profile.html", user=user_data) diff --git a/client/requirements.txt b/client/requirements.txt index 7d41032..bb25435 100644 --- a/client/requirements.txt +++ b/client/requirements.txt @@ -1,6 +1,7 @@ # Flask flask>=3.0.0 flask-babel>=4.0.0 +flask-wtf>=1.2.0 # HTTP Client (to call FastAPI server) requests>=2.31.0 diff --git a/client/services/api_client.py b/client/services/api_client.py index a10c965..6d42e82 100644 --- a/client/services/api_client.py +++ b/client/services/api_client.py @@ -23,58 +23,108 @@ class APIClient: headers["X-API-Key"] = api_key return headers + def _handle_response(self, response: requests.Response) -> dict[str, Any]: + """Parse response and return normalized dict. + + Returns: + - If 2xx and not 204: response JSON + - If 204 No Content: empty dict + - If 4xx/5xx: {"error": True, "status_code": ..., "detail": "..."} + """ + if response.ok: + if response.status_code == 204: + return {} + return response.json() + + # Non-OK response (4xx/5xx) + try: + error_body = response.json() + detail = error_body.get("detail", str(error_body)) + except Exception: + detail = response.text or f"HTTP {response.status_code}" + + return { + "error": True, + "status_code": response.status_code, + "detail": detail + } + def get(self, endpoint: str, params: dict | None = None) -> dict[str, Any]: """GET request to API server.""" - response = requests.get( - f"{self.base_url}{endpoint}", - headers=self._headers, - params=params, - timeout=self.timeout, - ) - response.raise_for_status() - return response.json() + try: + response = requests.get( + f"{self.base_url}{endpoint}", + headers=self._headers, + params=params, + timeout=self.timeout, + ) + return self._handle_response(response) + except (requests.ConnectionError, requests.Timeout) as e: + return { + "error": True, + "status_code": 0, + "detail": f"Errore di connessione al server: {str(e)}" + } def post(self, endpoint: str, data: dict | None = None, files: dict | None = None) -> dict[str, Any]: """POST request to API server.""" - if files: - headers = {"X-API-Key": session.get("api_key", "")} - response = requests.post( - f"{self.base_url}{endpoint}", - headers=headers, - data=data, - files=files, - timeout=self.timeout, - ) - else: - response = requests.post( + try: + if files: + headers = {"X-API-Key": session.get("api_key", "")} + response = requests.post( + f"{self.base_url}{endpoint}", + headers=headers, + data=data, + files=files, + timeout=self.timeout, + ) + else: + response = requests.post( + f"{self.base_url}{endpoint}", + headers=self._headers, + json=data, + timeout=self.timeout, + ) + return self._handle_response(response) + except (requests.ConnectionError, requests.Timeout) as e: + return { + "error": True, + "status_code": 0, + "detail": f"Errore di connessione al server: {str(e)}" + } + + def put(self, endpoint: str, data: dict | None = None) -> dict[str, Any]: + """PUT request to API server.""" + try: + response = requests.put( f"{self.base_url}{endpoint}", headers=self._headers, json=data, timeout=self.timeout, ) - response.raise_for_status() - return response.json() - - def put(self, endpoint: str, data: dict | None = None) -> dict[str, Any]: - """PUT request to API server.""" - response = requests.put( - f"{self.base_url}{endpoint}", - headers=self._headers, - json=data, - timeout=self.timeout, - ) - response.raise_for_status() - return response.json() + return self._handle_response(response) + except (requests.ConnectionError, requests.Timeout) as e: + return { + "error": True, + "status_code": 0, + "detail": f"Errore di connessione al server: {str(e)}" + } def delete(self, endpoint: str) -> dict[str, Any]: """DELETE request to API server.""" - response = requests.delete( - f"{self.base_url}{endpoint}", - headers=self._headers, - timeout=self.timeout, - ) - response.raise_for_status() - return response.json() + try: + response = requests.delete( + f"{self.base_url}{endpoint}", + headers=self._headers, + timeout=self.timeout, + ) + return self._handle_response(response) + except (requests.ConnectionError, requests.Timeout) as e: + return { + "error": True, + "status_code": 0, + "detail": f"Errore di connessione al server: {str(e)}" + } api_client = APIClient() diff --git a/client/static/css/themes.css b/client/static/css/themes.css new file mode 100644 index 0000000..722dad8 --- /dev/null +++ b/client/static/css/themes.css @@ -0,0 +1,374 @@ +/* ============================================================ + TieMeasureFlow - Theme System + CSS Custom Properties for Light / Dark mode + ============================================================ */ + +/* ---- Light Theme (Default) ---- */ +:root { + --color-primary: #2563EB; + --color-primary-dark: #1E40AF; + --color-primary-light: #3B82F6; + --color-secondary: #64748B; + --color-accent: #1E40AF; + + --color-pass: #059669; + --color-warning: #D97706; + --color-fail: #DC2626; + + --bg-primary: #F8FAFC; + --bg-secondary: #F1F5F9; + --bg-card: #FFFFFF; + --bg-card-hover: #F8FAFC; + + --text-primary: #0F172A; + --text-secondary: #475569; + --text-muted: #94A3B8; + + --border-color: #E2E8F0; + --border-focus: #2563EB; + + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.04); + + --scrollbar-track: #F1F5F9; + --scrollbar-thumb: #CBD5E1; + --scrollbar-thumb-hover: #94A3B8; +} + +/* ---- Dark Theme ---- */ +.dark { + --bg-primary: #0F172A; + --bg-secondary: #1E293B; + --bg-card: #334155; + --bg-card-hover: #3B4A63; + + --text-primary: #F1F5F9; + --text-secondary: #94A3B8; + --text-muted: #64748B; + + --border-color: #475569; + --border-focus: #3B82F6; + + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.3); + + --scrollbar-track: #1E293B; + --scrollbar-thumb: #475569; + --scrollbar-thumb-hover: #64748B; +} + + +/* ============================================================ + Global Transitions + ============================================================ */ + +*, +*::before, +*::after { + transition-property: background-color, border-color, color, box-shadow; + transition-duration: 0s; +} + +/* Enable smooth transitions only after page load (avoid flash) */ +html.theme-transitions *, +html.theme-transitions *::before, +html.theme-transitions *::after { + transition-duration: 0.3s; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + + +/* ============================================================ + Focus Ring + ============================================================ */ + +*:focus-visible { + outline: 2px solid var(--border-focus); + outline-offset: 2px; + border-radius: 4px; +} + +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--bg-card), 0 0 0 4px var(--border-focus); +} + + +/* ============================================================ + Custom Scrollbar + ============================================================ */ + +/* Webkit (Chrome, Safari, Edge) */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--scrollbar-track); +} + +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); +} + +/* Firefox */ +* { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); +} + + +/* ============================================================ + Utility: Card + ============================================================ */ + +.tmf-card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 0.75rem; + box-shadow: var(--shadow-sm); + transition: box-shadow 0.2s ease, border-color 0.2s ease; +} + +.tmf-card:hover { + box-shadow: var(--shadow-md); +} + +.tmf-card-header { + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--border-color); + font-weight: 600; + font-size: 0.875rem; + color: var(--text-primary); +} + +.tmf-card-body { + padding: 1.25rem; +} + +.tmf-card-footer { + padding: 0.75rem 1.25rem; + border-top: 1px solid var(--border-color); + background: var(--bg-secondary); + border-radius: 0 0 0.75rem 0.75rem; +} + + +/* ============================================================ + Utility: Badges (Pass / Warning / Fail) + ============================================================ */ + +.badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.625rem; + font-size: 0.75rem; + font-weight: 600; + border-radius: 9999px; + line-height: 1.5; + white-space: nowrap; +} + +.badge-pass { + background: rgba(5, 150, 105, 0.1); + color: #059669; + border: 1px solid rgba(5, 150, 105, 0.25); +} + +.dark .badge-pass { + background: rgba(5, 150, 105, 0.15); + color: #34D399; + border-color: rgba(5, 150, 105, 0.3); +} + +.badge-warning { + background: rgba(217, 119, 6, 0.1); + color: #D97706; + border: 1px solid rgba(217, 119, 6, 0.25); +} + +.dark .badge-warning { + background: rgba(217, 119, 6, 0.15); + color: #FBBF24; + border-color: rgba(217, 119, 6, 0.3); +} + +.badge-fail { + background: rgba(220, 38, 38, 0.1); + color: #DC2626; + border: 1px solid rgba(220, 38, 38, 0.25); +} + +.dark .badge-fail { + background: rgba(220, 38, 38, 0.15); + color: #F87171; + border-color: rgba(220, 38, 38, 0.3); +} + +.badge-neutral { + background: var(--bg-secondary); + color: var(--text-secondary); + border: 1px solid var(--border-color); +} + + +/* ============================================================ + Utility: Buttons + ============================================================ */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.2s ease; + border: 1px solid transparent; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: var(--color-primary); + color: #FFFFFF; + border-color: var(--color-primary); +} + +.btn-primary:hover:not(:disabled) { + background: var(--color-primary-dark); + border-color: var(--color-primary-dark); +} + +.btn-secondary { + background: transparent; + color: var(--text-secondary); + border-color: var(--border-color); +} + +.btn-secondary:hover:not(:disabled) { + background: var(--bg-secondary); + color: var(--text-primary); + border-color: var(--text-secondary); +} + +.btn-danger { + background: var(--color-fail); + color: #FFFFFF; + border-color: var(--color-fail); +} + +.btn-danger:hover:not(:disabled) { + background: #B91C1C; + border-color: #B91C1C; +} + + +/* ============================================================ + Utility: Form Inputs + ============================================================ */ + +.tmf-input { + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + line-height: 1.5; + color: var(--text-primary); + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.tmf-input::placeholder { + color: var(--text-muted); +} + +.tmf-input:focus { + outline: none; + border-color: var(--border-focus); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); +} + +.dark .tmf-input:focus { + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); +} + +.tmf-label { + display: block; + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 0.375rem; +} + + +/* ============================================================ + Utility: Measurement Value Display + ============================================================ */ + +.measure-value { + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-weight: 500; + letter-spacing: -0.025em; + font-variant-numeric: tabular-nums; +} + + +/* ============================================================ + Utility: Table + ============================================================ */ + +.tmf-table { + width: 100%; + font-size: 0.875rem; + border-collapse: collapse; +} + +.tmf-table th { + padding: 0.625rem 1rem; + text-align: left; + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); +} + +.tmf-table td { + padding: 0.625rem 1rem; + color: var(--text-primary); + border-bottom: 1px solid var(--border-color); +} + +.tmf-table tbody tr:hover { + background: var(--bg-card-hover); +} + + +/* ============================================================ + Alpine.js x-cloak (hide until Alpine loads) + ============================================================ */ + +[x-cloak] { + display: none !important; +} diff --git a/client/static/img/tmflow-icon.svg b/client/static/img/tmflow-icon.svg new file mode 100644 index 0000000..a225b99 --- /dev/null +++ b/client/static/img/tmflow-icon.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/static/img/tmflow-logo.svg b/client/static/img/tmflow-logo.svg new file mode 100644 index 0000000..82e925c --- /dev/null +++ b/client/static/img/tmflow-logo.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Tie + + Measure + + Flow + + diff --git a/client/static/js/alpine-init.js b/client/static/js/alpine-init.js new file mode 100644 index 0000000..e5c0e46 --- /dev/null +++ b/client/static/js/alpine-init.js @@ -0,0 +1,126 @@ +/** + * TieMeasureFlow - Alpine.js Theme Initialization + * + * This script MUST load before Alpine.js (which uses defer). + * It reads the saved theme from localStorage or the system preference, + * applies the 'dark' class to immediately (no flash), and + * registers an Alpine.store('theme') for reactive toggling. + */ + +(function () { + 'use strict'; + + // ---- Immediate theme application (before paint) ---- + + var STORAGE_KEY = 'tmf-theme'; + + /** + * Resolve the initial dark-mode state. + * Priority: localStorage > system preference > light (default). + */ + function getInitialDark() { + var stored = null; + try { + stored = localStorage.getItem(STORAGE_KEY); + } catch (_) { + // localStorage may be unavailable (private browsing, etc.) + } + + if (stored === 'dark') return true; + if (stored === 'light') return false; + + // Fall back to OS preference + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return true; + } + + return false; + } + + var isDark = getInitialDark(); + + // Apply immediately to prevent flash of wrong theme + if (isDark) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + + // ---- Enable smooth transitions after first paint ---- + + // Use requestAnimationFrame to wait one frame, then enable transitions + // so the initial theme application does not animate. + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(function () { + requestAnimationFrame(function () { + document.documentElement.classList.add('theme-transitions'); + }); + }); + } + + // ---- Alpine.js Store Registration ---- + + // Alpine.store is registered via the alpine:init event, which fires + // when Alpine begins initialization but before it processes x-data. + + document.addEventListener('alpine:init', function () { + Alpine.store('theme', { + dark: isDark, + + toggle: function () { + this.dark = !this.dark; + this._apply(); + }, + + set: function (dark) { + this.dark = dark; + this._apply(); + }, + + _apply: function () { + if (this.dark) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + try { + localStorage.setItem(STORAGE_KEY, this.dark ? 'dark' : 'light'); + } catch (_) { + // Silently ignore storage errors + } + } + }); + }); + + // ---- Listen for system preference changes ---- + + if (window.matchMedia) { + try { + var mq = window.matchMedia('(prefers-color-scheme: dark)'); + mq.addEventListener('change', function (e) { + // Only auto-switch if the user hasn't manually set a preference + var stored = null; + try { + stored = localStorage.getItem(STORAGE_KEY); + } catch (_) {} + + if (!stored) { + var newDark = e.matches; + if (typeof Alpine !== 'undefined' && Alpine.store('theme')) { + Alpine.store('theme').set(newDark); + } else { + // Alpine not loaded yet, apply directly + if (newDark) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + } + } + }); + } catch (_) { + // Older browsers may not support addEventListener on matchMedia + } + } + +})(); diff --git a/client/static/js/locales/en.json b/client/static/js/locales/en.json new file mode 100644 index 0000000..7150140 --- /dev/null +++ b/client/static/js/locales/en.json @@ -0,0 +1,76 @@ +{ + "numpad": { + "title": "Numeric Keypad", + "clear": "Clear", + "submit": "Confirm", + "decimal": "Decimal", + "close": "Close" + }, + "measurement": { + "pass": "Pass", + "warning": "Warning", + "fail": "Fail", + "value": "Value", + "next": "Next measurement" + }, + "common": { + "loading": "Loading...", + "save": "Save", + "cancel": "Cancel", + "confirm": "Confirm", + "delete": "Delete", + "edit": "Edit", + "search": "Search...", + "noResults": "No results", + "close": "Close", + "error": "Error", + "success": "Success", + "warning": "Warning", + "info": "Info", + "page": "Page", + "of": "of", + "previous": "Previous", + "next": "Next" + }, + "theme": { + "light": "Light", + "dark": "Dark", + "toggle": "Toggle theme" + }, + "language": { + "switch": "Switch language", + "it": "Italian", + "en": "English" + }, + "auth": { + "login": "Sign in to your account", + "username": "Username", + "password": "Password", + "signIn": "Sign In", + "invalidCredentials": "Invalid credentials", + "enterCredentials": "Enter username and password", + "forgotPassword": "Forgot password? Contact administrator", + "welcome": "Welcome, {name}!", + "logoutSuccess": "Logged out successfully", + "loginRequired": "Please log in to continue" + }, + "nav": { + "measurements": "Measurements", + "recipes": "Recipes", + "statistics": "Statistics", + "admin": "Admin", + "users": "Users", + "settings": "Settings", + "profile": "Profile", + "logout": "Logout" + }, + "profile": { + "title": "User Profile", + "displayName": "Display Name", + "language": "Language", + "theme": "Theme", + "roles": "Roles", + "saveChanges": "Save Changes", + "updateSuccess": "Profile updated successfully" + } +} diff --git a/client/static/js/locales/it.json b/client/static/js/locales/it.json new file mode 100644 index 0000000..afde0fd --- /dev/null +++ b/client/static/js/locales/it.json @@ -0,0 +1,76 @@ +{ + "numpad": { + "title": "Tastierino Numerico", + "clear": "Cancella", + "submit": "Conferma", + "decimal": "Virgola", + "close": "Chiudi" + }, + "measurement": { + "pass": "Conforme", + "warning": "Attenzione", + "fail": "Non Conforme", + "value": "Valore", + "next": "Prossima misura" + }, + "common": { + "loading": "Caricamento...", + "save": "Salva", + "cancel": "Annulla", + "confirm": "Conferma", + "delete": "Elimina", + "edit": "Modifica", + "search": "Cerca...", + "noResults": "Nessun risultato", + "close": "Chiudi", + "error": "Errore", + "success": "Successo", + "warning": "Attenzione", + "info": "Info", + "page": "Pagina", + "of": "di", + "previous": "Precedente", + "next": "Successivo" + }, + "theme": { + "light": "Chiaro", + "dark": "Scuro", + "toggle": "Cambia tema" + }, + "language": { + "switch": "Cambia lingua", + "it": "Italiano", + "en": "English" + }, + "auth": { + "login": "Accedi al sistema", + "username": "Username", + "password": "Password", + "signIn": "Accedi", + "invalidCredentials": "Credenziali non valide", + "enterCredentials": "Inserisci username e password", + "forgotPassword": "Hai dimenticato la password? Contatta l'amministratore", + "welcome": "Benvenuto, {name}!", + "logoutSuccess": "Logout effettuato", + "loginRequired": "Effettua il login per continuare" + }, + "nav": { + "measurements": "Misure", + "recipes": "Ricette", + "statistics": "Statistiche", + "admin": "Admin", + "users": "Utenti", + "settings": "Impostazioni", + "profile": "Profilo", + "logout": "Esci" + }, + "profile": { + "title": "Profilo Utente", + "displayName": "Nome Visualizzato", + "language": "Lingua", + "theme": "Tema", + "roles": "Ruoli", + "saveChanges": "Salva Modifiche", + "updateSuccess": "Profilo aggiornato con successo" + } +} diff --git a/client/templates/auth/login.html b/client/templates/auth/login.html new file mode 100644 index 0000000..d6e4487 --- /dev/null +++ b/client/templates/auth/login.html @@ -0,0 +1,95 @@ +{% extends "base.html" %} +{% block title %}Login — TieMeasureFlow{% endblock %} + +{% block content %} +
+
+ +
+ +
+ TieMeasureFlow Logo +

+ TieMeasureFlow +

+

+ {{ _('Accedi al sistema') }} +

+
+ + +
+ + +
+ +
+
+ + + +
+ +
+
+ + +
+ +
+
+ + + +
+ +
+
+ + +
+ +
+
+ + +
+

+ {{ _('Hai dimenticato la password?') }} +
+ {{ _('Contatta l\'amministratore') }} +

+
+
+ + +
+

TieMeasureFlow © 2025 - {{ _('Sistema di misurazione industriale') }}

+
+
+
+{% endblock %} diff --git a/client/templates/auth/profile.html b/client/templates/auth/profile.html new file mode 100644 index 0000000..6b9180d --- /dev/null +++ b/client/templates/auth/profile.html @@ -0,0 +1,203 @@ +{% extends "base.html" %} +{% block title %}{{ _('Profilo') }} — TieMeasureFlow{% endblock %} + +{% block content %} +
+ +
+
+ + + +

+ {{ _('Profilo Utente') }} +

+
+

+ {{ _('Gestisci le tue informazioni e preferenze') }} +

+
+ + +
+
+ + + +
+

+ {{ _('Informazioni Account') }} +

+ +
+ +
+ +
+
+ + + +
+ +
+

+ {{ _('Il nome utente non può essere modificato') }} +

+
+ + +
+ +
+
+ + + +
+ +
+
+ + + {% if user and user.roles %} +
+ +
+ {% for role in user.roles %} + + + + + {{ _(role) }} + + {% endfor %} +
+
+ {% endif %} +
+
+ + +
+

+ {{ _('Preferenze') }} +

+ +
+ +
+ +
+
+ + + +
+ +
+
+ + +
+ +
+
+ + + +
+ +
+
+
+
+ + +
+ + + + + {{ _('Indietro') }} + + + +
+
+
+ + +
+
+

+ {{ _('Azioni Account') }} +

+
+
+ + + +
+
+

{{ _('Esci dal sistema') }}

+

+ {{ _('Termina la sessione corrente e torna alla schermata di login') }} +

+ +
+
+
+
+
+{% endblock %} diff --git a/client/templates/base.html b/client/templates/base.html new file mode 100644 index 0000000..0b1df4f --- /dev/null +++ b/client/templates/base.html @@ -0,0 +1,167 @@ + + + + + + + + + {% block title %}TieMeasureFlow{% endblock %} + + + + + + + + + + + + + + + + + + + + {% block extra_head %}{% endblock %} + + + + + +
+ + + {% include "components/navbar.html" %} + + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
+ + + {% if category == 'success' %} + + + + {% elif category == 'error' %} + + + + {% elif category == 'warning' %} + + + + {% else %} + + + + {% endif %} + + {{ message }} + + + +
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + +
+ {% block content %}{% endblock %} +
+ + +
+

+ Powered by TieMeasureFlow — Tielogic +

+
+ +
+ + + + + {% block extra_js %}{% endblock %} + + diff --git a/client/templates/components/navbar.html b/client/templates/components/navbar.html new file mode 100644 index 0000000..4e52d62 --- /dev/null +++ b/client/templates/components/navbar.html @@ -0,0 +1,324 @@ + + diff --git a/client/translations/babel.cfg b/client/translations/babel.cfg index 06537e8..63f2a52 100644 --- a/client/translations/babel.cfg +++ b/client/translations/babel.cfg @@ -1,3 +1,5 @@ +[python: *.py] [python: blueprints/**.py] +[python: services/**.py] [jinja2: templates/**.html] encoding = utf-8 diff --git a/client/translations/en/LC_MESSAGES/messages.po b/client/translations/en/LC_MESSAGES/messages.po new file mode 100644 index 0000000..411dd75 --- /dev/null +++ b/client/translations/en/LC_MESSAGES/messages.po @@ -0,0 +1,247 @@ +# English translations for TieMeasureFlow +msgid "" +msgstr "" +"Project-Id-Version: TieMeasureFlow 1.0\n" +"Language: en\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +# Login Page +msgid "Accedi al sistema" +msgstr "Sign in to your account" + +msgid "Username" +msgstr "Username" + +msgid "Password" +msgstr "Password" + +msgid "Accedi" +msgstr "Sign In" + +msgid "Credenziali non valide" +msgstr "Invalid credentials" + +msgid "Inserisci username e password" +msgstr "Enter username and password" + +msgid "Hai dimenticato la password?" +msgstr "Forgot password?" + +msgid "Contatta l'amministratore" +msgstr "Contact administrator" + +# Navbar +msgid "Misure" +msgstr "Measurements" + +msgid "Ricette" +msgstr "Recipes" + +msgid "Statistiche" +msgstr "Statistics" + +msgid "Admin" +msgstr "Admin" + +msgid "Utenti" +msgstr "Users" + +msgid "Impostazioni" +msgstr "Settings" + +msgid "Profilo" +msgstr "Profile" + +msgid "Esci" +msgstr "Logout" + +# Profile Page +msgid "Profilo Utente" +msgstr "User Profile" + +msgid "Nome Visualizzato" +msgstr "Display Name" + +msgid "Lingua" +msgstr "Language" + +msgid "Tema" +msgstr "Theme" + +msgid "Chiaro" +msgstr "Light" + +msgid "Scuro" +msgstr "Dark" + +msgid "Ruoli" +msgstr "Roles" + +msgid "Salva Modifiche" +msgstr "Save Changes" + +msgid "Profilo aggiornato con successo" +msgstr "Profile updated successfully" + +# Flash Messages +msgid "Benvenuto, %(name)s!" +msgstr "Welcome, %(name)s!" + +msgid "Logout effettuato" +msgstr "Logged out successfully" + +msgid "Effettua il login per continuare" +msgstr "Please log in to continue" + +msgid "Errore di connessione al server" +msgstr "Server connection error" + +# Common UI +msgid "Caricamento..." +msgstr "Loading..." + +msgid "Salva" +msgstr "Save" + +msgid "Annulla" +msgstr "Cancel" + +msgid "Conferma" +msgstr "Confirm" + +msgid "Elimina" +msgstr "Delete" + +msgid "Modifica" +msgstr "Edit" + +msgid "Cerca..." +msgstr "Search..." + +msgid "Nessun risultato" +msgstr "No results" + +msgid "Errore" +msgstr "Error" + +msgid "Successo" +msgstr "Success" + +msgid "Attenzione" +msgstr "Warning" + +msgid "Info" +msgstr "Info" + +msgid "Pagina" +msgstr "Page" + +msgid "di" +msgstr "of" + +msgid "Precedente" +msgstr "Previous" + +msgid "Successivo" +msgstr "Next" + +# Measurements +msgid "Conforme" +msgstr "Pass" + +msgid "Non Conforme" +msgstr "Fail" + +msgid "Valore" +msgstr "Value" + +msgid "Prossima misura" +msgstr "Next measurement" + +# Numpad +msgid "Tastierino Numerico" +msgstr "Numeric Keypad" + +msgid "Cancella" +msgstr "Clear" + +msgid "Virgola" +msgstr "Decimal" + +msgid "Chiudi" +msgstr "Close" + +# Theme +msgid "Cambia tema" +msgstr "Toggle theme" + +# Language +msgid "Cambia lingua" +msgstr "Switch language" + +msgid "Italiano" +msgstr "Italian" + +msgid "English" +msgstr "English" + +# Additional Login Page +msgid "Sistema di misurazione industriale" +msgstr "Industrial measurement system" + +# Additional Profile Page +msgid "Gestisci le tue informazioni e preferenze" +msgstr "Manage your information and preferences" + +msgid "Informazioni Account" +msgstr "Account Information" + +msgid "Il nome utente non può essere modificato" +msgstr "Username cannot be changed" + +msgid "Nome da visualizzare nell'interfaccia" +msgstr "Name to display in the interface" + +msgid "Lingua Preferita" +msgstr "Preferred Language" + +msgid "Tema Preferito" +msgstr "Preferred Theme" + +msgid "Indietro" +msgstr "Back" + +msgid "Azioni Account" +msgstr "Account Actions" + +msgid "Esci dal sistema" +msgstr "Logout from system" + +msgid "Termina la sessione corrente e torna alla schermata di login" +msgstr "End current session and return to login screen" + +msgid "Preferenze" +msgstr "Preferences" + +# Error Messages +msgid "Errore durante l'aggiornamento del profilo" +msgstr "Error updating profile" + +msgid "Errore di connessione al server: %(error)s" +msgstr "Server connection error: %(error)s" + +msgid "Errore nel caricamento del profilo: %(error)s" +msgstr "Error loading profile: %(error)s" + +# Roles +msgid "Maker" +msgstr "Maker" + +msgid "MeasurementTec" +msgstr "Measurement Technician" + +msgid "Metrologist" +msgstr "Metrologist" diff --git a/client/translations/it/LC_MESSAGES/messages.po b/client/translations/it/LC_MESSAGES/messages.po new file mode 100644 index 0000000..9d0d9af --- /dev/null +++ b/client/translations/it/LC_MESSAGES/messages.po @@ -0,0 +1,247 @@ +# Italian translations for TieMeasureFlow +msgid "" +msgstr "" +"Project-Id-Version: TieMeasureFlow 1.0\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +# Login Page +msgid "Accedi al sistema" +msgstr "Accedi al sistema" + +msgid "Username" +msgstr "Username" + +msgid "Password" +msgstr "Password" + +msgid "Accedi" +msgstr "Accedi" + +msgid "Credenziali non valide" +msgstr "Credenziali non valide" + +msgid "Inserisci username e password" +msgstr "Inserisci username e password" + +msgid "Hai dimenticato la password?" +msgstr "Hai dimenticato la password?" + +msgid "Contatta l'amministratore" +msgstr "Contatta l'amministratore" + +# Navbar +msgid "Misure" +msgstr "Misure" + +msgid "Ricette" +msgstr "Ricette" + +msgid "Statistiche" +msgstr "Statistiche" + +msgid "Admin" +msgstr "Admin" + +msgid "Utenti" +msgstr "Utenti" + +msgid "Impostazioni" +msgstr "Impostazioni" + +msgid "Profilo" +msgstr "Profilo" + +msgid "Esci" +msgstr "Esci" + +# Profile Page +msgid "Profilo Utente" +msgstr "Profilo Utente" + +msgid "Nome Visualizzato" +msgstr "Nome Visualizzato" + +msgid "Lingua" +msgstr "Lingua" + +msgid "Tema" +msgstr "Tema" + +msgid "Chiaro" +msgstr "Chiaro" + +msgid "Scuro" +msgstr "Scuro" + +msgid "Ruoli" +msgstr "Ruoli" + +msgid "Salva Modifiche" +msgstr "Salva Modifiche" + +msgid "Profilo aggiornato con successo" +msgstr "Profilo aggiornato con successo" + +# Flash Messages +msgid "Benvenuto, %(name)s!" +msgstr "Benvenuto, %(name)s!" + +msgid "Logout effettuato" +msgstr "Logout effettuato" + +msgid "Effettua il login per continuare" +msgstr "Effettua il login per continuare" + +msgid "Errore di connessione al server" +msgstr "Errore di connessione al server" + +# Common UI +msgid "Caricamento..." +msgstr "Caricamento..." + +msgid "Salva" +msgstr "Salva" + +msgid "Annulla" +msgstr "Annulla" + +msgid "Conferma" +msgstr "Conferma" + +msgid "Elimina" +msgstr "Elimina" + +msgid "Modifica" +msgstr "Modifica" + +msgid "Cerca..." +msgstr "Cerca..." + +msgid "Nessun risultato" +msgstr "Nessun risultato" + +msgid "Errore" +msgstr "Errore" + +msgid "Successo" +msgstr "Successo" + +msgid "Attenzione" +msgstr "Attenzione" + +msgid "Info" +msgstr "Info" + +msgid "Pagina" +msgstr "Pagina" + +msgid "di" +msgstr "di" + +msgid "Precedente" +msgstr "Precedente" + +msgid "Successivo" +msgstr "Successivo" + +# Measurements +msgid "Conforme" +msgstr "Conforme" + +msgid "Non Conforme" +msgstr "Non Conforme" + +msgid "Valore" +msgstr "Valore" + +msgid "Prossima misura" +msgstr "Prossima misura" + +# Numpad +msgid "Tastierino Numerico" +msgstr "Tastierino Numerico" + +msgid "Cancella" +msgstr "Cancella" + +msgid "Virgola" +msgstr "Virgola" + +msgid "Chiudi" +msgstr "Chiudi" + +# Theme +msgid "Cambia tema" +msgstr "Cambia tema" + +# Language +msgid "Cambia lingua" +msgstr "Cambia lingua" + +msgid "Italiano" +msgstr "Italiano" + +msgid "English" +msgstr "English" + +# Additional Login Page +msgid "Sistema di misurazione industriale" +msgstr "Sistema di misurazione industriale" + +# Additional Profile Page +msgid "Gestisci le tue informazioni e preferenze" +msgstr "Gestisci le tue informazioni e preferenze" + +msgid "Informazioni Account" +msgstr "Informazioni Account" + +msgid "Il nome utente non può essere modificato" +msgstr "Il nome utente non può essere modificato" + +msgid "Nome da visualizzare nell'interfaccia" +msgstr "Nome da visualizzare nell'interfaccia" + +msgid "Lingua Preferita" +msgstr "Lingua Preferita" + +msgid "Tema Preferito" +msgstr "Tema Preferito" + +msgid "Indietro" +msgstr "Indietro" + +msgid "Azioni Account" +msgstr "Azioni Account" + +msgid "Esci dal sistema" +msgstr "Esci dal sistema" + +msgid "Termina la sessione corrente e torna alla schermata di login" +msgstr "Termina la sessione corrente e torna alla schermata di login" + +msgid "Preferenze" +msgstr "Preferenze" + +# Error Messages +msgid "Errore durante l'aggiornamento del profilo" +msgstr "Errore durante l'aggiornamento del profilo" + +msgid "Errore di connessione al server: %(error)s" +msgstr "Errore di connessione al server: %(error)s" + +msgid "Errore nel caricamento del profilo: %(error)s" +msgstr "Errore nel caricamento del profilo: %(error)s" + +# Roles +msgid "Maker" +msgstr "Maker" + +msgid "MeasurementTec" +msgstr "Tecnico di Misura" + +msgid "Metrologist" +msgstr "Metrologo"