Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ Be kind. Be clear. Assume good intent. Keep feedback constructive.
- 👤 **Creator:** [Gilles Romer](https://romerg.de/)
- ✉️ **Contact:** [info@romerg.de](mailto:info@romerg.de)

![Version](https://img.shields.io/badge/version-v1.27.3-e3000b?style=flat-square) ![Dark Theme](https://img.shields.io/badge/theme-dark-1a1a2e?style=flat-square) ![TCGdex](https://img.shields.io/badge/card%20data-TCGdex-e3000b?style=flat-square) ![Docker](https://img.shields.io/badge/deploy-Docker-2496ed?style=flat-square) ![FastAPI](https://img.shields.io/badge/backend-FastAPI-009688?style=flat-square) ![React](https://img.shields.io/badge/frontend-React%2018-61dafb?style=flat-square) [![Ko-fi](https://img.shields.io/badge/support-Ko--fi-ff5e5b?style=flat-square&logo=ko-fi&logoColor=white)](https://ko-fi.com/gillesromer)
![Version](https://img.shields.io/badge/version-v1.27.4-e3000b?style=flat-square) ![Dark Theme](https://img.shields.io/badge/theme-dark-1a1a2e?style=flat-square) ![TCGdex](https://img.shields.io/badge/card%20data-TCGdex-e3000b?style=flat-square) ![Docker](https://img.shields.io/badge/deploy-Docker-2496ed?style=flat-square) ![FastAPI](https://img.shields.io/badge/backend-FastAPI-009688?style=flat-square) ![React](https://img.shields.io/badge/frontend-React%2018-61dafb?style=flat-square) [![Ko-fi](https://img.shields.io/badge/support-Ko--fi-ff5e5b?style=flat-square&logo=ko-fi&logoColor=white)](https://ko-fi.com/gillesromer)

**Current version:** `v1.27.3` · Releases are tracked on the [GitHub Releases page](https://github.com/Git-Romer/pokecollector/releases).
**Current version:** `v1.27.4` · Releases are tracked on the [GitHub Releases page](https://github.com/Git-Romer/pokecollector/releases).

![WebApp Preview](preview-homescreen.png)

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.27.3
1.27.4
4 changes: 2 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "pokemon-tcg-collection",
"private": true,
"version": "1.27.3",
"version": "1.27.4",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
76 changes: 76 additions & 0 deletions frontend/src/components/PokedexGenerationFilter.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import clsx from 'clsx'

export const POKEDEX_GENERATIONS = [
{ id: 1, region: 'Kanto', range: '#001–151' },
{ id: 2, region: 'Johto', range: '#152–251' },
{ id: 3, region: 'Hoenn', range: '#252–386' },
{ id: 4, region: 'Sinnoh', range: '#387–493' },
{ id: 5, region: 'Unova', range: '#494–649' },
{ id: 6, region: 'Kalos', range: '#650–721' },
{ id: 7, region: 'Alola', range: '#722–809' },
{ id: 8, region: 'Galar', range: '#810–905' },
{ id: 9, region: 'Paldea', range: '#906–1025' },
]

export default function PokedexGenerationFilter({ generation, onSelectGeneration, t }) {
const label = t('pokedex.generationFilter')

return (
<>
<label className="block sm:hidden">
<span className="sr-only">{label}</span>
<select
value={generation ?? ''}
onChange={(event) => {
const value = event.target.value
onSelectGeneration(value ? Number(value) : null)
}}
className="select w-full"
>
<option value="">{t('pokedex.national')}</option>
{POKEDEX_GENERATIONS.map((item) => (
<option key={item.id} value={item.id}>
Gen {item.id} · {item.region} · {item.range}
</option>
))}
</select>
</label>

<div
className="-mx-1 hidden gap-2 overflow-x-auto px-1 pb-1 sm:flex"
role="group"
aria-label={label}
>
<button
type="button"
onClick={() => onSelectGeneration(null)}
aria-pressed={!generation}
className={clsx(
'shrink-0 whitespace-nowrap rounded-full border px-3 py-1.5 text-xs font-bold',
!generation
? 'border-brand-red bg-brand-red/20 text-brand-red'
: 'border-border text-text-secondary'
)}
>
{t('pokedex.national')}
</button>
{POKEDEX_GENERATIONS.map((item) => (
<button
type="button"
key={item.id}
onClick={() => onSelectGeneration(item.id)}
aria-pressed={generation === item.id}
className={clsx(
'shrink-0 whitespace-nowrap rounded-full border px-3 py-1.5 text-xs font-bold',
generation === item.id
? 'border-brand-red bg-brand-red/20 text-brand-red'
: 'border-border text-text-secondary'
)}
>
Gen {item.id} · {item.region}
</button>
))}
</div>
</>
)
}
53 changes: 53 additions & 0 deletions frontend/src/components/PokedexGenerationFilter.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it, vi } from 'vitest'
import PokedexGenerationFilter from './PokedexGenerationFilter'

const translations = {
'pokedex.generationFilter': 'Pokédex region',
'pokedex.national': 'National',
}
const t = key => translations[key] || key

describe('PokedexGenerationFilter', () => {
it('renders a labelled mobile select and a non-shrinking desktop control', () => {
const markup = renderToStaticMarkup(createElement(PokedexGenerationFilter, {
generation: 3,
onSelectGeneration: vi.fn(),
t,
}))

expect(markup).toContain('<label class="block sm:hidden">')
expect(markup).toContain('<span class="sr-only">Pokédex region</span>')
expect(markup).toContain('<select class="select w-full">')
expect((markup.match(/aria-label="Pokédex region"/g) || [])).toHaveLength(1)
expect(markup).toContain('class="-mx-1 hidden gap-2 overflow-x-auto px-1 pb-1 sm:flex"')
expect(markup).toContain('role="group"')
expect(markup).toContain('aria-label="Pokédex region"')
expect(markup).toContain('shrink-0 whitespace-nowrap')
})

it('exposes all generations and the selected desktop state', () => {
const markup = renderToStaticMarkup(createElement(PokedexGenerationFilter, {
generation: 2,
onSelectGeneration: vi.fn(),
t,
}))

expect((markup.match(/<option/g) || [])).toHaveLength(10)
expect(markup).toContain('<option value="2" selected="">Gen 2 · Johto · #152–251</option>')
expect(markup).toContain('aria-pressed="true"')
expect(markup).toContain('Gen 2 · Johto')
})

it('selects National consistently when no generation is active', () => {
const markup = renderToStaticMarkup(createElement(PokedexGenerationFilter, {
generation: null,
onSelectGeneration: vi.fn(),
t,
}))

expect(markup).toContain('<option value="" selected="">National</option>')
expect(markup).toContain('<button type="button" aria-pressed="true"')
})
})
1 change: 1 addition & 0 deletions frontend/src/i18n/de.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const de = {
subtitle: 'Eine beliebige passende Karte vervollständigt das Pokémon.',
searchPlaceholder: 'Name oder Pokédex-Nummer',
national: 'National',
generationFilter: 'Pokédex-Region',
generation: 'Gen {generation}',
all: 'Alle',
owned: 'Vorhanden',
Expand Down
1 change: 1 addition & 0 deletions frontend/src/i18n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const en = {
subtitle: 'Own any matching card to complete a species.',
searchPlaceholder: 'Name or Pokédex number',
national: 'National',
generationFilter: 'Pokédex region',
generation: 'Gen {generation}',
all: 'All',
owned: 'Owned',
Expand Down
61 changes: 23 additions & 38 deletions frontend/src/pages/Pokedex.jsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,18 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
import { Check, Search } from 'lucide-react'
import clsx from 'clsx'
import { getPokedex } from '../api/client'
import { useSettings } from '../contexts/SettingsContext'
import PokeBallLoader from '../components/PokeBallLoader'
import PokedexGenerationFilter, { POKEDEX_GENERATIONS } from '../components/PokedexGenerationFilter'
import { getSavedListScrollPosition, isSavedPositionForLocation, useListScrollRestoration } from '../hooks/useListScrollRestoration'
import { getPokedexGeneration } from '../utils/pokedexUrlState'

const GENERATIONS = [
{ id: 1, region: 'Kanto', range: '#001–151' },
{ id: 2, region: 'Johto', range: '#152–251' },
{ id: 3, region: 'Hoenn', range: '#252–386' },
{ id: 4, region: 'Sinnoh', range: '#387–493' },
{ id: 5, region: 'Unova', range: '#494–649' },
{ id: 6, region: 'Kalos', range: '#650–721' },
{ id: 7, region: 'Alola', range: '#722–809' },
{ id: 8, region: 'Galar', range: '#810–905' },
{ id: 9, region: 'Paldea', range: '#906–1025' },
]
import {
getPokedexGeneration,
normalizePokedexSearchParams,
setPokedexGeneration,
} from '../utils/pokedexUrlState'

function SpeciesImage({ entry, name }) {
const handleError = (event) => {
Expand Down Expand Up @@ -104,6 +97,13 @@ export default function Pokedex() {
// Keep the URL as the source of truth so browser Back/Forward updates both
// the active filter and the query without requiring the page to remount.
const generation = getPokedexGeneration(searchParams)
const searchParamsKey = searchParams.toString()
useEffect(() => {
const normalized = normalizePokedexSearchParams(new URLSearchParams(searchParamsKey))
if (normalized.toString() !== searchParamsKey) {
setSearchParams(normalized, { replace: true })
}
}, [searchParamsKey, setSearchParams])
// The list remounts after Back. Restore non-URL filters before its query runs
// so the saved Pokémon anchor is present when scroll restoration occurs.
const savedPosition = getSavedListScrollPosition('pokedex')
Expand Down Expand Up @@ -138,18 +138,17 @@ export default function Pokedex() {
})
const grouped = useMemo(() => {
if (generation || search.trim()) return [{ generation, entries }]
return GENERATIONS.map((item) => ({
return POKEDEX_GENERATIONS.map((item) => ({
generation: item.id,
entries: entries.filter((entry) => entry.generation === item.id),
})).filter((group) => group.entries.length)
}, [entries, generation, search])

const summary = data?.summary || { total: 0, owned: 0, missing: 0 }
const progress = summary.total ? Math.round((summary.owned / summary.total) * 100) : 0
const scope = generation ? GENERATIONS.find((item) => item.id === generation) : null
const scope = generation ? POKEDEX_GENERATIONS.find((item) => item.id === generation) : null
const selectGeneration = (value) => {
if (value) setSearchParams({ generation: String(value) })
else setSearchParams({})
setSearchParams(setPokedexGeneration(searchParams, value))
}

return (
Expand Down Expand Up @@ -187,25 +186,11 @@ export default function Pokedex() {
/>
</label>

<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1">
<button
type="button"
onClick={() => selectGeneration(null)}
className={clsx('whitespace-nowrap rounded-full border px-3 py-1.5 text-xs font-bold', !generation ? 'border-brand-red bg-brand-red/20 text-brand-red' : 'border-border text-text-secondary')}
>
{t('pokedex.national')}
</button>
{GENERATIONS.map((item) => (
<button
type="button"
key={item.id}
onClick={() => selectGeneration(item.id)}
className={clsx('whitespace-nowrap rounded-full border px-3 py-1.5 text-xs font-bold', generation === item.id ? 'border-brand-red bg-brand-red/20 text-brand-red' : 'border-border text-text-secondary')}
>
Gen {item.id} · {item.region}
</button>
))}
</div>
<PokedexGenerationFilter
generation={generation}
onSelectGeneration={selectGeneration}
t={t}
/>

<div className="flex gap-2" role="group" aria-label="Ownership filter">
{['all', 'owned', 'missing'].map((value) => (
Expand All @@ -226,7 +211,7 @@ export default function Pokedex() {
{!isLoading && !isError && entries.length === 0 && <p className="py-12 text-center text-text-muted">{t('common.noResults')}</p>}

{!isLoading && grouped.map((group) => {
const info = GENERATIONS.find((item) => item.id === group.generation)
const info = POKEDEX_GENERATIONS.find((item) => item.id === group.generation)
return (
<section key={group.generation || 'results'} className="space-y-3">
{!generation && !search.trim() && info && (
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/utils/pokedexUrlState.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,25 @@ export const getPokedexGeneration = (searchParams) => {
? requestedGeneration
: null
}

export const normalizePokedexSearchParams = (searchParams) => {
const normalized = new URLSearchParams(searchParams)
const requestedGenerations = normalized.getAll('generation')
if (!requestedGenerations.length) return normalized

const generation = getPokedexGeneration(normalized)
if (generation) normalized.set('generation', String(generation))
else normalized.delete('generation')

return normalized
}

export const setPokedexGeneration = (searchParams, generation) => {
const updated = new URLSearchParams(searchParams)
if (Number.isInteger(generation) && generation >= 1 && generation <= 9) {
updated.set('generation', String(generation))
} else {
updated.delete('generation')
}
return updated
}
27 changes: 26 additions & 1 deletion frontend/src/utils/pokedexUrlState.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { getPokedexGeneration } from './pokedexUrlState'
import {
getPokedexGeneration,
normalizePokedexSearchParams,
setPokedexGeneration,
} from './pokedexUrlState'

describe('Pokédex URL state', () => {
it('follows generation changes from browser history', () => {
Expand All @@ -17,4 +21,25 @@ describe('Pokédex URL state', () => {
expect(getPokedexGeneration(new URLSearchParams('generation=10'))).toBeNull()
expect(getPokedexGeneration(new URLSearchParams('generation=2x'))).toBeNull()
})

it('normalizes malformed and duplicate generation parameters without removing unrelated state', () => {
expect(normalizePokedexSearchParams(
new URLSearchParams('generation=invalid&status=owned')
).toString()).toBe('status=owned')
expect(normalizePokedexSearchParams(
new URLSearchParams('generation=02&generation=3&status=owned')
).toString()).toBe('generation=2&status=owned')
})

it('writes valid generation changes and preserves unrelated search parameters', () => {
const initial = new URLSearchParams('status=owned&search=pika')

const johto = setPokedexGeneration(initial, 2)
expect(johto.toString()).toBe('status=owned&search=pika&generation=2')
expect(getPokedexGeneration(johto)).toBe(2)

const national = setPokedexGeneration(johto, null)
expect(national.toString()).toBe('status=owned&search=pika')
expect(getPokedexGeneration(national)).toBeNull()
})
})