ソースを参照

File Manager: tag filter stays small with a large catalog

- The tag filter rail showed every catalog tag as a chip: 160 tags
  wrapped to about a dozen rows and left the file list a strip.
- Selected tags show first, always. The rest are clamped to two rows,
  with "Show all (N)" opening them in a box that scrolls on its own.
  Whether they overflow is measured, so the toggle shows only when
  tags are hidden, at any width.
- From 12 tags on, a search box narrows the unselected tags by name.
- Chips stay one line high (long names truncate, full name on hover);
  tabbing into a hidden chip expands the list; folding it back
  returns to its first rows.
- Moved into LibraryTagFilterRail; filtering is unchanged.
maziggy 2 日 前
親
コミット
f3942673e2
53 ファイル変更、401 行追加、44 行削除
  1. 1 0
      CHANGELOG.md
  2. 172 0
      frontend/src/__tests__/components/LibraryTagFilterRail.test.tsx
  3. 154 0
      frontend/src/components/LibraryTagFilterRail.tsx
  4. 4 0
      frontend/src/i18n/locales/de.ts
  5. 4 0
      frontend/src/i18n/locales/en.ts
  6. 4 0
      frontend/src/i18n/locales/es.ts
  7. 4 0
      frontend/src/i18n/locales/fr.ts
  8. 4 0
      frontend/src/i18n/locales/it.ts
  9. 4 0
      frontend/src/i18n/locales/ja.ts
  10. 4 0
      frontend/src/i18n/locales/ko.ts
  11. 4 0
      frontend/src/i18n/locales/nl.ts
  12. 4 0
      frontend/src/i18n/locales/pt-BR.ts
  13. 4 0
      frontend/src/i18n/locales/ru.ts
  14. 4 0
      frontend/src/i18n/locales/sv.ts
  15. 4 0
      frontend/src/i18n/locales/tr.ts
  16. 4 0
      frontend/src/i18n/locales/uk.ts
  17. 4 0
      frontend/src/i18n/locales/zh-CN.ts
  18. 4 0
      frontend/src/i18n/locales/zh-TW.ts
  19. 8 38
      frontend/src/pages/FileManagerPage.tsx
  20. 0 0
      static/assets/ArchivesPage-mTKHeZQq.js
  21. 0 0
      static/assets/CamWallPage-Cai8UPc3.js
  22. 0 0
      static/assets/CameraPage-BGQ7T9Tv.js
  23. 1 0
      static/assets/FileManagerPage-Buw4gw8M.js
  24. 0 1
      static/assets/FileManagerPage-CNy0Ah3w.js
  25. 0 0
      static/assets/InventoryPage-DbwRZdxu.js
  26. 0 0
      static/assets/Layout-BXO7eUCC.js
  27. 0 0
      static/assets/ModelSourcesPage-DoLGdK26.js
  28. 0 0
      static/assets/PhotoGalleryModal-DYhQ6T3h.js
  29. 0 0
      static/assets/PrintersPage-R5nke-tf.js
  30. 0 0
      static/assets/SettingsPage-BF3b5S_-.js
  31. 0 0
      static/assets/SliceModal-aY-PMFNg.js
  32. 0 0
      static/assets/SpoolBuddyInventoryPage-BwbO5cRp.js
  33. 1 1
      static/assets/SpoolBuddySettingsPage-BNjpwbdQ.js
  34. 0 0
      static/assets/de-D8_8LLzZ.js
  35. 0 0
      static/assets/es-D6kp4JVT.js
  36. 0 0
      static/assets/fr--yxjoB78.js
  37. 0 0
      static/assets/index-BYqFvcvn.js
  38. 1 0
      static/assets/index-BhH7Qp2X.css
  39. 0 1
      static/assets/index-C8pvVtiW.css
  40. 0 0
      static/assets/it-CNGWV5Il.js
  41. 0 0
      static/assets/ja-Bm6PImW0.js
  42. 0 0
      static/assets/ko-DQMeK92t.js
  43. 0 0
      static/assets/locationSensorDefaults-DPDnv2QV.js
  44. 0 0
      static/assets/nl-AneFS2z1.js
  45. 0 0
      static/assets/pt-BR-BMm-yNOe.js
  46. 0 0
      static/assets/ru-DKnDYDpH.js
  47. 0 0
      static/assets/spoolCost-Bk5Fzy4q.js
  48. 0 0
      static/assets/sv-UgS0VK6m.js
  49. 0 0
      static/assets/tr-BDizLgDB.js
  50. 0 0
      static/assets/uk-D7Tu-IYh.js
  51. 0 0
      static/assets/zh-CN-DnpNjJ0y.js
  52. 0 0
      static/assets/zh-TW-BSsuaVh8.js
  53. 3 3
      static/index.html

+ 1 - 0
CHANGELOG.md

