Skip to content

Commit e9912ca

Browse files
authored
Add tag search (#345)
1 parent 6d89de7 commit e9912ca

File tree

6 files changed

+181
-17
lines changed

6 files changed

+181
-17
lines changed

backend/settings/settings.go

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@ import (
1515
"github.com/satisfactorymodding/SatisfactoryModManager/backend/utils"
1616
)
1717

18+
type TagSearchMode string
19+
1820
type SavedModFilters struct {
19-
Order string `json:"order"`
20-
Filter string `json:"filter"`
21+
Order string `json:"order"`
22+
Filter string `json:"filter"`
23+
TagSearchMode TagSearchMode `json:"tagSearchMode,omitempty"`
2124
}
2225

2326
type View string
@@ -35,6 +38,11 @@ var (
3538
UpdateAsk UpdateCheckMode = "ask"
3639
)
3740

41+
const (
42+
TagSearchModeAny TagSearchMode = "any"
43+
TagSearchModeAll TagSearchMode = "all"
44+
)
45+
3846
type settings struct {
3947
WindowPosition *utils.Position `json:"windowPosition,omitempty"`
4048
Maximized bool `json:"maximized,omitempty"`
@@ -83,8 +91,9 @@ var Settings = &settings{
8391

8492
FavoriteMods: []string{},
8593
ModFilters: SavedModFilters{
86-
Order: "last-updated",
87-
Filter: "compatible",
94+
Order: "last-updated",
95+
Filter: "compatible",
96+
TagSearchMode: TagSearchModeAny,
8897
},
8998

9099
RemoteNames: map[string]string{},
@@ -182,6 +191,15 @@ func (s *settings) SetModFiltersFilter(filter string) {
182191
_ = SaveSettings()
183192
}
184193

194+
func (s *settings) GetModFiltersTagSearchMode() TagSearchMode {
195+
return s.ModFilters.TagSearchMode
196+
}
197+
198+
func (s *settings) SetModFiltersTagSearchMode(mode TagSearchMode) {
199+
s.ModFilters.TagSearchMode = mode
200+
_ = SaveSettings()
201+
}
202+
185203
func (s *settings) emitFavoriteMods() {
186204
wailsRuntime.EventsEmit(common.AppContext, "favoriteMods", s.FavoriteMods)
187205
}
@@ -463,3 +481,11 @@ func SaveSettings() error {
463481

464482
return nil
465483
}
484+
485+
var AllTagSearchModes = []struct {
486+
Value TagSearchMode
487+
TSName string
488+
}{
489+
{TagSearchModeAll, "ALL"},
490+
{TagSearchModeAny, "ANY"},
491+
}

frontend/src/lib/components/mods-list/ModsList.svelte

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,13 @@
1414
import { queuedMods } from '$lib/store/actionQueue';
1515
import { favoriteMods, lockfileMods, manifestMods, selectedProfileTargets } from '$lib/store/ficsitCLIStore';
1616
import { expandedMod, hasFetchedMods } from '$lib/store/generalStore';
17-
import { type OfflineMod, type PartialMod, filter, filterOptions, order, search } from '$lib/store/modFiltersStore';
18-
import { offline, startView } from '$lib/store/settingsStore';
17+
import {
18+
type OfflineMod, type PartialMod, type PartialSMRMod, filter, filterOptions, order, search,
19+
selectedTags,
20+
} from '$lib/store/modFiltersStore';
21+
import { offline, startView, tagSearchMode } from '$lib/store/settingsStore';
1922
import { OfflineGetMods } from '$wailsjs/go/ficsitcli/ficsitCLI';
23+
import { settings } from '$wailsjs/go/models';
2024
2125
const dispatch = createEventDispatcher();
2226
@@ -25,7 +29,7 @@
2529
const client = getContextClient();
2630
2731
let fetchingMods = false;
28-
let onlineMods: PartialMod[] = [];
32+
let onlineMods: PartialSMRMod[] = [];
2933
async function fetchAllModsOnline() {
3034
try {
3135
const result = await client.query(GetModCountDocument, {}, { requestPolicy: 'network-only' }).toPromise();
@@ -46,7 +50,7 @@
4650
}
4751
}
4852
49-
let offlineMods: PartialMod[] = [];
53+
let offlineMods: OfflineMod[] = [];
5054
async function fetchAllModsOffline() {
5155
offlineMods = (await OfflineGetMods()).map((mod) => ({
5256
...mod,
@@ -92,6 +96,8 @@
9296
9397
$: mods = [...knownMods, ...unknownMods];
9498
99+
$: availableTags = _.sortBy(_.uniqBy(onlineMods?.map((mod) => mod.tags ?? []).flat() ?? [], 'id'), 'name');
100+
95101
let filteredMods: PartialMod[] = [];
96102
let filteringMods = false;
97103
$: {
@@ -101,13 +107,25 @@
101107
$favoriteMods;
102108
$queuedMods;
103109
$selectedProfileTargets;
110+
$selectedTags;
111+
$tagSearchMode;
104112
105113
filteringMods = true;
106-
Promise.all(mods.map((mod) => $filter.func(mod, client))).then((results) => {
107-
filteredMods = mods.filter((_, i) => results[i]);
108-
}).then(() => {
109-
filteringMods = false;
110-
});
114+
Promise.all(mods.map((mod) => $filter.func(mod, client)))
115+
.then((results) => mods.filter((_, i) => results[i]))
116+
.then((filtered) => {
117+
filteredMods = (filtered.filter((mod) => !('tags' in mod) || !mod.tags || matchTags(mod.tags.map((t) => t.id), $selectedTags)));
118+
filteringMods = false;
119+
});
120+
}
121+
122+
function matchTags(modTags: string[], tagIds: string[]): boolean {
123+
if (tagIds.length === 0) return true;
124+
const modTagsSet = new Set(modTags);
125+
if ($tagSearchMode === settings.TagSearchMode.ALL) {
126+
return tagIds.every((id) => modTagsSet.has(id));
127+
}
128+
return tagIds.some((id) => modTagsSet.has(id));
111129
}
112130
113131
let sortedMods: PartialMod[] = [];
@@ -165,20 +183,22 @@
165183
166184
$: userHasSearchText = $search != '';
167185
$: userHasSearchFilters = $filter != filterOptions[0];
186+
$: userHasTagFilter = $selectedTags.length > 0;
168187
169188
const removeSearchText = () => {
170189
$search = '';
171190
};
172191
const removeSearchFilters = () => {
173192
$filter = filterOptions[0];
193+
$selectedTags = [];
174194
};
175195
176196
export let hideMods: boolean = false;
177197
</script>
178198

179199
<div class="h-full flex flex-col">
180200
<div class="flex-none z-[1]">
181-
<ModListFilters />
201+
<ModListFilters {availableTags} />
182202
</div>
183203
<AnnouncementsBar />
184204
{#if hideMods}
@@ -195,7 +215,7 @@
195215
<div class="flex flex-col h-full items-center justify-center">
196216
{#if mods.length !== 0}
197217
<p class="text-xl text-center text-surface-400-700-token"><T defaultValue="No mods matching your search" keyName="mods-list.no-mods-filtered"/></p>
198-
{#if userHasSearchFilters}
218+
{#if userHasSearchFilters || userHasTagFilter}
199219
{#if userHasSearchText}
200220
<button
201221
class="btn variant-filled-primary mt-4"

frontend/src/lib/components/mods-list/ModsListFilters.svelte

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,48 @@
11
<script lang="ts">
2-
import { mdiClose, mdiFilter, mdiSort } from '@mdi/js';
2+
import type { SizeOptions } from '@floating-ui/dom';
3+
import { mdiClose, mdiFilter, mdiSort, mdiTagMultiple } from '@mdi/js';
34
import { getTranslate } from '@tolgee/svelte';
45
56
import Marquee from '$lib/components/Marquee.svelte';
67
import SvgIcon from '$lib/components/SVGIcon.svelte';
78
import Select from '$lib/components/Select.svelte';
8-
import { type FilterField, type OrderByField, filter, filterOptions, order, orderByOptions, search } from '$lib/store/modFiltersStore';
9+
import { type PopupSettings, popup } from '$lib/skeletonExtensions';
10+
import { type FilterField, type OrderByField, filter, filterOptions, order, orderByOptions, search, selectedTags } from '$lib/store/modFiltersStore';
11+
import { tagSearchMode } from '$lib/store/settingsStore';
12+
import { settings } from '$wailsjs/go/models';
13+
14+
export let availableTags: { id: string, name: string }[] = [];
15+
16+
$: if ($selectedTags.length > 0) {
17+
$selectedTags = $selectedTags.filter((t) => availableTags.some((a) => a.id === t));
18+
}
19+
20+
const tagPopupName = 'modsTagFilter';
21+
const tagPopup: PopupSettings = {
22+
event: 'click',
23+
target: tagPopupName,
24+
placement: 'bottom-start',
25+
closeQuery: '', // keep open when clicking tags so user can multi-select
26+
middleware: {
27+
offset: 6,
28+
size: {
29+
apply({ availableHeight, elements }: { availableHeight: number; elements: { floating: HTMLElement } }) {
30+
Object.assign(elements.floating.style, {
31+
maxHeight: `calc(${availableHeight}px - 1rem)`,
32+
});
33+
},
34+
} as SizeOptions,
35+
shift: { padding: 0 },
36+
},
37+
};
38+
39+
function toggleTag(tag: string) {
40+
if ($selectedTags.includes(tag)) {
41+
$selectedTags = $selectedTags.filter((t) => t !== tag);
42+
} else {
43+
$selectedTags = [...$selectedTags, tag];
44+
}
45+
}
946
1047
const { t } = getTranslate();
1148
@@ -45,6 +82,74 @@
4582
<SvgIcon class="h-5 w-5 text-error-500/80" icon={mdiClose} />
4683
</button>
4784
</div>
85+
<div class="relative !h-full">
86+
<div class="h-full w-full" use:popup={tagPopup}>
87+
<button
88+
class="btn px-2 text-sm space-x-1 !h-full"
89+
aria-label={$t('mods-list-filter.tag.button-label', 'Filter by tags')}
90+
type="button"
91+
on:contextmenu|preventDefault={() => ($selectedTags = [])}
92+
>
93+
<SvgIcon class="h-5 w-5 shrink-0" icon={mdiTagMultiple} />
94+
{#if $selectedTags.length > 0}
95+
<span class="text-primary-600 font-medium tabular-nums">{$selectedTags.length}</span>
96+
{/if}
97+
</button>
98+
</div>
99+
<div
100+
class="card min-w-[24rem] max-h-96 shadow-xl z-10 duration-0 !mt-0 hidden opacity-0 pointer-events-none inert flex flex-col"
101+
aria-multiselectable="true"
102+
data-popup={tagPopupName}
103+
role="listbox"
104+
>
105+
<div
106+
class="flex items-center gap-2 px-3 py-2 border-b border-surface-400-600-token shrink-0"
107+
aria-label={$t('mods-list-filter.tag.match-mode', 'Match mode')}
108+
role="group"
109+
>
110+
<button
111+
class="flex-1 px-3 py-1.5 text-sm rounded transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 {$tagSearchMode === settings.TagSearchMode.ALL ? 'text-primary-600 font-medium bg-surface-300/20' : 'text-surface-400-700-token hover:bg-surface-300/20'}"
112+
aria-pressed={$tagSearchMode === settings.TagSearchMode.ALL}
113+
type="button"
114+
on:click|stopPropagation={() => tagSearchMode.set(settings.TagSearchMode.ALL)}
115+
>
116+
{$t('mods-list-filter.tag.match-all', 'Match all')}
117+
</button>
118+
<button
119+
class="flex-1 px-3 py-1.5 text-sm rounded transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 {$tagSearchMode === settings.TagSearchMode.ANY ? 'text-primary-600 font-medium bg-surface-300/20' : 'text-surface-400-700-token hover:bg-surface-300/20'}"
120+
aria-pressed={$tagSearchMode === settings.TagSearchMode.ANY}
121+
type="button"
122+
on:click|stopPropagation={() => tagSearchMode.set(settings.TagSearchMode.ANY)}
123+
>
124+
{$t('mods-list-filter.tag.match-any', 'Match any')}
125+
</button>
126+
</div>
127+
<div class="overflow-y-auto min-h-0 flex-1">
128+
{#if availableTags.length > 0}
129+
<div class="columns-3 [column-gap:0.5rem] min-h-0 p-2">
130+
{#each availableTags as tag}
131+
<button
132+
class="w-full text-left px-3 py-2 text-sm transition-colors rounded-none {$selectedTags.includes(tag.id) ? 'bg-surface-300/20' : 'bg-surface-50-900-token hover:!bg-surface-300/20'} flex items-center gap-2 break-inside-avoid"
133+
aria-selected={$selectedTags.includes(tag.id)}
134+
role="option"
135+
type="button"
136+
on:click={() => toggleTag(tag.id)}
137+
>
138+
{#if $selectedTags.includes(tag.id)}
139+
<span class="text-primary-600 font-medium" aria-hidden="true">✓</span>
140+
{/if}
141+
<span class="{$selectedTags.includes(tag.id) ? 'font-medium' : ''}">{tag.name}</span>
142+
</button>
143+
{/each}
144+
</div>
145+
{:else}
146+
<div class="px-3 py-2 text-sm text-surface-400-600-token">
147+
{$t('mods-list-filter.tag.none-available', 'No tags available')}
148+
</div>
149+
{/if}
150+
</div>
151+
</div>
152+
</div>
48153
<Select
49154
name="modsFilter"
50155
class="!h-full"

frontend/src/lib/store/modFiltersStore.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ export interface MissingMod {
7171
export type PartialMod = PartialSMRMod | OfflineMod | MissingMod;
7272

7373
export const search = writable('');
74+
75+
export const selectedTags = writable<string[]>([]);
76+
7477
export const order = bindingTwoWayNoExcept(orderByOptions[1], {
7578
initialGet: async () => GetModFiltersOrder().then((i) => orderByOptions.find((o) => o.id === i) || orderByOptions[1]),
7679
}, {

frontend/src/lib/store/settingsStore.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@ import { binding, bindingTwoWay, bindingTwoWayNoExcept } from './wailsStoreBindi
33
import type { LaunchButtonType, ViewType } from '$lib/wailsTypesExtensions';
44
import { GetVersion } from '$wailsjs/go/app/app';
55
import { GetOffline, SetOffline } from '$wailsjs/go/ficsitcli/ficsitCLI';
6+
import { settings } from '$wailsjs/go/models';
67
import {
78
GetCacheDir,
89
GetDebug,
910
GetIgnoredUpdates,
1011
GetKonami,
1112
GetLanguage,
1213
GetLaunchButton,
14+
GetModFiltersTagSearchMode,
1315
GetProxy,
1416
GetQueueAutoStart,
1517
GetRestoreWindowPosition,
@@ -21,6 +23,7 @@ import {
2123
SetKonami,
2224
SetLanguage,
2325
SetLaunchButton,
26+
SetModFiltersTagSearchMode,
2427
SetProxy,
2528
SetQueueAutoStart, SetRestoreWindowPosition,
2629
SetStartView,
@@ -29,6 +32,12 @@ import {
2932

3033
export const startView = bindingTwoWayNoExcept<ViewType | null>(null, { initialGet: GetStartView }, { updateFunction: SetStartView });
3134

35+
export const tagSearchMode = bindingTwoWayNoExcept<settings.TagSearchMode>(
36+
settings.TagSearchMode.ANY,
37+
{ initialGet: GetModFiltersTagSearchMode },
38+
{ updateFunction: SetModFiltersTagSearchMode },
39+
);
40+
3241
export const saveWindowPosition = bindingTwoWayNoExcept(true, { initialGet: GetRestoreWindowPosition }, { updateFunction: SetRestoreWindowPosition });
3342

3443
export const konami = bindingTwoWayNoExcept(false, { initialGet: GetKonami }, { updateFunction: SetKonami });

main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ func main() {
230230
common.AllLocationTypes,
231231
ficsitcli.AllInstallationStates,
232232
ficsitcli.AllActionTypes,
233+
settings.AllTagSearchModes,
233234
},
234235
Logger: backend.WailsZeroLogLogger{},
235236
Debug: options.Debug{

0 commit comments

Comments
 (0)