feat(filters): add downloaded filter to browse pages
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user