فهرست منبع

Merge branch '0.2.1b' into add-H2D-Pro-png

MartinNYHC 3 ماه پیش
والد
کامیت
bb4902ad53
40فایلهای تغییر یافته به همراه4459 افزوده شده و 163 حذف شده
  1. 2 0
      CHANGELOG.md
  2. 1 0
      CONTRIBUTING.md
  3. 114 2
      backend/app/api/routes/archives.py
  4. 56 0
      backend/app/api/routes/library.py
  5. 37 0
      backend/app/core/auth.py
  6. 4 0
      backend/app/main.py
  7. 3 3
      frontend/src/__tests__/components/FileManagerModal.test.tsx
  8. 2 2
      frontend/src/__tests__/components/PrinterQueueWidget.test.tsx
  9. 437 0
      frontend/src/__tests__/utils/date.test.ts
  10. 43 0
      frontend/src/__tests__/utils/file.test.ts
  11. 12 0
      frontend/src/api/client.ts
  12. 1 1
      frontend/src/components/AssignSpoolModal.tsx
  13. 6 2
      frontend/src/components/FilamentHoverCard.tsx
  14. 2 8
      frontend/src/components/FileManagerModal.tsx
  15. 3 25
      frontend/src/components/GitHubBackupSettings.tsx
  16. 20 8
      frontend/src/components/ModelViewerModal.tsx
  17. 1 6
      frontend/src/components/PendingUploadsPanel.tsx
  18. 2 16
      frontend/src/components/PrinterQueueWidget.tsx
  19. 7 1
      frontend/src/components/SpoolFormModal.tsx
  20. 2 2
      frontend/src/components/spool-form/FilamentSection.tsx
  21. 8 0
      frontend/src/components/spool-form/types.ts
  22. 4 1
      frontend/src/i18n/index.ts
  23. 16 0
      frontend/src/i18n/locales/de.ts
  24. 16 0
      frontend/src/i18n/locales/en.ts
  25. 16 0
      frontend/src/i18n/locales/fr.ts
  26. 18 2
      frontend/src/i18n/locales/it.ts
  27. 16 0
      frontend/src/i18n/locales/ja.ts
  28. 3441 0
      frontend/src/i18n/locales/pt-BR.ts
  29. 39 22
      frontend/src/pages/ArchivesPage.tsx
  30. 1 8
      frontend/src/pages/FileManagerPage.tsx
  31. 3 0
      frontend/src/pages/PrintersPage.tsx
  32. 2 19
      frontend/src/pages/ProfilesPage.tsx
  33. 8 20
      frontend/src/pages/QueuePage.tsx
  34. 64 3
      frontend/src/utils/date.ts
  35. 20 0
      frontend/src/utils/file.ts
  36. 30 10
      frontend/src/utils/slicer.ts
  37. 0 0
      static/assets/index-Dnmq8_Ro.js
  38. 0 0
      static/assets/index-EqFdfChN.css
  39. 0 0
      static/assets/index-tulFiIvt.css
  40. 2 2
      static/index.html

+ 2 - 0
CHANGELOG.md

@@ -19,6 +19,8 @@ All notable changes to Bambuddy will be documented in this file.
 
 
 - **Usage Tracking Wrong Spool on Dual-Nozzle / Multi-AMS Printers** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — On H2C, H2D Pro, and other dual-nozzle printers with multiple AMS units, the usage tracker attributed filament consumption to the wrong spools. The MQTT `mapping` field — a per-print array that maps slicer filament slots to physical AMS trays — was preserved in state but never parsed or used. The tracker fell back to `slot_id - 1` as the global tray ID, which is incorrect when AMS hardware IDs differ from sequential indices (e.g., AMS-HT units with ID 128). Now decodes the MQTT mapping field from its snow encoding (`ams_hw_id * 256 + local_slot`) into bambuddy global tray IDs and uses it as a universal mapping source — working for all printer models and all print sources (slicer, queue, reprint) without relying on `tray_now` disambiguation.
 - **Usage Tracking Wrong Spool on Dual-Nozzle / Multi-AMS Printers** ([#364](https://github.com/maziggy/bambuddy/issues/364)) — On H2C, H2D Pro, and other dual-nozzle printers with multiple AMS units, the usage tracker attributed filament consumption to the wrong spools. The MQTT `mapping` field — a per-print array that maps slicer filament slots to physical AMS trays — was preserved in state but never parsed or used. The tracker fell back to `slot_id - 1` as the global tray ID, which is incorrect when AMS hardware IDs differ from sequential indices (e.g., AMS-HT units with ID 128). Now decodes the MQTT mapping field from its snow encoding (`ams_hw_id * 256 + local_slot`) into bambuddy global tray IDs and uses it as a universal mapping source — working for all printer models and all print sources (slicer, queue, reprint) without relying on `tray_now` disambiguation.
 - **npm audit: suppress moderate ajv ReDoS finding** — Added `audit-level=high` to `frontend/.npmrc` so `npm audit` exits cleanly. The ajv@6 ReDoS (GHSA-2g4f-4pwh-qvx6) is a transitive dependency of eslint@9 with no patched v6 release; ajv@8 override breaks eslint. The vulnerability requires crafted `$data` schema input — not an attack vector in a linting config.
 - **npm audit: suppress moderate ajv ReDoS finding** — Added `audit-level=high` to `frontend/.npmrc` so `npm audit` exits cleanly. The ajv@6 ReDoS (GHSA-2g4f-4pwh-qvx6) is a transitive dependency of eslint@9 with no patched v6 release; ajv@8 override breaks eslint. The vulnerability requires crafted `$data` schema input — not an attack vector in a linting config.
+- **Spool Form Allows Empty Brand & Subtype** ([#417](https://github.com/maziggy/bambuddy/issues/417)) — The spool add/edit modal did not require Brand or Subtype fields, allowing spools to be saved without them. When such a spool was assigned to an AMS slot, the `tray_sub_brands` sent to the printer was incomplete (e.g., just "PETG" instead of "PETG Basic"), causing BambuStudio to not recognize the filament profile. Brand and Subtype are now mandatory fields with validation errors shown on submit.
+- **Open in Slicer Fails When Authentication Enabled** ([#421](https://github.com/maziggy/bambuddy/issues/421)) — The "Open in Slicer" buttons for BambuStudio and OrcaSlicer failed with "importing failed" when authentication was enabled. Slicer protocol handlers (`bambustudio://`, `orcaslicer://`) launch the slicer app which fetches the file via HTTP — but cannot send authentication headers, so the global auth middleware returned 401. Additionally, the URL format was wrong on Linux (used the macOS-only `bambustudioopen://` scheme instead of `bambustudio://open?file=`). Fixed with short-lived, single-use download tokens: the frontend fetches a token via an authenticated POST endpoint, then builds a `/dl/{token}/{filename}` URL that the slicer can access without auth headers. The token is validated server-side (5-minute expiry, single-use). Platform-specific URL formats now match the actual slicer source code: macOS uses `bambustudioopen://` with URL encoding, Windows/Linux use `bambustudio://open?file=`, and OrcaSlicer uses `orcaslicer://open?file=`.
 
 
 ### Changed
 ### Changed
 - **Filament Catalog API Renamed** ([#427](https://github.com/maziggy/bambuddy/issues/427)) — Renamed `/api/v1/filaments/` to `/api/v1/filament-catalog/` to avoid confusion with the inventory spools page (labeled "Filament" in the UI). The old endpoint managed material type definitions (cost, temperature, density), not physical spools — the shared name caused users to expect the API to return their spool inventory.
 - **Filament Catalog API Renamed** ([#427](https://github.com/maziggy/bambuddy/issues/427)) — Renamed `/api/v1/filaments/` to `/api/v1/filament-catalog/` to avoid confusion with the inventory spools page (labeled "Filament" in the UI). The old endpoint managed material type definitions (cost, temperature, density), not physical spools — the shared name caused users to expect the API to return their spool inventory.

+ 1 - 0
CONTRIBUTING.md

@@ -168,6 +168,7 @@ Translations live in `frontend/src/i18n/locales/`:
 | `de.ts` | German |
 | `de.ts` | German |
 | `fr.ts` | French |
 | `fr.ts` | French |
 | `ja.ts` | Japanese |
 | `ja.ts` | Japanese |
+| `pt-BR.ts` | Brazilian Portuguese |
 
 
 ### Adding New Strings
 ### Adding New Strings
 
 

+ 114 - 2
backend/app/api/routes/archives.py

@@ -1074,7 +1074,63 @@ async def download_archive_with_filename(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
 ):
-    """Download the 3MF file with filename in URL (for Bambu Studio protocol)."""
+    """Download the 3MF file with filename in URL."""
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    file_path = settings.base_dir / archive.file_path
+    if not file_path.exists():
+        raise HTTPException(404, "File not found")
+
+    return FileResponse(
+        path=file_path,
+        filename=archive.filename,
+        media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
+    )
+
+
+@router.post("/{archive_id}/slicer-token")
+async def create_archive_slicer_token(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
+    """Create a short-lived download token for opening files in slicer applications.
+
+    Slicer protocol handlers (bambustudioopen://, orcaslicer://) cannot send
+    auth headers, so they use this token in the URL path instead.
+    """
+    from backend.app.core.auth import create_slicer_download_token
+
+    service = ArchiveService(db)
+    archive = await service.get_archive(archive_id)
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    token = create_slicer_download_token("archive", archive_id)
+    return {"token": token}
+
+
+@router.get("/{archive_id}/dl/{token}/{filename}")
+async def download_archive_for_slicer(
+    archive_id: int,
+    token: str,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download 3MF file using a slicer download token.
+
+    Token-authenticated (no auth headers needed). The token is short-lived
+    and single-use, created by POST /{archive_id}/slicer-token.
+    Filename is at the end of the URL so slicers can detect the file format.
+    """
+    from backend.app.core.auth import verify_slicer_download_token
+
+    if not verify_slicer_download_token(token, "archive", archive_id):
+        raise HTTPException(403, "Invalid or expired download token")
+
     service = ArchiveService(db)
     service = ArchiveService(db)
     archive = await service.get_archive(archive_id)
     archive = await service.get_archive(archive_id)
     if not archive:
     if not archive:
@@ -3093,7 +3149,63 @@ async def download_source_3mf_for_slicer(
     db: AsyncSession = Depends(get_db),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
 ):
 ):
-    """Download source 3MF with filename in URL (for Bambu Studio compatibility)."""
+    """Download source 3MF with filename in URL."""
+    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+
+    if not archive.source_3mf_path:
+        raise HTTPException(404, "No source 3MF attached to this archive")
+
+    source_path = settings.base_dir / archive.source_3mf_path
+    if not source_path.exists():
+        raise HTTPException(404, "Source 3MF file not found on disk")
+
+    return FileResponse(
+        path=source_path,
+        filename=filename if filename.endswith(".3mf") else f"{filename}.3mf",
+        media_type="application/vnd.ms-package.3dmanufacturing-3dmodel+xml",
+    )
+
+
+@router.post("/{archive_id}/source-slicer-token")
+async def create_source_slicer_token(
+    archive_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.ARCHIVES_READ),
+):
+    """Create a short-lived download token for opening source 3MF in slicer."""
+    from backend.app.core.auth import create_slicer_download_token
+
+    result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(404, "Archive not found")
+    if not archive.source_3mf_path:
+        raise HTTPException(404, "No source 3MF attached to this archive")
+
+    token = create_slicer_download_token("source", archive_id)
+    return {"token": token}
+
+
+@router.get("/{archive_id}/source-dl/{token}/{filename}")
+async def download_source_3mf_for_slicer_with_token(
+    archive_id: int,
+    token: str,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download source 3MF using a slicer download token.
+
+    Token-authenticated (no auth headers needed). The token is short-lived
+    and single-use, created by POST /{archive_id}/source-slicer-token.
+    """
+    from backend.app.core.auth import verify_slicer_download_token
+
+    if not verify_slicer_download_token(token, "source", archive_id):
+        raise HTTPException(403, "Invalid or expired download token")
+
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     result = await db.execute(select(PrintArchive).where(PrintArchive.id == archive_id))
     archive = result.scalar_one_or_none()
     archive = result.scalar_one_or_none()
     if not archive:
     if not archive:

+ 56 - 0
backend/app/api/routes/library.py

@@ -2138,6 +2138,62 @@ async def download_file(
     )
     )
 
 
 
 
+@router.post("/files/{file_id}/slicer-token")
+async def create_library_slicer_token(
+    file_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = Depends(require_permission_if_auth_enabled(Permission.LIBRARY_READ)),
+):
+    """Create a short-lived download token for opening files in slicer applications.
+
+    Slicer protocol handlers (bambustudioopen://, orcaslicer://) cannot send
+    auth headers, so they use this token in the URL path instead.
+    """
+    from backend.app.core.auth import create_slicer_download_token
+
+    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    file = result.scalar_one_or_none()
+    if not file:
+        raise HTTPException(status_code=404, detail="File not found")
+
+    token = create_slicer_download_token("library", file_id)
+    return {"token": token}
+
+
+@router.get("/files/{file_id}/dl/{token}/{filename}")
+async def download_library_file_for_slicer(
+    file_id: int,
+    token: str,
+    filename: str,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download a library file using a slicer download token.
+
+    Token-authenticated (no auth headers needed). The token is short-lived
+    and single-use, created by POST /files/{file_id}/slicer-token.
+    Filename is at the end of the URL so slicers can detect the file format.
+    """
+    from backend.app.core.auth import verify_slicer_download_token
+
+    if not verify_slicer_download_token(token, "library", file_id):
+        raise HTTPException(status_code=403, detail="Invalid or expired download token")
+
+    result = await db.execute(select(LibraryFile).where(LibraryFile.id == file_id))
+    file = result.scalar_one_or_none()
+    if not file:
+        raise HTTPException(status_code=404, detail="File not found")
+
+    abs_path = to_absolute_path(file.file_path)
+    if not abs_path or not abs_path.exists():
+        raise HTTPException(status_code=404, detail="File not found on disk")
+
+    return FastAPIFileResponse(
+        str(abs_path),
+        filename=file.filename,
+        media_type="application/octet-stream",
+    )
+
+
 @router.get("/files/{file_id}/thumbnail")
 @router.get("/files/{file_id}/thumbnail")
 async def get_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)):
 async def get_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)):
     """Get a file's thumbnail."""
     """Get a file's thumbnail."""

+ 37 - 0
backend/app/core/auth.py

@@ -98,6 +98,43 @@ ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7  # 7 days
 # HTTP Bearer token
 # HTTP Bearer token
 security = HTTPBearer(auto_error=False)
 security = HTTPBearer(auto_error=False)
 
 
+# --- Slicer download tokens ---
+# Short-lived tokens for slicer protocol handlers that can't send auth headers.
+# Maps token → (resource_key, expiry). resource_key = "archive:{id}" or "library:{id}".
+_slicer_tokens: dict[str, tuple[str, datetime]] = {}
+SLICER_TOKEN_EXPIRE_MINUTES = 5
+
+
+def create_slicer_download_token(resource_type: str, resource_id: int) -> str:
+    """Create a short-lived download token for slicer protocol handlers."""
+    # Cleanup expired tokens
+    now = datetime.utcnow()
+    expired = [k for k, (_, exp) in _slicer_tokens.items() if exp < now]
+    for k in expired:
+        del _slicer_tokens[k]
+
+    token = secrets.token_urlsafe(24)
+    resource_key = f"{resource_type}:{resource_id}"
+    _slicer_tokens[token] = (resource_key, now + timedelta(minutes=SLICER_TOKEN_EXPIRE_MINUTES))
+    return token
+
+
+def verify_slicer_download_token(token: str, resource_type: str, resource_id: int) -> bool:
+    """Verify a slicer download token is valid for the given resource."""
+    entry = _slicer_tokens.get(token)
+    if not entry:
+        return False
+    resource_key, expiry = entry
+    if datetime.utcnow() > expiry:
+        del _slicer_tokens[token]
+        return False
+    expected_key = f"{resource_type}:{resource_id}"
+    if resource_key != expected_key:
+        return False
+    # Token is single-use
+    del _slicer_tokens[token]
+    return True
+
 
 
 def verify_password(plain_password: str, hashed_password: str) -> bool:
 def verify_password(plain_password: str, hashed_password: str) -> bool:
     """Verify a password against a hash.
     """Verify a password against a hash.

+ 4 - 0
backend/app/main.py

@@ -3356,6 +3356,10 @@ PUBLIC_API_PATTERNS = [
     # Camera (streams loaded via <img> tag)
     # Camera (streams loaded via <img> tag)
     "/camera/stream",  # /printers/{id}/camera/stream
     "/camera/stream",  # /printers/{id}/camera/stream
     "/camera/snapshot",  # /printers/{id}/camera/snapshot
     "/camera/snapshot",  # /printers/{id}/camera/snapshot
+    # Slicer token-authenticated downloads — protocol handlers (bambustudioopen://,
+    # orcaslicer://) cannot send auth headers. These endpoints validate a short-lived
+    # download token in the URL path instead.
+    "/dl/",  # /archives/{id}/dl/{token}/{filename}, /library/files/{id}/dl/{token}/{filename}
 ]
 ]
 
 
 
 

+ 3 - 3
frontend/src/__tests__/components/FileManagerModal.test.tsx

@@ -28,7 +28,7 @@ const mockFiles = [
   {
   {
     name: 'benchy.3mf',
     name: 'benchy.3mf',
     path: '/benchy.3mf',
     path: '/benchy.3mf',
-    size: 1024000,
+    size: 1048575,
     is_directory: false,
     is_directory: false,
     mtime: '2024-01-15T10:00:00Z',
     mtime: '2024-01-15T10:00:00Z',
   },
   },
@@ -135,8 +135,8 @@ describe('FileManagerModal', () => {
       );
       );
 
 
       await waitFor(() => {
       await waitFor(() => {
-        // 1024000 bytes = 1000 KB = ~1.0 MB
-        expect(screen.getByText('1000 KB')).toBeInTheDocument();
+        // 1024000 bytes = 1024.0 KB
+        expect(screen.getByText('1024.0 KB')).toBeInTheDocument();
       });
       });
     });
     });
   });
   });

+ 2 - 2
frontend/src/__tests__/components/PrinterQueueWidget.test.tsx

@@ -77,11 +77,11 @@ describe('PrinterQueueWidget', () => {
       });
       });
     });
     });
 
 
-    it('shows ASAP for unscheduled items', async () => {
+    it('shows Waiting for unscheduled items', async () => {
       render(<PrinterQueueWidget printerId={1} />);
       render(<PrinterQueueWidget printerId={1} />);
 
 
       await waitFor(() => {
       await waitFor(() => {
-        expect(screen.getByText('ASAP')).toBeInTheDocument();
+        expect(screen.getByText('Waiting')).toBeInTheDocument();
       });
       });
     });
     });
   });
   });

+ 437 - 0
frontend/src/__tests__/utils/date.test.ts

