Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions T04-Project-2/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import LoginPage from './components/LoginPage'
import FavoritesPage from './components/FavoritesPage'
import { FavoritesProvider } from './components/FavoritesProvider'
import { gameQueryOptions } from './api/games'
import { DEFAULT_FILTER_VALUES, toSearchOptions } from './api/filters'
import { toSearchOptions, type FilterValues } from './api/filters'
import { readSearchUrl, writeSearchUrl } from './api/searchUrl'
import type { AuthenticatedUser } from './api/auth'

const AUTHENTICATED_USER_STORAGE_KEY = 'gaminggoat:authenticated-user';
Expand Down Expand Up @@ -44,7 +45,6 @@ function readAuthenticatedUser(): AuthenticatedUser | null {
}

function App() {
const [query, setQuery] = useState('');
const [authenticatedUser, setAuthenticatedUser] = useState<AuthenticatedUser | null>(readAuthenticatedUser);
const [accountNotice, setAccountNotice] = useState<string | null>(null);
const [isAccountNoticeFading, setIsAccountNoticeFading] = useState(false);
Expand All @@ -56,6 +56,7 @@ function App() {
? { ...location, pathname: '/', search: '', hash: '' }
: location);

const { query, filters } = readSearchUrl(location.search);
const normalizedQuery = query.trim().toLowerCase();
const [debouncedQuery, setDebouncedQuery] = useState(normalizedQuery);

Expand Down Expand Up @@ -90,18 +91,23 @@ function App() {
}
}, [authenticatedUser]);

const [filters, setFilters] = useState(DEFAULT_FILTER_VALUES);

const search = useInfiniteQuery({
...gameQueryOptions.infiniteSearch(debouncedQuery, toSearchOptions(filters)),
enabled: debouncedQuery.length > 0,
});

const handleQueryChange = (nextQuery: string) => {
setQuery(nextQuery);
if (location.pathname !== '/' && nextQuery.trim()) {
navigate('/');
}
const searchParams = writeSearchUrl(location.search, nextQuery, filters);
const isNewSearch = !query.trim() && Boolean(nextQuery.trim());
navigate({ pathname: '/', search: searchParams ? `?${searchParams}` : '' }, {
replace: location.pathname === '/' && !isNewSearch,
});
};

const handleApplyFilters = (nextFilters: FilterValues) => {
const searchParams = writeSearchUrl(location.search, query, nextFilters);
navigate({ pathname: '/', search: searchParams ? `?${searchParams}` : '' });
};

