From 71def0a9dceedb83686a649eb5cc606cde28d59c Mon Sep 17 00:00:00 2001 From: maziggy Date: Mon, 2 Feb 2026 13:49:35 +0100 Subject: [PATCH] Added full i8n localization support. Available languages are English and German --- CHANGELOG.md | 5 + .../components/ConfirmModal.test.tsx | 2 +- .../components/LinkSpoolModal.test.tsx | 21 +- frontend/src/__tests__/i18n/locales.test.ts | 35 + .../src/__tests__/pages/CameraPage.test.tsx | 30 +- frontend/src/components/ConfirmModal.tsx | 15 +- frontend/src/components/EditArchiveModal.tsx | 98 +- frontend/src/components/FilamentHoverCard.tsx | 29 +- frontend/src/components/FileManagerModal.tsx | 24 +- frontend/src/components/HMSErrorModal.tsx | 11 +- frontend/src/components/KProfilesView.tsx | 192 +- frontend/src/components/Layout.tsx | 41 +- frontend/src/components/LinkSpoolModal.tsx | 21 +- frontend/src/components/MQTTDebugModal.tsx | 34 +- frontend/src/components/PrintModal/index.tsx | 26 +- frontend/src/components/UploadModal.tsx | 22 +- frontend/src/i18n/locales/de.ts | 2132 +++- frontend/src/i18n/locales/en.ts | 2130 +++- frontend/src/pages/ArchivesPage.tsx | 464 +- frontend/src/pages/CameraPage.tsx | 34 +- frontend/src/pages/ExternalLinkPage.tsx | 4 +- frontend/src/pages/FileManagerPage.tsx | 333 +- frontend/src/pages/GroupsPage.tsx | 60 +- frontend/src/pages/LoginPage.tsx | 40 +- frontend/src/pages/MaintenancePage.tsx | 216 +- frontend/src/pages/PrintersPage.tsx | 457 +- frontend/src/pages/ProfilesPage.tsx | 430 +- frontend/src/pages/ProjectDetailPage.tsx | 217 +- frontend/src/pages/ProjectsPage.tsx | 170 +- frontend/src/pages/QueuePage.tsx | 259 +- frontend/src/pages/SettingsPage.tsx | 475 +- frontend/src/pages/SetupPage.tsx | 39 +- frontend/src/pages/StatsPage.tsx | 130 +- frontend/src/pages/StreamOverlayPage.tsx | 40 +- frontend/src/pages/UsersPage.tsx | 108 +- static/assets/index-BQgTn9O8.js | 8665 +++++++++++++++++ static/assets/index-BuwcX3H7.js | 8665 ----------------- static/index.html | 2 +- 38 files changed, 15033 insertions(+), 10643 deletions(-) create mode 100644 frontend/src/__tests__/i18n/locales.test.ts create mode 100644 static/assets/index-BQgTn9O8.js delete mode 100644 static/assets/index-BuwcX3H7.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a78635816..8ebcf9fe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ All notable changes to Bambuddy will be documented in this file. - Link button correctly disabled when no unlinked spools are available in Spoolman - Toast notification shown on successful/failed spool linking - Added `/api/v1/spoolman/spools/linked` endpoint returning map of linked spool tags to IDs +- **Complete German Translations**: + - All UI strings now fully translated to German (1800+ translation keys) + - Pages translated: Settings, Archives, File Manager, Queue, Printers, Profiles, Projects, Stats, Maintenance, Camera, Groups, Users, Login, Setup, Stream Overlay + - Components translated: ConfirmModal, LinkSpoolModal, FilamentHoverCard, Layout + - Added locale parity test to ensure English and German stay in sync ## [0.1.6.2] - 2026-02-02 diff --git a/frontend/src/__tests__/components/ConfirmModal.test.tsx b/frontend/src/__tests__/components/ConfirmModal.test.tsx index 4f360753e..a8882a11c 100644 --- a/frontend/src/__tests__/components/ConfirmModal.test.tsx +++ b/frontend/src/__tests__/components/ConfirmModal.test.tsx @@ -131,7 +131,7 @@ describe('ConfirmModal', () => { it('shows default loading text when loadingText not provided', () => { render(); - expect(screen.getByText('Processing...')).toBeInTheDocument(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); }); it('disables buttons when loading', () => { diff --git a/frontend/src/__tests__/components/LinkSpoolModal.test.tsx b/frontend/src/__tests__/components/LinkSpoolModal.test.tsx index c9da66686..d7738e24a 100644 --- a/frontend/src/__tests__/components/LinkSpoolModal.test.tsx +++ b/frontend/src/__tests__/components/LinkSpoolModal.test.tsx @@ -78,7 +78,8 @@ describe('LinkSpoolModal', () => { render(); await waitFor(() => { - expect(screen.getByText('Link to Spoolman')).toBeInTheDocument(); + // Look for the title in h2 element + expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument(); }); }); @@ -127,13 +128,13 @@ describe('LinkSpoolModal', () => { render(); await waitFor(() => { - expect(screen.getByText('No unlinked spools found in Spoolman.')).toBeInTheDocument(); + expect(screen.getByText('No unlinked spools available')).toBeInTheDocument(); }); }); it('does not render when isOpen is false', () => { render(); - expect(screen.queryByText('Link to Spoolman')).not.toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: /link to spoolman/i })).not.toBeInTheDocument(); }); }); @@ -160,7 +161,7 @@ describe('LinkSpoolModal', () => { expect(screen.getByText('PLA Red')).toBeInTheDocument(); }); - const linkButton = screen.getByRole('button', { name: /link spool/i }); + const linkButton = screen.getByRole('button', { name: /link to spoolman/i }); expect(linkButton).toBeDisabled(); // Select a spool @@ -182,7 +183,7 @@ describe('LinkSpoolModal', () => { fireEvent.click(screen.getByText('PLA Red')); // Click link button - fireEvent.click(screen.getByRole('button', { name: /link spool/i })); + fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i })); await waitFor(() => { expect(api.linkSpool).toHaveBeenCalledWith(1, defaultProps.trayUuid); @@ -197,7 +198,7 @@ describe('LinkSpoolModal', () => { }); fireEvent.click(screen.getByText('PLA Red')); - fireEvent.click(screen.getByRole('button', { name: /link spool/i })); + fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i })); await waitFor(() => { expect(mockShowToast).toHaveBeenCalledWith( @@ -215,7 +216,7 @@ describe('LinkSpoolModal', () => { }); fireEvent.click(screen.getByText('PLA Red')); - fireEvent.click(screen.getByRole('button', { name: /link spool/i })); + fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i })); await waitFor(() => { expect(defaultProps.onClose).toHaveBeenCalled(); @@ -233,7 +234,7 @@ describe('LinkSpoolModal', () => { }); fireEvent.click(screen.getByText('PLA Red')); - fireEvent.click(screen.getByRole('button', { name: /link spool/i })); + fireEvent.click(screen.getByRole('button', { name: /link to spoolman/i })); await waitFor(() => { expect(mockShowToast).toHaveBeenCalledWith( @@ -260,7 +261,7 @@ describe('LinkSpoolModal', () => { render(); await waitFor(() => { - expect(screen.getByText('Link to Spoolman')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument(); }); // Click the backdrop (the element with bg-black/60) @@ -275,7 +276,7 @@ describe('LinkSpoolModal', () => { render(); await waitFor(() => { - expect(screen.getByText('Link to Spoolman')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /link to spoolman/i })).toBeInTheDocument(); }); // Find and click the X button in the header diff --git a/frontend/src/__tests__/i18n/locales.test.ts b/frontend/src/__tests__/i18n/locales.test.ts new file mode 100644 index 000000000..be676d6e7 --- /dev/null +++ b/frontend/src/__tests__/i18n/locales.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import en from '../../i18n/locales/en'; +import de from '../../i18n/locales/de'; + +/** + * Recursively extracts all keys from a nested object as dot-notation paths. + * Example: { foo: { bar: 'baz' } } => ['foo.bar'] + */ +const getKeys = (obj: object, prefix = ''): string[] => { + return Object.entries(obj).flatMap(([key, value]) => { + const path = prefix ? `${prefix}.${key}` : key; + return typeof value === 'object' && value !== null + ? getKeys(value, path) + : [path]; + }); +}; + +describe('i18n locale parity', () => { + const enKeys = new Set(getKeys(en)); + const deKeys = new Set(getKeys(de)); + + it('German locale has all English keys', () => { + const missingInGerman = [...enKeys].filter((k) => !deKeys.has(k)).sort(); + expect(missingInGerman, `Missing ${missingInGerman.length} key(s) in German locale`).toEqual([]); + }); + + it('English locale has all German keys', () => { + const missingInEnglish = [...deKeys].filter((k) => !enKeys.has(k)).sort(); + expect(missingInEnglish, `Missing ${missingInEnglish.length} key(s) in English locale`).toEqual([]); + }); + + it('both locales have the same number of keys', () => { + expect(enKeys.size).toBe(deKeys.size); + }); +}); diff --git a/frontend/src/__tests__/pages/CameraPage.test.tsx b/frontend/src/__tests__/pages/CameraPage.test.tsx index 734f5f82c..c83520cc9 100644 --- a/frontend/src/__tests__/pages/CameraPage.test.tsx +++ b/frontend/src/__tests__/pages/CameraPage.test.tsx @@ -11,6 +11,8 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider } from '../../contexts/ThemeContext'; import { ToastProvider } from '../../contexts/ToastContext'; +import { I18nextProvider } from 'react-i18next'; +import i18n from '../../i18n'; // Mock navigator.sendBeacon which isn't available in jsdom vi.stubGlobal('navigator', { @@ -39,15 +41,17 @@ function renderCameraPage(printerId: number) { return rtlRender( - - - - - } /> - - - - + + + + + + } /> + + + + + ); } @@ -94,8 +98,9 @@ describe('CameraPage', () => { renderCameraPage(1); await waitFor(() => { - expect(screen.getByText('Live')).toBeInTheDocument(); - expect(screen.getByText('Snapshot')).toBeInTheDocument(); + // Check for translation key or translated text + expect(screen.getByText(/Live|camera\.live/)).toBeInTheDocument(); + expect(screen.getByText(/Snapshot|camera\.snapshot/)).toBeInTheDocument(); }); }); @@ -124,7 +129,8 @@ describe('CameraPage', () => { renderCameraPage(0); await waitFor(() => { - expect(screen.getByText('Invalid printer ID')).toBeInTheDocument(); + // Check for translation key or translated text + expect(screen.getByText(/Invalid printer ID|camera\.invalidPrinterId/)).toBeInTheDocument(); }); }); }); diff --git a/frontend/src/components/ConfirmModal.tsx b/frontend/src/components/ConfirmModal.tsx index c71b527b4..775a88216 100644 --- a/frontend/src/components/ConfirmModal.tsx +++ b/frontend/src/components/ConfirmModal.tsx @@ -1,4 +1,5 @@ import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import { AlertTriangle, Loader2 } from 'lucide-react'; import { Card, CardContent } from './Card'; import { Button } from './Button'; @@ -18,14 +19,18 @@ interface ConfirmModalProps { export function ConfirmModal({ title, message, - confirmText = 'Confirm', - cancelText = 'Cancel', + confirmText, + cancelText, variant = 'default', isLoading = false, loadingText, onConfirm, onCancel, }: ConfirmModalProps) { + const { t } = useTranslation(); + const resolvedConfirmText = confirmText ?? t('common.confirm'); + const resolvedCancelText = cancelText ?? t('common.cancel'); + const resolvedLoadingText = loadingText ?? t('common.loading'); // Close on Escape key (but not while loading) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -70,7 +75,7 @@ export function ConfirmModal({
diff --git a/frontend/src/components/EditArchiveModal.tsx b/frontend/src/components/EditArchiveModal.tsx index 22f6ddc88..9aa637037 100644 --- a/frontend/src/components/EditArchiveModal.tsx +++ b/frontend/src/components/EditArchiveModal.tsx @@ -1,30 +1,28 @@ import { useState, useEffect, useRef } from 'react'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; import { X, Save, Tag, Camera, Trash2, Loader2, Plus, FolderKanban, Hash, Link } from 'lucide-react'; import { api } from '../api/client'; import type { Archive } from '../api/client'; import { Button } from './Button'; -const FAILURE_REASONS = [ - 'Adhesion failure', - 'Spaghetti / Detached', - 'Layer shift', - 'Clogged nozzle', - 'Filament runout', - 'Warping', - 'Stringing', - 'Under-extrusion', - 'Power failure', - 'User cancelled', - 'Other', -]; +// Keys for failure reasons - translated at render time +const FAILURE_REASON_KEYS = [ + 'adhesionFailure', + 'spaghettiDetached', + 'layerShift', + 'cloggedNozzle', + 'filamentRunout', + 'warping', + 'stringing', + 'underExtrusion', + 'powerFailure', + 'userCancelled', + 'other', +] as const; -const ARCHIVE_STATUSES = [ - { value: 'completed', label: 'Completed' }, - { value: 'failed', label: 'Failed' }, - { value: 'aborted', label: 'Cancelled' }, - { value: 'printing', label: 'Printing' }, -]; +// Keys for archive statuses - translated at render time +const ARCHIVE_STATUS_KEYS = ['completed', 'failed', 'aborted', 'printing'] as const; interface EditArchiveModalProps { archive: Archive; @@ -33,6 +31,8 @@ interface EditArchiveModalProps { } export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditArchiveModalProps) { + const { t } = useTranslation(); + // Close on Escape key useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -205,7 +205,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr > {/* Header */}
-

Edit Archive

+

{t('editArchive.title')}