Sfoglia il codice sorgente

VP settings: access code card, collapsed VP cards (#1430)

- The personal access code is a card in Settings > Virtual Printer,
  below the setup row, instead of a sidebar icon; the sidebar footer
  is back to seven icons
- Hidden while auth is off; same show, copy and new-code actions
- VP cards start collapsed and open on a click of their header
- VP card hint and CHANGELOG point to the new place (16 locales)
maziggy 2 giorni fa
parent
commit
3294d04222
86 ha cambiato i file con 326 aggiunte e 305 eliminazioni
  1. 2 1
      CHANGELOG.md
  2. 98 0
      frontend/src/__tests__/components/SlicerAccessCodeCard.test.tsx
  3. 0 64
      frontend/src/__tests__/components/SlicerAccessCodeModal.test.tsx
  4. 51 33
      frontend/src/__tests__/components/VirtualPrinterCard.test.tsx
  5. 3 22
      frontend/src/components/Layout.tsx
  6. 135 0
      frontend/src/components/SlicerAccessCodeCard.tsx
  7. 0 155
      frontend/src/components/SlicerAccessCodeModal.tsx
  8. 9 3
      frontend/src/components/VirtualPrinterCard.tsx
  9. 4 0
      frontend/src/components/VirtualPrinterList.tsx
  10. 1 1
      frontend/src/i18n/locales/de.ts
  11. 1 1
      frontend/src/i18n/locales/en.ts
  12. 1 1
      frontend/src/i18n/locales/es.ts
  13. 1 1
      frontend/src/i18n/locales/fr.ts
  14. 1 1
      frontend/src/i18n/locales/it.ts
  15. 1 1
      frontend/src/i18n/locales/ja.ts
  16. 1 1
      frontend/src/i18n/locales/ko.ts
  17. 1 1
      frontend/src/i18n/locales/nl.ts
  18. 1 1
      frontend/src/i18n/locales/pl.ts
  19. 1 1
      frontend/src/i18n/locales/pt-BR.ts
  20. 1 1
      frontend/src/i18n/locales/ru.ts
  21. 1 1
      frontend/src/i18n/locales/sv.ts
  22. 1 1
      frontend/src/i18n/locales/tr.ts
  23. 1 1
      frontend/src/i18n/locales/uk.ts
  24. 1 1
      frontend/src/i18n/locales/zh-CN.ts
  25. 1 1
      frontend/src/i18n/locales/zh-TW.ts
  26. 0 0
      static/assets/ArchivesPage-D4fup-k4.js
  27. 0 0
      static/assets/CamWallPage-DhcRIx1T.js
  28. 0 0
      static/assets/CameraPage-CcjrCinJ.js
  29. 0 0
      static/assets/ConfirmOutcomeDialog-CQpv7o5z.js
  30. 0 0
      static/assets/CopyButton-CHrYh5zK.js
  31. 0 0
      static/assets/CopyButton-CjReDRoV.js
  32. 0 0
      static/assets/EmbeddedCameraViewer-Bk29eJ0S.js
  33. 0 0
      static/assets/FileManagerPage-hbCSlr4l.js
  34. 0 0
      static/assets/FinancePage-C8INGIjf.js
  35. 0 0
      static/assets/InventoryPage-BwnnkoA5.js
  36. 0 0
      static/assets/Layout-rse8GjU_.js
  37. 0 0
      static/assets/Layout-scPYLaXx.js
  38. 0 0
      static/assets/LibraryTrashPage-ZUMEViJN.js
  39. 0 0
      static/assets/LoginPage-Cs4SmQMX.js
  40. 0 0
      static/assets/LoginPage-IkWE9La7.js
  41. 0 0
      static/assets/MaintenancePage-DvCixLlw.js
  42. 0 0
      static/assets/ModelSourcesPage-DQ4_vzbk.js
  43. 0 0
      static/assets/ModelSourcesPage-VTmJQkIi.js
  44. 0 0
      static/assets/NotificationsPage-BrbNYZDT.js
  45. 0 0
      static/assets/PrintModal-BudpZCka.js
  46. 0 0
      static/assets/PrinterLocationsPage-d3l4FtLZ.js
  47. 0 0
      static/assets/PrintersPage-CKRMPHHu.js
  48. 0 0
      static/assets/PrintersPage-UI7jUYli.js
  49. 0 0
      static/assets/ProfilesPage--kzzRPwC.js
  50. 0 0
      static/assets/ProjectDetailPage-DF86_ahX.js
  51. 0 0
      static/assets/ProjectsPage-BVStDcqu.js
  52. 0 0
      static/assets/QueuePage-D_DawDPo.js
  53. 0 0
      static/assets/RichTextEditor-BLPXz5xL.js
  54. 0 0
      static/assets/SettingsPage-B4Pmgkey.js
  55. 0 0
      static/assets/SettingsPage-C-EIsTJa.js
  56. 0 0
      static/assets/SkipObjectsModal-dzJeyv4c.js
  57. 0 0
      static/assets/SliceModal-wgWoCMu8.js
  58. 0 0
      static/assets/SpoolBuddyInventoryPage-CYNMv6eM.js
  59. 1 1
      static/assets/SpoolBuddySettingsPage-CwUiePTN.js
  60. 0 0
      static/assets/StatsPage-D9lzDLr9.js
  61. 0 0
      static/assets/SystemInfoPage-CWFfKSrX.js
  62. 0 0
      static/assets/SystemInfoPage-DIOUJQYs.js
  63. 0 0
      static/assets/camWallControls-C-CB8l0g.js
  64. 0 0
      static/assets/de-BmyMhQXe.js
  65. 0 0
      static/assets/es-BBLXtgob.js
  66. 0 0
      static/assets/fr-OyOBLg5Q.js
  67. 0 1
      static/assets/index-C7v2Gy3m.css
  68. 1 0
      static/assets/index-Dyz22jcu.css
  69. 0 0
      static/assets/index-E5ta5OG-.js
  70. 0 0
      static/assets/it--V3y3MSb.js
  71. 0 0
      static/assets/ja-CpkwBFct.js
  72. 0 0
      static/assets/ko-DoZNdyt1.js
  73. 0 0
      static/assets/locationSensorDefaults-GEmFnT4h.js
  74. 0 0
      static/assets/nl-CoDQiR8c.js
  75. 0 0
      static/assets/pl-eNbHvIbF.js
  76. 0 1
      static/assets/plateCheck-Dg4HkH97.js
  77. 0 0
      static/assets/pt-BR-Ddolisl7.js
  78. 0 0
      static/assets/ru-BhWW-k8_.js
  79. 0 0
      static/assets/spoolCost-DhL5lCIR.js
  80. 0 0
      static/assets/sv-xnoSEuDk.js
  81. 0 0
      static/assets/tr-B2OS0PhC.js
  82. 0 0
      static/assets/uk-CvvctVJM.js
  83. 0 0
      static/assets/usePageFileDrop-CqjK0H7Q.js
  84. 0 0
      static/assets/zh-CN-BaVFeTkw.js
  85. 0 0
      static/assets/zh-TW-CT0QjWrb.js
  86. 6 8
      static/index.html

+ 2 - 1
CHANGELOG.md

@@ -5,12 +5,13 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.3.1b1] - Unreleased
 
 ### Added
