From 38572b1c80a62195e5c08b11a438dbc8a48f942f Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Thu, 11 Jun 2026 21:58:01 +0300 Subject: [PATCH] feat(frontend): add "add to pool" button on the file viewer Extract the bottom-sheet pool picker (load, search, add) into a reusable PoolPicker component and use it both for the grid's bulk selection and from a new button in the file viewer's top bar, which adds the single open file to a chosen pool. While the picker is open the viewer hands it the keyboard so Escape closes the sheet (even from its search) instead of the viewer. Co-Authored-By: Claude Opus 4.8 --- .../src/lib/components/file/FileViewer.svelte | 62 +++++ .../src/lib/components/file/PoolPicker.svelte | 251 ++++++++++++++++++ frontend/src/routes/files/+page.svelte | 163 +----------- 3 files changed, 324 insertions(+), 152 deletions(-) create mode 100644 frontend/src/lib/components/file/PoolPicker.svelte diff --git a/frontend/src/lib/components/file/FileViewer.svelte b/frontend/src/lib/components/file/FileViewer.svelte index a936447..eb0bffe 100644 --- a/frontend/src/lib/components/file/FileViewer.svelte +++ b/frontend/src/lib/components/file/FileViewer.svelte @@ -4,6 +4,7 @@ import { api, ApiError } from '$lib/api/client'; import { authStore } from '$lib/stores/auth'; import TagPicker from '$lib/components/file/TagPicker.svelte'; + import PoolPicker from '$lib/components/file/PoolPicker.svelte'; import type { File, Tag } from '$lib/api/types'; interface Props { @@ -26,6 +27,7 @@ let loading = $state(true); let saving = $state(false); let error = $state(''); + let poolPickerOpen = $state(false); // Tags are loaded lazily — the Tags section sits below a full-viewport // preview, so fetching them on open just hammers the DB for data the user @@ -196,6 +198,17 @@ } function handleKeydown(e: KeyboardEvent) { + // While the pool picker is open it owns the keyboard: Escape closes it + // (even from its search field), and every other key is swallowed so the + // viewer's shortcuts don't fire behind the modal. Typing still works — + // non-Escape keys aren't prevented, only ignored here. + if (poolPickerOpen) { + if (e.key === 'Escape') { + e.preventDefault(); + poolPickerOpen = false; + } + return; + } if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; if (e.ctrlKey || e.metaKey || e.altKey) return; // Letter keys are matched by physical position (e.code) so j/k/e work on any @@ -265,6 +278,32 @@ {file?.original_name ?? ''} + {#if file} + + {/if} @@ -409,6 +448,10 @@ +{#if poolPickerOpen && file} + (poolPickerOpen = false)} /> +{/if} + diff --git a/frontend/src/routes/files/+page.svelte b/frontend/src/routes/files/+page.svelte index ebd7e4e..dc1a5b8 100644 --- a/frontend/src/routes/files/+page.svelte +++ b/frontend/src/routes/files/+page.svelte @@ -15,9 +15,10 @@ import { selectionStore, selectionActive } from '$lib/stores/selection'; import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import BulkTagEditor from '$lib/components/file/BulkTagEditor.svelte'; + import PoolPicker from '$lib/components/file/PoolPicker.svelte'; import { tick, flushSync } from 'svelte'; import { parseDslFilter } from '$lib/utils/dsl'; - import type { File, FileCursorPage, Pool, PoolOffsetPage } from '$lib/api/types'; + import type { File, FileCursorPage } from '$lib/api/types'; import { appSettings } from '$lib/stores/appSettings'; // What the section cache stores for the Files grid. `resetKey` guards against @@ -207,44 +208,14 @@ } // ---- Add to pool picker ---- + // The picker itself (load, search, add) lives in PoolPicker; here we just + // gate it open and clear the selection once files land in a pool. let poolPickerOpen = $state(false); - let pools = $state([]); - let poolsLoading = $state(false); - let poolPickerSearch = $state(''); - let poolPickerError = $state(''); - async function openPoolPicker() { + function openPoolPicker() { poolPickerOpen = true; - poolPickerError = ''; - poolsLoading = true; - poolPickerSearch = ''; - try { - const res = await api.get('/pools?limit=200&sort=name&order=asc'); - pools = res.items ?? []; - } catch { - poolPickerError = 'Failed to load pools'; - } finally { - poolsLoading = false; - } } - async function addToPool(poolId: string) { - const ids = [...$selectionStore.ids]; - poolPickerOpen = false; - selectionStore.exit(); - try { - await api.post(`/pools/${poolId}/files`, { file_ids: ids }); - } catch { - // silently ignore - } - } - - let filteredPools = $derived( - poolPickerSearch.trim() - ? pools.filter((p) => p.name?.toLowerCase().includes(poolPickerSearch.toLowerCase())) - : pools - ); - function handleUploaded(file: File) { files = [file, ...files]; } @@ -904,52 +875,11 @@ {/if} {#if poolPickerOpen} - - - + selectionStore.exit()} + onClose={() => (poolPickerOpen = false)} + /> {/if} {#if confirmDeleteFiles} @@ -1035,7 +965,7 @@ flex: 1; } - /* ---- Pool picker ---- */ + /* ---- Bottom-sheet shell (shared by the tag editor sheet) ---- */ .picker-backdrop { position: fixed; inset: 0; @@ -1095,75 +1025,4 @@ .picker-close:hover { color: var(--color-text-primary); } - - .picker-search-wrap { - padding: 0 14px 10px; - } - - .picker-search { - width: 100%; - box-sizing: border-box; - height: 34px; - padding: 0 10px; - border-radius: 8px; - border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent); - background-color: var(--color-bg-elevated); - color: var(--color-text-primary); - font-size: 0.9rem; - font-family: inherit; - outline: none; - } - - .picker-search:focus { - border-color: var(--color-accent); - } - - .picker-list { - list-style: none; - margin: 0; - padding: 0 8px 12px; - overflow-y: auto; - flex: 1; - } - - .picker-item { - display: flex; - align-items: center; - width: 100%; - text-align: left; - padding: 11px 10px; - border-radius: 8px; - background: none; - border: none; - cursor: pointer; - font-family: inherit; - gap: 8px; - } - - .picker-item:hover { - background-color: color-mix(in srgb, var(--color-accent) 12%, transparent); - } - - .picker-item-name { - flex: 1; - font-size: 0.95rem; - color: var(--color-text-primary); - } - - .picker-item-count { - font-size: 0.8rem; - color: var(--color-text-muted); - } - - .picker-empty, - .picker-error { - text-align: center; - padding: 20px; - font-size: 0.9rem; - color: var(--color-text-muted); - } - - .picker-error { - color: var(--color-danger); - }