Просмотр исходного кода

Give camera settings their own tab and sort the settings menu

External cameras (General) and camera tokens plus the Streaming Overlay
builder (API Keys) move to a new Camera tab. Old links to the tokens or
the builder, /camera-tokens included, open it. API Keys is hidden from a
user who may see nothing on it. The settings menu keeps General first and
sorts the other tabs by their label in the interface language.

Also lets the Connected Apps fields shrink so Add app stays inside its
card, and fixes the pt-BR camera button label.
maziggy 3 дней назад
Родитель
Сommit
5869bdb5e4

+ 1 - 0
CHANGELOG.md

@@ -59,6 +59,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **Dutch (nl) is now a supported interface language (#2891, requested and contributed by @Igiegel)** — Adds `nl` as the fourteenth locale, listed as "Nederlands" in the language picker. The translation was contributed as a file on the issue and needed three corrections before it could be wired up, all of which the parity gate found. First, the nine `stats.timeframe.*` entries had their **keys** translated along with their values (`'today'` had become `'vandaag'`), which would have left the Statistics timeframe selector resolving nothing and rendering raw key names for every Dutch user — the values were kept and the keys restored. Second, the file was translated against an older `en.ts` and was 84 leaves short, missing the Filament Track Switch feed prompts, the AI-detection status strings, the no-3MF internal-history banner, the batch-order stranded-plate notices, the Avery starting-position field and the whole `locationHaSensors` section from #2824; those were translated and added. Rather than splice them in, `nl.ts` was regenerated from the `en.ts` skeleton with the contributor's strings carried over, so its structure, key order and section comments now match the reference locale exactly and a future diff against `en.ts` reads as content rather than as reordering. Third, 229 leaves were identical to English; each was checked individually and all were kept, because Dutch takes most technical UI vocabulary verbatim — printer, filament, status, nozzle, timelapse, dashboard — and Dutch slicer users use the English feature names (support, ironing, prime tower, gap fill) untranslated. Those 123 distinct values are now listed explicitly in a `NL_COGNATES` allow-list in `check-i18n-parity.mjs`, the same shape the other twelve locales use, so the exemption is an enumerated translator decision rather than a blanket skip. Parity green at 6264 leaves across all 14 locales.
 - **Dutch (nl) is now a supported interface language (#2891, requested and contributed by @Igiegel)** — Adds `nl` as the fourteenth locale, listed as "Nederlands" in the language picker. The translation was contributed as a file on the issue and needed three corrections before it could be wired up, all of which the parity gate found. First, the nine `stats.timeframe.*` entries had their **keys** translated along with their values (`'today'` had become `'vandaag'`), which would have left the Statistics timeframe selector resolving nothing and rendering raw key names for every Dutch user — the values were kept and the keys restored. Second, the file was translated against an older `en.ts` and was 84 leaves short, missing the Filament Track Switch feed prompts, the AI-detection status strings, the no-3MF internal-history banner, the batch-order stranded-plate notices, the Avery starting-position field and the whole `locationHaSensors` section from #2824; those were translated and added. Rather than splice them in, `nl.ts` was regenerated from the `en.ts` skeleton with the contributor's strings carried over, so its structure, key order and section comments now match the reference locale exactly and a future diff against `en.ts` reads as content rather than as reordering. Third, 229 leaves were identical to English; each was checked individually and all were kept, because Dutch takes most technical UI vocabulary verbatim — printer, filament, status, nozzle, timelapse, dashboard — and Dutch slicer users use the English feature names (support, ironing, prime tower, gap fill) untranslated. Those 123 distinct values are now listed explicitly in a `NL_COGNATES` allow-list in `check-i18n-parity.mjs`, the same shape the other twelve locales use, so the exemption is an enumerated translator decision rather than a blanket skip. Parity green at 6264 leaves across all 14 locales.
 
 
 ### Changed
 ### Changed
+- **Camera settings have their own tab, and the settings menu is sorted alphabetically** — Camera settings were split across two tabs: external cameras under General, and camera tokens and the Streaming Overlay builder under API Keys. A new **Camera** tab now holds all three: **External Cameras**, **Camera API Tokens** and **Streaming Overlay**. Old links to the tokens or the overlay builder, including `/camera-tokens`, open the Camera tab. API Keys keeps API keys, webhooks, Connected Apps and the API browser, and is no longer shown to a user who may see none of them; before, such a user only went there for their camera tokens, so an API Keys link now opens the Camera tab for them. Settings search finds the Streaming Overlay builder. The settings menu now lists **General** first and the other tabs alphabetically in the interface language. In Connected Apps, the **Add app** button no longer runs past the edge of the card on mid-sized screens, and the Brazilian Portuguese camera button on the printer card reads "Câmera" again instead of "âmera". Wiki updated. Covered by frontend tests.
 - **The Slice dialog uses more of a large screen** — The dialog stopped at 1024 px wide however large the screen, and its left column was fixed at 320 px. So preset names were cut off, and every filament row wrapped its label and its "hidden / Show all" note, while the process-settings panel beside it sat half empty. The dialog now widens to 1280 px on wide screens and 1536 px on very wide ones, and the left column takes two fifths of it instead of a fixed width. Below 1024 px it is still a single column.
 - **The Slice dialog uses more of a large screen** — The dialog stopped at 1024 px wide however large the screen, and its left column was fixed at 320 px. So preset names were cut off, and every filament row wrapped its label and its "hidden / Show all" note, while the process-settings panel beside it sat half empty. The dialog now widens to 1280 px on wide screens and 1536 px on very wide ones, and the left column takes two fifths of it instead of a fixed width. Below 1024 px it is still a single column.
 - **A STEP preview says it is converting, and for how long (#2976)** — A large STEP export can take over a minute to turn into a mesh in the browser (a 25 MB FreeCAD file took about 75 seconds; coarser meshing settings do not help, the time goes into reading the file), and for all that time the preview showed only a small spinner, which looked like a preview that never opens. STEP loads now show **Converting STEP model… N s** with a running counter and a note that large files can take a minute or more, in all 15 languages. Other formats load in a moment and keep the plain spinner.
 - **A STEP preview says it is converting, and for how long (#2976)** — A large STEP export can take over a minute to turn into a mesh in the browser (a 25 MB FreeCAD file took about 75 seconds; coarser meshing settings do not help, the time goes into reading the file), and for all that time the preview showed only a small spinner, which looked like a preview that never opens. STEP loads now show **Converting STEP model… N s** with a running counter and a note that large files can take a minute or more, in all 15 languages. Other formats load in a moment and keep the plain spinner.
 - **The schema no longer contains a cycle that `metadata.sorted_tables` cannot sort** — `print_archives.library_file_id` → `library_files.folder_id` → `library_folders.archive_id` → back to `print_archives`: three nullable `SET NULL` links, each reasonable alone, that together made a loop SQLAlchemy answered with a sort it could not make and a warning on every backup and every restore. The order it returned could place a child before its parent, which is what once imported `library_files` ahead of `library_folders` and killed a restore on a foreign key violation — the restore has since stopped depending on that order by stripping every foreign key before importing and adding them back afterwards, but the backup export sorts the same way, and the warning ends with "may raise an error in a future release", which would break backup and restore on the same upgrade. One edge of the loop is now marked `use_alter`, which removes it from the sort graph without removing the constraint from the database: PostgreSQL emits it as `ALTER TABLE ... ADD CONSTRAINT`, as it already did for every constraint on those three tables, and SQLite — which reports no ALTER support — inlines it into `CREATE TABLE` exactly as before, so `ON DELETE SET NULL` keeps working on both. `use_alter` requires a name, so new databases carry `fk_library_folders_archive_id` where existing ones keep PostgreSQL's generated `library_folders_archive_id_fkey`; nothing refers to either by name. Pinned by a test that fails if a new foreign key closes a loop again, and by one asserting the key still exists on SQLite — if that inlining ever stopped, deleting an archive would leave a dangling `library_folders.archive_id` instead of nulling it, silently.
 - **The schema no longer contains a cycle that `metadata.sorted_tables` cannot sort** — `print_archives.library_file_id` → `library_files.folder_id` → `library_folders.archive_id` → back to `print_archives`: three nullable `SET NULL` links, each reasonable alone, that together made a loop SQLAlchemy answered with a sort it could not make and a warning on every backup and every restore. The order it returned could place a child before its parent, which is what once imported `library_files` ahead of `library_folders` and killed a restore on a foreign key violation — the restore has since stopped depending on that order by stripping every foreign key before importing and adding them back afterwards, but the backup export sorts the same way, and the warning ends with "may raise an error in a future release", which would break backup and restore on the same upgrade. One edge of the loop is now marked `use_alter`, which removes it from the sort graph without removing the constraint from the database: PostgreSQL emits it as `ALTER TABLE ... ADD CONSTRAINT`, as it already did for every constraint on those three tables, and SQLite — which reports no ALTER support — inlines it into `CREATE TABLE` exactly as before, so `ON DELETE SET NULL` keeps working on both. `use_alter` requires a name, so new databases carry `fk_library_folders_archive_id` where existing ones keep PostgreSQL's generated `library_folders_archive_id_fkey`; nothing refers to either by name. Pinned by a test that fails if a new foreign key closes a loop again, and by one asserting the key still exists on SQLite — if that inlining ever stopped, deleting an archive would leave a dangling `library_folders.archive_id` instead of nulling it, silently.

+ 1 - 1
frontend/src/App.tsx

@@ -236,7 +236,7 @@ function App() {
                   <Route path="notifications" element={<NotificationsPage />} />
                   <Route path="notifications" element={<NotificationsPage />} />
                   <Route path="gcode-viewer" element={<GCodeViewerPage />} />
                   <Route path="gcode-viewer" element={<GCodeViewerPage />} />
                   <Route path="external/:id" element={<ExternalLinkPage />} />
                   <Route path="external/:id" element={<ExternalLinkPage />} />
-                  <Route path="camera-tokens" element={<Navigate to="/settings?tab=apikeys#card-camera-tokens" replace />} />
+                  <Route path="camera-tokens" element={<Navigate to="/settings?tab=camera#card-camera-tokens" replace />} />
                 </Route>
                 </Route>
               </Routes>
               </Routes>
             </BrowserRouter>
             </BrowserRouter>

+ 76 - 0
frontend/src/__tests__/pages/SettingsPage.test.tsx

@@ -198,6 +198,7 @@ describe('SettingsPage', () => {
       // choice is made per stream rather than once for the whole install. The
       // choice is made per stream rather than once for the whole install. The
       // External Cameras section is still here, which is what keeps this from
       // External Cameras section is still here, which is what keeps this from
       // passing merely because the Camera card failed to render.
       // passing merely because the Camera card failed to render.
+      window.history.replaceState({}, '', '/?tab=camera');
       render(<SettingsPage />);
       render(<SettingsPage />);
 
 
       await waitFor(() => {
       await waitFor(() => {
@@ -1518,6 +1519,76 @@ describe('SettingsPage', () => {
     });
     });
   });
   });
 
 
+  describe('Camera tab and menu order', () => {
+    const tabLabels = () => Array.from(document.querySelectorAll('nav button')).map((button) => button.textContent?.trim() ?? '');
+
+    it('lists General first and the other tabs alphabetically', async () => {
+      render(<SettingsPage />);
+      await waitFor(() => expect(screen.getByRole('button', { name: 'Camera' })).toBeInTheDocument());
+      const labels = tabLabels().map((label) => label.replace(/\d+$/, ''));
+      expect(labels[0]).toBe('General');
+      const rest = labels.slice(1);
+      expect(rest).toEqual([...rest].sort((a, b) => a.localeCompare(b, 'en')));
+      expect(rest).toContain('Camera');
+    });
+
+    it('holds external cameras, camera tokens and the streaming overlay', async () => {
+      window.history.replaceState({}, '', '/?tab=camera');
+      render(<SettingsPage />);
+      await waitFor(() => expect(screen.getByText('External Cameras')).toBeInTheDocument());
+      expect(document.getElementById('card-camera-tokens')).not.toBeNull();
+      expect(document.getElementById('card-stream-overlay')).not.toBeNull();
+    });
+
+    it('no longer shows camera settings on General or API Keys', async () => {
+      const user = userEvent.setup();
+      render(<SettingsPage />);
+      await waitFor(() => expect(screen.getByText('Default Printer')).toBeInTheDocument());
+      expect(screen.queryByText('External Cameras')).toBeNull();
+      await user.click(screen.getByRole('button', { name: /^API Keys/ }));
+      await waitFor(() => expect(document.getElementById('card-createapi')).not.toBeNull());
+      expect(document.getElementById('card-camera-tokens')).toBeNull();
+      expect(document.getElementById('card-stream-overlay')).toBeNull();
+    });
+
+    const signInWith = (permissions: string[]) => {
+      server.use(
+        http.get('/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
+        http.get('/api/v1/auth/me', () => HttpResponse.json({
+          id: 2, username: 'viewer', role: 'user', is_active: true, is_admin: false,
+          groups: [{ id: 2, name: 'Viewers' }], permissions, created_at: '2026-01-01T00:00:00Z',
+        })),
+      );
+      setAuthToken('test-token');
+    };
+
+    it('hides API Keys from a user who may see nothing on it, but keeps Camera', async () => {
+      signInWith(['settings:read', 'camera:view']);
+      render(<SettingsPage />);
+      await waitFor(() => expect(screen.getByRole('button', { name: 'Camera' })).toBeInTheDocument());
+      expect(screen.queryByRole('button', { name: /^API Keys/ })).toBeNull();
+    });
+
+    it('sends an API Keys link to the Camera tab for a user who cannot see API Keys', async () => {
+      signInWith(['settings:read', 'camera:view']);
+      window.history.replaceState({}, '', '/?tab=apikeys');
+      render(<SettingsPage />);
+      await waitFor(() => expect(screen.getByText('External Cameras')).toBeInTheDocument());
+    });
+
+    it.each([['api_keys:read'], ['settings:update']])('shows API Keys to a user with %s', async (permission) => {
+      signInWith(['settings:read', permission]);
+      render(<SettingsPage />);
+      await waitFor(() => expect(screen.getByRole('button', { name: /^API Keys/ })).toBeInTheDocument());
+    });
+
+    it.each(['#card-camera-tokens', '#card-stream-overlay'])('opens the Camera tab for an old API Keys link to %s', async (hash) => {
+      window.history.replaceState({}, '', `/?tab=apikeys${hash}`);
+      render(<SettingsPage />);
+      await waitFor(() => expect(screen.getByText('External Cameras')).toBeInTheDocument());
+    });
+  });
+
   describe('external camera snapshot URL override (#1177)', () => {
   describe('external camera snapshot URL override (#1177)', () => {
     /**
     /**
      * The snapshot URL input only appears for stream camera types where the
      * The snapshot URL input only appears for stream camera types where the
@@ -1546,6 +1617,11 @@ describe('SettingsPage', () => {
       updated_at: '2026-01-01T00:00:00Z',
       updated_at: '2026-01-01T00:00:00Z',
     };
     };
 
 
+    // External cameras live on the Camera tab.
+    beforeEach(() => {
+      window.history.replaceState({}, '', '/?tab=camera');
+    });
+
     it('renders the snapshot URL input when camera_type is mjpeg', async () => {
     it('renders the snapshot URL input when camera_type is mjpeg', async () => {
       server.use(
       server.use(
         http.get('/api/v1/printers/', () => HttpResponse.json([mjpegPrinter])),
         http.get('/api/v1/printers/', () => HttpResponse.json([mjpegPrinter])),

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

@@ -215,7 +215,7 @@ export function ConnectedAppsSection() {
           e.preventDefault();
           e.preventDefault();
           if (name.trim() && redirectUri.trim()) create.mutate();
           if (name.trim() && redirectUri.trim()) create.mutate();
         }}
         }}
-        className="grid gap-3 md:grid-cols-[1fr_1.5fr_auto]"
+        className="grid gap-3 md:grid-cols-[minmax(0,1fr)_minmax(0,1.5fr)_auto]"
       >
       >
         <input
         <input
           type="text"
           type="text"

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

@@ -1972,6 +1972,7 @@ export default {
       queueDispatch: 'Warteschlange & Dispatch',
       queueDispatch: 'Warteschlange & Dispatch',
       queuePipelines: 'Pipelines',
       queuePipelines: 'Pipelines',
       filament: 'Filament',
       filament: 'Filament',
+      camera: 'Kamera',
       network: 'Netzwerk',
       network: 'Netzwerk',
       apiKeys: 'API-Schlüssel',
       apiKeys: 'API-Schlüssel',
       virtualPrinter: 'Virtueller Drucker',
       virtualPrinter: 'Virtueller Drucker',

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

@@ -1992,6 +1992,7 @@ export default {
       queueDispatch: 'Queue & Dispatch',
       queueDispatch: 'Queue & Dispatch',
       queuePipelines: 'Pipelines',
       queuePipelines: 'Pipelines',
       filament: 'Filament',
       filament: 'Filament',
+      camera: 'Camera',
       network: 'Network',
       network: 'Network',
       apiKeys: 'API Keys',
       apiKeys: 'API Keys',
       virtualPrinter: 'Virtual Printer',
       virtualPrinter: 'Virtual Printer',

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

@@ -1973,6 +1973,7 @@ export default {
       queueDispatch: 'Cola y Despacho',
       queueDispatch: 'Cola y Despacho',
       queuePipelines: 'Pipelines',
       queuePipelines: 'Pipelines',
       filament: 'Filamento',
       filament: 'Filamento',
+      camera: 'Cámara',
       network: 'Red',
       network: 'Red',
       apiKeys: 'Claves API',
       apiKeys: 'Claves API',
       virtualPrinter: 'Impresora virtual',
       virtualPrinter: 'Impresora virtual',

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

@@ -1971,6 +1971,7 @@ export default {
       queueDispatch: 'File & Distribution',
       queueDispatch: 'File & Distribution',
       queuePipelines: 'Pipelines',
       queuePipelines: 'Pipelines',
       filament: 'Filament',
       filament: 'Filament',
+      camera: 'Caméra',
       network: 'Réseau',
       network: 'Réseau',
       apiKeys: 'Clés API',
       apiKeys: 'Clés API',
       virtualPrinter: 'Imprimante virtuelle',
       virtualPrinter: 'Imprimante virtuelle',

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

@@ -1971,6 +1971,7 @@ export default {
       queueDispatch: 'Coda e Dispatch',
       queueDispatch: 'Coda e Dispatch',
       queuePipelines: 'Pipeline',
       queuePipelines: 'Pipeline',
       filament: 'Filamento',
       filament: 'Filamento',
+      camera: 'Telecamera',
       network: 'Rete',
       network: 'Rete',
       apiKeys: 'Chiavi API',
       apiKeys: 'Chiavi API',
       virtualPrinter: 'Stampante virtuale',
       virtualPrinter: 'Stampante virtuale',

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

@@ -1971,6 +1971,7 @@ export default {
       queueDispatch: 'キューとディスパッチ',
       queueDispatch: 'キューとディスパッチ',
       queuePipelines: 'パイプライン',
       queuePipelines: 'パイプライン',
       filament: 'フィラメント',
       filament: 'フィラメント',
+      camera: 'カメラ',
       network: 'ネットワーク',
       network: 'ネットワーク',
       apiKeys: 'APIキー',
       apiKeys: 'APIキー',
       virtualPrinter: '仮想プリンター',
       virtualPrinter: '仮想プリンター',

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

@@ -1884,6 +1884,7 @@ export default {
       queueDispatch: '큐 및 디스패치',
       queueDispatch: '큐 및 디스패치',
       queuePipelines: '파이프라인',
       queuePipelines: '파이프라인',
       filament: '필라멘트',
       filament: '필라멘트',
+      camera: '카메라',
       network: '네트워크',
       network: '네트워크',
       apiKeys: 'API 키',
       apiKeys: 'API 키',
       virtualPrinter: '가상 프린터',
       virtualPrinter: '가상 프린터',

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

@@ -1990,6 +1990,7 @@ export default {
       queueDispatch: 'Wachtrij en verzending',
       queueDispatch: 'Wachtrij en verzending',
       queuePipelines: 'Pipelines',
       queuePipelines: 'Pipelines',
       filament: 'Filament',
       filament: 'Filament',
+      camera: 'Camera',
       network: 'Netwerk',
       network: 'Netwerk',
       apiKeys: 'API-sleutels',
       apiKeys: 'API-sleutels',
       virtualPrinter: 'Virtuele printer',
       virtualPrinter: 'Virtuele printer',

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

@@ -457,7 +457,7 @@ export default {
     resume: 'Retomar',
     resume: 'Retomar',
     pause: 'Pausar',
     pause: 'Pausar',
     stop: 'Parar',
     stop: 'Parar',
-    camera: 'âmera',
+    camera: 'Câmera',
     skipObject: 'Ignorar objeto',
     skipObject: 'Ignorar objeto',
     reconnect: 'Reconectar',
     reconnect: 'Reconectar',
     forceRefresh: 'Forçar atualização',
     forceRefresh: 'Forçar atualização',
@@ -1971,6 +1971,7 @@ export default {
       queueDispatch: 'Fila e Dispatch',
       queueDispatch: 'Fila e Dispatch',
       queuePipelines: 'Pipelines',
       queuePipelines: 'Pipelines',
       filament: 'Filamento',
       filament: 'Filamento',
+      camera: 'Câmera',
       network: 'Rede',
       network: 'Rede',
       apiKeys: 'Chaves API',
       apiKeys: 'Chaves API',
       virtualPrinter: 'Impressora Virtual',
       virtualPrinter: 'Impressora Virtual',

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

@@ -1881,6 +1881,7 @@ export default {
       queueDispatch: "Очередь и отправка",
       queueDispatch: "Очередь и отправка",
       queuePipelines: "Конвейеры",
       queuePipelines: "Конвейеры",
       filament: "Филамент",
       filament: "Филамент",
+      camera: 'Камера',
       network: "Сеть",
       network: "Сеть",
       apiKeys: "Ключи API",
       apiKeys: "Ключи API",
       virtualPrinter: "Виртуальный принтер",
       virtualPrinter: "Виртуальный принтер",

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

@@ -1989,6 +1989,7 @@ export default {
       queueDispatch: 'Kö och utskick',
       queueDispatch: 'Kö och utskick',
       queuePipelines: 'Pipelines',
       queuePipelines: 'Pipelines',
       filament: 'Filament',
       filament: 'Filament',
+      camera: 'Kamera',
       network: 'Nätverk',
       network: 'Nätverk',
       apiKeys: 'API-nycklar',
       apiKeys: 'API-nycklar',
       virtualPrinter: 'Virtuell skrivare',
       virtualPrinter: 'Virtuell skrivare',

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

@@ -1973,6 +1973,7 @@ export default {
       queueDispatch: 'Kuyruk ve Sevkıyat',
       queueDispatch: 'Kuyruk ve Sevkıyat',
       queuePipelines: 'Pipeline\'lar',
       queuePipelines: 'Pipeline\'lar',
       filament: 'Filament',
       filament: 'Filament',
+      camera: 'Kamera',
       network: 'Ağ',
       network: 'Ağ',
       apiKeys: 'API Anahtarları',
       apiKeys: 'API Anahtarları',
       virtualPrinter: 'Sanal Yazıcı',
       virtualPrinter: 'Sanal Yazıcı',

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

@@ -1988,6 +1988,7 @@ export default {
       queueDispatch: "Черга та відправка",
       queueDispatch: "Черга та відправка",
       queuePipelines: "Конвеєри",
       queuePipelines: "Конвеєри",
       filament: "Філамент",
       filament: "Філамент",
+      camera: 'Камера',
       network: "Мережа",
       network: "Мережа",
       apiKeys: "Ключі API",
       apiKeys: "Ключі API",
       virtualPrinter: "Віртуальний принтер",
       virtualPrinter: "Віртуальний принтер",

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

@@ -1971,6 +1971,7 @@ export default {
       queueDispatch: '队列与调度',
       queueDispatch: '队列与调度',
       queuePipelines: '流水线',
       queuePipelines: '流水线',
       filament: '耗材',
       filament: '耗材',
+      camera: '摄像头',
       network: '网络',
       network: '网络',
       apiKeys: 'API 密钥',
       apiKeys: 'API 密钥',
       virtualPrinter: '虚拟打印机',
       virtualPrinter: '虚拟打印机',

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

@@ -1971,6 +1971,7 @@ export default {
       queueDispatch: '佇列與分派',
       queueDispatch: '佇列與分派',
       queuePipelines: '管線',
       queuePipelines: '管線',
       filament: '耗材',
       filament: '耗材',
+      camera: '攝影機',
       network: '網路',
       network: '網路',
       apiKeys: 'API 金鑰',
       apiKeys: 'API 金鑰',
       virtualPrinter: '虛擬印表機',
       virtualPrinter: '虛擬印表機',

+ 1 - 0
frontend/src/lib/settingsSearch.ts

@@ -15,6 +15,7 @@ export type SettingsSearchTab =
   | 'notifications'
   | 'notifications'
   | 'queue'
   | 'queue'
   | 'filament'
   | 'filament'
+  | 'camera'
   | 'network'
   | 'network'
   | 'apikeys'
   | 'apikeys'
   | 'virtual-printer'
   | 'virtual-printer'

+ 2 - 2
frontend/src/pages/CameraTokensPage.tsx

@@ -4,7 +4,7 @@
  * Exports two surfaces:
  * Exports two surfaces:
  *
  *
  * - ``CameraTokensSection`` — the actual list+create+revoke UI. Designed to
  * - ``CameraTokensSection`` — the actual list+create+revoke UI. Designed to
- *   drop into Settings → API Keys (or any other host card) without page
+ *   drop into Settings → Camera (or any other host card) without page
  *   chrome of its own.
  *   chrome of its own.
  *
  *
  * - ``CameraTokensPage`` (default export) — a thin wrapper that puts the
  * - ``CameraTokensPage`` (default export) — a thin wrapper that puts the
@@ -451,7 +451,7 @@ function TokenTable({ tokens, showOwner, userIdToName, onRevoke, emptyMessage }:
 /**
 /**
  * The actual UI block: create form + my-tokens table + admin all-tokens table.
  * The actual UI block: create form + my-tokens table + admin all-tokens table.
  * Renders without any outer page chrome so it can be embedded inside
  * Renders without any outer page chrome so it can be embedded inside
- * Settings → API Keys (the canonical home) or any other host card.
+ * Settings → Camera (the canonical home) or any other host card.
  */
  */
 export function CameraTokensSection() {
 export function CameraTokensSection() {
   const { t } = useTranslation();
   const { t } = useTranslation();

+ 249 - 357
frontend/src/pages/SettingsPage.tsx

@@ -59,15 +59,16 @@ import { defaultNavItems, getDefaultView, setDefaultView } from '../components/L
 import { availableLanguages } from '../i18n';
 import { availableLanguages } from '../i18n';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
 import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, type ThemeAccent } from '../contexts/ThemeContext';
 import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, type ThemeAccent } from '../contexts/ThemeContext';
-import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
-import { Gauge, Link2, Palette } from 'lucide-react';
+import { useState, useEffect, useRef, useCallback, useMemo, type ReactNode } from 'react';
+import { Gauge, Link2, Palette, type LucideIcon } from 'lucide-react';
 import { registerSettingsSearch, getSettingsSearchEntries } from '../lib/settingsSearch';
 import { registerSettingsSearch, getSettingsSearchEntries } from '../lib/settingsSearch';
 import type { UsersSubTab } from '../lib/settingsSearch';
 import type { UsersSubTab } from '../lib/settingsSearch';
 import { availableEngines, hasEngineChoice, resolveEngine, type SliceEngineId } from '../lib/sliceEngines';
 import { availableEngines, hasEngineChoice, resolveEngine, type SliceEngineId } from '../lib/sliceEngines';
 import { NumberInput } from '../components/NumberInput';
 import { NumberInput } from '../components/NumberInput';
 
 
-const validTabs = ['general', 'plugs', 'sensors', 'notifications', 'queue', 'filament', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const;
+const validTabs = ['general', 'plugs', 'sensors', 'notifications', 'queue', 'filament', 'camera', 'network', 'apikeys', 'virtual-printer', 'spoolbuddy', 'failure-detection', 'users', 'backup'] as const;
 type TabType = typeof validTabs[number];
 type TabType = typeof validTabs[number];
+interface SettingsTabEntry { tab: TabType; icon: LucideIcon; label: string; extra?: ReactNode }
 
 
 // Cross-tab search registrations for cards rendered inline in this file.
 // Cross-tab search registrations for cards rendered inline in this file.
 // Adding a new settings card? Register it here (or, if the card lives in its
 // Adding a new settings card? Register it here (or, if the card lives in its
@@ -75,7 +76,7 @@ type TabType = typeof validTabs[number];
 registerSettingsSearch({ labelKey: 'settings.general', tab: 'general', keywords: 'language date time format printer model printers cards', anchor: 'card-general' });
 registerSettingsSearch({ labelKey: 'settings.general', tab: 'general', keywords: 'language date time format printer model printers cards', anchor: 'card-general' });
 registerSettingsSearch({ labelKey: 'settings.appearance', tab: 'general', keywords: 'theme dark light mode colors', anchor: 'card-appearance' });
 registerSettingsSearch({ labelKey: 'settings.appearance', tab: 'general', keywords: 'theme dark light mode colors', anchor: 'card-appearance' });
 registerSettingsSearch({ labelKey: 'settings.archiveSettings', tab: 'general', keywords: 'archive auto save thumbnails captures', anchor: 'card-archive' });
 registerSettingsSearch({ labelKey: 'settings.archiveSettings', tab: 'general', keywords: 'archive auto save thumbnails captures', anchor: 'card-archive' });
-registerSettingsSearch({ labelKey: 'settings.camera', tab: 'general', keywords: 'camera external video stream', anchor: 'card-camera' });
+registerSettingsSearch({ labelKey: 'settings.camera', tab: 'camera', keywords: 'camera external video stream rtsp mjpeg usb snapshot rotation', anchor: 'card-camera' });
 registerSettingsSearch({ labelKey: 'settings.costTracking', tab: 'general', keywords: 'currency filament cost energy kwh price', anchor: 'card-cost' });
 registerSettingsSearch({ labelKey: 'settings.costTracking', tab: 'general', keywords: 'currency filament cost energy kwh price', anchor: 'card-cost' });
 registerSettingsSearch({ labelKey: 'settings.fileManager', tab: 'general', keywords: 'file manager archive mode disk warning storage', anchor: 'card-filemanager' });
 registerSettingsSearch({ labelKey: 'settings.fileManager', tab: 'general', keywords: 'file manager archive mode disk warning storage', anchor: 'card-filemanager' });
 registerSettingsSearch({ labelKey: 'settings.updates', tab: 'general', keywords: 'updates version firmware beta check', anchor: 'card-updates' });
 registerSettingsSearch({ labelKey: 'settings.updates', tab: 'general', keywords: 'updates version firmware beta check', anchor: 'card-updates' });
@@ -105,7 +106,8 @@ registerSettingsSearch({ labelKey: 'settings.createNewApiKey', tab: 'apikeys', k
 registerSettingsSearch({ labelKey: 'settings.webhookEndpoints', tab: 'apikeys', keywords: 'webhook endpoint post http', anchor: 'card-webhooks' });
 registerSettingsSearch({ labelKey: 'settings.webhookEndpoints', tab: 'apikeys', keywords: 'webhook endpoint post http', anchor: 'card-webhooks' });
 registerSettingsSearch({ labelKey: 'settings.apiBrowser', tab: 'apikeys', keywords: 'api browser endpoint documentation test', anchor: 'card-apibrowser' });
 registerSettingsSearch({ labelKey: 'settings.apiBrowser', tab: 'apikeys', keywords: 'api browser endpoint documentation test', anchor: 'card-apibrowser' });
 registerSettingsSearch({ labelKey: 'connectedApps.title', tab: 'apikeys', keywords: 'connected app sign in single sign-on sso oauth login orders', anchor: 'card-connected-apps' });
 registerSettingsSearch({ labelKey: 'connectedApps.title', tab: 'apikeys', keywords: 'connected app sign in single sign-on sso oauth login orders', anchor: 'card-connected-apps' });
-registerSettingsSearch({ labelKey: 'cameraTokens.title', tab: 'apikeys', keywords: 'camera token long-lived home assistant frigate kiosk stream', anchor: 'card-camera-tokens' });
+registerSettingsSearch({ labelKey: 'cameraTokens.title', tab: 'camera', keywords: 'camera token long-lived home assistant frigate kiosk stream cam wall', anchor: 'card-camera-tokens' });
+registerSettingsSearch({ labelKey: 'streamOverlay.builder.title', labelFallback: 'Streaming Overlay', tab: 'camera', keywords: 'streaming overlay obs browser source stream url token', anchor: 'card-stream-overlay' });
 registerSettingsSearch({ labelKey: 'settings.tabs.virtualPrinter', tab: 'virtual-printer', keywords: 'virtual printer proxy archive slicer bambustudio orcaslicer ip bind', anchor: 'card-vp' });
 registerSettingsSearch({ labelKey: 'settings.tabs.virtualPrinter', tab: 'virtual-printer', keywords: 'virtual printer proxy archive slicer bambustudio orcaslicer ip bind', anchor: 'card-vp' });
 registerSettingsSearch({ labelKey: 'settings.tabs.spoolbuddy', tab: 'spoolbuddy', keywords: 'spoolbuddy device scale nfc rfid kiosk unregister', anchor: 'card-spoolbuddy' });
 registerSettingsSearch({ labelKey: 'settings.tabs.spoolbuddy', tab: 'spoolbuddy', keywords: 'spoolbuddy device scale nfc rfid kiosk unregister', anchor: 'card-spoolbuddy' });
 registerSettingsSearch({ labelKey: 'settings.currentUser', tab: 'users', subTab: 'users', keywords: 'current user profile password change', anchor: 'card-currentuser' });
 registerSettingsSearch({ labelKey: 'settings.currentUser', tab: 'users', subTab: 'users', keywords: 'current user profile password change', anchor: 'card-currentuser' });
@@ -205,7 +207,7 @@ export function SettingsPage() {
   const [searchParams, setSearchParams] = useSearchParams();
   const [searchParams, setSearchParams] = useSearchParams();
   const { t, i18n } = useTranslation();
   const { t, i18n } = useTranslation();
   const { showToast } = useToast();
   const { showToast } = useToast();
-  const { authEnabled, user, isAdmin, refreshAuth, hasPermission } = useAuth();
+  const { authEnabled, user, isAdmin, refreshAuth, hasPermission, loading: authLoading } = useAuth();
   const {
   const {
     mode, resolvedMode,
     mode, resolvedMode,
     darkStyle, darkBackground, darkAccent,
     darkStyle, darkBackground, darkAccent,
@@ -256,7 +258,14 @@ export function SettingsPage() {
   // Initialize tab from URL params (handle legacy ?tab=email → users tab + email sub-tab)
   // Initialize tab from URL params (handle legacy ?tab=email → users tab + email sub-tab)
   const tabParam = searchParams.get('tab');
   const tabParam = searchParams.get('tab');
   const isLegacyEmailTab = tabParam === 'email';
   const isLegacyEmailTab = tabParam === 'email';
-  const initialTab = isLegacyEmailTab ? 'users' : (tabParam && validTabs.includes(tabParam as TabType) ? tabParam as TabType : 'general');
+  // Camera tokens and the overlay builder used to live under API Keys; links
+  // to them (bookmarks, the wiki, /camera-tokens) open the Camera tab instead.
+  const canSeeApiKeysTab = hasPermission('api_keys:read') || hasPermission('settings:update');
+  const isLegacyCameraLink = tabParam === 'apikeys'
+    && ['#card-camera-tokens', '#card-stream-overlay'].includes(window.location.hash);
+  const initialTab = isLegacyEmailTab ? 'users'
+    : isLegacyCameraLink ? 'camera'
+    : (tabParam && validTabs.includes(tabParam as TabType) ? tabParam as TabType : 'general');
   const [activeTab, setActiveTab] = useState<TabType>(initialTab);
   const [activeTab, setActiveTab] = useState<TabType>(initialTab);
   const [usersSubTab, setUsersSubTab] = useState<UsersSubTab>(isLegacyEmailTab ? 'email' : 'users');
   const [usersSubTab, setUsersSubTab] = useState<UsersSubTab>(isLegacyEmailTab ? 'email' : 'users');
   // Workflow tab sub-tabs (#1425): 'dispatch' = current Workflow content,
   // Workflow tab sub-tabs (#1425): 'dispatch' = current Workflow content,
@@ -283,6 +292,14 @@ export function SettingsPage() {
     setSearchParams(searchParams, { replace: true });
     setSearchParams(searchParams, { replace: true });
   };
   };
 
 
+  // Any other API Keys link, for a user who cannot see that tab, opens Camera:
+  // the camera tokens were the only thing such a user ever went there for.
+  // Waits for the session so a permission not loaded yet doesn't count as none.
+  useEffect(() => {
+    if (!authLoading && activeTab === 'apikeys' && !canSeeApiKeysTab) handleTabChange('camera');
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [authLoading, activeTab, canSeeApiKeysTab]);
+
   // Switch the Workflow tab's sub-tab and reflect it in the URL so deep-links work.
   // Switch the Workflow tab's sub-tab and reflect it in the URL so deep-links work.
   const handleQueueSubTabChange = (sub: 'dispatch' | 'pipelines') => {
   const handleQueueSubTabChange = (sub: 'dispatch' | 'pipelines') => {
     setQueueSubTab(sub);
     setQueueSubTab(sub);
@@ -1539,6 +1556,36 @@ export function SettingsPage() {
     ? `cd ${/\s/.test(composeDir) ? `"${composeDir}"` : composeDir} && docker compose pull && docker compose up -d`
     ? `cd ${/\s/.test(composeDir) ? `"${composeDir}"` : composeDir} && docker compose pull && docker compose up -d`
     : 'docker compose pull && docker compose up -d';
     : 'docker compose pull && docker compose up -d';
 
 
+  const countBadge = (count: number) => count > 0 && (
+    <span className="text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full shrink-0">{count}</span>
+  );
+  const statusDot = (on: boolean) => (
+    <span className={`w-2 h-2 rounded-full shrink-0 ${on ? 'bg-green-400' : 'bg-gray-500'}`} />
+  );
+  const haSensorCount = (haSensors?.length ?? 0) + (locationHaSensors?.length ?? 0);
+  // General stays first, where most visits start; the rest are sorted by their
+  // label in the current language so each language reads alphabetically.
+  const generalTab: SettingsTabEntry = { tab: 'general', icon: SettingsIcon, label: t('settings.tabs.general') };
+  const otherTabs = ([
+    { tab: 'plugs', icon: Plug, label: t('settings.tabs.smartPlugs'), extra: countBadge(smartPlugs?.length ?? 0) },
+    { tab: 'sensors', icon: Gauge, label: t('settings.tabs.sensors'), extra: countBadge(haSensorCount) },
+    { tab: 'notifications', icon: Bell, label: t('settings.tabs.notifications'), extra: countBadge(notificationProviders?.length ?? 0) },
+    { tab: 'queue', icon: ListOrdered, label: t('settings.tabs.queue', 'Workflow') },
+    { tab: 'filament', icon: Cylinder, label: t('settings.tabs.filament') },
+    { tab: 'camera', icon: Video, label: t('settings.tabs.camera') },
+    { tab: 'network', icon: Wifi, label: t('settings.tabs.network'), extra: statusDot(!!mqttStatus?.enabled) },
+    // API Keys holds API keys, webhooks, the API browser and Connected Apps;
+    // a user who may see none of them would land on an empty tab.
+    ...(canSeeApiKeysTab
+      ? [{ tab: 'apikeys', icon: Key, label: t('settings.tabs.apiKeys'), extra: countBadge(apiKeys?.length ?? 0) } as SettingsTabEntry]
+      : []),
+    { tab: 'virtual-printer', icon: Printer, label: t('settings.tabs.virtualPrinter'), extra: statusDot(!!virtualPrinterRunning) },
+    { tab: 'spoolbuddy', icon: Scale, label: t('settings.tabs.spoolbuddy'), extra: <>{countBadge(spoolbuddyDeviceCount)}{statusDot(!!spoolbuddyAnyOnline)}</> },
+    { tab: 'failure-detection', icon: ScanEye, label: t('settings.tabs.failureDetection'), extra: statusDot(!!obicoActive) },
+    { tab: 'users', icon: Users, label: t('settings.tabs.users'), extra: authEnabled && <span className="w-2 h-2 rounded-full shrink-0 bg-green-400" /> },
+    { tab: 'backup', icon: Database, label: t('settings.tabs.backup'), extra: statusDot(!!((cloudAuthStatus?.is_authenticated && githubBackupStatus?.configured && githubBackupStatus?.enabled) || settings?.local_backup_enabled)) },
+  ] as SettingsTabEntry[]).sort((x, y) => x.label.localeCompare(y.label, i18n.language));
+
   return (
   return (
     <CardDensityProvider density="dense">
     <CardDensityProvider density="dense">
     <div className="p-4 md:p-8">
     <div className="p-4 md:p-8">
@@ -1597,182 +1644,21 @@ export function SettingsPage() {
       {/* Tab Navigation + content: horizontal tabs on mobile, vertical rail on lg+ */}
       {/* Tab Navigation + content: horizontal tabs on mobile, vertical rail on lg+ */}
       <div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
       <div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
       <nav className="flex flex-wrap gap-1 border-b border-bambu-dark-tertiary lg:flex-col lg:flex-nowrap lg:gap-0 lg:border-b-0 lg:border-r lg:w-60 lg:flex-shrink-0 lg:self-start lg:sticky lg:top-4">
       <nav className="flex flex-wrap gap-1 border-b border-bambu-dark-tertiary lg:flex-col lg:flex-nowrap lg:gap-0 lg:border-b-0 lg:border-r lg:w-60 lg:flex-shrink-0 lg:self-start lg:sticky lg:top-4">
-        <button
-          onClick={() => handleTabChange('general')}
-          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
-            activeTab === 'general'
-              ? 'text-bambu-green border-bambu-green'
-              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
-          }`}
-        >
-          <SettingsIcon className="w-4 h-4 shrink-0" />
-          {t('settings.tabs.general')}
-        </button>
-        <button
-          onClick={() => handleTabChange('plugs')}
-          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
-            activeTab === 'plugs'
-              ? 'text-bambu-green border-bambu-green'
-              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
-          }`}
-        >
-          <Plug className="w-4 h-4 shrink-0" />
-          {t('settings.tabs.smartPlugs')}
-          {smartPlugs && smartPlugs.length > 0 && (
-            <span className="text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full shrink-0">
-              {smartPlugs.length}
-            </span>
-          )}
-        </button>
-        <button
-          onClick={() => handleTabChange('sensors')}
-          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
-            activeTab === 'sensors'
-              ? 'text-bambu-green border-bambu-green'
-              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
-          }`}
-        >
-          <Gauge className="w-4 h-4 shrink-0" />
-          {t('settings.tabs.sensors')}
-          {(haSensors?.length ?? 0) + (locationHaSensors?.length ?? 0) > 0 && (
-            <span className="text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full shrink-0">
-              {(haSensors?.length ?? 0) + (locationHaSensors?.length ?? 0)}
-            </span>
-          )}
-        </button>
-        <button
-          onClick={() => handleTabChange('notifications')}
-          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
-            activeTab === 'notifications'
-              ? 'text-bambu-green border-bambu-green'
-              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
-          }`}
-        >
-          <Bell className="w-4 h-4 shrink-0" />
-          {t('settings.tabs.notifications')}
-          {notificationProviders && notificationProviders.length > 0 && (
-            <span className="text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full shrink-0">
-              {notificationProviders.length}
-            </span>
-          )}
-        </button>
-        <button
-          onClick={() => handleTabChange('queue')}
-          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
-            activeTab === 'queue'
-              ? 'text-bambu-green border-bambu-green'
-              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
-          }`}
-        >
-          <ListOrdered className="w-4 h-4 shrink-0" />
-          {t('settings.tabs.queue', 'Workflow')}
-        </button>
-        <button
-          onClick={() => handleTabChange('filament')}
-          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
-            activeTab === 'filament'
-              ? 'text-bambu-green border-bambu-green'
-              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
-          }`}
-        >
-          <Cylinder className="w-4 h-4 shrink-0" />
-          {t('settings.tabs.filament')}
-        </button>
-        <button
-          onClick={() => handleTabChange('network')}
-          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
-            activeTab === 'network'
-              ? 'text-bambu-green border-bambu-green'
-              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
-          }`}
-        >
-          <Wifi className="w-4 h-4 shrink-0" />
-          {t('settings.tabs.network')}
-          <span className={`w-2 h-2 rounded-full shrink-0 ${mqttStatus?.enabled ? 'bg-green-400' : 'bg-gray-500'}`} />
-        </button>
-        <button
-          onClick={() => handleTabChange('apikeys')}
-          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
-            activeTab === 'apikeys'
-              ? 'text-bambu-green border-bambu-green'
-              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
-          }`}
-        >
-          <Key className="w-4 h-4 shrink-0" />
-          {t('settings.tabs.apiKeys')}
-          {apiKeys && apiKeys.length > 0 && (
-            <span className="text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full shrink-0">
-              {apiKeys.length}
-            </span>
-          )}
-        </button>
-        <button
-          onClick={() => handleTabChange('virtual-printer')}
-          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
-            activeTab === 'virtual-printer'
-              ? 'text-bambu-green border-bambu-green'
-              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
-          }`}
-        >
-          <Printer className="w-4 h-4 shrink-0" />
-          {t('settings.tabs.virtualPrinter')}
-          <span className={`w-2 h-2 rounded-full shrink-0 ${virtualPrinterRunning ? 'bg-green-400' : 'bg-gray-500'}`} />
-        </button>
-        <button
-          onClick={() => handleTabChange('spoolbuddy')}
-          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
-            activeTab === 'spoolbuddy'
-              ? 'text-bambu-green border-bambu-green'
-              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
-          }`}
-        >
-          <Scale className="w-4 h-4 shrink-0" />
-          {t('settings.tabs.spoolbuddy')}
-          {spoolbuddyDeviceCount > 0 && (
-            <span className="text-xs bg-bambu-dark-tertiary px-1.5 py-0.5 rounded-full shrink-0">
-              {spoolbuddyDeviceCount}
-            </span>
-          )}
-          <span className={`w-2 h-2 rounded-full shrink-0 ${spoolbuddyAnyOnline ? 'bg-green-400' : 'bg-gray-500'}`} />
-        </button>
-        <button
-          onClick={() => handleTabChange('failure-detection')}
-          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
-            activeTab === 'failure-detection'
-              ? 'text-bambu-green border-bambu-green'
-              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
-          }`}
-        >
-          <ScanEye className="w-4 h-4 shrink-0" />
-          {t('settings.tabs.failureDetection')}
-          <span className={`w-2 h-2 rounded-full shrink-0 ${obicoActive ? 'bg-green-400' : 'bg-gray-500'}`} />
-        </button>
-        <button
-          onClick={() => handleTabChange('users')}
-          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
-            activeTab === 'users'
-              ? 'text-bambu-green border-bambu-green'
-              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
-          }`}
-        >
-          <Users className="w-4 h-4 shrink-0" />
-          {t('settings.tabs.users')}
-          {authEnabled && (
-            <span className="w-2 h-2 rounded-full shrink-0 bg-green-400" />
-          )}
-        </button>
-        <button
-          onClick={() => handleTabChange('backup')}
-          className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
-            activeTab === 'backup'
-              ? 'text-bambu-green border-bambu-green'
-              : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
-          }`}
-        >
-          <Database className="w-4 h-4 shrink-0" />
-          {t('settings.tabs.backup')}
-          <span className={`w-2 h-2 rounded-full shrink-0 ${(cloudAuthStatus?.is_authenticated && githubBackupStatus?.configured && githubBackupStatus?.enabled) || settings?.local_backup_enabled ? 'bg-green-400' : 'bg-gray-500'}`} />
-        </button>
+        {[generalTab, ...otherTabs].map(({ tab, icon: Icon, label, extra }) => (
+          <button
+            key={tab}
+            onClick={() => handleTabChange(tab)}
+            className={`px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px lg:border-b-0 lg:border-l-2 lg:-ml-px lg:mb-0 lg:justify-start flex items-center gap-2 ${
+              activeTab === tab
+                ? 'text-bambu-green border-bambu-green'
+                : 'text-bambu-gray hover:text-gray-900 dark:hover:text-white border-transparent'
+            }`}
+          >
+            <Icon className="w-4 h-4 shrink-0" />
+            {label}
+            {extra}
+          </button>
+        ))}
       </nav>
       </nav>
       <div className="flex-1 min-w-0">
       <div className="flex-1 min-w-0">
       {activeTab === 'general' && (
       {activeTab === 'general' && (
@@ -2256,149 +2142,8 @@ export function SettingsPage() {
 
 
         </div>
         </div>
 
 
-        {/* Second Column - Camera, Cost, AMS & Spoolman */}
+        {/* Second Column - Cost, AMS & Spoolman */}
         <div className="space-y-3 flex-1 lg:max-w-md">
         <div className="space-y-3 flex-1 lg:max-w-md">
-          {/* Camera Settings */}
-          <Card id="card-camera">
-            <CardHeader>
-              <h2 className="text-lg font-semibold text-white flex items-center gap-2">
-                <Video className="w-5 h-5 text-bambu-green" />
-                {t('settings.camera')}
-              </h2>
-            </CardHeader>
-            <CardContent className="space-y-3">
-              {/* Camera View Mode used to live here. It is now the split
-                  camera button on each printer card, where the choice is made
-                  per stream instead of once for the whole install. The stored
-                  setting stays as the default a fresh browser starts from. */}
-
-              {/* External Cameras Section */}
-              <div>
-                <h3 className="text-sm font-medium text-white mb-2">{t('settings.externalCameras')}</h3>
-                <p className="text-xs text-bambu-gray mb-3">
-                  {t('settings.externalCamerasDescription')}
-                </p>
-
-                {printers && printers.length > 0 ? (
-                  <div className="space-y-3">
-                    {printers.map(printer => (
-                      <div key={printer.id} className="p-3 bg-bambu-dark rounded-lg">
-                        <div className="flex items-center justify-between mb-2">
-                          <span className="text-white font-medium text-sm">{printer.name}</span>
-                          <label className="relative inline-flex items-center cursor-pointer">
-                            <input
-                              type="checkbox"
-                              checked={printer.external_camera_enabled}
-                              onChange={(e) => handleUpdatePrinterCamera(printer.id, { enabled: e.target.checked })}
-                              className="sr-only peer"
-                            />
-                            <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
-                          </label>
-                        </div>
-
-                        {printer.external_camera_enabled && (
-                          <div className="space-y-2 mt-2">
-                            <input
-                              type="text"
-                              placeholder={printer.external_camera_type === 'usb' ? t('settings.cameraPlaceholderUsb') : t('settings.cameraPlaceholderUrl')}
-                              value={localCameraUrls[printer.id] ?? printer.external_camera_url ?? ''}
-                              onChange={(e) => handleCameraUrlChange(printer.id, e.target.value)}
-                              className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
-                            />
-                            <div className="flex gap-2">
-                              <select
-                                value={printer.external_camera_type || 'mjpeg'}
-                                onChange={(e) => handleUpdatePrinterCamera(printer.id, { type: e.target.value })}
-                                className="flex-1 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
-                              >
-                                <option value="mjpeg">{t('settings.cameraTypeMjpeg')}</option>
-                                <option value="rtsp">{t('settings.cameraTypeRtsp')}</option>
-                                <option value="snapshot">{t('settings.cameraTypeSnapshot')}</option>
-                                <option value="usb">{t('settings.cameraTypeUsb')}</option>
-                              </select>
-                              <Button
-                                size="sm"
-                                variant="secondary"
-                                onClick={() => handleTestExternalCamera(printer.id, localCameraUrls[printer.id] ?? printer.external_camera_url ?? '', printer.external_camera_type || 'mjpeg')}
-                                disabled={extCameraTestLoading[printer.id] || !(localCameraUrls[printer.id] ?? printer.external_camera_url)}
-                              >
-                                {extCameraTestLoading[printer.id] ? (
-                                  <Loader2 className="w-4 h-4 animate-spin" />
-                                ) : (
-                                  t('settings.test')
-                                )}
-                              </Button>
-                            </div>
-                            {extCameraTestResults[printer.id] && (
-                              <div className={`text-xs flex items-center gap-1 ${extCameraTestResults[printer.id]?.success ? 'text-green-500' : 'text-red-500'}`}>
-                                {extCameraTestResults[printer.id]?.success ? (
-                                  <>
-                                    <CheckCircle className="w-3 h-3" />
-                                    {t('settings.connected')}{extCameraTestResults[printer.id]?.resolution && ` (${extCameraTestResults[printer.id]?.resolution})`}
-                                  </>
-                                ) : (
-                                  <>
-                                    <XCircle className="w-3 h-3" />
-                                    {extCameraTestResults[printer.id]?.error || t('settings.toast.connectionFailed')}
-                                  </>
-                                )}
-                              </div>
-                            )}
-                            {(printer.external_camera_type === 'mjpeg' || printer.external_camera_type === 'rtsp' || printer.external_camera_type === 'usb') && (
-                              <div className="space-y-1">
-                                <label className="text-xs text-bambu-gray">{t('settings.cameraSnapshotUrl', 'Snapshot URL (optional)')}</label>
-                                <div className="flex gap-2">
-                                  <input
-                                    type="text"
-                                    placeholder={t('settings.cameraSnapshotUrlPlaceholder', 'http://192.168.1.61:1984/api/frame.jpeg?src=printer')}
-                                    value={localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url ?? ''}
-                                    onChange={(e) => handleSnapshotUrlChange(printer.id, e.target.value)}
-                                    className="flex-1 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
-                                  />
-                                  <Button
-                                    size="sm"
-                                    variant="secondary"
-                                    onClick={() => handleTestExternalCamera(printer.id, localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url ?? '', 'snapshot')}
-                                    disabled={extCameraTestLoading[printer.id] || !(localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url)}
-                                  >
-                                    {extCameraTestLoading[printer.id] ? (
-                                      <Loader2 className="w-4 h-4 animate-spin" />
-                                    ) : (
-                                      t('settings.test')
-                                    )}
-                                  </Button>
-                                </div>
-                                <p className="text-xs text-bambu-gray opacity-75">
-                                  {t('settings.cameraSnapshotUrlHelp', 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.')}
-                                </p>
-                              </div>
-                            )}
-                            <div className="flex items-center gap-2">
-                              <label className="text-xs text-bambu-gray">{t('settings.cameraRotation')}</label>
-                              <select
-                                value={printer.camera_rotation || 0}
-                                onChange={(e) => handleUpdatePrinterCamera(printer.id, { rotation: parseInt(e.target.value) })}
-                                className="px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs focus:border-bambu-green focus:outline-none"
-                              >
-                                <option value={0}>0°</option>
-                                <option value={90}>90°</option>
-                                <option value={180}>180°</option>
-                                <option value={270}>270°</option>
-                              </select>
-                            </div>
-                          </div>
-                        )}
-                      </div>
-                    ))}
-                  </div>
-                ) : (
-                  <p className="text-xs text-bambu-gray italic">{t('settings.noPrintersConfigured')}</p>
-                )}
-              </div>
-            </CardContent>
-          </Card>
-
-
           <Card id="card-cost">
           <Card id="card-cost">
             <CardHeader>
             <CardHeader>
               <h2 className="text-lg font-semibold text-white">{t('settings.costTracking')}</h2>
               <h2 className="text-lg font-semibold text-white">{t('settings.costTracking')}</h2>
@@ -4443,14 +4188,190 @@ export function SettingsPage() {
       )}
       )}
 
 
       {/* API Keys Tab */}
       {/* API Keys Tab */}
+      {activeTab === 'camera' && (
+        <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
+          {/* Everything about cameras in one place: external cameras, the
+              long-lived tokens other tools use to reach a stream, and the
+              streaming overlay built from those tokens. */}
+          <div className="min-w-0 space-y-6">
+            <Card id="card-camera">
+              <CardHeader>
+                <h2 className="text-lg font-semibold text-white flex items-center gap-2">
+                  <Video className="w-5 h-5 text-bambu-green" />
+                  {t('settings.camera')}
+                </h2>
+              </CardHeader>
+              <CardContent className="space-y-3">
+                {/* Camera View Mode used to live here. It is now the split
+                    camera button on each printer card, where the choice is made
+                    per stream instead of once for the whole install. The stored
+                    setting stays as the default a fresh browser starts from. */}
+
+                {/* External Cameras Section */}
+                <div>
+                  <h3 className="text-sm font-medium text-white mb-2">{t('settings.externalCameras')}</h3>
+                  <p className="text-xs text-bambu-gray mb-3">
+                    {t('settings.externalCamerasDescription')}
+                  </p>
+
+                  {printers && printers.length > 0 ? (
+                    <div className="space-y-3">
+                      {printers.map(printer => (
+                        <div key={printer.id} className="p-3 bg-bambu-dark rounded-lg">
+                          <div className="flex items-center justify-between mb-2">
+                            <span className="text-white font-medium text-sm">{printer.name}</span>
+                            <label className="relative inline-flex items-center cursor-pointer">
+                              <input
+                                type="checkbox"
+                                checked={printer.external_camera_enabled}
+                                onChange={(e) => handleUpdatePrinterCamera(printer.id, { enabled: e.target.checked })}
+                                className="sr-only peer"
+                              />
+                              <div className="w-9 h-5 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-bambu-green"></div>
+                            </label>
+                          </div>
+
+                          {printer.external_camera_enabled && (
+                            <div className="space-y-2 mt-2">
+                              <input
+                                type="text"
+                                placeholder={printer.external_camera_type === 'usb' ? t('settings.cameraPlaceholderUsb') : t('settings.cameraPlaceholderUrl')}
+                                value={localCameraUrls[printer.id] ?? printer.external_camera_url ?? ''}
+                                onChange={(e) => handleCameraUrlChange(printer.id, e.target.value)}
+                                className="w-full px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
+                              />
+                              <div className="flex gap-2">
+                                <select
+                                  value={printer.external_camera_type || 'mjpeg'}
+                                  onChange={(e) => handleUpdatePrinterCamera(printer.id, { type: e.target.value })}
+                                  className="flex-1 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
+                                >
+                                  <option value="mjpeg">{t('settings.cameraTypeMjpeg')}</option>
+                                  <option value="rtsp">{t('settings.cameraTypeRtsp')}</option>
+                                  <option value="snapshot">{t('settings.cameraTypeSnapshot')}</option>
+                                  <option value="usb">{t('settings.cameraTypeUsb')}</option>
+                                </select>
+                                <Button
+                                  size="sm"
+                                  variant="secondary"
+                                  onClick={() => handleTestExternalCamera(printer.id, localCameraUrls[printer.id] ?? printer.external_camera_url ?? '', printer.external_camera_type || 'mjpeg')}
+                                  disabled={extCameraTestLoading[printer.id] || !(localCameraUrls[printer.id] ?? printer.external_camera_url)}
+                                >
+                                  {extCameraTestLoading[printer.id] ? (
+                                    <Loader2 className="w-4 h-4 animate-spin" />
+                                  ) : (
+                                    t('settings.test')
+                                  )}
+                                </Button>
+                              </div>
+                              {extCameraTestResults[printer.id] && (
+                                <div className={`text-xs flex items-center gap-1 ${extCameraTestResults[printer.id]?.success ? 'text-green-500' : 'text-red-500'}`}>
+                                  {extCameraTestResults[printer.id]?.success ? (
+                                    <>
+                                      <CheckCircle className="w-3 h-3" />
+                                      {t('settings.connected')}{extCameraTestResults[printer.id]?.resolution && ` (${extCameraTestResults[printer.id]?.resolution})`}
+                                    </>
+                                  ) : (
+                                    <>
+                                      <XCircle className="w-3 h-3" />
+                                      {extCameraTestResults[printer.id]?.error || t('settings.toast.connectionFailed')}
+                                    </>
+                                  )}
+                                </div>
+                              )}
+                              {(printer.external_camera_type === 'mjpeg' || printer.external_camera_type === 'rtsp' || printer.external_camera_type === 'usb') && (
+                                <div className="space-y-1">
+                                  <label className="text-xs text-bambu-gray">{t('settings.cameraSnapshotUrl', 'Snapshot URL (optional)')}</label>
+                                  <div className="flex gap-2">
+                                    <input
+                                      type="text"
+                                      placeholder={t('settings.cameraSnapshotUrlPlaceholder', 'http://192.168.1.61:1984/api/frame.jpeg?src=printer')}
+                                      value={localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url ?? ''}
+                                      onChange={(e) => handleSnapshotUrlChange(printer.id, e.target.value)}
+                                      className="flex-1 px-3 py-2 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-sm focus:border-bambu-green focus:outline-none"
+                                    />
+                                    <Button
+                                      size="sm"
+                                      variant="secondary"
+                                      onClick={() => handleTestExternalCamera(printer.id, localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url ?? '', 'snapshot')}
+                                      disabled={extCameraTestLoading[printer.id] || !(localSnapshotUrls[printer.id] ?? printer.external_camera_snapshot_url)}
+                                    >
+                                      {extCameraTestLoading[printer.id] ? (
+                                        <Loader2 className="w-4 h-4 animate-spin" />
+                                      ) : (
+                                        t('settings.test')
+                                      )}
+                                    </Button>
+                                  </div>
+                                  <p className="text-xs text-bambu-gray opacity-75">
+                                    {t('settings.cameraSnapshotUrlHelp', 'Single-frame URL used for notification thumbnails, finish photos, timelapse and plate detection. Leave blank to capture from the live stream above. Useful for go2rtc (/api/frame.jpeg) and IP cameras with a dedicated snapshot endpoint.')}
+                                  </p>
+                                </div>
+                              )}
+                              <div className="flex items-center gap-2">
+                                <label className="text-xs text-bambu-gray">{t('settings.cameraRotation')}</label>
+                                <select
+                                  value={printer.camera_rotation || 0}
+                                  onChange={(e) => handleUpdatePrinterCamera(printer.id, { rotation: parseInt(e.target.value) })}
+                                  className="px-2 py-1 bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded text-white text-xs focus:border-bambu-green focus:outline-none"
+                                >
+                                  <option value={0}>0°</option>
+                                  <option value={90}>90°</option>
+                                  <option value={180}>180°</option>
+                                  <option value={270}>270°</option>
+                                </select>
+                              </div>
+                            </div>
+                          )}
+                        </div>
+                      ))}
+                    </div>
+                  ) : (
+                    <p className="text-xs text-bambu-gray italic">{t('settings.noPrintersConfigured')}</p>
+                  )}
+                </div>
+              </CardContent>
+            </Card>
+
+            {/* Long-lived camera-stream tokens (#1108) */}
+            <Card>
+              <CardHeader>
+                <h3 className="text-base font-semibold text-white flex items-center gap-2" id="card-camera-tokens">
+                  <Video className="w-4 h-4 text-bambu-green" />
+                  {t('cameraTokens.title', 'Camera API Tokens')}
+                </h3>
+              </CardHeader>
+              <CardContent>
+                <CameraTokensSection key={cameraTokenRevision} />
+              </CardContent>
+            </Card>
+          </div>
+          <div className="min-w-0">
+            {/* Streaming-overlay URL builder (#1422). Sits beside the camera
+                tokens it usually needs — an overlay for a login-enabled
+                deployment is a token plus a URL, and both are made here. */}
+            <Card>
+              <CardHeader>
+                <h3 className="text-base font-semibold text-white flex items-center gap-2" id="card-stream-overlay">
+                  <MonitorPlay className="w-4 h-4 text-bambu-green" />
+                  {t('streamOverlay.builder.title', 'Streaming Overlay')}
+                </h3>
+              </CardHeader>
+              <CardContent>
+                <StreamOverlayBuilder onTokenCreated={() => setCameraTokenRevision((revision) => revision + 1)} />
+              </CardContent>
+            </Card>
+          </div>
+        </div>
+      )}
+
       {activeTab === 'apikeys' && (
       {activeTab === 'apikeys' && (
         <div className={hasPermission('api_keys:read')
         <div className={hasPermission('api_keys:read')
           ? 'grid grid-cols-1 lg:grid-cols-2 gap-4'
           ? 'grid grid-cols-1 lg:grid-cols-2 gap-4'
           : 'grid grid-cols-1 gap-4'}>
           : 'grid grid-cols-1 gap-4'}>
           {/* Left Column - API Keys Management. Admin-gated content
           {/* Left Column - API Keys Management. Admin-gated content
               (webhook keys, webhook docs) is hidden from users without
               (webhook keys, webhook docs) is hidden from users without
-              api_keys:read; the Camera Tokens panel is always shown so
-              users with camera:view can self-manage their own tokens. */}
+              api_keys:read. Camera tokens moved to the Camera tab. */}
           <div className="min-w-0">
           <div className="min-w-0">
             {hasPermission('api_keys:read') && <>
             {hasPermission('api_keys:read') && <>
             <div className="flex items-start justify-between gap-4 mb-6">
             <div className="flex items-start justify-between gap-4 mb-6">
@@ -4887,40 +4808,11 @@ export function SettingsPage() {
                 </CardContent>
                 </CardContent>
               </Card>
               </Card>
             )}
             )}
-
-            {/* Long-lived camera-stream tokens (#1108) */}
-            <Card className="mt-6">
-              <CardHeader>
-                <h3 className="text-base font-semibold text-white flex items-center gap-2" id="card-camera-tokens">
-                  <Video className="w-4 h-4 text-bambu-green" />
-                  {t('cameraTokens.title', 'Camera API Tokens')}
-                </h3>
-              </CardHeader>
-              <CardContent>
-                <CameraTokensSection key={cameraTokenRevision} />
-              </CardContent>
-            </Card>
-
-            {/* Streaming-overlay URL builder (#1422). Sits under the camera
-                tokens it usually needs — an overlay for a login-enabled
-                deployment is a token plus a URL, and both are made here. */}
-            <Card className="mt-6">
-              <CardHeader>
-                <h3 className="text-base font-semibold text-white flex items-center gap-2" id="card-stream-overlay">
-                  <MonitorPlay className="w-4 h-4 text-bambu-green" />
-                  {t('streamOverlay.builder.title', 'Streaming Overlay')}
-                </h3>
-              </CardHeader>
-              <CardContent>
-                <StreamOverlayBuilder onTokenCreated={() => setCameraTokenRevision((revision) => revision + 1)} />
-              </CardContent>
-            </Card>
           </div>
           </div>
 
 
           {/* Right Column - API Browser. Hidden from users without
           {/* Right Column - API Browser. Hidden from users without
               api_keys:read since the API Browser is the testing surface
               api_keys:read since the API Browser is the testing surface
-              for those keys; non-admins land in this tab only for the
-              Camera Tokens panel and don't need the browser. */}
+              for those keys. */}
           {hasPermission('api_keys:read') && <div className="min-w-0">
           {hasPermission('api_keys:read') && <div className="min-w-0">
             <div className="mb-6">
             <div className="mb-6">
               <h2 className="text-lg font-semibold text-white flex items-center gap-2" id="card-apibrowser">
               <h2 className="text-lg font-semibold text-white flex items-center gap-2" id="card-apibrowser">

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/ImagePreviewModal-pA-fHPw_.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
static/assets/PdfPreviewModal-BBvXON_Z.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/SpreadsheetPreviewModal-DhbzUtWd.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-D1nJM-VU.css


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
static/assets/index-iHfLBieq.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/pdf-CY6NGbLl.js


+ 2 - 2
static/index.html

@@ -26,9 +26,9 @@
 
 
     <!-- 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-BeZKSDWa.js"></script>
+    <script type="module" crossorigin src="/assets/index-iHfLBieq.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
-    <link rel="stylesheet" crossorigin href="/assets/index-CDJwMBhY.css">
+    <link rel="stylesheet" crossorigin href="/assets/index-D1nJM-VU.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

Некоторые файлы не были показаны из-за большого количества измененных файлов