feat(filters): add downloaded filter to browse pages

This commit is contained in:
Darkraise
2026-08-11 18:30:53 +07:00
parent e7bbd36aff
commit 11bce2af41
5 changed files with 110 additions and 11 deletions
@@ -5,6 +5,7 @@ import { useFilterOptions, FilterOptionsParams } from '@/hooks/useFilterOptions'
import { useDebounce } from '@/hooks/useDebounce';
import { useFavoriteGameIds } from '@/hooks/useFavorites';
import { useBatchRatingAggregates } from '@/hooks/useRatings';
import { useFeatureFlags } from '@/hooks/useFeatureFlags';
import { GameGrid } from './GameGrid';
import { GameList } from './GameList';
import { GameGridSkeleton } from './GameGridSkeleton';
@@ -27,12 +28,14 @@ import {
interface GameBrowseLayoutProps {
title: string;
library: 'arcade' | 'theatre';
library?: 'arcade' | 'theatre';
platform?: string;
headerContent?: ReactNode;
breadcrumbContext?: BreadcrumbContext;
/** Section key for URL building ('flash', 'html5', 'animations', 'browse') */
sectionKey?: string | null;
/** Pin the downloaded filter on and hide its switch (used by the Downloaded page) */
forceDownloaded?: boolean;
}
export function GameBrowseLayout({
@@ -42,6 +45,7 @@ export function GameBrowseLayout({
headerContent,
breadcrumbContext,
sectionKey = null,
forceDownloaded,
}: GameBrowseLayoutProps) {
const [searchParams, setSearchParams] = useSearchParams();
const viewMode = useUIStore((state) => state.viewMode);
@@ -67,6 +71,15 @@ export function GameBrowseLayout({
}
}, [searchParams, setSearchParams]);
const { enableDownloadedFilterDefault } = useFeatureFlags();
const downloaded = useMemo(() => {
if (forceDownloaded) return true;
if (urlParams.downloaded === '1') return true;
if (urlParams.downloaded === '0') return false;
return enableDownloadedFilterDefault;
}, [forceDownloaded, urlParams.downloaded, enableDownloadedFilterDefault]);
const filters: GameFilters = useMemo(
() => ({
search: urlParams.search,
@@ -77,6 +90,7 @@ export function GameBrowseLayout({
playModes: urlParams.playModes,
languages: urlParams.languages,
library,
downloaded: downloaded ? true : undefined,
tags: urlParams.tags,
yearFrom: urlParams.yearFrom,
yearTo: urlParams.yearTo,
@@ -85,7 +99,7 @@ export function GameBrowseLayout({
page: urlParams.page ?? 1,
limit: 50,
}),
[urlParams, platform, library]
[urlParams, platform, library, downloaded]
);
// Memoize filter options params for context-aware filter options
@@ -94,6 +108,7 @@ export function GameBrowseLayout({
() => ({
platform: filters.platform,
library: filters.library,
downloaded: filters.downloaded,
// Pass current filter selections for context-aware options
series: filters.series?.split(',').filter(Boolean),
developers: filters.developers?.split(',').filter(Boolean),
@@ -193,6 +208,16 @@ export function GameBrowseLayout({
});
}
if (filters.downloaded && !forceDownloaded) {
chips.push({
id: 'downloaded',
label: 'Downloaded',
value: 'Downloaded only',
category: 'Downloaded',
order: getOrder('Downloaded'),
});
}
if (filters.series) {
const order = getOrder('Series');
filters.series.split(',').forEach((series) => {
@@ -291,7 +316,7 @@ export function GameBrowseLayout({
// Sort chips by order to maintain hierarchy (application order)
return chips.sort((a, b) => a.order - b.order);
}, [filters, platform, filterOrder]);
}, [filters, platform, filterOrder, forceDownloaded]);
// Helper to get updated filter order
const getUpdatedFilterOrder = useCallback(
@@ -314,6 +339,11 @@ export function GameBrowseLayout({
} else if (chipId === 'platform') {
newParams.platform = undefined;
categoryRemoved = 'Platform';
} else if (chipId === 'downloaded') {
// Clearing the param would fall back to the admin default and re-apply the
// filter, so an explicit off is required when the default is on.
newParams.downloaded = enableDownloadedFilterDefault ? '0' : undefined;
categoryRemoved = 'Downloaded';
} else if (chipId === 'yearRange') {
newParams.yearFrom = undefined;
newParams.yearTo = undefined;
@@ -356,7 +386,7 @@ export function GameBrowseLayout({
setSearchParams(buildFilterSearchParams(newParams));
},
[urlParams, filters, setSearchParams, getUpdatedFilterOrder]
[urlParams, filters, setSearchParams, getUpdatedFilterOrder, enableDownloadedFilterDefault]
);
// Remove all filters at or after a certain order position (for tree hierarchy)
@@ -369,6 +399,7 @@ export function GameBrowseLayout({
const categoryToParamKeys: Record<string, (keyof FilterUrlParams)[]> = {
Search: ['search'],
Platform: ['platform'],
Downloaded: ['downloaded'],
Series: ['series'],
Developer: ['developers'],
Publisher: ['publishers'],
@@ -390,21 +421,26 @@ export function GameBrowseLayout({
}
}
if (categoriesToRemove.includes('Downloaded') && enableDownloadedFilterDefault) {
newParams.downloaded = '0';
}
// Update filter order - keep only categories before the removed one
newParams.fo = getUpdatedFilterOrder(urlParams.fo, categoriesToRemove);
setSearchParams(buildFilterSearchParams(newParams));
},
[urlParams, setSearchParams, filterOrder, getUpdatedFilterOrder]
[urlParams, setSearchParams, filterOrder, getUpdatedFilterOrder, enableDownloadedFilterDefault]
);
const handleClearAllFilters = useCallback(() => {
const newParams: FilterUrlParams = {
sortBy: urlParams.sortBy,
sortOrder: urlParams.sortOrder,
downloaded: enableDownloadedFilterDefault ? '0' : undefined,
};
setSearchParams(buildFilterSearchParams(newParams));
}, [urlParams.sortBy, urlParams.sortOrder, setSearchParams]);
}, [urlParams.sortBy, urlParams.sortOrder, enableDownloadedFilterDefault, setSearchParams]);
const handlePageChange = useCallback(
(newPage: number) => {
@@ -455,6 +491,8 @@ export function GameBrowseLayout({
filterOptionsError={filterOptionsError}
refetchFilterOptions={refetchFilterOptions}
showPlatformFilter={!platform}
downloaded={downloaded}
showDownloadedFilter={!forceDownloaded}
filterChips={filterChips}
onRemoveChip={handleRemoveChip}
onRemoveWithChildren={handleRemoveWithChildren}
+62 -5
View File
@@ -6,6 +6,8 @@ import { FilterDropdown } from './FilterDropdown';
import { FilterChips, FilterChip } from './FilterChips';
import { YearRangeFilter } from './YearRangeFilter';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { FILTER_CONFIGS } from './filterConfig';
import {
parseFilterParams,
@@ -21,6 +23,10 @@ interface FilterPanelProps {
filterOptionsError: Error | null;
refetchFilterOptions: () => void;
showPlatformFilter?: boolean;
/** Current resolved value of the downloaded filter */
downloaded?: boolean;
/** Hide the downloaded switch (the Downloaded page pins it on) */
showDownloadedFilter?: boolean;
/** Active filter chips to display */
filterChips?: FilterChip[];
/** Callback when a chip is removed */
@@ -38,6 +44,8 @@ export function FilterPanel({
filterOptionsError,
refetchFilterOptions,
showPlatformFilter = true,
downloaded,
showDownloadedFilter = true,
filterChips = [],
onRemoveChip,
onRemoveWithChildren,
@@ -52,7 +60,11 @@ export function FilterPanel({
// Helper to update filter order when categories are added/removed
const getUpdatedFilterOrder = useCallback(
(currentFo: string | undefined, categoryName: string, action: 'add' | 'remove'): string | undefined => {
(
currentFo: string | undefined,
categoryName: string,
action: 'add' | 'remove'
): string | undefined => {
const currentOrder = currentFo?.split(',').filter(Boolean) ?? [];
if (action === 'add' && !currentOrder.includes(categoryName)) {
@@ -72,10 +84,7 @@ export function FilterPanel({
// Memoize handlers for all filter configs to avoid recreation on every render
const filterHandlers = useMemo(() => {
const handlers: Record<
string,
{ apply: (values: string[]) => void; clear: () => void }
> = {};
const handlers: Record<string, { apply: (values: string[]) => void; clear: () => void }> = {};
FILTER_CONFIGS.forEach((config) => {
handlers[config.id] = {
@@ -230,6 +239,30 @@ export function FilterPanel({
maxYear={filterOptions?.yearRange?.max ?? new Date().getFullYear()}
onYearChange={handleYearChange}
/>
{showDownloadedFilter ? (
<div className="flex items-center gap-2">
<Switch
id="downloaded-filter-desktop"
checked={downloaded === true}
onCheckedChange={(checked: boolean) => {
const newParams: FilterUrlParams = {
...urlParams,
downloaded: checked ? '1' : '0',
page: undefined,
fo: getUpdatedFilterOrder(
urlParams.fo,
'Downloaded',
checked ? 'add' : 'remove'
),
};
setSearchParams(buildFilterSearchParams(newParams));
}}
/>
<Label htmlFor="downloaded-filter-desktop" className="text-sm whitespace-nowrap">
Downloaded only
</Label>
</div>
) : null}
</div>
{/* Mobile Layout */}
@@ -243,6 +276,30 @@ export function FilterPanel({
onYearChange={handleYearChange}
compact
/>
{showDownloadedFilter ? (
<div className="flex items-center gap-2">
<Switch
id="downloaded-filter-mobile"
checked={downloaded === true}
onCheckedChange={(checked: boolean) => {
const newParams: FilterUrlParams = {
...urlParams,
downloaded: checked ? '1' : '0',
page: undefined,
fo: getUpdatedFilterOrder(
urlParams.fo,
'Downloaded',
checked ? 'add' : 'remove'
),
};
setSearchParams(buildFilterSearchParams(newParams));
}}
/>
<Label htmlFor="downloaded-filter-mobile" className="text-sm whitespace-nowrap">
Downloaded only
</Label>
</div>
) : null}
</div>
{/* Active Filter Chips */}
+1
View File
@@ -6,6 +6,7 @@ import { FilterOptions } from '@/types/game';
export interface FilterOptionsParams {
platform?: string;
library?: string;
downloaded?: boolean;
// Context filters - when set, filter options update to reflect current selection
series?: string[];
developers?: string[];
+2
View File
@@ -19,6 +19,7 @@ export const gamesApi = {
playModes: filters.playModes?.split(',').filter(Boolean),
languages: filters.languages?.split(',').filter(Boolean),
library: filters.library,
downloaded: filters.downloaded,
tags: filters.tags?.split(',').filter(Boolean),
yearFrom: filters.yearFrom,
yearTo: filters.yearTo,
@@ -39,6 +40,7 @@ export const gamesApi = {
getFilterOptions: async (params?: {
platform?: string;
library?: string;
downloaded?: boolean;
// Context filters for dynamic options
series?: string;
developers?: string;
+1
View File
@@ -59,6 +59,7 @@ export interface GameFilters {
yearTo?: number;
dateAddedSince?: string; // ISO 8601 timestamp - filter games added after this date
dateModifiedSince?: string; // ISO 8601 timestamp - filter games modified after this date
downloaded?: boolean;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
page?: number;