Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions backend/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ import (
"github.com/satisfactorymodding/SatisfactoryModManager/backend/utils"
)

type TagSearchMode string

type SavedModFilters struct {
Order string `json:"order"`
Filter string `json:"filter"`
Order string `json:"order"`
Filter string `json:"filter"`
TagSearchMode TagSearchMode `json:"tagSearchMode,omitempty"`
}

type View string
Expand All @@ -35,6 +38,11 @@ var (
UpdateAsk UpdateCheckMode = "ask"
)

const (
TagSearchModeAny TagSearchMode = "any"
TagSearchModeAll TagSearchMode = "all"
)

type settings struct {
WindowPosition *utils.Position `json:"windowPosition,omitempty"`
Maximized bool `json:"maximized,omitempty"`
Expand Down Expand Up @@ -83,8 +91,9 @@ var Settings = &settings{

FavoriteMods: []string{},
ModFilters: SavedModFilters{
Order: "last-updated",
Filter: "compatible",
Order: "last-updated",
Filter: "compatible",
TagSearchMode: TagSearchModeAny,
},

RemoteNames: map[string]string{},
Expand Down Expand Up @@ -182,6 +191,15 @@ func (s *settings) SetModFiltersFilter(filter string) {
_ = SaveSettings()
}

func (s *settings) GetModFiltersTagSearchMode() TagSearchMode {
return s.ModFilters.TagSearchMode
}

func (s *settings) SetModFiltersTagSearchMode(mode TagSearchMode) {
s.ModFilters.TagSearchMode = mode
_ = SaveSettings()
}

func (s *settings) emitFavoriteMods() {
wailsRuntime.EventsEmit(common.AppContext, "favoriteMods", s.FavoriteMods)
}
Expand Down Expand Up @@ -463,3 +481,11 @@ func SaveSettings() error {

return nil
}

var AllTagSearchModes = []struct {
Value TagSearchMode
TSName string
}{
{TagSearchModeAll, "ALL"},
{TagSearchModeAny, "ANY"},
}
42 changes: 31 additions & 11 deletions frontend/src/lib/components/mods-list/ModsList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,13 @@
import { queuedMods } from '$lib/store/actionQueue';
import { favoriteMods, lockfileMods, manifestMods, selectedProfileTargets } from '$lib/store/ficsitCLIStore';
import { expandedMod, hasFetchedMods } from '$lib/store/generalStore';
import { type OfflineMod, type PartialMod, filter, filterOptions, order, search } from '$lib/store/modFiltersStore';
import { offline, startView } from '$lib/store/settingsStore';
import {
type OfflineMod, type PartialMod, type PartialSMRMod, filter, filterOptions, order, search,
selectedTags,
} from '$lib/store/modFiltersStore';
import { offline, startView, tagSearchMode } from '$lib/store/settingsStore';
import { OfflineGetMods } from '$wailsjs/go/ficsitcli/ficsitCLI';
import { settings } from '$wailsjs/go/models';

const dispatch = createEventDispatcher();

Expand All @@ -25,7 +29,7 @@
const client = getContextClient();

let fetchingMods = false;
let onlineMods: PartialMod[] = [];
let onlineMods: PartialSMRMod[] = [];
async function fetchAllModsOnline() {
try {
const result = await client.query(GetModCountDocument, {}, { requestPolicy: 'network-only' }).toPromise();
Expand All @@ -46,7 +50,7 @@
}
}

let offlineMods: PartialMod[] = [];
let offlineMods: OfflineMod[] = [];
async function fetchAllModsOffline() {
offlineMods = (await OfflineGetMods()).map((mod) => ({
...mod,
Expand Down Expand Up @@ -92,6 +96,8 @@

$: mods = [...knownMods, ...unknownMods];

$: availableTags = _.sortBy(_.uniqBy(onlineMods?.map((mod) => mod.tags ?? []).flat() ?? [], 'id'), 'name');

let filteredMods: PartialMod[] = [];
let filteringMods = false;
$: {
Expand All @@ -101,13 +107,25 @@
$favoriteMods;
$queuedMods;
$selectedProfileTargets;
$selectedTags;
$tagSearchMode;

filteringMods = true;
Promise.all(mods.map((mod) => $filter.func(mod, client))).then((results) => {
filteredMods = mods.filter((_, i) => results[i]);
}).then(() => {
filteringMods = false;
});
Promise.all(mods.map((mod) => $filter.func(mod, client)))
.then((results) => mods.filter((_, i) => results[i]))
.then((filtered) => {
filteredMods = (filtered.filter((mod) => !('tags' in mod) || !mod.tags || matchTags(mod.tags.map((t) => t.id), $selectedTags)));
filteringMods = false;
});
}

function matchTags(modTags: string[], tagIds: string[]): boolean {
if (tagIds.length === 0) return true;
const modTagsSet = new Set(modTags);
if ($tagSearchMode === settings.TagSearchMode.ALL) {
return tagIds.every((id) => modTagsSet.has(id));
}
return tagIds.some((id) => modTagsSet.has(id));
}

let sortedMods: PartialMod[] = [];
Expand Down Expand Up @@ -165,20 +183,22 @@

$: userHasSearchText = $search != '';
$: userHasSearchFilters = $filter != filterOptions[0];
$: userHasTagFilter = $selectedTags.length > 0;

const removeSearchText = () => {
$search = '';
};
const removeSearchFilters = () => {
$filter = filterOptions[0];
$selectedTags = [];
};

export let hideMods: boolean = false;
</script>

<div class="h-full flex flex-col">
<div class="flex-none z-[1]">
<ModListFilters />
<ModListFilters {availableTags} />
</div>
<AnnouncementsBar />
{#if hideMods}
Expand All @@ -195,7 +215,7 @@
<div class="flex flex-col h-full items-center justify-center">
{#if mods.length !== 0}
<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>
{#if userHasSearchFilters}
{#if userHasSearchFilters || userHasTagFilter}
{#if userHasSearchText}
<button
class="btn variant-filled-primary mt-4"
Expand Down
109 changes: 107 additions & 2 deletions frontend/src/lib/components/mods-list/ModsListFilters.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,48 @@
<script lang="ts">
import { mdiClose, mdiFilter, mdiSort } from '@mdi/js';
import type { SizeOptions } from '@floating-ui/dom';
import { mdiClose, mdiFilter, mdiSort, mdiTagMultiple } from '@mdi/js';
import { getTranslate } from '@tolgee/svelte';

import Marquee from '$lib/components/Marquee.svelte';
import SvgIcon from '$lib/components/SVGIcon.svelte';
import Select from '$lib/components/Select.svelte';
import { type FilterField, type OrderByField, filter, filterOptions, order, orderByOptions, search } from '$lib/store/modFiltersStore';
import { type PopupSettings, popup } from '$lib/skeletonExtensions';
import { type FilterField, type OrderByField, filter, filterOptions, order, orderByOptions, search, selectedTags } from '$lib/store/modFiltersStore';
import { tagSearchMode } from '$lib/store/settingsStore';
import { settings } from '$wailsjs/go/models';

export let availableTags: { id: string, name: string }[] = [];

$: if ($selectedTags.length > 0) {
$selectedTags = $selectedTags.filter((t) => availableTags.some((a) => a.id === t));
}

const tagPopupName = 'modsTagFilter';
const tagPopup: PopupSettings = {
event: 'click',
target: tagPopupName,
placement: 'bottom-start',
closeQuery: '', // keep open when clicking tags so user can multi-select
middleware: {
offset: 6,
size: {
apply({ availableHeight, elements }: { availableHeight: number; elements: { floating: HTMLElement } }) {
Object.assign(elements.floating.style, {
maxHeight: `calc(${availableHeight}px - 1rem)`,
});
},
} as SizeOptions,
shift: { padding: 0 },
},
};

function toggleTag(tag: string) {
if ($selectedTags.includes(tag)) {
$selectedTags = $selectedTags.filter((t) => t !== tag);
} else {
$selectedTags = [...$selectedTags, tag];
}
}

const { t } = getTranslate();

Expand Down Expand Up @@ -45,6 +82,74 @@
<SvgIcon class="h-5 w-5 text-error-500/80" icon={mdiClose} />
</button>
</div>
<div class="relative !h-full">
<div class="h-full w-full" use:popup={tagPopup}>
<button
class="btn px-2 text-sm space-x-1 !h-full"
aria-label={$t('mods-list-filter.tag.button-label', 'Filter by tags')}
type="button"
on:contextmenu|preventDefault={() => ($selectedTags = [])}
>
<SvgIcon class="h-5 w-5 shrink-0" icon={mdiTagMultiple} />
{#if $selectedTags.length > 0}
<span class="text-primary-600 font-medium tabular-nums">{$selectedTags.length}</span>
{/if}
</button>
</div>
<div
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"
aria-multiselectable="true"
data-popup={tagPopupName}
role="listbox"
>
<div
class="flex items-center gap-2 px-3 py-2 border-b border-surface-400-600-token shrink-0"
aria-label={$t('mods-list-filter.tag.match-mode', 'Match mode')}
role="group"
>
<button
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'}"
aria-pressed={$tagSearchMode === settings.TagSearchMode.ALL}
type="button"
on:click|stopPropagation={() => tagSearchMode.set(settings.TagSearchMode.ALL)}
>
{$t('mods-list-filter.tag.match-all', 'Match all')}
</button>
<button
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'}"
aria-pressed={$tagSearchMode === settings.TagSearchMode.ANY}
type="button"
on:click|stopPropagation={() => tagSearchMode.set(settings.TagSearchMode.ANY)}
>
{$t('mods-list-filter.tag.match-any', 'Match any')}
</button>
</div>
<div class="overflow-y-auto min-h-0 flex-1">
{#if availableTags.length > 0}
<div class="columns-3 [column-gap:0.5rem] min-h-0 p-2">
{#each availableTags as tag}
<button
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"
aria-selected={$selectedTags.includes(tag.id)}
role="option"
type="button"
on:click={() => toggleTag(tag.id)}
>
{#if $selectedTags.includes(tag.id)}
<span class="text-primary-600 font-medium" aria-hidden="true">✓</span>
{/if}
<span class="{$selectedTags.includes(tag.id) ? 'font-medium' : ''}">{tag.name}</span>
</button>
{/each}
</div>
{:else}
<div class="px-3 py-2 text-sm text-surface-400-600-token">
{$t('mods-list-filter.tag.none-available', 'No tags available')}
</div>
{/if}
</div>
</div>
</div>
<Select
name="modsFilter"
class="!h-full"
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/lib/store/modFiltersStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ export interface MissingMod {
export type PartialMod = PartialSMRMod | OfflineMod | MissingMod;

export const search = writable('');

export const selectedTags = writable<string[]>([]);

export const order = bindingTwoWayNoExcept(orderByOptions[1], {
initialGet: async () => GetModFiltersOrder().then((i) => orderByOptions.find((o) => o.id === i) || orderByOptions[1]),
}, {
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/lib/store/settingsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import { binding, bindingTwoWay, bindingTwoWayNoExcept } from './wailsStoreBindi
import type { LaunchButtonType, ViewType } from '$lib/wailsTypesExtensions';
import { GetVersion } from '$wailsjs/go/app/app';
import { GetOffline, SetOffline } from '$wailsjs/go/ficsitcli/ficsitCLI';
import { settings } from '$wailsjs/go/models';
import {
GetCacheDir,
GetDebug,
GetIgnoredUpdates,
GetKonami,
GetLanguage,
GetLaunchButton,
GetModFiltersTagSearchMode,
GetProxy,
GetQueueAutoStart,
GetRestoreWindowPosition,
Expand All @@ -21,6 +23,7 @@ import {
SetKonami,
SetLanguage,
SetLaunchButton,
SetModFiltersTagSearchMode,
SetProxy,
SetQueueAutoStart, SetRestoreWindowPosition,
SetStartView,
Expand All @@ -29,6 +32,12 @@ import {

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

export const tagSearchMode = bindingTwoWayNoExcept<settings.TagSearchMode>(
settings.TagSearchMode.ANY,
{ initialGet: GetModFiltersTagSearchMode },
{ updateFunction: SetModFiltersTagSearchMode },
);

export const saveWindowPosition = bindingTwoWayNoExcept(true, { initialGet: GetRestoreWindowPosition }, { updateFunction: SetRestoreWindowPosition });

export const konami = bindingTwoWayNoExcept(false, { initialGet: GetKonami }, { updateFunction: SetKonami });
Expand Down
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,7 @@ func main() {
common.AllLocationTypes,
ficsitcli.AllInstallationStates,
ficsitcli.AllActionTypes,
settings.AllTagSearchModes,
},
Logger: backend.WailsZeroLogLogger{},
Debug: options.Debug{
Expand Down
Loading