-- **Personal access codes show who sent a file to a virtual printer (#1430)** — A slicer signs in to a printer with one access code, so a virtual printer never knew who sent a file: in a school or makerspace, queue jobs and files had no owner. With auth on, every user now has a personal access code, under the printer icon next to Change password in the sidebar (copy it, or make a new one, which retires the old). Entered in Bambu Studio or OrcaSlicer instead of the virtual printer's own code, it works on any virtual printer in File Manager, Review or Queue mode, and what that slicer sends is the user's: the File Manager file, the queue jobs (which wait for a reviewer when the user's jobs must) and, in Review mode, the pending upload, whose file stays the sender's when someone else approves it. Each sign-in checks the user's account is active, that they may upload to the File Manager (plus queue jobs in Queue mode) and, for a virtual printer with a target printer, that the printer is one of theirs and they may see it, so a change of permissions or a new code takes effect at once. Sending files is all a personal code allows: the slicer's commands to the target printer (stop, pause, G-code, AMS) go through only for users with the printer control permission, checked at every command. An upload waiting for review keeps its own copy, so a later file of the same name can't replace it. The virtual printer's own code keeps working as before, without an owner. Not for Proxy mode, and with a personal code the slicer's own camera view through a virtual printer with a target printer does not work (Bambuddy's camera does). The virtual printer's FTP sign-in now refuses an address for a minute after five wrong codes, as its MQTT sign-in already did, and on both a good sign-in no longer resets the count.
+- **Personal access codes show who sent a file to a virtual printer (#1430)** — A slicer signs in to a printer with one access code, so a virtual printer never knew who sent a file: in a school or makerspace, queue jobs and files had no owner. With auth on, every user now has a personal access code, at the top of Settings > Virtual Printer (copy it, or make a new one, which retires the old). Entered in Bambu Studio or OrcaSlicer instead of the virtual printer's own code, it works on any virtual printer in File Manager, Review or Queue mode, and what that slicer sends is the user's: the File Manager file, the queue jobs (which wait for a reviewer when the user's jobs must) and, in Review mode, the pending upload, whose file stays the sender's when someone else approves it. Each sign-in checks the user's account is active, that they may upload to the File Manager (plus queue jobs in Queue mode) and, for a virtual printer with a target printer, that the printer is one of theirs and they may see it, so a change of permissions or a new code takes effect at once. Sending files is all a personal code allows: the slicer's commands to the target printer (stop, pause, G-code, AMS) go through only for users with the printer control permission, checked at every command. An upload waiting for review keeps its own copy, so a later file of the same name can't replace it. The virtual printer's own code keeps working as before, without an owner. Not for Proxy mode, and with a personal code the slicer's own camera view through a virtual printer with a target printer does not work (Bambuddy's camera does). The virtual printer's FTP sign-in now refuses an address for a minute after five wrong codes, as its MQTT sign-in already did, and on both a good sign-in no longer resets the count.
 - **A script adds the virtual printer certificate to the slicers** — Bambu Studio and OrcaSlicer only connect to a virtual printer whose certificate is in their printer.cer file, and every slicer update or reinstall puts the original file back, so the certificate had to be added by hand again. The **Slicer certificate** card under Settings > Virtual Printer now downloads a script for **Windows** or **Linux / macOS** with this Bambuddy's certificate already in it. It finds Bambu Studio and OrcaSlicer, adds the certificate, and keeps the original file next to it. It changes nothing where the certificate is already there, and it removes an older certificate of the same Bambuddy, which would otherwise stop the slicer from connecting. On Windows it is a double-click: it asks for administrator rights, and it installs a task that adds the certificate again at every start, at every sign-in and once an hour, so a slicer update no longer breaks the connection (`/uninstall` removes the task). On Linux and macOS it runs with bash, and is run again after a slicer update. Wiki updated.
 - **WLED lights show the printer's status (#1528, requested and contributed by @d3nn3s08 in #3124)** — A WLED controller can now show what each printer is doing. Under **Settings > WLED**, pick a printer, enter its WLED address and assign one of the presets saved in WLED to each state: Idle, Preparing, Printing, Paused, Finished, Failed / Error, Awaiting plate clear, Filament problem, HMS error and Offline. Colours, effects and segments stay in WLED; Bambuddy only switches presets. A state without a preset falls back to the next one that has one (a filament runout without its own preset shows Paused). **Awaiting plate clear** applies only while the queue's **Require plate clear** setting holds the printer, and **Filament problem** only when the printer paused because the filament ran out. An optional timeout switches Finished back to Idle. The page can test the connection, load the presets from WLED and try each preset on the strip. If WLED is unreachable, Bambuddy retries every 30 seconds and logs the outage once. Several printers can share one controller when each preset is saved with "Checked segments only". The tab needs the `printers:update` permission.
 - **Cam Wall tiles show temperatures and finish time, and can pause, stop and switch the light (#3479, sponsor vote Q4 2026)** — With the **Full** status overlay, every connected tile shows its nozzle (both on a dual-nozzle printer), bed and chamber temperatures, and a printing tile adds its finish time beside the time left. Hovering over a tile shows **Pause/Resume** and **Stop**, with the same confirmations as the printer card, and a **chamber light** switch; **Tile controls: Always** in the wall settings keeps them on screen, and a touch screen always shows them. The controls need the `printers:control` permission; a user without it, an offline printer and a Cam Wall token (kiosk) wall get none.
 
 ### Changed
+- **Virtual printer cards start collapsed** — Settings > Virtual Printer opened every virtual printer's card with all its settings, so a few of them filled the page. Each card now shows only its header (name, mode, model, status) until it is clicked.
 - **Virtual printers put uploads in the File Manager, not the archive** — An archive is a print that happened, but a virtual printer archived every file the slicer sent, in Archive, Review and Queue mode, before it ever printed. Uploads now go to the File Manager, and the archive gets the file when it prints, like any other File Manager print. **Archive** mode is now **File Manager** mode; virtual printers set to it switch over on upgrade, and archives they created before stay as they are. Each virtual printer card has a **File Manager folder** (by default a shared top-level folder named **Virtual Printer**; external folders can't be picked). The same file sent again under the same name into that folder doesn't make a second copy. **Review** mode stays: pending uploads now wait at the top of the File Manager page (the sidebar badge moved there too), and **Add to File Manager** stores them in the virtual printer's folder with their tags, notes and project (approving now needs the `library:upload` permission as well as `queue:create`, since it adds a file); the panel is translated now too. **Queue** mode stores the file and queues it from there, and a new switch, **Remove from File Manager once it prints** (off by default), takes it out of the File Manager when its print starts. With **Save AMS mapping** on, the slicer's tray pick still reaches the archive for reprints: the queue job keeps it until the print creates the archive, shows the saved-mapping badge meanwhile, and offers it under **Mapping** when edited. The virtual printer's archive-name setting (file name or 3MF title) applies to the archive made when the print starts. Wiki updated.
 - **A queue virtual printer shows where its jobs go, and its target can be cleared** — In Print Queue mode a virtual printer without a target printer sends each job to the next free printer of its model, but the card's target list only said "Select a printer...", and once a printer was picked there was no way back. The first entry now reads **Any available [model]** and switches the virtual printer back to that, and a line under the list says where the jobs go. The access code stays the same, so the slicer stays bound. In the other server modes the entry reads **None**; proxy mode still needs a printer. Wiki updated.
 - **Profile defaults is compact and has Apply all** — Settings → Filament → Profile defaults showed every default as a large box with all its presets and K profiles, so a few products already filled the page. Each default is now one line (product, colour, slicer preset, and how many printer presets and K profiles it has), sorted by product, with a search box; click a line to see its presets and K profiles. The list scrolls on its own. **Apply all** applies every default to the spools in stock in one go, and like Apply to existing spools it only fills in what a spool doesn't have yet. Wiki updated.

+ 98 - 0
frontend/src/__tests__/components/SlicerAccessCodeCard.test.tsx

@@ -0,0 +1,98 @@
+/**
+ * The signed-in user's personal access code for virtual printers (#1430),
+ * at the top of Settings > Virtual Printer.
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { SlicerAccessCodeCard } from '../../components/SlicerAccessCodeCard';
+import { setAuthToken } from '../../api/client';
+
+let renewCalls = 0;
+
+function signIn(authEnabled = true) {
+  setAuthToken('test-token-1', 'session');
+  server.use(
+    http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: authEnabled, requires_setup: false })),
+    http.get('*/api/v1/auth/me', () =>
+      HttpResponse.json({
+        id: 9,
+        username: 'student',
+        is_active: true,
+        is_admin: false,
+        groups: [],
+        permissions: ['library:upload'],
+        created_at: '2026-01-01T00:00:00Z',
+      }),
+    ),
+    http.get('*/api/v1/virtual-printers/my-access-code', () => HttpResponse.json({ code: '12345678' })),
+    http.post('*/api/v1/virtual-printers/my-access-code', () => {
+      renewCalls += 1;
+      return HttpResponse.json({ code: '87654321' });
+    }),
+  );
+}
+
+describe('SlicerAccessCodeCard', () => {
+  beforeEach(() => {
+    renewCalls = 0;
+  });
+
+  afterEach(() => {
+    setAuthToken(null);
+  });
+
+  it('shows the code hidden until asked', async () => {
+    signIn();
+    const user = userEvent.setup();
+    render(<SlicerAccessCodeCard />);
+
+    const code = await screen.findByTestId('slicer-access-code');
+    await waitFor(() => expect(code.textContent).toBe('•'.repeat(8)));
+
+    await user.click(screen.getByRole('button', { name: 'Show code' }));
+
+    expect(screen.getByTestId('slicer-access-code').textContent).toBe('12345678');
+  });
+
+  it('a new code replaces the old one after confirming', async () => {
+    signIn();
+    const user = userEvent.setup();
+    render(<SlicerAccessCodeCard />);
+    await screen.findByTestId('slicer-access-code');
+
+    await user.click(screen.getByRole('button', { name: /New code/ }));
+    const title = await screen.findByText('Replace your access code?');
+    const dialog = title.closest('div.flex-1')?.parentElement?.parentElement as HTMLElement;
+    await user.click(within(dialog).getByRole('button', { name: 'New code' }));
+
+    await waitFor(() => expect(screen.getByTestId('slicer-access-code').textContent).toBe('87654321'));
+    expect(renewCalls).toBe(1);
+  });
+
+  it('cancelling keeps the code', async () => {
+    signIn();
+    const user = userEvent.setup();
+    render(<SlicerAccessCodeCard />);
+    await screen.findByTestId('slicer-access-code');
+
+    await user.click(screen.getByRole('button', { name: /New code/ }));
+    await screen.findByText('Replace your access code?');
+    await user.click(screen.getByRole('button', { name: 'Cancel' }));
+
+    expect(renewCalls).toBe(0);
+  });
+
+  it('is not there while auth is off', async () => {
+    server.use(
+      http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: false, requires_setup: false })),
+    );
+    const { container } = render(<SlicerAccessCodeCard />);
+
+    await new Promise((resolve) => setTimeout(resolve, 50));
+    expect(container.textContent).not.toContain('Slicer access code');
+  });
+});

