diff --git a/frontend/src/lib/components/file/BulkTagEditor.svelte b/frontend/src/lib/components/file/BulkTagEditor.svelte
new file mode 100644
index 0000000..a94cf52
--- /dev/null
+++ b/frontend/src/lib/components/file/BulkTagEditor.svelte
@@ -0,0 +1,350 @@
+
+
+
+ {#if loading}
+
Loading…
+ {:else if error}
+
{error}
+ {:else}
+
+ {#if assignedTags.length > 0}
+
+ Assigned
+ — partial tags shown with dashed border, click to apply to all
+
+
+ {#each assignedTags as tag (tag.id)}
+ {@const isPartial = partialIds.has(tag.id ?? '')}
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+
+ {#if search}
+
+ {/if}
+
+
+
+ {#if availableTags.length > 0}
+
Add tag
+
+ {#each availableTags as tag (tag.id)}
+
+ {/each}
+
+ {:else if search.trim() && availableTags.length === 0 && assignedTags.length === 0}
+
No matching tags
+ {/if}
+ {/if}
+
+
+
\ No newline at end of file
diff --git a/frontend/src/routes/files/+page.svelte b/frontend/src/routes/files/+page.svelte
index 05192d6..f410fec 100644
--- a/frontend/src/routes/files/+page.svelte
+++ b/frontend/src/routes/files/+page.svelte
@@ -12,12 +12,16 @@
import { fileSorting, type FileSortField } from '$lib/stores/sorting';
import { selectionStore, selectionActive } from '$lib/stores/selection';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
+ import BulkTagEditor from '$lib/components/file/BulkTagEditor.svelte';
import { parseDslFilter } from '$lib/utils/dsl';
import type { File, FileCursorPage, Pool, PoolOffsetPage } from '$lib/api/types';
let uploader = $state<{ open: () => void } | undefined>();
let confirmDeleteFiles = $state(false);
+ // ---- Bulk tag editor ----
+ let tagEditorOpen = $state(false);
+
// ---- Add to pool picker ----
let poolPickerOpen = $state(false);
let pools = $state