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([]); @@ -268,12 +272,30 @@ {#if $selectionActive} {/* TODO */}} + onEditTags={() => (tagEditorOpen = true)} onAddToPool={openPoolPicker} onDelete={() => (confirmDeleteFiles = true)} /> {/if} +{#if tagEditorOpen} + + + +{/if} + {#if poolPickerOpen} @@ -378,6 +400,17 @@ font-size: 0.95rem; } + /* ---- Tag editor sheet ---- */ + .tag-sheet { + max-height: 80dvh; + } + + .tag-sheet-body { + padding: 0 14px 16px; + overflow-y: auto; + flex: 1; + } + /* ---- Pool picker ---- */ .picker-backdrop { position: fixed; diff --git a/frontend/vite-mock-plugin.ts b/frontend/vite-mock-plugin.ts index e436c2d..f50b565 100644 --- a/frontend/vite-mock-plugin.ts +++ b/frontend/vite-mock-plugin.ts @@ -366,6 +366,40 @@ export function mockApiPlugin(): Plugin { return json(res, 200, getMockFile(id)); } + // POST /files/bulk/common-tags + if (method === 'POST' && path === '/files/bulk/common-tags') { + const body = (await readBody(req)) as { file_ids?: string[] }; + const ids = body.file_ids ?? []; + if (ids.length === 0) return json(res, 200, { common_tag_ids: [], partial_tag_ids: [] }); + const sets = ids.map((fid) => fileTags.get(fid) ?? new Set()); + const allTagIds = new Set(); + sets.forEach((s) => s.forEach((t) => allTagIds.add(t))); + const common: string[] = []; + const partial: string[] = []; + allTagIds.forEach((tid) => { + if (sets.every((s) => s.has(tid))) common.push(tid); + else partial.push(tid); + }); + return json(res, 200, { common_tag_ids: common, partial_tag_ids: partial }); + } + + // POST /files/bulk/tags + if (method === 'POST' && path === '/files/bulk/tags') { + const body = (await readBody(req)) as { file_ids?: string[]; action?: string; tag_ids?: string[] }; + const fileIds = body.file_ids ?? []; + const tagIds = body.tag_ids ?? []; + const action = body.action ?? 'add'; + for (const fid of fileIds) { + if (!fileTags.has(fid)) fileTags.set(fid, new Set()); + const set = fileTags.get(fid)!; + for (const tid of tagIds) { + if (action === 'add') set.add(tid); + else set.delete(tid); + } + } + return json(res, 200, { applied_tag_ids: action === 'add' ? tagIds : [] }); + } + // POST /files/bulk/delete — soft delete (just remove from mock array) if (method === 'POST' && path === '/files/bulk/delete') { const body = (await readBody(req)) as { file_ids?: string[] };