Skip to content
Open
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
30 changes: 26 additions & 4 deletions backend/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ import (
)

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

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

const (
TagSearchModeAny = "any"
TagSearchModeAnd = "and"
)

type settings struct {
WindowPosition *utils.Position `json:"windowPosition,omitempty"`
Maximized bool `json:"maximized,omitempty"`
Expand Down Expand Up @@ -83,8 +89,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 +189,21 @@ func (s *settings) SetModFiltersFilter(filter string) {
_ = SaveSettings()
}

func (s *settings) GetModFiltersTagSearchMode() string {
if s.ModFilters.TagSearchMode != TagSearchModeAnd && s.ModFilters.TagSearchMode != TagSearchModeAny {
return TagSearchModeAny
}
return s.ModFilters.TagSearchMode
}

func (s *settings) SetModFiltersTagSearchMode(mode string) {
if mode != TagSearchModeAnd && mode != TagSearchModeAny {
return
}
s.ModFilters.TagSearchMode = mode
_ = SaveSettings()
}

func (s *settings) emitFavoriteMods() {
wailsRuntime.EventsEmit(common.AppContext, "favoriteMods", s.FavoriteMods)
}
Expand Down
39 changes: 33 additions & 6 deletions frontend/src/lib/components/mods-list/ModsList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
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 TagOption, filter, filterOptions, order, search, selectedTags } from '$lib/store/modFiltersStore';
import { offline, startView, tagSearchMode } from '$lib/store/settingsStore';
import { OfflineGetMods } from '$wailsjs/go/ficsitcli/ficsitCLI';

const dispatch = createEventDispatcher();
Expand Down Expand Up @@ -92,6 +92,18 @@

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

$: availableTags = ((): TagOption[] => {
const tagNames = new Set<string>();
for (const mod of knownMods) {
if ('tags' in mod && mod.tags) {
for (const t of mod.tags) {
tagNames.add(t.name);
}
}
}
return [...tagNames].sort((a, b) => a.localeCompare(b)).map((name) => ({ id: name, name }));
})();

let filteredMods: PartialMod[] = [];
let filteringMods = false;
$: {
Expand Down Expand Up @@ -121,13 +133,26 @@
sortedMods = _.sortBy(filteredMods, $order.func) as PartialMod[];
}

$: selectedTagIds = new Set($selectedTags.map((t) => t.id));
$: tagFilteredMods = (() => {
if (selectedTagIds.size === 0) return sortedMods;
return sortedMods.filter((mod) => {
if (!('tags' in mod) || !mod.tags) return false;
const modTagNames = new Set(mod.tags.map((t) => t.name));
if ($tagSearchMode === 'and') {
return [...selectedTagIds].every((id) => modTagNames.has(id));
}
return [...selectedTagIds].some((id) => modTagNames.has(id));
});
})();

let displayMods: PartialMod[] = [];
$: if(!$search) {
displayMods = sortedMods;
displayMods = tagFilteredMods;
} else {
const modifiedSearchString = $search.replace(/(?:author:"(.+?)"|author:([^\s"]+))/g, '="$1$2"');

const fuse = new Fuse(sortedMods, {
const fuse = new Fuse(tagFilteredMods, {
keys: [
{
name: 'name',
Expand Down Expand Up @@ -165,20 +190,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 +222,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
110 changes: 108 additions & 2 deletions frontend/src/lib/components/mods-list/ModsListFilters.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,49 @@
<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, type TagOption, filter, filterOptions, order, orderByOptions, search, selectedTags } from '$lib/store/modFiltersStore';
import { tagSearchMode } from '$lib/store/settingsStore';

export let availableTags: TagOption[] = [];
$: selectedTagIds = new Set($selectedTags.map((t) => t.id));
$: if ($selectedTags.length > 0) {
$selectedTags = $selectedTags.filter((t) => availableTags.some((a) => a.id === t.id));
}

let _tagPopupOpen = false;
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 },
},
state: ({ state }) => (_tagPopupOpen = state),
};

function toggleTag(tag: TagOption) {
if (selectedTagIds.has(tag.id)) {
$selectedTags = $selectedTags.filter((t) => t.id !== tag.id);
} else {
$selectedTags = [...$selectedTags, tag];
}
}

const { t } = getTranslate();

Expand Down Expand Up @@ -45,6 +83,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 === 'and' ? 'text-primary-600 font-medium bg-surface-300/20' : 'text-surface-400-700-token hover:bg-surface-300/20'}"
aria-pressed={$tagSearchMode === 'and'}
type="button"
on:click|stopPropagation={() => tagSearchMode.set('and')}
>
{$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 === 'any' ? 'text-primary-600 font-medium bg-surface-300/20' : 'text-surface-400-700-token hover:bg-surface-300/20'}"
aria-pressed={$tagSearchMode === 'any'}
type="button"
on:click|stopPropagation={() => tagSearchMode.set('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 {selectedTagIds.has(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={selectedTagIds.has(tag.id)}
role="option"
type="button"
on:click={() => toggleTag(tag)}
>
{#if selectedTagIds.has(tag.id)}
<span class="text-primary-600 font-medium" aria-hidden="true">✓</span>
{/if}
<span class="{selectedTagIds.has(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
8 changes: 8 additions & 0 deletions frontend/src/lib/store/modFiltersStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ export interface MissingMod {
export type PartialMod = PartialSMRMod | OfflineMod | MissingMod;

export const search = writable('');

export interface TagOption {
id: string;
name: string;
}

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

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 @@ -10,6 +10,7 @@ import {
GetKonami,
GetLanguage,
GetLaunchButton,
GetModFiltersTagSearchMode,
GetProxy,
GetQueueAutoStart,
GetRestoreWindowPosition,
Expand All @@ -21,6 +22,7 @@ import {
SetKonami,
SetLanguage,
SetLaunchButton,
SetModFiltersTagSearchMode,
SetProxy,
SetQueueAutoStart, SetRestoreWindowPosition,
SetStartView,
Expand All @@ -29,6 +31,13 @@ import {

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

export type TagSearchMode = 'any' | 'and';
export const tagSearchMode = bindingTwoWayNoExcept<TagSearchMode>(
'any',
{ initialGet: () => GetModFiltersTagSearchMode().then((m) => (m === 'and' ? 'and' : 'any')) },
{ updateFunction: SetModFiltersTagSearchMode },
);

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

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