+ 0 - 64
frontend/src/__tests__/components/SlicerAccessCodeModal.test.tsx

@@ -1,64 +0,0 @@
-/**
- * The signed-in user's personal access code for virtual printers (#1430).
- */
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { screen, waitFor, within } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { render } from '../utils';
-import { SlicerAccessCodeModal } from '../../components/SlicerAccessCodeModal';
-
-vi.mock('../../api/client', () => ({
-  multiVirtualPrinterApi: {
-    getMyAccessCode: vi.fn(),
-    renewMyAccessCode: vi.fn(),
-  },
-  api: { getSettings: vi.fn().mockResolvedValue({}) },
-}));
-
-import { multiVirtualPrinterApi } from '../../api/client';
-
-describe('SlicerAccessCodeModal', () => {
-  beforeEach(() => {
-    vi.clearAllMocks();
-    vi.mocked(multiVirtualPrinterApi.getMyAccessCode).mockResolvedValue({ code: '12345678' });
-    vi.mocked(multiVirtualPrinterApi.renewMyAccessCode).mockResolvedValue({ code: '87654321' });
-  });
-
-  it('shows the code hidden until asked', async () => {
-    const user = userEvent.setup();
-    render(<SlicerAccessCodeModal onClose={() => {}} />);
-
-    const code = await screen.findByTestId('slicer-access-code');
-    await waitFor(() => expect(code.textContent).toBe('•'.repeat(8)));
-
-    await user.click(screen.getByRole('button', { name: 'Show code' }));
-
-    expect(screen.getByTestId('slicer-access-code').textContent).toBe('12345678');
-  });
-
-  it('a new code replaces the old one after confirming', async () => {
-    const user = userEvent.setup();
-    render(<SlicerAccessCodeModal onClose={() => {}} />);
-    await screen.findByTestId('slicer-access-code');
-
-    await user.click(await screen.findByRole('button', { name: /New code/ }));
-    const title = await screen.findByText('Replace your access code?');
-    const dialog = title.closest('div.flex-1')?.parentElement?.parentElement as HTMLElement;
-    await user.click(within(dialog).getByRole('button', { name: 'New code' }));
-
-    await waitFor(() => expect(screen.getByTestId('slicer-access-code').textContent).toBe('87654321'));
-    expect(multiVirtualPrinterApi.renewMyAccessCode).toHaveBeenCalledTimes(1);
-  });
-
-  it('cancelling keeps the code', async () => {
-    const user = userEvent.setup();
-    render(<SlicerAccessCodeModal onClose={() => {}} />);
-    await screen.findByTestId('slicer-access-code');
-
-    await user.click(await screen.findByRole('button', { name: /New code/ }));
-    await screen.findByText('Replace your access code?');
-    await user.click(screen.getByRole('button', { name: 'Cancel' }));
-
-    expect(multiVirtualPrinterApi.renewMyAccessCode).not.toHaveBeenCalled();
-  });
-});

+ 51 - 33
frontend/src/__tests__/components/VirtualPrinterCard.test.tsx