@@ -200,6 +200,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A large tag catalog no longer pushes the File Manager's file list off the page (#3411, reported by @iammenasco)** — The tag filter showed every tag in the catalog as a chip, so 160 tags wrapped to about a dozen rows and left the file list a strip at the bottom. Active tags now always show first. The others take up to two rows, with **Show all (N)** opening them in a box that scrolls on its own. From 12 tags on, **Search tags** narrows them by name. Filtering works as before. Wiki updated.
 - **A printer whose SD card or USB drive refuses the file no longer eats the queue (#3409, reported by @simonkreuchauff-oss)** — When a printer refused an upload with 553 or 552 (storage missing, full or unusable), or rejected the access code, the job failed. The printer was still idle, so the next job went to it and failed the same way: an H2D with an unusable USB stick put seven jobs in History within an hour.
   - **The job stays in the queue:** these refusals now work like a printer whose file service doesn't answer (#3210). The printer gets no jobs for five minutes, then the wait doubles with each refusal in a row, up to an hour, and the job is sent again when the wait is over. A job assigned to that printer shows what to check: "refused to store the file — check its SD card or USB drive", or its access code on the Printers page.
   - **"Any <model>" jobs move on:** a job for any printer of a model now goes back to the pool when its printer refuses the file, so another printer of that model can take it. This also applies to the refusals #3210 already kept in the queue. While it waits, its row names the refusing printer and why, instead of "Busy".

+ 172 - 0
frontend/src/__tests__/components/LibraryTagFilterRail.test.tsx

@@ -0,0 +1,172 @@
+/**
+ * The File Manager's tag filter with a large catalog (#3411).
+ *
+ * 160 tags wrapped to a dozen rows of chips and left the file list a strip at
+ * the bottom of the page. jsdom does no layout, so "the two rows overflow" is
+ * stubbed through scrollHeight/clientHeight on the chip list.
+ */
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+import type { LibraryTag } from '../../api/client';
+import { LibraryTagFilterRail, TAG_SEARCH_MIN_TAGS } from '../../components/LibraryTagFilterRail';
+
+const tag = (id: number, name: string): LibraryTag => ({
+  id,
+  name,
+  file_count: 1,
+  created_at: '2026-01-01',
+  updated_at: '2026-01-01',
+});
+
+const catalog = (n: number) => Array.from({ length: n }, (_, i) => tag(i + 1, `tag-${String(i + 1).padStart(3, '0')}`));
+
+let overflow = false;
+
+beforeEach(() => {
+  overflow = false;
+  vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(function (this: HTMLElement) {
+    return this.dataset.testid === 'tag-filter-list' && overflow ? 200 : 40;
+  });
+  vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 40);
+});
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
+
+function renderRail(tags: LibraryTag[], selectedIds: number[] = []) {
+  const onToggle = vi.fn();
+  const onClear = vi.fn();
+  const view = render(
+    <LibraryTagFilterRail tags={tags} selectedIds={selectedIds} onToggle={onToggle} onClear={onClear} />,
+  );
+  const rerender = (next: LibraryTag[]) =>
+    view.rerender(<LibraryTagFilterRail tags={next} selectedIds={selectedIds} onToggle={onToggle} onClear={onClear} />);
+  return { onToggle, onClear, rerender };
+}
+
+describe('LibraryTagFilterRail', () => {
+  it('a small catalog shows every tag, with no toggle and no search', () => {
+    renderRail(catalog(5));
+
+    expect(screen.getAllByRole('button', { pressed: false })).toHaveLength(5);
+    expect(screen.queryByRole('button', { name: /Show all/ })).toBeNull();
+    expect(screen.queryByRole('searchbox')).toBeNull();
+  });
+
+  it('a large catalog is clamped to two rows until shown in full', async () => {
+    overflow = true;
+    renderRail(catalog(160));
+
+    const list = screen.getByTestId('tag-filter-list');
+    expect(list.className).toContain('max-h-[3.25rem]');
+    expect(list.className).toContain('overflow-hidden');
+
+    await userEvent.click(screen.getByRole('button', { name: 'Show all (160)' }));
+    expect(list.className).toContain('max-h-[40vh]');
+    expect(list.className).toContain('overflow-y-auto');
+
+    await userEvent.click(screen.getByRole('button', { name: 'Show fewer' }));
+    expect(list.className).toContain('max-h-[3.25rem]');
+  });
+
+  it('selected tags show first and outside the clamp', async () => {
+    overflow = true;
+    const tags = catalog(160);
+    const { onToggle, onClear } = renderRail(tags, [150, 3]);
+
+    const list = screen.getByTestId('tag-filter-list');
+    const selected = screen.getAllByRole('button', { pressed: true });
+    expect(selected.map(b => b.textContent)).toEqual(['tag-150', 'tag-003']);
+    for (const chip of selected) expect(list.contains(chip)).toBe(false);
+    expect(screen.getByRole('button', { name: 'Show all (158)' })).toBeTruthy();
+
+    await userEvent.click(selected[0]);
+    expect(onToggle).toHaveBeenCalledWith(150);
+    await userEvent.click(screen.getByRole('button', { name: 'Clear all' }));
+    expect(onClear).toHaveBeenCalled();
+  });
+
+  it('the search box narrows the unselected tags and leaves the selected ones', async () => {
+    const tags = [...catalog(TAG_SEARCH_MIN_TAGS), tag(99, 'PETG-CF')];
+    renderRail(tags, [1]);
+
+    await userEvent.type(screen.getByRole('searchbox', { name: 'Search tags' }), 'petg');
+
+    expect(screen.getAllByRole('button', { pressed: false }).map(b => b.textContent)).toEqual(['PETG-CF']);
+    expect(screen.getAllByRole('button', { pressed: true }).map(b => b.textContent)).toEqual(['tag-001']);
+  });
+
+  it('says so when no tag matches', async () => {
+    renderRail(catalog(TAG_SEARCH_MIN_TAGS));
+
+    await userEvent.type(screen.getByRole('searchbox', { name: 'Search tags' }), 'zzz');
+
+    expect(screen.getByText('No tags match')).toBeTruthy();
+    expect(screen.queryByTestId('tag-filter-list')).toBeNull();
+  });
+
+  it('no search box just below the threshold', () => {
+    renderRail(catalog(TAG_SEARCH_MIN_TAGS - 1));
+    expect(screen.queryByRole('searchbox')).toBeNull();
+  });
+
+  it('a click on an unselected tag selects it', async () => {
+    const { onToggle } = renderRail(catalog(3));
+    await userEvent.click(screen.getByRole('button', { name: 'tag-002' }));
+    expect(onToggle).toHaveBeenCalledWith(2);
+  });
+
+  it('folding the list back shows its first rows, not where it was scrolled to', async () => {
+    overflow = true;
+    renderRail(catalog(160));
+    await userEvent.click(screen.getByRole('button', { name: 'Show all (160)' }));
+    const list = screen.getByTestId('tag-filter-list');
+    list.scrollTop = 600;
+
+    await userEvent.click(screen.getByRole('button', { name: 'Show fewer' }));
+
+    expect(list.scrollTop).toBe(0);
+  });
+
+  it('tabbing into a chip the clamp hides shows them all', () => {
+    overflow = true;
+    vi.spyOn(HTMLElement.prototype, 'offsetTop', 'get').mockImplementation(function (this: HTMLElement) {
+      return this.title === 'tag-150' ? 400 : 0;
+    });
+    vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(() => 22);
+    renderRail(catalog(160));
+
+    act(() => screen.getByRole('button', { name: 'tag-001' }).focus());
+    expect(screen.queryByRole('button', { name: 'Show fewer' })).toBeNull();
+
+    act(() => screen.getByRole('button', { name: 'tag-150' }).focus());
+    expect(screen.getByRole('button', { name: 'Show fewer' })).toBeTruthy();
+  });
+
+  it('a rename that makes the tags overflow brings the toggle', () => {
+    const tags = catalog(30);
+    const { rerender } = renderRail(tags);
+    expect(screen.queryByRole('button', { name: /Show all/ })).toBeNull();
+
+    overflow = true;
+    rerender(tags.map(tg => (tg.id === 7 ? { ...tg, name: 'a much longer name than before' } : tg)));
+
+    expect(screen.getByRole('button', { name: 'Show all (30)' })).toBeTruthy();
+  });
+
+  it('a query left behind when the search box goes away stops filtering', async () => {
+    const tags = catalog(TAG_SEARCH_MIN_TAGS);
+    const { rerender } = renderRail(tags);
+    await userEvent.type(screen.getByRole('searchbox', { name: 'Search tags' }), 'zzz');
+    expect(screen.getByText('No tags match')).toBeTruthy();
+
+    rerender(tags.slice(1));
+
+    expect(screen.queryByRole('searchbox')).toBeNull();
+    expect(screen.getAllByRole('button', { pressed: false })).toHaveLength(TAG_SEARCH_MIN_TAGS - 1);
+  });
+});

