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 {
|
.table thead tr {
|
||||||
background: var(--color-bg);
|
background: var(--table-header-bg, rgba(0, 0, 0, 0.03));
|
||||||
}
|
}
|
||||||
|
|
||||||
.th {
|
.th {
|
||||||
background: var(--color-bg, #f8fafc);
|
background: var(--table-header-bg, rgba(0, 0, 0, 0.03));
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -148,7 +148,7 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
border-bottom: 1px solid var(--color-border, #e2e8f0);
|
border-bottom: 2px solid var(--color-border, #e2e8f0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.th.actionsColumn {
|
.th.actionsColumn {
|
||||||
|
|
@ -161,7 +161,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.th.sortable:hover {
|
.th.sortable:hover {
|
||||||
background: var(--color-gray-disabled, #f1f5f9);
|
background: rgba(0, 0, 0, 0.06);
|
||||||
color: var(--color-text, #334155);
|
color: var(--color-text, #334155);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -380,7 +380,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
thead .selectColumn {
|
thead .selectColumn {
|
||||||
background: var(--color-bg);
|
background: var(--table-header-bg, rgba(0, 0, 0, 0.03));
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody .selectColumn {
|
tbody .selectColumn {
|
||||||
|
|
@ -431,7 +431,7 @@ tbody .selectColumn {
|
||||||
}
|
}
|
||||||
|
|
||||||
thead .actionsColumn {
|
thead .actionsColumn {
|
||||||
background: var(--color-bg);
|
background: var(--table-header-bg, rgba(0, 0, 0, 0.03));
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody .actionsColumn {
|
tbody .actionsColumn {
|
||||||
|
|
@ -766,8 +766,22 @@ tbody .actionsColumn {
|
||||||
|
|
||||||
/* Dark theme */
|
/* Dark theme */
|
||||||
@media (prefers-color-scheme: dark) {
|
@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 {
|
.th.sortable:hover {
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tr:hover {
|
.tr:hover {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,13 @@ export interface I18nCodeInfo {
|
||||||
label?: string;
|
label?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
keysCount?: number;
|
entriesCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface I18nEntryRaw {
|
||||||
|
context?: string;
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAvailableLanguageCodes(): Promise<I18nCodeInfo[]> {
|
export async function fetchAvailableLanguageCodes(): Promise<I18nCodeInfo[]> {
|
||||||
|
|
@ -27,8 +33,21 @@ export const loadLanguage = async (language: Language): Promise<TranslationKeys>
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to load language ${code}: ${res.status}`);
|
throw new Error(`Failed to load language ${code}: ${res.status}`);
|
||||||
}
|
}
|
||||||
const data = (await res.json()) as { keys?: TranslationKeys };
|
const data = await res.json();
|
||||||
return data.keys ?? {};
|
|
||||||
|
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';
|
export type { Language, TranslationKeys } from './types';
|
||||||
|
|
|
||||||
|
|
@ -439,7 +439,7 @@ const NeutralizationMappingsTab: React.FC = () => {
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export const SettingsPage: 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 { user: currentUser, refetch: refetchUser } = useCurrentUser();
|
||||||
const { updateUser } = useUser();
|
const { updateUser } = useUser();
|
||||||
|
|
||||||
|
|
@ -449,6 +449,12 @@ export const SettingsPage: React.FC = () => {
|
||||||
const [isSavingLanguage, setIsSavingLanguage] = useState(false);
|
const [isSavingLanguage, setIsSavingLanguage] = useState(false);
|
||||||
const [languageError, setLanguageError] = useState<string | null>(null);
|
const [languageError, setLanguageError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (availableLanguages.length === 0) {
|
||||||
|
refreshAvailableLanguages();
|
||||||
|
}
|
||||||
|
}, [availableLanguages.length, refreshAvailableLanguages]);
|
||||||
|
|
||||||
const handleThemeChange = (newTheme: 'light' | 'dark') => {
|
const handleThemeChange = (newTheme: 'light' | 'dark') => {
|
||||||
setTheme(newTheme);
|
setTheme(newTheme);
|
||||||
localStorage.setItem('theme', newTheme);
|
localStorage.setItem('theme', newTheme);
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
* SysAdmin: UI language sets (DB-backed i18n).
|
* SysAdmin: UI language sets (DB-backed i18n).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { FaDownload, FaFileExport, FaFileImport, FaRedo, FaTrash } from 'react-icons/fa';
|
import { FaDownload, FaFileExport, FaFileImport, FaRedo, FaSync, FaTrash } from 'react-icons/fa';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable/FormGeneratorTable';
|
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable/FormGeneratorTable';
|
||||||
import { useConfirm } from '../../hooks/useConfirm';
|
import { useConfirm } from '../../hooks/useConfirm';
|
||||||
|
|
@ -14,38 +14,157 @@ type LangRow = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
status: string;
|
status: string;
|
||||||
keysCount: number;
|
entriesCount: number;
|
||||||
is?: boolean;
|
};
|
||||||
|
|
||||||
|
type ProgressInfo = {
|
||||||
|
message: string;
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
error?: string;
|
||||||
|
done?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const _columns: ColumnConfig[] = [
|
const _columns: ColumnConfig[] = [
|
||||||
{ key: 'id', label: 'Code', type: 'text', sortable: true, filterable: true, width: 90 },
|
{ 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: '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 = [
|
const _PRIORITY_CODES = ['de', 'en', 'fr', 'it'];
|
||||||
{ value: 'it', label: 'it — Italiano' },
|
|
||||||
{ value: 'es', label: 'es — Español' },
|
const _isoChoices: { value: string; label: string }[] = [
|
||||||
{ value: 'pt', label: 'pt — Português' },
|
{ value: 'de', label: 'de — Deutsch' }, { value: 'en', label: 'en — English' },
|
||||||
{ value: 'nl', label: 'nl — Nederlands' },
|
{ value: 'fr', label: 'fr — Français' }, { value: 'it', label: 'it — Italiano' },
|
||||||
{ value: 'pl', label: 'pl — Polski' },
|
{ value: 'es', label: 'es — Español' }, { value: 'pt', label: 'pt — Português' },
|
||||||
{ value: 'cs', label: 'cs — Čeština' },
|
{ value: 'nl', label: 'nl — Nederlands' }, { value: 'pl', label: 'pl — Polski' },
|
||||||
{ value: 'sk', label: 'sk — Slovenčina' },
|
{ value: 'cs', label: 'cs — Čeština' }, { value: 'sk', label: 'sk — Slovenčina' },
|
||||||
{ value: 'sv', label: 'sv — Svenska' },
|
{ value: 'sv', label: 'sv — Svenska' }, { value: 'no', label: 'no — Norsk' },
|
||||||
{ value: 'no', label: 'no — Norsk' },
|
{ value: 'da', label: 'da — Dansk' }, { value: 'fi', label: 'fi — Suomi' },
|
||||||
{ value: 'da', label: 'da — Dansk' },
|
{ 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 = () => {
|
export const AdminLanguagesPage: React.FC = () => {
|
||||||
const { t, reloadLanguage, refreshAvailableLanguages } = useLanguage();
|
const { t, reloadLanguage, refreshAvailableLanguages } = useLanguage();
|
||||||
const { confirm, ConfirmDialog } = useConfirm();
|
const { confirm, ConfirmDialog } = useConfirm();
|
||||||
const [rows, setRows] = useState<LangRow[]>([]);
|
const [rows, setRows] = useState<LangRow[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [addCode, setAddCode] = useState('it');
|
const [addCode, setAddCode] = useState('');
|
||||||
const [addLabel, setAddLabel] = useState('Italiano');
|
const [progress, setProgress] = useState<ProgressInfo | null>(null);
|
||||||
|
const busyRef = useRef(false);
|
||||||
|
|
||||||
const _load = useCallback(async () => {
|
const _load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -58,7 +177,7 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
id: r.code,
|
id: r.code,
|
||||||
label: r.label || r.code,
|
label: r.label || r.code,
|
||||||
status: r.status || '',
|
status: r.status || '',
|
||||||
keysCount: r.keysCount ?? 0,
|
entriesCount: r.entriesCount ?? 0,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -72,35 +191,176 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
_load();
|
_load();
|
||||||
}, [_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 {
|
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 _load();
|
||||||
await refreshAvailableLanguages();
|
await refreshAvailableLanguages();
|
||||||
await reloadLanguage();
|
await reloadLanguage();
|
||||||
} catch (e: any) {
|
} 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 _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'),
|
confirmLabel: t('Alle aktualisieren'),
|
||||||
cancelLabel: t('Abbrechen'),
|
cancelLabel: t('Abbrechen'),
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
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 {
|
try {
|
||||||
await api.put('/api/i18n/sets/update-all');
|
const entries = await _fetchI18nEntriesFromBundle();
|
||||||
await _load();
|
await api.put('/api/i18n/sets/sync-xx', { entries });
|
||||||
await refreshAvailableLanguages();
|
step++;
|
||||||
await reloadLanguage();
|
setProgress({ message: t('Basisset synchronisiert.'), current: step, total: totalSteps });
|
||||||
} catch (e: any) {
|
} 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) => {
|
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 }), {
|
const ok = await confirm(t('Sprachset {code} wirklich löschen?', { code }), {
|
||||||
confirmLabel: t('Löschen'),
|
confirmLabel: t('Löschen'),
|
||||||
cancelLabel: t('Abbrechen'),
|
cancelLabel: t('Abbrechen'),
|
||||||
|
|
@ -137,19 +397,27 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const _add = async () => {
|
const _add = async () => {
|
||||||
|
if (busyRef.current) return;
|
||||||
const code = String(addCode).trim().toLowerCase();
|
const code = String(addCode).trim().toLowerCase();
|
||||||
const label = String(addLabel).trim();
|
if (!code) return;
|
||||||
const go = await confirm(
|
const go = await confirm(
|
||||||
t('Die Erstellung einer neuen Sprache kann AI-Guthaben auf Ihrem Mandats-Pool belasten. Fortfahren?'),
|
t('Die Erstellung einer neuen Sprache kann AI-Guthaben auf Ihrem Mandats-Pool belasten. Fortfahren?'),
|
||||||
{ confirmLabel: t('Fortfahren'), cancelLabel: t('Abbrechen') },
|
{ confirmLabel: t('Fortfahren'), cancelLabel: t('Abbrechen') },
|
||||||
);
|
);
|
||||||
if (!go) return;
|
if (!go) return;
|
||||||
|
busyRef.current = true;
|
||||||
|
setProgress({ message: t('Sprache wird erstellt…'), current: 0, total: 1 });
|
||||||
try {
|
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 _load();
|
||||||
await refreshAvailableLanguages();
|
await refreshAvailableLanguages();
|
||||||
} catch (e: any) {
|
} 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 () => {
|
const _importFile = async () => {
|
||||||
|
if (busyRef.current) return;
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'file';
|
input.type = 'file';
|
||||||
input.accept = '.json';
|
input.accept = '.json';
|
||||||
|
|
@ -182,6 +451,8 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
{ confirmLabel: t('Importieren'), cancelLabel: t('Abbrechen') },
|
{ confirmLabel: t('Importieren'), cancelLabel: t('Abbrechen') },
|
||||||
);
|
);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
busyRef.current = true;
|
||||||
|
setProgress({ message: t('Importiere…'), current: 0, total: 1 });
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
@ -190,29 +461,37 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
});
|
});
|
||||||
const d = res.data || {};
|
const d = res.data || {};
|
||||||
setError(null);
|
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 _load();
|
||||||
await refreshAvailableLanguages();
|
await refreshAvailableLanguages();
|
||||||
await reloadLanguage();
|
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) {
|
} 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();
|
input.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
const existingCodes = new Set(rows.map((r) => r.id));
|
const isBusy = progress !== null;
|
||||||
const addChoices = _isoChoices.filter((c) => !existingCodes.has(c.value));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`} style={{ gap: '1rem' }}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`} style={{ gap: '1rem', position: 'relative' }}>
|
||||||
<header>
|
<header>
|
||||||
<h1 className={styles.pageTitle}>{t('UI-Sprachen')}</h1>
|
<h1 className={styles.pageTitle}>{t('UI-Sprachen')}</h1>
|
||||||
<p className={styles.pageSubtitle}>{t('Globale Sprachsets verwalten (SysAdmin).')}</p>
|
<p className={styles.pageSubtitle}>{t('Globale Sprachsets verwalten (SysAdmin).')}</p>
|
||||||
{error && (
|
{error && !progress && (
|
||||||
<p style={{ color: 'var(--error-color, #c53030)' }}>
|
<p style={{ color: 'var(--error-color, #c53030)' }}>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -220,13 +499,13 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', alignItems: 'center' }}>
|
<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')}
|
{t('Alle aktualisieren')}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={styles.secondaryButton} onClick={_exportAll}>
|
<button type="button" className={styles.secondaryButton} onClick={_exportAll} disabled={isBusy}>
|
||||||
<FaFileExport /> {t('Export')}
|
<FaFileExport /> {t('Export')}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className={styles.secondaryButton} onClick={_importFile}>
|
<button type="button" className={styles.secondaryButton} onClick={_importFile} disabled={isBusy}>
|
||||||
<FaFileImport /> {t('Import')}
|
<FaFileImport /> {t('Import')}
|
||||||
</button>
|
</button>
|
||||||
<span style={{ borderLeft: '1px solid var(--border-color)', height: '1.5rem' }} />
|
<span style={{ borderLeft: '1px solid var(--border-color)', height: '1.5rem' }} />
|
||||||
|
|
@ -235,6 +514,7 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
value={addCode}
|
value={addCode}
|
||||||
onChange={(e) => setAddCode(e.target.value)}
|
onChange={(e) => setAddCode(e.target.value)}
|
||||||
style={{ padding: '0.35rem 0.5rem' }}
|
style={{ padding: '0.35rem 0.5rem' }}
|
||||||
|
disabled={isBusy}
|
||||||
>
|
>
|
||||||
{addChoices.map((c) => (
|
{addChoices.map((c) => (
|
||||||
<option key={c.value} value={c.value}>
|
<option key={c.value} value={c.value}>
|
||||||
|
|
@ -242,18 +522,12 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<input
|
<button type="button" className={styles.primaryButton} onClick={_add} disabled={addChoices.length === 0 || isBusy}>
|
||||||
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}>
|
|
||||||
{t('Hinzufügen')}
|
{t('Hinzufügen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, minHeight: 0 }}>
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={rows}
|
data={rows}
|
||||||
columns={_columns}
|
columns={_columns}
|
||||||
|
|
@ -261,12 +535,19 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
pagination={false}
|
pagination={false}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
customActions={[
|
customActions={[
|
||||||
|
{
|
||||||
|
id: 'sync-xx',
|
||||||
|
title: t('UI-Keys einlesen'),
|
||||||
|
icon: <FaSync />,
|
||||||
|
onClick: () => _syncXx(),
|
||||||
|
visible: (row: LangRow) => row.id === 'xx',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'upd',
|
id: 'upd',
|
||||||
title: t('Aktualisieren'),
|
title: t('Aktualisieren'),
|
||||||
icon: <FaRedo />,
|
icon: <FaRedo />,
|
||||||
onClick: (row: LangRow) => _updateOne(row.id),
|
onClick: (row: LangRow) => _updateOne(row.id),
|
||||||
visible: (row: LangRow) => row.id !== 'de',
|
visible: (row: LangRow) => row.id !== 'xx',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'dl',
|
id: 'dl',
|
||||||
|
|
@ -279,11 +560,13 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
title: t('Löschen'),
|
title: t('Löschen'),
|
||||||
icon: <FaTrash />,
|
icon: <FaTrash />,
|
||||||
onClick: (row: LangRow) => _delete(row.id),
|
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')}
|
emptyMessage={t('Keine Einträge')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{progress && <_ProgressOverlay progress={progress} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|
|
||||||
|
|
@ -1,83 +1,258 @@
|
||||||
/**
|
/**
|
||||||
* ToolActivityLog -- Real-time tool call activity display.
|
* 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';
|
import type { ToolActivity } from './useWorkspace';
|
||||||
|
|
||||||
interface ToolActivityLogProps {
|
interface ToolActivityLogProps {
|
||||||
activities: ToolActivity[];
|
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 }) => {
|
export const ToolActivityLog: React.FC<ToolActivityLogProps> = ({ activities }) => {
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
if (!activities.length) {
|
if (!activities.length) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 16, textAlign: 'center', color: '#999', fontSize: 12 }}>
|
<div style={{ padding: 16, textAlign: 'center', color: 'var(--color-text-secondary, #999)', fontSize: 12 }}>
|
||||||
No tool activity yet
|
Noch keine Aktivität
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 8 }}>
|
<div style={{ padding: 8 }}>
|
||||||
{activities.map(activity => (
|
{activities.map(activity => {
|
||||||
<div
|
const isError = activity.status === 'error';
|
||||||
key={activity.id}
|
const isExpanded = expandedId === activity.id;
|
||||||
style={{
|
const friendlyName = _getToolLabel(activity.toolName);
|
||||||
padding: '8px 10px',
|
const argsText = activity.args && Object.keys(activity.args).length > 0
|
||||||
marginBottom: 6,
|
? _formatArgs(activity.args, isError)
|
||||||
borderRadius: 6,
|
: '';
|
||||||
fontSize: 12,
|
const resultText = activity.result ? _formatResult(activity.result) : '';
|
||||||
border: `1px solid ${
|
|
||||||
activity.status === 'calling'
|
return (
|
||||||
? '#ffc107'
|
<div
|
||||||
: activity.status === 'success'
|
key={activity.id}
|
||||||
? '#4caf50'
|
style={{
|
||||||
: '#f44336'
|
padding: '8px 10px',
|
||||||
}30`,
|
marginBottom: 6,
|
||||||
background: activity.status === 'calling'
|
borderRadius: 6,
|
||||||
? '#fff8e1'
|
fontSize: 12,
|
||||||
: activity.status === 'success'
|
border: `1px solid ${
|
||||||
? '#e8f5e9'
|
activity.status === 'calling'
|
||||||
: '#ffebee',
|
? 'var(--color-warning, #ffc107)'
|
||||||
}}
|
: activity.status === 'success'
|
||||||
>
|
? 'var(--color-success, #4caf50)'
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
: 'var(--color-error, #f44336)'
|
||||||
<span style={{ fontWeight: 600 }}>{activity.toolName}</span>
|
}30`,
|
||||||
<span style={{
|
|
||||||
fontSize: 10,
|
|
||||||
padding: '1px 6px',
|
|
||||||
borderRadius: 3,
|
|
||||||
background: activity.status === 'calling'
|
background: activity.status === 'calling'
|
||||||
? '#ffc107'
|
? 'rgba(255, 193, 7, 0.08)'
|
||||||
: activity.status === 'success'
|
: activity.status === 'success'
|
||||||
? '#4caf50'
|
? 'rgba(76, 175, 80, 0.06)'
|
||||||
: '#f44336',
|
: 'rgba(244, 67, 54, 0.06)',
|
||||||
color: '#fff',
|
cursor: 'pointer',
|
||||||
}}>
|
transition: 'background 0.15s ease',
|
||||||
{activity.status}
|
}}
|
||||||
</span>
|
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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -26,55 +26,42 @@ interface LanguageProviderProps {
|
||||||
export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children }) => {
|
export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children }) => {
|
||||||
const [currentLanguage, setCurrentLanguage] = useState<Language>('de');
|
const [currentLanguage, setCurrentLanguage] = useState<Language>('de');
|
||||||
const [translations, setTranslations] = useState<TranslationKeys>({});
|
const [translations, setTranslations] = useState<TranslationKeys>({});
|
||||||
const [deTranslations, setDeTranslations] = useState<TranslationKeys>({});
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [availableLanguages, setAvailableLanguages] = useState<I18nCodeInfo[]>([]);
|
const [availableLanguages, setAvailableLanguages] = useState<I18nCodeInfo[]>([]);
|
||||||
|
|
||||||
// Function to load and set a language
|
|
||||||
const loadAndSetLanguage = async (language: Language) => {
|
const loadAndSetLanguage = async (language: Language) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const deKeys = await loadLanguage('de');
|
const targetKeys = await loadLanguage(language);
|
||||||
setDeTranslations(deKeys);
|
|
||||||
const targetKeys =
|
|
||||||
language === 'de' ? deKeys : await loadLanguage(language);
|
|
||||||
setTranslations(targetKeys);
|
setTranslations(targetKeys);
|
||||||
setCurrentLanguage(language);
|
setCurrentLanguage(language);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load language:', error);
|
console.error('Failed to load language:', error);
|
||||||
// Keep current language and translations if loading fails
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load language from user profile on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initializeLanguage = async () => {
|
const initializeLanguage = async () => {
|
||||||
let initialLanguage: Language = 'de';
|
let initialLanguage: Language = 'de';
|
||||||
|
|
||||||
// Priority 1: Check if user data has language setting (ONLY source of truth!)
|
|
||||||
const userData = getUserDataCache();
|
const userData = getUserDataCache();
|
||||||
if (userData?.language && String(userData.language).trim()) {
|
if (userData?.language && String(userData.language).trim()) {
|
||||||
initialLanguage = String(userData.language).trim() as Language;
|
initialLanguage = String(userData.language).trim() as Language;
|
||||||
console.log('🌍 Using language from user profile (sessionStorage cache):', initialLanguage);
|
|
||||||
await loadAndSetLanguage(initialLanguage);
|
await loadAndSetLanguage(initialLanguage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: Detect browser language (fallback only if no user data)
|
|
||||||
const browserLang = navigator.language.split('-')[0] as Language;
|
const browserLang = navigator.language.split('-')[0] as Language;
|
||||||
try {
|
try {
|
||||||
const codes = await fetchAvailableLanguageCodes();
|
const codes = await fetchAvailableLanguageCodes();
|
||||||
const codeSet = new Set(codes.map((c) => c.code));
|
const codeSet = new Set(codes.map((c) => c.code));
|
||||||
if (codeSet.has(browserLang)) {
|
if (codeSet.has(browserLang) && browserLang !== 'xx') {
|
||||||
initialLanguage = browserLang;
|
initialLanguage = browserLang;
|
||||||
console.log('🌍 Using browser language as fallback:', initialLanguage);
|
|
||||||
} else {
|
|
||||||
console.log('🌍 Using default language:', initialLanguage);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
console.log('🌍 Using default language:', initialLanguage);
|
// keep default
|
||||||
}
|
}
|
||||||
|
|
||||||
await loadAndSetLanguage(initialLanguage);
|
await loadAndSetLanguage(initialLanguage);
|
||||||
|
|
@ -82,19 +69,22 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
|
||||||
|
|
||||||
initializeLanguage();
|
initializeLanguage();
|
||||||
|
|
||||||
// Listen for user data updates to sync language
|
const handleUserUpdate = async () => {
|
||||||
const handleUserUpdate = () => {
|
|
||||||
const userData = getUserDataCache();
|
const userData = getUserDataCache();
|
||||||
if (userData?.language && String(userData.language).trim()) {
|
if (userData?.language && String(userData.language).trim()) {
|
||||||
const userLanguage = String(userData.language).trim() as Language;
|
const userLanguage = String(userData.language).trim() as Language;
|
||||||
if (userLanguage !== currentLanguage) {
|
if (userLanguage !== currentLanguage) {
|
||||||
console.log('🔄 Syncing language with user data (sessionStorage cache):', userLanguage);
|
|
||||||
loadAndSetLanguage(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);
|
window.addEventListener('userInfoUpdated', handleUserUpdate);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -103,15 +93,7 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setLanguage = async (language: Language) => {
|
const setLanguage = async (language: Language) => {
|
||||||
// Load the new language immediately for UI
|
|
||||||
await loadAndSetLanguage(language);
|
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 () => {
|
const reloadLanguage = async () => {
|
||||||
|
|
@ -121,7 +103,7 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
|
||||||
const refreshAvailableLanguages = useCallback(async () => {
|
const refreshAvailableLanguages = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const list = await fetchAvailableLanguageCodes();
|
const list = await fetchAvailableLanguageCodes();
|
||||||
setAvailableLanguages(list);
|
setAvailableLanguages(list.filter((l) => l.code !== 'xx'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load language codes:', e);
|
console.error('Failed to load language codes:', e);
|
||||||
}
|
}
|
||||||
|
|
@ -151,7 +133,6 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
|
||||||
|
|
||||||
const resolved =
|
const resolved =
|
||||||
translations[key] ??
|
translations[key] ??
|
||||||
deTranslations[key] ??
|
|
||||||
(typeof paramsOrFallback === 'string' ? paramsOrFallback : undefined) ??
|
(typeof paramsOrFallback === 'string' ? paramsOrFallback : undefined) ??
|
||||||
`[${key}]`;
|
`[${key}]`;
|
||||||
return _applyParams(resolved, params);
|
return _applyParams(resolved, params);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,97 @@
|
||||||
import { defineConfig, loadEnv, Plugin } from 'vite';
|
import { defineConfig, loadEnv, Plugin } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { createHtmlPlugin } from 'vite-plugin-html';
|
import { createHtmlPlugin } from 'vite-plugin-html';
|
||||||
|
import type { IncomingMessage, ServerResponse } from 'http';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
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
|
// Custom plugin to serve static HTML files from public directory BEFORE SPA fallback
|
||||||
function serveStaticHtml(): Plugin {
|
function serveStaticHtml(): Plugin {
|
||||||
return {
|
return {
|
||||||
|
|
@ -37,6 +125,7 @@ export default defineConfig(({ mode }) => {
|
||||||
plugins: [
|
plugins: [
|
||||||
// Serve static HTML files
|
// Serve static HTML files
|
||||||
serveStaticHtml(),
|
serveStaticHtml(),
|
||||||
|
extractI18nKeys(),
|
||||||
react(),
|
react(),
|
||||||
createHtmlPlugin({
|
createHtmlPlugin({
|
||||||
// Only process main index.html, not public static files
|
// Only process main index.html, not public static files
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue