Skip to content

Commit cad6bbe

Browse files
authored
Deeplinkable deck filters (#853)
* Add helper hook * Use search params in decks page
1 parent e4120cc commit cad6bbe

File tree

5 files changed

+125
-141
lines changed

5 files changed

+125
-141
lines changed

frontend/src/App.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,7 @@ function App() {
8585
{ path: '/collection/missions', element: <Missions /> },
8686
{ path: '/collection/:friendId?', element: <Collection /> },
8787
{ path: '/collection/:friendId/trade', element: <TradeWithRedirect /> }, // support old trading path
88-
{ path: '/decks', element: <Navigate to="/decks/filter/popular" replace /> },
89-
{ path: '/decks/filter/:kind', element: <Decks /> },
88+
{ path: '/decks', element: <Decks /> },
9089
{ path: '/decks/edit/:id?', element: <DeckBuilder /> },
9190
{ path: '/decks/:id', element: <DeckView /> },
9291
{ path: '/scan', element: <Scan /> },
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { useSearchParams } from 'react-router'
2+
import { z } from 'zod'
3+
4+
function safeJson(val: string) {
5+
try {
6+
return JSON.parse(val)
7+
} catch {
8+
return val
9+
}
10+
}
11+
12+
export default function useSearchState<T extends z.ZodObject>(schema: T): [z.infer<T>, (updates: Partial<z.infer<T>>) => void, number] {
13+
const [searchParams, setSearchParams] = useSearchParams()
14+
15+
const setValues = (updates: Partial<z.infer<T>>) => {
16+
const params = new URLSearchParams(searchParams)
17+
for (const key in updates) {
18+
const val = updates[key]
19+
const field = schema.shape[key] as z.ZodType
20+
21+
if (field === undefined) {
22+
console.warn(`useSearchState(): Unknown key '${key}'`)
23+
continue
24+
}
25+
26+
if (field instanceof z.ZodArray || (field instanceof z.ZodDefault && field.unwrap() instanceof z.ZodArray)) {
27+
if (!Array.isArray(val)) {
28+
console.warn(`useSearchState(): ${key} should be an array`)
29+
continue
30+
}
31+
if (val.length === 0) {
32+
params.delete(key)
33+
} else {
34+
params.set(key, val.join(','))
35+
}
36+
} else {
37+
if (val === field.parse(undefined)) {
38+
params.delete(key)
39+
} else {
40+
params.set(key, String(val))
41+
}
42+
}
43+
}
44+
setSearchParams(params)
45+
}
46+
47+
const obj = Object.fromEntries(
48+
Object.entries(schema.shape).map(([key, field]) => {
49+
if (!searchParams.has(key)) {
50+
return [key, undefined]
51+
}
52+
if (field instanceof z.ZodArray || (field instanceof z.ZodDefault && field.unwrap() instanceof z.ZodArray)) {
53+
return [key, searchParams.get(key)?.split(',').map(safeJson)]
54+
} else {
55+
return [key, safeJson(searchParams.get(key) as string)]
56+
}
57+
}),
58+
)
59+
60+
const count = Object.keys(schema.shape).filter((key) => searchParams.has(key)).length
61+
62+
return [schema.parse(obj), setValues, count]
63+
}

frontend/src/pages/collection/CollectionCards.tsx

Lines changed: 30 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -5,50 +5,34 @@ import { useTranslation } from 'react-i18next'
55
import { useMediaQuery } from 'react-responsive'
66
import { Link, useSearchParams } from 'react-router'
77
import { Tooltip } from 'react-tooltip'
8+
import { z } from 'zod'
89
import { Card } from '@/components/Card'
910
import { CardsTable } from '@/components/CardsTable.tsx'
1011
import FiltersPanel from '@/components/FiltersPanel'
1112
import { Button } from '@/components/ui/button'
1213
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
14+
import useSearchState from '@/hooks/use-search-state'
1315
import { toast } from '@/hooks/use-toast'
14-
import {
15-
type CardTypeOption,
16-
cardTypeOptions,
17-
expansionOptions,
18-
type Filters,
19-
type FiltersAll,
20-
getFilteredCards,
21-
ownershipOptions,
22-
sortByOptions,
23-
tradingOptions,
24-
} from '@/lib/filters'
16+
import { getFilteredCards, ownershipOptions, sortByOptions, tradingOptions } from '@/lib/filters'
2517
import { getCardNameByLang } from '@/lib/utils.ts'
2618
import { useAccount, useProfileDialog } from '@/services/account/useAccount'
27-
import { type Card as CardType, type CollectionRow, type Rarity, rarities } from '@/types'
28-
29-
const numberParser = (s: string) => {
30-
const x = Number(s)
31-
return Number.isNaN(x) ? undefined : x
32-
}
33-
const boolParser = (s: string) => {
34-
return s === 'true' ? true : s === 'false' ? false : undefined
35-
}
36-
37-
const filterParsers: { [K in keyof FiltersAll]: (s: string) => Filters[K] | undefined } = {
38-
search: (s) => s,
39-
expansion: (s) => ((expansionOptions as readonly string[]).includes(s) ? (s as Filters['expansion']) : undefined),
40-
pack: (s) => s,
41-
cardType: (s) => s.split(',').filter((x): x is CardTypeOption => (cardTypeOptions as readonly string[]).includes(x)),
42-
rarity: (s) => s.split(',').filter((x): x is Rarity => (rarities as readonly string[]).includes(x)),
43-
ownership: (s) => ((ownershipOptions as readonly string[]).includes(s) ? (s as Filters['ownership']) : undefined),
44-
trading: (s) => ((tradingOptions as readonly string[]).includes(s) ? (s as Filters['trading']) : undefined),
45-
sortBy: (s) => ((sortByOptions as readonly string[]).includes(s) ? (s as Filters['sortBy']) : undefined),
46-
sortDesc: boolParser,
47-
minNumber: numberParser,
48-
maxNumber: numberParser,
49-
deckbuildingMode: boolParser,
50-
allTextSearch: boolParser,
51-
}
19+
import { type Card as CardType, type CollectionRow, cardTypes, expansionIds, rarities } from '@/types'
20+
21+
const schema = z.object({
22+
search: z.string().default(''),
23+
expansion: z.union([z.enum(expansionIds), z.literal('all')]).default('all'),
24+
pack: z.string().default('all'),
25+
cardType: z.array(z.enum(cardTypes)).default([]),
26+
rarity: z.array(z.enum(rarities)).default([]),
27+
ownership: z.enum(ownershipOptions).default('all'),
28+
trading: z.enum(tradingOptions).default('all'),
29+
sortBy: z.enum(sortByOptions).default('expansion-newest'),
30+
sortDesc: z.boolean().default(false),
31+
minNumber: z.number().default(0),
32+
maxNumber: z.union([z.number(), z.literal('∞')]).default('∞'),
33+
deckbuildingMode: z.boolean().default(false),
34+
allTextSearch: z.boolean().default(false),
35+
})
5236

5337
interface Props {
5438
children?: ReactNode
@@ -57,80 +41,19 @@ interface Props {
5741
share?: boolean // undefined => disable, false => open settings, true => copy link
5842
}
5943

60-
const defaultFilters: Filters = {
61-
search: '',
62-
expansion: 'all',
63-
pack: 'all',
64-
cardType: [],
65-
rarity: [],
66-
ownership: 'all',
67-
trading: 'all',
68-
sortBy: 'expansion-newest',
69-
sortDesc: false,
70-
minNumber: 0,
71-
maxNumber: '∞',
72-
deckbuildingMode: false,
73-
allTextSearch: false,
74-
}
75-
7644
export default function CollectionCards({ children, cards, isPublic, share }: Props) {
7745
const { t } = useTranslation('pages/collection')
7846
const isMobile = useMediaQuery({ query: '(max-width: 767px)' }) // tailwind "md"
7947

8048
const { setIsProfileDialogOpen } = useProfileDialog()
8149
const [isFiltersSheetOpen, setIsFiltersSheetOpen] = useState(false) // used only on mobile
82-
const [searchParams, setSearchParams] = useSearchParams()
8350
const { data: account } = useAccount()
8451

85-
const filters = () => {
86-
const res: Filters = { ...defaultFilters }
87-
88-
const updateFilter = <K extends keyof Filters>(key: K, value: string) => {
89-
const parsed = filterParsers[key](value) as Filters[K]
90-
if (parsed !== undefined) {
91-
res[key] = parsed
92-
}
93-
}
94-
95-
for (const key in res) {
96-
const raw = searchParams.get(key)
97-
if (raw != null) {
98-
updateFilter(key as keyof Filters, raw)
99-
}
100-
}
101-
return res
102-
}
103-
104-
const setFilters = (updates: Partial<Filters>) => {
105-
const params = new URLSearchParams(searchParams)
106-
for (const key in updates) {
107-
const val = updates[key as keyof Filters]
108-
if (val == null || val === 'all' || (Array.isArray(val) && val.length === 0) || val === '') {
109-
params.delete(key)
110-
} else if (Array.isArray(val)) {
111-
params.set(key, val.join(','))
112-
} else {
113-
params.set(key, String(val))
114-
}
115-
setSearchParams(params)
116-
}
117-
}
118-
52+
const [filters, setFilters, activeFilters] = useSearchState(schema)
53+
const [_, setSearchParams] = useSearchParams()
11954
const clearFilters = () => setSearchParams(new URLSearchParams())
12055

121-
const activeFilters = () => {
122-
let res = 0
123-
const currentFilters = filters()
124-
for (const key in currentFilters) {
125-
const k = key as keyof FiltersAll
126-
if (currentFilters[k] !== defaultFilters[k]) {
127-
res++
128-
}
129-
}
130-
return res
131-
}
132-
133-
const filteredCards = getFilteredCards(filters(), cards, account?.trade_rarity_settings)
56+
const filteredCards = getFilteredCards(filters, cards, account?.trade_rarity_settings)
13457

13558
const getTradingMessage = () => {
13659
if (!account) {
@@ -164,10 +87,10 @@ export default function CollectionCards({ children, cards, isPublic, share }: Pr
16487
}
16588

16689
cardValues += `${t('trade.lookingForCards')}:\n`
167-
putCards(getFilteredCards({ ...filters(), trading: 'wanted' }, cards, account.trade_rarity_settings))
90+
putCards(getFilteredCards({ ...filters, trading: 'wanted' }, cards, account.trade_rarity_settings))
16891

16992
cardValues += `\n\n${t('trade.forTradeCards')}:\n`
170-
putCards(getFilteredCards({ ...filters(), trading: 'extra' }, cards, account.trade_rarity_settings))
93+
putCards(getFilteredCards({ ...filters, trading: 'extra' }, cards, account.trade_rarity_settings))
17194

17295
return cardValues
17396
}
@@ -215,7 +138,7 @@ export default function CollectionCards({ children, cards, isPublic, share }: Pr
215138
</>
216139
)}
217140
</small>
218-
<FiltersPanel className="flex flex-col gap-y-3" filters={filters()} setFilters={setFilters} clearFilters={clearFilters} />
141+
<FiltersPanel className="flex flex-col gap-y-3" filters={filters} setFilters={setFilters} clearFilters={clearFilters} />
219142
<div className="flex flex-col mt-4 gap-2">
220143
{share !== undefined && (
221144
<Button variant="outline" onClick={onShare}>
@@ -264,9 +187,9 @@ export default function CollectionCards({ children, cards, isPublic, share }: Pr
264187
<div className="h-9 flex overflow-hidden text-center rounded-md text-sm font-medium border shadow-sm border-neutral-700 divide-x divide-neutral-700 [&>*]:cursor-pointer [&>*]:hover:bg-neutral-600 [&>*]:hover:text-neutral-50">
265188
<button type="button" className="flex-1" onClick={() => setIsFiltersSheetOpen(true)}>
266189
Filters
267-
{activeFilters() > 0 && ` (${activeFilters()})`}
190+
{activeFilters > 0 && ` (${activeFilters})`}
268191
</button>
269-
{activeFilters() > 0 && (
192+
{activeFilters > 0 && (
270193
<button type="button" className="group px-2" onClick={clearFilters}>
271194
<Trash2 className="stroke-neutral-200 group-hover:stroke-neutral-50" />
272195
</button>
@@ -276,8 +199,8 @@ export default function CollectionCards({ children, cards, isPublic, share }: Pr
276199
{children}
277200
<CardsTable
278201
cards={filteredCards}
279-
groupExpansions={filters().sortBy === 'expansion-newest'}
280-
render={(c) => <Card card={c} editable={!filters().deckbuildingMode && !isPublic} />}
202+
groupExpansions={filters.sortBy === 'expansion-newest'}
203+
render={(c) => <Card card={c} editable={!filters.deckbuildingMode && !isPublic} />}
281204
/>
282205
</div>
283206
</div>

frontend/src/pages/decks/Decks.tsx

Lines changed: 25 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,29 @@
11
import { ChevronFirst, ChevronLeft, ChevronRight } from 'lucide-react'
2-
import { useState } from 'react'
3-
import { Link, useNavigate, useParams } from 'react-router'
2+
import { Link } from 'react-router'
3+
import { z } from 'zod'
44
import ErrorAlert from '@/components/ErrorAlert'
55
import { DropdownFilter, TabsFilter, ToggleFilter } from '@/components/Filters'
66
import { Spinner } from '@/components/Spinner'
77
import { Button } from '@/components/ui/button'
88
import { showCardType } from '@/components/utils'
9+
import useSearchState from '@/hooks/use-search-state'
910
import { capitalize } from '@/lib/utils'
10-
import { type DeckFilters, deckKinds, deckOrder } from '@/services/decks/deckService'
11+
import { deckKinds, deckOrder } from '@/services/decks/deckService'
1112
import { useDecksSearch } from '@/services/decks/useDeck'
1213
import { energies } from '@/types'
1314
import { DeckItem } from './DeckItem'
1415

15-
export default function Decks() {
16-
const navigate = useNavigate()
17-
const { kind } = useParams<{ kind: DeckFilters['kind'] }>()
18-
const validKind = kind && deckKinds.includes(kind) ? kind : 'community'
16+
const schema = z.object({
17+
from: z.enum(deckKinds).default('community'),
18+
orderby: z.enum(deckOrder).default('popular'),
19+
page: z.number().gte(0).default(0),
20+
energy: z.array(z.enum(energies)).default([]),
21+
})
1922

20-
const [filters, setFilters] = useState<DeckFilters>({ kind: validKind, orderby: 'popular', page: 0, energy: [] })
23+
export default function Decks() {
24+
const [filters, setFilters] = useSearchState(schema)
2125
const { data, isLoading, isError, error } = useDecksSearch(filters)
2226

23-
const handleKindChange = (newKind: DeckFilters['kind']) => {
24-
setFilters((prev) => ({ ...prev, kind: newKind, page: 0 }))
25-
navigate(`/decks/filter/${newKind}`, { replace: true })
26-
}
27-
2827
return (
2928
<div className="flex gap-4 flex-col sm:flex-row sm:w-fit mx-auto px-1">
3029
<div className="flex flex-col gap-2">
@@ -34,28 +33,28 @@ export default function Decks() {
3433
<ChevronRight />
3534
</Button>
3635
</Link>
37-
<TabsFilter className="w-full" options={deckKinds} value={filters.kind} onChange={handleKindChange} show={(kind) => `${capitalize(kind)} decks`} />
38-
{filters.kind === 'community' && (
39-
<DropdownFilter
40-
options={deckOrder}
41-
value={filters.orderby}
42-
onChange={(orderby) => setFilters((prev) => ({ ...prev, orderby }))}
43-
label="Sort by"
44-
show={capitalize}
45-
/>
36+
<TabsFilter
37+
className="w-full"
38+
options={deckKinds}
39+
value={filters.from}
40+
onChange={(from) => setFilters({ from })}
41+
show={(from) => `${capitalize(from)} decks`}
42+
/>
43+
{filters.from === 'community' && (
44+
<DropdownFilter options={deckOrder} value={filters.orderby} onChange={(orderby) => setFilters({ orderby })} label="Sort by" show={capitalize} />
4645
)}
47-
<ToggleFilter options={energies} value={filters.energy} onChange={(energy) => setFilters((prev) => ({ ...prev, energy }))} show={showCardType} />
46+
<ToggleFilter options={energies} value={filters.energy} onChange={(energy) => setFilters({ energy })} show={showCardType} />
4847
</div>
4948
<div className="flex flex-col gap-2 sm:w-xl">
5049
<div className="flex items-center gap-2">
5150
<span>Page {filters.page + 1}</span>
52-
<Button variant="outline" onClick={() => setFilters((prev) => ({ ...prev, page: 0 }))} disabled={filters.page <= 0}>
51+
<Button variant="outline" onClick={() => setFilters({ page: 0 })} disabled={filters.page <= 0}>
5352
<ChevronFirst />
5453
</Button>
55-
<Button variant="outline" onClick={() => setFilters((prev) => ({ ...prev, page: Math.max(prev.page - 1, 0) }))} disabled={filters.page <= 0}>
54+
<Button variant="outline" onClick={() => setFilters({ page: Math.max(filters.page - 1, 0) })} disabled={filters.page <= 0}>
5655
<ChevronLeft />
5756
</Button>
58-
<Button variant="outline" onClick={() => setFilters((prev) => ({ ...prev, page: prev.page + 1 }))} disabled={!data?.hasNext}>
57+
<Button variant="outline" onClick={() => setFilters({ page: filters.page + 1 })} disabled={!data?.hasNext}>
5958
<ChevronRight />
6059
</Button>
6160
{data && <p className="italic text-neutral-400">Found {data.count} decks</p>}

0 commit comments

Comments
 (0)