+ 154 - 0
frontend/src/components/LibraryTagFilterRail.tsx

@@ -0,0 +1,154 @@
+import { useLayoutEffect, useRef, useState } from 'react';
+import { Search, Tag as TagIcon, X } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+import type { LibraryTag } from '../api/client';
+
+/** From this many tags on, the rail offers a search box. */
+export const TAG_SEARCH_MIN_TAGS = 12;
+
+interface LibraryTagFilterRailProps {
+  /** The whole catalog, in the order to show it. */
+  tags: LibraryTag[];
+  selectedIds: number[];
+  onToggle: (tagId: number) => void;
+  onClear: () => void;
+}
+
+// One line high whatever the name, so the two-row clamp below holds two
+// whole rows on a narrow screen too; the full name is in the title.
+const CHIP =
+  'inline-flex items-center gap-1 max-w-full whitespace-nowrap px-2 py-0.5 rounded-full text-xs border transition-colors';
+
+/**
+ * The File Manager's tag filter (#1268).
+ *
+ * It used to list every catalog tag as a chip, which with 160 tags wrapped to
+ * a dozen rows and left the file list a strip at the bottom (#3411). Selected
+ * tags still always show, first; the rest are clamped to two rows, with a
+ * toggle to show them all in a box of bounded height that scrolls on its own.
+ * Larger catalogs get a search box over the unselected tags.
+ *
+ * Whether the two rows overflow is measured, not counted, so the toggle
+ * appears exactly when tags are hidden, at any window width.
+ */
+export function LibraryTagFilterRail({ tags, selectedIds, onToggle, onClear }: LibraryTagFilterRailProps) {
+  const { t } = useTranslation();
+  const [expanded, setExpanded] = useState(false);
+  const [query, setQuery] = useState('');
+  const [overflowing, setOverflowing] = useState(false);
+  const listRef = useRef<HTMLDivElement>(null);
+
+  const selected = selectedIds.map(id => tags.find(tg => tg.id === id)).filter((tg): tg is LibraryTag => !!tg);
+  const showSearch = tags.length >= TAG_SEARCH_MIN_TAGS;
+  // A query typed before the catalog shrank below the threshold must not
+  // keep filtering with no box left to clear it in.
+  const needle = showSearch ? query.trim().toLowerCase() : '';
+  const unselected = tags.filter(
+    tg => !selectedIds.includes(tg.id) && (!needle || tg.name.toLowerCase().includes(needle)),
+  );
+  // Names too: a rename changes the chips' widths without changing the ids.
+  const unselectedKey = unselected.map(tg => `${tg.id}:${tg.name}`).join('\n');
+
+  useLayoutEffect(() => {
+    const element = listRef.current;
+    if (!element || expanded) return;
+    // An overflow-hidden box keeps its scroll offset: after "Show fewer" the
+    // two rows would show wherever the expanded list was scrolled to.
+    element.scrollTop = 0;
+    const measure = () => setOverflowing(element.scrollHeight > element.clientHeight + 1);
+    measure();
+    // A web font arriving late changes every chip's width, and the clamped
+    // box's own size, which is all the observer sees, stays the same.
+    document.fonts?.ready.then(measure).catch(() => {});
+    if (typeof ResizeObserver === 'undefined') {
+      window.addEventListener('resize', measure);
+      return () => window.removeEventListener('resize', measure);
+    }
+    const observer = new ResizeObserver(measure);
+    observer.observe(element);
+    return () => observer.disconnect();
+  }, [expanded, unselectedKey]);
+
+  return (
+    <div className="mb-3 flex flex-col gap-2 p-2 sm:p-3 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary">
+      <div className="flex flex-wrap items-center gap-2">
+        <span className="text-xs text-bambu-gray font-medium shrink-0">{t('fileManager.tags.filterLabel')}</span>
+        {selected.map(tg => (
+          <button
+            key={tg.id}
+            type="button"
+            onClick={() => onToggle(tg.id)}
+            aria-pressed="true"
+            className={`${CHIP} bg-bambu-green/20 text-bambu-green border-bambu-green/40 hover:bg-bambu-green/30`}
+            title={tg.name}
+          >
+            <TagIcon className="w-3 h-3 shrink-0" />
+            <span className="truncate">{tg.name}</span>
+            <X className="w-3 h-3 shrink-0" />
+          </button>
+        ))}
+        {showSearch && (
+          <div className="relative w-full sm:w-48">
+            <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-bambu-gray" />
+            <input
+              type="search"
+              value={query}
+              onChange={e => setQuery(e.target.value)}
+              placeholder={t('fileManager.tags.searchTags')}
+              aria-label={t('fileManager.tags.searchTags')}
+              className="w-full pl-7 pr-2 py-1 bg-bambu-dark border border-bambu-dark-tertiary rounded text-xs text-white placeholder-bambu-gray focus:outline-none focus:border-bambu-green"
+            />
+          </div>
+        )}
+        {selected.length > 0 && (
+          <button type="button" onClick={onClear} className="ml-auto text-xs text-bambu-gray hover:text-white shrink-0">
+            {t('fileManager.tags.clearAll')}
+          </button>
+        )}
+      </div>
+      {unselected.length > 0 ? (
+        <div
+          ref={listRef}
+          data-testid="tag-filter-list"
+          onFocus={e => {
+            // Tabbing into a chip the clamp hides would scroll the clipped
+            // rows under it out of view: show them all instead.
+            const list = listRef.current;
+            const chip = e.target as HTMLElement;
+            if (!expanded && list && chip.offsetTop + chip.offsetHeight > list.clientHeight) setExpanded(true);
+          }}
+          className={`relative flex flex-wrap items-center gap-2 ${
+            expanded ? 'max-h-[40vh] overflow-y-auto' : 'max-h-[3.25rem] overflow-hidden'
+          }`}
+        >
+          {unselected.map(tg => (
+            <button
+              key={tg.id}
+              type="button"
+              onClick={() => onToggle(tg.id)}
+              aria-pressed="false"
+              className={`${CHIP} bg-bambu-dark text-bambu-gray border-bambu-dark-tertiary hover:text-white hover:border-bambu-green/40`}
+              title={tg.name}
+            >
+              <TagIcon className="w-3 h-3 shrink-0" />
+              <span className="truncate">{tg.name}</span>
+            </button>
+          ))}
+        </div>
+      ) : (
+        needle && <p className="text-xs text-bambu-gray">{t('fileManager.tags.noTagMatches')}</p>
+      )}
+      {(expanded || overflowing) && unselected.length > 0 && (
+        <button
+          type="button"
+          onClick={() => setExpanded(e => !e)}
+          aria-expanded={expanded}
+          className="self-start text-xs text-bambu-green hover:underline"
+        >
+          {expanded ? t('fileManager.tags.showFewerTags') : t('fileManager.tags.showAllTags', { count: unselected.length })}
+        </button>
+      )}
+    </div>
+  );
+}

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

