🌐 i18n(i18n): add multilingual support with translations

- create i18n.py for managing translations and resolving locale
- add German and English translations for various UI components
- integrate translation functions into templates for dynamic language support
This commit is contained in:
nocci 2025-12-06 13:58:46 +00:00
parent 1aafd6d5a3
commit cc7c75ba33
11 changed files with 548 additions and 157 deletions

350
app/i18n.py Normal file
View file

@ -0,0 +1,350 @@
from typing import Dict
AVAILABLE_LANGUAGES: Dict[str, str] = {
"de": "Deutsch",
"en": "English",
}
translations: Dict[str, Dict[str, str]] = {
"de": {
"brand.tagline": "Deine gemieteten Server im Blick",
"nav.admin": "Admin-Dashboard",
"nav.users": "User",
"nav.overview": "Übersicht",
"nav.map": "Karte",
"nav.new_server": "Server anlegen",
"nav.logout": "Logout",
"nav.login": "Login",
"nav.register": "Registrieren",
"nav.logged_in_as": "Eingeloggt als",
"nav.console": "Konsole öffnen",
"footer.left": "Selfhosted VPS overview",
"footer.right": "PWA ready · Dark Mode",
"warning.no_encryption": "Hinweis: ENCRYPTION_KEY nicht gesetzt Passwörter werden nicht gespeichert.",
"warning.no_encryption_short": "ENCRYPTION_KEY ist nicht gesetzt eingegebene Management-Passwörter werden nicht gespeichert.",
"server_list.title": "Deine Server",
"server_list.subtitle": "Übersicht aller gemieteten VPS, dedizierten Server und Storage-Systeme.",
"stats.total": "Gesamt",
"stats.active": "aktive Server",
"stats.costs": "Laufende Kosten",
"stats.per_month": "pro Monat",
"stats.mixed_currencies": "(gemischte Währungen)",
"stats.expiring_soon": "Laufen bald aus",
"stats.expired": "Abgelaufen",
"stats.expiring_soon_hint": "≤ 30 Tage",
"stats.expired_hint": "Vertrag beendet",
"table.name": "Name",
"table.provider": "Provider",
"table.type": "Typ",
"table.location": "Location",
"table.ipv4": "IPv4",
"table.costs": "Kosten",
"table.action": "Aktion",
"status.expired": "abgelaufen",
"status.expiring": "läuft bald aus",
"server_list.empty": "Noch keine Server erfasst. Leg den ersten mit „Server anlegen“ oben rechts an.",
"price.month": "Monat",
"price.year": "Jahr",
"server_detail.server": "Server",
"server_detail.contract_end": "Vertragsende",
"server_detail.days_ago": "vor {days} Tagen",
"server_detail.today": "heute",
"server_detail.in_days": "in {days} Tagen",
"server_detail.edit": "Bearbeiten",
"server_detail.archive": "Archivieren",
"server_detail.archive_confirm": "Diesen Server archivieren?",
"section.net_costs": "Netz & Kosten",
"section.hardware": "Hardware",
"section.access": "Zugänge",
"section.notes": "Notizen",
"field.ipv4": "IPv4",
"field.ipv6": "IPv6",
"field.costs": "Kosten",
"field.contract": "Vertrag",
"field.cpu": "CPU",
"field.ram": "RAM",
"field.storage": "Storage",
"field.management": "Management",
"field.mgmt_user": "Mgmt-User",
"field.mgmt_password": "Mgmt-Passwort",
"field.ssh_user": "SSH User",
"field.ssh_key_hint": "SSH Key Hint",
"note.ssh_keys": "Hinweis: Es werden nur Key-Namen gespeichert, keine privaten SSH-Schlüssel.",
"notes.empty": "Keine Notizen.",
"mgmt.password_encrypted_missing": "verschlüsselt gespeichert (Key fehlt?)",
"back.overview": "Zurück zur Übersicht",
"updated_at": "Zuletzt aktualisiert:",
"form.title.new": "Neuen Server anlegen",
"form.title.edit": "Server bearbeiten",
"form.subtitle": "Trage alle relevanten Infos zu deinem VPS / Server ein.",
"section.general": "Allgemein",
"label.name": "Name",
"label.provider": "Provider",
"label.hostname": "Hostname",
"label.type": "Typ",
"label.location": "Location",
"label.tags": "Tags (kommagetrennt)",
"label.ipv4": "IPv4",
"label.ipv6": "IPv6",
"section.network": "Netzwerk",
"section.costs": "Kosten",
"label.amount": "Betrag",
"label.currency": "Währung",
"label.billing": "Abrechnung",
"label.contract_start": "Vertragsbeginn",
"label.contract_end": "Vertragsende",
"section.hardware.small": "Hardware",
"label.cpu_model": "CPU-Modell",
"label.cpu_cores": "CPU Cores",
"label.ram_mb": "RAM (MB)",
"label.storage_gb": "Storage (GB)",
"label.storage_type": "Storage-Typ",
"section.access.small": "Zugänge",
"hint.ssh": "SSH: hier nur Key-Namen oder Hints eintragen, keine privaten Keys.",
"label.mgmt_url": "Management URL",
"label.mgmt_user": "Management User",
"label.mgmt_password": "Management Passwort",
"label.ssh_user": "SSH User",
"label.ssh_key_hint": "SSH Key Hint",
"section.notes.small": "Notizen",
"btn.cancel": "Abbrechen",
"btn.save": "Speichern",
"btn.save_changes": "Änderungen speichern",
"map.title": "Server-Karte",
"map.subtitle": "Zeigt alle nicht archivierten Server mit gesetzter Location auf einer Karte. Für grobe Übersicht reicht die Stadt keine exakten GPS-Daten notwendig.",
"map.note": "Hinweis: Marker werden anhand der Location-Namen grob auf der Weltkarte platziert. Mehrere Server in derselben Stadt werden leicht versetzt dargestellt, damit sie klickbar bleiben.",
"admin.title": "Admin-Dashboard",
"admin.subtitle": "Globale Übersicht über alle nicht archivierten Server und Benutzer.",
"admin.users": "Benutzer",
"admin.users_caption": "Accounts",
"admin.servers": "Server",
"admin.servers_caption": "nicht archiviert",
"admin.costs": "Laufende Kosten",
"admin.per_month": "pro Monat",
"admin.contract_status": "Vertragstatus",
"admin.expiring": "Bald auslaufend",
"admin.expired": "Abgelaufen",
"admin.by_provider": "Nach Provider",
"admin.provider": "Provider",
"admin.count": "Server",
"admin.monthly_costs": "Monatskosten",
"admin.expiring_soon": "Laufen bald aus",
"admin.expired_count": "Abgelaufen",
"admin.contracts_expiring": "Laufen bald aus (≤ 30 Tage)",
"admin.contracts_none_soon": "Keine Verträge laufen in den nächsten 30 Tagen aus.",
"admin.contracts_expired": "Abgelaufene Verträge",
"admin.contracts_none_expired": "Keine abgelaufenen Verträge gefunden.",
"users.title": "Benutzerverwaltung",
"users.subtitle": "Admins können Benutzer aktivieren/deaktivieren. Der eigene Account kann nicht deaktiviert werden.",
"users.username": "Benutzername",
"users.email": "E-Mail",
"users.role": "Rolle",
"users.status": "Status",
"users.action": "Aktion",
"role.admin": "Admin",
"role.user": "User",
"status.active": "aktiv",
"status.inactive": "deaktiviert",
"action.deactivate": "Deaktivieren",
"action.activate": "Aktivieren",
"users.own_account": "Eigener Account",
"login.title": "Login",
"login.subtitle": "Melde dich an, um deine Server zu verwalten.",
"login.username": "Benutzername",
"login.password": "Passwort",
"login.no_account": "Noch kein Account?",
"login.register": "Registrieren",
"login.submit": "Einloggen",
"register.title": "Registrieren",
"register.subtitle": "Erstelle einen neuen Account. Der erste Benutzer wird automatisch Admin.",
"register.username": "Benutzername",
"register.email": "E-Mail (optional)",
"register.password": "Passwort",
"register.password_confirm": "Passwort bestätigen",
"register.submit": "Account anlegen",
},
"en": {
"brand.tagline": "Your rented servers at a glance",
"nav.admin": "Admin Dashboard",
"nav.users": "Users",
"nav.overview": "Overview",
"nav.map": "Map",
"nav.new_server": "Add server",
"nav.logout": "Logout",
"nav.login": "Login",
"nav.register": "Register",
"nav.logged_in_as": "Logged in as",
"nav.console": "Open console",
"footer.left": "Selfhosted VPS overview",
"footer.right": "PWA ready · Dark Mode",
"warning.no_encryption": "Note: ENCRYPTION_KEY not set passwords will not be stored.",
"warning.no_encryption_short": "ENCRYPTION_KEY is not set management passwords will not be stored.",
"server_list.title": "Your servers",
"server_list.subtitle": "Overview of all rented VPS, dedicated servers and storage systems.",
"stats.total": "Total",
"stats.active": "active servers",
"stats.costs": "Recurring costs",
"stats.per_month": "per month",
"stats.mixed_currencies": "(mixed currencies)",
"stats.expiring_soon": "Expiring soon",
"stats.expired": "Expired",
"stats.expiring_soon_hint": "≤ 30 days",
"stats.expired_hint": "Contract ended",
"table.name": "Name",
"table.provider": "Provider",
"table.type": "Type",
"table.location": "Location",
"table.ipv4": "IPv4",
"table.costs": "Costs",
"table.action": "Action",
"status.expired": "expired",
"status.expiring": "expiring soon",
"server_list.empty": "No servers yet. Create the first one via “Add server” in the top right.",
"price.month": "Month",
"price.year": "Year",
"server_detail.server": "Server",
"server_detail.contract_end": "Contract end",
"server_detail.days_ago": "{days} days ago",
"server_detail.today": "today",
"server_detail.in_days": "in {days} days",
"server_detail.edit": "Edit",
"server_detail.archive": "Archive",
"server_detail.archive_confirm": "Archive this server?",
"section.net_costs": "Network & Costs",
"section.hardware": "Hardware",
"section.access": "Access",
"section.notes": "Notes",
"field.ipv4": "IPv4",
"field.ipv6": "IPv6",
"field.costs": "Costs",
"field.contract": "Contract",
"field.cpu": "CPU",
"field.ram": "RAM",
"field.storage": "Storage",
"field.management": "Management",
"field.mgmt_user": "Mgmt user",
"field.mgmt_password": "Mgmt password",
"field.ssh_user": "SSH user",
"field.ssh_key_hint": "SSH key hint",
"note.ssh_keys": "Note: Only key names are stored, no private SSH keys.",
"notes.empty": "No notes.",
"mgmt.password_encrypted_missing": "stored encrypted (key missing?)",
"back.overview": "Back to overview",
"updated_at": "Last updated:",
"form.title.new": "Create new server",
"form.title.edit": "Edit server",
"form.subtitle": "Enter all relevant details about your VPS / server.",
"section.general": "General",
"label.name": "Name",
"label.provider": "Provider",
"label.hostname": "Hostname",
"label.type": "Type",
"label.location": "Location",
"label.tags": "Tags (comma-separated)",
"label.ipv4": "IPv4",
"label.ipv6": "IPv6",
"section.network": "Network",
"section.costs": "Costs",
"label.amount": "Amount",
"label.currency": "Currency",
"label.billing": "Billing",
"label.contract_start": "Contract start",
"label.contract_end": "Contract end",
"section.hardware.small": "Hardware",
"label.cpu_model": "CPU model",
"label.cpu_cores": "CPU cores",
"label.ram_mb": "RAM (MB)",
"label.storage_gb": "Storage (GB)",
"label.storage_type": "Storage type",
"section.access.small": "Access",
"hint.ssh": "SSH: only enter key names or hints, no private keys.",
"label.mgmt_url": "Management URL",
"label.mgmt_user": "Management user",
"label.mgmt_password": "Management password",
"label.ssh_user": "SSH user",
"label.ssh_key_hint": "SSH key hint",
"section.notes.small": "Notes",
"btn.cancel": "Cancel",
"btn.save": "Save",
"btn.save_changes": "Save changes",
"map.title": "Server map",
"map.subtitle": "Shows all non-archived servers with a location on a map. City names are sufficient for rough positioning.",
"map.note": "Note: Markers are placed roughly based on location names. Multiple servers in one city are slightly offset to remain clickable.",
"admin.title": "Admin dashboard",
"admin.subtitle": "Global overview of all non-archived servers and users.",
"admin.users": "Users",
"admin.users_caption": "Accounts",
"admin.servers": "Servers",
"admin.servers_caption": "not archived",
"admin.costs": "Recurring costs",
"admin.per_month": "per month",
"admin.contract_status": "Contract status",
"admin.expiring": "Expiring soon",
"admin.expired": "Expired",
"admin.by_provider": "By provider",
"admin.provider": "Provider",
"admin.count": "Servers",
"admin.monthly_costs": "Monthly costs",
"admin.expiring_soon": "Expiring soon",
"admin.expired_count": "Expired",
"admin.contracts_expiring": "Expiring soon (≤ 30 days)",
"admin.contracts_none_soon": "No contracts expiring in the next 30 days.",
"admin.contracts_expired": "Expired contracts",
"admin.contracts_none_expired": "No expired contracts found.",
"users.title": "User management",
"users.subtitle": "Admins can activate/deactivate users. You cannot deactivate your own account.",
"users.username": "Username",
"users.email": "Email",
"users.role": "Role",
"users.status": "Status",
"users.action": "Action",
"role.admin": "Admin",
"role.user": "User",
"status.active": "active",
"status.inactive": "deactivated",
"action.deactivate": "Deactivate",
"action.activate": "Activate",
"users.own_account": "Own account",
"login.title": "Login",
"login.subtitle": "Sign in to manage your servers.",
"login.username": "Username",
"login.password": "Password",
"login.no_account": "No account yet?",
"login.register": "Register",
"login.submit": "Log in",
"register.title": "Register",
"register.subtitle": "Create a new account. The first user becomes admin automatically.",
"register.username": "Username",
"register.email": "Email (optional)",
"register.password": "Password",
"register.password_confirm": "Confirm password",
"register.submit": "Create account",
},
}
def resolve_locale(request) -> str:
"""Detect locale from session override or Accept-Language; default to de."""
session_lang = request.session.get("lang")
if session_lang in AVAILABLE_LANGUAGES:
return session_lang
accept = request.headers.get("accept-language", "")
for part in accept.split(","):
code = part.split(";")[0].strip().lower()
if code.startswith("de"):
return "de"
if code.startswith("en"):
return "en"
return "de"
def translate(key: str, locale: str = "de", **kwargs) -> str:
"""Return translated string for a key."""
text = translations.get(locale, {}).get(
key, translations["en"].get(key, key)
)
try:
return text.format(**kwargs)
except Exception:
return text

