diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..243bd7e --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +[build] +# Use sccache as the rustc wrapper for compile caching. +# Falls back gracefully: if sccache is not installed, cargo will warn but +# still compile. Install with: cargo install sccache +rustc-wrapper = "sccache" diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 1bbeb87..6312edd 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -11,6 +11,10 @@ on: env: CARGO_TERM_COLOR: always RUSTFLAGS: "-D warnings" + # sccache caches compilation artifacts within a job so that compiling + # both --features server and --features web shares common crate work. + RUSTC_WRAPPER: /usr/local/bin/sccache + SCCACHE_DIR: /tmp/sccache # Cancel in-progress runs for the same branch/PR concurrency: @@ -34,7 +38,10 @@ jobs: git fetch --depth=1 origin "${GITHUB_SHA}" git checkout FETCH_HEAD - run: rustup component add rustfmt + # Format check does not compile, so sccache is not needed here. - run: cargo fmt --check + env: + RUSTC_WRAPPER: "" clippy: name: Clippy @@ -48,12 +55,21 @@ jobs: git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" git fetch --depth=1 origin "${GITHUB_SHA}" git checkout FETCH_HEAD + - name: Install sccache + run: | + curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \ + | tar xz --strip-components=1 -C /usr/local/bin/ sccache-v0.9.1-x86_64-unknown-linux-musl/sccache + chmod +x /usr/local/bin/sccache - run: rustup component add clippy - # Lint both feature sets independently + # Lint both feature sets independently. + # sccache deduplicates shared crates between the two compilations. - name: Clippy (server) run: cargo clippy --features server --no-default-features -- -D warnings - name: Clippy (web) run: cargo clippy --features web --no-default-features -- -D warnings + - name: Show sccache stats + run: sccache --show-stats + if: always() audit: name: Security Audit @@ -69,7 +85,11 @@ jobs: git fetch --depth=1 origin "${GITHUB_SHA}" git checkout FETCH_HEAD - run: cargo install cargo-audit + env: + RUSTC_WRAPPER: "" - run: cargo audit + env: + RUSTC_WRAPPER: "" # --------------------------------------------------------------------------- # Stage 2: Tests (only after all quality checks pass) @@ -87,10 +107,18 @@ jobs: git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" git fetch --depth=1 origin "${GITHUB_SHA}" git checkout FETCH_HEAD + - name: Install sccache + run: | + curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \ + | tar xz --strip-components=1 -C /usr/local/bin/ sccache-v0.9.1-x86_64-unknown-linux-musl/sccache + chmod +x /usr/local/bin/sccache - name: Run tests (server) run: cargo test --features server --no-default-features - name: Run tests (web) run: cargo test --features web --no-default-features + - name: Show sccache stats + run: sccache --show-stats + if: always() # --------------------------------------------------------------------------- # Stage 3: Deploy (only after tests pass, only on main) @@ -108,4 +136,3 @@ jobs: apk add --no-cache curl curl -sf "${{ secrets.COOLIFY_WEBHOOK }}" \ -H "Authorization: Bearer ${{ secrets.COOLIFY_TOKEN }}" - diff --git a/Dockerfile b/Dockerfile index 8c1d477..d4d8e61 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,4 @@ +# syntax=docker/dockerfile:1 # Stage 1: Generate dependency recipe for caching FROM rust:1.89-bookworm AS chef RUN cargo install cargo-chef @@ -15,16 +16,26 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ pkg-config libssl-dev curl unzip \ && rm -rf /var/lib/apt/lists/* +# Install sccache for compile caching across Docker builds +RUN curl -fsSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \ + | tar xz --strip-components=1 -C /usr/local/bin/ sccache-v0.9.1-x86_64-unknown-linux-musl/sccache \ + && chmod +x /usr/local/bin/sccache + +ENV RUSTC_WRAPPER=/usr/local/bin/sccache +ENV SCCACHE_DIR=/tmp/sccache + # Install bun (for Tailwind CSS build step) RUN curl -fsSL https://bun.sh/install | bash ENV PATH="/root/.bun/bin:$PATH" # Install dx CLI from source (binstall binaries require GLIBC >= 2.38) -RUN cargo install dioxus-cli@0.7.3 --locked +RUN --mount=type=cache,target=/tmp/sccache \ + cargo install dioxus-cli@0.7.3 --locked # Cook dependencies from recipe (cached layer) COPY --from=planner /app/recipe.json recipe.json -RUN cargo chef cook --release --recipe-path recipe.json +RUN --mount=type=cache,target=/tmp/sccache \ + cargo chef cook --release --recipe-path recipe.json # Copy source and build COPY . . @@ -33,7 +44,8 @@ COPY . . RUN bun install --frozen-lockfile # Bundle the fullstack application -RUN dx bundle --release --fullstack +RUN --mount=type=cache,target=/tmp/sccache \ + dx bundle --release --fullstack # Stage 3: Minimal runtime image FROM debian:bookworm-slim AS runtime diff --git a/assets/i18n/de.json b/assets/i18n/de.json new file mode 100644 index 0000000..4ca034c --- /dev/null +++ b/assets/i18n/de.json @@ -0,0 +1,302 @@ +{ + "common": { + "loading": "Wird geladen...", + "cancel": "Abbrechen", + "save": "Speichern", + "delete": "Loeschen", + "send": "Senden", + "close": "Schliessen", + "login": "Anmelden", + "logout": "Abmelden", + "on": "EIN", + "off": "AUS", + "online": "Online", + "offline": "Offline", + "settings": "Einstellungen", + "search": "Suche", + "rename": "Umbenennen", + "copy": "Kopieren", + "share": "Teilen", + "edit": "Bearbeiten", + "get_started": "Jetzt starten", + "coming_soon": "Demnachst verfuegbar", + "back_to_home": "Zurueck zur Startseite", + "privacy_policy": "Datenschutzerklaerung", + "impressum": "Impressum", + "chunks": "Abschnitte", + "upload_file": "Datei hochladen", + "eur_per_month": "EUR / Monat", + "up_to_seats": "Bis zu {n} Plaetze", + "unlimited_seats": "Unbegrenzte Plaetze", + "set": "Gesetzt", + "not_set": "Nicht gesetzt", + "log_in": "Anmelden", + "features": "Funktionen", + "how_it_works": "So funktioniert es" + }, + "nav": { + "dashboard": "Dashboard", + "providers": "Provider", + "chat": "Chat", + "tools": "Werkzeuge", + "knowledge_base": "Wissensdatenbank", + "developer": "Entwickler", + "organization": "Organisation", + "switch_light": "Zum hellen Modus wechseln", + "switch_dark": "Zum dunklen Modus wechseln", + "github": "GitHub", + "agents": "Agenten", + "flow": "Flow", + "analytics": "Analytics", + "pricing": "Preise" + }, + "auth": { + "redirecting_login": "Weiterleitung zur Anmeldung...", + "redirecting_secure": "Weiterleitung zur sicheren Anmeldeseite...", + "auth_error": "Authentifizierungsfehler: {msg}", + "log_in": "Anmelden" + }, + "dashboard": { + "title": "Dashboard", + "subtitle": "KI-Nachrichten und Neuigkeiten", + "topic_placeholder": "Themenname...", + "ollama_settings": "Ollama-Einstellungen", + "settings_hint": "Leer lassen, um OLLAMA_URL / OLLAMA_MODEL aus .env zu verwenden", + "ollama_url": "Ollama-URL", + "ollama_url_placeholder": "Verwendet OLLAMA_URL aus .env", + "model": "Modell", + "model_placeholder": "Verwendet OLLAMA_MODEL aus .env", + "searching": "Suche laeuft...", + "search_failed": "Suche fehlgeschlagen: {e}", + "ollama_status": "Ollama-Status", + "trending": "Im Trend", + "recent_searches": "Letzte Suchen" + }, + "chat": { + "new_chat": "Neuer Chat", + "general": "Allgemein", + "conversations": "Unterhaltungen", + "news_chats": "Nachrichten-Chats", + "all_chats": "Alle Chats", + "no_conversations": "Noch keine Unterhaltungen", + "type_message": "Nachricht eingeben...", + "model_label": "Modell:", + "no_models": "Keine Modelle verfuegbar", + "send_to_start": "Senden Sie eine Nachricht, um die Unterhaltung zu starten.", + "you": "Sie", + "assistant": "Assistent", + "thinking": "Denkt nach...", + "copy_response": "Letzte Antwort kopieren", + "copy_conversation": "Unterhaltung kopieren", + "edit_last": "Letzte Nachricht bearbeiten", + "just_now": "gerade eben", + "minutes_ago": "vor {n} Min.", + "hours_ago": "vor {n} Std.", + "days_ago": "vor {n} T." + }, + "providers": { + "title": "Provider", + "subtitle": "Konfigurieren Sie Ihre LLM- und Embedding-Backends", + "provider": "Provider", + "model": "Modell", + "embedding_model": "Embedding-Modell", + "api_key": "API-Schluessel", + "api_key_placeholder": "API-Schluessel eingeben...", + "save_config": "Konfiguration speichern", + "config_saved": "Konfiguration gespeichert.", + "active_config": "Aktive Konfiguration", + "embedding": "Embedding" + }, + "tools": { + "title": "Werkzeuge", + "subtitle": "MCP-Server und Werkzeugintegrationen verwalten", + "calculator": "Taschenrechner", + "calculator_desc": "Mathematische Berechnungen und Einheitenumrechnung", + "tavily": "Tavily-Suche", + "tavily_desc": "KI-optimierte Websuche-API fuer Echtzeitinformationen", + "searxng": "SearXNG", + "searxng_desc": "Datenschutzfreundliche Metasuchmaschine", + "file_reader": "Dateileser", + "file_reader_desc": "Lokale Dateien in verschiedenen Formaten lesen und analysieren", + "code_executor": "Code-Ausfuehrer", + "code_executor_desc": "Isolierte Codeausfuehrung fuer Python und JavaScript", + "web_scraper": "Web-Scraper", + "web_scraper_desc": "Strukturierte Daten aus Webseiten extrahieren", + "email_sender": "E-Mail-Versand", + "email_sender_desc": "E-Mails ueber konfigurierten SMTP-Server versenden", + "git_ops": "Git-Operationen", + "git_ops_desc": "Mit Git-Repositories fuer Versionskontrolle interagieren" + }, + "knowledge": { + "title": "Wissensdatenbank", + "subtitle": "Dokumente fuer RAG-Abfragen verwalten", + "search_placeholder": "Dateien suchen...", + "name": "Name", + "type": "Typ", + "size": "Groesse", + "chunks": "Abschnitte", + "uploaded": "Hochgeladen", + "actions": "Aktionen" + }, + "developer": { + "agents_title": "Agent Builder", + "agents_desc": "Erstellen und verwalten Sie KI-Agenten mit LangGraph. Erstellen Sie mehrstufige Schlussfolgerungspipelines, werkzeugnutzende Agenten und autonome Workflows.", + "launch_agents": "Agent Builder starten", + "flow_title": "Flow Builder", + "flow_desc": "Entwerfen Sie visuelle KI-Workflows mit LangFlow. Ziehen Sie Knoten per Drag-and-Drop, um Datenverarbeitungspipelines, Prompt-Ketten und Integrationsflows zu erstellen.", + "launch_flow": "Flow Builder starten", + "analytics_title": "Analytics und Observability", + "analytics_desc": "Ueberwachen und analysieren Sie Ihre KI-Pipelines mit LangFuse. Verfolgen Sie Token-Verbrauch, Latenz, Kosten und Qualitaetsmetriken ueber alle Ihre Deployments hinweg.", + "launch_analytics": "LangFuse starten", + "total_requests": "Anfragen gesamt", + "avg_latency": "Durchschn. Latenz", + "tokens_used": "Verbrauchte Token", + "error_rate": "Fehlerrate" + }, + "org": { + "title": "Organisation", + "subtitle": "Mitglieder und Abrechnung verwalten", + "invite_member": "Mitglied einladen", + "seats_used": "Belegte Plaetze", + "of_tokens": "von {limit} Token", + "cycle_ends": "Zyklusende", + "name": "Name", + "email": "E-Mail", + "role": "Rolle", + "joined": "Beigetreten", + "invite_title": "Neues Mitglied einladen", + "email_address": "E-Mail-Adresse", + "email_placeholder": "kollege@firma.de", + "send_invite": "Einladung senden", + "pricing_title": "Preise", + "pricing_subtitle": "Waehlen Sie den passenden Plan fuer Ihre Organisation" + }, + "pricing": { + "starter": "Starter", + "team": "Team", + "enterprise": "Enterprise", + "up_to_users": "Bis zu {n} Benutzer", + "unlimited_users": "Unbegrenzte Benutzer", + "llm_provider_1": "1 LLM-Provider", + "all_providers": "Alle LLM-Provider", + "tokens_100k": "100K Token/Monat", + "tokens_1m": "1M Token/Monat", + "unlimited_tokens": "Unbegrenzte Token", + "community_support": "Community-Support", + "priority_support": "Priorisierter Support", + "dedicated_support": "Dedizierter Support", + "basic_analytics": "Basis-Analytics", + "advanced_analytics": "Erweiterte Analytics", + "full_observability": "Volle Observability", + "custom_mcp": "Benutzerdefinierte MCP-Werkzeuge", + "sso": "SSO-Integration", + "custom_integrations": "Benutzerdefinierte Integrationen", + "sla": "SLA-Garantie", + "on_premise": "On-Premise-Bereitstellung" + }, + "landing": { + "badge": "Datenschutzorientierte GenAI-Infrastruktur", + "hero_title_1": "Ihre KI. Ihre Daten.", + "hero_title_2": "Ihre Infrastruktur.", + "hero_subtitle": "Selbst gehostete, GDPR-konforme Plattform fuer generative KI fuer Unternehmen, die bei der Datensouveraenitaet keine Kompromisse eingehen. Betreiben Sie LLMs, Agenten und MCP-Server nach Ihren eigenen Regeln.", + "learn_more": "Mehr erfahren", + "social_proof": "Entwickelt fuer Unternehmen, die ", + "data_sovereignty": "Datensouveraenitaet", + "on_premise": "On-Premise", + "compliant": "Konform", + "data_residency": "Datenresidenz", + "third_party": "Weitergabe an Dritte", + "features_title": "Alles, was Sie brauchen", + "features_subtitle": "Ein vollstaendiger, selbst gehosteter GenAI-Stack unter Ihrer vollen Kontrolle.", + "feat_infra_title": "Selbst gehostete Infrastruktur", + "feat_infra_desc": "Betreiben Sie die Plattform auf Ihrer eigenen Hardware oder in Ihrer privaten Cloud. Volle Kontrolle ueber Ihren KI-Stack ohne externe Abhaengigkeiten.", + "feat_gdpr_title": "GDPR-konform", + "feat_gdpr_desc": "EU-Datenresidenz garantiert. Ihre Daten verlassen niemals Ihre Infrastruktur und werden nicht an Dritte weitergegeben.", + "feat_llm_title": "LLM-Verwaltung", + "feat_llm_desc": "Stellen Sie mehrere Sprachmodelle bereit, ueberwachen und verwalten Sie diese. Wechseln Sie zwischen Modellen ohne Ausfallzeit.", + "feat_agent_title": "Agent Builder", + "feat_agent_desc": "Erstellen Sie benutzerdefinierte KI-Agenten mit integriertem Langchain und Langfuse fuer volle Observability und Kontrolle.", + "feat_mcp_title": "MCP-Server-Verwaltung", + "feat_mcp_desc": "Verwalten Sie Model Context Protocol-Server, um Ihre KI-Faehigkeiten mit externen Werkzeugintegrationen zu erweitern.", + "feat_api_title": "API-Schluessel-Verwaltung", + "feat_api_desc": "Generieren Sie API-Schluessel, verfolgen Sie die Nutzung pro Platz und setzen Sie feingranulare Berechtigungen fuer jede Integration.", + "how_title": "In wenigen Minuten einsatzbereit", + "how_subtitle": "Drei Schritte zur souveraenen KI-Infrastruktur.", + "step_deploy": "Bereitstellen", + "step_deploy_desc": "Installieren Sie CERTifAI auf Ihrer Infrastruktur mit einem einzigen Befehl. Unterstuetzt Docker, Kubernetes und Bare-Metal.", + "step_configure": "Konfigurieren", + "step_configure_desc": "Verbinden Sie Ihren Identitaets-Provider, waehlen Sie Ihre Modelle und richten Sie Teamberechtigungen ueber das Admin-Dashboard ein.", + "step_scale": "Skalieren", + "step_scale_desc": "Fuegen Sie Benutzer hinzu, stellen Sie weitere Modelle bereit und integrieren Sie Ihre bestehenden Werkzeuge ueber API-Schluessel und MCP-Server.", + "cta_title": "Bereit, die Kontrolle ueber Ihre KI-Infrastruktur zu uebernehmen?", + "cta_subtitle": "Beginnen Sie noch heute mit dem Betrieb souveraener GenAI. Keine Kreditkarte erforderlich.", + "get_started_free": "Kostenlos starten", + "footer_tagline": "Souveraene GenAI-Infrastruktur fuer Unternehmen.", + "product": "Produkt", + "legal": "Rechtliches", + "resources": "Ressourcen", + "documentation": "Dokumentation", + "api_reference": "API-Referenz", + "support": "Support", + "copyright": "2026 CERTifAI. Alle Rechte vorbehalten." + }, + "article": { + "read_original": "Originalartikel lesen", + "summarizing": "Wird zusammengefasst...", + "summarized_with_ai": "Mit KI zusammengefasst", + "ask_followup": "Stellen Sie eine Anschlussfrage..." + }, + "impressum": { + "title": "Impressum", + "info_tmg": "Angaben gemaess 5 TMG", + "company": "CERTifAI GmbH", + "address_street": "Musterstrasse 1", + "address_city": "10115 Berlin", + "address_country": "Deutschland", + "represented_by": "Vertreten durch", + "managing_director": "Geschaeftsfuehrer: [Name]", + "contact": "Kontakt", + "email": "E-Mail: info@certifai.example", + "phone": "Telefon: +49 (0) 30 1234567", + "commercial_register": "Handelsregister", + "registered_at": "Eingetragen beim: Amtsgericht Berlin-Charlottenburg", + "registration_number": "Registernummer: HRB XXXXXX", + "vat_id": "Umsatzsteuer-ID", + "vat_number": "Umsatzsteuer-Identifikationsnummer gemaess 27a UStG: DE XXXXXXXXX", + "responsible_content": "Verantwortlich fuer den Inhalt nach 55 Abs. 2 RStV" + }, + "privacy": { + "title": "Datenschutzerklaerung", + "last_updated": "Zuletzt aktualisiert: Februar 2026", + "intro_title": "1. Einleitung", + "intro_text": "Die CERTifAI GmbH (\"wir\", \"unser\", \"uns\") verpflichtet sich zum Schutz Ihrer personenbezogenen Daten. Diese Datenschutzerklaerung erlaeutert, wie wir Ihre Informationen erheben, verwenden und schuetzen, wenn Sie unsere Plattform nutzen.", + "controller_title": "2. Verantwortlicher", + "controller_address": "Musterstrasse 1, 10115 Berlin, Deutschland", + "controller_email": "E-Mail: privacy@certifai.example", + "data_title": "3. Erhobene Daten", + "data_intro": "Wir erheben nur die fuer die Erbringung unserer Dienste mindestens erforderlichen Daten:", + "data_account_label": "Kontodaten: ", + "data_account_text": "Name, E-Mail-Adresse und Organisationsangaben, die bei der Registrierung angegeben werden.", + "data_usage_label": "Nutzungsdaten: ", + "data_usage_text": "API-Aufrufprotokolle, Token-Zaehler und Funktionsnutzungsmetriken fuer Abrechnung und Analyse.", + "data_technical_label": "Technische Daten: ", + "data_technical_text": "IP-Adressen, Browsertyp und Sitzungskennungen fuer Sicherheit und Plattformstabilitaet.", + "use_title": "4. Verwendung Ihrer Daten", + "use_1": "Zur Bereitstellung und Wartung der CERTifAI-Plattform", + "use_2": "Zur Verwaltung Ihres Kontos und Abonnements", + "use_3": "Zur Mitteilung von Dienstaktualisierungen und Sicherheitshinweisen", + "use_4": "Zur Erfuellung gesetzlicher Verpflichtungen", + "storage_title": "5. Datenspeicherung und Datensouveraenitaet", + "storage_text": "CERTifAI ist eine selbst gehostete Plattform. Alle KI-Workloads, Modelldaten und Inferenzergebnisse verbleiben vollstaendig innerhalb Ihrer eigenen Infrastruktur. Wir greifen nicht auf Ihre KI-Daten zu, speichern oder verarbeiten diese nicht auf unseren Servern.", + "rights_title": "6. Ihre Rechte (GDPR)", + "rights_intro": "Gemaess der GDPR haben Sie das Recht auf:", + "rights_access": "Auskunft ueber Ihre personenbezogenen Daten", + "rights_rectify": "Berichtigung unrichtiger Daten", + "rights_erasure": "Loeschung Ihrer Daten", + "rights_restrict": "Einschraenkung oder Widerspruch gegen die Verarbeitung", + "rights_portability": "Datenuebertragbarkeit", + "rights_complaint": "Beschwerde bei einer Aufsichtsbehoerde", + "contact_title": "7. Kontakt", + "contact_text": "Fuer datenschutzbezogene Anfragen kontaktieren Sie uns unter privacy@certifai.example." + } +} diff --git a/assets/i18n/en.json b/assets/i18n/en.json new file mode 100644 index 0000000..662b0b7 --- /dev/null +++ b/assets/i18n/en.json @@ -0,0 +1,302 @@ +{ + "common": { + "loading": "Loading...", + "cancel": "Cancel", + "save": "Save", + "delete": "Delete", + "send": "Send", + "close": "Close", + "login": "Login", + "logout": "Logout", + "on": "ON", + "off": "OFF", + "online": "Online", + "offline": "Offline", + "settings": "Settings", + "search": "Search", + "rename": "Rename", + "copy": "Copy", + "share": "Share", + "edit": "Edit", + "get_started": "Get Started", + "coming_soon": "Coming Soon", + "back_to_home": "Back to Home", + "privacy_policy": "Privacy Policy", + "impressum": "Impressum", + "chunks": "chunks", + "upload_file": "Upload File", + "eur_per_month": "EUR / month", + "up_to_seats": "Up to {n} seats", + "unlimited_seats": "Unlimited seats", + "set": "Set", + "not_set": "Not set", + "log_in": "Log In", + "features": "Features", + "how_it_works": "How It Works" + }, + "nav": { + "dashboard": "Dashboard", + "providers": "Providers", + "chat": "Chat", + "tools": "Tools", + "knowledge_base": "Knowledge Base", + "developer": "Developer", + "organization": "Organization", + "switch_light": "Switch to light mode", + "switch_dark": "Switch to dark mode", + "github": "GitHub", + "agents": "Agents", + "flow": "Flow", + "analytics": "Analytics", + "pricing": "Pricing" + }, + "auth": { + "redirecting_login": "Redirecting to login...", + "redirecting_secure": "Redirecting to secure login page...", + "auth_error": "Authentication error: {msg}", + "log_in": "Login" + }, + "dashboard": { + "title": "Dashboard", + "subtitle": "AI news and updates", + "topic_placeholder": "Topic name...", + "ollama_settings": "Ollama Settings", + "settings_hint": "Leave empty to use OLLAMA_URL / OLLAMA_MODEL from .env", + "ollama_url": "Ollama URL", + "ollama_url_placeholder": "Uses OLLAMA_URL from .env", + "model": "Model", + "model_placeholder": "Uses OLLAMA_MODEL from .env", + "searching": "Searching...", + "search_failed": "Search failed: {e}", + "ollama_status": "Ollama Status", + "trending": "Trending", + "recent_searches": "Recent Searches" + }, + "chat": { + "new_chat": "New Chat", + "general": "General", + "conversations": "Conversations", + "news_chats": "News Chats", + "all_chats": "All Chats", + "no_conversations": "No conversations yet", + "type_message": "Type a message...", + "model_label": "Model:", + "no_models": "No models available", + "send_to_start": "Send a message to start the conversation.", + "you": "You", + "assistant": "Assistant", + "thinking": "Thinking...", + "copy_response": "Copy last response", + "copy_conversation": "Copy conversation", + "edit_last": "Edit last message", + "just_now": "just now", + "minutes_ago": "{n}m ago", + "hours_ago": "{n}h ago", + "days_ago": "{n}d ago" + }, + "providers": { + "title": "Providers", + "subtitle": "Configure your LLM and embedding backends", + "provider": "Provider", + "model": "Model", + "embedding_model": "Embedding Model", + "api_key": "API Key", + "api_key_placeholder": "Enter API key...", + "save_config": "Save Configuration", + "config_saved": "Configuration saved.", + "active_config": "Active Configuration", + "embedding": "Embedding" + }, + "tools": { + "title": "Tools", + "subtitle": "Manage MCP servers and tool integrations", + "calculator": "Calculator", + "calculator_desc": "Mathematical computation and unit conversion", + "tavily": "Tavily Search", + "tavily_desc": "AI-optimized web search API for real-time information", + "searxng": "SearXNG", + "searxng_desc": "Privacy-respecting metasearch engine", + "file_reader": "File Reader", + "file_reader_desc": "Read and parse local files in various formats", + "code_executor": "Code Executor", + "code_executor_desc": "Sandboxed code execution for Python and JavaScript", + "web_scraper": "Web Scraper", + "web_scraper_desc": "Extract structured data from web pages", + "email_sender": "Email Sender", + "email_sender_desc": "Send emails via configured SMTP server", + "git_ops": "Git Operations", + "git_ops_desc": "Interact with Git repositories for version control" + }, + "knowledge": { + "title": "Knowledge Base", + "subtitle": "Manage documents for RAG retrieval", + "search_placeholder": "Search files...", + "name": "Name", + "type": "Type", + "size": "Size", + "chunks": "Chunks", + "uploaded": "Uploaded", + "actions": "Actions" + }, + "developer": { + "agents_title": "Agent Builder", + "agents_desc": "Build and manage AI agents with LangGraph. Create multi-step reasoning pipelines, tool-using agents, and autonomous workflows.", + "launch_agents": "Launch Agent Builder", + "flow_title": "Flow Builder", + "flow_desc": "Design visual AI workflows with LangFlow. Drag-and-drop nodes to create data processing pipelines, prompt chains, and integration flows.", + "launch_flow": "Launch Flow Builder", + "analytics_title": "Analytics & Observability", + "analytics_desc": "Monitor and analyze your AI pipelines with LangFuse. Track token usage, latency, costs, and quality metrics across all your deployments.", + "launch_analytics": "Launch LangFuse", + "total_requests": "Total Requests", + "avg_latency": "Avg Latency", + "tokens_used": "Tokens Used", + "error_rate": "Error Rate" + }, + "org": { + "title": "Organization", + "subtitle": "Manage members and billing", + "invite_member": "Invite Member", + "seats_used": "Seats Used", + "of_tokens": "of {limit} tokens", + "cycle_ends": "Cycle Ends", + "name": "Name", + "email": "Email", + "role": "Role", + "joined": "Joined", + "invite_title": "Invite New Member", + "email_address": "Email Address", + "email_placeholder": "colleague@company.com", + "send_invite": "Send Invite", + "pricing_title": "Pricing", + "pricing_subtitle": "Choose the plan that fits your organization" + }, + "pricing": { + "starter": "Starter", + "team": "Team", + "enterprise": "Enterprise", + "up_to_users": "Up to {n} users", + "unlimited_users": "Unlimited users", + "llm_provider_1": "1 LLM provider", + "all_providers": "All LLM providers", + "tokens_100k": "100K tokens/month", + "tokens_1m": "1M tokens/month", + "unlimited_tokens": "Unlimited tokens", + "community_support": "Community support", + "priority_support": "Priority support", + "dedicated_support": "Dedicated support", + "basic_analytics": "Basic analytics", + "advanced_analytics": "Advanced analytics", + "full_observability": "Full observability", + "custom_mcp": "Custom MCP tools", + "sso": "SSO integration", + "custom_integrations": "Custom integrations", + "sla": "SLA guarantee", + "on_premise": "On-premise deployment" + }, + "landing": { + "badge": "Privacy-First GenAI Infrastructure", + "hero_title_1": "Your AI. Your Data.", + "hero_title_2": "Your Infrastructure.", + "hero_subtitle": "Self-hosted, GDPR-compliant generative AI platform for enterprises that refuse to compromise on data sovereignty. Deploy LLMs, agents, and MCP servers on your own terms.", + "learn_more": "Learn More", + "social_proof": "Built for enterprises that value ", + "data_sovereignty": "data sovereignty", + "on_premise": "On-Premise", + "compliant": "Compliant", + "data_residency": "Data Residency", + "third_party": "Third-Party Sharing", + "features_title": "Everything You Need", + "features_subtitle": "A complete, self-hosted GenAI stack under your full control.", + "feat_infra_title": "Self-Hosted Infrastructure", + "feat_infra_desc": "Deploy on your own hardware or private cloud. Full control over your AI stack with no external dependencies.", + "feat_gdpr_title": "GDPR Compliant", + "feat_gdpr_desc": "EU data residency guaranteed. Your data never leaves your infrastructure or gets shared with third parties.", + "feat_llm_title": "LLM Management", + "feat_llm_desc": "Deploy, monitor, and manage multiple language models. Switch between models with zero downtime.", + "feat_agent_title": "Agent Builder", + "feat_agent_desc": "Create custom AI agents with integrated Langchain and Langfuse for full observability and control.", + "feat_mcp_title": "MCP Server Management", + "feat_mcp_desc": "Manage Model Context Protocol servers to extend your AI capabilities with external tool integrations.", + "feat_api_title": "API Key Management", + "feat_api_desc": "Generate API keys, track usage per seat, and set fine-grained permissions for every integration.", + "how_title": "Up and Running in Minutes", + "how_subtitle": "Three steps to sovereign AI infrastructure.", + "step_deploy": "Deploy", + "step_deploy_desc": "Install CERTifAI on your infrastructure with a single command. Supports Docker, Kubernetes, and bare metal.", + "step_configure": "Configure", + "step_configure_desc": "Connect your identity provider, select your models, and set up team permissions through the admin dashboard.", + "step_scale": "Scale", + "step_scale_desc": "Add users, deploy more models, and integrate with your existing tools via API keys and MCP servers.", + "cta_title": "Ready to take control of your AI infrastructure?", + "cta_subtitle": "Start deploying sovereign GenAI today. No credit card required.", + "get_started_free": "Get Started Free", + "footer_tagline": "Sovereign GenAI infrastructure for enterprises.", + "product": "Product", + "legal": "Legal", + "resources": "Resources", + "documentation": "Documentation", + "api_reference": "API Reference", + "support": "Support", + "copyright": "2026 CERTifAI. All rights reserved." + }, + "article": { + "read_original": "Read original article", + "summarizing": "Summarizing...", + "summarized_with_ai": "Summarized with AI", + "ask_followup": "Ask a follow-up question..." + }, + "impressum": { + "title": "Impressum", + "info_tmg": "Information according to 5 TMG", + "company": "CERTifAI GmbH", + "address_street": "Musterstrasse 1", + "address_city": "10115 Berlin", + "address_country": "Germany", + "represented_by": "Represented by", + "managing_director": "Managing Director: [Name]", + "contact": "Contact", + "email": "Email: info@certifai.example", + "phone": "Phone: +49 (0) 30 1234567", + "commercial_register": "Commercial Register", + "registered_at": "Registered at: Amtsgericht Berlin-Charlottenburg", + "registration_number": "Registration number: HRB XXXXXX", + "vat_id": "VAT ID", + "vat_number": "VAT identification number according to 27a UStG: DE XXXXXXXXX", + "responsible_content": "Responsible for content according to 55 Abs. 2 RStV" + }, + "privacy": { + "title": "Privacy Policy", + "last_updated": "Last updated: February 2026", + "intro_title": "1. Introduction", + "intro_text": "CERTifAI GmbH (\"we\", \"our\", \"us\") is committed to protecting your personal data. This privacy policy explains how we collect, use, and safeguard your information when you use our platform.", + "controller_title": "2. Data Controller", + "controller_address": "Musterstrasse 1, 10115 Berlin, Germany", + "controller_email": "Email: privacy@certifai.example", + "data_title": "3. Data We Collect", + "data_intro": "We collect only the minimum data necessary to provide our services:", + "data_account_label": "Account data: ", + "data_account_text": "Name, email address, and organization details provided during registration.", + "data_usage_label": "Usage data: ", + "data_usage_text": "API call logs, token counts, and feature usage metrics for billing and analytics.", + "data_technical_label": "Technical data: ", + "data_technical_text": "IP addresses, browser type, and session identifiers for security and platform stability.", + "use_title": "4. How We Use Your Data", + "use_1": "To provide and maintain the CERTifAI platform", + "use_2": "To manage your account and subscription", + "use_3": "To communicate service updates and security notices", + "use_4": "To comply with legal obligations", + "storage_title": "5. Data Storage and Sovereignty", + "storage_text": "CERTifAI is a self-hosted platform. All AI workloads, model data, and inference results remain entirely within your own infrastructure. We do not access, store, or process your AI data on our servers.", + "rights_title": "6. Your Rights (GDPR)", + "rights_intro": "Under the GDPR, you have the right to:", + "rights_access": "Access your personal data", + "rights_rectify": "Rectify inaccurate data", + "rights_erasure": "Request erasure of your data", + "rights_restrict": "Restrict or object to processing", + "rights_portability": "Data portability", + "rights_complaint": "Lodge a complaint with a supervisory authority", + "contact_title": "7. Contact", + "contact_text": "For privacy-related inquiries, contact us at privacy@certifai.example." + } +} diff --git a/assets/i18n/es.json b/assets/i18n/es.json new file mode 100644 index 0000000..eef7960 --- /dev/null +++ b/assets/i18n/es.json @@ -0,0 +1,302 @@ +{ + "common": { + "loading": "Cargando...", + "cancel": "Cancelar", + "save": "Guardar", + "delete": "Eliminar", + "send": "Enviar", + "close": "Cerrar", + "login": "Iniciar sesion", + "logout": "Cerrar sesion", + "on": "ACTIVADO", + "off": "DESACTIVADO", + "online": "En linea", + "offline": "Sin conexion", + "settings": "Configuracion", + "search": "Buscar", + "rename": "Renombrar", + "copy": "Copiar", + "share": "Compartir", + "edit": "Editar", + "get_started": "Comenzar", + "coming_soon": "Proximamente", + "back_to_home": "Volver al inicio", + "privacy_policy": "Politica de privacidad", + "impressum": "Aviso legal", + "chunks": "fragmentos", + "upload_file": "Subir archivo", + "eur_per_month": "EUR / mes", + "up_to_seats": "Hasta {n} puestos", + "unlimited_seats": "Puestos ilimitados", + "set": "Configurado", + "not_set": "No configurado", + "log_in": "Iniciar sesion", + "features": "Funcionalidades", + "how_it_works": "Como funciona" + }, + "nav": { + "dashboard": "Panel de control", + "providers": "Proveedores", + "chat": "Chat", + "tools": "Herramientas", + "knowledge_base": "Base de conocimiento", + "developer": "Desarrollador", + "organization": "Organizacion", + "switch_light": "Cambiar a modo claro", + "switch_dark": "Cambiar a modo oscuro", + "github": "GitHub", + "agents": "Agentes", + "flow": "Flujo", + "analytics": "Estadisticas", + "pricing": "Precios" + }, + "auth": { + "redirecting_login": "Redirigiendo al inicio de sesion...", + "redirecting_secure": "Redirigiendo a la pagina de inicio de sesion segura...", + "auth_error": "Error de autenticacion: {msg}", + "log_in": "Iniciar sesion" + }, + "dashboard": { + "title": "Panel de control", + "subtitle": "Noticias y actualizaciones de IA", + "topic_placeholder": "Nombre del tema...", + "ollama_settings": "Configuracion de Ollama", + "settings_hint": "Dejar vacio para usar OLLAMA_URL / OLLAMA_MODEL del archivo .env", + "ollama_url": "URL de Ollama", + "ollama_url_placeholder": "Usa OLLAMA_URL del archivo .env", + "model": "Modelo", + "model_placeholder": "Usa OLLAMA_MODEL del archivo .env", + "searching": "Buscando...", + "search_failed": "La busqueda fallo: {e}", + "ollama_status": "Estado de Ollama", + "trending": "Tendencias", + "recent_searches": "Busquedas recientes" + }, + "chat": { + "new_chat": "Nuevo chat", + "general": "General", + "conversations": "Conversaciones", + "news_chats": "Chats de noticias", + "all_chats": "Todos los chats", + "no_conversations": "Aun no hay conversaciones", + "type_message": "Escriba un mensaje...", + "model_label": "Modelo:", + "no_models": "No hay modelos disponibles", + "send_to_start": "Envie un mensaje para iniciar la conversacion.", + "you": "Usted", + "assistant": "Asistente", + "thinking": "Pensando...", + "copy_response": "Copiar ultima respuesta", + "copy_conversation": "Copiar conversacion", + "edit_last": "Editar ultimo mensaje", + "just_now": "justo ahora", + "minutes_ago": "hace {n}m", + "hours_ago": "hace {n}h", + "days_ago": "hace {n}d" + }, + "providers": { + "title": "Proveedores", + "subtitle": "Configure sus backends de LLM y embeddings", + "provider": "Proveedor", + "model": "Modelo", + "embedding_model": "Modelo de embedding", + "api_key": "Clave API", + "api_key_placeholder": "Introduzca la clave API...", + "save_config": "Guardar configuracion", + "config_saved": "Configuracion guardada.", + "active_config": "Configuracion activa", + "embedding": "Embedding" + }, + "tools": { + "title": "Herramientas", + "subtitle": "Gestione servidores MCP e integraciones de herramientas", + "calculator": "Calculadora", + "calculator_desc": "Calculo matematico y conversion de unidades", + "tavily": "Tavily Search", + "tavily_desc": "API de busqueda web optimizada con IA para informacion en tiempo real", + "searxng": "SearXNG", + "searxng_desc": "Motor de metabusqueda que respeta la privacidad", + "file_reader": "Lector de archivos", + "file_reader_desc": "Leer y analizar archivos locales en varios formatos", + "code_executor": "Ejecutor de codigo", + "code_executor_desc": "Ejecucion de codigo en entorno aislado para Python y JavaScript", + "web_scraper": "Web Scraper", + "web_scraper_desc": "Extraer datos estructurados de paginas web", + "email_sender": "Envio de correo", + "email_sender_desc": "Enviar correos electronicos a traves del servidor SMTP configurado", + "git_ops": "Operaciones Git", + "git_ops_desc": "Interactuar con repositorios Git para control de versiones" + }, + "knowledge": { + "title": "Base de conocimiento", + "subtitle": "Gestione documentos para recuperacion RAG", + "search_placeholder": "Buscar archivos...", + "name": "Nombre", + "type": "Tipo", + "size": "Tamano", + "chunks": "Fragmentos", + "uploaded": "Subido", + "actions": "Acciones" + }, + "developer": { + "agents_title": "Constructor de agentes", + "agents_desc": "Construya y gestione agentes de IA con LangGraph. Cree pipelines de razonamiento de varios pasos, agentes que utilizan herramientas y flujos de trabajo autonomos.", + "launch_agents": "Abrir constructor de agentes", + "flow_title": "Constructor de flujos", + "flow_desc": "Disene flujos de trabajo de IA visuales con LangFlow. Arrastre y suelte nodos para crear pipelines de procesamiento de datos, cadenas de prompts y flujos de integracion.", + "launch_flow": "Abrir constructor de flujos", + "analytics_title": "Estadisticas y observabilidad", + "analytics_desc": "Monitoree y analice sus pipelines de IA con LangFuse. Realice seguimiento del uso de tokens, latencia, costos y metricas de calidad en todos sus despliegues.", + "launch_analytics": "Abrir LangFuse", + "total_requests": "Total de solicitudes", + "avg_latency": "Latencia promedio", + "tokens_used": "Tokens utilizados", + "error_rate": "Tasa de errores" + }, + "org": { + "title": "Organizacion", + "subtitle": "Gestione miembros y facturacion", + "invite_member": "Invitar miembro", + "seats_used": "Puestos utilizados", + "of_tokens": "de {limit} tokens", + "cycle_ends": "Fin del ciclo", + "name": "Nombre", + "email": "Correo electronico", + "role": "Rol", + "joined": "Fecha de ingreso", + "invite_title": "Invitar nuevo miembro", + "email_address": "Direccion de correo electronico", + "email_placeholder": "colega@empresa.com", + "send_invite": "Enviar invitacion", + "pricing_title": "Precios", + "pricing_subtitle": "Elija el plan que se adapte a su organizacion" + }, + "pricing": { + "starter": "Starter", + "team": "Team", + "enterprise": "Enterprise", + "up_to_users": "Hasta {n} usuarios", + "unlimited_users": "Usuarios ilimitados", + "llm_provider_1": "1 proveedor de LLM", + "all_providers": "Todos los proveedores de LLM", + "tokens_100k": "100K tokens/mes", + "tokens_1m": "1M tokens/mes", + "unlimited_tokens": "Tokens ilimitados", + "community_support": "Soporte comunitario", + "priority_support": "Soporte prioritario", + "dedicated_support": "Soporte dedicado", + "basic_analytics": "Estadisticas basicas", + "advanced_analytics": "Estadisticas avanzadas", + "full_observability": "Observabilidad completa", + "custom_mcp": "Herramientas MCP personalizadas", + "sso": "Integracion SSO", + "custom_integrations": "Integraciones personalizadas", + "sla": "Garantia de SLA", + "on_premise": "Despliegue en infraestructura propia" + }, + "landing": { + "badge": "Infraestructura GenAI con privacidad ante todo", + "hero_title_1": "Su IA. Sus datos.", + "hero_title_2": "Su infraestructura.", + "hero_subtitle": "Plataforma de IA generativa autoalojada y conforme al RGPD para empresas que no comprometen la soberania de sus datos. Despliegue LLMs, agentes y servidores MCP bajo sus propias condiciones.", + "learn_more": "Mas informacion", + "social_proof": "Creado para empresas que valoran la ", + "data_sovereignty": "soberania de datos", + "on_premise": "En infraestructura propia", + "compliant": "Conforme", + "data_residency": "Residencia de datos", + "third_party": "Comparticion con terceros", + "features_title": "Todo lo que necesita", + "features_subtitle": "Una pila GenAI completa y autoalojada bajo su total control.", + "feat_infra_title": "Infraestructura autoalojada", + "feat_infra_desc": "Despliegue en su propio hardware o nube privada. Control total sobre su pila de IA sin dependencias externas.", + "feat_gdpr_title": "Conforme al RGPD", + "feat_gdpr_desc": "Residencia de datos en la UE garantizada. Sus datos nunca abandonan su infraestructura ni se comparten con terceros.", + "feat_llm_title": "Gestion de LLM", + "feat_llm_desc": "Despliegue, monitoree y gestione multiples modelos de lenguaje. Cambie entre modelos sin tiempo de inactividad.", + "feat_agent_title": "Constructor de agentes", + "feat_agent_desc": "Cree agentes de IA personalizados con Langchain y Langfuse integrados para observabilidad y control total.", + "feat_mcp_title": "Gestion de servidores MCP", + "feat_mcp_desc": "Gestione servidores de Model Context Protocol para ampliar sus capacidades de IA con integraciones de herramientas externas.", + "feat_api_title": "Gestion de claves API", + "feat_api_desc": "Genere claves API, realice seguimiento del uso por puesto y establezca permisos detallados para cada integracion.", + "how_title": "En funcionamiento en minutos", + "how_subtitle": "Tres pasos hacia una infraestructura de IA soberana.", + "step_deploy": "Desplegar", + "step_deploy_desc": "Instale CERTifAI en su infraestructura con un solo comando. Compatible con Docker, Kubernetes e instalacion directa.", + "step_configure": "Configurar", + "step_configure_desc": "Conecte su proveedor de identidad, seleccione sus modelos y configure los permisos del equipo a traves del panel de administracion.", + "step_scale": "Escalar", + "step_scale_desc": "Anada usuarios, despliegue mas modelos e integre con sus herramientas existentes mediante claves API y servidores MCP.", + "cta_title": "Listo para tomar el control de su infraestructura de IA?", + "cta_subtitle": "Comience a desplegar IA generativa soberana hoy. No se requiere tarjeta de credito.", + "get_started_free": "Comenzar gratis", + "footer_tagline": "Infraestructura GenAI soberana para empresas.", + "product": "Producto", + "legal": "Legal", + "resources": "Recursos", + "documentation": "Documentacion", + "api_reference": "Referencia API", + "support": "Soporte", + "copyright": "2026 CERTifAI. Todos los derechos reservados." + }, + "article": { + "read_original": "Leer articulo original", + "summarizing": "Resumiendo...", + "summarized_with_ai": "Resumido con IA", + "ask_followup": "Haga una pregunta de seguimiento..." + }, + "impressum": { + "title": "Aviso legal", + "info_tmg": "Informacion segun el 5 TMG", + "company": "CERTifAI GmbH", + "address_street": "Musterstrasse 1", + "address_city": "10115 Berlin", + "address_country": "Alemania", + "represented_by": "Representado por", + "managing_director": "Director general: [Name]", + "contact": "Contacto", + "email": "Correo electronico: info@certifai.example", + "phone": "Telefono: +49 (0) 30 1234567", + "commercial_register": "Registro mercantil", + "registered_at": "Registrado en: Amtsgericht Berlin-Charlottenburg", + "registration_number": "Numero de registro: HRB XXXXXX", + "vat_id": "Numero de IVA", + "vat_number": "Numero de identificacion fiscal segun 27a UStG: DE XXXXXXXXX", + "responsible_content": "Responsable del contenido segun 55 Abs. 2 RStV" + }, + "privacy": { + "title": "Politica de privacidad", + "last_updated": "Ultima actualizacion: febrero de 2026", + "intro_title": "1. Introduccion", + "intro_text": "CERTifAI GmbH (\"nosotros\", \"nuestro/a\") se compromete a proteger sus datos personales. Esta politica de privacidad explica como recopilamos, utilizamos y protegemos su informacion cuando utiliza nuestra plataforma.", + "controller_title": "2. Responsable del tratamiento", + "controller_address": "Musterstrasse 1, 10115 Berlin, Alemania", + "controller_email": "Correo electronico: privacy@certifai.example", + "data_title": "3. Datos que recopilamos", + "data_intro": "Recopilamos unicamente los datos minimos necesarios para prestar nuestros servicios:", + "data_account_label": "Datos de cuenta: ", + "data_account_text": "Nombre, direccion de correo electronico y datos de la organizacion proporcionados durante el registro.", + "data_usage_label": "Datos de uso: ", + "data_usage_text": "Registros de llamadas API, recuento de tokens y metricas de uso de funcionalidades para facturacion y estadisticas.", + "data_technical_label": "Datos tecnicos: ", + "data_technical_text": "Direcciones IP, tipo de navegador e identificadores de sesion para la seguridad y estabilidad de la plataforma.", + "use_title": "4. Como utilizamos sus datos", + "use_1": "Para proporcionar y mantener la plataforma CERTifAI", + "use_2": "Para gestionar su cuenta y suscripcion", + "use_3": "Para comunicar actualizaciones del servicio y avisos de seguridad", + "use_4": "Para cumplir con las obligaciones legales", + "storage_title": "5. Almacenamiento y soberania de datos", + "storage_text": "CERTifAI es una plataforma autoalojada. Todas las cargas de trabajo de IA, datos de modelos y resultados de inferencia permanecen completamente dentro de su propia infraestructura. No accedemos, almacenamos ni procesamos sus datos de IA en nuestros servidores.", + "rights_title": "6. Sus derechos (RGPD)", + "rights_intro": "Segun el RGPD, usted tiene derecho a:", + "rights_access": "Acceder a sus datos personales", + "rights_rectify": "Rectificar datos inexactos", + "rights_erasure": "Solicitar la supresion de sus datos", + "rights_restrict": "Limitar u oponerse al tratamiento", + "rights_portability": "Portabilidad de datos", + "rights_complaint": "Presentar una reclamacion ante una autoridad de control", + "contact_title": "7. Contacto", + "contact_text": "Para consultas relacionadas con la privacidad, contactenos en privacy@certifai.example." + } +} diff --git a/assets/i18n/fr.json b/assets/i18n/fr.json new file mode 100644 index 0000000..113e6f6 --- /dev/null +++ b/assets/i18n/fr.json @@ -0,0 +1,302 @@ +{ + "common": { + "loading": "Chargement...", + "cancel": "Annuler", + "save": "Enregistrer", + "delete": "Supprimer", + "send": "Envoyer", + "close": "Fermer", + "login": "Connexion", + "logout": "Deconnexion", + "on": "ON", + "off": "OFF", + "online": "En ligne", + "offline": "Hors ligne", + "settings": "Parametres", + "search": "Rechercher", + "rename": "Renommer", + "copy": "Copier", + "share": "Partager", + "edit": "Modifier", + "get_started": "Commencer", + "coming_soon": "Bientot disponible", + "back_to_home": "Retour a l'accueil", + "privacy_policy": "Politique de confidentialite", + "impressum": "Mentions legales", + "chunks": "segments", + "upload_file": "Importer un fichier", + "eur_per_month": "EUR / mois", + "up_to_seats": "Jusqu'a {n} postes", + "unlimited_seats": "Postes illimites", + "set": "Defini", + "not_set": "Non defini", + "log_in": "Se connecter", + "features": "Fonctionnalites", + "how_it_works": "Comment ca marche" + }, + "nav": { + "dashboard": "Tableau de bord", + "providers": "Fournisseurs", + "chat": "Chat", + "tools": "Outils", + "knowledge_base": "Base de connaissances", + "developer": "Developpeur", + "organization": "Organisation", + "switch_light": "Passer en mode clair", + "switch_dark": "Passer en mode sombre", + "github": "GitHub", + "agents": "Agents", + "flow": "Flux", + "analytics": "Analytique", + "pricing": "Tarifs" + }, + "auth": { + "redirecting_login": "Redirection vers la connexion...", + "redirecting_secure": "Redirection vers la page de connexion securisee...", + "auth_error": "Erreur d'authentification : {msg}", + "log_in": "Connexion" + }, + "dashboard": { + "title": "Tableau de bord", + "subtitle": "Actualites et mises a jour IA", + "topic_placeholder": "Nom du sujet...", + "ollama_settings": "Parametres Ollama", + "settings_hint": "Laissez vide pour utiliser OLLAMA_URL / OLLAMA_MODEL du fichier .env", + "ollama_url": "URL Ollama", + "ollama_url_placeholder": "Utilise OLLAMA_URL du fichier .env", + "model": "Modele", + "model_placeholder": "Utilise OLLAMA_MODEL du fichier .env", + "searching": "Recherche en cours...", + "search_failed": "Echec de la recherche : {e}", + "ollama_status": "Statut Ollama", + "trending": "Tendances", + "recent_searches": "Recherches recentes" + }, + "chat": { + "new_chat": "Nouvelle conversation", + "general": "General", + "conversations": "Conversations", + "news_chats": "Conversations actualites", + "all_chats": "Toutes les conversations", + "no_conversations": "Aucune conversation pour le moment", + "type_message": "Saisissez un message...", + "model_label": "Modele :", + "no_models": "Aucun modele disponible", + "send_to_start": "Envoyez un message pour demarrer la conversation.", + "you": "Vous", + "assistant": "Assistant", + "thinking": "Reflexion en cours...", + "copy_response": "Copier la derniere reponse", + "copy_conversation": "Copier la conversation", + "edit_last": "Modifier le dernier message", + "just_now": "a l'instant", + "minutes_ago": "il y a {n} min", + "hours_ago": "il y a {n} h", + "days_ago": "il y a {n} j" + }, + "providers": { + "title": "Fournisseurs", + "subtitle": "Configurez vos backends LLM et d'embeddings", + "provider": "Fournisseur", + "model": "Modele", + "embedding_model": "Modele d'embedding", + "api_key": "Cle API", + "api_key_placeholder": "Saisissez la cle API...", + "save_config": "Enregistrer la configuration", + "config_saved": "Configuration enregistree.", + "active_config": "Configuration active", + "embedding": "Embedding" + }, + "tools": { + "title": "Outils", + "subtitle": "Gerez les serveurs MCP et les integrations d'outils", + "calculator": "Calculatrice", + "calculator_desc": "Calcul mathematique et conversion d'unites", + "tavily": "Recherche Tavily", + "tavily_desc": "API de recherche web optimisee par IA pour des informations en temps reel", + "searxng": "SearXNG", + "searxng_desc": "Metamoteur de recherche respectueux de la vie privee", + "file_reader": "Lecteur de fichiers", + "file_reader_desc": "Lire et analyser des fichiers locaux dans divers formats", + "code_executor": "Executeur de code", + "code_executor_desc": "Execution de code en bac a sable pour Python et JavaScript", + "web_scraper": "Extracteur web", + "web_scraper_desc": "Extraire des donnees structurees a partir de pages web", + "email_sender": "Envoi d'e-mails", + "email_sender_desc": "Envoyer des e-mails via le serveur SMTP configure", + "git_ops": "Operations Git", + "git_ops_desc": "Interagir avec les depots Git pour le controle de version" + }, + "knowledge": { + "title": "Base de connaissances", + "subtitle": "Gerez les documents pour la recuperation RAG", + "search_placeholder": "Rechercher des fichiers...", + "name": "Nom", + "type": "Type", + "size": "Taille", + "chunks": "Segments", + "uploaded": "Importe", + "actions": "Actions" + }, + "developer": { + "agents_title": "Constructeur d'agents", + "agents_desc": "Construisez et gerez des agents IA avec LangGraph. Creez des pipelines de raisonnement multi-etapes, des agents utilisant des outils et des flux de travail autonomes.", + "launch_agents": "Lancer le constructeur d'agents", + "flow_title": "Constructeur de flux", + "flow_desc": "Concevez des flux de travail IA visuels avec LangFlow. Glissez-deposez des noeuds pour creer des pipelines de traitement de donnees, des chaines de prompts et des flux d'integration.", + "launch_flow": "Lancer le constructeur de flux", + "analytics_title": "Analytique et observabilite", + "analytics_desc": "Surveillez et analysez vos pipelines IA avec LangFuse. Suivez l'utilisation des tokens, la latence, les couts et les metriques de qualite sur tous vos deployments.", + "launch_analytics": "Lancer LangFuse", + "total_requests": "Requetes totales", + "avg_latency": "Latence moyenne", + "tokens_used": "Tokens utilises", + "error_rate": "Taux d'erreur" + }, + "org": { + "title": "Organisation", + "subtitle": "Gerez les membres et la facturation", + "invite_member": "Inviter un membre", + "seats_used": "Postes utilises", + "of_tokens": "sur {limit} tokens", + "cycle_ends": "Fin du cycle", + "name": "Nom", + "email": "E-mail", + "role": "Role", + "joined": "Inscrit le", + "invite_title": "Inviter un nouveau membre", + "email_address": "Adresse e-mail", + "email_placeholder": "collegue@entreprise.com", + "send_invite": "Envoyer l'invitation", + "pricing_title": "Tarifs", + "pricing_subtitle": "Choisissez le plan adapte a votre organisation" + }, + "pricing": { + "starter": "Starter", + "team": "Team", + "enterprise": "Enterprise", + "up_to_users": "Jusqu'a {n} utilisateurs", + "unlimited_users": "Utilisateurs illimites", + "llm_provider_1": "1 fournisseur LLM", + "all_providers": "Tous les fournisseurs LLM", + "tokens_100k": "100K tokens/mois", + "tokens_1m": "1M tokens/mois", + "unlimited_tokens": "Tokens illimites", + "community_support": "Support communautaire", + "priority_support": "Support prioritaire", + "dedicated_support": "Support dedie", + "basic_analytics": "Analytique de base", + "advanced_analytics": "Analytique avancee", + "full_observability": "Observabilite complete", + "custom_mcp": "Outils MCP personnalises", + "sso": "Integration SSO", + "custom_integrations": "Integrations personnalisees", + "sla": "Garantie SLA", + "on_premise": "Deploiement sur site" + }, + "landing": { + "badge": "Infrastructure GenAI axee sur la confidentialite", + "hero_title_1": "Votre IA. Vos donnees.", + "hero_title_2": "Votre infrastructure.", + "hero_subtitle": "Plateforme d'IA generative auto-hebergee et conforme au RGPD pour les entreprises qui refusent de compromettre leur souverainete des donnees. Deployez des LLM, des agents et des serveurs MCP selon vos propres conditions.", + "learn_more": "En savoir plus", + "social_proof": "Concu pour les entreprises qui valorisent la ", + "data_sovereignty": "souverainete des donnees", + "on_premise": "Sur site", + "compliant": "Conforme", + "data_residency": "Residence des donnees", + "third_party": "Partage avec des tiers", + "features_title": "Tout ce dont vous avez besoin", + "features_subtitle": "Une pile GenAI complete et auto-hebergee sous votre controle total.", + "feat_infra_title": "Infrastructure auto-hebergee", + "feat_infra_desc": "Deployez sur votre propre materiel ou cloud prive. Controle total de votre pile IA sans dependances externes.", + "feat_gdpr_title": "Conforme au RGPD", + "feat_gdpr_desc": "Residence des donnees dans l'UE garantie. Vos donnees ne quittent jamais votre infrastructure et ne sont jamais partagees avec des tiers.", + "feat_llm_title": "Gestion des LLM", + "feat_llm_desc": "Deployez, surveillez et gerez plusieurs modeles de langage. Basculez entre les modeles sans interruption de service.", + "feat_agent_title": "Constructeur d'agents", + "feat_agent_desc": "Creez des agents IA personnalises avec Langchain et Langfuse integres pour une observabilite et un controle complets.", + "feat_mcp_title": "Gestion des serveurs MCP", + "feat_mcp_desc": "Gerez les serveurs Model Context Protocol pour etendre vos capacites IA avec des integrations d'outils externes.", + "feat_api_title": "Gestion des cles API", + "feat_api_desc": "Generez des cles API, suivez l'utilisation par poste et definissez des permissions granulaires pour chaque integration.", + "how_title": "Operationnel en quelques minutes", + "how_subtitle": "Trois etapes vers une infrastructure IA souveraine.", + "step_deploy": "Deployer", + "step_deploy_desc": "Installez CERTifAI sur votre infrastructure avec une seule commande. Compatible Docker, Kubernetes et bare metal.", + "step_configure": "Configurer", + "step_configure_desc": "Connectez votre fournisseur d'identite, selectionnez vos modeles et configurez les permissions d'equipe via le tableau de bord d'administration.", + "step_scale": "Evoluer", + "step_scale_desc": "Ajoutez des utilisateurs, deployez plus de modeles et integrez vos outils existants via des cles API et des serveurs MCP.", + "cta_title": "Pret a prendre le controle de votre infrastructure IA ?", + "cta_subtitle": "Commencez a deployer une IA generative souveraine des aujourd'hui. Aucune carte de credit requise.", + "get_started_free": "Commencer gratuitement", + "footer_tagline": "Infrastructure GenAI souveraine pour les entreprises.", + "product": "Produit", + "legal": "Mentions legales", + "resources": "Ressources", + "documentation": "Documentation", + "api_reference": "Reference API", + "support": "Support", + "copyright": "2026 CERTifAI. Tous droits reserves." + }, + "article": { + "read_original": "Lire l'article original", + "summarizing": "Resume en cours...", + "summarized_with_ai": "Resume par IA", + "ask_followup": "Posez une question complementaire..." + }, + "impressum": { + "title": "Mentions legales", + "info_tmg": "Informations conformement au 5 TMG", + "company": "CERTifAI GmbH", + "address_street": "Musterstrasse 1", + "address_city": "10115 Berlin", + "address_country": "Allemagne", + "represented_by": "Represente par", + "managing_director": "Directeur general : [Nom]", + "contact": "Contact", + "email": "E-mail : info@certifai.example", + "phone": "Telephone : +49 (0) 30 1234567", + "commercial_register": "Registre du commerce", + "registered_at": "Enregistre aupres de : Amtsgericht Berlin-Charlottenburg", + "registration_number": "Numero d'immatriculation : HRB XXXXXX", + "vat_id": "Numero de TVA", + "vat_number": "Numero d'identification TVA conformement au 27a UStG : DE XXXXXXXXX", + "responsible_content": "Responsable du contenu conformement au 55 al. 2 RStV" + }, + "privacy": { + "title": "Politique de confidentialite", + "last_updated": "Derniere mise a jour : fevrier 2026", + "intro_title": "1. Introduction", + "intro_text": "CERTifAI GmbH (\"nous\", \"notre\", \"nos\") s'engage a proteger vos donnees personnelles. Cette politique de confidentialite explique comment nous collectons, utilisons et protegeons vos informations lorsque vous utilisez notre plateforme.", + "controller_title": "2. Responsable du traitement", + "controller_address": "Musterstrasse 1, 10115 Berlin, Allemagne", + "controller_email": "E-mail : privacy@certifai.example", + "data_title": "3. Donnees collectees", + "data_intro": "Nous ne collectons que les donnees strictement necessaires a la fourniture de nos services :", + "data_account_label": "Donnees de compte : ", + "data_account_text": "Nom, adresse e-mail et informations sur l'organisation fournis lors de l'inscription.", + "data_usage_label": "Donnees d'utilisation : ", + "data_usage_text": "Journaux d'appels API, compteurs de tokens et metriques d'utilisation des fonctionnalites pour la facturation et l'analytique.", + "data_technical_label": "Donnees techniques : ", + "data_technical_text": "Adresses IP, type de navigateur et identifiants de session pour la securite et la stabilite de la plateforme.", + "use_title": "4. Utilisation de vos donnees", + "use_1": "Pour fournir et maintenir la plateforme CERTifAI", + "use_2": "Pour gerer votre compte et votre abonnement", + "use_3": "Pour communiquer les mises a jour du service et les avis de securite", + "use_4": "Pour respecter les obligations legales", + "storage_title": "5. Stockage des donnees et souverainete", + "storage_text": "CERTifAI est une plateforme auto-hebergee. Toutes les charges de travail IA, les donnees de modeles et les resultats d'inference restent entierement au sein de votre propre infrastructure. Nous n'accedon pas, ne stockons pas et ne traitons pas vos donnees IA sur nos serveurs.", + "rights_title": "6. Vos droits (RGPD)", + "rights_intro": "En vertu du RGPD, vous avez le droit de :", + "rights_access": "Acceder a vos donnees personnelles", + "rights_rectify": "Rectifier des donnees inexactes", + "rights_erasure": "Demander l'effacement de vos donnees", + "rights_restrict": "Limiter ou vous opposer au traitement", + "rights_portability": "Portabilite des donnees", + "rights_complaint": "Deposer une plainte aupres d'une autorite de controle", + "contact_title": "7. Contact", + "contact_text": "Pour toute question relative a la confidentialite, contactez-nous a privacy@certifai.example." + } +} diff --git a/assets/i18n/pt.json b/assets/i18n/pt.json new file mode 100644 index 0000000..85ee33e --- /dev/null +++ b/assets/i18n/pt.json @@ -0,0 +1,302 @@ +{ + "common": { + "loading": "A carregar...", + "cancel": "Cancelar", + "save": "Guardar", + "delete": "Eliminar", + "send": "Enviar", + "close": "Fechar", + "login": "Iniciar sessao", + "logout": "Terminar sessao", + "on": "LIGADO", + "off": "DESLIGADO", + "online": "Online", + "offline": "Offline", + "settings": "Definicoes", + "search": "Pesquisar", + "rename": "Renomear", + "copy": "Copiar", + "share": "Partilhar", + "edit": "Editar", + "get_started": "Comecar", + "coming_soon": "Em breve", + "back_to_home": "Voltar ao inicio", + "privacy_policy": "Politica de Privacidade", + "impressum": "Impressum", + "chunks": "fragmentos", + "upload_file": "Carregar ficheiro", + "eur_per_month": "EUR / mes", + "up_to_seats": "Ate {n} lugares", + "unlimited_seats": "Lugares ilimitados", + "set": "Definido", + "not_set": "Nao definido", + "log_in": "Iniciar Sessao", + "features": "Funcionalidades", + "how_it_works": "Como Funciona" + }, + "nav": { + "dashboard": "Painel", + "providers": "Fornecedores", + "chat": "Chat", + "tools": "Ferramentas", + "knowledge_base": "Base de Conhecimento", + "developer": "Programador", + "organization": "Organizacao", + "switch_light": "Mudar para modo claro", + "switch_dark": "Mudar para modo escuro", + "github": "GitHub", + "agents": "Agentes", + "flow": "Fluxo", + "analytics": "Analise", + "pricing": "Precos" + }, + "auth": { + "redirecting_login": "A redirecionar para o inicio de sessao...", + "redirecting_secure": "A redirecionar para a pagina de inicio de sessao segura...", + "auth_error": "Erro de autenticacao: {msg}", + "log_in": "Iniciar sessao" + }, + "dashboard": { + "title": "Painel", + "subtitle": "Noticias e atualizacoes de IA", + "topic_placeholder": "Nome do topico...", + "ollama_settings": "Definicoes do Ollama", + "settings_hint": "Deixe vazio para usar OLLAMA_URL / OLLAMA_MODEL do .env", + "ollama_url": "URL do Ollama", + "ollama_url_placeholder": "Utiliza OLLAMA_URL do .env", + "model": "Modelo", + "model_placeholder": "Utiliza OLLAMA_MODEL do .env", + "searching": "A pesquisar...", + "search_failed": "A pesquisa falhou: {e}", + "ollama_status": "Estado do Ollama", + "trending": "Em destaque", + "recent_searches": "Pesquisas recentes" + }, + "chat": { + "new_chat": "Nova conversa", + "general": "Geral", + "conversations": "Conversas", + "news_chats": "Conversas de noticias", + "all_chats": "Todas as conversas", + "no_conversations": "Ainda sem conversas", + "type_message": "Escreva uma mensagem...", + "model_label": "Modelo:", + "no_models": "Nenhum modelo disponivel", + "send_to_start": "Envie uma mensagem para iniciar a conversa.", + "you": "Voce", + "assistant": "Assistente", + "thinking": "A pensar...", + "copy_response": "Copiar ultima resposta", + "copy_conversation": "Copiar conversa", + "edit_last": "Editar ultima mensagem", + "just_now": "agora mesmo", + "minutes_ago": "ha {n}m", + "hours_ago": "ha {n}h", + "days_ago": "ha {n}d" + }, + "providers": { + "title": "Fornecedores", + "subtitle": "Configure os seus backends de LLM e embeddings", + "provider": "Fornecedor", + "model": "Modelo", + "embedding_model": "Modelo de Embedding", + "api_key": "Chave API", + "api_key_placeholder": "Introduza a chave API...", + "save_config": "Guardar Configuracao", + "config_saved": "Configuracao guardada.", + "active_config": "Configuracao Ativa", + "embedding": "Embedding" + }, + "tools": { + "title": "Ferramentas", + "subtitle": "Gerir servidores MCP e integracoes de ferramentas", + "calculator": "Calculadora", + "calculator_desc": "Calculo matematico e conversao de unidades", + "tavily": "Pesquisa Tavily", + "tavily_desc": "API de pesquisa web otimizada por IA para informacao em tempo real", + "searxng": "SearXNG", + "searxng_desc": "Motor de metapesquisa que respeita a privacidade", + "file_reader": "Leitor de Ficheiros", + "file_reader_desc": "Ler e analisar ficheiros locais em varios formatos", + "code_executor": "Executor de Codigo", + "code_executor_desc": "Execucao de codigo em sandbox para Python e JavaScript", + "web_scraper": "Web Scraper", + "web_scraper_desc": "Extrair dados estruturados de paginas web", + "email_sender": "Envio de Email", + "email_sender_desc": "Enviar emails atraves do servidor SMTP configurado", + "git_ops": "Operacoes Git", + "git_ops_desc": "Interagir com repositorios Git para controlo de versoes" + }, + "knowledge": { + "title": "Base de Conhecimento", + "subtitle": "Gerir documentos para recuperacao RAG", + "search_placeholder": "Pesquisar ficheiros...", + "name": "Nome", + "type": "Tipo", + "size": "Tamanho", + "chunks": "Fragmentos", + "uploaded": "Carregado", + "actions": "Acoes" + }, + "developer": { + "agents_title": "Construtor de Agentes", + "agents_desc": "Construa e gira agentes de IA com LangGraph. Crie pipelines de raciocinio multi-etapa, agentes com ferramentas e fluxos de trabalho autonomos.", + "launch_agents": "Abrir Construtor de Agentes", + "flow_title": "Construtor de Fluxos", + "flow_desc": "Desenhe fluxos de trabalho de IA visuais com LangFlow. Arraste e solte nos para criar pipelines de processamento de dados, cadeias de prompts e fluxos de integracao.", + "launch_flow": "Abrir Construtor de Fluxos", + "analytics_title": "Analise e Observabilidade", + "analytics_desc": "Monitorize e analise os seus pipelines de IA com LangFuse. Acompanhe o uso de tokens, latencia, custos e metricas de qualidade em todas as suas implementacoes.", + "launch_analytics": "Abrir LangFuse", + "total_requests": "Total de Pedidos", + "avg_latency": "Latencia Media", + "tokens_used": "Tokens Utilizados", + "error_rate": "Taxa de Erros" + }, + "org": { + "title": "Organizacao", + "subtitle": "Gerir membros e faturacao", + "invite_member": "Convidar Membro", + "seats_used": "Lugares Utilizados", + "of_tokens": "de {limit} tokens", + "cycle_ends": "Fim do Ciclo", + "name": "Nome", + "email": "Email", + "role": "Funcao", + "joined": "Aderiu", + "invite_title": "Convidar Novo Membro", + "email_address": "Endereco de Email", + "email_placeholder": "colleague@company.com", + "send_invite": "Enviar Convite", + "pricing_title": "Precos", + "pricing_subtitle": "Escolha o plano adequado a sua organizacao" + }, + "pricing": { + "starter": "Inicial", + "team": "Equipa", + "enterprise": "Empresarial", + "up_to_users": "Ate {n} utilizadores", + "unlimited_users": "Utilizadores ilimitados", + "llm_provider_1": "1 fornecedor LLM", + "all_providers": "Todos os fornecedores LLM", + "tokens_100k": "100K tokens/mes", + "tokens_1m": "1M tokens/mes", + "unlimited_tokens": "Tokens ilimitados", + "community_support": "Suporte comunitario", + "priority_support": "Suporte prioritario", + "dedicated_support": "Suporte dedicado", + "basic_analytics": "Analise basica", + "advanced_analytics": "Analise avancada", + "full_observability": "Observabilidade completa", + "custom_mcp": "Ferramentas MCP personalizadas", + "sso": "Integracao SSO", + "custom_integrations": "Integracoes personalizadas", + "sla": "Garantia de SLA", + "on_premise": "Implementacao on-premise" + }, + "landing": { + "badge": "Infraestrutura GenAI com Privacidade em Primeiro Lugar", + "hero_title_1": "A Sua IA. Os Seus Dados.", + "hero_title_2": "A Sua Infraestrutura.", + "hero_subtitle": "Plataforma de IA generativa auto-alojada e em conformidade com o RGPD para empresas que nao comprometem a soberania dos dados. Implemente LLMs, agentes e servidores MCP nos seus proprios termos.", + "learn_more": "Saber Mais", + "social_proof": "Criado para empresas que valorizam a ", + "data_sovereignty": "soberania dos dados", + "on_premise": "On-Premise", + "compliant": "Em Conformidade", + "data_residency": "Residencia dos Dados", + "third_party": "Partilha com Terceiros", + "features_title": "Tudo o que Precisa", + "features_subtitle": "Uma stack GenAI completa e auto-alojada sob o seu total controlo.", + "feat_infra_title": "Infraestrutura Auto-Alojada", + "feat_infra_desc": "Implemente no seu proprio hardware ou cloud privada. Controlo total sobre a sua stack de IA sem dependencias externas.", + "feat_gdpr_title": "Em Conformidade com o RGPD", + "feat_gdpr_desc": "Residencia de dados na UE garantida. Os seus dados nunca saem da sua infraestrutura nem sao partilhados com terceiros.", + "feat_llm_title": "Gestao de LLMs", + "feat_llm_desc": "Implemente, monitorize e gira multiplos modelos de linguagem. Alterne entre modelos sem tempo de inatividade.", + "feat_agent_title": "Construtor de Agentes", + "feat_agent_desc": "Crie agentes de IA personalizados com Langchain e Langfuse integrados para total observabilidade e controlo.", + "feat_mcp_title": "Gestao de Servidores MCP", + "feat_mcp_desc": "Gira servidores Model Context Protocol para expandir as capacidades da sua IA com integracoes de ferramentas externas.", + "feat_api_title": "Gestao de Chaves API", + "feat_api_desc": "Gere chaves API, acompanhe o uso por lugar e defina permissoes granulares para cada integracao.", + "how_title": "Operacional em Minutos", + "how_subtitle": "Tres passos para uma infraestrutura de IA soberana.", + "step_deploy": "Implementar", + "step_deploy_desc": "Instale o CERTifAI na sua infraestrutura com um unico comando. Suporte para Docker, Kubernetes e bare metal.", + "step_configure": "Configurar", + "step_configure_desc": "Ligue o seu fornecedor de identidade, selecione os seus modelos e configure as permissoes da equipa atraves do painel de administracao.", + "step_scale": "Escalar", + "step_scale_desc": "Adicione utilizadores, implemente mais modelos e integre com as suas ferramentas existentes atraves de chaves API e servidores MCP.", + "cta_title": "Pronto para assumir o controlo da sua infraestrutura de IA?", + "cta_subtitle": "Comece a implementar GenAI soberana hoje. Sem necessidade de cartao de credito.", + "get_started_free": "Comecar Gratuitamente", + "footer_tagline": "Infraestrutura GenAI soberana para empresas.", + "product": "Produto", + "legal": "Legal", + "resources": "Recursos", + "documentation": "Documentacao", + "api_reference": "Referencia API", + "support": "Suporte", + "copyright": "2026 CERTifAI. Todos os direitos reservados." + }, + "article": { + "read_original": "Ler artigo original", + "summarizing": "A resumir...", + "summarized_with_ai": "Resumido com IA", + "ask_followup": "Faca uma pergunta de seguimento..." + }, + "impressum": { + "title": "Impressum", + "info_tmg": "Informacao de acordo com o 5 TMG", + "company": "CERTifAI GmbH", + "address_street": "Musterstrasse 1", + "address_city": "10115 Berlim", + "address_country": "Alemanha", + "represented_by": "Representado por", + "managing_director": "Diretor Geral: [Name]", + "contact": "Contacto", + "email": "Email: info@certifai.example", + "phone": "Telefone: +49 (0) 30 1234567", + "commercial_register": "Registo Comercial", + "registered_at": "Registado em: Amtsgericht Berlin-Charlottenburg", + "registration_number": "Numero de registo: HRB XXXXXX", + "vat_id": "NIF", + "vat_number": "Numero de identificacao fiscal de acordo com o 27a UStG: DE XXXXXXXXX", + "responsible_content": "Responsavel pelo conteudo de acordo com o 55 Abs. 2 RStV" + }, + "privacy": { + "title": "Politica de Privacidade", + "last_updated": "Ultima atualizacao: fevereiro de 2026", + "intro_title": "1. Introducao", + "intro_text": "A CERTifAI GmbH (\"nos\", \"nosso\", \"nossa\") esta empenhada em proteger os seus dados pessoais. Esta politica de privacidade explica como recolhemos, utilizamos e protegemos as suas informacoes quando utiliza a nossa plataforma.", + "controller_title": "2. Responsavel pelo Tratamento de Dados", + "controller_address": "Musterstrasse 1, 10115 Berlim, Alemanha", + "controller_email": "Email: privacy@certifai.example", + "data_title": "3. Dados que Recolhemos", + "data_intro": "Recolhemos apenas os dados minimos necessarios para prestar os nossos servicos:", + "data_account_label": "Dados da conta: ", + "data_account_text": "Nome, endereco de email e detalhes da organizacao fornecidos durante o registo.", + "data_usage_label": "Dados de utilizacao: ", + "data_usage_text": "Registos de chamadas API, contagem de tokens e metricas de utilizacao de funcionalidades para faturacao e analise.", + "data_technical_label": "Dados tecnicos: ", + "data_technical_text": "Enderecos IP, tipo de navegador e identificadores de sessao para seguranca e estabilidade da plataforma.", + "use_title": "4. Como Utilizamos os Seus Dados", + "use_1": "Para fornecer e manter a plataforma CERTifAI", + "use_2": "Para gerir a sua conta e subscricao", + "use_3": "Para comunicar atualizacoes do servico e avisos de seguranca", + "use_4": "Para cumprir obrigacoes legais", + "storage_title": "5. Armazenamento e Soberania dos Dados", + "storage_text": "O CERTifAI e uma plataforma auto-alojada. Todas as cargas de trabalho de IA, dados de modelos e resultados de inferencia permanecem inteiramente dentro da sua propria infraestrutura. Nao acedemos, armazenamos nem processamos os seus dados de IA nos nossos servidores.", + "rights_title": "6. Os Seus Direitos (RGPD)", + "rights_intro": "Ao abrigo do RGPD, tem o direito de:", + "rights_access": "Aceder aos seus dados pessoais", + "rights_rectify": "Retificar dados incorretos", + "rights_erasure": "Solicitar a eliminacao dos seus dados", + "rights_restrict": "Restringir ou opor-se ao tratamento", + "rights_portability": "Portabilidade dos dados", + "rights_complaint": "Apresentar uma reclamacao junto de uma autoridade de supervisao", + "contact_title": "7. Contacto", + "contact_text": "Para questoes relacionadas com privacidade, contacte-nos em privacy@certifai.example." + } +} diff --git a/assets/main.css b/assets/main.css index 50b37fc..1ed9d8a 100644 --- a/assets/main.css +++ b/assets/main.css @@ -57,6 +57,16 @@ h6 { min-height: 100vh; } +/* ===== Mobile Header ===== */ +.mobile-header { + display: none; +} + +/* ===== Sidebar Backdrop ===== */ +.sidebar-backdrop { + display: none; +} + /* ===== Sidebar ===== */ .sidebar { width: 260px; @@ -70,13 +80,113 @@ h6 { top: 0; } -/* -- Sidebar Header -- */ +/* -- Sidebar Top Row (header + locale picker) -- */ +.sidebar-top-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 20px 14px 16px 20px; + border-bottom: 1px solid var(--border-primary); +} + .sidebar-header { display: flex; align-items: center; gap: 12px; - padding: 24px 20px 20px; - border-bottom: 1px solid var(--border-primary); + min-width: 0; + flex: 1; +} + +/* -- Locale Picker -- */ +.locale-picker { + position: relative; + flex-shrink: 0; + margin-top: 2px; +} + +.locale-picker-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 6px; + border: 1px solid var(--border-primary); + background: transparent; + color: var(--text-muted); + font-size: 11px; + font-weight: 600; + font-family: 'Space Grotesk', sans-serif; + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease; + letter-spacing: 0.5px; +} + +.locale-picker-btn:hover { + background-color: var(--bg-surface); + color: var(--text-primary); + border-color: var(--text-muted); +} + +.locale-picker-code { + line-height: 1; +} + +.locale-picker-backdrop { + position: fixed; + inset: 0; + z-index: 49; +} + +.locale-picker-dropdown { + position: absolute; + top: calc(100% + 4px); + right: 0; + z-index: 50; + min-width: 140px; + background-color: var(--bg-sidebar); + border: 1px solid var(--border-primary); + border-radius: 8px; + padding: 4px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); +} + +.locale-picker-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 10px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-muted); + font-size: 13px; + cursor: pointer; + transition: background-color 0.12s ease, color 0.12s ease; + text-align: left; +} + +.locale-picker-item:hover { + background-color: var(--bg-surface); + color: var(--text-primary); +} + +.locale-picker-item--active { + color: var(--accent); + background-color: rgba(145, 164, 210, 0.1); +} + +.locale-picker-item-code { + font-family: 'Space Grotesk', sans-serif; + font-weight: 600; + font-size: 11px; + letter-spacing: 0.5px; + width: 22px; + text-align: center; +} + +.locale-picker-item-label { + font-weight: 500; } .avatar-circle { @@ -2840,7 +2950,7 @@ h6 { color: var(--text-primary); } -/* ===== Responsive: Dashboard Pages ===== */ +/* ===== Responsive: Tablet (max-width: 1024px) ===== */ @media (max-width: 1024px) { .news-grid, @@ -2888,10 +2998,97 @@ h6 { .news-grid--compact { grid-template-columns: repeat(2, 1fr); } + + .main-content { + padding: 32px 24px; + } + + .chat-page { + margin: -32px -24px; + height: calc(100vh - 64px); + } } +/* ===== Responsive: Mobile (max-width: 768px) ===== */ @media (max-width: 768px) { + /* -- Mobile header bar with hamburger -- */ + .mobile-header { + display: flex; + align-items: center; + gap: 12px; + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 90; + height: 56px; + padding: 0 16px; + background-color: var(--bg-sidebar); + border-bottom: 1px solid var(--border-primary); + } + + .mobile-menu-btn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-primary); + cursor: pointer; + transition: background-color 0.15s ease; + } + + .mobile-menu-btn:hover { + background-color: var(--bg-surface); + } + + .mobile-header-title { + font-family: 'Space Grotesk', sans-serif; + font-size: 18px; + font-weight: 700; + color: var(--text-heading); + } + + /* -- Sidebar: hidden off-screen, slides in as overlay -- */ + .app-shell { + flex-direction: column; + } + + .sidebar { + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 200; + transform: translateX(-100%); + transition: transform 0.25s ease; + width: 280px; + min-width: 280px; + } + + .sidebar--open { + transform: translateX(0); + } + + .sidebar-backdrop { + display: block; + position: fixed; + inset: 0; + z-index: 199; + background-color: rgba(0, 0, 0, 0.5); + } + + /* -- Main content: add top padding for mobile header -- */ + .main-content { + padding: 72px 16px 24px; + min-height: calc(100vh - 56px); + } + + /* -- Dashboard grids -- */ .news-grid, .tools-grid, .pricing-grid { @@ -2902,9 +3099,16 @@ h6 { grid-template-columns: 1fr; } + .dashboard-grid { + grid-template-columns: 1fr; + } + + /* -- Chat page -- */ .chat-page { flex-direction: column; height: auto; + min-height: calc(100vh - 56px); + margin: -72px -16px -24px; } .chat-sidebar-panel { @@ -2915,11 +3119,34 @@ h6 { border-bottom: 1px solid var(--border-primary); } + .chat-messages, + .chat-message-list { + padding: 16px; + } + + .chat-input-bar { + padding: 12px 16px; + } + + .chat-model-bar { + padding: 8px 16px; + } + + .chat-bubble { + max-width: 90%; + } + + /* -- Page header -- */ .page-header { flex-direction: column; gap: 12px; } + .page-title { + font-size: 22px; + } + + /* -- Stats bars -- */ .analytics-stats-bar { flex-direction: column; } @@ -2928,8 +3155,171 @@ h6 { flex-direction: column; } + /* -- Sub navigation (Developer/Org tabs) -- */ + .sub-nav { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + white-space: nowrap; + padding-bottom: 12px; + } + + .sub-nav-item { + flex-shrink: 0; + } + + /* -- Modal -- */ .modal-content { min-width: unset; margin: 16px; + max-width: calc(100vw - 32px); + } + + /* -- Dashboard filters -- */ + .dashboard-filters { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + flex-wrap: nowrap; + padding-bottom: 4px; + } + + .filter-tab { + flex-shrink: 0; + } + + /* -- Providers page -- */ + .providers-layout { + grid-template-columns: 1fr; + } + + /* -- Tables: horizontal scroll -- */ + .knowledge-table-wrapper, + .org-table-wrapper { + margin: 0 -16px; + padding: 0 16px; + } + + /* -- Settings -- */ + .settings-input { + max-width: 100%; + } + + /* -- Placeholder pages -- */ + .placeholder-card { + padding: 32px 20px; + } + + /* -- Article detail -- */ + .article-detail-title { + font-size: 18px; + } + + .article-detail-content { + padding-right: 32px; + } + + /* -- CTA banner -- */ + .cta-banner { + padding: 32px 20px; + } + + .cta-title { + font-size: 22px; + } + + .cta-actions { + flex-direction: column; + align-items: stretch; + } + + /* -- Pricing cards -- */ + .pricing-card { + padding: 24px 20px; + } +} + +/* ===== Responsive: Small Phones (max-width: 480px) ===== */ +@media (max-width: 480px) { + + .main-content { + padding: 64px 12px 16px; + } + + .chat-page { + margin: -64px -12px -16px; + } + + .hero-title { + font-size: 28px; + } + + .hero-subtitle { + font-size: 15px; + } + + .section-title { + font-size: 24px; + } + + .page-title { + font-size: 20px; + } + + .overview-heading { + font-size: 22px; + } + + .dashboard-card { + padding: 16px; + } + + .tool-card { + padding: 16px; + } + + .news-card-body { + padding: 14px; + } + + .social-proof-stats { + gap: 16px; + } + + .proof-divider { + display: none; + } + + .proof-stat-value { + font-size: 20px; + } + + .sidebar { + width: 260px; + min-width: 260px; + } + + .chat-bubble { + max-width: 95%; + padding: 10px 14px; + } + + .modal-content { + padding: 20px; + } + + .landing-nav-inner { + padding: 12px 16px; + } + + .features-section, + .how-it-works-section { + padding: 48px 16px; + } + + .step-card { + padding: 24px 16px; + } + + .feature-card { + padding: 20px 16px; } } \ No newline at end of file diff --git a/assets/tailwind.css b/assets/tailwind.css index ab4799a..c7c9fdd 100644 --- a/assets/tailwind.css +++ b/assets/tailwind.css @@ -356,6 +356,95 @@ } } } + .dropdown { + @layer daisyui.l1.l2.l3 { + position: relative; + display: inline-block; + position-area: var(--anchor-v, bottom) var(--anchor-h, span-right); + & > *:not(:has(~ [class*="dropdown-content"])):focus { + --tw-outline-style: none; + outline-style: none; + @media (forced-colors: active) { + outline: 2px solid transparent; + outline-offset: 2px; + } + } + .dropdown-content { + position: absolute; + } + &.dropdown-close .dropdown-content, &:not(details, .dropdown-open, .dropdown-hover:hover, :focus-within) .dropdown-content, &.dropdown-hover:not(:hover) [tabindex]:first-child:focus:not(:focus-visible) ~ .dropdown-content { + display: none; + transform-origin: top; + opacity: 0%; + scale: 95%; + } + &[popover], .dropdown-content { + z-index: 999; + @media (prefers-reduced-motion: no-preference) { + animation: dropdown 0.2s; + transition-property: opacity, scale, display; + transition-behavior: allow-discrete; + transition-duration: 0.2s; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + } + } + @starting-style { + &[popover], .dropdown-content { + scale: 95%; + opacity: 0; + } + } + &:not(.dropdown-close) { + &.dropdown-open, &:not(.dropdown-hover):focus, &:focus-within { + > [tabindex]:first-child { + pointer-events: none; + } + .dropdown-content { + opacity: 100%; + scale: 100%; + } + } + &.dropdown-hover:hover { + .dropdown-content { + opacity: 100%; + scale: 100%; + } + } + } + &:is(details) { + summary { + &::-webkit-details-marker { + display: none; + } + } + } + &:where([popover]) { + background: #0000; + } + &[popover] { + position: fixed; + color: inherit; + @supports not (position-area: bottom) { + margin: auto; + &.dropdown-close, &.dropdown-open:not(:popover-open) { + display: none; + transform-origin: top; + opacity: 0%; + scale: 95%; + } + &::backdrop { + background-color: color-mix(in oklab, #000 30%, #0000); + } + } + &.dropdown-close, &:not(.dropdown-open, :popover-open) { + display: none; + transform-origin: top; + opacity: 0%; + scale: 95%; + } + } + } + } .btn { :where(&) { @layer daisyui.l1.l2.l3 { diff --git a/src/app.rs b/src/app.rs index 7dd5f29..be6778c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,3 +1,4 @@ +use crate::i18n::Locale; use crate::{components::*, pages::*}; use dioxus::prelude::*; @@ -61,8 +62,29 @@ const GOOGLE_FONTS: &str = "https://fonts.googleapis.com/css2?\ display=swap"; /// Root application component. Loads global assets and mounts the router. +/// +/// Provides a `Signal` context that all child components can read +/// via `use_context::>()` to access the current locale. +/// The locale is persisted in `localStorage` under `"certifai_locale"`. #[component] pub fn App() -> Element { + // Read persisted locale from localStorage on first render. + let initial_locale = { + #[cfg(feature = "web")] + { + web_sys::window() + .and_then(|w| w.local_storage().ok().flatten()) + .and_then(|s| s.get_item("certifai_locale").ok().flatten()) + .map(|code| Locale::from_code(&code)) + .unwrap_or_default() + } + #[cfg(not(feature = "web"))] + { + Locale::default() + } + }; + use_context_provider(|| Signal::new(initial_locale)); + rsx! { // Seggwat feedback widget document::Script { diff --git a/src/components/app_shell.rs b/src/components/app_shell.rs index 9711336..c129558 100644 --- a/src/components/app_shell.rs +++ b/src/components/app_shell.rs @@ -1,6 +1,9 @@ use dioxus::prelude::*; +use dioxus_free_icons::icons::bs_icons::{BsList, BsX}; +use dioxus_free_icons::Icon; use crate::components::sidebar::Sidebar; +use crate::i18n::{t, tw, Locale}; use crate::infrastructure::auth_check::check_auth; use crate::models::AuthInfo; use crate::Route; @@ -12,6 +15,9 @@ use crate::Route; /// sidebar with real user data and the active child route. #[component] pub fn AppShell() -> Element { + let locale = use_context::>(); + let mut mobile_menu_open = use_signal(|| false); + // use_resource memoises the async call and avoids infinite re-render // loops that use_effect + spawn + signal writes can cause. #[allow(clippy::redundant_closure)] @@ -23,12 +29,44 @@ pub fn AppShell() -> Element { match auth_snapshot { Some(Ok(info)) if info.authenticated => { + let menu_open = *mobile_menu_open.read(); + let sidebar_cls = if menu_open { + "sidebar sidebar--open" + } else { + "sidebar" + }; + rsx! { div { class: "app-shell", + // Mobile top bar (visible only on small screens via CSS) + header { class: "mobile-header", + button { + class: "mobile-menu-btn", + onclick: move |_| { + let current = *mobile_menu_open.read(); + mobile_menu_open.set(!current); + }, + if menu_open { + Icon { icon: BsX, width: 24, height: 24 } + } else { + Icon { icon: BsList, width: 24, height: 24 } + } + } + span { class: "mobile-header-title", "CERTifAI" } + } + // Backdrop overlay when sidebar is open on mobile + if menu_open { + div { + class: "sidebar-backdrop", + onclick: move |_| mobile_menu_open.set(false), + } + } Sidebar { email: info.email, name: info.name, avatar_url: info.avatar_url, + class: sidebar_cls, + on_nav: move |_| mobile_menu_open.set(false), } main { class: "main-content", Outlet:: {} } } @@ -40,16 +78,17 @@ pub fn AppShell() -> Element { nav.push(NavigationTarget::::External("/auth".into())); rsx! { div { class: "app-shell loading", - p { "Redirecting to login..." } + p { {t(*locale.read(), "auth.redirecting_login")} } } } } Some(Err(e)) => { let msg = e.to_string(); + let error_text = tw(*locale.read(), "auth.auth_error", &[("msg", &msg)]); rsx! { div { class: "auth-error", - p { "Authentication error: {msg}" } - a { href: "/auth", "Login" } + p { {error_text} } + a { href: "/auth", {t(*locale.read(), "common.login")} } } } } @@ -57,7 +96,7 @@ pub fn AppShell() -> Element { // Still loading. rsx! { div { class: "app-shell loading", - p { "Loading..." } + p { {t(*locale.read(), "common.loading")} } } } } diff --git a/src/components/article_detail.rs b/src/components/article_detail.rs index 51bd7ed..1e7235b 100644 --- a/src/components/article_detail.rs +++ b/src/components/article_detail.rs @@ -1,6 +1,8 @@ +use dioxus::prelude::*; + +use crate::i18n::{t, Locale}; use crate::infrastructure::llm::FollowUpMessage; use crate::models::NewsCard; -use dioxus::prelude::*; /// Side panel displaying the full details of a selected news article. /// @@ -27,6 +29,9 @@ pub fn ArticleDetail( #[props(default = false)] is_chatting: bool, on_chat_send: EventHandler, ) -> Element { + let locale = use_context::>(); + let l = *locale.read(); + let css_suffix = card.category.to_lowercase().replace(' ', "-"); let badge_class = format!("news-badge news-badge--{css_suffix}"); let mut chat_input = use_signal(String::new); @@ -41,7 +46,7 @@ pub fn ArticleDetail( button { class: "article-detail-close", onclick: move |_| on_close.call(()), - "X" + "{t(l, \"common.close\")}" } div { class: "article-detail-content", @@ -74,7 +79,7 @@ pub fn ArticleDetail( href: "{card.url}", target: "_blank", rel: "noopener", - "Read original article" + "{t(l, \"article.read_original\")}" } // AI Summary bubble (below the link) @@ -82,11 +87,11 @@ pub fn ArticleDetail( if is_summarizing { div { class: "ai-summary-bubble-loading", div { class: "ai-summary-dot-pulse" } - span { "Summarizing..." } + span { "{t(l, \"article.summarizing\")}" } } } else if let Some(ref text) = summary { p { class: "ai-summary-bubble-text", "{text}" } - span { class: "ai-summary-bubble-label", "Summarized with AI" } + span { class: "ai-summary-bubble-label", "{t(l, \"article.summarized_with_ai\")}" } } } @@ -123,7 +128,7 @@ pub fn ArticleDetail( input { class: "article-chat-textbox", r#type: "text", - placeholder: "Ask a follow-up question...", + placeholder: "{t(l, \"article.ask_followup\")}", value: "{chat_input}", disabled: is_chatting, oninput: move |e| chat_input.set(e.value()), @@ -147,7 +152,7 @@ pub fn ArticleDetail( chat_input.set(String::new()); } }, - "Send" + "{t(l, \"common.send\")}" } } } diff --git a/src/components/chat_action_bar.rs b/src/components/chat_action_bar.rs index 09e9d48..0dee6be 100644 --- a/src/components/chat_action_bar.rs +++ b/src/components/chat_action_bar.rs @@ -1,3 +1,4 @@ +use crate::i18n::{t, Locale}; use dioxus::prelude::*; use dioxus_free_icons::icons::fa_solid_icons::{FaCopy, FaPenToSquare, FaShareNodes}; @@ -22,6 +23,9 @@ pub fn ChatActionBar( has_assistant_message: bool, has_user_message: bool, ) -> Element { + let locale = use_context::>(); + let l = *locale.read(); + if !has_messages { return rsx! {}; } @@ -31,34 +35,34 @@ pub fn ChatActionBar( button { class: "chat-action-btn", disabled: !has_assistant_message, - title: "Copy last response", + title: "{t(l, \"chat.copy_response\")}", onclick: move |_| on_copy.call(()), dioxus_free_icons::Icon { icon: FaCopy, width: 14, height: 14, } - span { class: "chat-action-label", "Copy" } + span { class: "chat-action-label", "{t(l, \"common.copy\")}" } } button { class: "chat-action-btn", - title: "Copy conversation", + title: "{t(l, \"chat.copy_conversation\")}", onclick: move |_| on_share.call(()), dioxus_free_icons::Icon { icon: FaShareNodes, width: 14, height: 14, } - span { class: "chat-action-label", "Share" } + span { class: "chat-action-label", "{t(l, \"common.share\")}" } } button { class: "chat-action-btn", disabled: !has_user_message, - title: "Edit last message", + title: "{t(l, \"chat.edit_last\")}", onclick: move |_| on_edit.call(()), dioxus_free_icons::Icon { icon: FaPenToSquare, width: 14, height: 14, } - span { class: "chat-action-label", "Edit" } + span { class: "chat-action-label", "{t(l, \"common.edit\")}" } } } } diff --git a/src/components/chat_bubble.rs b/src/components/chat_bubble.rs index ad103a2..5007186 100644 --- a/src/components/chat_bubble.rs +++ b/src/components/chat_bubble.rs @@ -1,3 +1,4 @@ +use crate::i18n::{t, Locale}; use crate::models::{ChatMessage, ChatRole}; use dioxus::prelude::*; @@ -35,6 +36,9 @@ fn markdown_to_html(md: &str) -> String { /// * `message` - The chat message to render #[component] pub fn ChatBubble(message: ChatMessage) -> Element { + let locale = use_context::>(); + let l = *locale.read(); + // System messages are not rendered in the UI if message.role == ChatRole::System { return rsx! {}; @@ -47,8 +51,8 @@ pub fn ChatBubble(message: ChatMessage) -> Element { }; let role_label = match message.role { - ChatRole::User => "You", - ChatRole::Assistant => "Assistant", + ChatRole::User => t(l, "chat.you"), + ChatRole::Assistant => t(l, "chat.assistant"), ChatRole::System => unreachable!(), }; @@ -99,6 +103,9 @@ pub fn ChatBubble(message: ChatMessage) -> Element { /// * `content` - The accumulated streaming content so far #[component] pub fn StreamingBubble(content: String) -> Element { + let locale = use_context::>(); + let l = *locale.read(); + if content.is_empty() { // Thinking state -- no tokens yet rsx! { @@ -109,7 +116,9 @@ pub fn StreamingBubble(content: String) -> Element { span { class: "chat-dot" } span { class: "chat-dot" } } - span { class: "chat-thinking-text", "Thinking..." } + span { class: "chat-thinking-text", + "{t(l, \"chat.thinking\")}" + } } } } @@ -118,7 +127,9 @@ pub fn StreamingBubble(content: String) -> Element { rsx! { div { class: "chat-bubble chat-bubble--assistant chat-bubble--streaming", div { class: "chat-bubble-header", - span { class: "chat-bubble-role", "Assistant" } + span { class: "chat-bubble-role", + "{t(l, \"chat.assistant\")}" + } } div { class: "chat-bubble-content chat-prose", diff --git a/src/components/chat_input_bar.rs b/src/components/chat_input_bar.rs index 44b0bae..d8dc50e 100644 --- a/src/components/chat_input_bar.rs +++ b/src/components/chat_input_bar.rs @@ -1,3 +1,4 @@ +use crate::i18n::{t, Locale}; use dioxus::prelude::*; /// Chat input bar with a textarea and send button. @@ -16,13 +17,16 @@ pub fn ChatInputBar( on_send: EventHandler, is_streaming: bool, ) -> Element { + let locale = use_context::>(); + let l = *locale.read(); + let mut input = input_text; rsx! { div { class: "chat-input-bar", textarea { class: "chat-input", - placeholder: "Type a message...", + placeholder: "{t(l, \"chat.type_message\")}", disabled: is_streaming, rows: "1", value: "{input}", diff --git a/src/components/chat_message_list.rs b/src/components/chat_message_list.rs index f4c6991..a9175ce 100644 --- a/src/components/chat_message_list.rs +++ b/src/components/chat_message_list.rs @@ -1,4 +1,5 @@ use crate::components::{ChatBubble, StreamingBubble}; +use crate::i18n::{t, Locale}; use crate::models::ChatMessage; use dioxus::prelude::*; @@ -18,13 +19,16 @@ pub fn ChatMessageList( streaming_content: String, is_streaming: bool, ) -> Element { + let locale = use_context::>(); + let l = *locale.read(); + rsx! { div { class: "chat-message-list", id: "chat-message-list", if messages.is_empty() && !is_streaming { div { class: "chat-empty", - p { "Send a message to start the conversation." } + p { "{t(l, \"chat.send_to_start\")}" } } } for msg in &messages { diff --git a/src/components/chat_model_selector.rs b/src/components/chat_model_selector.rs index f49adb5..c0d3a9e 100644 --- a/src/components/chat_model_selector.rs +++ b/src/components/chat_model_selector.rs @@ -1,3 +1,4 @@ +use crate::i18n::{t, Locale}; use dioxus::prelude::*; /// Dropdown bar for selecting the LLM model for the current chat session. @@ -17,9 +18,14 @@ pub fn ChatModelSelector( available_models: Vec, on_change: EventHandler, ) -> Element { + let locale = use_context::>(); + let l = *locale.read(); + rsx! { div { class: "chat-model-bar", - label { class: "chat-model-label", "Model:" } + label { class: "chat-model-label", + "{t(l, \"chat.model_label\")}" + } select { class: "chat-model-select", value: "{selected_model}", @@ -34,7 +40,9 @@ pub fn ChatModelSelector( } } if available_models.is_empty() { - option { disabled: true, "No models available" } + option { disabled: true, + "{t(l, \"chat.no_models\")}" + } } } } diff --git a/src/components/chat_sidebar.rs b/src/components/chat_sidebar.rs index f0a32c4..cb1c88c 100644 --- a/src/components/chat_sidebar.rs +++ b/src/components/chat_sidebar.rs @@ -1,3 +1,4 @@ +use crate::i18n::{t, tw, Locale}; use crate::models::{ChatNamespace, ChatSession}; use dioxus::prelude::*; @@ -24,6 +25,9 @@ pub fn ChatSidebar( on_rename: EventHandler<(String, String)>, on_delete: EventHandler, ) -> Element { + let locale = use_context::>(); + let l = *locale.read(); + // Split sessions by namespace let news_sessions: Vec<&ChatSession> = sessions .iter() @@ -40,10 +44,10 @@ pub fn ChatSidebar( rsx! { div { class: "chat-sidebar-panel", div { class: "chat-sidebar-header", - h3 { "Conversations" } + h3 { "{t(l, \"chat.conversations\")}" } button { class: "btn-icon", - title: "New Chat", + title: "{t(l, \"chat.new_chat\")}", onclick: move |_| on_new.call(()), "+" } @@ -51,7 +55,9 @@ pub fn ChatSidebar( div { class: "chat-session-list", // News Chats section if !news_sessions.is_empty() { - div { class: "chat-namespace-header", "News Chats" } + div { class: "chat-namespace-header", + "{t(l, \"chat.news_chats\")}" + } for session in &news_sessions { SessionItem { session: (*session).clone(), @@ -66,10 +72,16 @@ pub fn ChatSidebar( // General section div { class: "chat-namespace-header", - if news_sessions.is_empty() { "All Chats" } else { "General" } + if news_sessions.is_empty() { + "{t(l, \"chat.all_chats\")}" + } else { + "{t(l, \"chat.general\")}" + } } if general_sessions.is_empty() { - p { class: "chat-empty-hint", "No conversations yet" } + p { class: "chat-empty-hint", + "{t(l, \"chat.no_conversations\")}" + } } for session in &general_sessions { SessionItem { @@ -96,6 +108,9 @@ fn SessionItem( on_rename: EventHandler<(String, String)>, on_delete: EventHandler, ) -> Element { + let locale = use_context::>(); + let l = *locale.read(); + let mut rename_sig = rename_state; let item_class = if is_active { "chat-session-item chat-session-item--active" @@ -110,7 +125,7 @@ fn SessionItem( let session_id = session.id.clone(); let session_title = session.title.clone(); - let date_display = format_relative_date(&session.updated_at); + let date_display = format_relative_date(&session.updated_at, l); if is_renaming { let rename_value = rename_sig @@ -172,7 +187,7 @@ fn SessionItem( div { class: "chat-session-actions", button { class: "btn-icon-sm", - title: "Rename", + title: "{t(l, \"common.rename\")}", onclick: move |e: Event| { e.stop_propagation(); rename_sig.set(Some(( @@ -187,7 +202,7 @@ fn SessionItem( } button { class: "btn-icon-sm btn-icon-danger", - title: "Delete", + title: "{t(l, \"common.delete\")}", onclick: move |e: Event| { e.stop_propagation(); on_delete.call(sid_delete.clone()); @@ -204,19 +219,36 @@ fn SessionItem( } /// Format an ISO 8601 timestamp as a relative date string. -fn format_relative_date(iso: &str) -> String { +/// +/// # Arguments +/// +/// * `iso` - ISO 8601 timestamp string +/// * `locale` - The locale to use for translated time labels +fn format_relative_date(iso: &str, locale: Locale) -> String { if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(iso) { let now = chrono::Utc::now(); let diff = now.signed_duration_since(dt); if diff.num_minutes() < 1 { - "just now".to_string() + t(locale, "chat.just_now") } else if diff.num_hours() < 1 { - format!("{}m ago", diff.num_minutes()) + tw( + locale, + "chat.minutes_ago", + &[("n", &diff.num_minutes().to_string())], + ) } else if diff.num_hours() < 24 { - format!("{}h ago", diff.num_hours()) + tw( + locale, + "chat.hours_ago", + &[("n", &diff.num_hours().to_string())], + ) } else if diff.num_days() < 7 { - format!("{}d ago", diff.num_days()) + tw( + locale, + "chat.days_ago", + &[("n", &diff.num_days().to_string())], + ) } else { dt.format("%b %d").to_string() } diff --git a/src/components/dashboard_sidebar.rs b/src/components/dashboard_sidebar.rs index 878e2a5..0623dfb 100644 --- a/src/components/dashboard_sidebar.rs +++ b/src/components/dashboard_sidebar.rs @@ -1,5 +1,6 @@ use dioxus::prelude::*; +use crate::i18n::{t, Locale}; use crate::infrastructure::ollama::{get_ollama_status, OllamaStatus}; /// Right sidebar for the dashboard, showing Ollama status, trending topics, @@ -21,6 +22,9 @@ pub fn DashboardSidebar( recent_searches: Vec, on_topic_click: EventHandler, ) -> Element { + let locale = use_context::>(); + let l = *locale.read(); + // Fetch Ollama status once on mount. // use_resource with no signal dependencies runs exactly once and // won't re-fire on parent re-renders (unlike use_effect). @@ -50,14 +54,14 @@ pub fn DashboardSidebar( // -- Ollama Status Section -- div { class: "sidebar-section", - h4 { class: "sidebar-section-title", "Ollama Status" } + h4 { class: "sidebar-section-title", "{t(l, \"dashboard.ollama_status\")}" } div { class: "sidebar-status-row", span { class: if current_status.online { "sidebar-status-dot sidebar-status-dot--online" } else { "sidebar-status-dot sidebar-status-dot--offline" } } span { class: "sidebar-status-label", if current_status.online { - "Online" + "{t(l, \"common.online\")}" } else { - "Offline" + "{t(l, \"common.offline\")}" } } } @@ -73,7 +77,7 @@ pub fn DashboardSidebar( // -- Trending Topics Section -- if !trending.is_empty() { div { class: "sidebar-section", - h4 { class: "sidebar-section-title", "Trending" } + h4 { class: "sidebar-section-title", "{t(l, \"dashboard.trending\")}" } for topic in trending.iter() { { let t = topic.clone(); @@ -92,7 +96,7 @@ pub fn DashboardSidebar( // -- Recent Searches Section -- if !recent_searches.is_empty() { div { class: "sidebar-section", - h4 { class: "sidebar-section-title", "Recent Searches" } + h4 { class: "sidebar-section-title", "{t(l, \"dashboard.recent_searches\")}" } for search in recent_searches.iter() { { let s = search.clone(); diff --git a/src/components/file_row.rs b/src/components/file_row.rs index 1042da6..fd495dc 100644 --- a/src/components/file_row.rs +++ b/src/components/file_row.rs @@ -1,6 +1,8 @@ -use crate::models::KnowledgeFile; use dioxus::prelude::*; +use crate::i18n::{t, Locale}; +use crate::models::KnowledgeFile; + /// Renders a table row for a knowledge base file. /// /// # Arguments @@ -9,6 +11,9 @@ use dioxus::prelude::*; /// * `on_delete` - Callback fired when the delete button is clicked #[component] pub fn FileRow(file: KnowledgeFile, on_delete: EventHandler) -> Element { + let locale = use_context::>(); + let l = *locale.read(); + // Format file size for human readability (Python devs: similar to humanize.naturalsize) let size_display = format_size(file.size_bytes); @@ -20,7 +25,7 @@ pub fn FileRow(file: KnowledgeFile, on_delete: EventHandler) -> Element } td { "{file.kind.label()}" } td { "{size_display}" } - td { "{file.chunk_count} chunks" } + td { "{file.chunk_count} {t(l, \"common.chunks\")}" } td { "{file.uploaded_at}" } td { button { @@ -29,7 +34,7 @@ pub fn FileRow(file: KnowledgeFile, on_delete: EventHandler) -> Element let id = file.id.clone(); move |_| on_delete.call(id.clone()) }, - "Delete" + "{t(l, \"common.delete\")}" } } } diff --git a/src/components/login.rs b/src/components/login.rs index 80b5bf9..887c6f9 100644 --- a/src/components/login.rs +++ b/src/components/login.rs @@ -1,6 +1,8 @@ -use crate::Route; use dioxus::prelude::*; +use crate::i18n::{t, Locale}; +use crate::Route; + /// Login redirect component. /// /// Redirects the user to the external OAuth authentication endpoint. @@ -12,6 +14,8 @@ use dioxus::prelude::*; #[component] pub fn Login(redirect_url: String) -> Element { let navigator = use_navigator(); + let locale = use_context::>(); + let l = *locale.read(); use_effect(move || { // Default to /dashboard when redirect_url is empty. @@ -25,6 +29,6 @@ pub fn Login(redirect_url: String) -> Element { }); rsx!( - div { class: "text-center p-6", "Redirecting to secure login page…" } + div { class: "text-center p-6", "{t(l, \"auth.redirecting_secure\")}" } ) } diff --git a/src/components/pricing_card.rs b/src/components/pricing_card.rs index 82532e0..9522842 100644 --- a/src/components/pricing_card.rs +++ b/src/components/pricing_card.rs @@ -1,6 +1,8 @@ -use crate::models::PricingPlan; use dioxus::prelude::*; +use crate::i18n::{t, tw, Locale}; +use crate::models::PricingPlan; + /// Renders a pricing plan card with features list and call-to-action button. /// /// # Arguments @@ -9,6 +11,9 @@ use dioxus::prelude::*; /// * `on_select` - Callback fired when the CTA button is clicked #[component] pub fn PricingCard(plan: PricingPlan, on_select: EventHandler) -> Element { + let locale = use_context::>(); + let l = *locale.read(); + let card_class = if plan.highlighted { "pricing-card pricing-card--highlighted" } else { @@ -16,8 +21,8 @@ pub fn PricingCard(plan: PricingPlan, on_select: EventHandler) -> Elemen }; let seats_label = match plan.max_seats { - Some(n) => format!("Up to {n} seats"), - None => "Unlimited seats".to_string(), + Some(n) => tw(l, "common.up_to_seats", &[("n", &n.to_string())]), + None => t(l, "common.unlimited_seats"), }; rsx! { @@ -25,7 +30,7 @@ pub fn PricingCard(plan: PricingPlan, on_select: EventHandler) -> Elemen h3 { class: "pricing-card-name", "{plan.name}" } div { class: "pricing-card-price", span { class: "pricing-card-amount", "{plan.price_eur}" } - span { class: "pricing-card-period", " EUR / month" } + span { class: "pricing-card-period", " {t(l, \"common.eur_per_month\")}" } } p { class: "pricing-card-seats", "{seats_label}" } ul { class: "pricing-card-features", @@ -39,7 +44,7 @@ pub fn PricingCard(plan: PricingPlan, on_select: EventHandler) -> Elemen let id = plan.id.clone(); move |_| on_select.call(id.clone()) }, - "Get Started" + "{t(l, \"common.get_started\")}" } } } diff --git a/src/components/sidebar.rs b/src/components/sidebar.rs index 9b35c14..a1d81c6 100644 --- a/src/components/sidebar.rs +++ b/src/components/sidebar.rs @@ -1,15 +1,20 @@ use dioxus::prelude::*; use dioxus_free_icons::icons::bs_icons::{ BsBoxArrowRight, BsBuilding, BsChatDots, BsCloudArrowUp, BsCodeSlash, BsCollection, BsGithub, - BsGrid, BsHouseDoor, BsMoonFill, BsPuzzle, BsSunFill, + BsGlobe2, BsGrid, BsHouseDoor, BsMoonFill, BsPuzzle, BsSunFill, }; use dioxus_free_icons::Icon; +use crate::i18n::{t, Locale}; use crate::Route; /// Navigation entry for the sidebar. +/// +/// `key` is a stable identifier used for active-route detection and never +/// changes across locales. `label` is the translated display string. struct NavItem { - label: &'static str, + key: &'static str, + label: String, route: Route, /// Bootstrap icon element rendered beside the label. icon: Element, @@ -22,41 +27,60 @@ struct NavItem { /// * `name` - User display name (shown in header if non-empty). /// * `email` - Email address displayed beneath the avatar placeholder. /// * `avatar_url` - URL for the avatar image (unused placeholder for now). +/// * `class` - CSS class override (e.g. to add `sidebar--open` on mobile). +/// * `on_nav` - Callback fired when a nav link is clicked (used to close +/// the mobile menu). #[component] -pub fn Sidebar(name: String, email: String, avatar_url: String) -> Element { +pub fn Sidebar( + name: String, + email: String, + avatar_url: String, + #[props(default = "sidebar".to_string())] class: String, + #[props(default)] on_nav: EventHandler<()>, +) -> Element { + let locale = use_context::>(); + let locale_val = *locale.read(); + let nav_items: Vec = vec![ NavItem { - label: "Dashboard", + key: "dashboard", + label: t(locale_val, "nav.dashboard"), route: Route::DashboardPage {}, icon: rsx! { Icon { icon: BsHouseDoor, width: 18, height: 18 } }, }, NavItem { - label: "Providers", + key: "providers", + label: t(locale_val, "nav.providers"), route: Route::ProvidersPage {}, icon: rsx! { Icon { icon: BsCloudArrowUp, width: 18, height: 18 } }, }, NavItem { - label: "Chat", + key: "chat", + label: t(locale_val, "nav.chat"), route: Route::ChatPage {}, icon: rsx! { Icon { icon: BsChatDots, width: 18, height: 18 } }, }, NavItem { - label: "Tools", + key: "tools", + label: t(locale_val, "nav.tools"), route: Route::ToolsPage {}, icon: rsx! { Icon { icon: BsPuzzle, width: 18, height: 18 } }, }, NavItem { - label: "Knowledge Base", + key: "knowledge_base", + label: t(locale_val, "nav.knowledge_base"), route: Route::KnowledgePage {}, icon: rsx! { Icon { icon: BsCollection, width: 18, height: 18 } }, }, NavItem { - label: "Developer", + key: "developer", + label: t(locale_val, "nav.developer"), route: Route::AgentsPage {}, icon: rsx! { Icon { icon: BsCodeSlash, width: 18, height: 18 } }, }, NavItem { - label: "Organization", + key: "organization", + label: t(locale_val, "nav.organization"), route: Route::OrgPricingPage {}, icon: rsx! { Icon { icon: BsBuilding, width: 18, height: 18 } }, }, @@ -64,10 +88,14 @@ pub fn Sidebar(name: String, email: String, avatar_url: String) -> Element { // Determine current path to highlight the active nav link. let current_route = use_route::(); + let logout_label = t(locale_val, "common.logout"); rsx! { - aside { class: "sidebar", - SidebarHeader { name, email: email.clone(), avatar_url } + aside { class: "{class}", + div { class: "sidebar-top-row", + SidebarHeader { name, email: email.clone(), avatar_url } + LocalePicker {} + } nav { class: "sidebar-nav", for item in nav_items { @@ -76,16 +104,19 @@ pub fn Sidebar(name: String, email: String, avatar_url: String) -> Element { // item when any child route within the nested shell is active. let is_active = match ¤t_route { Route::AgentsPage {} | Route::FlowPage {} | Route::AnalyticsPage {} => { - item.label == "Developer" + item.key == "developer" } Route::OrgPricingPage {} | Route::OrgDashboardPage {} => { - item.label == "Organization" + item.key == "organization" } _ => item.route == current_route, }; let cls = if is_active { "sidebar-link active" } else { "sidebar-link" }; rsx! { - Link { to: item.route, class: cls, + Link { + to: item.route, + class: cls, + onclick: move |_| on_nav.call(()), {item.icon} span { "{item.label}" } } @@ -99,7 +130,7 @@ pub fn Sidebar(name: String, email: String, avatar_url: String) -> Element { to: NavigationTarget::::External("/logout".into()), class: "sidebar-link logout-btn", Icon { icon: BsBoxArrowRight, width: 18, height: 18 } - span { "Logout" } + span { "{logout_label}" } } ThemeToggle {} } @@ -157,6 +188,8 @@ fn SidebarHeader(name: String, email: String, avatar_url: String) -> Element { /// in `localStorage` so it survives page reloads. #[component] fn ThemeToggle() -> Element { + let locale = use_context::>(); + let mut is_dark = use_signal(|| { // Read persisted preference from localStorage on first render. #[cfg(feature = "web")] @@ -215,11 +248,17 @@ fn ThemeToggle() -> Element { }; let dark = *is_dark.read(); + let locale_val = *locale.read(); + let title = if dark { + t(locale_val, "nav.switch_light") + } else { + t(locale_val, "nav.switch_dark") + }; rsx! { button { class: "theme-toggle-btn", - title: if dark { "Switch to light mode" } else { "Switch to dark mode" }, + title: "{title}", onclick: toggle, if dark { Icon { icon: BsSunFill, width: 16, height: 16 } @@ -230,25 +269,106 @@ fn ThemeToggle() -> Element { } } +/// Compact language picker with globe icon and ISO 3166-1 alpha-2 code. +/// +/// Renders a button showing a globe icon and the current locale's two-letter +/// country code (e.g. "EN", "DE"). Clicking toggles a dropdown overlay with +/// all available locales. Persists the selection to `localStorage`. +#[component] +fn LocalePicker() -> Element { + let mut locale = use_context::>(); + let current = *locale.read(); + let mut open = use_signal(|| false); + + let mut select_locale = move |new_locale: Locale| { + locale.set(new_locale); + open.set(false); + + #[cfg(feature = "web")] + { + if let Some(storage) = web_sys::window().and_then(|w| w.local_storage().ok().flatten()) + { + let _ = storage.set_item("certifai_locale", new_locale.code()); + } + } + }; + + let code_upper = current.code().to_uppercase(); + + rsx! { + div { class: "locale-picker", + button { + class: "locale-picker-btn", + title: current.label(), + onclick: move |_| { + let cur = *open.read(); + open.set(!cur); + }, + Icon { icon: BsGlobe2, width: 14, height: 14 } + span { class: "locale-picker-code", "{code_upper}" } + } + if *open.read() { + // Invisible backdrop to close dropdown on outside click + div { + class: "locale-picker-backdrop", + onclick: move |_| open.set(false), + } + div { class: "locale-picker-dropdown", + for loc in Locale::all() { + { + let is_active = *loc == current; + let cls = if is_active { + "locale-picker-item locale-picker-item--active" + } else { + "locale-picker-item" + }; + let loc_copy = *loc; + rsx! { + button { + class: "{cls}", + onclick: move |_| select_locale(loc_copy), + span { class: "locale-picker-item-code", + "{loc_copy.code().to_uppercase()}" + } + span { class: "locale-picker-item-label", + "{loc_copy.label()}" + } + } + } + } + } + } + } + } + } +} + /// Footer section with version string and placeholder social links. #[component] fn SidebarFooter() -> Element { + let locale = use_context::>(); + let locale_val = *locale.read(); let version = env!("CARGO_PKG_VERSION"); + let github_title = t(locale_val, "nav.github"); + let impressum_title = t(locale_val, "common.impressum"); + let privacy_label = t(locale_val, "common.privacy_policy"); + let impressum_label = t(locale_val, "common.impressum"); + rsx! { footer { class: "sidebar-footer", div { class: "sidebar-social", - a { href: "#", class: "social-link", title: "GitHub", + a { href: "#", class: "social-link", title: "{github_title}", Icon { icon: BsGithub, width: 16, height: 16 } } - a { href: "#", class: "social-link", title: "Impressum", + a { href: "#", class: "social-link", title: "{impressum_title}", Icon { icon: BsGrid, width: 16, height: 16 } } } div { class: "sidebar-legal", - Link { to: Route::PrivacyPage {}, class: "legal-link", "Privacy Policy" } + Link { to: Route::PrivacyPage {}, class: "legal-link", "{privacy_label}" } span { class: "legal-sep", "|" } - Link { to: Route::ImpressumPage {}, class: "legal-link", "Impressum" } + Link { to: Route::ImpressumPage {}, class: "legal-link", "{impressum_label}" } } p { class: "sidebar-version", "v{version}" } } diff --git a/src/components/sub_nav.rs b/src/components/sub_nav.rs index 2ddfc03..2081216 100644 --- a/src/components/sub_nav.rs +++ b/src/components/sub_nav.rs @@ -9,7 +9,7 @@ use dioxus::prelude::*; /// * `route` - Route to navigate to when clicked #[derive(Clone, PartialEq)] pub struct SubNavItem { - pub label: &'static str, + pub label: String, pub route: Route, } diff --git a/src/components/tool_card.rs b/src/components/tool_card.rs index 0383083..d90bfc2 100644 --- a/src/components/tool_card.rs +++ b/src/components/tool_card.rs @@ -1,6 +1,8 @@ -use crate::models::McpTool; use dioxus::prelude::*; +use crate::i18n::{t, Locale}; +use crate::models::McpTool; + /// Renders an MCP tool card with name, description, status indicator, and toggle. /// /// # Arguments @@ -9,6 +11,9 @@ use dioxus::prelude::*; /// * `on_toggle` - Callback fired when the enable/disable toggle is clicked #[component] pub fn ToolCard(tool: McpTool, on_toggle: EventHandler) -> Element { + let locale = use_context::>(); + let l = *locale.read(); + let status_class = format!("tool-status tool-status--{}", tool.status.css_class()); let toggle_class = if tool.enabled { "tool-toggle tool-toggle--on" @@ -33,9 +38,9 @@ pub fn ToolCard(tool: McpTool, on_toggle: EventHandler) -> Element { move |_| on_toggle.call(id.clone()) }, if tool.enabled { - "ON" + "{t(l, \"common.on\")}" } else { - "OFF" + "{t(l, \"common.off\")}" } } } diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs new file mode 100644 index 0000000..2f52baa --- /dev/null +++ b/src/i18n/mod.rs @@ -0,0 +1,242 @@ +use std::collections::HashMap; +use std::sync::LazyLock; + +use serde_json::Value; + +/// Supported application locales. +/// +/// Each variant maps to an ISO 639-1 code and a human-readable label +/// displayed in the language picker. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Locale { + #[default] + En, + De, + Fr, + Es, + Pt, +} + +impl Locale { + /// ISO 639-1 language code. + pub fn code(self) -> &'static str { + match self { + Locale::En => "en", + Locale::De => "de", + Locale::Fr => "fr", + Locale::Es => "es", + Locale::Pt => "pt", + } + } + + /// Human-readable label in the locale's own language. + pub fn label(self) -> &'static str { + match self { + Locale::En => "English", + Locale::De => "Deutsch", + Locale::Fr => "Francais", + Locale::Es => "Espanol", + Locale::Pt => "Portugues", + } + } + + /// All available locales. + pub fn all() -> &'static [Locale] { + &[Locale::En, Locale::De, Locale::Fr, Locale::Es, Locale::Pt] + } + + /// Parse a locale from its ISO 639-1 code. + /// + /// Returns `Locale::En` for unrecognized codes. + pub fn from_code(code: &str) -> Self { + match code { + "de" => Locale::De, + "fr" => Locale::Fr, + "es" => Locale::Es, + "pt" => Locale::Pt, + _ => Locale::En, + } + } +} + +type TranslationMap = HashMap; + +/// All translations loaded at compile time and parsed lazily on first access. +/// +/// Uses `LazyLock` (stable since Rust 1.80) to avoid runtime file I/O. +/// Each locale's JSON is embedded via `include_str!` and flattened into +/// dot-separated keys (e.g. `"nav.dashboard"` -> `"Dashboard"`). +static TRANSLATIONS: LazyLock> = LazyLock::new(|| { + let mut map = HashMap::with_capacity(5); + map.insert( + "en", + parse_translations(include_str!("../../assets/i18n/en.json")), + ); + map.insert( + "de", + parse_translations(include_str!("../../assets/i18n/de.json")), + ); + map.insert( + "fr", + parse_translations(include_str!("../../assets/i18n/fr.json")), + ); + map.insert( + "es", + parse_translations(include_str!("../../assets/i18n/es.json")), + ); + map.insert( + "pt", + parse_translations(include_str!("../../assets/i18n/pt.json")), + ); + map +}); + +/// Parse a JSON string into a flat `key -> value` map. +/// +/// Nested objects are flattened with dot separators: +/// `{ "nav": { "home": "Home" } }` becomes `"nav.home" -> "Home"`. +fn parse_translations(json: &str) -> TranslationMap { + // SAFETY: translation JSON files are bundled at compile time and are + // validated during development. A malformed file will panic here during + // the first access, which surfaces immediately in testing. + let value: Value = serde_json::from_str(json).unwrap_or(Value::Object(Default::default())); + let mut map = TranslationMap::new(); + flatten_json("", &value, &mut map); + map +} + +/// Recursively flatten a JSON value into dot-separated keys. +fn flatten_json(prefix: &str, value: &Value, map: &mut TranslationMap) { + match value { + Value::Object(obj) => { + for (key, val) in obj { + let new_prefix = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + flatten_json(&new_prefix, val, map); + } + } + Value::String(s) => { + map.insert(prefix.to_string(), s.clone()); + } + // Non-string leaf values are skipped (numbers, bools, nulls) + _ => {} + } +} + +/// Look up a translation for the given locale and key. +/// +/// Falls back to English if the key is missing in the target locale. +/// Returns the raw key if not found in any locale (useful for debugging +/// missing translations). +/// +/// # Arguments +/// +/// * `locale` - The target locale +/// * `key` - Dot-separated translation key (e.g. `"nav.dashboard"`) +/// +/// # Returns +/// +/// The translated string, or the key itself as a fallback. +pub fn t(locale: Locale, key: &str) -> String { + TRANSLATIONS + .get(locale.code()) + .and_then(|map| map.get(key)) + .cloned() + .unwrap_or_else(|| { + // Fallback to English + TRANSLATIONS + .get("en") + .and_then(|map| map.get(key)) + .cloned() + .unwrap_or_else(|| key.to_string()) + }) +} + +/// Look up a translation and substitute variables. +/// +/// Variables in the translation string use `{name}` syntax. +/// Each `(name, value)` pair in `vars` replaces `{name}` with `value`. +/// +/// # Arguments +/// +/// * `locale` - The target locale +/// * `key` - Dot-separated translation key +/// * `vars` - Slice of `(name, value)` pairs for substitution +/// +/// # Returns +/// +/// The translated string with all variables substituted. +/// +/// # Examples +/// +/// ``` +/// use dashboard::i18n::{tw, Locale}; +/// let text = tw(Locale::En, "chat.minutes_ago", &[("n", "5")]); +/// assert_eq!(text, "5m ago"); +/// ``` +pub fn tw(locale: Locale, key: &str, vars: &[(&str, &str)]) -> String { + let mut result = t(locale, key); + for (name, value) in vars { + result = result.replace(&format!("{{{name}}}"), value); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn english_lookup() { + let result = t(Locale::En, "nav.dashboard"); + assert_eq!(result, "Dashboard"); + } + + #[test] + fn german_lookup() { + let result = t(Locale::De, "nav.dashboard"); + assert_eq!(result, "Dashboard"); + } + + #[test] + fn fallback_to_english() { + // If a key exists in English but not in another locale, English is returned + let en = t(Locale::En, "common.loading"); + let result = t(Locale::De, "common.loading"); + // German should have its own translation, but if missing, falls back to EN + assert!(!result.is_empty()); + // Just verify it doesn't return the key itself + assert_ne!(result, "common.loading"); + let _ = en; // suppress unused warning + } + + #[test] + fn missing_key_returns_key() { + let result = t(Locale::En, "nonexistent.key"); + assert_eq!(result, "nonexistent.key"); + } + + #[test] + fn variable_substitution() { + let result = tw(Locale::En, "chat.minutes_ago", &[("n", "5")]); + assert_eq!(result, "5m ago"); + } + + #[test] + fn locale_from_code() { + assert_eq!(Locale::from_code("de"), Locale::De); + assert_eq!(Locale::from_code("fr"), Locale::Fr); + assert_eq!(Locale::from_code("unknown"), Locale::En); + } + + #[test] + fn all_locales_loaded() { + for locale in Locale::all() { + let result = t(*locale, "nav.dashboard"); + assert!(!result.is_empty()); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 6b3c738..780c17c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,12 @@ mod app; mod components; +pub mod i18n; pub mod infrastructure; mod models; mod pages; pub use app::*; pub use components::*; - +pub use i18n::*; pub use models::*; pub use pages::*; diff --git a/src/pages/chat.rs b/src/pages/chat.rs index cc59c32..2e66310 100644 --- a/src/pages/chat.rs +++ b/src/pages/chat.rs @@ -1,6 +1,7 @@ use crate::components::{ ChatActionBar, ChatInputBar, ChatMessageList, ChatModelSelector, ChatSidebar, }; +use crate::i18n::{t, Locale}; use crate::infrastructure::chat::{ chat_complete, create_chat_session, delete_chat_session, list_chat_messages, list_chat_sessions, rename_chat_session, save_chat_message, @@ -15,6 +16,9 @@ use dioxus::prelude::*; /// Messages stream via `EventSource` connected to `/api/chat/stream`. #[component] pub fn ChatPage() -> Element { + let locale = use_context::>(); + let l = *locale.read(); + // ---- Signals ---- let mut active_session_id: Signal> = use_signal(|| None); let mut messages: Signal> = use_signal(Vec::new); @@ -68,9 +72,10 @@ pub fn ChatPage() -> Element { // Create new session let on_new = move |_: ()| { let model = selected_model.read().clone(); + let new_chat_title = t(l, "chat.new_chat"); spawn(async move { match create_chat_session( - "New Chat".to_string(), + new_chat_title, "General".to_string(), "ollama".to_string(), model, @@ -235,14 +240,17 @@ pub fn ChatPage() -> Element { let on_share = move |_: ()| { #[cfg(feature = "web")] { + let you_label = t(l, "chat.you"); + let assistant_label = t(l, "chat.assistant"); let text: String = messages .read() .iter() .filter(|m| m.role != ChatRole::System) .map(|m| { let label = match m.role { - ChatRole::User => "You", - ChatRole::Assistant => "Assistant", + ChatRole::User => &you_label, + ChatRole::Assistant => &assistant_label, + // Filtered out above, but required for exhaustive match ChatRole::System => "System", }; format!("{label}:\n{}\n", m.content) diff --git a/src/pages/dashboard.rs b/src/pages/dashboard.rs index 2dc6d81..aedfbc4 100644 --- a/src/pages/dashboard.rs +++ b/src/pages/dashboard.rs @@ -2,6 +2,7 @@ use dioxus::prelude::*; use dioxus_sdk::storage::use_persistent; use crate::components::{ArticleDetail, DashboardSidebar, NewsCardView, PageHeader}; +use crate::i18n::{t, Locale}; use crate::infrastructure::chat::{create_chat_session, save_chat_message}; use crate::infrastructure::llm::FollowUpMessage; use crate::models::NewsCard; @@ -28,6 +29,9 @@ const DEFAULT_TOPICS: &[&str] = &[ /// - `certifai_ollama_model`: Ollama model ID for summarization #[component] pub fn DashboardPage() -> Element { + let locale = use_context::>(); + let l = *locale.read(); + // Persistent state stored in localStorage let mut custom_topics = use_persistent("certifai_topics".to_string(), Vec::::new); // Default to empty so the server functions use OLLAMA_URL / OLLAMA_MODEL @@ -133,8 +137,8 @@ pub fn DashboardPage() -> Element { rsx! { section { class: "dashboard-page", PageHeader { - title: "Dashboard".to_string(), - subtitle: "AI news and updates".to_string(), + title: t(l, "dashboard.title"), + subtitle: t(l, "dashboard.subtitle"), } // Topic tabs row @@ -188,7 +192,7 @@ pub fn DashboardPage() -> Element { input { class: "topic-input", r#type: "text", - placeholder: "Topic name...", + placeholder: "{t(l, \"dashboard.topic_placeholder\")}", value: "{new_topic_text}", oninput: move |e| new_topic_text.set(e.value()), onkeypress: move |e| { @@ -214,7 +218,7 @@ pub fn DashboardPage() -> Element { show_add_input.set(false); new_topic_text.set(String::new()); }, - "Cancel" + "{t(l, \"common.cancel\")}" } } } else { @@ -236,33 +240,33 @@ pub fn DashboardPage() -> Element { } show_settings.set(!currently_shown); }, - "Settings" + "{t(l, \"common.settings\")}" } } // Settings panel (collapsible) if *show_settings.read() { div { class: "settings-panel", - h4 { class: "settings-panel-title", "Ollama Settings" } + h4 { class: "settings-panel-title", "{t(l, \"dashboard.ollama_settings\")}" } p { class: "settings-hint", - "Leave empty to use OLLAMA_URL / OLLAMA_MODEL from .env" + "{t(l, \"dashboard.settings_hint\")}" } div { class: "settings-field", - label { "Ollama URL" } + label { "{t(l, \"dashboard.ollama_url\")}" } input { class: "settings-input", r#type: "text", - placeholder: "Uses OLLAMA_URL from .env", + placeholder: "{t(l, \"dashboard.ollama_url_placeholder\")}", value: "{settings_url}", oninput: move |e| settings_url.set(e.value()), } } div { class: "settings-field", - label { "Model" } + label { "{t(l, \"dashboard.model\")}" } input { class: "settings-input", r#type: "text", - placeholder: "Uses OLLAMA_MODEL from .env", + placeholder: "{t(l, \"dashboard.model_placeholder\")}", value: "{settings_model}", oninput: move |e| settings_model.set(e.value()), } @@ -274,14 +278,14 @@ pub fn DashboardPage() -> Element { *ollama_model.write() = settings_model.read().trim().to_string(); show_settings.set(false); }, - "Save" + "{t(l, \"common.save\")}" } } } // Loading / error state if is_loading { - div { class: "dashboard-loading", "Searching..." } + div { class: "dashboard-loading", "{t(l, \"dashboard.searching\")}" } } if let Some(ref err) = search_error { div { class: "settings-hint", "{err}" } diff --git a/src/pages/developer/agents.rs b/src/pages/developer/agents.rs index efcc141..1396e4b 100644 --- a/src/pages/developer/agents.rs +++ b/src/pages/developer/agents.rs @@ -1,23 +1,26 @@ use dioxus::prelude::*; +use crate::i18n::{t, Locale}; + /// Agents page placeholder for the LangGraph agent builder. /// /// Shows a "Coming Soon" card with a disabled launch button. /// Will eventually integrate with the LangGraph framework. #[component] pub fn AgentsPage() -> Element { + let locale = use_context::>(); + let l = *locale.read(); + rsx! { section { class: "placeholder-page", div { class: "placeholder-card", div { class: "placeholder-icon", "A" } - h2 { "Agent Builder" } + h2 { "{t(l, \"developer.agents_title\")}" } p { class: "placeholder-desc", - "Build and manage AI agents with LangGraph. \ - Create multi-step reasoning pipelines, tool-using agents, \ - and autonomous workflows." + "{t(l, \"developer.agents_desc\")}" } - button { class: "btn-primary", disabled: true, "Launch Agent Builder" } - span { class: "placeholder-badge", "Coming Soon" } + button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_agents\")}" } + span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" } } } } diff --git a/src/pages/developer/analytics.rs b/src/pages/developer/analytics.rs index 39e9154..b04883d 100644 --- a/src/pages/developer/analytics.rs +++ b/src/pages/developer/analytics.rs @@ -1,5 +1,6 @@ use dioxus::prelude::*; +use crate::i18n::{t, Locale}; use crate::models::AnalyticsMetric; /// Analytics page placeholder for LangFuse integration. @@ -8,7 +9,10 @@ use crate::models::AnalyticsMetric; /// plus a mock stats bar showing sample metrics. #[component] pub fn AnalyticsPage() -> Element { - let metrics = mock_metrics(); + let locale = use_context::>(); + let l = *locale.read(); + + let metrics = mock_metrics(l); rsx! { section { class: "placeholder-page", @@ -25,39 +29,41 @@ pub fn AnalyticsPage() -> Element { } div { class: "placeholder-card", div { class: "placeholder-icon", "L" } - h2 { "Analytics & Observability" } + h2 { "{t(l, \"developer.analytics_title\")}" } p { class: "placeholder-desc", - "Monitor and analyze your AI pipelines with LangFuse. \ - Track token usage, latency, costs, and quality metrics \ - across all your deployments." + "{t(l, \"developer.analytics_desc\")}" } - button { class: "btn-primary", disabled: true, "Launch LangFuse" } - span { class: "placeholder-badge", "Coming Soon" } + button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_analytics\")}" } + span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" } } } } } /// Returns mock analytics metrics for the stats bar. -fn mock_metrics() -> Vec { +/// +/// # Arguments +/// +/// * `locale` - The current locale for translating metric labels +fn mock_metrics(locale: Locale) -> Vec { vec![ AnalyticsMetric { - label: "Total Requests".into(), + label: t(locale, "developer.total_requests"), value: "12,847".into(), change_pct: 14.2, }, AnalyticsMetric { - label: "Avg Latency".into(), + label: t(locale, "developer.avg_latency"), value: "245ms".into(), change_pct: -8.5, }, AnalyticsMetric { - label: "Tokens Used".into(), + label: t(locale, "developer.tokens_used"), value: "2.4M".into(), change_pct: 22.1, }, AnalyticsMetric { - label: "Error Rate".into(), + label: t(locale, "developer.error_rate"), value: "0.3%".into(), change_pct: -12.0, }, diff --git a/src/pages/developer/flow.rs b/src/pages/developer/flow.rs index 58365d3..0f95496 100644 --- a/src/pages/developer/flow.rs +++ b/src/pages/developer/flow.rs @@ -1,23 +1,26 @@ use dioxus::prelude::*; +use crate::i18n::{t, Locale}; + /// Flow page placeholder for the LangFlow visual workflow builder. /// /// Shows a "Coming Soon" card with a disabled launch button. /// Will eventually integrate with LangFlow for visual flow design. #[component] pub fn FlowPage() -> Element { + let locale = use_context::>(); + let l = *locale.read(); + rsx! { section { class: "placeholder-page", div { class: "placeholder-card", div { class: "placeholder-icon", "F" } - h2 { "Flow Builder" } + h2 { "{t(l, \"developer.flow_title\")}" } p { class: "placeholder-desc", - "Design visual AI workflows with LangFlow. \ - Drag-and-drop nodes to create data processing pipelines, \ - prompt chains, and integration flows." + "{t(l, \"developer.flow_desc\")}" } - button { class: "btn-primary", disabled: true, "Launch Flow Builder" } - span { class: "placeholder-badge", "Coming Soon" } + button { class: "btn-primary", disabled: true, "{t(l, \"developer.launch_flow\")}" } + span { class: "placeholder-badge", "{t(l, \"common.coming_soon\")}" } } } } diff --git a/src/pages/developer/mod.rs b/src/pages/developer/mod.rs index 79ef966..a8d67f4 100644 --- a/src/pages/developer/mod.rs +++ b/src/pages/developer/mod.rs @@ -10,6 +10,7 @@ use dioxus::prelude::*; use crate::app::Route; use crate::components::sub_nav::{SubNav, SubNavItem}; +use crate::i18n::{t, Locale}; /// Shell layout for the Developer section. /// @@ -17,17 +18,20 @@ use crate::components::sub_nav::{SubNav, SubNavItem}; /// the child route outlet. Sits inside the main `AppShell` layout. #[component] pub fn DeveloperShell() -> Element { + let locale = use_context::>(); + let l = *locale.read(); + let tabs = vec![ SubNavItem { - label: "Agents", + label: t(l, "nav.agents"), route: Route::AgentsPage {}, }, SubNavItem { - label: "Flow", + label: t(l, "nav.flow"), route: Route::FlowPage {}, }, SubNavItem { - label: "Analytics", + label: t(l, "nav.analytics"), route: Route::AnalyticsPage {}, }, ]; diff --git a/src/pages/impressum.rs b/src/pages/impressum.rs index c35a1c9..501d560 100644 --- a/src/pages/impressum.rs +++ b/src/pages/impressum.rs @@ -2,6 +2,7 @@ use dioxus::prelude::*; use dioxus_free_icons::icons::bs_icons::BsShieldCheck; use dioxus_free_icons::Icon; +use crate::i18n::{t, Locale}; use crate::Route; /// Impressum (legal notice) page required by German/EU law. @@ -10,6 +11,9 @@ use crate::Route; /// accessible without authentication. #[component] pub fn ImpressumPage() -> Element { + let locale = use_context::>(); + let l = *locale.read(); + rsx! { div { class: "legal-page", nav { class: "legal-nav", @@ -21,53 +25,53 @@ pub fn ImpressumPage() -> Element { } } main { class: "legal-content", - h1 { "Impressum" } + h1 { "{t(l, \"impressum.title\")}" } - h2 { "Information according to 5 TMG" } + h2 { "{t(l, \"impressum.info_tmg\")}" } p { - "CERTifAI GmbH" + "{t(l, \"impressum.company\")}" br {} - "Musterstrasse 1" + "{t(l, \"impressum.address_street\")}" br {} - "10115 Berlin" + "{t(l, \"impressum.address_city\")}" br {} - "Germany" + "{t(l, \"impressum.address_country\")}" } - h2 { "Represented by" } - p { "Managing Director: [Name]" } + h2 { "{t(l, \"impressum.represented_by\")}" } + p { "{t(l, \"impressum.managing_director\")}" } - h2 { "Contact" } + h2 { "{t(l, \"impressum.contact\")}" } p { - "Email: info@certifai.example" + "{t(l, \"impressum.email\")}" br {} - "Phone: +49 (0) 30 1234567" + "{t(l, \"impressum.phone\")}" } - h2 { "Commercial Register" } + h2 { "{t(l, \"impressum.commercial_register\")}" } p { - "Registered at: Amtsgericht Berlin-Charlottenburg" + "{t(l, \"impressum.registered_at\")}" br {} - "Registration number: HRB XXXXXX" + "{t(l, \"impressum.registration_number\")}" } - h2 { "VAT ID" } - p { "VAT identification number according to 27a UStG: DE XXXXXXXXX" } + h2 { "{t(l, \"impressum.vat_id\")}" } + p { "{t(l, \"impressum.vat_number\")}" } - h2 { "Responsible for content according to 55 Abs. 2 RStV" } + h2 { "{t(l, \"impressum.responsible_content\")}" } p { "[Name]" br {} - "CERTifAI GmbH" + "{t(l, \"impressum.company\")}" br {} - "Musterstrasse 1" + "{t(l, \"impressum.address_street\")}" br {} - "10115 Berlin" + "{t(l, \"impressum.address_city\")}" } } footer { class: "legal-footer", - Link { to: Route::LandingPage {}, "Back to Home" } - Link { to: Route::PrivacyPage {}, "Privacy Policy" } + Link { to: Route::LandingPage {}, "{t(l, \"common.back_to_home\")}" } + Link { to: Route::PrivacyPage {}, "{t(l, \"common.privacy_policy\")}" } } } } diff --git a/src/pages/knowledge.rs b/src/pages/knowledge.rs index 8f3877c..3e2f682 100644 --- a/src/pages/knowledge.rs +++ b/src/pages/knowledge.rs @@ -1,6 +1,7 @@ use dioxus::prelude::*; use crate::components::{FileRow, PageHeader}; +use crate::i18n::{t, Locale}; use crate::models::{FileKind, KnowledgeFile}; /// Knowledge Base page with file explorer table and upload controls. @@ -9,6 +10,9 @@ use crate::models::{FileKind, KnowledgeFile}; /// metadata, chunk counts, and management actions. #[component] pub fn KnowledgePage() -> Element { + let locale = use_context::>(); + let l = *locale.read(); + let mut files = use_signal(mock_files); let mut search_query = use_signal(String::new); @@ -29,17 +33,17 @@ pub fn KnowledgePage() -> Element { rsx! { section { class: "knowledge-page", PageHeader { - title: "Knowledge Base".to_string(), - subtitle: "Manage documents for RAG retrieval".to_string(), + title: t(l, "knowledge.title"), + subtitle: t(l, "knowledge.subtitle"), actions: rsx! { - button { class: "btn-primary", "Upload File" } + button { class: "btn-primary", {t(l, "common.upload_file")} } }, } div { class: "knowledge-toolbar", input { class: "form-input knowledge-search", r#type: "text", - placeholder: "Search files...", + placeholder: t(l, "knowledge.search_placeholder"), value: "{search_query}", oninput: move |evt: Event| { search_query.set(evt.value()); @@ -50,12 +54,12 @@ pub fn KnowledgePage() -> Element { table { class: "knowledge-table", thead { tr { - th { "Name" } - th { "Type" } - th { "Size" } - th { "Chunks" } - th { "Uploaded" } - th { "Actions" } + th { {t(l, "knowledge.name")} } + th { {t(l, "knowledge.type")} } + th { {t(l, "knowledge.size")} } + th { {t(l, "knowledge.chunks")} } + th { {t(l, "knowledge.uploaded")} } + th { {t(l, "knowledge.actions")} } } } tbody { diff --git a/src/pages/landing.rs b/src/pages/landing.rs index d6cbd34..fc56259 100644 --- a/src/pages/landing.rs +++ b/src/pages/landing.rs @@ -5,6 +5,7 @@ use dioxus_free_icons::icons::bs_icons::{ use dioxus_free_icons::icons::fa_solid_icons::FaCubes; use dioxus_free_icons::Icon; +use crate::i18n::{t, Locale}; use crate::Route; /// Public landing page for the CERTifAI platform. @@ -30,6 +31,9 @@ pub fn LandingPage() -> Element { /// Sticky top navigation bar with logo, nav links, and CTA buttons. #[component] fn LandingNav() -> Element { + let locale = use_context::>(); + let l = *locale.read(); + rsx! { nav { class: "landing-nav", div { class: "landing-nav-inner", @@ -40,9 +44,9 @@ fn LandingNav() -> Element { span { "CERTifAI" } } div { class: "landing-nav-links", - a { href: "#features", "Features" } - a { href: "#how-it-works", "How It Works" } - a { href: "#pricing", "Pricing" } + a { href: "#features", {t(l, "common.features")} } + a { href: "#how-it-works", {t(l, "common.how_it_works")} } + a { href: "#pricing", {t(l, "nav.pricing")} } } div { class: "landing-nav-actions", Link { @@ -50,14 +54,14 @@ fn LandingNav() -> Element { redirect_url: "/dashboard".into(), }, class: "btn btn-ghost btn-sm", - "Log In" + {t(l, "common.log_in")} } Link { to: Route::Login { redirect_url: "/dashboard".into(), }, class: "btn btn-primary btn-sm", - "Get Started" + {t(l, "common.get_started")} } } } @@ -68,19 +72,20 @@ fn LandingNav() -> Element { /// Hero section with headline, subtitle, and CTA buttons. #[component] fn HeroSection() -> Element { + let locale = use_context::>(); + let l = *locale.read(); + rsx! { section { class: "hero-section", div { class: "hero-content", - div { class: "hero-badge badge badge-outline", "Privacy-First GenAI Infrastructure" } + div { class: "hero-badge badge badge-outline", {t(l, "landing.badge")} } h1 { class: "hero-title", - "Your AI. Your Data." + {t(l, "landing.hero_title_1")} br {} - span { class: "hero-title-accent", "Your Infrastructure." } + span { class: "hero-title-accent", {t(l, "landing.hero_title_2")} } } p { class: "hero-subtitle", - "Self-hosted, GDPR-compliant generative AI platform for " - "enterprises that refuse to compromise on data sovereignty. " - "Deploy LLMs, agents, and MCP servers on your own terms." + {t(l, "landing.hero_subtitle")} } div { class: "hero-actions", Link { @@ -88,10 +93,12 @@ fn HeroSection() -> Element { redirect_url: "/dashboard".into(), }, class: "btn btn-primary btn-lg", - "Get Started" + {t(l, "common.get_started")} Icon { icon: BsArrowRight, width: 18, height: 18 } } - a { href: "#features", class: "btn btn-outline btn-lg", "Learn More" } + a { href: "#features", class: "btn btn-outline btn-lg", + {t(l, "landing.learn_more")} + } } } div { class: "hero-graphic", @@ -273,31 +280,34 @@ fn HeroSection() -> Element { /// Social proof / trust indicator strip. #[component] fn SocialProof() -> Element { + let locale = use_context::>(); + let l = *locale.read(); + rsx! { section { class: "social-proof", p { class: "social-proof-text", - "Built for enterprises that value " - span { class: "social-proof-highlight", "data sovereignty" } + {t(l, "landing.social_proof")} + span { class: "social-proof-highlight", {t(l, "landing.data_sovereignty")} } } div { class: "social-proof-stats", div { class: "proof-stat", span { class: "proof-stat-value", "100%" } - span { class: "proof-stat-label", "On-Premise" } + span { class: "proof-stat-label", {t(l, "landing.on_premise")} } } div { class: "proof-divider" } div { class: "proof-stat", span { class: "proof-stat-value", "GDPR" } - span { class: "proof-stat-label", "Compliant" } + span { class: "proof-stat-label", {t(l, "landing.compliant")} } } div { class: "proof-divider" } div { class: "proof-stat", span { class: "proof-stat-value", "EU" } - span { class: "proof-stat-label", "Data Residency" } + span { class: "proof-stat-label", {t(l, "landing.data_residency")} } } div { class: "proof-divider" } div { class: "proof-stat", span { class: "proof-stat-value", "Zero" } - span { class: "proof-stat-label", "Third-Party Sharing" } + span { class: "proof-stat-label", {t(l, "landing.third_party")} } } } } @@ -307,60 +317,57 @@ fn SocialProof() -> Element { /// Feature cards grid section. #[component] fn FeaturesGrid() -> Element { + let locale = use_context::>(); + let l = *locale.read(); + rsx! { section { id: "features", class: "features-section", - h2 { class: "section-title", "Everything You Need" } + h2 { class: "section-title", {t(l, "landing.features_title")} } p { class: "section-subtitle", - "A complete, self-hosted GenAI stack under your full control." + {t(l, "landing.features_subtitle")} } div { class: "features-grid", FeatureCard { icon: rsx! { Icon { icon: BsServer, width: 28, height: 28 } }, - title: "Self-Hosted Infrastructure", - description: "Deploy on your own hardware or private cloud. \ - Full control over your AI stack with no external dependencies.", + title: t(l, "landing.feat_infra_title"), + description: t(l, "landing.feat_infra_desc"), } FeatureCard { icon: rsx! { Icon { icon: BsShieldCheck, width: 28, height: 28 } }, - title: "GDPR Compliant", - description: "EU data residency guaranteed. Your data never \ - leaves your infrastructure or gets shared with third parties.", + title: t(l, "landing.feat_gdpr_title"), + description: t(l, "landing.feat_gdpr_desc"), } FeatureCard { icon: rsx! { Icon { icon: FaCubes, width: 28, height: 28 } }, - title: "LLM Management", - description: "Deploy, monitor, and manage multiple language \ - models. Switch between models with zero downtime.", + title: t(l, "landing.feat_llm_title"), + description: t(l, "landing.feat_llm_desc"), } FeatureCard { icon: rsx! { Icon { icon: BsRobot, width: 28, height: 28 } }, - title: "Agent Builder", - description: "Create custom AI agents with integrated Langchain \ - and Langfuse for full observability and control.", + title: t(l, "landing.feat_agent_title"), + description: t(l, "landing.feat_agent_desc"), } FeatureCard { icon: rsx! { Icon { icon: BsGlobe2, width: 28, height: 28 } }, - title: "MCP Server Management", - description: "Manage Model Context Protocol servers to extend \ - your AI capabilities with external tool integrations.", + title: t(l, "landing.feat_mcp_title"), + description: t(l, "landing.feat_mcp_desc"), } FeatureCard { icon: rsx! { Icon { icon: BsKey, width: 28, height: 28 } }, - title: "API Key Management", - description: "Generate API keys, track usage per seat, and \ - set fine-grained permissions for every integration.", + title: t(l, "landing.feat_api_title"), + description: t(l, "landing.feat_api_desc"), } } } @@ -372,10 +379,10 @@ fn FeaturesGrid() -> Element { /// # Arguments /// /// * `icon` - The icon element to display -/// * `title` - Feature title -/// * `description` - Feature description text +/// * `title` - Feature title (owned String from translation lookup) +/// * `description` - Feature description text (owned String from translation lookup) #[component] -fn FeatureCard(icon: Element, title: &'static str, description: &'static str) -> Element { +fn FeatureCard(icon: Element, title: String, description: String) -> Element { rsx! { div { class: "card feature-card", div { class: "feature-card-icon", {icon} } @@ -388,31 +395,28 @@ fn FeatureCard(icon: Element, title: &'static str, description: &'static str) -> /// Three-step "How It Works" section. #[component] fn HowItWorks() -> Element { + let locale = use_context::>(); + let l = *locale.read(); + rsx! { section { id: "how-it-works", class: "how-it-works-section", - h2 { class: "section-title", "Up and Running in Minutes" } - p { class: "section-subtitle", "Three steps to sovereign AI infrastructure." } + h2 { class: "section-title", {t(l, "landing.how_title")} } + p { class: "section-subtitle", {t(l, "landing.how_subtitle")} } div { class: "steps-grid", StepCard { number: "01", - title: "Deploy", - description: "Install CERTifAI on your infrastructure \ - with a single command. Supports Docker, Kubernetes, \ - and bare metal.", + title: t(l, "landing.step_deploy"), + description: t(l, "landing.step_deploy_desc"), } StepCard { number: "02", - title: "Configure", - description: "Connect your identity provider, select \ - your models, and set up team permissions through \ - the admin dashboard.", + title: t(l, "landing.step_configure"), + description: t(l, "landing.step_configure_desc"), } StepCard { number: "03", - title: "Scale", - description: "Add users, deploy more models, and \ - integrate with your existing tools via API keys \ - and MCP servers.", + title: t(l, "landing.step_scale"), + description: t(l, "landing.step_scale_desc"), } } } @@ -424,10 +428,10 @@ fn HowItWorks() -> Element { /// # Arguments /// /// * `number` - Step number string (e.g. "01") -/// * `title` - Step title -/// * `description` - Step description text +/// * `title` - Step title (owned String from translation lookup) +/// * `description` - Step description text (owned String from translation lookup) #[component] -fn StepCard(number: &'static str, title: &'static str, description: &'static str) -> Element { +fn StepCard(number: &'static str, title: String, description: String) -> Element { rsx! { div { class: "step-card", span { class: "step-number", "{number}" } @@ -440,11 +444,14 @@ fn StepCard(number: &'static str, title: &'static str, description: &'static str /// Call-to-action banner before the footer. #[component] fn CtaBanner() -> Element { + let locale = use_context::>(); + let l = *locale.read(); + rsx! { section { class: "cta-banner", - h2 { class: "cta-title", "Ready to take control of your AI infrastructure?" } + h2 { class: "cta-title", {t(l, "landing.cta_title")} } p { class: "cta-subtitle", - "Start deploying sovereign GenAI today. No credit card required." + {t(l, "landing.cta_subtitle")} } div { class: "cta-actions", Link { @@ -452,7 +459,7 @@ fn CtaBanner() -> Element { redirect_url: "/dashboard".into(), }, class: "btn btn-primary btn-lg", - "Get Started Free" + {t(l, "landing.get_started_free")} Icon { icon: BsArrowRight, width: 18, height: 18 } } Link { @@ -460,7 +467,7 @@ fn CtaBanner() -> Element { redirect_url: "/dashboard".into(), }, class: "btn btn-outline btn-lg", - "Log In" + {t(l, "common.log_in")} } } } @@ -470,6 +477,9 @@ fn CtaBanner() -> Element { /// Landing page footer with links and copyright. #[component] fn LandingFooter() -> Element { + let locale = use_context::>(); + let l = *locale.read(); + rsx! { footer { class: "landing-footer", div { class: "landing-footer-inner", @@ -480,28 +490,28 @@ fn LandingFooter() -> Element { } span { "CERTifAI" } } - p { class: "footer-tagline", "Sovereign GenAI infrastructure for enterprises." } + p { class: "footer-tagline", {t(l, "landing.footer_tagline")} } } div { class: "footer-links-group", - h4 { class: "footer-links-heading", "Product" } - a { href: "#features", "Features" } - a { href: "#how-it-works", "How It Works" } - a { href: "#pricing", "Pricing" } + h4 { class: "footer-links-heading", {t(l, "landing.product")} } + a { href: "#features", {t(l, "common.features")} } + a { href: "#how-it-works", {t(l, "common.how_it_works")} } + a { href: "#pricing", {t(l, "nav.pricing")} } } div { class: "footer-links-group", - h4 { class: "footer-links-heading", "Legal" } - Link { to: Route::ImpressumPage {}, "Impressum" } - Link { to: Route::PrivacyPage {}, "Privacy Policy" } + h4 { class: "footer-links-heading", {t(l, "landing.legal")} } + Link { to: Route::ImpressumPage {}, {t(l, "common.impressum")} } + Link { to: Route::PrivacyPage {}, {t(l, "common.privacy_policy")} } } div { class: "footer-links-group", - h4 { class: "footer-links-heading", "Resources" } - a { href: "#", "Documentation" } - a { href: "#", "API Reference" } - a { href: "#", "Support" } + h4 { class: "footer-links-heading", {t(l, "landing.resources")} } + a { href: "#", {t(l, "landing.documentation")} } + a { href: "#", {t(l, "landing.api_reference")} } + a { href: "#", {t(l, "landing.support")} } } } div { class: "footer-bottom", - p { "2026 CERTifAI. All rights reserved." } + p { {t(l, "landing.copyright")} } } } } diff --git a/src/pages/organization/dashboard.rs b/src/pages/organization/dashboard.rs index eadf6af..a0e369b 100644 --- a/src/pages/organization/dashboard.rs +++ b/src/pages/organization/dashboard.rs @@ -1,6 +1,7 @@ use dioxus::prelude::*; use crate::components::{MemberRow, PageHeader}; +use crate::i18n::{t, tw, Locale}; use crate::models::{BillingUsage, MemberRole, OrgMember}; /// Organization dashboard with billing stats, member table, and invite modal. @@ -9,6 +10,9 @@ use crate::models::{BillingUsage, MemberRole, OrgMember}; /// with role management, and a button to invite new members. #[component] pub fn OrgDashboardPage() -> Element { + let locale = use_context::>(); + let l = *locale.read(); + let members = use_signal(mock_members); let usage = mock_usage(); let mut show_invite = use_signal(|| false); @@ -23,10 +27,10 @@ pub fn OrgDashboardPage() -> Element { rsx! { section { class: "org-dashboard-page", PageHeader { - title: "Organization".to_string(), - subtitle: "Manage members and billing".to_string(), + title: t(l, "org.title"), + subtitle: t(l, "org.subtitle"), actions: rsx! { - button { class: "btn-primary", onclick: move |_| show_invite.set(true), "Invite Member" } + button { class: "btn-primary", onclick: move |_| show_invite.set(true), {t(l, "org.invite_member")} } }, } @@ -34,15 +38,15 @@ pub fn OrgDashboardPage() -> Element { div { class: "org-stats-bar", div { class: "org-stat", span { class: "org-stat-value", "{usage.seats_used}/{usage.seats_total}" } - span { class: "org-stat-label", "Seats Used" } + span { class: "org-stat-label", {t(l, "org.seats_used")} } } div { class: "org-stat", span { class: "org-stat-value", "{tokens_display}" } - span { class: "org-stat-label", "of {tokens_limit_display} tokens" } + span { class: "org-stat-label", {tw(l, "org.of_tokens", &[("limit", &tokens_limit_display)])} } } div { class: "org-stat", span { class: "org-stat-value", "{usage.billing_cycle_end}" } - span { class: "org-stat-label", "Cycle Ends" } + span { class: "org-stat-label", {t(l, "org.cycle_ends")} } } } @@ -51,10 +55,10 @@ pub fn OrgDashboardPage() -> Element { table { class: "org-table", thead { tr { - th { "Name" } - th { "Email" } - th { "Role" } - th { "Joined" } + th { {t(l, "org.name")} } + th { {t(l, "org.email")} } + th { {t(l, "org.role")} } + th { {t(l, "org.joined")} } } } tbody { @@ -78,13 +82,13 @@ pub fn OrgDashboardPage() -> Element { class: "modal-content", // Prevent clicks inside modal from closing it onclick: move |evt: Event| evt.stop_propagation(), - h3 { "Invite New Member" } + h3 { {t(l, "org.invite_title")} } div { class: "form-group", - label { "Email Address" } + label { {t(l, "org.email_address")} } input { class: "form-input", r#type: "email", - placeholder: "colleague@company.com", + placeholder: t(l, "org.email_placeholder"), value: "{invite_email}", oninput: move |evt: Event| { invite_email.set(evt.value()); @@ -95,12 +99,12 @@ pub fn OrgDashboardPage() -> Element { button { class: "btn-secondary", onclick: move |_| show_invite.set(false), - "Cancel" + {t(l, "common.cancel")} } button { class: "btn-primary", onclick: move |_| show_invite.set(false), - "Send Invite" + {t(l, "org.send_invite")} } } } diff --git a/src/pages/organization/mod.rs b/src/pages/organization/mod.rs index be03149..5a26f1e 100644 --- a/src/pages/organization/mod.rs +++ b/src/pages/organization/mod.rs @@ -8,6 +8,7 @@ use dioxus::prelude::*; use crate::app::Route; use crate::components::sub_nav::{SubNav, SubNavItem}; +use crate::i18n::{t, Locale}; /// Shell layout for the Organization section. /// @@ -15,13 +16,16 @@ use crate::components::sub_nav::{SubNav, SubNavItem}; /// the child route outlet. Sits inside the main `AppShell` layout. #[component] pub fn OrgShell() -> Element { + let locale = use_context::>(); + let l = *locale.read(); + let tabs = vec![ SubNavItem { - label: "Pricing", + label: t(l, "nav.pricing"), route: Route::OrgPricingPage {}, }, SubNavItem { - label: "Dashboard", + label: t(l, "nav.dashboard"), route: Route::OrgDashboardPage {}, }, ]; diff --git a/src/pages/organization/pricing.rs b/src/pages/organization/pricing.rs index c2e83ba..bf7e9d0 100644 --- a/src/pages/organization/pricing.rs +++ b/src/pages/organization/pricing.rs @@ -2,6 +2,7 @@ use dioxus::prelude::*; use crate::app::Route; use crate::components::{PageHeader, PricingCard}; +use crate::i18n::{t, tw, Locale}; use crate::models::PricingPlan; /// Organization pricing page displaying three plan tiers. @@ -10,14 +11,17 @@ use crate::models::PricingPlan; /// organization dashboard. #[component] pub fn OrgPricingPage() -> Element { + let locale = use_context::>(); + let l = *locale.read(); + let navigator = use_navigator(); - let plans = mock_plans(); + let plans = mock_plans(l); rsx! { section { class: "pricing-page", PageHeader { - title: "Pricing".to_string(), - subtitle: "Choose the plan that fits your organization".to_string(), + title: t(l, "org.pricing_title"), + subtitle: t(l, "org.pricing_subtitle"), } div { class: "pricing-grid", for plan in plans { @@ -34,52 +38,56 @@ pub fn OrgPricingPage() -> Element { } } -/// Returns mock pricing plans. -fn mock_plans() -> Vec { +/// Returns mock pricing plans with translated names and features. +/// +/// # Arguments +/// +/// * `l` - The active locale for translating user-facing plan text +fn mock_plans(l: Locale) -> Vec { vec![ PricingPlan { id: "starter".into(), - name: "Starter".into(), + name: t(l, "pricing.starter"), price_eur: 49, features: vec![ - "Up to 5 users".into(), - "1 LLM provider".into(), - "100K tokens/month".into(), - "Community support".into(), - "Basic analytics".into(), + tw(l, "pricing.up_to_users", &[("n", "5")]), + t(l, "pricing.llm_provider_1"), + t(l, "pricing.tokens_100k"), + t(l, "pricing.community_support"), + t(l, "pricing.basic_analytics"), ], highlighted: false, max_seats: Some(5), }, PricingPlan { id: "team".into(), - name: "Team".into(), + name: t(l, "pricing.team"), price_eur: 199, features: vec![ - "Up to 25 users".into(), - "All LLM providers".into(), - "1M tokens/month".into(), - "Priority support".into(), - "Advanced analytics".into(), - "Custom MCP tools".into(), - "SSO integration".into(), + tw(l, "pricing.up_to_users", &[("n", "25")]), + t(l, "pricing.all_providers"), + t(l, "pricing.tokens_1m"), + t(l, "pricing.priority_support"), + t(l, "pricing.advanced_analytics"), + t(l, "pricing.custom_mcp"), + t(l, "pricing.sso"), ], highlighted: true, max_seats: Some(25), }, PricingPlan { id: "enterprise".into(), - name: "Enterprise".into(), + name: t(l, "pricing.enterprise"), price_eur: 499, features: vec![ - "Unlimited users".into(), - "All LLM providers".into(), - "Unlimited tokens".into(), - "Dedicated support".into(), - "Full observability".into(), - "Custom integrations".into(), - "SLA guarantee".into(), - "On-premise deployment".into(), + t(l, "pricing.unlimited_users"), + t(l, "pricing.all_providers"), + t(l, "pricing.unlimited_tokens"), + t(l, "pricing.dedicated_support"), + t(l, "pricing.full_observability"), + t(l, "pricing.custom_integrations"), + t(l, "pricing.sla"), + t(l, "pricing.on_premise"), ], highlighted: false, max_seats: None, diff --git a/src/pages/privacy.rs b/src/pages/privacy.rs index f7bccd0..073284d 100644 --- a/src/pages/privacy.rs +++ b/src/pages/privacy.rs @@ -2,6 +2,7 @@ use dioxus::prelude::*; use dioxus_free_icons::icons::bs_icons::BsShieldCheck; use dioxus_free_icons::Icon; +use crate::i18n::{t, Locale}; use crate::Route; /// Privacy Policy page. @@ -10,6 +11,9 @@ use crate::Route; /// without authentication. #[component] pub fn PrivacyPage() -> Element { + let locale = use_context::>(); + let l = *locale.read(); + rsx! { div { class: "legal-page", nav { class: "legal-nav", @@ -21,85 +25,66 @@ pub fn PrivacyPage() -> Element { } } main { class: "legal-content", - h1 { "Privacy Policy" } - p { class: "legal-updated", "Last updated: February 2026" } + h1 { "{t(l, \"privacy.title\")}" } + p { class: "legal-updated", "{t(l, \"privacy.last_updated\")}" } - h2 { "1. Introduction" } - p { - "CERTifAI GmbH (\"we\", \"our\", \"us\") is committed to " - "protecting your personal data. This privacy policy explains " - "how we collect, use, and safeguard your information when you " - "use our platform." - } + h2 { "{t(l, \"privacy.intro_title\")}" } + p { "{t(l, \"privacy.intro_text\")}" } - h2 { "2. Data Controller" } + h2 { "{t(l, \"privacy.controller_title\")}" } p { - "CERTifAI GmbH" + "{t(l, \"impressum.company\")}" br {} - "Musterstrasse 1, 10115 Berlin, Germany" + "{t(l, \"privacy.controller_address\")}" br {} - "Email: privacy@certifai.example" + "{t(l, \"privacy.controller_email\")}" } - h2 { "3. Data We Collect" } - p { - "We collect only the minimum data necessary to provide " - "our services:" - } + h2 { "{t(l, \"privacy.data_title\")}" } + p { "{t(l, \"privacy.data_intro\")}" } ul { li { - strong { "Account data: " } - "Name, email address, and organization details " - "provided during registration." + strong { "{t(l, \"privacy.data_account_label\")}" } + "{t(l, \"privacy.data_account_text\")}" } li { - strong { "Usage data: " } - "API call logs, token counts, and feature usage " - "metrics for billing and analytics." + strong { "{t(l, \"privacy.data_usage_label\")}" } + "{t(l, \"privacy.data_usage_text\")}" } li { - strong { "Technical data: " } - "IP addresses, browser type, and session identifiers " - "for security and platform stability." + strong { "{t(l, \"privacy.data_technical_label\")}" } + "{t(l, \"privacy.data_technical_text\")}" } } - h2 { "4. How We Use Your Data" } + h2 { "{t(l, \"privacy.use_title\")}" } ul { - li { "To provide and maintain the CERTifAI platform" } - li { "To manage your account and subscription" } - li { "To communicate service updates and security notices" } - li { "To comply with legal obligations" } + li { "{t(l, \"privacy.use_1\")}" } + li { "{t(l, \"privacy.use_2\")}" } + li { "{t(l, \"privacy.use_3\")}" } + li { "{t(l, \"privacy.use_4\")}" } } - h2 { "5. Data Storage and Sovereignty" } - p { - "CERTifAI is a self-hosted platform. All AI workloads, " - "model data, and inference results remain entirely within " - "your own infrastructure. We do not access, store, or " - "process your AI data on our servers." - } + h2 { "{t(l, \"privacy.storage_title\")}" } + p { "{t(l, \"privacy.storage_text\")}" } - h2 { "6. Your Rights (GDPR)" } - p { "Under the GDPR, you have the right to:" } + h2 { "{t(l, \"privacy.rights_title\")}" } + p { "{t(l, \"privacy.rights_intro\")}" } ul { - li { "Access your personal data" } - li { "Rectify inaccurate data" } - li { "Request erasure of your data" } - li { "Restrict or object to processing" } - li { "Data portability" } - li { "Lodge a complaint with a supervisory authority" } + li { "{t(l, \"privacy.rights_access\")}" } + li { "{t(l, \"privacy.rights_rectify\")}" } + li { "{t(l, \"privacy.rights_erasure\")}" } + li { "{t(l, \"privacy.rights_restrict\")}" } + li { "{t(l, \"privacy.rights_portability\")}" } + li { "{t(l, \"privacy.rights_complaint\")}" } } - h2 { "7. Contact" } - p { - "For privacy-related inquiries, contact us at " - "privacy@certifai.example." - } + h2 { "{t(l, \"privacy.contact_title\")}" } + p { "{t(l, \"privacy.contact_text\")}" } } footer { class: "legal-footer", - Link { to: Route::LandingPage {}, "Back to Home" } - Link { to: Route::ImpressumPage {}, "Impressum" } + Link { to: Route::LandingPage {}, "{t(l, \"common.back_to_home\")}" } + Link { to: Route::ImpressumPage {}, "{t(l, \"common.impressum\")}" } } } } diff --git a/src/pages/providers.rs b/src/pages/providers.rs index 9167e01..9a6e039 100644 --- a/src/pages/providers.rs +++ b/src/pages/providers.rs @@ -1,6 +1,7 @@ use dioxus::prelude::*; use crate::components::PageHeader; +use crate::i18n::{t, Locale}; use crate::models::{EmbeddingEntry, LlmProvider, ModelEntry, ProviderConfig}; /// Providers page for configuring LLM and embedding model backends. @@ -9,6 +10,9 @@ use crate::models::{EmbeddingEntry, LlmProvider, ModelEntry, ProviderConfig}; /// shows the currently active provider status. #[component] pub fn ProvidersPage() -> Element { + let locale = use_context::>(); + let l = *locale.read(); + let mut selected_provider = use_signal(|| LlmProvider::Ollama); let mut selected_model = use_signal(|| "llama3.1:8b".to_string()); let mut selected_embedding = use_signal(|| "nomic-embed-text".to_string()); @@ -39,13 +43,13 @@ pub fn ProvidersPage() -> Element { rsx! { section { class: "providers-page", PageHeader { - title: "Providers".to_string(), - subtitle: "Configure your LLM and embedding backends".to_string(), + title: t(l, "providers.title"), + subtitle: t(l, "providers.subtitle"), } div { class: "providers-layout", div { class: "providers-form", div { class: "form-group", - label { "Provider" } + label { "{t(l, \"providers.provider\")}" } select { class: "form-select", value: "{provider_val.label()}", @@ -67,7 +71,7 @@ pub fn ProvidersPage() -> Element { } } div { class: "form-group", - label { "Model" } + label { "{t(l, \"providers.model\")}" } select { class: "form-select", value: "{selected_model}", @@ -81,7 +85,7 @@ pub fn ProvidersPage() -> Element { } } div { class: "form-group", - label { "Embedding Model" } + label { "{t(l, \"providers.embedding_model\")}" } select { class: "form-select", value: "{selected_embedding}", @@ -95,11 +99,11 @@ pub fn ProvidersPage() -> Element { } } div { class: "form-group", - label { "API Key" } + label { "{t(l, \"providers.api_key\")}" } input { class: "form-input", r#type: "password", - placeholder: "Enter API key...", + placeholder: "{t(l, \"providers.api_key_placeholder\")}", value: "{api_key}", oninput: move |evt: Event| { api_key.set(evt.value()); @@ -110,34 +114,34 @@ pub fn ProvidersPage() -> Element { button { class: "btn-primary", onclick: move |_| saved.set(true), - "Save Configuration" + "{t(l, \"providers.save_config\")}" } if *saved.read() { - p { class: "form-success", "Configuration saved." } + p { class: "form-success", "{t(l, \"providers.config_saved\")}" } } } div { class: "providers-status", - h3 { "Active Configuration" } + h3 { "{t(l, \"providers.active_config\")}" } div { class: "status-card", div { class: "status-row", - span { class: "status-label", "Provider" } + span { class: "status-label", "{t(l, \"providers.provider\")}" } span { class: "status-value", "{active_config.provider.label()}" } } div { class: "status-row", - span { class: "status-label", "Model" } + span { class: "status-label", "{t(l, \"providers.model\")}" } span { class: "status-value", "{active_config.selected_model}" } } div { class: "status-row", - span { class: "status-label", "Embedding" } + span { class: "status-label", "{t(l, \"providers.embedding\")}" } span { class: "status-value", "{active_config.selected_embedding}" } } div { class: "status-row", - span { class: "status-label", "API Key" } + span { class: "status-label", "{t(l, \"providers.api_key\")}" } span { class: "status-value", if active_config.api_key_set { - "Set" + "{t(l, \"common.set\")}" } else { - "Not set" + "{t(l, \"common.not_set\")}" } } } diff --git a/src/pages/tools.rs b/src/pages/tools.rs index 56dc95b..a08d0f0 100644 --- a/src/pages/tools.rs +++ b/src/pages/tools.rs @@ -1,6 +1,9 @@ +use std::collections::HashMap; + use dioxus::prelude::*; use crate::components::{PageHeader, ToolCard}; +use crate::i18n::{t, Locale}; use crate::models::{McpTool, ToolCategory, ToolStatus}; /// Tools page displaying a grid of MCP tool cards with toggle switches. @@ -9,24 +12,50 @@ use crate::models::{McpTool, ToolCategory, ToolStatus}; /// enabling/disabling them via toggle buttons. #[component] pub fn ToolsPage() -> Element { - let mut tools = use_signal(mock_tools); + let locale = use_context::>(); + let l = *locale.read(); - // Toggle a tool's enabled state by its ID - let on_toggle = move |id: String| { - tools.write().iter_mut().for_each(|t| { - if t.id == id { - t.enabled = !t.enabled; + // Track which tool IDs have been toggled off/on by the user. + // The canonical tool definitions (including translated names) come + // from `mock_tools(l)` on every render so they react to locale changes. + let mut enabled_overrides = use_signal(HashMap::::new); + + // Build the display list: translated names from mock_tools, with + // enabled state merged from user overrides. + let tool_list: Vec = mock_tools(l) + .into_iter() + .map(|mut tool| { + if let Some(&enabled) = enabled_overrides.read().get(&tool.id) { + tool.enabled = enabled; } - }); - }; + tool + }) + .collect(); - let tool_list = tools.read().clone(); + // Toggle a tool's enabled state by its ID. + // Reads the current state from overrides (or falls back to the default + // enabled value from mock_tools) and flips it. + let on_toggle = move |id: String| { + let defaults = mock_tools(l); + let current = enabled_overrides + .read() + .get(&id) + .copied() + .unwrap_or_else(|| { + defaults + .iter() + .find(|tool| tool.id == id) + .map(|tool| tool.enabled) + .unwrap_or(false) + }); + enabled_overrides.write().insert(id, !current); + }; rsx! { section { class: "tools-page", PageHeader { - title: "Tools".to_string(), - subtitle: "Manage MCP servers and tool integrations".to_string(), + title: t(l, "tools.title"), + subtitle: t(l, "tools.subtitle"), } div { class: "tools-grid", for tool in tool_list { @@ -37,13 +66,17 @@ pub fn ToolsPage() -> Element { } } -/// Returns mock MCP tools for the tools grid. -fn mock_tools() -> Vec { +/// Returns mock MCP tools for the tools grid with translated names. +/// +/// # Arguments +/// +/// * `l` - The current locale for translating tool names and descriptions +fn mock_tools(l: Locale) -> Vec { vec![ McpTool { id: "calculator".into(), - name: "Calculator".into(), - description: "Mathematical computation and unit conversion".into(), + name: t(l, "tools.calculator"), + description: t(l, "tools.calculator_desc"), category: ToolCategory::Compute, status: ToolStatus::Active, enabled: true, @@ -51,8 +84,8 @@ fn mock_tools() -> Vec { }, McpTool { id: "tavily".into(), - name: "Tavily Search".into(), - description: "AI-optimized web search API for real-time information".into(), + name: t(l, "tools.tavily"), + description: t(l, "tools.tavily_desc"), category: ToolCategory::Search, status: ToolStatus::Active, enabled: true, @@ -60,8 +93,8 @@ fn mock_tools() -> Vec { }, McpTool { id: "searxng".into(), - name: "SearXNG".into(), - description: "Privacy-respecting metasearch engine".into(), + name: t(l, "tools.searxng"), + description: t(l, "tools.searxng_desc"), category: ToolCategory::Search, status: ToolStatus::Active, enabled: true, @@ -69,8 +102,8 @@ fn mock_tools() -> Vec { }, McpTool { id: "file-reader".into(), - name: "File Reader".into(), - description: "Read and parse local files in various formats".into(), + name: t(l, "tools.file_reader"), + description: t(l, "tools.file_reader_desc"), category: ToolCategory::FileSystem, status: ToolStatus::Active, enabled: true, @@ -78,8 +111,8 @@ fn mock_tools() -> Vec { }, McpTool { id: "code-exec".into(), - name: "Code Executor".into(), - description: "Sandboxed code execution for Python and JavaScript".into(), + name: t(l, "tools.code_executor"), + description: t(l, "tools.code_executor_desc"), category: ToolCategory::Code, status: ToolStatus::Inactive, enabled: false, @@ -87,8 +120,8 @@ fn mock_tools() -> Vec { }, McpTool { id: "web-scraper".into(), - name: "Web Scraper".into(), - description: "Extract structured data from web pages".into(), + name: t(l, "tools.web_scraper"), + description: t(l, "tools.web_scraper_desc"), category: ToolCategory::Search, status: ToolStatus::Active, enabled: true, @@ -96,8 +129,8 @@ fn mock_tools() -> Vec { }, McpTool { id: "email".into(), - name: "Email Sender".into(), - description: "Send emails via configured SMTP server".into(), + name: t(l, "tools.email_sender"), + description: t(l, "tools.email_sender_desc"), category: ToolCategory::Communication, status: ToolStatus::Inactive, enabled: false, @@ -105,8 +138,8 @@ fn mock_tools() -> Vec { }, McpTool { id: "git".into(), - name: "Git Operations".into(), - description: "Interact with Git repositories for version control".into(), + name: t(l, "tools.git_ops"), + description: t(l, "tools.git_ops_desc"), category: ToolCategory::Code, status: ToolStatus::Active, enabled: true,