diff --git a/backend/endpoints/rom.py b/backend/endpoints/rom.py index 1a9218d8b..0ff26dfee 100644 --- a/backend/endpoints/rom.py +++ b/backend/endpoints/rom.py @@ -1,5 +1,6 @@ import binascii import json +import logging from base64 import b64encode from datetime import datetime, timezone from io import BytesIO @@ -258,9 +259,10 @@ def get_roms( description=( "Associated genre. Multiple values are allowed by repeating the" " parameter, and results that match any of the values will be" - " returned." + " returned. Maximum 50 values allowed." ), alias="genre", + max_length=50, ), ] = None, franchises: Annotated[ @@ -269,9 +271,10 @@ def get_roms( description=( "Associated franchise. Multiple values are allowed by repeating" " the parameter, and results that match any of the values will" - " be returned." + " be returned. Maximum 50 values allowed." ), alias="franchise", + max_length=50, ), ] = None, collections: Annotated[ @@ -280,9 +283,10 @@ def get_roms( description=( "Associated collection. Multiple values are allowed by" " repeating the parameter, and results that match any of the" - " values will be returned." + " values will be returned. Maximum 50 values allowed." ), alias="collection", + max_length=50, ), ] = None, companies: Annotated[ @@ -291,9 +295,10 @@ def get_roms( description=( "Associated company. Multiple values are allowed by repeating" " the parameter, and results that match any of the values will" - " be returned." + " be returned. Maximum 50 values allowed." ), alias="company", + max_length=50, ), ] = None, age_ratings: Annotated[ @@ -302,9 +307,10 @@ def get_roms( description=( "Associated age rating. Multiple values are allowed by" " repeating the parameter, and results that match any of the" - " values will be returned." + " values will be returned. Maximum 20 values allowed." ), alias="age_rating", + max_length=20, ), ] = None, selected_status: Annotated[ @@ -317,9 +323,10 @@ def get_roms( description=( "Associated region tag. Multiple values are allowed by" " repeating the parameter, and results that match any of the" - " values will be returned." + " values will be returned. Maximum 30 values allowed." ), alias="region", + max_length=30, ), ] = None, languages: Annotated[ @@ -328,9 +335,10 @@ def get_roms( description=( "Associated language tag. Multiple values are allowed by" " repeating the parameter, and results that match any of the" - " values will be returned." + " values will be returned. Maximum 30 values allowed." ), alias="language", + max_length=30, ), ] = None, order_by: Annotated[ @@ -344,6 +352,43 @@ def get_roms( ) -> CustomLimitOffsetPage[SimpleRomSchema]: """Retrieve roms.""" + # Sanitize and validate filter arrays + def sanitize_filter_array( + values: list[str] | None, max_length: int = 50, filter_name: str = "" + ) -> list[str] | None: + """Sanitize filter array by removing empty strings and limiting size.""" + if not values: + return None + + try: + # Remove empty/whitespace-only values and strip whitespace + sanitized = [ + v.strip() for v in values if v and isinstance(v, str) and v.strip() + ] + if not sanitized: + return None + + # Limit array size to prevent abuse + if len(sanitized) > max_length: + logging.warning( + f"Filter array '{filter_name}' truncated from {len(sanitized)} to {max_length} items" + ) + sanitized = sanitized[:max_length] + + return sanitized + except Exception as e: + logging.error(f"Error sanitizing filter array '{filter_name}': {e}") + return None + + # Apply sanitization with specific limits and names + genres = sanitize_filter_array(genres, 50, "genres") + franchises = sanitize_filter_array(franchises, 50, "franchises") + collections = sanitize_filter_array(collections, 50, "collections") + companies = sanitize_filter_array(companies, 50, "companies") + age_ratings = sanitize_filter_array(age_ratings, 20, "age_ratings") + regions = sanitize_filter_array(regions, 30, "regions") + languages = sanitize_filter_array(languages, 30, "languages") + # Get the base roms query query, order_by_attr = db_rom_handler.get_roms_query( user_id=request.user.id, diff --git a/backend/handler/database/collections_handler.py b/backend/handler/database/collections_handler.py index c5fefe822..974e87279 100644 --- a/backend/handler/database/collections_handler.py +++ b/backend/handler/database/collections_handler.py @@ -213,36 +213,23 @@ class DBCollectionsHandler(DBBaseHandler): # Extract filter criteria criteria = smart_collection.filter_criteria - # Backwards compatibility with old criteria names - genres = criteria.get("genres") or ( - [criteria["selected_genre"]] if criteria.get("selected_genre") else None - ) - franchises = criteria.get("franchises") or ( - [criteria["selected_franchise"]] - if criteria.get("selected_franchise") - else None - ) - collections = criteria.get("collections") or ( - [criteria["selected_collection"]] - if criteria.get("selected_collection") - else None - ) - companies = criteria.get("companies") or ( - [criteria["selected_company"]] if criteria.get("selected_company") else None - ) - age_ratings = criteria.get("age_ratings") or ( - [criteria["selected_age_rating"]] - if criteria.get("selected_age_rating") - else None - ) - regions = criteria.get("regions") or ( - [criteria["selected_region"]] if criteria.get("selected_region") else None - ) - languages = criteria.get("languages") or ( - [criteria["selected_language"]] - if criteria.get("selected_language") - else None - ) + # Convert legacy single-value criteria to arrays for backward compatibility + def convert_legacy_filter(new_key: str, old_key: str) -> list[str] | None: + """Convert legacy single-value filter to array format.""" + if new_value := criteria.get(new_key): + return new_value if isinstance(new_value, list) else [new_value] + if old_value := criteria.get(old_key): + return [old_value] + return None + + # Apply conversions + genres = convert_legacy_filter("genres", "selected_genre") + franchises = convert_legacy_filter("franchises", "selected_franchise") + collections = convert_legacy_filter("collections", "selected_collection") + companies = convert_legacy_filter("companies", "selected_company") + age_ratings = convert_legacy_filter("age_ratings", "selected_age_rating") + regions = convert_legacy_filter("regions", "selected_region") + languages = convert_legacy_filter("languages", "selected_language") # Use the existing filter_roms method with the stored criteria return db_rom_handler.get_roms_scalar( diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index f971f147e..cd57f6ec7 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -587,9 +587,15 @@ class DBRomsHandler(DBBaseHandler): ) ) - if genres or franchises or collections or companies or age_ratings: + # Optimize JOINs - only join tables when needed + needs_metadata_join = any( + [genres, franchises, collections, companies, age_ratings] + ) + + if needs_metadata_join: query = query.outerjoin(RomMetadata) + # Apply metadata filters efficiently if genres: query = self.filter_by_genres(query, session=session, values=genres) if franchises: @@ -604,6 +610,8 @@ class DBRomsHandler(DBBaseHandler): query = self.filter_by_age_ratings( query, session=session, values=age_ratings ) + + # Apply rom-level filters if regions: query = self.filter_by_regions(query, session=session, values=regions) if languages: diff --git a/backend/utils/database.py b/backend/utils/database.py index d923f8ba8..e1dcfa948 100644 --- a/backend/utils/database.py +++ b/backend/utils/database.py @@ -44,7 +44,7 @@ def is_mariadb(conn: sa.Connection, min_version: tuple[int, ...] | None = None) def json_array_contains_value( - column: sa.Column, value: str | int, *, session: Session + column: sa.Column | Any, value: str | int, *, session: Session ) -> ColumnElement: """Check if a JSON array column contains the given value.""" conn = session.get_bind() @@ -66,12 +66,16 @@ def json_array_contains_value( def json_array_contains_any( - column: sa.Column, values: Sequence[str] | Sequence[int], *, session: Session + column: sa.Column | Any, values: Sequence[str] | Sequence[int], *, session: Session ) -> ColumnElement: """Check if a JSON array column contains any of the given values.""" if not values: return sa.false() + # Optimize for single value case + if len(values) == 1: + return json_array_contains_value(column, values[0], session=session) + conn = session.get_bind() if is_postgresql(conn): # In PostgreSQL, string arrays can be checked for overlap using the `?|` operator. @@ -98,7 +102,7 @@ def json_array_contains_any( def json_array_contains_all( - column: sa.Column, values: Sequence[Any], *, session: Session + column: sa.Column | Any, values: Sequence[Any], *, session: Session ) -> ColumnElement: """Check if a JSON array column contains all of the given values.""" if not values: diff --git a/frontend/src/components/Details/MultiNoteManager.vue b/frontend/src/components/Details/MultiNoteManager.vue index a716a7fb1..5263d8fb2 100644 --- a/frontend/src/components/Details/MultiNoteManager.vue +++ b/frontend/src/components/Details/MultiNoteManager.vue @@ -499,15 +499,25 @@ watch( - - {{ t("common.cancel") }} - - {{ t("common.add") }} - + + + {{ + t("common.cancel") + }} + + {{ t("common.add") }} + + + diff --git a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue index f727b7d0c..cd33af854 100644 --- a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue +++ b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/Base.vue @@ -9,11 +9,10 @@ import { useDisplay } from "vuetify"; import SearchTextField from "@/components/Gallery/AppBar/Search/SearchTextField.vue"; import FilterDuplicatesBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterDuplicatesBtn.vue"; import FilterFavoritesBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterFavoritesBtn.vue"; -import FilterMatchedBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterMatchedBtn.vue"; +import FilterMatchStateBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterMatchStateBtn.vue"; import FilterMissingBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterMissingBtn.vue"; import FilterPlayablesBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterPlayablesBtn.vue"; import FilterRaBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterRaBtn.vue"; -import FilterUnmatchedBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterUnmatchedBtn.vue"; import FilterVerifiedBtn from "@/components/Gallery/AppBar/common/FilterDrawer/FilterVerifiedBtn.vue"; import MissingFromFSIcon from "@/components/common/MissingFromFSIcon.vue"; import PlatformIcon from "@/components/common/Platform/PlatformIcon.vue"; @@ -317,7 +316,7 @@ onMounted(async () => { v-model="activeFilterDrawer" mobile floating - width="400" + width="500" :class="{ 'ml-2': activeFilterDrawer, 'drawer-mobile': smAndDown && activeFilterDrawer, @@ -331,11 +330,7 @@ onMounted(async () => { - - + import type { Emitter } from "mitt"; import { storeToRefs } from "pinia"; +import { computed } from "vue"; import { inject } from "vue"; import { useI18n } from "vue-i18n"; import storeGalleryFilter from "@/stores/galleryFilter"; @@ -13,29 +14,81 @@ const galleryFilterStore = storeGalleryFilter(); const { fetchTotalRoms } = storeToRefs(romsStore); const { filterDuplicates } = storeToRefs(galleryFilterStore); const emitter = inject>("emitter"); -function setDuplicates() { - galleryFilterStore.switchFilterDuplicates(); + +// Computed property to determine current state +const currentState = computed(() => { + if (filterDuplicates.value === true) return "duplicates"; + if (filterDuplicates.value === false) return "not-duplicates"; + return "all"; // null +}); + +// Handler for state changes +function setState(state: string | null) { + if (!state) return; + + galleryFilterStore.setFilterDuplicatesState( + state as "all" | "duplicates" | "not-duplicates", + ); emitter?.emit("filterRoms", null); } - - - mdi-card-multiple - - {{ t("platform.show-duplicates") }} - - + + + mdi-card-multiple + + + {{ t("platform.show-duplicates") }} + + + + All + + + + mdi-card-multiple + + + + + + + mdi-card-outline + + + + + diff --git a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterFavoritesBtn.vue b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterFavoritesBtn.vue index 0e505bbd7..1c8f8afc1 100644 --- a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterFavoritesBtn.vue +++ b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterFavoritesBtn.vue @@ -1,6 +1,7 @@ - - mdi-star {{ t("platform.show-favorites") }} + + mdi-star + + + {{ t("platform.show-favorites") }} + + + - + All + + + + mdi-star + + + + + + + mdi-star-outline + + + + + diff --git a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterMatchStateBtn.vue b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterMatchStateBtn.vue new file mode 100644 index 000000000..2ad73284c --- /dev/null +++ b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterMatchStateBtn.vue @@ -0,0 +1,86 @@ + + + + + + + mdi-file-search-outline + + + Match Status + + + + All + + + + mdi-file-find + + + + + + + mdi-file-find-outline + + + + + + diff --git a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterMatchedBtn.vue b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterMatchedBtn.vue deleted file mode 100644 index 660a0aecf..000000000 --- a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterMatchedBtn.vue +++ /dev/null @@ -1,35 +0,0 @@ - - - - - mdi-file-find {{ t("platform.show-matched") }} - - diff --git a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterMissingBtn.vue b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterMissingBtn.vue index bf5ce35eb..8224b1414 100644 --- a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterMissingBtn.vue +++ b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterMissingBtn.vue @@ -1,6 +1,7 @@ - - - mdi-folder-question {{ t("platform.show-missing") }} + + mdi-folder-question + + + {{ t("platform.show-missing") }} + + + - + All + + + + mdi-folder-question + + + + + + + mdi-folder-check + + + + + diff --git a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterPlayablesBtn.vue b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterPlayablesBtn.vue index bcf82f1cf..a1239af3f 100644 --- a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterPlayablesBtn.vue +++ b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterPlayablesBtn.vue @@ -1,6 +1,7 @@ - - mdi-play - {{ t("platform.show-playables") }} - - + + + mdi-play + + + {{ t("platform.show-playables") }} + + + + All + + + + mdi-play + + + + + + + mdi-play-outline + + + + + diff --git a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterRaBtn.vue b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterRaBtn.vue index a97996f12..c79bee843 100644 --- a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterRaBtn.vue +++ b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterRaBtn.vue @@ -1,6 +1,7 @@ - - mdi-trophy - {{ t("platform.show-ra") }} - - + + + mdi-trophy + + + {{ t("platform.show-ra") }} + + + + All + + + + mdi-trophy + + + + + + + mdi-trophy-outline + + + + + diff --git a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterUnmatchedBtn.vue b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterUnmatchedBtn.vue deleted file mode 100644 index 82158d970..000000000 --- a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterUnmatchedBtn.vue +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - mdi-file-find-outline {{ t("platform.show-unmatched") }} - - diff --git a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterVerifiedBtn.vue b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterVerifiedBtn.vue index e4ef84288..47b52a40d 100644 --- a/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterVerifiedBtn.vue +++ b/frontend/src/components/Gallery/AppBar/common/FilterDrawer/FilterVerifiedBtn.vue @@ -1,6 +1,7 @@ - - - mdi-check-decagram {{ t("platform.show-verified") }} + + mdi-check-decagram + + + {{ t("platform.show-verified") }} + + + - + All + + + + mdi-check-decagram + + + + + + + mdi-check-decagram-outline + + + + + diff --git a/frontend/src/components/common/Collection/Dialog/CreateSmartCollection.vue b/frontend/src/components/common/Collection/Dialog/CreateSmartCollection.vue index 9244a5987..3c2b507f0 100644 --- a/frontend/src/components/common/Collection/Dialog/CreateSmartCollection.vue +++ b/frontend/src/components/common/Collection/Dialog/CreateSmartCollection.vue @@ -90,6 +90,10 @@ const filterSummary = computed(() => { return filters || ["No filters applied"]; }); +function toggleCollectionVisibility() { + isPublic.value = !isPublic.value; +} + function closeDialog() { show.value = false; name.value = ""; @@ -212,12 +216,16 @@ async function createSmartCollection() { - + + + {{ isPublic ? "mdi-lock-open-variant" : "mdi-lock" }} + + {{ isPublic ? t("rom.public") : t("rom.private") }} + @@ -242,8 +250,7 @@ async function createSmartCollection() { - - + diff --git a/frontend/src/services/api/rom.ts b/frontend/src/services/api/rom.ts index 319b6403f..c5eb086eb 100644 --- a/frontend/src/services/api/rom.ts +++ b/frontend/src/services/api/rom.ts @@ -72,12 +72,12 @@ export interface GetRomsParams { orderDir?: string | null; filterUnmatched?: boolean; filterMatched?: boolean; - filterFavorites?: boolean; - filterDuplicates?: boolean; - filterPlayables?: boolean; - filterRA?: boolean; - filterMissing?: boolean; - filterVerified?: boolean; + filterFavorites?: boolean | null; + filterDuplicates?: boolean | null; + filterPlayables?: boolean | null; + filterRA?: boolean | null; + filterMissing?: boolean | null; + filterVerified?: boolean | null; groupByMetaId?: boolean; selectedGenre?: string | null; selectedFranchise?: string | null; @@ -139,12 +139,12 @@ async function getRoms({ language: selectedLanguage, ...(filterUnmatched ? { matched: false } : {}), ...(filterMatched ? { matched: true } : {}), - ...(filterFavorites ? { favorite: true } : {}), - ...(filterDuplicates ? { duplicate: true } : {}), - ...(filterPlayables ? { playable: true } : {}), - ...(filterMissing ? { missing: true } : {}), - ...(filterRA ? { has_ra: true } : {}), - ...(filterVerified ? { verified: true } : {}), + ...(filterFavorites !== null ? { favorite: filterFavorites } : {}), + ...(filterDuplicates !== null ? { duplicate: filterDuplicates } : {}), + ...(filterPlayables !== null ? { playable: filterPlayables } : {}), + ...(filterMissing !== null ? { missing: filterMissing } : {}), + ...(filterRA !== null ? { has_ra: filterRA } : {}), + ...(filterVerified !== null ? { verified: filterVerified } : {}), }, }); } diff --git a/frontend/src/services/cache/api.ts b/frontend/src/services/cache/api.ts index a97cbe92c..a4588b011 100644 --- a/frontend/src/services/cache/api.ts +++ b/frontend/src/services/cache/api.ts @@ -50,12 +50,22 @@ class CachedApiService { selected_language: params.selectedLanguage, ...(params.filterUnmatched ? { matched: false } : {}), ...(params.filterMatched ? { matched: true } : {}), - ...(params.filterFavorites ? { favorite: true } : {}), - ...(params.filterDuplicates ? { duplicate: true } : {}), - ...(params.filterPlayables ? { playable: true } : {}), - ...(params.filterMissing ? { missing: true } : {}), - ...(params.filterRA ? { has_ra: true } : {}), - ...(params.filterVerified ? { verified: true } : {}), + ...(params.filterFavorites !== null + ? { favorite: params.filterFavorites } + : {}), + ...(params.filterDuplicates !== null + ? { duplicate: params.filterDuplicates } + : {}), + ...(params.filterPlayables !== null + ? { playable: params.filterPlayables } + : {}), + ...(params.filterMissing !== null + ? { missing: params.filterMissing } + : {}), + ...(params.filterRA !== null ? { has_ra: params.filterRA } : {}), + ...(params.filterVerified !== null + ? { verified: params.filterVerified } + : {}), }); return cacheService.request(config, onBackgroundUpdate); diff --git a/frontend/src/stores/galleryFilter.ts b/frontend/src/stores/galleryFilter.ts index f3152342a..0863ff8f0 100644 --- a/frontend/src/stores/galleryFilter.ts +++ b/frontend/src/stores/galleryFilter.ts @@ -28,12 +28,12 @@ const defaultFilterState = { filterStatuses: Object.values(romStatusMap).map((status) => status.text), filterUnmatched: false, filterMatched: false, - filterFavorites: false, - filterDuplicates: false, - filterPlayables: false, - filterRA: false, - filterMissing: false, - filterVerified: false, + filterFavorites: null as boolean | null, // null = all, true = favorites, false = not favorites + filterDuplicates: null as boolean | null, // null = all, true = duplicates, false = not duplicates + filterPlayables: null as boolean | null, // null = all, true = playables, false = not playables + filterRA: null as boolean | null, // null = all, true = has RA, false = no RA + filterMissing: null as boolean | null, // null = all, true = missing, false = not missing + filterVerified: null as boolean | null, // null = all, true = verified, false = not verified selectedPlatform: null as Platform | null, selectedGenre: null as string | null, selectedFranchise: null as string | null, @@ -121,52 +121,166 @@ export default defineStore("galleryFilter", { this.filterMatched = !this.filterMatched; this.filterUnmatched = false; }, - setFilterFavorites(value: boolean) { + setFilterFavorites(value: boolean | null) { this.filterFavorites = value; }, - switchFilterFavorites() { - this.filterFavorites = !this.filterFavorites; + setFilterFavoritesState(state: "all" | "favorites" | "not-favorites") { + switch (state) { + case "favorites": + this.filterFavorites = true; + break; + case "not-favorites": + this.filterFavorites = false; + break; + default: // "all" + this.filterFavorites = null; + break; + } }, - setFilterDuplicates(value: boolean) { + switchFilterFavorites() { + if (this.filterFavorites === null) { + this.filterFavorites = true; + } else if (this.filterFavorites === true) { + this.filterFavorites = false; + } else { + this.filterFavorites = null; + } + }, + setFilterDuplicates(value: boolean | null) { this.filterDuplicates = value; }, - switchFilterDuplicates() { - this.filterDuplicates = !this.filterDuplicates; + setFilterDuplicatesState(state: "all" | "duplicates" | "not-duplicates") { + switch (state) { + case "duplicates": + this.filterDuplicates = true; + break; + case "not-duplicates": + this.filterDuplicates = false; + break; + default: // "all" + this.filterDuplicates = null; + break; + } }, - setFilterPlayables(value: boolean) { + switchFilterDuplicates() { + if (this.filterDuplicates === null) { + this.filterDuplicates = true; + } else if (this.filterDuplicates === true) { + this.filterDuplicates = false; + } else { + this.filterDuplicates = null; + } + }, + setFilterPlayables(value: boolean | null) { this.filterPlayables = value; }, - switchFilterPlayables() { - this.filterPlayables = !this.filterPlayables; + setFilterPlayablesState(state: "all" | "playables" | "not-playables") { + switch (state) { + case "playables": + this.filterPlayables = true; + break; + case "not-playables": + this.filterPlayables = false; + break; + default: // "all" + this.filterPlayables = null; + break; + } }, - setFilterRA(value: boolean) { + switchFilterPlayables() { + if (this.filterPlayables === null) { + this.filterPlayables = true; + } else if (this.filterPlayables === true) { + this.filterPlayables = false; + } else { + this.filterPlayables = null; + } + }, + setFilterRA(value: boolean | null) { this.filterRA = value; }, - switchFilterRA() { - this.filterRA = !this.filterRA; + setFilterRAState(state: "all" | "has-ra" | "no-ra") { + switch (state) { + case "has-ra": + this.filterRA = true; + break; + case "no-ra": + this.filterRA = false; + break; + default: // "all" + this.filterRA = null; + break; + } }, - setFilterMissing(value: boolean) { + switchFilterRA() { + if (this.filterRA === null) { + this.filterRA = true; + } else if (this.filterRA === true) { + this.filterRA = false; + } else { + this.filterRA = null; + } + }, + setFilterMissing(value: boolean | null) { this.filterMissing = value; }, - switchFilterMissing() { - this.filterMissing = !this.filterMissing; + setFilterMissingState(state: "all" | "missing" | "not-missing") { + switch (state) { + case "missing": + this.filterMissing = true; + break; + case "not-missing": + this.filterMissing = false; + break; + default: // "all" + this.filterMissing = null; + break; + } }, - setFilterVerified(value: boolean) { + switchFilterMissing() { + if (this.filterMissing === null) { + this.filterMissing = true; + } else if (this.filterMissing === true) { + this.filterMissing = false; + } else { + this.filterMissing = null; + } + }, + setFilterVerified(value: boolean | null) { this.filterVerified = value; }, + setFilterVerifiedState(state: "all" | "verified" | "not-verified") { + switch (state) { + case "verified": + this.filterVerified = true; + break; + case "not-verified": + this.filterVerified = false; + break; + default: // "all" + this.filterVerified = null; + break; + } + }, switchFilterVerified() { - this.filterVerified = !this.filterVerified; + if (this.filterVerified === null) { + this.filterVerified = true; + } else if (this.filterVerified === true) { + this.filterVerified = false; + } else { + this.filterVerified = null; + } }, isFiltered() { return Boolean( this.filterUnmatched || this.filterMatched || - this.filterFavorites || - this.filterDuplicates || - this.filterPlayables || - this.filterRA || - this.filterMissing || - this.filterVerified || + this.filterFavorites !== null || + this.filterDuplicates !== null || + this.filterPlayables !== null || + this.filterRA !== null || + this.filterMissing !== null || + this.filterVerified !== null || this.selectedPlatform || this.selectedGenre || this.selectedFranchise || @@ -193,12 +307,12 @@ export default defineStore("galleryFilter", { this.selectedStatus = null; this.filterUnmatched = false; this.filterMatched = false; - this.filterFavorites = false; - this.filterDuplicates = false; - this.filterPlayables = false; - this.filterRA = false; - this.filterMissing = false; - this.filterVerified = false; + this.filterFavorites = null; + this.filterDuplicates = null; + this.filterPlayables = null; + this.filterRA = null; + this.filterMissing = null; + this.filterVerified = null; }, }, });