From 96c1e012630a145fefb588220a4581a64efc09a2 Mon Sep 17 00:00:00 2001 From: Dylann Batisse Date: Tue, 24 Mar 2026 19:54:48 +0100 Subject: [PATCH] feat(ui): refine grid toolbar actions --- .../components/grid/GridFiltersPanel.svelte | 286 ++++++++++++++++++ ui/src/components/grid/GridTable.svelte | 6 +- ui/src/components/grid/GridTagPopover.svelte | 12 +- ui/src/components/grid/GridToolbar.svelte | 116 ++++--- ui/src/components/grid/GridView.svelte | 132 +++++++- ui/src/components/layout/Sidebar.svelte | 136 +-------- ui/src/lib/gridFilterOptions.js | 4 + ui/src/lib/gridFilters.js | 141 ++++++++- ui/src/lib/gridStore.js | 54 ++-- ui/src/lib/gridStore.test.js | 113 ++++++- 10 files changed, 744 insertions(+), 256 deletions(-) create mode 100644 ui/src/components/grid/GridFiltersPanel.svelte diff --git a/ui/src/components/grid/GridFiltersPanel.svelte b/ui/src/components/grid/GridFiltersPanel.svelte new file mode 100644 index 0000000..9261257 --- /dev/null +++ b/ui/src/components/grid/GridFiltersPanel.svelte @@ -0,0 +1,286 @@ + + +
+
+ {#if showHeader} +
+
+
Filters
+
+ {activeFiltersCount > 0 + ? `${activeFiltersCount} active filter${activeFiltersCount > 1 ? 's' : ''}` + : 'Browse instances by facet'} +
+
+ {#if activeFiltersCount > 0} + + {/if} +
+ {/if} + +
+
+ + + {#if sections.status} +
+ {#each $stateFilterEntries as entry (entry.value)} + {@const option = getStateMeta(entry.value)} + {@const Icon = iconMap[option.icon]} + + {/each} +
+ {/if} +
+ +
+ + + {#if sections.tags} +
+
+ + +
+ +
+ {#if $tagFilterEntries.length === 0} +
No tags found.
+ {:else} + {#each $tagFilterEntries as entry (entry.value)} + + {/each} + {/if} +
+
+ {/if} +
+ +
+ + + {#if sections.trackers} +
+
+ + +
+ +
+ {#if $trackerFilterEntries.length === 0} +
No trackers found.
+ {:else} + {#each $trackerFilterEntries as entry (entry.value)} + + {/each} + {/if} +
+
+ {/if} +
+
+
+
diff --git a/ui/src/components/grid/GridTable.svelte b/ui/src/components/grid/GridTable.svelte index 9bf669a..79944e6 100644 --- a/ui/src/components/grid/GridTable.svelte +++ b/ui/src/components/grid/GridTable.svelte @@ -319,12 +319,12 @@ onscroll={onScroll} > - - + + {#each columns as col (col.id)}
- diff --git a/ui/src/components/grid/GridToolbar.svelte b/ui/src/components/grid/GridToolbar.svelte index ec9205e..ecab82a 100644 --- a/ui/src/components/grid/GridToolbar.svelte +++ b/ui/src/components/grid/GridToolbar.svelte @@ -3,19 +3,16 @@ import ConfirmDialog from '../common/ConfirmDialog.svelte'; import Input from '$lib/components/ui/input.svelte'; import GridTagPopover from './GridTagPopover.svelte'; - import GridFilterSelect from './GridFilterSelect.svelte'; - import { GRID_STATE_FILTER_OPTIONS, getGridTagFilterOptions } from '$lib/gridFilterOptions.js'; import { selectedIds, gridFilters, gridActions, - allTags, gridInstances, filteredGridInstances, } from '$lib/gridStore.js'; - import { Play, Square, Pause, Trash2, Upload, Search, ChevronDown } from '@lucide/svelte'; + import { Play, Square, Pause, Trash2, Upload, Search, ChevronDown, Funnel } from '@lucide/svelte'; - let { onImport = () => {} } = $props(); + let { onImport = () => {}, onOpenFilters = () => {} } = $props(); let selectionCount = $derived($selectedIds.size); let totalCount = $derived($gridInstances.length); @@ -115,20 +112,6 @@ gridFilters.update(f => ({ ...f, search: e.target.value })); } - let tagFilterOptions = $derived(getGridTagFilterOptions($allTags)); - - function handleStateFilter(value) { - gridFilters.update(f => ({ ...f, stateFilter: value })); - } - - function handleTagFilter(value) { - gridFilters.update(f => ({ ...f, tagFilter: value })); - } - - function clearTrackerFilter() { - gridFilters.update(f => ({ ...f, trackerFilter: [], trackerSearch: '' })); - } - function handleSelectMenuClick(e) { e.stopPropagation(); selectMenuOpen = !selectMenuOpen; @@ -147,13 +130,26 @@
- + +
@@ -242,47 +238,57 @@ {selectionCount} selected - @@ -290,10 +296,16 @@ - {/if} @@ -304,7 +316,7 @@
- +
@@ -315,32 +327,6 @@ class="h-8 pl-8 text-xs" />
- - - - {#if $allTags.length > 0} - - {/if} - - {#if $gridFilters.trackerFilter.length > 0} - - {/if}
diff --git a/ui/src/components/grid/GridView.svelte b/ui/src/components/grid/GridView.svelte index 1c24bbc..269b6e9 100644 --- a/ui/src/components/grid/GridView.svelte +++ b/ui/src/components/grid/GridView.svelte @@ -1,16 +1,27 @@ -
- (importDialogOpen = true)} /> +
+ + +
+ (importDialogOpen = true)} onOpenFilters={openMobileFilters} /> + + {#if $filteredGridInstances.length === 0} +
+

No instances found

+

Import torrents or adjust filters to see instances here.

+
+ {:else} + + {/if} +
+
+ +{#if mobileFiltersOpen} + +{/if} + +{#if mobileFiltersOpen} +
+
+
+
+
Filters
+
+ {activeFiltersCount > 0 + ? `${activeFiltersCount} active filter${activeFiltersCount > 1 ? 's' : ''}` + : 'Browse instances by facet'} +
+
+
+ {#if activeFiltersCount > 0} + + {/if} + +
+
+ +
+ +
+
+{/if} + +
+
+
+ - {#if $filteredGridInstances.length === 0} -
-

No instances found

-

Import torrents or switch filters to see instances here.

+
- {:else} - - {/if} +
({ ...f, stateFilter: state })); - } - - function setTrackerFilter(tracker) { - gridFilters.update(f => ({ - ...f, - trackerFilter: f.trackerFilter.includes(tracker) - ? f.trackerFilter.filter(value => value !== tracker) - : [...f.trackerFilter, tracker], - })); - } - - function clearTrackerFilters() { - gridFilters.update(f => ({ ...f, trackerFilter: [] })); - } - - function handleTrackerSearch(event) { - gridFilters.update(f => ({ ...f, trackerSearch: event.target.value })); - } - function formatRate(rate) { if (!rate || rate === 0) return '0 KB/s'; if (rate >= 1000) return (rate / 1024).toFixed(1) + ' MB/s'; @@ -912,15 +871,8 @@ {@const count = gridStats().stateCounts[sc.key] || 0} {#if count > 0} {@const StateIcon = sc.icon} - +
{/if} {/each}
{/if} - -
- Filter -
- {#each quickFilters as qf (qf.key)} - - {/each} -
-
- - {#if $trackerFilterEntries.length > 0} -
-
- Trackers - {#if activeTrackerFilter.length > 0} - - {/if} -
- -
- - -
- -
- {#each $trackerFilterEntries as tracker (tracker.value)} - - {/each} -
-
- {/if} - {#if selectionStats()}
diff --git a/ui/src/lib/gridFilterOptions.js b/ui/src/lib/gridFilterOptions.js index 25ee9e2..3d9072b 100644 --- a/ui/src/lib/gridFilterOptions.js +++ b/ui/src/lib/gridFilterOptions.js @@ -19,3 +19,7 @@ export function getGridTagFilterOptions(tags = []) { return [{ value: '', label: 'All Tags' }, ...values.map(tag => ({ value: tag, label: tag }))]; } + +export function getGridStateMeta(value) { + return getGridStateFilterOption(value); +} diff --git a/ui/src/lib/gridFilters.js b/ui/src/lib/gridFilters.js index a2eb4f2..cb5c8a2 100644 --- a/ui/src/lib/gridFilters.js +++ b/ui/src/lib/gridFilters.js @@ -1,23 +1,134 @@ +import { buildTrackerFilterEntries, getPrimaryTrackerHost } from './trackerUtils.js'; + +export const UNTAGGED_FILTER_VALUE = '__untagged__'; + +function hasAny(items) { + return Array.isArray(items) && items.length > 0; +} + +export function applySearchFilter(instances, filters) { + if (!filters.search) { + return instances; + } + + const search = filters.search.toLowerCase(); + return instances.filter( + inst => + inst.name.toLowerCase().includes(search) || + inst.infoHash?.toLowerCase().includes(search) || + inst.tags?.some(t => t.toLowerCase().includes(search)) + ); +} + +export function applyStateFilter(instances, filters) { + if (filters.stateFilter === 'all') { + return instances; + } + + return instances.filter(inst => inst.state.toLowerCase() === filters.stateFilter); +} + +export function applyTagFilter(instances, filters) { + if (!hasAny(filters.tagFilter)) { + return instances; + } + + const selected = new Set(filters.tagFilter); + return instances.filter(inst => { + const tags = inst.tags || []; + if (tags.length === 0) { + return selected.has(UNTAGGED_FILTER_VALUE); + } + + return tags.some(tag => selected.has(tag)); + }); +} + +export function applyTrackerFilter(instances, filters) { + if (!hasAny(filters.trackerFilter)) { + return instances; + } + + const selected = new Set(filters.trackerFilter); + return instances.filter(inst => selected.has(getPrimaryTrackerHost(inst))); +} + export function applyBaseGridFilters(instances, filters) { - let result = instances; - - if (filters.search) { - const search = filters.search.toLowerCase(); - result = result.filter( - inst => - inst.name.toLowerCase().includes(search) || - inst.infoHash?.toLowerCase().includes(search) || - inst.tags?.some(t => t.toLowerCase().includes(search)) - ); + return applyTagFilter(applyStateFilter(applySearchFilter(instances, filters), filters), filters); +} + +export function applyAllGridFilters(instances, filters) { + return applyTrackerFilter(applyBaseGridFilters(instances, filters), filters); +} + +export function buildStateFilterEntries(instances) { + const counts = new Map(); + + for (const instance of instances || []) { + const state = String(instance.state || 'stopped').toLowerCase(); + counts.set(state, (counts.get(state) || 0) + 1); } - if (filters.stateFilter !== 'all') { - result = result.filter(inst => inst.state.toLowerCase() === filters.stateFilter); + const order = ['running', 'paused', 'idle', 'starting', 'stopping', 'stopped']; + + return order + .filter(state => counts.has(state)) + .map(state => ({ value: state, count: counts.get(state) || 0 })); +} + +export function buildTagFilterEntries(instances, filters = {}) { + const counts = new Map(); + const query = String(filters.tagSearch || '') + .toLowerCase() + .trim(); + let untaggedCount = 0; + + for (const instance of instances || []) { + const tags = instance.tags || []; + + if (tags.length === 0) { + untaggedCount += 1; + continue; + } + + for (const tag of tags) { + if (query && !tag.toLowerCase().includes(query)) { + continue; + } + counts.set(tag, (counts.get(tag) || 0) + 1); + } } - if (filters.tagFilter) { - result = result.filter(inst => inst.tags?.includes(filters.tagFilter)); + const entries = [...counts.entries()] + .map(([value, count]) => ({ value, label: value, count })) + .sort((left, right) => { + if (right.count !== left.count) { + return right.count - left.count; + } + return left.label.localeCompare(right.label); + }); + + if (untaggedCount > 0 && (!query || 'untagged'.includes(query))) { + entries.unshift({ + value: UNTAGGED_FILTER_VALUE, + label: 'Untagged', + count: untaggedCount, + muted: true, + }); } - return result; + return entries; +} + +export function clearAllGridFilters(filters) { + return { + ...filters, + stateFilter: 'all', + tagFilter: [], + trackerFilter: [], + tagSearch: '', + trackerSearch: '', + }; } + +export { buildTrackerFilterEntries }; diff --git a/ui/src/lib/gridStore.js b/ui/src/lib/gridStore.js index 437a589..58fcd52 100644 --- a/ui/src/lib/gridStore.js +++ b/ui/src/lib/gridStore.js @@ -2,8 +2,15 @@ import { writable, derived, get } from 'svelte/store'; import { api, getRunMode } from '$lib/api'; import { normalizeViewMode } from '$lib/viewMode.js'; import { instanceActions } from '$lib/instanceStore.js'; -import { applyBaseGridFilters } from '$lib/gridFilters.js'; -import { buildTrackerFilterEntries, getPrimaryTrackerHost } from '$lib/trackerUtils.js'; +import { + applyAllGridFilters, + applyBaseGridFilters, + applySearchFilter, + applyStateFilter, + buildStateFilterEntries, + buildTagFilterEntries, + buildTrackerFilterEntries, +} from '$lib/gridFilters.js'; const VIEW_MODE_KEY = 'rustatio-view-mode'; @@ -35,16 +42,33 @@ export const selectedIds = writable(new Set()); export const gridFilters = writable({ search: '', stateFilter: 'all', - tagFilter: '', + tagFilter: [], trackerFilter: [], + tagSearch: '', trackerSearch: '', }); +export const stateFilterEntries = derived( + [gridInstances, gridFilters], + ([$instances, $filters]) => { + const scoped = applyBaseGridFilters(applySearchFilter($instances, $filters), { + ...$filters, + stateFilter: 'all', + }); + return buildStateFilterEntries(scoped); + } +); + +export const tagFilterEntries = derived([gridInstances, gridFilters], ([$instances, $filters]) => { + const scoped = applyStateFilter(applySearchFilter($instances, $filters), $filters); + return buildTagFilterEntries(scoped, $filters); +}); + export const trackerFilterEntries = derived( [gridInstances, gridFilters], ([$instances, $filters]) => { - const base = applyBaseGridFilters($instances, $filters); - return buildTrackerFilterEntries(base, $filters); + const scoped = applyBaseGridFilters($instances, { ...$filters, trackerFilter: [] }); + return buildTrackerFilterEntries(scoped, $filters); } ); @@ -58,12 +82,7 @@ export const gridSort = writable({ export const filteredGridInstances = derived( [gridInstances, gridFilters, gridSort], ([$instances, $filters, $sort]) => { - let result = applyBaseGridFilters($instances, $filters); - - if ($filters.trackerFilter.length > 0) { - const selectedTrackers = new Set($filters.trackerFilter); - result = result.filter(inst => selectedTrackers.has(getPrimaryTrackerHost(inst))); - } + let result = applyAllGridFilters($instances, $filters); // Sort result = [...result].sort((a, b) => { @@ -85,19 +104,6 @@ export const filteredGridInstances = derived( } ); -// All unique tags across all instances -export const allTags = derived(gridInstances, $instances => { - const tagSet = new Set(); - for (const inst of $instances) { - if (inst.tags) { - for (const tag of inst.tags) { - tagSet.add(tag); - } - } - } - return [...tagSet].sort(); -}); - // Polling interval reference let pollInterval = null; let isFetching = false; diff --git a/ui/src/lib/gridStore.test.js b/ui/src/lib/gridStore.test.js index 15f6281..fc3429b 100644 --- a/ui/src/lib/gridStore.test.js +++ b/ui/src/lib/gridStore.test.js @@ -1,7 +1,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { applyBaseGridFilters } from './gridFilters.js'; +import { + UNTAGGED_FILTER_VALUE, + applyAllGridFilters, + applyBaseGridFilters, + buildStateFilterEntries, + buildTagFilterEntries, +} from './gridFilters.js'; test('applyBaseGridFilters keeps search, state, and tag behavior', () => { const instances = [ @@ -23,7 +29,7 @@ test('applyBaseGridFilters keeps search, state, and tag behavior', () => { applyBaseGridFilters(instances, { search: 'alp', stateFilter: 'all', - tagFilter: '', + tagFilter: [], }).length, 1 ); @@ -32,7 +38,7 @@ test('applyBaseGridFilters keeps search, state, and tag behavior', () => { applyBaseGridFilters(instances, { search: '', stateFilter: 'stopped', - tagFilter: '', + tagFilter: [], })[0].name, 'Beta' ); @@ -41,8 +47,107 @@ test('applyBaseGridFilters keeps search, state, and tag behavior', () => { applyBaseGridFilters(instances, { search: '', stateFilter: 'all', - tagFilter: 'movie', + tagFilter: ['movie'], })[0].name, 'Alpha' ); }); + +test('applyAllGridFilters supports multi-select tags and trackers', () => { + const instances = [ + { + name: 'Alpha', + infoHash: 'abc123', + tags: ['movie'], + state: 'running', + primaryTrackerHost: 'c411.org', + }, + { + name: 'Beta', + infoHash: 'def456', + tags: ['tv'], + state: 'stopped', + primaryTrackerHost: 'nyaa.tracker.wf', + }, + { + name: 'Gamma', + infoHash: 'ghi789', + tags: [], + state: 'running', + primaryTrackerHost: 'open.stealth.si', + }, + ]; + + const result = applyAllGridFilters(instances, { + search: '', + stateFilter: 'all', + tagFilter: ['movie', 'tv'], + trackerFilter: ['c411.org', 'nyaa.tracker.wf'], + }); + + assert.deepEqual( + result.map(instance => instance.name), + ['Alpha', 'Beta'] + ); +}); + +test('applyBaseGridFilters matches untagged torrents when selected', () => { + const result = applyBaseGridFilters( + [ + { name: 'Alpha', infoHash: 'a', tags: ['movie'], state: 'running' }, + { name: 'Beta', infoHash: 'b', tags: [], state: 'stopped' }, + ], + { + search: '', + stateFilter: 'all', + tagFilter: [UNTAGGED_FILTER_VALUE], + } + ); + + assert.deepEqual( + result.map(instance => instance.name), + ['Beta'] + ); +}); + +test('buildStateFilterEntries returns ordered state counts', () => { + assert.deepEqual( + buildStateFilterEntries([ + { state: 'stopped' }, + { state: 'running' }, + { state: 'running' }, + { state: 'idle' }, + ]), + [ + { value: 'running', count: 2 }, + { value: 'idle', count: 1 }, + { value: 'stopped', count: 1 }, + ] + ); +}); + +test('buildTagFilterEntries aggregates and searches tags', () => { + assert.deepEqual( + buildTagFilterEntries( + [{ tags: ['movie', 'anime'] }, { tags: [] }, { tags: ['movie'] }, { tags: ['tv'] }], + { tagSearch: 'mo' } + ), + [{ value: 'movie', label: 'movie', count: 2 }] + ); +}); + +test('buildTagFilterEntries includes untagged row', () => { + assert.deepEqual(buildTagFilterEntries([{ tags: [] }, { tags: ['movie'] }, { tags: [] }]), [ + { + value: UNTAGGED_FILTER_VALUE, + label: 'Untagged', + count: 2, + muted: true, + }, + { + value: 'movie', + label: 'movie', + count: 1, + }, + ]); +});