@@ -72,7 +72,7 @@ describe('VirtualPrinterCard - auto-dispatch toggle', () => {
 
   it('renders auto-dispatch toggle when mode is print_queue', async () => {
     const printer = createMockPrinter({ mode: 'queue' });
-    render(<VirtualPrinterCard printer={printer} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={printer} models={models} />);
 
     await waitFor(() => {
       expect(screen.getByText('Auto-dispatch')).toBeInTheDocument();
@@ -81,7 +81,7 @@ describe('VirtualPrinterCard - auto-dispatch toggle', () => {
 
   it('does not render auto-dispatch toggle when mode is immediate', async () => {
     const printer = createMockPrinter({ mode: 'archive' });
-    render(<VirtualPrinterCard printer={printer} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={printer} models={models} />);
 
     // Wait for the card to render fully (check for something that should be there)
     await waitFor(() => {
@@ -93,7 +93,7 @@ describe('VirtualPrinterCard - auto-dispatch toggle', () => {
 
   it('does not render auto-dispatch toggle when mode is proxy', async () => {
     const printer = createMockPrinter({ mode: 'proxy' });
-    render(<VirtualPrinterCard printer={printer} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={printer} models={models} />);
 
     await waitFor(() => {
       expect(screen.getByText('Test VP')).toBeInTheDocument();
@@ -104,7 +104,7 @@ describe('VirtualPrinterCard - auto-dispatch toggle', () => {
 
   it('auto-dispatch toggle defaults to on', async () => {
     const printer = createMockPrinter({ mode: 'queue', auto_dispatch: true });
-    render(<VirtualPrinterCard printer={printer} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={printer} models={models} />);
 
     await waitFor(() => {
       expect(screen.getByText('Auto-dispatch')).toBeInTheDocument();
@@ -126,7 +126,7 @@ describe('VirtualPrinterCard - auto-dispatch toggle', () => {
       createMockPrinter({ mode: 'queue', auto_dispatch: false })
     );
 
-    render(<VirtualPrinterCard printer={printer} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={printer} models={models} />);
 
     await waitFor(() => {
       expect(screen.getByText('Auto-dispatch')).toBeInTheDocument();
@@ -160,7 +160,7 @@ describe('VirtualPrinterCard - force color match toggle (#1188)', () => {
 
   it('renders force-color-match toggle when mode is print_queue', async () => {
     const printer = createMockPrinter({ mode: 'queue' });
-    render(<VirtualPrinterCard printer={printer} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={printer} models={models} />);
 
     await waitFor(() => {
       expect(screen.getByText('Force color match')).toBeInTheDocument();
@@ -169,7 +169,7 @@ describe('VirtualPrinterCard - force color match toggle (#1188)', () => {
 
   it('does not render force-color-match toggle when mode is immediate', async () => {
     const printer = createMockPrinter({ mode: 'archive' });
-    render(<VirtualPrinterCard printer={printer} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={printer} models={models} />);
 
     await waitFor(() => {
       expect(screen.getByText('Test VP')).toBeInTheDocument();
@@ -179,7 +179,7 @@ describe('VirtualPrinterCard - force color match toggle (#1188)', () => {
 
   it('does not render force-color-match toggle when mode is proxy', async () => {
     const printer = createMockPrinter({ mode: 'proxy' });
-    render(<VirtualPrinterCard printer={printer} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={printer} models={models} />);
 
     await waitFor(() => {
       expect(screen.getByText('Test VP')).toBeInTheDocument();
@@ -189,7 +189,7 @@ describe('VirtualPrinterCard - force color match toggle (#1188)', () => {
 
   it('force-color-match toggle defaults off (not green) — preserves pre-fix behaviour', async () => {
     const printer = createMockPrinter({ mode: 'queue', queue_force_color_match: false });
-    render(<VirtualPrinterCard printer={printer} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={printer} models={models} />);
 
     await waitFor(() => {
       expect(screen.getByText('Force color match')).toBeInTheDocument();
@@ -205,7 +205,7 @@ describe('VirtualPrinterCard - force color match toggle (#1188)', () => {
 
   it('force-color-match toggle renders enabled (green) when queue_force_color_match is true', async () => {
     const printer = createMockPrinter({ mode: 'queue', queue_force_color_match: true });
-    render(<VirtualPrinterCard printer={printer} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={printer} models={models} />);
 
     await waitFor(() => {
       expect(screen.getByText('Force color match')).toBeInTheDocument();
@@ -224,7 +224,7 @@ describe('VirtualPrinterCard - force color match toggle (#1188)', () => {
       createMockPrinter({ mode: 'queue', queue_force_color_match: true })
     );
 
-    render(<VirtualPrinterCard printer={printer} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={printer} models={models} />);
 
     await waitFor(() => {
       expect(screen.getByText('Force color match')).toBeInTheDocument();
@@ -250,7 +250,7 @@ describe('VirtualPrinterCard - tailscale toggle', () => {
 
   it('renders tailscale toggle as enabled (green) when tailscale_disabled is false', async () => {
     const printer = createMockPrinter({ tailscale_disabled: false });
-    render(<VirtualPrinterCard printer={printer} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={printer} models={models} />);
 
     await waitFor(() => {
       expect(screen.getByText('Tailscale integration')).toBeInTheDocument();
@@ -266,7 +266,7 @@ describe('VirtualPrinterCard - tailscale toggle', () => {
 
   it('renders tailscale toggle as disabled (not green) when tailscale_disabled is true', async () => {
     const printer = createMockPrinter({ tailscale_disabled: true });
-    render(<VirtualPrinterCard printer={printer} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={printer} models={models} />);
 
     await waitFor(() => {
       expect(screen.getByText('Tailscale integration')).toBeInTheDocument();
@@ -287,7 +287,7 @@ describe('VirtualPrinterCard - tailscale toggle', () => {
       createMockPrinter({ tailscale_disabled: true })
     );
 
-    render(<VirtualPrinterCard printer={printer} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={printer} models={models} />);
 
     await waitFor(() => {
       expect(screen.getByText('Tailscale integration')).toBeInTheDocument();
@@ -344,7 +344,7 @@ describe('VirtualPrinterCard - Tailscale FQDN copy', () => {
     });
 
     const printer = createMockPrinter({ tailscale_disabled: false });
-    render(<VirtualPrinterCard printer={printer} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={printer} models={models} />);
 
     const copyBtn = await waitFor(() => {
       const btn = getCopyButton();
@@ -368,7 +368,7 @@ describe('VirtualPrinterCard - Tailscale FQDN copy', () => {
     document.execCommand = execCommandMock;
 
     const printer = createMockPrinter({ tailscale_disabled: false });
-    render(<VirtualPrinterCard printer={printer} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={printer} models={models} />);
 
     const copyBtn = await waitFor(() => {
       const btn = getCopyButton();
@@ -395,7 +395,7 @@ describe('VirtualPrinterCard - Tailscale FQDN copy', () => {
     });
 
     const printer = createMockPrinter({ tailscale_disabled: false });
-    render(<VirtualPrinterCard printer={printer} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={printer} models={models} />);
 
     const copyBtn = await waitFor(() => {
       const btn = getCopyButton();
@@ -440,7 +440,7 @@ describe('VirtualPrinterCard - access code inherits from target', () => {
 
   it('shows target printer access code read-only when target is set on a non-proxy VP', async () => {
     const printer = createMockPrinter({ mode: 'queue', target_printer_id: 7 });
-    render(<VirtualPrinterCard printer={printer} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={printer} models={models} />);
 
     // Wait for the inheritance badge AND the actual code value to appear —
     // the badge renders synchronously from local state, but the value
@@ -462,7 +462,7 @@ describe('VirtualPrinterCard - access code inherits from target', () => {
   it('toggles the access code to plaintext via the Eye button', async () => {
     const user = userEvent.setup();
     const printer = createMockPrinter({ mode: 'queue', target_printer_id: 7 });
-    render(<VirtualPrinterCard printer={printer} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={printer} models={models} />);
 
     await waitFor(() => {
       expect(screen.getByText('Inherited from target')).toBeInTheDocument();
@@ -477,7 +477,7 @@ describe('VirtualPrinterCard - access code inherits from target', () => {
 
   it('keeps the editable input + Save button when no target is set', async () => {
     const printer = createMockPrinter({ mode: 'archive', target_printer_id: null });
-    render(<VirtualPrinterCard printer={printer} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={printer} models={models} />);
 
     await waitFor(() => {
       expect(screen.getByPlaceholderText('Enter 8-char code')).toBeInTheDocument();
@@ -531,7 +531,7 @@ describe('VirtualPrinterCard - header does not overflow the card', () => {
   });
 
   it('keeps both IPs visible instead of truncating them away', async () => {
-    render(<VirtualPrinterCard printer={twoVlanPrinter()} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={twoVlanPrinter()} models={models} />);
 
     await waitFor(() => {
       expect(screen.getByText('192.168.20.175')).toBeInTheDocument();
@@ -542,7 +542,7 @@ describe('VirtualPrinterCard - header does not overflow the card', () => {
   });
 
   it('puts the metadata in a shrinkable, wrapping group', async () => {
-    render(<VirtualPrinterCard printer={twoVlanPrinter()} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={twoVlanPrinter()} models={models} />);
 
     const bindIp = await screen.findByText('192.168.20.175');
     const group = bindIp.parentElement as HTMLElement;
@@ -555,7 +555,7 @@ describe('VirtualPrinterCard - header does not overflow the card', () => {
   });
 
   it('has no header item that is both unshrinkable and truncating', async () => {
-    render(<VirtualPrinterCard printer={twoVlanPrinter()} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={twoVlanPrinter()} models={models} />);
 
     const bindIp = await screen.findByText('192.168.20.175');
     const group = bindIp.parentElement as HTMLElement;
@@ -567,7 +567,7 @@ describe('VirtualPrinterCard - header does not overflow the card', () => {
   });
 
   it('clips at the card border as a backstop', async () => {
-    const { container } = render(<VirtualPrinterCard printer={twoVlanPrinter()} models={models} />);
+    const { container } = render(<VirtualPrinterCard defaultExpanded printer={twoVlanPrinter()} models={models} />);
 
     await screen.findByText('192.168.20.175');
     // Scoped to this card on purpose: cards elsewhere render menus that paint
@@ -596,7 +596,7 @@ describe('VirtualPrinterCard - target printer can be cleared', () => {
     screen.getAllByRole('combobox').find((el) => (el as HTMLSelectElement).querySelector('option[value="7"]')) as HTMLSelectElement;
 
   it('queue mode without a target offers any printer of the model', async () => {
-    render(<VirtualPrinterCard printer={createMockPrinter({ mode: 'queue' })} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={createMockPrinter({ mode: 'queue' })} models={models} />);
 
     await waitFor(() => expect(targetSelect()).toBeTruthy());
     expect(targetSelect().selectedOptions[0].textContent).toBe('Any available X1C');
@@ -605,7 +605,7 @@ describe('VirtualPrinterCard - target printer can be cleared', () => {
 
   it('choosing "any" in queue mode clears the target', async () => {
     const user = userEvent.setup();
-    render(<VirtualPrinterCard printer={createMockPrinter({ mode: 'queue', target_printer_id: 7 })} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={createMockPrinter({ mode: 'queue', target_printer_id: 7 })} models={models} />);
     await waitFor(() => expect(targetSelect().value).toBe('7'));
     expect(screen.getByText('Every job goes to this printer.')).toBeInTheDocument();
 
@@ -618,7 +618,7 @@ describe('VirtualPrinterCard - target printer can be cleared', () => {
 
   it('proxy mode keeps the empty entry as a prompt', async () => {
     const user = userEvent.setup();
-    render(<VirtualPrinterCard printer={createMockPrinter({ mode: 'proxy', target_printer_id: 7 })} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={createMockPrinter({ mode: 'proxy', target_printer_id: 7 })} models={models} />);
     await waitFor(() => expect(targetSelect().value).toBe('7'));
     expect(targetSelect().options[0].textContent).toBe('Select a printer...');
 
@@ -628,7 +628,7 @@ describe('VirtualPrinterCard - target printer can be cleared', () => {
   });
 
   it('archive mode calls the empty entry none', async () => {
-    render(<VirtualPrinterCard printer={createMockPrinter({ mode: 'archive' })} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={createMockPrinter({ mode: 'archive' })} models={models} />);
     await waitFor(() => expect(targetSelect()).toBeTruthy());
     expect(targetSelect().selectedOptions[0].textContent).toBe('None');
   });
@@ -656,7 +656,7 @@ describe('VirtualPrinterCard - File Manager folder', () => {
   const folderSelect = () => screen.getByRole('combobox', { name: 'File Manager folder' }) as HTMLSelectElement;
 
   it('offers internal folders the user can add to, nested, and the shared default', async () => {
-    render(<VirtualPrinterCard printer={createMockPrinter()} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={createMockPrinter()} models={models} />);
 
     await waitFor(() => expect(folderSelect().options).toHaveLength(3));
     const labels = Array.from(folderSelect().options).map((o) => o.textContent);
@@ -666,7 +666,7 @@ describe('VirtualPrinterCard - File Manager folder', () => {
 
   it('choosing a folder saves it, and the default clears it', async () => {
     const user = userEvent.setup();
-    render(<VirtualPrinterCard printer={createMockPrinter()} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={createMockPrinter()} models={models} />);
     await waitFor(() => expect(folderSelect().options).toHaveLength(3));
 
     await user.selectOptions(folderSelect(), '4');
@@ -677,7 +677,7 @@ describe('VirtualPrinterCard - File Manager folder', () => {
   });
 
   it('a proxy keeps no files, so it has no folder', async () => {
-    render(<VirtualPrinterCard printer={createMockPrinter({ mode: 'proxy' })} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={createMockPrinter({ mode: 'proxy' })} models={models} />);
 
     await screen.findByText('Target Printer');
     expect(screen.queryByRole('combobox', { name: 'File Manager folder' })).toBeNull();
@@ -685,7 +685,7 @@ describe('VirtualPrinterCard - File Manager folder', () => {
 
   it('queue mode can remove the file from the File Manager once it prints', async () => {
     const user = userEvent.setup();
-    render(<VirtualPrinterCard printer={createMockPrinter({ mode: 'queue' })} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={createMockPrinter({ mode: 'queue' })} models={models} />);
 
     const toggle = await screen.findByRole('switch', { name: 'Remove from File Manager once it prints' });
     expect(toggle.getAttribute('aria-checked')).toBe('false');
@@ -697,9 +697,27 @@ describe('VirtualPrinterCard - File Manager folder', () => {
   });
 
   it('an old "archive" printer shows as File Manager mode', async () => {
-    render(<VirtualPrinterCard printer={createMockPrinter({ mode: 'archive' })} models={models} />);
+    render(<VirtualPrinterCard defaultExpanded printer={createMockPrinter({ mode: 'archive' })} models={models} />);
 
     const button = await screen.findByText('Store files in the File Manager');
     expect(button.closest('button')?.className).toContain('border-bambu-green');
   });
 });
+
+
+// With several virtual printers the settings page stays a list: each card
+// opens on a click of its header.
+describe('VirtualPrinterCard - collapsed by default', () => {
+  it('shows only the header until opened', async () => {
+    const user = userEvent.setup();
+    render(<VirtualPrinterCard printer={createMockPrinter({ mode: 'queue' })} models={models} />);
+
+    expect(screen.getByText('Test VP')).toBeInTheDocument();
+    expect(screen.queryByText('Target Printer')).toBeNull();
+
+    await user.click(screen.getByRole('button', { name: 'Expand' }));
+
+    expect(await screen.findByText('Target Printer')).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Collapse' }).getAttribute('aria-expanded')).toBe('true');
+  });
+});

+ 3 - 22
frontend/src/components/Layout.tsx

@@ -2,7 +2,6 @@ import { Suspense, useState, useEffect, useCallback, useRef, useMemo } from 'rea
 import { PageLoading } from './PageLoading';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
 import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, Bell, Receipt, Megaphone, type LucideIcon } from 'lucide-react';
-import { SlicerAccessCodeModal } from './SlicerAccessCodeModal';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -115,7 +114,6 @@ export function Layout() {
   const { user, authEnabled, logout, hasPermission, hasAnyPermission, loading: authLoading } = useAuth();
   const { showToast } = useToast();
   const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
-  const [showSlicerCodeModal, setShowSlicerCodeModal] = useState(false);
   const [changePasswordData, setChangePasswordData] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
   const [changePasswordLoading, setChangePasswordLoading] = useState(false);
   const [sidebarExpanded, setSidebarExpanded] = useState(() => {
@@ -833,10 +831,9 @@ export function Layout() {
         <div className="flex-shrink-0 p-2 border-t border-bambu-dark-tertiary">
           {isSidebarCompact || sidebarExpanded ? (
             <div className="flex flex-col gap-2">
-              {/* Top row: icons, 32px each with no gap: seven fit the 239px of
-                  an expanded sidebar -- announcements, System, GitHub,
-                  shortcuts, theme, password, logout. Signed in, the slicer
-                  access code (#1430) makes eight, which wrap to a second row. */}
+              {/* Top row: icons. 32px each with no gap so seven fit the 239px
+                  of an expanded sidebar -- announcements, System, GitHub,
+                  shortcuts, theme, password, logout -- without wrapping. */}
               <div className="flex items-center justify-center flex-wrap [&>a]:p-1.5 [&>button]:p-1.5 [&>span]:p-1.5 [&>div>button]:p-1.5">
                 {hasSwitchbarPlugs && (
                   <div className="relative">
@@ -926,13 +923,6 @@ export function Layout() {
                     >
                       <Key className="w-5 h-5" />
                     </button>
-                    <button
-                      onClick={() => setShowSlicerCodeModal(true)}
-                      className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                      title={t('slicerAccessCode.title')}
-                    >
-                      <Printer className="w-5 h-5" />
-                    </button>
                     <button
                       onClick={logout}
                       className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
@@ -1055,13 +1045,6 @@ export function Layout() {
                   >
                     <Key className="w-5 h-5" />
                   </button>
-                  <button
-                    onClick={() => setShowSlicerCodeModal(true)}
-                    className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
-                    title={t('slicerAccessCode.title')}
-                  >
-                    <Printer className="w-5 h-5" />
-                  </button>
                   <button
                     onClick={logout}
                     className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
@@ -1242,8 +1225,6 @@ export function Layout() {
       )}
 
       {/* Change Password Modal */}
-      {showSlicerCodeModal && <SlicerAccessCodeModal onClose={() => setShowSlicerCodeModal(false)} />}
-
       {showChangePasswordModal && (
         <div
           className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"

+ 135 - 0
frontend/src/components/SlicerAccessCodeCard.tsx

@@ -0,0 +1,135 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Check, Copy, Eye, EyeOff, KeyRound, Loader2, RefreshCw } from 'lucide-react';
+import { multiVirtualPrinterApi } from '../api/client';
+import { Card, CardContent } from './Card';
+import { ConfirmModal } from './ConfirmModal';
+import { useToast } from '../contexts/ToastContext';
+import { useAuth } from '../contexts/AuthContext';
+import { copyTextToClipboard } from '../utils/clipboard';
+
+/**
+ * The signed-in user's personal access code for virtual printers (#1430),
+ * at the top of Settings > Virtual Printer.
+ *
+ * Entered in their slicer instead of a virtual printer's own access code, it
+ * marks what they send as theirs. A virtual printer checks at every sign-in
+ * whether they may send to it. Renders nothing while auth is off.
+ */
+export function SlicerAccessCodeCard() {
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+  const { user, authEnabled } = useAuth();
+  const [revealed, setRevealed] = useState(false);
+  const [copied, setCopied] = useState(false);
+  const [confirmRenew, setConfirmRenew] = useState(false);
+  // Per user: on a shared PC the next person to sign in must never see the
+  // code cached for the one before.
+  const queryKey = ['my-slicer-access-code', user?.id ?? null];
+  const enabled = authEnabled && !!user;
+
+  const { data, isLoading, isError } = useQuery({
+    queryKey,
+    queryFn: multiVirtualPrinterApi.getMyAccessCode,
+    enabled,
+    gcTime: 0,
+  });
+
+  const renew = useMutation({
+    mutationFn: multiVirtualPrinterApi.renewMyAccessCode,
+    onSuccess: (result) => {
+      queryClient.setQueryData(queryKey, result);
+      setRevealed(true);
+      showToast(t('slicerAccessCode.renewed'));
+    },
+    onError: (error: Error) => showToast(error.message || t('slicerAccessCode.loadFailed'), 'error'),
+  });
+
+  if (!enabled) return null;
+
+  const copy = async () => {
+    if (!data) return;
+    if (await copyTextToClipboard(data.code)) {
+      setCopied(true);
+      setTimeout(() => setCopied(false), 2000);
+    } else {
+      showToast(t('slicerAccessCode.copyFailed'), 'error');
+    }
+  };
+
+  const iconButton =
+    'inline-flex items-center gap-1.5 px-2.5 py-1 text-xs rounded bg-bambu-dark-secondary border border-bambu-dark-tertiary text-white hover:border-bambu-gray disabled:opacity-50 transition-colors';
+
+  return (
+    <Card>
+      <CardContent className="py-3 px-4">
+        <div className="flex items-start gap-3">
+          <KeyRound className="w-4 h-4 text-bambu-green flex-shrink-0 mt-1" />
+          <div className="flex-1 min-w-0">
+            <div className="flex flex-wrap items-center justify-between gap-3">
+              <p className="text-sm text-white font-medium">{t('slicerAccessCode.title')}</p>
+              {isLoading ? (
+                <Loader2 className="w-4 h-4 animate-spin text-bambu-green" />
+              ) : isError || !data ? (
+                <p className="text-xs text-red-600 dark:text-red-400">{t('slicerAccessCode.loadFailed')}</p>
+              ) : (
+                <div className="flex flex-wrap items-center gap-2">
+                  <code
+                    data-testid="slicer-access-code"
+                    className="font-mono text-sm tracking-[0.25em] text-white bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded px-2 py-0.5"
+                  >
+                    {revealed ? data.code : '•'.repeat(data.code.length)}
+                  </code>
+                  <button
+                    onClick={() => setRevealed(!revealed)}
+                    aria-label={revealed ? t('slicerAccessCode.hide') : t('slicerAccessCode.show')}
+                    className={iconButton}
+                  >
+                    {revealed ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
+                  </button>
+                  <button onClick={copy} aria-label={t('slicerAccessCode.copy')} className={iconButton}>
+                    {copied ? <Check className="w-3.5 h-3.5 text-bambu-green" /> : <Copy className="w-3.5 h-3.5" />}
+                  </button>
+                  <button
+                    onClick={() => setConfirmRenew(true)}
+                    disabled={renew.isPending}
+                    className={iconButton}
+                  >
+                    {renew.isPending ? (
+                      <Loader2 className="w-3.5 h-3.5 animate-spin" />
+                    ) : (
+                      <RefreshCw className="w-3.5 h-3.5" />
+                    )}
+                    {t('slicerAccessCode.renew')}
+                  </button>
+                </div>
+              )}
+            </div>
+            <p className="text-xs text-bambu-gray mt-1">{t('slicerAccessCode.description')}</p>
+            <ul className="text-xs text-bambu-gray list-disc pl-4 mt-1 space-y-0.5">
+              <li>{t('slicerAccessCode.hintChecked')}</li>
+              <li>{t('slicerAccessCode.hintCamera')}</li>
+              <li>{t('slicerAccessCode.hintSecret')}</li>
+            </ul>
+          </div>
+        </div>
+      </CardContent>
+
+      {confirmRenew && (
+        <ConfirmModal
+          title={t('slicerAccessCode.renewTitle')}
+          message={t('slicerAccessCode.renewConfirm')}
+          confirmText={t('slicerAccessCode.renew')}
+          variant="warning"
+          onConfirm={() => {
+            setConfirmRenew(false);
+            renew.mutate();
+          }}
+          onCancel={() => setConfirmRenew(false)}
+        />
+      )}
+    </Card>
+  );
+}

+ 0 - 155
frontend/src/components/SlicerAccessCodeModal.tsx

@@ -1,155 +0,0 @@
-import { useEffect, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { Check, Copy, Eye, EyeOff, Loader2, Printer, RefreshCw, X } from 'lucide-react';
-import { multiVirtualPrinterApi } from '../api/client';
-import { Card, CardContent } from './Card';
-import { Button } from './Button';
-import { ConfirmModal } from './ConfirmModal';
-import { useToast } from '../contexts/ToastContext';
-import { useAuth } from '../contexts/AuthContext';
-import { copyTextToClipboard } from '../utils/clipboard';
-
-/**
- * The signed-in user's personal access code for virtual printers (#1430).
- *
- * Entered in their slicer instead of a virtual printer's own access code, it
- * marks what they send as theirs. A virtual printer checks at every sign-in
- * whether they may send to it.
- */
-export function SlicerAccessCodeModal({ onClose }: { onClose: () => void }) {
-  const { t } = useTranslation();
-  const { showToast } = useToast();
-  const queryClient = useQueryClient();
-  const [revealed, setRevealed] = useState(false);
-  const [copied, setCopied] = useState(false);
-  const [confirmRenew, setConfirmRenew] = useState(false);
-  // Per user: on a shared PC the next person to sign in must never see the
-  // code cached for the one before.
-  const { user } = useAuth();
-  const queryKey = ['my-slicer-access-code', user?.id ?? null];
-
-  const { data, isLoading, isError } = useQuery({
-    queryKey,
-    queryFn: multiVirtualPrinterApi.getMyAccessCode,
-    gcTime: 0,
-  });
-
-  const renew = useMutation({
-    mutationFn: multiVirtualPrinterApi.renewMyAccessCode,
-    onSuccess: (result) => {
-      queryClient.setQueryData(queryKey, result);
-      setRevealed(true);
-      showToast(t('slicerAccessCode.renewed'));
-    },
-    onError: (error: Error) => showToast(error.message || t('slicerAccessCode.loadFailed'), 'error'),
-  });
-
-  useEffect(() => {
-    const onKey = (e: KeyboardEvent) => {
-      if (e.key === 'Escape' && !confirmRenew) onClose();
-    };
-    window.addEventListener('keydown', onKey);
-    return () => window.removeEventListener('keydown', onKey);
-  }, [onClose, confirmRenew]);
-
-  const copy = async () => {
-    if (!data) return;
-    if (await copyTextToClipboard(data.code)) {
-      setCopied(true);
-      setTimeout(() => setCopied(false), 2000);
-    } else {
-      showToast(t('slicerAccessCode.copyFailed'), 'error');
-    }
-  };
-
-  return (
-    <div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50" onClick={onClose}>
-      <Card className="w-full max-w-md" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
-        <CardContent className="p-6 space-y-4">
-          <div className="flex items-start justify-between gap-3">
-            <div className="flex items-center gap-2">
-              <Printer className="w-5 h-5 text-bambu-green" />
-              <h3 className="text-lg font-semibold text-white">{t('slicerAccessCode.title')}</h3>
-            </div>
-            <button
-              onClick={onClose}
-              aria-label={t('common.close')}
-              className="p-1 text-bambu-gray hover:text-white transition-colors"
-            >
-              <X className="w-5 h-5" />
-            </button>
-          </div>
-
-          <p className="text-sm text-bambu-gray">{t('slicerAccessCode.description')}</p>
-
-          {isLoading ? (
-            <div className="flex justify-center py-4">
-              <Loader2 className="w-6 h-6 animate-spin text-bambu-green" />
-            </div>
-          ) : isError || !data ? (
-            <p className="text-sm text-red-600 dark:text-red-400">{t('slicerAccessCode.loadFailed')}</p>
-          ) : (
-            <div className="flex items-center gap-2">
-              <code
-                data-testid="slicer-access-code"
-                className="flex-1 text-center text-2xl font-mono tracking-[0.3em] text-white bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md py-2"
-              >
-                {revealed ? data.code : '•'.repeat(data.code.length)}
-              </code>
-              <button
-                onClick={() => setRevealed(!revealed)}
-                aria-label={revealed ? t('slicerAccessCode.hide') : t('slicerAccessCode.show')}
-                className="p-2 text-bambu-gray hover:text-white transition-colors"
-              >
-                {revealed ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
-              </button>
-              <button
-                onClick={copy}
-                aria-label={t('slicerAccessCode.copy')}
-                className="p-2 text-bambu-gray hover:text-white transition-colors"
-              >
-                {copied ? <Check className="w-5 h-5 text-bambu-green" /> : <Copy className="w-5 h-5" />}
-              </button>
-            </div>
-          )}
-
-          <ul className="text-xs text-bambu-gray list-disc pl-4 space-y-1">
-            <li>{t('slicerAccessCode.hintChecked')}</li>
-            <li>{t('slicerAccessCode.hintCamera')}</li>
-            <li>{t('slicerAccessCode.hintSecret')}</li>
-          </ul>
-
-          <div className="flex justify-end">
-            <Button
-              variant="secondary"
-              size="sm"
-              onClick={() => setConfirmRenew(true)}
-              disabled={!data || renew.isPending}
-            >
-              {renew.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
-              {t('slicerAccessCode.renew')}
-            </Button>
-          </div>
-        </CardContent>
-      </Card>
-
-      {confirmRenew && (
-        <div onClick={(e) => e.stopPropagation()}>
-          <ConfirmModal
-            title={t('slicerAccessCode.renewTitle')}
-            message={t('slicerAccessCode.renewConfirm')}
-            confirmText={t('slicerAccessCode.renew')}
-            variant="warning"
-            overlayZIndex="z-[60]"
-            onConfirm={() => {
-              setConfirmRenew(false);
-              renew.mutate();
-            }}
-            onCancel={() => setConfirmRenew(false)}
-          />
-        </div>
-      )}
-    </div>
-  );
-}

+ 9 - 3
frontend/src/components/VirtualPrinterCard.tsx

@@ -48,14 +48,16 @@ function uploadFolderOptions(tree: LibraryFolderTree[] | undefined, depth = 0):
 interface VirtualPrinterCardProps {
   printer: VirtualPrinterConfig;
   models: Record<string, string>;
+  // Collapsed by default: with several virtual printers the page stays a list.
+  defaultExpanded?: boolean;
 }
 
-export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps) {
+export function VirtualPrinterCard({ printer, models, defaultExpanded = false }: VirtualPrinterCardProps) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const { showToast } = useToast();
 
-  const [expanded, setExpanded] = useState(true);
+  const [expanded, setExpanded] = useState(defaultExpanded);
   const [localEnabled, setLocalEnabled] = useState(printer.enabled);
   const [localName, setLocalName] = useState(printer.name);
   const [localAccessCode, setLocalAccessCode] = useState('');
@@ -299,7 +301,11 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
           className="px-4 py-3 flex items-center gap-3 cursor-pointer select-none"
           onClick={() => setExpanded(!expanded)}
         >
-          <button className="text-bambu-gray flex-shrink-0">
+          <button
+            className="text-bambu-gray flex-shrink-0"
+            aria-expanded={expanded}
+            aria-label={expanded ? t('common.collapse') : t('common.expand')}
+          >
             {expanded
               ? <ChevronDown className="w-4 h-4" />
               : <ChevronRight className="w-4 h-4" />

+ 4 - 0
frontend/src/components/VirtualPrinterList.tsx

@@ -10,6 +10,7 @@ import { useToast } from '../contexts/ToastContext';
 import { copyTextToClipboard, downloadTextFile } from '../utils/clipboard';
 import { VirtualPrinterCard } from './VirtualPrinterCard';
 import { VirtualPrinterAddDialog } from './VirtualPrinterAddDialog';
+import { SlicerAccessCodeCard } from './SlicerAccessCodeCard';
 
 // Operating system names, the same in every language.
 const INSTALLER_PLATFORMS = [
@@ -142,6 +143,9 @@ export function VirtualPrinterList() {
         </Card>
       </div>
 
+      {/* The signed-in user's own access code (#1430); nothing while auth is off. */}
+      <SlicerAccessCodeCard />
+
       {/* Global VP behavior settings — two side-by-side cards, not full width */}
       <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-stretch">
         {/* Slicer CA certificate — shared by every VP, imported into the

+ 1 - 1
frontend/src/i18n/locales/de.ts

@@ -6345,7 +6345,7 @@ export default {
     },
     accessCode: {
       title: 'Zugangscode',
-      personalHint: 'Mit aktivierter Anmeldung können Benutzer auch ihren persönlichen Zugangscode eingeben (Seitenleiste, Druckersymbol): Was sie senden, gehört dann ihnen.',
+      personalHint: 'Mit aktivierter Anmeldung können Benutzer auch ihren persönlichen Zugangscode eingeben, der oben auf dieser Seite steht: Was sie senden, gehört dann ihnen.',
       isSet: 'Zugangscode ist gesetzt',
       notSet: 'Kein Zugangscode gesetzt - erforderlich zum Aktivieren',
       placeholder: '8-Zeichen-Code eingeben',

+ 1 - 1
frontend/src/i18n/locales/en.ts

@@ -6398,7 +6398,7 @@ export default {
     },
     accessCode: {
       title: 'Access Code',
-      personalHint: 'With auth on, users can also enter their personal access code (sidebar, printer icon): what they send is then theirs.',
+      personalHint: 'With auth on, users can also enter their personal access code, shown at the top of this page: what they send is then theirs.',
       isSet: 'Access code is set',
       notSet: 'No access code set - required to enable',
       placeholder: 'Enter 8-char code',

+ 1 - 1
frontend/src/i18n/locales/es.ts

@@ -6353,7 +6353,7 @@ export default {
     },
     accessCode: {
       title: 'Código de acceso',
-      personalHint: 'Con la autenticación activada, los usuarios también pueden introducir su código de acceso personal (barra lateral, icono de impresora): lo que envíen será suyo.',
+      personalHint: 'Con la autenticación activada, los usuarios también pueden introducir su código de acceso personal, que aparece arriba en esta página: lo que envíen será suyo.',
       isSet: 'El código de acceso está establecido',
       notSet: 'No hay código de acceso establecido - necesario para activarla',
       placeholder: 'Introduzca un código de 8 caracteres',

+ 1 - 1
frontend/src/i18n/locales/fr.ts

@@ -6333,7 +6333,7 @@ export default {
     },
     accessCode: {
       title: 'Code d\'accès',
-      personalHint: 'Avec l\'authentification activée, les utilisateurs peuvent aussi saisir leur code d\'accès personnel (barre latérale, icône d\'imprimante) : ce qu\'ils envoient est alors à eux.',
+      personalHint: 'Avec l\'authentification activée, les utilisateurs peuvent aussi saisir leur code d\'accès personnel, affiché en haut de cette page : ce qu\'ils envoient est alors à eux.',
       isSet: 'Code défini',
       notSet: 'Code requis pour activer',
       placeholder: 'Code 8 char',

+ 1 - 1
frontend/src/i18n/locales/it.ts

@@ -6332,7 +6332,7 @@ export default {
     },
     accessCode: {
       title: 'Codice accesso',
-      personalHint: 'Con l\'autenticazione attiva, gli utenti possono inserire anche il loro codice di accesso personale (barra laterale, icona della stampante): ciò che inviano sarà loro.',
+      personalHint: 'Con l\'autenticazione attiva, gli utenti possono inserire anche il loro codice di accesso personale, mostrato in cima a questa pagina: ciò che inviano sarà loro.',
       isSet: 'Codice accesso impostato',
       notSet: 'Nessun codice accesso impostato - richiesto per abilitare',
       placeholder: 'Inserisci codice 8 caratteri',

+ 1 - 1
frontend/src/i18n/locales/ja.ts

@@ -6346,7 +6346,7 @@ export default {
     },
     accessCode: {
       title: 'アクセスコード',
-      personalHint: '認証が有効なとき、ユーザーは個人用アクセスコード(サイドバーのプリンターアイコン)も入力できます。送ったものはそのユーザーのものになります。',
+      personalHint: '認証が有効なとき、ユーザーはこのページ上部に表示される個人用アクセスコードも入力できます。送ったものはそのユーザーのものになります。',
       isSet: 'アクセスコードが設定されています',
       notSet: 'アクセスコード未設定 - 有効化に必要です',
       placeholder: '8文字のコードを入力',

+ 1 - 1
frontend/src/i18n/locales/ko.ts

@@ -6082,7 +6082,7 @@ export default {
     },
     accessCode: {
       title: '액세스 코드',
-      personalHint: '인증이 켜져 있으면 사용자는 개인 액세스 코드(사이드바의 프린터 아이콘)도 입력할 수 있습니다. 보낸 것은 해당 사용자의 것이 됩니다.',
+      personalHint: '인증이 켜져 있으면 사용자는 이 페이지 상단에 표시된 개인 액세스 코드도 입력할 수 있습니다. 보낸 것은 해당 사용자의 것이 됩니다.',
       isSet: '액세스 코드가 설정됨',
       notSet: '액세스 코드가 설정되지 않음 - 활성화하려면 필요',
       placeholder: '8자리 코드 입력',

+ 1 - 1
frontend/src/i18n/locales/nl.ts

@@ -6396,7 +6396,7 @@ export default {
     },
     accessCode: {
       title: 'Toegangscode',
-      personalHint: 'Met aanmelding aan kunnen gebruikers ook hun persoonlijke toegangscode invoeren (zijbalk, printerpictogram): wat ze sturen is dan van hen.',
+      personalHint: 'Met aanmelding aan kunnen gebruikers ook hun persoonlijke toegangscode invoeren, bovenaan deze pagina: wat ze sturen is dan van hen.',
       isSet: 'Toegangscode is ingesteld',
       notSet: 'Geen toegangscode ingesteld - vereist om in te schakelen',
       placeholder: 'Voer een code van 8 tekens in',

+ 1 - 1
frontend/src/i18n/locales/pl.ts

@@ -6398,7 +6398,7 @@ export default {
     },
     accessCode: {
       title: 'Kod dostępu',
-      personalHint: 'Przy włączonym uwierzytelnianiu użytkownicy mogą też wpisać swój osobisty kod dostępu (pasek boczny, ikona drukarki): to, co wyślą, będzie ich.',
+      personalHint: 'Przy włączonym uwierzytelnianiu użytkownicy mogą też wpisać swój osobisty kod dostępu, widoczny na górze tej strony: to, co wyślą, będzie ich.',
       isSet: 'Kod dostępu jest ustawiony',
       notSet: 'Brak kodu dostępu — wymagany do włączenia',
       placeholder: 'Wpisz 8-znakowy kod',

+ 1 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -6332,7 +6332,7 @@ export default {
     },
     accessCode: {
       title: 'Código de acesso',
-      personalHint: 'Com a autenticação ativa, os usuários também podem digitar seu código de acesso pessoal (barra lateral, ícone de impressora): o que enviarem passa a ser deles.',
+      personalHint: 'Com a autenticação ativa, os usuários também podem digitar seu código de acesso pessoal, mostrado no topo desta página: o que enviarem passa a ser deles.',
       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',

+ 1 - 1
frontend/src/i18n/locales/ru.ts

@@ -6070,7 +6070,7 @@ export default {
     },
     accessCode: {
       title: "Код доступа",
-      personalHint: "При включённой авторизации пользователи могут ввести и свой личный код доступа (боковая панель, значок принтера): отправленное ими будет принадлежать им.",
+      personalHint: "При включённой авторизации пользователи могут ввести и свой личный код доступа, показанный вверху этой страницы: отправленное ими будет принадлежать им.",
       isSet: "Код доступа задан",
       notSet: "Код доступа не задан — он необходим для включения",
       placeholder: "Введите 8-значный код",

+ 1 - 1
frontend/src/i18n/locales/sv.ts

@@ -6427,7 +6427,7 @@ errors: {
     },
     accessCode: {
       title: 'Åtkomstkod',
-      personalHint: 'Med inloggning på kan användare också ange sin personliga åtkomstkod (sidofältet, skrivarikonen): det de skickar blir då deras.',
+      personalHint: 'Med inloggning på kan användare också ange sin personliga åtkomstkod, som visas överst på den här sidan: det de skickar blir då deras.',
       isSet: 'Åtkomstkod är inställd',
       notSet: 'Ingen åtkomstkod inställd — krävs för att aktivera',
       placeholder: 'Ange 8-teckens kod',

+ 1 - 1
frontend/src/i18n/locales/tr.ts

@@ -6309,7 +6309,7 @@ export default {
     },
     accessCode: {
       title: 'Erişim Kodu',
-      personalHint: 'Kimlik doğrulama açıkken kullanıcılar kişisel erişim kodlarını da girebilir (kenar çubuğu, yazıcı simgesi): gönderdikleri böylece onların olur.',
+      personalHint: 'Kimlik doğrulama açıkken kullanıcılar bu sayfanın üstünde görünen kişisel erişim kodlarını da girebilir: gönderdikleri böylece onların olur.',
       isSet: 'Erişim kodu ayarlandı',
       notSet: 'Erişim kodu ayarlanmadı - etkinleştirmek için gerekli',
       placeholder: '8 karakterli kodu girin',

+ 1 - 1
frontend/src/i18n/locales/uk.ts

@@ -6386,7 +6386,7 @@ export default {
     },
     accessCode: {
       title: "Код доступу",
-      personalHint: "З увімкненою автентифікацією користувачі можуть ввести і свій особистий код доступу (бічна панель, значок принтера): надіслане ними належатиме їм.",
+      personalHint: "З увімкненою автентифікацією користувачі можуть ввести і свій особистий код доступу, показаний угорі цієї сторінки: надіслане ними належатиме їм.",
       isSet: "Код доступу встановлено",
       notSet: "Немає встановленого коду доступу – потрібно для ввімкнення",
       placeholder: "Введіть 8-символьний код",

+ 1 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -6332,7 +6332,7 @@ export default {
     },
     accessCode: {
       title: '访问码',
-      personalHint: '启用身份验证后,用户也可以输入自己的个人访问码(侧边栏的打印机图标):他们发送的内容就属于他们。',
+      personalHint: '启用身份验证后,用户也可以输入显示在本页顶部的个人访问码:他们发送的内容就属于他们。',
       isSet: '访问码已设置',
       notSet: '未设置访问码 - 需要设置才能启用',
       placeholder: '输入 8 位字符代码',

+ 1 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -6332,7 +6332,7 @@ export default {
     },
     accessCode: {
       title: '存取碼',
-      personalHint: '啟用身分驗證後,使用者也可以輸入自己的個人存取碼(側邊欄的印表機圖示):他們傳送的內容就屬於他們。',
+      personalHint: '啟用身分驗證後,使用者也可以輸入顯示在本頁頂端的個人存取碼:他們傳送的內容就屬於他們。',
       isSet: '存取碼已設定',
       notSet: '未設定存取碼 - 需要設定才能啟用',
       placeholder: '輸入 8 位字元程式碼',

File diff suppressed because it is too large
+ 0 - 0
static/assets/ArchivesPage-D4fup-k4.js


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/SettingsPage-C-EIsTJa.js


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


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


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


File diff suppressed because it is too large
+ 1 - 1
static/assets/SpoolBuddySettingsPage-CwUiePTN.js


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


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


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/camWallControls-C-CB8l0g.js


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


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


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


File diff suppressed because it is too large
+ 0 - 1
static/assets/index-C7v2Gy3m.css


File diff suppressed because it is too large
+ 1 - 0
static/assets/index-Dyz22jcu.css


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


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


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


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


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


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


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


File diff suppressed because it is too large
+ 0 - 1
static/assets/plateCheck-Dg4HkH97.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pt-BR-Ddolisl7.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/ru-BhWW-k8_.js


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


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


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


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


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/zh-CN-BaVFeTkw.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/zh-TW-CT0QjWrb.js


+ 6 - 8
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-zedKfa7g.js"></script>
+    <script type="module" crossorigin src="/assets/index-E5ta5OG-.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-Bol9xAus.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-DOwGWmSv.js">
@@ -37,7 +37,7 @@
     <link rel="modulepreload" crossorigin href="/assets/info-DlBw4lHQ.js">
     <link rel="modulepreload" crossorigin href="/assets/AuthContext-D0GViS03.js">
     <link rel="modulepreload" crossorigin href="/assets/camera-MsEzBYBi.js">
-    <link rel="modulepreload" crossorigin href="/assets/plateCheck-DHqKgJvm.js">
+    <link rel="modulepreload" crossorigin href="/assets/plateCheck-Dg4HkH97.js">
     <link rel="modulepreload" crossorigin href="/assets/useMutation-Bln19oAR.js">
     <link rel="modulepreload" crossorigin href="/assets/useQueries-CL8SxBuQ.js">
     <link rel="modulepreload" crossorigin href="/assets/archive-DZtBvrsV.js">
@@ -99,10 +99,8 @@
     <link rel="modulepreload" crossorigin href="/assets/printer-DfB6SVGc.js">
     <link rel="modulepreload" crossorigin href="/assets/filamentSwatchHelpers-C3r5m07g.js">
     <link rel="modulepreload" crossorigin href="/assets/FilamentSwatch-DPXw7nIu.js">
-    <link rel="modulepreload" crossorigin href="/assets/PrintModal-D6wrVARn.js">
-    <link rel="modulepreload" crossorigin href="/assets/copy-BmKk1nJz.js">
+    <link rel="modulepreload" crossorigin href="/assets/PrintModal-BudpZCka.js">
     <link rel="modulepreload" crossorigin href="/assets/download-BQnowFA9.js">
-    <link rel="modulepreload" crossorigin href="/assets/eye-off-B3wPAuTw.js">
     <link rel="modulepreload" crossorigin href="/assets/eye-saxCObrc.js">
     <link rel="modulepreload" crossorigin href="/assets/folder-kanban-BGgYaBSZ.js">
     <link rel="modulepreload" crossorigin href="/assets/folder-open-DdUmmuhr.js">
@@ -112,7 +110,7 @@
     <link rel="modulepreload" crossorigin href="/assets/trash-2-flS03FnS.js">
     <link rel="modulepreload" crossorigin href="/assets/weight-yye8c-LK.js">
     <link rel="modulepreload" crossorigin href="/assets/projectTree-C5LrJHZP.js">
-    <link rel="modulepreload" crossorigin href="/assets/ConfirmOutcomeDialog-C2spD3Yf.js">
+    <link rel="modulepreload" crossorigin href="/assets/ConfirmOutcomeDialog-CQpv7o5z.js">
     <link rel="modulepreload" crossorigin href="/assets/key-g6VYTJ2w.js">
     <link rel="modulepreload" crossorigin href="/assets/log-out-CrnQe2x_.js">
     <link rel="modulepreload" crossorigin href="/assets/shield-alert-BfxCCD0Y.js">
@@ -124,8 +122,8 @@
     <link rel="modulepreload" crossorigin href="/assets/useColorCatalogVersion-CdArQBCv.js">
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-PUlx2UXc.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-CKKaaGUZ.js">
-    <link rel="modulepreload" crossorigin href="/assets/Layout-rse8GjU_.js">
-    <link rel="stylesheet" crossorigin href="/assets/index-C7v2Gy3m.css">
+    <link rel="modulepreload" crossorigin href="/assets/Layout-scPYLaXx.js">
+    <link rel="stylesheet" crossorigin href="/assets/index-Dyz22jcu.css">
   </head>
   <body>
     <div id="root"></div>

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