Refactor gallery filter components to use toggle buttons for filter states, allowing null values for all filters. Update filter logic in the store and API services to accommodate new states. Enhance UI for better visibility and interaction with filter options in the gallery app.

This commit is contained in:
zurdi
2025-11-27 12:56:01 +00:00
parent 1cec07af11
commit c6717ee635
19 changed files with 777 additions and 290 deletions

View File

@@ -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,

View File

@@ -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(

View File

@@ -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:

View File

@@ -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:

View File

@@ -499,15 +499,25 @@ watch(
</div>
</template>
<template #footer>
<v-spacer />
<v-btn @click="closeAddNote">{{ t("common.cancel") }}</v-btn>
<v-btn
color="primary"
:disabled="!newNoteTitle.trim() || newNoteTitleErrors.length > 0"
@click="addNewNote"
>
{{ t("common.add") }}
</v-btn>
<v-row class="justify-center pa-2" no-gutters>
<v-btn-group divided density="compact">
<v-btn class="bg-toplayer" @click="closeAddNote">{{
t("common.cancel")
}}</v-btn>
<v-btn
class="bg-toplayer text-romm-green"
:disabled="!newNoteTitle.trim() || newNoteTitleErrors.length > 0"
:variant="
!newNoteTitle.trim() || newNoteTitleErrors.length > 0
? 'plain'
: 'flat'
"
@click="addNewNote"
>
{{ t("common.add") }}
</v-btn>
</v-btn-group>
</v-row>
</template>
</RDialog>

View File

@@ -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 () => {
</v-list-item>
</template>
<v-list-item>
<FilterUnmatchedBtn :tabindex="activeFilterDrawer ? 0 : -1" />
<FilterMatchedBtn
class="mt-2"
:tabindex="activeFilterDrawer ? 0 : -1"
/>
<FilterMatchStateBtn :tabindex="activeFilterDrawer ? 0 : -1" />
<FilterFavoritesBtn
class="mt-2"
:tabindex="activeFilterDrawer ? 0 : -1"

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
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<Events>>("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);
}
</script>
<template>
<v-btn
block
variant="tonal"
:color="filterDuplicates ? 'primary' : ''"
:disabled="fetchTotalRoms > 10000"
@click="setDuplicates"
<div
class="d-flex align-center justify-space-between py-2 px-4 rounded-lg border"
:class="{ 'opacity-50': fetchTotalRoms > 10000 }"
>
<v-icon :color="filterDuplicates ? 'primary' : ''">
mdi-card-multiple
</v-icon>
<span
class="ml-2"
:class="{
'text-primary': filterDuplicates,
}"
>{{ t("platform.show-duplicates") }}
</span>
</v-btn>
<div class="d-flex align-center">
<v-icon
:color="currentState !== 'all' ? 'primary' : 'grey-lighten-1'"
class="mr-3"
>
mdi-card-multiple
</v-icon>
<span
:class="
currentState !== 'all'
? 'text-primary font-weight-medium'
: 'text-medium-emphasis'
"
class="text-body-1"
>
{{ t("platform.show-duplicates") }}
</span>
</div>
<v-btn-toggle
:model-value="currentState"
color="primary"
density="compact"
variant="outlined"
:disabled="fetchTotalRoms > 10000"
@update:model-value="setState"
>
<v-btn value="all" size="small">All</v-btn>
<v-tooltip text="Show duplicate ROMs only" location="bottom">
<template #activator="{ props }">
<v-btn
value="duplicates"
size="small"
v-bind="props"
:disabled="fetchTotalRoms > 10000"
>
<v-icon size="x-large">mdi-card-multiple</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip text="Show non-duplicate ROMs only" location="bottom">
<template #activator="{ props }">
<v-btn
value="not-duplicates"
size="small"
v-bind="props"
:disabled="fetchTotalRoms > 10000"
>
<v-icon size="x-large">mdi-card-outline</v-icon>
</v-btn>
</template>
</v-tooltip>
</v-btn-toggle>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
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";
@@ -10,26 +11,69 @@ const { t } = useI18n();
const galleryFilterStore = storeGalleryFilter();
const { filterFavorites } = storeToRefs(galleryFilterStore);
const emitter = inject<Emitter<Events>>("emitter");
function setFavorites() {
galleryFilterStore.switchFilterFavorites();
// Computed property to determine current state
const currentState = computed(() => {
if (filterFavorites.value === true) return "favorites";
if (filterFavorites.value === false) return "not-favorites";
return "all"; // null
});
// Handler for state changes
function setState(state: string | null) {
if (!state) return;
galleryFilterStore.setFilterFavoritesState(
state as "all" | "favorites" | "not-favorites",
);
emitter?.emit("filterRoms", null);
}
</script>
<template>
<v-btn
block
variant="tonal"
:color="filterFavorites ? 'primary' : ''"
@click="setFavorites"
<div
class="d-flex align-center justify-space-between py-2 px-4 rounded-lg border"
>
<v-icon :color="filterFavorites ? 'primary' : ''"> mdi-star </v-icon
><span
class="ml-2"
:class="{
'text-primary': filterFavorites,
}"
>{{ t("platform.show-favorites") }}</span
<div class="d-flex align-center">
<v-icon
:color="currentState !== 'all' ? 'primary' : 'grey-lighten-1'"
class="mr-3"
>
mdi-star
</v-icon>
<span
:class="
currentState !== 'all'
? 'text-primary font-weight-medium'
: 'text-medium-emphasis'
"
class="text-body-1"
>
{{ t("platform.show-favorites") }}
</span>
</div>
<v-btn-toggle
:model-value="currentState"
color="primary"
density="compact"
variant="outlined"
@update:model-value="setState"
>
</v-btn>
<v-btn value="all" size="small">All</v-btn>
<v-tooltip text="Show favorite ROMs only" location="bottom">
<template #activator="{ props }">
<v-btn value="favorites" size="small" v-bind="props">
<v-icon size="x-large">mdi-star</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip text="Show non-favorite ROMs only" location="bottom">
<template #activator="{ props }">
<v-btn value="not-favorites" size="small" v-bind="props">
<v-icon size="x-large">mdi-star-outline</v-icon>
</v-btn>
</template>
</v-tooltip>
</v-btn-toggle>
</div>
</template>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
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";
import type { Events } from "@/types/emitter";
const { t } = useI18n();
const galleryFilterStore = storeGalleryFilter();
const { filterMatched, filterUnmatched } = storeToRefs(galleryFilterStore);
const emitter = inject<Emitter<Events>>("emitter");
// Computed property to determine current state
const matchState = computed(() => {
if (filterMatched.value) return "matched";
if (filterUnmatched.value) return "unmatched";
return "all";
});
// Handler for state changes
function setMatchState(state: string) {
switch (state) {
case "matched":
galleryFilterStore.setFilterMatched(true);
break;
case "unmatched":
galleryFilterStore.setFilterUnmatched(true);
break;
default: // "all"
galleryFilterStore.setFilterMatched(false);
galleryFilterStore.setFilterUnmatched(false);
break;
}
emitter?.emit("filterRoms", null);
}
</script>
<template>
<div
class="d-flex align-center justify-space-between py-2 px-4 rounded-lg border"
>
<div class="d-flex align-center">
<v-icon
:color="matchState !== 'all' ? 'primary' : 'grey-lighten-1'"
class="mr-3"
>
mdi-file-search-outline
</v-icon>
<span
:class="
matchState !== 'all'
? 'text-primary font-weight-medium'
: 'text-medium-emphasis'
"
class="text-body-1"
>
Match Status
</span>
</div>
<v-btn-toggle
:model-value="matchState"
color="primary"
density="compact"
variant="outlined"
@update:model-value="setMatchState"
>
<v-btn value="all" size="small"> All </v-btn>
<v-tooltip :text="t('platform.show-matched')" location="bottom">
<template #activator="{ props }">
<v-btn value="matched" size="small" v-bind="props">
<v-icon size="x-large">mdi-file-find</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip :text="t('platform.show-unmatched')" location="bottom">
<template #activator="{ props }">
<v-btn value="unmatched" size="small" v-bind="props">
<v-icon size="x-large">mdi-file-find-outline</v-icon>
</v-btn>
</template>
</v-tooltip>
</v-btn-toggle>
</div>
</template>

View File

@@ -1,35 +0,0 @@
<script setup lang="ts">
import type { Emitter } from "mitt";
import { storeToRefs } from "pinia";
import { inject } from "vue";
import { useI18n } from "vue-i18n";
import storeGalleryFilter from "@/stores/galleryFilter";
import type { Events } from "@/types/emitter";
const { t } = useI18n();
const galleryFilterStore = storeGalleryFilter();
const { filterMatched } = storeToRefs(galleryFilterStore);
const emitter = inject<Emitter<Events>>("emitter");
function setUnmatched() {
galleryFilterStore.switchFilterMatched();
emitter?.emit("filterRoms", null);
}
</script>
<template>
<v-btn
block
variant="tonal"
:color="filterMatched ? 'primary' : ''"
@click="setUnmatched"
>
<v-icon :color="filterMatched ? 'primary' : ''"> mdi-file-find </v-icon
><span
class="ml-2"
:class="{
'text-primary': filterMatched,
}"
>{{ t("platform.show-matched") }}</span
>
</v-btn>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
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";
@@ -10,27 +11,69 @@ const { t } = useI18n();
const galleryFilterStore = storeGalleryFilter();
const { filterMissing } = storeToRefs(galleryFilterStore);
const emitter = inject<Emitter<Events>>("emitter");
function setMissing() {
galleryFilterStore.switchFilterMissing();
// Computed property to determine current state
const currentState = computed(() => {
if (filterMissing.value === true) return "missing";
if (filterMissing.value === false) return "not-missing";
return "all"; // null
});
// Handler for state changes
function setState(state: string | null) {
if (!state) return;
galleryFilterStore.setFilterMissingState(
state as "all" | "missing" | "not-missing",
);
emitter?.emit("filterRoms", null);
}
</script>
<template>
<v-btn
block
variant="tonal"
:color="filterMissing ? 'primary' : ''"
@click="setMissing"
<div
class="d-flex align-center justify-space-between py-2 px-4 rounded-lg border"
>
<v-icon :color="filterMissing ? 'primary' : ''">
mdi-folder-question </v-icon
><span
class="ml-2"
:class="{
'text-primary': filterMissing,
}"
>{{ t("platform.show-missing") }}</span
<div class="d-flex align-center">
<v-icon
:color="currentState !== 'all' ? 'primary' : 'grey-lighten-1'"
class="mr-3"
>
mdi-folder-question
</v-icon>
<span
:class="
currentState !== 'all'
? 'text-primary font-weight-medium'
: 'text-medium-emphasis'
"
class="text-body-1"
>
{{ t("platform.show-missing") }}
</span>
</div>
<v-btn-toggle
:model-value="currentState"
color="primary"
density="compact"
variant="outlined"
@update:model-value="setState"
>
</v-btn>
<v-btn value="all" size="small">All</v-btn>
<v-tooltip text="Show missing ROMs only" location="bottom">
<template #activator="{ props }">
<v-btn value="missing" size="small" v-bind="props">
<v-icon size="x-large">mdi-folder-question</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip text="Show non-missing ROMs only" location="bottom">
<template #activator="{ props }">
<v-btn value="not-missing" size="small" v-bind="props">
<v-icon size="x-large">mdi-folder-check</v-icon>
</v-btn>
</template>
</v-tooltip>
</v-btn-toggle>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
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";
@@ -14,27 +15,80 @@ const { fetchTotalRoms } = storeToRefs(romsStore);
const { filterPlayables } = storeToRefs(galleryFilterStore);
const emitter = inject<Emitter<Events>>("emitter");
function setPlayables() {
galleryFilterStore.switchFilterPlayables();
// Computed property to determine current state
const currentState = computed(() => {
if (filterPlayables.value === true) return "playables";
if (filterPlayables.value === false) return "not-playables";
return "all"; // null
});
// Handler for state changes
function setState(state: string | null) {
if (!state) return;
galleryFilterStore.setFilterPlayablesState(
state as "all" | "playables" | "not-playables",
);
emitter?.emit("filterRoms", null);
}
</script>
<template>
<v-btn
block
variant="tonal"
:color="filterPlayables ? 'primary' : ''"
:disabled="fetchTotalRoms > 10000"
@click="setPlayables"
<div
class="d-flex align-center justify-space-between py-2 px-4 rounded-lg border"
:class="{ 'opacity-50': fetchTotalRoms > 10000 }"
>
<v-icon :color="filterPlayables ? 'primary' : ''"> mdi-play </v-icon>
<span
class="ml-2"
:class="{
'text-primary': filterPlayables,
}"
>{{ t("platform.show-playables") }}
</span>
</v-btn>
<div class="d-flex align-center">
<v-icon
:color="currentState !== 'all' ? 'primary' : 'grey-lighten-1'"
class="mr-3"
>
mdi-play
</v-icon>
<span
:class="
currentState !== 'all'
? 'text-primary font-weight-medium'
: 'text-medium-emphasis'
"
class="text-body-1"
>
{{ t("platform.show-playables") }}
</span>
</div>
<v-btn-toggle
:model-value="currentState"
color="primary"
density="compact"
variant="outlined"
:disabled="fetchTotalRoms > 10000"
@update:model-value="setState"
>
<v-btn value="all" size="small">All</v-btn>
<v-tooltip text="Show playable ROMs only" location="bottom">
<template #activator="{ props }">
<v-btn
value="playables"
size="small"
v-bind="props"
:disabled="fetchTotalRoms > 10000"
>
<v-icon size="x-large">mdi-play</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip text="Show non-playable ROMs only" location="bottom">
<template #activator="{ props }">
<v-btn
value="not-playables"
size="small"
v-bind="props"
:disabled="fetchTotalRoms > 10000"
>
<v-icon size="x-large">mdi-play-outline</v-icon>
</v-btn>
</template>
</v-tooltip>
</v-btn-toggle>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
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";
@@ -14,27 +15,81 @@ const { fetchTotalRoms } = storeToRefs(romsStore);
const { filterRA } = storeToRefs(galleryFilterStore);
const emitter = inject<Emitter<Events>>("emitter");
function setRA() {
galleryFilterStore.switchFilterRA();
// Computed property to determine current state
const currentState = computed(() => {
if (filterRA.value === true) return "has-ra";
if (filterRA.value === false) return "no-ra";
return "all"; // null
});
// Handler for state changes
function setState(state: string | null) {
if (!state) return;
galleryFilterStore.setFilterRAState(state as "all" | "has-ra" | "no-ra");
emitter?.emit("filterRoms", null);
}
</script>
<template>
<v-btn
block
variant="tonal"
:color="filterRA ? 'primary' : ''"
:disabled="fetchTotalRoms > 10000"
@click="setRA"
<div
class="d-flex align-center justify-space-between py-2 px-4 rounded-lg border"
:class="{ 'opacity-50': fetchTotalRoms > 10000 }"
>
<v-icon :color="filterRA ? 'primary' : ''"> mdi-trophy </v-icon>
<span
class="ml-2"
:class="{
'text-primary': filterRA,
}"
>{{ t("platform.show-ra") }}
</span>
</v-btn>
<div class="d-flex align-center">
<v-icon
:color="currentState !== 'all' ? 'primary' : 'grey-lighten-1'"
class="mr-3"
>
mdi-trophy
</v-icon>
<span
:class="
currentState !== 'all'
? 'text-primary font-weight-medium'
: 'text-medium-emphasis'
"
class="text-body-1"
>
{{ t("platform.show-ra") }}
</span>
</div>
<v-btn-toggle
:model-value="currentState"
color="primary"
density="compact"
variant="outlined"
:disabled="fetchTotalRoms > 10000"
@update:model-value="setState"
>
<v-btn value="all" size="small">All</v-btn>
<v-tooltip text="Show ROMs with RetroAchievements only" location="bottom">
<template #activator="{ props }">
<v-btn
value="has-ra"
size="small"
v-bind="props"
:disabled="fetchTotalRoms > 10000"
>
<v-icon size="x-large">mdi-trophy</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip
text="Show ROMs without RetroAchievements only"
location="bottom"
>
<template #activator="{ props }">
<v-btn
value="no-ra"
size="small"
v-bind="props"
:disabled="fetchTotalRoms > 10000"
>
<v-icon size="x-large">mdi-trophy-outline</v-icon>
</v-btn>
</template>
</v-tooltip>
</v-btn-toggle>
</div>
</template>

View File

@@ -1,36 +0,0 @@
<script setup lang="ts">
import type { Emitter } from "mitt";
import { storeToRefs } from "pinia";
import { inject } from "vue";
import { useI18n } from "vue-i18n";
import storeGalleryFilter from "@/stores/galleryFilter";
import type { Events } from "@/types/emitter";
const { t } = useI18n();
const galleryFilterStore = storeGalleryFilter();
const { filterUnmatched } = storeToRefs(galleryFilterStore);
const emitter = inject<Emitter<Events>>("emitter");
function setUnmatched() {
galleryFilterStore.switchFilterUnmatched();
emitter?.emit("filterRoms", null);
}
</script>
<template>
<v-btn
block
variant="tonal"
:color="filterUnmatched ? 'primary' : ''"
@click="setUnmatched"
>
<v-icon :color="filterUnmatched ? 'primary' : ''">
mdi-file-find-outline </v-icon
><span
class="ml-2"
:class="{
'text-primary': filterUnmatched,
}"
>{{ t("platform.show-unmatched") }}</span
>
</v-btn>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
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";
@@ -10,27 +11,69 @@ const { t } = useI18n();
const galleryFilterStore = storeGalleryFilter();
const { filterVerified } = storeToRefs(galleryFilterStore);
const emitter = inject<Emitter<Events>>("emitter");
function setVerified() {
galleryFilterStore.switchFilterVerified();
// Computed property to determine current state
const currentState = computed(() => {
if (filterVerified.value === true) return "verified";
if (filterVerified.value === false) return "not-verified";
return "all"; // null
});
// Handler for state changes
function setState(state: string | null) {
if (!state) return;
galleryFilterStore.setFilterVerifiedState(
state as "all" | "verified" | "not-verified",
);
emitter?.emit("filterRoms", null);
}
</script>
<template>
<v-btn
block
variant="tonal"
:color="filterVerified ? 'primary' : ''"
@click="setVerified"
<div
class="d-flex align-center justify-space-between py-2 px-4 rounded-lg border"
>
<v-icon :color="filterVerified ? 'primary' : ''">
mdi-check-decagram </v-icon
><span
class="ml-2"
:class="{
'text-primary': filterVerified,
}"
>{{ t("platform.show-verified") }}</span
<div class="d-flex align-center">
<v-icon
:color="currentState !== 'all' ? 'primary' : 'grey-lighten-1'"
class="mr-3"
>
mdi-check-decagram
</v-icon>
<span
:class="
currentState !== 'all'
? 'text-primary font-weight-medium'
: 'text-medium-emphasis'
"
class="text-body-1"
>
{{ t("platform.show-verified") }}
</span>
</div>
<v-btn-toggle
:model-value="currentState"
color="primary"
density="compact"
variant="outlined"
@update:model-value="setState"
>
</v-btn>
<v-btn value="all" size="small">All</v-btn>
<v-tooltip text="Show verified ROMs only" location="bottom">
<template #activator="{ props }">
<v-btn value="verified" size="small" v-bind="props">
<v-icon size="x-large">mdi-check-decagram</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip text="Show non-verified ROMs only" location="bottom">
<template #activator="{ props }">
<v-btn value="not-verified" size="small" v-bind="props">
<v-icon size="x-large">mdi-check-decagram-outline</v-icon>
</v-btn>
</template>
</v-tooltip>
</v-btn-toggle>
</div>
</template>

View File

@@ -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() {
</v-row>
<v-row class="pa-2" no-gutters>
<v-col>
<v-switch
v-model="isPublic"
:label="t('collection.public-desc')"
color="primary"
hide-details
/>
<v-btn
:color="isPublic ? 'romm-green' : 'accent'"
variant="outlined"
@click="toggleCollectionVisibility"
>
<v-icon class="mr-2">
{{ isPublic ? "mdi-lock-open-variant" : "mdi-lock" }}
</v-icon>
{{ isPublic ? t("rom.public") : t("rom.private") }}
</v-btn>
</v-col>
</v-row>
</v-col>
@@ -242,8 +250,7 @@ async function createSmartCollection() {
</v-col>
</v-row>
</template>
<template #append>
<v-divider />
<template #footer>
<v-row class="justify-center pa-2" no-gutters>
<v-btn-group divided density="compact">
<v-btn class="bg-toplayer" @click="closeDialog">

View File

@@ -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 } : {}),
},
});
}

View File

@@ -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<GetRomsResponse>(config, onBackgroundUpdate);

View File

@@ -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;
},
},
});