const closeLogin = () => {
Expand Down Expand Up @@ -144,7 +150,7 @@ function App() {
isLoading={normalizedQuery !== debouncedQuery || search.isLoading}
error={search.isLoadingError ? search.error : null}
filters={filters}
onApplyFilters={setFilters}
onApplyFilters={handleApplyFilters}
hasNextPage={search.hasNextPage}
isFetchingNextPage={search.isFetchingNextPage}
nextPageError={search.isFetchNextPageError ? search.error : null}
Expand Down
44 changes: 44 additions & 0 deletions T04-Project-2/frontend/src/api/searchUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { DEFAULT_FILTER_VALUES, MAX_YEAR, MIN_YEAR, type FilterValues, type SortField } from './filters'

const keys = ['q', 'genre', 'platform', 'rating', 'from', 'to', 'playtime', 'sort', 'order']
const sorts: SortField[] = ['rating', 'released', 'playtime']

function numberParam(params: URLSearchParams, key: string, fallback: number, min: number, max: number, integer = false): number {
const raw = params.get(key)
if (!raw || !/^\d+(?:\.\d+)?$/.test(raw)) return fallback
const value = Number(raw)
return Number.isFinite(value) && value >= min && value <= max && (!integer || Number.isInteger(value)) ? value : fallback
}

export function readSearchUrl(search: string): { query: string; filters: FilterValues } {
const params = new URLSearchParams(search)
const ids = (key: string) => [...new Set(params.getAll(key).filter(id => /^[1-9]\d*$/.test(id)))]
const sort = params.get('sort')
const from = numberParam(params, 'from', MIN_YEAR, MIN_YEAR, MAX_YEAR, true)
const to = numberParam(params, 'to', MAX_YEAR, MIN_YEAR, MAX_YEAR, true)
const sortBy = sorts.find(field => field === sort) ?? null
return {
query: params.get('q') ?? '',
filters: {
genres: ids('genre'), platforms: ids('platform'),
minRating: numberParam(params, 'rating', 0, 0, 5),
yearFrom: from <= to ? from : MIN_YEAR, yearTo: from <= to ? to : MAX_YEAR,
minPlaytime: numberParam(params, 'playtime', 0, 0, 100, true),
sortBy, sortOrder: sortBy && params.get('order') === 'ASC' ? 'ASC' : 'DESC',
},
}
}

export function writeSearchUrl(search: string, query: string, values: FilterValues): string {
const params = new URLSearchParams(search)
for (const key of keys) params.delete(key)
if (query.trim()) params.set('q', query)
values.genres.forEach(id => params.append('genre', id))
values.platforms.forEach(id => params.append('platform', id))
if (values.minRating > 0) params.set('rating', String(values.minRating))
if (values.yearFrom !== DEFAULT_FILTER_VALUES.yearFrom) params.set('from', String(values.yearFrom))
if (values.yearTo !== DEFAULT_FILTER_VALUES.yearTo) params.set('to', String(values.yearTo))
if (values.minPlaytime > 0) params.set('playtime', String(values.minPlaytime))
if (values.sortBy) { params.set('sort', values.sortBy); params.set('order', values.sortOrder) }
return params.toString()
}
31 changes: 31 additions & 0 deletions T04-Project-2/frontend/src/components/ActiveFilters.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
.active-filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin: 0;
min-width: 0;
max-width: 100%;
}

.active-filter-chip {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.7rem;
border: 1px solid rgba(25, 210, 74, 0.4);
border-radius: 999px;
background: rgba(25, 210, 74, 0.1);
color: var(--text);
font: inherit;
font-size: 0.8rem;
cursor: pointer;
min-height: 2.5rem;
max-width: 100%;
text-align: left;
}

.active-filter-chip:hover, .active-filter-chip:focus-visible {
background: rgba(25, 210, 74, 0.2);
outline-color: var(--primary-color);
}
35 changes: 35 additions & 0 deletions T04-Project-2/frontend/src/components/ActiveFilters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useQuery } from '@tanstack/react-query'
import { XIcon } from '@phosphor-icons/react'
import { DEFAULT_FILTER_VALUES, filterQueryOptions, type FilterValues } from '../api/filters'
import './ActiveFilters.css'

type Props = { filters: FilterValues; onChange: (filters: FilterValues) => void }