View file

@ -18,6 +18,9 @@ from .utils import (
ensure_csrf_token, ensure_csrf_token,
validate_csrf, validate_csrf,
) )
from jinja2 import pass_context
from .i18n import AVAILABLE_LANGUAGES, resolve_locale, translate
from .auth import ( from .auth import (
hash_password, hash_password,
verify_password, verify_password,
@ -30,6 +33,7 @@ app = FastAPI(title="FleetLedger")
app.mount("/static", StaticFiles(directory="app/static"), name="static") app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates") templates = Jinja2Templates(directory="app/templates")
templates.env.globals["languages"] = AVAILABLE_LANGUAGES
# Session middleware (server-side session based on signed cookie) # Session middleware (server-side session based on signed cookie)
SESSION_SECRET = os.getenv("SESSION_SECRET") SESSION_SECRET = os.getenv("SESSION_SECRET")
@ -50,6 +54,23 @@ app.add_middleware(
) )
@app.middleware("http")
async def add_locale_to_request(request: Request, call_next):
request.state.locale = resolve_locale(request)
response = await call_next(request)
return response
@pass_context
def _t(ctx, key: str, **kwargs) -> str:
request = ctx.get("request")
locale = getattr(request.state, "locale", "de") if request else "de"
return translate(key, locale, **kwargs)
templates.env.globals["t"] = _t
@app.on_event("startup") @app.on_event("startup")
def on_startup() -> None: def on_startup() -> None:
"""Initialize database on startup.""" """Initialize database on startup."""
@ -74,6 +95,17 @@ def service_worker() -> FileResponse:
) )
@app.get("/lang/{code}", include_in_schema=False)
def switch_language(code: str, request: Request):
"""Persist preferred language in session and redirect back."""
code = code.lower()
if code not in AVAILABLE_LANGUAGES:
return RedirectResponse("/", status_code=303)
request.session["lang"] = code
referer = request.headers.get("referer") or "/"
return RedirectResponse(referer, status_code=303)
# ------------- Auth: Register / Login / Logout ------------- # ------------- Auth: Register / Login / Logout -------------

View file

