feat(frontend): richer pool multi-select and clearer date label
Give the in-pool file selection the same action set as the files grid. SelectionBar is now driven by props (count + callbacks) instead of reading the selection store directly, with an optional "Remove from pool" action. The pool page uses it to offer Edit tags, Add to pool, Mark reviewed, Remove from pool and Delete (to trash), replacing the previous add/remove-only bar. Also rename the content_datetime label from the photo-centric "Date taken" to "Content date" across the files sort, the file viewer field and the pool sort options (and align the pool's "created" option with the files grid). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -554,7 +554,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<label class="field-label" for="datetime">Date taken</label>
|
<label class="field-label" for="datetime">Content date</label>
|
||||||
<input
|
<input
|
||||||
id="datetime"
|
id="datetime"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
|
|||||||
@@ -1,21 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { selectionStore, selectionCount } from '$lib/stores/selection';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
count: number;
|
||||||
|
onClear: () => void;
|
||||||
onEditTags: () => void;
|
onEditTags: () => void;
|
||||||
onAddToPool: () => void;
|
onAddToPool: () => void;
|
||||||
onMarkReviewed: () => void;
|
onMarkReviewed: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
// Optional: only shown in a pool context (removes the files from that pool
|
||||||
|
// without deleting them). Omitted on the global files list.
|
||||||
|
onRemoveFromPool?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onEditTags, onAddToPool, onMarkReviewed, onDelete }: Props = $props();
|
let {
|
||||||
|
count,
|
||||||
|
onClear,
|
||||||
|
onEditTags,
|
||||||
|
onAddToPool,
|
||||||
|
onMarkReviewed,
|
||||||
|
onDelete,
|
||||||
|
onRemoveFromPool
|
||||||
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="bar" role="toolbar" aria-label="Selection actions">
|
<div class="bar" role="toolbar" aria-label="Selection actions">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<!-- Count / deselect all -->
|
<!-- Count / deselect all -->
|
||||||
<button class="count" onclick={() => selectionStore.exit()} title="Clear selection">
|
<button class="count" onclick={onClear} title="Clear selection">
|
||||||
<span class="num">{$selectionCount}</span>
|
<span class="num">{count}</span>
|
||||||
<span class="label">selected</span>
|
<span class="label">selected</span>
|
||||||
<svg
|
<svg
|
||||||
class="close-icon"
|
class="close-icon"
|
||||||
@@ -39,6 +50,9 @@
|
|||||||
<button class="action edit-tags" onclick={onEditTags}>Edit tags</button>
|
<button class="action edit-tags" onclick={onEditTags}>Edit tags</button>
|
||||||
<button class="action add-pool" onclick={onAddToPool}>Add to pool</button>
|
<button class="action add-pool" onclick={onAddToPool}>Add to pool</button>
|
||||||
<button class="action mark-reviewed" onclick={onMarkReviewed}>Mark reviewed</button>
|
<button class="action mark-reviewed" onclick={onMarkReviewed}>Mark reviewed</button>
|
||||||
|
{#if onRemoveFromPool}
|
||||||
|
<button class="action remove-pool" onclick={onRemoveFromPool}>Remove from pool</button>
|
||||||
|
{/if}
|
||||||
<button class="action delete" onclick={onDelete}>Delete</button>
|
<button class="action delete" onclick={onDelete}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,6 +86,7 @@
|
|||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +139,7 @@
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-tags {
|
.edit-tags {
|
||||||
@@ -150,6 +166,15 @@
|
|||||||
background-color: color-mix(in srgb, var(--color-success) 15%, transparent);
|
background-color: color-mix(in srgb, var(--color-success) 15%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.remove-pool {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-pool:hover {
|
||||||
|
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.delete {
|
.delete {
|
||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
import SelectionBar from '$lib/components/layout/SelectionBar.svelte';
|
import SelectionBar from '$lib/components/layout/SelectionBar.svelte';
|
||||||
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
||||||
import { fileSorting, type FileSortField } from '$lib/stores/sorting';
|
import { fileSorting, type FileSortField } from '$lib/stores/sorting';
|
||||||
import { selectionStore, selectionActive } from '$lib/stores/selection';
|
import { selectionStore, selectionActive, selectionCount } 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 PoolPicker from '$lib/components/file/PoolPicker.svelte';
|
||||||
@@ -784,6 +784,8 @@
|
|||||||
|
|
||||||
{#if $selectionActive}
|
{#if $selectionActive}
|
||||||
<SelectionBar
|
<SelectionBar
|
||||||
|
count={$selectionCount}
|
||||||
|
onClear={() => selectionStore.exit()}
|
||||||
onEditTags={openTagEditor}
|
onEditTags={openTagEditor}
|
||||||
onAddToPool={openPoolPicker}
|
onAddToPool={openPoolPicker}
|
||||||
onMarkReviewed={markSelectionReviewed}
|
onMarkReviewed={markSelectionReviewed}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
import FileViewer from '$lib/components/file/FileViewer.svelte';
|
import FileViewer from '$lib/components/file/FileViewer.svelte';
|
||||||
import FilterBar from '$lib/components/file/FilterBar.svelte';
|
import FilterBar from '$lib/components/file/FilterBar.svelte';
|
||||||
import PoolPicker from '$lib/components/file/PoolPicker.svelte';
|
import PoolPicker from '$lib/components/file/PoolPicker.svelte';
|
||||||
|
import BulkTagEditor from '$lib/components/file/BulkTagEditor.svelte';
|
||||||
|
import SelectionBar from '$lib/components/layout/SelectionBar.svelte';
|
||||||
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
|
||||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||||
import { parseDslFilter } from '$lib/utils/dsl';
|
import { parseDslFilter } from '$lib/utils/dsl';
|
||||||
@@ -44,11 +46,13 @@
|
|||||||
let filterOpen = $state(false);
|
let filterOpen = $state(false);
|
||||||
let activeTokens = $derived(parseDslFilter(filterParam));
|
let activeTokens = $derived(parseDslFilter(filterParam));
|
||||||
|
|
||||||
// ---- Selection (for removal) ----
|
// ---- Selection + bulk actions (mirrors the files page) ----
|
||||||
let selectedIds = $state(new Set<string>());
|
let selectedIds = $state(new Set<string>());
|
||||||
let selectionMode = $derived(selectedIds.size > 0);
|
let selectionMode = $derived(selectedIds.size > 0);
|
||||||
let lastSelectedIdx = $state<number | null>(null);
|
let lastSelectedIdx = $state<number | null>(null);
|
||||||
let confirmRemove = $state(false);
|
let confirmRemove = $state(false);
|
||||||
|
let confirmDeleteFiles = $state(false);
|
||||||
|
let tagEditorOpen = $state(false);
|
||||||
let poolPickerOpen = $state(false);
|
let poolPickerOpen = $state(false);
|
||||||
|
|
||||||
// ---- Add files mode ----
|
// ---- Add files mode ----
|
||||||
@@ -238,10 +242,20 @@
|
|||||||
lastSelectedIdx = idx;
|
lastSelectedIdx = idx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
selectedIds = new Set();
|
||||||
|
lastSelectedIdx = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTagEditor() {
|
||||||
|
tagEditorOpen = true;
|
||||||
|
void tick().then(() => document.querySelector<HTMLInputElement>('.tag-sheet input')?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
async function removeSelected() {
|
async function removeSelected() {
|
||||||
confirmRemove = false;
|
confirmRemove = false;
|
||||||
const ids = [...selectedIds];
|
const ids = [...selectedIds];
|
||||||
selectedIds = new Set();
|
clearSelection();
|
||||||
try {
|
try {
|
||||||
await api.post(`/pools/${poolId}/files/remove`, { file_ids: ids });
|
await api.post(`/pools/${poolId}/files/remove`, { file_ids: ids });
|
||||||
files = files.filter((f) => !ids.includes(f.id ?? ''));
|
files = files.filter((f) => !ids.includes(f.id ?? ''));
|
||||||
@@ -251,6 +265,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark the selection as review-done; optimistically clear the local badges.
|
||||||
|
async function markSelectionReviewed() {
|
||||||
|
const ids = [...selectedIds];
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
clearSelection();
|
||||||
|
try {
|
||||||
|
await api.post('/files/bulk/review', { file_ids: ids, needs_review: false });
|
||||||
|
files = files.map((f) => (ids.includes(f.id ?? '') ? { ...f, needs_review: false } : f));
|
||||||
|
} catch {
|
||||||
|
// ignore — the list already reflects the intended state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move the selection to trash. Trashed files drop out of the pool view (which
|
||||||
|
// only lists live files); pool membership is kept, so file_count is left to
|
||||||
|
// the server's truth on the next load rather than adjusted optimistically.
|
||||||
|
async function deleteSelected() {
|
||||||
|
confirmDeleteFiles = false;
|
||||||
|
const ids = [...selectedIds];
|
||||||
|
clearSelection();
|
||||||
|
try {
|
||||||
|
await api.post('/files/bulk/delete', { file_ids: ids });
|
||||||
|
files = files.filter((f) => !ids.includes(f.id ?? ''));
|
||||||
|
} catch {
|
||||||
|
// silently ignore — list already updated optimistically
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- File viewer overlay (shallow routing) ----
|
// ---- File viewer overlay (shallow routing) ----
|
||||||
// Open the viewer on top of the still-mounted pool grid so the back button (and
|
// Open the viewer on top of the still-mounted pool grid so the back button (and
|
||||||
// the viewer's own close) returns here — with the pool's list and scroll intact —
|
// the viewer's own close) returns here — with the pool's list and scroll intact —
|
||||||
@@ -629,8 +671,8 @@
|
|||||||
aria-label="Sort files"
|
aria-label="Sort files"
|
||||||
>
|
>
|
||||||
<option value="manual">Manual order</option>
|
<option value="manual">Manual order</option>
|
||||||
<option value="content_datetime">Date</option>
|
<option value="content_datetime">Content date</option>
|
||||||
<option value="created">Date added</option>
|
<option value="created">Created</option>
|
||||||
<option value="original_name">Name</option>
|
<option value="original_name">Name</option>
|
||||||
</select>
|
</select>
|
||||||
{#if sortKey !== 'manual'}
|
{#if sortKey !== 'manual'}
|
||||||
@@ -739,46 +781,61 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Selection bar (remove mode) -->
|
<!-- Selection actions — same set as the files page, plus Remove from pool. -->
|
||||||
{#if selectionMode && !addMode}
|
{#if selectionMode && !addMode}
|
||||||
<div class="selection-bar" role="toolbar">
|
<SelectionBar
|
||||||
<button
|
count={selectedIds.size}
|
||||||
class="sel-cancel"
|
onClear={clearSelection}
|
||||||
onclick={() => {
|
onEditTags={openTagEditor}
|
||||||
selectedIds = new Set();
|
onAddToPool={() => (poolPickerOpen = true)}
|
||||||
lastSelectedIdx = null;
|
onMarkReviewed={markSelectionReviewed}
|
||||||
}}
|
onRemoveFromPool={() => (confirmRemove = true)}
|
||||||
>
|
onDelete={() => (confirmDeleteFiles = true)}
|
||||||
<span class="sel-num">{selectedIds.size}</span>
|
/>
|
||||||
<span class="sel-label">selected</span>
|
{/if}
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
||||||
<path
|
<!-- Bulk tag editor sheet -->
|
||||||
d="M2 2l10 10M12 2L2 12"
|
{#if tagEditorOpen}
|
||||||
stroke="currentColor"
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
stroke-width="1.8"
|
<div class="picker-backdrop" role="presentation" onclick={() => (tagEditorOpen = false)}></div>
|
||||||
stroke-linecap="round"
|
<div class="picker-sheet tag-sheet" role="dialog" aria-label="Edit tags">
|
||||||
/>
|
<div class="picker-header">
|
||||||
</svg>
|
<span class="picker-title"
|
||||||
</button>
|
>Edit tags — {selectedIds.size} file{selectedIds.size !== 1 ? 's' : ''}</span
|
||||||
<div class="sel-spacer"></div>
|
>
|
||||||
<button class="sel-action add-action" onclick={() => (poolPickerOpen = true)}>
|
<button class="picker-close" onclick={() => (tagEditorOpen = false)} aria-label="Close">
|
||||||
Add to pool
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||||
</button>
|
<path
|
||||||
<button class="sel-action remove-action" onclick={() => (confirmRemove = true)}>
|
d="M3 3l10 10M13 3L3 13"
|
||||||
Remove from pool
|
stroke="currentColor"
|
||||||
</button>
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="tag-sheet-body">
|
||||||
|
<BulkTagEditor fileIds={[...selectedIds]} onDone={() => (tagEditorOpen = false)} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if confirmDeleteFiles}
|
||||||
|
<ConfirmDialog
|
||||||
|
message={`Move ${selectedIds.size} file${selectedIds.size !== 1 ? 's' : ''} to trash?`}
|
||||||
|
confirmLabel="Move to trash"
|
||||||
|
danger
|
||||||
|
onConfirm={deleteSelected}
|
||||||
|
onCancel={() => (confirmDeleteFiles = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Add the selected files to another pool (reuses the file picker; the order
|
<!-- Add the selected files to another pool (reuses the file picker; the order
|
||||||
follows the selection's insertion order). -->
|
follows the selection's insertion order). -->
|
||||||
{#if poolPickerOpen}
|
{#if poolPickerOpen}
|
||||||
<PoolPicker
|
<PoolPicker
|
||||||
fileIds={[...selectedIds]}
|
fileIds={[...selectedIds]}
|
||||||
onAdded={() => {
|
onAdded={clearSelection}
|
||||||
selectedIds = new Set();
|
|
||||||
lastSelectedIdx = null;
|
|
||||||
}}
|
|
||||||
onClose={() => (poolPickerOpen = false)}
|
onClose={() => (poolPickerOpen = false)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1222,21 +1279,64 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Selection bar ---- */
|
/* ---- Bulk tag editor sheet (shared shell with the files page) ---- */
|
||||||
.selection-bar {
|
.picker-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 10px;
|
inset: 0;
|
||||||
right: 10px;
|
z-index: 110;
|
||||||
bottom: 65px;
|
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);
|
background-color: var(--color-bg-secondary);
|
||||||
border-radius: 10px;
|
border-radius: 14px 14px 0 0;
|
||||||
box-shadow: 0 0 12px rgba(0, 0, 0, 0.5);
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
padding: 10px 14px;
|
max-height: 70dvh;
|
||||||
z-index: 100;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: slide-up 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-sheet {
|
||||||
|
max-height: 80dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
padding: 14px 16px 10px;
|
||||||
animation: slide-up 0.18s ease-out;
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-sheet-body {
|
||||||
|
padding: 0 14px 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slide-up {
|
@keyframes slide-up {
|
||||||
@@ -1250,62 +1350,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sel-cancel {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px 6px;
|
|
||||||
border-radius: 6px;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
.sel-cancel:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sel-num {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sel-label {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sel-spacer {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sel-action {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 6px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-family: inherit;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-action {
|
|
||||||
color: var(--color-accent);
|
|
||||||
}
|
|
||||||
.add-action:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-accent) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove-action {
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
.remove-action:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-danger) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Add files overlay ---- */
|
/* ---- Add files overlay ---- */
|
||||||
.add-overlay {
|
.add-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
Reference in New Issue
Block a user