feat(frontend): add "add to pool" button on the file viewer
deploy / deploy (push) Successful in 58s

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:58:01 +03:00
parent fedfa8df3a
commit 38572b1c80
3 changed files with 324 additions and 152 deletions
@@ -4,6 +4,7 @@
import { api, ApiError } from '$lib/api/client'; import { api, ApiError } from '$lib/api/client';
import { authStore } from '$lib/stores/auth'; import { authStore } from '$lib/stores/auth';
import TagPicker from '$lib/components/file/TagPicker.svelte'; import TagPicker from '$lib/components/file/TagPicker.svelte';
import PoolPicker from '$lib/components/file/PoolPicker.svelte';
import type { File, Tag } from '$lib/api/types'; import type { File, Tag } from '$lib/api/types';
interface Props { interface Props {
@@ -26,6 +27,7 @@
let loading = $state(true); let loading = $state(true);
let saving = $state(false); let saving = $state(false);
let error = $state(''); let error = $state('');
let poolPickerOpen = $state(false);
// Tags are loaded lazily — the Tags section sits below a full-viewport // 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 // preview, so fetching them on open just hammers the DB for data the user
@@ -196,6 +198,17 @@
} }
function handleKeydown(e: KeyboardEvent) { 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.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.ctrlKey || e.metaKey || e.altKey) 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 // Letter keys are matched by physical position (e.code) so j/k/e work on any
@@ -265,6 +278,32 @@
</svg> </svg>
</button> </button>
<span class="filename">{file?.original_name ?? ''}</span> <span class="filename">{file?.original_name ?? ''}</span>
{#if file}
<button
class="pool-btn"
onclick={() => (poolPickerOpen = true)}
aria-label="Add to pool"
title="Add to pool"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<rect
x="3"
y="5"
width="14"
height="11"
rx="2"
stroke="currentColor"
stroke-width="1.6"
/>
<path
d="M10 8.5v4M8 10.5h4"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
/>
</svg>
</button>
{/if}
</div> </div>
<!-- Preview --> <!-- Preview -->
@@ -409,6 +448,10 @@
</div> </div>
</div> </div>
{#if poolPickerOpen && file}
<PoolPicker fileIds={[file.id!]} onClose={() => (poolPickerOpen = false)} />
{/if}
<style> <style>
.viewer-page { .viewer-page {
display: flex; display: flex;
@@ -457,6 +500,25 @@
white-space: nowrap; white-space: nowrap;
} }
.pool-btn {
margin-left: auto;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
border: none;
background: none;
color: var(--color-text-primary);
cursor: pointer;
flex-shrink: 0;
}
.pool-btn:hover {
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
}
/* ---- Preview ---- */ /* ---- Preview ---- */
.preview-wrap { .preview-wrap {
position: relative; position: relative;
@@ -0,0 +1,251 @@
<script lang="ts">
import { api } from '$lib/api/client';
import type { Pool, PoolOffsetPage } from '$lib/api/types';
interface Props {
/** Files to add to the chosen pool. */
fileIds: string[];
/** Called after a successful add (before close) — e.g. to clear a selection. */
onAdded?: (poolId: string) => void;
/** Close the picker without adding. */
onClose: () => void;
}
let { fileIds, onAdded, onClose }: Props = $props();
let pools = $state<Pool[]>([]);
let loading = $state(true);
let loadError = $state('');
let addError = $state('');
let search = $state('');
let busy = $state(false);
$effect(() => {
void load();
});
async function load() {
loading = true;
loadError = '';
try {
const res = await api.get<PoolOffsetPage>('/pools?limit=200&sort=name&order=asc');
pools = res.items ?? [];
} catch {
loadError = 'Failed to load pools';
} finally {
loading = false;
}
}
let filtered = $derived(
search.trim()
? pools.filter((p) => p.name?.toLowerCase().includes(search.toLowerCase()))
: pools
);
async function add(poolId: string) {
if (busy) return;
busy = true;
addError = '';
try {
await api.post(`/pools/${poolId}/files`, { file_ids: fileIds });
onAdded?.(poolId);
onClose();
} catch {
addError = 'Failed to add to pool';
busy = false;
}
}
let count = $derived(fileIds.length);
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="picker-backdrop" role="presentation" onclick={onClose}></div>
<div class="picker-sheet" class:busy role="dialog" aria-label="Add to pool">
<div class="picker-header">
<span class="picker-title">Add {count} file{count !== 1 ? 's' : ''} to pool</span>
<button class="picker-close" onclick={onClose} aria-label="Close">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M3 3l10 10M13 3L3 13"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
/>
</svg>
</button>
</div>
<div class="picker-search-wrap">
<input
class="picker-search"
type="search"
placeholder="Search pools…"
bind:value={search}
autocomplete="off"
/>
</div>
{#if loading}
<p class="picker-empty">Loading…</p>
{:else if loadError}
<p class="picker-error">{loadError}</p>
{:else}
{#if addError}
<p class="picker-error">{addError}</p>
{/if}
{#if filtered.length === 0}
<p class="picker-empty">No pools found.</p>
{:else}
<ul class="picker-list">
{#each filtered as pool (pool.id)}
<li>
<button class="picker-item" onclick={() => pool.id && add(pool.id)}>
<span class="picker-item-name">{pool.name}</span>
<span class="picker-item-count">{pool.file_count ?? 0} files</span>
</button>
</li>
{/each}
</ul>
{/if}
{/if}
</div>
<style>
.picker-backdrop {
position: fixed;
inset: 0;
z-index: 110;
background: rgba(0, 0, 0, 0.5);
}
.picker-sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 111;
background-color: var(--color-bg-secondary);
border-radius: 14px 14px 0 0;
padding-bottom: env(safe-area-inset-bottom, 0px);
max-height: 70dvh;
display: flex;
flex-direction: column;
animation: slide-up 0.18s ease-out;
}
.picker-sheet.busy {
opacity: 0.6;
pointer-events: none;
}
@keyframes slide-up {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.picker-header {
display: flex;
align-items: center;
padding: 14px 16px 10px;
gap: 8px;
}
.picker-title {
flex: 1;
font-size: 0.95rem;
font-weight: 600;
}
.picker-close {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
padding: 4px;
display: flex;
align-items: center;
}
.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);
}
</style>
+11 -152
View File
@@ -15,9 +15,10 @@
import { selectionStore, selectionActive } from '$lib/stores/selection'; import { selectionStore, selectionActive } from '$lib/stores/selection';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import BulkTagEditor from '$lib/components/file/BulkTagEditor.svelte'; import BulkTagEditor from '$lib/components/file/BulkTagEditor.svelte';
import PoolPicker from '$lib/components/file/PoolPicker.svelte';
import { tick, flushSync } from 'svelte'; import { tick, flushSync } from 'svelte';
import { parseDslFilter } from '$lib/utils/dsl'; 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'; import { appSettings } from '$lib/stores/appSettings';
// What the section cache stores for the Files grid. `resetKey` guards against // What the section cache stores for the Files grid. `resetKey` guards against
@@ -207,44 +208,14 @@
} }
// ---- Add to pool picker ---- // ---- 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 poolPickerOpen = $state(false);
let pools = $state<Pool[]>([]);
let poolsLoading = $state(false);
let poolPickerSearch = $state('');
let poolPickerError = $state('');
async function openPoolPicker() { function openPoolPicker() {
poolPickerOpen = true; poolPickerOpen = true;
poolPickerError = '';
poolsLoading = true;
poolPickerSearch = '';
try {
const res = await api.get<PoolOffsetPage>('/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) { function handleUploaded(file: File) {
files = [file, ...files]; files = [file, ...files];
} }
@@ -904,52 +875,11 @@
{/if} {/if}
{#if poolPickerOpen} {#if poolPickerOpen}
<!-- svelte-ignore a11y_click_events_have_key_events --> <PoolPicker
<div class="picker-backdrop" role="presentation" onclick={() => (poolPickerOpen = false)}></div> fileIds={[...$selectionStore.ids]}
<div class="picker-sheet" role="dialog" aria-label="Add to pool"> onAdded={() => selectionStore.exit()}
<div class="picker-header"> onClose={() => (poolPickerOpen = false)}
<span class="picker-title" />
>Add {$selectionStore.ids.size} file{$selectionStore.ids.size !== 1 ? 's' : ''} to pool</span
>
<button class="picker-close" onclick={() => (poolPickerOpen = false)} aria-label="Close">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M3 3l10 10M13 3L3 13"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
/>
</svg>
</button>
</div>
<div class="picker-search-wrap">
<input
class="picker-search"
type="search"
placeholder="Search pools…"
bind:value={poolPickerSearch}
autocomplete="off"
/>
</div>
{#if poolPickerError}
<p class="picker-error">{poolPickerError}</p>
{:else if poolsLoading}
<p class="picker-empty">Loading…</p>
{:else if filteredPools.length === 0}
<p class="picker-empty">No pools found.</p>
{:else}
<ul class="picker-list">
{#each filteredPools as pool (pool.id)}
<li>
<button class="picker-item" onclick={() => pool.id && addToPool(pool.id)}>
<span class="picker-item-name">{pool.name}</span>
<span class="picker-item-count">{pool.file_count ?? 0} files</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if} {/if}
{#if confirmDeleteFiles} {#if confirmDeleteFiles}
@@ -1035,7 +965,7 @@
flex: 1; flex: 1;
} }
/* ---- Pool picker ---- */ /* ---- Bottom-sheet shell (shared by the tag editor sheet) ---- */
.picker-backdrop { .picker-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -1095,75 +1025,4 @@
.picker-close:hover { .picker-close:hover {
color: var(--color-text-primary); 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);
}
</style> </style>