@@ -0,0 +1,437 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+  getDatePlaceholder,
+  getTimePlaceholder,
+  formatDateInput,
+  formatTimeInput,
+  parseDateInput,
+  parseTimeInput,
+  toDateTimeLocalValue,
+  applyTimeFormat,
+  parseUTCDate,
+  formatDate,
+  formatDateOnly,
+  formatDateTime,
+  formatTimeOnly,
+  formatETA,
+  formatDuration,
+  formatRelativeTime,
+} from '../../utils/date';
+
+describe('getDatePlaceholder', () => {
+  it('returns MM/DD/YYYY for us format', () => {
+    expect(getDatePlaceholder('us')).toBe('MM/DD/YYYY');
+  });
+
+  it('returns DD/MM/YYYY for eu format', () => {
+    expect(getDatePlaceholder('eu')).toBe('DD/MM/YYYY');
+  });
+
+  it('returns YYYY-MM-DD for iso format', () => {
+    expect(getDatePlaceholder('iso')).toBe('YYYY-MM-DD');
+  });
+
+  it('returns a placeholder for system format', () => {
+    const result = getDatePlaceholder('system');
+    expect(['MM/DD/YYYY', 'DD/MM/YYYY', 'YYYY-MM-DD']).toContain(result);
+  });
+});
+
+describe('getTimePlaceholder', () => {
+  it('returns HH:MM AM/PM for 12h format', () => {
+    expect(getTimePlaceholder('12h')).toBe('HH:MM AM/PM');
+  });
+
+  it('returns HH:MM for 24h format', () => {
+    expect(getTimePlaceholder('24h')).toBe('HH:MM');
+  });
+
+  it('returns a placeholder for system format', () => {
+    const result = getTimePlaceholder('system');
+    expect(['HH:MM AM/PM', 'HH:MM']).toContain(result);
+  });
+});
+
+describe('formatDateInput', () => {
+  const date = new Date(2025, 5, 15); // June 15, 2025
+
+  it('formats as MM/DD/YYYY for us format', () => {
+    expect(formatDateInput(date, 'us')).toBe('06/15/2025');
+  });
+
+  it('formats as DD/MM/YYYY for eu format', () => {
+    expect(formatDateInput(date, 'eu')).toBe('15/06/2025');
+  });
+
+  it('formats as YYYY-MM-DD for iso format', () => {
+    expect(formatDateInput(date, 'iso')).toBe('2025-06-15');
+  });
+
+  it('uses toLocaleDateString for system format', () => {
+    const result = formatDateInput(date, 'system');
+    expect(result).toBeTruthy();
+  });
+});
+
+describe('formatTimeInput', () => {
+  it('formats as 12h with AM', () => {
+    const date = new Date(2025, 0, 1, 9, 30);
+    expect(formatTimeInput(date, '12h')).toBe('9:30 AM');
+  });
+
+  it('formats as 12h with PM', () => {
+    const date = new Date(2025, 0, 1, 14, 45);
+    expect(formatTimeInput(date, '12h')).toBe('2:45 PM');
+  });
+
+  it('formats 12:00 as 12:00 PM', () => {
+    const date = new Date(2025, 0, 1, 12, 0);
+    expect(formatTimeInput(date, '12h')).toBe('12:00 PM');
+  });
+
+  it('formats 00:00 as 12:00 AM', () => {
+    const date = new Date(2025, 0, 1, 0, 0);
+    expect(formatTimeInput(date, '12h')).toBe('12:00 AM');
+  });
+
+  it('formats as 24h', () => {
+    const date = new Date(2025, 0, 1, 14, 30);
+    expect(formatTimeInput(date, '24h')).toBe('14:30');
+  });
+
+  it('pads hours in 24h format', () => {
+    const date = new Date(2025, 0, 1, 9, 5);
+    expect(formatTimeInput(date, '24h')).toBe('09:05');
+  });
+});
+
+describe('parseDateInput', () => {
+  it('parses us format MM/DD/YYYY', () => {
+    const result = parseDateInput('06/15/2025', 'us');
+    expect(result?.getFullYear()).toBe(2025);
+    expect(result?.getMonth()).toBe(5); // June
+    expect(result?.getDate()).toBe(15);
+  });
+
+  it('parses eu format DD/MM/YYYY', () => {
+    const result = parseDateInput('15/06/2025', 'eu');
+    expect(result?.getFullYear()).toBe(2025);
+    expect(result?.getMonth()).toBe(5);
+    expect(result?.getDate()).toBe(15);
+  });
+
+  it('parses iso format YYYY-MM-DD', () => {
+    const result = parseDateInput('2025-06-15', 'iso');
+    expect(result?.getFullYear()).toBe(2025);
+    expect(result?.getMonth()).toBe(5);
+    expect(result?.getDate()).toBe(15);
+  });
+
+  it('accepts different separators', () => {
+    expect(parseDateInput('06-15-2025', 'us')?.getDate()).toBe(15);
+    expect(parseDateInput('15.06.2025', 'eu')?.getDate()).toBe(15);
+    expect(parseDateInput('2025/06/15', 'iso')?.getDate()).toBe(15);
+  });
+
+  it('returns null for invalid input', () => {
+    expect(parseDateInput('', 'us')).toBeNull();
+    expect(parseDateInput('invalid', 'us')).toBeNull();
+    expect(parseDateInput('13/32/2025', 'us')).toBeNull(); // invalid month
+    expect(parseDateInput('01/01/1800', 'us')).toBeNull(); // year out of range
+  });
+
+  it('returns null for invalid month', () => {
+    expect(parseDateInput('13/01/2025', 'us')).toBeNull();
+    expect(parseDateInput('00/01/2025', 'us')).toBeNull();
+  });
+
+  it('returns null for invalid day', () => {
+    expect(parseDateInput('01/32/2025', 'us')).toBeNull();
+    expect(parseDateInput('01/00/2025', 'us')).toBeNull();
+  });
+});
+
+describe('parseTimeInput', () => {
+  it('parses 24h format', () => {
+    expect(parseTimeInput('14:30')).toEqual({ hours: 14, minutes: 30 });
+    expect(parseTimeInput('09:05')).toEqual({ hours: 9, minutes: 5 });
+    expect(parseTimeInput('0:00')).toEqual({ hours: 0, minutes: 0 });
+  });
+
+  it('parses 12h format with AM', () => {
+    expect(parseTimeInput('9:30 AM')).toEqual({ hours: 9, minutes: 30 });
+    expect(parseTimeInput('12:00 AM')).toEqual({ hours: 0, minutes: 0 });
+  });
+
+  it('parses 12h format with PM', () => {
+    expect(parseTimeInput('2:45 PM')).toEqual({ hours: 14, minutes: 45 });
+    expect(parseTimeInput('12:00 PM')).toEqual({ hours: 12, minutes: 0 });
+  });
+
+  it('is case insensitive for AM/PM', () => {
+    expect(parseTimeInput('9:30 am')).toEqual({ hours: 9, minutes: 30 });
+    expect(parseTimeInput('2:45 pm')).toEqual({ hours: 14, minutes: 45 });
+  });
+
+  it('returns null for invalid input', () => {
+    expect(parseTimeInput('')).toBeNull();
+    expect(parseTimeInput('invalid')).toBeNull();
+    expect(parseTimeInput('25:00')).toBeNull();
+    expect(parseTimeInput('12:60')).toBeNull();
+    expect(parseTimeInput('-1:00')).toBeNull();
+  });
+});
+
+describe('toDateTimeLocalValue', () => {
+  it('formats date to datetime-local value', () => {
+    const date = new Date(2025, 5, 15, 14, 30);
+    expect(toDateTimeLocalValue(date)).toBe('2025-06-15T14:30');
+  });
+
+  it('pads single digit values', () => {
+    const date = new Date(2025, 0, 5, 9, 5);
+    expect(toDateTimeLocalValue(date)).toBe('2025-01-05T09:05');
+  });
+});
+
+describe('applyTimeFormat', () => {
+  it('sets hour12 true for 12h format', () => {
+    const options: Intl.DateTimeFormatOptions = {};
+    applyTimeFormat(options, '12h');
+    expect(options.hour12).toBe(true);
+  });
+
+  it('sets hour12 false for 24h format', () => {
+    const options: Intl.DateTimeFormatOptions = {};
+    applyTimeFormat(options, '24h');
+    expect(options.hour12).toBe(false);
+  });
+
+  it('leaves hour12 undefined for system format', () => {
+    const options: Intl.DateTimeFormatOptions = {};
+    applyTimeFormat(options, 'system');
+    expect(options.hour12).toBeUndefined();
+  });
+
+  it('returns the modified options object', () => {
+    const options: Intl.DateTimeFormatOptions = { hour: '2-digit' };
+    const result = applyTimeFormat(options, '12h');
+    expect(result).toBe(options);
+    expect(result.hour).toBe('2-digit');
+  });
+});
+
+describe('parseUTCDate', () => {
+  it('returns null for null/undefined input', () => {
+    expect(parseUTCDate(null)).toBeNull();
+    expect(parseUTCDate(undefined)).toBeNull();
+    expect(parseUTCDate('')).toBeNull();
+  });
+
+  it('parses ISO string with Z suffix as-is', () => {
+    const result = parseUTCDate('2025-06-15T12:00:00Z');
+    expect(result).toBeInstanceOf(Date);
+    expect(result?.getUTCHours()).toBe(12);
+  });
+
+  it('parses ISO string with timezone offset as-is', () => {
+    const result = parseUTCDate('2025-06-15T12:00:00+05:00');
+    expect(result).toBeInstanceOf(Date);
+  });
+
+  it('appends Z to strings without timezone indicator', () => {
+    const result = parseUTCDate('2025-06-15T12:00:00');
+    expect(result).toBeInstanceOf(Date);
+    expect(result?.getUTCHours()).toBe(12);
+  });
+});
+
+describe('formatDate', () => {
+  it('returns empty string for null input', () => {
+    expect(formatDate(null)).toBe('');
+    expect(formatDate(undefined)).toBe('');
+  });
+
+  it('formats a valid date string', () => {
+    const result = formatDate('2025-06-15T12:00:00Z');
+    expect(result).toBeTruthy();
+    expect(result).toContain('2025');
+  });
+
+  it('accepts custom options', () => {
+    const result = formatDate('2025-06-15T12:00:00Z', { year: 'numeric' });
+    expect(result).toContain('2025');
+  });
+});
+
+describe('formatDateOnly', () => {
+  it('returns empty string for null input', () => {
+    expect(formatDateOnly(null)).toBe('');
+  });
+
+  it('formats date without time', () => {
+    const result = formatDateOnly('2025-06-15T12:00:00Z');
+    expect(result).toBeTruthy();
+    expect(result).toContain('2025');
+  });
+});
+
+describe('formatDateTime', () => {
+  it('returns empty string for null input', () => {
+    expect(formatDateTime(null)).toBe('');
+  });
+
+  it('formats with 12h time format', () => {
+    const result = formatDateTime('2025-06-15T14:00:00Z', '12h');
+    expect(result).toBeTruthy();
+  });
+
+  it('formats with 24h time format', () => {
+    const result = formatDateTime('2025-06-15T14:00:00Z', '24h');
+    expect(result).toBeTruthy();
+  });
+});
+
+describe('formatTimeOnly', () => {
+  it('formats time with 12h format', () => {
+    const date = new Date(2025, 5, 15, 14, 30);
+    const result = formatTimeOnly(date, '12h');
+    expect(result).toMatch(/2:30|02:30/);
+    expect(result.toUpperCase()).toContain('PM');
+  });
+
+  it('formats time with 24h format', () => {
+    const date = new Date(2025, 5, 15, 14, 30);
+    const result = formatTimeOnly(date, '24h');
+    expect(result).toContain('14:30');
+  });
+});
+
+describe('formatETA', () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+    vi.setSystemTime(new Date('2025-06-15T12:00:00Z'));
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  it('returns time only for same day', () => {
+    const result = formatETA(60); // 1 hour from now
+    expect(result).toBeTruthy();
+  });
+
+  it('includes "Tomorrow" for next day', () => {
+    const result = formatETA(60 * 24); // 24 hours from now
+    expect(result).toContain('Tomorrow');
+  });
+
+  it('uses translation function for tomorrow', () => {
+    const t = vi.fn((key: string) => (key === 'common.tomorrow' ? 'Demain' : key));
+    const result = formatETA(60 * 24, 'system', t);
+    expect(result).toContain('Demain');
+  });
+
+  it('shows weekday for dates beyond tomorrow', () => {
+    const result = formatETA(60 * 48); // 48 hours from now
+    expect(result).not.toContain('Tomorrow');
+  });
+});
+
+describe('formatDuration', () => {
+  it('returns "--" for null/undefined', () => {
+    expect(formatDuration(null)).toBe('--');
+    expect(formatDuration(undefined)).toBe('--');
+  });
+
+  it('returns "--" for negative values', () => {
+    expect(formatDuration(-1)).toBe('--');
+  });
+
+  it('formats minutes only when under 1 hour', () => {
+    expect(formatDuration(0)).toBe('0m');
+    expect(formatDuration(60)).toBe('1m');
+    expect(formatDuration(2700)).toBe('45m');
+  });
+
+  it('formats hours and minutes', () => {
+    expect(formatDuration(3600)).toBe('1h 0m');
+    expect(formatDuration(5400)).toBe('1h 30m');
+    expect(formatDuration(9000)).toBe('2h 30m');
+  });
+});
+
+describe('formatRelativeTime', () => {
+  beforeEach(() => {
+    vi.useFakeTimers();
+    vi.setSystemTime(new Date('2025-06-15T12:00:00Z'));
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  it('returns "-" for null input', () => {
+    expect(formatRelativeTime(null)).toBe('-');
+  });
+
+  it('returns translated unknown for null with translation', () => {
+    const t = vi.fn((key: string) => (key === 'time.unknown' ? 'Unknown' : key));
+    expect(formatRelativeTime(null, 'system', t)).toBe('Unknown');
+  });
+
+  it('returns "Just now" for times less than 1 minute ago', () => {
+    expect(formatRelativeTime('2025-06-15T11:59:30Z')).toBe('Just now');
+  });
+
+  it('returns "Now" for times less than 1 minute in future', () => {
+    expect(formatRelativeTime('2025-06-15T12:00:30Z')).toBe('Now');
+  });
+
+  it('returns minutes ago for times under 1 hour ago', () => {
+    expect(formatRelativeTime('2025-06-15T11:55:00Z')).toBe('5m ago');
+    expect(formatRelativeTime('2025-06-15T11:30:00Z')).toBe('30m ago');
+  });
+
+  it('returns "in Xm" for times under 1 hour in future', () => {
+    expect(formatRelativeTime('2025-06-15T12:05:00Z')).toBe('in 5m');
+    expect(formatRelativeTime('2025-06-15T12:30:00Z')).toBe('in 30m');
+  });
+
+  it('returns hours ago for times under 1 day ago', () => {
+    expect(formatRelativeTime('2025-06-15T10:00:00Z')).toBe('2h ago');
+    expect(formatRelativeTime('2025-06-15T06:00:00Z')).toBe('6h ago');
+  });
+
+  it('returns "in Xh" for times under 1 day in future', () => {
+    expect(formatRelativeTime('2025-06-15T14:00:00Z')).toBe('in 2h');
+    expect(formatRelativeTime('2025-06-15T18:00:00Z')).toBe('in 6h');
+  });
+
+  it('returns days ago for times under 7 days ago', () => {
+    expect(formatRelativeTime('2025-06-14T12:00:00Z')).toBe('1d ago');
+    expect(formatRelativeTime('2025-06-10T12:00:00Z')).toBe('5d ago');
+  });
+
+  it('returns "in Xd" for times under 7 days in future', () => {
+    expect(formatRelativeTime('2025-06-16T12:00:00Z')).toBe('in 1d');
+    expect(formatRelativeTime('2025-06-20T12:00:00Z')).toBe('in 5d');
+  });
+
+  it('returns formatted date for times older than 7 days', () => {
+    const result = formatRelativeTime('2025-06-01T12:00:00Z');
+    expect(result).toContain('2025');
+  });
+
+  it('uses translation function when provided', () => {
+    const t = vi.fn((key: string, options?: Record<string, unknown>) => {
+      if (key === 'time.minsAgo') return `${options?.count} minutes ago`;
+      if (key === 'time.inMins') return `in ${options?.count} minutes`;
+      return key;
+    });
+
+    expect(formatRelativeTime('2025-06-15T11:55:00Z', 'system', t)).toBe('5 minutes ago');
+    expect(formatRelativeTime('2025-06-15T12:05:00Z', 'system', t)).toBe('in 5 minutes');
+  });
+});

+ 43 - 0
frontend/src/__tests__/utils/file.test.ts

@@ -0,0 +1,43 @@
+import { describe, it, expect } from 'vitest';
+import { formatFileSize } from '../../utils/file';
+
+describe('formatFileSize', () => {
+  it('returns "0 B" for 0 bytes', () => {
+    expect(formatFileSize(0)).toBe('0 B');
+  });
+
+  it('returns bytes without decimals for values under 1 KB', () => {
+    expect(formatFileSize(1)).toBe('1 B');
+    expect(formatFileSize(500)).toBe('500 B');
+    expect(formatFileSize(1023)).toBe('1023 B');
+  });
+
+  it('returns KB with 1 decimal for values under 1 MB', () => {
+    expect(formatFileSize(1024)).toBe('1.0 KB');
+    expect(formatFileSize(1536)).toBe('1.5 KB');
+    expect(formatFileSize(10240)).toBe('10.0 KB');
+  });
+
+  it('returns MB with 1 decimal for values under 1 GB', () => {
+    expect(formatFileSize(1048576)).toBe('1.0 MB');
+    expect(formatFileSize(1572864)).toBe('1.5 MB');
+    expect(formatFileSize(10485760)).toBe('10.0 MB');
+  });
+
+  it('returns GB with 1 decimal for values under 1 TB', () => {
+    expect(formatFileSize(1073741824)).toBe('1.0 GB');
+    expect(formatFileSize(1610612736)).toBe('1.5 GB');
+  });
+
+  it('returns TB with 1 decimal for very large values', () => {
+    expect(formatFileSize(1099511627776)).toBe('1.0 TB');
+    expect(formatFileSize(1649267441664)).toBe('1.5 TB');
+  });
+
+  it('handles edge cases at unit boundaries', () => {
+    expect(formatFileSize(1023)).toBe('1023 B');
+    expect(formatFileSize(1024)).toBe('1.0 KB');
+    expect(formatFileSize(1048575)).toBe('1024.0 KB');
+    expect(formatFileSize(1048576)).toBe('1.0 MB');
+  });
+});

+ 12 - 0
frontend/src/api/client.ts

@@ -2786,6 +2786,10 @@ export const api = {
   },
   },
   getSource3mfForSlicer: (archiveId: number, filename: string) =>
   getSource3mfForSlicer: (archiveId: number, filename: string) =>
     `${API_BASE}/archives/${archiveId}/source/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
     `${API_BASE}/archives/${archiveId}/source/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
+  createSourceSlicerToken: (archiveId: number) =>
+    request<{ token: string }>(`/archives/${archiveId}/source-slicer-token`, { method: 'POST' }),
+  getSourceSlicerDownloadUrl: (archiveId: number, token: string, filename: string) =>
+    `${API_BASE}/archives/${archiveId}/source-dl/${token}/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
   uploadSource3mf: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
   uploadSource3mf: async (archiveId: number, file: File): Promise<{ status: string; filename: string }> => {
     const formData = new FormData();
     const formData = new FormData();
     formData.append('file', file);
     formData.append('file', file);
@@ -2908,6 +2912,10 @@ export const api = {
     `${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`,
     `${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`,
   getArchiveForSlicer: (id: number, filename: string) =>
   getArchiveForSlicer: (id: number, filename: string) =>
     `${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
     `${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
+  createArchiveSlicerToken: (archiveId: number) =>
+    request<{ token: string }>(`/archives/${archiveId}/slicer-token`, { method: 'POST' }),
+  getArchiveSlicerDownloadUrl: (archiveId: number, token: string, filename: string) =>
+    `${API_BASE}/archives/${archiveId}/dl/${token}/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,
   getArchivePlates: (archiveId: number) =>
   getArchivePlates: (archiveId: number) =>
     request<ArchivePlatesResponse>(`/archives/${archiveId}/plates`),
     request<ArchivePlatesResponse>(`/archives/${archiveId}/plates`),
   getArchiveFilamentRequirements: (archiveId: number, plateId?: number) =>
   getArchiveFilamentRequirements: (archiveId: number, plateId?: number) =>
@@ -3938,6 +3946,10 @@ export const api = {
   deleteLibraryFile: (id: number) =>
   deleteLibraryFile: (id: number) =>
     request<{ status: string; message: string }>(`/library/files/${id}`, { method: 'DELETE' }),
     request<{ status: string; message: string }>(`/library/files/${id}`, { method: 'DELETE' }),
   getLibraryFileDownloadUrl: (id: number) => `${API_BASE}/library/files/${id}/download`,
   getLibraryFileDownloadUrl: (id: number) => `${API_BASE}/library/files/${id}/download`,
+  createLibrarySlicerToken: (fileId: number) =>
+    request<{ token: string }>(`/library/files/${fileId}/slicer-token`, { method: 'POST' }),
+  getLibrarySlicerDownloadUrl: (fileId: number, token: string, filename: string) =>
+    `${API_BASE}/library/files/${fileId}/dl/${token}/${encodeURIComponent(filename)}`,
   downloadLibraryFile: async (id: number, filename?: string): Promise<void> => {
   downloadLibraryFile: async (id: number, filename?: string): Promise<void> => {
     const headers: Record<string, string> = {};
     const headers: Record<string, string> = {};
     if (authToken) {
     if (authToken) {

+ 1 - 1
frontend/src/components/AssignSpoolModal.tsx

@@ -177,7 +177,7 @@ export function AssignSpoolModal({ isOpen, onClose, printerId, amsId, trayId, tr
                         <p className="text-xs text-bambu-gray">
                         <p className="text-xs text-bambu-gray">
                           {spool.color_name || ''}
                           {spool.color_name || ''}
                           {spool.label_weight ? ` - ${spool.label_weight}g` : ''}
                           {spool.label_weight ? ` - ${spool.label_weight}g` : ''}
-                          {spool.weight_used > 0 ? ` (${Math.round(spool.weight_used)}g used)` : ''}
+                          {spool.label_weight ? ` (${Math.max(0, Math.round(spool.label_weight - spool.weight_used))}g ${t('ams.remainingUnit')})` : ''}
                         </p>
                         </p>
                       </div>
                       </div>
                       {selectedSpoolId === spool.id && (
                       {selectedSpoolId === spool.id && (

+ 6 - 2
frontend/src/components/FilamentHoverCard.tsx

@@ -24,7 +24,7 @@ interface SpoolmanConfig {
 interface InventoryConfig {
 interface InventoryConfig {
   onAssignSpool?: () => void;
   onAssignSpool?: () => void;
   onUnassignSpool?: () => void;
   onUnassignSpool?: () => void;
-  assignedSpool?: { id: number; material: string; brand: string | null; color_name: string | null } | null;
+  assignedSpool?: { id: number; material: string; brand: string | null; color_name: string | null; remainingWeightGrams?: number | null } | null;
 }
 }
 
 
 interface ConfigureSlotConfig {
 interface ConfigureSlotConfig {
@@ -148,6 +148,7 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
   };
   };
 
 
   const colorHex = data.colorHex ? `#${data.colorHex.replace('#', '')}` : null;
   const colorHex = data.colorHex ? `#${data.colorHex.replace('#', '')}` : null;
+  const assignedRemainingWeight = inventory?.assignedSpool?.remainingWeightGrams ?? null;
 
 
   return (
   return (
     <div
     <div
@@ -238,7 +239,10 @@ export function FilamentHoverCard({ data, children, disabled, className = '', sp
                     {t('ams.fill')}
                     {t('ams.fill')}
                   </span>
                   </span>
                   <span className="text-xs text-white font-semibold flex items-center gap-1">
                   <span className="text-xs text-white font-semibold flex items-center gap-1">
-                    {data.fillLevel !== null ? `${data.fillLevel}%` : '—'}
+                    <span>{data.fillLevel !== null ? `${data.fillLevel}%` : '—'}</span>
+                    {assignedRemainingWeight !== null && data.fillLevel !== null && (
+                      <span className="text-[9px] text-bambu-gray font-normal">• {assignedRemainingWeight}g</span>
+                    )}
                     {data.fillSource === 'spoolman' && data.fillLevel !== null && (
                     {data.fillSource === 'spoolman' && data.fillLevel !== null && (
                       <span className="text-[9px] text-bambu-gray font-normal">{t('spoolman.fillSourceLabel')}</span>
                       <span className="text-[9px] text-bambu-gray font-normal">{t('spoolman.fillSourceLabel')}</span>
                     )}
                     )}

+ 2 - 8
frontend/src/components/FileManagerModal.tsx

@@ -29,6 +29,7 @@ import { ModelViewer } from './ModelViewer';
 import { GcodeViewer } from './GcodeViewer';
 import { GcodeViewer } from './GcodeViewer';
 import type { PlateMetadata } from '../types/plates';
 import type { PlateMetadata } from '../types/plates';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { formatFileSize } from '../utils/file';
 
 
 interface FileManagerModalProps {
 interface FileManagerModalProps {
   printerId: number;
   printerId: number;
@@ -235,14 +236,6 @@ function PrinterFileViewerModal({ printerId, filePath, filename, onClose }: Prin
   );
   );
 }
 }
 
 
-function formatFileSize(bytes: number): string {
-  if (bytes === 0) return '0 B';
-  const k = 1024;
-  const sizes = ['B', 'KB', 'MB', 'GB'];
-  const i = Math.floor(Math.log(bytes) / Math.log(k));
-  return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
-}
-
 function formatStorageSize(bytes: number): string {
 function formatStorageSize(bytes: number): string {
   if (bytes === 0) return '0 GB';
   if (bytes === 0) return '0 GB';
   const gb = bytes / (1024 * 1024 * 1024);
   const gb = bytes / (1024 * 1024 * 1024);
@@ -253,6 +246,7 @@ function formatStorageSize(bytes: number): string {
   return `${mb.toFixed(0)} MB`;
   return `${mb.toFixed(0)} MB`;
 }
 }
 
 
+
 function getFileIcon(filename: string, isDirectory: boolean) {
 function getFileIcon(filename: string, isDirectory: boolean) {
   if (isDirectory) return Folder;
   if (isDirectory) return Folder;
 
 

+ 3 - 25
frontend/src/components/GitHubBackupSettings.tsx

@@ -35,6 +35,7 @@ import { Button } from './Button';
 import { Toggle } from './Toggle';
 import { Toggle } from './Toggle';
 import { ConfirmModal } from './ConfirmModal';
 import { ConfirmModal } from './ConfirmModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { formatRelativeTime } from '../utils/date';
 
 
 interface StatusBadgeProps {
 interface StatusBadgeProps {
   status: string | null;
   status: string | null;
@@ -71,29 +72,6 @@ function formatDateTime(dateStr: string | null): string {
   return date.toLocaleString();
   return date.toLocaleString();
 }
 }
 
 
-function formatRelativeTime(dateStr: string | null): string {
-  if (!dateStr) return '-';
-  const date = new Date(dateStr);
-  const now = new Date();
-  const diffMs = date.getTime() - now.getTime();
-  const diffMins = Math.round(diffMs / 60000);
-
-  if (diffMins < 0) {
-    const absMins = Math.abs(diffMins);
-    if (absMins < 60) return `${absMins}m ago`;
-    const hours = Math.floor(absMins / 60);
-    if (hours < 24) return `${hours}h ago`;
-    const days = Math.floor(hours / 24);
-    return `${days}d ago`;
-  } else {
-    if (diffMins < 60) return `in ${diffMins}m`;
-    const hours = Math.floor(diffMins / 60);
-    if (hours < 24) return `in ${hours}h`;
-    const days = Math.floor(hours / 24);
-    return `in ${days}d`;
-  }
-}
-
 export function GitHubBackupSettings() {
 export function GitHubBackupSettings() {
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
   const { showToast } = useToast();
@@ -563,7 +541,7 @@ export function GitHubBackupSettings() {
                   <div className="flex items-center gap-2 text-bambu-gray">
                   <div className="flex items-center gap-2 text-bambu-gray">
                     {status.last_backup_at ? (
                     {status.last_backup_at ? (
                       <>
                       <>
-                        <span>Last backup: {formatRelativeTime(status.last_backup_at)}</span>
+                        <span>Last backup: {formatRelativeTime(status.last_backup_at, 'system', t)}</span>
                         <StatusBadge status={status.last_backup_status} />
                         <StatusBadge status={status.last_backup_status} />
                       </>
                       </>
                     ) : (
                     ) : (
@@ -573,7 +551,7 @@ export function GitHubBackupSettings() {
                   {status.next_scheduled_run && (
                   {status.next_scheduled_run && (
                     <span className="text-bambu-gray">
                     <span className="text-bambu-gray">
                       <Clock className="w-3 h-3 inline mr-1" />
                       <Clock className="w-3 h-3 inline mr-1" />
-                      Next: {formatRelativeTime(status.next_scheduled_run)}
+                      Next: {formatRelativeTime(status.next_scheduled_run, 'system', t)}
                     </span>
                     </span>
                   )}
                   )}
                 </div>
                 </div>

+ 20 - 8
frontend/src/components/ModelViewerModal.tsx

@@ -263,17 +263,29 @@ export function ModelViewerModal({ archiveId, libraryFileId, title, fileType, on
 
 
   const canOpenInSlicer = isLibrary ? (fileType || '').toLowerCase() === '3mf' : true;
   const canOpenInSlicer = isLibrary ? (fileType || '').toLowerCase() === '3mf' : true;
 
 
-  const handleOpenInSlicer = () => {
+  const handleOpenInSlicer = async () => {
     if (!canOpenInSlicer) return;
     if (!canOpenInSlicer) return;
-    // URL must include .3mf filename for Bambu Studio to recognize the format
     const filename = title || 'model';
     const filename = title || 'model';
-    if (isLibrary) {
-      const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
-      openInSlicer(downloadUrl, preferredSlicer);
-      return;
+    try {
+      if (isLibrary) {
+        const { token } = await api.createLibrarySlicerToken(libraryFileId!);
+        const path = api.getLibrarySlicerDownloadUrl(libraryFileId!, token, filename);
+        openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
+      } else {
+        const { token } = await api.createArchiveSlicerToken(archiveId!);
+        const path = api.getArchiveSlicerDownloadUrl(archiveId!, token, filename);
+        openInSlicer(`${window.location.origin}${path}`, preferredSlicer);
+      }
+    } catch {
+      // Fallback to direct URL (works when auth is disabled)
+      if (isLibrary) {
+        const downloadUrl = `${window.location.origin}${api.getLibraryFileDownloadUrl(libraryFileId!)}`;
+        openInSlicer(downloadUrl, preferredSlicer);
+      } else {
+        const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
+        openInSlicer(downloadUrl, preferredSlicer);
+      }
     }
     }
-    const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archiveId!, filename)}`;
-    openInSlicer(downloadUrl, preferredSlicer);
   };
   };
 
 
   return (
   return (

+ 1 - 6
frontend/src/components/PendingUploadsPanel.tsx

@@ -8,12 +8,7 @@ import { Card, CardContent, CardHeader } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { ConfirmModal } from './ConfirmModal';
 import { ConfirmModal } from './ConfirmModal';
-
-function formatFileSize(bytes: number): string {
-  if (bytes < 1024) return `${bytes} B`;
-  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
-  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
-}
+import { formatFileSize } from '../utils/file';
 
 
 function formatTimeAgo(dateStr: string): string {
 function formatTimeAgo(dateStr: string): string {
   const date = new Date(dateStr);
   const date = new Date(dateStr);

+ 2 - 16
frontend/src/components/PrinterQueueWidget.tsx

@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
-import { parseUTCDate } from '../utils/date';
+import { formatRelativeTime } from '../utils/date';
 
 
 interface PrinterQueueWidgetProps {
 interface PrinterQueueWidgetProps {
   printerId: number;
   printerId: number;
@@ -13,20 +13,6 @@ interface PrinterQueueWidgetProps {
   plateCleared?: boolean;
   plateCleared?: boolean;
 }
 }
 
 
-function formatRelativeTime(dateString: string | null): string {
-  if (!dateString) return 'ASAP';
-  const date = parseUTCDate(dateString);
-  if (!date) return 'ASAP';
-  const now = new Date();
-  const diff = date.getTime() - now.getTime();
-
-  if (diff < 0) return 'Now';
-  if (diff < 60000) return 'In <1 min';
-  if (diff < 3600000) return `In ${Math.round(diff / 60000)} min`;
-  if (diff < 86400000) return `In ${Math.round(diff / 3600000)}h`;
-  return date.toLocaleDateString();
-}
-
 export function PrinterQueueWidget({ printerId, printerState, plateCleared }: PrinterQueueWidgetProps) {
 export function PrinterQueueWidget({ printerId, printerState, plateCleared }: PrinterQueueWidgetProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
@@ -117,7 +103,7 @@ export function PrinterQueueWidget({ printerId, printerState, plateCleared }: Pr
         <div className="flex items-center gap-2 flex-shrink-0">
         <div className="flex items-center gap-2 flex-shrink-0">
           <span className="text-xs text-bambu-gray flex items-center gap-1">
           <span className="text-xs text-bambu-gray flex items-center gap-1">
             <Clock className="w-3 h-3" />
             <Clock className="w-3 h-3" />
-            {formatRelativeTime(nextItem?.scheduled_time || null)}
+            {nextItem?.scheduled_time ? formatRelativeTime(nextItem.scheduled_time, 'system', t) : t('time.waiting')}
           </span>
           </span>
           {totalPending > 1 && (
           {totalPending > 1 && (
             <span className="text-xs px-1.5 py-0.5 bg-yellow-400/20 text-yellow-400 rounded">
             <span className="text-xs px-1.5 py-0.5 bg-yellow-400/20 text-yellow-400 rounded">

+ 7 - 1
frontend/src/components/SpoolFormModal.tsx

@@ -320,7 +320,7 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
     if (!validation.isValid) {
     if (!validation.isValid) {
       setErrors(validation.errors);
       setErrors(validation.errors);
       // Switch to filament tab if there are errors there
       // Switch to filament tab if there are errors there
-      if (validation.errors.slicer_filament || validation.errors.material) {
+      if (validation.errors.slicer_filament || validation.errors.material || validation.errors.brand || validation.errors.subtype) {
         setActiveTab('filament');
         setActiveTab('filament');
       }
       }
       return;
       return;
@@ -438,6 +438,12 @@ export function SpoolFormModal({ isOpen, onClose, spool, printersWithCalibration
                 {errors.material && (
                 {errors.material && (
                   <p className="mt-1 text-xs text-red-400">{errors.material}</p>
                   <p className="mt-1 text-xs text-red-400">{errors.material}</p>
                 )}
                 )}
+                {errors.brand && (
+                  <p className="mt-1 text-xs text-red-400">{errors.brand}</p>
+                )}
+                {errors.subtype && (
+                  <p className="mt-1 text-xs text-red-400">{errors.subtype}</p>
+                )}
               </div>
               </div>
 
 
               {/* Color Section */}
               {/* Color Section */}

+ 2 - 2
frontend/src/components/spool-form/FilamentSection.tsx

@@ -161,7 +161,7 @@ export function FilamentSection({
 
 
       {/* Brand (dropdown with search) */}
       {/* Brand (dropdown with search) */}
       <div>
       <div>
-        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.brand')}</label>
+        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.brand')} *</label>
         <div className="relative" ref={brandRef}>
         <div className="relative" ref={brandRef}>
           <input
           <input
             type="text"
             type="text"
@@ -221,7 +221,7 @@ export function FilamentSection({
 
 
       {/* Variant / Subtype */}
       {/* Variant / Subtype */}
       <div>
       <div>
-        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.subtype')}</label>
+        <label className="block text-sm font-medium text-bambu-gray mb-1">{t('inventory.subtype')} *</label>
         <div className="relative" ref={subtypeRef}>
         <div className="relative" ref={subtypeRef}>
           <input
           <input
             type="text"
             type="text"

+ 8 - 0
frontend/src/components/spool-form/types.ts

@@ -117,6 +117,14 @@ export function validateForm(formData: SpoolFormData): ValidationResult {
     errors.material = 'Material is required';
     errors.material = 'Material is required';
   }
   }
 
 
+  if (!formData.brand) {
+    errors.brand = 'Brand is required';
+  }
+
+  if (!formData.subtype) {
+    errors.subtype = 'Subtype is required';
+  }
+
   return {
   return {
     isValid: Object.keys(errors).length === 0,
     isValid: Object.keys(errors).length === 0,
     errors,
     errors,

+ 4 - 1
frontend/src/i18n/index.ts

@@ -8,6 +8,7 @@ import de from './locales/de';
 import fr from './locales/fr';
 import fr from './locales/fr';
 import ja from './locales/ja';
 import ja from './locales/ja';
 import it from './locales/it';
 import it from './locales/it';
+import ptBR from './locales/pt-BR';
 
 
 const resources = {
 const resources = {
   en: { translation: en },
   en: { translation: en },
@@ -15,6 +16,7 @@ const resources = {
   fr: { translation: fr },
   fr: { translation: fr },
   ja: { translation: ja },
   ja: { translation: ja },
   it: { translation: it },
   it: { translation: it },
+  'pt-BR': { translation: ptBR },
 };
 };
 
 
 i18n
 i18n
@@ -23,7 +25,7 @@ i18n
   .init({
   .init({
     resources,
     resources,
     fallbackLng: 'en',
     fallbackLng: 'en',
-    supportedLngs: ['en', 'de', 'fr', 'ja', 'it'],
+    supportedLngs: ['en', 'de', 'fr', 'ja', 'it', 'pt-BR'],
 
 
     detection: {
     detection: {
       // Order of detection methods
       // Order of detection methods
@@ -52,4 +54,5 @@ export const availableLanguages = [
   { code: 'fr', name: 'French', nativeName: 'Français' },
   { code: 'fr', name: 'French', nativeName: 'Français' },
   { code: 'ja', name: 'Japanese', nativeName: '日本語' },
   { code: 'ja', name: 'Japanese', nativeName: '日本語' },
   { code: 'it', name: 'Italian', nativeName: 'Italiano' },
   { code: 'it', name: 'Italian', nativeName: 'Italiano' },
+  { code: 'pt-BR', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
 ];
 ];

+ 16 - 0
frontend/src/i18n/locales/de.ts

@@ -2682,6 +2682,8 @@ export default {
     kFactor: 'K-Faktor',
     kFactor: 'K-Faktor',
     fill: 'Füllstand',
     fill: 'Füllstand',
     configure: 'Konfigurieren',
     configure: 'Konfigurieren',
+    used: 'verwendet',
+    remainingUnit: 'verbleibend',
   },
   },
 
 
   // Print modal
   // Print modal
@@ -3433,4 +3435,18 @@ export default {
 
 
   // Spoolman Settings
   // Spoolman Settings
   spoolmanSettings: {},
   spoolmanSettings: {},
+
+  // Time
+  time: {
+    unknown: '-',
+    waiting: 'Wartend',
+    justNow: 'Gerade eben',
+    now: 'Jetzt',
+    minsAgo: 'vor {{count}}m',
+    inMins: 'in {{count}}m',
+    hoursAgo: 'vor {{count}}h',
+    inHours: 'in {{count}}h',
+    daysAgo: 'vor {{count}}d',
+    inDays: 'in {{count}}d',
+  },
 };
 };

+ 16 - 0
frontend/src/i18n/locales/en.ts

@@ -2686,6 +2686,8 @@ export default {
     kFactor: 'K Factor',
     kFactor: 'K Factor',
     fill: 'Fill',
     fill: 'Fill',
     configure: 'Configure',
     configure: 'Configure',
+    used: 'used',
+    remainingUnit: 'remaining',
   },
   },
 
 
   // Print modal
   // Print modal
@@ -3438,4 +3440,18 @@ export default {
 
 
   // Spoolman Settings
   // Spoolman Settings
   spoolmanSettings: {},
   spoolmanSettings: {},
+
+  // Time
+  time: {
+    unknown: '-',
+    waiting: 'Waiting',
+    justNow: 'Just now',
+    now: 'Now',
+    minsAgo: '{{count}}m ago',
+    inMins: 'in {{count}}m',
+    hoursAgo: '{{count}}h ago',
+    inHours: 'in {{count}}h',
+    daysAgo: '{{count}}d ago',
+    inDays: 'in {{count}}d',
+  },
 };
 };

+ 16 - 0
frontend/src/i18n/locales/fr.ts

@@ -2674,6 +2674,8 @@ export default {
     kFactor: 'Facteur K',
     kFactor: 'Facteur K',
     fill: 'Remplir',
     fill: 'Remplir',
     configure: 'Configurer',
     configure: 'Configurer',
+    used: 'utilisé',
+    remainingUnit: 'restant',
   },
   },
 
 
   // Print modal
   // Print modal
@@ -3426,4 +3428,18 @@ export default {
 
 
   // Spoolman Settings
   // Spoolman Settings
   spoolmanSettings: {},
   spoolmanSettings: {},
+
+  // Time
+  time: {
+    unknown: '-',
+    waiting: 'En attente',
+    justNow: 'À l\'instant',
+    now: 'Maintenant',
+    minsAgo: 'il y a {{count}}m',
+    inMins: 'dans {{count}}m',
+    hoursAgo: 'il y a {{count}}h',
+    inHours: 'dans {{count}}h',
+    daysAgo: 'il y a {{count}}j',
+    inDays: 'dans {{count}}j',
+  },
 };
 };

+ 18 - 2
frontend/src/i18n/locales/it.ts

@@ -2347,7 +2347,7 @@ export default {
     kProfiles: 'K-Profiles',
     kProfiles: 'K-Profiles',
     addKProfile: 'Aggiungi K-Profile',
     addKProfile: 'Aggiungi K-Profile',
     assignSpool: 'Assegna Bobina',
     assignSpool: 'Assegna Bobina',
-    unassignSpool: 'Deassegna',
+    unassignSpool: 'Scollega',
     assignSuccess: 'Bobina assegnata e slot AMS configurato',
     assignSuccess: 'Bobina assegnata e slot AMS configurato',
     assignFailed: 'Assegnazione bobina fallita',
     assignFailed: 'Assegnazione bobina fallita',
     selectSpool: 'Seleziona una bobina da assegnare a questo slot',
     selectSpool: 'Seleziona una bobina da assegnare a questo slot',
@@ -2397,8 +2397,10 @@ export default {
     externalSpool: 'Bobina esterna',
     externalSpool: 'Bobina esterna',
     profile: 'Profilo',
     profile: 'Profilo',
     kFactor: 'K Factor',
     kFactor: 'K Factor',
-    fill: 'Riempi',
+    fill: 'Livello',
     configure: 'Configura',
     configure: 'Configura',
+    used: 'utilizzato',
+    remainingUnit: 'rimanente',
   },
   },
 
 
   // Print modal
   // Print modal
@@ -2814,4 +2816,18 @@ export default {
     configuring: 'Configurazione...',
     configuring: 'Configurazione...',
     configureSlot: 'Configura slot',
     configureSlot: 'Configura slot',
   },
   },
+
+  // Time
+  time: {
+    unknown: '-',
+    waiting: 'In attesa',
+    justNow: 'Proprio ora',
+    now: 'Ora',
+    minsAgo: '{{count}}m fa',
+    inMins: 'tra {{count}}m',
+    hoursAgo: '{{count}}h fa',
+    inHours: 'tra {{count}}h',
+    daysAgo: '{{count}}g fa',
+    inDays: 'tra {{count}}g',
+  },
 };
 };

+ 16 - 0
frontend/src/i18n/locales/ja.ts

@@ -2604,6 +2604,8 @@ export default {
     kFactor: 'K値',
     kFactor: 'K値',
     fill: '充填率',
     fill: '充填率',
     configure: '設定',
     configure: '設定',
+    used: '使用済み',
+    remainingUnit: '残り',
   },
   },
   printModal: {
   printModal: {
     flowCalibration: 'フローキャリブレーション',
     flowCalibration: 'フローキャリブレーション',
@@ -3269,4 +3271,18 @@ export default {
 
 
   // Spoolman Settings
   // Spoolman Settings
   spoolmanSettings: {},
   spoolmanSettings: {},
+
+  // Time
+  time: {
+    unknown: '-',
+    waiting: '待機中',
+    justNow: 'たった今',
+    now: '今すぐ',
+    minsAgo: '{{count}}分前',
+    inMins: 'あと{{count}}分',
+    hoursAgo: '{{count}}時間前',
+    inHours: 'あと{{count}}時間',
+    daysAgo: '{{count}}日前',
+    inDays: 'あと{{count}}日',
+  },
 };
 };

+ 3441 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -0,0 +1,3441 @@
+export default {
+  // Navigation
+  nav: {
+    printers: 'Impressoras',
+    archives: 'Arquivos',
+    queue: 'Fila',
+    stats: 'Estatísticas',
+    profiles: 'Perfis',
+    maintenance: 'Manutenção',
+    projects: 'Projetos',
+    inventory: 'Inventário',
+    files: 'Gerenciador de Arquivos',
+    settings: 'Configurações',
+    system: 'Sistema',
+    collapseSidebar: 'Recolher barra lateral',
+    expandSidebar: 'Expandir barra lateral',
+    update: 'Atualizar',
+    updateAvailable: 'Atualização disponível: v{{version}}',
+    updateAvailableBanner: 'Versão {{version}} está disponível!',
+    viewUpdate: 'Ver atualização',
+    viewOnGithub: 'Ver no GitHub',
+    keyboardShortcuts: 'Atalhos de teclado (?)',
+    switchToLight: 'Mudar para modo claro',
+    switchToDark: 'Mudar para modo escuro',
+    smartSwitches: 'Interruptores inteligentes',
+    logout: 'Sair',
+  },
+
+  // Common
+  common: {
+    save: 'Salvar',
+    saving: 'Salvando...',
+    cancel: 'Cancelar',
+    delete: 'Excluir',
+    edit: 'Editar',
+    add: 'Adicionar',
+    close: 'Fechar',
+    confirm: 'Confirmar',
+    loading: 'Carregando...',
+    error: 'Erro',
+    success: 'Sucesso',
+    warning: 'Aviso',
+    enabled: 'Ativado',
+    disabled: 'Desativado',
+    yes: 'Sim',
+    no: 'Não',
+    on: 'Ligado',
+    off: 'Desligado',
+    all: 'Todos',
+    none: 'Nenhum',
+    search: 'Pesquisar',
+    filter: 'Filtrar',
+    sort: 'Ordenar',
+    refresh: 'Atualizar',
+    download: 'Baixar',
+    upload: 'Enviar',
+    actions: 'Ações',
+    status: 'Status',
+    name: 'Nome',
+    description: 'Descrição',
+    date: 'Data',
+    time: 'Hora',
+    hours: 'Horas',
+    minutes: 'Minutos',
+    seconds: 'Segundos',
+    days: 'Dias',
+    enable: 'Ativar',
+    disable: 'Desativar',
+    permissions: 'Permissões',
+    noPrinters: 'Nenhuma impressora configurada',
+    noData: 'Nenhum dado disponível',
+    linkNotFound: 'Link não encontrado',
+    required: 'Obrigatório',
+    optional: 'Opcional',
+    dismiss: 'Dispensar',
+    apply: 'Aplicar',
+    reset: 'Redefinir',
+    export: 'Exportar',
+    import: 'Importar',
+    clear: 'Limpar',
+    selectAll: 'Selecionar tudo',
+    deselectAll: 'Desmarcar tudo',
+    noChange: '— Sem alterações —',
+    unchanged: 'Inalterado',
+    unassigned: 'Não atribuído',
+    unknown: 'Desconhecido',
+    unknownError: 'Erro desconhecido',
+    today: 'Hoje',
+    tomorrow: 'Amanhã',
+    asap: 'O mais rápido possível',
+    overdue: 'Atrasado',
+    now: 'Agora',
+    collapse: 'Recolher',
+    expand: 'Expandir',
+    viewArchive: 'Ver arquivo',
+    viewInFileManager: 'Ver no Gerenciador de Arquivos',
+    addedBy: 'Adicionado por {{username}}',
+    prints: 'impressões',
+    more: '+{{count}} mais',
+    ascending: 'Crescente',
+    descending: 'Decrescente',
+    printer: 'Impressora',
+    remove: 'Remover',
+    type: 'Tipo',
+    print: 'Imprimir',
+    rename: 'Renomear',
+    move: 'Mover',
+    create: 'Criar',
+    duplicate: 'Duplicar',
+    left: 'Esquerda',
+    right: 'Direita',
+  },
+
+  // Printers page
+  printers: {
+    title: 'Impressoras',
+    addPrinter: 'Adicionar Impressora',
+    editPrinter: 'Editar Impressora',
+    deletePrinter: 'Excluir Impressora',
+    printerName: 'Nome da Impressora',
+    serialNumber: 'Número de Série',
+    ipAddress: 'Endereço IP / Nome do Host',
+    accessCode: 'Código de Acesso',
+    model: 'Modelo',
+    nozzleCount: 'Número de Bicos',
+    autoArchive: 'Arquivamento Automático',
+    status: {
+      available: 'Disponível',
+      idle: 'Ocioso',
+      printing: 'Imprimindo',
+      paused: 'Pausado',
+      offline: 'Offline',
+      error: 'Erro',
+      finished: 'Concluído',
+      unknown: 'Desconhecido',
+    },
+    temperatures: {
+      nozzle: 'Bico',
+      bed: 'Cama',
+      chamber: 'Câmara',
+    },
+    progress: '{{percent}}% concluído',
+    timeRemaining: '{{time}} restante',
+    deleteConfirm: 'Tem certeza de que deseja excluir "{{name}}"?',
+    maintenanceOk: 'Manutenção OK',
+    maintenanceWarning: '{{count}} aviso',
+    maintenanceWarning_plural: '{{count}} avisos',
+    maintenanceDue: '{{count}} devido',
+    maintenanceDue_plural: '{{count}} devido',
+    // Sort options
+    sort: {
+      name: 'Nome',
+      status: 'Status',
+      model: 'Modelo',
+      location: 'Localização',
+      ascending: 'Ordem crescente',
+      descending: 'Ordem decrescente',
+    },
+    // Card size
+    cardSize: {
+      small: 'Cartões pequenos',
+      medium: 'Cartões médios',
+      large: 'Cartões grandes',
+      extraLarge: 'Cartões extra grandes',
+    },
+    // Controls
+    hideOffline: 'Ocultar offline',
+    nextAvailable: 'Próximo disponível',
+    powerOn: 'Ligar',
+    offlinePrintersWithPlugs: 'Impressoras offline com tomadas inteligentes',
+    noPrintersConfigured: 'Nenhuma impressora configurada ainda',
+    // Printer card
+    readyToPrint: 'Pronto para imprimir',
+    external: 'Externo',
+    extL: 'Ext-L',
+    extR: 'Ext-R',
+    deleteArchives: 'Excluir arquivos de impressão',
+    noLabel: 'Sem etiqueta',
+    printPreview: 'Pré-visualização de impressão',
+    width: 'Largura',
+    height: 'Altura',
+    noObjectsFound: 'Nenhum objeto encontrado',
+    objectsLoadedOnPrintStart: 'Objetos são carregados quando uma impressão começa',
+    willBeSkipped: 'Será ignorado',
+    name: 'Nome',
+    serialCannotBeChanged: 'Número de série não pode ser alterado',
+    locationHelp: 'Usado para agrupar impressoras e filtrar trabalhos na fila',
+    // WiFi signal strength
+    wifiSignal: {
+      veryWeak: 'Muito fraco',
+      weak: 'Fraco',
+      fair: 'Regular',
+      good: 'Bom',
+      excellent: 'Excelente',
+    },
+    // Maintenance
+    maintenanceUpToDate: 'Toda a manutenção está em dia - Clique para ver',
+    // Chamber light
+    chamberLightOn: 'Ligar luz da câmara',
+    chamberLightOff: 'Desligar luz da câmara',
+    // Files
+    browseFiles: 'Procurar arquivos da impressora',
+    // Smart plug
+    autoOffAfterPrint: 'Desligamento automático após impressão',
+    autoOffExecuted: 'Desligamento automático executado - ligue a impressora para reiniciar',
+    // HMS errors
+    hmsErrors: 'Erros HMS',
+    viewHmsErrors: 'Ver {{count}} erro(s) HMS',
+    // Actions
+    resume: 'Retomar',
+    pause: 'Pausar',
+    stop: 'Parar',
+    camera: 'âmera',
+    skipObject: 'Ignorar objeto',
+    reconnect: 'Reconectar',
+    mqttDebug: 'Depuração MQTT',
+    activeNozzle: 'Ativo: {{nozzle}} bico',
+    nozzleRack: 'Suporte de bicos',
+    nozzleDocked: 'Acoplado',
+    nozzleMounted: 'Montado',
+    nozzleActive: 'Ativo',
+    nozzleIdle: 'Ocioso',
+    nozzleDiameter: 'Diâmetro',
+    nozzleType: 'Tipo',
+    nozzleStatus: 'Status',
+    nozzleFilament: 'Filamento',
+    nozzleWear: 'Desgaste',
+    nozzleMaxTemp: 'Temp Máx',
+    nozzleSerial: 'Serial',
+    nozzleHardenedSteel: 'Aço Endurecido',
+    nozzleStainlessSteel: 'Aço Inoxidável',
+    nozzleTungstenCarbide: 'Carboneto de Tungstênio',
+    nozzleFlow: 'Fluxo',
+    nozzleHighFlow: 'Alto Fluxo',
+    nozzleStandardFlow: 'Fluxo Padrão',
+    // Firmware
+    firmwareUpdate: 'Atualização de Firmware',
+    firmwareInstructions: 'No visor da impressora, vá para',
+    firmwareNav: 'Navegar para',
+    settings: 'Configurações',
+    firmware: 'Firmware',
+    // Discovery
+    discoverPrinters: 'Descobrir Impressoras',
+    searching: 'Procurando...',
+    manualEntry: 'Entrada Manual',
+    addFromCloud: 'Adicionar da Nuvem',
+    // Toast messages
+    toast: {
+      printerDeleted: 'Impressora excluída',
+      printerAdded: 'Impressora adicionada',
+      printerUpdated: 'Impressora atualizada',
+      failedToDelete: 'Falha ao excluir impressora',
+      failedToAdd: 'Falha ao adicionar impressora',
+      failedToUpdate: 'Falha ao atualizar impressora',
+      commandSent: 'Comando enviado',
+      failedToSendCommand: 'Falha ao enviar comando',
+      turnedOn: '{{name}} ligado',
+      failedToPowerOn: 'Falha ao ligar {{name}}',
+      scriptTriggered: 'Script acionado',
+      printStopped: 'Impressão parada',
+      printPaused: 'Impressão pausada',
+      printResumed: 'Impressão retomada',
+      referenceDeleted: 'Referência excluída',
+      detectionAreaSaved: 'Área de detecção salva',
+      failedToRunScript: 'Falha ao executar script',
+      failedToStopPrint: 'Falha ao parar impressão',
+      failedToPausePrint: 'Falha ao pausar impressão',
+      failedToResumePrint: 'Falha ao retomar impressão',
+      failedToControlChamberLight: 'Falha ao controlar a luz da câmara',
+      failedToUpdateSetting: 'Falha ao atualizar configuração',
+      failedToSkipObjects: 'Falha ao ignorar objetos',
+      failedToRereadRfid: 'Falha ao reler RFID',
+      failedToCheckPlate: 'Falha ao verificar a placa',
+      failedToUpdateLabel: 'Falha ao atualizar etiqueta',
+      failedToDeleteReference: 'Falha ao excluir referência',
+      failedToSaveDetectionArea: 'Falha ao salvar área de detecção',
+      plateCheckEnabled: 'Verificação da placa ativada',
+      plateCheckDisabled: 'Verificação da placa desativada',
+      calibrationSaved: 'Calibração salva!',
+      calibrationFailed: 'Falha na calibração',
+      rfidRereadInitiated: 'Releitura de RFID iniciada',
+    },
+    // Connection status
+    connection: {
+      connected: 'Conectado',
+      offline: 'Offline',
+    },
+    // Queue info
+    queue: {
+      inQueue: '{{count}} impressão na fila',
+      inQueue_plural: '{{count}} impressões na fila',
+    },
+    // Controls section
+    controls: 'Controles',
+    // RFID
+    rfid: {
+      reread: 'Releitura de RFID',
+    },
+    // Permissions
+    permission: {
+      noAdd: 'Você não tem permissão para adicionar impressoras',
+      noEdit: 'Você não tem permissão para editar impressoras',
+      noDelete: 'Você não tem permissão para excluir impressoras',
+      noControl: 'Você não tem permissão para controlar impressoras',
+      noFiles: 'Você não tem permissão para acessar arquivos de impressora',
+      noAmsRfid: 'Você não tem permissão para reler RFID AMS',
+      noSmartPlugControl: 'Você não tem permissão para controlar tomadas inteligentes',
+    },
+    // Add/Edit modal
+    modal: {
+      addTitle: 'Adicionar Impressora',
+      editTitle: 'Editar Impressora',
+      myPrinter: 'Minha Impressora',
+      selectModel: 'Selecionar modelo...',
+      locationGroup: 'Localização / Grupo (opcional)',
+      locationPlaceholder: 'ex.: Oficina, Escritório, Porão',
+      autoArchiveLabel: 'Arquivar automaticamente impressões concluídas',
+      fromPrinterSettings: 'A partir das configurações da impressora',
+      modelOptional: 'Modelo (opcional)',
+      saveChanges: 'Salvar alterações',
+    },
+    // Skip objects
+    skipObjects: {
+      tooltip: 'Ignorar objetos',
+      onlyWhilePrinting: 'Ignorar objetos (apenas durante a impressão)',
+      requiresMultiple: 'Ignorar objetos (requer 2+ objetos)',
+      title: 'Ignorar Objetos',
+      matchIdsInfo: 'Correspondência de IDs com o display da sua impressora',
+      printerShowsIds: 'A tela da impressora mostra os IDs dos objetos na placa de construção',
+      skipSelected: 'Ignorar Selecionados',
+      skipping: 'Ignorando...',
+      noObjectsSelected: 'Nenhum objeto selecionado',
+      selectObjectsToSkip: 'Selecione os objetos que deseja ignorar na impressão atual',
+      skipped: 'Ignorado',
+      objectsSkipped: 'Objetos ignorados',
+      activeCount: '{{count}} ativo',
+      waitForLayer: 'Aguarde a camada 2+ para ignorar objetos (atualmente na camada {{layer}})',
+      skip: 'Ignorar',
+      confirmTitle: 'Ignorar Objeto?',
+      confirmMessage: 'Tem certeza de que deseja ignorar "{{name}}"? Isso não pode ser desfeito.',
+    },
+    // Confirm modals
+    confirm: {
+      deleteTitle: 'Excluir Impressora',
+      deleteMessage: 'Tem certeza de que deseja excluir "{{name}}"? Isso removerá todas as configurações de conexão.',
+      deleteArchivesNote: 'Todo o histórico de impressão desta impressora será permanentemente excluído.',
+      keepArchivesNote: 'O histórico de impressão será mantido, mas não estará mais associado a esta impressora.',
+      stopTitle: 'Parar Impressão',
+      stopMessage: 'Tem certeza de que deseja parar a impressão atual em "{{name}}"? Isso cancelará o trabalho de impressão.',
+      stopButton: 'Parar Impressão',
+      pauseTitle: 'Pausar Impressão',
+      pauseMessage: 'Tem certeza de que deseja pausar a impressão atual em "{{name}}"?',
+      pauseButton: 'Pausar Impressão',
+      resumeTitle: 'Retomar Impressão',
+      resumeMessage: 'Tem certeza de que deseja retomar a impressão em "{{name}}"?',
+      resumeButton: 'Retomar Impressão',
+      powerOnTitle: 'Ligar Impressora',
+      powerOnMessage: 'Tem certeza de que deseja ligar a impressora "{{name}}"?',
+      powerOnButton: 'Ligar',
+      powerOffTitle: 'Desligar Impressora',
+      powerOffMessage: 'Tem certeza de que deseja desligar a impressora "{{name}}"?',
+      powerOffWarning: 'AVISO: "{{name}}" está imprimindo no momento! Tem certeza de que deseja desligar a impressora? Isso interromperá a impressão e pode danificar a impressora.',
+      powerOffButton: 'Desligar',
+    },
+    // Discovery
+    discovery: {
+      title: 'Descobrir Impressoras',
+      searching: 'Procurando...',
+      scanning: 'Escaneando...',
+      scanProgress: 'Escaneando... {{scanned}}/{{total}}',
+      foundPrinters: '{{count}} impressora(s) encontrada(s)',
+      noPrintersFound: 'Nenhuma impressora encontrada',
+      noPrintersFoundSubnet: 'Nenhuma impressora encontrada na sub-rede especificada.',
+      noPrintersFoundNetwork: 'Nenhuma impressora encontrada na rede.',
+      allConfigured: 'Todas as impressoras descobertas já estão configuradas.',
+      alreadyAdded: 'Já adicionada',
+      select: 'Selecionar',
+      manualEntry: 'Entrada Manual',
+      addFromCloud: 'Adicionar da Nuvem',
+      subnetToScan: 'Sub-rede para escanear',
+      dockerNote: 'Docker detectado. Insira a sub-rede da sua impressora em notação CIDR. Requer network_mode: host no docker-compose.yml.',
+      scanSubnet: 'Escanear Sub-rede para Impressoras',
+      discoverNetwork: 'Descobrir Impressoras na Rede',
+      scanningSubnet: 'Escaneando sub-rede para impressoras Bambu...',
+      scanningNetwork: 'Escaneando rede...',
+      serialRequired: 'Serial necessário',
+      unknown: 'Desconhecido',
+      failedToStart: 'Falha ao iniciar a descoberta',
+    },
+    // Filaments section
+    filaments: 'Filamentos',
+    // Camera
+    openCameraOverlay: 'Abrir sobreposição da câmera',
+    openCameraWindow: 'Abrir câmera em nova janela',
+    // Firmware
+    firmwareUpdateAvailable: 'Atualização de firmware disponível: {{current}} → {{latest}}',
+    firmwareUpToDate: 'Firmware {{version}} — Atualizado',
+    firmwareUpdateButton: 'Atualizar',
+    // Plate detection
+    plateDetection: {
+      noPermission: 'Você não tem permissão para atualizar impressoras',
+      enabledClick: 'Verificação da placa ativada - Clique para desativar',
+      disabledClick: 'Verificação da placa desativada - Clique para ativar',
+      manageCalibration: 'Gerenciar calibração da detecção da placa',
+      calibrationRequired: 'Calibração necessária',
+      calibrationInstructions: 'Certifique-se de que a placa de construção esteja <strong>completamente vazia</strong>, em seguida clique em Calibrar.',
+      calibrationDescription: 'A calibração captura uma imagem de referência da placa vazia. Verificações futuras compararão com esta referência para detectar objetos.',
+      calibrationTip: '<strong>Dica:</strong> Você pode armazenar até 5 calibrações para diferentes placas. O sistema usa automaticamente a melhor correspondência ao verificar.',
+      plateEmpty: 'A placa parece vazia',
+      objectsDetected: 'Objetos detectados na placa',
+      confidence: 'Confiança',
+      difference: 'Diferença',
+      analysisPreview: 'Pré-visualização da análise:',
+      analysisLegend: 'Caixa verde = área de detecção, Sobreposição vermelha = diferenças em relação à calibração',
+      savedReferences: 'Referências salvas ({{count}}/{{max}})',
+      deleteReference: 'Excluir referência',
+      labelPlaceholder: 'Etiqueta...',
+      clickToEdit: '{{label}} - Clique para editar',
+      clickToAddLabel: 'Clique para adicionar etiqueta',
+    },
+    // Fans
+    fans: {
+      partCooling: 'Ventilador de resfriamento da peça',
+      auxiliary: 'Ventilador auxiliar',
+      chamber: 'Ventilador da câmara',
+    },
+    // HMS errors
+    clickToViewHmsErrors: 'Clique para ver erros do HMS',
+    estimatedCompletion: 'Tempo estimado de conclusão',
+    slotOptions: 'Opções de slot',
+    // Firmware modal
+    firmwareModal: {
+      title: 'Atualização de Firmware',
+      titleUpToDate: 'Informações do Firmware',
+      currentVersion: 'Atual:',
+      latestVersion: 'Última:',
+      releaseNotes: 'Notas de Lançamento',
+      checkingPrereqs: 'Verificando pré-requisitos...',
+      sdCardReady: 'Cartão SD pronto. Clique abaixo para enviar o firmware.',
+      uploadedSuccess: 'Firmware enviado para o cartão SD!',
+      applyInstructions: 'Para aplicar a atualização na sua impressora:',
+      step1: 'Na tela sensível ao toque da impressora, vá para <strong>Configurações</strong>',
+      step2: 'Navegue até <strong>Firmware</strong>',
+      step3: 'Selecione <strong>Atualizar a partir do cartão SD</strong>',
+      step4: 'A atualização levará de 10 a 20 minutos',
+      done: 'Concluído',
+      starting: 'Iniciando...',
+      uploadFirmware: 'Enviar Firmware',
+      uploadFailed: 'Falha ao iniciar o envio: {{error}}',
+      uploadedToast: 'Firmware enviado! Inicie a atualização na tela da impressora.',
+    },
+    accessCodePlaceholder: 'Deixe vazio para manter o atual',
+    // ROI editor
+    roi: {
+      title: 'Área de Detecção (ROI)',
+      xStart: 'Início X',
+      yStart: 'Início Y',
+      width: 'Largura',
+      height: 'Altura',
+      instruction: 'Ajuste a área de detecção para focar na placa de construção. A caixa verde na pré-visualização mostra a área atual.',
+    },
+  },
+
+  // Archives page
+  archives: {
+    title: 'Arquivos de Impressão',
+    searchPlaceholder: 'Pesquisar arquivos...',
+    filterByPrinter: 'Filtrar por impressora',
+    filterByStatus: 'Filtrar por status',
+    sortBy: 'Ordenar por',
+    sortNewest: 'Mais recentes primeiro',
+    sortOldest: 'Mais antigos primeiro',
+    sortName: 'Nome',
+    sortDuration: 'Duração',
+    sortLargest: 'Maiores primeiro',
+    sortSmallest: 'Menores primeiro',
+    sortSize: 'Tamanho',
+    noArchives: 'Nenhum arquivo encontrado',
+    noArchivesSearch: 'Nenhum arquivo corresponde à sua pesquisa',
+    noArchivesYet: 'Ainda não há arquivos',
+    loadingArchives: 'Carregando arquivos...',
+    releaseToUpload: 'Solte para enviar',
+    showAll: 'Mostrar todos',
+    showFavoritesOnly: 'Mostrar apenas favoritos',
+    gridView: 'Visualização em grade',
+    listView: 'Visualização em lista',
+    calendarView: 'Visualização em calendário',
+    logView: 'Registro de impressão',
+    manageTags: 'Gerenciar etiquetas',
+    showFailedPrints: 'Mostrar impressões falhas',
+    hideFailedPrints: 'Ocultar impressões falhas',
+    printTime: 'Tempo de impressão',
+    filamentUsed: 'Filamento usado',
+    cost: 'Custo',
+    reprint: 'Reimprimir',
+    preview: 'Pré-visualizar',
+    deleteArchive: 'Excluir arquivo',
+    deleteConfirm: 'Tem certeza de que deseja excluir este arquivo?',
+    favorite: 'Favorito',
+    unfavorite: 'Remover dos favoritos',
+    viewDetails: 'Ver detalhes',
+    status: {
+      completed: 'Concluído',
+      failed: 'Falhou',
+      stopped: 'Parado',
+    },
+    toast: {
+      source3mfAttached: 'Arquivo de origem 3MF anexado: {{filename}}',
+      failedUploadSource3mf: 'Falha ao enviar arquivo de origem 3MF',
+      source3mfRemoved: 'Arquivo de origem 3MF removido',
+      failedRemoveSource3mf: 'Falha ao remover arquivo de origem 3MF',
+      f3dAttached: 'F3D anexado: {{filename}}',
+      failedUploadF3d: 'Falha ao enviar F3D',
+      f3dRemoved: 'F3D removido',
+      failedRemoveF3d: 'Falha ao remover F3D',
+      timelapseAttached: 'Timelapse anexado: {{filename}}',
+      timelapseAlreadyAttached: 'Timelapse já anexado',
+      noMatchingTimelapse: 'Nenhum timelapse correspondente encontrado',
+      failedScanTimelapse: 'Falha ao escanear timelapse',
+      failedAttachTimelapse: 'Falha ao anexar timelapse',
+      timelapseRemoved: 'Timelapse removido',
+      failedRemoveTimelapse: 'Falha ao remover timelapse',
+      timelapseUploaded: 'Timelapse enviado: {{filename}}',
+      failedUploadTimelapse: 'Falha ao enviar timelapse',
+      archiveDeleted: 'Arquivo excluído',
+      failedDeleteArchive: 'Falha ao excluir arquivo',
+      addedToFavorites: 'Adicionado aos favoritos',
+      removedFromFavorites: 'Removido dos favoritos',
+      projectUpdated: 'Projeto atualizado',
+      failedUpdateProject: 'Falha ao atualizar projeto',
+      linkCopied: 'Link copiado para a área de transferência',
+      failedCopyLink: 'Falha ao copiar link',
+      photoDeleted: 'Foto excluída',
+      failedDeletePhoto: 'Falha ao excluir foto',
+      failedDeleteArchives: 'Falha ao excluir arquivos',
+      failedUpdateFavorites: 'Falha ao atualizar favoritos',
+      exportDownloaded: 'Exportação baixada',
+      exportFailed: 'Falha na exportação',
+    },
+    menu: {
+      print: 'Imprimir',
+      schedule: 'Agendar',
+      openInBambuStudio: 'Abrir no Slicer',
+      slice: 'Fatiar',
+      externalLink: 'Link externo',
+      viewOnMakerWorld: 'Ver no MakerWorld',
+      preview3d: 'Pré-visualização 3D',
+      viewTimelapse: 'Ver Timelapse',
+      scanForTimelapse: 'Escanear Timelapse',
+      uploadTimelapse: 'Enviar Timelapse',
+      removeTimelapse: 'Remover Timelapse',
+      downloadSource3mf: 'Baixar Source 3MF',
+      uploadSource3mf: 'Enviar Source 3MF',
+      replaceSource3mf: 'Substituir Source 3MF',
+      removeSource3mf: 'Remover Source 3MF',
+      uploadF3d: 'Enviar F3D',
+      replaceF3d: 'Substituir F3D',
+      downloadF3d: 'Baixar F3D',
+      removeF3d: 'Remover F3D',
+      download: 'Baixar',
+      copyDownloadLink: 'Copiar link de download',
+      qrCode: 'Qr Code',
+      viewPhotos: 'Ver fotos',
+      viewPhotosCount: 'Ver fotos ({{count}})',
+      projectPage: 'Página do projeto',
+      addToFavorites: 'Adicionar aos favoritos',
+      removeFromFavorites: 'Remover dos favoritos',
+      edit: 'Editar',
+      goToProject: 'Ir para o projeto: {{name}}',
+      addToProject: 'Adicionar ao projeto',
+      removeFromProject: 'Remover do projeto',
+      loading: 'Carregando...',
+      noProjectsAvailable: 'Nenhum projeto disponível',
+      select: 'Selecionar',
+      deselect: 'Desmarcar',
+      delete: 'Excluir',
+    },
+    permission: {
+      noReprint: 'Você não tem permissão para reimprimir este arquivo',
+      noAddToQueue: 'Você não tem permissão para adicionar à fila',
+      noUpdateArchives: 'Você não tem permissão para atualizar arquivos',
+      noUploadFiles: 'Você não tem permissão para enviar arquivos',
+      noDownload: 'Você não tem permissão para baixar arquivos',
+      noCopyLink: 'Você não tem permissão para copiar links de download',
+      noDelete: 'Você não tem permissão para excluir este arquivo',
+      noCreate: 'Você não tem permissão para criar arquivos',
+    },
+    card: {
+      previousPlate: 'Placa anterior',
+      nextPlate: 'Próxima placa',
+      plateNumber: 'Placa {{index}}',
+      moreOptions: 'Clique com o botão direito para mais opções',
+      addToFavorites: 'Adicionar aos favoritos',
+      removeFromFavorites: 'Remover dos favoritos',
+      cancelled: 'cancelado',
+      failed: 'falha',
+      duplicate: 'duplicado',
+      duplicateTitle: 'Este modelo já foi impresso antes',
+      openSource3mf: 'Abrir source 3MF no Bambu Studio (clique com o botão direito para mais opções)',
+      downloadF3d: 'Baixar arquivo de design do Fusion 360',
+      viewTimelapse: 'Ver timelapse',
+      viewPhoto: 'Ver 1 foto',
+      viewPhotos: 'Ver {{count}} fotos',
+      openFolder: 'Abrir pasta: {{name}}',
+      slicedFile: 'Arquivo fatiado - pronto para imprimir',
+      sourceFile: 'Apenas arquivo fonte - nenhum mapeamento AMS disponível',
+      gcode: 'GCODE',
+      source: 'SOURCE',
+      project: 'Projeto: {{name}}',
+      estimated: 'Estimado: {{time}}',
+      actual: 'Real: {{time}}',
+      accuracy: 'Precisão: {{percent}}%',
+      filament: '{{weight}}g',
+      layer: '{{count}} camada',
+      layers: '{{count}} camadas',
+      object: '{{count}} objeto',
+      objects: '{{count}} objetos',
+      slicedFor: 'Fatiado para {{model}}',
+      uploadedBy: 'Enviado por',
+      noPermissionReprint: 'Você não tem permissão para reimprimir',
+      noFileForReprint: 'Nenhum arquivo 3MF disponível — o arquivo não pôde ser baixado da impressora quando a impressão foi registrada',
+      noPermissionEdit: 'Você não tem permissão para editar arquivos',
+      noPermissionDelete: 'Você não tem permissão para excluir arquivos',
+      reprint: 'Reimprimir',
+      schedulePrint: 'Agendar impressão',
+      schedule: 'Agendar',
+      openInBambuStudio: 'Abrir no Bambu Studio',
+      openInBambuStudioToSlice: 'Abrir no Bambu Studio para fatiar',
+      slice: 'Fatiar',
+      externalLink: 'Link externo',
+      makerWorld: 'MakerWorld: {{designer}}',
+      viewProject: 'Ver projeto',
+      noExternalLink: 'Nenhum link externo',
+      preview3d: 'Visualização 3D',
+      download: 'Baixar',
+      edit: 'Editar',
+      delete: 'Excluir',
+    },
+    modal: {
+      deleteArchive: 'Excluir Arquivo',
+      deleteConfirm: 'Tem certeza de que deseja excluir "{{name}}"? Esta ação não pode ser desfeita.',
+      deleteButton: 'Excluir',
+      removeSource3mf: 'Remover Source 3MF',
+      removeSource3mfConfirm: 'Tem certeza de que deseja remover o arquivo source 3MF de "{{name}}"? Isso excluirá o arquivo original do projeto do fatiador.',
+      removeButton: 'Remover',
+      removeF3d: 'Remover F3D',
+      removeF3dConfirm: 'Tem certeza de que deseja remover o arquivo de design do Fusion 360 de "{{name}}"?',
+      removeTimelapse: 'Remover Timelapse',
+      removeTimelapseConfirm: 'Tem certeza de que deseja remover o vídeo timelapse de "{{name}}"?',
+      timelapse: '{{name}} - Timelapse',
+      selectTimelapse: 'Selecionar Timelapse',
+      selectTimelapseDesc: 'Nenhuma correspondência automática encontrada. Selecione o timelapse para esta impressão:',
+      deleteArchives: 'Excluir Arquivos',
+      deleteArchivesConfirm: 'Tem certeza de que deseja excluir {{count}} arquivo(s)? Esta ação não pode ser desfeita.',
+      deleteCount: 'Excluir {{count}}',
+    },
+    page: {
+      title: 'Arquivos',
+      printsCount: '{{filtered}} de {{total}} impressões',
+      dropFilesHere: 'Solte arquivos .3mf aqui',
+      releaseToUpload: 'Solte para enviar',
+      only3mfSupported: 'Apenas arquivos .3mf são suportados',
+      close: 'Fechar',
+      selected: '{{count}} selecionado(s)',
+      selectAll: 'Selecionar Todos',
+      tags: 'Tags',
+      project: 'Projeto',
+      favorite: 'Favorito',
+      delete: 'Excluir',
+      toggledFavorites: 'Favoritos alternados para {{count}} arquivo(s)',
+      failedUpdateFavorites: 'Falha ao atualizar favoritos',
+      archivesDeleted: '{{count}} arquivo(s) excluído(s)',
+      failedDeleteArchives: 'Falha ao excluir arquivos',
+      photoDeleted: 'Foto excluída',
+      failedDeletePhoto: 'Falha ao excluir foto',
+    },
+    list: {
+      name: 'Nome',
+      printer: 'Impressora',
+      date: 'Data',
+      size: 'Tamanho',
+      actions: 'Ações',
+      hasTimelapse: 'Possui timelapse',
+    },
+    log: {
+      date: 'Data',
+      printName: 'Nome da Impressão',
+      printer: 'Impressora',
+      user: 'Usuário',
+      status: 'Status',
+      duration: 'Duração',
+      filament: 'Filamento',
+      allPrinters: 'Todas as Impressoras',
+      allUsers: 'Todos os Usuários',
+      allStatuses: 'Todos os Status',
+      cancelled: 'Cancelado',
+      skipped: 'Ignorado',
+      dateFrom: 'De',
+      dateTo: 'Até',
+      noEntries: 'Nenhuma entrada de registro de impressão encontrada',
+      showing: 'Mostrando {{count}} de {{total}} entradas',
+      rowsPerPage: 'Linhas',
+      page: 'Página',
+      prev: 'Anterior',
+      next: 'Próxima',
+      clearLog: 'Limpar Registro',
+      clearLogTitle: 'Limpar Registro de Impressão',
+      clearLogConfirm: 'Todas as entradas do registro de impressão serão permanentemente excluídas. Arquivos e itens da fila não serão afetados. Esta ação não pode ser desfeita. Tem certeza?',
+      clearLogButton: 'Limpar Tudo',
+      cleared: '{{count}} entradas do registro de impressão limpas',
+      clearFailed: 'Falha ao limpar o registro de impressão',
+    },
+  },
+
+  // Queue page
+  queue: {
+    title: 'Fila de Impressão',
+    subtitle: 'Agende e gerencie seus trabalhos de impressão',
+    addToQueue: 'Adicionar à Fila',
+    // Print modal
+    print: 'Imprimir',
+    reprint: 'Reimprimir',
+    schedulePrint: 'Agendar Impressão',
+    editQueueItem: 'Editar Item da Fila',
+    printToPrinters: 'Imprimir para {{count}} Impressoras',
+    queueToPrinters: 'Adicionar à Fila para {{count}} Impressoras',
+    sending: 'Enviando...',
+    sendingProgress: 'Enviando {{current}}/{{total}}...',
+    adding: 'Adicionando...',
+    addingProgress: 'Adicionando {{current}}/{{total}}...',
+    savingProgress: 'Salvando {{current}}/{{total}}...',
+    clearQueue: 'Limpar Fila',
+    clearHistory: 'Limpar Histórico',
+    emptyQueue: 'Fila vazia',
+    position: 'Posição',
+    scheduledTime: 'Hora Agendada',
+    moveUp: 'Mover para Cima',
+    moveDown: 'Mover para Baixo',
+    startNow: 'Iniciar Agora',
+    printingInProgress: 'Impressão em andamento...',
+    viewArchive: 'Ver Arquivo',
+    viewInFileManager: 'Ver no Gerenciador de Arquivos',
+    itemCount: '{{count}} item',
+    itemCount_plural: '{{count}} itens',
+    dragToReorder: 'Arraste para reordenar (apenas ASAP)',
+    reorderHint: 'A posição afeta apenas itens ASAP. Itens agendados são executados no horário definido.',
+    addedBy: 'Adicionado por {{name}}',
+    nextInQueue: 'Próximo na fila',
+    clearPlate: 'Limpar Placa e Iniciar Próximo',
+    clearPlateSuccess: 'Placa limpa — pronta para a próxima impressão',
+    plateReady: 'Placa limpa — pronta para a próxima impressão',
+    plateNumber: 'Placa {{index}}',
+    // Sections
+    sections: {
+      currentlyPrinting: 'Imprimindo Atualmente',
+      queued: 'Na Fila',
+      history: 'Histórico',
+    },
+    // Status
+    status: {
+      pending: 'Pendente',
+      waiting: 'Aguardando',
+      printing: 'Imprimindo',
+      paused: 'Pausado',
+      completed: 'Concluído',
+      failed: 'Falhou',
+      skipped: 'Ignorado',
+      cancelled: 'Cancelado',
+    },
+    // Summary cards
+    summary: {
+      printing: 'Imprimindo',
+      queued: 'Na Fila',
+      totalTime: 'Tempo Total da Fila',
+      totalWeight: 'Peso Total da Fila',
+      history: 'Histórico',
+    },
+    // Filters
+    filter: {
+      allPrinters: 'Todas as Impressoras',
+      unassigned: 'Não Atribuído',
+      allStatus: 'Todos os Status',
+      allLocations: 'Todos os Locais',
+      any: 'Qualquer',
+    },
+    // Sort
+    sort: {
+      byPosition: 'Ordenar por Posição',
+      byName: 'Ordenar por Nome',
+      byPrinter: 'Ordenar por Impressora',
+      bySchedule: 'Ordenar por Agendamento',
+      byDate: 'Ordenar por Data',
+      ascendingOldest: 'Crescente (mais antigo primeiro)',
+      descendingNewest: 'Decrescente (mais recente primeiro)',
+    },
+    // Badges
+    badges: {
+      staged: 'Preparado (início manual)',
+      requiresPrevious: 'Requer sucesso anterior',
+      autoPowerOff: 'Desligamento automático',
+    },
+    // Empty state
+    empty: {
+      title: 'Nenhuma impressão agendada',
+      description: 'Agende uma impressão a partir da página de Arquivos usando a opção "Agendar" no menu de contexto, ou arraste e solte arquivos para começar.',
+    },
+    // Time
+    time: {
+      asap: 'ASAP',
+      overdue: 'Atrasado',
+      now: 'Agora',
+      lessThanMinute: 'Em menos de um minuto',
+      inMinutes: 'Em {{count}} min',
+      inHours: 'Em {{count}} horas',
+    },
+    // Actions
+    actions: {
+      stopPrint: 'Parar Impressão',
+      startPrint: 'Iniciar Impressão',
+      requeue: 'Reenfileirar',
+    },
+    // Bulk edit
+    bulkEdit: {
+      title: 'Editar {{count}} Item',
+      title_plural: 'Editar {{count}} Itens',
+      description: 'Apenas as configurações alteradas serão aplicadas aos itens selecionados.',
+      printer: 'Impressora',
+      noChange: '— Sem alterações —',
+      queueOptions: 'Opções de Fila',
+      staged: 'Preparado (início manual)',
+      autoPowerOff: 'Desligamento automático após impressão',
+      requirePrevious: 'Requer sucesso anterior',
+      printOptions: 'Opções de Impressão',
+      bedLevelling: 'Nivelamento da Mesa',
+      flowCalibration: 'Calibração de Fluxo',
+      vibrationCalibration: 'Calibração de Vibração',
+      layerInspection: 'Inspeção da Primeira Camada',
+      timelapse: 'Timelapse',
+      useAms: 'Usar AMS',
+      applyChanges: 'Aplicar Alterações',
+      selectAll: 'Selecionar Todos',
+      deselectAll: 'Desmarcar Todos',
+      selected: '{{count}} selecionado(s)',
+      editSelected: 'Editar Selecionados',
+      cancelSelected: 'Cancelar Selecionados',
+    },
+    // Confirmations
+    confirm: {
+      cancelTitle: 'Cancelar Impressão Agendada',
+      cancelMessage: 'Tem certeza de que deseja cancelar "{{name}}"?',
+      stopTitle: 'Parar Impressão',
+      stopMessage: 'Tem certeza de que deseja parar a impressão atual "{{name}}"? Isso cancelará o trabalho de impressão na impressora.',
+      removeTitle: 'Remover do Histórico',
+      removeMessage: 'Tem certeza de que deseja remover "{{name}}" do histórico da fila?',
+      clearHistoryTitle: 'Limpar Histórico',
+      clearHistoryMessage: 'Tem certeza de que deseja remover todos os {{count}} itens do histórico?',
+      cancelButton: 'Cancelar Impressão',
+      stopButton: 'Parar Impressão',
+      thisPrint: 'esta impressão',
+      thisItem: 'este item',
+    },
+    // Toast messages
+    toast: {
+      cancelled: 'Item da fila cancelado',
+      cancelFailed: 'Falha ao cancelar item',
+      removed: 'Item da fila removido',
+      removeFailed: 'Falha ao remover item',
+      stopped: 'Impressão parada',
+      stopFailed: 'Falha ao parar impressão',
+      released: 'Impressão liberada para a fila',
+      startFailed: 'Falha ao iniciar impressão',
+      reorderFailed: 'Falha ao reordenar fila',
+      historyCleared: 'Limpar {{count}} item(s) do histórico',
+      clearHistoryFailed: 'Falha ao limpar histórico',
+      updateFailed: 'Falha ao atualizar itens',
+      bulkCancelled: 'Cancelado {{count}} item(s)',
+      bulkCancelFailed: 'Falha ao cancelar itens',
+    },
+    // Permissions
+    permissions: {
+      noStopPrint: 'Você não tem permissão para parar impressões',
+      noStartPrint: 'Você não tem permissão para iniciar impressões',
+      noEdit: 'Você não tem permissão para editar este item da fila',
+      noCancel: 'Você não tem permissão para cancelar este item da fila',
+      noRequeue: 'Você não tem permissão para reenfileirar itens',
+      noRemove: 'Você não tem permissão para remover este item da fila',
+      noClearHistory: 'Você não tem permissão para limpar todo o histórico',
+      noEditItems: 'Você não tem permissão para editar itens da fila',
+      noCancelItems: 'Você não tem permissão para cancelar itens da fila',
+    },
+  },
+
+  // Statistics page
+  stats: {
+    title: 'Dashboard',
+    subtitle: 'Arraste os widgets para reorganizar. Clique no ícone de olho para ocultar.',
+    overview: 'Visão Geral',
+    totalPrints: 'Total de Impressões',
+    successRate: 'Taxa de Sucesso',
+    totalPrintTime: 'Tempo Total de Impressão',
+    printTime: 'Tempo de Impressão',
+    totalFilament: 'Filamento Total Utilizado',
+    filamentUsed: 'Filamento Utilizado',
+    filamentCost: 'Custo do Filamento',
+    totalCost: 'Custo Total',
+    energyUsed: 'Energia Utilizada',
+    energyCost: 'Custo da Energia',
+    averagePrintTime: 'Tempo Médio de Impressão',
+    printsPerDay: 'Impressões por Dia',
+    byPrinter: 'Por Impressora',
+    printsByPrinter: 'Impressões por Impressora',
+    byMaterial: 'Por Material',
+    byMonth: 'Por Mês',
+    last7Days: 'Últimos 7 Dias',
+    last30Days: 'Últimos 30 Dias',
+    last90Days: 'Últimos 90 Dias',
+    allTime: 'Todo o Tempo',
+    // Widgets
+    quickStats: 'Estatísticas Rápidas',
+    printActivity: 'Atividade de Impressão',
+    filamentTypes: 'Tipos de Filamento',
+    filamentTrends: 'Tendências de Filamento',
+    failureAnalysis: 'Análise de Falhas',
+    timeAccuracy: 'Precisão do Tempo',
+    successful: 'Bem-sucedido:',
+    failed: 'Falhou:',
+    perfectEstimate: '100% = estimativa perfeita',
+    noTimeAccuracyData: 'Nenhum dado de precisão de tempo disponível',
+    noFilamentData: 'Nenhum dado de filamento disponível',
+    noPrinterData: 'Nenhum dado de impressora disponível',
+    noPrintData: 'Nenhum dado de impressão disponível',
+    noPrintDataLast30Days: 'Nenhum dado de impressão nos últimos 30 dias',
+    failureReasons: 'Razões de Falha',
+    topFailureReasons: 'Principais Razões de Falha',
+    failedPrintsCount: '{{failed}} / {{total}} impressões falharam',
+    lastWeekRate: 'Última semana: {{rate}}%',
+    // Actions
+    resetLayout: 'Redefinir Layout',
+    recalculateCosts: 'Recalcular Custos',
+    recalculateCostsHint: 'Recalcular todos os custos do arquivo usando os preços atuais do filamento',
+    exportStats: 'Exportar Estatísticas',
+    exportAsCsv: 'Exportar como CSV',
+    exportAsExcel: 'Exportar como Excel',
+    hiddenCount: '{{count}} Oculto',
+    // Toast
+    exportDownloaded: 'Exportação baixada',
+    exportFailed: 'Falha na exportação',
+    layoutReset: 'Layout redefinido',
+    recalculatedCosts: 'Custos recalculados para {{count}} arquivos',
+    recalculateFailed: 'Falha ao recalcular custos',
+    // Loading
+    loadingStats: 'Carregando estatísticas...',
+    // Permissions
+    noPermissionResetLayout: 'Você não tem permissão para redefinir o layout',
+    noPermissionRecalculate: 'Você não tem permissão para recalcular custos',
+  },
+
+  // Maintenance page
+  maintenance: {
+    title: 'Manutenção',
+    overview: 'Visão Geral',
+    allOk: 'Todas as manutenções estão em dia',
+    dueCount: '{{count}} item pendente',
+    dueCount_plural: '{{count}} itens pendentes',
+    warningCount: '{{count}} aviso',
+    warningCount_plural: '{{count}} avisos',
+    totalPrintTime: 'Tempo Total de Impressão',
+    nextMaintenance: 'Próxima Manutenção',
+    nothingDue: 'Nada pendente',
+    tasks: 'Tarefas',
+    lastPerformed: 'Última execução',
+    interval: 'Intervalo',
+    hoursRemaining: '{{hours}}h restantes',
+    hoursOverdue: '{{hours}}h atrasadas',
+    markDone: 'Marcar como Concluída',
+    performMaintenance: 'Realizar Manutenção',
+    history: 'Histórico',
+    noHistory: 'Nenhum histórico de manutenção',
+    editPrintHours: 'Editar Horas de Impressão',
+    currentHours: 'Horas Atuais',
+    // Tabs
+    statusTab: 'Status',
+    settingsTab: 'Configurações',
+    // Status
+    overdueCount: '{{count}} atrasado',
+    dueSoonCount: '{{count}} prestes a vencer',
+    dueSoon: 'Prestes a vencer',
+    allGood: 'Tudo certo',
+    overdueBy: 'Atrasado por {{duration}}',
+    dueIn: 'Vence em {{duration}}',
+    timeLeft: '{{duration}} restantes',
+    // Duration formats
+    day: '1 dia',
+    days: '{{count}} dias',
+    week: '1 semana',
+    weeks: '{{count}} semanas',
+    month: '1 mês',
+    months: '{{count}} meses',
+    year: '1 ano',
+    // Settings
+    maintenanceTypes: 'Tipos de Manutenção',
+    maintenanceTypesDescription: 'Tipos de sistema e suas tarefas de manutenção personalizadas',
+    addCustomType: 'Adicionar Tipo Personalizado',
+    restoreDefaults: 'Restaurar Tarefas Padrão',
+    intervalType: 'Tipo de Intervalo',
+    intervalValue: 'Intervalo ({{type}})',
+    icon: 'Icon',
+    documentationLink: 'Link da Documentação (opcional)',
+    assignToPrinters: 'Atribuir a Impressoras',
+    selectAtLeastOnePrinter: 'Selecione pelo menos uma impressora',
+    addType: 'Adicionar Tipo',
+    custom: 'Personalizado',
+    printHours: 'Horas de Impressão',
+    calendarDays: 'Dias de Calendário',
+    exampleName: 'ex., Substituir Filtro HEPA',
+    viewDocumentation: 'Ver documentação',
+    timeBasedInterval: 'Intervalo baseado em tempo',
+    // Interval overrides
+    intervalOverrides: 'Substituições de Intervalo',
+    intervalOverridesDescription: 'Personalize os intervalos para impressoras específicas',
+    // Printer assignment
+    assignedToPrinters: 'Atribuído a impressoras:',
+    noPrintersAssigned: 'Nenhuma impressora atribuída',
+    addPrinterShort: 'Adicionar:',
+    printersAssignedClick: '{{count}} impressora(s) atribuída(s) - clique para gerenciar',
+    removeFromPrinter: 'Remover desta impressora',
+    // Types
+    types: {
+      lubricateCarbonRods: 'Lubricar Barras de Carbono',
+      lubricateRails: 'Lubricar Trilhos Lineares',
+      cleanNozzle: 'Limpar Bico/Hotend',
+      checkBelts: 'Verificar Tensão das Correias',
+      cleanBuildPlate: 'Limpar Plataforma de Impressão',
+      checkExtruder: 'Verificar Engrenagens do Extrusor',
+      checkCooling: 'Verificar Ventiladores de Resfriamento',
+      generalInspection: 'Inspeção Geral',
+      cleanCarbonRods: 'Limpar Barras de Carbono',
+      cleanLinearRails: 'Limpar Trilhos Lineares',
+      checkPtfeTube: 'Verificar Tubo PTFE',
+      replaceHepaFilter: 'Substituir Filtro HEPA',
+      replaceCarbonFilter: 'Substituir Filtro de Carbono',
+      lubricateLeftNozzleRail: 'Lubrificar Trilho do Bico Esquerdo',
+    },
+    // Toast
+    maintenanceComplete: 'Manutenção marcada como concluída',
+    typeUpdated: 'Tipo de manutenção atualizado',
+    typeDeleted: 'Tipo de manutenção excluído',
+    defaultsRestored: 'Restauradas {{count}} tarefa(s) padrão',
+    printHoursUpdated: 'Horas de impressão atualizadas',
+    printerAssigned: 'Impressora atribuída',
+    printerRemoved: 'Impressora removida',
+    // Confirmation
+    deleteTypeConfirm: 'Excluir "{{name}}"?',
+    deleteSystemTypeTitle: 'Excluir tarefa de manutenção padrão?',
+    deleteSystemTypeMessage: 'Tem certeza de que deseja excluir a tarefa de manutenção padrão "{{name}}"?',
+    // Permissions
+    noPermissionUpdate: 'Você não tem permissão para atualizar itens de manutenção',
+    noPermissionPerform: 'Você não tem permissão para realizar manutenção',
+    noPermissionEditTypes: 'Você não tem permissão para editar tipos de manutenção',
+    noPermissionDeleteTypes: 'Você não tem permissão para excluir tipos de manutenção',
+    noPermissionEditHours: 'Você não tem permissão para editar horas de impressão',
+    noPermissionRemovePrinter: 'Você não tem permissão para remover atribuições de impressora',
+    noPermissionAssignPrinter: 'Você não tem permissão para atribuir impressoras',
+    noPermissionEditIntervals: 'Você não tem permissão para editar intervalos',
+    // Configure link
+    configureSettings: 'Configure tipos de manutenção e intervalos',
+  },
+
+  // Settings page
+  settings: {
+    title: 'Configurações',
+    general: 'Geral',
+    // Tab names
+    tabs: {
+      general: 'Geral',
+      smartPlugs: 'Tomadas Inteligentes',
+      notifications: 'Notificações',
+      filament: 'Filamento',
+      network: 'Rede',
+      apiKeys: 'Chaves API',
+      virtualPrinter: 'Impressora Virtual',
+      users: 'Autenticação',
+      backup: 'Backup',
+      emailAuth: 'Autenticação por Email',
+    },
+    // Email settings
+    email: {
+      smtpSettings: 'Configuração SMTP',
+      smtpHost: 'Servidor SMTP',
+      smtpPort: 'Porta SMTP',
+      security: 'Segurança',
+      authentication: 'Autenticação',
+      username: 'Nome de Usuário',
+      password: 'Senha',
+      fromEmail: 'Email de Remetente',
+      fromName: 'Nome de Remetente',
+      testConnection: 'Testar Conexão SMTP',
+      testRecipient: 'Email de Teste',
+      sendTest: 'Enviar Email de Teste',
+      sending: 'Enviando...',
+      save: 'Salvar Configurações',
+      saving: 'Salvando...',
+      advancedAuth: 'Autenticação Avançada',
+      advancedAuthEnabled: 'Autenticação Avançada está habilitada',
+      advancedAuthEnabledDesc: 'Recursos de gerenciamento de usuários baseados em email estão ativos. Novos usuários receberão senhas geradas automaticamente por email, e os usuários podem redefinir suas senhas através do recurso de esqueci minha senha.',
+      advancedAuthDisabled: 'Autenticação Avançada está desabilitada',
+      advancedAuthDisabledDesc: 'Habilite a autenticação avançada para ativar recursos baseados em email para gerenciamento de usuários.',
+      enable: 'Habilitar',
+      disable: 'Desabilitar',
+      feature1: 'Senhas são geradas automaticamente e enviadas por email para novos usuários',
+      feature2: 'Usuários podem fazer login com nome de usuário ou email',
+      feature3: 'Recurso de esqueci minha senha está disponível',
+      feature4: 'Administradores podem redefinir senhas de usuários via email',
+      // Error messages
+      errors: {
+        requiredFields: 'Por favor, preencha todos os campos obrigatórios',
+        usernameRequired: 'Nome de usuário é obrigatório quando a autenticação está habilitada',
+        enterTestEmail: 'Por favor, insira um endereço de email de teste',
+        smtpServerAndEmail: 'Por favor, preencha o servidor SMTP e o email de remetente antes de testar',
+        usernamePasswordRequired: 'Nome de usuário e senha são obrigatórios quando a autenticação está habilitada',
+        configureSmtpFirst: 'Por favor, configure e teste as configurações SMTP primeiro',
+      },
+      // Success messages
+      success: {
+        settingsSaved: 'Configurações SMTP salvas com sucesso',
+      },
+      // Security options
+      securityOptions: {
+        starttls: 'STARTTLS (Porta 587)',
+        ssl: 'SSL/TLS (Porta 465)',
+        none: 'Nenhuma (Porta 25)',
+      },
+      // Authentication options
+      authOptions: {
+        enabled: 'Habilitado',
+        disabled: 'Desabilitado',
+      },
+    },
+    appearance: 'Aparência',
+    notifications: 'Notificações',
+    smartPlugs: 'Tomadas Inteligentes',
+    spoolman: 'Spoolman',
+    updates: 'Atualizações',
+    language: 'Idioma',
+    languageDescription: 'Selecione seu idioma preferido',
+    theme: 'Tema',
+    themeLight: 'Claro',
+    themeDark: 'Escuro',
+    themeSystem: 'Sistema',
+    defaultView: 'Visualização Padrão',
+    defaultViewDescription: 'Página a ser exibida ao abrir o aplicativo',
+    checkForUpdates: 'Verificar Atualizações',
+    autoUpdate: 'Atualização Automática',
+    currentVersion: 'Versão Atual',
+    latestVersion: 'Última Versão',
+    upToDate: 'Você está atualizado',
+    updateAvailable: 'Atualização disponível',
+    // Notifications
+    notificationLanguage: 'Idioma das Notificações',
+    notificationLanguageDescription: 'Idioma para notificações push',
+    bedCooledThreshold: 'Limite de Resfriamento da Cama',
+    bedCooledThresholdDescription: 'Temperatura abaixo da qual a cama é considerada resfriada após uma impressão',
+    notificationProviders: 'Provedores de Notificação',
+    addProvider: 'Adicionar Provedor',
+    editProvider: 'Editar Provedor',
+    providerType: 'Tipo de Provedor',
+    testNotification: 'Testar Notificação',
+    testSuccess: 'Notificação de teste enviada com sucesso',
+    testFailed: 'Falha ao enviar notificação de teste',
+    quietHours: 'Horas de Silêncio',
+    quietHoursDescription: 'Não perturbe durante essas horas',
+    quietHoursStart: 'Início',
+    quietHoursEnd: 'Fim',
+    events: {
+      title: 'Eventos de Notificação',
+      printStart: 'Impressão Iniciada',
+      printComplete: 'Impressão Concluída',
+      printFailed: 'Falha na Impressão',
+      printStopped: 'Impressão Interrompida',
+      printProgress: 'Marcos de Progresso',
+      printProgressDescription: 'Notificar em 25%, 50%, 75%',
+      printerOffline: 'Impressora Offline',
+      printerError: 'Erro na Impressora',
+      filamentLow: 'Filamento Baixo',
+      maintenanceDue: 'Manutenção Pendente',
+      maintenanceDueDescription: 'Notificar quando a manutenção for necessária',
+    },
+    // Smart Plugs
+    smartPlug: {
+      title: 'Tomadas Inteligentes',
+      add: 'Adicionar Tomada Inteligente',
+      edit: 'Editar Tomada Inteligente',
+      name: 'Nome',
+      ipAddress: 'Endereço IP',
+      linkedPrinter: 'Impressora Vinculada',
+      autoOn: 'Ligar Automaticamente',
+      autoOnDescription: 'Ligar quando a impressão começar',
+      autoOff: 'Desligar Automaticamente',
+      autoOffDescription: 'Desligar após a conclusão da impressão',
+      offDelay: 'Atraso para Desligar',
+      offDelayMinutes: 'Minutos após a impressão',
+      offDelayTemp: 'Quando o bico estiver abaixo da temperatura',
+      currentState: 'Estado Atual',
+      turnOn: 'Ligar',
+      turnOff: 'Desligar',
+    },
+    // Filament Tracking Mode
+    filamentTracking: 'Rastreamento de Filamento',
+    filamentTrackingDesc: 'Escolha como rastrear seus rolos de filamento. Você pode usar o inventário interno ou conectar a um servidor Spoolman externo.',
+    trackingModeBuiltIn: 'Inventário Interno',
+    trackingModeBuiltInDesc: 'Correspondência automática de RFID e rastreamento de uso incluídos',
+    trackingModeSpoolmanDesc: 'Servidor de gerenciamento de filamento externo',
+    builtInFeatureRfid: 'Detecta automaticamente rolos RFID da Bambu Lab no AMS',
+    builtInFeatureUsage: 'Rastreia o consumo de filamento por impressão',
+    builtInFeatureCatalog: 'Gerencia rolos, cores e perfis de fator K',
+    builtInFeatureThirdParty: 'Rolos de terceiros podem ser atribuídos aos rolos do inventário',
+    amsSyncButton: 'Sincronizar Pesos do AMS',
+    amsSyncTitle: 'Sincronizar Pesos dos Rolos do AMS',
+    amsSyncMessage: 'Isso substituirá todos os pesos dos rolos do inventário pelos valores atuais de % restante do AMS das impressoras conectadas. Use isso para recuperar dados de peso corrompidos. As impressoras devem estar online.',
+    amsSyncing: 'Sincronizando...',
+    amsSyncSuccess: '{{synced}} rolo(s) sincronizado(s), {{skipped}} ignorado(s)',
+    amsSyncError: 'Falha ao sincronizar pesos do AMS',
+    // Spoolman settings
+    spoolmanUrl: 'Spoolman URL',
+    spoolmanUrlHint: 'URL do seu servidor Spoolman (por exemplo, http://localhost:7912)',
+    spoolmanConnected: 'Conectado',
+    spoolmanDisconnected: 'Desconectado',
+    status: 'Status',
+    connect: 'Conectar',
+    disconnect: 'Desconectar',
+    howSyncWorks: 'Como a Sincronização Funciona',
+    syncInfoRfidOnly: 'Apenas rolos oficiais da Bambu Lab com RFID são sincronizados',
+    syncInfoAutoCreate: 'Novos rolos são criados automaticamente no Spoolman na primeira sincronização',
+    syncInfoThirdPartySkipped: 'Rolos não oficiais da Bambu Lab (terceiros, reabastecidos) são ignorados',
+    linkingExistingSpools: 'Vinculando Rolos Existentes',
+    linkingExistingSpoolsDesc: 'Para vincular rolos existentes do Spoolman ao seu AMS, passe o mouse sobre um slot do AMS e clique em "Vincular ao Spoolman".',
+    syncMode: 'Modo de Sincronização',
+    syncModeAuto: 'Automático',
+    syncModeManual: 'Apenas Manual',
+    syncModeAutoDesc: 'Os dados do AMS são sincronizados automaticamente quando alterações são detectadas',
+    syncModeManualDesc: 'Somente sincronize quando acionado manualmente',
+    syncAmsData: 'Sincronizar Dados do AMS',
+    syncAmsDataDesc: 'Sincronize manualmente os dados do AMS da impressora com o Spoolman',
+    allPrinters: 'Todas as Impressoras',
+    // Default printer
+    noDefaultPrinter: 'Sem padrão (perguntar a cada vez)',
+    // Sidebar
+    sidebarOrder: 'Ordem da barra lateral',
+    // Camera
+    saveThumbnails: 'Salvar miniaturas',
+    captureFinishPhoto: 'Capturar foto de conclusão',
+    noPrintersConfigured: 'Nenhuma impressora configurada',
+    // Archive settings
+    archiveMode: {
+      always: 'Sempre criar entrada de arquivo',
+      never: 'Nunca criar entrada de arquivo',
+      ask: 'Perguntar a cada vez',
+    },
+    // Updates
+    checkForUpdatesLabel: 'Verificar atualizações',
+    checkPrinterFirmware: 'Verificar firmware da impressora',
+    // Queue
+    enableRetry: 'Habilitar tentativa',
+    // Home Assistant
+    homeAssistantDescription: 'Controlar tomadas inteligentes via Home Assistant',
+    environmentManagedLabel: '(Gerenciado pelo Ambiente)',
+    autoEnabledViaEnv: 'Habilitado automaticamente via variáveis de ambiente',
+    urlFromEnvReadOnly: 'Valor definido pela variável de ambiente HA_URL (somente leitura)',
+    tokenFromEnvReadOnly: 'Valor definido pela variável de ambiente HA_TOKEN (somente leitura)',
+    // MQTT
+    mqttConnectedTo: 'Conectado a',
+    // Prometheus
+    prometheusDescription: 'Expor dados da impressora no formato Prometheus',
+    // Smart plugs empty state
+    noSmartPlugsTitle: 'Nenhuma tomada inteligente configurada',
+    noSmartPlugsDescription: 'Adicione uma tomada inteligente baseada em Tasmota para monitorar o consumo de energia e automatizar o controle de energia.',
+    // Notifications empty state
+    noProvidersTitle: 'Nenhum provedor configurado',
+    noProvidersDescription: 'Adicione um provedor para receber alertas.',
+    noTemplatesAvailable: 'Nenhum modelo disponível. Reinicie o backend para gerar os modelos padrão.',
+    // API permissions
+    apiPermissionView: 'Visualizar status da impressora e fila',
+    apiPermissionEdit: 'Adicionar e remover itens da fila de impressão',
+    // API keys
+    apiKeysEmptyTitle: 'Nenhuma chave API',
+    apiKeysEmptyDescription: 'Crie uma chave API para integrar com serviços externos.',
+    // Users
+    noUsersFound: 'Nenhum usuário encontrado',
+    noGroupsFound: 'Nenhum grupo encontrado',
+    noGroupsAvailable: 'Nenhum grupo disponível',
+    passwordsDoNotMatch: 'As senhas não coincidem',
+    systemGroupWarning: 'Os nomes dos grupos do sistema não podem ser alterados',
+    // Auth disabled
+    authDisabledTitle: 'Autenticação Desativada',
+    authDisabledFeature1: 'Exigir login para acessar o sistema',
+    authDisabledFeature2: 'Criar múltiplos usuários com permissões baseadas em grupos',
+    authDisabledFeature3: 'Controlar acesso com mais de 50 permissões granulares',
+    // User deletion
+    userHasCreated: 'Este usuário criou:',
+    userItemsQuestion: 'O que você gostaria de fazer com esses itens?',
+    deleteUserConfirm: 'Tem certeza de que deseja excluir este usuário?',
+    actionCannotBeUndone: 'Esta ação não pode ser desfeita.',
+    // Smart plugs
+    addFirstSmartPlug: 'Adicione sua primeira tomada inteligente',
+    // Notifications
+    providers: 'Provedores',
+    log: 'Registro',
+    testAll: 'Testar tudo',
+    testResults: 'Resultados do teste',
+    testPassedCount: '{{count}} aprovado',
+    testFailedCount: '{{count}} falhou',
+    messageTemplates: 'Modelos de mensagem',
+    messageTemplatesDescription: 'Personalize as mensagens de notificação para cada evento.',
+    // API Keys section
+    apiKeys: 'Chaves API',
+    apiKeysDescription: 'Crie chaves API para integrações externas e webhooks.',
+    createKey: 'Criar Chave',
+    apiKeyCreated: 'Chave API criada com sucesso',
+    apiKeyCopyWarning: "Copie esta chave agora - ela não será exibida novamente!",
+    useInApiBrowser: 'Usar no Navegador API',
+    createNewApiKey: 'Criar Nova Chave API',
+    keyName: 'Nome da Chave',
+    keyNamePlaceholder: 'e.g., Home Assistant, OctoPrint',
+    readStatus: 'Status de Leitura',
+    readStatusDescription: 'Visualizar status da impressora e fila',
+    manageQueue: 'Gerenciar Fila',
+    manageQueueDescription: 'Adicionar e remover itens da fila de impressão',
+    controlPrinter: 'Controlar Impressora',
+    controlPrinterDescription: 'Pausar, retomar e parar impressões',
+    unnamedKey: 'Chave Sem Nome',
+    lastUsed: 'Último uso',
+    read: 'Ler',
+    control: 'Controlar',
+    createFirstKey: 'Crie sua primeira chave',
+    webhookEndpoints: 'Endpoints de Webhook',
+    webhookApiKeyHint: 'Use sua chave API no cabeçalho X-API-Key.',
+    webhook: {
+      getAllStatus: 'Obter status de todas as impressoras',
+      getSpecificStatus: 'Obter status de uma impressora específica',
+      addToQueue: 'Adicionar à fila de impressão',
+      pausePrint: 'Pausar impressão',
+      resumePrint: 'Retomar impressão',
+      stopPrint: 'Parar impressão',
+    },
+    apiBrowser: 'Navegador API',
+    apiBrowserDescription: 'Explore e teste todos os endpoints de API disponíveis.',
+    apiKeyForTesting: 'Chave API para Teste',
+    apiKeyPlaceholder: 'Cole sua chave API aqui para testar endpoints autenticados...',
+    apiKeyHint: 'Esta chave será enviada como cabeçalho X-API-Key nas solicitações.',
+    deleteApiKeyTitle: 'Excluir Chave API',
+    deleteApiKeyMessage: 'Tem certeza de que deseja excluir esta chave API? Quaisquer integrações usando esta chave deixarão de funcionar.',
+    deleteKey: 'Excluir Chave',
+    // Filament tab
+    amsDisplayThresholds: 'Limiares de Exibição AMS',
+    amsThresholdsDescription: 'Configure os limiares de cores para os indicadores de umidade e temperatura do AMS.',
+    humidity: 'Umidade',
+    goodGreen: 'Bom (verde)',
+    fairOrange: 'Razoável (laranja)',
+    aboveFairBad: 'Acima do limiar razoável mostra como vermelho (ruim)',
+    temperature: 'Temperatura',
+    goodBlue: 'Bom (azul)',
+    aboveFairHot: 'Acima do limiar razoável mostra como vermelho (quente)',
+    historyRetention: 'Retenção de Histórico',
+    keepSensorHistory: 'Manter histórico do sensor por',
+    historyRetentionDescription: 'Dados antigos de umidade e temperatura serão automaticamente excluídos',
+    printModal: 'Modal de Impressão',
+    expandCustomMapping: 'Expandir mapeamento personalizado por padrão',
+    expandCustomMappingDescription: 'Ao imprimir em várias impressoras, mostrar o mapeamento AMS por impressora expandido',
+    // User management
+    authentication: 'Autenticação',
+    authEnabledDescription: 'Sua instância está protegida com autenticação de usuário',
+    authDisabledDescription: 'Ative para exigir login e gerenciar o acesso dos usuários',
+    authDisabledMessage: 'Ative a autenticação para criar contas de usuário, gerenciar permissões e proteger sua instância do Bambuddy.',
+    enableAuthentication: 'Ativar Autenticação',
+    currentUser: 'Usuário Atual',
+    changePassword: 'Alterar Senha',
+    admin: 'Administrador',
+    users: 'Usuários',
+    addUser: 'Adicionar Usuário',
+    groups: 'Grupos',
+    addGroup: 'Adicionar Grupo',
+    system: 'Sistema',
+    noDescription: 'Sem descrição',
+    userCount: '{{count}} usuários',
+    permissionCount: '{{count}} permissões',
+    createUser: 'Criar Usuário',
+    username: 'Nome de Usuário',
+    enterUsername: 'Digite o nome de usuário',
+    password: 'Senha',
+    enterPassword: 'Digite a senha (mínimo 6 caracteres)',
+    confirmPassword: 'Confirmar Senha',
+    confirmPasswordPlaceholder: 'Confirme a senha',
+    // Title tooltips
+    viewReleaseOnGitHub: 'Ver lançamento no GitHub',
+    turnAllPlugsOn: 'Ligar todas as tomadas',
+    turnAllPlugsOff: 'Desligar todas as tomadas',
+    // Modal: Clear logs
+    clearNotificationLogs: 'Limpar Logs de Notificação',
+    clearLogsMessage: 'Isso excluirá permanentemente todos os logs de notificação com mais de 30 dias. Esta ação não pode ser desfeita.',
+    clearLogs: 'Limpar Logs',
+    // Modal: Reset UI
+    resetUiPreferences: 'Redefinir Preferências de UI',
+    resetUiPreferencesMessage: 'Isso redefinirá todas as preferências de UI para os padrões: ordem da barra lateral, tema, layout do painel, modos de exibição e preferências de classificação. Suas impressoras, arquivos e configurações do servidor NÃO serão afetados. A página será recarregada após a limpeza.',
+    resetPreferences: 'Redefinir Preferências',
+    // Modal: Delete group
+    deleteGroupTitle: 'Excluir Grupo',
+    deleteGroupMessage: 'Tem certeza de que deseja excluir este grupo? Usuários neste grupo perderão essas permissões.',
+    deleteGroup: 'Excluir Grupo',
+    // Modal: Disable auth
+    disableAuthenticationTitle: 'Desativar Autenticação',
+    disableAuthenticationMessage: 'Tem certeza de que deseja desativar a autenticação? Isso tornará sua instância do Bambuddy acessível sem login. Todos os usuários permanecerão no banco de dados, mas a autenticação será desativada.',
+    disableAuthentication: 'Desativar Autenticação',
+    // Additional settings
+    configureBambuddy: 'Configurar Bambuddy',
+    systemDefault: 'Padrão do Sistema',
+    archiveSettings: 'Configurações de Arquivo',
+    newWindow: 'Nova Janela',
+    embeddedOverlay: 'Sobreposição Incorporada',
+    preferredSlicer: 'Fatiador Preferido',
+    preferredSlicerDescription: 'Escolha qual aplicativo de fatiamento abrirá os arquivos',
+    externalCameras: 'Câmeras Externas',
+    costTracking: 'Rastreamento de Custos',
+    printsOnly: 'Apenas Impressões',
+    totalConsumption: 'Consumo Total',
+    dataManagement: 'Gerenciamento de Dados',
+    storageUsage: 'Uso de Armazenamento',
+    storageUsageDescription: 'Detalhamento do uso de dados por categoria',
+    storageUsageTotal: 'Total',
+    storageUsageErrors: 'Erros',
+    storageUsageOtherBreakdown: 'Outros (inclui ativos estáticos, scripts e arquivos de configuração)',
+    storageUsageSystem: 'Sistema',
+    storageUsageData: 'Dados',
+    storageUsageUnavailable: 'Informações de uso de armazenamento indisponíveis',
+    clearNotificationLogsDescription: 'Excluir logs de notificação com mais de 30 dias',
+    resetUiPreferencesDescription: 'Redefinir ordem da barra lateral, tema, modos de exibição e preferências de layout. Impressoras, arquivos e configurações não são afetados.',
+    enableHomeAssistant: 'Ativar Home Assistant',
+    enableMqtt: 'Ativar MQTT',
+    useTls: 'Usar TLS',
+    enableMetricsEndpoint: 'Ativar Endpoint de Métricas',
+    availableMetrics: 'Métricas Disponíveis',
+    editUser: 'Editar Usuário',
+    deleteUserTitle: 'Excluir Usuário',
+    groupName: 'Nome do Grupo',
+    // Placeholders
+    leaveEmptyForAnonymous: 'Deixe vazio para anônimo',
+    leaveEmptyForNoAuth: 'Deixe vazio para sem autenticação',
+    enterNewPassword: 'Digite a nova senha',
+    confirmNewPassword: 'Confirme a nova senha',
+    enterGroupName: 'Digite o nome do grupo',
+    enterDescriptionOptional: 'Digite a descrição (opcional)',
+    enterCurrentPassword: 'Digite a senha atual',
+    enterNewPasswordMin6: 'Digite a nova senha (mínimo 6 caracteres)',
+    toast: {
+      keyCopied: 'Chave copiada para a área de transferência',
+      copyFailed: 'Falha ao copiar a chave',
+      keyAddedToBrowser: 'Chave adicionada ao Navegador de API',
+      clearLogsFailed: 'Falha ao limpar logs',
+      uiPreferencesReset: 'Preferências de UI redefinidas. Atualizando...',
+      authDisabled: 'Autenticação desativada com sucesso',
+      authDisableFailed: 'Falha ao desativar a autenticação',
+      apiKeyCreated: 'Chave de API criada',
+      apiKeyDeleted: 'Chave de API excluída',
+      userCreated: 'Usuário criado com sucesso',
+      userUpdated: 'Usuário atualizado com sucesso',
+      userDeleted: 'Usuário excluído com sucesso',
+      groupCreated: 'Grupo criado com sucesso',
+      groupUpdated: 'Grupo atualizado com sucesso',
+      groupDeleted: 'Grupo excluído com sucesso',
+      fillRequiredFields: 'Por favor, preencha todos os campos obrigatórios',
+      passwordsDoNotMatch: 'As senhas não coincidem',
+      passwordTooShort: 'A senha deve ter pelo menos 6 caracteres',
+      enterGroupName: 'Por favor, insira um nome de grupo',
+      settingsSaved: 'Configurações salvas',
+      cameraSettingsSaved: 'Configurações da câmera salvas',
+      enterCameraUrl: 'Por favor, insira a URL da câmera',
+      passwordChanged: 'Senha alterada com sucesso',
+      connectionFailed: 'Falha na conexão',
+      testFailed: 'Falha no teste',
+      cameraConnected: 'Câmera conectada{{resolution}}',
+    },
+    testConnection: 'Testar Conexão',
+    catalog: {
+      spoolCatalog: 'Catálogo de Carretéis',
+      spoolCatalogDescription: 'Pesos de carretéis vazios por marca/tipo. Usado para pesquisa automática de peso ao adicionar carretéis.',
+      searchCatalog: 'Pesquisar no catálogo...',
+      addNewEntry: 'Adicionar Nova Entrada',
+      namePlaceholder: 'Nome (ex.: Bambu Lab - Plástico)',
+      weight: 'Peso',
+      type: 'Tipo',
+      default: 'Padrão',
+      custom: 'Personalizado',
+      noMatch: 'Nenhuma entrada corresponde à sua pesquisa',
+      empty: 'Nenhuma entrada no catálogo',
+      deleteEntry: 'Excluir Entrada',
+      deleteConfirm: 'Tem certeza de que deseja excluir "{{name}}"?',
+      resetCatalog: 'Redefinir Catálogo',
+      resetConfirm: 'Redefinir catálogo para os padrões? Isso removerá todas as entradas personalizadas.',
+      loadFailed: 'Falha ao carregar o catálogo de carretéis',
+      nameWeightRequired: 'Nome e peso são obrigatórios',
+      entryAdded: 'Entrada adicionada',
+      addFailed: 'Falha ao adicionar entrada',
+      entryUpdated: 'Entrada atualizada',
+      updateFailed: 'Falha ao atualizar entrada',
+      entryDeleted: 'Entrada excluída',
+      deleteFailed: 'Falha ao excluir entrada',
+      resetSuccess: 'Catálogo redefinido para os padrões',
+      resetFailed: 'Falha ao redefinir catálogo',
+      exported: 'Exportadas {{count}} entradas',
+      imported: 'Importadas {{added}} entradas ({{skipped}} ignoradas)',
+      importFailed: 'Falha ao importar: formato JSON inválido',
+      exportTooltip: 'Exportar catálogo para JSON',
+      importTooltip: 'Importar catálogo de JSON',
+      resetTooltip: 'Redefinir para os padrões',
+    },
+    colorCatalog: {
+      title: 'Catálogo de Cores',
+      description: 'Cores de filamento por fabricante/material. Usado para pesquisa automática de cores ao adicionar carretéis.',
+      searchColors: 'Pesquisar cores...',
+      allManufacturers: 'Todos os fabricantes',
+      addNewColor: 'Adicionar Nova Cor',
+      manufacturer: 'Fabricante',
+      colorName: 'Nome da Cor',
+      hex: 'Hex',
+      materialOptional: 'Material (opcional)',
+      showing: 'Mostrando {{filtered}} de {{total}} cores',
+      noMatch: 'Nenhuma cor corresponde à sua pesquisa',
+      empty: 'Nenhuma cor no catálogo',
+      deleteColor: 'Excluir Cor',
+      deleteConfirm: 'Tem certeza de que deseja excluir "{{name}}"?',
+      resetCatalog: 'Redefinir Catálogo de Cores',
+      resetConfirm: 'Redefinir catálogo para os padrões? Isso removerá todas as cores personalizadas.',
+      sync: 'Sincronizar',
+      starting: 'Iniciando...',
+      syncTooltip: 'Sincronizar do FilamentColors.xyz (2000+ cores, pode levar um minuto)',
+      loadFailed: 'Falha ao carregar o catálogo de cores',
+      fieldsRequired: 'Fabricante, nome da cor e cor hex são obrigatórios',
+      colorAdded: 'Cor adicionada',
+      addFailed: 'Falha ao adicionar cor',
+      colorUpdated: 'Cor atualizada',
+      updateFailed: 'Falha ao atualizar cor',
+      colorDeleted: 'Cor excluída',
+      deleteFailed: 'Falha ao excluir cor',
+      resetSuccess: 'Catálogo de cores redefinido para os padrões',
+      resetFailed: 'Falha ao redefinir catálogo',
+      syncUpToDate: 'Já está atualizado ({{count}} cores verificadas)',
+      syncComplete: 'Adicionadas {{added}} novas cores ({{skipped}} já existiam)',
+      syncError: 'Erro de sincronização',
+      syncFailed: 'Falha ao sincronizar do FilamentColors.xyz',
+      exported: 'Exportadas {{count}} cores',
+      imported: 'Importadas {{added}} cores ({{skipped}} ignoradas)',
+      importFailed: 'Falha ao importar: formato JSON inválido',
+    },
+  },
+
+  // Notifications (for push notifications)
+  notification: {
+    printStarted: {
+      title: 'Impressão Iniciada',
+      body: '{{printer}}: {{filename}} iniciou a impressão',
+    },
+    printCompleted: {
+      title: 'Impressão Concluída',
+      body: '{{printer}}: {{filename}} foi concluída com sucesso',
+    },
+    printFailed: {
+      title: 'Falha na Impressão',
+      body: '{{printer}}: {{filename}} falhou',
+    },
+    printStopped: {
+      title: 'Impressão Interrompida',
+      body: '{{printer}}: {{filename}} foi interrompida',
+    },
+    printProgress: {
+      title: 'Progresso da Impressão',
+      body: '{{printer}}: {{filename}} está {{percent}}% concluída',
+    },
+    printerOffline: {
+      title: 'Impressora Offline',
+      body: '{{printer}} está offline',
+    },
+    printerError: {
+      title: 'Erro na Impressora',
+      body: '{{printer}}: {{error}}',
+    },
+    filamentLow: {
+      title: 'Filamento Baixo',
+      body: '{{printer}}: O filamento está acabando',
+    },
+    maintenanceDue: {
+      title: 'Manutenção Pendente',
+      body: '{{printer}}: {{items}} precisam de atenção',
+    },
+  },
+
+  // Errors
+  errors: {
+    generic: 'Algo deu errado',
+    networkError: 'Erro de rede. Por favor, verifique sua conexão.',
+    notFound: 'Não encontrado',
+    unauthorized: 'Não autorizado',
+    serverError: 'Erro no servidor',
+    validationError: 'Por favor, verifique sua entrada',
+    printerConnectionFailed: 'Falha ao conectar à impressora',
+    saveFailed: 'Falha ao salvar alterações',
+    deleteFailed: 'Falha ao excluir',
+    loadFailed: 'Falha ao carregar dados',
+  },
+
+  // HMS Errors modal
+  hmsErrors: {
+    title: 'Erros - {{name}}',
+    noErrors: 'Nenhum erro',
+    viewOnWiki: 'Ver no Bambu Lab Wiki',
+    clearInstructions: 'Limpe os erros na impressora para descartá-los aqui.',
+    clearErrors: 'Limpar Erros',
+    clearSuccess: 'Erros HMS limpos',
+    clearFailed: 'Falha ao limpar erros HMS',
+  },
+
+  // MQTT Debug modal
+  mqttDebug: {
+    title: 'MQTT Log de Depuração',
+    searchPlaceholder: 'Pesquisar tópico ou payload...',
+    noMessages: 'Nenhuma mensagem registrada ainda',
+    startLoggingHint: 'Clique em "Iniciar Registro" para começar a capturar mensagens MQTT',
+    noMessagesMatch: 'Nenhuma mensagem corresponde ao seu filtro',
+    adjustFilterHint: 'Tente ajustar seus critérios de pesquisa ou filtro',
+    incoming: 'Entrada',
+    outgoing: 'Saída',
+    loggingStopped: 'Registro interrompido',
+    loggingActive: 'Registro ativo - as mensagens serão atualizadas automaticamente',
+    startLogging: 'Iniciar Registro',
+    stopLogging: 'Parar Registro',
+    clearLog: 'Limpar Registro',
+    topic: 'ópico',
+    timestamp: 'Carimbo de Data/Hora',
+    direction: 'Direção',
+    all: 'Todos',
+  },
+
+  // Printer File Manager modal (printer internal storage)
+  printerFiles: {
+    title: 'Gerenciador de Arquivos',
+    storageUsed: 'Usado:',
+    storageFree: 'Livre:',
+    filterPlaceholder: 'Filtrar arquivos...',
+    deleteButton: 'Excluir',
+    deleteFiles: 'Excluir {{count}} arquivos',
+    deleteFileConfirm: 'Excluir "{{name}}"? Isso não pode ser desfeito.',
+    deleteFilesConfirm: 'Excluir {{count}} arquivos selecionados? Isso não pode ser desfeito.',
+    noFiles: 'Nenhum arquivo na impressora',
+    loadingFiles: 'Carregando arquivos...',
+    failedToLoad: 'Falha ao carregar arquivos',
+    toast: {
+      filesDeleted: 'Arquivos excluídos: {{count}}',
+      deleteFailed: 'Falha ao excluir: {{error}}',
+    },
+  },
+
+  // Confirmations
+  confirm: {
+    delete: 'Tem certeza de que deseja excluir isso?',
+    unsavedChanges: 'Você tem alterações não salvas. Tem certeza de que deseja sair?',
+    clearQueue: 'Tem certeza de que deseja limpar a fila?',
+  },
+
+  // Login page
+  login: {
+    title: 'Bambuddy Login',
+    subtitle: 'Faça login na sua conta',
+    username: 'Nome de usuário',
+    usernamePlaceholder: 'Digite seu nome de usuário',
+    usernameOrEmail: 'Nome de usuário ou Email',
+    usernameOrEmailPlaceholder: 'Nome de usuário ou Email',
+    password: 'Senha',
+    passwordPlaceholder: 'Digite sua senha',
+    signIn: 'Entrar',
+    signingIn: 'Entrando...',
+    forgotPassword: 'Esqueceu sua senha?',
+    loginSuccess: 'Login realizado com sucesso',
+    loginFailed: 'Falha no login',
+    enterCredentials: 'Por favor, insira nome de usuário e senha',
+    forgotPasswordTitle: 'Esqueceu a Senha',
+    forgotPasswordMessage: "Se você esqueceu sua senha, entre em contato com o administrador do sistema para redefini-la.",
+    forgotPasswordEmailMessage: "Digite seu endereço de email e enviaremos uma nova senha.",
+    emailAddress: 'Endereço de Email',
+    emailPlaceholder: 'seu.email@exemplo.com',
+    cancel: 'Cancelar',
+    sending: 'Enviando...',
+    sendResetEmail: 'Enviar Email de Redefinição',
+    howToReset: 'Como redefinir sua senha:',
+    resetStep1: 'Entre em contato com o administrador do Bambuddy',
+    resetStep2: 'Peça para redefinir sua senha na Gestão de Usuários',
+    resetStep3: 'Eles podem definir uma nova senha temporária para você',
+    resetStep4: 'Faça login com a nova senha e altere-a nas Configurações',
+    gotIt: 'Entendi',
+  },
+
+  // Setup page
+  setup: {
+    title: 'Bambuddy Configuração',
+    subtitle: 'Configure a autenticação para sua instância do Bambuddy',
+    enableAuth: 'Ativar Autenticação',
+    adminAccount: 'Conta de Administrador',
+    adminAccountDesc: 'Se usuários administradores já existirem, a autenticação será ativada usando as contas de administrador existentes. Deixe os campos abaixo vazios para usar os administradores existentes ou insira novas credenciais para criar um novo usuário administrador.',
+    adminUsername: 'Nome de usuário do administrador',
+    adminPassword: 'Senha do administrador',
+    optionalIfAdminExists: '(opcional se usuários administradores existirem)',
+    adminUsernamePlaceholder: 'Digite o nome de usuário do administrador (opcional)',
+    adminPasswordPlaceholder: 'Digite a senha do administrador (opcional)',
+    confirmPassword: 'Confirmar Senha',
+    confirmPasswordPlaceholder: 'Confirme a senha do administrador',
+    settingUp: 'Configurando...',
+    completeSetup: 'Concluir Configuração',
+    toast: {
+      authEnabledAdminCreated: 'Autenticação ativada e usuário administrador criado',
+      authEnabledExistingAdmins: 'Autenticação ativada usando usuários administradores existentes',
+      setupCompleted: 'Configuração concluída',
+      enterBothCredentials: 'Por favor, insira o nome de usuário e a senha do administrador, ou deixe ambos vazios para usar os usuários administradores existentes',
+      passwordsDoNotMatch: 'As senhas não coincidem',
+      passwordTooShort: 'A senha deve ter pelo menos 6 caracteres',
+    },
+  },
+
+  // Password change
+  changePassword: {
+    title: 'Alterar Senha',
+    currentPassword: 'Senha Atual',
+    currentPasswordPlaceholder: 'Digite a senha atual',
+    newPassword: 'Nova Senha',
+    newPasswordPlaceholder: 'Digite a nova senha (mínimo 6 caracteres)',
+    confirmPassword: 'Confirmar Senha',
+    confirmPasswordPlaceholder: 'Confirme a nova senha',
+    passwordsDoNotMatch: 'As senhas não coincidem',
+    passwordTooShort: 'A senha deve ter pelo menos 6 caracteres',
+    changing: 'Alterando...',
+    success: 'Senha alterada com sucesso',
+    failed: 'Falha ao alterar a senha',
+  },
+
+  // Plate detection alert
+  plateAlert: {
+    title: 'Impressão Pausada!',
+    message: 'Objetos detectados na mesa de impressão. A impressão foi automaticamente pausada. Por favor, limpe a mesa e retome a impressão.',
+    understand: 'Entendi',
+  },
+
+  // Camera page
+  camera: {
+    title: 'Visualização da Câmera',
+    invalidPrinterId: 'ID da impressora inválido',
+    live: 'Ao Vivo',
+    snapshot: 'Captura',
+    restartStream: 'Reiniciar transmissão',
+    refreshSnapshot: 'Atualizar captura',
+    fullscreen: 'Tela Cheia',
+    exitFullscreen: 'Sair da Tela Cheia',
+    connectingToCamera: 'Conectando à câmera...',
+    capturingSnapshot: 'Capturando imagem...',
+    connectionLost: 'Conexão perdida',
+    connectionFailed: 'Falha na conexão com a câmera',
+    reconnecting: 'Reconectando em {{countdown}}s... (tentativa {{attempt}}/{{max}})',
+    reconnectNow: 'Reconectar agora',
+    cameraUnavailable: 'Câmera indisponível',
+    cameraUnavailableDesc: 'Certifique-se de que a impressora está ligada e conectada.',
+    noCamera: 'Nenhuma câmera disponível',
+    retry: 'Tentar novamente',
+    cameraStream: 'Transmissão da câmera',
+    zoomOut: 'Reduzir zoom',
+    zoomIn: 'Aumentar zoom',
+    resetZoom: 'Redefinir zoom',
+    recording: 'Gravando',
+    startRecording: 'Iniciar gravação',
+    stopRecording: 'Parar gravação',
+    chamberLight: 'Alternar luz da câmara',
+  },
+
+  // Groups management
+  groups: {
+    title: 'Gerenciamento de Grupos',
+    subtitle: 'Gerenciar grupos de permissão para controle de acesso',
+    backToSettings: 'Voltar para Configurações',
+    createGroup: 'Criar Grupo',
+    noPermission: 'Você não tem permissão para acessar esta página.',
+    system: 'Sistema',
+    noDescription: 'Sem descrição',
+    usersCount: '{{count}} usuários',
+    permissionsCount: '{{count}} permissões',
+    edit: 'Editar',
+    delete: 'Excluir',
+    toast: {
+      created: 'Grupo criado com sucesso',
+      updated: 'Grupo atualizado com sucesso',
+      deleted: 'Grupo excluído com sucesso',
+      enterGroupName: 'Por favor, insira um nome para o grupo',
+    },
+    modal: {
+      editGroup: 'Editar Grupo',
+      createGroup: 'Criar Grupo',
+      cancel: 'Cancelar',
+      saving: 'Salvando...',
+      creating: 'Criando...',
+      saveChanges: 'Salvar Alterações',
+    },
+    form: {
+      groupName: 'Nome do Grupo',
+      groupNamePlaceholder: 'Insira o nome do grupo',
+      systemGroupWarning: 'Os nomes dos grupos do sistema não podem ser alterados',
+      description: 'Descrição',
+      descriptionPlaceholder: 'Insira a descrição (opcional)',
+      permissions: 'Permissões ({{count}} selecionadas)',
+    },
+    deleteModal: {
+      title: 'Excluir Grupo',
+      message: 'Tem certeza de que deseja excluir este grupo? Os usuários deste grupo perderão essas permissões.',
+      confirm: 'Excluir Grupo',
+    },
+  },
+
+  // Users management
+  users: {
+    title: 'Gerenciamento de Usuários',
+    subtitle: 'Gerenciar usuários e seu acesso à sua instância do Bambuddy',
+    backToSettings: 'Voltar para Configurações',
+    createUser: 'Criar Usuário',
+    noPermission: 'Você não tem permissão para acessar esta página.',
+    admin: 'Admin',
+    noGroups: 'Sem grupos',
+    active: 'Ativo',
+    inactive: 'Inativo',
+    edit: 'Editar',
+    delete: 'Excluir',
+    system: 'Sistema',
+    noGroupsAvailable: 'Nenhum grupo disponível',
+    table: {
+      username: 'Nome de Usuário',
+      groups: 'Grupos',
+      status: 'Status',
+      actions: 'Ações',
+    },
+    toast: {
+      created: 'Usuário criado com sucesso',
+      updated: 'Usuário atualizado com sucesso',
+      deleted: 'Usuário excluído com sucesso',
+      fillRequired: 'Por favor, preencha todos os campos obrigatórios',
+      passwordsDoNotMatch: 'As senhas não coincidem',
+      passwordTooShort: 'A senha deve ter pelo menos 6 caracteres',
+    },
+    modal: {
+      createUser: 'Criar Usuário',
+      editUser: 'Editar Usuário',
+      cancel: 'Cancelar',
+      creating: 'Criando...',
+      saving: 'Salvando...',
+      saveChanges: 'Salvar Alterações',
+      advancedAuthSubtitle: 'com Autenticação Avançada',
+    },
+    form: {
+      username: 'Nome de Usuário',
+      usernamePlaceholder: 'Insira o nome de usuário',
+      email: 'Email',
+      emailPlaceholder: 'user@example.com',
+      password: 'Senha',
+      passwordPlaceholder: 'Insira a senha',
+      confirmPassword: 'Confirmar Senha',
+      confirmPasswordPlaceholder: 'Confirme a senha',
+      newPasswordPlaceholder: 'Insira a nova senha',
+      confirmNewPasswordPlaceholder: 'Confirme a nova senha',
+      leaveBlankToKeep: 'deixe em branco para manter a atual',
+      groups: 'Grupos',
+      optional: 'opcional',
+      autoGeneratedPassword: 'Uma senha segura será gerada automaticamente e enviada por e-mail ao usuário.',
+      passwordManagedByAdvancedAuth: 'A senha é gerenciada pela Autenticação Avançada. Use "Redefinir Senha" para enviar uma nova senha ao usuário por e-mail.',
+      resetPassword: 'Redefinir Senha',
+      resettingPassword: 'Redefinindo Senha...',
+    },
+    deleteModal: {
+      title: 'Excluir Usuário',
+      message: 'Tem certeza de que deseja excluir este usuário? Esta ação não pode ser desfeita.',
+      confirm: 'Excluir Usuário',
+    },
+  },
+
+  // Stream overlay
+  streamOverlay: {
+    title: 'Stream Overlay',
+    invalidPrinterId: 'ID da impressora inválido',
+    cameraStream: 'Transmissão da câmera',
+    progress: 'Progresso da impressão',
+    eta: 'ETA',
+    printerIdle: 'Impressora ociosa',
+    printerOffline: 'Impressora offline',
+    status: {
+      printing: 'Imprimindo',
+      paused: 'Pausado',
+      finished: 'Concluído',
+      failed: 'Falhou',
+      idle: 'Ocioso',
+      unknown: 'Desconhecido',
+    },
+  },
+
+  // Profiles
+  profiles: {
+    title: 'Perfis',
+    subtitle: 'Gerencie seus presets de fatiador e calibrações de avanço de pressão',
+    tabs: {
+      cloud: 'Perfis na Nuvem',
+      local: 'Perfis Locais',
+      kprofiles: 'K-Perfis',
+    },
+    localProfiles: {
+      title: 'Perfis Locais',
+      subtitle: 'Importe e gerencie presets de fatiador do OrcaSlicer',
+      import: 'Importar Perfis',
+      importDesc: 'Solte arquivos .bbscfg, .bbsflmt, .orca_filament, .zip ou .json aqui',
+      importing: 'Importando...',
+      search: 'Pesquisar presets locais...',
+      noPresets: 'Nenhum preset local ainda',
+      badge: 'Local',
+      edit: 'Editar',
+      delete: 'Excluir',
+      cancel: 'Cancelar',
+      deleteConfirmTitle: 'Excluir Preset',
+      deleteConfirm: 'Tem certeza de que deseja excluir este preset? Esta ação não pode ser desfeita.',
+      source: 'Fonte',
+      inheritsFrom: 'Herdado de',
+      filamentType: 'Tipo',
+      vendor: 'Fornecedor',
+      compatiblePrinters: 'Impressoras Compatíveis',
+      nozzleTemp: 'Temperatura do Bico',
+      cost: 'Custo',
+      density: 'Densidade',
+      pressureAdvance: 'Avanço de Pressão',
+      filament: 'Filamento',
+      process: 'Processo',
+      printer: 'Impressora',
+      toast: {
+        importSuccess: '{{count}} preset(s) importada(s)',
+        importSkipped: '{{count}} preset(s) ignorada(s) (duplicadas)',
+        importError: '{{count}} erro(s) durante a importação',
+        deleted: 'Preset excluído',
+        updated: 'Preset atualizado',
+      },
+    },
+    connectedAs: 'Conectado como',
+    logout: 'Sair',
+    noLogoutPermission: 'Você não tem permissão para sair',
+    failedToLoad: 'Falha ao carregar perfis',
+    retry: 'Tentar novamente',
+    time: {
+      justNow: 'Agora mesmo',
+      minsAgo: 'há {{count}} minutos',
+      hoursAgo: 'há {{count}} horas',
+      daysAgo: 'há {{count}} dias',
+    },
+    toast: {
+      loggedOut: 'Desconectado',
+    },
+    login: {
+      title: 'Conectar ao Bambu Cloud',
+      subtitle: 'Sincronize seus presets de fatiador entre dispositivos',
+      email: 'Email',
+      password: 'Senha',
+      region: 'Região',
+      regionGlobal: 'Global',
+      regionChina: 'China',
+      verificationCode: 'Código de Verificação',
+      totpCode: 'Código do Autenticador',
+      checkEmail: 'Verifique seu email ({{email}}) para um código de 6 dígitos',
+      enterTotpHint: 'Digite o código de 6 dígitos do seu aplicativo autenticador',
+      accessToken: 'Token de Acesso',
+      accessTokenHint: 'Cole seu token de acesso Bambu Lab (do Bambu Studio)',
+      back: 'Voltar',
+      loginButton: 'Entrar',
+      verifyButton: 'Verificar',
+      setTokenButton: 'Definir Token',
+      useToken: 'Usar token de acesso em vez disso',
+      useEmail: 'Entrar com email em vez disso',
+      toast: {
+        loggedIn: 'Conectado com sucesso',
+        codeSent: 'Código de verificação enviado para seu email',
+        enterTotp: 'Digite o código do seu aplicativo autenticador',
+        tokenSet: 'Token definido com sucesso',
+      },
+    },
+    presets: {
+      myPreset: 'Meu preset (editável)',
+      duplicate: 'Duplicar',
+      editable: 'Editável',
+      failedToLoadDetails: 'Falha ao carregar detalhes do preset',
+      deleteConfirm: 'Excluir este preset?',
+      deleteWarning: 'Isso excluirá permanentemente "{{name}}" do Bambu Cloud. Esta ação não pode ser desfeita.',
+      noDuplicatePermission: 'Você não tem permissão para duplicar presets',
+      noEditPermission: 'Você não tem permissão para editar presets',
+      noDeletePermission: 'Você não tem permissão para excluir presets',
+      types: {
+        filament: 'Preset de filamento',
+        printer: 'Preset de impressora',
+        process: 'Preset de processo',
+      },
+      toast: {
+        deleted: 'Preset excluído',
+        created: 'Preset criado',
+        updated: 'Preset atualizado',
+        duplicated: 'Preset duplicado',
+        fieldAdded: 'Campo "{{key}}" adicionado',
+        exported: 'Preset exportado',
+      },
+      baseLabel: 'Base: {{name}}',
+      currentLabel: 'Atual: {{name}}',
+      newPreset: 'Novo Preset',
+      editPreset: 'Editar Preset',
+      duplicatePreset: 'Duplicar Preset',
+      createNewPreset: 'Criar Novo Preset',
+      customizeSettings: 'Personalizar configurações para seu novo preset',
+      compareWithBase: 'Comparar com o preset base',
+      compare: 'Comparar',
+      // CreatePresetModal - Basic Info
+      basePreset: 'Preset Base',
+      selectBasePreset: 'Selecionar preset base...',
+      presetName: 'Nome do Preset',
+      myCustomPreset: 'Meu preset personalizado',
+      inheritsFrom: 'Herdado de',
+      dropJsonToImport: 'Solte o arquivo JSON para importar',
+      // CreatePresetModal - Tabs
+      tabs: {
+        common: 'Comum',
+        allFields: 'Todos os Campos',
+      },
+      // CreatePresetModal - All Fields Tab
+      availableFields: 'Campos Disponíveis',
+      searchFieldsPlaceholder: 'Pesquisar campos...',
+      noMatchingFields: 'Nenhum campo correspondente',
+      allFieldsAdded: 'Todos os campos adicionados',
+      addCustomField: 'Adicionar campo personalizado',
+      yourOverrides: 'Suas Substituições',
+      noOverridesYet: 'Nenhuma substituição ainda',
+      clickFieldsToAdd: 'Clique nos campos à esquerda para adicioná-los',
+      saveAsTemplate: 'Salvar como modelo',
+      jsonTip: 'Dica: Arraste e solte um arquivo .json em qualquer lugar deste modal para importar configurações',
+    },
+    cloudView: {
+      searchPlaceholder: 'Pesquisar presets...',
+      templates: 'Modelos',
+      refresh: 'Atualizar',
+      newPreset: 'Novo Preset',
+      clearFilters: 'Limpar filtros',
+      // Compare mode
+      compareMode: 'Modo de Comparação',
+      selectAnotherPreset: 'Selecionar outro preset {{type}}',
+      clickTwoPresets: 'Clique em dois presets do mesmo tipo para comparar',
+      selectFirst: '1. Selecionar primeiro',
+      selectSecond: '2. Selecionar segundo',
+      compareNow: 'Comparar Agora',
+      // Status row
+      lastSynced: 'Última sincronização:',
+      showingCount: 'Mostrando {{showing}} de {{total}} presets',
+      noPresetsFound: 'Nenhum preset encontrado',
+      // Column headers
+      columns: {
+        filament: 'Filamento',
+        process: 'Processo',
+        printer: 'Impressora',
+      },
+      noFilamentPresets: 'Nenhum preset de filamento',
+      noProcessPresets: 'Nenhum preset de processo',
+      noPrinterPresets: 'Nenhum preset de impressora',
+      // Filters
+      filters: {
+        type: 'Tipo',
+        owner: 'Proprietário',
+        printer: 'Impressora',
+        nozzle: 'Bico',
+        filament: 'Filamento',
+        layer: 'Camada',
+        all: 'Todos',
+        myPresets: 'Meus Presets',
+        builtIn: 'Integrado',
+        process: 'Processo',
+      },
+      // Permissions
+      noTemplatesPermission: 'Você não tem permissão para gerenciar modelos',
+      noRefreshPermission: 'Você não tem permissão para atualizar perfis',
+      noCreatePermission: 'Você não tem permissão para criar presets',
+    },
+    templates: {
+      title: 'Modelos Rápidos',
+      noTemplates: 'Nenhum modelo ainda',
+      createFirst: 'Crie modelos a partir do editor de presets',
+      typeFilter: 'Tipo:',
+      deleteTitle: 'Excluir Modelo',
+      deleteWarning: 'Esta ação não pode ser desfeita',
+      deleteConfirm: 'Tem certeza de que deseja excluir "{{name}}"?',
+      namePlaceholder: 'Nome do modelo',
+      descriptionPlaceholder: 'Descrição',
+      settingsJson: 'Configurações (JSON)',
+      fieldsCount: '{{count}} campos',
+      shownInModals: 'Exibido em modais',
+      hiddenInModals: 'Oculto em modais',
+      apply: 'Aplicar',
+      toast: {
+        deleted: 'Modelo excluído',
+        updated: 'Modelo atualizado',
+        created: 'Modelo criado',
+        applied: 'Modelo aplicado',
+      },
+    },
+  },
+
+  // Support/Debug
+  support: {
+    debugLoggingActive: 'Registro de depuração ativo',
+    manageLogs: 'Gerenciar',
+    collectItem7: 'Conectividade da impressora e versões de firmware',
+    collectItem8: 'Status de integração (Spoolman, MQTT, HA)',
+    collectItem9: 'Interfaces de rede (somente sub-redes)',
+    collectItem10: 'Versões de pacotes Python',
+    collectItem11: 'Verificações de integridade do banco de dados',
+    collectItem12: 'Detalhes do ambiente Docker',
+  },
+
+  // File manager
+  fileManager: {
+    title: 'Gerenciador de Arquivos',
+    subtitle: 'Organize e gerencie seus arquivos de impressão',
+    uploadFiles: 'Enviar Arquivos',
+    newFolder: 'Nova Pasta',
+    folderName: 'Nome da Pasta',
+    folderNamePlaceholder: 'ex.: Peças Funcionais',
+    renameFile: 'Renomear Arquivo',
+    renameFolder: 'Renomear Pasta',
+    moveFiles: 'Mover {{count}} Arquivo(s)',
+    rootNoFolder: 'Raiz (Sem Pasta)',
+    current: 'Atual',
+    linkFolder: 'Vincular Pasta',
+    linkFolderDescription: 'Vincular "{{name}}" a um projeto ou arquivo para acesso rápido.',
+    project: 'Projeto',
+    archive: 'Arquivo',
+    noProjectsFound: 'Nenhum projeto encontrado',
+    noArchivesFound: 'Nenhum arquivo encontrado',
+    unlink: 'Desvincular',
+    link: 'Vincular',
+    dragDropFiles: 'Arraste e solte os arquivos aqui',
+    dropFilesHere: 'Solte os arquivos aqui',
+    orClickToBrowse: 'ou clique para procurar',
+    allFileTypesSupported: 'Todos os tipos de arquivos são suportados. Arquivos ZIP serão extraídos.',
+    zipFilesDetected: 'Arquivos ZIP detectados',
+    zipExtractOptions: 'Arquivos ZIP serão extraídos. Escolha como lidar com a estrutura de pastas:',
+    preserveZipStructure: 'Preservar estrutura de pastas do ZIP',
+    createFolderFromZip: 'Criar pasta a partir do nome do arquivo ZIP',
+    stlThumbnailGeneration: 'Geração de miniaturas STL',
+    zipMayContainStl: 'Arquivos ZIP podem conter arquivos STL. Miniaturas podem ser geradas durante a extração.',
+    thumbnailsCanBeGenerated: 'Miniaturas podem ser geradas para arquivos STL. Modelos grandes podem levar mais tempo para processar.',
+    generateThumbnailsForStl: 'Gerar miniaturas para arquivos STL',
+    threemfDetected: 'Arquivos 3MF detectados',
+    threemfExtractionInfo: 'Modelo da impressora, material, cor e configurações de impressão serão extraídos automaticamente dos arquivos 3MF.',
+    willBeExtracted: 'Será extraído',
+    filesExtracted: '{{count}} arquivos extraídos',
+    uploadComplete: 'Upload concluído: {{succeeded}} bem-sucedidos',
+    uploadFailed: '{{count}} falhou',
+    uploading: 'Enviando...',
+    changeLink: 'Alterar link...',
+    linkTo: 'Vincular a...',
+    linkToProjectOrArchive: 'Vincular a projeto ou arquivo',
+    addToQueue: 'Adicionar à fila',
+    schedulePrint: 'Agendar impressão',
+    generateThumbnail: 'Gerar miniatura',
+    generateThumbnails: 'Gerar miniaturas',
+    generateThumbnailsForMissing: 'Gerar miniaturas para arquivos STL que não possuem',
+    gridView: 'Visualização em grade',
+    listView: 'Visualização em lista',
+    lowDiskSpaceWarning: 'Aviso de pouco espaço em disco',
+    lowDiskSpaceDetails: 'Apenas {{free}} livres de {{total}} no total. O limite está definido para {{threshold}} GB nas configurações.',
+    files: 'Arquivos',
+    folders: 'Pastas',
+    size: 'Tamanho',
+    free: 'Livre',
+    allFiles: 'Todos os arquivos',
+    wrap: 'Quebrar texto',
+    enableTextWrapping: 'Ativar quebra de texto',
+    disableTextWrapping: 'Desativar quebra de texto',
+    dragToResizeTooltip: 'Arraste para redimensionar, clique duas vezes para redefinir',
+    searchFiles: 'Pesquisar arquivos...',
+    allTypes: 'Todos os tipos',
+    prints: 'Impressões',
+    ascending: 'Crescente',
+    descending: 'Decrescente',
+    resultsCount: '{{showing}} de {{total}} arquivos',
+    selectAll: 'Selecionar tudo',
+    deselectAll: 'Desmarcar tudo',
+    selected: '{{count}} selecionado(s)',
+    adding: 'Adicionando...',
+    loadingFiles: 'Carregando arquivos...',
+    folderIsEmpty: 'A pasta está vazia',
+    noFilesYet: 'Nenhum arquivo ainda',
+    folderEmptyDescription: 'Envie arquivos ou mova arquivos para esta pasta para começar.',
+    noFilesDescription: 'Envie arquivos para começar a organizar seus arquivos relacionados à impressão.',
+    noMatchingFiles: 'Nenhum arquivo correspondente',
+    noMatchingFilesDescription: 'Nenhum arquivo corresponde aos seus critérios de pesquisa ou filtro.',
+    clearFilters: 'Limpar filtros',
+    printedCount: 'Impresso {{count}}x',
+    uploadedBy: 'Enviado por',
+    deleteFolder: 'Excluir pasta',
+    deleteFile: 'Excluir arquivo',
+    deleteFilesCount: 'Excluir {{count}} arquivos',
+    deleteFolderConfirm: 'Tem certeza de que deseja excluir esta pasta? Todos os arquivos dentro também serão excluídos.',
+    deleteFileConfirm: 'Tem certeza de que deseja excluir este arquivo?',
+    deleteFilesConfirm: 'Tem certeza de que deseja excluir {{count}} arquivos selecionados? Esta ação não pode ser desfeita.',
+    deleting: 'Excluindo...',
+    noPermissionRenameFolder: 'Você não tem permissão para renomear pastas',
+    noPermissionLinkFolder: 'Você não tem permissão para vincular pastas',
+    noPermissionDeleteFolder: 'Você não tem permissão para excluir pastas',
+    noPermissionPrint: 'Você não tem permissão para imprimir',
+    noPermissionAddToQueue: 'Você não tem permissão para adicionar à fila',
+    noPermissionDownload: 'Você não tem permissão para baixar arquivos',
+    noPermissionRenameFile: 'Você não tem permissão para renomear este arquivo',
+    noPermissionGenerateThumbnail: 'Você não tem permissão para gerar miniaturas',
+    noPermissionDeleteFile: 'Você não tem permissão para excluir este arquivo',
+    noPermissionCreateFolder: 'Você não tem permissão para criar pastas',
+    noPermissionUpload: 'Você não tem permissão para enviar arquivos',
+    noPermissionMoveFiles: 'Você não tem permissão para mover arquivos',
+    noPermissionDeleteFiles: 'Você não tem permissão para excluir arquivos',
+    toast: {
+      folderCreated: 'Pasta criada',
+      folderDeleted: 'Pasta excluída',
+      fileDeleted: 'Arquivo excluído',
+      filesDeleted: 'Excluídos {{count}} arquivos',
+      filesMoved: 'Arquivos movidos',
+      folderLinked: 'Pasta vinculada',
+      folderUnlinked: 'Pasta desvinculada',
+      addedToQueue: 'Adicionado {{count}} arquivo(s) à fila',
+      addedToQueuePartial: 'Adicionado {{added}} arquivo(s), {{failed}} falharam',
+      failedToAddToQueue: 'Falha ao adicionar arquivos: {{error}}',
+      fileRenamed: 'Arquivo renomeado',
+      folderRenamed: 'Pasta renomeada',
+      thumbnailsGenerated: 'Geradas {{count}} miniatura(s)',
+      thumbnailsGeneratedPartial: 'Geradas {{succeeded}} miniatura(s), {{failed}} falharam',
+      noStlMissingThumbnails: 'Nenhum arquivo STL sem miniatura',
+      failedToGenerateThumbnails: 'Falha ao gerar miniaturas: {{error}}',
+      thumbnailGenerated: 'Miniatura gerada',
+      failedToGenerateThumbnail: 'Falha ao gerar miniatura: {{error}}',
+    },
+  },
+
+  // Projects
+  projects: {
+    title: 'Projetos',
+    subtitle: 'Organize e acompanhe seus projetos de impressão 3D',
+    newProject: 'Novo Projeto',
+    editProject: 'Editar Projeto',
+    deleteProject: 'Excluir Projeto',
+    projectName: 'Nome do Projeto',
+    description: 'Descrição',
+    noProjects: 'Nenhum projeto ainda',
+    noProjectsFiltered: 'Nenhum projeto {{status}}',
+    noProjectsFilteredHelp: "Você não tem nenhum projeto {{status}}. Os projetos aparecerão aqui quando seu status mudar.",
+    createFirst: 'Crie seu primeiro projeto para começar a organizar impressões relacionadas, acompanhar o progresso e gerenciar suas construções.',
+    createFirstButton: 'Crie Seu Primeiro Projeto',
+    create: 'Criar',
+    files: 'Arquivos',
+    prints: 'Impressões',
+    plates: 'Placas',
+    parts: 'Peças',
+    lastModified: 'Última Modificação',
+    deleteConfirm: 'Tem certeza de que deseja excluir este projeto? Arquivos e itens da fila serão desvinculados, mas não excluídos.',
+    addFiles: 'Adicionar Arquivos',
+    removeFile: 'Remover Arquivo',
+    viewDetails: 'Ver Detalhes',
+    // Modal fields
+    namePlaceholder: 'ex., Voron 2.4 Build',
+    descriptionPlaceholder: 'Descrição opcional...',
+    color: 'Cor',
+    targetPlates: 'Placas Alvo',
+    targetPlatesPlaceholder: 'ex., 25',
+    targetPlatesHelp: 'Número de trabalhos de impressão',
+    targetParts: 'Peças Alvo',
+    targetPartsPlaceholder: 'ex., 150',
+    targetPartsHelp: 'Total de objetos necessários',
+    tagsLabel: 'Tags (separadas por vírgula)',
+    tagsPlaceholder: 'ex., voron, funcional, presente',
+    dueDate: 'Data de Vencimento',
+    priority: 'Prioridade',
+    priorityLow: 'Baixa',
+    priorityNormal: 'Normal',
+    priorityHigh: 'Alta',
+    priorityUrgent: 'Urgente',
+    // Status
+    statusActive: 'Ativo',
+    statusCompleted: 'Concluído',
+    statusArchived: 'Arquivado',
+    done: 'Concluído',
+    completed: 'Concluído',
+    failed: 'Falhou',
+    inQueue: 'Na fila',
+    noPrintsYet: 'Nenhuma impressão ainda',
+    // Footer stats
+    printJobs: 'Trabalhos de impressão (placas)',
+    partsPrinted: 'Peças impressas',
+    failedParts: 'Peças falhadas',
+    // Actions
+    import: 'Importar',
+    export: 'Exportar',
+    importProject: 'Importar projeto',
+    exportAll: 'Exportar todos os projetos',
+    loading: 'Carregando projetos...',
+    // Permissions
+    noEditPermission: 'Você não tem permissão para editar projetos',
+    noDeletePermission: 'Você não tem permissão para excluir projetos',
+    noCreatePermission: 'Você não tem permissão para criar projetos',
+    noImportPermission: 'Você não tem permissão para importar projetos',
+    noExportPermission: 'Você não tem permissão para exportar projetos',
+    // Toast
+    toast: {
+      created: 'Projeto criado',
+      updated: 'Projeto atualizado',
+      deleted: 'Projeto excluído',
+      imported: 'Projeto importado',
+      multipleImported: '{{count}} projetos importados',
+      importFailed: 'Falha na importação',
+      exported: 'Projetos exportados (apenas metadados)',
+    },
+  },
+
+  // Project detail page
+  projectDetail: {
+    notFound: 'Projeto não encontrado',
+    backToProjects: 'Voltar para Projetos',
+    export: 'Exportar',
+    exportProject: 'Exportar projeto',
+    noExportPermission: 'Você não tem permissão para exportar projetos',
+    noEditPermission: 'Você não tem permissão para editar projetos',
+    partOf: 'Parte de:',
+    priorityLabel: 'Prioridade:',
+    noPrints: 'Nenhuma impressão neste projeto ainda',
+    status: {
+      active: 'Ativo',
+      completed: 'Concluído',
+      archived: 'Arquivado',
+    },
+    priority: {
+      low: 'Baixa',
+      normal: 'Normal',
+      high: 'Alta',
+      urgent: 'Urgente',
+    },
+    dueDate: {
+      overdue: 'Atrasado',
+      today: 'Vence hoje',
+      daysLeft: '{{count}} dias restantes',
+    },
+    progress: {
+      platesProgress: 'Progresso das Placas',
+      partsProgress: 'Progresso das Peças',
+      printJobs: 'Trabalhos de Impressão',
+      parts: 'Peças',
+      percentComplete: '{{percent}}% concluído',
+      remaining: '{{count}} restantes',
+    },
+    stats: {
+      printJobs: 'Trabalhos de Impressão',
+      total: 'total',
+      failed: '{{count}} falhou',
+      partsPrinted: '{{count}} peças impressas',
+      printTime: 'Tempo de Impressão',
+      filamentUsed: 'Filamento Usado',
+    },
+    cost: {
+      title: 'Rastreamento de Custos',
+      filamentCost: 'Custo do Filamento',
+      energy: 'Energia',
+      budget: 'Orçamento',
+      remaining: 'Restante',
+    },
+    subProjects: {
+      title: 'Sub-projetos ({{count}})',
+    },
+    notes: {
+      title: 'Notas',
+      noEditPermission: 'Você não tem permissão para editar notas',
+      placeholder: 'Adicione notas sobre este projeto...',
+      empty: 'Nenhuma nota ainda. Clique em Editar para adicionar notas.',
+    },
+    files: {
+      title: 'Arquivos',
+      linkFolders: 'Vincular pastas do Gerenciador de Arquivos',
+      forQuickAccess: 'a este projeto para acesso rápido.',
+      fileCount: '{{count}} arquivo(s)',
+      empty: 'Nenhuma pasta vinculada. Vá para o Gerenciador de Arquivos e vincule uma pasta a este projeto.',
+    },
+    bom: {
+      title: 'Lista de Materiais',
+      acquired: '{{completed}}/{{total}} adquiridos',
+      showAll: 'Mostrar todos',
+      hideDone: 'Ocultar concluídos',
+      addPart: 'Adicionar Peça',
+      noAddPermission: 'Você não tem permissão para adicionar peças',
+      partNamePlaceholder: 'Nome da peça (ex.: parafusos M3x8)',
+      partName: 'Nome da peça',
+      qty: 'Quantidade',
+      price: 'Preço ({{currency}})',
+      sourcingUrlPlaceholder: 'URL de fornecimento (opcional)',
+      remarksPlaceholder: 'Observações (opcional)',
+      deletePart: 'Excluir Peça',
+      deleteConfirm: 'Tem certeza de que deseja excluir "{{name}}"?',
+      noUpdatePermission: 'Você não tem permissão para atualizar peças',
+      noEditPermission: 'Você não tem permissão para editar peças',
+      noDeletePermission: 'Você não tem permissão para excluir peças',
+      totalCost: 'Custo total:',
+      empty: 'Nenhuma peça na lista de materiais. Adicione hardware, eletrônicos ou outros componentes para rastrear o que precisa ser adquirido.',
+    },
+    timeline: {
+      title: 'Linha do Tempo de Atividades',
+      empty: 'Nenhuma atividade ainda.',
+    },
+    template: {
+      saveAsTemplate: 'Salvar como Modelo',
+      noCreatePermission: 'Você não tem permissão para criar modelos',
+    },
+    queue: {
+      title: 'Fila',
+      viewAll: 'Ver todos',
+      printing: '{{count}} imprimindo',
+      queued: '{{count}} na fila',
+    },
+    prints: {
+      title: 'Impressões ({{count}})',
+    },
+    toast: {
+      projectUpdated: 'Projeto atualizado',
+      partAdded: 'Peça adicionada',
+      partRemoved: 'Peça removida',
+      exportFailed: 'Falha na exportação',
+      projectExported: 'Projeto exportado',
+      templateCreated: 'Modelo criado',
+    },
+  },
+
+  // System info
+  system: {
+    title: 'Informações do Sistema',
+    version: 'Versão',
+    uptime: 'Tempo de Atividade',
+    cpuUsage: 'Uso da CPU',
+    memoryUsage: 'Uso da Memória',
+    diskUsage: 'Uso do Disco',
+    networkInfo: 'Informações de Rede',
+    logs: 'Logs',
+    debugMode: 'Modo de Depuração',
+    enableDebug: 'Ativar Registro de Depuração',
+    disableDebug: 'Desativar Registro de Depuração',
+    downloadLogs: 'Baixar Logs',
+    clearLogs: 'Limpar Logs',
+    dockerInfo: 'Informações do Docker',
+    containerName: 'Nome do Contêiner',
+    imageName: 'Nome da Imagem',
+    platform: 'Plataforma',
+    architecture: 'Arquitetura',
+  },
+
+  // Library (K Profiles)
+  library: {
+    title: 'Biblioteca de Filamentos',
+    addFilament: 'Adicionar Filamento',
+    editFilament: 'Editar Filamento',
+    deleteFilament: 'Excluir Filamento',
+    vendor: 'Fornecedor',
+    material: 'Material',
+    color: 'Cor',
+    kFactor: 'Fator K',
+    temperature: 'Temperatura',
+    noFilaments: 'Nenhum filamento na biblioteca',
+    deleteConfirm: 'Tem certeza de que deseja excluir este filamento?',
+    importFromPrinter: 'Importar da Impressora',
+    exportToFile: 'Exportar para Arquivo',
+  },
+
+  // Spoolman
+  spoolman: {
+    title: 'Integração com Spoolman',
+    enabled: 'Spoolman Ativado',
+    url: 'URL do Spoolman',
+    connected: 'Conectado',
+    disconnected: 'Não Conectado',
+    testConnection: 'Testar Conexão',
+    sync: 'Sincronizar',
+    syncing: 'Sincronizando...',
+    lastSync: 'Última Sincronização',
+    linkToSpoolman: 'Vincular ao Spoolman',
+    openInSpoolman: 'Abrir no Spoolman',
+    unlinkSpool: 'Desvincular Carretel',
+    selectSpool: 'Selecionar Carretel',
+    noUnlinkedSpools: 'Nenhum carretel desvinculado disponível',
+    linkSuccess: 'Carretel vinculado ao Spoolman com sucesso',
+    linkFailed: 'Falha ao vincular carretel',
+    spoolId: 'Carretel ID (Spool ID)',
+    fillSourceLabel: '(Spoolman)',
+    weight: 'Peso',
+    remaining: 'Restante',
+    disableWeightSync: 'Desativar Sincronização de Peso Estimado do AMS',
+    disableWeightSyncDesc: "Não atualize a capacidade restante a partir das estimativas do AMS. Use isso se preferir o rastreamento de uso do Spoolman em vez das estimativas baseadas em porcentagem do AMS. Novos carretéis ainda usarão a estimativa do AMS como seu peso inicial.",
+    reportPartialUsage: 'Relatar Uso Parcial para Impressões Falhadas',
+    reportPartialUsageDesc: 'Quando uma impressão falha ou é cancelada, relate o filamento estimado usado até aquele ponto com base no progresso das camadas.',
+  },
+
+  // Inventory
+  inventory: {
+    title: 'Inventário de Carretéis',
+    addSpool: 'Adicionar Carretel',
+    editSpool: 'Editar Carretel',
+    material: 'Material',
+    selectMaterial: 'Selecionar material...',
+    subtype: 'Subtipo',
+    brand: 'Marca',
+    searchBrand: 'Pesquisar marca...',
+    useCustomBrand: 'Usar "{{brand}}"',
+    colorName: 'Nome da Cor',
+    colorNamePlaceholder: 'Jade White, Fire Red...',
+    color: 'Cor',
+    hexColor: 'Cor Hexadecimal',
+    pickColor: 'Escolher cor personalizada',
+    labelWeight: 'Peso da Etiqueta',
+    coreWeight: 'Peso do Carretel Vazio',
+    searchSpoolWeight: 'Pesquisar peso do carretel...',
+    weightUsed: 'Usado',
+    currentWeight: 'Peso Restante',
+    measuredWeight: 'Peso Medido',
+    measuredWeightError: 'O peso medido deve estar entre {{min}}g e {{max}}g.',
+    slicerFilament: 'Filamento do Fatiador',
+    slicerFilamentName: 'Nome do Predefinido do Fatiador',
+    slicerPreset: 'Predefinido do Fatiador',
+    searchPresets: 'Pesquisar predefinições de filamento...',
+    selectedPreset: 'Selecionado',
+    noPresetsFound: 'Nenhuma predefinição encontrada',
+    tempOverrides: 'Substituições de Temperatura',
+    note: 'Nota',
+    notePlaceholder: 'Quaisquer notas adicionais sobre este spool...',
+    archive: 'Arquivar',
+    restore: 'Restaurar',
+    noSpools: 'Nenhum carretel ainda. Adicione seu primeiro carretel para começar.',
+    noManualSpools: 'Nenhum carretel adicionado manualmente disponível. Adicione um carretel ao seu inventário primeiro.',
+    kProfiles: 'K-Perfis',
+    addKProfile: 'Adicionar K-Perfil',
+    assignSpool: 'Atribuir Carretel',
+    unassignSpool: 'Desatribuir',
+    assignSuccess: 'Carretel atribuído e slot AMS configurado',
+    assignFailed: 'Falha ao atribuir carretel',
+    selectSpool: 'Selecione um carretel para atribuir a este slot',
+    assigned: 'Atribuído',
+    assigning: 'Atribuindo...',
+    searchSpools: 'Pesquisar carretéis...',
+    allMaterials: 'Todos os Materiais',
+    filterByBrand: 'Filtrar por marca...',
+    showArchived: 'Mostrar arquivados',
+    spoolCreated: 'Carretel criado',
+    spoolUpdated: 'Carretel atualizado',
+    spoolDeleted: 'Carretel excluído',
+    spoolArchived: 'Carretel arquivado',
+    spoolRestored: 'Carretel restaurado',
+    deleteConfirm: 'Tem certeza de que deseja excluir este carretel? Esta ação não pode ser desfeita.',
+    archiveConfirm: 'Tem certeza de que deseja arquivar este carretel?',
+    advancedSettings: 'Configurações Avançadas',
+    // Tabs
+    filamentInfoTab: 'Informações do Filamento',
+    paProfileTab: 'Perfil PA',
+    filamentInfo: 'Filamento',
+    additional: 'Adicional',
+    // Cloud
+    loadingPresets: 'Carregando predefinições da nuvem...',
+    cloudConnected: 'Nuvem conectada',
+    cloudNotConnected: 'Nuvem não conectada (usando padrões)',
+    // Colors
+    recentColors: 'Recentes',
+    searchColors: 'Pesquisar cores...',
+    searchResults: 'Resultados da pesquisa',
+    allColors: 'Todas as cores',
+    commonColors: 'Cores comuns',
+    showLess: 'Mostrar menos',
+    showAll: 'Mostrar tudo',
+    noColorsFound: 'Nenhuma cor corresponde à sua pesquisa',
+    noResults: 'Nenhum resultado encontrado',
+    // PA Profiles
+    selectMaterialFirst: 'Por favor, selecione um material primeiro na aba Informações do Filamento.',
+    noPrintersConfigured: 'Nenhuma impressora configurada. Adicione impressoras para usar perfis PA.',
+    matchingFilter: 'Correspondente',
+    anyBrand: 'Qualquer marca',
+    anyVariant: 'Qualquer variante',
+    autoSelect: 'Seleção automática',
+    matches: 'correspondências',
+    match: 'correspondência',
+    noMatches: 'Nenhuma correspondência',
+    connected: 'Conectado',
+    offline: 'Offline',
+    printerOffline: 'A impressora está offline. Conecte-se para visualizar os perfis de calibração.',
+    noKProfilesMatch: 'Nenhum K-perfil corresponde ao filamento selecionado.',
+    leftNozzle: 'Bico Esquerdo',
+    rightNozzle: 'Bico Direito',
+    profilesSelected: 'perfil(is) de calibração selecionado(s)',
+    // Stats & enhanced table
+    totalInventory: 'Inventário Total',
+    totalConsumed: 'Total Consumido',
+    byMaterial: 'Por Material',
+    inPrinter: 'Na Impressora',
+    lowStock: 'Estoque Baixo',
+    sinceTracking: 'Desde o início do rastreamento',
+    loadedInAms: 'Carregado no AMS/Ext',
+    remaining: 'Restante',
+    lowStockThreshold: '<20% restante',
+    search: 'Pesquisar carretéis...',
+    showing: 'Mostrando',
+    to: 'até',
+    of: 'de',
+    show: 'Mostrar',
+    spools: 'carretéis',
+    spool: 'carretel',
+    page: 'Página',
+    noSpoolsMatch: 'Nenhum resultado encontrado',
+    noSpoolsMatchDesc: 'Tente ajustar sua pesquisa ou filtros para encontrar o que você está procurando.',
+    active: 'Ativo',
+    archived: 'Arquivado',
+    all: 'Todos',
+    used: 'Usado',
+    new: 'Novo',
+    clearFilters: 'Limpar filtros',
+    table: 'Tabela',
+    cards: 'Cartões',
+    net: 'Líquido',
+    // Column config
+    columns: 'Colunas',
+    configureColumns: 'Configurar Colunas',
+    configureColumnsDesc: 'Arraste para reordenar as colunas ou use as setas. Alterne a visibilidade com o ícone de olho.',
+    visible: 'Visível',
+    reset: 'Redefinir',
+    cancel: 'Cancelar',
+    applyChanges: 'Aplicar Alterações',
+    moveUp: 'Mover para cima',
+    moveDown: 'Mover para baixo',
+    hideColumn: 'Ocultar coluna',
+    showColumn: 'Mostrar coluna',
+    // Tag linking
+    linkToSpool: 'Vincular ao Carretel',
+    tagLinked: 'Tag vinculada ao carretel',
+    tagLinkFailed: 'Falha ao vincular tag',
+    tagAlreadyLinked: 'Tag já vinculada a outro carretel',
+    unknownTag: 'Tag RFID desconhecida detectada',
+    // Usage history
+    usageHistory: 'Histórico de Uso',
+    noUsageHistory: 'Nenhum uso registrado ainda',
+    printName: 'Nome da Impressão',
+    weightConsumed: 'Peso Consumido',
+    clearHistory: 'Limpar',
+    historyCleared: 'Histórico de uso limpo',
+    fillSourceLabel: '(Inv)',
+  },
+
+  // Timelapse
+  timelapse: {
+    title: 'Timelapse',
+    create: 'Criar Timelapse',
+    download: 'Baixar',
+    delete: 'Excluir',
+    preview: 'Visualizar',
+    frameRate: 'Taxa de Quadros',
+    quality: 'Qualidade',
+    processing: 'Processando...',
+    noTimelapses: 'Nenhum timelapse disponível',
+  },
+
+  // AMS
+  ams: {
+    title: 'AMS',
+    slot: 'Slot',
+    empty: 'Vazio',
+    emptySlot: 'Slot vazio',
+    unknown: 'Desconhecido',
+    humidity: 'Umidade',
+    temperature: 'Temperatura',
+    filamentType: 'Tipo de Filamento',
+    filamentColor: 'Cor',
+    remaining: 'Restante',
+    history: 'Histórico do AMS',
+    noHistory: 'Nenhum histórico disponível',
+    configureSlot: 'Configurar Slot',
+    externalSpool: 'Carretel Externo',
+    profile: 'Perfil',
+    kFactor: 'Fator K',
+    fill: 'Preencher',
+    configure: 'Configurar',
+  },
+
+  // Print modal
+  printModal: {
+    title: 'Iniciar Impressão',
+    selectPrinter: 'Selecionar Impressora',
+    selectPlate: 'Selecionar Placa',
+    filamentMapping: 'Mapeamento de Filamento',
+    printSettings: 'Configurações de Impressão',
+    bedLeveling: 'Nivelamento da Mesa',
+    flowCalibration: 'Calibração de Fluxo',
+    vibrationCalibration: 'Calibração de Vibração',
+    layerInspection: 'Inspeção da Primeira Camada',
+    timelapse: 'Timelapse',
+    startPrint: 'Iniciar Impressão',
+    addToQueue: 'Adicionar à Fila',
+    cancel: 'Cancelar',
+    noPrintersAvailable: 'Nenhuma impressora disponível',
+    printerBusy: 'Impressora ocupada',
+    printerOffline: 'Impressora offline',
+    sameTypeDifferentColor: 'Mesmo tipo, cor diferente',
+    filamentTypeNotLoaded: 'Tipo de filamento não carregado',
+    openCalendar: 'Abrir calendário',
+    leftNozzle: 'L',
+    rightNozzle: 'R',
+    leftNozzleTooltip: 'Bico esquerdo',
+    rightNozzleTooltip: 'Bico direito',
+  },
+
+  // Backup
+  backup: {
+    title: 'Bakup e Restauração',
+    createBackup: 'Criar Backup',
+    restoreBackup: 'Restaurar Backup',
+    restoreDescription: 'Substituir todos os dados a partir de um arquivo de backup',
+    downloadBackup: 'Baixar Backup',
+    uploadBackup: 'Enviar Backup',
+    lastBackup: 'Último Backup',
+    autoBackup: 'Auto Backup',
+    backupNow: 'Fazer Backup Agora',
+    restoreWarning: 'Aviso: Restaurar um backup substituirá todos os dados atuais.',
+    includeArchives: 'Incluir Arquivos',
+    includeSettings: 'Incluir Configurações',
+    includeProfiles: 'Incluir Perfis',
+    backupSuccess: 'Backup criado com sucesso',
+    restoreSuccess: 'Backup restaurado com sucesso',
+    backupFailed: 'Falha ao criar backup',
+    restoreFailed: 'Falha ao restaurar backup',
+    restoreNote: 'A impressora virtual será parada durante a restauração',
+  },
+
+  // Tags
+  tags: {
+    title: 'Tags',
+    addTag: 'Adicionar Tag',
+    editTag: 'Editar Tag',
+    deleteTag: 'Excluir Tag',
+    tagName: 'Nome da Tag',
+    tagColor: 'Cor da Tag',
+    noTags: 'Nenhuma tag',
+    deleteConfirm: 'Tem certeza de que deseja excluir esta tag?',
+    manageTags: 'Gerenciar Tags',
+  },
+
+  // Upload modal (archives)
+  uploadModal: {
+    title: 'Upload Arquivos 3MF',
+    dragDrop: 'Arraste e solte arquivos .3mf aqui',
+    or: 'ou',
+    browseFiles: 'Procurar Arquivos',
+    extractionInfo: 'O modelo da impressora será extraído automaticamente dos metadados do arquivo 3MF.',
+    uploaded: 'enviado',
+    failed: 'falhou',
+    uploading: 'Enviando...',
+    upload: 'Enviar',
+    uploadFailed: 'Falha no envio',
+  },
+
+  // Edit archive modal
+  // Edit Archive Modal
+  editArchive: {
+    title: 'Editar Arquivo',
+    name: 'Nome',
+    namePlaceholder: 'Nome da impressão',
+    printer: 'Impressora',
+    noPrinter: 'Nenhuma impressora',
+    project: 'Projeto',
+    noProject: 'Nenhum projeto',
+    itemsPrinted: 'Itens Impressos',
+    itemsPrintedHelp: 'Número de itens produzidos neste trabalho de impressão',
+    notes: 'Notas',
+    notesPlaceholder: 'Adicione notas sobre esta impressão...',
+    externalLink: 'Link Externo',
+    externalLinkPlaceholder: 'https://printables.com/model/...',
+    externalLinkHelp: 'Link para Printables, Thingiverse ou outra fonte',
+    tags: 'Tags',
+    tagsPlaceholder: 'Adicionar tags...',
+    addMoreTags: 'Adicionar mais tags...',
+    matchingTags: 'Correspondendo "{{query}}"',
+    existingTags: 'Tags existentes',
+    clickToAdd: '(clique para adicionar)',
+    status: 'Status',
+    failureReason: 'Motivo da Falha',
+    selectReason: 'Selecione o motivo...',
+    photos: 'Fotos do Resultado da Impressão',
+    photosHelp: 'Clique em + para adicionar fotos do seu resultado impresso',
+    printResult: 'Resultado da Impressão',
+    saving: 'Salvando...',
+    // Failure reasons
+    failureReasons: {
+      adhesionFailure: 'Falha de adesão',
+      spaghettiDetached: 'Spaghetti / Destacado',
+      layerShift: 'Deslocamento de camada',
+      cloggedNozzle: 'Bico entupido',
+      filamentRunout: 'Fim do filamento',
+      warping: 'Warping',
+      stringing: 'Stringing',
+      underExtrusion: 'Under-extrusion',
+      powerFailure: 'Falha de energia',
+      userCancelled: 'Cancelado pelo usuário',
+      other: 'Outro',
+    },
+    // Archive statuses
+    statuses: {
+      completed: 'Concluído',
+      failed: 'Falhou',
+      aborted: 'Cancelado',
+      printing: 'Imprimindo',
+    },
+  },
+
+  // K-Profiles
+  kProfiles: {
+    title: 'K-Profiles',
+    noPrintersConfigured: 'Nenhuma impressora configurada',
+    addPrinterInSettings: 'Adicione uma impressora nas Configurações para gerenciar K-profiles',
+    noActivePrinters: 'Nenhuma impressora ativa',
+    enablePrinterConnection: 'Ative a conexão da impressora para visualizar seus K-profiles',
+    loadingProfiles: 'Carregando K-Profiles...',
+    printerOffline: 'Impressora Offline',
+    printerOfflineDesc: 'A impressora selecionada não está conectada. Ligue-a para visualizar os K-profiles.',
+    noMatchingProfiles: 'Nenhum Perfil Correspondente',
+    noMatchingProfilesDesc: 'Nenhum perfil corresponde aos seus critérios de pesquisa',
+    noKProfiles: 'Nenhum K-Profile',
+    noKProfilesDesc: 'Nenhum perfil de avanço de pressão encontrado para bico de {{diameter}}mm',
+    createFirstProfile: 'Criar Primeiro Perfil',
+    // Controls
+    printer: 'Impressora',
+    nozzle: 'Bico',
+    refresh: 'Atualizar',
+    addProfile: 'Adicionar Perfil',
+    export: 'Exportar',
+    import: 'Importar',
+    select: 'Selecionar',
+    selectAll: 'Selecionar Todos',
+    delete: 'Excluir',
+    // Filters
+    searchPlaceholder: 'Pesquisar por nome ou filamento...',
+    allExtruders: 'Todos os Extrusores',
+    leftOnly: 'Apenas Esquerdo',
+    rightOnly: 'Apenas Direito',
+    allFlow: 'Todo Fluxo',
+    hfOnly: 'Apenas HF',
+    sOnly: 'Apenas S',
+    sortName: 'Ordenar: Nome',
+    sortKValue: 'Ordenar: Valor K',
+    sortFilament: 'Ordenar: Filamento',
+    // Dual extruder labels
+    leftExtruder: 'Extrusor Esquerdo',
+    rightExtruder: 'Extrusor Direito',
+    // Modal
+    modal: {
+      addTitle: 'Adicionar K-Profile',
+      editTitle: 'Editar K-Profile',
+      profileName: 'Nome do Perfil',
+      profileNamePlaceholder: 'Meu Perfil PLA',
+      kValue: 'Valor K',
+      kValuePlaceholder: '0.020',
+      kValueHelp: 'Faixa típica: 0.01 - 0.06 para PLA, 0.02 - 0.10 para PETG',
+      filament: 'Filamento',
+      selectFilament: 'Selecionar filamento...',
+      noFilamentsHelp: 'Nenhum filamento encontrado. Crie um K-profile no Bambu Studio primeiro.',
+      flowType: 'Tipo de Fluxo',
+      highFlow: 'Alto Fluxo',
+      standard: 'Padrão',
+      nozzleSize: 'Tamanho do Bico',
+      extruder: 'Extrusor',
+      extruders: 'Extrusores',
+      left: 'Esquerdo',
+      right: 'Direito',
+      notes: 'Notas (armazenadas localmente)',
+      notesPlaceholder: 'Adicione notas sobre este perfil...',
+      notesHelp: 'As notas são salvas no Bambuddy, não na impressora',
+      syncing: 'Sincronizando com a impressora...',
+      savingExtruder: 'Salvando no extrusor {{current}}/{{total}}...',
+      pleaseWait: 'Por favor, aguarde',
+    },
+    // Delete confirmation
+    deleteConfirm: {
+      title: 'Excluir Perfil',
+      cannotUndo: 'Isso não pode ser desfeito',
+      message: 'Tem certeza de que deseja excluir "{{name}}" da impressora?',
+    },
+    // Bulk delete
+    bulkDelete: {
+      title: 'Excluir Perfis',
+      cannotUndo: 'Isso não pode ser desfeito',
+      message: 'Tem certeza de que deseja excluir {{count}} perfis selecionados da impressora?',
+    },
+    // Toast
+    toast: {
+      profileSaved: 'K-profile salvo',
+      profilesSaved: 'K-profile salvo em {{count}} extrusores',
+      selectAtLeastOneExtruder: 'Por favor, selecione pelo menos um extrusor',
+      profileDeleted: 'K-profile excluído',
+      profilesDeleted: '{{count}} perfis excluídos',
+      exportedProfiles: '{{count}} perfis exportados',
+      importedProfiles: '{{count}} de {{total}} perfis importados',
+      noProfilesToExport: 'Nenhum perfil para exportar',
+      invalidFileFormat: 'Formato de arquivo inválido',
+      failedToParseImport: 'Falha ao analisar o arquivo de importação',
+      failedToSaveBatch: 'Falha ao salvar K-profiles',
+      noteSaved: 'Nota salva',
+      failedToSaveNote: 'Falha ao salvar nota',
+    },
+    // Permissions
+    permission: {
+      noRead: 'Você não tem permissão para atualizar perfis',
+      noCreate: 'Você não tem permissão para adicionar perfis',
+      noUpdate: 'Você não tem permissão para atualizar K-profiles',
+      noDelete: 'Você não tem permissão para excluir K-profiles',
+      noExport: 'Você não tem permissão para exportar perfis',
+      noImport: 'Você não tem permissão para importar perfis',
+    },
+  },
+
+  // Virtual Printer
+  virtualPrinter: {
+    title: 'Impressora Virtual',
+    running: 'Em execução',
+    stopped: 'Parada',
+    description: {
+      default: 'Ative uma impressora virtual que aparece no Bambu Studio e no OrcaSlicer. Os arquivos enviados para esta impressora serão arquivados diretamente sem impressão.',
+      proxy: 'Ative um proxy que retransmite o tráfego do slicer para uma impressora real, permitindo impressão remota em qualquer rede.',
+    },
+    enable: {
+      title: 'Ativar Impressora Virtual',
+      visibleInSlicer: 'Visível como "Bambuddy" na descoberta do slicer',
+      proxyingTo: 'Proxy para {{name}}',
+      notActive: 'Não ativo',
+    },
+    model: {
+      title: 'Modelo da Impressora',
+      description: 'Selecione qual modelo de impressora emular.',
+      restartWarning: 'Alterar o modelo reiniciará a impressora virtual',
+    },
+    accessCode: {
+      title: 'Código de acesso',
+      isSet: 'O código de acesso está definido',
+      notSet: 'Nenhum código de acesso definido — necessário para ativar.',
+      placeholder: 'Digite um código de 8 caracteres',
+      placeholderChange: 'Digite um novo código para alterar',
+      hint: 'Deve ter exatamente 8 caracteres. Usado pelos slicers para autenticação.',
+      charCount: '({{count}}/8)',
+    },
+    targetPrinter: {
+      title: 'Impressora Alvo',
+      configured: 'Proxy alvo configurado',
+      notConfigured: 'Nenhuma impressora alvo selecionada - necessário para o modo proxy',
+      placeholder: 'Selecione uma impressora...',
+      hint: 'Selecione a impressora para a qual o tráfego do slicer será enviado. A impressora deve estar no modo LAN.',
+      noPrinters: 'Nenhuma impressora configurada. Adicione uma impressora primeiro para usar o modo proxy.',
+    },
+    remoteInterface: {
+      title: 'Substituição da Interface de Rede',
+      configured: 'Substituição da interface ativa',
+      optional: 'Opcional - use se o IP detectado automaticamente estiver errado (por exemplo, várias NICs, Docker, VPN)',
+      placeholder: 'Detecção automática (padrão)...',
+      hint: 'Substitua o endereço IP anunciado via SSDP e usado no certificado TLS. Útil quando o Bambuddy possui várias interfaces de rede.',
+    },
+    mode: {
+      title: 'Modo',
+      archive: 'Arquivar',
+      archiveDesc: 'Arquivar arquivos imediatamente',
+      review: 'Revisar',
+      reviewDesc: 'Revisar antes de arquivar',
+      queue: 'Fila',
+      queueDesc: 'Arquivar e adicionar à fila',
+      proxy: 'Proxy',
+      proxyDesc: 'Retransmitir para impressora real',
+    },
+    setupRequired: {
+      title: 'Configuração Necessária',
+      description: 'O recurso de impressora virtual requer configuração adicional do sistema antes de funcionar. Isso inclui encaminhamento de portas, regras de firewall e configurações específicas da plataforma.',
+      readGuide: 'Leia o guia de configuração antes de ativar',
+    },
+    howItWorks: {
+      title: 'Como funciona',
+      titleProxy: 'Como funciona (Modo Proxy)',
+      step1: 'Complete o guia de configuração para sua plataforma',
+      step2: 'Ative a impressora virtual e defina um código de acesso',
+      step3: 'No Bambu Studio ou OrcaSlicer, vá para "Adicionar Impressora"',
+      step4: 'A impressora "Bambuddy" deve aparecer na lista de descoberta',
+      step5: 'Conecte-se usando o código de acesso que você definiu',
+      step6: 'Quando você "imprimir" para o Bambuddy, o arquivo 3MF será arquivado em vez disso',
+      proxyStep1: 'Selecione a impressora alvo (deve estar no modo LAN)',
+      proxyStep2: 'Para rede cruzada: selecione a interface de rede do slicer',
+      proxyStep3: 'Ative o proxy - a impressora aparece na descoberta do slicer via SSDP',
+      proxyStep4: 'Conecte-se usando o código de acesso da impressora',
+      proxyStep5: 'Imprima normalmente - o tráfego é retransmitido pelo Bambuddy',
+      proxyStep6: 'O streaming da câmera requer NAT/encaminhamento de IP (veja a documentação)',
+    },
+    status: {
+      title: 'Detalhes do Status',
+      printerName: 'Nome da Impressora',
+      model: 'Modelo',
+      serialNumber: 'Número de Série',
+      mode: 'Modo',
+      pendingFiles: 'Arquivos Pendentes',
+      targetPrinter: 'Impressora Alvo',
+      ftpPort: 'Porta FTP',
+      mqttPort: 'Porta MQTT',
+      ftpConnections: 'Conexões FTP',
+      mqttConnections: 'Conexões MQTT',
+    },
+    toast: {
+      updated: 'Configurações da impressora virtual atualizadas',
+      failedToUpdate: 'Falha ao atualizar as configurações',
+      accessCodeRequired: 'Defina um código de acesso primeiro',
+      targetPrinterRequired: 'Selecione uma impressora alvo primeiro',
+      accessCodeEmpty: 'O código de acesso não pode estar vazio',
+      accessCodeLength: 'O código de acesso deve ter exatamente 8 caracteres',
+    },
+  },
+
+  // Model Viewer
+  modelViewer: {
+    openInSlicer: 'Abrir no Slicer',
+    tabs: {
+      model: 'Modelo 3D',
+      gcode: 'Pré-visualização G-code',
+    },
+    notAvailable: 'Não disponível',
+    notSliced: 'Não fatiado',
+    plates: 'Placas',
+    allPlates: 'Todas as Placas',
+    plateNumber: 'Placa {{number}}',
+    plateCount: '{{count}} placa',
+    plateCount_other: '{{count}} placas',
+    objectCount: '{{count}} objeto',
+    objectCount_other: '{{count}} objetos',
+    filamentCount: '{{count}} filamento',
+    filamentCount_other: '{{count}} filamentos',
+    eta: 'ETA {{minutes}} min',
+    noPreview: 'Pré-visualização não disponível para este arquivo',
+    pagination: {
+      pageOf: 'Página {{current}} de {{total}}',
+      prev: 'Anterior',
+      next: 'Próximo',
+    },
+    errors: {
+      failedToLoad: 'Falha ao carregar o arquivo',
+      noMeshes: 'Nenhuma malha encontrada no arquivo 3MF',
+      unsupportedFormat: 'Formato de arquivo não suportado',
+    },
+  },
+
+  // Maintenance type descriptions (built-in)
+  maintenanceDescriptions: {
+    lubricateCarbonRods: 'Aplique lubrificante nos eixos de carbono para um movimento suave',
+    lubricateRails: 'Aplique lubrificante nos trilhos lineares para um movimento suave',
+    cleanNozzle: 'Limpe o hotend e o bico para evitar entupimentos',
+    checkBelts: 'Verifique a tensão das correias para impressões precisas',
+    cleanBuildPlate: 'Limpe a placa de construção para melhor adesão',
+    checkExtruder: 'Verifique as engrenagens do extrusor quanto ao desgaste',
+    checkCooling: 'Verifique se os ventiladores de resfriamento estão funcionando corretamente',
+    generalInspection: 'Inspeção geral da impressora',
+    cleanCarbonRods: 'Limpe os eixos de carbono para reduzir o atrito',
+    cleanLinearRails: 'Limpe os trilhos lineares para remover poeira e detritos',
+    checkPtfeTube: 'Verifique o tubo PTFE quanto ao desgaste ou danos',
+    replaceHepaFilter: 'Substitua o filtro HEPA para qualidade do ar',
+    replaceCarbonFilter: 'Substitua o filtro de carbono ativado',
+    lubricateLeftNozzleRail: 'Lubrifique o trilho do bico esquerdo (série H2)',
+  },
+
+  // Smart Plugs
+  smartPlugs: {
+    offline: 'Offline',
+    admin: 'Admin',
+    openPlugAdminPage: 'Abrir o painel de administração da tomada inteligente',
+    deleteSmartPlug: 'Excluir Tomada Inteligente',
+    turnOnSmartPlug: 'Ligar Tomada Inteligente',
+    turnOffSmartPlug: 'Desligar Tomada Inteligente',
+    turnOn: 'Ligar',
+    turnOff: 'Desligar',
+    addSmartPlug: {
+      scanningNetwork: 'Procurando na rede...',
+      chooseEntity: 'Escolha uma entidade...',
+      connectionFailed: 'Falha na conexão',
+      searchEntities: 'Pesquisar entidades...',
+      searchPowerSensors: 'Pesquisar sensores de energia...',
+      searchEnergySensors: 'Pesquisar sensores de energia...',
+      placeholders: {
+        plugName: 'Tomada da Sala',
+        mqttStateOnValue: 'ON, true, 1',
+        mqttSameAsPower: 'Mesmo que o tópico de energia, ou diferente',
+      },
+    },
+  },
+
+  // Rich Text Editor
+  richTextEditor: {
+    bold: 'Negrito',
+    italic: 'Itálico',
+    underline: 'Sublinhado',
+    bulletList: 'Lista com marcadores',
+    numberedList: 'Lista numerada',
+    alignLeft: 'Alinhar à esquerda',
+    alignCenter: 'Centralizar',
+    alignRight: 'Alinhar à direita',
+    addLink: 'Adicionar link',
+    removeLink: 'Remover link',
+  },
+
+  // External Links
+  externalLinks: {
+    noLinksConfigured: 'Nenhum link externo configurado',
+    deleteLink: 'Excluir link',
+    removeCustomIcon: 'Remover ícone personalizado',
+    openInNewTab: 'Abrir em nova aba',
+    placeholders: {
+      linkName: 'Meu Link',
+    },
+  },
+
+  // Keyboard Shortcuts Modal
+  keyboardShortcuts: {
+    title: 'Atalhos de Teclado',
+    navigation: 'Navegação',
+    archivesSection: 'Arquivos',
+    kProfilesSection: 'K-Profiles',
+    generalSection: 'Geral',
+    shortcuts: {
+      goToPrinters: 'Ir para Impressoras',
+      goToArchives: 'Ir para Arquivos',
+      goToQueue: 'Ir para Fila',
+      goToStats: 'Ir para Estatísticas',
+      goToProfiles: 'Ir para Perfis na Nuvem',
+      goToSettings: 'Ir para Configurações',
+      focusSearch: 'Focar na pesquisa',
+      openUploadModal: 'Abrir modal de upload',
+      clearSelection: 'Limpar seleção / desfocar input',
+      contextMenu: 'Menu de contexto nos cartões',
+      refreshProfiles: 'Atualizar perfis',
+      newProfile: 'Novo perfil',
+      exitSelectionMode: 'Sair do modo de seleção',
+      showHelp: 'Mostrar esta ajuda',
+    },
+    footer: 'Pressione Esc ou clique fora para fechar',
+  },
+
+  // Notification Log
+  notificationLog: {
+    title: 'Registro de Notificações',
+    events: {
+      printStarted: 'Impressão Iniciada',
+      printComplete: 'Impressão Concluída',
+      printFailed: 'Impressão Falhou',
+      printStopped: 'Impressão Interrompida',
+      progress: 'Progresso',
+      printerOffline: 'Impressora Offline',
+      printerError: 'Erro na Impressora',
+      lowFilament: 'Filamento Baixo',
+      maintenanceDue: 'Manutenção Pendente',
+      test: 'Teste',
+    },
+    timeAgo: {
+      justNow: 'Agora mesmo',
+      minutesAgo: 'há {{minutes}} minutos',
+      hoursAgo: 'há {{hours}} horas',
+    },
+  },
+
+  // Restore/Backup Modal
+  restoreBackup: {
+    title: 'Restaurar Backup',
+    restoring: 'Restaurando...',
+    restoreComplete: 'Restauração Concluída',
+    restoreFailed: 'Falha na Restauração',
+    importSettings: 'Importar configurações de um arquivo de backup',
+    pleaseWait: 'Aguarde enquanto seus dados estão sendo restaurados',
+    clickToSelect: 'Clique para selecionar o arquivo de backup (.json ou .zip)',
+    howDuplicateHandling: 'Como funciona o tratamento de duplicatas:',
+    categories: {
+      printers: 'Impressoras',
+      smartPlugs: 'Tomadas Inteligentes',
+      notificationProviders: 'Provedores de Notificação',
+      filaments: 'Filamentos',
+      archives: 'Arquivos',
+      pendingUploads: 'Uploads Pendentes',
+      settingsTemplates: 'Configurações e Modelos',
+    },
+    matchingInfo: {
+      printers: 'correspondem pelo número de série',
+      smartPlugs: 'correspondem pelo endereço IP',
+      notificationProviders: 'correspondem pelo nome',
+      filaments: 'correspondem pelo nome + tipo + marca',
+      archives: 'correspondem pelo hash do conteúdo',
+      pendingUploads: 'correspondem pelo nome do arquivo',
+      settingsTemplates: 'sempre sobrescrito',
+    },
+    replaceExisting: 'Substituir dados existentes',
+    keepExisting: 'Manter dados existentes',
+    replaceDescription: 'Substituir itens que já existem com os dados do backup',
+    keepDescription: 'Restaurar apenas itens que não existem',
+    caution: 'Atenção:',
+    cautionText: 'Sobrescrever substituirá suas configurações atuais pelos dados do backup. Os códigos de acesso da impressora nunca são sobrescritos por segurança.',
+    itemsRestored: 'Itens Restaurados',
+    itemsSkipped: 'Itens Ignorados',
+    restored: 'Restaurado',
+    skipped: 'Ignorado (já existe)',
+    filesLabel: 'Arquivos (3MF, miniaturas, etc.)',
+    newApiKeysGenerated: 'Novas Chaves API Geradas',
+    newApiKeysWarning: 'Essas chaves são exibidas apenas uma vez. Copie-as agora!',
+    processingBackup: 'Processando arquivo de backup...',
+    noDataFound: 'Nenhum dado foi encontrado para restaurar no arquivo de backup.',
+    failedToRestore: 'Falha ao restaurar o backup. Verifique o formato do arquivo.',
+  },
+
+  // Backup Export Modal
+  backupExport: {
+    title: 'Exportar Backup',
+    selectData: 'Selecione os dados a incluir',
+    selectAll: 'Selecionar Todos',
+    selectNone: 'Selecionar Nenhum',
+    categoryDescriptions: {
+      settings: 'Idioma, tema, preferências de atualização',
+      notifications: 'ntfy, Pushover, Discord, etc.',
+      templates: 'Modelos de mensagens personalizadas',
+      smartPlugs: 'Configurações de tomadas Tasmota',
+      externalLinks: 'Links da barra lateral para serviços externos',
+      printers: 'Informações da impressora (códigos de acesso excluídos)',
+      plateDetection: 'Imagens de referência de placa vazia',
+      filaments: 'Tipos e custos de filamento',
+      maintenance: 'Cronogramas de manutenção personalizados',
+      archives: 'Todos os dados de impressão + arquivos (3MF, miniaturas, fotos)',
+      projects: 'Projetos, itens de BOM e anexos',
+      pendingUploads: 'Uploads de impressora virtual aguardando revisão',
+      apiKeys: 'Chaves API de webhook (novas chaves geradas na importação)',
+    },
+    requiresPrinters: 'Requer que as impressoras sejam selecionadas',
+    zipFileWarning: 'Um arquivo ZIP será criado.',
+    zipFileDescription: 'Inclui todos os arquivos 3MF, miniaturas, timelapses e fotos. Isso pode levar algum tempo e resultar em um arquivo grande.',
+    includeAccessCodes: 'Incluir Códigos de Acesso',
+    includeAccessCodesDescription: 'Para transferir para outra máquina',
+    includeAccessCodesWarning: 'Os códigos de acesso serão incluídos em texto simples. Mantenha este arquivo de backup seguro!',
+    categoriesSelected: '{{selectedCount}} categorias selecionadas',
+  },
+
+  // Pending Uploads Panel
+  pendingUploads: {
+    placeholders: {
+      notes: 'Adicione notas sobre esta impressão...',
+    },
+    discardUpload: 'Descartar Upload',
+    archiveAllUploads: 'Arquivar Todos os Uploads',
+    discardAllUploads: 'Descartar Todos os Uploads',
+    archive: 'Arquivar',
+    timeAgo: {
+      justNow: 'Agora mesmo',
+      minutesAgo: 'há {{minutes}} minutos',
+      hoursAgo: 'há {{hours}} horas',
+      daysAgo: 'há {{days}} dias',
+    },
+  },
+
+  // API Browser
+  apiBrowser: {
+    placeholders: {
+      requestBody: 'Corpo da requisição JSON...',
+      searchEndpoints: 'Pesquisar endpoints...',
+    },
+  },
+
+  // Configure AMS Slot Modal
+  configureAmsSlot: {
+    title: 'Configurar Slot AMS',
+    slotConfigured: 'Slot Configurado!',
+    configuringSlot: 'Configurando slot:',
+    slotLabel: '{{ams}} Slot {{slot}}',
+    searchPresets: 'Pesquisar predefinições...',
+    colorPlaceholder: 'Nome da cor ou hex (ex.: marrom, FF8800)',
+    clearCustomColor: 'Limpar cor personalizada',
+    noCloudPresets: 'Nenhuma predefinição na nuvem. Faça login no Bambu Cloud para sincronizar.',
+    noPresetsAvailable: 'Nenhuma predefinição disponível. Faça login no Bambu Cloud ou importe perfis locais.',
+    noMatchingPresets: 'Nenhuma predefinição correspondente encontrada.',
+    custom: 'Personalizado',
+    builtin: 'Integrado',
+    settingsSentToPrinter: 'Configurações enviadas para a impressora',
+    filamentProfile: 'Perfil de Filamento',
+    kProfileLabel: 'Perfil K (Avanço de Pressão)',
+    filteringFor: 'Filtrando por: {{material}}',
+    noKProfile: 'Nenhum perfil K (usar padrão 0.020)',
+    noMatchingKProfiles: 'Nenhum perfil K correspondente encontrado. O K padrão=0.020 será usado.',
+    selectFilamentFirst: 'Selecione um perfil de filamento primeiro',
+    kFromCalibration: 'K={{value}} da calibração da impressora',
+    customColorLabel: 'Cor Personalizada (opcional)',
+    presetColors: 'Cores de {{name}}:',
+    showLessColors: 'Mostrar menos cores',
+    showMoreColors: 'Mostrar mais cores',
+    clear: 'Limpar',
+    hexLabel: 'Hex: #{{hex}}',
+    resetting: 'Redefinindo...',
+    resetSlot: 'Redefinir Slot',
+    cancel: 'Cancelar',
+    configuring: 'Configurando...',
+    configureSlot: 'Configurar Slot',
+  },
+
+  // GitHub Backup Settings
+  githubBackup: {
+    title: 'Backup do GitHub',
+    history: 'Histórico',
+    downloadBackup: 'Baixar Backup',
+    restoreBackup: 'Restaurar Backup',
+    noBackupsYet: 'Nenhum backup ainda',
+  },
+
+  // Email Settings
+  emailSettings: {
+    placeholders: {
+      fromName: 'BamBuddy',
+    },
+  },
+
+  // Tag Management Modal
+  tagManagement: {
+    searchTags: 'Pesquisar tags...',
+    renameTag: 'Renomear tag',
+    deleteTag: 'Excluir tag',
+  },
+
+  // Notification Template Editor
+  notificationTemplates: {
+    placeholders: {
+      title: 'Título da notificação...',
+      body: 'Corpo da notificação...',
+    },
+  },
+
+  // Batch Tag Modal
+  batchTag: {
+    placeholders: {
+      newTag: 'Digite uma nova tag...',
+    },
+  },
+
+  // Photo Gallery Modal
+  photoGallery: {
+    deletePhoto: 'Excluir foto',
+  },
+
+  // Filament Hover Card
+  filamentHoverCard: {
+    copySpoolUuid: 'Copiar UUID do carretel',
+  },
+
+  // K Profiles View
+  kProfilesView: {
+    hasNote: 'Possui nota',
+    copyProfile: 'Copiar perfil',
+  },
+
+  // Layout/Navigation
+  layout: {
+    openMenu: 'Abrir menu',
+    noPermissionSystemInfo: 'Você não tem permissão para visualizar informações do sistema',
+  },
+
+  // Dashboard
+  dashboard: {
+    dragToReorder: 'Arrastar para reordenar',
+    hideWidget: 'Ocultar widget',
+  },
+
+  // Notification Provider Card
+  notificationProviderCard: {
+    deleteNotificationProvider: 'Excluir provedor de notificação',
+  },
+
+  // File Manager Modal
+  fileManagerModal: {
+    closeFileManager: 'Fechar gerenciador de arquivos',
+    sortFiles: 'Ordenar arquivos',
+    goToParentFolder: 'Ir para a pasta pai',
+    threeView: 'Visualização 3D',
+  },
+
+  // Embedded Camera Viewer
+  embeddedCameraViewer: {
+    refreshStream: 'Atualizar stream',
+    close: 'Fechar',
+    zoomOut: 'Reduzir zoom',
+    resetZoom: 'Redefinir zoom',
+    zoomIn: 'Aumentar zoom',
+    dragToResize: 'Arrastar para redimensionar',
+  },
+
+  // Timelapse Viewer
+  timelapseViewer: {
+    skipBack5s: 'Voltar 5s',
+    skipForward5s: 'Avançar 5s',
+  },
+
+  // Notification Providers
+  notificationProviders: {
+    descriptions: {
+      email: 'Notificações por email SMTP',
+      telegram: 'Notificações via bot do Telegram',
+      discord: 'Enviar para canal do Discord via webhook',
+      ntfy: 'Notificações push gratuitas e auto-hospedáveis',
+      pushover: 'Notificações push simples e confiáveis',
+      callmebot: 'Notificações gratuitas via WhatsApp pelo CallMeBot',
+      webhook: 'POST HTTP genérico para qualquer URL',
+    },
+  },
+
+  // Log Viewer
+  logViewer: {
+    searchPlaceholder: 'Pesquisar mensagem ou nome do logger...',
+    noLogEntries: 'Nenhuma entrada de log encontrada',
+  },
+
+  // Switchbar Popover
+  switchbarPopover: {
+    noSwitchesInSwitchbar: 'Nenhum switch na barra de switches',
+  },
+
+  // Project Page Modal
+  projectPageModal: {
+    placeholders: {
+      title: 'Título',
+      designer: 'Designer',
+      license: 'Licença',
+      description: 'Digite a descrição...',
+      profileTitle: 'Título do perfil',
+      profileDescription: 'Descrição do perfil...',
+    },
+  },
+
+  // Spoolman Settings
+  spoolmanSettings: {},
+};

+ 39 - 22
frontend/src/pages/ArchivesPage.tsx

@@ -74,15 +74,10 @@ import { PendingUploadsPanel } from '../components/PendingUploadsPanel';
 import { TagManagementModal } from '../components/TagManagementModal';
 import { TagManagementModal } from '../components/TagManagementModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
+import { formatFileSize } from '../utils/file';
 
 
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
 
 
-function formatFileSize(bytes: number): string {
-  if (bytes < 1024) return `${bytes} B`;
-  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
-  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
-}
-
 /**
 /**
  * Check if an archive filename represents a sliced/printable file.
  * Check if an archive filename represents a sliced/printable file.
  * Matches: .gcode, .gcode.3mf, .gcode.anything
  * Matches: .gcode, .gcode.3mf, .gcode.anything
@@ -105,6 +100,36 @@ function getArchiveFileType(filename: string | null | undefined): string | undef
 
 
 // formatDate imported from '../utils/date' - handles UTC conversion
 // formatDate imported from '../utils/date' - handles UTC conversion
 
 
+/**
+ * Open an archive file in the slicer.
+ * Fetches a short-lived download token, then builds a token-authenticated URL
+ * that bypasses auth middleware (slicer protocol handlers can't send auth headers).
+ */
+async function openInSlicerWithToken(
+  archiveId: number,
+  filename: string,
+  resourceType: 'file' | 'source',
+  slicer: SlicerType,
+): Promise<void> {
+  try {
+    if (resourceType === 'source') {
+      const { token } = await api.createSourceSlicerToken(archiveId);
+      const path = api.getSourceSlicerDownloadUrl(archiveId, token, filename);
+      openInSlicer(`${window.location.origin}${path}`, slicer);
+    } else {
+      const { token } = await api.createArchiveSlicerToken(archiveId);
+      const path = api.getArchiveSlicerDownloadUrl(archiveId, token, filename);
+      openInSlicer(`${window.location.origin}${path}`, slicer);
+    }
+  } catch {
+    // Fallback to direct URL (works when auth is disabled)
+    const path = resourceType === 'source'
+      ? api.getSource3mfForSlicer(archiveId, filename)
+      : api.getArchiveForSlicer(archiveId, filename);
+    openInSlicer(`${window.location.origin}${path}`, slicer);
+  }
+}
+
 function ArchiveCard({
 function ArchiveCard({
   archive,
   archive,
   printerName,
   printerName,
@@ -337,8 +362,7 @@ function ArchiveCard({
         icon: <ExternalLink className="w-4 h-4" />,
         icon: <ExternalLink className="w-4 h-4" />,
         onClick: () => {
         onClick: () => {
           const filename = archive.print_name || archive.filename || 'model';
           const filename = archive.print_name || archive.filename || 'model';
-          const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-          openInSlicer(downloadUrl, preferredSlicer);
+          openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
         },
         },
         disabled: !archive.file_path,
         disabled: !archive.file_path,
         title: !archive.file_path ? t('archives.card.noFileForReprint') : undefined,
         title: !archive.file_path ? t('archives.card.noFileForReprint') : undefined,
@@ -349,8 +373,7 @@ function ArchiveCard({
         icon: <ExternalLink className="w-4 h-4" />,
         icon: <ExternalLink className="w-4 h-4" />,
         onClick: () => {
         onClick: () => {
           const filename = archive.print_name || archive.filename || 'model';
           const filename = archive.print_name || archive.filename || 'model';
-          const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-          openInSlicer(downloadUrl, preferredSlicer);
+          openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
         },
         },
       },
       },
     ]),
     ]),
@@ -738,8 +761,7 @@ function ArchiveCard({
               e.stopPropagation();
               e.stopPropagation();
               // Open source 3MF in Bambu Studio - use filename in URL for slicer compatibility
               // Open source 3MF in Bambu Studio - use filename in URL for slicer compatibility
               const sourceName = (archive.print_name || archive.filename || 'source').replace(/\.gcode\.3mf$/i, '') + '_source';
               const sourceName = (archive.print_name || archive.filename || 'source').replace(/\.gcode\.3mf$/i, '') + '_source';
-              const downloadUrl = `${window.location.origin}${api.getSource3mfForSlicer(archive.id, sourceName)}`;
-              openInSlicer(downloadUrl, preferredSlicer);
+              openInSlicerWithToken(archive.id, sourceName, 'source', preferredSlicer);
             }}
             }}
             title={t('archives.card.openSource3mf')}
             title={t('archives.card.openSource3mf')}
           >
           >
@@ -1008,8 +1030,7 @@ function ArchiveCard({
                 className="min-w-0 p-1 sm:p-1.5"
                 className="min-w-0 p-1 sm:p-1.5"
                 onClick={() => {
                 onClick={() => {
                   const filename = archive.print_name || archive.filename || 'model';
                   const filename = archive.print_name || archive.filename || 'model';
-                  const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-                  openInSlicer(downloadUrl, preferredSlicer);
+                  openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
                 }}
                 }}
                 title={t('archives.card.openInBambuStudio')}
                 title={t('archives.card.openInBambuStudio')}
               >
               >
@@ -1024,8 +1045,7 @@ function ArchiveCard({
               className="flex-1 min-w-0"
               className="flex-1 min-w-0"
               onClick={() => {
               onClick={() => {
                 const filename = archive.print_name || archive.filename || 'model';
                 const filename = archive.print_name || archive.filename || 'model';
-                const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-                openInSlicer(downloadUrl, preferredSlicer);
+                openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
               }}
               }}
               title={t('archives.card.openInBambuStudioToSlice')}
               title={t('archives.card.openInBambuStudioToSlice')}
             >
             >
@@ -1546,8 +1566,7 @@ function ArchiveListRow({
         icon: <ExternalLink className="w-4 h-4" />,
         icon: <ExternalLink className="w-4 h-4" />,
         onClick: () => {
         onClick: () => {
           const filename = archive.print_name || archive.filename || 'model';
           const filename = archive.print_name || archive.filename || 'model';
-          const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-          openInSlicer(downloadUrl, preferredSlicer);
+          openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
         },
         },
         disabled: !archive.file_path,
         disabled: !archive.file_path,
         title: !archive.file_path ? t('archives.card.noFileForReprint') : undefined,
         title: !archive.file_path ? t('archives.card.noFileForReprint') : undefined,
@@ -1558,8 +1577,7 @@ function ArchiveListRow({
         icon: <ExternalLink className="w-4 h-4" />,
         icon: <ExternalLink className="w-4 h-4" />,
         onClick: () => {
         onClick: () => {
           const filename = archive.print_name || archive.filename || 'model';
           const filename = archive.print_name || archive.filename || 'model';
-          const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-          openInSlicer(downloadUrl, preferredSlicer);
+          openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
         },
         },
       },
       },
     ]),
     ]),
@@ -1900,8 +1918,7 @@ function ArchiveListRow({
             size="sm"
             size="sm"
             onClick={() => {
             onClick={() => {
               const filename = archive.print_name || archive.filename || 'model';
               const filename = archive.print_name || archive.filename || 'model';
-              const downloadUrl = `${window.location.origin}${api.getArchiveForSlicer(archive.id, filename)}`;
-              openInSlicer(downloadUrl, preferredSlicer);
+              openInSlicerWithToken(archive.id, filename, 'file', preferredSlicer);
             }}
             }}
             title={t('archives.card.openInBambuStudio')}
             title={t('archives.card.openInBambuStudio')}
           >
           >

+ 1 - 8
frontend/src/pages/FileManagerPage.tsx

@@ -58,19 +58,12 @@ import { useToast } from '../contexts/ToastContext';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useIsMobile } from '../hooks/useIsMobile';
 import { useAuth } from '../contexts/AuthContext';
 import { useAuth } from '../contexts/AuthContext';
 import { formatDuration } from '../utils/date';
 import { formatDuration } from '../utils/date';
+import { formatFileSize } from '../utils/file';
 
 
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortField = 'name' | 'date' | 'size' | 'type' | 'prints';
 type SortDirection = 'asc' | 'desc';
 type SortDirection = 'asc' | 'desc';
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
 type TFunction = (key: string, options?: Record<string, unknown>) => string;
 
 
-// Utility to format file size
-function formatFileSize(bytes: number): string {
-  if (bytes < 1024) return `${bytes} B`;
-  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
-  if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
-  return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
-}
-
 // New Folder Modal
 // New Folder Modal
 interface NewFolderModalProps {
 interface NewFolderModalProps {
   parentId: number | null;
   parentId: number | null;

+ 3 - 0
frontend/src/pages/PrintersPage.tsx

@@ -2908,6 +2908,7 @@ function PrinterCard({
                                               material: assignment.spool.material,
                                               material: assignment.spool.material,
                                               brand: assignment.spool.brand,
                                               brand: assignment.spool.brand,
                                               color_name: assignment.spool.color_name,
                                               color_name: assignment.spool.color_name,
+                                              remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)),
                                             } : null,
                                             } : null,
                                             onAssignSpool: filamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({
                                             onAssignSpool: filamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({
                                               printerId: printer.id,
                                               printerId: printer.id,
@@ -3145,6 +3146,7 @@ function PrinterCard({
                                           material: assignment.spool.material,
                                           material: assignment.spool.material,
                                           brand: assignment.spool.brand,
                                           brand: assignment.spool.brand,
                                           color_name: assignment.spool.color_name,
                                           color_name: assignment.spool.color_name,
+                                          remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)),
                                         } : null,
                                         } : null,
                                         onAssignSpool: filamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({
                                         onAssignSpool: filamentData.vendor !== 'Bambu Lab' ? () => setAssignSpoolModal({
                                           printerId: printer.id,
                                           printerId: printer.id,
@@ -3331,6 +3333,7 @@ function PrinterCard({
                                             material: assignment.spool.material,
                                             material: assignment.spool.material,
                                             brand: assignment.spool.brand,
                                             brand: assignment.spool.brand,
                                             color_name: assignment.spool.color_name,
                                             color_name: assignment.spool.color_name,
+                                            remainingWeightGrams: Math.max(0, Math.round(assignment.spool.label_weight - assignment.spool.weight_used)),
                                           } : null,
                                           } : null,
                                           onAssignSpool: () => setAssignSpoolModal({
                                           onAssignSpool: () => setAssignSpoolModal({
                                             printerId: printer.id,
                                             printerId: printer.id,

+ 2 - 19
frontend/src/pages/ProfilesPage.tsx

@@ -43,7 +43,7 @@ import {
   HardDrive,
   HardDrive,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import { parseUTCDate } from '../utils/date';
+import { formatRelativeTime } from '../utils/date';
 import type { SlicerSetting, SlicerSettingsResponse, SlicerSettingDetail, SlicerSettingCreate, Printer, FieldDefinition, Permission } from '../api/client';
 import type { SlicerSetting, SlicerSettingsResponse, SlicerSettingDetail, SlicerSettingCreate, Printer, FieldDefinition, Permission } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
@@ -90,23 +90,6 @@ function isUserPreset(settingId: string): boolean {
   return /^(P[FPM]US|PF\d|PP\d)/.test(settingId);
   return /^(P[FPM]US|PF\d|PP\d)/.test(settingId);
 }
 }
 
 
-// Format relative time
-function formatRelativeTime(dateStr: string, t: TFunction): string {
-  const date = parseUTCDate(dateStr);
-  if (!date) return '';
-  const now = new Date();
-  const diffMs = now.getTime() - date.getTime();
-  const diffMins = Math.floor(diffMs / 60000);
-  const diffHours = Math.floor(diffMs / 3600000);
-  const diffDays = Math.floor(diffMs / 86400000);
-
-  if (diffMins < 1) return t('profiles.time.justNow');
-  if (diffMins < 60) return t('profiles.time.minsAgo', { count: diffMins });
-  if (diffHours < 24) return t('profiles.time.hoursAgo', { count: diffHours });
-  if (diffDays < 7) return t('profiles.time.daysAgo', { count: diffDays });
-  return date.toLocaleDateString();
-}
-
 // ============================================================================
 // ============================================================================
 // LOGIN FORM
 // LOGIN FORM
 // ============================================================================
 // ============================================================================
@@ -2646,7 +2629,7 @@ function CloudProfilesView({
         {lastSyncTime && (
         {lastSyncTime && (
           <div className="flex items-center gap-1">
           <div className="flex items-center gap-1">
             <Clock className="w-3 h-3" />
             <Clock className="w-3 h-3" />
-            {t('profiles.cloudView.lastSynced')} {formatRelativeTime(lastSyncTime.toISOString(), t)}
+            {t('profiles.cloudView.lastSynced')} {formatRelativeTime(lastSyncTime.toISOString(), 'system', t)}
           </div>
           </div>
         )}
         )}
         <span>{t('profiles.cloudView.showingCount', { showing: filteredPresets.length, total: totalCount })}</span>
         <span>{t('profiles.cloudView.showingCount', { showing: filteredPresets.length, total: totalCount })}</span>

+ 8 - 20
frontend/src/pages/QueuePage.tsx

@@ -50,7 +50,7 @@ import {
   Weight,
   Weight,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
-import { parseUTCDate, formatDateTime, type TimeFormat, formatETA, formatDuration } from '../utils/date';
+import { type TimeFormat, formatETA, formatDuration, formatRelativeTime } from '../utils/date';
 import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
 import type { PrintQueueItem, PrintQueueBulkUpdate, Permission } from '../api/client';
 import { Card, CardContent } from '../components/Card';
 import { Card, CardContent } from '../components/Card';
 import { Button } from '../components/Button';
 import { Button } from '../components/Button';
@@ -64,21 +64,6 @@ function formatWeight(g: number, useKg = false): string {
   return `${Math.round(g)}g`;
   return `${Math.round(g)}g`;
 }
 }
 
 
-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 t?.('queue.time.asap') ?? 'ASAP';
-  const now = new Date();
-  const diff = date.getTime() - now.getTime();
-
-  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, printerState, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; printerState?: string | null; t: (key: string) => string }) {
 function StatusBadge({ status, waitingReason, printerState, t }: { status: PrintQueueItem['status']; waitingReason?: string | null; printerState?: string | null; t: (key: string) => string }) {
   // Special case: pending with waiting_reason shows as "Waiting"
   // Special case: pending with waiting_reason shows as "Waiting"
   if (status === 'pending' && waitingReason) {
   if (status === 'pending' && waitingReason) {
@@ -506,9 +491,13 @@ function SortableQueueItem({
               </span>
               </span>
             )}
             )}
             {isPending && !item.manual_start && (
             {isPending && !item.manual_start && (
-              <span className="flex items-center gap-1 sm:gap-1.5">
-                <Clock className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
-                {formatRelativeTime(item.scheduled_time, timeFormat, t)}
+              <span className="flex items-center gap-1.5">
+                <Clock className="w-3.5 h-3.5" />
+                {item.scheduled_time
+                  ? (new Date(item.scheduled_time).getTime() - Date.now() < -60000
+                      ? t?.('queue.time.overdue') ?? 'Overdue'
+                      : formatRelativeTime(item.scheduled_time, timeFormat, t))
+                  : t?.('queue.time.asap') ?? 'ASAP'}
               </span>
               </span>
             )}
             )}
           </div>
           </div>
@@ -586,7 +575,6 @@ function SortableQueueItem({
         </div>
         </div>
 
 
         {/* Status badge + Actions */}
         {/* Status badge + Actions */}
-        {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
         <div className="flex flex-col sm:flex-row items-end sm:items-center gap-2 sm:gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
         <div className="flex flex-col sm:flex-row items-end sm:items-center gap-2 sm:gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
           <StatusBadge status={item.status} waitingReason={item.waiting_reason} printerState={printerState} t={t} />
           <StatusBadge status={item.status} waitingReason={item.waiting_reason} printerState={printerState} t={t} />
 
 

+ 64 - 3
frontend/src/utils/date.ts

@@ -392,7 +392,7 @@ export function formatETA(
 ): string {
 ): string {
   const now = new Date();
   const now = new Date();
   const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
   const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
-  
+
   const today = new Date();
   const today = new Date();
   today.setHours(0, 0, 0, 0);
   today.setHours(0, 0, 0, 0);
   const etaDay = new Date(eta);
   const etaDay = new Date(eta);
@@ -418,9 +418,70 @@ export function formatETA(
  */
  */
 export function formatDuration(seconds: number | null | undefined): string {
 export function formatDuration(seconds: number | null | undefined): string {
   if (seconds == null || seconds < 0) return '--';
   if (seconds == null || seconds < 0) return '--';
-  
+
   const hours = Math.floor(seconds / 3600);
   const hours = Math.floor(seconds / 3600);
   const minutes = Math.floor((seconds % 3600) / 60);
   const minutes = Math.floor((seconds % 3600) / 60);
-  
+
   return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
   return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
 }
 }
+
+type TranslateFunction = (key: string, options?: Record<string, unknown>) => string;
+
+/**
+ * Format a date string as a human-readable relative time expression.
+ *
+ * @param dateStr - UTC date string, or null
+ * @param timeFormat - Time format preference ('12h', '24h', or 'system')
+ * @param t - Optional translation function for i18n support
+ * @returns Relative string (e.g., "5m ago", "in 2h", "3d ago") or formatted date if older than 7 days
+ */
+export function formatRelativeTime(
+  dateStr: string | null,
+  timeFormat: TimeFormat = 'system',
+  t?: TranslateFunction
+): string {
+  if (!dateStr) return t?.('time.unknown') ?? '-';
+
+  const date = parseUTCDate(dateStr);
+  if (!date) return t?.('time.unknown') ?? '-';
+
+  const now = new Date();
+  const diffMs = date.getTime() - now.getTime();
+  const isPast = diffMs < 0;
+  const absDiffMs = Math.abs(diffMs);
+
+  const minutes = Math.floor(absDiffMs / 60000);
+  const hours = Math.floor(absDiffMs / 3600000);
+  const days = Math.floor(absDiffMs / 86400000);
+
+  // Less than 1 minute
+  if (minutes < 1) {
+    return isPast
+      ? t?.('time.justNow') ?? 'Just now'
+      : t?.('time.now') ?? 'Now';
+  }
+
+  // Less than 1 hour
+  if (hours < 1) {
+    return isPast
+      ? t?.('time.minsAgo', { count: minutes }) ?? `${minutes}m ago`
+      : t?.('time.inMins', { count: minutes }) ?? `in ${minutes}m`;
+  }
+
+  // Less than 1 day
+  if (days < 1) {
+    return isPast
+      ? t?.('time.hoursAgo', { count: hours }) ?? `${hours}h ago`
+      : t?.('time.inHours', { count: hours }) ?? `in ${hours}h`;
+  }
+
+  // Less than 7 days
+  if (days < 7) {
+    return isPast
+      ? t?.('time.daysAgo', { count: days }) ?? `${days}d ago`
+      : t?.('time.inDays', { count: days }) ?? `in ${days}d`;
+  }
+
+  // Older than 7 days
+  return formatDateTime(dateStr, timeFormat);
+}

+ 20 - 0
frontend/src/utils/file.ts

@@ -0,0 +1,20 @@
+/**
+ * Formats a byte count into a human-readable string (e.g. `1.5 MB`).
+ *
+ * @param bytes - The number of bytes to format.
+ * @returns A formatted string with the appropriate unit (B, KB, MB, GB, or TB).
+ */
+export function formatFileSize(bytes: number): string {
+  if (bytes === 0) return '0 B';
+
+  const units = ['B', 'KB', 'MB', 'GB', 'TB'];
+  const k = 1024;
+  const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+  const size = bytes / Math.pow(k, i);
+
+  // No decimals for bytes, 1 decimal for larger units
+  return i === 0
+    ? `${size} ${units[i]}`
+    : `${size.toFixed(1)} ${units[i]}`;
+}

+ 30 - 10
frontend/src/utils/slicer.ts

@@ -1,12 +1,23 @@
 /**
 /**
  * Utility for opening files in slicer applications
  * Utility for opening files in slicer applications
  *
  *
- * Bambu Studio URL protocol is OS-specific:
- * - Windows: bambustudio://<encoded-URL>
- * - macOS/Linux: bambustudioopen://<encoded-URL>
+ * Protocol handler URL formats (from BambuStudio/OrcaSlicer source code):
  *
  *
- * OrcaSlicer uses the same protocol on all platforms:
- * - orcaslicer://open?file=<URL>
+ * Bambu Studio has TWO separate URL handlers:
+ *   1. post_init() [Windows/Linux CLI args]: bambustudio://open?file=<URL>
+ *      - Checks: starts_with("bambustudio://open")
+ *      - Calls url_decode(), then split_str(url, "file=")
+ *   2. MacOpenURL() [macOS Apple Events]: bambustudioopen://<encoded-URL>
+ *      - Checks: starts_with("bambustudioopen://")
+ *      - Strips prefix, then url_decode()
+ *
+ * OrcaSlicer Downloader accepts both formats via regex:
+ *   - (orcaslicer|bambustudio|...)://open?file=<URL>
+ *   - bambustudioopen://<URL>
+ *
+ * Key insight: Using ?file= query format, the browser's URL parser preserves
+ * http:// in the query string without any encoding. Only the macOS-specific
+ * bambustudioopen:// format needs encodeURIComponent (BS calls url_decode).
  */
  */
 
 
 export type SlicerType = 'bambu_studio' | 'orcaslicer';
 export type SlicerType = 'bambu_studio' | 'orcaslicer';
@@ -34,8 +45,6 @@ export function detectPlatform(): Platform {
 
 
 /**
 /**
  * Open a URL in the specified slicer application.
  * Open a URL in the specified slicer application.
- * Uses a temporary link element to trigger the protocol handler,
- * which avoids browser "unknown protocol" blocks on window.location.href.
  * @param downloadUrl - The URL to the file to open
  * @param downloadUrl - The URL to the file to open
  * @param slicer - Which slicer to use (defaults to bambu_studio)
  * @param slicer - Which slicer to use (defaults to bambu_studio)
  */
  */
@@ -43,15 +52,26 @@ export function openInSlicer(downloadUrl: string, slicer: SlicerType = 'bambu_st
   let url: string;
   let url: string;
 
 
   if (slicer === 'orcaslicer') {
   if (slicer === 'orcaslicer') {
+    // OrcaSlicer: ?file= query format — http:// preserved in query string
     url = `orcaslicer://open?file=${downloadUrl}`;
     url = `orcaslicer://open?file=${downloadUrl}`;
   } else {
   } else {
     const platform = detectPlatform();
     const platform = detectPlatform();
-    const protocol = platform === 'windows' ? 'bambustudio' : 'bambustudioopen';
-    url = `${protocol}://${encodeURIComponent(downloadUrl)}`;
+    if (platform === 'macos') {
+      // macOS only: bambustudioopen scheme via MacOpenURL() callback.
+      // Must encode because bare http:// in authority gets mangled by browser.
+      // BS calls url_decode() after stripping "bambustudioopen://" prefix.
+      url = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
+    } else {
+      // Windows/Linux: bambustudio://open?file= via post_init() CLI args.
+      // The ?file= query format preserves http:// without encoding.
+      // IMPORTANT: On Linux, BS only handles "bambustudio://open" prefix —
+      // it does NOT process "bambustudioopen://" (that's macOS-only).
+      url = `bambustudio://open?file=${downloadUrl}`;
+    }
   }
   }
 
 
   // Use a temporary <a> element to trigger the protocol handler.
   // Use a temporary <a> element to trigger the protocol handler.
-  // This works more reliably than window.location.href for custom protocols.
+  // This avoids navigating away from the page (unlike window.location.href).
   const link = document.createElement('a');
   const link = document.createElement('a');
   link.href = url;
   link.href = url;
   link.style.display = 'none';
   link.style.display = 'none';

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-Dnmq8_Ro.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-EqFdfChN.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
static/assets/index-tulFiIvt.css


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DmiEmqVo.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-tulFiIvt.css">
+    <script type="module" crossorigin src="/assets/index-Dnmq8_Ro.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-EqFdfChN.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است