@@ -4326,6 +4326,10 @@ export default {
       noPermission: 'Du hast keine Berechtigung, Dateien zu taggen.',
       filterLabel: 'Filtern nach:',
       clearAll: 'Alle entfernen',
+      searchTags: 'Tags suchen',
+      noTagMatches: 'Keine passenden Tags',
+      showAllTags: 'Alle anzeigen ({{count}})',
+      showFewerTags: 'Weniger anzeigen',
       confirmDelete: 'Tag "{{name}}" löschen?',
       confirmDeleteMessage: 'Entfernt den Tag aus dem Katalog. Dateien behalten ihre übrigen Tags.',
       confirmDeleteInUseMessage: 'Dieser Tag ist auf {{count}} Datei(en). Beim Löschen verschwindet er von allen; die Dateien selbst bleiben unverändert.',

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

@@ -4358,6 +4358,10 @@ export default {
       noPermission: 'You do not have permission to tag files.',
       filterLabel: 'Filtering by:',
       clearAll: 'Clear all',
+      searchTags: 'Search tags',
+      noTagMatches: 'No tags match',
+      showAllTags: 'Show all ({{count}})',
+      showFewerTags: 'Show fewer',
       confirmDelete: 'Delete tag "{{name}}"?',
       confirmDeleteMessage: 'This removes the tag from the catalog. Files keep their other tags.',
       confirmDeleteInUseMessage: 'This tag is on {{count}} file(s). Deleting removes the chip from all of them; files themselves are untouched.',

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

@@ -4328,6 +4328,10 @@ export default {
       noPermission: 'No tienes permiso para etiquetar archivos.',
       filterLabel: 'Filtrando por:',
       clearAll: 'Limpiar todo',
+      searchTags: 'Buscar etiquetas',
+      noTagMatches: 'Ninguna etiqueta coincide',
+      showAllTags: 'Mostrar todas ({{count}})',
+      showFewerTags: 'Mostrar menos',
       confirmDelete: '¿Eliminar la etiqueta "{{name}}"?',
       confirmDeleteMessage: 'Esto elimina la etiqueta del catálogo. Los archivos conservan sus otras etiquetas.',
       confirmDeleteInUseMessage: 'Esta etiqueta está en {{count}} archivo(s). Al eliminarla desaparece de todos; los archivos en sí no se tocan.',

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

@@ -4314,6 +4314,10 @@ export default {
       noPermission: 'Vous n\'avez pas la permission d\'étiqueter les fichiers.',
       filterLabel: 'Filtré par :',
       clearAll: 'Tout effacer',
+      searchTags: 'Rechercher des étiquettes',
+      noTagMatches: 'Aucune étiquette ne correspond',
+      showAllTags: 'Tout afficher ({{count}})',
+      showFewerTags: 'Afficher moins',
       confirmDelete: 'Supprimer l\'étiquette « {{name}} » ?',
       confirmDeleteMessage: 'Cela supprime l\'étiquette du catalogue. Les fichiers conservent leurs autres étiquettes.',
       confirmDeleteInUseMessage: 'Cette étiquette est sur {{count}} fichier(s). La suppression la retire de tous ; les fichiers eux-mêmes ne sont pas touchés.',

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

@@ -4313,6 +4313,10 @@ export default {
       noPermission: 'Non hai il permesso per etichettare i file.',
       filterLabel: 'Filtraggio per:',
       clearAll: 'Pulisci tutto',
+      searchTags: 'Cerca tag',
+      noTagMatches: 'Nessun tag corrisponde',
+      showAllTags: 'Mostra tutti ({{count}})',
+      showFewerTags: 'Mostra meno',
       confirmDelete: 'Eliminare l\'etichetta "{{name}}"?',
       confirmDeleteMessage: 'Rimuove l\'etichetta dal catalogo. I file mantengono le altre etichette.',
       confirmDeleteInUseMessage: 'Questa etichetta è su {{count}} file. L\'eliminazione la rimuove da tutti; i file non vengono toccati.',

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

@@ -4326,6 +4326,10 @@ export default {
       noPermission: 'ファイルにタグを付ける権限がありません。',
       filterLabel: '絞り込み:',
       clearAll: 'すべてクリア',
+      searchTags: 'タグを検索',
+      noTagMatches: '一致するタグがありません',
+      showAllTags: 'すべて表示 ({{count}})',
+      showFewerTags: '表示を減らす',
       confirmDelete: 'タグ「{{name}}」を削除しますか?',
       confirmDeleteMessage: 'カタログからタグを削除します。ファイル自体や他のタグはそのまま残ります。',
       confirmDeleteInUseMessage: 'このタグは{{count}}個のファイルに付いています。削除するとすべてのファイルからチップが消えますが、ファイル自体はそのままです。',

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

@@ -4137,6 +4137,10 @@ export default {
       noPermission: '파일에 태그를 붙일 권한이 없습니다.',
       filterLabel: '필터:',
       clearAll: '모두 지우기',
+      searchTags: '태그 검색',
+      noTagMatches: '일치하는 태그가 없습니다',
+      showAllTags: '모두 보기 ({{count}})',
+      showFewerTags: '간단히 보기',
       confirmDelete: '태그 "{{name}}"을(를) 삭제하시겠습니까?',
       confirmDeleteMessage: '카탈로그에서 태그를 제거합니다. 파일과 다른 태그는 그대로 유지됩니다.',
       confirmDeleteInUseMessage: '이 태그는 {{count}}개 파일에 있습니다. 삭제하면 모든 파일에서 칩이 사라지지만 파일 자체는 변경되지 않습니다.',

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

@@ -4356,6 +4356,10 @@ export default {
       noPermission: 'Je hebt geen toestemming om bestanden te taggen.',
       filterLabel: 'Filteren op:',
       clearAll: 'Alles wissen',
+      searchTags: 'Tags zoeken',
+      noTagMatches: 'Geen tags gevonden',
+      showAllTags: 'Alles tonen ({{count}})',
+      showFewerTags: 'Minder tonen',
       confirmDelete: 'Tag "{{name}}" verwijderen?',
       confirmDeleteMessage: 'Hiermee wordt de tag uit de catalogus verwijderd. Bestanden behouden hun andere tags.',
       confirmDeleteInUseMessage: 'Deze tag staat op {{count}} bestand(en). Bij verwijderen verdwijnt het label van al deze bestanden; de bestanden zelf blijven intact.',

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

@@ -4313,6 +4313,10 @@ export default {
       noPermission: 'Você não tem permissão para marcar arquivos.',
       filterLabel: 'Filtrando por:',
       clearAll: 'Limpar tudo',
+      searchTags: 'Buscar tags',
+      noTagMatches: 'Nenhuma tag corresponde',
+      showAllTags: 'Mostrar todas ({{count}})',
+      showFewerTags: 'Mostrar menos',
       confirmDelete: 'Excluir a tag "{{name}}"?',
       confirmDeleteMessage: 'Isto remove a tag do catálogo. Os arquivos mantêm as outras tags.',
       confirmDeleteInUseMessage: 'Esta tag está em {{count}} arquivo(s). Excluí-la remove o chip de todos; os arquivos em si ficam intactos.',

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

@@ -4129,6 +4129,10 @@ export default {
       noPermission: "У вас нет прав на изменение меток файлов.",
       filterLabel: "Фильтр по меткам:",
       clearAll: "Очистить всё",
+      searchTags: "Поиск меток",
+      noTagMatches: "Нет подходящих меток",
+      showAllTags: "Показать все ({{count}})",
+      showFewerTags: "Показать меньше",
       confirmDelete: "Удалить метку «{{name}}»?",
       confirmDeleteMessage: "Метка будет удалена из каталога. Остальные метки файлов сохранятся.",
       confirmDeleteInUseMessage: "Эта метка назначена файлам ({{count}}). При удалении метка исчезнет у всех файлов; сами файлы не изменятся.",

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

@@ -4355,6 +4355,10 @@ errors: {
       noPermission: 'Du har inte behörighet att tagga filer.',
       filterLabel: 'Filtrerar efter:',
       clearAll: 'Rensa alla',
+      searchTags: 'Sök taggar',
+      noTagMatches: 'Inga taggar matchar',
+      showAllTags: 'Visa alla ({{count}})',
+      showFewerTags: 'Visa färre',
       confirmDelete: 'Ta bort tagg "{{name}}"?',
       confirmDeleteMessage: 'Detta tar bort taggen från katalogen. Filerna behåller sina andra taggar.',
       confirmDeleteInUseMessage: 'Denna tagg används på {{count}} fil(er). Om du tar bort den kommer taggen att tas bort från alla filer; själva filerna påverkas ej.',

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

@@ -4321,6 +4321,10 @@ export default {
       noPermission: 'Dosyaları etiketleme izniniz yok.',
       filterLabel: 'Filtre:',
       clearAll: 'Tümünü temizle',
+      searchTags: 'Etiket ara',
+      noTagMatches: 'Eşleşen etiket yok',
+      showAllTags: 'Tümünü göster ({{count}})',
+      showFewerTags: 'Daha az göster',
       confirmDelete: '"{{name}}" etiketi silinsin mi?',
       confirmDeleteMessage: 'Bu, etiketi katalogdan kaldırır. Dosyalar diğer etiketlerini korur.',
       confirmDeleteInUseMessage: 'Bu etiket {{count}} dosyada bulunuyor. Silmek hepsinden çıkarır; dosyaların kendisine dokunulmaz.',

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

@@ -4353,6 +4353,10 @@ export default {
       noPermission: "Ви не маєте дозволу позначати файли тегами.",
       filterLabel: "Фільтрування за:",
       clearAll: "Очистити все",
+      searchTags: "Пошук тегів",
+      noTagMatches: "Немає відповідних тегів",
+      showAllTags: "Показати всі ({{count}})",
+      showFewerTags: "Показати менше",
       confirmDelete: "Видалити тег \"{{name}}\"?",
       confirmDeleteMessage: "Це видалить тег з каталогу. Файли зберігають інші теги.",
       confirmDeleteInUseMessage: "Цей тег призначено файлам ({{count}}). Його буде видалено з усіх файлів, але самі файли залишаться без змін.",

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

@@ -4313,6 +4313,10 @@ export default {
       noPermission: '您没有为文件加标签的权限。',
       filterLabel: '筛选条件:',
       clearAll: '全部清除',
+      searchTags: '搜索标签',
+      noTagMatches: '没有匹配的标签',
+      showAllTags: '显示全部({{count}})',
+      showFewerTags: '收起',
       confirmDelete: '删除标签 "{{name}}"?',
       confirmDeleteMessage: '此操作会从目录中删除该标签。文件保留其它标签。',
       confirmDeleteInUseMessage: '此标签存在于 {{count}} 个文件上。删除后所有文件上的标签都会消失,但文件本身不会动。',

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

@@ -4313,6 +4313,10 @@ export default {
       noPermission: '您沒有為檔案加標籤的權限。',
       filterLabel: '篩選條件:',
       clearAll: '全部清除',
+      searchTags: '搜尋標籤',
+      noTagMatches: '沒有符合的標籤',
+      showAllTags: '顯示全部({{count}})',
+      showFewerTags: '收合',
       confirmDelete: '刪除標籤 "{{name}}"?',
       confirmDeleteMessage: '此動作會從目錄中刪除該標籤。檔案保留其他標籤。',
       confirmDeleteInUseMessage: '此標籤存在於 {{count}} 個檔案上。刪除後所有檔案上的標籤都會消失,但檔案本身不會動。',

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

@@ -70,6 +70,7 @@ import type {
 import { Button } from '../components/Button';
 import { ConfirmModal } from '../components/ConfirmModal';
 import { ContextMenu, type ContextMenuItem } from '../components/ContextMenu';
+import { LibraryTagFilterRail } from '../components/LibraryTagFilterRail';
 import { PrintModal } from '../components/PrintModal';
 import { ModelViewerModal } from '../components/ModelViewerModal';
 import { SliceModal } from '../components/SliceModal';
@@ -3148,46 +3149,15 @@ export function FileManagerPage() {
             via `order-first` and the page itself scrolls. */}
         <div className="flex-1 flex flex-col lg:flex-row min-w-0 min-h-0 gap-4 lg:gap-6">
         <div className="flex-1 flex flex-col min-w-0 min-h-0">
-          {/* Tag filter rail (#1268). Lists every catalog tag as a togglable
-              chip — active chips are filled green and show an X, inactive
-              chips are outlined and toggle ON when clicked. Clicking an active
-              chip removes it from the filter. Hidden entirely when the
+          {/* Tag filter rail (#1268, #3411). Hidden entirely when the
               catalog is empty so brand-new installs don't see a stray rail. */}
           {tagCatalog.length > 0 && (
-            <div className="mb-3 flex flex-wrap items-center gap-2 p-2 sm:p-3 bg-bambu-dark-secondary rounded-lg border border-bambu-dark-tertiary">
-              <span className="text-xs text-bambu-gray font-medium shrink-0">
-                {t('fileManager.tags.filterLabel')}
-              </span>
-              {tagCatalog.map((tg) => {
-                const active = selectedTagIds.includes(tg.id);
-                return (
-                  <button
-                    key={tg.id}
-                    type="button"
-                    onClick={() => toggleTagFilter(tg.id)}
-                    className={
-                      active
-                        ? 'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-bambu-green/20 text-bambu-green border border-bambu-green/40 hover:bg-bambu-green/30 transition-colors'
-                        : 'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-bambu-dark text-bambu-gray border border-bambu-dark-tertiary hover:text-white hover:border-bambu-green/40 transition-colors'
-                    }
-                    title={tg.name}
-                  >
-                    <TagIcon className="w-3 h-3" />
-                    <span>{tg.name}</span>
-                    {active && <X className="w-3 h-3" />}
-                  </button>
-                );
-              })}
-              {selectedTagIds.length > 0 && (
-                <button
-                  type="button"
-                  onClick={() => setSelectedTagIds([])}
-                  className="ml-auto text-xs text-bambu-gray hover:text-white shrink-0"
-                >
-                  {t('fileManager.tags.clearAll')}
-                </button>
-              )}
-            </div>
+            <LibraryTagFilterRail
+              tags={tagCatalog}
+              selectedIds={selectedTagIds}
+              onToggle={toggleTagFilter}
+              onClear={() => setSelectedTagIds([])}
+            />
           )}
           {/* External folder info bar */}
           {selectedFolder?.is_external && (

ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/ArchivesPage-mTKHeZQq.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/CamWallPage-Cai8UPc3.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/CameraPage-BGQ7T9Tv.js


ファイルの差分が大きいため隠しています
+ 1 - 0
static/assets/FileManagerPage-Buw4gw8M.js


ファイルの差分が大きいため隠しています
+ 0 - 1
static/assets/FileManagerPage-CNy0Ah3w.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/InventoryPage-DbwRZdxu.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/Layout-BXO7eUCC.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/ModelSourcesPage-DoLGdK26.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/PhotoGalleryModal-DYhQ6T3h.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/PrintersPage-R5nke-tf.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/SettingsPage-BF3b5S_-.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/SliceModal-aY-PMFNg.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/SpoolBuddyInventoryPage-BwbO5cRp.js


ファイルの差分が大きいため隠しています
+ 1 - 1
static/assets/SpoolBuddySettingsPage-BNjpwbdQ.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/de-D8_8LLzZ.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/es-D6kp4JVT.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/fr--yxjoB78.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/index-BYqFvcvn.js


ファイルの差分が大きいため隠しています
+ 1 - 0
static/assets/index-BhH7Qp2X.css


ファイルの差分が大きいため隠しています
+ 0 - 1
static/assets/index-C8pvVtiW.css


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/it-CNGWV5Il.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/ja-Bm6PImW0.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/ko-DQMeK92t.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/locationSensorDefaults-DPDnv2QV.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/nl-AneFS2z1.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/pt-BR-BMm-yNOe.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/ru-DKnDYDpH.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/spoolCost-Bk5Fzy4q.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/sv-UgS0VK6m.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/tr-BDizLgDB.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/uk-D7Tu-IYh.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/zh-CN-DnpNjJ0y.js


ファイルの差分が大きいため隠しています
+ 0 - 0
static/assets/zh-TW-BSsuaVh8.js


+ 3 - 3
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-CEuljnWb.js"></script>
+    <script type="module" crossorigin src="/assets/index-BYqFvcvn.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-aKtaBQYM.js">
     <link rel="modulepreload" crossorigin href="/assets/client-Wixdakxn.js">
     <link rel="modulepreload" crossorigin href="/assets/react-dom-9hW1swN1.js">
@@ -122,8 +122,8 @@
     <link rel="modulepreload" crossorigin href="/assets/useColorCatalogVersion-me0iWO3m.js">
     <link rel="modulepreload" crossorigin href="/assets/circle-minus-BWJkPGS-.js">
     <link rel="modulepreload" crossorigin href="/assets/ConnectionDiagnostic-DSIiTSOB.js">
-    <link rel="modulepreload" crossorigin href="/assets/Layout-BfiKv1bK.js">
-    <link rel="stylesheet" crossorigin href="/assets/index-C8pvVtiW.css">
+    <link rel="modulepreload" crossorigin href="/assets/Layout-BXO7eUCC.js">
+    <link rel="stylesheet" crossorigin href="/assets/index-BhH7Qp2X.css">
   </head>
   <body>
     <div id="root"></div>

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません