Browse Source

Added full i8n localization support. Available languages are English and German

maziggy 3 months ago
parent
commit
71def0a9dc
38 changed files with 3811 additions and 1632 deletions
  1. 5 0
      CHANGELOG.md
  2. 1 1
      frontend/src/__tests__/components/ConfirmModal.test.tsx
  3. 11 10
      frontend/src/__tests__/components/LinkSpoolModal.test.tsx
  4. 35 0
      frontend/src/__tests__/i18n/locales.test.ts
  5. 18 12
      frontend/src/__tests__/pages/CameraPage.test.tsx
  6. 10 5
      frontend/src/components/ConfirmModal.tsx
  7. 50 50
      frontend/src/components/EditArchiveModal.tsx
  8. 16 13
      frontend/src/components/FilamentHoverCard.tsx
  9. 13 11
      frontend/src/components/FileManagerModal.tsx
  10. 7 4
      frontend/src/components/HMSErrorModal.tsx
  11. 97 95
      frontend/src/components/KProfilesView.tsx
  12. 20 21
      frontend/src/components/Layout.tsx
  13. 11 10
      frontend/src/components/LinkSpoolModal.tsx
  14. 18 16
      frontend/src/components/MQTTDebugModal.tsx
  15. 14 12
      frontend/src/components/PrintModal/index.tsx
  16. 12 10
      frontend/src/components/UploadModal.tsx
  17. 1018 20
      frontend/src/i18n/locales/de.ts
  18. 1017 19
      frontend/src/i18n/locales/en.ts
  19. 157 150
      frontend/src/pages/ArchivesPage.tsx
  20. 18 16
      frontend/src/pages/CameraPage.tsx
  21. 3 1
      frontend/src/pages/ExternalLinkPage.tsx
  22. 148 137
      frontend/src/pages/FileManagerPage.tsx
  23. 31 29
      frontend/src/pages/GroupsPage.tsx
  24. 21 19
      frontend/src/pages/LoginPage.tsx
  25. 119 97
      frontend/src/pages/MaintenancePage.tsx
  26. 147 145
      frontend/src/pages/PrintersPage.tsx
  27. 147 134
      frontend/src/pages/ProfilesPage.tsx
  28. 114 103
      frontend/src/pages/ProjectDetailPage.tsx
  29. 89 81
      frontend/src/pages/ProjectsPage.tsx
  30. 135 124
      frontend/src/pages/QueuePage.tsx
  31. 139 138
      frontend/src/pages/SettingsPage.tsx
  32. 20 19
      frontend/src/pages/SetupPage.tsx
  33. 72 58
      frontend/src/pages/StatsPage.tsx
  34. 22 18
      frontend/src/pages/StreamOverlayPage.tsx
  35. 55 53
      frontend/src/pages/UsersPage.tsx
  36. 0 0
      static/assets/index-BQgTn9O8.js
  37. 0 0
      static/assets/index-BuwcX3H7.js
  38. 1 1
      static/index.html

+ 5 - 0
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
 

+ 1 - 1
frontend/src/__tests__/components/ConfirmModal.test.tsx

@@ -131,7 +131,7 @@ describe('ConfirmModal', () => {
 
     it('shows default loading text when loadingText not provided', () => {
       render(<ConfirmModal {...defaultProps} isLoading={true} />);
-      expect(screen.getByText('Processing...')).toBeInTheDocument();
+      expect(screen.getByText('Loading...')).toBeInTheDocument();
     });
 
     it('disables buttons when loading', () => {

+ 11 - 10
frontend/src/__tests__/components/LinkSpoolModal.test.tsx

@@ -78,7 +78,8 @@ describe('LinkSpoolModal', () => {
       render(<LinkSpoolModal {...defaultProps} />);
 
       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(<LinkSpoolModal {...defaultProps} />);
 
       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(<LinkSpoolModal {...defaultProps} isOpen={false} />);
-      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(<LinkSpoolModal {...defaultProps} />);
 
       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(<LinkSpoolModal {...defaultProps} />);
 
       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

+ 35 - 0
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);
+  });
+});

+ 18 - 12
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(
     <QueryClientProvider client={queryClient}>
-      <MemoryRouter initialEntries={[`/cameras/${printerId}`]}>
-        <ThemeProvider>
-          <ToastProvider>
-            <Routes>
-              <Route path="/cameras/:printerId" element={<CameraPage />} />
-            </Routes>
-          </ToastProvider>
-        </ThemeProvider>
-      </MemoryRouter>
+      <I18nextProvider i18n={i18n}>
+        <MemoryRouter initialEntries={[`/cameras/${printerId}`]}>
+          <ThemeProvider>
+            <ToastProvider>
+              <Routes>
+                <Route path="/cameras/:printerId" element={<CameraPage />} />
+              </Routes>
+            </ToastProvider>
+          </ThemeProvider>
+        </MemoryRouter>
+      </I18nextProvider>
     </QueryClientProvider>
   );
 }
@@ -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();
       });
     });
   });

