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 <noreply@anthropic.com>
This commit is contained in:
Adriano
2026-02-07 01:10:13 +01:00
parent d6508e0ae8
commit edd4580a5a
17 changed files with 2230 additions and 52 deletions
+12
View File
@@ -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/<lang>")
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
+123 -13
View File
@@ -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)
+1
View File
@@ -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
+58 -8
View File
@@ -23,19 +23,52 @@ 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."""
try:
response = requests.get(
f"{self.base_url}{endpoint}",
headers=self._headers,
params=params,
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 post(self, endpoint: str, data: dict | None = None, files: dict | None = None) -> dict[str, Any]:
"""POST request to API server."""
try:
if files:
headers = {"X-API-Key": session.get("api_key", "")}
response = requests.post(
@@ -52,29 +85,46 @@ class APIClient:
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 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()
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."""
try:
response = requests.delete(
f"{self.base_url}{endpoint}",
headers=self._headers,
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)}"
}
api_client = APIClient()
+374
View File
@@ -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;
}
+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<!-- Stylized Caliper Icon for Favicon -->
<!-- Caliper outer frame -->
<rect x="2" y="3" width="5" height="26" rx="1.5" fill="#2563EB"/>
<rect x="2" y="3" width="19" height="5" rx="1.5" fill="#2563EB"/>
<rect x="2" y="24" width="19" height="5" rx="1.5" fill="#2563EB"/>
<!-- Sliding jaw -->
<rect x="12" y="8" width="4.5" height="6.5" rx="1" fill="#1E40AF"/>
<rect x="12" y="17.5" width="4.5" height="6.5" rx="1" fill="#1E40AF"/>
<!-- Depth rod -->
<rect x="4" y="14" width="12" height="3.5" rx="1" fill="#3B82F6" opacity="0.55"/>
<!-- Scale markings -->
<rect x="8" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
<rect x="11" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
<rect x="14" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
<rect x="17" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
<!-- Flow arrow -->
<path d="M22 16 C25 16, 26.5 11, 29 11"
stroke="#3B82F6" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M27 8.5 L29.5 11 L27 13.5"
stroke="#3B82F6" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+41
View File
@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 44" fill="none">
<!-- Stylized Caliper Icon -->
<g transform="translate(2, 2)">
<!-- Caliper outer frame -->
<rect x="0" y="4" width="6" height="32" rx="1.5" fill="#2563EB"/>
<rect x="0" y="4" width="24" height="6" rx="1.5" fill="#2563EB"/>
<rect x="0" y="30" width="24" height="6" rx="1.5" fill="#2563EB"/>
<!-- Caliper sliding jaw -->
<rect x="14" y="10" width="5" height="8" rx="1" fill="#1E40AF"/>
<rect x="14" y="22" width="5" height="8" rx="1" fill="#1E40AF"/>
<!-- Caliper depth rod -->
<rect x="2" y="18" width="16" height="4" rx="1" fill="#3B82F6" opacity="0.6"/>
<!-- Scale markings -->
<rect x="7" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<rect x="10" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<rect x="13" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<rect x="16" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<rect x="19" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<!-- Flow arrow (smooth curve) -->
<path d="M26 20 C30 20, 32 14, 36 14 C40 14, 40 20, 36 20"
stroke="#2563EB" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<path d="M34 17 L37 20 L34 23" stroke="#2563EB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</g>
<!-- Text: TieMeasureFlow -->
<g transform="translate(48, 0)">
<!-- "Tie" in bold primary -->
<text x="0" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="700" fill="#2563EB"
letter-spacing="-0.5">Tie</text>
<!-- "Measure" in medium dark -->
<text x="32" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="500" fill="#1E40AF"
letter-spacing="-0.5">Measure</text>
<!-- "Flow" in bold primary -->
<text x="120" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="700" fill="#2563EB"
letter-spacing="-0.5">Flow</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

