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:
Darkraise
2026-02-15 17:52:42 +07:00
parent 9984b70a70
commit e0656e81cb
7 changed files with 693 additions and 127 deletions
+24 -5
View File
@@ -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 },
+134 -105
View File
@@ -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>
);
}
+20 -12
View File
@@ -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]
);
}