From df187f0f0cd12a1af5f4be704bfe3861523dd239 Mon Sep 17 00:00:00 2001 From: IC3P3 Date: Wed, 4 Nov 2026 22:30:59 +0100 Subject: [PATCH 1/7] Add store query for sort and order --- server/components/StoreView.vue | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/server/components/StoreView.vue b/server/components/StoreView.vue index 65a94a90f..ec114c9b4 100644 --- a/server/components/StoreView.vue +++ b/server/components/StoreView.vue @@ -412,8 +412,18 @@ const sorts: Array = [ param: "name", }, ]; -const currentSort = ref(sorts[0].param); -const sortOrder = ref<"asc" | "desc">("desc"); +const sortOders = ["asc", "desc"] as const; + +const route = useRoute(); + +const initialSort = route.query.sort; +const currentSort = ref( + sorts.find((s) => s.param === initialSort) ? initialSort : "default", +); +const initialOrder = route.query.order; +const sortOrder = ref( + sortOders.find((s) => s === initialOrder) ? initialOrder : "desc", +); const options: Array = [ ...(tags.length > 0 @@ -481,6 +491,7 @@ const { reset } = useInfiniteScroll( }, ); +const router = useRouter(); const canLoadMore = ref(true); async function updateGames(query: string, resetGames: boolean) { loading.value = true; @@ -509,6 +520,19 @@ watch(currentSort, (_) => { watch(sortOrder, (_) => { updateGames(filterQuery.value, true); }); +watch( + [currentSort, sortOrder], + (_) => { + router.replace({ + query: { + ...route.query, + sort: currentSort.value === "default" ? undefined : currentSort.value, + order: sortOrder.value === "desc" ? undefined : sortOrder.value, + }, + }); + }, + { deep: true }, +); await updateGames(filterQuery.value, true); From bfbdcebbb7938a64e3d6cd78eab16f875ea2a2ec Mon Sep 17 00:00:00 2001 From: IC3P3 Date: Wed, 4 Nov 2026 23:15:28 +0100 Subject: [PATCH 2/7] Added filter to query --- server/components/StoreView.vue | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/server/components/StoreView.vue b/server/components/StoreView.vue index ec114c9b4..3eaec50cb 100644 --- a/server/components/StoreView.vue +++ b/server/components/StoreView.vue @@ -214,7 +214,7 @@ type="button" :class="[ '-m-2 ml-4 p-2 sm:ml-6 lg:hidden', - filterQuery + filterApiQuery ? 'text-zinc-100 hover:text-zinc-200' : 'text-zinc-500 hover:text-zinc-400', ]" @@ -454,6 +454,20 @@ const optionValues = ref<{ Object.assign(optionValues.value, props.prefilled); const filterQuery = computed(() => { + return Object.fromEntries( + Object.entries(optionValues.value).map(([name, value]) => { + if (typeof value === "string") return [name, value]; + + const picked = Object.entries(value ?? {}) + .filter(([_, on]) => on) + .map(([key]) => key); + + return [name, picked.length > 0 ? picked.join(",") : undefined]; + }), + ); +}); + +const filterApiQuery = computed(() => { const query = Object.entries(optionValues.value) .filter( ([_, v]) => @@ -482,7 +496,7 @@ const productGrid = useTemplateRef("product-grid"); const { reset } = useInfiniteScroll( productGrid, - async () => await updateGames(filterQuery.value, false), + async () => await updateGames(filterApiQuery.value, false), { distance: 10, canLoadMore: () => { @@ -511,30 +525,31 @@ async function updateGames(query: string, resetGames: boolean) { canLoadMore.value = games.value.length < newValues.count; loading.value = false; } -watch(filterQuery, (newUrl) => { +watch(filterApiQuery, (newUrl) => { updateGames(newUrl, true); }); watch(currentSort, (_) => { - updateGames(filterQuery.value, true); + updateGames(filterApiQuery.value, true); }); watch(sortOrder, (_) => { - updateGames(filterQuery.value, true); + updateGames(filterApiQuery.value, true); }); watch( - [currentSort, sortOrder], + [currentSort, sortOrder, filterQuery], (_) => { router.replace({ query: { ...route.query, sort: currentSort.value === "default" ? undefined : currentSort.value, order: sortOrder.value === "desc" ? undefined : sortOrder.value, + ...filterQuery.value, }, }); }, { deep: true }, ); -await updateGames(filterQuery.value, true); +await updateGames(filterApiQuery.value, true); function handleSortClick(option: StoreSortOption, event: MouseEvent) { event.stopPropagation(); From ad014d515b5bd67e73d4572deb8fcbc5d605c7a5 Mon Sep 17 00:00:00 2001 From: IC3P3 Date: Wed, 4 Nov 2026 23:26:29 +0100 Subject: [PATCH 3/7] Reread the filter options from query --- server/components/StoreView.vue | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/server/components/StoreView.vue b/server/components/StoreView.vue index 3eaec50cb..7394f462c 100644 --- a/server/components/StoreView.vue +++ b/server/components/StoreView.vue @@ -448,7 +448,15 @@ const optionValues = ref<{ [key: string]: string | undefined | { [key: string]: boolean | undefined }; }>( Object.fromEntries( - options.map((v) => [v.param, v.multiple ? {} : undefined]), + options.map((option) => { + const raw = route.query[option.param]; + const fromUrl = typeof raw === "string" ? raw : undefined; + + if (!option.multiple) return [option.param, fromUrl]; + + const keys = fromUrl ? fromUrl.split(",") : []; + return [option.param, Object.fromEntries(keys.map((key) => [key, true]))]; + }), ), ); Object.assign(optionValues.value, props.prefilled); From 0709c350d0481464272d0879c82644d9943a7414 Mon Sep 17 00:00:00 2001 From: IC3P3 Date: Thu, 5 Nov 2026 00:16:11 +0100 Subject: [PATCH 4/7] Check if tag and platform exist --- server/components/StoreView.vue | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/server/components/StoreView.vue b/server/components/StoreView.vue index 7394f462c..c23857345 100644 --- a/server/components/StoreView.vue +++ b/server/components/StoreView.vue @@ -387,7 +387,7 @@ const props = defineProps<{ params?: { [key: string]: string }; extraOptions?: Array; prefilled?: { - [key: string]: { [key: string]: string | { [key: string]: boolean } }; + [key: string]: string | { [key: string]: boolean }; }; }>(); @@ -412,7 +412,7 @@ const sorts: Array = [ param: "name", }, ]; -const sortOders = ["asc", "desc"] as const; +const sortOrders = ["asc", "desc"] as const; const route = useRoute(); @@ -422,7 +422,7 @@ const currentSort = ref( ); const initialOrder = route.query.order; const sortOrder = ref( - sortOders.find((s) => s === initialOrder) ? initialOrder : "desc", + sortOrders.find((s) => s === initialOrder) ? initialOrder : "desc", ); const options: Array = [ @@ -452,14 +452,24 @@ const optionValues = ref<{ const raw = route.query[option.param]; const fromUrl = typeof raw === "string" ? raw : undefined; - if (!option.multiple) return [option.param, fromUrl]; + const allowed = option.options.map((o) => o.param); - const keys = fromUrl ? fromUrl.split(",") : []; + if (!option.multiple) + return [ + option.param, + fromUrl && allowed.includes(fromUrl) ? fromUrl : undefined, + ]; + + const keys = (fromUrl ? fromUrl.split(",") : []).filter((k) => + allowed.includes(k), + ); return [option.param, Object.fromEntries(keys.map((key) => [key, true]))]; }), ), ); -Object.assign(optionValues.value, props.prefilled); +for (const [key, value] of Object.entries(props.prefilled ?? {})) { + if (route.query[key] === undefined) optionValues.value[key] = value; +} const filterQuery = computed(() => { return Object.fromEntries( From 6bb45e3a0f55d8e8e72a9a3afab3acae0ba977f6 Mon Sep 17 00:00:00 2001 From: IC3P3 Date: Thu, 5 Nov 2026 00:26:23 +0100 Subject: [PATCH 5/7] Fix query resetting, but not filters --- server/components/StoreView.vue | 83 ++++++++++++++++++++++----------- 1 file changed, 55 insertions(+), 28 deletions(-) diff --git a/server/components/StoreView.vue b/server/components/StoreView.vue index c23857345..faf524da6 100644 --- a/server/components/StoreView.vue +++ b/server/components/StoreView.vue @@ -416,14 +416,20 @@ const sortOrders = ["asc", "desc"] as const; const route = useRoute(); -const initialSort = route.query.sort; -const currentSort = ref( - sorts.find((s) => s.param === initialSort) ? initialSort : "default", -); -const initialOrder = route.query.order; -const sortOrder = ref( - sortOrders.find((s) => s === initialOrder) ? initialOrder : "desc", -); +type RouteQuery = typeof route.query; +type OptionValues = { + [key: string]: string | undefined | { [key: string]: boolean | undefined }; +}; + +function parseSort(query: RouteQuery) { + return sorts.find((s) => s.param === query.sort)?.param ?? "default"; +} +function parseOrder(query: RouteQuery) { + return sortOrders.find((s) => s === query.order) ?? "desc"; +} + +const currentSort = ref(parseSort(route.query)); +const sortOrder = ref(parseOrder(route.query)); const options: Array = [ ...(tags.length > 0 @@ -444,12 +450,10 @@ const options: Array = [ }, ...(props.extraOptions ?? []), ]; -const optionValues = ref<{ - [key: string]: string | undefined | { [key: string]: boolean | undefined }; -}>( - Object.fromEntries( +function parseOptionValues(query: RouteQuery) { + const values: OptionValues = Object.fromEntries( options.map((option) => { - const raw = route.query[option.param]; + const raw = query[option.param]; const fromUrl = typeof raw === "string" ? raw : undefined; const allowed = option.options.map((o) => o.param); @@ -465,12 +469,17 @@ const optionValues = ref<{ ); return [option.param, Object.fromEntries(keys.map((key) => [key, true]))]; }), - ), -); -for (const [key, value] of Object.entries(props.prefilled ?? {})) { - if (route.query[key] === undefined) optionValues.value[key] = value; + ); + for (const [key, value] of Object.entries(props.prefilled ?? {})) { + // Copy so that unticking a filter never mutates the prefilled prop + if (query[key] === undefined) + values[key] = typeof value === "string" ? value : { ...value }; + } + return values; } +const optionValues = ref(parseOptionValues(route.query)); + const filterQuery = computed(() => { return Object.fromEntries( Object.entries(optionValues.value).map(([name, value]) => { @@ -552,19 +561,37 @@ watch(currentSort, (_) => { watch(sortOrder, (_) => { updateGames(filterApiQuery.value, true); }); + +// The query this component's state wants in the URL +const stateQuery = computed(() => ({ + sort: currentSort.value === "default" ? undefined : currentSort.value, + order: sortOrder.value === "desc" ? undefined : sortOrder.value, + ...filterQuery.value, +})); + +watch(stateQuery, (query) => { + router.replace({ query: { ...route.query, ...query } }); +}); + +// Picks up navigation that didn't come from this component, like the header's +// store link. The URL already matches the state after our own replace, so that +// returns early, which also stops prefilled values re-applying after an untick. +const storePath = route.path; watch( - [currentSort, sortOrder, filterQuery], - (_) => { - router.replace({ - query: { - ...route.query, - sort: currentSort.value === "default" ? undefined : currentSort.value, - order: sortOrder.value === "desc" ? undefined : sortOrder.value, - ...filterQuery.value, - }, - }); + () => route.query, + (query) => { + if (route.path !== storePath) return; + + const inSync = Object.entries(stateQuery.value).every( + ([key, value]) => (query[key] ?? undefined) === value, + ); + if (inSync) return; + + // The watchers above refetch the games and tidy the URL if needed + currentSort.value = parseSort(query); + sortOrder.value = parseOrder(query); + optionValues.value = parseOptionValues(query); }, - { deep: true }, ); await updateGames(filterApiQuery.value, true); From abf8f5609a30977af49ac71b15f1231596964c02 Mon Sep 17 00:00:00 2001 From: IC3P3 Date: Fri, 6 Nov 2026 01:28:37 +0100 Subject: [PATCH 6/7] Removed duplicate logic from filterQuery creation --- server/components/StoreView.vue | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/server/components/StoreView.vue b/server/components/StoreView.vue index faf524da6..b6ef69662 100644 --- a/server/components/StoreView.vue +++ b/server/components/StoreView.vue @@ -495,25 +495,11 @@ const filterQuery = computed(() => { }); const filterApiQuery = computed(() => { - const query = Object.entries(optionValues.value) - .filter( - ([_, v]) => - v && - (typeof v !== "object" || Object.values(v).filter((e) => e).length > 0), - ) - .map(([n, v]) => { - if (typeof v === "string") return [`${n}=${v}`]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const enabledOptions = Object.entries(v as any).filter(([_, e]) => e); - return `${n}=${enabledOptions.map(([k, _]) => k).join(",")}`; - }) - .join("&"); - const extraFilters = props.params - ? Object.entries(props.params) - .map(([k, v]) => `${k}=${v}`) - .join("&") - : props.params; - return `${query}${extraFilters ? (query ? "&" : "") + extraFilters : ""}`; + const filters = Object.entries(filterQuery.value).filter(([_, v]) => v); + const extraFilters = Object.entries(props.params ?? {}).filter( + ([k]) => !filterQuery.value[k], + ); + return [...filters, ...extraFilters].map(([k, v]) => `${k}=${v}`).join("&"); }); const games = ref>>(); From fed2966df5ff357d37c3c5528caaa6afc189ca23 Mon Sep 17 00:00:00 2001 From: IC3P3 Date: Fri, 6 Nov 2026 01:35:27 +0100 Subject: [PATCH 7/7] Remove unneeded params filter from filterApiQuery --- server/components/StoreView.vue | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/server/components/StoreView.vue b/server/components/StoreView.vue index b6ef69662..0cac8083b 100644 --- a/server/components/StoreView.vue +++ b/server/components/StoreView.vue @@ -496,9 +496,7 @@ const filterQuery = computed(() => { const filterApiQuery = computed(() => { const filters = Object.entries(filterQuery.value).filter(([_, v]) => v); - const extraFilters = Object.entries(props.params ?? {}).filter( - ([k]) => !filterQuery.value[k], - ); + const extraFilters = Object.entries(props.params ?? {}); return [...filters, ...extraFilters].map(([k, v]) => `${k}=${v}`).join("&"); });