@ -3,9 +3,9 @@
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div> <div>
<h1 class="text-lg font-semibold tracking-tight">Admin-Dashboard</h1> <h1 class="text-lg font-semibold tracking-tight">{{ t("admin.title") }}</h1>
<p class="text-xs text-slate-400"> <p class="text-xs text-slate-400">
Globale Übersicht über alle nicht archivierten Server und Benutzer. {{ t("admin.subtitle") }}
</p> </p>
</div> </div>
</div> </div>
@ -13,36 +13,36 @@
{% if stats %} {% if stats %}
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs"> <div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col"> <div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col">
<span class="text-[11px] text-slate-400 mb-1">Benutzer</span> <span class="text-[11px] text-slate-400 mb-1">{{ t("admin.users") }}</span>
<span class="text-lg font-semibold text-slate-100"> <span class="text-lg font-semibold text-slate-100">
{{ stats.total_users }} {{ stats.total_users }}
</span> </span>
<span class="text-[11px] text-slate-500 mt-1">Accounts</span> <span class="text-[11px] text-slate-500 mt-1">{{ t("admin.users_caption") }}</span>
</div> </div>
<div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col"> <div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col">
<span class="text-[11px] text-slate-400 mb-1">Server</span> <span class="text-[11px] text-slate-400 mb-1">{{ t("admin.servers") }}</span>
<span class="text-lg font-semibold text-slate-100"> <span class="text-lg font-semibold text-slate-100">
{{ stats.total_servers }} {{ stats.total_servers }}
</span> </span>
<span class="text-[11px] text-slate-500 mt-1">nicht archiviert</span> <span class="text-[11px] text-slate-500 mt-1">{{ t("admin.servers_caption") }}</span>
</div> </div>
<div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col"> <div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col">
<span class="text-[11px] text-slate-400 mb-1">Laufende Kosten</span> <span class="text-[11px] text-slate-400 mb-1">{{ t("admin.costs") }}</span>
<span class="text-lg font-semibold text-slate-100"> <span class="text-lg font-semibold text-slate-100">
{{ "%.2f"|format(stats.monthly_total) }} {{ "%.2f"|format(stats.monthly_total) }}
{% if stats.monthly_currency %} {{ stats.monthly_currency }}{% endif %} {% if stats.monthly_currency %} {{ stats.monthly_currency }}{% endif %}
</span> </span>
<span class="text-[11px] text-slate-500 mt-1"> <span class="text-[11px] text-slate-500 mt-1">
pro Monat{% if stats.mixed_currencies %} (gemischte Währungen){% endif %} {{ t("admin.per_month") }}{% if stats.mixed_currencies %} {{ t("stats.mixed_currencies") }}{% endif %}
</span> </span>
</div> </div>
<div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col"> <div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col">
<span class="text-[11px] text-slate-400 mb-1">Vertragstatus</span> <span class="text-[11px] text-slate-400 mb-1">{{ t("admin.contract_status") }}</span>
<span class="text-[11px] text-amber-200"> <span class="text-[11px] text-amber-200">
Bald auslaufend: {{ stats.expiring_soon }} {{ t("admin.expiring") }}: {{ stats.expiring_soon }}
</span> </span>
<span class="text-[11px] text-rose-200"> <span class="text-[11px] text-rose-200">
Abgelaufen: {{ stats.expired }} {{ t("admin.expired") }}: {{ stats.expired }}
</span> </span>
</div> </div>
</div> </div>
@ -50,16 +50,16 @@
<!-- Provider breakdown --> <!-- Provider breakdown -->
<section class="space-y-2 mt-4"> <section class="space-y-2 mt-4">
<h2 class="text-sm font-semibold tracking-tight text-slate-200">Nach Provider</h2> <h2 class="text-sm font-semibold tracking-tight text-slate-200">{{ t("admin.by_provider") }}</h2>
<div class="overflow-hidden rounded-xl border border-slate-800 bg-slate-900/60"> <div class="overflow-hidden rounded-xl border border-slate-800 bg-slate-900/60">
<table class="min-w-full text-sm"> <table class="min-w-full text-sm">
<thead class="bg-slate-900/80 text-xs uppercase tracking-wide text-slate-400"> <thead class="bg-slate-900/80 text-xs uppercase tracking-wide text-slate-400">
<tr> <tr>
<th class="px-3 py-2 text-left">Provider</th> <th class="px-3 py-2 text-left">{{ t("admin.provider") }}</th>
<th class="px-3 py-2 text-right">Server</th> <th class="px-3 py-2 text-right">{{ t("admin.count") }}</th>
<th class="px-3 py-2 text-right">Monatskosten</th> <th class="px-3 py-2 text-right">{{ t("admin.monthly_costs") }}</th>
<th class="px-3 py-2 text-right">Laufen bald aus</th> <th class="px-3 py-2 text-right">{{ t("admin.expiring_soon") }}</th>
<th class="px-3 py-2 text-right">Abgelaufen</th> <th class="px-3 py-2 text-right">{{ t("admin.expired_count") }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -95,7 +95,7 @@
<!-- Contracts: expiring soon & expired --> <!-- Contracts: expiring soon & expired -->
<section class="grid md:grid-cols-2 gap-4 mt-4"> <section class="grid md:grid-cols-2 gap-4 mt-4">
<div class="space-y-2"> <div class="space-y-2">
<h2 class="text-sm font-semibold tracking-tight text-slate-200">Laufen bald aus (≤ 30 Tage)</h2> <h2 class="text-sm font-semibold tracking-tight text-slate-200">{{ t("admin.contracts_expiring") }}</h2>
<div class="rounded-xl border border-slate-800 bg-slate-900/60 p-3 text-xs"> <div class="rounded-xl border border-slate-800 bg-slate-900/60 p-3 text-xs">
{% if expiring_soon_list %} {% if expiring_soon_list %}
<ul class="space-y-2"> <ul class="space-y-2">
@ -117,13 +117,13 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<div class="text-slate-500">Keine Verträge laufen in den nächsten 30 Tagen aus.</div> <div class="text-slate-500">{{ t("admin.contracts_none_soon") }}</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<h2 class="text-sm font-semibold tracking-tight text-slate-200">Abgelaufene Verträge</h2> <h2 class="text-sm font-semibold tracking-tight text-slate-200">{{ t("admin.contracts_expired") }}</h2>
<div class="rounded-xl border border-slate-800 bg-slate-900/60 p-3 text-xs"> <div class="rounded-xl border border-slate-800 bg-slate-900/60 p-3 text-xs">
{% if expired_list %} {% if expired_list %}
<ul class="space-y-2"> <ul class="space-y-2">
@ -145,7 +145,7 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<div class="text-slate-500">Keine abgelaufenen Verträge gefunden.</div> <div class="text-slate-500">{{ t("admin.contracts_none_expired") }}</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>

View file

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="de" class="dark" id="html-root"> <html lang="{{ request.state.locale if request and request.state and request.state.locale else 'de' }}" class="dark" id="html-root">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>FleetLedger</title> <title>FleetLedger</title>
@ -37,52 +37,63 @@
</div> </div>
<div> <div>
<div class="text-sm font-semibold tracking-tight">FleetLedger</div> <div class="text-sm font-semibold tracking-tight">FleetLedger</div>
<div class="text-xs text-slate-400">Deine gemieteten Server im Blick</div> <div class="text-xs text-slate-400">{{ t("brand.tagline") }}</div>
</div> </div>
</a> </a>
<div class="flex items-center gap-3 text-xs"> <div class="flex items-center gap-3 text-xs">
{% if current_user %} {% if current_user %}
<span class="text-slate-300"> <span class="text-slate-300">
Eingeloggt als <span class="font-semibold">{{ current_user.username }}</span> {{ t("nav.logged_in_as") }} <span class="font-semibold">{{ current_user.username }}</span>
{% if current_user.is_admin %} {% if current_user.is_admin %}
<span class="ml-1 px-1.5 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/40 text-emerald-200 text-[10px]">Admin</span> <span class="ml-1 px-1.5 py-0.5 rounded bg-emerald-500/10 border border-emerald-500/40 text-emerald-200 text-[10px]">Admin</span>
{% endif %} {% endif %}
</span> </span>
{% if current_user.is_admin %} {% if current_user.is_admin %}
<a href="/admin/dashboard" class="text-slate-300 hover:text-white underline-offset-2 hover:underline"> <a href="/admin/dashboard" class="text-slate-300 hover:text-white underline-offset-2 hover:underline">
Admin-Dashboard {{ t("nav.admin") }}
</a> </a>
<a href="/users" class="text-slate-300 hover:text-white underline-offset-2 hover:underline"> <a href="/users" class="text-slate-300 hover:text-white underline-offset-2 hover:underline">
User {{ t("nav.users") }}
</a> </a>
{% endif %} {% endif %}
<a href="/" class="text-slate-300 hover:text-white underline-offset-2 hover:underline"> <a href="/" class="text-slate-300 hover:text-white underline-offset-2 hover:underline">
Übersicht {{ t("nav.overview") }}
</a> </a>
<a href="/map" class="text-slate-300 hover:text-white underline-offset-2 hover:underline"> <a href="/map" class="text-slate-300 hover:text-white underline-offset-2 hover:underline">
Karte {{ t("nav.map") }}
</a> </a>
<a <a
href="/servers/new" href="/servers/new"
class="inline-flex items-center gap-1 rounded-lg bg-indigo-500 px-3 py-1.5 font-medium hover:bg-indigo-400 focus:outline-none focus:ring focus:ring-indigo-500/40" class="inline-flex items-center gap-1 rounded-lg bg-indigo-500 px-3 py-1.5 font-medium hover:bg-indigo-400 focus:outline-none focus:ring focus:ring-indigo-500/40"
> >
<span class="text-lg leading-none"></span> <span class="text-lg leading-none"></span>
<span>Server anlegen</span> <span>{{ t("nav.new_server") }}</span>
</a> </a>
<a <a
href="/logout" href="/logout"
class="rounded-lg border border-slate-700 px-2.5 py-1 text-slate-300 hover:border-slate-500 hover:text-white" class="rounded-lg border border-slate-700 px-2.5 py-1 text-slate-300 hover:border-slate-500 hover:text-white"
> >
Logout {{ t("nav.logout") }}
</a> </a>
{% else %} {% else %}
<a href="/login" class="text-slate-300 hover:text-white underline-offset-2 hover:underline"> <a href="/login" class="text-slate-300 hover:text-white underline-offset-2 hover:underline">
Login {{ t("nav.login") }}
</a> </a>
<a href="/register" class="text-slate-300 hover:text-white underline-offset-2 hover:underline"> <a href="/register" class="text-slate-300 hover:text-white underline-offset-2 hover:underline">
Registrieren {{ t("nav.register") }}
</a> </a>
{% endif %} {% endif %}
<div class="flex items-center gap-1 border border-slate-800 rounded-lg px-2 py-1">
{% for code, label in languages.items() %}
<a
href="/lang/{{ code }}"
class="px-1 text-xs {% if request.state.locale == code %}text-white font-semibold{% else %}text-slate-400 hover:text-white{% endif %}"
>
{{ label }}
</a>
{% if not loop.last %}<span class="text-slate-700">|</span>{% endif %}
{% endfor %}
</div>
<button <button
id="theme-toggle" id="theme-toggle"
class="rounded-lg border border-slate-700 bg-slate-900/60 px-2.5 py-1 text-slate-300 hover:border-slate-500 hover:text-white" class="rounded-lg border border-slate-700 bg-slate-900/60 px-2.5 py-1 text-slate-300 hover:border-slate-500 hover:text-white"
@ -102,8 +113,8 @@
<footer class="border-t border-slate-800 bg-slate-900/60"> <footer class="border-t border-slate-800 bg-slate-900/60">
<div class="max-w-5xl mx-auto px-4 py-3 text-xs text-slate-500 flex justify-between items-center"> <div class="max-w-5xl mx-auto px-4 py-3 text-xs text-slate-500 flex justify-between items-center">
<span>Selfhosted VPS overview</span> <span>{{ t("footer.left") }}</span>
<span>PWA ready · Dark Mode</span> <span>{{ t("footer.right") }}</span>
</div> </div>
</footer> </footer>
</div> </div>

View file

@ -1,8 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="max-w-sm mx-auto mt-8"> <div class="max-w-sm mx-auto mt-8">
<h1 class="text-lg font-semibold tracking-tight mb-1">Login</h1> <h1 class="text-lg font-semibold tracking-tight mb-1">{{ t("login.title") }}</h1>
<p class="text-xs text-slate-400 mb-4">Melde dich an, um deine Server zu verwalten.</p> <p class="text-xs text-slate-400 mb-4">{{ t("login.subtitle") }}</p>
{% if error %} {% if error %}
<div class="mb-4 text-xs text-rose-200 bg-rose-500/10 border border-rose-500/60 rounded-lg p-3"> <div class="mb-4 text-xs text-rose-200 bg-rose-500/10 border border-rose-500/60 rounded-lg p-3">
@ -13,7 +13,7 @@
<form method="post" class="space-y-4"> <form method="post" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">Benutzername</label> <label class="text-xs text-slate-300">{{ t("login.username") }}</label>
<input <input
type="text" type="text"
name="username" name="username"
@ -22,7 +22,7 @@
/> />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">Passwort</label> <label class="text-xs text-slate-300">{{ t("login.password") }}</label>
<input <input
type="password" type="password"
name="password" name="password"
@ -31,13 +31,13 @@
/> />
</div> </div>
<div class="flex justify-between items-center text-xs text-slate-400"> <div class="flex justify-between items-center text-xs text-slate-400">
<span>Noch kein Account? <a href="/register" class="text-indigo-300 hover:text-indigo-200 underline">Registrieren</a></span> <span>{{ t("login.no_account") }} <a href="/register" class="text-indigo-300 hover:text-indigo-200 underline">{{ t("login.register") }}</a></span>
</div> </div>
<button <button
type="submit" type="submit"
class="w-full rounded-lg bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-400 focus:outline-none focus:ring focus:ring-indigo-500/40" class="w-full rounded-lg bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-400 focus:outline-none focus:ring focus:ring-indigo-500/40"
> >
Einloggen {{ t("login.submit") }}
</button> </button>
</form> </form>
</div> </div>

View file

@ -1,9 +1,9 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<div class="max-w-sm mx-auto mt-8"> <div class="max-w-sm mx-auto mt-8">
<h1 class="text-lg font-semibold tracking-tight mb-1">Registrieren</h1> <h1 class="text-lg font-semibold tracking-tight mb-1">{{ t("register.title") }}</h1>
<p class="text-xs text-slate-400 mb-4"> <p class="text-xs text-slate-400 mb-4">
Erstelle einen neuen Account. Der erste Benutzer wird automatisch Admin. {{ t("register.subtitle") }}
</p> </p>
{% if error %} {% if error %}
@ -15,7 +15,7 @@
<form method="post" class="space-y-4"> <form method="post" class="space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">Benutzername</label> <label class="text-xs text-slate-300">{{ t("register.username") }}</label>
<input <input
type="text" type="text"
name="username" name="username"
@ -24,7 +24,7 @@
/> />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">E-Mail (optional)</label> <label class="text-xs text-slate-300">{{ t("register.email") }}</label>
<input <input
type="email" type="email"
name="email" name="email"
@ -32,7 +32,7 @@
/> />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">Passwort</label> <label class="text-xs text-slate-300">{{ t("register.password") }}</label>
<input <input
type="password" type="password"
name="password" name="password"
@ -42,7 +42,7 @@
/> />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">Passwort bestätigen</label> <label class="text-xs text-slate-300">{{ t("register.password_confirm") }}</label>
<input <input
type="password" type="password"
name="password_confirm" name="password_confirm"
@ -55,7 +55,7 @@
type="submit" type="submit"
class="w-full rounded-lg bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-400 focus:outline-none focus:ring focus:ring-indigo-500/40" class="w-full rounded-lg bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-400 focus:outline-none focus:ring focus:ring-indigo-500/40"
> >
Account anlegen {{ t("register.submit") }}
</button> </button>
</form> </form>
</div> </div>

View file

@ -3,16 +3,16 @@
<div class="space-y-4"> <div class="space-y-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div> <div>
<div class="text-xs text-slate-400 mb-1">Server</div> <div class="text-xs text-slate-400 mb-1">{{ t("server_detail.server") }}</div>
<h1 class="text-lg font-semibold tracking-tight flex items-center gap-2"> <h1 class="text-lg font-semibold tracking-tight flex items-center gap-2">
{{ server.name }} {{ server.name }}
{% if server.is_expired %} {% if server.is_expired %}
<span class="inline-flex items-center rounded-full bg-rose-600/15 border border-rose-600/60 px-2 py-0.5 text-[10px] text-rose-200"> <span class="inline-flex items-center rounded-full bg-rose-600/15 border border-rose-600/60 px-2 py-0.5 text-[10px] text-rose-200">
abgelaufen {{ t("status.expired") }}
</span> </span>
{% elif server.is_expiring_soon %} {% elif server.is_expiring_soon %}
<span class="inline-flex items-center rounded-full bg-amber-500/15 border border-amber-500/60 px-2 py-0.5 text-[10px] text-amber-200"> <span class="inline-flex items-center rounded-full bg-amber-500/15 border border-amber-500/60 px-2 py-0.5 text-[10px] text-amber-200">
läuft bald aus {{ t("status.expiring") }}
</span> </span>
{% endif %} {% endif %}
</h1> </h1>
@ -21,14 +21,14 @@
{% endif %} {% endif %}
{% if server.contract_end %} {% if server.contract_end %}
<p class="text-[11px] text-slate-500 mt-1"> <p class="text-[11px] text-slate-500 mt-1">
Vertragsende: {{ server.contract_end }} {{ t("server_detail.contract_end") }}: {{ server.contract_end }}
{% if server.days_until_contract_end is not none %} {% if server.days_until_contract_end is not none %}
{% if server.days_until_contract_end < 0 %} {% if server.days_until_contract_end < 0 %}
(vor {{ (server.days_until_contract_end * -1) }} Tagen) ({{ t("server_detail.days_ago", days=(server.days_until_contract_end * -1)) }})
{% elif server.days_until_contract_end == 0 %} {% elif server.days_until_contract_end == 0 %}
(heute) ({{ t("server_detail.today") }})
{% else %} {% else %}
(in {{ server.days_until_contract_end }} Tagen) ({{ t("server_detail.in_days", days=server.days_until_contract_end) }})
{% endif %} {% endif %}
{% endif %} {% endif %}
</p> </p>
@ -55,15 +55,15 @@
href="/servers/{{ server.id }}/edit" href="/servers/{{ server.id }}/edit"
class="ml-2 rounded-lg border border-slate-700 px-3 py-1 text-xs text-slate-100 hover:border-slate-500 hover:text-white" class="ml-2 rounded-lg border border-slate-700 px-3 py-1 text-xs text-slate-100 hover:border-slate-500 hover:text-white"
> >
Bearbeiten {{ t("server_detail.edit") }}
</a> </a>
<form method="post" action="/servers/{{ server.id }}/archive" onsubmit="return confirm('Diesen Server archivieren?');"> <form method="post" action="/servers/{{ server.id }}/archive" onsubmit="return confirm('{{ t("server_detail.archive_confirm") }}');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<button <button
type="submit" type="submit"
class="rounded-lg border border-rose-600 px-3 py-1 text-xs text-rose-200 hover:bg-rose-600/10" class="rounded-lg border border-rose-600 px-3 py-1 text-xs text-rose-200 hover:bg-rose-600/10"
> >
Archivieren {{ t("server_detail.archive") }}
</button> </button>
</form> </form>
</div> </div>
@ -72,30 +72,30 @@
<div class="grid md:grid-cols-2 gap-4"> <div class="grid md:grid-cols-2 gap-4">
<!-- Network & Cost --> <!-- Network & Cost -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-2"> <section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-2">
<h2 class="text-sm font-semibold text-slate-100">Netz & Kosten</h2> <h2 class="text-sm font-semibold text-slate-100">{{ t("section.net_costs") }}</h2>
<dl class="text-xs text-slate-300 space-y-1"> <dl class="text-xs text-slate-300 space-y-1">
<div class="flex justify-between gap-3"> <div class="flex justify-between gap-3">
<dt class="text-slate-400">IPv4</dt> <dt class="text-slate-400">{{ t("field.ipv4") }}</dt>
<dd class="text-right">{% if server.ipv4 %}{{ server.ipv4 }}{% else %}{% endif %}</dd> <dd class="text-right">{% if server.ipv4 %}{{ server.ipv4 }}{% else %}{% endif %}</dd>
</div> </div>
<div class="flex justify-between gap-3"> <div class="flex justify-between gap-3">
<dt class="text-slate-400">IPv6</dt> <dt class="text-slate-400">{{ t("field.ipv6") }}</dt>
<dd class="text-right">{% if server.ipv6 %}{{ server.ipv6 }}{% else %}{% endif %}</dd> <dd class="text-right">{% if server.ipv6 %}{{ server.ipv6 }}{% else %}{% endif %}</dd>
</div> </div>
<div class="flex justify-between gap-3"> <div class="flex justify-between gap-3">
<dt class="text-slate-400">Kosten</dt> <dt class="text-slate-400">{{ t("field.costs") }}</dt>
<dd class="text-right"> <dd class="text-right">
{% if server.price %} {% if server.price %}
{{ "%.2f"|format(server.price) }} {{ server.currency }} / {{ "%.2f"|format(server.price) }} {{ server.currency }} /
{{ "Monat" if server.billing_period == "monthly" else "Jahr" }} {{ t("price.month") if server.billing_period == "monthly" else t("price.year") }}
{% else %}{% endif %} {% else %}{% endif %}
</dd> </dd>
</div> </div>
<div class="flex justify-between gap-3"> <div class="flex justify-between gap-3">
<dt class="text-slate-400">Vertrag</dt> <dt class="text-slate-400">{{ t("field.contract") }}</dt>
<dd class="text-right"> <dd class="text-right">
{% if server.contract_start %}ab {{ server.contract_start }}{% endif %} {% if server.contract_start %}{{ t("label.contract_start") }} {{ server.contract_start }}{% endif %}
{% if server.contract_end %} bis {{ server.contract_end }}{% endif %} {% if server.contract_end %} {{ t("label.contract_end") }} {{ server.contract_end }}{% endif %}
{% if not server.contract_start and not server.contract_end %}{% endif %} {% if not server.contract_start and not server.contract_end %}{% endif %}
</dd> </dd>
</div> </div>
@ -104,23 +104,23 @@
<!-- Hardware --> <!-- Hardware -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-2"> <section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-2">
<h2 class="text-sm font-semibold text-slate-100">Hardware</h2> <h2 class="text-sm font-semibold text-slate-100">{{ t("section.hardware") }}</h2>
<dl class="text-xs text-slate-300 space-y-1"> <dl class="text-xs text-slate-300 space-y-1">
<div class="flex justify-between gap-3"> <div class="flex justify-between gap-3">
<dt class="text-slate-400">CPU</dt> <dt class="text-slate-400">{{ t("field.cpu") }}</dt>
<dd class="text-right"> <dd class="text-right">
{% if server.cpu_model %}{{ server.cpu_model }}{% else %}{% endif %} {% if server.cpu_model %}{{ server.cpu_model }}{% else %}{% endif %}
{% if server.cpu_cores %} ({{ server.cpu_cores }} Cores){% endif %} {% if server.cpu_cores %} ({{ server.cpu_cores }} Cores){% endif %}
</dd> </dd>
</div> </div>
<div class="flex justify-between gap-3"> <div class="flex justify-between gap-3">
<dt class="text-slate-400">RAM</dt> <dt class="text-slate-400">{{ t("field.ram") }}</dt>
<dd class="text-right"> <dd class="text-right">
{% if server.ram_mb %}{{ server.ram_mb }} MB{% else %}{% endif %} {% if server.ram_mb %}{{ server.ram_mb }} MB{% else %}{% endif %}
</dd> </dd>
</div> </div>
<div class="flex justify-between gap-3"> <div class="flex justify-between gap-3">
<dt class="text-slate-400">Storage</dt> <dt class="text-slate-400">{{ t("field.storage") }}</dt>
<dd class="text-right"> <dd class="text-right">
{% if server.storage_gb %}{{ server.storage_gb }} GB{% else %}{% endif %} {% if server.storage_gb %}{{ server.storage_gb }} GB{% else %}{% endif %}
{% if server.storage_type %} ({{ server.storage_type }}){% endif %} {% if server.storage_type %} ({{ server.storage_type }}){% endif %}
@ -131,30 +131,30 @@
<!-- Access --> <!-- Access -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-2"> <section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-2">
<h2 class="text-sm font-semibold text-slate-100">Zugänge</h2> <h2 class="text-sm font-semibold text-slate-100">{{ t("section.access") }}</h2>
<dl class="text-xs text-slate-300 space-y-1"> <dl class="text-xs text-slate-300 space-y-1">
<div class="flex justify-between gap-3"> <div class="flex justify-between gap-3">
<dt class="text-slate-400">Management</dt> <dt class="text-slate-400">{{ t("field.management") }}</dt>
<dd class="text-right"> <dd class="text-right">
{% if server.mgmt_url %} {% if server.mgmt_url %}
<a href="{{ server.mgmt_url }}" target="_blank" rel="noopener noreferrer" class="text-indigo-300 hover:text-indigo-200 underline"> <a href="{{ server.mgmt_url }}" target="_blank" rel="noopener noreferrer" class="text-indigo-300 hover:text-indigo-200 underline">
Console öffnen {{ t("nav.console") }}
</a> </a>
{% else %}{% endif %} {% else %}{% endif %}
</dd> </dd>
</div> </div>
<div class="flex justify-between gap-3"> <div class="flex justify-between gap-3">
<dt class="text-slate-400">Mgmt-User</dt> <dt class="text-slate-400">{{ t("field.mgmt_user") }}</dt>
<dd class="text-right">{% if server.mgmt_user %}{{ server.mgmt_user }}{% else %}{% endif %}</dd> <dd class="text-right">{% if server.mgmt_user %}{{ server.mgmt_user }}{% else %}{% endif %}</dd>
</div> </div>
<div class="flex justify-between gap-3"> <div class="flex justify-between gap-3">
<dt class="text-slate-400">Mgmt-Passwort</dt> <dt class="text-slate-400">{{ t("field.mgmt_password") }}</dt>
<dd class="text-right"> <dd class="text-right">
{% if server.mgmt_password_encrypted %} {% if server.mgmt_password_encrypted %}
{% if mgmt_password %} {% if mgmt_password %}
<span class="font-mono text-slate-100">{{ mgmt_password }}</span> <span class="font-mono text-slate-100">{{ mgmt_password }}</span>
{% else %} {% else %}
<span class="text-slate-500">verschlüsselt gespeichert (Key fehlt?)</span> <span class="text-slate-500">{{ t("mgmt.password_encrypted_missing") }}</span>
{% endif %} {% endif %}
{% else %} {% else %}
@ -162,11 +162,11 @@
</dd> </dd>
</div> </div>
<div class="flex justify-between gap-3"> <div class="flex justify-between gap-3">
<dt class="text-slate-400">SSH User</dt> <dt class="text-slate-400">{{ t("field.ssh_user") }}</dt>
<dd class="text-right">{% if server.ssh_user %}{{ server.ssh_user }}{% else %}{% endif %}</dd> <dd class="text-right">{% if server.ssh_user %}{{ server.ssh_user }}{% else %}{% endif %}</dd>
</div> </div>
<div class="flex justify-between gap-3"> <div class="flex justify-between gap-3">
<dt class="text-slate-400">SSH Key Hint</dt> <dt class="text-slate-400">{{ t("field.ssh_key_hint") }}</dt>
<dd class="text-right"> <dd class="text-right">
{% if server.ssh_key_hint %} {% if server.ssh_key_hint %}
<span class="font-mono">{{ server.ssh_key_hint }}</span> <span class="font-mono">{{ server.ssh_key_hint }}</span>
@ -175,22 +175,22 @@
</div> </div>
</dl> </dl>
<p class="mt-2 text-[11px] text-slate-500"> <p class="mt-2 text-[11px] text-slate-500">
Hinweis: Es werden nur Key-Namen gespeichert, keine privaten SSH-Schlüssel. {{ t("note.ssh_keys") }}
</p> </p>
</section> </section>
<!-- Notes --> <!-- Notes -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-2 md:col-span-2"> <section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-2 md:col-span-2">
<h2 class="text-sm font-semibold text-slate-100">Notizen</h2> <h2 class="text-sm font-semibold text-slate-100">{{ t("section.notes") }}</h2>
<div class="text-xs text-slate-200 whitespace-pre-wrap"> <div class="text-xs text-slate-200 whitespace-pre-wrap">
{% if server.notes %}{{ server.notes }}{% else %}<span class="text-slate-500">Keine Notizen.</span>{% endif %} {% if server.notes %}{{ server.notes }}{% else %}<span class="text-slate-500">{{ t("notes.empty") }}</span>{% endif %}
</div> </div>
</section> </section>
</div> </div>
<div class="flex justify-between items-center text-xs text-slate-500"> <div class="flex justify-between items-center text-xs text-slate-500">
<a href="/" class="hover:text-slate-200 underline underline-offset-2">Zurück zur Übersicht</a> <a href="/" class="hover:text-slate-200 underline underline-offset-2">{{ t("back.overview") }}</a>
<span>Zuletzt aktualisiert: {{ server.updated_at }}</span> <span>{{ t("updated_at") }} {{ server.updated_at }}</span>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -2,15 +2,15 @@
{% block content %} {% block content %}
<div class="max-w-3xl mx-auto"> <div class="max-w-3xl mx-auto">
<h1 class="text-lg font-semibold tracking-tight mb-1"> <h1 class="text-lg font-semibold tracking-tight mb-1">
{% if server %}Server bearbeiten{% else %}Neuen Server anlegen{% endif %} {% if server %}{{ t("form.title.edit") }}{% else %}{{ t("form.title.new") }}{% endif %}
</h1> </h1>
<p class="text-xs text-slate-400 mb-4"> <p class="text-xs text-slate-400 mb-4">
Trage alle relevanten Infos zu deinem VPS / Server ein. {{ t("form.subtitle") }}
</p> </p>
{% if not can_encrypt %} {% if not can_encrypt %}
<div class="mb-4 text-xs text-amber-200 bg-amber-500/10 border border-amber-500/60 rounded-lg p-3"> <div class="mb-4 text-xs text-amber-200 bg-amber-500/10 border border-amber-500/60 rounded-lg p-3">
ENCRYPTION_KEY ist nicht gesetzt eingegebene Management-Passwörter werden nicht gespeichert. {{ t("warning.no_encryption_short") }}
</div> </div>
{% endif %} {% endif %}
@ -18,10 +18,10 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<!-- General --> <!-- General -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3"> <section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3">
<h2 class="text-sm font-semibold text-slate-100">Allgemein</h2> <h2 class="text-sm font-semibold text-slate-100">{{ t("section.general") }}</h2>
<div class="grid md:grid-cols-2 gap-4"> <div class="grid md:grid-cols-2 gap-4">
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">Name *</label> <label class="text-xs text-slate-300">{{ t("label.name") }} *</label>
<input <input
type="text" type="text"
name="name" name="name"
@ -32,7 +32,7 @@
/> />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">Provider *</label> <label class="text-xs text-slate-300">{{ t("label.provider") }} *</label>
<input <input
type="text" type="text"
name="provider" name="provider"
@ -43,7 +43,7 @@
/> />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">Hostname</label> <label class="text-xs text-slate-300">{{ t("label.hostname") }}</label>
<input <input
type="text" type="text"
name="hostname" name="hostname"
@ -53,7 +53,7 @@
/> />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">Typ</label> <label class="text-xs text-slate-300">{{ t("label.type") }}</label>
<select <select
name="type" name="type"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500" class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
@ -67,7 +67,7 @@
</select> </select>
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">Location</label> <label class="text-xs text-slate-300">{{ t("label.location") }}</label>
<input <input
type="text" type="text"
name="location" name="location"
@ -77,7 +77,7 @@
/> />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">Tags (kommagetrennt)</label> <label class="text-xs text-slate-300">{{ t("label.tags") }}</label>
<input <input
type="text" type="text"
name="tags" name="tags"
@ -91,10 +91,10 @@
<!-- Network --> <!-- Network -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3"> <section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3">
<h2 class="text-sm font-semibold text-slate-100">Netzwerk</h2> <h2 class="text-sm font-semibold text-slate-100">{{ t("section.network") }}</h2>
<div class="grid md:grid-cols-2 gap-4"> <div class="grid md:grid-cols-2 gap-4">
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">IPv4</label> <label class="text-xs text-slate-300">{{ t("label.ipv4") }}</label>
<input <input
type="text" type="text"
name="ipv4" name="ipv4"
@ -104,7 +104,7 @@
/> />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">IPv6</label> <label class="text-xs text-slate-300">{{ t("label.ipv6") }}</label>
<input <input
type="text" type="text"
name="ipv6" name="ipv6"
@ -118,10 +118,10 @@
<!-- Costs --> <!-- Costs -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3"> <section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3">
<h2 class="text-sm font-semibold text-slate-100">Kosten</h2> <h2 class="text-sm font-semibold text-slate-100">{{ t("section.costs") }}</h2>
<div class="grid md:grid-cols-3 gap-4"> <div class="grid md:grid-cols-3 gap-4">
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">Betrag</label> <label class="text-xs text-slate-300">{{ t("label.amount") }}</label>
<input <input
type="number" type="number"
step="0.01" step="0.01"
@ -132,7 +132,7 @@
/> />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">Währung</label> <label class="text-xs text-slate-300">{{ t("label.currency") }}</label>
<input <input
type="text" type="text"
name="currency" name="currency"
@ -141,7 +141,7 @@
/> />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">Abrechnung</label> <label class="text-xs text-slate-300">{{ t("label.billing") }}</label>
<select <select
name="billing_period" name="billing_period"
class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500" class="w-full rounded-lg border border-slate-700 bg-slate-950/60 px-3 py-2 text-sm outline-none focus:border-indigo-500"
@ -153,7 +153,7 @@
</select> </select>
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">Vertragsbeginn</label> <label class="text-xs text-slate-300">{{ t("label.contract_start") }}</label>
<input <input
type="date" type="date"
name="contract_start" name="contract_start"
@ -162,7 +162,7 @@
/> />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">Vertragsende</label> <label class="text-xs text-slate-300">{{ t("label.contract_end") }}</label>
<input <input
type="date" type="date"
name="contract_end" name="contract_end"
@ -175,10 +175,10 @@
<!-- Hardware --> <!-- Hardware -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3"> <section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3">
<h2 class="text-sm font-semibold text-slate-100">Hardware</h2> <h2 class="text-sm font-semibold text-slate-100">{{ t("section.hardware.small") }}</h2>
<div class="grid md:grid-cols-2 gap-4"> <div class="grid md:grid-cols-2 gap-4">
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">CPU-Modell</label> <label class="text-xs text-slate-300">{{ t("label.cpu_model") }}</label>
<input <input
type="text" type="text"
name="cpu_model" name="cpu_model"
@ -188,7 +188,7 @@
/> />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">CPU Cores</label> <label class="text-xs text-slate-300">{{ t("label.cpu_cores") }}</label>
<input <input
type="number" type="number"
name="cpu_cores" name="cpu_cores"
@ -197,7 +197,7 @@
/> />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">RAM (MB)</label> <label class="text-xs text-slate-300">{{ t("label.ram_mb") }}</label>
<input <input
type="number" type="number"
name="ram_mb" name="ram_mb"
@ -206,7 +206,7 @@
/> />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">Storage (GB)</label> <label class="text-xs text-slate-300">{{ t("label.storage_gb") }}</label>
<input <input
type="number" type="number"
name="storage_gb" name="storage_gb"
@ -215,7 +215,7 @@
/> />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">Storage-Typ</label> <label class="text-xs text-slate-300">{{ t("label.storage_type") }}</label>
<input <input
type="text" type="text"
name="storage_type" name="storage_type"
@ -229,13 +229,13 @@
<!-- Access --> <!-- Access -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3"> <section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3">
<h2 class="text-sm font-semibold text-slate-100">Zugänge</h2> <h2 class="text-sm font-semibold text-slate-100">{{ t("section.access.small") }}</h2>
<p class="text-xs text-slate-400"> <p class="text-xs text-slate-400">
SSH: hier nur <strong>Key-Namen</strong> oder Hints eintragen, keine privaten Keys. {{ t("hint.ssh") }}
</p> </p>
<div class="grid md:grid-cols-2 gap-4"> <div class="grid md:grid-cols-2 gap-4">
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">Management URL</label> <label class="text-xs text-slate-300">{{ t("label.mgmt_url") }}</label>
<input <input
type="url" type="url"
name="mgmt_url" name="mgmt_url"
@ -245,7 +245,7 @@
/> />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">Management User</label> <label class="text-xs text-slate-300">{{ t("label.mgmt_user") }}</label>
<input <input
type="text" type="text"
name="mgmt_user" name="mgmt_user"
@ -254,7 +254,7 @@
/> />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">Management Passwort</label> <label class="text-xs text-slate-300">{{ t("label.mgmt_password") }}</label>
<input <input
type="password" type="password"
name="mgmt_password" name="mgmt_password"
@ -263,7 +263,7 @@
/> />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">SSH User</label> <label class="text-xs text-slate-300">{{ t("label.ssh_user") }}</label>
<input <input
type="text" type="text"
name="ssh_user" name="ssh_user"
@ -273,7 +273,7 @@
/> />
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs text-slate-300">SSH Key Hint</label> <label class="text-xs text-slate-300">{{ t("label.ssh_key_hint") }}</label>
<input <input
type="text" type="text"
name="ssh_key_hint" name="ssh_key_hint"
@ -287,7 +287,7 @@
<!-- Notes --> <!-- Notes -->
<section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3"> <section class="rounded-xl border border-slate-800 bg-slate-900/70 p-4 space-y-3">
<h2 class="text-sm font-semibold text-slate-100">Notizen</h2> <h2 class="text-sm font-semibold text-slate-100">{{ t("section.notes.small") }}</h2>
<textarea <textarea
name="notes" name="notes"
rows="4" rows="4"
@ -300,13 +300,13 @@
<a <a
href="/" href="/"
class="rounded-lg border border-slate-700 px-4 py-2 text-sm text-slate-200 hover:border-slate-500 hover:text-white" class="rounded-lg border border-slate-700 px-4 py-2 text-sm text-slate-200 hover:border-slate-500 hover:text-white"
>Abbrechen</a >{{ t("btn.cancel") }}</a
> >
<button <button
type="submit" type="submit"
class="rounded-lg bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-400 focus:outline-none focus:ring focus:ring-indigo-500/40" class="rounded-lg bg-indigo-500 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-400 focus:outline-none focus:ring focus:ring-indigo-500/40"
> >
{% if server %}Änderungen speichern{% else %}Speichern{% endif %} {% if server %}{{ t("btn.save_changes") }}{% else %}{{ t("btn.save") }}{% endif %}
</button> </button>
</div> </div>
</form> </form>

View file

@ -3,15 +3,15 @@
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div> <div>
<h1 class="text-lg font-semibold tracking-tight">Deine Server</h1> <h1 class="text-lg font-semibold tracking-tight">{{ t("server_list.title") }}</h1>
<p class="text-xs text-slate-400"> <p class="text-xs text-slate-400">
Übersicht aller gemieteten VPS, dedizierten Server und Storage-Systeme. {{ t("server_list.subtitle") }}
</p> </p>
</div> </div>
<div class="flex items-center gap-3 text-xs text-slate-400"> <div class="flex items-center gap-3 text-xs text-slate-400">
{% if not can_encrypt %} {% if not can_encrypt %}
<span class="px-2 py-1 rounded-md border border-amber-500/60 bg-amber-500/10 text-amber-200"> <span class="px-2 py-1 rounded-md border border-amber-500/60 bg-amber-500/10 text-amber-200">
Hinweis: ENCRYPTION_KEY nicht gesetzt Passwörter werden nicht gespeichert. {{ t("warning.no_encryption") }}
</span> </span>
{% endif %} {% endif %}
</div> </div>
@ -21,35 +21,35 @@
<!-- Small dashboard summary row --> <!-- Small dashboard summary row -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs"> <div class="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
<div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col"> <div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col">
<span class="text-[11px] text-slate-400 mb-1">Gesamt</span> <span class="text-[11px] text-slate-400 mb-1">{{ t("stats.total") }}</span>
<span class="text-lg font-semibold text-slate-100"> <span class="text-lg font-semibold text-slate-100">
{{ stats.total_servers }} {{ stats.total_servers }}
</span> </span>
<span class="text-[11px] text-slate-500 mt-1">aktive Server</span> <span class="text-[11px] text-slate-500 mt-1">{{ t("stats.active") }}</span>
</div> </div>
<div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col"> <div class="rounded-xl border border-slate-800 bg-slate-900/70 px-3 py-3 flex flex-col">
<span class="text-[11px] text-slate-400 mb-1">Laufende Kosten</span> <span class="text-[11px] text-slate-400 mb-1">{{ t("stats.costs") }}</span>
<span class="text-lg font-semibold text-slate-100"> <span class="text-lg font-semibold text-slate-100">
{{ "%.2f"|format(stats.monthly_total) }} {{ "%.2f"|format(stats.monthly_total) }}
{% if stats.monthly_currency %} {{ stats.monthly_currency }}{% endif %} {% if stats.monthly_currency %} {{ stats.monthly_currency }}{% endif %}
</span> </span>
<span class="text-[11px] text-slate-500 mt-1"> <span class="text-[11px] text-slate-500 mt-1">
pro Monat{% if stats.mixed_currencies %} (gemischte Währungen){% endif %} {{ t("stats.per_month") }}{% if stats.mixed_currencies %} {{ t("stats.mixed_currencies") }}{% endif %}
</span> </span>
</div> </div>
<div class="rounded-xl border border-amber-500/40 bg-amber-500/10 px-3 py-3 flex flex-col"> <div class="rounded-xl border border-amber-500/40 bg-amber-500/10 px-3 py-3 flex flex-col">
<span class="text-[11px] text-amber-200 mb-1">Laufen bald aus</span> <span class="text-[11px] text-amber-200 mb-1">{{ t("stats.expiring_soon") }}</span>
<span class="text-lg font-semibold text-amber-100"> <span class="text-lg font-semibold text-amber-100">
{{ stats.expiring_soon }} {{ stats.expiring_soon }}
</span> </span>
<span class="text-[11px] text-amber-200/80 mt-1">≤ 30 Tage</span> <span class="text-[11px] text-amber-200/80 mt-1">{{ t("stats.expiring_soon_hint") }}</span>
</div> </div>
<div class="rounded-xl border border-rose-600/60 bg-rose-600/10 px-3 py-3 flex flex-col"> <div class="rounded-xl border border-rose-600/60 bg-rose-600/10 px-3 py-3 flex flex-col">
<span class="text-[11px] text-rose-100 mb-1">Abgelaufen</span> <span class="text-[11px] text-rose-100 mb-1">{{ t("stats.expired") }}</span>
<span class="text-lg font-semibold text-rose-50"> <span class="text-lg font-semibold text-rose-50">
{{ stats.expired }} {{ stats.expired }}
</span> </span>
<span class="text-[11px] text-rose-100/80 mt-1">Vertrag beendet</span> <span class="text-[11px] text-rose-100/80 mt-1">{{ t("stats.expired_hint") }}</span>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@ -59,13 +59,13 @@
<table class="min-w-full text-sm"> <table class="min-w-full text-sm">
<thead class="bg-slate-900/80"> <thead class="bg-slate-900/80">
<tr class="text-xs uppercase tracking-wide text-slate-400"> <tr class="text-xs uppercase tracking-wide text-slate-400">
<th class="px-3 py-2 text-left">Name</th> <th class="px-3 py-2 text-left">{{ t("table.name") }}</th>
<th class="px-3 py-2 text-left">Provider</th> <th class="px-3 py-2 text-left">{{ t("table.provider") }}</th>
<th class="px-3 py-2 text-left">Type</th> <th class="px-3 py-2 text-left">{{ t("table.type") }}</th>
<th class="px-3 py-2 text-left">Location</th> <th class="px-3 py-2 text-left">{{ t("table.location") }}</th>
<th class="px-3 py-2 text-left">IPv4</th> <th class="px-3 py-2 text-left">{{ t("table.ipv4") }}</th>
<th class="px-3 py-2 text-right">Kosten</th> <th class="px-3 py-2 text-right">{{ t("table.costs") }}</th>
<th class="px-3 py-2 text-right">Aktion</th> <th class="px-3 py-2 text-right">{{ t("table.action") }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -83,11 +83,11 @@
</div> </div>
{% if s.is_expired %} {% if s.is_expired %}
<span class="inline-flex items-center rounded-full bg-rose-600/15 border border-rose-600/60 px-2 py-0.5 text-[10px] text-rose-200"> <span class="inline-flex items-center rounded-full bg-rose-600/15 border border-rose-600/60 px-2 py-0.5 text-[10px] text-rose-200">
abgelaufen {{ t("status.expired") }}
</span> </span>
{% elif s.is_expiring_soon %} {% elif s.is_expiring_soon %}
<span class="inline-flex items-center rounded-full bg-amber-500/15 border border-amber-500/60 px-2 py-0.5 text-[10px] text-amber-200"> <span class="inline-flex items-center rounded-full bg-amber-500/15 border border-amber-500/60 px-2 py-0.5 text-[10px] text-amber-200">
läuft bald aus {{ t("status.expiring") }}
</span> </span>
{% endif %} {% endif %}
</div> </div>
@ -107,14 +107,14 @@
<td class="px-3 py-2 text-right text-slate-200"> <td class="px-3 py-2 text-right text-slate-200">
{% if s.price %} {% if s.price %}
{{ "%.2f"|format(s.price) }} {{ s.currency }} {{ "%.2f"|format(s.price) }} {{ s.currency }}
<span class="text-[11px] text-slate-400">/ {{ "Monat" if s.billing_period == "monthly" else "Jahr" }}</span> <span class="text-[11px] text-slate-400">/ {{ t("price.month") if s.billing_period == "monthly" else t("price.year") }}</span>
{% else %} {% else %}
<span class="text-slate-500"></span> <span class="text-slate-500"></span>
{% endif %} {% endif %}
</td> </td>
<td class="px-3 py-2 text-right text-slate-200"> <td class="px-3 py-2 text-right text-slate-200">
<a href="/servers/{{ s.id }}/edit" class="text-indigo-300 hover:text-indigo-200 underline text-xs"> <a href="/servers/{{ s.id }}/edit" class="text-indigo-300 hover:text-indigo-200 underline text-xs">
Bearbeiten {{ t("server_detail.edit") }}
</a> </a>
</td> </td>
</tr> </tr>
@ -124,7 +124,7 @@
</div> </div>
{% else %} {% else %}
<div class="rounded-xl border border-dashed border-slate-700 bg-slate-900/40 p-6 text-sm text-slate-300"> <div class="rounded-xl border border-dashed border-slate-700 bg-slate-900/40 p-6 text-sm text-slate-300">
Noch keine Server erfasst. Leg den ersten mit „Server anlegen“ oben rechts an. {{ t("server_list.empty") }}
</div> </div>
{% endif %} {% endif %}
</div> </div>

View file

@ -23,10 +23,9 @@
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div> <div>
<h1 class="text-lg font-semibold tracking-tight">Server-Karte</h1> <h1 class="text-lg font-semibold tracking-tight">{{ t("map.title") }}</h1>
<p class="text-xs text-slate-400"> <p class="text-xs text-slate-400">
Zeigt alle nicht archivierten Server mit gesetzter Location auf einer Karte. {{ t("map.subtitle") }}
Für grobe Übersicht reicht die Stadt keine exakten GPS-Daten notwendig.
</p> </p>
</div> </div>
</div> </div>
@ -50,8 +49,7 @@
></div> ></div>
<p class="text-[11px] text-slate-500"> <p class="text-[11px] text-slate-500">
Hinweis: Marker werden anhand der Location-Namen grob auf der Weltkarte platziert. Mehrere Server {{ t("map.note") }}
in derselben Stadt werden leicht versetzt dargestellt, damit sie klickbar bleiben.
</p> </p>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -3,9 +3,9 @@
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h1 class="text-lg font-semibold tracking-tight">Benutzerverwaltung</h1> <h1 class="text-lg font-semibold tracking-tight">{{ t("users.title") }}</h1>
<p class="text-xs text-slate-400"> <p class="text-xs text-slate-400">
Admins können Benutzer aktivieren/deaktivieren. Der eigene Account kann nicht deaktiviert werden. {{ t("users.subtitle") }}
</p> </p>
</div> </div>
</div> </div>
@ -14,11 +14,11 @@
<table class="min-w-full text-sm"> <table class="min-w-full text-sm">
<thead class="bg-slate-900/80 text-xs uppercase tracking-wide text-slate-400"> <thead class="bg-slate-900/80 text-xs uppercase tracking-wide text-slate-400">
<tr> <tr>
<th class="px-3 py-2 text-left">Benutzername</th> <th class="px-3 py-2 text-left">{{ t("users.username") }}</th>
<th class="px-3 py-2 text-left">E-Mail</th> <th class="px-3 py-2 text-left">{{ t("users.email") }}</th>
<th class="px-3 py-2 text-left">Rolle</th> <th class="px-3 py-2 text-left">{{ t("users.role") }}</th>
<th class="px-3 py-2 text-left">Status</th> <th class="px-3 py-2 text-left">{{ t("users.status") }}</th>
<th class="px-3 py-2 text-right">Aktion</th> <th class="px-3 py-2 text-right">{{ t("users.action") }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -31,16 +31,16 @@
{% if u.email %}{{ u.email }}{% else %}<span class="text-slate-500"></span>{% endif %} {% if u.email %}{{ u.email }}{% else %}<span class="text-slate-500"></span>{% endif %}
</td> </td>
<td class="px-3 py-2 text-slate-200"> <td class="px-3 py-2 text-slate-200">
{% if u.is_admin %}Admin{% else %}User{% endif %} {% if u.is_admin %}{{ t("role.admin") }}{% else %}{{ t("role.user") }}{% endif %}
</td> </td>
<td class="px-3 py-2 text-slate-200"> <td class="px-3 py-2 text-slate-200">
{% if u.is_active %} {% if u.is_active %}
<span class="inline-flex rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] text-emerald-200 border border-emerald-500/50"> <span class="inline-flex rounded-full bg-emerald-500/10 px-2 py-0.5 text-[11px] text-emerald-200 border border-emerald-500/50">
aktiv {{ t("status.active") }}
</span> </span>
{% else %} {% else %}
<span class="inline-flex rounded-full bg-slate-800 px-2 py-0.5 text-[11px] text-slate-300 border border-slate-600"> <span class="inline-flex rounded-full bg-slate-800 px-2 py-0.5 text-[11px] text-slate-300 border border-slate-600">
deaktiviert {{ t("status.inactive") }}
</span> </span>
{% endif %} {% endif %}
</td> </td>
@ -52,11 +52,11 @@
type="submit" type="submit"
class="rounded-lg border border-slate-700 px-3 py-1 text-xs text-slate-200 hover:border-slate-500 hover:text-white" class="rounded-lg border border-slate-700 px-3 py-1 text-xs text-slate-200 hover:border-slate-500 hover:text-white"
> >
{% if u.is_active %}Deaktivieren{% else %}Aktivieren{% endif %} {% if u.is_active %}{{ t("action.deactivate") }}{% else %}{{ t("action.activate") }}{% endif %}
</button> </button>
</form> </form>
{% else %} {% else %}
<span class="text-[11px] text-slate-500">Eigener Account</span> <span class="text-[11px] text-slate-500">{{ t("users.own_account") }}</span>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>