+ 10 - 5
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({
           </div>
           <div className="flex gap-3 mt-6">
             <Button variant="secondary" onClick={onCancel} className="flex-1" disabled={isLoading}>
-              {cancelText}
+              {resolvedCancelText}
             </Button>
             <Button
               onClick={onConfirm}
@@ -80,10 +85,10 @@ export function ConfirmModal({
               {isLoading ? (
                 <>
                   <Loader2 className="w-4 h-4 mr-2 animate-spin" />
-                  {loadingText || 'Processing...'}
+                  {resolvedLoadingText}
                 </>
               ) : (
-                confirmText
+                resolvedConfirmText
               )}
             </Button>
           </div>

+ 50 - 50
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',
-];
-
-const ARCHIVE_STATUSES = [
-  { value: 'completed', label: 'Completed' },
-  { value: 'failed', label: 'Failed' },
-  { value: 'aborted', label: 'Cancelled' },
-  { value: 'printing', label: 'Printing' },
-];
+// 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;
+
+// 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 */}
         <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
-          <h2 className="text-lg font-semibold text-white">Edit Archive</h2>
+          <h2 className="text-lg font-semibold text-white">{t('editArchive.title')}</h2>
           <button
             onClick={onClose}
             className="text-bambu-gray hover:text-white transition-colors"
@@ -218,25 +218,25 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
         <form onSubmit={handleSubmit} className="p-6 space-y-4 overflow-y-auto flex-1">
           {/* Print Name */}
           <div>
-            <label className="block text-sm text-bambu-gray mb-1">Name</label>
+            <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.name')}</label>
             <input
               type="text"
               value={printName}
               onChange={(e) => setPrintName(e.target.value)}
               className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-              placeholder="Print name"
+              placeholder={t('editArchive.namePlaceholder')}
             />
           </div>
 
           {/* Printer */}
           <div>
-            <label className="block text-sm text-bambu-gray mb-1">Printer</label>
+            <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.printer')}</label>
             <select
               value={printerId ?? ''}
               onChange={(e) => setPrinterId(e.target.value ? Number(e.target.value) : null)}
               className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
             >
-              <option value="">No printer</option>
+              <option value="">{t('editArchive.noPrinter')}</option>
               {printers?.map((p) => (
                 <option key={p.id} value={p.id}>
                   {p.name}
@@ -249,14 +249,14 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
           <div>
             <label className="block text-sm text-bambu-gray mb-1">
               <FolderKanban className="w-4 h-4 inline mr-1" />
-              Project
+              {t('editArchive.project')}
             </label>
             <select
               value={projectId ?? ''}
               onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}
               className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
             >
-              <option value="">No project</option>
+              <option value="">{t('editArchive.noProject')}</option>
               {projects?.map((p) => (
                 <option key={p.id} value={p.id}>
                   {p.name}
@@ -269,7 +269,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
           <div>
             <label className="block text-sm text-bambu-gray mb-1">
               <Hash className="w-4 h-4 inline mr-1" />
-              Items Printed
+              {t('editArchive.itemsPrinted')}
             </label>
             <input
               type="number"
@@ -280,19 +280,19 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
               placeholder="1"
             />
             <p className="text-xs text-bambu-gray mt-1">
-              Number of items produced in this print job
+              {t('editArchive.itemsPrintedHelp')}
             </p>
           </div>
 
           {/* Notes */}
           <div>
-            <label className="block text-sm text-bambu-gray mb-1">Notes</label>
+            <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.notes')}</label>
             <textarea
               value={notes}
               onChange={(e) => setNotes(e.target.value)}
               rows={3}
               className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none resize-none"
-              placeholder="Add notes about this print..."
+              placeholder={t('editArchive.notesPlaceholder')}
             />
           </div>
 
@@ -300,7 +300,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
           <div>
             <label className="block text-sm text-bambu-gray mb-1">
               <Link className="w-4 h-4 inline mr-1" />
-              External Link
+              {t('editArchive.externalLink')}
             </label>
             <input
               type="url"
@@ -310,13 +310,13 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
               placeholder="https://printables.com/model/..."
             />
             <p className="text-xs text-bambu-gray mt-1">
-              Link to Printables, Thingiverse, or other source
+              {t('editArchive.externalLinkHelp')}
             </p>
           </div>
 
           {/* Tags */}
           <div>
-            <label className="block text-sm text-bambu-gray mb-1">Tags</label>
+            <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.tags')}</label>
             {/* Current tags as chips */}
             {currentTags.length > 0 && (
               <div className="flex flex-wrap gap-1.5 mb-2">
@@ -355,13 +355,13 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
                   blurTimeoutRef.current = window.setTimeout(() => setShowTagSuggestions(false), 200);
                 }}
                 className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
-                placeholder={currentTags.length > 0 ? "Add more tags..." : "Add tags..."}
+                placeholder={currentTags.length > 0 ? t('editArchive.addMoreTags') : t('editArchive.tagsPlaceholder')}
               />
               {/* Suggestions dropdown */}
               {showTagSuggestions && tagSuggestions.length > 0 && (
                 <div className="absolute top-full left-0 right-0 mt-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-lg z-10 max-h-40 overflow-y-auto">
                   <div className="p-2 text-xs text-bambu-gray border-b border-bambu-dark-tertiary">
-                    {currentInput ? `Matching "${currentInput}"` : 'Existing tags'} (click to add)
+                    {currentInput ? t('editArchive.matchingTags', { query: currentInput }) : t('editArchive.existingTags')} {t('editArchive.clickToAdd')}
                   </div>
                   <div className="p-2 flex flex-wrap gap-1.5">
                     {tagSuggestions.map((tag) => (
@@ -382,7 +382,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
 
           {/* Status */}
           <div>
-            <label className="block text-sm text-bambu-gray mb-1">Status</label>
+            <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.status')}</label>
             <select
               value={status}
               onChange={(e) => {
@@ -394,9 +394,9 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
               }}
               className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
             >
-              {ARCHIVE_STATUSES.map((s) => (
-                <option key={s.value} value={s.value}>
-                  {s.label}
+              {ARCHIVE_STATUS_KEYS.map((statusKey) => (
+                <option key={statusKey} value={statusKey}>
+                  {t(`editArchive.statuses.${statusKey}`)}
                 </option>
               ))}
             </select>
@@ -405,16 +405,16 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
           {/* Failure Reason - only show for failed/aborted prints */}
           {(status === 'failed' || status === 'aborted') && (
             <div>
-              <label className="block text-sm text-bambu-gray mb-1">Failure Reason</label>
+              <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.failureReason')}</label>
               <select
                 value={failureReason}
                 onChange={(e) => setFailureReason(e.target.value)}
                 className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
               >
-                <option value="">Select reason...</option>
-                {FAILURE_REASONS.map((reason) => (
-                  <option key={reason} value={reason}>
-                    {reason}
+                <option value="">{t('editArchive.selectReason')}</option>
+                {FAILURE_REASON_KEYS.map((reasonKey) => (
+                  <option key={reasonKey} value={t(`editArchive.failureReasons.${reasonKey}`)}>
+                    {t(`editArchive.failureReasons.${reasonKey}`)}
                   </option>
                 ))}
               </select>
@@ -425,7 +425,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
           <div>
             <label className="block text-sm text-bambu-gray mb-1">
               <Camera className="w-4 h-4 inline mr-1" />
-              Photos of Printed Result
+              {t('editArchive.photos')}
             </label>
             {/* Photo grid */}
             <div className="flex flex-wrap gap-2 mb-2">
@@ -433,7 +433,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
                 <div key={filename} className="relative group">
                   <img
                     src={api.getArchivePhotoUrl(archive.id, filename)}
-                    alt="Print result"
+                    alt={t('editArchive.printResult')}
                     className="w-20 h-20 object-cover rounded-lg border border-bambu-dark-tertiary"
                   />
                   <button
@@ -462,7 +462,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
                 )}
               </label>
             </div>
-            <p className="text-xs text-bambu-gray">Click + to add photos of your printed result</p>
+            <p className="text-xs text-bambu-gray">{t('editArchive.photosHelp')}</p>
           </div>
 
           {/* Actions */}
@@ -473,7 +473,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
               onClick={onClose}
               className="flex-1"
             >
-              Cancel
+              {t('common.cancel')}
             </Button>
             <Button
               type="submit"
@@ -481,7 +481,7 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
               className="flex-1"
             >
               <Save className="w-4 h-4" />
-              {updateMutation.isPending ? 'Saving...' : 'Save'}
+              {updateMutation.isPending ? t('common.saving') : t('common.save')}
             </Button>
           </div>
         </form>

+ 16 - 13
frontend/src/components/FilamentHoverCard.tsx

@@ -1,4 +1,5 @@
 import { useState, useRef, useEffect, type ReactNode } from 'react';
+import { useTranslation } from 'react-i18next';
 import { Droplets, Link2, Copy, Check, Settings2, ExternalLink } from 'lucide-react';
 
 interface FilamentData {
@@ -38,6 +39,7 @@ interface FilamentHoverCardProps {
  * Replaces the basic browser tooltip with a styled popover.
  */
 export function FilamentHoverCard({ data, children, disabled, className = '', spoolman, configureSlot }: FilamentHoverCardProps) {
+  const { t } = useTranslation();
   const [isVisible, setIsVisible] = useState(false);
   const [position, setPosition] = useState<'top' | 'bottom'>('top');
   const [copied, setCopied] = useState(false);
@@ -201,7 +203,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
               {/* Profile name */}
               <div className="flex items-center justify-between">
                 <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
-                  Profile
+                  {t('ams.profile')}
                 </span>
                 <span className="text-xs text-white font-semibold truncate max-w-[120px]">
                   {data.profile}
@@ -211,7 +213,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
               {/* K Factor */}
               <div className="flex items-center justify-between">
                 <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
-                  K Factor
+                  {t('ams.kFactor')}
                 </span>
                 <span className="text-xs text-bambu-green font-mono font-bold">
                   {data.kFactor}
@@ -223,7 +225,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                 <div className="flex items-center justify-between">
                   <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium flex items-center gap-1">
                     <Droplets className="w-3 h-3" />
-                    Fill
+                    {t('ams.fill')}
                   </span>
                   <span className="text-xs text-white font-semibold">
                     {data.fillLevel !== null ? `${data.fillLevel}%` : '—'}
@@ -251,7 +253,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                   {/* Tray UUID with copy button */}
                   <div className="flex items-center justify-between">
                     <span className="text-[10px] uppercase tracking-wider text-bambu-gray font-medium">
-                      Spool ID
+                      {t('spoolman.spoolId')}
                     </span>
                     <button
                       onClick={(e) => {
@@ -280,10 +282,10 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                       rel="noopener noreferrer"
                       onClick={(e) => e.stopPropagation()}
                       className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green"
-                      title="View this spool in Spoolman"
+                      title={t('spoolman.openInSpoolman')}
                     >
                       <ExternalLink className="w-3.5 h-3.5" />
-                      Open in Spoolman
+                      {t('spoolman.openInSpoolman')}
                     </a>
                   )}
 
@@ -302,10 +304,10 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                           ? 'bg-bambu-gray/10 text-bambu-gray cursor-not-allowed'
                           : 'bg-bambu-green/20 hover:bg-bambu-green/30 text-bambu-green'
                       }`}
-                      title={spoolman.hasUnlinkedSpools === false ? 'No unlinked spools available' : 'Link this spool to a Spoolman spool'}
+                      title={spoolman.hasUnlinkedSpools === false ? t('spoolman.noUnlinkedSpools') : t('spoolman.linkToSpoolman')}
                     >
                       <Link2 className="w-3.5 h-3.5" />
-                      Link to Spoolman
+                      {t('spoolman.linkToSpoolman')}
                     </button>
                   )}
                 </div>
@@ -320,10 +322,10 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                       configureSlot.onConfigure?.();
                     }}
                     className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
-                    title="Configure slot with filament profile and K value"
+                    title={t('ams.configureSlot')}
                   >
                     <Settings2 className="w-3.5 h-3.5" />
-                    Configure
+                    {t('ams.configure')}
                   </button>
                 </div>
               )}
@@ -357,6 +359,7 @@ interface EmptySlotHoverCardProps {
  * Wrapper for empty slots - shows "Empty" on hover with optional configure button
  */
 export function EmptySlotHoverCard({ children, className = '', configureSlot }: EmptySlotHoverCardProps) {
+  const { t } = useTranslation();
   const [isVisible, setIsVisible] = useState(false);
   const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
@@ -394,7 +397,7 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot }:
             rounded-md shadow-lg overflow-hidden
           ">
             <div className="px-3 py-1.5 text-xs text-bambu-gray whitespace-nowrap">
-              Empty slot
+              {t('ams.emptySlot')}
             </div>
             {/* Configure slot button */}
             {configureSlot?.enabled && (
@@ -405,10 +408,10 @@ export function EmptySlotHoverCard({ children, className = '', configureSlot }:
                     configureSlot.onConfigure?.();
                   }}
                   className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs font-medium rounded transition-colors bg-bambu-blue/20 hover:bg-bambu-blue/30 text-bambu-blue"
-                  title="Configure slot with filament profile and K value"
+                  title={t('ams.configureSlot')}
                 >
                   <Settings2 className="w-3.5 h-3.5" />
-                  Configure
+                  {t('ams.configure')}
                 </button>
               </div>
             )}

+ 13 - 11
frontend/src/components/FileManagerModal.tsx

@@ -1,5 +1,6 @@
 import { useState, useEffect } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import {
   X,
   Folder,
@@ -82,6 +83,7 @@ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
 ];
 
 export function FileManagerModal({ printerId, printerName, onClose }: FileManagerModalProps) {
+  const { t } = useTranslation();
   const { showToast } = useToast();
   const queryClient = useQueryClient();
   const [currentPath, setCurrentPath] = useState('/');
@@ -119,13 +121,13 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
       }
     },
     onSuccess: () => {
-      showToast(`Deleted ${filesToDelete.length} file${filesToDelete.length > 1 ? 's' : ''}`);
+      showToast(t('printerFiles.toast.filesDeleted', { count: filesToDelete.length }));
       queryClient.invalidateQueries({ queryKey: ['printerFiles', printerId] });
       setSelectedFiles(new Set());
       setFilesToDelete([]);
     },
     onError: (error: Error) => {
-      showToast(`Delete failed: ${error.message}`, 'error');
+      showToast(t('printerFiles.toast.deleteFailed', { error: error.message }), 'error');
     },
   });
 
@@ -227,7 +229,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
             <div className="flex items-center gap-3">
               <HardDrive className="w-5 h-5 text-bambu-green" />
               <div>
-                <h2 className="text-lg font-semibold text-white">File Manager</h2>
+                <h2 className="text-lg font-semibold text-white">{t('printerFiles.title')}</h2>
                 <p className="text-sm text-bambu-gray">{printerName}</p>
               </div>
             </div>
@@ -236,13 +238,13 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
               {storageData && (storageData.used_bytes != null || storageData.free_bytes != null) && (
                 <div className="text-sm text-bambu-gray flex items-center gap-2">
                   {storageData.used_bytes != null && (
-                    <span>Used: {formatStorageSize(storageData.used_bytes)}</span>
+                    <span>{t('printerFiles.storageUsed')} {formatStorageSize(storageData.used_bytes)}</span>
                   )}
                   {storageData.used_bytes != null && storageData.free_bytes != null && (
                     <span className="text-bambu-dark-tertiary">|</span>
                   )}
                   {storageData.free_bytes != null && (
-                    <span>Free: {formatStorageSize(storageData.free_bytes)}</span>
+                    <span>{t('printerFiles.storageFree')} {formatStorageSize(storageData.free_bytes)}</span>
                   )}
                 </div>
               )}
@@ -278,7 +280,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
             <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
             <input
               type="text"
-              placeholder="Filter files..."
+              placeholder={t('printerFiles.filterPlaceholder')}
               value={searchQuery}
               onChange={(e) => setSearchQuery(e.target.value)}
               className="w-40 pl-8 pr-3 py-1.5 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
@@ -481,7 +483,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
               ) : (
                 <Trash2 className="w-4 h-4" />
               )}
-              Delete{selectedFiles.size > 1 ? ` (${selectedFiles.size})` : ''}
+              {t('printerFiles.deleteButton')}{selectedFiles.size > 1 ? ` (${selectedFiles.size})` : ''}
             </Button>
           </div>
         </div>
@@ -490,13 +492,13 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
       {/* Delete Confirmation Modal */}
       {filesToDelete.length > 0 && (
         <ConfirmModal
-          title={filesToDelete.length > 1 ? `Delete ${filesToDelete.length} Files` : 'Delete File'}
+          title={filesToDelete.length > 1 ? t('printerFiles.deleteFiles', { count: filesToDelete.length }) : t('fileManager.deleteFile')}
           message={
             filesToDelete.length > 1
-              ? `Delete ${filesToDelete.length} selected files? This cannot be undone.`
-              : `Delete "${filesToDelete[0].split('/').pop()}"? This cannot be undone.`
+              ? t('printerFiles.deleteFilesConfirm', { count: filesToDelete.length })
+              : t('printerFiles.deleteFileConfirm', { name: filesToDelete[0].split('/').pop() })
           }
-          confirmText="Delete"
+          confirmText={t('common.delete')}
           variant="danger"
           onConfirm={() => {
             deleteMutation.mutate(filesToDelete);

+ 7 - 4
frontend/src/components/HMSErrorModal.tsx

@@ -1,6 +1,7 @@
 // HMS Error Modal - Comprehensive error code database
 // Source: https://github.com/greghesp/ha-bambulab
 import { useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
 import { X, AlertTriangle, AlertCircle, Info, ExternalLink } from 'lucide-react';
 import type { HMSError } from '../api/client';
 
@@ -904,6 +905,8 @@ function getHMSHomeUrl(): string {
 }
 
 export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalProps) {
+  const { t } = useTranslation();
+
   // Debug: log errors to see what data we're receiving
   console.log('HMSErrorModal errors:', JSON.stringify(errors, null, 2));
 
@@ -930,7 +933,7 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
         <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
           <div className="flex items-center gap-2">
             <AlertTriangle className="w-5 h-5 text-orange-400" />
-            <h2 className="text-lg font-semibold text-white">Errors - {printerName}</h2>
+            <h2 className="text-lg font-semibold text-white">{t('hmsErrors.title', { name: printerName })}</h2>
           </div>
           <button
             onClick={onClose}
@@ -945,7 +948,7 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
           {knownErrors.length === 0 ? (
             <div className="text-center py-8 text-bambu-gray">
               <AlertCircle className="w-12 h-12 mx-auto mb-3 opacity-30" />
-              <p>No errors</p>
+              <p>{t('hmsErrors.noErrors')}</p>
             </div>
           ) : (
             <div className="space-y-3">
@@ -979,7 +982,7 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
                           className="inline-flex items-center gap-1 text-xs text-bambu-green hover:underline"
                         >
                           <ExternalLink className="w-3 h-3" />
-                          View on Bambu Lab Wiki
+                          {t('hmsErrors.viewOnWiki')}
                         </a>
                       </div>
                     </div>
@@ -993,7 +996,7 @@ export function HMSErrorModal({ printerName, errors, onClose }: HMSErrorModalPro
         {/* Footer */}
         <div className="p-4 border-t border-bambu-dark-tertiary">
           <p className="text-xs text-bambu-gray">
-            Clear errors on the printer to dismiss them here.
+            {t('hmsErrors.clearInstructions')}
           </p>
         </div>
       </div>

+ 97 - 95
frontend/src/components/KProfilesView.tsx

@@ -1,5 +1,6 @@
 import React, { useState, useEffect, useCallback } from 'react';
 import { useQuery, useMutation } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import {
   Gauge,
   Loader2,
@@ -171,6 +172,7 @@ function KProfileModal({
   onSaveNote,
   hasPermission,
 }: KProfileModalProps) {
+  const { t } = useTranslation();
   const { showToast } = useToast();
 
   const [name, setName] = useState(profile?.name || '');
@@ -219,7 +221,7 @@ function KProfileModal({
     },
     onSuccess: (result) => {
       console.log('[KProfile] Save success:', result);
-      showToast('K-profile saved');
+      showToast(t('kProfiles.toast.profileSaved'));
       // Save note if it changed (including clearing it)
       if (onSaveNote && note !== initialNote) {
         let profileKey: string;
@@ -258,7 +260,7 @@ function KProfileModal({
     },
     onSuccess: (result) => {
       console.log('[KProfile] Delete success:', result);
-      showToast('K-profile deleted');
+      showToast(t('kProfiles.toast.profileDeleted'));
       // Show syncing indicator while printer processes the command
       setIsSyncing(true);
       // Add longer delay for delete - printer needs more time to process
@@ -294,7 +296,7 @@ function KProfileModal({
 
     // Validate at least one extruder is selected for dual-nozzle
     if (isDualNozzle && !profile && selectedExtruders.length === 0) {
-      showToast('Please select at least one extruder', 'error');
+      showToast(t('kProfiles.toast.selectAtLeastOneExtruder'), 'error');
       return;
     }
 
@@ -340,7 +342,7 @@ function KProfileModal({
 
     try {
       await api.setKProfilesBatch(printerId, batchPayload);
-      showToast(`K-profile saved to ${selectedExtruders.length} extruders`);
+      showToast(t('kProfiles.toast.profilesSaved', { count: selectedExtruders.length }));
       // Save note for new batch profiles
       if (onSaveNote && note) {
         const profileKey = `name_${name}_${filamentId}`;
@@ -348,7 +350,7 @@ function KProfileModal({
       }
     } catch (error) {
       console.error('[KProfile] Failed to save batch:', error);
-      showToast('Failed to save K-profiles', 'error');
+      showToast(t('kProfiles.toast.failedToSaveBatch'), 'error');
       setIsSyncing(false);
       setSavingProgress({ current: 0, total: 0 });
       return;
@@ -373,16 +375,16 @@ function KProfileModal({
             <Loader2 className="w-8 h-8 text-bambu-green animate-spin mb-3" />
             <p className="text-white font-medium">
               {savingProgress.total > 1
-                ? `Saving to extruder ${savingProgress.current}/${savingProgress.total}...`
-                : 'Syncing with printer...'}
+                ? t('kProfiles.modal.savingExtruder', { current: savingProgress.current, total: savingProgress.total })
+                : t('kProfiles.modal.syncing')}
             </p>
-            <p className="text-bambu-gray text-sm mt-1">Please wait</p>
+            <p className="text-bambu-gray text-sm mt-1">{t('kProfiles.modal.pleaseWait')}</p>
           </div>
         )}
         <CardContent className="p-0">
           <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
             <h2 className="text-xl font-semibold text-white">
-              {profile ? 'Edit K-Profile' : 'Add K-Profile'}
+              {profile ? t('kProfiles.modal.editTitle') : t('kProfiles.modal.addTitle')}
             </h2>
             <button
               onClick={onClose}
@@ -396,21 +398,21 @@ function KProfileModal({
           <form onSubmit={handleSubmit} className="p-4 space-y-4">
             {/* Profile Name - read-only when editing */}
             <div>
-              <label className="block text-sm text-bambu-gray mb-1">Profile Name</label>
+              <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.profileName')}</label>
               <input
                 type="text"
                 value={name}
                 onChange={(e) => setName(e.target.value)}
                 disabled={!!profile}
                 className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
-                placeholder="My PLA Profile"
+                placeholder={t('kProfiles.modal.profileNamePlaceholder')}
                 required={!profile}
               />
             </div>
 
             {/* K-Value - always editable */}
             <div>
-              <label className="block text-sm text-bambu-gray mb-1">K-Value</label>
+              <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.kValue')}</label>
               <input
                 type="text"
                 inputMode="decimal"
@@ -430,17 +432,17 @@ function KProfileModal({
                   }
                 }}
                 className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none font-mono"
-                placeholder="0.020"
+                placeholder={t('kProfiles.modal.kValuePlaceholder')}
                 required
               />
               <p className="text-xs text-bambu-gray mt-1">
-                Typical range: 0.01 - 0.06 for PLA, 0.02 - 0.10 for PETG
+                {t('kProfiles.modal.kValueHelp')}
               </p>
             </div>
 
             {/* Filament - read-only when editing */}
             <div>
-              <label className="block text-sm text-bambu-gray mb-1">Filament</label>
+              <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.filament')}</label>
               <select
                 value={filamentId}
                 onChange={(e) => {
@@ -460,7 +462,7 @@ function KProfileModal({
                 className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
                 required={!profile}
               >
-                <option value="">Select filament...</option>
+                <option value="">{t('kProfiles.modal.selectFilament')}</option>
                 {/* Show current filament when editing - look up from knownFilaments */}
                 {profile?.filament_id && (
                   <option key={profile.filament_id} value={profile.filament_id}>
@@ -476,7 +478,7 @@ function KProfileModal({
               </select>
               {!profile && knownFilaments.length === 0 && (
                 <p className="text-xs text-bambu-gray mt-1">
-                  No filaments found. Create a K-profile in Bambu Studio first.
+                  {t('kProfiles.modal.noFilamentsHelp')}
                 </p>
               )}
             </div>
@@ -484,7 +486,7 @@ function KProfileModal({
             {/* Flow Type and Nozzle Size - read-only when editing */}
             <div className="grid grid-cols-2 gap-4">
               <div>
-                <label className="block text-sm text-bambu-gray mb-1">Flow Type</label>
+                <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.flowType')}</label>
                 <select
                   value={nozzleType}
                   onChange={(e) => {
@@ -503,12 +505,12 @@ function KProfileModal({
                   disabled={!!profile}
                   className={`w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none ${profile ? 'opacity-60 cursor-not-allowed' : ''}`}
                 >
-                  <option value="HH00">High Flow</option>
-                  <option value="HS00">Standard</option>
+                  <option value="HH00">{t('kProfiles.modal.highFlow')}</option>
+                  <option value="HS00">{t('kProfiles.modal.standard')}</option>
                 </select>
               </div>
               <div>
-                <label className="block text-sm text-bambu-gray mb-1">Nozzle Size</label>
+                <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.nozzleSize')}</label>
                 <select
                   value={modalDiameter}
                   onChange={(e) => setModalDiameter(e.target.value)}
@@ -527,12 +529,12 @@ function KProfileModal({
             {isDualNozzle && (
               <div>
                 <label className="block text-sm text-bambu-gray mb-1">
-                  {profile ? 'Extruder' : 'Extruders'}
+                  {profile ? t('kProfiles.modal.extruder') : t('kProfiles.modal.extruders')}
                 </label>
                 {profile ? (
                   // Read-only display for editing
                   <div className="px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white opacity-60">
-                    {profile.extruder_id === 1 ? 'Left' : 'Right'}
+                    {profile.extruder_id === 1 ? t('kProfiles.modal.left') : t('kProfiles.modal.right')}
                   </div>
                 ) : (
                   // Checkboxes for new profile - can select both
@@ -550,7 +552,7 @@ function KProfileModal({
                         }}
                         className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0 accent-bambu-green"
                       />
-                      <span className="text-white">Left</span>
+                      <span className="text-white">{t('kProfiles.modal.left')}</span>
                     </label>
                     <label className="flex items-center gap-2 cursor-pointer">
                       <input
@@ -565,7 +567,7 @@ function KProfileModal({
                         }}
                         className="w-4 h-4 rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0 accent-bambu-green"
                       />
-                      <span className="text-white">Right</span>
+                      <span className="text-white">{t('kProfiles.modal.right')}</span>
                     </label>
                   </div>
                 )}
@@ -574,16 +576,16 @@ function KProfileModal({
 
             {/* Notes */}
             <div>
-              <label className="block text-sm text-bambu-gray mb-1">Notes (stored locally)</label>
+              <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.modal.notes')}</label>
               <textarea
                 value={note}
                 onChange={(e) => setNote(e.target.value)}
-                placeholder="Add notes about this profile..."
+                placeholder={t('kProfiles.modal.notesPlaceholder')}
                 rows={2}
                 className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none resize-none"
               />
               <p className="text-xs text-bambu-gray mt-1">
-                Notes are saved in Bambuddy, not on the printer
+                {t('kProfiles.modal.notesHelp')}
               </p>
             </div>
 
@@ -594,7 +596,7 @@ function KProfileModal({
                   variant="secondary"
                   onClick={() => setShowDeleteConfirm(true)}
                   disabled={deleteMutation.isPending || isSyncing || !hasPermission('kprofiles:delete')}
-                  title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete K-profiles' : undefined}
+                  title={!hasPermission('kprofiles:delete') ? t('kProfiles.permission.noDelete') : undefined}
                   className="text-red-500 hover:bg-red-500/10"
                 >
                   {deleteMutation.isPending ? (
@@ -611,12 +613,12 @@ function KProfileModal({
                 disabled={isSyncing}
                 className="flex-1"
               >
-                Cancel
+                {t('common.cancel')}
               </Button>
               <Button
                 type="submit"
                 disabled={saveMutation.isPending || isSyncing || !hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create')}
-                title={!hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create') ? `You do not have permission to ${profile ? 'update' : 'create'} K-profiles` : undefined}
+                title={!hasPermission(profile ? 'kprofiles:update' : 'kprofiles:create') ? t(profile ? 'kProfiles.permission.noUpdate' : 'kProfiles.permission.noCreate') : undefined}
                 className="flex-1"
               >
                 {saveMutation.isPending ? (
@@ -624,7 +626,7 @@ function KProfileModal({
                 ) : (
                   <Gauge className="w-4 h-4" />
                 )}
-                Save
+                {t('common.save')}
               </Button>
             </div>
           </form>
@@ -641,12 +643,12 @@ function KProfileModal({
                   <Trash2 className="w-5 h-5 text-red-500" />
                 </div>
                 <div>
-                  <h3 className="text-lg font-semibold text-white">Delete Profile</h3>
-                  <p className="text-sm text-bambu-gray">This cannot be undone</p>
+                  <h3 className="text-lg font-semibold text-white">{t('kProfiles.deleteConfirm.title')}</h3>
+                  <p className="text-sm text-bambu-gray">{t('kProfiles.deleteConfirm.cannotUndo')}</p>
                 </div>
               </div>
               <p className="text-bambu-gray mb-6">
-                Are you sure you want to delete <span className="text-white font-medium">"{profile?.name}"</span> from the printer?
+                {t('kProfiles.deleteConfirm.message', { name: profile?.name })}
               </p>
               <div className="flex gap-3">
                 <Button
@@ -654,7 +656,7 @@ function KProfileModal({
                   onClick={() => setShowDeleteConfirm(false)}
                   className="flex-1"
                 >
-                  Cancel
+                  {t('common.cancel')}
                 </Button>
                 <Button
                   onClick={() => {
@@ -669,7 +671,7 @@ function KProfileModal({
                   ) : (
                     <Trash2 className="w-4 h-4" />
                   )}
-                  Delete
+                  {t('common.delete')}
                 </Button>
               </div>
             </CardContent>
@@ -691,6 +693,7 @@ const STORAGE_KEYS = {
 };
 
 export function KProfilesView() {
+  const { t } = useTranslation();
   const { showToast } = useToast();
   const { hasPermission } = useAuth();
   const [selectedPrinter, setSelectedPrinter] = useState<number | null>(null);
@@ -882,7 +885,7 @@ export function KProfilesView() {
   // Export profiles to JSON file
   const handleExport = useCallback(() => {
     if (!kprofiles?.profiles || kprofiles.profiles.length === 0) {
-      showToast('No profiles to export', 'error');
+      showToast(t('kProfiles.toast.noProfilesToExport'), 'error');
       return;
     }
 
@@ -910,8 +913,8 @@ export function KProfilesView() {
     a.click();
     document.body.removeChild(a);
     URL.revokeObjectURL(url);
-    showToast(`Exported ${kprofiles.profiles.length} profiles`);
-  }, [kprofiles?.profiles, selectedPrinterData, nozzleDiameter, showToast]);
+    showToast(t('kProfiles.toast.exportedProfiles', { count: kprofiles.profiles.length }));
+  }, [kprofiles?.profiles, selectedPrinterData, nozzleDiameter, showToast, t]);
 
   // Import profiles from JSON file
   const handleImport = useCallback(() => {
@@ -927,7 +930,7 @@ export function KProfilesView() {
         const data = JSON.parse(text);
 
         if (!data.profiles || !Array.isArray(data.profiles)) {
-          showToast('Invalid file format', 'error');
+          showToast(t('kProfiles.toast.invalidFileFormat'), 'error');
           return;
         }
 
@@ -954,15 +957,15 @@ export function KProfilesView() {
           }
         }
 
-        showToast(`Imported ${imported} of ${data.profiles.length} profiles`);
+        showToast(t('kProfiles.toast.importedProfiles', { count: imported, total: data.profiles.length }));
         refetchProfiles();
       } catch (err) {
         console.error('Import error:', err);
-        showToast('Failed to parse import file', 'error');
+        showToast(t('kProfiles.toast.failedToParseImport'), 'error');
       }
     };
     input.click();
-  }, [selectedPrinter, nozzleDiameter, showToast, refetchProfiles]);
+  }, [selectedPrinter, nozzleDiameter, showToast, refetchProfiles, t]);
 
   // Toggle profile selection using composite key
   const toggleProfileSelection = useCallback((profileKey: string) => {
@@ -1012,13 +1015,13 @@ export function KProfilesView() {
       }
     }
 
-    showToast(`Deleted ${deleted} profiles`);
+    showToast(t('kProfiles.toast.profilesDeleted', { count: deleted }));
     setBulkDeleteInProgress(false);
     setShowBulkDeleteConfirm(false);
     setSelectionMode(false);
     setSelectedProfiles(new Set());
     refetchProfiles();
-  }, [selectedPrinter, selectedProfiles, filteredProfiles, showToast, refetchProfiles, getProfileKey]);
+  }, [selectedPrinter, selectedProfiles, filteredProfiles, showToast, refetchProfiles, getProfileKey, t]);
 
   // Generate possible keys for a profile (for notes lookup)
   // Returns array of keys to check: setting_id, slot-based, name-based
@@ -1042,9 +1045,9 @@ export function KProfilesView() {
       refetchNotes();
     } catch (err) {
       console.error('Failed to save note:', err);
-      showToast('Failed to save note', 'error');
+      showToast(t('kProfiles.toast.failedToSaveNote'), 'error');
     }
-  }, [selectedPrinter, refetchNotes, showToast]);
+  }, [selectedPrinter, refetchNotes, showToast, t]);
 
   // Get note for a profile (checks all possible keys)
   // Returns { note, key } so we know which key the note was stored under
@@ -1077,9 +1080,9 @@ export function KProfilesView() {
       <Card>
         <CardContent className="py-12 text-center">
           <AlertCircle className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
-          <h3 className="text-lg font-semibold text-white mb-2">No Printers Configured</h3>
+          <h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noPrintersConfigured')}</h3>
           <p className="text-bambu-gray">
-            Add a printer in Settings to manage K-profiles
+            {t('kProfiles.addPrinterInSettings')}
           </p>
         </CardContent>
       </Card>
@@ -1091,9 +1094,9 @@ export function KProfilesView() {
       <Card>
         <CardContent className="py-12 text-center">
           <Printer className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
-          <h3 className="text-lg font-semibold text-white mb-2">No Active Printers</h3>
+          <h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noActivePrinters')}</h3>
           <p className="text-bambu-gray">
-            Enable a printer connection to view its K-profiles
+            {t('kProfiles.enablePrinterConnection')}
           </p>
         </CardContent>
       </Card>
@@ -1106,14 +1109,14 @@ export function KProfilesView() {
       {isFetching && !kprofilesLoading && (
         <div className="fixed inset-0 bg-black/50 flex flex-col items-center justify-center z-40">
           <Loader2 className="w-10 h-10 text-bambu-green animate-spin mb-3" />
-          <p className="text-white font-medium">Loading K-Profiles...</p>
+          <p className="text-white font-medium">{t('kProfiles.loadingProfiles')}</p>
         </div>
       )}
 
       {/* Printer & Nozzle Selector */}
       <div className="flex flex-wrap gap-4 mb-6">
         <div className="flex-1 min-w-48">
-          <label className="block text-sm text-bambu-gray mb-1">Printer</label>
+          <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.printer')}</label>
           <select
             value={selectedPrinter || ''}
             onChange={(e) => setSelectedPrinter(parseInt(e.target.value))}
@@ -1128,7 +1131,7 @@ export function KProfilesView() {
         </div>
 
         <div className="w-32">
-          <label className="block text-sm text-bambu-gray mb-1">Nozzle</label>
+          <label className="block text-sm text-bambu-gray mb-1">{t('kProfiles.nozzle')}</label>
           <select
             value={nozzleDiameter}
             onChange={(e) => setNozzleDiameter(e.target.value)}
@@ -1146,18 +1149,18 @@ export function KProfilesView() {
             variant="secondary"
             onClick={() => refetchProfiles()}
             disabled={isFetching || !hasPermission('kprofiles:read')}
-            title={!hasPermission('kprofiles:read') ? 'You do not have permission to refresh profiles' : undefined}
+            title={!hasPermission('kprofiles:read') ? t('kProfiles.permission.noRead') : undefined}
           >
             <RefreshCw className={`w-4 h-4 ${isFetching ? 'animate-spin' : ''}`} />
-            Refresh
+            {t('kProfiles.refresh')}
           </Button>
           <Button
             onClick={() => setShowAddModal(true)}
             disabled={!hasPermission('kprofiles:create')}
-            title={!hasPermission('kprofiles:create') ? 'You do not have permission to add profiles' : undefined}
+            title={!hasPermission('kprofiles:create') ? t('kProfiles.permission.noCreate') : undefined}
           >
             <Plus className="w-4 h-4" />
-            Add Profile
+            {t('kProfiles.addProfile')}
           </Button>
         </div>
       </div>
@@ -1170,7 +1173,7 @@ export function KProfilesView() {
             type="text"
             value={searchQuery}
             onChange={(e) => setSearchQuery(e.target.value)}
-            placeholder="Search by name or filament..."
+            placeholder={t('kProfiles.searchPlaceholder')}
             className="w-full pl-10 pr-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
           />
         </div>
@@ -1181,9 +1184,9 @@ export function KProfilesView() {
               onChange={(e) => setExtruderFilter(e.target.value as ExtruderFilter)}
               className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
             >
-              <option value="all">All Extruders</option>
-              <option value="left">Left Only</option>
-              <option value="right">Right Only</option>
+              <option value="all">{t('kProfiles.allExtruders')}</option>
+              <option value="left">{t('kProfiles.leftOnly')}</option>
+              <option value="right">{t('kProfiles.rightOnly')}</option>
             </select>
           </div>
         )}
@@ -1193,9 +1196,9 @@ export function KProfilesView() {
             onChange={(e) => setFlowTypeFilter(e.target.value as FlowTypeFilter)}
             className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
           >
-            <option value="all">All Flow</option>
-            <option value="hf">HF Only</option>
-            <option value="s">S Only</option>
+            <option value="all">{t('kProfiles.allFlow')}</option>
+            <option value="hf">{t('kProfiles.hfOnly')}</option>
+            <option value="s">{t('kProfiles.sOnly')}</option>
           </select>
         </div>
         <div className="w-32">
@@ -1204,9 +1207,9 @@ export function KProfilesView() {
             onChange={(e) => setSortOption(e.target.value as SortOption)}
             className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
           >
-            <option value="name">Sort: Name</option>
-            <option value="k_value">Sort: K-Value</option>
-            <option value="filament">Sort: Filament</option>
+            <option value="name">{t('kProfiles.sortName')}</option>
+            <option value="k_value">{t('kProfiles.sortKValue')}</option>
+            <option value="filament">{t('kProfiles.sortFilament')}</option>
           </select>
         </div>
       </div>
@@ -1217,19 +1220,19 @@ export function KProfilesView() {
           variant="secondary"
           onClick={handleExport}
           disabled={!kprofiles?.profiles?.length || !hasPermission('kprofiles:read')}
-          title={!hasPermission('kprofiles:read') ? 'You do not have permission to export profiles' : 'Export profiles to JSON'}
+          title={!hasPermission('kprofiles:read') ? t('kProfiles.permission.noExport') : undefined}
         >
           <Download className="w-4 h-4" />
-          Export
+          {t('kProfiles.export')}
         </Button>
         <Button
           variant="secondary"
           onClick={handleImport}
           disabled={!hasPermission('kprofiles:create')}
-          title={!hasPermission('kprofiles:create') ? 'You do not have permission to import profiles' : 'Import profiles from JSON'}
+          title={!hasPermission('kprofiles:create') ? t('kProfiles.permission.noImport') : undefined}
         >
           <Upload className="w-4 h-4" />
-          Import
+          {t('kProfiles.import')}
         </Button>
         <div className="flex-1" />
         {selectionMode ? (
@@ -1237,20 +1240,19 @@ export function KProfilesView() {
             <Button
               variant="secondary"
               onClick={selectAllProfiles}
-              title="Select all visible profiles"
             >
               <CheckSquare className="w-4 h-4" />
-              Select All
+              {t('kProfiles.selectAll')}
             </Button>
             <Button
               variant="secondary"
               onClick={handleBulkDelete}
               disabled={selectedProfiles.size === 0 || !hasPermission('kprofiles:delete')}
               className="text-red-500 hover:bg-red-500/10"
-              title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete profiles' : `Delete ${selectedProfiles.size} selected profiles`}
+              title={!hasPermission('kprofiles:delete') ? t('kProfiles.permission.noDelete') : undefined}
             >
               <Trash2 className="w-4 h-4" />
-              Delete ({selectedProfiles.size})
+              {t('kProfiles.delete')} ({selectedProfiles.size})
             </Button>
             <Button
               variant="secondary"
@@ -1260,7 +1262,7 @@ export function KProfilesView() {
               }}
             >
               <X className="w-4 h-4" />
-              Cancel
+              {t('common.cancel')}
             </Button>
           </>
         ) : (
@@ -1268,10 +1270,10 @@ export function KProfilesView() {
             variant="secondary"
             onClick={() => setSelectionMode(true)}
             disabled={!filteredProfiles.length || !hasPermission('kprofiles:delete')}
-            title={!hasPermission('kprofiles:delete') ? 'You do not have permission to delete profiles' : 'Enter selection mode for bulk delete'}
+            title={!hasPermission('kprofiles:delete') ? t('kProfiles.permission.noDelete') : undefined}
           >
             <CheckSquare className="w-4 h-4" />
-            Select
+            {t('kProfiles.select')}
           </Button>
         )}
       </div>
@@ -1285,13 +1287,13 @@ export function KProfilesView() {
         <Card>
           <CardContent className="py-12 text-center">
             <WifiOff className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
-            <h3 className="text-lg font-semibold text-white mb-2">Printer Offline</h3>
+            <h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.printerOffline')}</h3>
             <p className="text-bambu-gray mb-4">
-              The selected printer is not connected. Power it on to view K-profiles.
+              {t('kProfiles.printerOfflineDesc')}
             </p>
             <Button variant="secondary" onClick={() => refetchProfiles()}>
               <RefreshCw className="w-4 h-4" />
-              Retry
+              {t('common.refresh')}
             </Button>
           </CardContent>
         </Card>
@@ -1301,7 +1303,7 @@ export function KProfilesView() {
           <div className="grid grid-cols-2 gap-4">
             {/* Left Extruder (extruder_id 1 on Bambu) */}
             <div>
-              <h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">Left Extruder</h3>
+              <h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">{t('kProfiles.leftExtruder')}</h3>
               <div className="space-y-1">
                 {filteredProfiles
                   .filter((p) => p.extruder_id === 1)
@@ -1321,7 +1323,7 @@ export function KProfilesView() {
             </div>
             {/* Right Extruder (extruder_id 0 on Bambu) */}
             <div>
-              <h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">Right Extruder</h3>
+              <h3 className="text-sm font-medium text-bambu-gray mb-2 px-1">{t('kProfiles.rightExtruder')}</h3>
               <div className="space-y-1">
                 {filteredProfiles
                   .filter((p) => p.extruder_id === 0)
@@ -1361,9 +1363,9 @@ export function KProfilesView() {
         <Card>
           <CardContent className="py-12 text-center">
             <Search className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
-            <h3 className="text-lg font-semibold text-white mb-2">No Matching Profiles</h3>
+            <h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noMatchingProfiles')}</h3>
             <p className="text-bambu-gray">
-              No profiles match your search criteria
+              {t('kProfiles.noMatchingProfilesDesc')}
             </p>
           </CardContent>
         </Card>
@@ -1371,13 +1373,13 @@ export function KProfilesView() {
         <Card>
           <CardContent className="py-12 text-center">
             <Gauge className="w-12 h-12 text-bambu-gray mx-auto mb-4" />
-            <h3 className="text-lg font-semibold text-white mb-2">No K-Profiles</h3>
+            <h3 className="text-lg font-semibold text-white mb-2">{t('kProfiles.noKProfiles')}</h3>
             <p className="text-bambu-gray mb-4">
-              No pressure advance profiles found for {nozzleDiameter}mm nozzle
+              {t('kProfiles.noKProfilesDesc', { diameter: nozzleDiameter })}
             </p>
             <Button onClick={() => setShowAddModal(true)}>
               <Plus className="w-4 h-4" />
-              Create First Profile
+              {t('kProfiles.createFirstProfile')}
             </Button>
           </CardContent>
         </Card>
@@ -1466,12 +1468,12 @@ export function KProfilesView() {
                   <Trash2 className="w-5 h-5 text-red-500" />
                 </div>
                 <div>
-                  <h3 className="text-lg font-semibold text-white">Delete Profiles</h3>
-                  <p className="text-sm text-bambu-gray">This cannot be undone</p>
+                  <h3 className="text-lg font-semibold text-white">{t('kProfiles.bulkDelete.title')}</h3>
+                  <p className="text-sm text-bambu-gray">{t('kProfiles.bulkDelete.cannotUndo')}</p>
                 </div>
               </div>
               <p className="text-bambu-gray mb-6">
-                Are you sure you want to delete <span className="text-white font-medium">{selectedProfiles.size}</span> selected profiles from the printer?
+                {t('kProfiles.bulkDelete.message', { count: selectedProfiles.size })}
               </p>
               <div className="flex gap-3">
                 <Button
@@ -1480,7 +1482,7 @@ export function KProfilesView() {
                   disabled={bulkDeleteInProgress}
                   className="flex-1"
                 >
-                  Cancel
+                  {t('common.cancel')}
                 </Button>
                 <Button
                   onClick={executeBulkDelete}
@@ -1492,7 +1494,7 @@ export function KProfilesView() {
                   ) : (
                     <Trash2 className="w-4 h-4" />
                   )}
-                  Delete
+                  {t('common.delete')}
                 </Button>
               </div>
             </CardContent>

+ 20 - 21
frontend/src/components/Layout.tsx

@@ -619,7 +619,7 @@ export function Layout() {
                     <button
                       onClick={() => setShowChangePasswordModal(true)}
                       className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                      title="Change Password"
+                      title={t('changePassword.title')}
                     >
                       <Key className="w-5 h-5" />
                     </button>
@@ -723,7 +723,7 @@ export function Layout() {
                   <button
                     onClick={() => setShowChangePasswordModal(true)}
                     className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                    title="Change Password"
+                    title={t('changePassword.title')}
                   >
                     <Key className="w-5 h-5" />
                   </button>
@@ -824,20 +824,19 @@ export function Layout() {
                 </svg>
               </div>
               <h2 className="text-xl font-bold text-yellow-400 mb-2">
-                Print Paused!
+                {t('plateAlert.title')}
               </h2>
               <p className="text-lg text-white mb-2">
                 {plateDetectionAlert.printer_name}
               </p>
               <p className="text-bambu-gray mb-6">
-                Objects detected on build plate. The print has been automatically paused.
-                Please clear the plate and resume the print.
+                {t('plateAlert.message')}
               </p>
               <button
                 onClick={() => setPlateDetectionAlert(null)}
                 className="w-full py-3 px-6 bg-yellow-500 hover:bg-yellow-600 text-black font-semibold rounded-lg transition-colors"
               >
-                I Understand
+                {t('plateAlert.understand')}
               </button>
             </div>
           </div>
@@ -861,7 +860,7 @@ export function Layout() {
               <div className="flex items-center justify-between">
                 <div className="flex items-center gap-2">
                   <Key className="w-5 h-5 text-bambu-green" />
-                  <h2 className="text-lg font-semibold text-white">Change Password</h2>
+                  <h2 className="text-lg font-semibold text-white">{t('changePassword.title')}</h2>
                 </div>
                 <Button
                   variant="ghost"
@@ -879,34 +878,34 @@ export function Layout() {
               <div className="space-y-4">
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
-                    Current Password
+                    {t('changePassword.currentPassword')}
                   </label>
                   <input
                     type="password"
                     value={changePasswordData.currentPassword}
                     onChange={(e) => setChangePasswordData({ ...changePasswordData, currentPassword: e.target.value })}
                     className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
-                    placeholder="Enter current password"
+                    placeholder={t('changePassword.currentPasswordPlaceholder')}
                     autoComplete="current-password"
                   />
                 </div>
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
-                    New Password
+                    {t('changePassword.newPassword')}
                   </label>
                   <input
                     type="password"
                     value={changePasswordData.newPassword}
                     onChange={(e) => setChangePasswordData({ ...changePasswordData, newPassword: e.target.value })}
                     className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
-                    placeholder="Enter new password (min 6 characters)"
+                    placeholder={t('changePassword.newPasswordPlaceholder')}
                     autoComplete="new-password"
                     minLength={6}
                   />
                 </div>
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
-                    Confirm New Password
+                    {t('changePassword.confirmPassword')}
                   </label>
                   <input
                     type="password"
@@ -917,12 +916,12 @@ export function Layout() {
                         ? 'border-red-500'
                         : 'border-bambu-dark-tertiary'
                     }`}
-                    placeholder="Confirm new password"
+                    placeholder={t('changePassword.confirmPasswordPlaceholder')}
                     autoComplete="new-password"
                     minLength={6}
                   />
                   {changePasswordData.confirmPassword && changePasswordData.newPassword !== changePasswordData.confirmPassword && (
-                    <p className="text-red-400 text-xs mt-1">Passwords do not match</p>
+                    <p className="text-red-400 text-xs mt-1">{t('changePassword.passwordsDoNotMatch')}</p>
                   )}
                 </div>
               </div>
@@ -934,26 +933,26 @@ export function Layout() {
                     setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
                   }}
                 >
-                  Cancel
+                  {t('common.cancel')}
                 </Button>
                 <Button
                   onClick={async () => {
                     if (changePasswordData.newPassword !== changePasswordData.confirmPassword) {
-                      showToast('Passwords do not match', 'error');
+                      showToast(t('changePassword.passwordsDoNotMatch'), 'error');
                       return;
                     }
                     if (changePasswordData.newPassword.length < 6) {
-                      showToast('Password must be at least 6 characters', 'error');
+                      showToast(t('changePassword.passwordTooShort'), 'error');
                       return;
                     }
                     setChangePasswordLoading(true);
                     try {
                       await api.changePassword(changePasswordData.currentPassword, changePasswordData.newPassword);
-                      showToast('Password changed successfully', 'success');
+                      showToast(t('changePassword.success'), 'success');
                       setShowChangePasswordModal(false);
                       setChangePasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
                     } catch (error: unknown) {
-                      const message = error instanceof Error ? error.message : 'Failed to change password';
+                      const message = error instanceof Error ? error.message : t('changePassword.failed');
                       showToast(message, 'error');
                     } finally {
                       setChangePasswordLoading(false);
@@ -964,12 +963,12 @@ export function Layout() {
                   {changePasswordLoading ? (
                     <>
                       <Loader2 className="w-4 h-4 animate-spin" />
-                      Changing...
+                      {t('changePassword.changing')}
                     </>
                   ) : (
                     <>
                       <Key className="w-4 h-4" />
-                      Change Password
+                      {t('changePassword.title')}
                     </>
                   )}
                 </Button>

+ 11 - 10
frontend/src/components/LinkSpoolModal.tsx

@@ -1,5 +1,6 @@
 import { useState } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { X, Loader2, Link2, Check } from 'lucide-react';
 import { api } from '../api/client';
 import { Button } from './Button';
@@ -17,6 +18,7 @@ interface LinkSpoolModalProps {
 }
 
 export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoolModalProps) {
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const [selectedSpoolId, setSelectedSpoolId] = useState<number | null>(null);
@@ -35,11 +37,11 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
       queryClient.invalidateQueries({ queryKey: ['unlinked-spools'] });
       queryClient.invalidateQueries({ queryKey: ['linked-spools'] });
       queryClient.invalidateQueries({ queryKey: ['spoolman-status'] });
-      showToast('Spool linked to Spoolman successfully', 'success');
+      showToast(t('spoolman.linkSuccess'), 'success');
       onClose();
     },
     onError: (error: Error) => {
-      showToast(`Failed to link spool: ${error.message}`, 'error');
+      showToast(`${t('spoolman.linkFailed')}: ${error.message}`, 'error');
     },
   });
 
@@ -65,7 +67,7 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
         <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
           <div className="flex items-center gap-2">
             <Link2 className="w-5 h-5 text-bambu-green" />
-            <h2 className="text-lg font-semibold text-white">Link to Spoolman</h2>
+            <h2 className="text-lg font-semibold text-white">{t('spoolman.linkToSpoolman')}</h2>
           </div>
           <button
             onClick={onClose}
@@ -96,14 +98,14 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
 
           {/* Spool UUID */}
           <div className="p-3 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
-            <p className="text-xs text-bambu-gray mb-1">Spool UUID:</p>
+            <p className="text-xs text-bambu-gray mb-1">{t('spoolman.spoolId')}:</p>
             <code className="text-xs text-bambu-green font-mono break-all">{trayUuid}</code>
           </div>
 
           {/* Spool list */}
           <div>
             <p className="text-sm text-bambu-gray mb-2">
-              Select a Spoolman spool to link:
+              {t('spoolman.selectSpool')}:
             </p>
 
             {isLoading ? (
@@ -148,8 +150,7 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
               </div>
             ) : (
               <div className="text-center py-8 text-bambu-gray">
-                <p>No unlinked spools found in Spoolman.</p>
-                <p className="text-xs mt-1">All spools are already linked to AMS trays.</p>
+                <p>{t('spoolman.noUnlinkedSpools')}</p>
               </div>
             )}
           </div>
@@ -158,7 +159,7 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
         {/* Footer */}
         <div className="flex justify-end gap-2 p-4 border-t border-bambu-dark-tertiary">
           <Button variant="secondary" onClick={onClose}>
-            Cancel
+            {t('common.cancel')}
           </Button>
           <Button
             onClick={handleLink}
@@ -167,12 +168,12 @@ export function LinkSpoolModal({ isOpen, onClose, trayUuid, trayInfo }: LinkSpoo
             {linkMutation.isPending ? (
               <>
                 <Loader2 className="w-4 h-4 animate-spin" />
-                Linking...
+                {t('spoolman.syncing')}
               </>
             ) : (
               <>
                 <Link2 className="w-4 h-4" />
-                Link Spool
+                {t('spoolman.linkToSpoolman')}
               </>
             )}
           </Button>

+ 18 - 16
frontend/src/components/MQTTDebugModal.tsx

@@ -1,4 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { X, Play, Square, Trash2, RefreshCw, ArrowDown, ArrowUp, Search } from 'lucide-react';
 import { api, type MQTTLogEntry } from '../api/client';
 import { Button } from './Button';
@@ -11,6 +12,7 @@ interface MQTTDebugModalProps {
 }
 
 export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugModalProps) {
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const [autoScroll, setAutoScroll] = useState(true);
   const [expandedLogs, setExpandedLogs] = useState<Set<number>>(new Set());
@@ -119,7 +121,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
         {/* Header */}
         <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
           <div>
-            <h2 className="text-lg font-semibold text-white">MQTT Debug Log</h2>
+            <h2 className="text-lg font-semibold text-white">{t('mqttDebug.title')}</h2>
             <p className="text-sm text-bambu-gray">{printerName}</p>
           </div>
           <button
@@ -141,7 +143,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
                 disabled={disableMutation.isPending}
               >
                 <Square className="w-4 h-4" />
-                Stop
+                {t('mqttDebug.stopLogging')}
               </Button>
             ) : (
               <Button
@@ -150,7 +152,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
                 disabled={enableMutation.isPending}
               >
                 <Play className="w-4 h-4" />
-                Start Logging
+                {t('mqttDebug.startLogging')}
               </Button>
             )}
             <Button
@@ -160,7 +162,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
               disabled={clearMutation.isPending || logs.length === 0}
             >
               <Trash2 className="w-4 h-4" />
-              Clear
+              {t('mqttDebug.clearLog')}
             </Button>
             <Button
               size="sm"
@@ -191,7 +193,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
               <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray" />
               <input
                 type="text"
-                placeholder="Search topic or payload..."
+                placeholder={t('mqttDebug.searchPlaceholder')}
                 value={searchQuery}
                 onChange={(e) => setSearchQuery(e.target.value)}
                 className="w-full pl-8 pr-3 py-1.5 text-sm bg-bambu-dark border border-bambu-dark-tertiary rounded text-white placeholder-bambu-gray focus:border-bambu-green focus:outline-none"
@@ -214,7 +216,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
                     : 'text-bambu-gray hover:text-white'
                 }`}
               >
-                All
+                {t('mqttDebug.all')}
               </button>
               <button
                 onClick={() => setDirectionFilter('in')}
@@ -225,7 +227,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
                 }`}
               >
                 <ArrowDown className="w-3 h-3" />
-                In
+                {t('mqttDebug.incoming')}
               </button>
               <button
                 onClick={() => setDirectionFilter('out')}
@@ -236,7 +238,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
                 }`}
               >
                 <ArrowUp className="w-3 h-3" />
-                Out
+                {t('mqttDebug.outgoing')}
               </button>
             </div>
           </div>
@@ -249,15 +251,15 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
         >
           {logs.length === 0 ? (
             <div className="flex flex-col items-center justify-center h-full text-bambu-gray">
-              <p className="mb-2">No messages logged yet</p>
+              <p className="mb-2">{t('mqttDebug.noMessages')}</p>
               {!loggingEnabled && (
-                <p className="text-sm">Click "Start Logging" to begin capturing MQTT messages</p>
+                <p className="text-sm">{t('mqttDebug.startLoggingHint')}</p>
               )}
             </div>
           ) : filteredLogs.length === 0 ? (
             <div className="flex flex-col items-center justify-center h-full text-bambu-gray">
-              <p className="mb-2">No messages match your filter</p>
-              <p className="text-sm">Try adjusting your search or filter criteria</p>
+              <p className="mb-2">{t('mqttDebug.noMessagesMatch')}</p>
+              <p className="text-sm">{t('mqttDebug.adjustFilterHint')}</p>
             </div>
           ) : (
             <div className="space-y-1">
@@ -281,7 +283,7 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
                         className={`shrink-0 ${
                           isIncoming ? 'text-blue-400' : 'text-green-400'
                         }`}
-                        title={isIncoming ? 'Incoming' : 'Outgoing'}
+                        title={isIncoming ? t('mqttDebug.incoming') : t('mqttDebug.outgoing')}
                       >
                         {isIncoming ? (
                           <ArrowDown className="w-3 h-3" />
@@ -313,14 +315,14 @@ export function MQTTDebugModal({ printerId, printerName, onClose }: MQTTDebugMod
             {loggingEnabled ? (
               <span className="flex items-center gap-2">
                 <span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
-                Logging active - messages will auto-refresh
+                {t('mqttDebug.loggingActive')}
               </span>
             ) : (
-              <span>Logging stopped</span>
+              <span>{t('mqttDebug.loggingStopped')}</span>
             )}
           </div>
           <Button variant="secondary" onClick={onClose}>
-            Close
+            {t('common.close')}
           </Button>
         </div>
       </div>

+ 14 - 12
frontend/src/components/PrintModal/index.tsx

@@ -1,5 +1,6 @@
 import { useState, useEffect, useMemo } from 'react';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { X, Printer, Loader2, Calendar, Pencil, AlertCircle, AlertTriangle } from 'lucide-react';
 import { api } from '../../api/client';
 import type { PrintQueueItemCreate, PrintQueueItemUpdate } from '../../api/client';
@@ -41,6 +42,7 @@ export function PrintModal({
   onClose,
   onSuccess,
 }: PrintModalProps) {
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
 
@@ -529,35 +531,35 @@ export function PrintModal({
 
     if (mode === 'reprint') {
       return {
-        title: isLibraryFile ? 'Print' : 'Re-print',
+        title: isLibraryFile ? t('queue.print') : t('queue.reprint'),
         icon: Printer,
-        submitText: printerCount > 1 ? `Print to ${printerCount} Printers` : 'Print',
+        submitText: printerCount > 1 ? t('queue.printToPrinters', { count: printerCount }) : t('queue.print'),
         submitIcon: Printer,
         loadingText: submitProgress.total > 1
-          ? `Sending ${submitProgress.current}/${submitProgress.total}...`
-          : 'Sending...',
+          ? t('queue.sendingProgress', { current: submitProgress.current, total: submitProgress.total })
+          : t('queue.sending'),
       };
     }
     if (mode === 'add-to-queue') {
       return {
-        title: 'Schedule Print',
+        title: t('queue.schedulePrint'),
         icon: Calendar,
-        submitText: printerCount > 1 ? `Queue to ${printerCount} Printers` : 'Add to Queue',
+        submitText: printerCount > 1 ? t('queue.queueToPrinters', { count: printerCount }) : t('queue.addToQueue'),
         submitIcon: Calendar,
         loadingText: submitProgress.total > 1
-          ? `Adding ${submitProgress.current}/${submitProgress.total}...`
-          : 'Adding...',
+          ? t('queue.addingProgress', { current: submitProgress.current, total: submitProgress.total })
+          : t('queue.adding'),
       };
     }
     // edit-queue-item mode
     return {
-      title: 'Edit Queue Item',
+      title: t('queue.editQueueItem'),
       icon: Pencil,
-      submitText: 'Save',
+      submitText: t('common.save'),
       submitIcon: Pencil,
       loadingText: submitProgress.total > 1
-        ? `Saving ${submitProgress.current}/${submitProgress.total}...`
-        : 'Saving...',
+        ? t('queue.savingProgress', { current: submitProgress.current, total: submitProgress.total })
+        : t('common.saving'),
     };
   };
 

+ 12 - 10
frontend/src/components/UploadModal.tsx

@@ -1,5 +1,6 @@
 import { useState, useCallback, useRef, useEffect } from 'react';
 import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { Upload, X, File, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
 import { api } from '../api/client';
 import type { BulkUploadResult } from '../api/client';
@@ -20,6 +21,7 @@ interface UploadModalProps {
 }
 
 export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const fileInputRef = useRef<HTMLInputElement>(null);
@@ -150,7 +152,7 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
         <CardContent className="p-0 flex flex-col h-full">
           {/* Header */}
           <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
-            <h2 className="text-xl font-semibold text-white">Upload 3MF Files</h2>
+            <h2 className="text-xl font-semibold text-white">{t('uploadModal.title')}</h2>
             <button
               onClick={onClose}
               className="text-bambu-gray hover:text-white transition-colors"
@@ -173,15 +175,15 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
             >
               <Upload className="w-12 h-12 mx-auto mb-4 text-bambu-gray" />
               <p className="text-white mb-2">
-                Drag & drop .3mf files here
+                {t('uploadModal.dragDrop')}
               </p>
-              <p className="text-bambu-gray text-sm mb-4">or</p>
+              <p className="text-bambu-gray text-sm mb-4">{t('uploadModal.or')}</p>
               <Button
                 variant="secondary"
                 onClick={() => fileInputRef.current?.click()}
                 disabled={isUploading}
               >
-                Browse Files
+                {t('uploadModal.browseFiles')}
               </Button>
               <input
                 ref={fileInputRef}
@@ -197,7 +199,7 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
           {/* Info about printer model extraction */}
           <div className="px-4 pb-4">
             <p className="text-xs text-bambu-gray">
-              The printer model will be automatically extracted from the 3MF file metadata.
+              {t('uploadModal.extractionInfo')}
             </p>
           </div>
 
@@ -249,9 +251,9 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
             <div className="px-4 pb-4">
               <div className="p-3 bg-bambu-dark rounded-lg">
                 <p className="text-sm text-white">
-                  <span className="text-bambu-green">{uploadResult.uploaded}</span> uploaded
+                  <span className="text-bambu-green">{uploadResult.uploaded}</span> {t('uploadModal.uploaded')}
                   {uploadResult.failed > 0 && (
-                    <>, <span className="text-red-400">{uploadResult.failed}</span> failed</>
+                    <>, <span className="text-red-400">{uploadResult.failed}</span> {t('uploadModal.failed')}</>
                   )}
                 </p>
               </div>
@@ -261,7 +263,7 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
           {/* Footer */}
           <div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary">
             <Button variant="secondary" onClick={onClose} className="flex-1">
-              {uploadResult ? 'Close' : 'Cancel'}
+              {uploadResult ? t('common.close') : t('common.cancel')}
             </Button>
             {!uploadResult && (
               <Button
@@ -272,12 +274,12 @@ export function UploadModal({ onClose, initialFiles }: UploadModalProps) {
                 {isUploading ? (
                   <>
                     <Loader2 className="w-4 h-4 animate-spin" />
-                    Uploading...
+                    {t('uploadModal.uploading')}
                   </>
                 ) : (
                   <>
                     <Upload className="w-4 h-4" />
-                    Upload {pendingCount > 0 && `(${pendingCount})`}
+                    {t('uploadModal.upload')} {pendingCount > 0 && `(${pendingCount})`}
                   </>
                 )}
               </Button>

File diff suppressed because it is too large
+ 1018 - 20
frontend/src/i18n/locales/de.ts


File diff suppressed because it is too large
+ 1017 - 19
frontend/src/i18n/locales/en.ts


File diff suppressed because it is too large
+ 157 - 150
frontend/src/pages/ArchivesPage.tsx


+ 18 - 16
frontend/src/pages/CameraPage.tsx

@@ -1,6 +1,7 @@
 import { useState, useEffect, useRef, useCallback } from 'react';
 import { useParams } from 'react-router-dom';
 import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { RefreshCw, AlertTriangle, Camera, Maximize, Minimize, WifiOff, ZoomIn, ZoomOut } from 'lucide-react';
 import { api } from '../api/client';
 
@@ -10,6 +11,7 @@ const MAX_RECONNECT_DELAY = 30000; // 30 seconds
 const STALL_CHECK_INTERVAL = 5000; // Check every 5 seconds
 
 export function CameraPage() {
+  const { t } = useTranslation();
   const { printerId } = useParams<{ printerId: string }>();
   const id = parseInt(printerId || '0', 10);
 
@@ -539,7 +541,7 @@ export function CameraPage() {
   if (!id) {
     return (
       <div className="min-h-screen bg-black flex items-center justify-center">
-        <p className="text-white">Invalid printer ID</p>
+        <p className="text-white">{t('camera.invalidPrinterId')}</p>
       </div>
     );
   }
@@ -564,7 +566,7 @@ export function CameraPage() {
                   : 'text-bambu-gray hover:text-white disabled:opacity-50'
               }`}
             >
-              Live
+              {t('camera.live')}
             </button>
             <button
               onClick={() => switchToMode('snapshot')}
@@ -575,21 +577,21 @@ export function CameraPage() {
                   : 'text-bambu-gray hover:text-white disabled:opacity-50'
               }`}
             >
-              Snapshot
+              {t('camera.snapshot')}
             </button>
           </div>
           <button
             onClick={refresh}
             disabled={isDisabled}
             className="p-1.5 hover:bg-bambu-dark-tertiary rounded disabled:opacity-50"
-            title={streamMode === 'stream' ? 'Restart stream' : 'Refresh snapshot'}
+            title={streamMode === 'stream' ? t('camera.restartStream') : t('camera.refreshSnapshot')}
           >
             <RefreshCw className={`w-4 h-4 text-bambu-gray ${isDisabled ? 'animate-spin' : ''}`} />
           </button>
           <button
             onClick={toggleFullscreen}
             className="p-1.5 hover:bg-bambu-dark-tertiary rounded"
-            title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
+            title={isFullscreen ? t('camera.exitFullscreen') : t('camera.fullscreen')}
           >
             {isFullscreen ? (
               <Minimize className="w-4 h-4 text-bambu-gray" />
@@ -618,7 +620,7 @@ export function CameraPage() {
               <div className="text-center">
                 <RefreshCw className="w-8 h-8 text-bambu-gray animate-spin mx-auto mb-2" />
                 <p className="text-sm text-bambu-gray">
-                  {streamMode === 'stream' ? 'Connecting to camera...' : 'Capturing snapshot...'}
+                  {streamMode === 'stream' ? t('camera.connectingToCamera') : t('camera.capturingSnapshot')}
                 </p>
               </div>
             </div>
@@ -627,15 +629,15 @@ export function CameraPage() {
             <div className="absolute inset-0 flex items-center justify-center bg-black/80 z-10">
               <div className="text-center p-4">
                 <WifiOff className="w-10 h-10 text-orange-400 mx-auto mb-3" />
-                <p className="text-white mb-2">Connection lost</p>
+                <p className="text-white mb-2">{t('camera.connectionLost')}</p>
                 <p className="text-sm text-bambu-gray mb-3">
-                  Reconnecting in {reconnectCountdown}s... (attempt {reconnectAttempts + 1}/{MAX_RECONNECT_ATTEMPTS})
+                  {t('camera.reconnecting', { countdown: reconnectCountdown, attempt: reconnectAttempts + 1, max: MAX_RECONNECT_ATTEMPTS })}
                 </p>
                 <button
                   onClick={refresh}
                   className="px-4 py-2 bg-bambu-green text-white text-sm rounded hover:bg-bambu-green/80 transition-colors"
                 >
-                  Reconnect now
+                  {t('camera.reconnectNow')}
                 </button>
               </div>
             </div>
@@ -644,15 +646,15 @@ export function CameraPage() {
             <div className="absolute inset-0 flex items-center justify-center bg-black z-10">
               <div className="text-center p-4">
                 <AlertTriangle className="w-12 h-12 text-orange-400 mx-auto mb-3" />
-                <p className="text-white mb-2">Camera unavailable</p>
+                <p className="text-white mb-2">{t('camera.cameraUnavailable')}</p>
                 <p className="text-xs text-bambu-gray mb-4 max-w-md">
-                  Make sure the printer is powered on and connected.
+                  {t('camera.cameraUnavailableDesc')}
                 </p>
                 <button
                   onClick={refresh}
                   className="px-4 py-2 bg-bambu-green text-white rounded hover:bg-bambu-green/80 transition-colors"
                 >
-                  Retry
+                  {t('camera.retry')}
                 </button>
               </div>
             </div>
@@ -661,7 +663,7 @@ export function CameraPage() {
             ref={imgRef}
             key={imageKey}
             src={currentUrl}
-            alt="Camera stream"
+            alt={t('camera.cameraStream')}
             className="max-w-full max-h-full object-contain select-none"
             style={{
               transform: `scale(${zoomLevel}) translate(${panOffset.x / zoomLevel}px, ${panOffset.y / zoomLevel}px)`,
@@ -679,14 +681,14 @@ export function CameraPage() {
               onClick={handleZoomOut}
               disabled={zoomLevel <= 1}
               className="p-1.5 hover:bg-white/10 rounded disabled:opacity-30"
-              title="Zoom out"
+              title={t('camera.zoomOut')}
             >
               <ZoomOut className="w-4 h-4 text-white" />
             </button>
             <button
               onClick={resetZoom}
               className="px-2 py-1 text-sm text-white hover:bg-white/10 rounded min-w-[48px]"
-              title="Reset zoom"
+              title={t('camera.resetZoom')}
             >
               {Math.round(zoomLevel * 100)}%
             </button>
@@ -694,7 +696,7 @@ export function CameraPage() {
               onClick={handleZoomIn}
               disabled={zoomLevel >= 4}
               className="p-1.5 hover:bg-white/10 rounded disabled:opacity-30"
-              title="Zoom in"
+              title={t('camera.zoomIn')}
             >
               <ZoomIn className="w-4 h-4 text-white" />
             </button>

+ 3 - 1
frontend/src/pages/ExternalLinkPage.tsx

@@ -1,10 +1,12 @@
 import { useParams } from 'react-router-dom';
 import { useQuery } from '@tanstack/react-query';
 import { Loader2, AlertTriangle } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { useTheme } from '../contexts/ThemeContext';
 
 export function ExternalLinkPage() {
+  const { t } = useTranslation();
   const { id } = useParams<{ id: string }>();
   const { mode } = useTheme();
 
@@ -26,7 +28,7 @@ export function ExternalLinkPage() {
     return (
       <div className="flex flex-col items-center justify-center h-full gap-4 text-bambu-gray">
         <AlertTriangle className="w-12 h-12" />
-        <p>Link not found</p>
+        <p>{t('common.linkNotFound')}</p>
       </div>
     );
   }

File diff suppressed because it is too large
+ 148 - 137
frontend/src/pages/FileManagerPage.tsx


+ 31 - 29
frontend/src/pages/GroupsPage.tsx

@@ -1,6 +1,7 @@
 import { useState, useEffect } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import {
   X,
   Plus,
@@ -25,6 +26,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
 
 export function GroupsPage() {
   const navigate = useNavigate();
+  const { t } = useTranslation();
   const { hasPermission } = useAuth();
   const { showToast } = useToast();
   const queryClient = useQueryClient();
@@ -74,7 +76,7 @@ export function GroupsPage() {
       queryClient.invalidateQueries({ queryKey: ['groups'] });
       setShowCreateModal(false);
       resetForm();
-      showToast('Group created successfully');
+      showToast(t('groups.toast.created'));
     },
     onError: (error: Error) => {
       showToast(error.message, 'error');
@@ -87,7 +89,7 @@ export function GroupsPage() {
       queryClient.invalidateQueries({ queryKey: ['groups'] });
       setEditingGroup(null);
       resetForm();
-      showToast('Group updated successfully');
+      showToast(t('groups.toast.updated'));
     },
     onError: (error: Error) => {
       showToast(error.message, 'error');
@@ -98,7 +100,7 @@ export function GroupsPage() {
     mutationFn: (id: number) => api.deleteGroup(id),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['groups'] });
-      showToast('Group deleted successfully');
+      showToast(t('groups.toast.deleted'));
     },
     onError: (error: Error) => {
       showToast(error.message, 'error');
@@ -112,7 +114,7 @@ export function GroupsPage() {
 
   const handleCreate = () => {
     if (!formData.name.trim()) {
-      showToast('Please enter a group name', 'error');
+      showToast(t('groups.toast.enterGroupName'), 'error');
       return;
     }
     createMutation.mutate({
@@ -125,7 +127,7 @@ export function GroupsPage() {
   const handleUpdate = () => {
     if (!editingGroup) return;
     if (!formData.name.trim()) {
-      showToast('Please enter a group name', 'error');
+      showToast(t('groups.toast.enterGroupName'), 'error');
       return;
     }
     updateMutation.mutate({
@@ -206,7 +208,7 @@ export function GroupsPage() {
           <CardContent className="py-6">
             <div className="flex items-center gap-3 text-red-400">
               <Shield className="w-5 h-5" />
-              <p className="text-white">You do not have permission to access this page.</p>
+              <p className="text-white">{t('groups.noPermission')}</p>
             </div>
           </CardContent>
         </Card>
@@ -283,17 +285,17 @@ export function GroupsPage() {
           <button
             onClick={() => navigate('/settings?tab=users')}
             className="p-2 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
-            title="Back to Settings"
+            title={t('groups.backToSettings')}
           >
             <ArrowLeft className="w-5 h-5" />
           </button>
           <div>
             <h1 className="text-2xl font-bold text-white flex items-center gap-2">
               <Shield className="w-6 h-6 text-bambu-green" />
-              Group Management
+              {t('groups.title')}
             </h1>
             <p className="text-sm text-bambu-gray mt-1">
-              Manage permission groups for access control
+              {t('groups.subtitle')}
             </p>
           </div>
         </div>
@@ -305,7 +307,7 @@ export function GroupsPage() {
             }}
           >
             <Plus className="w-4 h-4" />
-            Create Group
+            {t('groups.createGroup')}
           </Button>
         )}
       </div>
@@ -335,34 +337,34 @@ export function GroupsPage() {
                     <h3 className="text-lg font-semibold text-white">{group.name}</h3>
                     {group.is_system && (
                       <span className="px-2 py-0.5 rounded text-xs bg-yellow-500/20 text-yellow-400">
-                        System
+                        {t('groups.system')}
                       </span>
                     )}
                   </div>
                 </div>
               </CardHeader>
               <CardContent>
-                <p className="text-sm text-bambu-gray mb-4">{group.description || 'No description'}</p>
+                <p className="text-sm text-bambu-gray mb-4">{group.description || t('groups.noDescription')}</p>
                 <div className="flex items-center justify-between">
                   <div className="flex items-center gap-2 text-sm text-bambu-gray">
                     <Users className="w-4 h-4" />
-                    <span>{group.user_count} users</span>
+                    <span>{t('groups.usersCount', { count: group.user_count })}</span>
                   </div>
                   <div className="text-xs text-bambu-gray">
-                    {group.permissions.length} permissions
+                    {t('groups.permissionsCount', { count: group.permissions.length })}
                   </div>
                 </div>
                 <div className="flex gap-2 mt-4 pt-4 border-t border-bambu-dark-tertiary">
                   {hasPermission('groups:update') && (
                     <Button size="sm" variant="ghost" onClick={() => startEdit(group)}>
                       <Edit2 className="w-4 h-4" />
-                      Edit
+                      {t('groups.edit')}
                     </Button>
                   )}
                   {hasPermission('groups:delete') && !group.is_system && (
                     <Button size="sm" variant="ghost" onClick={() => handleDelete(group.id)}>
                       <Trash2 className="w-4 h-4" />
-                      Delete
+                      {t('groups.delete')}
                     </Button>
                   )}
                 </div>
@@ -391,7 +393,7 @@ export function GroupsPage() {
                 <div className="flex items-center gap-2">
                   <Shield className="w-5 h-5 text-bambu-green" />
                   <h2 className="text-lg font-semibold text-white">
-                    {editingGroup ? 'Edit Group' : 'Create Group'}
+                    {editingGroup ? t('groups.modal.editGroup') : t('groups.modal.createGroup')}
                   </h2>
                 </div>
                 <Button
@@ -411,7 +413,7 @@ export function GroupsPage() {
               <div className="space-y-4">
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
-                    Group Name
+                    {t('groups.form.groupName')}
                   </label>
                   <input
                     type="text"
@@ -419,27 +421,27 @@ export function GroupsPage() {
                     onChange={(e) => setFormData({ ...formData, name: e.target.value })}
                     disabled={editingGroup?.is_system}
                     className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors disabled:opacity-50"
-                    placeholder="Enter group name"
+                    placeholder={t('groups.form.groupNamePlaceholder')}
                   />
                   {editingGroup?.is_system && (
-                    <p className="text-xs text-yellow-400 mt-1">System group names cannot be changed</p>
+                    <p className="text-xs text-yellow-400 mt-1">{t('groups.form.systemGroupWarning')}</p>
                   )}
                 </div>
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
-                    Description
+                    {t('groups.form.description')}
                   </label>
                   <textarea
                     value={formData.description}
                     onChange={(e) => setFormData({ ...formData, description: e.target.value })}
                     rows={2}
                     className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors resize-none"
-                    placeholder="Enter description (optional)"
+                    placeholder={t('groups.form.descriptionPlaceholder')}
                   />
                 </div>
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
-                    Permissions ({formData.permissions.length} selected)
+                    {t('groups.form.permissions', { count: formData.permissions.length })}
                   </label>
                   {renderPermissionEditor()}
                 </div>
@@ -453,7 +455,7 @@ export function GroupsPage() {
                     resetForm();
                   }}
                 >
-                  Cancel
+                  {t('groups.modal.cancel')}
                 </Button>
                 <Button
                   onClick={editingGroup ? handleUpdate : handleCreate}
@@ -462,12 +464,12 @@ export function GroupsPage() {
                   {(createMutation.isPending || updateMutation.isPending) ? (
                     <>
                       <Loader2 className="w-4 h-4 animate-spin" />
-                      {editingGroup ? 'Saving...' : 'Creating...'}
+                      {editingGroup ? t('groups.modal.saving') : t('groups.modal.creating')}
                     </>
                   ) : (
                     <>
                       <Save className="w-4 h-4" />
-                      {editingGroup ? 'Save Changes' : 'Create Group'}
+                      {editingGroup ? t('groups.modal.saveChanges') : t('groups.modal.createGroup')}
                     </>
                   )}
                 </Button>
@@ -480,9 +482,9 @@ export function GroupsPage() {
       {/* Delete Confirmation Modal */}
       {deleteGroupId !== null && (
         <ConfirmModal
-          title="Delete Group"
-          message="Are you sure you want to delete this group? Users in this group will lose these permissions."
-          confirmText="Delete Group"
+          title={t('groups.deleteModal.title')}
+          message={t('groups.deleteModal.message')}
+          confirmText={t('groups.deleteModal.confirm')}
           variant="danger"
           onConfirm={() => {
             deleteMutation.mutate(deleteGroupId);

+ 21 - 19
frontend/src/pages/LoginPage.tsx

@@ -1,6 +1,7 @@
 import { useState } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useMutation } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
 import { useTheme } from '../contexts/ThemeContext';
@@ -8,6 +9,7 @@ import { HelpCircle, X } from 'lucide-react';
 
 export function LoginPage() {
   const navigate = useNavigate();
+  const { t } = useTranslation();
   const { login } = useAuth();
   const { showToast } = useToast();
   const { mode } = useTheme();
@@ -18,18 +20,18 @@ export function LoginPage() {
   const loginMutation = useMutation({
     mutationFn: () => login(username, password),
     onSuccess: () => {
-      showToast('Logged in successfully');
+      showToast(t('login.loginSuccess'));
       navigate('/');
     },
     onError: (error: Error) => {
-      showToast(error.message || 'Login failed', 'error');
+      showToast(error.message || t('login.loginFailed'), 'error');
     },
   });
 
   const handleSubmit = (e: React.FormEvent) => {
     e.preventDefault();
     if (!username || !password) {
-      showToast('Please enter username and password', 'error');
+      showToast(t('login.enterCredentials'), 'error');
       return;
     }
     loginMutation.mutate();
@@ -47,10 +49,10 @@ export function LoginPage() {
             />
           </div>
           <h2 className="text-3xl font-bold text-white">
-            Bambuddy Login
+            {t('login.title')}
           </h2>
           <p className="mt-2 text-sm text-bambu-gray">
-            Sign in to your account
+            {t('login.subtitle')}
           </p>
         </div>
 
@@ -58,7 +60,7 @@ export function LoginPage() {
           <div className="space-y-4">
             <div>
               <label htmlFor="username" className="block text-sm font-medium text-white mb-2">
-                Username
+                {t('login.username')}
               </label>
               <input
                 id="username"
@@ -67,14 +69,14 @@ export function LoginPage() {
                 value={username}
                 onChange={(e) => setUsername(e.target.value)}
                 className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
-                placeholder="Enter your username"
+                placeholder={t('login.usernamePlaceholder')}
                 autoComplete="username"
               />
             </div>
 
             <div>
               <label htmlFor="password" className="block text-sm font-medium text-white mb-2">
-                Password
+                {t('login.password')}
               </label>
               <input
                 id="password"
@@ -83,7 +85,7 @@ export function LoginPage() {
                 value={password}
                 onChange={(e) => setPassword(e.target.value)}
                 className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
-                placeholder="Enter your password"
+                placeholder={t('login.passwordPlaceholder')}
                 autoComplete="current-password"
               />
             </div>
@@ -95,7 +97,7 @@ export function LoginPage() {
               disabled={loginMutation.isPending}
               className="w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-bambu-green"
             >
-              {loginMutation.isPending ? 'Logging in...' : 'Sign in'}
+              {loginMutation.isPending ? t('login.signingIn') : t('login.signIn')}
             </button>
           </div>
 
@@ -105,7 +107,7 @@ export function LoginPage() {
               onClick={() => setShowForgotPassword(true)}
               className="text-sm text-bambu-gray hover:text-bambu-green transition-colors"
             >
-              Forgot your password?
+              {t('login.forgotPassword')}
             </button>
           </div>
         </form>
@@ -124,7 +126,7 @@ export function LoginPage() {
             <div className="flex items-center justify-between mb-4">
               <div className="flex items-center gap-2">
                 <HelpCircle className="w-5 h-5 text-bambu-green" />
-                <h2 className="text-lg font-semibold text-white">Forgot Password</h2>
+                <h2 className="text-lg font-semibold text-white">{t('login.forgotPasswordTitle')}</h2>
               </div>
               <button
                 onClick={() => setShowForgotPassword(false)}
@@ -136,16 +138,16 @@ export function LoginPage() {
 
             <div className="space-y-4">
               <p className="text-bambu-gray">
-                If you've forgotten your password, please contact your system administrator to reset it.
+                {t('login.forgotPasswordMessage')}
               </p>
 
               <div className="bg-bambu-dark rounded-lg p-4 space-y-2">
-                <p className="text-sm text-white font-medium">How to reset your password:</p>
+                <p className="text-sm text-white font-medium">{t('login.howToReset')}</p>
                 <ol className="text-sm text-bambu-gray space-y-1 list-decimal list-inside">
-                  <li>Contact your Bambuddy administrator</li>
-                  <li>Ask them to reset your password in User Management</li>
-                  <li>They can set a new temporary password for you</li>
-                  <li>Log in with the new password and change it in Settings</li>
+                  <li>{t('login.resetStep1')}</li>
+                  <li>{t('login.resetStep2')}</li>
+                  <li>{t('login.resetStep3')}</li>
+                  <li>{t('login.resetStep4')}</li>
                 </ol>
               </div>
 
@@ -153,7 +155,7 @@ export function LoginPage() {
                 onClick={() => setShowForgotPassword(false)}
                 className="w-full py-2 px-4 bg-bambu-dark-tertiary hover:bg-bambu-dark text-white rounded-lg transition-colors"
               >
-                Got it
+                {t('login.gotIt')}
               </button>
             </div>
           </div>

+ 119 - 97
frontend/src/pages/MaintenancePage.tsx

@@ -1,4 +1,5 @@
 import { useState, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import {
   Wrench,
@@ -73,15 +74,26 @@ function getIcon(iconName: string | null) {
   return iconMap[iconName] || Wrench;
 }
 
-function formatDuration(value: number, type: 'hours' | 'days'): string {
+type TFunction = (key: string, options?: Record<string, unknown>) => string;
+
+function formatDuration(value: number, type: 'hours' | 'days', t?: TFunction): string {
   if (type === 'days') {
-    if (value < 1) return 'Today';
-    if (value === 1) return '1 day';
-    if (value < 7) return `${Math.round(value)} days`;
+    if (value < 1) return t ? t('common.today') : 'Today';
+    if (value === 1) return t ? t('maintenance.day') : '1 day';
+    if (value < 7) {
+      const days = Math.round(value);
+      return t ? t('maintenance.days', { count: days }) : `${days} days`;
+    }
     // Show weeks for anything under 6 months for better precision
-    if (value < 180) return `${Math.round(value / 7)} weeks`;
+    if (value < 180) {
+      const weeks = Math.round(value / 7);
+      if (weeks === 1) return t ? t('maintenance.week') : '1 week';
+      return t ? t('maintenance.weeks', { count: weeks }) : `${weeks} weeks`;
+    }
     // 6+ months show as months
-    return `${Math.round(value / 30)} months`;
+    const months = Math.round(value / 30);
+    if (months === 1) return t ? t('maintenance.month') : '1 month';
+    return t ? t('maintenance.months', { count: months }) : `${months} months`;
   } else {
     // Print hours - convert to readable units
     if (value < 1) return `${Math.round(value * 60)}m`;
@@ -97,17 +109,17 @@ function formatDuration(value: number, type: 'hours' | 'days'): string {
   }
 }
 
-function formatIntervalLabel(value: number, type: 'hours' | 'days'): string {
+function formatIntervalLabel(value: number, type: 'hours' | 'days', t?: TFunction): string {
   if (type === 'days') {
-    if (value === 1) return '1 day';
-    if (value === 7) return '1 week';
-    if (value === 14) return '2 weeks';
-    if (value === 30) return '1 month';
-    if (value === 60) return '2 months';
-    if (value === 90) return '3 months';
-    if (value === 180) return '6 months';
-    if (value === 365) return '1 year';
-    return `${value} days`;
+    if (value === 1) return t ? t('maintenance.day') : '1 day';
+    if (value === 7) return t ? t('maintenance.week') : '1 week';
+    if (value === 14) return t ? t('maintenance.weeks', { count: 2 }) : '2 weeks';
+    if (value === 30) return t ? t('maintenance.month') : '1 month';
+    if (value === 60) return t ? t('maintenance.months', { count: 2 }) : '2 months';
+    if (value === 90) return t ? t('maintenance.months', { count: 3 }) : '3 months';
+    if (value === 180) return t ? t('maintenance.months', { count: 6 }) : '6 months';
+    if (value === 365) return t ? t('maintenance.year') : '1 year';
+    return t ? t('maintenance.days', { count: value }) : `${value} days`;
   }
   return `${value}h`;
 }
@@ -201,11 +213,13 @@ function MaintenanceCard({
   onPerform,
   onToggle,
   hasPermission,
+  t,
 }: {
   item: MaintenanceStatus;
   onPerform: (id: number) => void;
   onToggle: (id: number, enabled: boolean) => void;
   hasPermission: (permission: Permission) => boolean;
+  t: TFunction;
 }) {
   const Icon = getIcon(item.maintenance_type_icon);
   const intervalType = item.interval_type || 'hours';
@@ -245,17 +259,17 @@ function MaintenanceCard({
   };
 
   const getStatusText = () => {
-    if (!item.enabled) return 'Disabled';
+    if (!item.enabled) return t('common.disabled');
 
     if (intervalType === 'days') {
       const daysUntil = item.days_until_due ?? 0;
-      if (item.is_due) return `Overdue by ${formatDuration(Math.abs(daysUntil), 'days')}`;
-      if (item.is_warning) return `Due in ${formatDuration(daysUntil, 'days')}`;
-      return `${formatDuration(daysUntil, 'days')} left`;
+      if (item.is_due) return t('maintenance.overdueBy', { duration: formatDuration(Math.abs(daysUntil), 'days', t) });
+      if (item.is_warning) return t('maintenance.dueIn', { duration: formatDuration(daysUntil, 'days', t) });
+      return t('maintenance.timeLeft', { duration: formatDuration(daysUntil, 'days', t) });
     } else {
-      if (item.is_due) return `Overdue by ${formatDuration(Math.abs(item.hours_until_due), 'hours')}`;
-      if (item.is_warning) return `Due in ${formatDuration(item.hours_until_due, 'hours')}`;
-      return `${formatDuration(item.hours_until_due, 'hours')} left`;
+      if (item.is_due) return t('maintenance.overdueBy', { duration: formatDuration(Math.abs(item.hours_until_due), 'hours', t) });
+      if (item.is_warning) return t('maintenance.dueIn', { duration: formatDuration(item.hours_until_due, 'hours', t) });
+      return t('maintenance.timeLeft', { duration: formatDuration(item.hours_until_due, 'hours', t) });
     }
   };
 
@@ -283,7 +297,7 @@ function MaintenanceCard({
               {item.maintenance_type_name}
             </h3>
             {intervalType === 'days' && (
-              <span title="Time-based interval">
+              <span title={t('maintenance.timeBasedInterval')}>
                 <Calendar className="w-3.5 h-3.5 text-bambu-gray shrink-0" />
               </span>
             )}
@@ -297,7 +311,7 @@ function MaintenanceCard({
                   target="_blank"
                   rel="noopener noreferrer"
                   className="text-bambu-gray hover:text-bambu-green transition-colors shrink-0"
-                  title="View documentation"
+                  title={t('maintenance.viewDocumentation')}
                   onClick={(e) => e.stopPropagation()}
                 >
                   <ExternalLink className="w-3.5 h-3.5" />
@@ -327,7 +341,7 @@ function MaintenanceCard({
 
         {/* Actions */}
         <div className="flex items-center gap-2 shrink-0">
-          <span title={!hasPermission('maintenance:update') ? 'You do not have permission to update maintenance items' : undefined}>
+          <span title={!hasPermission('maintenance:update') ? t('maintenance.noPermissionUpdate') : undefined}>
             <Toggle
               checked={item.enabled}
               onChange={(checked) => onToggle(item.id, checked)}
@@ -339,11 +353,11 @@ function MaintenanceCard({
             variant={item.is_due ? 'primary' : 'secondary'}
             onClick={() => onPerform(item.id)}
             disabled={!item.enabled || !hasPermission('maintenance:update')}
-            title={!hasPermission('maintenance:update') ? 'You do not have permission to perform maintenance' : undefined}
+            title={!hasPermission('maintenance:update') ? t('maintenance.noPermissionPerform') : undefined}
             className="!px-3"
           >
             <RotateCcw className="w-3.5 h-3.5" />
-            Reset
+            {t('common.reset')}
           </Button>
         </div>
       </div>
@@ -358,12 +372,14 @@ function PrinterSection({
   onToggle,
   onSetHours,
   hasPermission,
+  t,
 }: {
   overview: PrinterMaintenanceOverview;
   onPerform: (id: number) => void;
   onToggle: (id: number, enabled: boolean) => void;
   onSetHours: (printerId: number, hours: number) => void;
   hasPermission: (permission: Permission) => boolean;
+  t: TFunction;
 }) {
   const [expanded, setExpanded] = useState(true);
   const [editingHours, setEditingHours] = useState(false);
@@ -399,19 +415,19 @@ function PrinterSection({
               {overview.due_count > 0 && (
                 <span className="px-2.5 py-1 bg-red-500/20 text-red-400 text-xs font-medium rounded-full flex items-center gap-1.5">
                   <AlertTriangle className="w-3 h-3" />
-                  {overview.due_count} overdue
+                  {t('maintenance.overdueCount', { count: overview.due_count })}
                 </span>
               )}
               {overview.warning_count > 0 && (
                 <span className="px-2.5 py-1 bg-amber-500/20 text-amber-400 text-xs font-medium rounded-full flex items-center gap-1.5">
                   <Clock className="w-3 h-3" />
-                  {overview.warning_count} due soon
+                  {t('maintenance.dueSoonCount', { count: overview.warning_count })}
                 </span>
               )}
               {overview.due_count === 0 && overview.warning_count === 0 && (
                 <span className="px-2.5 py-1 bg-bambu-green/20 text-bambu-green text-xs font-medium rounded-full flex items-center gap-1.5">
                   <Check className="w-3 h-3" />
-                  All good
+                  {t('maintenance.allGood')}
                 </span>
               )}
             </div>
@@ -421,7 +437,7 @@ function PrinterSection({
             className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-bambu-gray hover:text-white hover:bg-bambu-dark rounded-lg transition-colors"
           >
             {expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
-            {expanded ? 'Collapse' : 'Expand'}
+            {expanded ? t('common.collapse') : t('common.expand')}
           </button>
         </div>
 
@@ -447,9 +463,9 @@ function PrinterSection({
                   step="1"
                   autoFocus
                 />
-                <span className="text-xs text-bambu-gray">hours</span>
-                <Button size="sm" onClick={handleSaveHours}>Save</Button>
-                <Button size="sm" variant="secondary" onClick={() => setEditingHours(false)}>Cancel</Button>
+                <span className="text-xs text-bambu-gray">{t('common.hours')}</span>
+                <Button size="sm" onClick={handleSaveHours}>{t('common.save')}</Button>
+                <Button size="sm" variant="secondary" onClick={() => setEditingHours(false)}>{t('common.cancel')}</Button>
               </div>
             ) : (
               <button
@@ -459,13 +475,13 @@ function PrinterSection({
                   setEditingHours(true);
                 }}
                 className={`group ${!hasPermission('maintenance:update') ? 'cursor-not-allowed opacity-60' : ''}`}
-                title={!hasPermission('maintenance:update') ? 'You do not have permission to edit print hours' : undefined}
+                title={!hasPermission('maintenance:update') ? t('maintenance.noPermissionEditHours') : undefined}
               >
                 <div className={`text-sm font-medium text-white ${hasPermission('maintenance:update') ? 'group-hover:text-bambu-green' : ''} transition-colors flex items-center gap-1`}>
-                  {Math.round(overview.total_print_hours)} hours
+                  {Math.round(overview.total_print_hours)} {t('common.hours')}
                   <Edit3 className={`w-3 h-3 text-bambu-gray ${hasPermission('maintenance:update') ? 'group-hover:text-bambu-green' : ''}`} />
                 </div>
-                <div className="text-xs text-bambu-gray">Total print time</div>
+                <div className="text-xs text-bambu-gray">{t('maintenance.totalPrintTime')}</div>
               </button>
             )}
           </div>
@@ -489,7 +505,7 @@ function PrinterSection({
                   {nextTask.maintenance_type_name}
                 </div>
                 <div className={`text-xs ${nextTask.is_due ? 'text-red-400/70' : 'text-amber-400/70'}`}>
-                  {nextTask.is_due ? 'Overdue' : 'Due soon'}
+                  {nextTask.is_due ? t('common.overdue') : t('maintenance.dueSoon')}
                 </div>
               </div>
             </div>
@@ -508,6 +524,7 @@ function PrinterSection({
                 onPerform={onPerform}
                 onToggle={onToggle}
                 hasPermission={hasPermission}
+                t={t}
               />
             ))}
           </div>
@@ -528,6 +545,7 @@ function SettingsSection({
   onAssignType,
   onRemoveItem,
   hasPermission,
+  t,
 }: {
   overview: PrinterMaintenanceOverview[] | undefined;
   types: MaintenanceType[];
@@ -538,6 +556,7 @@ function SettingsSection({
   onAssignType: (printerId: number, typeId: number) => void;
   onRemoveItem: (itemId: number) => void;
   hasPermission: (permission: Permission) => boolean;
+  t: TFunction;
 }) {
   const [editingInterval, setEditingInterval] = useState<number | null>(null);
   const [intervalInput, setIntervalInput] = useState('');
@@ -665,16 +684,16 @@ function SettingsSection({
       <div>
         <div className="flex items-center justify-between mb-4">
           <div>
-            <h2 className="text-lg font-semibold text-white">Maintenance Types</h2>
-            <p className="text-sm text-bambu-gray mt-1">System types and your custom maintenance tasks</p>
+            <h2 className="text-lg font-semibold text-white">{t('maintenance.maintenanceTypes')}</h2>
+            <p className="text-sm text-bambu-gray mt-1">{t('maintenance.maintenanceTypesDescription')}</p>
           </div>
           <Button
             onClick={() => setShowAddType(!showAddType)}
             disabled={!hasPermission('maintenance:create')}
-            title={!hasPermission('maintenance:create') ? 'You do not have permission to create maintenance types' : undefined}
+            title={!hasPermission('maintenance:create') ? t('maintenance.noPermissionEditTypes') : undefined}
           >
             <Plus className="w-4 h-4" />
-            Add Custom Type
+            {t('maintenance.addCustomType')}
           </Button>
         </div>
 
@@ -685,18 +704,18 @@ function SettingsSection({
               <form onSubmit={handleAddType}>
                 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
                   <div className="lg:col-span-2">
-                    <label className="block text-xs text-bambu-gray mb-1.5">Name</label>
+                    <label className="block text-xs text-bambu-gray mb-1.5">{t('common.name')}</label>
                     <input
                       type="text"
                       value={newTypeName}
                       onChange={(e) => setNewTypeName(e.target.value)}
                       className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
-                      placeholder="e.g., Replace HEPA Filter"
+                      placeholder={t('maintenance.exampleName')}
                       autoFocus
                     />
                   </div>
                   <div>
-                    <label className="block text-xs text-bambu-gray mb-1.5">Interval Type</label>
+                    <label className="block text-xs text-bambu-gray mb-1.5">{t('maintenance.intervalType')}</label>
                     <select
                       value={newTypeIntervalType}
                       onChange={(e) => {
@@ -710,13 +729,13 @@ function SettingsSection({
                       }}
                       className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
                     >
-                      <option value="hours">Print Hours</option>
-                      <option value="days">Calendar Days</option>
+                      <option value="hours">{t('maintenance.printHours')}</option>
+                      <option value="days">{t('maintenance.calendarDays')}</option>
                     </select>
                   </div>
                   <div>
                     <label className="block text-xs text-bambu-gray mb-1.5">
-                      Interval ({newTypeIntervalType === 'days' ? 'days' : 'hours'})
+                      {t('maintenance.intervalValue', { type: newTypeIntervalType === 'days' ? t('maintenance.calendarDays').toLowerCase() : t('common.hours') })}
                     </label>
                     <input
                       type="number"
@@ -729,7 +748,7 @@ function SettingsSection({
                 </div>
                 <div className="mt-4 flex items-end justify-between">
                   <div>
-                    <label className="block text-xs text-bambu-gray mb-1.5">Icon</label>
+                    <label className="block text-xs text-bambu-gray mb-1.5">{t('maintenance.icon')}</label>
                     <div className="flex gap-1">
                       {Object.keys(iconMap).map((iconName) => {
                         const IconComp = iconMap[iconName];
@@ -753,7 +772,7 @@ function SettingsSection({
                 </div>
                 {/* Wiki URL */}
                 <div className="mt-4">
-                  <label className="block text-xs text-bambu-gray mb-1.5">Documentation Link (optional)</label>
+                  <label className="block text-xs text-bambu-gray mb-1.5">{t('maintenance.documentationLink')}</label>
                   <input
                     type="url"
                     value={newTypeWikiUrl}
@@ -764,7 +783,7 @@ function SettingsSection({
                 </div>
                 {/* Printer selection */}
                 <div className="mt-4">
-                  <label className="block text-xs text-bambu-gray mb-1.5">Assign to Printers</label>
+                  <label className="block text-xs text-bambu-gray mb-1.5">{t('maintenance.assignToPrinters')}</label>
                   <div className="flex flex-wrap gap-2">
                     {printers.map(p => (
                       <button
@@ -782,15 +801,15 @@ function SettingsSection({
                     ))}
                   </div>
                   {selectedPrinters.size === 0 && (
-                    <p className="text-xs text-orange-400 mt-1">Select at least one printer</p>
+                    <p className="text-xs text-orange-400 mt-1">{t('maintenance.selectAtLeastOnePrinter')}</p>
                   )}
                 </div>
                 <div className="mt-4 flex justify-end gap-2">
                   <Button type="button" variant="secondary" onClick={() => { setShowAddType(false); setSelectedPrinters(new Set()); }}>
-                    Cancel
+                    {t('common.cancel')}
                   </Button>
                   <Button type="submit" disabled={!newTypeName.trim() || selectedPrinters.size === 0}>
-                    Add Type
+                    {t('maintenance.addType')}
                   </Button>
                 </div>
               </form>
@@ -814,7 +833,7 @@ function SettingsSection({
                     <div className="text-sm font-medium text-white truncate">{type.name}</div>
                     <div className="text-xs text-bambu-gray mt-0.5 flex items-center gap-1">
                       {intervalType === 'days' ? <Calendar className="w-3 h-3" /> : <Timer className="w-3 h-3" />}
-                      {formatIntervalLabel(type.default_interval_hours, intervalType)}
+                      {formatIntervalLabel(type.default_interval_hours, intervalType, t)}
                     </div>
                   </div>
                 </div>
@@ -836,7 +855,7 @@ function SettingsSection({
                       value={editTypeName}
                       onChange={(e) => setEditTypeName(e.target.value)}
                       className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
-                      placeholder="Name"
+                      placeholder={t('common.name')}
                       autoFocus
                     />
                     <div className="flex gap-2">
@@ -845,8 +864,8 @@ function SettingsSection({
                         onChange={(e) => setEditTypeIntervalType(e.target.value as 'hours' | 'days')}
                         className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
                       >
-                        <option value="hours">Print Hours</option>
-                        <option value="days">Calendar Days</option>
+                        <option value="hours">{t('maintenance.printHours')}</option>
+                        <option value="days">{t('maintenance.calendarDays')}</option>
                       </select>
                       <input
                         type="number"
@@ -880,14 +899,14 @@ function SettingsSection({
                       value={editTypeWikiUrl}
                       onChange={(e) => setEditTypeWikiUrl(e.target.value)}
                       className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"
-                      placeholder="Documentation link (optional)"
+                      placeholder={t('maintenance.documentationLink')}
                     />
                     <div className="flex gap-2">
                       <Button size="sm" onClick={handleSaveEditType} disabled={!editTypeName.trim()}>
-                        Save
+                        {t('common.save')}
                       </Button>
                       <Button size="sm" variant="secondary" onClick={() => setEditingType(null)}>
-                        Cancel
+                        {t('common.cancel')}
                       </Button>
                     </div>
                   </div>
@@ -909,12 +928,12 @@ function SettingsSection({
                     <div className="flex items-center gap-2">
                       <span className="text-sm font-medium text-white truncate">{type.name}</span>
                       <span className="px-1.5 py-0.5 bg-bambu-green/20 text-bambu-green text-[10px] font-medium rounded">
-                        Custom
+                        {t('maintenance.custom')}
                       </span>
                     </div>
                     <div className="text-xs text-bambu-gray mt-0.5 flex items-center gap-1">
                       {intervalType === 'days' ? <Calendar className="w-3 h-3" /> : <Timer className="w-3 h-3" />}
-                      {formatIntervalLabel(type.default_interval_hours, intervalType)}
+                      {formatIntervalLabel(type.default_interval_hours, intervalType, t)}
                     </div>
                   </div>
                   <button
@@ -924,7 +943,7 @@ function SettingsSection({
                         ? 'border-bambu-green/50 bg-bambu-green/10 text-bambu-green hover:bg-bambu-green/20'
                         : 'border-orange-400/50 bg-orange-400/10 text-orange-400 hover:bg-orange-400/20'
                     }`}
-                    title={`${assignedPrinters.length} printer(s) assigned - click to manage`}
+                    title={t('maintenance.printersAssignedClick', { count: assignedPrinters.length })}
                   >
                     <Printer className="w-3 h-3" />
                     <span className="text-xs font-medium">{assignedPrinters.length}</span>
@@ -933,19 +952,19 @@ function SettingsSection({
                   <button
                     onClick={() => startEditType(type)}
                     disabled={!hasPermission('maintenance:update')}
-                    title={!hasPermission('maintenance:update') ? 'You do not have permission to edit maintenance types' : undefined}
+                    title={!hasPermission('maintenance:update') ? t('maintenance.noPermissionEditTypes') : undefined}
                     className={`p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-white transition-colors ${!hasPermission('maintenance:update') ? 'opacity-50 cursor-not-allowed' : ''}`}
                   >
                     <Edit3 className="w-4 h-4" />
                   </button>
                   <button
                     onClick={() => {
-                      if (confirm(`Delete "${type.name}"?`)) {
+                      if (confirm(t('maintenance.deleteTypeConfirm', { name: type.name }))) {
                         onDeleteType(type.id);
                       }
                     }}
                     disabled={!hasPermission('maintenance:delete')}
-                    title={!hasPermission('maintenance:delete') ? 'You do not have permission to delete maintenance types' : undefined}
+                    title={!hasPermission('maintenance:delete') ? t('maintenance.noPermissionDeleteTypes') : undefined}
                     className={`p-2 rounded-lg hover:bg-bambu-dark text-bambu-gray hover:text-red-400 transition-colors ${!hasPermission('maintenance:delete') ? 'opacity-50 cursor-not-allowed' : ''}`}
                   >
                     <Trash2 className="w-4 h-4" />
@@ -955,9 +974,9 @@ function SettingsSection({
                 {/* Printer assignment management */}
                 {isExpanded && (
                   <div className="mt-3 pt-3 border-t border-bambu-dark-tertiary">
-                    <p className="text-xs text-bambu-gray mb-2">Assigned to printers:</p>
+                    <p className="text-xs text-bambu-gray mb-2">{t('maintenance.assignedToPrinters')}</p>
                     {assignedPrinters.length === 0 ? (
-                      <p className="text-xs text-orange-400">No printers assigned</p>
+                      <p className="text-xs text-orange-400">{t('maintenance.noPrintersAssigned')}</p>
                     ) : (
                       <div className="flex flex-wrap gap-1 mb-2">
                         {assignedPrinters.map(p => (
@@ -969,7 +988,7 @@ function SettingsSection({
                             <button
                               onClick={() => p.itemId && onRemoveItem(p.itemId)}
                               disabled={!hasPermission('maintenance:delete')}
-                              title={!hasPermission('maintenance:delete') ? 'You do not have permission to remove printer assignments' : 'Remove from this printer'}
+                              title={!hasPermission('maintenance:delete') ? t('maintenance.noPermissionRemovePrinter') : t('maintenance.removeFromPrinter')}
                               className={`ml-1 ${hasPermission('maintenance:delete') ? 'hover:text-red-400' : 'opacity-50 cursor-not-allowed'}`}
                             >
                               ×
@@ -980,13 +999,13 @@ function SettingsSection({
                     )}
                     {unassignedPrinters.length > 0 && (
                       <div className="flex flex-wrap gap-1">
-                        <span className="text-xs text-bambu-gray mr-1">Add:</span>
+                        <span className="text-xs text-bambu-gray mr-1">{t('maintenance.addPrinterShort')}</span>
                         {unassignedPrinters.map(p => (
                           <button
                             key={p.id}
                             onClick={() => onAssignType(p.id, type.id)}
                             disabled={!hasPermission('maintenance:create')}
-                            title={!hasPermission('maintenance:create') ? 'You do not have permission to assign printers' : undefined}
+                            title={!hasPermission('maintenance:create') ? t('maintenance.noPermissionAssignPrinter') : undefined}
                             className={`px-2 py-1 bg-bambu-dark rounded text-xs transition-colors ${hasPermission('maintenance:create') ? 'hover:bg-bambu-green/20 text-bambu-gray hover:text-bambu-green' : 'opacity-50 cursor-not-allowed text-bambu-gray'}`}
                           >
                             + {p.name}
@@ -1006,8 +1025,8 @@ function SettingsSection({
       {printerItems.length > 0 && (
         <div>
           <div className="mb-4">
-            <h2 className="text-lg font-semibold text-white">Interval Overrides</h2>
-            <p className="text-sm text-bambu-gray mt-1">Customize intervals for specific printers</p>
+            <h2 className="text-lg font-semibold text-white">{t('maintenance.intervalOverrides')}</h2>
+            <p className="text-sm text-bambu-gray mt-1">{t('maintenance.intervalOverridesDescription')}</p>
           </div>
           <div className="space-y-4">
             {printerItems.map((printer) => (
@@ -1040,8 +1059,8 @@ function SettingsSection({
                                 onChange={(e) => setIntervalTypeInput(e.target.value as 'hours' | 'days')}
                                 className="px-1.5 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs"
                               >
-                                <option value="hours">Print Hours</option>
-                                <option value="days">Calendar Days</option>
+                                <option value="hours">{t('maintenance.printHours')}</option>
+                                <option value="days">{t('maintenance.calendarDays')}</option>
                               </select>
                               <input
                                 type="number"
@@ -1065,11 +1084,11 @@ function SettingsSection({
                                 setIntervalTypeInput(intervalType);
                               }}
                               disabled={!hasPermission('maintenance:update')}
-                              title={!hasPermission('maintenance:update') ? 'You do not have permission to edit intervals' : undefined}
+                              title={!hasPermission('maintenance:update') ? t('maintenance.noPermissionEditIntervals') : undefined}
                               className={`px-2 py-1 bg-bambu-dark-tertiary border border-bambu-dark-tertiary rounded text-xs font-medium text-white transition-colors flex items-center gap-1 ${hasPermission('maintenance:update') ? 'hover:bg-bambu-dark-secondary hover:border-bambu-green' : 'opacity-50 cursor-not-allowed'}`}
                             >
                               {intervalType === 'days' ? <Calendar className="w-3 h-3" /> : <Timer className="w-3 h-3" />}
-                              {formatIntervalLabel(item.interval_hours, intervalType)}
+                              {formatIntervalLabel(item.interval_hours, intervalType, t)}
                               <Edit3 className="w-3 h-3 text-bambu-gray" />
                             </button>
                           )}
@@ -1088,9 +1107,9 @@ function SettingsSection({
         <Card>
           <CardContent className="text-center py-12">
             <Clock className="w-12 h-12 mx-auto mb-4 text-bambu-gray/30" />
-            <p className="text-bambu-gray">No printers configured</p>
+            <p className="text-bambu-gray">{t('common.noPrinters')}</p>
             <p className="text-sm text-bambu-gray/70 mt-1">
-              Add printers to configure maintenance intervals
+              {t('maintenance.intervalOverridesDescription')}
             </p>
           </CardContent>
         </Card>
@@ -1102,6 +1121,7 @@ function SettingsSection({
 type TabType = 'status' | 'settings';
 
 export function MaintenancePage() {
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { hasPermission } = useAuth();
@@ -1123,7 +1143,7 @@ export function MaintenancePage() {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
       queryClient.invalidateQueries({ queryKey: ['maintenanceSummary'] });
-      showToast('Maintenance marked as complete');
+      showToast(t('maintenance.maintenanceComplete'));
     },
     onError: (error: Error) => {
       showToast(error.message, 'error');
@@ -1150,7 +1170,7 @@ export function MaintenancePage() {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
       queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
-      showToast('Maintenance type updated');
+      showToast(t('maintenance.typeUpdated'));
     },
     onError: (error: Error) => {
       showToast(error.message, 'error');
@@ -1162,7 +1182,7 @@ export function MaintenancePage() {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
       queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
-      showToast('Maintenance type deleted');
+      showToast(t('maintenance.typeDeleted'));
     },
     onError: (error: Error) => {
       showToast(error.message, 'error');
@@ -1175,7 +1195,7 @@ export function MaintenancePage() {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
       queryClient.invalidateQueries({ queryKey: ['maintenanceSummary'] });
-      showToast('Print hours updated');
+      showToast(t('maintenance.printHoursUpdated'));
     },
     onError: (error: Error) => {
       showToast(error.message, 'error');
@@ -1187,7 +1207,7 @@ export function MaintenancePage() {
       api.assignMaintenanceType(printerId, typeId),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
-      showToast('Printer assigned');
+      showToast(t('maintenance.printerAssigned'));
     },
     onError: (error: Error) => {
       showToast(error.message, 'error');
@@ -1198,7 +1218,7 @@ export function MaintenancePage() {
     mutationFn: api.removeMaintenanceItem,
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
-      showToast('Printer removed');
+      showToast(t('maintenance.printerRemoved'));
     },
     onError: (error: Error) => {
       showToast(error.message, 'error');
@@ -1232,17 +1252,17 @@ export function MaintenancePage() {
     <div className="p-4 md:p-8">
       {/* Header */}
       <div className="mb-6">
-        <h1 className="text-2xl font-bold text-white">Maintenance</h1>
+        <h1 className="text-2xl font-bold text-white">{t('maintenance.title')}</h1>
         <p className="text-bambu-gray text-sm mt-1">
           {activeTab === 'status' ? (
             <>
-              {totalDue > 0 && <span className="text-red-400">{totalDue} task{totalDue !== 1 ? 's' : ''} overdue</span>}
+              {totalDue > 0 && <span className="text-red-400">{t('maintenance.dueCount', { count: totalDue })}</span>}
               {totalDue > 0 && totalWarning > 0 && ' · '}
-              {totalWarning > 0 && <span className="text-amber-400">{totalWarning} due soon</span>}
-              {totalDue === 0 && totalWarning === 0 && <span className="text-bambu-green">All maintenance up to date</span>}
+              {totalWarning > 0 && <span className="text-amber-400">{t('maintenance.warningCount', { count: totalWarning })}</span>}
+              {totalDue === 0 && totalWarning === 0 && <span className="text-bambu-green">{t('maintenance.allOk')}</span>}
             </>
           ) : (
-            'Configure maintenance types and intervals'
+            t('maintenance.configureSettings')
           )}
         </p>
       </div>
@@ -1257,7 +1277,7 @@ export function MaintenancePage() {
               : 'text-bambu-gray border-transparent hover:text-white'
           }`}
         >
-          Status
+          {t('maintenance.statusTab')}
         </button>
         <button
           onClick={() => setActiveTab('settings')}
@@ -1267,7 +1287,7 @@ export function MaintenancePage() {
               : 'text-bambu-gray border-transparent hover:text-white'
           }`}
         >
-          Settings
+          {t('maintenance.settingsTab')}
         </button>
       </div>
 
@@ -1289,14 +1309,15 @@ export function MaintenancePage() {
                 onToggle={handleToggle}
                 onSetHours={handleSetHours}
                 hasPermission={hasPermission}
+                t={t}
               />
             ))
           ) : (
             <Card>
               <CardContent className="text-center py-16">
                 <Wrench className="w-16 h-16 mx-auto mb-4 text-bambu-gray/30" />
-                <p className="text-lg font-medium text-white mb-2">No printers configured</p>
-                <p className="text-bambu-gray">Add printers to start tracking maintenance</p>
+                <p className="text-lg font-medium text-white mb-2">{t('common.noPrinters')}</p>
+                <p className="text-bambu-gray">{t('maintenance.configureSettings')}</p>
               </CardContent>
             </Card>
           )}
@@ -1317,13 +1338,14 @@ export function MaintenancePage() {
             }
             queryClient.invalidateQueries({ queryKey: ['maintenanceTypes'] });
             queryClient.invalidateQueries({ queryKey: ['maintenanceOverview'] });
-            showToast('Maintenance type added');
+            showToast(t('maintenance.typeUpdated'));
           }}
           onUpdateType={(id, data) => updateTypeMutation.mutate({ id, data })}
           onDeleteType={(id) => deleteTypeMutation.mutate(id)}
           onAssignType={(printerId, typeId) => assignTypeMutation.mutate({ printerId, typeId })}
           onRemoveItem={(itemId) => removeItemMutation.mutate(itemId)}
           hasPermission={hasPermission}
+          t={t}
         />
       )}
     </div>

File diff suppressed because it is too large
+ 147 - 145
frontend/src/pages/PrintersPage.tsx


File diff suppressed because it is too large
+ 147 - 134
frontend/src/pages/ProfilesPage.tsx


+ 114 - 103
frontend/src/pages/ProjectDetailPage.tsx

@@ -1,6 +1,7 @@
 import { useState } from 'react';
 import { useParams, useNavigate, Link } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import {
   ArrowLeft,
   Edit3,
@@ -59,7 +60,9 @@ function formatFilament(grams: number): string {
   return `${Math.round(grams)}g`;
 }
 
-function StatusBadge({ status }: { status: string }) {
+type TFunction = (key: string, options?: Record<string, unknown>) => string;
+
+function StatusBadge({ status, t }: { status: string; t: TFunction }) {
   const colors = {
     active: 'bg-bambu-green/20 text-bambu-green',
     completed: 'bg-blue-500/20 text-blue-400',
@@ -67,9 +70,15 @@ function StatusBadge({ status }: { status: string }) {
   };
   const color = colors[status as keyof typeof colors] || colors.active;
 
+  const labels: Record<string, string> = {
+    active: t('projectDetail.status.active'),
+    completed: t('projectDetail.status.completed'),
+    archived: t('projectDetail.status.archived'),
+  };
+
   return (
     <span className={`px-2 py-1 rounded text-sm font-medium ${color}`}>
-      {status.charAt(0).toUpperCase() + status.slice(1)}
+      {labels[status] || status.charAt(0).toUpperCase() + status.slice(1)}
     </span>
   );
 }
@@ -107,12 +116,12 @@ function StatCard({
   );
 }
 
-function ArchiveGrid({ archives }: { archives: Archive[] }) {
+function ArchiveGrid({ archives, t }: { archives: Archive[]; t: TFunction }) {
   if (archives.length === 0) {
     return (
       <div className="text-center py-8 text-bambu-gray">
         <Package className="w-12 h-12 mx-auto mb-2 opacity-50" />
-        <p>No prints in this project yet</p>
+        <p>{t('projectDetail.noPrints')}</p>
       </div>
     );
   }
@@ -159,12 +168,12 @@ function ArchiveGrid({ archives }: { archives: Archive[] }) {
   );
 }
 
-function PriorityBadge({ priority }: { priority: string }) {
+function PriorityBadge({ priority, t }: { priority: string; t: TFunction }) {
   const config = {
-    low: { color: 'bg-gray-500/20 text-gray-400', label: 'Low' },
-    normal: { color: 'bg-blue-500/20 text-blue-400', label: 'Normal' },
-    high: { color: 'bg-orange-500/20 text-orange-400', label: 'High' },
-    urgent: { color: 'bg-red-500/20 text-red-400', label: 'Urgent' },
+    low: { color: 'bg-gray-500/20 text-gray-400', label: t('projectDetail.priority.low') },
+    normal: { color: 'bg-blue-500/20 text-blue-400', label: t('projectDetail.priority.normal') },
+    high: { color: 'bg-orange-500/20 text-orange-400', label: t('projectDetail.priority.high') },
+    urgent: { color: 'bg-red-500/20 text-red-400', label: t('projectDetail.priority.urgent') },
   };
   const { color, label } = config[priority as keyof typeof config] || config.normal;
 
@@ -181,22 +190,23 @@ function formatDate(dateString: string | null): string {
   return formatDateOnly(dateString, { year: 'numeric', month: 'short', day: 'numeric' });
 }
 
-function getDueDateStatus(dateString: string | null): { color: string; label: string } | null {
+function getDueDateStatus(dateString: string | null, t: TFunction): { color: string; label: string } | null {
   if (!dateString) return null;
   const dueDate = parseUTCDate(dateString);
   if (!dueDate) return null;
   const now = new Date();
   const diffDays = Math.ceil((dueDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
 
-  if (diffDays < 0) return { color: 'text-red-400', label: 'Overdue' };
-  if (diffDays === 0) return { color: 'text-orange-400', label: 'Due today' };
-  if (diffDays <= 3) return { color: 'text-yellow-400', label: `${diffDays} days left` };
-  return { color: 'text-bambu-gray', label: `${diffDays} days left` };
+  if (diffDays < 0) return { color: 'text-red-400', label: t('projectDetail.dueDate.overdue') };
+  if (diffDays === 0) return { color: 'text-orange-400', label: t('projectDetail.dueDate.today') };
+  if (diffDays <= 3) return { color: 'text-yellow-400', label: t('projectDetail.dueDate.daysLeft', { count: diffDays }) };
+  return { color: 'text-bambu-gray', label: t('projectDetail.dueDate.daysLeft', { count: diffDays }) };
 }
 
 export function ProjectDetailPage() {
   const { id } = useParams<{ id: string }>();
   const navigate = useNavigate();
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { hasPermission } = useAuth();
@@ -251,7 +261,7 @@ export function ProjectDetailPage() {
       queryClient.invalidateQueries({ queryKey: ['projects'] });
       setShowEditModal(false);
       setEditingNotes(false);
-      showToast('Project updated', 'success');
+      showToast(t('projectDetail.toast.projectUpdated'), 'success');
     },
     onError: (error: Error) => {
       showToast(error.message, 'error');
@@ -306,7 +316,7 @@ export function ProjectDetailPage() {
       setNewBomUrl('');
       setNewBomRemarks('');
       setShowBomForm(false);
-      showToast('Part added', 'success');
+      showToast(t('projectDetail.toast.partAdded'), 'success');
     },
     onError: (error: Error) => showToast(error.message, 'error'),
   });
@@ -327,7 +337,7 @@ export function ProjectDetailPage() {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['project-bom', projectId] });
       queryClient.invalidateQueries({ queryKey: ['project', projectId] });
-      showToast('Part removed', 'success');
+      showToast(t('projectDetail.toast.partRemoved'), 'success');
     },
     onError: (error: Error) => showToast(error.message, 'error'),
   });
@@ -355,8 +365,8 @@ export function ProjectDetailPage() {
   const handleDeleteBomItem = (itemId: number, itemName: string) => {
     setConfirmModal({
       isOpen: true,
-      title: 'Delete Part',
-      message: `Are you sure you want to delete "${itemName}"?`,
+      title: t('projectDetail.bom.deletePart'),
+      message: t('projectDetail.bom.deleteConfirm', { name: itemName }),
       onConfirm: () => {
         setConfirmModal(prev => ({ ...prev, isOpen: false }));
         deleteBomMutation.mutate(itemId);
@@ -397,7 +407,7 @@ export function ProjectDetailPage() {
       // Fetch ZIP file directly
       const response = await fetch(`/api/v1/projects/${projectId}/export`);
       if (!response.ok) {
-        throw new Error('Export failed');
+        throw new Error(t('projectDetail.toast.exportFailed'));
       }
       const blob = await response.blob();
       const url = URL.createObjectURL(blob);
@@ -409,7 +419,7 @@ export function ProjectDetailPage() {
       a.download = filenameMatch?.[1] || `${project?.name || 'project'}_${new Date().toISOString().split('T')[0]}.zip`;
       a.click();
       URL.revokeObjectURL(url);
-      showToast('Project exported', 'success');
+      showToast(t('projectDetail.toast.projectExported'), 'success');
     } catch (error) {
       showToast((error as Error).message, 'error');
     }
@@ -420,7 +430,7 @@ export function ProjectDetailPage() {
     mutationFn: () => api.createTemplateFromProject(projectId),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['projects'] });
-      showToast('Template created', 'success');
+      showToast(t('projectDetail.toast.templateCreated'), 'success');
     },
     onError: (error: Error) => showToast(error.message, 'error'),
   });
@@ -446,10 +456,10 @@ export function ProjectDetailPage() {
     return (
       <div className="text-center py-24">
         <p className="text-bambu-gray">
-          {projectError ? `Error: ${(projectError as Error).message}` : 'Project not found'}
+          {projectError ? `${t('common.error')}: ${(projectError as Error).message}` : t('projectDetail.notFound')}
         </p>
         <Button variant="secondary" className="mt-4" onClick={() => navigate('/projects')}>
-          Back to Projects
+          {t('projectDetail.backToProjects')}
         </Button>
       </div>
     );
@@ -466,7 +476,7 @@ export function ProjectDetailPage() {
       {/* Breadcrumb */}
       <div className="flex items-center gap-2 text-sm text-bambu-gray">
         <Link to="/projects" className="hover:text-white transition-colors">
-          Projects
+          {t('navigation.projects')}
         </Link>
         <ChevronRight className="w-4 h-4" />
         <span className="text-white">{project.name}</span>
@@ -493,25 +503,25 @@ export function ProjectDetailPage() {
               )}
             </div>
           </div>
-          <StatusBadge status={project.status} />
+          <StatusBadge status={project.status} t={t} />
         </div>
         <div className="flex gap-2">
           <Button
             variant="secondary"
             onClick={handleExportProject}
             disabled={!hasPermission('projects:read')}
-            title={!hasPermission('projects:read') ? 'You do not have permission to export projects' : 'Export project'}
+            title={!hasPermission('projects:read') ? t('projectDetail.noExportPermission') : t('projectDetail.exportProject')}
           >
             <Download className="w-4 h-4 mr-2" />
-            Export
+            {t('projectDetail.export')}
           </Button>
           <Button
             onClick={() => setShowEditModal(true)}
             disabled={!hasPermission('projects:update')}
-            title={!hasPermission('projects:update') ? 'You do not have permission to edit projects' : undefined}
+            title={!hasPermission('projects:update') ? t('projectDetail.noEditPermission') : undefined}
           >
             <Edit3 className="w-4 h-4 mr-2" />
-            Edit
+            {t('common.edit')}
           </Button>
         </div>
       </div>
@@ -524,9 +534,9 @@ export function ProjectDetailPage() {
             {project.target_count && (
               <div>
                 <div className="flex items-center justify-between mb-2">
-                  <span className="text-sm text-bambu-gray">Plates Progress</span>
+                  <span className="text-sm text-bambu-gray">{t('projectDetail.progress.platesProgress')}</span>
                   <span className="text-sm font-medium text-white">
-                    {stats?.total_archives || 0} / {project.target_count} print jobs
+                    {stats?.total_archives || 0} / {project.target_count} {t('projectDetail.progress.printJobs')}
                   </span>
                 </div>
                 <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
@@ -540,11 +550,11 @@ export function ProjectDetailPage() {
                 </div>
                 <div className="flex justify-between mt-1">
                   <span className="text-xs text-bambu-gray/70">
-                    {platesProgressPercent.toFixed(0)}% complete
+                    {t('projectDetail.progress.percentComplete', { percent: platesProgressPercent.toFixed(0) })}
                   </span>
                   {stats?.remaining_prints != null && stats.remaining_prints > 0 && (
                     <span className="text-xs text-bambu-gray/70">
-                      {stats.remaining_prints} remaining
+                      {t('projectDetail.progress.remaining', { count: stats.remaining_prints })}
                     </span>
                   )}
                 </div>
@@ -554,9 +564,9 @@ export function ProjectDetailPage() {
             {project.target_parts_count && (
               <div>
                 <div className="flex items-center justify-between mb-2">
-                  <span className="text-sm text-bambu-gray">Parts Progress</span>
+                  <span className="text-sm text-bambu-gray">{t('projectDetail.progress.partsProgress')}</span>
                   <span className="text-sm font-medium text-white">
-                    {stats?.completed_prints || 0} / {project.target_parts_count} parts
+                    {stats?.completed_prints || 0} / {project.target_parts_count} {t('projectDetail.progress.parts')}
                   </span>
                 </div>
                 <div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
@@ -570,11 +580,11 @@ export function ProjectDetailPage() {
                 </div>
                 <div className="flex justify-between mt-1">
                   <span className="text-xs text-bambu-gray/70">
-                    {partsProgressPercent.toFixed(0)}% complete
+                    {t('projectDetail.progress.percentComplete', { percent: partsProgressPercent.toFixed(0) })}
                   </span>
                   {stats?.remaining_parts != null && stats.remaining_parts > 0 && (
                     <span className="text-xs text-bambu-gray/70">
-                      {stats.remaining_parts} remaining
+                      {t('projectDetail.progress.remaining', { count: stats.remaining_parts })}
                     </span>
                   )}
                 </div>
@@ -594,25 +604,25 @@ export function ProjectDetailPage() {
                   <Package className="w-5 h-5" />
                 </div>
                 <div>
-                  <p className="text-sm text-bambu-gray">Print Jobs</p>
-                  <p className="text-xl font-semibold text-white">{stats.total_archives} <span className="text-sm font-normal text-bambu-gray">total</span></p>
+                  <p className="text-sm text-bambu-gray">{t('projectDetail.stats.printJobs')}</p>
+                  <p className="text-xl font-semibold text-white">{stats.total_archives} <span className="text-sm font-normal text-bambu-gray">{t('projectDetail.stats.total')}</span></p>
                   {stats.failed_prints > 0 && (
-                    <p className="text-sm text-status-error">{stats.failed_prints} failed</p>
+                    <p className="text-sm text-status-error">{t('projectDetail.stats.failed', { count: stats.failed_prints })}</p>
                   )}
-                  <p className="text-sm text-bambu-gray">{stats.completed_prints} parts printed</p>
+                  <p className="text-sm text-bambu-gray">{t('projectDetail.stats.partsPrinted', { count: stats.completed_prints })}</p>
                 </div>
               </div>
             </CardContent>
           </Card>
           <StatCard
             icon={Clock}
-            label="Print Time"
+            label={t('projectDetail.stats.printTime')}
             value={formatDuration(stats.total_print_time_hours)}
             color="text-yellow-400"
           />
           <StatCard
             icon={Printer}
-            label="Filament Used"
+            label={t('projectDetail.stats.filamentUsed')}
             value={formatFilament(stats.total_filament_grams)}
             color="text-purple-400"
           />
@@ -624,18 +634,18 @@ export function ProjectDetailPage() {
         <Card>
           <CardContent className="p-4">
             <h2 className="text-lg font-semibold text-white mb-3">
-              Cost Tracking
+              {t('projectDetail.cost.title')}
             </h2>
             <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
               <div>
-                <p className="text-xs text-bambu-gray uppercase">Filament Cost</p>
+                <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.filamentCost')}</p>
                 <p className="text-lg font-semibold text-white">
                   {currency}{stats.estimated_cost.toFixed(2)}
                 </p>
               </div>
               {stats.total_energy_kwh > 0 && (
                 <div>
-                  <p className="text-xs text-bambu-gray uppercase">Energy</p>
+                  <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.energy')}</p>
                   <p className="text-lg font-semibold text-white">
                     {stats.total_energy_kwh.toFixed(2)} kWh
                     {stats.total_energy_cost > 0 && (
@@ -649,11 +659,11 @@ export function ProjectDetailPage() {
               {project.budget && (
                 <>
                   <div>
-                    <p className="text-xs text-bambu-gray uppercase">Budget</p>
+                    <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.budget')}</p>
                     <p className="text-lg font-semibold text-white">{currency}{project.budget.toFixed(2)}</p>
                   </div>
                   <div>
-                    <p className="text-xs text-bambu-gray uppercase">Remaining</p>
+                    <p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.remaining')}</p>
                     <p className={`text-lg font-semibold ${project.budget - stats.estimated_cost >= 0 ? 'text-bambu-green' : 'text-red-400'}`}>
                       {currency}{(project.budget - stats.estimated_cost).toFixed(2)}
                     </p>
@@ -671,7 +681,7 @@ export function ProjectDetailPage() {
           <CardContent className="p-4">
             <h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-3">
               <FolderTree className="w-5 h-5" />
-              Sub-projects ({project.children.length})
+              {t('projectDetail.subProjects.title', { count: project.children.length })}
             </h2>
             <div className="space-y-2">
               {project.children.map((child) => (
@@ -710,7 +720,7 @@ export function ProjectDetailPage() {
       {project.parent_id && project.parent_name && (
         <div className="flex items-center gap-2 text-sm">
           <Layers className="w-4 h-4 text-bambu-gray" />
-          <span className="text-bambu-gray">Part of:</span>
+          <span className="text-bambu-gray">{t('projectDetail.partOf')}</span>
           <Link
             to={`/projects/${project.parent_id}`}
             className="text-bambu-green hover:underline"
@@ -726,8 +736,8 @@ export function ProjectDetailPage() {
           {/* Priority */}
           {project.priority && project.priority !== 'normal' && (
             <div className="flex items-center gap-2">
-              <span className="text-xs text-bambu-gray uppercase">Priority:</span>
-              <PriorityBadge priority={project.priority} />
+              <span className="text-xs text-bambu-gray uppercase">{t('projectDetail.priorityLabel')}</span>
+              <PriorityBadge priority={project.priority} t={t} />
             </div>
           )}
 
@@ -736,9 +746,9 @@ export function ProjectDetailPage() {
             <div className="flex items-center gap-2">
               <Calendar className="w-4 h-4 text-bambu-gray" />
               <span className="text-sm text-white">{formatDate(project.due_date)}</span>
-              {getDueDateStatus(project.due_date) && (
-                <span className={`text-xs ${getDueDateStatus(project.due_date)!.color}`}>
-                  ({getDueDateStatus(project.due_date)!.label})
+              {getDueDateStatus(project.due_date, t) && (
+                <span className={`text-xs ${getDueDateStatus(project.due_date, t)!.color}`}>
+                  ({getDueDateStatus(project.due_date, t)!.label})
                 </span>
               )}
             </div>
@@ -769,7 +779,7 @@ export function ProjectDetailPage() {
           <div className="flex items-center justify-between mb-3">
             <h2 className="text-lg font-semibold text-white flex items-center gap-2">
               <FileText className="w-5 h-5" />
-              Notes
+              {t('projectDetail.notes.title')}
             </h2>
             {!editingNotes ? (
               <Button
@@ -777,10 +787,10 @@ export function ProjectDetailPage() {
                 size="sm"
                 onClick={handleStartEditNotes}
                 disabled={!hasPermission('projects:update')}
-                title={!hasPermission('projects:update') ? 'You do not have permission to edit notes' : undefined}
+                title={!hasPermission('projects:update') ? t('projectDetail.notes.noEditPermission') : undefined}
               >
                 <Edit3 className="w-4 h-4 mr-1" />
-                Edit
+                {t('common.edit')}
               </Button>
             ) : (
               <div className="flex gap-2">
@@ -791,7 +801,7 @@ export function ProjectDetailPage() {
                   disabled={updateMutation.isPending}
                 >
                   <X className="w-4 h-4 mr-1" />
-                  Cancel
+                  {t('common.cancel')}
                 </Button>
                 <Button
                   size="sm"
@@ -803,7 +813,7 @@ export function ProjectDetailPage() {
                   ) : (
                     <Save className="w-4 h-4 mr-1" />
                   )}
-                  Save
+                  {t('common.save')}
                 </Button>
               </div>
             )}
@@ -813,7 +823,7 @@ export function ProjectDetailPage() {
             <RichTextEditor
               content={notesContent}
               onChange={setNotesContent}
-              placeholder="Add notes about this project..."
+              placeholder={t('projectDetail.notes.placeholder')}
             />
           ) : project.notes ? (
             <div
@@ -822,7 +832,7 @@ export function ProjectDetailPage() {
             />
           ) : (
             <p className="text-bambu-gray/70 text-sm italic">
-              No notes yet. Click Edit to add notes.
+              {t('projectDetail.notes.empty')}
             </p>
           )}
         </CardContent>
@@ -834,15 +844,15 @@ export function ProjectDetailPage() {
           <div className="flex items-center justify-between mb-3">
             <h2 className="text-lg font-semibold text-white flex items-center gap-2">
               <FolderOpen className="w-5 h-5" />
-              Files
+              {t('projectDetail.files.title')}
             </h2>
           </div>
 
           <p className="text-xs text-bambu-gray mb-3">
             <Link to="/files" className="text-bambu-green hover:underline">
-              Link folders from the File Manager
+              {t('projectDetail.files.linkFolders')}
             </Link>
-            {' '}to this project for quick access.
+            {' '}{t('projectDetail.files.forQuickAccess')}
           </p>
 
           {linkedFolders && linkedFolders.length > 0 ? (
@@ -860,7 +870,7 @@ export function ProjectDetailPage() {
                         {folder.name}
                       </p>
                       <p className="text-xs text-bambu-gray">
-                        {folder.file_count} file{folder.file_count !== 1 ? 's' : ''}
+                        {t('projectDetail.files.fileCount', { count: folder.file_count })}
                       </p>
                     </div>
                   </div>
@@ -870,7 +880,7 @@ export function ProjectDetailPage() {
             </div>
           ) : (
             <p className="text-bambu-gray/70 text-sm italic">
-              No folders linked. Go to File Manager and link a folder to this project.
+              {t('projectDetail.files.empty')}
             </p>
           )}
         </CardContent>
@@ -882,10 +892,10 @@ export function ProjectDetailPage() {
           <div className="flex items-center justify-between mb-4">
             <h2 className="text-lg font-semibold text-white flex items-center gap-2">
               <ShoppingCart className="w-5 h-5" />
-              Bill of Materials
+              {t('projectDetail.bom.title')}
               {stats && stats.bom_total_items > 0 && (
                 <span className="text-sm font-normal text-bambu-gray">
-                  ({stats.bom_completed_items}/{stats.bom_total_items} acquired)
+                  ({t('projectDetail.bom.acquired', { completed: stats.bom_completed_items, total: stats.bom_total_items })})
                 </span>
               )}
             </h2>
@@ -899,7 +909,7 @@ export function ProjectDetailPage() {
                       : 'bg-bambu-dark text-bambu-gray hover:text-white'
                   }`}
                 >
-                  {hideBomCompleted ? 'Show all' : 'Hide done'}
+                  {hideBomCompleted ? t('projectDetail.bom.showAll') : t('projectDetail.bom.hideDone')}
                 </button>
               )}
               {!showBomForm && (
@@ -908,10 +918,10 @@ export function ProjectDetailPage() {
                   size="sm"
                   onClick={() => setShowBomForm(true)}
                   disabled={!hasPermission('projects:update')}
-                  title={!hasPermission('projects:update') ? 'You do not have permission to add parts' : undefined}
+                  title={!hasPermission('projects:update') ? t('projectDetail.bom.noAddPermission') : undefined}
                 >
                   <Plus className="w-4 h-4 mr-1" />
-                  Add Part
+                  {t('projectDetail.bom.addPart')}
                 </Button>
               )}
             </div>
@@ -926,7 +936,7 @@ export function ProjectDetailPage() {
                   value={newBomName}
                   onChange={(e) => setNewBomName(e.target.value)}
                   className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
-                  placeholder="Part name (e.g., M3x8 screws)"
+                  placeholder={t('projectDetail.bom.partNamePlaceholder')}
                   autoFocus
                 />
                 <div className="flex gap-2">
@@ -936,7 +946,7 @@ export function ProjectDetailPage() {
                     onChange={(e) => setNewBomQty(parseInt(e.target.value) || 1)}
                     className="w-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green"
                     min="1"
-                    placeholder="Qty"
+                    placeholder={t('projectDetail.bom.qty')}
                   />
                   <input
                     type="number"
@@ -944,7 +954,7 @@ export function ProjectDetailPage() {
                     value={newBomPrice}
                     onChange={(e) => setNewBomPrice(e.target.value)}
                     className="flex-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
-                    placeholder={`Price (${currency})`}
+                    placeholder={t('projectDetail.bom.price', { currency })}
                   />
                 </div>
               </div>
@@ -953,24 +963,24 @@ export function ProjectDetailPage() {
                 value={newBomUrl}
                 onChange={(e) => setNewBomUrl(e.target.value)}
                 className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
-                placeholder="Sourcing URL (optional)"
+                placeholder={t('projectDetail.bom.sourcingUrlPlaceholder')}
               />
               <input
                 type="text"
                 value={newBomRemarks}
                 onChange={(e) => setNewBomRemarks(e.target.value)}
                 className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
-                placeholder="Remarks (optional)"
+                placeholder={t('projectDetail.bom.remarksPlaceholder')}
               />
               <div className="flex justify-end gap-2">
                 <Button type="button" variant="secondary" size="sm" onClick={() => setShowBomForm(false)}>
-                  Cancel
+                  {t('common.cancel')}
                 </Button>
                 <Button type="submit" size="sm" disabled={!newBomName.trim() || createBomMutation.isPending}>
                   {createBomMutation.isPending ? (
                     <Loader2 className="w-4 h-4 animate-spin" />
                   ) : (
-                    'Add Part'
+                    t('projectDetail.bom.addPart')
                   )}
                 </Button>
               </div>
@@ -1001,7 +1011,7 @@ export function ProjectDetailPage() {
                           value={editBomName}
                           onChange={(e) => setEditBomName(e.target.value)}
                           className="bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
-                          placeholder="Part name"
+                          placeholder={t('projectDetail.bom.partName')}
                           autoFocus
                         />
                         <div className="flex gap-2">
@@ -1011,7 +1021,7 @@ export function ProjectDetailPage() {
                             onChange={(e) => setEditBomQty(parseInt(e.target.value) || 1)}
                             className="w-20 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white focus:outline-none focus:border-bambu-green"
                             min="1"
-                            placeholder="Qty"
+                            placeholder={t('projectDetail.bom.qty')}
                           />
                           <input
                             type="number"
@@ -1019,7 +1029,7 @@ export function ProjectDetailPage() {
                             value={editBomPrice}
                             onChange={(e) => setEditBomPrice(e.target.value)}
                             className="flex-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
-                            placeholder={`Price (${currency})`}
+                            placeholder={t('projectDetail.bom.price', { currency })}
                           />
                         </div>
                       </div>
@@ -1028,24 +1038,24 @@ export function ProjectDetailPage() {
                         value={editBomUrl}
                         onChange={(e) => setEditBomUrl(e.target.value)}
                         className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
-                        placeholder="Sourcing URL (optional)"
+                        placeholder={t('projectDetail.bom.sourcingUrlPlaceholder')}
                       />
                       <input
                         type="text"
                         value={editBomRemarks}
                         onChange={(e) => setEditBomRemarks(e.target.value)}
                         className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-3 py-2 text-sm text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
-                        placeholder="Remarks (optional)"
+                        placeholder={t('projectDetail.bom.remarksPlaceholder')}
                       />
                       <div className="flex justify-end gap-2">
                         <Button type="button" variant="secondary" size="sm" onClick={handleCancelBomEdit}>
-                          Cancel
+                          {t('common.cancel')}
                         </Button>
                         <Button type="submit" size="sm" disabled={!editBomName.trim() || updateBomMutation.isPending}>
                           {updateBomMutation.isPending ? (
                             <Loader2 className="w-4 h-4 animate-spin" />
                           ) : (
-                            'Save'
+                            t('common.save')
                           )}
                         </Button>
                       </div>
@@ -1056,7 +1066,7 @@ export function ProjectDetailPage() {
                       <button
                         onClick={() => hasPermission('projects:update') && handleToggleAcquired(item)}
                         disabled={updateBomMutation.isPending || !hasPermission('projects:update')}
-                        title={!hasPermission('projects:update') ? 'You do not have permission to update parts' : undefined}
+                        title={!hasPermission('projects:update') ? t('projectDetail.bom.noUpdatePermission') : undefined}
                         className={`w-5 h-5 mt-0.5 rounded border-2 flex items-center justify-center transition-colors flex-shrink-0 ${
                           item.is_complete
                             ? 'bg-status-ok border-status-ok text-white'
@@ -1091,7 +1101,7 @@ export function ProjectDetailPage() {
                                   ? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white'
                                   : 'text-bambu-gray/50 cursor-not-allowed'
                               }`}
-                              title={!hasPermission('projects:update') ? 'You do not have permission to edit parts' : 'Edit'}
+                              title={!hasPermission('projects:update') ? t('projectDetail.bom.noEditPermission') : t('common.edit')}
                             >
                               <Pencil className="w-4 h-4" />
                             </button>
@@ -1103,7 +1113,7 @@ export function ProjectDetailPage() {
                                   ? 'hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-red-400'
                                   : 'text-bambu-gray/50 cursor-not-allowed'
                               }`}
-                              title={!hasPermission('projects:update') ? 'You do not have permission to delete parts' : 'Delete'}
+                              title={!hasPermission('projects:update') ? t('projectDetail.bom.noDeletePermission') : t('common.delete')}
                             >
                               <Trash2 className="w-4 h-4" />
                             </button>
@@ -1144,7 +1154,7 @@ export function ProjectDetailPage() {
               {/* BOM Total */}
               {bomItems.some(item => item.unit_price !== null) && (
                 <div className="pt-2 mt-2 border-t border-bambu-dark-tertiary flex justify-between text-sm">
-                  <span className="text-bambu-gray">Total cost:</span>
+                  <span className="text-bambu-gray">{t('projectDetail.bom.totalCost')}</span>
                   <span className="text-white font-medium">
                     {currency}{bomItems.reduce((sum, item) => sum + (item.unit_price || 0) * item.quantity_needed, 0).toFixed(2)}
                   </span>
@@ -1153,7 +1163,7 @@ export function ProjectDetailPage() {
             </div>
           ) : (
             <p className="text-bambu-gray/70 text-sm italic">
-              No parts in the bill of materials. Add hardware, electronics, or other components to track what needs to be sourced.
+              {t('projectDetail.bom.empty')}
             </p>
           )}
         </CardContent>
@@ -1165,7 +1175,7 @@ export function ProjectDetailPage() {
           <div className="flex items-center justify-between mb-3">
             <h2 className="text-lg font-semibold text-white flex items-center gap-2">
               <History className="w-5 h-5" />
-              Activity Timeline
+              {t('projectDetail.timeline.title')}
             </h2>
           </div>
 
@@ -1201,7 +1211,7 @@ export function ProjectDetailPage() {
             </div>
           ) : (
             <p className="text-bambu-gray/70 text-sm italic">
-              No activity yet.
+              {t('projectDetail.timeline.empty')}
             </p>
           )}
         </CardContent>
@@ -1215,14 +1225,14 @@ export function ProjectDetailPage() {
             size="sm"
             onClick={() => createTemplateMutation.mutate()}
             disabled={createTemplateMutation.isPending || !hasPermission('projects:create')}
-            title={!hasPermission('projects:create') ? 'You do not have permission to create templates' : undefined}
+            title={!hasPermission('projects:create') ? t('projectDetail.template.noCreatePermission') : undefined}
           >
             {createTemplateMutation.isPending ? (
               <Loader2 className="w-4 h-4 animate-spin mr-2" />
             ) : (
               <Copy className="w-4 h-4 mr-2" />
             )}
-            Save as Template
+            {t('projectDetail.template.saveAsTemplate')}
           </Button>
         </div>
       )}
@@ -1234,24 +1244,24 @@ export function ProjectDetailPage() {
             <div className="flex items-center justify-between mb-3">
               <h2 className="text-lg font-semibold text-white flex items-center gap-2">
                 <ListTodo className="w-5 h-5" />
-                Queue
+                {t('projectDetail.queue.title')}
               </h2>
               <Link
                 to={`/queue?project=${projectId}`}
                 className="text-sm text-bambu-green hover:underline"
               >
-                View all
+                {t('projectDetail.queue.viewAll')}
               </Link>
             </div>
             <div className="flex items-center gap-4 text-sm">
               {stats.in_progress_prints > 0 && (
                 <span className="text-yellow-400">
-                  {stats.in_progress_prints} printing
+                  {t('projectDetail.queue.printing', { count: stats.in_progress_prints })}
                 </span>
               )}
               {stats.queued_prints > 0 && (
                 <span className="text-bambu-gray">
-                  {stats.queued_prints} queued
+                  {t('projectDetail.queue.queued', { count: stats.queued_prints })}
                 </span>
               )}
             </div>
@@ -1264,7 +1274,7 @@ export function ProjectDetailPage() {
         <div className="flex items-center justify-between mb-4">
           <h2 className="text-lg font-semibold text-white flex items-center gap-2">
             <Package className="w-5 h-5" />
-            Prints ({archives?.length || 0})
+            {t('projectDetail.prints.title', { count: archives?.length || 0 })}
           </h2>
         </div>
         {archivesLoading ? (
@@ -1272,13 +1282,14 @@ export function ProjectDetailPage() {
             <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
           </div>
         ) : (
-          <ArchiveGrid archives={archives || []} />
+          <ArchiveGrid archives={archives || []} t={t} />
         )}
       </div>
 
       {/* Edit Modal */}
       {showEditModal && (
         <ProjectModal
+          t={t}
           project={{
             ...project,
             archive_count: stats?.total_archives || 0,
@@ -1300,7 +1311,7 @@ export function ProjectDetailPage() {
         <ConfirmModal
           title={confirmModal.title}
           message={confirmModal.message}
-          confirmText="Delete"
+          confirmText={t('common.delete')}
           variant="danger"
           onConfirm={confirmModal.onConfirm}
           onCancel={() => setConfirmModal(prev => ({ ...prev, isOpen: false }))}

+ 89 - 81
frontend/src/pages/ProjectsPage.tsx

@@ -1,4 +1,5 @@
 import { useState, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
 import { useNavigate } from 'react-router-dom';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import {
@@ -38,14 +39,17 @@ const PROJECT_COLORS = [
   '#6b7280', // gray
 ];
 
+type TFunction = (key: string, options?: Record<string, unknown>) => string;
+
 interface ProjectModalProps {
   project?: ProjectListItem;
   onClose: () => void;
   onSave: (data: ProjectCreate | ProjectUpdate) => void;
   isLoading: boolean;
+  t: TFunction;
 }
 
-export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectModalProps) {
+export function ProjectModal({ project, onClose, onSave, isLoading, t }: ProjectModalProps) {
   const [name, setName] = useState(project?.name || '');
   const [description, setDescription] = useState(project?.description || '');
   const [color, setColor] = useState(project?.color || PROJECT_COLORS[0]);
@@ -76,41 +80,41 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
       <div className="bg-bambu-dark-secondary rounded-lg w-full max-w-md border border-bambu-dark-tertiary">
         <div className="p-4 border-b border-bambu-dark-tertiary">
           <h2 className="text-lg font-semibold text-white">
-            {project ? 'Edit Project' : 'New Project'}
+            {project ? t('projects.editProject') : t('projects.newProject')}
           </h2>
         </div>
 
         <form onSubmit={handleSubmit} className="p-4 space-y-4">
           <div>
             <label className="block text-sm font-medium text-white mb-1">
-              Name
+              {t('common.name')}
             </label>
             <input
               type="text"
               value={name}
               onChange={(e) => setName(e.target.value)}
               className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
-              placeholder="e.g., Voron 2.4 Build"
+              placeholder={t('projects.namePlaceholder')}
               required
             />
           </div>
 
           <div>
             <label className="block text-sm font-medium text-white mb-1">
-              Description
+              {t('common.description')}
             </label>
             <textarea
               value={description}
               onChange={(e) => setDescription(e.target.value)}
               className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green resize-none"
-              placeholder="Optional description..."
+              placeholder={t('projects.descriptionPlaceholder')}
               rows={2}
             />
           </div>
 
           <div>
             <label className="block text-sm font-medium text-white mb-1">
-              Color
+              {t('projects.color')}
             </label>
             <div className="flex gap-2 flex-wrap">
               {PROJECT_COLORS.map((c) => (
@@ -131,45 +135,45 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
           <div className="grid grid-cols-2 gap-4">
             <div>
               <label className="block text-sm font-medium text-white mb-1">
-                Target Plates
+                {t('projects.targetPlates')}
               </label>
               <input
                 type="number"
                 value={targetCount}
                 onChange={(e) => setTargetCount(e.target.value)}
                 className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
-                placeholder="e.g., 25"
+                placeholder={t('projects.targetPlatesPlaceholder')}
                 min="1"
               />
-              <p className="text-xs text-bambu-gray mt-1">Number of print jobs</p>
+              <p className="text-xs text-bambu-gray mt-1">{t('projects.targetPlatesHelp')}</p>
             </div>
             <div>
               <label className="block text-sm font-medium text-white mb-1">
-                Target Parts
+                {t('projects.targetParts')}
               </label>
               <input
                 type="number"
                 value={targetPartsCount}
                 onChange={(e) => setTargetPartsCount(e.target.value)}
                 className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
-                placeholder="e.g., 150"
+                placeholder={t('projects.targetPartsPlaceholder')}
                 min="1"
               />
-              <p className="text-xs text-bambu-gray mt-1">Total objects needed</p>
+              <p className="text-xs text-bambu-gray mt-1">{t('projects.targetPartsHelp')}</p>
             </div>
           </div>
 
           {/* Tags */}
           <div>
             <label className="block text-sm font-medium text-white mb-1">
-              Tags (comma-separated)
+              {t('projects.tagsLabel')}
             </label>
             <input
               type="text"
               value={tags}
               onChange={(e) => setTags(e.target.value)}
               className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
-              placeholder="e.g., voron, functional, gift"
+              placeholder={t('projects.tagsPlaceholder')}
             />
           </div>
 
@@ -177,7 +181,7 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
           <div className="grid grid-cols-2 gap-4">
             <div>
               <label className="block text-sm font-medium text-white mb-1">
-                Due Date
+                {t('projects.dueDate')}
               </label>
               <input
                 type="date"
@@ -188,17 +192,17 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
             </div>
             <div>
               <label className="block text-sm font-medium text-white mb-1">
-                Priority
+                {t('projects.priority')}
               </label>
               <select
                 value={priority}
                 onChange={(e) => setPriority(e.target.value)}
                 className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
               >
-                <option value="low">Low</option>
-                <option value="normal">Normal</option>
-                <option value="high">High</option>
-                <option value="urgent">Urgent</option>
+                <option value="low">{t('projects.priorityLow')}</option>
+                <option value="normal">{t('projects.priorityNormal')}</option>
+                <option value="high">{t('projects.priorityHigh')}</option>
+                <option value="urgent">{t('projects.priorityUrgent')}</option>
               </select>
             </div>
           </div>
@@ -206,31 +210,31 @@ export function ProjectModal({ project, onClose, onSave, isLoading }: ProjectMod
           {project && (
             <div>
               <label className="block text-sm font-medium text-white mb-1">
-                Status
+                {t('common.status')}
               </label>
               <select
                 value={status}
                 onChange={(e) => setStatus(e.target.value)}
                 className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
               >
-                <option value="active">Active</option>
-                <option value="completed">Completed</option>
-                <option value="archived">Archived</option>
+                <option value="active">{t('projects.statusActive')}</option>
+                <option value="completed">{t('projects.statusCompleted')}</option>
+                <option value="archived">{t('projects.statusArchived')}</option>
               </select>
             </div>
           )}
 
           <div className="flex justify-end gap-2 pt-2">
             <Button type="button" variant="secondary" onClick={onClose}>
-              Cancel
+              {t('common.cancel')}
             </Button>
             <Button type="submit" disabled={!name.trim() || isLoading}>
               {isLoading ? (
                 <Loader2 className="w-4 h-4 animate-spin" />
               ) : project ? (
-                'Save'
+                t('common.save')
               ) : (
-                'Create'
+                t('projects.create')
               )}
             </Button>
           </div>
@@ -246,9 +250,10 @@ interface ProjectCardProps {
   onEdit: () => void;
   onDelete: () => void;
   hasPermission: (permission: Permission) => boolean;
+  t: TFunction;
 }
 
-function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: ProjectCardProps) {
+function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission, t }: ProjectCardProps) {
   // Plates progress: archive_count / target_count
   const platesProgressPercent = project.target_count
     ? Math.round((project.archive_count / project.target_count) * 100)
@@ -300,7 +305,7 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
                       ? 'bg-bambu-green/20 text-bambu-green'
                       : 'bg-bambu-dark text-bambu-gray'
                   }`}>
-                    {project.completed_count}/{project.target_parts_count} parts
+                    {project.completed_count}/{project.target_parts_count} {t('projects.parts')}
                   </span>
                 ) : project.target_count ? (
                   <span className={`text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium ${
@@ -308,21 +313,21 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
                       ? 'bg-bambu-green/20 text-bambu-green'
                       : 'bg-bambu-dark text-bambu-gray'
                   }`}>
-                    {project.archive_count}/{project.target_count} plates
+                    {project.archive_count}/{project.target_count} {t('projects.plates')}
                   </span>
                 ) : project.completed_count > 0 ? (
                   <span className="text-xs px-2 py-0.5 rounded-full whitespace-nowrap font-medium bg-bambu-dark text-bambu-gray">
-                    {project.completed_count} parts
+                    {project.completed_count} {t('projects.parts')}
                   </span>
                 ) : null}
                 {isCompleted && (
                   <span className="text-xs bg-bambu-green/20 text-bambu-green px-2 py-0.5 rounded-full whitespace-nowrap">
-                    Done
+                    {t('projects.done')}
                   </span>
                 )}
                 {isArchived && (
                   <span className="text-xs bg-bambu-gray/20 text-bambu-gray px-2 py-0.5 rounded-full whitespace-nowrap">
-                    Archived
+                    {t('projects.statusArchived')}
                   </span>
                 )}
               </div>
@@ -396,10 +401,10 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
                     }`}
                     onClick={() => { if (hasPermission('projects:update')) { onEdit(); setShowActions(false); } }}
                     disabled={!hasPermission('projects:update')}
-                    title={!hasPermission('projects:update') ? 'You do not have permission to edit projects' : undefined}
+                    title={!hasPermission('projects:update') ? t('projects.noEditPermission') : undefined}
                   >
                     <Edit3 className="w-4 h-4" />
-                    Edit
+                    {t('common.edit')}
                   </button>
                   <button
                     className={`w-full px-3 py-2 text-left text-sm flex items-center gap-2 ${
@@ -407,10 +412,10 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
                     }`}
                     onClick={() => { if (hasPermission('projects:delete')) { onDelete(); setShowActions(false); } }}
                     disabled={!hasPermission('projects:delete')}
-                    title={!hasPermission('projects:delete') ? 'You do not have permission to delete projects' : undefined}
+                    title={!hasPermission('projects:delete') ? t('projects.noDeletePermission') : undefined}
                   >
                     <Trash2 className="w-4 h-4" />
-                    Delete
+                    {t('common.delete')}
                   </button>
                 </div>
               </>
@@ -426,7 +431,7 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
               {project.target_count && (
                 <div>
                   <div className="flex items-center justify-between text-xs mb-1">
-                    <span className="text-bambu-gray">Plates</span>
+                    <span className="text-bambu-gray">{t('projects.plates')}</span>
                     <span className={platesProgressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
                       {project.archive_count} / {project.target_count}
                     </span>
@@ -449,7 +454,7 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
               {project.target_parts_count && (
                 <div>
                   <div className="flex items-center justify-between text-xs mb-1">
-                    <span className="text-bambu-gray">Parts</span>
+                    <span className="text-bambu-gray">{t('projects.parts')}</span>
                     <span className={partsProgressPercent >= 100 ? 'text-bambu-green font-medium' : 'text-white'}>
                       {project.completed_count} / {project.target_parts_count}
                     </span>
@@ -471,7 +476,7 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
               {/* Failed count */}
               {project.failed_count > 0 && (
                 <div className="text-xs text-red-400">
-                  {project.failed_count} failed
+                  {project.failed_count} {t('projects.failed')}
                 </div>
               )}
             </div>
@@ -480,25 +485,25 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
               {project.completed_count > 0 && (
                 <div className="flex items-center gap-1.5 text-bambu-gray">
                   <Archive className="w-3.5 h-3.5" />
-                  <span>{project.completed_count} completed</span>
+                  <span>{project.completed_count} {t('projects.completed')}</span>
                 </div>
               )}
               {project.failed_count > 0 && (
                 <div className="flex items-center gap-1.5 text-red-400">
                   <AlertTriangle className="w-3.5 h-3.5" />
-                  <span>{project.failed_count} failed</span>
+                  <span>{project.failed_count} {t('projects.failed')}</span>
                 </div>
               )}
               {project.queue_count > 0 && (
                 <div className="flex items-center gap-1.5 text-blue-400">
                   <Clock className="w-3.5 h-3.5" />
-                  <span>{project.queue_count} in queue</span>
+                  <span>{project.queue_count} {t('projects.inQueue')}</span>
                 </div>
               )}
             </div>
           ) : (
             <div className="text-xs text-bambu-gray/60 italic">
-              No prints yet
+              {t('projects.noPrintsYet')}
             </div>
           )}
         </div>
@@ -534,7 +539,7 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
             </div>
             {project.archive_count > 4 && (
               <p className="text-xs text-bambu-gray mt-1.5 text-center">
-                +{project.archive_count - 4} more
+                {t('common.more', { count: project.archive_count - 4 })}
               </p>
             )}
           </div>
@@ -543,22 +548,22 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
         {/* Stats footer */}
         <div className="flex items-center justify-between pt-3 border-t border-bambu-dark-tertiary">
           <div className="flex items-center gap-4 text-xs text-bambu-gray">
-            <div className="flex items-center gap-1.5" title="Print jobs (plates)">
+            <div className="flex items-center gap-1.5" title={t('projects.printJobs')}>
               <Layers className="w-3.5 h-3.5 text-blue-400" />
-              <span>{project.archive_count} plates</span>
+              <span>{project.archive_count} {t('projects.plates')}</span>
             </div>
-            <div className="flex items-center gap-1.5" title="Parts printed">
+            <div className="flex items-center gap-1.5" title={t('projects.partsPrinted')}>
               <Package className="w-3.5 h-3.5 text-bambu-green" />
-              <span>{project.completed_count} parts</span>
+              <span>{project.completed_count} {t('projects.parts')}</span>
             </div>
             {project.failed_count > 0 && (
-              <div className="flex items-center gap-1.5 text-red-400" title="Failed parts">
+              <div className="flex items-center gap-1.5 text-red-400" title={t('projects.failedParts')}>
                 <AlertTriangle className="w-3.5 h-3.5" />
                 <span>{project.failed_count}</span>
               </div>
             )}
             {project.queue_count > 0 && (
-              <div className="flex items-center gap-1.5 text-yellow-400" title="In queue">
+              <div className="flex items-center gap-1.5 text-yellow-400" title={t('projects.inQueue')}>
                 <ListTodo className="w-3.5 h-3.5" />
                 <span>{project.queue_count}</span>
               </div>
@@ -572,6 +577,7 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission }: Proj
 }
 
 export function ProjectsPage() {
+  const { t } = useTranslation();
   const navigate = useNavigate();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
@@ -591,7 +597,7 @@ export function ProjectsPage() {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['projects'] });
       setShowModal(false);
-      showToast('Project created', 'success');
+      showToast(t('projects.toast.created'), 'success');
     },
     onError: (error: Error) => {
       showToast(error.message, 'error');
@@ -605,7 +611,7 @@ export function ProjectsPage() {
       queryClient.invalidateQueries({ queryKey: ['projects'] });
       setShowModal(false);
       setEditingProject(undefined);
-      showToast('Project updated', 'success');
+      showToast(t('projects.toast.updated'), 'success');
     },
     onError: (error: Error) => {
       showToast(error.message, 'error');
@@ -616,7 +622,7 @@ export function ProjectsPage() {
     mutationFn: (id: number) => api.deleteProject(id),
     onSuccess: () => {
       setDeleteConfirm(null);
-      showToast('Project deleted', 'success');
+      showToast(t('projects.toast.deleted'), 'success');
       // Reload to refresh the list (React Query cache invalidation not working reliably)
       setTimeout(() => window.location.reload(), 100);
     },
@@ -630,7 +636,7 @@ export function ProjectsPage() {
     mutationFn: (data: ProjectImport) => api.importProject(data),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['projects'] });
-      showToast('Project imported', 'success');
+      showToast(t('projects.toast.imported'), 'success');
     },
     onError: (error: Error) => {
       showToast(error.message, 'error');
@@ -656,7 +662,7 @@ export function ProjectsPage() {
       a.download = `bambuddy_projects_${new Date().toISOString().split('T')[0]}.json`;
       a.click();
       URL.revokeObjectURL(url);
-      showToast('Projects exported (metadata only)', 'success');
+      showToast(t('projects.toast.exported'), 'success');
     } catch (error) {
       showToast((error as Error).message, 'error');
     }
@@ -689,7 +695,7 @@ export function ProjectsPage() {
         }
 
         queryClient.invalidateQueries({ queryKey: ['projects'] });
-        showToast('Project imported', 'success');
+        showToast(t('projects.toast.imported'), 'success');
       } else {
         // JSON file: parse and handle bulk or single import
         const text = await file.text();
@@ -703,11 +709,11 @@ export function ProjectsPage() {
         }
 
         if (projectsToImport.length > 1) {
-          showToast(`${projectsToImport.length} projects imported`, 'success');
+          showToast(t('projects.toast.multipleImported', { count: projectsToImport.length }), 'success');
         }
       }
     } catch (error) {
-      showToast(`Import failed: ${(error as Error).message}`, 'error');
+      showToast(`${t('projects.toast.importFailed')}: ${(error as Error).message}`, 'error');
     }
 
     // Reset file input
@@ -767,10 +773,10 @@ export function ProjectsPage() {
             <div className="p-2.5 bg-bambu-green/10 rounded-xl">
               <FolderKanban className="w-6 h-6 text-bambu-green" />
             </div>
-            Projects
+            {t('projects.title')}
           </h1>
           <p className="text-sm text-bambu-gray mt-2 ml-14">
-            Organize and track your 3D printing projects
+            {t('projects.subtitle')}
           </p>
         </div>
         <div className="flex gap-2">
@@ -778,28 +784,28 @@ export function ProjectsPage() {
             variant="secondary"
             onClick={handleImportClick}
             disabled={!hasPermission('projects:create')}
-            title={!hasPermission('projects:create') ? 'You do not have permission to import projects' : 'Import project'}
+            title={!hasPermission('projects:create') ? t('projects.noImportPermission') : t('projects.importProject')}
           >
             <Upload className="w-4 h-4 mr-2" />
-            Import
+            {t('projects.import')}
           </Button>
           <Button
             variant="secondary"
             onClick={handleExportAll}
             disabled={!hasPermission('projects:read')}
-            title={!hasPermission('projects:read') ? 'You do not have permission to export projects' : 'Export all projects'}
+            title={!hasPermission('projects:read') ? t('projects.noExportPermission') : t('projects.exportAll')}
           >
             <Download className="w-4 h-4 mr-2" />
-            Export
+            {t('projects.export')}
           </Button>
           <Button
             onClick={() => setShowModal(true)}
             className="sm:w-auto w-full"
             disabled={!hasPermission('projects:create')}
-            title={!hasPermission('projects:create') ? 'You do not have permission to create projects' : undefined}
+            title={!hasPermission('projects:create') ? t('projects.noCreatePermission') : undefined}
           >
             <Plus className="w-4 h-4 mr-2" />
-            New Project
+            {t('projects.newProject')}
           </Button>
         </div>
       </div>
@@ -807,10 +813,10 @@ export function ProjectsPage() {
       {/* Filter tabs */}
       <div className="flex gap-1 p-1 bg-bambu-dark rounded-xl w-fit">
         {[
-          { key: 'active', label: 'Active', icon: Clock },
-          { key: 'completed', label: 'Completed', icon: CheckCircle2 },
-          { key: 'archived', label: 'Archived', icon: Archive },
-          { key: 'all', label: 'All', icon: FolderKanban },
+          { key: 'active', label: t('projects.statusActive'), icon: Clock },
+          { key: 'completed', label: t('projects.statusCompleted'), icon: CheckCircle2 },
+          { key: 'archived', label: t('projects.statusArchived'), icon: Archive },
+          { key: 'all', label: t('common.all'), icon: FolderKanban },
         ].map(({ key, label, icon: Icon }) => (
           <button
             key={key}
@@ -839,7 +845,7 @@ export function ProjectsPage() {
         <div className="flex items-center justify-center py-20">
           <div className="flex flex-col items-center gap-3">
             <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
-            <p className="text-sm text-bambu-gray">Loading projects...</p>
+            <p className="text-sm text-bambu-gray">{t('projects.loading')}</p>
           </div>
         </div>
       ) : projects?.length === 0 ? (
@@ -848,22 +854,22 @@ export function ProjectsPage() {
             <FolderKanban className="w-12 h-12 text-bambu-gray/50" />
           </div>
           <h3 className="text-lg font-medium text-white mb-2">
-            {statusFilter === 'all' ? 'No projects yet' : `No ${statusFilter} projects`}
+            {statusFilter === 'all' ? t('projects.noProjects') : t('projects.noProjectsFiltered', { status: statusFilter })}
           </h3>
           <p className="text-bambu-gray text-center max-w-md mb-6">
             {statusFilter === 'all'
-              ? 'Create your first project to start organizing related prints, tracking progress, and managing your builds.'
-              : `You don't have any ${statusFilter} projects. Projects will appear here when their status changes.`
+              ? t('projects.createFirst')
+              : t('projects.noProjectsFilteredHelp', { status: statusFilter })
             }
           </p>
           {statusFilter === 'all' && (
             <Button
               onClick={() => setShowModal(true)}
               disabled={!hasPermission('projects:create')}
-              title={!hasPermission('projects:create') ? 'You do not have permission to create projects' : undefined}
+              title={!hasPermission('projects:create') ? t('projects.noCreatePermission') : undefined}
             >
               <Plus className="w-4 h-4 mr-2" />
-              Create Your First Project
+              {t('projects.createFirstButton')}
             </Button>
           )}
         </div>
@@ -877,6 +883,7 @@ export function ProjectsPage() {
               onEdit={() => handleEdit(project)}
               onDelete={() => handleDeleteClick(project.id)}
               hasPermission={hasPermission}
+              t={t}
             />
           ))}
         </div>
@@ -885,9 +892,9 @@ export function ProjectsPage() {
       {/* Delete Confirmation Modal */}
       {deleteConfirm !== null && (
         <ConfirmModal
-          title="Delete Project"
-          message="Are you sure you want to delete this project? Archives and queue items will be unlinked but not deleted."
-          confirmText="Delete Project"
+          title={t('projects.deleteProject')}
+          message={t('projects.deleteConfirm')}
+          confirmText={t('projects.deleteProject')}
           variant="danger"
           onConfirm={handleDeleteConfirm}
           onCancel={() => setDeleteConfirm(null)}
@@ -904,6 +911,7 @@ export function ProjectsPage() {
           }}
           onSave={handleSave}
           isLoading={createMutation.isPending || updateMutation.isPending}
+          t={t}
         />
       )}
     </div>

+ 135 - 124
frontend/src/pages/QueuePage.tsx

@@ -1,4 +1,5 @@
 import { useState, useMemo, useEffect, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { Link } from 'react-router-dom';
 import {
@@ -64,39 +65,39 @@ function formatDuration(seconds: number | null | undefined): string {
   return `${minutes}m`;
 }
 
-function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat = 'system'): string {
-  if (!dateString) return 'ASAP';
+function formatRelativeTime(dateString: string | null, timeFormat: TimeFormat = 'system', t?: (key: string, options?: Record<string, unknown>) => string): string {
+  if (!dateString) return t?.('queue.time.asap') ?? 'ASAP';
   const date = parseUTCDate(dateString);
-  if (!date) return 'ASAP';
+  if (!date) return t?.('queue.time.asap') ?? 'ASAP';
   const now = new Date();
   const diff = date.getTime() - now.getTime();
 
-  if (diff < -60000) return 'Overdue';
-  if (diff < 0) return 'Now';
-  if (diff < 60000) return 'In less than a minute';
-  if (diff < 3600000) return `In ${Math.round(diff / 60000)} min`;
-  if (diff < 86400000) return `In ${Math.round(diff / 3600000)} hours`;
+  if (diff < -60000) return t?.('queue.time.overdue') ?? 'Overdue';
+  if (diff < 0) return t?.('queue.time.now') ?? 'Now';
+  if (diff < 60000) return t?.('queue.time.lessThanMinute') ?? 'In less than a minute';
+  if (diff < 3600000) return t?.('queue.time.inMinutes', { count: Math.round(diff / 60000) }) ?? `In ${Math.round(diff / 60000)} min`;
+  if (diff < 86400000) return t?.('queue.time.inHours', { count: Math.round(diff / 3600000) }) ?? `In ${Math.round(diff / 3600000)} hours`;
   return formatDateTime(dateString, timeFormat);
 }
 
-function StatusBadge({ status, waitingReason }: { status: PrintQueueItem['status']; waitingReason?: string | null }) {
+function StatusBadge({ status, waitingReason, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; t: (key: string) => string }) {
   // Special case: pending with waiting_reason shows as "Waiting"
   if (status === 'pending' && waitingReason) {
     return (
       <span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border text-purple-400 bg-purple-400/10 border-purple-400/20">
         <Clock className="w-3.5 h-3.5" />
-        Waiting
+        {t('queue.status.waiting')}
       </span>
     );
   }
 
   const config = {
-    pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: 'Pending' },
-    printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: 'Printing' },
-    completed: { icon: CheckCircle, color: 'text-status-ok bg-status-ok/10 border-status-ok/20', label: 'Completed' },
-    failed: { icon: XCircle, color: 'text-status-error bg-status-error/10 border-status-error/20', label: 'Failed' },
-    skipped: { icon: SkipForward, color: 'text-orange-400 bg-orange-400/10 border-orange-400/20', label: 'Skipped' },
-    cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10 border-gray-400/20', label: 'Cancelled' },
+    pending: { icon: Clock, color: 'text-status-warning bg-status-warning/10 border-status-warning/20', label: t('queue.status.pending') },
+    printing: { icon: Play, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20', label: t('queue.status.printing') },
+    completed: { icon: CheckCircle, color: 'text-status-ok bg-status-ok/10 border-status-ok/20', label: t('queue.status.completed') },
+    failed: { icon: XCircle, color: 'text-status-error bg-status-error/10 border-status-error/20', label: t('queue.status.failed') },
+    skipped: { icon: SkipForward, color: 'text-orange-400 bg-orange-400/10 border-orange-400/20', label: t('queue.status.skipped') },
+    cancelled: { icon: X, color: 'text-gray-400 bg-gray-400/10 border-gray-400/20', label: t('queue.status.cancelled') },
   };
 
   const { icon: Icon, color, label } = config[status];
@@ -116,12 +117,14 @@ function BulkEditModal({
   onSave,
   onClose,
   isSaving,
+  t,
 }: {
   selectedCount: number;
   printers: { id: number; name: string }[];
   onSave: (data: Partial<PrintQueueBulkUpdate>) => void;
   onClose: () => void;
   isSaving: boolean;
+  t: (key: string, options?: Record<string, unknown>) => string;
 }) {
   const [printerId, setPrinterId] = useState<number | null | 'unchanged'>('unchanged');
   const [manualStart, setManualStart] = useState<boolean | 'unchanged'>('unchanged');
@@ -158,7 +161,7 @@ function BulkEditModal({
       <div className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg max-h-[90vh] overflow-y-auto">
         <div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
           <h2 className="text-lg font-semibold text-white">
-            Edit {selectedCount} Item{selectedCount !== 1 ? 's' : ''}
+            {t('queue.bulkEdit.title', { count: selectedCount })}
           </h2>
           <button onClick={onClose} className="p-1 hover:bg-bambu-dark rounded">
             <X className="w-5 h-5 text-bambu-gray" />
@@ -167,12 +170,12 @@ function BulkEditModal({
 
         <div className="p-4 space-y-4">
           <p className="text-sm text-bambu-gray">
-            Only changed settings will be applied to selected items.
+            {t('queue.bulkEdit.description')}
           </p>
 
           {/* Printer Assignment */}
           <div>
-            <label className="block text-sm font-medium text-white mb-2">Printer</label>
+            <label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.printer')}</label>
             <select
               value={printerId === null ? 'null' : printerId === 'unchanged' ? 'unchanged' : String(printerId)}
               onChange={(e) => {
@@ -183,8 +186,8 @@ function BulkEditModal({
               }}
               className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
             >
-              <option value="unchanged">— No change —</option>
-              <option value="null">Unassigned</option>
+              <option value="unchanged">{t('queue.bulkEdit.noChange')}</option>
+              <option value="null">{t('queue.filter.unassigned')}</option>
               {printers.map(p => (
                 <option key={p.id} value={p.id}>{p.name}</option>
               ))}
@@ -193,35 +196,35 @@ function BulkEditModal({
 
           {/* Queue Options */}
           <div>
-            <label className="block text-sm font-medium text-white mb-2">Queue Options</label>
+            <label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.queueOptions')}</label>
             <div className="space-y-2">
-              <TriStateToggle label="Staged (manual start)" value={manualStart} onChange={setManualStart} />
-              <TriStateToggle label="Auto power off after print" value={autoOffAfter} onChange={setAutoOffAfter} />
-              <TriStateToggle label="Require previous success" value={requirePreviousSuccess} onChange={setRequirePreviousSuccess} />
+              <TriStateToggle label={t('queue.bulkEdit.staged')} value={manualStart} onChange={setManualStart} t={t} />
+              <TriStateToggle label={t('queue.bulkEdit.autoPowerOff')} value={autoOffAfter} onChange={setAutoOffAfter} t={t} />
+              <TriStateToggle label={t('queue.bulkEdit.requirePrevious')} value={requirePreviousSuccess} onChange={setRequirePreviousSuccess} t={t} />
             </div>
           </div>
 
           {/* Print Options */}
           <div>
-            <label className="block text-sm font-medium text-white mb-2">Print Options</label>
+            <label className="block text-sm font-medium text-white mb-2">{t('queue.bulkEdit.printOptions')}</label>
             <div className="space-y-2">
-              <TriStateToggle label="Bed levelling" value={bedLevelling} onChange={setBedLevelling} />
-              <TriStateToggle label="Flow calibration" value={flowCali} onChange={setFlowCali} />
-              <TriStateToggle label="Vibration calibration" value={vibrationCali} onChange={setVibrationCali} />
-              <TriStateToggle label="First layer inspection" value={layerInspect} onChange={setLayerInspect} />
-              <TriStateToggle label="Timelapse" value={timelapse} onChange={setTimelapse} />
-              <TriStateToggle label="Use AMS" value={useAms} onChange={setUseAms} />
+              <TriStateToggle label={t('queue.bulkEdit.bedLevelling')} value={bedLevelling} onChange={setBedLevelling} t={t} />
+              <TriStateToggle label={t('queue.bulkEdit.flowCalibration')} value={flowCali} onChange={setFlowCali} t={t} />
+              <TriStateToggle label={t('queue.bulkEdit.vibrationCalibration')} value={vibrationCali} onChange={setVibrationCali} t={t} />
+              <TriStateToggle label={t('queue.bulkEdit.layerInspection')} value={layerInspect} onChange={setLayerInspect} t={t} />
+              <TriStateToggle label={t('queue.bulkEdit.timelapse')} value={timelapse} onChange={setTimelapse} t={t} />
+              <TriStateToggle label={t('queue.bulkEdit.useAms')} value={useAms} onChange={setUseAms} t={t} />
             </div>
           </div>
         </div>
 
         <div className="flex justify-end gap-3 p-4 border-t border-bambu-dark-tertiary">
-          <Button variant="secondary" onClick={onClose}>Cancel</Button>
+          <Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
           <Button
             onClick={handleSave}
             disabled={!hasChanges || isSaving}
           >
-            {isSaving ? 'Saving...' : 'Apply Changes'}
+            {isSaving ? t('common.saving') : t('queue.bulkEdit.applyChanges')}
           </Button>
         </div>
       </div>
@@ -234,10 +237,12 @@ function TriStateToggle({
   label,
   value,
   onChange,
+  t,
 }: {
   label: string;
   value: boolean | 'unchanged';
   onChange: (val: boolean | 'unchanged') => void;
+  t: (key: string) => string;
 }) {
   return (
     <div className="flex items-center justify-between py-1">
@@ -253,13 +258,13 @@ function TriStateToggle({
           onClick={() => onChange(false)}
           className={`px-2 py-1 text-xs rounded ${value === false ? 'bg-red-500/20 text-red-400' : 'text-bambu-gray hover:text-white'}`}
         >
-          Off
+          {t('common.off')}
         </button>
         <button
           onClick={() => onChange(true)}
           className={`px-2 py-1 text-xs rounded ${value === true ? 'bg-bambu-green/20 text-bambu-green' : 'text-bambu-gray hover:text-white'}`}
         >
-          On
+          {t('common.on')}
         </button>
       </div>
     </div>
@@ -281,6 +286,7 @@ function SortableQueueItem({
   onToggleSelect,
   hasPermission,
   canModify,
+  t,
 }: {
   item: PrintQueueItem;
   position?: number;
@@ -295,6 +301,7 @@ function SortableQueueItem({
   onToggleSelect?: () => void;
   hasPermission: (permission: Permission) => boolean;
   canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean;
+  t: (key: string, options?: Record<string, unknown>) => string;
 }) {
   const canReorder = hasPermission('queue:reorder');
   const {
@@ -400,7 +407,7 @@ function SortableQueueItem({
               <Link
                 to={`/archives?highlight=${item.archive_id}`}
                 className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
-                title="View archive"
+                title={t('queue.viewArchive')}
               >
                 <ExternalLink className="w-3.5 h-3.5" />
               </Link>
@@ -408,7 +415,7 @@ function SortableQueueItem({
               <Link
                 to={`/library?highlight=${item.library_file_id}`}
                 className="text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
-                title="View in File Manager"
+                title={t('queue.viewInFileManager')}
               >
                 <ExternalLink className="w-3.5 h-3.5" />
               </Link>
@@ -419,10 +426,10 @@ function SortableQueueItem({
             <span className={`flex items-center gap-1.5 ${item.printer_id === null && !item.target_model ? 'text-orange-400' : ''} ${item.target_model ? 'text-blue-400' : ''}`}>
               <Printer className="w-3.5 h-3.5" />
               {item.target_model
-                ? `Any ${item.target_model}${item.target_location ? ` @ ${item.target_location}` : ''}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
+                ? `${t('queue.filter.any')} ${item.target_model}${item.target_location ? ` @ ${item.target_location}` : ''}${item.required_filament_types?.length ? ` (${item.required_filament_types.join(', ')})` : ''}`
                 : item.printer_id === null
-                  ? 'Unassigned'
-                  : (item.printer_name || `Printer #${item.printer_id}`)}
+                  ? t('queue.filter.unassigned')
+                  : (item.printer_name || `${t('common.printer')} #${item.printer_id}`)}
             </span>
             {item.print_time_seconds && (
               <span className="flex items-center gap-1.5">
@@ -431,7 +438,7 @@ function SortableQueueItem({
               </span>
             )}
             {item.created_by_username && (
-              <span className="flex items-center gap-1.5" title={`Added by ${item.created_by_username}`}>
+              <span className="flex items-center gap-1.5" title={t('queue.addedBy', { name: item.created_by_username })}>
                 <User className="w-3.5 h-3.5" />
                 {item.created_by_username}
               </span>
@@ -439,7 +446,7 @@ function SortableQueueItem({
             {isPending && !item.manual_start && (
               <span className="flex items-center gap-1.5">
                 <Clock className="w-3.5 h-3.5" />
-                {formatRelativeTime(item.scheduled_time, timeFormat)}
+                {formatRelativeTime(item.scheduled_time, timeFormat, t)}
               </span>
             )}
           </div>
@@ -449,18 +456,18 @@ function SortableQueueItem({
             {item.manual_start && (
               <span className="text-xs px-2 py-0.5 bg-purple-500/10 text-purple-400 rounded-full border border-purple-500/20 flex items-center gap-1">
                 <Hand className="w-3 h-3" />
-                Staged
+                {t('queue.badges.staged')}
               </span>
             )}
             {item.require_previous_success && (
               <span className="text-xs px-2 py-0.5 bg-orange-500/10 text-orange-400 rounded-full border border-orange-500/20">
-                Requires previous success
+                {t('queue.badges.requiresPrevious')}
               </span>
             )}
             {item.auto_off_after && (
               <span className="text-xs px-2 py-0.5 bg-blue-500/10 text-blue-400 rounded-full border border-blue-500/20 flex items-center gap-1">
                 <Power className="w-3 h-3" />
-                Auto power off
+                {t('queue.badges.autoPowerOff')}
               </span>
             )}
           </div>
@@ -471,7 +478,7 @@ function SortableQueueItem({
               <div className="h-2 bg-bambu-dark rounded-full overflow-hidden">
                 <div className="h-full bg-gradient-to-r from-blue-500 to-blue-400 animate-pulse w-full opacity-50" />
               </div>
-              <p className="text-xs text-bambu-gray mt-1">Printing in progress...</p>
+              <p className="text-xs text-bambu-gray mt-1">{t('queue.printingInProgress')}</p>
             </div>
           )}
 
@@ -493,7 +500,7 @@ function SortableQueueItem({
         </div>
 
         {/* Status badge */}
-        <StatusBadge status={item.status} waitingReason={item.waiting_reason} />
+        <StatusBadge status={item.status} waitingReason={item.waiting_reason} t={t} />
 
         {/* Actions */}
         <div className="flex items-center gap-1">
@@ -503,7 +510,7 @@ function SortableQueueItem({
               size="sm"
               onClick={onStop}
               disabled={!hasPermission('printers:control')}
-              title={!hasPermission('printers:control') ? 'You do not have permission to stop prints' : 'Stop Print'}
+              title={!hasPermission('printers:control') ? t('queue.permissions.noStopPrint') : t('queue.actions.stopPrint')}
               className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
             >
               <StopCircle className="w-4 h-4" />
@@ -517,7 +524,7 @@ function SortableQueueItem({
                   size="sm"
                   onClick={onStart}
                   disabled={!hasPermission('printers:control')}
-                  title={!hasPermission('printers:control') ? 'You do not have permission to start prints' : 'Start Print'}
+                  title={!hasPermission('printers:control') ? t('queue.permissions.noStartPrint') : t('queue.actions.startPrint')}
                   className="text-bambu-green hover:text-bambu-green-light hover:bg-bambu-green/10"
                 >
                   <Play className="w-4 h-4" />
@@ -528,7 +535,7 @@ function SortableQueueItem({
                 size="sm"
                 onClick={onEdit}
                 disabled={!canModify('queue', 'update', item.created_by_id)}
-                title={!canModify('queue', 'update', item.created_by_id) ? 'You do not have permission to edit this queue item' : 'Edit'}
+                title={!canModify('queue', 'update', item.created_by_id) ? t('queue.permissions.noEdit') : t('common.edit')}
               >
                 <Pencil className="w-4 h-4" />
               </Button>
@@ -537,7 +544,7 @@ function SortableQueueItem({
                 size="sm"
                 onClick={onCancel}
                 disabled={!canModify('queue', 'delete', item.created_by_id)}
-                title={!canModify('queue', 'delete', item.created_by_id) ? 'You do not have permission to cancel this queue item' : 'Cancel'}
+                title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noCancel') : t('common.cancel')}
                 className="text-red-400 hover:text-red-300 hover:bg-red-500/10"
               >
                 <X className="w-4 h-4" />
@@ -551,7 +558,7 @@ function SortableQueueItem({
                 size="sm"
                 onClick={onRequeue}
                 disabled={!hasPermission('queue:create')}
-                title={!hasPermission('queue:create') ? 'You do not have permission to re-queue items' : 'Re-queue'}
+                title={!hasPermission('queue:create') ? t('queue.permissions.noRequeue') : t('queue.actions.requeue')}
                 className="text-bambu-green hover:text-bambu-green/80 hover:bg-bambu-green/10"
               >
                 <RefreshCw className="w-4 h-4" />
@@ -561,7 +568,7 @@ function SortableQueueItem({
                 size="sm"
                 onClick={onRemove}
                 disabled={!canModify('queue', 'delete', item.created_by_id)}
-                title={!canModify('queue', 'delete', item.created_by_id) ? 'You do not have permission to remove this queue item' : 'Remove'}
+                title={!canModify('queue', 'delete', item.created_by_id) ? t('queue.permissions.noRemove') : t('common.remove')}
               >
                 <Trash2 className="w-4 h-4" />
               </Button>
@@ -574,6 +581,7 @@ function SortableQueueItem({
 }
 
 export function QueuePage() {
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { hasPermission, hasAnyPermission, canModify } = useAuth();
@@ -650,36 +658,36 @@ export function QueuePage() {
     mutationFn: (id: number) => api.cancelQueueItem(id),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['queue'] });
-      showToast('Queue item cancelled');
+      showToast(t('queue.toast.cancelled'));
     },
-    onError: () => showToast('Failed to cancel item', 'error'),
+    onError: () => showToast(t('queue.toast.cancelFailed'), 'error'),
   });
 
   const removeMutation = useMutation({
     mutationFn: (id: number) => api.removeFromQueue(id),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['queue'] });
-      showToast('Queue item removed');
+      showToast(t('queue.toast.removed'));
     },
-    onError: () => showToast('Failed to remove item', 'error'),
+    onError: () => showToast(t('queue.toast.removeFailed'), 'error'),
   });
 
   const stopMutation = useMutation({
     mutationFn: (id: number) => api.stopQueueItem(id),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['queue'] });
-      showToast('Print stopped');
+      showToast(t('queue.toast.stopped'));
     },
-    onError: () => showToast('Failed to stop print', 'error'),
+    onError: () => showToast(t('queue.toast.stopFailed'), 'error'),
   });
 
   const startMutation = useMutation({
     mutationFn: (id: number) => api.startQueueItem(id),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['queue'] });
-      showToast('Print released to queue');
+      showToast(t('queue.toast.released'));
     },
-    onError: () => showToast('Failed to start print', 'error'),
+    onError: () => showToast(t('queue.toast.startFailed'), 'error'),
   });
 
   const reorderMutation = useMutation({
@@ -687,7 +695,7 @@ export function QueuePage() {
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['queue'] });
     },
-    onError: () => showToast('Failed to reorder queue', 'error'),
+    onError: () => showToast(t('queue.toast.reorderFailed'), 'error'),
   });
 
   const clearHistoryMutation = useMutation({
@@ -702,9 +710,9 @@ export function QueuePage() {
     },
     onSuccess: (count) => {
       queryClient.invalidateQueries({ queryKey: ['queue'] });
-      showToast(`Cleared ${count} history item${count !== 1 ? 's' : ''}`);
+      showToast(t('queue.toast.historyCleared', { count }));
     },
-    onError: () => showToast('Failed to clear history', 'error'),
+    onError: () => showToast(t('queue.toast.clearHistoryFailed'), 'error'),
   });
 
   const bulkUpdateMutation = useMutation({
@@ -715,7 +723,7 @@ export function QueuePage() {
       setShowBulkEditModal(false);
       showToast(result.message);
     },
-    onError: () => showToast('Failed to update items', 'error'),
+    onError: () => showToast(t('queue.toast.updateFailed'), 'error'),
   });
 
   const bulkCancelMutation = useMutation({
@@ -728,9 +736,9 @@ export function QueuePage() {
     onSuccess: (count) => {
       queryClient.invalidateQueries({ queryKey: ['queue'] });
       setSelectedItems([]);
-      showToast(`Cancelled ${count} item${count !== 1 ? 's' : ''}`);
+      showToast(t('queue.toast.bulkCancelled', { count }));
     },
-    onError: () => showToast('Failed to cancel items', 'error'),
+    onError: () => showToast(t('queue.toast.bulkCancelFailed'), 'error'),
   });
 
   const handleToggleSelect = (id: number) => {
@@ -867,9 +875,9 @@ export function QueuePage() {
         <div>
           <h1 className="text-2xl font-bold text-white flex items-center gap-3">
             <ListOrdered className="w-7 h-7 text-bambu-green" />
-            Print Queue
+            {t('queue.title')}
           </h1>
-          <p className="text-bambu-gray mt-1">Schedule and manage your print jobs</p>
+          <p className="text-bambu-gray mt-1">{t('queue.subtitle')}</p>
         </div>
       </div>
 
@@ -883,7 +891,7 @@ export function QueuePage() {
               </div>
               <div>
                 <p className="text-2xl font-bold text-white">{activeItems.length}</p>
-                <p className="text-sm text-bambu-gray">Printing</p>
+                <p className="text-sm text-bambu-gray">{t('queue.summary.printing')}</p>
               </div>
             </div>
           </CardContent>
@@ -897,7 +905,7 @@ export function QueuePage() {
               </div>
               <div>
                 <p className="text-2xl font-bold text-white">{pendingItems.length}</p>
-                <p className="text-sm text-bambu-gray">Queued</p>
+                <p className="text-sm text-bambu-gray">{t('queue.summary.queued')}</p>
               </div>
             </div>
           </CardContent>
@@ -911,7 +919,7 @@ export function QueuePage() {
               </div>
               <div>
                 <p className="text-2xl font-bold text-white">{formatDuration(totalQueueTime)}</p>
-                <p className="text-sm text-bambu-gray">Total Queue Time</p>
+                <p className="text-sm text-bambu-gray">{t('queue.summary.totalTime')}</p>
               </div>
             </div>
           </CardContent>
@@ -925,7 +933,7 @@ export function QueuePage() {
               </div>
               <div>
                 <p className="text-2xl font-bold text-white">{historyItems.length}</p>
-                <p className="text-sm text-bambu-gray">History</p>
+                <p className="text-sm text-bambu-gray">{t('queue.summary.history')}</p>
               </div>
             </div>
           </CardContent>
@@ -944,8 +952,8 @@ export function QueuePage() {
             else setFilterPrinter(Number(val));
           }}
         >
-          <option value="">All Printers</option>
-          <option value="unassigned">Unassigned</option>
+          <option value="">{t('queue.filter.allPrinters')}</option>
+          <option value="unassigned">{t('queue.filter.unassigned')}</option>
           {printers?.map((p) => (
             <option key={p.id} value={p.id}>{p.name}</option>
           ))}
@@ -956,13 +964,13 @@ export function QueuePage() {
           value={filterStatus}
           onChange={(e) => setFilterStatus(e.target.value)}
         >
-          <option value="">All Status</option>
-          <option value="pending">Pending</option>
-          <option value="printing">Printing</option>
-          <option value="completed">Completed</option>
-          <option value="failed">Failed</option>
-          <option value="skipped">Skipped</option>
-          <option value="cancelled">Cancelled</option>
+          <option value="">{t('queue.filter.allStatus')}</option>
+          <option value="pending">{t('queue.status.pending')}</option>
+          <option value="printing">{t('queue.status.printing')}</option>
+          <option value="completed">{t('queue.status.completed')}</option>
+          <option value="failed">{t('queue.status.failed')}</option>
+          <option value="skipped">{t('queue.status.skipped')}</option>
+          <option value="cancelled">{t('queue.status.cancelled')}</option>
         </select>
 
         {uniqueLocations.length > 0 && (
@@ -971,7 +979,7 @@ export function QueuePage() {
             value={filterLocation}
             onChange={(e) => setFilterLocation(e.target.value)}
           >
-            <option value="">All Locations</option>
+            <option value="">{t('queue.filter.allLocations')}</option>
             {uniqueLocations.map((loc) => (
               <option key={loc} value={loc}>{loc}</option>
             ))}
@@ -986,23 +994,22 @@ export function QueuePage() {
             size="sm"
             onClick={() => setShowClearHistoryConfirm(true)}
             disabled={!hasPermission('queue:delete_all')}
-            title={!hasPermission('queue:delete_all') ? 'You do not have permission to clear all history' : undefined}
+            title={!hasPermission('queue:delete_all') ? t('queue.permissions.noClearHistory') : undefined}
           >
             <Trash2 className="w-4 h-4" />
-            Clear History
+            {t('queue.clearHistory')}
           </Button>
         )}
       </div>
 
       {isLoading ? (
-        <div className="text-center py-12 text-bambu-gray">Loading...</div>
+        <div className="text-center py-12 text-bambu-gray">{t('common.loading')}</div>
       ) : queue?.length === 0 ? (
         <Card className="p-12 text-center border-dashed">
           <Calendar className="w-16 h-16 text-bambu-gray mx-auto mb-4 opacity-50" />
-          <h3 className="text-xl font-medium text-white mb-2">No prints scheduled</h3>
+          <h3 className="text-xl font-medium text-white mb-2">{t('queue.empty.title')}</h3>
           <p className="text-bambu-gray max-w-md mx-auto">
-            Schedule a print from the Archives page using the "Schedule" option in the context menu,
-            or drag and drop files to get started.
+            {t('queue.empty.description')}
           </p>
         </Card>
       ) : (
@@ -1012,7 +1019,7 @@ export function QueuePage() {
             <div>
               <h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
                 <div className="w-2 h-2 rounded-full bg-blue-400 animate-pulse" />
-                Currently Printing
+                {t('queue.sections.currentlyPrinting')}
               </h2>
               <div className="space-y-3">
                 {activeItems.map((item) => (
@@ -1028,6 +1035,7 @@ export function QueuePage() {
                     timeFormat={timeFormat}
                     hasPermission={hasPermission}
                     canModify={canModify}
+                    t={t}
                   />
                 ))}
               </div>
@@ -1040,12 +1048,12 @@ export function QueuePage() {
               <div className="flex items-center justify-between mb-4">
                 <h2 className="text-lg font-semibold text-white flex items-center gap-2">
                   <Clock className="w-5 h-5 text-yellow-400" />
-                  Queued
+                  {t('queue.sections.queued')}
                   <span className="text-sm font-normal text-bambu-gray">
-                    ({pendingItems.length} item{pendingItems.length !== 1 ? 's' : ''})
+                    ({t('queue.itemCount', { count: pendingItems.length })})
                   </span>
-                  <span className="text-xs text-bambu-gray ml-2" title="Position only affects ASAP items. Scheduled items run at their set time.">
-                    Drag to reorder (ASAP only)
+                  <span className="text-xs text-bambu-gray ml-2" title={t('queue.reorderHint')}>
+                    {t('queue.dragToReorder')}
                   </span>
                 </h2>
                 <div className="flex items-center gap-2">
@@ -1054,16 +1062,16 @@ export function QueuePage() {
                     value={pendingSortBy}
                     onChange={(e) => setPendingSortBy(e.target.value as 'position' | 'name' | 'printer' | 'time')}
                   >
-                    <option value="position">Sort by Position</option>
-                    <option value="name">Sort by Name</option>
-                    <option value="printer">Sort by Printer</option>
-                    <option value="time">Sort by Schedule</option>
+                    <option value="position">{t('queue.sort.byPosition')}</option>
+                    <option value="name">{t('queue.sort.byName')}</option>
+                    <option value="printer">{t('queue.sort.byPrinter')}</option>
+                    <option value="time">{t('queue.sort.bySchedule')}</option>
                   </select>
                   <Button
                     variant="ghost"
                     size="sm"
                     onClick={() => setPendingSortAsc(!pendingSortAsc)}
-                    title={pendingSortAsc ? 'Ascending' : 'Descending'}
+                    title={pendingSortAsc ? t('common.ascending') : t('common.descending')}
                     className="px-2"
                   >
                     {pendingSortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
@@ -1084,12 +1092,12 @@ export function QueuePage() {
                   ) : (
                     <Square className="w-4 h-4" />
                   )}
-                  {selectedItems.length === pendingItems.length && pendingItems.length > 0 ? 'Deselect All' : 'Select All'}
+                  {selectedItems.length === pendingItems.length && pendingItems.length > 0 ? t('queue.bulkEdit.deselectAll') : t('queue.bulkEdit.selectAll')}
                 </Button>
                 {selectedItems.length > 0 && (
                   <>
                     <span className="text-sm text-bambu-gray">
-                      {selectedItems.length} selected
+                      {t('queue.bulkEdit.selected', { count: selectedItems.length })}
                     </span>
                     <div className="h-4 w-px bg-bambu-dark-tertiary" />
                     <Button
@@ -1098,10 +1106,10 @@ export function QueuePage() {
                       onClick={() => setShowBulkEditModal(true)}
                       className="flex items-center gap-2 text-bambu-green hover:text-bambu-green-light"
                       disabled={!hasAnyPermission('queue:update_own', 'queue:update_all')}
-                      title={!hasAnyPermission('queue:update_own', 'queue:update_all') ? 'You do not have permission to edit queue items' : undefined}
+                      title={!hasAnyPermission('queue:update_own', 'queue:update_all') ? t('queue.permissions.noEditItems') : undefined}
                     >
                       <Pencil className="w-4 h-4" />
-                      Edit Selected
+                      {t('queue.bulkEdit.editSelected')}
                     </Button>
                     <Button
                       variant="ghost"
@@ -1109,10 +1117,10 @@ export function QueuePage() {
                       onClick={() => bulkCancelMutation.mutate(selectedItems)}
                       className="flex items-center gap-2 text-red-400 hover:text-red-300"
                       disabled={bulkCancelMutation.isPending || !hasAnyPermission('queue:delete_own', 'queue:delete_all')}
-                      title={!hasAnyPermission('queue:delete_own', 'queue:delete_all') ? 'You do not have permission to cancel queue items' : undefined}
+                      title={!hasAnyPermission('queue:delete_own', 'queue:delete_all') ? t('queue.permissions.noCancelItems') : undefined}
                     >
                       <X className="w-4 h-4" />
-                      Cancel Selected
+                      {t('queue.bulkEdit.cancelSelected')}
                     </Button>
                   </>
                 )}
@@ -1144,6 +1152,7 @@ export function QueuePage() {
                         onToggleSelect={() => handleToggleSelect(item.id)}
                         hasPermission={hasPermission}
                         canModify={canModify}
+                        t={t}
                       />
                     ))}
                   </div>
@@ -1158,9 +1167,9 @@ export function QueuePage() {
               <div className="flex items-center justify-between mb-4">
                 <h2 className="text-lg font-semibold text-white flex items-center gap-2">
                   <CheckCircle className="w-5 h-5 text-bambu-gray" />
-                  History
+                  {t('queue.sections.history')}
                   <span className="text-sm font-normal text-bambu-gray">
-                    ({historyItems.length} item{historyItems.length !== 1 ? 's' : ''})
+                    ({t('queue.itemCount', { count: historyItems.length })})
                   </span>
                 </h2>
                 <div className="flex items-center gap-2">
@@ -1169,15 +1178,15 @@ export function QueuePage() {
                     value={historySortBy}
                     onChange={(e) => setHistorySortBy(e.target.value as 'date' | 'name' | 'printer')}
                   >
-                    <option value="date">Sort by Date</option>
-                    <option value="name">Sort by Name</option>
-                    <option value="printer">Sort by Printer</option>
+                    <option value="date">{t('queue.sort.byDate')}</option>
+                    <option value="name">{t('queue.sort.byName')}</option>
+                    <option value="printer">{t('queue.sort.byPrinter')}</option>
                   </select>
                   <Button
                     variant="ghost"
                     size="sm"
                     onClick={() => setHistorySortAsc(!historySortAsc)}
-                    title={historySortAsc ? 'Ascending (oldest first)' : 'Descending (newest first)'}
+                    title={historySortAsc ? t('queue.sort.ascendingOldest') : t('queue.sort.descendingNewest')}
                     className="px-2"
                   >
                     {historySortAsc ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />}
@@ -1199,6 +1208,7 @@ export function QueuePage() {
                     timeFormat={timeFormat}
                     hasPermission={hasPermission}
                     canModify={canModify}
+                    t={t}
                   />
                 ))}
               </div>
@@ -1234,21 +1244,21 @@ export function QueuePage() {
       {confirmAction && (
         <ConfirmModal
           title={
-            confirmAction.type === 'cancel' ? 'Cancel Scheduled Print' :
-            confirmAction.type === 'stop' ? 'Stop Print' :
-            'Remove from History'
+            confirmAction.type === 'cancel' ? t('queue.confirm.cancelTitle') :
+            confirmAction.type === 'stop' ? t('queue.confirm.stopTitle') :
+            t('queue.confirm.removeTitle')
           }
           message={
             confirmAction.type === 'cancel'
-              ? `Are you sure you want to cancel "${confirmAction.item.archive_name || confirmAction.item.library_file_name || 'this print'}"?`
+              ? t('queue.confirm.cancelMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisPrint') })
               : confirmAction.type === 'stop'
-              ? `Are you sure you want to stop the current print "${confirmAction.item.archive_name || confirmAction.item.library_file_name || 'this print'}"? This will cancel the print job on the printer.`
-              : `Are you sure you want to remove "${confirmAction.item.archive_name || confirmAction.item.library_file_name || 'this item'}" from the queue history?`
+              ? t('queue.confirm.stopMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisPrint') })
+              : t('queue.confirm.removeMessage', { name: confirmAction.item.archive_name || confirmAction.item.library_file_name || t('queue.confirm.thisItem') })
           }
           confirmText={
-            confirmAction.type === 'cancel' ? 'Cancel Print' :
-            confirmAction.type === 'stop' ? 'Stop Print' :
-            'Remove'
+            confirmAction.type === 'cancel' ? t('queue.confirm.cancelButton') :
+            confirmAction.type === 'stop' ? t('queue.confirm.stopButton') :
+            t('common.remove')
           }
           variant="danger"
           onConfirm={() => {
@@ -1268,9 +1278,9 @@ export function QueuePage() {
       {/* Clear History Confirm Modal */}
       {showClearHistoryConfirm && (
         <ConfirmModal
-          title="Clear History"
-          message={`Are you sure you want to remove all ${historyItems.length} item${historyItems.length !== 1 ? 's' : ''} from the history?`}
-          confirmText="Clear History"
+          title={t('queue.confirm.clearHistoryTitle')}
+          message={t('queue.confirm.clearHistoryMessage', { count: historyItems.length })}
+          confirmText={t('queue.clearHistory')}
           variant="danger"
           onConfirm={() => {
             clearHistoryMutation.mutate();
@@ -1292,6 +1302,7 @@ export function QueuePage() {
           }}
           onClose={() => setShowBulkEditModal(false)}
           isSaving={bulkUpdateMutation.isPending}
+          t={t}
         />
       )}
     </div>

File diff suppressed because it is too large
+ 139 - 138
frontend/src/pages/SettingsPage.tsx


+ 20 - 19
frontend/src/pages/SetupPage.tsx

@@ -1,6 +1,7 @@
 import { useState } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useMutation } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useTheme } from '../contexts/ThemeContext';
@@ -9,6 +10,7 @@ import { Info } from 'lucide-react';
 
 export function SetupPage() {
   const navigate = useNavigate();
+  const { t } = useTranslation();
   const { showToast } = useToast();
   const { mode } = useTheme();
   const { refreshAuth } = useAuth();
@@ -30,14 +32,14 @@ export function SetupPage() {
 
       if (data.auth_enabled) {
         if (data.admin_created) {
-          showToast('Authentication enabled and admin user created');
+          showToast(t('setup.toast.authEnabledAdminCreated'));
           navigate('/login');
         } else {
-          showToast('Authentication enabled using existing admin users');
+          showToast(t('setup.toast.authEnabledExistingAdmins'));
           navigate('/login');
         }
       } else {
-        showToast('Setup completed');
+        showToast(t('setup.toast.setupCompleted'));
         navigate('/');
       }
     },
@@ -54,15 +56,15 @@ export function SetupPage() {
       // If no credentials provided, backend will use existing admin users if they exist
       if (adminUsername || adminPassword) {
         if (!adminUsername || !adminPassword) {
-          showToast('Please enter both admin username and password, or leave both empty to use existing admin users', 'error');
+          showToast(t('setup.toast.enterBothCredentials'), 'error');
           return;
         }
         if (adminPassword !== confirmPassword) {
-          showToast('Passwords do not match', 'error');
+          showToast(t('setup.toast.passwordsDoNotMatch'), 'error');
           return;
         }
         if (adminPassword.length < 6) {
-          showToast('Password must be at least 6 characters', 'error');
+          showToast(t('setup.toast.passwordTooShort'), 'error');
           return;
         }
       }
@@ -83,10 +85,10 @@ export function SetupPage() {
             />
           </div>
           <h2 className="text-3xl font-bold text-white">
-            Bambuddy Setup
+            {t('setup.title')}
           </h2>
           <p className="mt-2 text-sm text-bambu-gray">
-            Configure authentication for your Bambuddy instance
+            {t('setup.subtitle')}
           </p>
         </div>
 
@@ -101,7 +103,7 @@ export function SetupPage() {
                 className="h-4 w-4 text-bambu-green focus:ring-bambu-green border-bambu-dark-tertiary rounded bg-bambu-dark-secondary"
               />
               <label htmlFor="auth-enabled" className="ml-3 block text-sm font-medium text-white">
-                Enable Authentication
+                {t('setup.enableAuth')}
               </label>
             </div>
 
@@ -111,10 +113,9 @@ export function SetupPage() {
                   <div className="flex items-start gap-2">
                     <Info className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
                     <div className="text-sm text-bambu-gray">
-                      <p className="text-white font-medium mb-1">Admin Account</p>
+                      <p className="text-white font-medium mb-1">{t('setup.adminAccount')}</p>
                       <p>
-                        If admin users already exist, authentication will be enabled using the existing admin accounts.
-                        Leave the fields below empty to use existing admins, or enter new credentials to create a new admin user.
+                        {t('setup.adminAccountDesc')}
                       </p>
                     </div>
                   </div>
@@ -122,7 +123,7 @@ export function SetupPage() {
 
                 <div>
                   <label htmlFor="admin-username" className="block text-sm font-medium text-white mb-2">
-                    Admin Username <span className="text-bambu-gray text-xs">(optional if admin users exist)</span>
+                    {t('setup.adminUsername')} <span className="text-bambu-gray text-xs">{t('setup.optionalIfAdminExists')}</span>
                   </label>
                   <input
                     id="admin-username"
@@ -130,14 +131,14 @@ export function SetupPage() {
                     value={adminUsername}
                     onChange={(e) => setAdminUsername(e.target.value)}
                     className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
-                    placeholder="Enter admin username (optional)"
+                    placeholder={t('setup.adminUsernamePlaceholder')}
                     autoComplete="username"
                   />
                 </div>
 
                 <div>
                   <label htmlFor="admin-password" className="block text-sm font-medium text-white mb-2">
-                    Admin Password <span className="text-bambu-gray text-xs">(optional if admin users exist)</span>
+                    {t('setup.adminPassword')} <span className="text-bambu-gray text-xs">{t('setup.optionalIfAdminExists')}</span>
                   </label>
                   <input
                     id="admin-password"
@@ -145,7 +146,7 @@ export function SetupPage() {
                     value={adminPassword}
                     onChange={(e) => setAdminPassword(e.target.value)}
                     className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
-                    placeholder="Enter admin password (optional)"
+                    placeholder={t('setup.adminPasswordPlaceholder')}
                     minLength={6}
                     autoComplete="new-password"
                   />
@@ -154,7 +155,7 @@ export function SetupPage() {
                 {adminPassword && (
                   <div>
                     <label htmlFor="confirm-password" className="block text-sm font-medium text-white mb-2">
-                      Confirm Password
+                      {t('setup.confirmPassword')}
                     </label>
                     <input
                       id="confirm-password"
@@ -162,7 +163,7 @@ export function SetupPage() {
                       value={confirmPassword}
                       onChange={(e) => setConfirmPassword(e.target.value)}
                       className="block w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
-                      placeholder="Confirm admin password"
+                      placeholder={t('setup.confirmPasswordPlaceholder')}
                       minLength={6}
                       autoComplete="new-password"
                     />
@@ -178,7 +179,7 @@ export function SetupPage() {
               disabled={setupMutation.isPending}
               className="w-full flex justify-center py-3 px-4 bg-bambu-green hover:bg-bambu-green-light text-white font-medium rounded-lg shadow-lg shadow-bambu-green/20 hover:shadow-bambu-green/30 focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:ring-offset-2 focus:ring-offset-bambu-dark-secondary transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-bambu-green"
             >
-              {setupMutation.isPending ? 'Setting up...' : 'Complete Setup'}
+              {setupMutation.isPending ? t('setup.settingUp') : t('setup.completeSetup')}
             </button>
           </div>
         </form>

+ 72 - 58
frontend/src/pages/StatsPage.tsx

@@ -1,5 +1,6 @@
 import { useQuery } from '@tanstack/react-query';
 import { useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
 import {
   Package,
   Clock,
@@ -30,6 +31,7 @@ import { Dashboard, type DashboardWidget } from '../components/Dashboard';
 function QuickStatsWidget({
   stats,
   currency,
+  t,
 }: {
   stats: {
     total_prints: number;
@@ -42,6 +44,7 @@ function QuickStatsWidget({
     total_energy_cost: number;
   } | undefined;
   currency: string;
+  t: (key: string) => string;
 }) {
   return (
     <div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
@@ -50,7 +53,7 @@ function QuickStatsWidget({
           <Package className="w-5 h-5" />
         </div>
         <div>
-          <p className="text-xs text-bambu-gray">Total Prints</p>
+          <p className="text-xs text-bambu-gray">{t('stats.totalPrints')}</p>
           <p className="text-xl font-bold text-white">{stats?.total_prints || 0}</p>
         </div>
       </div>
@@ -59,7 +62,7 @@ function QuickStatsWidget({
           <Clock className="w-5 h-5" />
         </div>
         <div>
-          <p className="text-xs text-bambu-gray">Print Time</p>
+          <p className="text-xs text-bambu-gray">{t('stats.printTime')}</p>
           <p className="text-xl font-bold text-white">{stats?.total_print_time_hours.toFixed(1) || 0}h</p>
         </div>
       </div>
@@ -68,7 +71,7 @@ function QuickStatsWidget({
           <Package className="w-5 h-5" />
         </div>
         <div>
-          <p className="text-xs text-bambu-gray">Filament Used</p>
+          <p className="text-xs text-bambu-gray">{t('stats.filamentUsed')}</p>
           <p className="text-xl font-bold text-white">{((stats?.total_filament_grams || 0) / 1000).toFixed(2)}kg</p>
         </div>
       </div>
@@ -77,7 +80,7 @@ function QuickStatsWidget({
           <DollarSign className="w-5 h-5" />
         </div>
         <div>
-          <p className="text-xs text-bambu-gray">Filament Cost</p>
+          <p className="text-xs text-bambu-gray">{t('stats.filamentCost')}</p>
           <p className="text-xl font-bold text-white">{currency} {stats?.total_cost.toFixed(2) || '0.00'}</p>
         </div>
       </div>
@@ -86,7 +89,7 @@ function QuickStatsWidget({
           <Zap className="w-5 h-5" />
         </div>
         <div>
-          <p className="text-xs text-bambu-gray">Energy Used</p>
+          <p className="text-xs text-bambu-gray">{t('stats.energyUsed')}</p>
           <p className="text-xl font-bold text-white">{stats?.total_energy_kwh.toFixed(2) || '0.00'} kWh</p>
         </div>
       </div>
@@ -95,7 +98,7 @@ function QuickStatsWidget({
           <DollarSign className="w-5 h-5" />
         </div>
         <div>
-          <p className="text-xs text-bambu-gray">Energy Cost</p>
+          <p className="text-xs text-bambu-gray">{t('stats.energyCost')}</p>
           <p className="text-xl font-bold text-white">{currency} {stats?.total_energy_cost.toFixed(2) || '0.00'}</p>
         </div>
       </div>
@@ -107,6 +110,7 @@ function SuccessRateWidget({
   stats,
   printerMap,
   size = 1,
+  t,
 }: {
   stats: {
     total_prints: number;
@@ -116,6 +120,7 @@ function SuccessRateWidget({
   } | undefined;
   printerMap: Map<string, string>;
   size?: 1 | 2 | 4;
+  t: (key: string) => string;
 }) {
   const successRate = stats?.total_prints
     ? Math.round((stats.successful_prints / stats.total_prints) * 100)
@@ -157,24 +162,24 @@ function SuccessRateWidget({
         <div className="space-y-2">
           <div className="flex items-center gap-2">
             <CheckCircle className="w-4 h-4 text-status-ok flex-shrink-0" />
-            <span className="text-sm text-bambu-gray">Successful:</span>
+            <span className="text-sm text-bambu-gray">{t('stats.successful')}</span>
             <span className="text-sm text-white font-medium">{stats?.successful_prints || 0}</span>
           </div>
           <div className="flex items-center gap-2">
             <XCircle className="w-4 h-4 text-status-error flex-shrink-0" />
-            <span className="text-sm text-bambu-gray">Failed:</span>
+            <span className="text-sm text-bambu-gray">{t('stats.failed')}</span>
             <span className="text-sm text-white font-medium">{stats?.failed_prints || 0}</span>
           </div>
         </div>
         {/* Show per-printer breakdown when expanded */}
         {size >= 2 && stats?.prints_by_printer && Object.keys(stats.prints_by_printer).length > 0 && (
           <div className="mt-4 pt-4 border-t border-bambu-dark-tertiary">
-            <p className="text-xs text-bambu-gray font-medium mb-2">Prints by Printer</p>
+            <p className="text-xs text-bambu-gray font-medium mb-2">{t('stats.printsByPrinter')}</p>
             <div className={`grid gap-x-6 gap-y-1 ${size === 4 ? 'grid-cols-3' : 'grid-cols-2'}`} style={{ width: 'fit-content' }}>
               {Object.entries(stats.prints_by_printer).map(([printerId, count]) => (
                 <div key={printerId} className="flex items-center gap-3 text-sm">
                   <span className="text-bambu-gray truncate max-w-[120px]">
-                    {printerMap.get(printerId) || `Printer ${printerId}`}
+                    {printerMap.get(printerId) || `${t('common.printer')} ${printerId}`}
                   </span>
                   <span className="text-white font-medium">{count}</span>
                 </div>
@@ -191,6 +196,7 @@ function TimeAccuracyWidget({
   stats,
   printerMap,
   size = 1,
+  t,
 }: {
   stats: {
     average_time_accuracy: number | null;
@@ -198,13 +204,14 @@ function TimeAccuracyWidget({
   } | undefined;
   printerMap: Map<string, string>;
   size?: 1 | 2 | 4;
+  t: (key: string) => string;
 }) {
   const accuracy = stats?.average_time_accuracy;
 
   if (accuracy === null || accuracy === undefined) {
     return (
       <div className="flex items-center justify-center h-full">
-        <p className="text-bambu-gray text-center py-4">No time accuracy data yet</p>
+        <p className="text-bambu-gray text-center py-4">{t('stats.noTimeAccuracyData')}</p>
       </div>
     );
   }
@@ -267,14 +274,14 @@ function TimeAccuracyWidget({
       <div className="flex-1 min-w-0">
         <div className="flex items-center gap-2 text-xs text-bambu-gray">
           <Target className="w-3 h-3 flex-shrink-0" />
-          <span>100% = perfect estimate</span>
+          <span>{t('stats.perfectEstimate')}</span>
         </div>
         {printerEntries.length > 0 && (
           <div className={`mt-2 ${size === 4 ? 'grid grid-cols-3 gap-x-6 gap-y-1' : size === 2 ? 'grid grid-cols-2 gap-x-6 gap-y-1' : 'space-y-1'}`} style={{ width: 'fit-content' }}>
             {printerEntries.map(([printerId, acc]) => (
               <div key={printerId} className="flex items-center gap-2 text-xs">
                 <span className="text-bambu-gray truncate max-w-[100px]">
-                  {printerMap.get(printerId) || `Printer ${printerId}`}
+                  {printerMap.get(printerId) || `${t('common.printer')} ${printerId}`}
                 </span>
                 <span className={`font-medium ${
                   acc >= 95 && acc <= 105 ? 'text-status-ok' :
@@ -294,15 +301,17 @@ function TimeAccuracyWidget({
 function FilamentTypesWidget({
   stats,
   size = 1,
+  t,
 }: {
   stats: {
     total_prints: number;
     prints_by_filament_type: Record<string, number>;
   } | undefined;
   size?: 1 | 2 | 4;
+  t: (key: string, options?: Record<string, unknown>) => string;
 }) {
   if (!stats?.prints_by_filament_type || Object.keys(stats.prints_by_filament_type).length === 0) {
-    return <p className="text-bambu-gray text-center py-4">No filament data available</p>;
+    return <p className="text-bambu-gray text-center py-4">{t('stats.noFilamentData')}</p>;
   }
 
   // Sort by print count descending
@@ -348,7 +357,7 @@ function FilamentTypesWidget({
           <div key={type}>
             <div className="flex justify-between text-sm mb-1">
               <span className="text-white">{type}</span>
-              <span className="text-bambu-gray">{count} prints</span>
+              <span className="text-bambu-gray">{count} {t('common.prints')}</span>
             </div>
             <div className="h-2 bg-bambu-dark rounded-full">
               <div
@@ -361,7 +370,7 @@ function FilamentTypesWidget({
       })}
       {hasMore && (
         <p className="text-xs text-bambu-gray text-center pt-1">
-          +{sortedEntries.length - maxEntries} more types
+          {t('common.more', { count: sortedEntries.length - maxEntries })}
         </p>
       )}
     </div>
@@ -383,12 +392,14 @@ function PrintActivityWidget({
 function PrintsByPrinterWidget({
   stats,
   printerMap,
+  t,
 }: {
   stats: { prints_by_printer: Record<string, number> } | undefined;
   printerMap: Map<string, string>;
+  t: (key: string) => string;
 }) {
   if (!stats?.prints_by_printer || Object.keys(stats.prints_by_printer).length === 0) {
-    return <p className="text-bambu-gray text-center py-4">No printer data available</p>;
+    return <p className="text-bambu-gray text-center py-4">{t('stats.noPrinterData')}</p>;
   }
 
   return (
@@ -400,9 +411,9 @@ function PrintsByPrinterWidget({
           </div>
           <div>
             <p className="text-white font-medium text-sm">
-              {printerMap.get(printerId) || `Printer ${printerId}`}
+              {printerMap.get(printerId) || `${t('common.printer')} ${printerId}`}
             </p>
-            <p className="text-xs text-bambu-gray">{count} prints</p>
+            <p className="text-xs text-bambu-gray">{count} {t('common.prints')}</p>
           </div>
         </div>
       ))}
@@ -413,17 +424,19 @@ function PrintsByPrinterWidget({
 function FilamentTrendsWidget({
   archives,
   currency,
+  t,
 }: {
   archives: Parameters<typeof FilamentTrends>[0]['archives'];
   currency: string;
+  t: (key: string) => string;
 }) {
   if (!archives || archives.length === 0) {
-    return <p className="text-bambu-gray text-center py-4">No print data available</p>;
+    return <p className="text-bambu-gray text-center py-4">{t('stats.noPrintData')}</p>;
   }
   return <FilamentTrends archives={archives} currency={currency} />;
 }
 
-function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
+function FailureAnalysisWidget({ size = 1, t }: { size?: 1 | 2 | 4; t: (key: string, options?: Record<string, unknown>) => string }) {
   const { data: analysis, isLoading } = useQuery({
     queryKey: ['failureAnalysis'],
     queryFn: () => api.getFailureAnalysis({ days: 30 }),
@@ -438,7 +451,7 @@ function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
   }
 
   if (!analysis || analysis.total_prints === 0) {
-    return <p className="text-bambu-gray text-center py-4">No print data in the last 30 days</p>;
+    return <p className="text-bambu-gray text-center py-4">{t('stats.noPrintDataLast30Days')}</p>;
   }
 
   // Show more reasons when expanded
@@ -458,7 +471,7 @@ function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
           </div>
         </div>
         <div className="text-sm text-bambu-gray mt-1">
-          {analysis.failed_prints} / {analysis.total_prints} prints failed
+          {t('stats.failedPrintsCount', { failed: analysis.failed_prints, total: analysis.total_prints })}
         </div>
         {/* Trend indicator */}
         {analysis.trend && analysis.trend.length >= 2 && (
@@ -470,7 +483,7 @@ function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
                   : 'text-status-error'
               }`} />
               <span className="text-bambu-gray">
-                Last week: {analysis.trend[analysis.trend.length - 1].failure_rate.toFixed(1)}%
+                {t('stats.lastWeekRate', { rate: analysis.trend[analysis.trend.length - 1].failure_rate.toFixed(1) })}
               </span>
             </div>
           </div>
@@ -481,13 +494,13 @@ function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
       {topReasons.length > 0 && (
         <div className={`flex-1 ${size >= 2 ? 'border-l border-bambu-dark-tertiary pl-8' : 'pt-2'}`}>
           <p className="text-xs text-bambu-gray font-medium mb-2">
-            {size >= 2 ? 'Failure Reasons' : 'Top Failure Reasons'}
+            {size >= 2 ? t('stats.failureReasons') : t('stats.topFailureReasons')}
           </p>
           <div className={`${size === 4 ? 'grid grid-cols-2 gap-x-6 gap-y-1' : 'space-y-1'}`}>
             {topReasons.map(([reason, count]) => (
               <div key={reason} className="flex items-center justify-between text-sm">
                 <span className={`text-white truncate ${size === 4 ? 'max-w-[200px]' : 'max-w-[160px]'}`}>
-                  {reason || 'Unknown'}
+                  {reason || t('common.unknown')}
                 </span>
                 <span className="text-bambu-gray ml-2">{count}</span>
               </div>
@@ -495,7 +508,7 @@ function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
           </div>
           {hasMore && (
             <p className="text-xs text-bambu-gray mt-2">
-              +{allReasons.length - maxReasons} more reasons
+              {t('common.more', { count: allReasons.length - maxReasons })}
             </p>
           )}
         </div>
@@ -505,6 +518,7 @@ function FailureAnalysisWidget({ size = 1 }: { size?: 1 | 2 | 4 }) {
 }
 
 export function StatsPage() {
+  const { t } = useTranslation();
   const { showToast } = useToast();
   const { hasPermission } = useAuth();
   const [isExporting, setIsExporting] = useState(false);
@@ -568,9 +582,9 @@ export function StatsPage() {
       a.download = filename;
       a.click();
       URL.revokeObjectURL(url);
-      showToast('Export downloaded');
+      showToast(t('stats.exportDownloaded'));
     } catch {
-      showToast('Export failed', 'error');
+      showToast(t('stats.exportFailed'), 'error');
     } finally {
       setIsExporting(false);
     }
@@ -581,9 +595,9 @@ export function StatsPage() {
     try {
       const result = await api.recalculateCosts();
       await refetchStats();
-      showToast(`Recalculated costs for ${result.updated} archives`);
+      showToast(t('stats.recalculatedCosts', { count: result.updated }));
     } catch {
-      showToast('Failed to recalculate costs', 'error');
+      showToast(t('stats.recalculateFailed'), 'error');
     } finally {
       setIsRecalculating(false);
     }
@@ -596,7 +610,7 @@ export function StatsPage() {
   if (isLoading) {
     return (
       <div className="p-4 md:p-8">
-        <div className="text-center py-12 text-bambu-gray">Loading statistics...</div>
+        <div className="text-center py-12 text-bambu-gray">{t('stats.loadingStats')}</div>
       </div>
     );
   }
@@ -607,50 +621,50 @@ export function StatsPage() {
   const widgets: DashboardWidget[] = [
     {
       id: 'quick-stats',
-      title: 'Quick Stats',
-      component: <QuickStatsWidget stats={stats} currency={currency} />,
+      title: t('stats.quickStats'),
+      component: <QuickStatsWidget stats={stats} currency={currency} t={t} />,
       defaultSize: 2,
     },
     {
       id: 'success-rate',
-      title: 'Success Rate',
-      component: (size) => <SuccessRateWidget stats={stats} printerMap={printerMap} size={size} />,
+      title: t('stats.successRate'),
+      component: (size) => <SuccessRateWidget stats={stats} printerMap={printerMap} size={size} t={t} />,
       defaultSize: 1,
     },
     {
       id: 'time-accuracy',
-      title: 'Time Accuracy',
-      component: (size) => <TimeAccuracyWidget stats={stats} printerMap={printerMap} size={size} />,
+      title: t('stats.timeAccuracy'),
+      component: (size) => <TimeAccuracyWidget stats={stats} printerMap={printerMap} size={size} t={t} />,
       defaultSize: 1,
     },
     {
       id: 'filament-types',
-      title: 'Filament Types',
-      component: (size) => <FilamentTypesWidget stats={stats} size={size} />,
+      title: t('stats.filamentTypes'),
+      component: (size) => <FilamentTypesWidget stats={stats} size={size} t={t} />,
       defaultSize: 1,
     },
     {
       id: 'failure-analysis',
-      title: 'Failure Analysis (30 days)',
-      component: (size) => <FailureAnalysisWidget size={size} />,
+      title: t('stats.failureAnalysis'),
+      component: (size) => <FailureAnalysisWidget size={size} t={t} />,
       defaultSize: 1,
     },
     {
       id: 'print-activity',
-      title: 'Print Activity',
+      title: t('stats.printActivity'),
       component: (size) => <PrintActivityWidget printDates={printDates} size={size} />,
       defaultSize: 2,
     },
     {
       id: 'prints-by-printer',
-      title: 'Prints by Printer',
-      component: <PrintsByPrinterWidget stats={stats} printerMap={printerMap} />,
+      title: t('stats.printsByPrinter'),
+      component: <PrintsByPrinterWidget stats={stats} printerMap={printerMap} t={t} />,
       defaultSize: 2,
     },
     {
       id: 'filament-trends',
-      title: 'Filament Usage Trends',
-      component: <FilamentTrendsWidget archives={archives || []} currency={currency} />,
+      title: t('stats.filamentTrends'),
+      component: <FilamentTrendsWidget archives={archives || []} currency={currency} t={t} />,
       defaultSize: 4,
     },
   ];
@@ -660,8 +674,8 @@ export function StatsPage() {
     <div className="p-4 md:p-8">
       <div className="flex items-center justify-between mb-6">
         <div>
-          <h1 className="text-2xl font-bold text-white">Dashboard</h1>
-          <p className="text-bambu-gray">Drag widgets to rearrange. Click the eye icon to hide.</p>
+          <h1 className="text-2xl font-bold text-white">{t('stats.title')}</h1>
+          <p className="text-bambu-gray">{t('stats.subtitle')}</p>
         </div>
         <div className="flex items-center gap-2">
           {/* Hidden widgets button - toggles panel in Dashboard */}
@@ -674,7 +688,7 @@ export function StatsPage() {
               }}
             >
               <Eye className="w-4 h-4" />
-              {hiddenCount} Hidden
+              {t('stats.hiddenCount', { count: hiddenCount })}
             </Button>
           )}
           {/* Reset Layout */}
@@ -683,27 +697,27 @@ export function StatsPage() {
             onClick={() => {
               localStorage.removeItem('bambusy-dashboard-layout');
               setDashboardKey(prev => prev + 1);
-              showToast('Layout reset');
+              showToast(t('stats.layoutReset'));
             }}
             disabled={!hasPermission('settings:update')}
-            title={!hasPermission('settings:update') ? 'You do not have permission to reset layout' : undefined}
+            title={!hasPermission('settings:update') ? t('stats.noPermissionResetLayout') : undefined}
           >
             <RotateCcw className="w-4 h-4" />
-            Reset Layout
+            {t('stats.resetLayout')}
           </Button>
           {/* Recalculate Costs */}
           <Button
             variant="secondary"
             onClick={handleRecalculateCosts}
             disabled={isRecalculating || !hasPermission('archives:update_all')}
-            title={!hasPermission('archives:update_all') ? 'You do not have permission to recalculate costs' : 'Recalculate all archive costs using current filament prices'}
+            title={!hasPermission('archives:update_all') ? t('stats.noPermissionRecalculate') : t('stats.recalculateCostsHint')}
           >
             {isRecalculating ? (
               <Loader2 className="w-4 h-4 animate-spin" />
             ) : (
               <Calculator className="w-4 h-4" />
             )}
-            Recalculate Costs
+            {t('stats.recalculateCosts')}
           </Button>
           {/* Export dropdown */}
           <div className="relative">
@@ -717,7 +731,7 @@ export function StatsPage() {
               ) : (
                 <FileSpreadsheet className="w-4 h-4" />
               )}
-              Export Stats
+              {t('stats.exportStats')}
             </Button>
             {showExportMenu && (
               <div className="absolute right-0 top-full mt-1 w-48 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg shadow-xl z-20">
@@ -726,14 +740,14 @@ export function StatsPage() {
                   onClick={() => handleExport('csv')}
                 >
                   <FileText className="w-4 h-4" />
-                  Export as CSV
+                  {t('stats.exportAsCsv')}
                 </button>
                 <button
                   className="w-full px-4 py-2 text-left text-white hover:bg-bambu-dark-tertiary transition-colors flex items-center gap-2 rounded-b-lg"
                   onClick={() => handleExport('xlsx')}
                 >
                   <FileSpreadsheet className="w-4 h-4" />
-                  Export as Excel
+                  {t('stats.exportAsExcel')}
                 </button>
               </div>
             )}

+ 22 - 18
frontend/src/pages/StreamOverlayPage.tsx

@@ -1,10 +1,13 @@
 import { useEffect, useMemo, useState } from 'react';
 import { useParams, useSearchParams } from 'react-router-dom';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { Layers, Clock, Timer, Printer } from 'lucide-react';
 import { api } from '../api/client';
 import type { PrinterStatus } from '../api/client';
 
+type TFunction = (key: string, options?: Record<string, unknown>) => string;
+
 type OverlaySize = 'small' | 'medium' | 'large';
 
 interface OverlayConfig {
@@ -49,7 +52,7 @@ function formatTime(seconds: number): string {
   return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
 }
 
-function formatETA(remainingMinutes: number): string {
+function formatETA(remainingMinutes: number, t: TFunction): string {
   const now = new Date();
   const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
   const today = new Date();
@@ -62,22 +65,22 @@ function formatETA(remainingMinutes: number): string {
   if (etaDay.getTime() === today.getTime()) {
     return timeStr;
   } else if (etaDay.getTime() === today.getTime() + 86400000) {
-    return `Tomorrow ${timeStr}`;
+    return `${t('streamOverlay.tomorrow')} ${timeStr}`;
   } else {
     return eta.toLocaleDateString([], { weekday: 'short' }) + ' ' + timeStr;
   }
 }
 
-function getStatusText(status: PrinterStatus): string {
+function getStatusText(status: PrinterStatus, t: TFunction): string {
   if (status.stg_cur_name) return status.stg_cur_name;
 
   switch (status.state) {
-    case 'RUNNING': return 'Printing';
-    case 'PAUSE': return 'Paused';
-    case 'FINISH': return 'Finished';
-    case 'FAILED': return 'Failed';
-    case 'IDLE': return 'Idle';
-    default: return status.state || 'Unknown';
+    case 'RUNNING': return t('streamOverlay.status.printing');
+    case 'PAUSE': return t('streamOverlay.status.paused');
+    case 'FINISH': return t('streamOverlay.status.finished');
+    case 'FAILED': return t('streamOverlay.status.failed');
+    case 'IDLE': return t('streamOverlay.status.idle');
+    default: return status.state || t('streamOverlay.status.unknown');
   }
 }
 
@@ -120,6 +123,7 @@ function getSizeClasses(size: OverlaySize) {
 export function StreamOverlayPage() {
   const { printerId } = useParams<{ printerId: string }>();
   const [searchParams] = useSearchParams();
+  const { t } = useTranslation();
   const queryClient = useQueryClient();
   const id = parseInt(printerId || '0', 10);
   const [imageKey, setImageKey] = useState(Date.now());
@@ -172,11 +176,11 @@ export function StreamOverlayPage() {
 
   // Update document title
   useEffect(() => {
-    document.title = printer ? `${printer.name} - Stream Overlay` : 'Stream Overlay';
+    document.title = printer ? `${printer.name} - ${t('streamOverlay.title')}` : t('streamOverlay.title');
     return () => {
       document.title = 'Bambuddy';
     };
-  }, [printer]);
+  }, [printer, t]);
 
   // Refresh stream on error
   const handleStreamError = () => {
@@ -188,7 +192,7 @@ export function StreamOverlayPage() {
   if (!id) {
     return (
       <div className="min-h-screen bg-black flex items-center justify-center">
-        <p className="text-white">Invalid printer ID</p>
+        <p className="text-white">{t('streamOverlay.invalidPrinterId')}</p>
       </div>
     );
   }
@@ -196,7 +200,7 @@ export function StreamOverlayPage() {
   if (!status) {
     return (
       <div className="min-h-screen bg-black flex items-center justify-center">
-        <p className="text-gray-400">Loading...</p>
+        <p className="text-gray-400">{t('common.loading')}</p>
       </div>
     );
   }
@@ -212,7 +216,7 @@ export function StreamOverlayPage() {
         <img
           key={imageKey}
           src={streamUrl}
-          alt="Camera stream"
+          alt={t('streamOverlay.cameraStream')}
           className="absolute inset-0 w-full h-full object-contain"
           onError={handleStreamError}
         />
@@ -253,7 +257,7 @@ export function StreamOverlayPage() {
           {/* Status text */}
           {config.showStatus && (
             <div className={`${sizes.text} text-white/70 mb-2`}>
-              {getStatusText(status)}
+              {getStatusText(status, t)}
             </div>
           )}
 
@@ -261,7 +265,7 @@ export function StreamOverlayPage() {
           {config.showProgress && isPrinting && (
             <div className="mb-3">
               <div className={`flex items-center justify-between mb-1 ${sizes.text}`}>
-                <span className="text-white/70">Progress</span>
+                <span className="text-white/70">{t('streamOverlay.progress')}</span>
                 <span className="text-white font-bold">{Math.round(progress)}%</span>
               </div>
               <div className={`w-full bg-white/20 rounded-full ${sizes.progressHeight}`}>
@@ -301,7 +305,7 @@ export function StreamOverlayPage() {
                   <div className={`flex items-center ${sizes.gap} text-white/70`}>
                     <Clock className={sizes.icon} />
                     <span className={`${sizes.text} text-white`}>
-                      ETA {formatETA(status.remaining_time)}
+                      {t('streamOverlay.eta')} {formatETA(status.remaining_time, t)}
                     </span>
                   </div>
                 </>
@@ -312,7 +316,7 @@ export function StreamOverlayPage() {
           {/* Idle state */}
           {!isPrinting && (
             <div className={`${sizes.text} text-white/70 py-2`}>
-              {status.connected ? 'Printer is idle' : 'Printer offline'}
+              {status.connected ? t('streamOverlay.printerIdle') : t('streamOverlay.printerOffline')}
             </div>
           )}
         </div>

+ 55 - 53
frontend/src/pages/UsersPage.tsx

@@ -1,6 +1,7 @@
 import { useState, useEffect } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
 import { X, Plus, Edit2, Trash2, Save, Loader2, Users as UsersIcon, Shield, ArrowLeft } from 'lucide-react';
 import { api } from '../api/client';
 import type { UserCreate, UserUpdate, UserResponse } from '../api/client';
@@ -17,6 +18,7 @@ interface FormData extends UserCreate {
 
 export function UsersPage() {
   const navigate = useNavigate();
+  const { t } = useTranslation();
   const { user: currentUser, hasPermission } = useAuth();
   const { showToast } = useToast();
   const queryClient = useQueryClient();
@@ -70,7 +72,7 @@ export function UsersPage() {
       queryClient.invalidateQueries({ queryKey: ['groups'] });
       setShowCreateModal(false);
       setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
-      showToast('User created successfully');
+      showToast(t('users.toast.created'));
     },
     onError: (error: Error) => {
       showToast(error.message, 'error');
@@ -85,7 +87,7 @@ export function UsersPage() {
       setShowEditModal(false);
       setEditingUserId(null);
       setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
-      showToast('User updated successfully');
+      showToast(t('users.toast.updated'));
     },
     onError: (error: Error) => {
       showToast(error.message, 'error');
@@ -96,7 +98,7 @@ export function UsersPage() {
     mutationFn: (id: number) => api.deleteUser(id),
     onSuccess: () => {
       queryClient.invalidateQueries({ queryKey: ['users'] });
-      showToast('User deleted successfully');
+      showToast(t('users.toast.deleted'));
     },
     onError: (error: Error) => {
       showToast(error.message, 'error');
@@ -105,15 +107,15 @@ export function UsersPage() {
 
   const handleCreate = () => {
     if (!formData.username || !formData.password) {
-      showToast('Please fill in all required fields', 'error');
+      showToast(t('users.toast.fillRequired'), 'error');
       return;
     }
     if (formData.password !== formData.confirmPassword) {
-      showToast('Passwords do not match', 'error');
+      showToast(t('users.toast.passwordsDoNotMatch'), 'error');
       return;
     }
     if (formData.password.length < 6) {
-      showToast('Password must be at least 6 characters', 'error');
+      showToast(t('users.toast.passwordTooShort'), 'error');
       return;
     }
     createMutation.mutate({
@@ -128,11 +130,11 @@ export function UsersPage() {
     // Validate password confirmation if a new password is being set
     if (formData.password) {
       if (formData.password !== formData.confirmPassword) {
-        showToast('Passwords do not match', 'error');
+        showToast(t('users.toast.passwordsDoNotMatch'), 'error');
         return;
       }
       if (formData.password.length < 6) {
-        showToast('Password must be at least 6 characters', 'error');
+        showToast(t('users.toast.passwordTooShort'), 'error');
         return;
       }
     }
@@ -187,7 +189,7 @@ export function UsersPage() {
           <CardContent className="py-6">
             <div className="flex items-center gap-3 text-red-400">
               <Shield className="w-5 h-5" />
-              <p className="text-white">You do not have permission to access this page.</p>
+              <p className="text-white">{t('users.noPermission')}</p>
             </div>
           </CardContent>
         </Card>
@@ -202,17 +204,17 @@ export function UsersPage() {
           <button
             onClick={() => navigate('/settings?tab=users')}
             className="p-2 rounded-lg bg-bambu-dark-secondary hover:bg-bambu-dark-tertiary text-bambu-gray hover:text-white transition-colors"
-            title="Back to Settings"
+            title={t('users.backToSettings')}
           >
             <ArrowLeft className="w-5 h-5" />
           </button>
           <div>
             <h1 className="text-2xl font-bold text-white flex items-center gap-2">
               <UsersIcon className="w-6 h-6 text-bambu-green" />
-              User Management
+              {t('users.title')}
             </h1>
             <p className="text-sm text-bambu-gray mt-1">
-              Manage users and their access to your Bambuddy instance
+              {t('users.subtitle')}
             </p>
           </div>
         </div>
@@ -223,7 +225,7 @@ export function UsersPage() {
           }}
         >
           <Plus className="w-4 h-4" />
-          Create User
+          {t('users.createUser')}
         </Button>
       </div>
 
@@ -238,16 +240,16 @@ export function UsersPage() {
               <thead>
                 <tr>
                   <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
-                    Username
+                    {t('users.table.username')}
                   </th>
                   <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
-                    Groups
+                    {t('users.table.groups')}
                   </th>
                   <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
-                    Status
+                    {t('users.table.status')}
                   </th>
                   <th className="px-6 py-3 text-left text-xs font-medium text-bambu-gray uppercase tracking-wider">
-                    Actions
+                    {t('users.table.actions')}
                   </th>
                 </tr>
               </thead>
@@ -261,7 +263,7 @@ export function UsersPage() {
                       <div className="flex flex-wrap gap-1">
                         {user.is_admin && (
                           <span className="px-2 py-0.5 rounded-full text-xs font-medium bg-purple-500/20 text-purple-300">
-                            Admin
+                            {t('users.admin')}
                           </span>
                         )}
                         {user.groups?.map(group => (
@@ -281,7 +283,7 @@ export function UsersPage() {
                           </span>
                         ))}
                         {(!user.groups || user.groups.length === 0) && !user.is_admin && (
-                          <span className="text-bambu-gray">No groups</span>
+                          <span className="text-bambu-gray">{t('users.noGroups')}</span>
                         )}
                       </div>
                     </td>
@@ -291,7 +293,7 @@ export function UsersPage() {
                           ? 'bg-bambu-green/20 text-bambu-green'
                           : 'bg-red-500/20 text-red-400'
                       }`}>
-                        {user.is_active ? 'Active' : 'Inactive'}
+                        {user.is_active ? t('users.active') : t('users.inactive')}
                       </span>
                     </td>
                     <td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
@@ -302,7 +304,7 @@ export function UsersPage() {
                           onClick={() => startEdit(user)}
                         >
                           <Edit2 className="w-4 h-4" />
-                          Edit
+                          {t('users.edit')}
                         </Button>
                         {user.id !== currentUser?.id && (
                           <Button
@@ -311,7 +313,7 @@ export function UsersPage() {
                             onClick={() => handleDelete(user.id)}
                           >
                             <Trash2 className="w-4 h-4" />
-                            Delete
+                            {t('users.delete')}
                           </Button>
                         )}
                       </div>
@@ -341,7 +343,7 @@ export function UsersPage() {
               <div className="flex items-center justify-between">
                 <div className="flex items-center gap-2">
                   <UsersIcon className="w-5 h-5 text-bambu-green" />
-                  <h2 className="text-lg font-semibold text-white">Create User</h2>
+                  <h2 className="text-lg font-semibold text-white">{t('users.modal.createUser')}</h2>
                 </div>
                 <Button
                   variant="ghost"
@@ -359,34 +361,34 @@ export function UsersPage() {
               <div className="space-y-4">
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
-                    Username
+                    {t('users.form.username')}
                   </label>
                   <input
                     type="text"
                     value={formData.username}
                     onChange={(e) => setFormData({ ...formData, username: e.target.value })}
                     className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
-                    placeholder="Enter username"
+                    placeholder={t('users.form.usernamePlaceholder')}
                     autoComplete="username"
                   />
                 </div>
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
-                    Password
+                    {t('users.form.password')}
                   </label>
                   <input
                     type="password"
                     value={formData.password}
                     onChange={(e) => setFormData({ ...formData, password: e.target.value })}
                     className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
-                    placeholder="Enter password"
+                    placeholder={t('users.form.passwordPlaceholder')}
                     autoComplete="new-password"
                     minLength={6}
                   />
                 </div>
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
-                    Confirm Password
+                    {t('users.form.confirmPassword')}
                   </label>
                   <input
                     type="password"
@@ -397,17 +399,17 @@ export function UsersPage() {
                         ? 'border-red-500'
                         : 'border-bambu-dark-tertiary'
                     }`}
-                    placeholder="Confirm password"
+                    placeholder={t('users.form.confirmPasswordPlaceholder')}
                     autoComplete="new-password"
                     minLength={6}
                   />
                   {formData.confirmPassword && formData.password !== formData.confirmPassword && (
-                    <p className="text-red-400 text-xs mt-1">Passwords do not match</p>
+                    <p className="text-red-400 text-xs mt-1">{t('users.toast.passwordsDoNotMatch')}</p>
                   )}
                 </div>
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
-                    Groups
+                    {t('users.form.groups')}
                   </label>
                   <div className="space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
                     {groups.map(group => (
@@ -423,12 +425,12 @@ export function UsersPage() {
                         />
                         <span className="text-sm text-white">{group.name}</span>
                         {group.is_system && (
-                          <span className="text-xs text-yellow-400">(System)</span>
+                          <span className="text-xs text-yellow-400">({t('users.system')})</span>
                         )}
                       </label>
                     ))}
                     {groups.length === 0 && (
-                      <p className="text-sm text-bambu-gray">No groups available</p>
+                      <p className="text-sm text-bambu-gray">{t('users.noGroupsAvailable')}</p>
                     )}
                   </div>
                 </div>
@@ -441,7 +443,7 @@ export function UsersPage() {
                     setFormData({ username: '', password: '', confirmPassword: '', role: 'user', group_ids: [] });
                   }}
                 >
-                  Cancel
+                  {t('users.modal.cancel')}
                 </Button>
                 <Button
                   onClick={handleCreate}
@@ -450,12 +452,12 @@ export function UsersPage() {
                   {createMutation.isPending ? (
                     <>
                       <Loader2 className="w-4 h-4 animate-spin" />
-                      Creating...
+                      {t('users.modal.creating')}
                     </>
                   ) : (
                     <>
                       <Plus className="w-4 h-4" />
-                      Create User
+                      {t('users.modal.createUser')}
                     </>
                   )}
                 </Button>
@@ -479,7 +481,7 @@ export function UsersPage() {
               <div className="flex items-center justify-between">
                 <div className="flex items-center gap-2">
                   <Edit2 className="w-5 h-5 text-bambu-green" />
-                  <h2 className="text-lg font-semibold text-white">Edit User</h2>
+                  <h2 className="text-lg font-semibold text-white">{t('users.modal.editUser')}</h2>
                 </div>
                 <Button
                   variant="ghost"
@@ -494,27 +496,27 @@ export function UsersPage() {
               <div className="space-y-4">
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
-                    Username
+                    {t('users.form.username')}
                   </label>
                   <input
                     type="text"
                     value={formData.username}
                     onChange={(e) => setFormData({ ...formData, username: e.target.value })}
                     className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
-                    placeholder="Enter username"
+                    placeholder={t('users.form.usernamePlaceholder')}
                     autoComplete="username"
                   />
                 </div>
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
-                    Password <span className="text-bambu-gray font-normal">(leave blank to keep current)</span>
+                    {t('users.form.password')} <span className="text-bambu-gray font-normal">({t('users.form.leaveBlankToKeep')})</span>
                   </label>
                   <input
                     type="password"
                     value={formData.password}
                     onChange={(e) => setFormData({ ...formData, password: e.target.value, confirmPassword: '' })}
                     className="w-full px-4 py-3 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg text-white placeholder-bambu-gray focus:outline-none focus:ring-2 focus:ring-bambu-green/50 focus:border-bambu-green transition-colors"
-                    placeholder="Enter new password"
+                    placeholder={t('users.form.newPasswordPlaceholder')}
                     autoComplete="new-password"
                     minLength={6}
                   />
@@ -522,7 +524,7 @@ export function UsersPage() {
                 {formData.password && (
                   <div>
                     <label className="block text-sm font-medium text-white mb-2">
-                      Confirm Password
+                      {t('users.form.confirmPassword')}
                     </label>
                     <input
                       type="password"
@@ -533,18 +535,18 @@ export function UsersPage() {
                           ? 'border-red-500'
                           : 'border-bambu-dark-tertiary'
                       }`}
-                      placeholder="Confirm new password"
+                      placeholder={t('users.form.confirmNewPasswordPlaceholder')}
                       autoComplete="new-password"
                       minLength={6}
                     />
                     {formData.confirmPassword && formData.password !== formData.confirmPassword && (
-                      <p className="text-red-400 text-xs mt-1">Passwords do not match</p>
+                      <p className="text-red-400 text-xs mt-1">{t('users.toast.passwordsDoNotMatch')}</p>
                     )}
                   </div>
                 )}
                 <div>
                   <label className="block text-sm font-medium text-white mb-2">
-                    Groups
+                    {t('users.form.groups')}
                   </label>
                   <div className="space-y-2 max-h-40 overflow-y-auto p-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-lg">
                     {groups.map(group => (
@@ -560,12 +562,12 @@ export function UsersPage() {
                         />
                         <span className="text-sm text-white">{group.name}</span>
                         {group.is_system && (
-                          <span className="text-xs text-yellow-400">(System)</span>
+                          <span className="text-xs text-yellow-400">({t('users.system')})</span>
                         )}
                       </label>
                     ))}
                     {groups.length === 0 && (
-                      <p className="text-sm text-bambu-gray">No groups available</p>
+                      <p className="text-sm text-bambu-gray">{t('users.noGroupsAvailable')}</p>
                     )}
                   </div>
                 </div>
@@ -575,7 +577,7 @@ export function UsersPage() {
                   variant="secondary"
                   onClick={closeEditModal}
                 >
-                  Cancel
+                  {t('users.modal.cancel')}
                 </Button>
                 <Button
                   onClick={() => handleUpdate(editingUserId)}
@@ -584,12 +586,12 @@ export function UsersPage() {
                   {updateMutation.isPending ? (
                     <>
                       <Loader2 className="w-4 h-4 animate-spin" />
-                      Saving...
+                      {t('users.modal.saving')}
                     </>
                   ) : (
                     <>
                       <Save className="w-4 h-4" />
-                      Save Changes
+                      {t('users.modal.saveChanges')}
                     </>
                   )}
                 </Button>
@@ -602,9 +604,9 @@ export function UsersPage() {
       {/* Delete Confirmation Modal */}
       {deleteUserId !== null && (
         <ConfirmModal
-          title="Delete User"
-          message={`Are you sure you want to delete this user? This action cannot be undone.`}
-          confirmText="Delete User"
+          title={t('users.deleteModal.title')}
+          message={t('users.deleteModal.message')}
+          confirmText={t('users.deleteModal.confirm')}
           variant="danger"
           onConfirm={() => {
             deleteMutation.mutate(deleteUserId);

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BQgTn9O8.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BuwcX3H7.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-BuwcX3H7.js"></script>
+    <script type="module" crossorigin src="/assets/index-BQgTn9O8.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-CPqcJWwC.css">
   </head>
   <body>

Some files were not shown because too many files changed in this diff