From 38517da850b3d967a0ba96add4b0ec5069d3b91e Mon Sep 17 00:00:00 2001 From: Christian Scheil Date: Sat, 2 May 2026 23:03:45 +0200 Subject: [PATCH 01/12] feat: i18n 10 languages + About section + deploy fix --- .github/CONTRIBUTING.md | 29 + .../translation_contribution.yml | 61 ++ .../src/opencloudtouch/core/logging.py | 32 +- .../src/opencloudtouch/core/logs_routes.py | 29 + apps/backend/src/opencloudtouch/main.py | 2 + apps/frontend/package.json | 3 + apps/frontend/src/App.tsx | 30 +- apps/frontend/src/api/devices.ts | 8 +- apps/frontend/src/api/health.ts | 17 + apps/frontend/src/components/AboutSection.css | 146 +++++ apps/frontend/src/components/AboutSection.tsx | 92 +++ apps/frontend/src/components/CloudBadge.tsx | 22 +- .../frontend/src/components/ConfirmDialog.tsx | 17 +- .../src/components/DeviceOfflineBanner.tsx | 8 +- apps/frontend/src/components/EmptyState.tsx | 70 +-- .../src/components/ErrorBoundary.test.tsx | 22 +- .../frontend/src/components/ErrorBoundary.tsx | 17 +- .../src/components/LanguageSelector.css | 114 ++++ .../src/components/LanguageSelector.tsx | 86 +++ .../frontend/src/components/ManualIPModal.tsx | 25 +- apps/frontend/src/components/Navigation.css | 14 +- apps/frontend/src/components/Navigation.tsx | 32 +- apps/frontend/src/components/NowPlaying.tsx | 19 +- apps/frontend/src/components/PresetButton.tsx | 20 +- apps/frontend/src/components/RadioSearch.tsx | 47 +- .../frontend/src/components/StationDetail.tsx | 10 +- apps/frontend/src/components/Toast.tsx | 4 +- .../components/wizard/Step2USBPreparation.tsx | 82 +-- .../src/components/wizard/Step3PowerCycle.tsx | 198 +++--- .../src/components/wizard/Step4Backup.tsx | 49 +- .../wizard/Step5ConfigModification.tsx | 118 ++-- .../wizard/Step6HostsModification.tsx | 66 +- .../components/wizard/Step7Verification.tsx | 94 +-- .../src/components/wizard/Step8Completion.tsx | 91 ++- .../src/components/wizard/WizardStep.tsx | 37 +- apps/frontend/src/hooks/useHealth.ts | 17 + apps/frontend/src/i18n/index.ts | 124 ++++ apps/frontend/src/i18n/locales/de.json | 576 ++++++++++++++++++ apps/frontend/src/i18n/locales/en.json | 576 ++++++++++++++++++ apps/frontend/src/i18n/locales/es.json | 567 +++++++++++++++++ apps/frontend/src/i18n/locales/fr.json | 576 ++++++++++++++++++ apps/frontend/src/i18n/locales/it.json | 576 ++++++++++++++++++ apps/frontend/src/i18n/locales/ja.json | 551 +++++++++++++++++ apps/frontend/src/i18n/locales/nl.json | 567 +++++++++++++++++ apps/frontend/src/i18n/locales/pl.json | 560 +++++++++++++++++ apps/frontend/src/i18n/locales/pt-BR.json | 566 +++++++++++++++++ apps/frontend/src/i18n/locales/sv.json | 560 +++++++++++++++++ apps/frontend/src/main.tsx | 1 + apps/frontend/src/pages/Firmware.tsx | 48 +- apps/frontend/src/pages/Licenses.tsx | 20 +- apps/frontend/src/pages/LocalControl.tsx | 26 +- apps/frontend/src/pages/MultiRoom.css | 10 + apps/frontend/src/pages/MultiRoom.tsx | 113 ++-- apps/frontend/src/pages/NotFound.tsx | 10 +- apps/frontend/src/pages/RadioPresets.tsx | 47 +- apps/frontend/src/pages/Settings.tsx | 56 +- apps/frontend/src/pages/SetupWizard.tsx | 113 ++-- apps/frontend/src/utils/errorMessages.ts | 95 +-- apps/frontend/tests/e2e/wizard-i18n.cy.ts | 281 +++++++++ .../tests/e2e/wizard-ui-rendering.cy.ts | 327 ++++++++++ apps/frontend/tests/setup.ts | 1 + .../frontend/tests/unit/AboutSection.test.tsx | 334 ++++++++++ apps/frontend/tests/unit/App.test.tsx | 30 +- .../tests/unit/ConfirmDialog.test.tsx | 8 +- .../tests/unit/DeviceOfflineBanner.test.tsx | 8 +- apps/frontend/tests/unit/EmptyState.test.tsx | 50 +- .../tests/unit/ErrorBoundary.test.tsx | 16 +- apps/frontend/tests/unit/Firmware.test.tsx | 32 +- .../tests/unit/LanguageSelector.test.tsx | 136 +++++ apps/frontend/tests/unit/Licenses.test.tsx | 8 +- .../frontend/tests/unit/LocalControl.test.tsx | 14 +- .../tests/unit/ManualIPModal.test.tsx | 4 +- apps/frontend/tests/unit/MultiRoom.test.tsx | 90 +-- apps/frontend/tests/unit/NowPlaying.test.tsx | 20 +- .../frontend/tests/unit/PresetButton.test.tsx | 22 +- .../frontend/tests/unit/RadioPresets.test.tsx | 4 +- apps/frontend/tests/unit/RadioSearch.test.tsx | 70 ++- .../tests/unit/Settings.discover.test.tsx | 30 +- apps/frontend/tests/unit/Settings.test.tsx | 45 +- .../tests/unit/StationDetail.test.tsx | 8 +- .../tests/unit/Step2USBPreparation.test.tsx | 6 +- apps/frontend/tests/unit/Toast.test.tsx | 10 +- apps/frontend/tests/unit/api/devices.test.ts | 1 + apps/frontend/tests/unit/health.test.ts | 32 + apps/frontend/tests/unit/i18n.test.ts | 173 ++++++ .../tests/unit/pages/SetupWizard.test.tsx | 4 +- apps/frontend/tests/unit/useHealth.test.ts | 46 ++ deployment/local/deploy-local.ps1 | 25 +- docs/adr/007-i18n.md | 111 ++++ package-lock.json | 126 +++- 90 files changed, 9072 insertions(+), 1066 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/translation_contribution.yml create mode 100644 apps/backend/src/opencloudtouch/core/logs_routes.py create mode 100644 apps/frontend/src/api/health.ts create mode 100644 apps/frontend/src/components/AboutSection.css create mode 100644 apps/frontend/src/components/AboutSection.tsx create mode 100644 apps/frontend/src/components/LanguageSelector.css create mode 100644 apps/frontend/src/components/LanguageSelector.tsx create mode 100644 apps/frontend/src/hooks/useHealth.ts create mode 100644 apps/frontend/src/i18n/index.ts create mode 100644 apps/frontend/src/i18n/locales/de.json create mode 100644 apps/frontend/src/i18n/locales/en.json create mode 100644 apps/frontend/src/i18n/locales/es.json create mode 100644 apps/frontend/src/i18n/locales/fr.json create mode 100644 apps/frontend/src/i18n/locales/it.json create mode 100644 apps/frontend/src/i18n/locales/ja.json create mode 100644 apps/frontend/src/i18n/locales/nl.json create mode 100644 apps/frontend/src/i18n/locales/pl.json create mode 100644 apps/frontend/src/i18n/locales/pt-BR.json create mode 100644 apps/frontend/src/i18n/locales/sv.json create mode 100644 apps/frontend/tests/e2e/wizard-i18n.cy.ts create mode 100644 apps/frontend/tests/e2e/wizard-ui-rendering.cy.ts create mode 100644 apps/frontend/tests/unit/AboutSection.test.tsx create mode 100644 apps/frontend/tests/unit/LanguageSelector.test.tsx create mode 100644 apps/frontend/tests/unit/health.test.ts create mode 100644 apps/frontend/tests/unit/i18n.test.ts create mode 100644 apps/frontend/tests/unit/useHealth.test.ts create mode 100644 docs/adr/007-i18n.md diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 06f67000..b69db13e 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -398,6 +398,35 @@ Kontakt: `security@` oder GitHub Security Advisory --- +## 🌍 Translations (i18n) + +OpenCloudTouch uses [react-i18next](https://react.i18next.com/) for internationalization. +See [docs/adr/007-i18n.md](../docs/adr/007-i18n.md) for the full library decision. + +### Source of Truth + +**`apps/frontend/src/i18n/locales/en.json` is the single source of truth for all UI strings.** + +Rules: +- Every user-visible string MUST exist in `en.json` before it appears in any component +- German translation lives in `de.json` and must be kept in sync +- New strings go to `en.json` first, then are translated in `de.json` (and any other locale) +- Never hardcode English text directly in components — always use `t("key")` + +### Adding a new UI string + +1. Add the key to `apps/frontend/src/i18n/locales/en.json` +2. Add the German translation to `apps/frontend/src/i18n/locales/de.json` +3. Add translations for any other supported locales (`fr.json`, `it.json`, 
) +4. Use `const { t } = useTranslation()` + `t("your.new.key")` in the component + +### Contributing a new language + +Use the **[Translation Contribution](https://github.com/scheilch/opencloudtouch/issues/new?template=translation_contribution.yml)** issue template. +You do not need to open a PR — paste your translated JSON into the issue and a maintainer will create the locale file. + +--- + ## 📚 Dokumentation Bei Code-Änderungen bitte auch Doku aktualisieren: diff --git a/.github/ISSUE_TEMPLATE/translation_contribution.yml b/.github/ISSUE_TEMPLATE/translation_contribution.yml new file mode 100644 index 00000000..c87583db --- /dev/null +++ b/.github/ISSUE_TEMPLATE/translation_contribution.yml @@ -0,0 +1,61 @@ +name: "Translation Contribution: [Language]" +description: Contribute translations for a new or existing language +title: "Translation Contribution: [Language]" +labels: ["i18n", "help wanted"] +body: + - type: input + id: language_name + attributes: + label: Language Name + description: The full English name of the language (e.g. "Spanish", "French") + placeholder: "e.g. Spanish" + validations: + required: true + + - type: input + id: locale_code + attributes: + label: Locale Code (ISO 639-1) + description: The 2-letter ISO 639-1 code for the language + placeholder: "e.g. es" + validations: + required: true + + - type: input + id: completeness + attributes: + label: Completeness (%) + description: Approximately what percentage of keys are translated? + placeholder: "e.g. 100" + validations: + required: true + + - type: textarea + id: translations + attributes: + label: Translated en.json snippet + description: > + Paste your translated JSON matching the structure of + `apps/frontend/src/i18n/locales/en.json`. You can provide a full + translation or a partial one — mark missing values with the English + string. + render: json + placeholder: | + { + "common": { + "save": "Guardar", + "cancel": "Cancelar" + } + } + validations: + required: true + + - type: textarea + id: notes + attributes: + label: Notes + description: > + Any additional notes, regional variants, or open questions about the + translation (optional). + validations: + required: false diff --git a/apps/backend/src/opencloudtouch/core/logging.py b/apps/backend/src/opencloudtouch/core/logging.py index 3305d107..ddbae196 100644 --- a/apps/backend/src/opencloudtouch/core/logging.py +++ b/apps/backend/src/opencloudtouch/core/logging.py @@ -3,14 +3,33 @@ Provides consistent logging format with context enrichment """ +import collections import json import logging import sys from datetime import UTC, datetime -from typing import Any, Dict +from typing import Any, Dict, List from opencloudtouch.core.config import get_config +# In-memory ring buffer: keeps the last 500 formatted log entries +_log_buffer: collections.deque[str] = collections.deque(maxlen=500) + + +def get_log_entries() -> List[str]: + """Return a snapshot of the in-memory log buffer.""" + return list(_log_buffer) + + +class MemoryLogHandler(logging.Handler): + """Logging handler that stores records in the module-level ring buffer.""" + + def emit(self, record: logging.LogRecord) -> None: + try: + _log_buffer.append(self.format(record)) + except Exception: # pragma: no cover + self.handleError(record) + class StructuredFormatter(logging.Formatter): """JSON formatter for structured logging.""" @@ -95,6 +114,17 @@ def setup_logging() -> None: root_logger.addHandler(console_handler) + # In-memory ring buffer handler (always active, used by /api/logs/backend) + memory_handler = MemoryLogHandler() + memory_handler.setLevel(config.log_level) + memory_handler.setFormatter( + ContextFormatter( + fmt="%(asctime)s - %(levelname)-8s - %(name)-30s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + root_logger.addHandler(memory_handler) + # Optional file handler if config.log_file: file_handler = logging.FileHandler(config.log_file) diff --git a/apps/backend/src/opencloudtouch/core/logs_routes.py b/apps/backend/src/opencloudtouch/core/logs_routes.py new file mode 100644 index 00000000..77ea6ece --- /dev/null +++ b/apps/backend/src/opencloudtouch/core/logs_routes.py @@ -0,0 +1,29 @@ +"""Log download routes for OpenCloudTouch.""" + +import datetime +import logging + +from fastapi import APIRouter +from fastapi.responses import PlainTextResponse + +from opencloudtouch.core.logging import get_log_entries + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/logs", tags=["logs"]) + + +@router.get( + "/backend", + response_class=PlainTextResponse, + summary="Download backend log buffer", + description="Returns the in-memory backend log ring-buffer (last 500 entries) as a plain-text file.", +) +async def download_backend_logs() -> PlainTextResponse: + entries = get_log_entries() + content = "\n".join(entries) if entries else "(no log entries captured yet)" + timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S") + filename = f"oct-backend-{timestamp}.log" + headers = {"Content-Disposition": f'attachment; filename="{filename}"'} + logger.debug("Backend log download requested: %d entries", len(entries)) + return PlainTextResponse(content=content, headers=headers) diff --git a/apps/backend/src/opencloudtouch/main.py b/apps/backend/src/opencloudtouch/main.py index 65526eb6..02a9e911 100644 --- a/apps/backend/src/opencloudtouch/main.py +++ b/apps/backend/src/opencloudtouch/main.py @@ -22,6 +22,7 @@ register_exception_handlers, # re-exported for backward compat ) from opencloudtouch.core.logging import setup_logging +from opencloudtouch.core.logs_routes import router as logs_router from opencloudtouch.core.static_files import ( find_frontend_static_dir, mount_static_files, @@ -244,6 +245,7 @@ async def lifespan(app: FastAPI): app.include_router(swupdate_router) # SWUpdate firmware index emulation app.include_router(zones_router) # Multi-room zone management app.include_router(device_zone_router) # Per-device zone status +app.include_router(logs_router) # Backend log download # Health endpoint diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 68f9f13c..f662372d 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -29,9 +29,12 @@ }, "dependencies": { "@tanstack/react-query": "^5.95.2", + "flag-icons": "^7.5.0", "framer-motion": "^12.38.0", + "i18next": "^26.0.8", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-i18next": "^17.0.6", "react-router-dom": "^7.13.0" }, "devDependencies": { diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index a5a6630f..f7e76f4b 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { ToastProvider } from "./contexts/ToastContext"; import { ErrorBoundary } from "./components/ErrorBoundary"; import Navigation from "./components/Navigation"; @@ -27,6 +28,7 @@ interface AppRouterProps { } function AppRouter({ devices, isLoading, error, onRetry }: AppRouterProps) { + const { t } = useTranslation(); // REFACT-137: Show hint after 3s loading, retry hint after 8s const [loadingSeconds, setLoadingSeconds] = useState(0); useEffect(() => { @@ -43,32 +45,29 @@ function AppRouter({ devices, isLoading, error, onRetry }: AppRouterProps) { if (isLoading) { const loadingMessage = loadingSeconds < 4 - ? "OpenCloudTouch wird geladen..." + ? t("common.openCloudTouchLoading") : loadingSeconds < 10 - ? "Verbindung zum Server wird hergestellt..." - : "Dies dauert lĂ€nger als erwartet. Bitte warten oder Seite neu laden."; + ? t("common.connectingToServer") + : t("common.loadingTimeout"); return (
@@ -81,13 +80,10 @@ function AppRouter({ devices, isLoading, error, onRetry }: AppRouterProps) {
⚠
-

Fehler beim Laden der GerÀte

-

- GerĂ€te konnten nicht geladen werden. Bitte prĂŒfen Sie die Verbindung und versuchen Sie - es erneut. -

-
diff --git a/apps/frontend/src/api/devices.ts b/apps/frontend/src/api/devices.ts index 7756685f..8423d8fc 100644 --- a/apps/frontend/src/api/devices.ts +++ b/apps/frontend/src/api/devices.ts @@ -32,6 +32,7 @@ export interface Device { setup_status?: SetupStatus; ssh_permanent?: boolean; setup_completed_at?: string | null; + last_seen?: string; capabilities?: { airplay?: boolean; }; @@ -51,13 +52,14 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ""; function mapDeviceFromAPI(apiDevice: DeviceAPIResponse): Device { return { device_id: apiDevice.device_id, - name: apiDevice.name, // Backend already returns 'name' - model: apiDevice.model, // Backend already returns 'model' - ip: apiDevice.ip, // Backend already returns 'ip' + name: apiDevice.name, + model: apiDevice.model, + ip: apiDevice.ip, firmware: apiDevice.firmware_version, setup_status: apiDevice.setup_status as SetupStatus, ssh_permanent: apiDevice.ssh_permanent, setup_completed_at: apiDevice.setup_completed_at, + last_seen: apiDevice.last_seen, }; } diff --git a/apps/frontend/src/api/health.ts b/apps/frontend/src/api/health.ts new file mode 100644 index 00000000..ce82aeb6 --- /dev/null +++ b/apps/frontend/src/api/health.ts @@ -0,0 +1,17 @@ +/** + * Health API Client + * Fetches application version and status from the /health endpoint + */ + +export interface HealthResponse { + status: string; + version: string; +} + +export async function getHealth(): Promise { + const res = await fetch("/health"); + if (!res.ok) { + throw new Error(`Health check failed: ${res.status}`); + } + return res.json() as Promise; +} diff --git a/apps/frontend/src/components/AboutSection.css b/apps/frontend/src/components/AboutSection.css new file mode 100644 index 00000000..489938fd --- /dev/null +++ b/apps/frontend/src/components/AboutSection.css @@ -0,0 +1,146 @@ +/* AboutSection — About card in Settings */ + +.about-section { + margin-top: var(--space-xl, 32px); +} + +.about-card { + padding: var(--space-md, 16px); +} + +/* App header row */ +.about-app-header { + display: flex; + align-items: flex-start; + gap: var(--space-md, 16px); +} + +.about-app-icon { + width: 40px; + height: 40px; + border-radius: 10px; + background: var(--color-bg-darker, #1a1a2e); + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + flex-shrink: 0; +} + +.about-app-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; +} + +.about-name-row { + display: flex; + align-items: center; + gap: var(--space-sm, 8px); + flex-wrap: wrap; +} + +.about-app-name { + font-size: 16px; + font-weight: var(--font-weight-bold, 700); + color: var(--color-text-primary, #ffffff); +} + +.about-version-badge { + background: var(--color-accent, #0066cc); + color: #fff; + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 20px; + letter-spacing: 0.3px; + white-space: nowrap; +} + +.about-version-error { + font-size: 12px; + color: var(--color-text-secondary, #888); +} + +.about-app-description { + font-size: 13px; + color: var(--color-text-secondary, #888); + line-height: 1.4; + margin: 4px 0 0; +} + +/* Divider */ +.about-divider { + border: none; + border-top: 1px solid var(--color-border, #333); + margin: var(--space-md, 16px) 0; +} + +/* Device count / meta row */ +.about-meta-row { + display: flex; + align-items: center; + gap: var(--space-sm, 8px); + padding: var(--space-xs, 4px) 0; +} + +.about-meta-icon { + font-size: 16px; +} + +.about-meta-text { + font-size: 14px; + color: var(--color-text-secondary, #888); +} + +/* External links */ +.about-links { + list-style: none; + margin: 0; + padding: 0; +} + +.about-links li { + display: block; +} + +.about-link-item { + display: flex; + align-items: center; + gap: var(--space-sm, 8px); + min-height: var(--touch-target-min, 44px); + padding: 0 var(--space-xs, 4px); + border-radius: var(--border-radius-sm, 4px); + cursor: pointer; + text-decoration: none; + color: var(--color-text-primary, #ffffff); + transition: background 150ms ease; +} + +.about-link-item:hover { + background: var(--color-bg-darker, #1a1a2e); +} + +.about-link-item:focus-visible { + outline: 2px solid var(--color-accent, #0066cc); + outline-offset: 2px; +} + +.about-link-icon { + font-size: 16px; + width: 20px; + text-align: center; + flex-shrink: 0; +} + +.about-link-label { + flex: 1; + font-size: 14px; + color: var(--color-text-primary, #ffffff); +} + +.about-link-chevron { + font-size: 16px; + color: var(--color-text-tertiary, #555); +} diff --git a/apps/frontend/src/components/AboutSection.tsx b/apps/frontend/src/components/AboutSection.tsx new file mode 100644 index 00000000..eac8227e --- /dev/null +++ b/apps/frontend/src/components/AboutSection.tsx @@ -0,0 +1,92 @@ +import { motion } from "framer-motion"; +import { useTranslation } from "react-i18next"; +import { useHealth } from "../hooks/useHealth"; +import { useDevices } from "../hooks/useDevices"; +import { Skeleton } from "./LoadingSkeleton"; +import "./AboutSection.css"; + +const GITHUB_URL = "https://github.com/scheilch/opencloudtouch"; +const ISSUES_URL = "https://github.com/scheilch/opencloudtouch/issues/new?template=bug_report.yml"; +const BMC_URL = "https://buymeacoffee.com/b49rjg5k6vj"; + +export default function AboutSection() { + const { t } = useTranslation(); + const { data: health, isLoading: healthLoading, isError: healthError } = useHealth(); + const { data: devices, isLoading: devicesLoading } = useDevices(); + + const deviceCount = devices?.length ?? 0; + + const links = [ + { icon: "\uD83D\uDC19", label: t("about.github"), href: GITHUB_URL }, + { icon: "\uD83D\uDC1B", label: t("about.reportIssue"), href: ISSUES_URL }, + ...(BMC_URL ? [{ icon: "\u2615", label: t("about.support"), href: BMC_URL }] : []), + ]; + + return ( + +

+ {"\u2139\uFE0F"} + {t("about.sectionTitle")} +

+ +
+ {/* App header row */} +
+
{"\uD83C\uDFB5"}
+
+
+ OpenCloudTouch + {healthLoading && } + {!healthLoading && healthError && ( + {t("about.versionUnavailable")} + )} + {!healthLoading && !healthError && health && ( + v{health.version} + )} +
+

{t("about.appDescription")}

+
+
+ +
+ + {/* Device count row */} +
+ {"\uD83D\uDD0A"} + {devicesLoading ? ( + + ) : ( + + {t("about.devicesConnected", { count: deviceCount })} + + )} +
+ +
+ + {/* External links */} + +
+
+ ); +} diff --git a/apps/frontend/src/components/CloudBadge.tsx b/apps/frontend/src/components/CloudBadge.tsx index 77e7f43b..0ea4fbdb 100644 --- a/apps/frontend/src/components/CloudBadge.tsx +++ b/apps/frontend/src/components/CloudBadge.tsx @@ -12,6 +12,7 @@ */ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import "./CloudBadge.css"; interface CloudBadgeProps { @@ -20,6 +21,7 @@ interface CloudBadgeProps { } export default function CloudBadge({ isCloudDependent, source }: CloudBadgeProps) { + const { t } = useTranslation(); const [showTooltip, setShowTooltip] = useState(false); if (!isCloudDependent) { @@ -33,13 +35,13 @@ export default function CloudBadge({ isCloudDependent, source }: CloudBadgeProps onBlur={() => setShowTooltip(false)} tabIndex={0} role="img" - aria-label="Kompatibel nach Cloud-Abschaltung" + aria-label={t("presets.cloudCompatible")} > ✓ {showTooltip && (
- Cloud-unabhÀngig -

Funktioniert auch nach dem 6. Mai 2026 (Bose Cloud-Abschaltung)

+ {t("presets.cloudIndependent")} +

{t("presets.cloudIndependentDesc")}

)}
@@ -56,22 +58,18 @@ export default function CloudBadge({ isCloudDependent, source }: CloudBadgeProps onBlur={() => setShowTooltip(false)} tabIndex={0} role="img" - aria-label="Cloud-abhÀngig - Funktioniert möglicherweise nicht nach Mai 2026" + aria-label={t("presets.cloudDependent")} > ☁ {showTooltip && (
- Cloud-abhÀngig + {t("presets.cloudDependent")}

{source === "TUNEIN" - ? "TuneIn-Presets benötigen Bose Cloud (streaming.bose.com)" - : "Dieses Preset benötigt möglicherweise Bose Cloud-Dienste"} -

-

- Nach dem 6. Mai 2026 eventuell nicht mehr verfĂŒgbar. -
- ErwÀgen Sie die Neukonfiguration mit direkten Streams. + ? t("presets.cloudDependentTunein") + : t("presets.cloudDependentDesc")}

+

{t("presets.cloudDependentNote")}

)}
diff --git a/apps/frontend/src/components/ConfirmDialog.tsx b/apps/frontend/src/components/ConfirmDialog.tsx index 8d2492c7..9ff1b9c8 100644 --- a/apps/frontend/src/components/ConfirmDialog.tsx +++ b/apps/frontend/src/components/ConfirmDialog.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef } from "react"; +import { useTranslation } from "react-i18next"; import "./ConfirmDialog.css"; /** @@ -36,13 +37,17 @@ interface ConfirmDialogProps { export default function ConfirmDialog({ open, - title = "BestÀtigen", + title, message, - confirmLabel = "BestÀtigen", - cancelLabel = "Abbrechen", + confirmLabel, + cancelLabel, onConfirm, onCancel, }: ConfirmDialogProps) { + const { t } = useTranslation(); + const resolvedTitle = title ?? t("common.confirm"); + const resolvedConfirmLabel = confirmLabel ?? t("common.confirm"); + const resolvedCancelLabel = cancelLabel ?? t("common.cancel"); const cancelRef = useRef(null); // Focus the cancel button when dialog opens (safe default) @@ -87,7 +92,7 @@ export default function ConfirmDialog({ onClick={(e) => e.stopPropagation()} >

- {title} + {resolvedTitle}

{message} @@ -99,14 +104,14 @@ export default function ConfirmDialog({ onClick={onCancel} data-testid="confirm-dialog-cancel" > - {cancelLabel} + {resolvedCancelLabel} diff --git a/apps/frontend/src/components/DeviceOfflineBanner.tsx b/apps/frontend/src/components/DeviceOfflineBanner.tsx index 513dbaa2..c0a7f7c8 100644 --- a/apps/frontend/src/components/DeviceOfflineBanner.tsx +++ b/apps/frontend/src/components/DeviceOfflineBanner.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import "./DeviceOfflineBanner.css"; interface DeviceOfflineBannerProps { @@ -5,6 +6,7 @@ interface DeviceOfflineBannerProps { } export default function DeviceOfflineBanner({ deviceName }: Readonly) { + const { t } = useTranslation(); return (

- GerĂ€t nicht erreichbar + {t("errors.offlineTitle")} {deviceName - ? `„${deviceName}" ist offline oder nicht im Netzwerk.` - : "Das GerĂ€t ist offline oder nicht im Netzwerk."} + ? t("errors.offlineDetail", { name: deviceName }) + : t("errors.offlineDetailNoName")}
diff --git a/apps/frontend/src/components/EmptyState.tsx b/apps/frontend/src/components/EmptyState.tsx index 97d4f465..887817ef 100644 --- a/apps/frontend/src/components/EmptyState.tsx +++ b/apps/frontend/src/components/EmptyState.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { useToast } from "../contexts/ToastContext"; import { useManualIPs } from "../hooks/useSettings"; import { useDiscoveryStream } from "../hooks/useDiscoveryStream"; @@ -15,6 +16,7 @@ import "./EmptyState.css"; export default function EmptyState() { const navigate = useNavigate(); + const { t } = useTranslation(); const { show: showToast } = useToast(); const [showModal, setShowModal] = useState(false); @@ -53,23 +55,18 @@ export default function EmptyState() { if (discoveryError) { const isAlreadyRunning = discoveryError.includes("already in progress"); showToast( - isAlreadyRunning - ? "GerĂ€tesuche lĂ€uft bereits. Bitte warten..." - : "Fehler bei der GerĂ€tesuche. Bitte versuche es erneut.", + isAlreadyRunning ? t("discovery.alreadyRunning") : t("discovery.failed"), isAlreadyRunning ? "info" : "error" ); } - }, [discoveryError, showToast]); + }, [discoveryError, showToast, t]); // Show completion toast if no devices found (must be in useEffect, not render phase) useEffect(() => { if (completed && devicesFound.length === 0 && !discoveryError) { - showToast( - "Keine GerĂ€te gefunden. PrĂŒfe ob deine GerĂ€te eingeschaltet und im gleichen Netzwerk sind.", - "warning" - ); + showToast(t("discovery.noDevicesNetwork"), "warning"); } - }, [completed, devicesFound.length, discoveryError, showToast]); + }, [completed, devicesFound.length, discoveryError, showToast, t]); return (
@@ -100,41 +97,32 @@ export default function EmptyState() {

- Willkommen bei OpenCloudTouch + {t("discovery.welcomeTitle")}

-

Noch keine GerÀte gefunden.

+

{t("discovery.welcomeDescription")}

1
-

GerÀte einschalten

-

- Stelle sicher, dass deine GerÀte eingeschaltet und mit dem gleichen Netzwerk - verbunden sind. -

+

{t("discovery.step1Title")}

+

{t("discovery.step1Desc")}

2
-

GerÀte suchen

-

- Klicke auf “Jetzt suchen” um automatisch alle GerĂ€te im Netzwerk zu - finden. -

+

{t("discovery.step2Title")}

+

{t("discovery.step2Desc")}

3
-

Presets verwalten

-

- Nach erfolgreicher Erkennung kannst du Radiosender auf die Preset-Tasten (1-6) - legen. -

+

{t("discovery.step3Title")}

+

{t("discovery.step3Desc")}

@@ -153,17 +141,17 @@ export default function EmptyState() { {isDiscovering - ? `Suche lÀuft... (${stats.synced} gefunden)` + ? t("discovery.searchingWithCount", { count: stats.synced }) : hasManualIPs - ? "Mit manuellen IPs suchen" - : "Jetzt GerÀte suchen"} + ? t("discovery.searchingWithManualIPs") + : t("discovery.searchNow")} {/* Progressive discovery results */} {isDiscovering && devicesFound.length > 0 && (

- {stats.synced} von {stats.discovered} GerÀten gespeichert... + {t("discovery.savedCount", { synced: stats.synced, discovered: stats.discovered })}

{devicesFound.map((device) => ( @@ -192,7 +180,7 @@ export default function EmptyState() { clipRule="evenodd" /> - GerÀt manuell einrichten + {t("discovery.setupManually")} {hasManualIPs && ( @@ -204,35 +192,31 @@ export default function EmptyState() { clipRule="evenodd" /> - Es wurden manuelle IP-Adressen konfiguriert. Diese werden zusÀtzlich zur automatischen - Erkennung verwendet. + {t("discovery.manualIpsConfigured")}

)}
- Keine GerÀte gefunden? + {t("discovery.noDevicesFoundHelp")}
    -
  • PrĂŒfe ob die GerĂ€te im gleichen WLAN sind wie OpenCloudTouch
  • -
  • Firewall-Regeln könnten die GerĂ€teerkennung blockieren
  • -
  • Starte die GerĂ€te und OpenCloudTouch neu
  • +
  • {t("discovery.helpSameNetwork")}
  • +
  • {t("discovery.helpFirewall")}
  • +
  • {t("discovery.helpRestart")}
  • - FĂŒge GerĂ€te-IPs{" "}
  • {/* REFACT-140: Inline guide link */}
  • - Folge dem{" "} {" "} - fĂŒr eine Schritt-fĂŒr-Schritt Anleitung + {t("discovery.helpSetupWizard")} +
diff --git a/apps/frontend/src/components/ErrorBoundary.test.tsx b/apps/frontend/src/components/ErrorBoundary.test.tsx index b5ffcb03..c2b86e20 100644 --- a/apps/frontend/src/components/ErrorBoundary.test.tsx +++ b/apps/frontend/src/components/ErrorBoundary.test.tsx @@ -1,4 +1,4 @@ -/** +ï»ż/** * Tests for ErrorBoundary component * * Tests error catching, fallback rendering, and reset functionality. @@ -47,8 +47,8 @@ describe("ErrorBoundary", () => { ); - expect(screen.getByText(/Etwas ist schiefgelaufen/i)).toBeInTheDocument(); - expect(screen.getByText(/Ein unerwarteter Fehler ist aufgetreten/i)).toBeInTheDocument(); + expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument(); + expect(screen.getByText(/An unexpected error occurred/i)).toBeInTheDocument(); }); it("displays error details in expandable section", () => { @@ -58,7 +58,7 @@ describe("ErrorBoundary", () => { ); - const details = screen.getByText("Fehlerdetails"); + const details = screen.getByText("Error details"); expect(details).toBeInTheDocument(); // Error message should be in the document (check for summary element, not text duplicates) @@ -94,7 +94,7 @@ describe("ErrorBoundary", () => { ); expect(screen.getByText("Custom error: Test error")).toBeInTheDocument(); - expect(screen.queryByText(/Etwas ist schiefgelaufen/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Something went wrong/i)).not.toBeInTheDocument(); }); it("provides reset function to custom fallback", async () => { @@ -139,7 +139,7 @@ describe("ErrorBoundary", () => { ); - const reloadButton = screen.getByRole("button", { name: /Seite neu laden/i }); + const reloadButton = screen.getByRole("button", { name: /Reload page/i }); expect(reloadButton).toBeInTheDocument(); }); @@ -157,7 +157,7 @@ describe("ErrorBoundary", () => { ); - const reloadButton = screen.getByRole("button", { name: /Seite neu laden/i }); + const reloadButton = screen.getByRole("button", { name: /Reload page/i }); await userEvent.click(reloadButton); expect(reloadMock).toHaveBeenCalledOnce(); @@ -181,7 +181,7 @@ describe("ErrorBoundary", () => { ); // Error boundary shows fallback - expect(screen.getByText(/Etwas ist schiefgelaufen/i)).toBeInTheDocument(); + expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument(); // Error details section exists (use getAllByText since error appears in two places) const errorElements = screen.getAllByText("Error: Deep error", { exact: false }); expect(errorElements.length).toBeGreaterThan(0); @@ -197,7 +197,7 @@ describe("ErrorBoundary", () => { ); // Should show fallback, not the non-throwing children - expect(screen.getByText(/Etwas ist schiefgelaufen/i)).toBeInTheDocument(); + expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument(); expect(screen.queryByText("Child 1")).not.toBeInTheDocument(); }); @@ -212,7 +212,7 @@ describe("ErrorBoundary", () => { ); // Error boundary catches the error - expect(screen.getByText(/Etwas ist schiefgelaufen/i)).toBeInTheDocument(); + expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument(); // Content outside boundary still renders expect(screen.getByText("Outside boundary")).toBeInTheDocument(); @@ -239,7 +239,7 @@ describe("ErrorBoundary", () => { ); - expect(screen.getByText(/Etwas ist schiefgelaufen/i)).toBeInTheDocument(); + expect(screen.getByText(/Something went wrong/i)).toBeInTheDocument(); }); it("handles errors from event handlers (manual trigger)", () => { diff --git a/apps/frontend/src/components/ErrorBoundary.tsx b/apps/frontend/src/components/ErrorBoundary.tsx index 1c7e0385..66659928 100644 --- a/apps/frontend/src/components/ErrorBoundary.tsx +++ b/apps/frontend/src/components/ErrorBoundary.tsx @@ -1,4 +1,5 @@ import { Component, ReactNode } from "react"; +import { i18next } from "../i18n"; import "./ErrorBoundary.css"; interface ErrorBoundaryProps { @@ -51,13 +52,11 @@ export class ErrorBoundary extends Component
⚠
-

Etwas ist schiefgelaufen

-

- Ein unerwarteter Fehler ist aufgetreten. Bitte laden Sie die Seite neu. -

+

{i18next.t("errors.errorBoundaryTitle")}

+

{i18next.t("errors.errorBoundaryMessage")}

- Fehlerdetails + {i18next.t("errors.errorDetails")}
{this.state.error.toString()}
{this.state.error.stack && (
{this.state.error.stack}
@@ -68,16 +67,16 @@ export class ErrorBoundary extends Component window.location.reload()} - aria-label="Seite neu laden" + aria-label={i18next.t("common.reloadPage")} > - Neu laden + {i18next.t("common.reloadPage")}
diff --git a/apps/frontend/src/components/LanguageSelector.css b/apps/frontend/src/components/LanguageSelector.css new file mode 100644 index 00000000..0e97acad --- /dev/null +++ b/apps/frontend/src/components/LanguageSelector.css @@ -0,0 +1,114 @@ +.lang-selector-wrapper { + position: relative; + display: inline-flex; + align-items: center; +} + +.lang-selector { + display: inline-flex; + align-items: center; + gap: 4px; + height: 32px; + padding: 4px 8px; + border: 1px solid #333333; + border-radius: 6px; + background: transparent; + color: inherit; + font-size: 12px; + cursor: pointer; + white-space: nowrap; + transition: background-color 0.15s; +} + +.lang-selector:hover { + background-color: var(--color-card, #1e1e1e); +} + +.lang-flag { + width: 20px; + height: 15px; + border-radius: 2px; + flex-shrink: 0; +} + +.lang-code { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; +} + +.lang-dropdown { + position: absolute; + top: calc(100% + 4px); + right: 0; + min-width: 140px; + list-style: none; + margin: 0; + padding: 4px 0; + background: #242424; + border: 1px solid #333333; + border-radius: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + z-index: 200; + opacity: 1; + transform: translateY(0); + animation: lang-dropdown-in 0.15s ease; +} + +@keyframes lang-dropdown-in { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.lang-option { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + min-height: 44px; + padding: 8px 14px; + font-size: 14px; + cursor: pointer; + transition: background-color 0.1s; +} + +.lang-option:hover { + background-color: rgba(255, 255, 255, 0.06); +} + +.lang-option--active { + color: var(--color-accent, #4a9eff); +} + +.lang-option-flag { + width: 22px; + height: 16px; + border-radius: 2px; + flex-shrink: 0; +} + +.lang-option-name { + flex: 1; +} + +.lang-option-code { + font-size: 11px; + opacity: 0.6; +} + +.lang-option-check { + font-size: 12px; + margin-left: 4px; +} + +@media (max-width: 480px) { + .lang-code { + display: none; + } +} diff --git a/apps/frontend/src/components/LanguageSelector.tsx b/apps/frontend/src/components/LanguageSelector.tsx new file mode 100644 index 00000000..32507b22 --- /dev/null +++ b/apps/frontend/src/components/LanguageSelector.tsx @@ -0,0 +1,86 @@ +import { useRef, useState, useEffect, useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import "flag-icons/css/flag-icons.min.css"; +import { changeLanguage, UI_LOCALES, LOCALE_CONFIGS, UILocale } from "../i18n"; +import "./LanguageSelector.css"; + +export default function LanguageSelector() { + const { i18n } = useTranslation(); + const [open, setOpen] = useState(false); + const wrapperRef = useRef(null); + + const currentLocale = ( + UI_LOCALES.includes(i18n.language as UILocale) ? i18n.language : "en" + ) as UILocale; + + const currentConfig = LOCALE_CONFIGS[currentLocale]; + + const close = useCallback(() => setOpen(false), []); + + // Close on outside click + useEffect(() => { + const handleMouseDown = (e: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { + close(); + } + }; + document.addEventListener("mousedown", handleMouseDown); + return () => document.removeEventListener("mousedown", handleMouseDown); + }, [close]); + + // Close on Escape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") close(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [close]); + + const handleSelect = (locale: UILocale) => { + changeLanguage(locale); + close(); + }; + + return ( +
+ + + {open && ( +
    + {UI_LOCALES.map((locale) => { + const config = LOCALE_CONFIGS[locale]; + const isActive = locale === currentLocale; + return ( +
  • handleSelect(locale)} + > +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/apps/frontend/src/components/ManualIPModal.tsx b/apps/frontend/src/components/ManualIPModal.tsx index c084fba4..2e2ab494 100644 --- a/apps/frontend/src/components/ManualIPModal.tsx +++ b/apps/frontend/src/components/ManualIPModal.tsx @@ -4,6 +4,7 @@ * Validates IP format and persists via the settings API. */ import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { useManualIPs, useSetManualIPs } from "../hooks/useSettings"; interface ManualIPModalProps { @@ -14,6 +15,7 @@ interface ManualIPModalProps { } export default function ManualIPModal({ isOpen, onClose }: ManualIPModalProps) { + const { t } = useTranslation(); const [ipList, setIpList] = useState(""); const [error, setError] = useState(null); const [validationError, setValidationError] = useState(null); @@ -46,7 +48,7 @@ export default function ManualIPModal({ isOpen, onClose }: ManualIPModalProps) { const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/; const invalidIPs = ips.filter((ip) => !ipRegex.test(ip)); if (invalidIPs.length > 0) { - setValidationError(`UngĂŒltiges Format: ${invalidIPs.join(", ")}`); + setValidationError(t("manualIpModal.invalidFormat", { ips: invalidIPs.join(", ") })); } } }; @@ -66,7 +68,7 @@ export default function ManualIPModal({ isOpen, onClose }: ManualIPModalProps) { const invalidIPs = ips.filter((ip) => !ipRegex.test(ip)); if (invalidIPs.length > 0) { - setError(`UngĂŒltige IP-Adressen: ${invalidIPs.join(", ")}`); + setError(t("manualIpModal.invalidFormat", { ips: invalidIPs.join(", ") })); return; } @@ -79,7 +81,7 @@ export default function ManualIPModal({ isOpen, onClose }: ManualIPModalProps) { setSuccess(false); }, 1500); } catch { - setError("Fehler beim Speichern der IP-Adressen"); + setError(t("manualIpModal.saveError")); } }; @@ -100,8 +102,8 @@ export default function ManualIPModal({ isOpen, onClose }: ManualIPModalProps) { >
-

Manuelle IP-Konfiguration

-
-

- Geben Sie die IP-Adressen Ihrer GerÀte ein (eine pro Zeile oder kommagetrennt). -

+

{t("manualIpModal.description")}