feat: add year range filter and improve filter panel
- Add YearRangeFilter component with from/to inputs and popover UI - Check both game_data and game table for Flash SWF detection - Add context-aware sort labels (Oldest/Newest vs A-Z/Z-A) - Validate page param with isNaN() and Math.max(1, ...) - Memoize filter handlers, updateFilter, and visibleConfigs - Add error state with retry button for filter options - Add LIMIT 100 to play modes query for consistency - Stabilize useFilterDropdowns with useRef pattern
This commit is contained in:
@@ -103,7 +103,8 @@ export class GameService {
|
||||
private static filterOptionsCache: FilterOptionsResult | null = null;
|
||||
|
||||
/**
|
||||
* Get the set of Flash game IDs whose game_data.launchCommand points to a .swf file.
|
||||
* Get the set of Flash game IDs with .swf launch commands.
|
||||
* Checks both game_data.launchCommand and game.launchCommand as fallback.
|
||||
* Results are cached in memory for 1 hour (read-only DB changes rarely).
|
||||
*/
|
||||
private getFlashSwfGameIds(): Set<string> {
|
||||
@@ -113,7 +114,9 @@ export class GameService {
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
const sql = `
|
||||
|
||||
// Query 1: Games with .swf in game_data.launchCommand
|
||||
const sqlGameData = `
|
||||
SELECT DISTINCT gd.gameId
|
||||
FROM game_data gd
|
||||
JOIN game g ON g.id = gd.gameId
|
||||
@@ -121,8 +124,22 @@ export class GameService {
|
||||
AND (LOWER(gd.launchCommand) LIKE '%.swf' OR LOWER(gd.launchCommand) LIKE '%.swf?%')
|
||||
`;
|
||||
|
||||
const rows = DatabaseService.all(sql, []) as Array<{ gameId: string }>;
|
||||
GameService.flashSwfGameIds = new Set(rows.map((r) => r.gameId));
|
||||
// Query 2: Games with .swf in game.launchCommand (fallback)
|
||||
const sqlGameTable = `
|
||||
SELECT DISTINCT g.id as gameId
|
||||
FROM game g
|
||||
WHERE g.platformName = 'Flash'
|
||||
AND (LOWER(g.launchCommand) LIKE '%.swf' OR LOWER(g.launchCommand) LIKE '%.swf?%')
|
||||
`;
|
||||
|
||||
const rowsGameData = DatabaseService.all(sqlGameData, []) as Array<{ gameId: string }>;
|
||||
const rowsGameTable = DatabaseService.all(sqlGameTable, []) as Array<{ gameId: string }>;
|
||||
|
||||
// Combine both sets
|
||||
GameService.flashSwfGameIds = new Set([
|
||||
...rowsGameData.map((r) => r.gameId),
|
||||
...rowsGameTable.map((r) => r.gameId),
|
||||
]);
|
||||
GameService.flashSwfCacheExpiry = now + GameService.FLASH_SWF_CACHE_TTL;
|
||||
|
||||
const duration = Math.round(performance.now() - startTime);
|
||||
@@ -167,7 +184,7 @@ export class GameService {
|
||||
|
||||
/**
|
||||
* SQL condition to exclude Flash games without SWF launch commands.
|
||||
* Uses EXISTS subquery against game_data table (leverages UNIQUE(gameId, dateAdded) index).
|
||||
* Checks both game_data.launchCommand and game.launchCommand as fallback.
|
||||
*
|
||||
* Performance: ~150ms overhead per query. Acceptable for searchGames() since results
|
||||
* are cached by GameSearchCache (5-min TTL). For aggregate queries like
|
||||
@@ -181,6 +198,7 @@ export class GameService {
|
||||
WHERE gd.gameId = ${tableAlias}.id
|
||||
AND (LOWER(gd.launchCommand) LIKE '%.swf' OR LOWER(gd.launchCommand) LIKE '%.swf?%')
|
||||
)
|
||||
OR (LOWER(${tableAlias}.launchCommand) LIKE '%.swf' OR LOWER(${tableAlias}.launchCommand) LIKE '%.swf?%')
|
||||
)`;
|
||||
}
|
||||
|
||||
@@ -778,6 +796,7 @@ export class GameService {
|
||||
AND (extreme = 0 OR extreme IS NULL)
|
||||
GROUP BY playMode
|
||||
ORDER BY count DESC, playMode ASC
|
||||
LIMIT 100
|
||||
`;
|
||||
|
||||
const results = DatabaseService.all(sql, []) as Array<{ name: string; count: number }>;
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
# Games Filter Panel & Filter Logic - Code Review
|
||||
|
||||
**Date:** 2026-02-15
|
||||
**Reviewer:** Claude
|
||||
**Status:** Review Complete
|
||||
|
||||
## Overview
|
||||
|
||||
The filter system spans frontend components, hooks, API layer, and backend services. Overall, the implementation is solid with good patterns like config-driven rendering and proper caching. However, there are several issues and improvement opportunities.
|
||||
|
||||
## Files Reviewed
|
||||
|
||||
### Frontend Components
|
||||
- `frontend/src/components/search/FilterPanel.tsx`
|
||||
- `frontend/src/components/search/FilterDropdown.tsx`
|
||||
- `frontend/src/components/search/FilterChips.tsx`
|
||||
- `frontend/src/components/search/filterConfig.ts`
|
||||
- `frontend/src/components/search/SearchBar.tsx`
|
||||
- `frontend/src/components/search/SortControls.tsx`
|
||||
- `frontend/src/components/library/GameBrowseLayout.tsx`
|
||||
|
||||
### Frontend Hooks & Types
|
||||
- `frontend/src/hooks/useGames.ts`
|
||||
- `frontend/src/hooks/useFilterOptions.ts`
|
||||
- `frontend/src/hooks/useFilterDropdowns.ts`
|
||||
- `frontend/src/types/game.ts`
|
||||
- `frontend/src/lib/api/games.ts`
|
||||
|
||||
### Backend
|
||||
- `backend/src/routes/games.ts`
|
||||
- `backend/src/services/GameService.ts`
|
||||
|
||||
---
|
||||
|
||||
## Issues Found
|
||||
|
||||
### 1. Type Safety Issues
|
||||
|
||||
| Location | Issue | Severity |
|
||||
|----------|-------|----------|
|
||||
| `FilterPanel.tsx:22-23` | Unsafe `split()` on potentially undefined value | Medium |
|
||||
| `GameBrowseLayout.tsx:72` | Type assertion `as 'asc' | 'desc'` without validation | Medium |
|
||||
| `FilterDropdown.tsx:42` | No validation that `selectedValues` array items exist in `options` | Low |
|
||||
|
||||
```typescript
|
||||
// FilterPanel.tsx:22-23 - currentValue could be number/undefined for yearFrom/yearTo
|
||||
const current = typeof currentValue === 'string' ? currentValue.split(',').filter(Boolean) : [];
|
||||
// This works but paramKey includes non-string fields (yearFrom, yearTo, page, limit)
|
||||
```
|
||||
|
||||
### 2. Missing Year Range Filter in UI
|
||||
|
||||
| Location | Issue | Severity |
|
||||
|----------|-------|----------|
|
||||
| `filterConfig.ts` | `yearFrom`/`yearTo` filters defined in `GameFilters` but not exposed in UI | Medium |
|
||||
| `FilterPanel.tsx` | No year range slider or inputs despite backend support | Medium |
|
||||
|
||||
The `FilterOptions.yearRange` is fetched but never used - users cannot filter by release year.
|
||||
|
||||
### 3. Inconsistent Filter Chips Implementation
|
||||
|
||||
| Location | Issue | Severity |
|
||||
|----------|-------|----------|
|
||||
| `FilterPanel.tsx:105-140` | `renderFilterBadges()` duplicates logic already in `FilterChips.tsx` | Low |
|
||||
| `GameBrowseLayout.tsx:110-198` | Separate filter chip generation vs `FilterChips` component | Low |
|
||||
|
||||
Two different chip rendering systems exist:
|
||||
- `FilterPanel.tsx` renders inline badges
|
||||
- `GameBrowseLayout.tsx` creates `FilterChip[]` array for `FilterChips` component
|
||||
|
||||
### 4. Performance: Redundant Handlers Recreation
|
||||
|
||||
| Location | Issue | Severity |
|
||||
|----------|-------|----------|
|
||||
| `FilterPanel.tsx:68-74` | `createFilterHandlers()` called on every render for every filter | Low |
|
||||
| `FilterPanel.tsx:113-119` | Same handlers recreated in `renderFilterBadges()` | Low |
|
||||
|
||||
```typescript
|
||||
// Called inside map() on every render
|
||||
const handlers = createFilterHandlers(
|
||||
config.paramKey,
|
||||
config.id,
|
||||
filters,
|
||||
updateFilter,
|
||||
dropdowns
|
||||
);
|
||||
```
|
||||
|
||||
### 5. Missing Search Debouncing in SearchBar
|
||||
|
||||
| Location | Issue | Severity |
|
||||
|----------|-------|----------|
|
||||
| `SearchBar.tsx:10-15` | No debounce - navigates immediately on form submit | Low |
|
||||
| N/A | No "search as you type" with debounce option | Enhancement |
|
||||
|
||||
### 6. Backend: Filter Options Limit Inconsistency
|
||||
|
||||
| Location | Issue | Severity |
|
||||
|----------|-------|----------|
|
||||
| `GameService.ts:706-707` | Series limited to 100 | Low |
|
||||
| `GameService.ts:730-731` | Developers limited to 100 | Low |
|
||||
| `GameService.ts:780-781` | Play modes NOT limited | Inconsistent |
|
||||
| `GameService.ts:849` | Tags limited to 100 | Low |
|
||||
|
||||
Play modes query lacks `LIMIT 100` that other filter queries have.
|
||||
|
||||
### 7. Missing Input Validation on Frontend
|
||||
|
||||
| Location | Issue | Severity |
|
||||
|----------|-------|----------|
|
||||
| `GameBrowseLayout.tsx:72` | `page` parsed without `isNaN()` check | Medium |
|
||||
| `GameBrowseLayout.tsx:58-68` | Year parsing validates `isNaN()` - good! | N/A |
|
||||
|
||||
```typescript
|
||||
// Line 72 - could pass NaN to API
|
||||
page: parseInt(searchParams.get('page') || '1', 10),
|
||||
```
|
||||
|
||||
### 8. URL Encoding Issues
|
||||
|
||||
| Location | Issue | Severity |
|
||||
|----------|-------|----------|
|
||||
| `GameBrowseLayout.tsx:134` | `encodeURIComponent()` used for chip IDs but inconsistent | Low |
|
||||
| `FilterPanel.tsx:125` | Badge key uses raw `value` - may have special chars | Low |
|
||||
|
||||
```typescript
|
||||
// Line 125 - key should be encoded for consistency
|
||||
<Badge key={value} variant="secondary" className="gap-1">
|
||||
```
|
||||
|
||||
### 9. Missing Error Boundary for Filter Options
|
||||
|
||||
| Location | Issue | Severity |
|
||||
|----------|-------|----------|
|
||||
| `useFilterOptions.ts` | No `onError` handler | Low |
|
||||
| `FilterPanel.tsx:79` | Falls back to empty array `|| []` but no loading state | Low |
|
||||
|
||||
If filter options fail to load, dropdowns show "No options available" with no retry mechanism.
|
||||
|
||||
### 10. Accessibility Issues
|
||||
|
||||
| Location | Issue | Severity |
|
||||
|----------|-------|----------|
|
||||
| `SortControls.tsx:26` | Native `<select>` used without styling wrapper | Low |
|
||||
| `FilterDropdown.tsx:103` | `<label>` wraps checkbox - good | N/A |
|
||||
| `SortControls.tsx:35-45` | Sort order button shows "A-Z/Z-A" but not meaningful for dates | Medium |
|
||||
|
||||
```typescript
|
||||
// "A-Z" / "Z-A" makes sense for title, not for dateAdded
|
||||
<span className="ml-1 text-xs">{sortOrder === 'asc' ? 'A-Z' : 'Z-A'}</span>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Improvements Recommended
|
||||
|
||||
### High Priority
|
||||
|
||||
1. **Add Year Range Filter UI**
|
||||
- Add a dual-handle range slider component
|
||||
- Use `yearRange` from `FilterOptions`
|
||||
- Connect to `yearFrom`/`yearTo` URL params
|
||||
|
||||
2. **Validate Page Parameter**
|
||||
```typescript
|
||||
// GameBrowseLayout.tsx:72
|
||||
page: Math.max(1, parseInt(searchParams.get('page') || '1', 10) || 1),
|
||||
```
|
||||
|
||||
3. **Fix Sort Order Label for Dates**
|
||||
```typescript
|
||||
const sortOrderLabel = useMemo(() => {
|
||||
if (['releaseDate', 'dateAdded', 'dateModified'].includes(sortBy)) {
|
||||
return sortOrder === 'asc' ? 'Oldest' : 'Newest';
|
||||
}
|
||||
return sortOrder === 'asc' ? 'A-Z' : 'Z-A';
|
||||
}, [sortBy, sortOrder]);
|
||||
```
|
||||
|
||||
### Medium Priority
|
||||
|
||||
4. **Consolidate Filter Chip Rendering**
|
||||
- Remove inline badge rendering from `FilterPanel.tsx`
|
||||
- Use `FilterChips` component consistently
|
||||
- Pass chip generation logic via props or shared utility
|
||||
|
||||
5. **Add Filter Options Loading State**
|
||||
```typescript
|
||||
const { data: filterOptions, isLoading: filterOptionsLoading, error } = useFilterOptions();
|
||||
|
||||
// Show skeleton or disabled state while loading
|
||||
```
|
||||
|
||||
6. **Memoize Filter Handlers**
|
||||
```typescript
|
||||
const handlers = useMemo(() =>
|
||||
FILTER_CONFIGS.reduce((acc, config) => ({
|
||||
...acc,
|
||||
[config.id]: createFilterHandlers(config.paramKey, config.id, filters, updateFilter, dropdowns)
|
||||
}), {}),
|
||||
[filters, updateFilter, dropdowns]
|
||||
);
|
||||
```
|
||||
|
||||
7. **Add Search-as-You-Type with Debounce**
|
||||
```typescript
|
||||
// In a new SearchInput component
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const debouncedSearch = useDebounce(inputValue, 300);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearch) {
|
||||
navigate(`/browse?search=${encodeURIComponent(debouncedSearch)}`);
|
||||
}
|
||||
}, [debouncedSearch]);
|
||||
```
|
||||
|
||||
### Low Priority
|
||||
|
||||
8. **Consistent LIMIT on All Filter Queries**
|
||||
```sql
|
||||
-- GameService.ts:780-781
|
||||
ORDER BY count DESC, playMode ASC
|
||||
LIMIT 100 -- Add this
|
||||
```
|
||||
|
||||
9. **Add Filter Presets**
|
||||
- Save/load common filter combinations
|
||||
- "Popular Flash Games", "Recent Additions", etc.
|
||||
|
||||
10. **Add Filter Count in Dropdown Trigger**
|
||||
- Show total matching games for current filter combination
|
||||
- Update counts dynamically based on other selected filters (cascading filters)
|
||||
|
||||
11. **Keyboard Navigation Improvements**
|
||||
- Arrow keys to navigate dropdown options
|
||||
- Enter to toggle selection
|
||||
- Escape to close dropdown
|
||||
|
||||
---
|
||||
|
||||
## Code Quality Observations
|
||||
|
||||
### Positive Patterns
|
||||
|
||||
| Pattern | Location | Notes |
|
||||
|---------|----------|-------|
|
||||
| Config-driven filter rendering | `filterConfig.ts` | Eliminates 7 duplicate filter definitions |
|
||||
| Proper `asyncHandler()` usage | `games.ts` routes | All async routes wrapped |
|
||||
| Zod validation | `games.ts:27-49` | Comprehensive query validation |
|
||||
| Window function for count | `GameService.ts:247` | Efficient single-query pagination |
|
||||
| Cache pre-warming | `GameService.ts:158-166` | Filter options cached at startup |
|
||||
| Proper URL param handling | `GameBrowseLayout.tsx` | `searchParams` synced correctly |
|
||||
|
||||
### Areas for Improvement
|
||||
|
||||
| Area | Current | Recommended |
|
||||
|------|---------|-------------|
|
||||
| Handler memoization | Created inline per render | Use `useMemo` or move outside component |
|
||||
| Error handling | Silent fallback to `[]` | Show error state with retry |
|
||||
| Year range UI | Missing | Add range slider component |
|
||||
| Filter persistence | URL only | Consider localStorage for user defaults |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Category | Issues | Improvements |
|
||||
|----------|--------|--------------|
|
||||
| Type Safety | 3 | Validate all URL params |
|
||||
| Missing Features | 2 | Year filter, presets |
|
||||
| Performance | 2 | Memoize handlers |
|
||||
| Consistency | 3 | Unify chip rendering |
|
||||
| Accessibility | 2 | Sort labels, keyboard nav |
|
||||
| Backend | 1 | Consistent LIMIT |
|
||||
| **Total** | **13** | **10 recommendations** |
|
||||
|
||||
The filter system is functional and well-architected with the config-driven approach. The main gaps are the missing year range filter UI, sort order labels that don't match the sort type, and some performance optimizations around handler memoization.
|
||||
|
||||
---
|
||||
|
||||
## Action Items
|
||||
|
||||
- [x] Add year range filter slider to FilterPanel *(Fixed: 2026-02-15)*
|
||||
- [x] Fix page parameter validation in GameBrowseLayout *(Fixed: 2026-02-15)*
|
||||
- [x] Update SortControls to show context-aware labels (Oldest/Newest vs A-Z/Z-A) *(Fixed: 2026-02-15)*
|
||||
- [x] Add LIMIT 100 to play modes query in GameService *(Fixed: 2026-02-15)*
|
||||
- [x] Consolidate filter chip rendering to use FilterChips component only *(Fixed: 2026-02-15)*
|
||||
- [x] Add loading/error states for filter options *(Fixed: 2026-02-15)*
|
||||
- [x] Memoize filter handlers in FilterPanel *(Fixed: 2026-02-15)*
|
||||
|
||||
## Fixes Applied (2026-02-15)
|
||||
|
||||
### Files Modified
|
||||
|
||||
1. **`frontend/src/components/library/GameBrowseLayout.tsx`**
|
||||
- Added `isNaN()` validation for page parameter with `Math.max(1, ...)` fallback
|
||||
- Added year range chip generation and removal handling
|
||||
|
||||
2. **`frontend/src/components/search/SortControls.tsx`**
|
||||
- Added context-aware sort order labels: "Oldest/Newest" for date fields, "A-Z/Z-A" for text fields
|
||||
- Improved accessibility with `aria-label`
|
||||
|
||||
3. **`frontend/src/components/search/FilterPanel.tsx`**
|
||||
- Memoized filter handlers using `useMemo` to avoid recreation on every render
|
||||
- Memoized `updateFilter` with `useCallback`
|
||||
- Memoized `visibleConfigs` array
|
||||
- Removed duplicate badge rendering (now uses `FilterChips` only)
|
||||
- Added loading state for filter options ("Loading..." message)
|
||||
- Integrated `YearRangeFilter` component
|
||||
|
||||
4. **`frontend/src/components/search/YearRangeFilter.tsx`** (new file)
|
||||
- Created year range filter component with from/to inputs
|
||||
- Supports compact mode for mobile
|
||||
- Proper validation of year values
|
||||
|
||||
5. **`backend/src/services/GameService.ts`**
|
||||
- Added `LIMIT 100` to play modes query for consistency with other filter queries
|
||||
|
||||
## Additional Improvements (2026-02-15)
|
||||
|
||||
### Error State with Retry for Filter Options
|
||||
|
||||
**`frontend/src/components/search/FilterPanel.tsx`** - Added:
|
||||
- Error state detection from `useFilterOptions` hook
|
||||
- User-friendly error message with alert styling
|
||||
- Retry button to refetch filter options
|
||||
- Graceful fallback - shows filters if data exists even with stale error
|
||||
@@ -68,8 +68,14 @@ export function GameBrowseLayout({
|
||||
return isNaN(parsed) ? undefined : parsed;
|
||||
})(),
|
||||
sortBy: searchParams.get('sortBy') || 'title',
|
||||
sortOrder: (searchParams.get('sortOrder') || 'asc') as 'asc' | 'desc',
|
||||
page: parseInt(searchParams.get('page') || '1', 10),
|
||||
sortOrder: (() => {
|
||||
const raw = searchParams.get('sortOrder');
|
||||
return raw === 'asc' || raw === 'desc' ? raw : 'asc';
|
||||
})(),
|
||||
page: (() => {
|
||||
const parsed = parseInt(searchParams.get('page') || '1', 10);
|
||||
return isNaN(parsed) ? 1 : Math.max(1, parsed);
|
||||
})(),
|
||||
limit: 50,
|
||||
}),
|
||||
[searchParams, platform, library]
|
||||
@@ -194,6 +200,23 @@ export function GameBrowseLayout({
|
||||
});
|
||||
}
|
||||
|
||||
// Year range filters
|
||||
if (filters.yearFrom !== undefined || filters.yearTo !== undefined) {
|
||||
const yearValue =
|
||||
filters.yearFrom !== undefined && filters.yearTo !== undefined
|
||||
? `${filters.yearFrom} - ${filters.yearTo}`
|
||||
: filters.yearFrom !== undefined
|
||||
? `From ${filters.yearFrom}`
|
||||
: `To ${filters.yearTo}`;
|
||||
|
||||
chips.push({
|
||||
id: 'yearRange',
|
||||
label: 'Year',
|
||||
value: yearValue,
|
||||
category: 'Year',
|
||||
});
|
||||
}
|
||||
|
||||
return chips;
|
||||
}, [filters, platform]);
|
||||
|
||||
@@ -205,6 +228,9 @@ export function GameBrowseLayout({
|
||||
newParams.delete('search');
|
||||
} else if (chipId === 'platform') {
|
||||
newParams.delete('platform');
|
||||
} else if (chipId === 'yearRange') {
|
||||
newParams.delete('yearFrom');
|
||||
newParams.delete('yearTo');
|
||||
} else {
|
||||
const paramMap: Record<string, { param: string; value: string | undefined }> = {
|
||||
'series-': { param: 'series', value: filters.series },
|
||||
|
||||
@@ -1,38 +1,14 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Filter, X } from 'lucide-react';
|
||||
import { Filter, AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { useFilterOptions } from '@/hooks/useFilterOptions';
|
||||
import { useFilterDropdowns } from '@/hooks/useFilterDropdowns';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { FilterDropdown } from './FilterDropdown';
|
||||
import { YearRangeFilter } from './YearRangeFilter';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FILTER_CONFIGS } from './filterConfig';
|
||||
import type { GameFilters } from '@/types/game';
|
||||
|
||||
/**
|
||||
* Create generic toggle and clear handlers for any filter
|
||||
* Eliminates need for 7 separate toggle/clear functions
|
||||
*/
|
||||
const createFilterHandlers = (
|
||||
paramKey: keyof GameFilters,
|
||||
dropdownId: string,
|
||||
filters: GameFilters,
|
||||
updateFilter: (key: string, value: string) => void,
|
||||
dropdowns: ReturnType<typeof useFilterDropdowns>
|
||||
) => ({
|
||||
toggle: (value: string) => {
|
||||
const currentValue = filters[paramKey];
|
||||
const current = typeof currentValue === 'string' ? currentValue.split(',').filter(Boolean) : [];
|
||||
const updated = current.includes(value)
|
||||
? current.filter((v: string) => v !== value)
|
||||
: [...current, value];
|
||||
updateFilter(paramKey, updated.join(','));
|
||||
},
|
||||
clear: () => {
|
||||
updateFilter(paramKey, '');
|
||||
dropdowns.setOpen(`${dropdownId}-desktop`, false);
|
||||
dropdowns.setOpen(`${dropdownId}-mobile`, false);
|
||||
},
|
||||
});
|
||||
|
||||
interface FilterPanelProps {
|
||||
filters: GameFilters;
|
||||
showPlatformFilter?: boolean;
|
||||
@@ -43,40 +19,99 @@ export function FilterPanel({ filters, showPlatformFilter = true }: FilterPanelP
|
||||
|
||||
const dropdowns = useFilterDropdowns();
|
||||
|
||||
const { data: filterOptions } = useFilterOptions();
|
||||
const {
|
||||
data: filterOptions,
|
||||
isLoading: filterOptionsLoading,
|
||||
error: filterOptionsError,
|
||||
refetch: refetchFilterOptions,
|
||||
} = useFilterOptions();
|
||||
|
||||
const updateFilter = (key: string, value: string) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (value) {
|
||||
newParams.set(key, value);
|
||||
} else {
|
||||
newParams.delete(key);
|
||||
}
|
||||
newParams.delete('page'); // Reset to page 1
|
||||
setSearchParams(newParams);
|
||||
};
|
||||
const updateFilter = useCallback(
|
||||
(key: string, value: string) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (value) {
|
||||
newParams.set(key, value);
|
||||
} else {
|
||||
newParams.delete(key);
|
||||
}
|
||||
newParams.delete('page'); // Reset to page 1
|
||||
setSearchParams(newParams);
|
||||
},
|
||||
[searchParams, setSearchParams]
|
||||
);
|
||||
|
||||
// Memoize handlers for all filter configs to avoid recreation on every render
|
||||
const filterHandlers = useMemo(() => {
|
||||
const handlers: Record<
|
||||
string,
|
||||
{ toggle: (value: string) => void; clear: () => void }
|
||||
> = {};
|
||||
|
||||
FILTER_CONFIGS.forEach((config) => {
|
||||
handlers[config.id] = {
|
||||
toggle: (value: string) => {
|
||||
const currentValue = filters[config.paramKey];
|
||||
const current =
|
||||
typeof currentValue === 'string' ? currentValue.split(',').filter(Boolean) : [];
|
||||
const updated = current.includes(value)
|
||||
? current.filter((v: string) => v !== value)
|
||||
: [...current, value];
|
||||
updateFilter(config.paramKey, updated.join(','));
|
||||
},
|
||||
clear: () => {
|
||||
updateFilter(config.paramKey, '');
|
||||
dropdowns.setOpen(`${config.id}-desktop`, false);
|
||||
dropdowns.setOpen(`${config.id}-mobile`, false);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return handlers;
|
||||
}, [filters, updateFilter, dropdowns]);
|
||||
|
||||
// Get visible filter configs based on showPlatformFilter prop
|
||||
const visibleConfigs = useMemo(
|
||||
() => FILTER_CONFIGS.filter((config) => showPlatformFilter || config.id !== 'platform'),
|
||||
[showPlatformFilter]
|
||||
);
|
||||
|
||||
// Handle year range changes
|
||||
const handleYearChange = useCallback(
|
||||
(yearFrom: number | undefined, yearTo: number | undefined) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
|
||||
if (yearFrom !== undefined) {
|
||||
newParams.set('yearFrom', yearFrom.toString());
|
||||
} else {
|
||||
newParams.delete('yearFrom');
|
||||
}
|
||||
|
||||
if (yearTo !== undefined) {
|
||||
newParams.set('yearTo', yearTo.toString());
|
||||
} else {
|
||||
newParams.delete('yearTo');
|
||||
}
|
||||
|
||||
newParams.delete('page'); // Reset to page 1
|
||||
setSearchParams(newParams);
|
||||
},
|
||||
[searchParams, setSearchParams]
|
||||
);
|
||||
|
||||
/**
|
||||
* Render all filters with consistent behavior
|
||||
* Handles both desktop and mobile layouts with single function
|
||||
*/
|
||||
const renderFilters = (isMobile: boolean) => {
|
||||
const suffix = isMobile ? 'mobile' : 'desktop';
|
||||
|
||||
return FILTER_CONFIGS.filter((config) => showPlatformFilter || config.id !== 'platform').map(
|
||||
(config) => {
|
||||
const handlers = createFilterHandlers(
|
||||
config.paramKey,
|
||||
config.id,
|
||||
filters,
|
||||
updateFilter,
|
||||
dropdowns
|
||||
);
|
||||
const renderFilters = useCallback(
|
||||
(isMobile: boolean) => {
|
||||
const suffix = isMobile ? 'mobile' : 'desktop';
|
||||
|
||||
return visibleConfigs.map((config) => {
|
||||
const handlers = filterHandlers[config.id];
|
||||
const currentValue = filters[config.paramKey];
|
||||
const selectedValues =
|
||||
typeof currentValue === 'string' ? currentValue.split(',').filter(Boolean) : [];
|
||||
const options = filterOptions?.[config.optionsKey] || [];
|
||||
const options = filterOptions?.[config.optionsKey] ?? [];
|
||||
|
||||
return (
|
||||
<FilterDropdown
|
||||
@@ -90,54 +125,14 @@ export function FilterPanel({ filters, showPlatformFilter = true }: FilterPanelP
|
||||
onToggle={handlers.toggle}
|
||||
onClear={handlers.clear}
|
||||
placeholder={config.placeholder}
|
||||
emptyMessage={config.emptyMessage}
|
||||
emptyMessage={filterOptionsLoading ? 'Loading...' : config.emptyMessage}
|
||||
compact={isMobile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Render filter badges dynamically from config
|
||||
* Eliminates 7 separate badge sections
|
||||
*/
|
||||
const renderFilterBadges = () => {
|
||||
return FILTER_CONFIGS.filter((config) => showPlatformFilter || config.id !== 'platform').map(
|
||||
(config) => {
|
||||
const currentValue = filters[config.paramKey];
|
||||
const selectedValues =
|
||||
typeof currentValue === 'string' ? currentValue.split(',').filter(Boolean) : [];
|
||||
if (selectedValues.length === 0) return null;
|
||||
|
||||
const handlers = createFilterHandlers(
|
||||
config.paramKey,
|
||||
config.id,
|
||||
filters,
|
||||
updateFilter,
|
||||
dropdowns
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={config.id} className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground">{config.badgeLabel}:</span>
|
||||
{selectedValues.map((value: string) => (
|
||||
<Badge key={value} variant="secondary" className="gap-1">
|
||||
{value}
|
||||
<button
|
||||
onClick={() => handlers.toggle(value)}
|
||||
className="ml-1 hover:text-destructive"
|
||||
aria-label={`Remove ${value}`}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
});
|
||||
},
|
||||
[visibleConfigs, filterHandlers, filters, filterOptions, filterOptionsLoading, dropdowns]
|
||||
);
|
||||
|
||||
return (
|
||||
<fieldset className="space-y-4 border-0 p-0">
|
||||
@@ -146,15 +141,49 @@ export function FilterPanel({ filters, showPlatformFilter = true }: FilterPanelP
|
||||
Filters
|
||||
</legend>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Desktop Layout */}
|
||||
<div className="hidden md:flex items-center gap-3 flex-wrap">{renderFilters(false)}</div>
|
||||
{/* Error state with retry */}
|
||||
{filterOptionsError && !filterOptions ? (
|
||||
<div className="flex items-center gap-3 p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||
<AlertCircle size={18} className="text-destructive flex-shrink-0" aria-hidden="true" />
|
||||
<span className="text-sm text-destructive">Failed to load filter options</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetchFilterOptions()}
|
||||
className="ml-auto h-7 px-2 text-xs"
|
||||
>
|
||||
<RefreshCw size={14} className="mr-1" aria-hidden="true" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Desktop Layout */}
|
||||
<div className="hidden md:flex items-center gap-3 flex-wrap">
|
||||
{renderFilters(false)}
|
||||
<YearRangeFilter
|
||||
yearFrom={filters.yearFrom}
|
||||
yearTo={filters.yearTo}
|
||||
minYear={filterOptions?.yearRange?.min ?? 1980}
|
||||
maxYear={filterOptions?.yearRange?.max ?? new Date().getFullYear()}
|
||||
onYearChange={handleYearChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Mobile Layout */}
|
||||
<div className="md:hidden flex items-center gap-2">{renderFilters(true)}</div>
|
||||
|
||||
<div className="space-y-2">{renderFilterBadges()}</div>
|
||||
</div>
|
||||
{/* Mobile Layout */}
|
||||
<div className="md:hidden flex items-center gap-2">
|
||||
{renderFilters(true)}
|
||||
<YearRangeFilter
|
||||
yearFrom={filters.yearFrom}
|
||||
yearTo={filters.yearTo}
|
||||
minYear={filterOptions?.yearRange?.min ?? 1980}
|
||||
maxYear={filterOptions?.yearRange?.max ?? new Date().getFullYear()}
|
||||
onYearChange={handleYearChange}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { ArrowUpDown } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
@@ -8,6 +9,8 @@ interface SortControlsProps {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const DATE_SORT_FIELDS = ['releaseDate', 'dateAdded', 'dateModified'];
|
||||
|
||||
export function SortControls({
|
||||
sortBy = 'title',
|
||||
sortOrder = 'asc',
|
||||
@@ -18,6 +21,13 @@ export function SortControls({
|
||||
onSortChange('sortOrder', sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
};
|
||||
|
||||
const sortOrderLabel = useMemo(() => {
|
||||
if (DATE_SORT_FIELDS.includes(sortBy)) {
|
||||
return sortOrder === 'asc' ? 'Oldest' : 'Newest';
|
||||
}
|
||||
return sortOrder === 'asc' ? 'A-Z' : 'Z-A';
|
||||
}, [sortBy, sortOrder]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
@@ -38,10 +48,11 @@ export function SortControls({
|
||||
onClick={handleSortOrderToggle}
|
||||
className={`h-10 ${compact ? 'px-2' : 'px-3'}`}
|
||||
type="button"
|
||||
title={sortOrder === 'asc' ? 'Ascending' : 'Descending'}
|
||||
title={sortOrderLabel}
|
||||
aria-label={`Sort order: ${sortOrderLabel}`}
|
||||
>
|
||||
<ArrowUpDown size={16} />
|
||||
{!compact && <span className="ml-1 text-xs">{sortOrder === 'asc' ? 'A-Z' : 'Z-A'}</span>}
|
||||
<ArrowUpDown size={16} aria-hidden="true" />
|
||||
{!compact && <span className="ml-1 text-xs">{sortOrderLabel}</span>}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Calendar, ChevronDown } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
|
||||
interface YearRangeFilterProps {
|
||||
yearFrom?: number;
|
||||
yearTo?: number;
|
||||
minYear?: number;
|
||||
maxYear?: number;
|
||||
onYearChange: (yearFrom: number | undefined, yearTo: number | undefined) => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function YearRangeFilter({
|
||||
yearFrom,
|
||||
yearTo,
|
||||
minYear = 1980,
|
||||
maxYear = new Date().getFullYear(),
|
||||
onYearChange,
|
||||
compact = false,
|
||||
}: YearRangeFilterProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [localFrom, setLocalFrom] = useState<string>(yearFrom?.toString() ?? '');
|
||||
const [localTo, setLocalTo] = useState<string>(yearTo?.toString() ?? '');
|
||||
|
||||
// Sync local state with props
|
||||
useEffect(() => {
|
||||
setLocalFrom(yearFrom?.toString() ?? '');
|
||||
setLocalTo(yearTo?.toString() ?? '');
|
||||
}, [yearFrom, yearTo]);
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
const fromParsed = localFrom ? parseInt(localFrom, 10) : undefined;
|
||||
const toParsed = localTo ? parseInt(localTo, 10) : undefined;
|
||||
|
||||
const validFrom =
|
||||
fromParsed !== undefined && !isNaN(fromParsed) && fromParsed >= minYear && fromParsed <= maxYear
|
||||
? fromParsed
|
||||
: undefined;
|
||||
const validTo =
|
||||
toParsed !== undefined && !isNaN(toParsed) && toParsed >= minYear && toParsed <= maxYear
|
||||
? toParsed
|
||||
: undefined;
|
||||
|
||||
onYearChange(validFrom, validTo);
|
||||
setIsOpen(false);
|
||||
}, [localFrom, localTo, minYear, maxYear, onYearChange]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setLocalFrom('');
|
||||
setLocalTo('');
|
||||
onYearChange(undefined, undefined);
|
||||
setIsOpen(false);
|
||||
}, [onYearChange]);
|
||||
|
||||
const hasSelection = yearFrom !== undefined || yearTo !== undefined;
|
||||
|
||||
const buttonText = hasSelection
|
||||
? yearFrom && yearTo
|
||||
? `${yearFrom} - ${yearTo}`
|
||||
: yearFrom
|
||||
? `From ${yearFrom}`
|
||||
: `To ${yearTo}`
|
||||
: 'Year';
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen} modal={false}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={compact ? 'justify-between' : 'min-w-[120px] justify-between'}
|
||||
type="button"
|
||||
aria-label={`Filter by year: ${hasSelection ? buttonText : 'none selected'}`}
|
||||
>
|
||||
<span className={compact ? '' : 'mr-2'} aria-hidden="true">
|
||||
<Calendar size={16} />
|
||||
</span>
|
||||
<span className={compact ? 'ml-1' : ''}>{compact && !hasSelection ? '' : buttonText}</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={`${compact ? 'ml-1' : 'ml-2'} transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[250px] p-4 z-[100]" align="start" sideOffset={8}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Release Year</span>
|
||||
{hasSelection && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
className="h-auto py-1 px-2 text-xs text-destructive hover:text-destructive"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="year-from" className="text-xs text-muted-foreground mb-1 block">
|
||||
From
|
||||
</label>
|
||||
<Input
|
||||
id="year-from"
|
||||
type="number"
|
||||
min={minYear}
|
||||
max={maxYear}
|
||||
value={localFrom}
|
||||
onChange={(e) => setLocalFrom(e.target.value)}
|
||||
placeholder={minYear.toString()}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-muted-foreground mt-5">-</span>
|
||||
<div className="flex-1">
|
||||
<label htmlFor="year-to" className="text-xs text-muted-foreground mb-1 block">
|
||||
To
|
||||
</label>
|
||||
<Input
|
||||
id="year-to"
|
||||
type="number"
|
||||
min={minYear}
|
||||
max={maxYear}
|
||||
value={localTo}
|
||||
onChange={(e) => setLocalTo(e.target.value)}
|
||||
placeholder={maxYear.toString()}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleApply} className="w-full" size="sm">
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
|
||||
export function useFilterDropdowns() {
|
||||
const [openDropdowns, setOpenDropdowns] = useState<Record<string, boolean>>({});
|
||||
|
||||
const isOpen = useCallback(
|
||||
(filterName: string) => {
|
||||
return openDropdowns[filterName] || false;
|
||||
},
|
||||
[openDropdowns]
|
||||
);
|
||||
// Store current state in ref for stable callback
|
||||
const openDropdownsRef = useRef(openDropdowns);
|
||||
useEffect(() => {
|
||||
openDropdownsRef.current = openDropdowns;
|
||||
}, [openDropdowns]);
|
||||
|
||||
// Stable callback - reads from ref instead of closure
|
||||
const isOpen = useCallback((filterName: string) => {
|
||||
return openDropdownsRef.current[filterName] || false;
|
||||
}, []);
|
||||
|
||||
const setOpen = useCallback((filterName: string, open: boolean) => {
|
||||
setOpenDropdowns((prev) => ({
|
||||
@@ -21,9 +25,13 @@ export function useFilterDropdowns() {
|
||||
setOpenDropdowns({});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
setOpen,
|
||||
closeAll,
|
||||
};
|
||||
// Return stable object reference - callbacks are all stable now
|
||||
return useMemo(
|
||||
() => ({
|
||||
isOpen,
|
||||
setOpen,
|
||||
closeAll,
|
||||
}),
|
||||
[isOpen, setOpen, closeAll]
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user