export function ActiveFilters({ filters, onChange }: Props) {
const { data: genres = [] } = useQuery({ ...filterQueryOptions.genres(), enabled: filters.genres.length > 0 })
const { data: platforms = [] } = useQuery({ ...filterQueryOptions.platforms(), enabled: filters.platforms.length > 0 })
const chips: { key: string; label: string; remove: () => void }[] = []
for (const id of filters.genres) {
const name = genres.find(genre => genre.id === id)?.name ?? `Genre ${id}`
chips.push({ key: `genre-${id}`, label: name, remove: () => onChange({ ...filters, genres: filters.genres.filter(value => value !== id) }) })
}
for (const id of filters.platforms) {
const name = platforms.find(platform => platform.id === id)?.name ?? `Platform ${id}`
chips.push({ key: `platform-${id}`, label: name, remove: () => onChange({ ...filters, platforms: filters.platforms.filter(value => value !== id) }) })
}
if (filters.minRating > 0) chips.push({ key: 'rating', label: `Rating ${filters.minRating}+`, remove: () => onChange({ ...filters, minRating: 0 }) })
if (filters.yearFrom !== DEFAULT_FILTER_VALUES.yearFrom) chips.push({ key: 'from', label: `From ${filters.yearFrom}`, remove: () => onChange({ ...filters, yearFrom: DEFAULT_FILTER_VALUES.yearFrom }) })
if (filters.yearTo !== DEFAULT_FILTER_VALUES.yearTo) chips.push({ key: 'to', label: `Until ${filters.yearTo}`, remove: () => onChange({ ...filters, yearTo: DEFAULT_FILTER_VALUES.yearTo }) })
if (filters.minPlaytime > 0) chips.push({ key: 'playtime', label: `Playtime ${filters.minPlaytime}+ hours`, remove: () => onChange({ ...filters, minPlaytime: 0 }) })
if (filters.sortBy) chips.push({ key: 'sort', label: `Sort: ${filters.sortBy === 'released' ? 'release year' : filters.sortBy} (${filters.sortOrder === 'ASC' ? 'ascending' : 'descending'})`, remove: () => onChange({ ...filters, sortBy: null, sortOrder: 'DESC' }) })
if (!chips.length) return null
return (
<div className="active-filters" aria-label="Active filters">
{chips.map(chip => (
<button className="active-filter-chip" type="button" key={chip.key} onClick={chip.remove} aria-label={`Remove ${chip.label} filter`}>
{chip.label}<XIcon size={15} weight="bold" aria-hidden="true" />
</button>
))}
</div>
)
}
3 changes: 2 additions & 1 deletion T04-Project-2/frontend/src/components/FilterMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ export const FilterMenu = ({ filters, onApply }: FilterMenuProps) => {
const buttonRef = useRef<HTMLButtonElement>(null)

function toggleMenu() {
setVisible((prev) => prev ? false : true)
if (!visible) setDraft(filters)
setVisible((prev) => !prev)
}

function updateDraft(changes: Partial<FilterValues>) {
Expand Down
5 changes: 3 additions & 2 deletions T04-Project-2/frontend/src/components/GameCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Link } from 'react-router-dom'
import { Link, useLocation } from 'react-router-dom'
import { Star } from '@phosphor-icons/react'
import { useFavorites } from './useFavorites'
import './GameCard.css'
Expand All @@ -11,6 +11,7 @@ type GameCardProps = {
}

export const GameCard = ({ id, title, image, releaseDate }: GameCardProps) => {
const location = useLocation();
const { userId, favoriteIds, toggleFavorite } = useFavorites();
const databaseId = id && /^\d+$/.test(id) ? id : null;
const isFavorite = Boolean(databaseId && favoriteIds.includes(databaseId));
Expand All @@ -30,7 +31,7 @@ export const GameCard = ({ id, title, image, releaseDate }: GameCardProps) => {
return (
<div className="game-card-wrapper">
{databaseId ? (
<Link className="game-card-link" to={`/games/${databaseId}`} aria-label={`View details for ${title}`}>
<Link className="game-card-link" to={{ pathname: `/games/${databaseId}`, search: location.search }} aria-label={`View details for ${title}`}>
{card}
</Link>
) : card}
Expand Down
9 changes: 5 additions & 4 deletions T04-Project-2/frontend/src/components/GameDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState, type FormEvent } from 'react'
import { ArrowLeft, CalendarBlank, Star, X } from '@phosphor-icons/react'
import DOMPurify from 'dompurify'
import { Link, useParams } from 'react-router-dom'
import { Link, useLocation, useParams } from 'react-router-dom'
import type { AuthenticatedUser } from '../api/auth'
import { useFavorites } from './useFavorites'
import { createGameReview, deleteGameReview, gameFavoriteQueryOptions, gameQueryOptions, gameReviewQueryOptions } from '../api/games'
Expand All @@ -14,6 +14,7 @@ type GameDetailPageProps = {

function GameDetailPage({ authenticatedUser }: GameDetailPageProps) {
const { gameId } = useParams();
const location = useLocation();
const { userId, favoriteIds, toggleFavorite } = useFavorites();
const isDatabaseId = Boolean(gameId && /^\d+$/.test(gameId));
const { data: game, isPending, error } = useQuery({
Expand Down Expand Up @@ -79,7 +80,7 @@ function GameDetailPage({ authenticatedUser }: GameDetailPageProps) {
return (
<section className="game-detail-message">
<h1>Game not found</h1>
<Link to="/">Back to games</Link>
<Link to={{ pathname: '/', search: location.search }}>Back to games</Link>
</section>
);
}
Expand All @@ -96,7 +97,7 @@ function GameDetailPage({ authenticatedUser }: GameDetailPageProps) {
return (
<section className="game-detail-message">
<h1>Game not found</h1>
<Link to="/">Back to games</Link>
<Link to={{ pathname: '/', search: location.search }}>Back to games</Link>
</section>
);
}
Expand All @@ -116,7 +117,7 @@ function GameDetailPage({ authenticatedUser }: GameDetailPageProps) {
}}
>
<header className="game-detail-heading">
<Link className="game-detail-back" to="/">
<Link className="game-detail-back" to={{ pathname: '/', search: location.search }}>
<ArrowLeft size={18} aria-hidden="true" />
Back to games
</Link>
Expand Down
44 changes: 38 additions & 6 deletions T04-Project-2/frontend/src/components/SearchResults.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,28 @@
}

.heading-and-filter {
display: flex;
justify-content: space-between;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, auto) auto;
align-items: center;
position: relative;
gap: 0.75rem;
margin-bottom: 1.25rem;
}

.heading-and-filter > h2 {
margin: 0;
}

.heading-and-filter > .filter-section {
grid-column: 3;
grid-row: 1;
justify-self: end;
}

.heading-and-filter > .active-filters {
grid-column: 2;
grid-row: 1;
justify-content: flex-end;
}

.filter-button {
Expand Down Expand Up @@ -51,10 +68,25 @@
}

@media (max-width: 700px) {
.games-section > h2 {
width: min(100%, 24rem);
margin-inline: auto;
text-align: left;
.heading-and-filter {
grid-template-columns: minmax(0, 1fr) auto;
}

.heading-and-filter > .filter-section {
grid-column: 2;
}

.heading-and-filter > .active-filters {
grid-column: 1 / -1;
grid-row: 2;
}

.heading-and-filter > h2 {
font-size: clamp(1.1rem, 5vw, 1.5rem);
}

.heading-and-filter .filter-button {
min-height: 2.5rem;
}

.game-grid {
Expand Down
2 changes: 2 additions & 0 deletions T04-Project-2/frontend/src/components/SearchResults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { GameCard } from './GameCard';
import type { GameSearchResult } from '../api/games';
import searchBackgroundImage from '../assets/GamingGoatSearchBackground.png'
import { FilterMenu } from './FilterMenu';
import { ActiveFilters } from './ActiveFilters';
import type { FilterValues } from '../api/filters';

function searchThumbnail(url: string | null): string {
Expand Down Expand Up @@ -52,6 +53,7 @@ function SearchResults({ games, isLoading, error, filters, onApplyFilters, hasNe
<header className='heading-and-filter'>
<h2>Search results</h2>
<FilterMenu filters={filters} onApply={onApplyFilters}></FilterMenu>
<ActiveFilters filters={filters} onChange={onApplyFilters} />
</header>

{isLoading ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
Expand All @@ -15,14 +16,14 @@ const game: GameSearchResult = {
}
const defaults = { games: [] as GameSearchResult[], isLoading: false, error: null, hasNextPage: false, isFetchingNextPage: false, nextPageError: null, onLoadMore: vi.fn(), filters: DEFAULT_FILTER_VALUES, onApplyFilters: vi.fn() }
function show(props: Partial<Parameters<typeof SearchResults>[0]> = {}) {
return render(<MemoryRouter><FavoritesProvider userId={null}><SearchResults {...defaults} {...props} /></FavoritesProvider></MemoryRouter>)
return render(<QueryClientProvider client={new QueryClient()}><MemoryRouter><FavoritesProvider userId={null}><SearchResults {...defaults} {...props} /></FavoritesProvider></MemoryRouter></QueryClientProvider>)
}

describe('search results', () => {
it('shows loading and error states', () => {
const { rerender } = show({ isLoading: true })
expect(screen.getByRole('status')).toHaveTextContent('Searching games')
rerender(<MemoryRouter><SearchResults {...defaults} error={new Error('offline')} /></MemoryRouter>)
rerender(<QueryClientProvider client={new QueryClient()}><MemoryRouter><SearchResults {...defaults} error={new Error('offline')} /></MemoryRouter></QueryClientProvider>)
expect(screen.getByRole('alert')).toHaveTextContent('offline')
})
it('shows an empty state when nothing matches', () => {
Expand Down
16 changes: 16 additions & 0 deletions T04-Project-2/frontend/tests/components/SearchUrl.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_FILTER_VALUES, MAX_YEAR, MIN_YEAR } from '../../src/api/filters'
import { readSearchUrl, writeSearchUrl } from '../../src/api/searchUrl'

describe('search URL', () => {
it('round trips search, filters, sorting, and unrelated parameters', () => {
const filters = { ...DEFAULT_FILTER_VALUES, genres: ['1', '2'], platforms: ['4'], minRating: 4.5, yearFrom: 2000, yearTo: 2020, minPlaytime: 5, sortBy: 'rating' as const, sortOrder: 'ASC' as const }
const result = writeSearchUrl('?other=keep', 'Goat games', filters)
expect(readSearchUrl(result)).toEqual({ query: 'Goat games', filters })
expect(new URLSearchParams(result).get('other')).toBe('keep')
})
it('ignores invalid IDs and out-of-range or contradictory values', () => {
const { filters } = readSearchUrl('?genre=1&genre=1&genre=bad&platform=-2&rating=99&from=2024&to=2000&playtime=-1&sort=invalid&order=ASC')
expect(filters).toEqual({ ...DEFAULT_FILTER_VALUES, genres: ['1'], yearFrom: MIN_YEAR, yearTo: MAX_YEAR })
})
})
Loading