+126
View File
@@ -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 <html> 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
}
}
})();
+76
View File
@@ -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"
}
}
+76
View File
@@ -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"
}
}
+95
View File
@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block title %}Login — TieMeasureFlow{% endblock %}
{% block content %}
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<!-- Card -->
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-xl p-8">
<!-- Logo -->
<div class="text-center mb-8">
<img src="{{ url_for('static', filename='img/tmflow-logo.svg') }}"
alt="TieMeasureFlow Logo"
class="mx-auto h-16 w-auto mb-4"
onerror="this.style.display='none'">
<h2 class="text-3xl font-bold text-slate-900 dark:text-white mb-2">
TieMeasureFlow
</h2>
<p class="text-sm text-slate-600 dark:text-slate-400">
{{ _('Accedi al sistema') }}
</p>
</div>
<!-- Login Form -->
<form method="POST" action="{{ url_for('auth.login') }}" class="space-y-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Username -->
<div>
<label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
{{ _('Username') }}
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<input type="text"
id="username"
name="username"
required
autofocus
placeholder="{{ _('Username') }}"
class="block w-full pl-10 pr-3 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 transition-colors">
</div>
</div>
<!-- Password -->
<div>
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
{{ _('Password') }}
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<input type="password"
id="password"
name="password"
required
placeholder="{{ _('Password') }}"
class="block w-full pl-10 pr-3 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 transition-colors">
</div>
</div>
<!-- Submit Button -->
<div>
<button type="submit"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-semibold text-white bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200">
<svg class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
</svg>
{{ _('Accedi') }}
</button>
</div>
</form>
<!-- Help Text -->
<div class="mt-6 text-center">
<p class="text-xs text-slate-500 dark:text-slate-400">
{{ _('Hai dimenticato la password?') }}
<br>
{{ _('Contatta l\'amministratore') }}
</p>
</div>
</div>
<!-- Footer -->
<div class="text-center text-xs text-slate-500 dark:text-slate-400">
<p>TieMeasureFlow &copy; 2025 - {{ _('Sistema di misurazione industriale') }}</p>
</div>
</div>
</div>
{% endblock %}
+203
View File
@@ -0,0 +1,203 @@
{% extends "base.html" %}
{% block title %}{{ _('Profilo') }} — TieMeasureFlow{% endblock %}
{% block content %}
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-4xl">
<!-- Page Header -->
<div class="mb-8">
<div class="flex items-center space-x-3">
<svg class="h-8 w-8 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<h1 class="text-3xl font-bold text-slate-900 dark:text-white">
{{ _('Profilo Utente') }}
</h1>
</div>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
{{ _('Gestisci le tue informazioni e preferenze') }}
</p>
</div>
<!-- Profile Card -->
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-xl overflow-hidden">
<form method="POST" action="{{ url_for('auth.profile') }}" class="divide-y divide-slate-200 dark:divide-slate-700">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Account Information Section -->
<div class="p-6 sm:p-8">
<h2 class="text-lg font-semibold text-slate-900 dark:text-white mb-6">
{{ _('Informazioni Account') }}
</h2>
<div class="space-y-6">
<!-- Username (readonly) -->
<div>
<label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
{{ _('Username') }}
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<input type="text"
id="username"
value="{{ user.username if user else '' }}"
disabled
class="block w-full pl-10 pr-3 py-2.5 border border-slate-200 dark:border-slate-700 rounded-lg bg-slate-50 dark:bg-slate-900 text-slate-500 dark:text-slate-400 cursor-not-allowed">
</div>
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
{{ _('Il nome utente non può essere modificato') }}
</p>
</div>
<!-- Display Name -->
<div>
<label for="display_name" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
{{ _('Nome Visualizzato') }}
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</div>
<input type="text"
id="display_name"
name="display_name"
value="{{ user.display_name if user else '' }}"
placeholder="{{ _('Nome da visualizzare nell\'interfaccia') }}"
class="block w-full pl-10 pr-3 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 transition-colors">
</div>
</div>
<!-- Roles (readonly badges) -->
{% if user and user.roles %}
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
{{ _('Ruoli') }}
</label>
<div class="flex flex-wrap gap-2">
{% for role in user.roles %}
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium
{% if role == 'Maker' %}
bg-indigo-100 dark:bg-indigo-900/30 text-indigo-800 dark:text-indigo-200 border border-indigo-200 dark:border-indigo-800
{% elif role == 'MeasurementTec' %}
bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-800
{% elif role == 'Metrologist' %}
bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200 border border-purple-200 dark:border-purple-800
{% else %}
bg-slate-100 dark:bg-slate-700 text-slate-800 dark:text-slate-200 border border-slate-200 dark:border-slate-600
{% endif %}">
<svg class="h-3 w-3 mr-1.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
{{ _(role) }}
</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
<!-- Preferences Section -->
<div class="p-6 sm:p-8">
<h2 class="text-lg font-semibold text-slate-900 dark:text-white mb-6">
{{ _('Preferenze') }}
</h2>
<div class="space-y-6">
<!-- Language Preference -->
<div>
<label for="language_pref" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
{{ _('Lingua Preferita') }}
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
</svg>
</div>
<select id="language_pref"
name="language_pref"
class="block w-full pl-10 pr-10 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white transition-colors">
<option value="it" {% if user and user.language_pref == 'it' %}selected{% endif %}>{{ _('Italiano') }}</option>
<option value="en" {% if user and user.language_pref == 'en' %}selected{% endif %}>{{ _('English') }}</option>
</select>
</div>
</div>
<!-- Theme Preference -->
<div>
<label for="theme_pref" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
{{ _('Tema Preferito') }}
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
</div>
<select id="theme_pref"
name="theme_pref"
class="block w-full pl-10 pr-10 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white transition-colors">
<option value="light" {% if user and user.theme_pref == 'light' %}selected{% endif %}>{{ _('Chiaro') }}</option>
<option value="dark" {% if user and user.theme_pref == 'dark' %}selected{% endif %}>{{ _('Scuro') }}</option>
</select>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="px-6 py-4 sm:px-8 bg-slate-50 dark:bg-slate-900/50 flex items-center justify-between">
<a href="/"
class="inline-flex items-center px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg shadow-sm text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200">
<svg class="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{{ _('Indietro') }}
</a>
<button type="submit"
class="inline-flex items-center px-6 py-2 border border-transparent rounded-lg shadow-sm text-sm font-semibold text-white bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200">
<svg class="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
{{ _('Salva Modifiche') }}
</button>
</div>
</form>
</div>
<!-- Account Actions -->
<div class="mt-8 bg-white dark:bg-slate-800 shadow-lg rounded-xl overflow-hidden">
<div class="p-6 sm:p-8">
<h2 class="text-lg font-semibold text-slate-900 dark:text-white mb-4">
{{ _('Azioni Account') }}
</h2>
<div class="flex items-start space-x-4">
<div class="flex-shrink-0">
<svg class="h-6 w-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</div>
<div class="flex-1">
<h3 class="text-sm font-medium text-slate-900 dark:text-white">{{ _('Esci dal sistema') }}</h3>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
{{ _('Termina la sessione corrente e torna alla schermata di login') }}
</p>
<div class="mt-3">
<a href="{{ url_for('auth.logout') }}"
class="inline-flex items-center px-4 py-2 border border-red-300 dark:border-red-700 rounded-lg shadow-sm text-sm font-medium text-red-700 dark:text-red-300 bg-white dark:bg-slate-800 hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-all duration-200">
{{ _('Logout') }}
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+167
View File
@@ -0,0 +1,167 @@
<!DOCTYPE html>
<html lang="{{ current_language|default('it') }}"
:class="{ 'dark': $store.theme.dark }"
class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="TieMeasureFlow - Sistema di gestione misurazioni industriali">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{% block title %}TieMeasureFlow{% endblock %}</title>
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='img/tmflow-icon.svg') }}">
<!-- Google Fonts: Inter + JetBrains Mono -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<!-- TailwindCSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#2563EB',
dark: '#1E40AF',
light: '#3B82F6',
50: '#EFF6FF',
100: '#DBEAFE',
200: '#BFDBFE',
500: '#3B82F6',
600: '#2563EB',
700: '#1D4ED8',
800: '#1E40AF',
900: '#1E3A8A',
},
steel: {
DEFAULT: '#64748B',
light: '#94A3B8',
dark: '#475569',
50: '#F8FAFC',
100: '#F1F5F9',
200: '#E2E8F0',
300: '#CBD5E1',
400: '#94A3B8',
500: '#64748B',
600: '#475569',
700: '#334155',
800: '#1E293B',
900: '#0F172A',
},
measure: {
pass: '#059669',
warning: '#D97706',
fail: '#DC2626',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
},
},
},
}
</script>
<!-- Theme CSS Variables -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/themes.css') }}">
<!-- Alpine.js Theme Init (before Alpine loads) -->
<script src="{{ url_for('static', filename='js/alpine-init.js') }}"></script>
{% block extra_head %}{% endblock %}
</head>
<body class="h-full bg-[var(--bg-primary)] text-[var(--text-primary)] font-sans antialiased transition-colors duration-300">
<!-- Alpine.js App Wrapper -->
<div x-data class="min-h-full flex flex-col">
<!-- Navbar -->
{% include "components/navbar.html" %}
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="fixed top-16 right-4 z-50 flex flex-col gap-2 max-w-md w-full"
x-data="{ messages: {{ messages|tojson }} }"
x-init="setTimeout(() => { $el.remove() }, 8000)">
{% for category, message in messages %}
<div x-data="{ show: true }"
x-show="show"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-x-4"
x-transition:enter-end="opacity-100 translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-x-0"
x-transition:leave-end="opacity-0 translate-x-4"
class="flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg border
{% if category == 'success' %}
bg-emerald-50 dark:bg-emerald-900/30 border-emerald-200 dark:border-emerald-800 text-emerald-800 dark:text-emerald-200
{% elif category == 'error' %}
bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200
{% elif category == 'warning' %}
bg-amber-50 dark:bg-amber-900/30 border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200
{% else %}
bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200
{% endif %}">
<!-- Icon -->
{% if category == 'success' %}
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{% elif category == 'error' %}
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{% elif category == 'warning' %}
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>
{% else %}
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{% endif %}
<span class="text-sm font-medium flex-1">{{ message }}</span>
<!-- Dismiss -->
<button @click="show = false" class="shrink-0 hover:opacity-70 transition-opacity">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- Main Content -->
<main class="flex-1">
{% block content %}{% endblock %}
</main>
<!-- Footer -->
<footer class="py-4 text-center border-t border-[var(--border-color)] transition-colors duration-300">
<p class="text-xs text-steel dark:text-steel-light tracking-wide">
Powered by <span class="font-semibold">TieMeasureFlow</span> &mdash; Tielogic
</p>
</footer>
</div>
<!-- Alpine.js CDN (defer) -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
+324
View File
@@ -0,0 +1,324 @@
<!-- TieMeasureFlow Navbar -->
<nav class="sticky top-0 z-40 bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm transition-colors duration-300"
x-data="{ mobileOpen: false, userDropdown: false, adminDropdown: false }">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-14">
<!-- Left: Logo -->
<div class="flex items-center gap-3">
<a href="{{ url_for('index') }}" class="flex items-center gap-2.5 group">
{% if company_logo %}
<img src="{{ url_for('static', filename='img/' ~ company_logo) }}" alt="Logo" class="h-8 w-auto"
onerror="this.src='{{ url_for('static', filename='img/tmflow-logo.svg') }}'">
{% else %}
<img src="{{ url_for('static', filename='img/tmflow-logo.svg') }}"
alt="TieMeasureFlow"
class="h-8 w-auto">
{% endif %}
</a>
</div>
<!-- Center: Navigation Links (Desktop) -->
{% if current_user %}
<div class="hidden md:flex items-center gap-1">
{# MeasurementTec: Misure #}
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
<a href="{{ url_for('measure.select_recipe') }}"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200
{% if request.endpoint and request.endpoint.startswith('measure.') %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<!-- Clipboard Icon -->
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
<span>Misure</span>
</a>
{% endif %}
{# Maker: Ricette #}
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
<a href="{{ url_for('maker.recipe_list') }}"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200
{% if request.endpoint and request.endpoint.startswith('maker.') %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<!-- Edit/Pencil Icon -->
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
<span>Ricette</span>
</a>
{% endif %}
{# Metrologist: Statistiche #}
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
<a href="{{ url_for('statistics.dashboard') }}"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200
{% if request.endpoint and request.endpoint.startswith('statistics.') %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<!-- Chart Icon -->
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
<span>Statistiche</span>
</a>
{% endif %}
{# Admin: dropdown #}
{% if current_user.get('is_admin') %}
<div class="relative" x-data="{ open: false }" @click.outside="open = false">
<button @click="open = !open"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200"
:class="{ 'text-primary bg-primary-50 dark:bg-primary-900/20': open }">
<!-- Cog Icon -->
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span>Admin</span>
<svg class="w-3.5 h-3.5 transition-transform duration-200" :class="{ 'rotate-180': open }"
fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<!-- Admin Dropdown -->
<div x-show="open"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95 -translate-y-1"
x-transition:enter-end="opacity-100 scale-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute left-0 mt-1 w-48 rounded-lg bg-[var(--bg-card)] border border-[var(--border-color)]
shadow-lg py-1 z-50">
<a href="#" class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-[var(--text-secondary)]
hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
Utenti
</a>
<a href="#" class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-[var(--text-secondary)]
hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
</svg>
Impostazioni
</a>
</div>
</div>
{% endif %}
</div>
{% endif %}
<!-- Right: Controls -->
<div class="flex items-center gap-2">
<!-- Language Toggle -->
<div class="flex items-center border border-[var(--border-color)] rounded-lg overflow-hidden">
<a href="{{ url_for('set_language', lang='it') }}"
class="px-2 py-1.5 text-xs font-semibold transition-colors duration-200
{% if current_language == 'it' %}
bg-primary text-white
{% else %}
text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]
{% endif %}">
IT
</a>
<a href="{{ url_for('set_language', lang='en') }}"
class="px-2 py-1.5 text-xs font-semibold transition-colors duration-200
{% if current_language == 'en' %}
bg-primary text-white
{% else %}
text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]
{% endif %}">
EN
</a>
</div>
<!-- Theme Toggle -->
<button @click="$store.theme.toggle()"
class="p-2 rounded-lg text-[var(--text-secondary)] hover:text-primary
hover:bg-[var(--bg-secondary)] transition-colors duration-200"
:title="$store.theme.dark ? 'Tema chiaro' : 'Tema scuro'">
<!-- Sun (shown in dark mode) -->
<svg x-show="$store.theme.dark" x-cloak class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
<!-- Moon (shown in light mode) -->
<svg x-show="!$store.theme.dark" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
</svg>
</button>
<!-- User Menu (when logged in) -->
{% if current_user %}
<div class="relative" x-data="{ open: false }" @click.outside="open = false">
<button @click="open = !open"
class="flex items-center gap-2 pl-2 pr-3 py-1.5 rounded-lg
hover:bg-[var(--bg-secondary)] transition-colors duration-200">
<!-- Avatar -->
<div class="w-7 h-7 rounded-full bg-primary/10 border border-primary/20
flex items-center justify-center text-primary font-semibold text-xs">
{{ current_user.get('display_name', current_user.get('username', '?'))[0]|upper }}
</div>
<span class="hidden sm:block text-sm font-medium text-[var(--text-primary)] max-w-[120px] truncate">
{{ current_user.get('display_name', current_user.get('username', '')) }}
</span>
<svg class="w-3.5 h-3.5 text-[var(--text-secondary)] transition-transform duration-200"
:class="{ 'rotate-180': open }"
fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<!-- User Dropdown -->
<div x-show="open"
x-transition:enter="transition ease-out duration-150"
x-transition:enter-start="opacity-0 scale-95 -translate-y-1"
x-transition:enter-end="opacity-100 scale-100 translate-y-0"
x-transition:leave="transition ease-in duration-100"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-1 w-52 rounded-lg bg-[var(--bg-card)] border border-[var(--border-color)]
shadow-lg py-1 z-50">
<!-- User Info Header -->
<div class="px-4 py-2.5 border-b border-[var(--border-color)]">
<p class="text-sm font-semibold text-[var(--text-primary)] truncate">
{{ current_user.get('display_name', current_user.get('username', '')) }}
</p>
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
{{ current_user.get('roles', [])|join(', ') }}
</p>
</div>
<a href="{{ url_for('auth.profile') }}"
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-[var(--text-secondary)]
hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
Profilo
</a>
<div class="border-t border-[var(--border-color)] my-1"></div>
<a href="{{ url_for('auth.logout') }}"
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-measure-fail
hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
Logout
</a>
</div>
</div>
{% endif %}
<!-- Mobile Hamburger (when logged in) -->
{% if current_user %}
<button @click="mobileOpen = !mobileOpen"
class="md:hidden p-2 rounded-lg text-[var(--text-secondary)]
hover:bg-[var(--bg-secondary)] transition-colors duration-200">
<svg x-show="!mobileOpen" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
<svg x-show="mobileOpen" x-cloak class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
{% endif %}
</div>
</div>
</div>
<!-- Mobile Menu -->
{% if current_user %}
<div x-show="mobileOpen"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0 -translate-y-2"
x-transition:enter-end="opacity-100 translate-y-0"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100 translate-y-0"
x-transition:leave-end="opacity-0 -translate-y-2"
x-cloak
class="md:hidden border-t border-[var(--border-color)] bg-[var(--bg-card)]">
<div class="px-4 py-3 space-y-1">
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
<a href="{{ url_for('measure.select_recipe') }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
Misure
</a>
{% endif %}
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
<a href="{{ url_for('maker.recipe_list') }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
Ricette
</a>
{% endif %}
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
<a href="{{ url_for('statistics.dashboard') }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
Statistiche
</a>
{% endif %}
{% if current_user.get('is_admin') %}
<div class="border-t border-[var(--border-color)] my-2"></div>
<p class="px-3 py-1 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">Admin</p>
<a href="#"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
Utenti
</a>
<a href="#"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
</svg>
Impostazioni
</a>
{% endif %}
</div>
</div>
{% endif %}
</nav>
+2
View File
@@ -1,3 +1,5 @@
[python: *.py]
[python: blueprints/**.py]
[python: services/**.py]
[jinja2: templates/**.html]
encoding = utf-8
@@ -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"
@@ -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"