issues fixed
This commit is contained in:
parent
db64505915
commit
9661a0f7a5
11 changed files with 35071 additions and 579 deletions
|
|
@ -1,112 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
"""
|
||||
i18n-Index für DB-gestützte UI-Sprachen (de = Key, siehe wiki).
|
||||
|
||||
Dieses Skript **regeneriert** `i18n_ui_strings_index.json` aus den im Projekt verwendeten
|
||||
`t('…')`- und `t("…")`-Aufrufen in den angegebenen Quellfiles. Es wendet **keine**
|
||||
Code-Änderungen an — Migration neuer Texte erfolgt im TSX mit `t()`, anschließend Index
|
||||
mit `--write` aktualisieren und fehlende Keys in der Admin-UI (UI-Sprachen) nachziehen.
|
||||
|
||||
Nutzung (im Ordner frontend_nyla):
|
||||
python scripts/apply_i18n_from_index.py --write
|
||||
python scripts/apply_i18n_from_index.py # nur prüfen: gleiche Keys wie in JSON?
|
||||
|
||||
Konvention: deutscher Klartext ist der Key (siehe `2026-04-ui-i18n-dynamic-language-sets.md`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
INDEX_PATH = Path(__file__).resolve().parent / "i18n_ui_strings_index.json"
|
||||
|
||||
# Dateien mit nutzerrelevanten t()-Aufrufen (erweitern bei weiteren Migrationen)
|
||||
SCAN_PATHS = [
|
||||
ROOT / "src/pages/views/trustee/TrusteeAccountingSettingsView.tsx",
|
||||
ROOT / "src/pages/AutomationsDashboardPage.tsx",
|
||||
ROOT / "src/pages/billing/BillingDataView.tsx",
|
||||
ROOT / "src/pages/billing/BillingAdmin.tsx",
|
||||
ROOT / "src/components/Navigation/MandateNavigation.tsx",
|
||||
]
|
||||
|
||||
|
||||
def _extractTKeys(source: str) -> set[str]:
|
||||
"""Extrahiert erste String-Literale aus t('…') / t("…") (ohne verschachtelte Klammern)."""
|
||||
keys: set[str] = set()
|
||||
for m in re.finditer(r"\bt\(\s*((?:'([^'\\]|\\.)*'|\"([^\"\\]|\\.)*\"))\s*(?:,|\))", source):
|
||||
raw = m.group(1)
|
||||
if raw.startswith("'"):
|
||||
inner = raw[1:-1].replace("\\'", "'").replace("\\\\", "\\")
|
||||
else:
|
||||
inner = raw[1:-1].replace('\\"', '"').replace("\\\\", "\\")
|
||||
keys.add(inner)
|
||||
return keys
|
||||
|
||||
|
||||
def _scanAllKeys() -> dict[str, list[str]]:
|
||||
fileToKeys: dict[str, list[str]] = {}
|
||||
for p in SCAN_PATHS:
|
||||
if not p.exists():
|
||||
print(f"WARN: fehlt {p}", file=sys.stderr)
|
||||
continue
|
||||
text = p.read_text(encoding="utf-8")
|
||||
keys = sorted(_extractTKeys(text))
|
||||
rel = str(p.relative_to(ROOT)).replace("\\", "/")
|
||||
fileToKeys[rel] = keys
|
||||
return fileToKeys
|
||||
|
||||
|
||||
def _mergeUnique(fileToKeys: dict[str, list[str]]) -> list[str]:
|
||||
allKeys: set[str] = set()
|
||||
for ks in fileToKeys.values():
|
||||
allKeys.update(ks)
|
||||
return sorted(allKeys)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="i18n-Index aus t()-Aufrufen pflegen.")
|
||||
parser.add_argument("--write", action="store_true", help="i18n_ui_strings_index.json schreiben")
|
||||
args = parser.parse_args()
|
||||
|
||||
byFile = _scanAllKeys()
|
||||
merged = _mergeUnique(byFile)
|
||||
payload = {
|
||||
"version": 2,
|
||||
"description": "Extrahierte UI-Keys (Deutsch = Key) für DB/Admin UI-Sprachen; erzeugt durch apply_i18n_from_index.py",
|
||||
"sourceRoot": "frontend_nyla",
|
||||
"keysByFile": byFile,
|
||||
"keysAll": merged,
|
||||
}
|
||||
|
||||
if args.write:
|
||||
INDEX_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"Written {INDEX_PATH} ({len(merged)} unique keys)")
|
||||
return
|
||||
|
||||
if INDEX_PATH.exists():
|
||||
existing = json.loads(INDEX_PATH.read_text(encoding="utf-8"))
|
||||
prev = set(existing.get("keysAll", []))
|
||||
now = set(merged)
|
||||
if prev != now:
|
||||
onlyNew = sorted(now - prev)
|
||||
onlyOld = sorted(prev - now)
|
||||
print("Index differs from scan. Run with --write to refresh.")
|
||||
if onlyNew:
|
||||
print(f" + new ({len(onlyNew)}):", onlyNew[:20], "..." if len(onlyNew) > 20 else "")
|
||||
if onlyOld:
|
||||
print(f" - removed ({len(onlyOld)}):", onlyOld[:20], "..." if len(onlyOld) > 20 else "")
|
||||
sys.exit(1)
|
||||
print(f"OK: index matches scan ({len(merged)} keys)")
|
||||
else:
|
||||
print("No index file; run with --write to create.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
19322
scripts/i18n_missing_report.json
Normal file
19322
scripts/i18n_missing_report.json
Normal file
File diff suppressed because it is too large
Load diff
15027
scripts/i18n_missing_report.md
Normal file
15027
scripts/i18n_missing_report.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,312 +0,0 @@
|
|||
{
|
||||
"version": 2,
|
||||
"description": "Extrahierte UI-Keys (Deutsch = Key) für DB/Admin UI-Sprachen; erzeugt durch apply_i18n_from_index.py",
|
||||
"sourceRoot": "frontend_nyla",
|
||||
"keysByFile": {
|
||||
"src/pages/views/trustee/TrusteeAccountingSettingsView.tsx": [
|
||||
"Aktueller Datenbestand:",
|
||||
"Anbindung entfernen",
|
||||
"Anzeigename",
|
||||
"Bis (optional)",
|
||||
"Buchhaltungsanbindung entfernen? Synchronisierte Daten bleiben erhalten.",
|
||||
"Buchhaltungsanbindung wurde entfernt.",
|
||||
"Buchhaltungsdaten importieren",
|
||||
"Buchhaltungseinstellungen werden geladen…",
|
||||
"Buchhaltungssystem",
|
||||
"Buchhaltungssystem-Anbindung",
|
||||
"Cache geleert",
|
||||
"Cache konnte nicht geleert werden.",
|
||||
"Daten jetzt einlesen",
|
||||
"Die Buchhaltungskonfiguration wurde erfolgreich gespeichert.",
|
||||
"Entfernen",
|
||||
"Entfernen der Konfiguration fehlgeschlagen.",
|
||||
"Entfernt",
|
||||
"Fehler",
|
||||
"Gespeichert",
|
||||
"Import abgeschlossen",
|
||||
"Import abgeschlossen in {sek}s:",
|
||||
"Import fehlgeschlagen",
|
||||
"Import teilweise fehlgeschlagen",
|
||||
"Importiere…",
|
||||
"KI-Cache leeren",
|
||||
"Keine Verbindung möglich.",
|
||||
"Konfiguration speichern",
|
||||
"Kontenplan, Buchungen, Kontakte und Salden aus dem Buchhaltungssystem einlesen. Diese Daten stehen anschließend im KI-Workspace für Analysen zur Verfügung.",
|
||||
"Laufendes Jahr",
|
||||
"Leere…",
|
||||
"Letzter Import:",
|
||||
"Letzter Monat",
|
||||
"Letzter Sync:",
|
||||
"Letztes Jahr",
|
||||
"Speichern der Konfiguration fehlgeschlagen.",
|
||||
"Speichern…",
|
||||
"Status:",
|
||||
"System auswählen…",
|
||||
"Teste…",
|
||||
"Unbekannter Fehler",
|
||||
"Verbinden Sie ein Buchhaltungssystem, um Buchungen aus dieser Trustee-Instanz automatisch zu synchronisieren.",
|
||||
"Verbindung OK",
|
||||
"Verbindung erfolgreich!",
|
||||
"Verbindung fehlgeschlagen",
|
||||
"Verbindung fehlgeschlagen: {message}",
|
||||
"Verbindung testen",
|
||||
"Verbindung zum Buchhaltungssystem erfolgreich.",
|
||||
"Verbindungstest fehlgeschlagen.",
|
||||
"Verbunden:",
|
||||
"z. B. Run My Accounts – Muster AG",
|
||||
"{konten} Konten, {buchungen} Buchungen ({zeilen} Zeilen), {kontakte} Kontakte, {salden} Salden",
|
||||
"{konten} Konten, {buchungen} Buchungen, {kontakte} Kontakte, {salden} Salden importiert.",
|
||||
"{konten} Konten, {buchungen} Buchungen, {zeilen} Zeilen, {kontakte} Kontakte, {salden} Salden",
|
||||
"{n} gecachte Abfragen entfernt. Die nächste KI-Abfrage liest frische Daten."
|
||||
],
|
||||
"src/pages/AutomationsDashboardPage.tsx": [
|
||||
"Aktive Workflows",
|
||||
"Aktualisieren",
|
||||
"Automations",
|
||||
"Beendet",
|
||||
"Credits gesamt:",
|
||||
"Download fehlgeschlagen",
|
||||
"Fehler beim Laden des Automations-Dashboards",
|
||||
"Gestartet",
|
||||
"Letzte Runs",
|
||||
"Mandant",
|
||||
"Noch keine Workflow-Runs vorhanden.",
|
||||
"Runs gesamt",
|
||||
"Status",
|
||||
"Tokens gesamt:",
|
||||
"Tracing-Protokoll herunterladen",
|
||||
"Workflow",
|
||||
"Workflow-Runs über alle Features und Mandanten",
|
||||
"Workflows",
|
||||
"—"
|
||||
],
|
||||
"src/pages/billing/BillingDataView.tsx": [
|
||||
"Alle (RBAC)",
|
||||
"Anbieter",
|
||||
"Balken",
|
||||
"Benutzer",
|
||||
"Beschreibung",
|
||||
"Betrag (CHF)",
|
||||
"Datum",
|
||||
"Diagramme",
|
||||
"Feature",
|
||||
"Fehler beim Laden der Transaktionen",
|
||||
"Keine Statistiken verfügbar",
|
||||
"Keine Transaktionen vorhanden",
|
||||
"Kontext:",
|
||||
"Mandant",
|
||||
"Mandant: {name}",
|
||||
"Meine Kosten",
|
||||
"Modell",
|
||||
"Pie",
|
||||
"Schließen",
|
||||
"Transaktionen",
|
||||
"Typ",
|
||||
"Zahlung abgebrochen.",
|
||||
"Zahlung erfolgreich, aber Verbuchung konnte nicht bestätigt werden.",
|
||||
"Zahlung erfolgreich. Guthaben wird gutgeschrieben.",
|
||||
"nur meine Daten",
|
||||
"Übersicht"
|
||||
],
|
||||
"src/pages/billing/BillingAdmin.tsx": [
|
||||
"Abonnement",
|
||||
"Aktiv",
|
||||
"Auto-Nachladung (KI-Guthaben bei niedrigem Stand)",
|
||||
"Bei Warnung benachrichtigen",
|
||||
"Beschreibung",
|
||||
"Beschreibung der Gutschrift",
|
||||
"Betrag (CHF)",
|
||||
"Betrag darf nicht null sein",
|
||||
"Billing-Einstellungen",
|
||||
"Bitte wählen Sie einen Mandanten aus.",
|
||||
"Checkout fehlgeschlagen",
|
||||
"Deaktiviert",
|
||||
"Einstellungen",
|
||||
"Einstellungen speichern",
|
||||
"Fehler bei der Buchung",
|
||||
"Guthaben abziehen",
|
||||
"Guthaben aufladen",
|
||||
"Guthaben manuell verwalten",
|
||||
"Guthaben via Stripe aufladen",
|
||||
"Guthaben:",
|
||||
"Kein Mandanten-Konto vorhanden",
|
||||
"Keine Konten vorhanden",
|
||||
"Konten",
|
||||
"Lade Konten…",
|
||||
"Mandant auswählen",
|
||||
"Mandanten-Konto",
|
||||
"Manuelle Buchung durch Admin",
|
||||
"Max. Nachladungen / Monat",
|
||||
"Mit Stripe bezahlen",
|
||||
"OK",
|
||||
"Sie werden zu Stripe weitergeleitet. Nach erfolgreicher Zahlung kehren Sie hierher zurück.",
|
||||
"Speichern…",
|
||||
"Status:",
|
||||
"Warnschwelle (%)",
|
||||
"Weiterleitung…",
|
||||
"Wird verbucht…",
|
||||
"Zahlung abgebrochen.",
|
||||
"Zahlung erfolgreich, aber Verbuchung konnte nicht bestätigt werden.",
|
||||
"Zahlung erfolgreich. Guthaben wird gutgeschrieben.",
|
||||
"Zahlung erfolgreich. Guthaben wurde verbucht.",
|
||||
"z.B. 50 oder -20",
|
||||
"{betrag} erfolgreich abgezogen.",
|
||||
"{betrag} erfolgreich gutgeschrieben."
|
||||
],
|
||||
"src/components/Navigation/MandateNavigation.tsx": [
|
||||
"Administration",
|
||||
"Fehler",
|
||||
"Keine Feature-Instanzen verfügbar.",
|
||||
"Kontaktiere einen Administrator, um Zugriff zu erhalten.",
|
||||
"Meine Sicht",
|
||||
"Navigation wird geladen…",
|
||||
"Neuer Name:",
|
||||
"Umbenennen",
|
||||
"Umbenennung fehlgeschlagen: {detail}"
|
||||
]
|
||||
},
|
||||
"keysAll": [
|
||||
"Abonnement",
|
||||
"Administration",
|
||||
"Aktiv",
|
||||
"Aktive Workflows",
|
||||
"Aktualisieren",
|
||||
"Aktueller Datenbestand:",
|
||||
"Alle (RBAC)",
|
||||
"Anbieter",
|
||||
"Anbindung entfernen",
|
||||
"Anzeigename",
|
||||
"Auto-Nachladung (KI-Guthaben bei niedrigem Stand)",
|
||||
"Automations",
|
||||
"Balken",
|
||||
"Beendet",
|
||||
"Bei Warnung benachrichtigen",
|
||||
"Benutzer",
|
||||
"Beschreibung",
|
||||
"Beschreibung der Gutschrift",
|
||||
"Betrag (CHF)",
|
||||
"Betrag darf nicht null sein",
|
||||
"Billing-Einstellungen",
|
||||
"Bis (optional)",
|
||||
"Bitte wählen Sie einen Mandanten aus.",
|
||||
"Buchhaltungsanbindung entfernen? Synchronisierte Daten bleiben erhalten.",
|
||||
"Buchhaltungsanbindung wurde entfernt.",
|
||||
"Buchhaltungsdaten importieren",
|
||||
"Buchhaltungseinstellungen werden geladen…",
|
||||
"Buchhaltungssystem",
|
||||
"Buchhaltungssystem-Anbindung",
|
||||
"Cache geleert",
|
||||
"Cache konnte nicht geleert werden.",
|
||||
"Checkout fehlgeschlagen",
|
||||
"Credits gesamt:",
|
||||
"Daten jetzt einlesen",
|
||||
"Datum",
|
||||
"Deaktiviert",
|
||||
"Diagramme",
|
||||
"Die Buchhaltungskonfiguration wurde erfolgreich gespeichert.",
|
||||
"Download fehlgeschlagen",
|
||||
"Einstellungen",
|
||||
"Einstellungen speichern",
|
||||
"Entfernen",
|
||||
"Entfernen der Konfiguration fehlgeschlagen.",
|
||||
"Entfernt",
|
||||
"Feature",
|
||||
"Fehler",
|
||||
"Fehler bei der Buchung",
|
||||
"Fehler beim Laden der Transaktionen",
|
||||
"Fehler beim Laden des Automations-Dashboards",
|
||||
"Gespeichert",
|
||||
"Gestartet",
|
||||
"Guthaben abziehen",
|
||||
"Guthaben aufladen",
|
||||
"Guthaben manuell verwalten",
|
||||
"Guthaben via Stripe aufladen",
|
||||
"Guthaben:",
|
||||
"Import abgeschlossen",
|
||||
"Import abgeschlossen in {sek}s:",
|
||||
"Import fehlgeschlagen",
|
||||
"Import teilweise fehlgeschlagen",
|
||||
"Importiere…",
|
||||
"KI-Cache leeren",
|
||||
"Kein Mandanten-Konto vorhanden",
|
||||
"Keine Feature-Instanzen verfügbar.",
|
||||
"Keine Konten vorhanden",
|
||||
"Keine Statistiken verfügbar",
|
||||
"Keine Transaktionen vorhanden",
|
||||
"Keine Verbindung möglich.",
|
||||
"Konfiguration speichern",
|
||||
"Kontaktiere einen Administrator, um Zugriff zu erhalten.",
|
||||
"Konten",
|
||||
"Kontenplan, Buchungen, Kontakte und Salden aus dem Buchhaltungssystem einlesen. Diese Daten stehen anschließend im KI-Workspace für Analysen zur Verfügung.",
|
||||
"Kontext:",
|
||||
"Lade Konten…",
|
||||
"Laufendes Jahr",
|
||||
"Leere…",
|
||||
"Letzte Runs",
|
||||
"Letzter Import:",
|
||||
"Letzter Monat",
|
||||
"Letzter Sync:",
|
||||
"Letztes Jahr",
|
||||
"Mandant",
|
||||
"Mandant auswählen",
|
||||
"Mandant: {name}",
|
||||
"Mandanten-Konto",
|
||||
"Manuelle Buchung durch Admin",
|
||||
"Max. Nachladungen / Monat",
|
||||
"Meine Kosten",
|
||||
"Meine Sicht",
|
||||
"Mit Stripe bezahlen",
|
||||
"Modell",
|
||||
"Navigation wird geladen…",
|
||||
"Neuer Name:",
|
||||
"Noch keine Workflow-Runs vorhanden.",
|
||||
"OK",
|
||||
"Pie",
|
||||
"Runs gesamt",
|
||||
"Schließen",
|
||||
"Sie werden zu Stripe weitergeleitet. Nach erfolgreicher Zahlung kehren Sie hierher zurück.",
|
||||
"Speichern der Konfiguration fehlgeschlagen.",
|
||||
"Speichern…",
|
||||
"Status",
|
||||
"Status:",
|
||||
"System auswählen…",
|
||||
"Teste…",
|
||||
"Tokens gesamt:",
|
||||
"Tracing-Protokoll herunterladen",
|
||||
"Transaktionen",
|
||||
"Typ",
|
||||
"Umbenennen",
|
||||
"Umbenennung fehlgeschlagen: {detail}",
|
||||
"Unbekannter Fehler",
|
||||
"Verbinden Sie ein Buchhaltungssystem, um Buchungen aus dieser Trustee-Instanz automatisch zu synchronisieren.",
|
||||
"Verbindung OK",
|
||||
"Verbindung erfolgreich!",
|
||||
"Verbindung fehlgeschlagen",
|
||||
"Verbindung fehlgeschlagen: {message}",
|
||||
"Verbindung testen",
|
||||
"Verbindung zum Buchhaltungssystem erfolgreich.",
|
||||
"Verbindungstest fehlgeschlagen.",
|
||||
"Verbunden:",
|
||||
"Warnschwelle (%)",
|
||||
"Weiterleitung…",
|
||||
"Wird verbucht…",
|
||||
"Workflow",
|
||||
"Workflow-Runs über alle Features und Mandanten",
|
||||
"Workflows",
|
||||
"Zahlung abgebrochen.",
|
||||
"Zahlung erfolgreich, aber Verbuchung konnte nicht bestätigt werden.",
|
||||
"Zahlung erfolgreich. Guthaben wird gutgeschrieben.",
|
||||
"Zahlung erfolgreich. Guthaben wurde verbucht.",
|
||||
"nur meine Daten",
|
||||
"z. B. Run My Accounts – Muster AG",
|
||||
"z.B. 50 oder -20",
|
||||
"{betrag} erfolgreich abgezogen.",
|
||||
"{betrag} erfolgreich gutgeschrieben.",
|
||||
"{konten} Konten, {buchungen} Buchungen ({zeilen} Zeilen), {kontakte} Kontakte, {salden} Salden",
|
||||
"{konten} Konten, {buchungen} Buchungen, {kontakte} Kontakte, {salden} Salden importiert.",
|
||||
"{konten} Konten, {buchungen} Buchungen, {zeilen} Zeilen, {kontakte} Kontakte, {salden} Salden",
|
||||
"{n} gecachte Abfragen entfernt. Die nächste KI-Abfrage liest frische Daten.",
|
||||
"Übersicht",
|
||||
"—"
|
||||
]
|
||||
}
|
||||
|
|
@ -133,11 +133,11 @@
|
|||
}
|
||||
|
||||
.table thead tr {
|
||||
background: var(--color-bg);
|
||||
background: var(--table-header-bg, rgba(0, 0, 0, 0.03));
|
||||
}
|
||||
|
||||
.th {
|
||||
background: var(--color-bg, #f8fafc);
|
||||
background: var(--table-header-bg, rgba(0, 0, 0, 0.03));
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
|
|
@ -148,7 +148,7 @@
|
|||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid var(--color-border, #e2e8f0);
|
||||
border-bottom: 2px solid var(--color-border, #e2e8f0);
|
||||
}
|
||||
|
||||
.th.actionsColumn {
|
||||
|
|
@ -161,7 +161,7 @@
|
|||
}
|
||||
|
||||
.th.sortable:hover {
|
||||
background: var(--color-gray-disabled, #f1f5f9);
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: var(--color-text, #334155);
|
||||
}
|
||||
|
||||
|
|
@ -380,7 +380,7 @@
|
|||
}
|
||||
|
||||
thead .selectColumn {
|
||||
background: var(--color-bg);
|
||||
background: var(--table-header-bg, rgba(0, 0, 0, 0.03));
|
||||
}
|
||||
|
||||
tbody .selectColumn {
|
||||
|
|
@ -431,7 +431,7 @@ tbody .selectColumn {
|
|||
}
|
||||
|
||||
thead .actionsColumn {
|
||||
background: var(--color-bg);
|
||||
background: var(--table-header-bg, rgba(0, 0, 0, 0.03));
|
||||
}
|
||||
|
||||
tbody .actionsColumn {
|
||||
|
|
@ -766,8 +766,22 @@ tbody .actionsColumn {
|
|||
|
||||
/* Dark theme */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.table thead tr {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.th {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-bottom-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
thead .selectColumn,
|
||||
thead .actionsColumn {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.th.sortable:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.tr:hover {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,13 @@ export interface I18nCodeInfo {
|
|||
label?: string;
|
||||
status?: string;
|
||||
isDefault?: boolean;
|
||||
keysCount?: number;
|
||||
entriesCount?: number;
|
||||
}
|
||||
|
||||
interface I18nEntryRaw {
|
||||
context?: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export async function fetchAvailableLanguageCodes(): Promise<I18nCodeInfo[]> {
|
||||
|
|
@ -27,8 +33,21 @@ export const loadLanguage = async (language: Language): Promise<TranslationKeys>
|
|||
if (!res.ok) {
|
||||
throw new Error(`Failed to load language ${code}: ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as { keys?: TranslationKeys };
|
||||
return data.keys ?? {};
|
||||
const data = await res.json();
|
||||
|
||||
if (Array.isArray(data.entries)) {
|
||||
const map: TranslationKeys = {};
|
||||
for (const e of data.entries as I18nEntryRaw[]) {
|
||||
if (e.key) map[e.key] = e.value ?? '';
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
if (data.keys && typeof data.keys === 'object') {
|
||||
return data.keys as TranslationKeys;
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
export type { Language, TranslationKeys } from './types';
|
||||
|
|
|
|||
|
|
@ -439,7 +439,7 @@ const NeutralizationMappingsTab: React.FC = () => {
|
|||
// =============================================================================
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const { currentLanguage, setLanguage, availableLanguages } = useLanguage();
|
||||
const { currentLanguage, setLanguage, availableLanguages, refreshAvailableLanguages } = useLanguage();
|
||||
const { user: currentUser, refetch: refetchUser } = useCurrentUser();
|
||||
const { updateUser } = useUser();
|
||||
|
||||
|
|
@ -449,6 +449,12 @@ export const SettingsPage: React.FC = () => {
|
|||
const [isSavingLanguage, setIsSavingLanguage] = useState(false);
|
||||
const [languageError, setLanguageError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (availableLanguages.length === 0) {
|
||||
refreshAvailableLanguages();
|
||||
}
|
||||
}, [availableLanguages.length, refreshAvailableLanguages]);
|
||||
|
||||
const handleThemeChange = (newTheme: 'light' | 'dark') => {
|
||||
setTheme(newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
* SysAdmin: UI language sets (DB-backed i18n).
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { FaDownload, FaFileExport, FaFileImport, FaRedo, FaTrash } from 'react-icons/fa';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FaDownload, FaFileExport, FaFileImport, FaRedo, FaSync, FaTrash } from 'react-icons/fa';
|
||||
import api from '../../api';
|
||||
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable/FormGeneratorTable';
|
||||
import { useConfirm } from '../../hooks/useConfirm';
|
||||
|
|
@ -14,38 +14,157 @@ type LangRow = {
|
|||
id: string;
|
||||
label: string;
|
||||
status: string;
|
||||
keysCount: number;
|
||||
is?: boolean;
|
||||
entriesCount: number;
|
||||
};
|
||||
|
||||
type ProgressInfo = {
|
||||
message: string;
|
||||
current: number;
|
||||
total: number;
|
||||
error?: string;
|
||||
done?: boolean;
|
||||
};
|
||||
|
||||
const _columns: ColumnConfig[] = [
|
||||
{ key: 'id', label: 'Code', type: 'text', sortable: true, filterable: true, width: 90 },
|
||||
{ key: 'label', label: 'Bezeichnung', type: 'text', sortable: true, filterable: true, width: 160 },
|
||||
{ key: 'label', label: 'Bezeichnung', type: 'text', sortable: true, filterable: true, width: 200 },
|
||||
{ key: 'status', label: 'Status', type: 'text', sortable: true, filterable: true, width: 120 },
|
||||
{ key: 'keysCount', label: 'Keys', type: 'number', sortable: true, width: 90 },
|
||||
{ key: 'entriesCount', label: 'Einträge', type: 'number', sortable: true, width: 100 },
|
||||
];
|
||||
|
||||
const _isoChoices = [
|
||||
{ value: 'it', label: 'it — Italiano' },
|
||||
{ value: 'es', label: 'es — Español' },
|
||||
{ value: 'pt', label: 'pt — Português' },
|
||||
{ value: 'nl', label: 'nl — Nederlands' },
|
||||
{ value: 'pl', label: 'pl — Polski' },
|
||||
{ value: 'cs', label: 'cs — Čeština' },
|
||||
{ value: 'sk', label: 'sk — Slovenčina' },
|
||||
{ value: 'sv', label: 'sv — Svenska' },
|
||||
{ value: 'no', label: 'no — Norsk' },
|
||||
{ value: 'da', label: 'da — Dansk' },
|
||||
const _PRIORITY_CODES = ['de', 'en', 'fr', 'it'];
|
||||
|
||||
const _isoChoices: { value: string; label: string }[] = [
|
||||
{ value: 'de', label: 'de — Deutsch' }, { value: 'en', label: 'en — English' },
|
||||
{ value: 'fr', label: 'fr — Français' }, { value: 'it', label: 'it — Italiano' },
|
||||
{ value: 'es', label: 'es — Español' }, { value: 'pt', label: 'pt — Português' },
|
||||
{ value: 'nl', label: 'nl — Nederlands' }, { value: 'pl', label: 'pl — Polski' },
|
||||
{ value: 'cs', label: 'cs — Čeština' }, { value: 'sk', label: 'sk — Slovenčina' },
|
||||
{ value: 'sv', label: 'sv — Svenska' }, { value: 'no', label: 'no — Norsk' },
|
||||
{ value: 'da', label: 'da — Dansk' }, { value: 'fi', label: 'fi — Suomi' },
|
||||
{ value: 'hu', label: 'hu — Magyar' }, { value: 'ro', label: 'ro — Română' },
|
||||
{ value: 'bg', label: 'bg — Български' }, { value: 'hr', label: 'hr — Hrvatski' },
|
||||
{ value: 'sl', label: 'sl — Slovenščina' }, { value: 'et', label: 'et — Eesti' },
|
||||
{ value: 'lv', label: 'lv — Latviešu' }, { value: 'lt', label: 'lt — Lietuvių' },
|
||||
{ value: 'el', label: 'el — Ελληνικά' }, { value: 'tr', label: 'tr — Türkçe' },
|
||||
{ value: 'ru', label: 'ru — Русский' }, { value: 'uk', label: 'uk — Українська' },
|
||||
{ value: 'ar', label: 'ar — العربية' }, { value: 'he', label: 'he — עברית' },
|
||||
{ value: 'zh', label: 'zh — 中文' }, { value: 'ja', label: 'ja — 日本語' },
|
||||
{ value: 'ko', label: 'ko — 한국어' }, { value: 'hi', label: 'hi — हिन्दी' },
|
||||
{ value: 'th', label: 'th — ไทย' }, { value: 'vi', label: 'vi — Tiếng Việt' },
|
||||
{ value: 'id', label: 'id — Bahasa Indonesia' }, { value: 'ms', label: 'ms — Bahasa Melayu' },
|
||||
{ value: 'tl', label: 'tl — Filipino' }, { value: 'sw', label: 'sw — Kiswahili' },
|
||||
{ value: 'af', label: 'af — Afrikaans' }, { value: 'sq', label: 'sq — Shqip' },
|
||||
{ value: 'am', label: 'am — አማርኛ' }, { value: 'hy', label: 'hy — Հայերեն' },
|
||||
{ value: 'az', label: 'az — Azərbaycan' }, { value: 'eu', label: 'eu — Euskara' },
|
||||
{ value: 'be', label: 'be — Беларуская' }, { value: 'bn', label: 'bn — বাংলা' },
|
||||
{ value: 'bs', label: 'bs — Bosanski' }, { value: 'ca', label: 'ca — Català' },
|
||||
{ value: 'cy', label: 'cy — Cymraeg' }, { value: 'eo', label: 'eo — Esperanto' },
|
||||
{ value: 'fa', label: 'fa — فارسی' }, { value: 'ga', label: 'ga — Gaeilge' },
|
||||
{ value: 'gl', label: 'gl — Galego' }, { value: 'gu', label: 'gu — ગુજરાતી' },
|
||||
{ value: 'ha', label: 'ha — Hausa' }, { value: 'is', label: 'is — Íslenska' },
|
||||
{ value: 'jv', label: 'jv — Basa Jawa' }, { value: 'ka', label: 'ka — ქართული' },
|
||||
{ value: 'kk', label: 'kk — Қазақ' }, { value: 'km', label: 'km — ខ្មែរ' },
|
||||
{ value: 'kn', label: 'kn — ಕನ್ನಡ' }, { value: 'ku', label: 'ku — Kurdî' },
|
||||
{ value: 'ky', label: 'ky — Кыргызча' }, { value: 'la', label: 'la — Latina' },
|
||||
{ value: 'lb', label: 'lb — Lëtzebuergesch' }, { value: 'lo', label: 'lo — ລາວ' },
|
||||
{ value: 'mk', label: 'mk — Македонски' }, { value: 'ml', label: 'ml — മലയാളം' },
|
||||
{ value: 'mn', label: 'mn — Монгол' }, { value: 'mr', label: 'mr — मराठी' },
|
||||
{ value: 'mt', label: 'mt — Malti' }, { value: 'my', label: 'my — မြန်မာ' },
|
||||
{ value: 'ne', label: 'ne — नेपाली' }, { value: 'or', label: 'or — ଓଡ଼ିଆ' },
|
||||
{ value: 'pa', label: 'pa — ਪੰਜਾਬੀ' }, { value: 'ps', label: 'ps — پښتو' },
|
||||
{ value: 'si', label: 'si — සිංහල' }, { value: 'so', label: 'so — Soomaali' },
|
||||
{ value: 'sr', label: 'sr — Српски' }, { value: 'su', label: 'su — Basa Sunda' },
|
||||
{ value: 'ta', label: 'ta — தமிழ்' }, { value: 'te', label: 'te — తెలుగు' },
|
||||
{ value: 'tg', label: 'tg — Тоҷикӣ' }, { value: 'tk', label: 'tk — Türkmen' },
|
||||
{ value: 'ur', label: 'ur — اردو' }, { value: 'uz', label: 'uz — Oʻzbek' },
|
||||
{ value: 'yo', label: 'yo — Yorùbá' }, { value: 'zu', label: 'zu — isiZulu' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Progress overlay component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _ProgressOverlay: React.FC<{ progress: ProgressInfo }> = ({ progress }) => {
|
||||
const pct = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'rgba(var(--bg-rgb, 255,255,255), 0.85)',
|
||||
backdropFilter: 'blur(2px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 50,
|
||||
borderRadius: 'var(--border-radius, 8px)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--card-bg, #fff)',
|
||||
border: '1px solid var(--border-color, #ddd)',
|
||||
borderRadius: 'var(--border-radius, 8px)',
|
||||
padding: '2rem 2.5rem',
|
||||
minWidth: 340,
|
||||
maxWidth: 480,
|
||||
boxShadow: '0 4px 24px rgba(0,0,0,0.12)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<p style={{ fontWeight: 600, fontSize: '1.05rem', marginBottom: '0.75rem' }}>
|
||||
{progress.message}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 8,
|
||||
background: 'var(--border-color, #e2e8f0)',
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
marginBottom: '0.5rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
height: '100%',
|
||||
background: progress.error
|
||||
? 'var(--error-color, #c53030)'
|
||||
: 'var(--primary-color, #3182ce)',
|
||||
borderRadius: 4,
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p style={{ fontSize: '0.85rem', opacity: 0.7 }}>
|
||||
{progress.current} / {progress.total}
|
||||
{progress.done && !progress.error && ' — fertig'}
|
||||
</p>
|
||||
{progress.error && (
|
||||
<p style={{ color: 'var(--error-color, #c53030)', fontSize: '0.85rem', marginTop: '0.5rem' }}>
|
||||
{progress.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const AdminLanguagesPage: React.FC = () => {
|
||||
const { t, reloadLanguage, refreshAvailableLanguages } = useLanguage();
|
||||
const { confirm, ConfirmDialog } = useConfirm();
|
||||
const [rows, setRows] = useState<LangRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [addCode, setAddCode] = useState('it');
|
||||
const [addLabel, setAddLabel] = useState('Italiano');
|
||||
const [addCode, setAddCode] = useState('');
|
||||
const [progress, setProgress] = useState<ProgressInfo | null>(null);
|
||||
const busyRef = useRef(false);
|
||||
|
||||
const _load = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -58,7 +177,7 @@ export const AdminLanguagesPage: React.FC = () => {
|
|||
id: r.code,
|
||||
label: r.label || r.code,
|
||||
status: r.status || '',
|
||||
keysCount: r.keysCount ?? 0,
|
||||
entriesCount: r.entriesCount ?? 0,
|
||||
})),
|
||||
);
|
||||
} catch (e: any) {
|
||||
|
|
@ -72,35 +191,176 @@ export const AdminLanguagesPage: React.FC = () => {
|
|||
_load();
|
||||
}, [_load]);
|
||||
|
||||
const _updateOne = async (code: string) => {
|
||||
const existingCodes = useMemo(() => new Set(rows.map((r) => r.id)), [rows]);
|
||||
|
||||
const addChoices = useMemo(() => {
|
||||
const available = _isoChoices.filter((c) => !existingCodes.has(c.value));
|
||||
available.sort((a, b) => {
|
||||
const aPrio = _PRIORITY_CODES.indexOf(a.value);
|
||||
const bPrio = _PRIORITY_CODES.indexOf(b.value);
|
||||
if (aPrio !== -1 && bPrio !== -1) return aPrio - bPrio;
|
||||
if (aPrio !== -1) return -1;
|
||||
if (bPrio !== -1) return 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
});
|
||||
return available;
|
||||
}, [existingCodes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (addChoices.length > 0 && (!addCode || !addChoices.find((c) => c.value === addCode))) {
|
||||
setAddCode(addChoices[0].value);
|
||||
}
|
||||
}, [addChoices, addCode]);
|
||||
|
||||
const _fetchI18nEntriesFromBundle = useCallback(async (): Promise<any[]> => {
|
||||
const base = import.meta.env.BASE_URL || '/';
|
||||
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
|
||||
const res = await fetch(`${normalizedBase}i18n-keys.json`);
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
t('i18n-keys.json nicht gefunden. Bitte Frontend neu bauen oder Dev-Server starten.'),
|
||||
);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(t('Ungültiges i18n-keys.json'));
|
||||
}
|
||||
return data;
|
||||
}, [t]);
|
||||
|
||||
// --- Actions with progress ------------------------------------------------
|
||||
|
||||
const _syncXx = async () => {
|
||||
if (busyRef.current) return;
|
||||
busyRef.current = true;
|
||||
setError(null);
|
||||
setProgress({ message: t('Basisset wird eingelesen…'), current: 0, total: 1 });
|
||||
try {
|
||||
await api.put(`/api/i18n/sets/${encodeURIComponent(code)}`);
|
||||
const entries = await _fetchI18nEntriesFromBundle();
|
||||
const res = await api.put('/api/i18n/sets/sync-xx', { entries });
|
||||
const d = res.data || {};
|
||||
const addedCount = d.added?.length ?? 0;
|
||||
const removedCount = d.removed?.length ?? 0;
|
||||
const entriesCount = d.entriesCount ?? 0;
|
||||
setProgress({
|
||||
message: t('Basisset synchronisiert: {added} neu, {removed} entfernt, {total} Einträge.', {
|
||||
added: String(addedCount),
|
||||
removed: String(removedCount),
|
||||
total: String(entriesCount),
|
||||
}),
|
||||
current: 1,
|
||||
total: 1,
|
||||
done: true,
|
||||
});
|
||||
await _load();
|
||||
await refreshAvailableLanguages();
|
||||
await reloadLanguage();
|
||||
} catch (e: any) {
|
||||
setError(e.response?.data?.detail || e.message);
|
||||
const msg = e.response?.data?.detail || e.message;
|
||||
setProgress({ message: t('Fehler beim Einlesen'), current: 0, total: 1, error: msg, done: true });
|
||||
setError(msg);
|
||||
} finally {
|
||||
setTimeout(() => { setProgress(null); busyRef.current = false; }, 2500);
|
||||
}
|
||||
};
|
||||
|
||||
const _updateOne = async (code: string) => {
|
||||
if (busyRef.current) return;
|
||||
busyRef.current = true;
|
||||
setError(null);
|
||||
const label = rows.find((r) => r.id === code)?.label || code;
|
||||
setProgress({ message: t('Aktualisiere {lang}…', { lang: label }), current: 0, total: 1 });
|
||||
try {
|
||||
await api.put(`/api/i18n/sets/${encodeURIComponent(code)}`);
|
||||
setProgress({ message: t('{lang} aktualisiert.', { lang: label }), current: 1, total: 1, done: true });
|
||||
await _load();
|
||||
await refreshAvailableLanguages();
|
||||
await reloadLanguage();
|
||||
} catch (e: any) {
|
||||
const msg = e.response?.data?.detail || e.message;
|
||||
setProgress({ message: t('Fehler bei {lang}', { lang: label }), current: 0, total: 1, error: msg, done: true });
|
||||
setError(msg);
|
||||
} finally {
|
||||
setTimeout(() => { setProgress(null); busyRef.current = false; }, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const _updateAll = async () => {
|
||||
const ok = await confirm(t('Alle Nicht-Standard-Sprachsets jetzt mit dem deutschen Master synchronisieren?'), {
|
||||
if (busyRef.current) return;
|
||||
const ok = await confirm(t('Alle Sprachsets jetzt mit dem Basisset synchronisieren und per KI aktualisieren?'), {
|
||||
confirmLabel: t('Alle aktualisieren'),
|
||||
cancelLabel: t('Abbrechen'),
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
busyRef.current = true;
|
||||
setError(null);
|
||||
|
||||
const langCodes = rows.filter((r) => r.id !== 'xx').map((r) => r.id);
|
||||
const totalSteps = 1 + langCodes.length;
|
||||
let step = 0;
|
||||
|
||||
setProgress({ message: t('Basisset wird eingelesen…'), current: step, total: totalSteps });
|
||||
|
||||
try {
|
||||
await api.put('/api/i18n/sets/update-all');
|
||||
await _load();
|
||||
await refreshAvailableLanguages();
|
||||
await reloadLanguage();
|
||||
const entries = await _fetchI18nEntriesFromBundle();
|
||||
await api.put('/api/i18n/sets/sync-xx', { entries });
|
||||
step++;
|
||||
setProgress({ message: t('Basisset synchronisiert.'), current: step, total: totalSteps });
|
||||
} catch (e: any) {
|
||||
setError(e.response?.data?.detail || e.message);
|
||||
const msg = e.response?.data?.detail || e.message;
|
||||
setProgress({ message: t('Fehler beim Basisset'), current: step, total: totalSteps, error: msg, done: true });
|
||||
setError(msg);
|
||||
setTimeout(() => { setProgress(null); busyRef.current = false; }, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
for (const code of langCodes) {
|
||||
const label = rows.find((r) => r.id === code)?.label || code;
|
||||
setProgress({
|
||||
message: t('Aktualisiere {lang}… ({n}/{total})', { lang: label, n: String(step + 1), total: String(totalSteps) }),
|
||||
current: step,
|
||||
total: totalSteps,
|
||||
});
|
||||
try {
|
||||
await api.put(`/api/i18n/sets/${encodeURIComponent(code)}`);
|
||||
} catch (e: any) {
|
||||
errors.push(`${code}: ${e.response?.data?.detail || e.message}`);
|
||||
}
|
||||
step++;
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
const errMsg = errors.join('; ');
|
||||
setProgress({
|
||||
message: t('{ok} von {total} Sprachen aktualisiert.', { ok: String(langCodes.length - errors.length), total: String(langCodes.length) }),
|
||||
current: step,
|
||||
total: totalSteps,
|
||||
error: errMsg,
|
||||
done: true,
|
||||
});
|
||||
setError(errMsg);
|
||||
} else {
|
||||
setProgress({
|
||||
message: t('Alle {count} Sprachen erfolgreich aktualisiert.', { count: String(langCodes.length) }),
|
||||
current: totalSteps,
|
||||
total: totalSteps,
|
||||
done: true,
|
||||
});
|
||||
}
|
||||
|
||||
await _load();
|
||||
await refreshAvailableLanguages();
|
||||
await reloadLanguage();
|
||||
setTimeout(() => { setProgress(null); busyRef.current = false; }, errors.length > 0 ? 5000 : 2500);
|
||||
};
|
||||
|
||||
// --- Other actions (unchanged logic, but with busy guard) -----------------
|
||||
|
||||
const _delete = async (code: string) => {
|
||||
if (code === 'de') return;
|
||||
if (busyRef.current) return;
|
||||
if (code === 'xx' || code === 'de') return;
|
||||
const ok = await confirm(t('Sprachset {code} wirklich löschen?', { code }), {
|
||||
confirmLabel: t('Löschen'),
|
||||
cancelLabel: t('Abbrechen'),
|
||||
|
|
@ -137,19 +397,27 @@ export const AdminLanguagesPage: React.FC = () => {
|
|||
};
|
||||
|
||||
const _add = async () => {
|
||||
if (busyRef.current) return;
|
||||
const code = String(addCode).trim().toLowerCase();
|
||||
const label = String(addLabel).trim();
|
||||
if (!code) return;
|
||||
const go = await confirm(
|
||||
t('Die Erstellung einer neuen Sprache kann AI-Guthaben auf Ihrem Mandats-Pool belasten. Fortfahren?'),
|
||||
{ confirmLabel: t('Fortfahren'), cancelLabel: t('Abbrechen') },
|
||||
);
|
||||
if (!go) return;
|
||||
busyRef.current = true;
|
||||
setProgress({ message: t('Sprache wird erstellt…'), current: 0, total: 1 });
|
||||
try {
|
||||
await api.post('/api/i18n/sets', { code, label });
|
||||
await api.post('/api/i18n/sets', { code });
|
||||
setProgress({ message: t('Sprache erstellt. KI-Übersetzung läuft im Hintergrund.'), current: 1, total: 1, done: true });
|
||||
await _load();
|
||||
await refreshAvailableLanguages();
|
||||
} catch (e: any) {
|
||||
setError(e.response?.data?.detail || e.message);
|
||||
const msg = e.response?.data?.detail || e.message;
|
||||
setProgress({ message: t('Fehler'), current: 0, total: 1, error: msg, done: true });
|
||||
setError(msg);
|
||||
} finally {
|
||||
setTimeout(() => { setProgress(null); busyRef.current = false; }, 2500);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -171,6 +439,7 @@ export const AdminLanguagesPage: React.FC = () => {
|
|||
};
|
||||
|
||||
const _importFile = async () => {
|
||||
if (busyRef.current) return;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.json';
|
||||
|
|
@ -182,6 +451,8 @@ export const AdminLanguagesPage: React.FC = () => {
|
|||
{ confirmLabel: t('Importieren'), cancelLabel: t('Abbrechen') },
|
||||
);
|
||||
if (!ok) return;
|
||||
busyRef.current = true;
|
||||
setProgress({ message: t('Importiere…'), current: 0, total: 1 });
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
|
@ -190,29 +461,37 @@ export const AdminLanguagesPage: React.FC = () => {
|
|||
});
|
||||
const d = res.data || {};
|
||||
setError(null);
|
||||
setProgress({
|
||||
message: t('Import abgeschlossen: {created} erstellt, {updated} aktualisiert.', {
|
||||
created: String(d.created?.length ?? 0),
|
||||
updated: String(d.updated?.length ?? 0),
|
||||
}),
|
||||
current: 1,
|
||||
total: 1,
|
||||
done: true,
|
||||
});
|
||||
await _load();
|
||||
await refreshAvailableLanguages();
|
||||
await reloadLanguage();
|
||||
alert(t('Import abgeschlossen: {created} erstellt, {updated} aktualisiert.', {
|
||||
created: String(d.created?.length ?? 0),
|
||||
updated: String(d.updated?.length ?? 0),
|
||||
}));
|
||||
} catch (e: any) {
|
||||
setError(e.response?.data?.detail || e.message);
|
||||
const msg = e.response?.data?.detail || e.message;
|
||||
setProgress({ message: t('Import fehlgeschlagen'), current: 0, total: 1, error: msg, done: true });
|
||||
setError(msg);
|
||||
} finally {
|
||||
setTimeout(() => { setProgress(null); busyRef.current = false; }, 2500);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const existingCodes = new Set(rows.map((r) => r.id));
|
||||
const addChoices = _isoChoices.filter((c) => !existingCodes.has(c.value));
|
||||
const isBusy = progress !== null;
|
||||
|
||||
return (
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`} style={{ gap: '1rem' }}>
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`} style={{ gap: '1rem', position: 'relative' }}>
|
||||
<header>
|
||||
<h1 className={styles.pageTitle}>{t('UI-Sprachen')}</h1>
|
||||
<p className={styles.pageSubtitle}>{t('Globale Sprachsets verwalten (SysAdmin).')}</p>
|
||||
{error && (
|
||||
{error && !progress && (
|
||||
<p style={{ color: 'var(--error-color, #c53030)' }}>
|
||||
{error}
|
||||
</p>
|
||||
|
|
@ -220,13 +499,13 @@ export const AdminLanguagesPage: React.FC = () => {
|
|||
</header>
|
||||
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', alignItems: 'center' }}>
|
||||
<button type="button" className={styles.primaryButton} onClick={_updateAll}>
|
||||
<button type="button" className={styles.primaryButton} onClick={_updateAll} disabled={isBusy}>
|
||||
{t('Alle aktualisieren')}
|
||||
</button>
|
||||
<button type="button" className={styles.secondaryButton} onClick={_exportAll}>
|
||||
<button type="button" className={styles.secondaryButton} onClick={_exportAll} disabled={isBusy}>
|
||||
<FaFileExport /> {t('Export')}
|
||||
</button>
|
||||
<button type="button" className={styles.secondaryButton} onClick={_importFile}>
|
||||
<button type="button" className={styles.secondaryButton} onClick={_importFile} disabled={isBusy}>
|
||||
<FaFileImport /> {t('Import')}
|
||||
</button>
|
||||
<span style={{ borderLeft: '1px solid var(--border-color)', height: '1.5rem' }} />
|
||||
|
|
@ -235,6 +514,7 @@ export const AdminLanguagesPage: React.FC = () => {
|
|||
value={addCode}
|
||||
onChange={(e) => setAddCode(e.target.value)}
|
||||
style={{ padding: '0.35rem 0.5rem' }}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{addChoices.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
|
|
@ -242,18 +522,12 @@ export const AdminLanguagesPage: React.FC = () => {
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
style={{ minWidth: 180, padding: '0.35rem 0.5rem' }}
|
||||
value={addLabel}
|
||||
onChange={(e) => setAddLabel(e.target.value)}
|
||||
placeholder={t('Anzeigename')}
|
||||
/>
|
||||
<button type="button" className={styles.primaryButton} onClick={_add} disabled={addChoices.length === 0}>
|
||||
<button type="button" className={styles.primaryButton} onClick={_add} disabled={addChoices.length === 0 || isBusy}>
|
||||
{t('Hinzufügen')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||
<FormGeneratorTable
|
||||
data={rows}
|
||||
columns={_columns}
|
||||
|
|
@ -261,12 +535,19 @@ export const AdminLanguagesPage: React.FC = () => {
|
|||
pagination={false}
|
||||
selectable={false}
|
||||
customActions={[
|
||||
{
|
||||
id: 'sync-xx',
|
||||
title: t('UI-Keys einlesen'),
|
||||
icon: <FaSync />,
|
||||
onClick: () => _syncXx(),
|
||||
visible: (row: LangRow) => row.id === 'xx',
|
||||
},
|
||||
{
|
||||
id: 'upd',
|
||||
title: t('Aktualisieren'),
|
||||
icon: <FaRedo />,
|
||||
onClick: (row: LangRow) => _updateOne(row.id),
|
||||
visible: (row: LangRow) => row.id !== 'de',
|
||||
visible: (row: LangRow) => row.id !== 'xx',
|
||||
},
|
||||
{
|
||||
id: 'dl',
|
||||
|
|
@ -279,11 +560,13 @@ export const AdminLanguagesPage: React.FC = () => {
|
|||
title: t('Löschen'),
|
||||
icon: <FaTrash />,
|
||||
onClick: (row: LangRow) => _delete(row.id),
|
||||
visible: (row: LangRow) => row.id !== 'de',
|
||||
visible: (row: LangRow) => row.id !== 'xx' && row.id !== 'de',
|
||||
},
|
||||
]}
|
||||
emptyMessage={t('Keine Einträge')}
|
||||
/>
|
||||
|
||||
{progress && <_ProgressOverlay progress={progress} />}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog />
|
||||
|
|
|
|||
|
|
@ -1,83 +1,258 @@
|
|||
/**
|
||||
* ToolActivityLog -- Real-time tool call activity display.
|
||||
*
|
||||
* Renders tool calls in a human-readable format:
|
||||
* - Friendly tool names instead of internal identifiers
|
||||
* - Args filtered to hide UUIDs/internal codes on success
|
||||
* - Full details shown on error for debugging
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import type { ToolActivity } from './useWorkspace';
|
||||
|
||||
interface ToolActivityLogProps {
|
||||
activities: ToolActivity[];
|
||||
}
|
||||
|
||||
const _UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
const _TOOL_LABELS: Record<string, string> = {
|
||||
browseTable: 'Tabelle durchsuchen',
|
||||
queryTable: 'Tabelle abfragen',
|
||||
aggregateTable: 'Tabelle aggregieren',
|
||||
browseContainer: 'Datei durchsuchen',
|
||||
readContentObjects: 'Inhalte lesen',
|
||||
extractContainerItem: 'Element extrahieren',
|
||||
queryFeatureInstance: 'Feature abfragen',
|
||||
requestToolbox: 'Toolbox anfordern',
|
||||
searchDocuments: 'Dokumente suchen',
|
||||
getFileInfo: 'Datei-Info abrufen',
|
||||
listFiles: 'Dateien auflisten',
|
||||
listTables: 'Tabellen auflisten',
|
||||
getTableSchema: 'Tabellenschema abrufen',
|
||||
createRecord: 'Datensatz erstellen',
|
||||
updateRecord: 'Datensatz aktualisieren',
|
||||
deleteRecord: 'Datensatz löschen',
|
||||
};
|
||||
|
||||
const _HIDDEN_ARG_KEYS = new Set([
|
||||
'mandateId', 'userId', 'featureInstanceId', 'workflowId', 'sessionId',
|
||||
]);
|
||||
|
||||
function _isInternalValue(v: unknown): boolean {
|
||||
if (typeof v !== 'string') return false;
|
||||
if (_UUID_RE.test(v)) return true;
|
||||
if (v.length > 60 && !v.includes(' ')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function _formatArgs(args: Record<string, any>, isError: boolean): string {
|
||||
const parts: string[] = [];
|
||||
for (const [k, v] of Object.entries(args)) {
|
||||
if (!isError && _HIDDEN_ARG_KEYS.has(k)) continue;
|
||||
if (!isError && _isInternalValue(v)) continue;
|
||||
|
||||
let display: string;
|
||||
if (typeof v === 'string') {
|
||||
display = v.length > 80 ? v.slice(0, 77) + '...' : v;
|
||||
} else if (typeof v === 'number' || typeof v === 'boolean') {
|
||||
display = String(v);
|
||||
} else if (v === null || v === undefined) {
|
||||
continue;
|
||||
} else {
|
||||
const json = JSON.stringify(v);
|
||||
display = json.length > 80 ? json.slice(0, 77) + '...' : json;
|
||||
}
|
||||
parts.push(`${k}: ${display}`);
|
||||
}
|
||||
return parts.join(', ') || (isError ? JSON.stringify(args) : '');
|
||||
}
|
||||
|
||||
function _formatResult(result: string): string {
|
||||
if (!result) return '';
|
||||
const trimmed = result.trim();
|
||||
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {
|
||||
return `${parsed.length} Ergebnisse`;
|
||||
}
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
const keys = Object.keys(parsed);
|
||||
if (parsed.count !== undefined) return `${parsed.count} Einträge`;
|
||||
if (parsed.total !== undefined) return `${parsed.total} Einträge`;
|
||||
if (parsed.rows && Array.isArray(parsed.rows)) return `${parsed.rows.length} Zeilen`;
|
||||
if (parsed.data && Array.isArray(parsed.data)) return `${parsed.data.length} Einträge`;
|
||||
if (parsed.result !== undefined) {
|
||||
const r = String(parsed.result);
|
||||
return r.length > 120 ? r.slice(0, 117) + '...' : r;
|
||||
}
|
||||
if (keys.length <= 3) {
|
||||
return keys.map((k) => `${k}: ${String(parsed[k]).slice(0, 40)}`).join(', ');
|
||||
}
|
||||
return `Objekt (${keys.length} Felder)`;
|
||||
}
|
||||
} catch {
|
||||
// not valid JSON
|
||||
}
|
||||
}
|
||||
|
||||
return trimmed.length > 150 ? trimmed.slice(0, 147) + '...' : trimmed;
|
||||
}
|
||||
|
||||
function _getToolLabel(toolName: string): string {
|
||||
return _TOOL_LABELS[toolName] || toolName;
|
||||
}
|
||||
|
||||
function _getStatusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'calling': return 'läuft';
|
||||
case 'success': return 'OK';
|
||||
case 'error': return 'Fehler';
|
||||
default: return status;
|
||||
}
|
||||
}
|
||||
|
||||
export const ToolActivityLog: React.FC<ToolActivityLogProps> = ({ activities }) => {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
if (!activities.length) {
|
||||
return (
|
||||
<div style={{ padding: 16, textAlign: 'center', color: '#999', fontSize: 12 }}>
|
||||
No tool activity yet
|
||||
<div style={{ padding: 16, textAlign: 'center', color: 'var(--color-text-secondary, #999)', fontSize: 12 }}>
|
||||
Noch keine Aktivität
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 8 }}>
|
||||
{activities.map(activity => (
|
||||
<div
|
||||
key={activity.id}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
marginBottom: 6,
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
border: `1px solid ${
|
||||
activity.status === 'calling'
|
||||
? '#ffc107'
|
||||
: activity.status === 'success'
|
||||
? '#4caf50'
|
||||
: '#f44336'
|
||||
}30`,
|
||||
background: activity.status === 'calling'
|
||||
? '#fff8e1'
|
||||
: activity.status === 'success'
|
||||
? '#e8f5e9'
|
||||
: '#ffebee',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 600 }}>{activity.toolName}</span>
|
||||
<span style={{
|
||||
fontSize: 10,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 3,
|
||||
{activities.map(activity => {
|
||||
const isError = activity.status === 'error';
|
||||
const isExpanded = expandedId === activity.id;
|
||||
const friendlyName = _getToolLabel(activity.toolName);
|
||||
const argsText = activity.args && Object.keys(activity.args).length > 0
|
||||
? _formatArgs(activity.args, isError)
|
||||
: '';
|
||||
const resultText = activity.result ? _formatResult(activity.result) : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={activity.id}
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
marginBottom: 6,
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
border: `1px solid ${
|
||||
activity.status === 'calling'
|
||||
? 'var(--color-warning, #ffc107)'
|
||||
: activity.status === 'success'
|
||||
? 'var(--color-success, #4caf50)'
|
||||
: 'var(--color-error, #f44336)'
|
||||
}30`,
|
||||
background: activity.status === 'calling'
|
||||
? '#ffc107'
|
||||
? 'rgba(255, 193, 7, 0.08)'
|
||||
: activity.status === 'success'
|
||||
? '#4caf50'
|
||||
: '#f44336',
|
||||
color: '#fff',
|
||||
}}>
|
||||
{activity.status}
|
||||
</span>
|
||||
? 'rgba(76, 175, 80, 0.06)'
|
||||
: 'rgba(244, 67, 54, 0.06)',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.15s ease',
|
||||
}}
|
||||
onClick={() => setExpandedId(isExpanded ? null : activity.id)}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
{friendlyName}
|
||||
{friendlyName !== activity.toolName && (
|
||||
<span style={{ fontWeight: 400, opacity: 0.5, marginLeft: 6, fontSize: 10 }}>
|
||||
{activity.toolName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 10,
|
||||
padding: '1px 6px',
|
||||
borderRadius: 3,
|
||||
background: activity.status === 'calling'
|
||||
? 'var(--color-warning, #ffc107)'
|
||||
: activity.status === 'success'
|
||||
? 'var(--color-success, #4caf50)'
|
||||
: 'var(--color-error, #f44336)',
|
||||
color: '#fff',
|
||||
}}>
|
||||
{_getStatusLabel(activity.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{argsText && (
|
||||
<div style={{ marginTop: 4, color: 'var(--color-text-secondary, #666)', fontSize: 11 }}>
|
||||
{argsText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resultText && !isError && (
|
||||
<div style={{
|
||||
marginTop: 4,
|
||||
color: 'var(--color-success, #388e3c)',
|
||||
fontSize: 11,
|
||||
maxHeight: isExpanded ? 'none' : 40,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{resultText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activity.error && (
|
||||
<div style={{ marginTop: 4, color: 'var(--color-error, #c62828)', fontSize: 11 }}>
|
||||
{activity.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpanded && activity.args && Object.keys(activity.args).length > 0 && (
|
||||
<details open style={{ marginTop: 6 }}>
|
||||
<summary style={{ fontSize: 10, color: 'var(--color-text-secondary, #888)', cursor: 'pointer' }}>
|
||||
Alle Parameter
|
||||
</summary>
|
||||
<pre style={{
|
||||
fontSize: 10,
|
||||
margin: '4px 0 0',
|
||||
padding: 6,
|
||||
background: 'rgba(0,0,0,0.04)',
|
||||
borderRadius: 4,
|
||||
overflow: 'auto',
|
||||
maxHeight: 200,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}>
|
||||
{JSON.stringify(activity.args, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{isExpanded && activity.result && (
|
||||
<details open style={{ marginTop: 4 }}>
|
||||
<summary style={{ fontSize: 10, color: 'var(--color-text-secondary, #888)', cursor: 'pointer' }}>
|
||||
Vollständiges Ergebnis
|
||||
</summary>
|
||||
<pre style={{
|
||||
fontSize: 10,
|
||||
margin: '4px 0 0',
|
||||
padding: 6,
|
||||
background: 'rgba(0,0,0,0.04)',
|
||||
borderRadius: 4,
|
||||
overflow: 'auto',
|
||||
maxHeight: 300,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}>
|
||||
{activity.result}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
{activity.args && Object.keys(activity.args).length > 0 && (
|
||||
<div style={{ marginTop: 4, color: '#666', fontSize: 11 }}>
|
||||
{Object.entries(activity.args)
|
||||
.map(([k, v]) => `${k}: ${typeof v === 'string' ? v.slice(0, 50) : JSON.stringify(v)}`)
|
||||
.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{activity.result && (
|
||||
<div style={{ marginTop: 4, color: '#388e3c', fontSize: 11, maxHeight: 60, overflow: 'hidden' }}>
|
||||
{activity.result.slice(0, 200)}
|
||||
{activity.result.length > 200 && '...'}
|
||||
</div>
|
||||
)}
|
||||
{activity.error && (
|
||||
<div style={{ marginTop: 4, color: '#c62828', fontSize: 11 }}>
|
||||
{activity.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,55 +26,42 @@ interface LanguageProviderProps {
|
|||
export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children }) => {
|
||||
const [currentLanguage, setCurrentLanguage] = useState<Language>('de');
|
||||
const [translations, setTranslations] = useState<TranslationKeys>({});
|
||||
const [deTranslations, setDeTranslations] = useState<TranslationKeys>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [availableLanguages, setAvailableLanguages] = useState<I18nCodeInfo[]>([]);
|
||||
|
||||
// Function to load and set a language
|
||||
const loadAndSetLanguage = async (language: Language) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const deKeys = await loadLanguage('de');
|
||||
setDeTranslations(deKeys);
|
||||
const targetKeys =
|
||||
language === 'de' ? deKeys : await loadLanguage(language);
|
||||
const targetKeys = await loadLanguage(language);
|
||||
setTranslations(targetKeys);
|
||||
setCurrentLanguage(language);
|
||||
} catch (error) {
|
||||
console.error('Failed to load language:', error);
|
||||
// Keep current language and translations if loading fails
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load language from user profile on mount
|
||||
useEffect(() => {
|
||||
const initializeLanguage = async () => {
|
||||
let initialLanguage: Language = 'de';
|
||||
|
||||
// Priority 1: Check if user data has language setting (ONLY source of truth!)
|
||||
const userData = getUserDataCache();
|
||||
if (userData?.language && String(userData.language).trim()) {
|
||||
initialLanguage = String(userData.language).trim() as Language;
|
||||
console.log('🌍 Using language from user profile (sessionStorage cache):', initialLanguage);
|
||||
await loadAndSetLanguage(initialLanguage);
|
||||
return;
|
||||
}
|
||||
|
||||
// Priority 2: Detect browser language (fallback only if no user data)
|
||||
const browserLang = navigator.language.split('-')[0] as Language;
|
||||
try {
|
||||
const codes = await fetchAvailableLanguageCodes();
|
||||
const codeSet = new Set(codes.map((c) => c.code));
|
||||
if (codeSet.has(browserLang)) {
|
||||
if (codeSet.has(browserLang) && browserLang !== 'xx') {
|
||||
initialLanguage = browserLang;
|
||||
console.log('🌍 Using browser language as fallback:', initialLanguage);
|
||||
} else {
|
||||
console.log('🌍 Using default language:', initialLanguage);
|
||||
}
|
||||
} catch {
|
||||
console.log('🌍 Using default language:', initialLanguage);
|
||||
// keep default
|
||||
}
|
||||
|
||||
await loadAndSetLanguage(initialLanguage);
|
||||
|
|
@ -82,19 +69,22 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
|
|||
|
||||
initializeLanguage();
|
||||
|
||||
// Listen for user data updates to sync language
|
||||
const handleUserUpdate = () => {
|
||||
const handleUserUpdate = async () => {
|
||||
const userData = getUserDataCache();
|
||||
if (userData?.language && String(userData.language).trim()) {
|
||||
const userLanguage = String(userData.language).trim() as Language;
|
||||
if (userLanguage !== currentLanguage) {
|
||||
console.log('🔄 Syncing language with user data (sessionStorage cache):', userLanguage);
|
||||
loadAndSetLanguage(userLanguage);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const list = await fetchAvailableLanguageCodes();
|
||||
setAvailableLanguages(list.filter((l) => l.code !== 'xx'));
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for user info update events
|
||||
window.addEventListener('userInfoUpdated', handleUserUpdate);
|
||||
|
||||
return () => {
|
||||
|
|
@ -103,15 +93,7 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
|
|||
}, []);
|
||||
|
||||
const setLanguage = async (language: Language) => {
|
||||
// Load the new language immediately for UI
|
||||
await loadAndSetLanguage(language);
|
||||
|
||||
// IMPORTANT: This should ONLY be called after the backend profile is updated
|
||||
// The settings component should:
|
||||
// 1. Update backend user profile with new language
|
||||
// 2. Refetch user data (which includes the new language)
|
||||
// 3. Update sessionStorage cache with new data (via setUserDataCache)
|
||||
// 4. Call this function to sync the UI
|
||||
};
|
||||
|
||||
const reloadLanguage = async () => {
|
||||
|
|
@ -121,7 +103,7 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
|
|||
const refreshAvailableLanguages = useCallback(async () => {
|
||||
try {
|
||||
const list = await fetchAvailableLanguageCodes();
|
||||
setAvailableLanguages(list);
|
||||
setAvailableLanguages(list.filter((l) => l.code !== 'xx'));
|
||||
} catch (e) {
|
||||
console.error('Failed to load language codes:', e);
|
||||
}
|
||||
|
|
@ -151,7 +133,6 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
|
|||
|
||||
const resolved =
|
||||
translations[key] ??
|
||||
deTranslations[key] ??
|
||||
(typeof paramsOrFallback === 'string' ? paramsOrFallback : undefined) ??
|
||||
`[${key}]`;
|
||||
return _applyParams(resolved, params);
|
||||
|
|
@ -180,4 +161,4 @@ export const useLanguage = (): LanguageContextType => {
|
|||
throw new Error('useLanguage must be used within a LanguageProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,97 @@
|
|||
import { defineConfig, loadEnv, Plugin } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { createHtmlPlugin } from 'vite-plugin-html';
|
||||
import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
type I18nEntry = { context: string; key: string; value: string };
|
||||
|
||||
/** Find the nearest enclosing function/component name above a given character offset. */
|
||||
function _findEnclosingComponent(content: string, charOffset: number): string {
|
||||
const reFunc = /(?:export\s+)?(?:const|function)\s+([A-Z_]\w*)/g;
|
||||
let best = '';
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = reFunc.exec(content)) !== null) {
|
||||
if (m.index > charOffset) break;
|
||||
best = m[1];
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Scan all .ts/.tsx under srcRoot for t() calls and return structured entries with context. */
|
||||
function _scanTKeys(srcRoot: string): I18nEntry[] {
|
||||
const keyMap = new Map<string, Set<string>>();
|
||||
const reSingle = /\bt\(\s*'((?:\\.|[^'])+)'\s*(?:,|\))/g;
|
||||
const reDouble = /\bt\(\s*"((?:\\.|[^"])+)"\s*(?:,|\))/g;
|
||||
|
||||
const _addKey = (key: string, relFile: string, content: string, offset: number): void => {
|
||||
const comp = _findEnclosingComponent(content, offset);
|
||||
const ctx = comp ? `${relFile} > ${comp}` : relFile;
|
||||
if (!keyMap.has(key)) keyMap.set(key, new Set());
|
||||
keyMap.get(key)!.add(ctx);
|
||||
};
|
||||
|
||||
const walk = (dir: string): void => {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, ent.name);
|
||||
if (ent.isDirectory()) {
|
||||
walk(full);
|
||||
} else if (/\.(tsx?)$/i.test(ent.name)) {
|
||||
const content = fs.readFileSync(full, 'utf-8');
|
||||
const relFile = path.relative(srcRoot, full).replace(/\\/g, '/');
|
||||
let m: RegExpExecArray | null;
|
||||
reSingle.lastIndex = 0;
|
||||
while ((m = reSingle.exec(content)) !== null) {
|
||||
const raw = m[1].replace(/\\'/g, "'").replace(/\\\\/g, '\\');
|
||||
if (raw) _addKey(raw, relFile, content, m.index);
|
||||
}
|
||||
reDouble.lastIndex = 0;
|
||||
while ((m = reDouble.exec(content)) !== null) {
|
||||
const raw = m[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
if (raw) _addKey(raw, relFile, content, m.index);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(srcRoot);
|
||||
|
||||
const entries: I18nEntry[] = [];
|
||||
for (const [key, contexts] of [...keyMap.entries()].sort((a, b) => a[0].localeCompare(b[0], 'de'))) {
|
||||
entries.push({ context: 'ui', key, value: [...contexts].sort().join(' | ') });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function extractI18nKeys(): Plugin {
|
||||
return {
|
||||
name: 'extract-i18n-keys',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req: IncomingMessage, res: ServerResponse, next: () => void) => {
|
||||
const url = req.url?.split('?')[0] ?? '';
|
||||
if (!url.endsWith('/i18n-keys.json')) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const srcDir = path.join(process.cwd(), 'src');
|
||||
const payload = JSON.stringify(_scanTKeys(srcDir), null, 2);
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.end(payload);
|
||||
});
|
||||
},
|
||||
writeBundle(options) {
|
||||
const outDir = options.dir ?? path.resolve(process.cwd(), 'dist');
|
||||
const srcDir = path.join(process.cwd(), 'src');
|
||||
const entries = _scanTKeys(srcDir);
|
||||
if (!fs.existsSync(outDir)) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(path.join(outDir, 'i18n-keys.json'), `${JSON.stringify(entries)}\n`, 'utf-8');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Custom plugin to serve static HTML files from public directory BEFORE SPA fallback
|
||||
function serveStaticHtml(): Plugin {
|
||||
return {
|
||||
|
|
@ -37,6 +125,7 @@ export default defineConfig(({ mode }) => {
|
|||
plugins: [
|
||||
// Serve static HTML files
|
||||
serveStaticHtml(),
|
||||
extractI18nKeys(),
|
||||
react(),
|
||||
createHtmlPlugin({
|
||||
// Only process main index.html, not public static files
|
||||
|
|
|
|||
Loading…
Reference in a new issue