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/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6ebce40..7fc49c37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,10 +10,11 @@ name: CI on: push: branches: - - '**' + - main + pull_request: - branches: - - '**' + types: [opened, synchronize, reopened] + workflow_dispatch: jobs: @@ -180,7 +181,9 @@ jobs: if: always() with: name: backend-coverage - path: .out/coverage/backend/coverage.json + path: | + .out/coverage/backend/coverage.json + .out/coverage/backend/coverage.xml retention-days: 1 # ============================================================================ @@ -225,7 +228,9 @@ jobs: if: always() with: name: frontend-coverage - path: .out/coverage/frontend/coverage-summary.json + path: | + .out/coverage/frontend/coverage-summary.json + .out/coverage/frontend/lcov.info retention-days: 1 # ============================================================================ diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 9be0ac4c..c85fc2ee 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -3,10 +3,11 @@ on: push: branches: - main - pull_request: - types: [opened, synchronize, reopened] schedule: - cron: '0 6 * * 1' # Every Monday 6:00 UTC + # NOTE: PR analysis is handled by the sonar job in ci.yml (which uses + # pre-built coverage artifacts). Do NOT add pull_request trigger here + # — it would create a competing scan that overwrites coverage results. jobs: sonarqube: name: SonarQube diff --git a/apps/backend/openapi.yaml b/apps/backend/openapi.yaml index 48210d92..59d77f4e 100644 --- a/apps/backend/openapi.yaml +++ b/apps/backend/openapi.yaml @@ -2531,6 +2531,42 @@ paths: content: application/json: schema: {} + /api/logs/backend: + get: + tags: + - logs + summary: Download Backend Log + description: Returns the in-memory backend log as a plain-text file download. + operationId: download_backend_log_api_logs_backend_get + responses: + '200': + description: Plain-text log file download + content: + text/plain: + schema: + type: string + /api/bug-report: + post: + tags: + - bug-report + summary: Submit Bug Report + description: Submit a bug report that creates a GitHub issue with diagnostic data. + operationId: submit_bug_report_api_bug_report_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BugReportRequest' + responses: + '200': + description: Bug report submitted successfully + content: + application/json: + schema: + $ref: '#/components/schemas/BugReportResponse' + '503': + description: GitHub token not configured components: schemas: BackupRequest: @@ -2570,6 +2606,50 @@ components: - message title: BackupResponse description: Response with backup results. + BugReportRequest: + type: object + required: + - description + - steps_to_reproduce + - expected_behavior + - installation_type + - hardware + properties: + description: + type: string + minLength: 20 + steps_to_reproduce: + type: string + minLength: 10 + expected_behavior: + type: string + minLength: 10 + installation_type: + type: string + hardware: + type: string + soundtouch_devices: + type: array + items: + type: string + default: [] + other_installation: + type: string + default: '' + other_hardware: + type: string + default: '' + title: BugReportRequest + BugReportResponse: + type: object + properties: + issue_url: + type: string + issue_number: + type: integer + message: + type: string + title: BugReportResponse Body_set_mute_api_devices__device_id__mute_put: properties: muted: diff --git a/apps/backend/src/opencloudtouch/api/bug_report.py b/apps/backend/src/opencloudtouch/api/bug_report.py new file mode 100644 index 00000000..60da2bf7 --- /dev/null +++ b/apps/backend/src/opencloudtouch/api/bug_report.py @@ -0,0 +1,355 @@ +"""Bug report API route — collects diagnostics and creates GitHub Issues.""" + +import logging +from datetime import UTC, datetime + +import httpx +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +from opencloudtouch.core.config import get_config +from opencloudtouch.core.logging import get_log_entries + + +def _anonymize_ip(ip: str) -> str: + """Mask middle octets of an IPv4 address: 192.168.178.88 → 192.x.x.88""" + parts = ip.split(".") + if len(parts) == 4: + return f"{parts[0]}.x.x.{parts[3]}" + return ip + + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api", tags=["bug-report"]) + + +# --------------------------------------------------------------------------- +# Request / Response models +# --------------------------------------------------------------------------- + + +class BugReportRequest(BaseModel): + description: str = Field(min_length=10, max_length=2000) + steps_to_reproduce: str = Field(min_length=10, max_length=2000) + expected_behavior: str = Field(min_length=5, max_length=1000) + installation_type: str + hardware: str + soundtouch_devices: list[str] = [] + network_config: str = "" + additional_info: str = "" + other_installation: str = "" + other_hardware: str = "" + other_device: str = "" + screenshot_data_url: str = "" + frontend_logs: list[dict] = [] + browser_info: str = "" + current_route: str = "" + click_timestamp: float = 0.0 + + +class BugReportResponse(BaseModel): + issue_url: str + + +# --------------------------------------------------------------------------- +# Route +# --------------------------------------------------------------------------- + + +@router.post("/bug-report", response_model=BugReportResponse) +async def create_bug_report(request_body: BugReportRequest, request: Request): + """Create a bug report as a GitHub Issue with auto-collected diagnostics.""" + config = get_config() + if not config.github_token: + raise HTTPException( + status_code=503, + detail="Bug reporting is not configured. Set OCT_GITHUB_TOKEN.", + ) + + diagnostics = await _collect_diagnostics(request, request_body.click_timestamp) + body = _build_issue_body(request_body, diagnostics) + + # Truncate to GitHub's 65536 char limit + if len(body) > 64000: + body = body[:64000] + "\n\n---\n*Truncated: body exceeded 64KB limit*" + + issue_url, issue_number = await _create_github_issue( + token=config.github_token, + repo=config.github_repo, + title=f"\U0001f41b [{request_body.installation_type}] {request_body.description[:70]}", + body=body, + labels=["bug", "user-report"], + ) + + # Upload screenshot as repo file and edit issue body to reference it + if request_body.screenshot_data_url: + try: + screenshot_url = await _upload_screenshot( + token=config.github_token, + repo=config.github_repo, + issue_number=issue_number, + data_url=request_body.screenshot_data_url, + ) + if screenshot_url: + screenshot_md = ( + f"## Screenshot\n\n![Browser Screenshot]({screenshot_url})" + ) + body = body + f"\n\n---\n\n{screenshot_md}" + await _update_issue_body( + token=config.github_token, + repo=config.github_repo, + issue_number=issue_number, + body=body, + ) + except Exception: + logger.debug("Could not upload screenshot to GitHub") + + logger.info(f"Bug report created: {issue_url}") + return BugReportResponse(issue_url=issue_url) + + +# --------------------------------------------------------------------------- +# Diagnostics collection +# --------------------------------------------------------------------------- + + +async def _collect_diagnostics(request: Request, click_timestamp: float = 0.0) -> dict: + """Collect backend diagnostic data (anonymized, no secrets).""" + from opencloudtouch import __version__ + + config = get_config() + + devices = [] + device_id_lookup: dict[str, int] = {} # device_id → DB id + db_stats = {"presets": "?", "recents": "?", "devices": "?"} + + try: + device_repo = request.app.state.device_repo + all_devices = await device_repo.get_all() + for d in all_devices: + device_id_lookup[d.device_id] = d.id + devices = [ + { + "name": d.name, + "uuid": d.id, + "ip": _anonymize_ip(d.ip), + } + for d in all_devices + ] + db_stats["devices"] = len(all_devices) + except Exception: + logger.debug("Could not collect device info for bug report") + + try: + preset_repo = request.app.state.preset_repo + total_presets = 0 + for d_id in device_id_lookup: + presets = await preset_repo.get_all_presets(d_id) + total_presets += len(presets) + db_stats["presets"] = total_presets + except Exception: + logger.debug("Could not collect preset count for bug report") + + try: + recents_repo = request.app.state.recents_repo + total_recents = 0 + for d_id in device_id_lookup: + recents = await recents_repo.get_recents(d_id) + total_recents += len(recents) + db_stats["recents"] = total_recents + except Exception: + logger.debug("Could not collect recents count for bug report") + + ring_buffer = get_log_entries() + backend_logs = ring_buffer[-100:] if ring_buffer else [] + + # Anonymize manual_device_ips + anon_ips = [_anonymize_ip(ip) for ip in config.manual_device_ips] + + return { + "backend_version": __version__, + "backend_logs": backend_logs, + "config": { + "discovery_enabled": config.discovery_enabled, + "mock_mode": config.mock_mode, + "log_level": config.log_level, + "manual_device_ips": anon_ips, + }, + "devices": devices, + "db_stats": db_stats, + "timestamp": datetime.now(UTC).isoformat(), + } + + +# --------------------------------------------------------------------------- +# Markdown builder +# --------------------------------------------------------------------------- + + +def _build_issue_body(req: BugReportRequest, diag: dict) -> str: + """Build structured Markdown issue body.""" + devices_str = ( + ", ".join(req.soundtouch_devices) if req.soundtouch_devices else "Not specified" + ) + network_labels = {"wifi": "Wi-Fi", "lan": "LAN", "mixed": "Mixed"} + + # Append "Other" details + install_str = req.installation_type + if req.other_installation: + install_str += f" ({req.other_installation})" + hw_str = req.hardware + if req.other_hardware: + hw_str += f" ({req.other_hardware})" + if req.other_device: + devices_str += f" ({req.other_device})" + + sections = [ + f"## Bug Description\n\n{req.description}", + f"## Steps to Reproduce\n\n{req.steps_to_reproduce}", + f"## Expected Behavior\n\n{req.expected_behavior}", + ( + f"## Environment\n\n" + f"| | |\n|---|---|\n" + f"| **OCT Version** | Backend v{diag['backend_version']} |\n" + f"| **Installation Type** | {install_str} |\n" + f"| **Hardware** | {hw_str} |\n" + f"| **SoundTouch Device(s)** | {devices_str} |\n" + f"| **Network** | {network_labels.get(req.network_config, req.network_config or 'Not specified')} |\n" + f"| **Browser** | {req.browser_info} |\n" + f"| **Route** | {req.current_route} |\n" + f"| **Timestamp** | {diag.get('timestamp', 'N/A')} |" + ), + ] + + if req.additional_info: + sections.append(f"## Additional Info\n\n{req.additional_info}") + + # Screenshot placeholder — actual image is uploaded separately after issue creation + + # Device Status + if diag.get("devices"): + device_lines = "\n".join( + f"- {d['name']} (ID {d['uuid']}) — {d.get('ip', 'unknown')}" + for d in diag["devices"] + ) + sections.append(f"## Device Status\n\n{device_lines}") + + # DB Stats + stats = diag.get("db_stats", {}) + if stats: + sections.append( + f"## DB Statistics\n\n" + f"- Presets: {stats.get('presets', '?')}\n" + f"- Recents: {stats.get('recents', '?')}\n" + f"- Devices: {stats.get('devices', '?')}" + ) + + # Config (sanitized) + cfg = diag.get("config", {}) + if cfg: + config_str = "\n".join(f"- {k}: `{v}`" for k, v in cfg.items()) + sections.append(f"## Configuration\n\n{config_str}") + + # Frontend Logs + if req.frontend_logs: + log_lines = "\n".join( + f"[{entry.get('timestamp', '')}] {entry.get('level', '')}: {entry.get('message', '')}" + for entry in req.frontend_logs[-100:] + ) + sections.append(f"## Frontend Logs (last 100)\n\n```\n{log_lines}\n```") + + # Backend Logs (captured before user clicked 'Report a Bug') + be_logs = diag.get("backend_logs", []) + if be_logs: + log_lines = "\n".join( + f"[{entry.get('timestamp', '')}] {entry.get('level', '')}: {entry.get('message', '')}" + for entry in be_logs + ) + sections.append( + f"## Backend Logs (last 100 before report)\n\n```\n{log_lines}\n```" + ) + + return "\n\n---\n\n".join(sections) + + +# --------------------------------------------------------------------------- +# GitHub API +# --------------------------------------------------------------------------- + + +async def _create_github_issue( + token: str, repo: str, title: str, body: str, labels: list[str] +) -> tuple[str, int]: + """Create a GitHub Issue via REST API. Returns (html_url, issue_number).""" + async with httpx.AsyncClient() as client: + response = await client.post( + f"https://api.github.com/repos/{repo}/issues", + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + }, + json={"title": title, "body": body, "labels": labels}, + timeout=15.0, + ) + if response.status_code != 201: + logger.error( + f"GitHub API error: {response.status_code} — {response.text[:200]}" + ) + raise HTTPException( + status_code=502, + detail=f"GitHub API error: {response.status_code}", + ) + data = response.json() + return data["html_url"], data["number"] + + +async def _upload_screenshot( + token: str, repo: str, issue_number: int, data_url: str +) -> str | None: + """Upload screenshot to repo via Contents API, return raw URL.""" + + # Parse data URL: "data:image/jpeg;base64,/9j/..." + if ";base64," not in data_url: + return None + raw_b64 = data_url.split(";base64,", 1)[1] + + path = f".github/bug-screenshots/issue-{issue_number}.jpg" + async with httpx.AsyncClient() as client: + response = await client.put( + f"https://api.github.com/repos/{repo}/contents/{path}", + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + }, + json={ + "message": f"screenshot for #{issue_number}", + "content": raw_b64, + }, + timeout=30.0, + ) + if response.status_code not in (200, 201): + logger.warning( + f"Screenshot upload failed: {response.status_code} — {response.text[:200]}" + ) + return None + return response.json()["content"]["download_url"] + + +async def _update_issue_body( + token: str, repo: str, issue_number: int, body: str +) -> None: + """Update the body of an existing issue.""" + async with httpx.AsyncClient() as client: + await client.patch( + f"https://api.github.com/repos/{repo}/issues/{issue_number}", + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + }, + json={"body": body}, + timeout=15.0, + ) 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/core/repository.py b/apps/backend/src/opencloudtouch/core/repository.py index 4667b8cb..a3017a22 100644 --- a/apps/backend/src/opencloudtouch/core/repository.py +++ b/apps/backend/src/opencloudtouch/core/repository.py @@ -41,6 +41,11 @@ async def initialize(self) -> None: # Connect to database self._db = await aiosqlite.connect(str(self.db_path)) + # Enable WAL journal mode for concurrent read/write access + await self._db.execute("PRAGMA journal_mode=WAL") + await self._db.execute("PRAGMA busy_timeout=5000") + await self._db.commit() + # Global schema_versions table (shared across all repos in the same DB) await self._db.execute(""" CREATE TABLE IF NOT EXISTS schema_versions ( diff --git a/apps/backend/src/opencloudtouch/devices/client_adapter.py b/apps/backend/src/opencloudtouch/devices/client_adapter.py index 843aa0e0..7925b9db 100644 --- a/apps/backend/src/opencloudtouch/devices/client_adapter.py +++ b/apps/backend/src/opencloudtouch/devices/client_adapter.py @@ -481,9 +481,7 @@ async def remove_zone_members(self, members: list[ZoneMemberInfo]) -> None: ZoneMember(ipAddress=m.ip_address, deviceId=m.device_id) for m in members ] - await asyncio.to_thread( - self._client.RemoveZoneMembers, zone_members, delay=3 - ) + await asyncio.to_thread(self._client.RemoveZoneMembers, zone_members, delay=3) # fmt: skip except Exception as e: logger.error( f"Failed to remove zone members on {self.base_url}: {e}", exc_info=True diff --git a/apps/backend/src/opencloudtouch/main.py b/apps/backend/src/opencloudtouch/main.py index 65526eb6..4e67989d 100644 --- a/apps/backend/src/opencloudtouch/main.py +++ b/apps/backend/src/opencloudtouch/main.py @@ -22,6 +22,8 @@ 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.api.bug_report import router as bug_report_router from opencloudtouch.core.static_files import ( find_frontend_static_dir, mount_static_files, @@ -244,6 +246,8 @@ 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 +app.include_router(bug_report_router) # Bug report submission # Health endpoint diff --git a/apps/backend/tests/unit/core/test_logs_routes.py b/apps/backend/tests/unit/core/test_logs_routes.py new file mode 100644 index 00000000..cd22e53b --- /dev/null +++ b/apps/backend/tests/unit/core/test_logs_routes.py @@ -0,0 +1,64 @@ +"""Tests for GET /api/logs/backend endpoint.""" + +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(): + from opencloudtouch.core.logs_routes import router + from fastapi import FastAPI + + app = FastAPI() + app.include_router(router) + return TestClient(app) + + +class TestDownloadBackendLogs: + """Tests for the /api/logs/backend download endpoint.""" + + def test_returns_200_with_plain_text_content_type(self, client: TestClient): + with patch( + "opencloudtouch.core.logs_routes.get_log_entries", + return_value=["2025-01-01 INFO line one", "2025-01-01 INFO line two"], + ): + response = client.get("/api/logs/backend") + + assert response.status_code == 200 + assert "text/plain" in response.headers["content-type"] + + def test_returns_entries_joined_by_newlines(self, client: TestClient): + entries = ["entry A", "entry B", "entry C"] + with patch( + "opencloudtouch.core.logs_routes.get_log_entries", + return_value=entries, + ): + response = client.get("/api/logs/backend") + + assert response.text == "entry A\nentry B\nentry C" + + def test_returns_placeholder_when_no_entries(self, client: TestClient): + with patch( + "opencloudtouch.core.logs_routes.get_log_entries", + return_value=[], + ): + response = client.get("/api/logs/backend") + + assert response.status_code == 200 + assert "(no log entries captured yet)" in response.text + + def test_content_disposition_is_attachment_with_log_filename( + self, client: TestClient + ): + with patch( + "opencloudtouch.core.logs_routes.get_log_entries", + return_value=["line"], + ): + response = client.get("/api/logs/backend") + + disposition = response.headers.get("content-disposition", "") + assert "attachment" in disposition + assert "oct-backend-" in disposition + assert ".log" in disposition diff --git a/apps/backend/tests/unit/test_bug_report.py b/apps/backend/tests/unit/test_bug_report.py new file mode 100644 index 00000000..1ff2b76f --- /dev/null +++ b/apps/backend/tests/unit/test_bug_report.py @@ -0,0 +1,662 @@ +"""Unit tests for the bug report API route.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from opencloudtouch.api.bug_report import ( + BugReportRequest, + _anonymize_ip, + _build_issue_body, + _collect_diagnostics, + _create_github_issue, + _update_issue_body, + _upload_screenshot, +) + +# --------------------------------------------------------------------------- +# Model validation +# --------------------------------------------------------------------------- + + +class TestBugReportRequest: + def test_valid_request(self): + req = BugReportRequest( + description="App crashes when clicking presets", + steps_to_reproduce="1. Open preset page\n2. Click any preset", + expected_behavior="Preset should play", + installation_type="docker", + hardware="raspberry-pi-4", + ) + assert req.description == "App crashes when clicking presets" + assert req.soundtouch_devices == [] + assert req.other_installation == "" + + def test_description_too_short(self): + with pytest.raises(Exception): + BugReportRequest( + description="short", + steps_to_reproduce="1. Open preset page\n2. Click any preset", + expected_behavior="Works", + installation_type="docker", + hardware="raspberry-pi-4", + ) + + def test_optional_other_fields(self): + req = BugReportRequest( + description="App crashes consistently on my system", + steps_to_reproduce="1. Start app\n2. Open browser", + expected_behavior="No crash", + installation_type="other", + hardware="other", + other_installation="Synology DSM", + other_hardware="Synology DS920+", + ) + assert req.other_installation == "Synology DSM" + assert req.other_hardware == "Synology DS920+" + + +# --------------------------------------------------------------------------- +# Markdown builder +# --------------------------------------------------------------------------- + + +class TestBuildIssueBody: + def test_basic_sections(self): + req = BugReportRequest( + description="Audio drops out randomly", + steps_to_reproduce="1. Play any radio station\n2. Wait 10 minutes", + expected_behavior="Continuous playback without drops", + installation_type="docker", + hardware="raspberry-pi-4", + soundtouch_devices=["SoundTouch 30", "SoundTouch 10"], + network_config="wifi", + ) + diag = { + "backend_version": "0.2.0", + "backend_logs": [], + "config": { + "discovery_enabled": True, + "mock_mode": False, + "log_level": "INFO", + "manual_device_ips": "", + }, + "devices": [], + "db_stats": {"presets": 12, "recents": 5, "devices": 2}, + "timestamp": "2025-01-01T00:00:00+00:00", + } + + body = _build_issue_body(req, diag) + + assert "## Bug Description" in body + assert "Audio drops out randomly" in body + assert "## Steps to Reproduce" in body + assert "## Expected Behavior" in body + assert "## Environment" in body + assert "docker" in body + assert "raspberry-pi-4" in body + assert "SoundTouch 30, SoundTouch 10" in body + assert "Wi-Fi" in body + assert "## DB Statistics" in body + + def test_other_fields_appended(self): + req = BugReportRequest( + description="Cannot access the web interface at all", + steps_to_reproduce="1. Open browser\n2. Navigate to URL", + expected_behavior="Web UI loads", + installation_type="other", + hardware="other", + other_installation="Synology DSM", + other_hardware="Synology DS920+", + ) + diag = { + "backend_version": "0.2.0", + "backend_logs": [], + "config": {}, + "devices": [], + "db_stats": {}, + "timestamp": "", + } + + body = _build_issue_body(req, diag) + + assert "other (Synology DSM)" in body + assert "other (Synology DS920+)" in body + + def test_frontend_logs_included(self): + req = BugReportRequest( + description="Error boundary triggered unexpectedly", + steps_to_reproduce="1. Navigate between pages quickly", + expected_behavior="Smooth navigation", + installation_type="docker", + hardware="raspberry-pi-4", + frontend_logs=[ + { + "timestamp": "12:00:00", + "level": "ERROR", + "message": "Component unmount race", + }, + ], + ) + diag = { + "backend_version": "0.2.0", + "backend_logs": [ + {"timestamp": "12:00:01", "level": "WARNING", "message": "Slow query"}, + ], + "config": {}, + "devices": [], + "db_stats": {}, + "timestamp": "", + } + + body = _build_issue_body(req, diag) + + assert "## Frontend Logs" in body + assert "Component unmount race" in body + assert "## Backend Logs" in body + assert "Slow query" in body + + def test_screenshot_not_in_body(self): + """Screenshot is uploaded separately, not embedded in issue body.""" + req = BugReportRequest( + description="Visual glitch in preset grid layout", + steps_to_reproduce="1. Open preset page\n2. Resize window", + expected_behavior="Grid adjusts properly", + installation_type="docker", + hardware="raspberry-pi-4", + screenshot_data_url="data:image/jpeg;base64,/9j/4AAQ...", + ) + diag = { + "backend_version": "0.2.0", + "backend_logs": [], + "config": {}, + "devices": [], + "db_stats": {}, + "timestamp": "", + } + + body = _build_issue_body(req, diag) + assert "## Screenshot" not in body + + def test_screenshot_placeholder_comment_in_body(self): + """Body should not contain data URL (uploaded separately).""" + req = BugReportRequest( + description="Visual glitch in preset grid layout", + steps_to_reproduce="1. Open preset page\n2. Resize window", + expected_behavior="Grid adjusts properly", + installation_type="docker", + hardware="raspberry-pi-4", + screenshot_data_url="data:image/jpeg;base64," + "A" * 50000, + ) + diag = { + "backend_version": "0.2.0", + "backend_logs": [], + "config": {}, + "devices": [], + "db_stats": {}, + "timestamp": "", + } + + body = _build_issue_body(req, diag) + assert "data:image" not in body + + def test_device_status_with_devices(self): + req = BugReportRequest( + description="Cannot control specific device properly", + steps_to_reproduce="1. Select device\n2. Try to change volume", + expected_behavior="Volume changes", + installation_type="docker", + hardware="raspberry-pi-4", + ) + diag = { + "backend_version": "0.2.0", + "backend_logs": [], + "config": {}, + "devices": [ + {"name": "Living Room", "uuid": 1, "ip": "192.x.x.50"}, + ], + "db_stats": {}, + "timestamp": "", + } + + body = _build_issue_body(req, diag) + assert "## Device Status" in body + assert "Living Room" in body + assert "ID 1" in body + + def test_additional_info_included(self): + req = BugReportRequest( + description="Intermittent connectivity loss to devices", + steps_to_reproduce="1. Wait for some time\n2. Check device status", + expected_behavior="Devices stay connected", + installation_type="docker", + hardware="raspberry-pi-4", + additional_info="This only happens after midnight", + ) + diag = { + "backend_version": "0.2.0", + "backend_logs": [], + "config": {}, + "devices": [], + "db_stats": {}, + "timestamp": "", + } + + body = _build_issue_body(req, diag) + assert "## Additional Info" in body + assert "This only happens after midnight" in body + + +# --------------------------------------------------------------------------- +# Diagnostics collection +# --------------------------------------------------------------------------- + + +class TestCollectDiagnostics: + @pytest.mark.asyncio + async def test_collect_with_mocked_repos(self): + mock_request = MagicMock() + + mock_device = MagicMock() + mock_device.name = "Kitchen" + mock_device.device_id = "dev001" + mock_device.ip_address = "10.0.0.1" + + mock_device_repo = AsyncMock() + mock_device_repo.get_all.return_value = [mock_device] + + mock_preset_repo = AsyncMock() + mock_preset_repo.get_all_presets.return_value = [MagicMock(), MagicMock()] + + mock_recents_repo = AsyncMock() + mock_recents_repo.get_recents.return_value = [MagicMock()] + + mock_request.app.state.device_repo = mock_device_repo + mock_request.app.state.preset_repo = mock_preset_repo + mock_request.app.state.recents_repo = mock_recents_repo + + diag = await _collect_diagnostics(mock_request) + + assert diag["backend_version"] is not None + assert isinstance(diag["backend_version"], str) + assert diag["devices"][0]["name"] == "Kitchen" + assert diag["db_stats"]["devices"] == 1 + assert diag["db_stats"]["presets"] == 2 + assert diag["db_stats"]["recents"] == 1 + assert "timestamp" in diag + assert "config" in diag + + @pytest.mark.asyncio + async def test_collect_handles_repo_errors(self): + mock_request = MagicMock() + mock_request.app.state.device_repo = AsyncMock( + get_all=AsyncMock(side_effect=RuntimeError("DB down")) + ) + + diag = await _collect_diagnostics(mock_request) + + assert diag["backend_version"] is not None + assert isinstance(diag["backend_version"], str) + assert diag["devices"] == [] + assert diag["db_stats"]["devices"] == "?" + + +# --------------------------------------------------------------------------- +# Route integration +# --------------------------------------------------------------------------- + + +class TestBugReportRoute: + def _make_payload(self, **overrides): + defaults = { + "description": "Something is broken in the application", + "steps_to_reproduce": "1. Open the app\n2. Click on presets", + "expected_behavior": "Presets should load correctly", + "installation_type": "docker", + "hardware": "raspberry-pi-4", + } + defaults.update(overrides) + return defaults + + def test_returns_503_without_token(self): + from opencloudtouch.main import app + + with patch("opencloudtouch.api.bug_report.get_config") as mock_cfg: + cfg = MagicMock() + cfg.github_token = "" + mock_cfg.return_value = cfg + + client = TestClient(app, raise_server_exceptions=False) + response = client.post("/api/bug-report", json=self._make_payload()) + assert response.status_code == 503 + + @patch("opencloudtouch.api.bug_report._create_github_issue") + @patch("opencloudtouch.api.bug_report._collect_diagnostics") + @patch("opencloudtouch.api.bug_report.get_config") + def test_success_returns_issue_url(self, mock_cfg, mock_diag, mock_create): + from opencloudtouch.main import app + + cfg = MagicMock() + cfg.github_token = "ghp_test123" + cfg.github_repo = "test/repo" + mock_cfg.return_value = cfg + + mock_diag.return_value = { + "backend_version": "0.2.0", + "backend_logs": [], + "config": {}, + "devices": [], + "db_stats": {}, + "timestamp": "", + } + mock_create.return_value = ("https://github.com/test/repo/issues/42", 42) + + client = TestClient(app, raise_server_exceptions=False) + response = client.post("/api/bug-report", json=self._make_payload()) + + assert response.status_code == 200 + assert response.json()["issue_url"] == "https://github.com/test/repo/issues/42" + mock_create.assert_called_once() + + def test_validation_rejects_short_description(self): + from opencloudtouch.main import app + + client = TestClient(app, raise_server_exceptions=False) + response = client.post( + "/api/bug-report", + json=self._make_payload(description="short"), + ) + assert response.status_code == 422 + + +# --------------------------------------------------------------------------- +# _anonymize_ip helper +# --------------------------------------------------------------------------- + + +class TestAnonymizeIp: + def test_ipv4_masks_middle_octets(self): + assert _anonymize_ip("192.168.178.88") == "192.x.x.88" + + def test_non_ipv4_returns_as_is(self): + assert _anonymize_ip("localhost") == "localhost" + assert _anonymize_ip("::1") == "::1" + assert _anonymize_ip("not.an.ip") == "not.an.ip" + + +# --------------------------------------------------------------------------- +# _create_github_issue error path +# --------------------------------------------------------------------------- + + +class TestCreateGithubIssue: + @pytest.mark.asyncio + async def test_raises_http_502_on_github_error(self): + from fastapi import HTTPException + + mock_response = MagicMock() + mock_response.status_code = 422 + mock_response.text = "Unprocessable Entity" + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client.post = AsyncMock(return_value=mock_response) + + with patch("httpx.AsyncClient", return_value=mock_client): + with pytest.raises(HTTPException) as exc_info: + await _create_github_issue( + token="ghp_test", + repo="test/repo", + title="Bug title", + body="Bug body", + labels=["bug"], + ) + assert exc_info.value.status_code == 502 + + @pytest.mark.asyncio + async def test_returns_url_and_number_on_success(self): + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = { + "html_url": "https://github.com/test/repo/issues/7", + "number": 7, + } + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client.post = AsyncMock(return_value=mock_response) + + with patch("httpx.AsyncClient", return_value=mock_client): + url, number = await _create_github_issue( + token="ghp_test", + repo="test/repo", + title="Bug title", + body="Bug body", + labels=["bug"], + ) + assert url == "https://github.com/test/repo/issues/7" + assert number == 7 + + +# --------------------------------------------------------------------------- +# _upload_screenshot paths +# --------------------------------------------------------------------------- + + +class TestUploadScreenshot: + @pytest.mark.asyncio + async def test_returns_none_for_non_base64_url(self): + result = await _upload_screenshot( + token="ghp_test", + repo="test/repo", + issue_number=1, + data_url="data:image/jpeg,raw-data-no-base64", + ) + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_on_upload_failure(self): + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.text = "Server Error" + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client.put = AsyncMock(return_value=mock_response) + + with patch("httpx.AsyncClient", return_value=mock_client): + result = await _upload_screenshot( + token="ghp_test", + repo="test/repo", + issue_number=1, + data_url="data:image/jpeg;base64,/9j/AAAA", + ) + assert result is None + + @pytest.mark.asyncio + async def test_returns_download_url_on_success(self): + mock_response = MagicMock() + mock_response.status_code = 201 + mock_response.json.return_value = { + "content": { + "download_url": "https://raw.githubusercontent.com/test/repo/main/.github/bug-screenshots/issue-1.jpg" + } + } + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client.put = AsyncMock(return_value=mock_response) + + with patch("httpx.AsyncClient", return_value=mock_client): + result = await _upload_screenshot( + token="ghp_test", + repo="test/repo", + issue_number=1, + data_url="data:image/jpeg;base64,/9j/AAAA", + ) + assert result is not None + assert "raw.githubusercontent.com" in result + + +# --------------------------------------------------------------------------- +# _update_issue_body +# --------------------------------------------------------------------------- + + +class TestUpdateIssueBody: + @pytest.mark.asyncio + async def test_sends_patch_request(self): + mock_response = MagicMock() + mock_response.status_code = 200 + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) + mock_client.patch = AsyncMock(return_value=mock_response) + + with patch("httpx.AsyncClient", return_value=mock_client): + await _update_issue_body( + token="ghp_test", + repo="test/repo", + issue_number=42, + body="Updated issue body", + ) + + mock_client.patch.assert_called_once() + call_kwargs = mock_client.patch.call_args + assert "issues/42" in call_kwargs[0][0] + assert call_kwargs[1]["json"]["body"] == "Updated issue body" + + +# --------------------------------------------------------------------------- +# Route — screenshot upload branch +# --------------------------------------------------------------------------- + + +class TestBugReportRouteScreenshot: + def _make_payload(self, **overrides): + defaults = { + "description": "Something is broken in the application", + "steps_to_reproduce": "1. Open the app\n2. Click on presets", + "expected_behavior": "Presets should load correctly", + "installation_type": "docker", + "hardware": "raspberry-pi-4", + } + defaults.update(overrides) + return defaults + + @patch("opencloudtouch.api.bug_report._update_issue_body") + @patch("opencloudtouch.api.bug_report._upload_screenshot") + @patch("opencloudtouch.api.bug_report._create_github_issue") + @patch("opencloudtouch.api.bug_report._collect_diagnostics") + @patch("opencloudtouch.api.bug_report.get_config") + def test_screenshot_uploaded_and_issue_updated( + self, mock_cfg, mock_diag, mock_create, mock_upload, mock_update + ): + from opencloudtouch.main import app + + cfg = MagicMock() + cfg.github_token = "ghp_test123" + cfg.github_repo = "test/repo" + mock_cfg.return_value = cfg + + mock_diag.return_value = { + "backend_version": "0.2.0", + "backend_logs": [], + "config": {}, + "devices": [], + "db_stats": {}, + "timestamp": "", + } + mock_create.return_value = ("https://github.com/test/repo/issues/42", 42) + mock_upload.return_value = "https://raw.githubusercontent.com/test/repo/main/.github/bug-screenshots/issue-42.jpg" + mock_update.return_value = None + + client = TestClient(app, raise_server_exceptions=False) + response = client.post( + "/api/bug-report", + json=self._make_payload( + screenshot_data_url="data:image/jpeg;base64,/9j/AAAA" + ), + ) + + assert response.status_code == 200 + mock_upload.assert_called_once() + mock_update.assert_called_once() + + @patch("opencloudtouch.api.bug_report._upload_screenshot") + @patch("opencloudtouch.api.bug_report._create_github_issue") + @patch("opencloudtouch.api.bug_report._collect_diagnostics") + @patch("opencloudtouch.api.bug_report.get_config") + def test_screenshot_upload_failure_does_not_break_route( + self, mock_cfg, mock_diag, mock_create, mock_upload + ): + from opencloudtouch.main import app + + cfg = MagicMock() + cfg.github_token = "ghp_test123" + cfg.github_repo = "test/repo" + mock_cfg.return_value = cfg + + mock_diag.return_value = { + "backend_version": "0.2.0", + "backend_logs": [], + "config": {}, + "devices": [], + "db_stats": {}, + "timestamp": "", + } + mock_create.return_value = ("https://github.com/test/repo/issues/99", 99) + mock_upload.return_value = None # upload returned None → skip update + + client = TestClient(app, raise_server_exceptions=False) + response = client.post( + "/api/bug-report", + json=self._make_payload( + screenshot_data_url="data:image/jpeg;base64,/9j/AAAA" + ), + ) + + assert response.status_code == 200 + assert response.json()["issue_url"] == "https://github.com/test/repo/issues/99" + + @patch("opencloudtouch.api.bug_report._upload_screenshot") + @patch("opencloudtouch.api.bug_report._create_github_issue") + @patch("opencloudtouch.api.bug_report._collect_diagnostics") + @patch("opencloudtouch.api.bug_report.get_config") + def test_screenshot_exception_is_caught( + self, mock_cfg, mock_diag, mock_create, mock_upload + ): + from opencloudtouch.main import app + + cfg = MagicMock() + cfg.github_token = "ghp_test123" + cfg.github_repo = "test/repo" + mock_cfg.return_value = cfg + + mock_diag.return_value = { + "backend_version": "0.2.0", + "backend_logs": [], + "config": {}, + "devices": [], + "db_stats": {}, + "timestamp": "", + } + mock_create.return_value = ("https://github.com/test/repo/issues/77", 77) + mock_upload.side_effect = RuntimeError("Network error") + + client = TestClient(app, raise_server_exceptions=False) + response = client.post( + "/api/bug-report", + json=self._make_payload( + screenshot_data_url="data:image/jpeg;base64,/9j/AAAA" + ), + ) + + # Should still return 200 — exception is caught inside the route + assert response.status_code == 200 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..79fc5194 --- /dev/null +++ b/apps/frontend/src/components/LanguageSelector.tsx @@ -0,0 +1,93 @@ +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)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + 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")}