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
3 changes: 1 addition & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,7 @@ function App() {
{ path: '/collection/missions', element: <Missions /> },
{ path: '/collection/:friendId?', element: <Collection /> },
{ path: '/collection/:friendId/trade', element: <TradeWithRedirect /> }, // support old trading path
{ path: '/decks', element: <Navigate to="/decks/filter/popular" replace /> },
{ path: '/decks/filter/:kind', element: <Decks /> },
{ path: '/decks', element: <Decks /> },
{ path: '/decks/edit/:id?', element: <DeckBuilder /> },
{ path: '/decks/:id', element: <DeckView /> },
{ path: '/scan', element: <Scan /> },
Expand Down
63 changes: 63 additions & 0 deletions frontend/src/hooks/use-search-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { useSearchParams } from 'react-router'
import { z } from 'zod'

function safeJson(val: string) {
try {
return JSON.parse(val)
} catch {
return val
}
}

export default function useSearchState<T extends z.ZodObject>(schema: T): [z.infer<T>, (updates: Partial<z.infer<T>>) => void, number] {
const [searchParams, setSearchParams] = useSearchParams()

const setValues = (updates: Partial<z.infer<T>>) => {
const params = new URLSearchParams(searchParams)
for (const key in updates) {
const val = updates[key]
const field = schema.shape[key] as z.ZodType

if (field === undefined) {
console.warn(`useSearchState(): Unknown key '${key}'`)
continue
}

if (field instanceof z.ZodArray || (field instanceof z.ZodDefault && field.unwrap() instanceof z.ZodArray)) {
if (!Array.isArray(val)) {
console.warn(`useSearchState(): ${key} should be an array`)
continue
}
if (val.length === 0) {
params.delete(key)
} else {
params.set(key, val.join(','))
}
} else {
if (val === field.parse(undefined)) {
params.delete(key)
} else {
params.set(key, String(val))
}
}
}
setSearchParams(params)
}

const obj = Object.fromEntries(
Object.entries(schema.shape).map(([key, field]) => {
if (!searchParams.has(key)) {
return [key, undefined]
}
if (field instanceof z.ZodArray || (field instanceof z.ZodDefault && field.unwrap() instanceof z.ZodArray)) {
return [key, searchParams.get(key)?.split(',').map(safeJson)]
} else {
return [key, safeJson(searchParams.get(key) as string)]
}
}),
)

const count = Object.keys(schema.shape).filter((key) => searchParams.has(key)).length

return [schema.parse(obj), setValues, count]
}
137 changes: 30 additions & 107 deletions frontend/src/pages/collection/CollectionCards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,50 +5,34 @@ import { useTranslation } from 'react-i18next'
import { useMediaQuery } from 'react-responsive'
import { Link, useSearchParams } from 'react-router'
import { Tooltip } from 'react-tooltip'
import { z } from 'zod'
import { Card } from '@/components/Card'
import { CardsTable } from '@/components/CardsTable.tsx'
import FiltersPanel from '@/components/FiltersPanel'
import { Button } from '@/components/ui/button'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import useSearchState from '@/hooks/use-search-state'
import { toast } from '@/hooks/use-toast'
import {
type CardTypeOption,
cardTypeOptions,
expansionOptions,
type Filters,
type FiltersAll,
getFilteredCards,
ownershipOptions,
sortByOptions,
tradingOptions,
} from '@/lib/filters'
import { getFilteredCards, ownershipOptions, sortByOptions, tradingOptions } from '@/lib/filters'
import { getCardNameByLang } from '@/lib/utils.ts'
import { useAccount, useProfileDialog } from '@/services/account/useAccount'
import { type Card as CardType, type CollectionRow, type Rarity, rarities } from '@/types'

const numberParser = (s: string) => {
const x = Number(s)
return Number.isNaN(x) ? undefined : x
}
const boolParser = (s: string) => {
return s === 'true' ? true : s === 'false' ? false : undefined
}

const filterParsers: { [K in keyof FiltersAll]: (s: string) => Filters[K] | undefined } = {
search: (s) => s,
expansion: (s) => ((expansionOptions as readonly string[]).includes(s) ? (s as Filters['expansion']) : undefined),
pack: (s) => s,
cardType: (s) => s.split(',').filter((x): x is CardTypeOption => (cardTypeOptions as readonly string[]).includes(x)),
rarity: (s) => s.split(',').filter((x): x is Rarity => (rarities as readonly string[]).includes(x)),
ownership: (s) => ((ownershipOptions as readonly string[]).includes(s) ? (s as Filters['ownership']) : undefined),
trading: (s) => ((tradingOptions as readonly string[]).includes(s) ? (s as Filters['trading']) : undefined),
sortBy: (s) => ((sortByOptions as readonly string[]).includes(s) ? (s as Filters['sortBy']) : undefined),
sortDesc: boolParser,
minNumber: numberParser,
maxNumber: numberParser,
deckbuildingMode: boolParser,
allTextSearch: boolParser,
}
import { type Card as CardType, type CollectionRow, cardTypes, expansionIds, rarities } from '@/types'

const schema = z.object({
search: z.string().default(''),
expansion: z.union([z.enum(expansionIds), z.literal('all')]).default('all'),
pack: z.string().default('all'),
cardType: z.array(z.enum(cardTypes)).default([]),
rarity: z.array(z.enum(rarities)).default([]),
ownership: z.enum(ownershipOptions).default('all'),
trading: z.enum(tradingOptions).default('all'),
sortBy: z.enum(sortByOptions).default('expansion-newest'),
sortDesc: z.boolean().default(false),
minNumber: z.number().default(0),
maxNumber: z.union([z.number(), z.literal('∞')]).default('∞'),
deckbuildingMode: z.boolean().default(false),
allTextSearch: z.boolean().default(false),
})

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

const defaultFilters: Filters = {
search: '',
expansion: 'all',
pack: 'all',
cardType: [],
rarity: [],
ownership: 'all',
trading: 'all',
sortBy: 'expansion-newest',
sortDesc: false,
minNumber: 0,
maxNumber: '∞',
deckbuildingMode: false,
allTextSearch: false,
}

export default function CollectionCards({ children, cards, isPublic, share }: Props) {
const { t } = useTranslation('pages/collection')
const isMobile = useMediaQuery({ query: '(max-width: 767px)' }) // tailwind "md"

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

const filters = () => {
const res: Filters = { ...defaultFilters }

const updateFilter = <K extends keyof Filters>(key: K, value: string) => {
const parsed = filterParsers[key](value) as Filters[K]
if (parsed !== undefined) {
res[key] = parsed
}
}

for (const key in res) {
const raw = searchParams.get(key)
if (raw != null) {
updateFilter(key as keyof Filters, raw)
}
}
return res
}

const setFilters = (updates: Partial<Filters>) => {
const params = new URLSearchParams(searchParams)
for (const key in updates) {
const val = updates[key as keyof Filters]
if (val == null || val === 'all' || (Array.isArray(val) && val.length === 0) || val === '') {
params.delete(key)
} else if (Array.isArray(val)) {
params.set(key, val.join(','))
} else {
params.set(key, String(val))
}
setSearchParams(params)
}
}

const [filters, setFilters, activeFilters] = useSearchState(schema)
const [_, setSearchParams] = useSearchParams()
const clearFilters = () => setSearchParams(new URLSearchParams())

const activeFilters = () => {
let res = 0
const currentFilters = filters()
for (const key in currentFilters) {
const k = key as keyof FiltersAll
if (currentFilters[k] !== defaultFilters[k]) {
res++
}
}
return res
}

const filteredCards = getFilteredCards(filters(), cards, account?.trade_rarity_settings)
const filteredCards = getFilteredCards(filters, cards, account?.trade_rarity_settings)

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

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

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

return cardValues
}
Expand Down Expand Up @@ -215,7 +138,7 @@ export default function CollectionCards({ children, cards, isPublic, share }: Pr
</>
)}
</small>
<FiltersPanel className="flex flex-col gap-y-3" filters={filters()} setFilters={setFilters} clearFilters={clearFilters} />
<FiltersPanel className="flex flex-col gap-y-3" filters={filters} setFilters={setFilters} clearFilters={clearFilters} />
<div className="flex flex-col mt-4 gap-2">
{share !== undefined && (
<Button variant="outline" onClick={onShare}>
Expand Down Expand Up @@ -264,9 +187,9 @@ export default function CollectionCards({ children, cards, isPublic, share }: Pr
<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">
<button type="button" className="flex-1" onClick={() => setIsFiltersSheetOpen(true)}>
Filters
{activeFilters() > 0 && ` (${activeFilters()})`}
{activeFilters > 0 && ` (${activeFilters})`}
</button>
{activeFilters() > 0 && (
{activeFilters > 0 && (
<button type="button" className="group px-2" onClick={clearFilters}>
<Trash2 className="stroke-neutral-200 group-hover:stroke-neutral-50" />
</button>
Expand All @@ -276,8 +199,8 @@ export default function CollectionCards({ children, cards, isPublic, share }: Pr
{children}
<CardsTable
cards={filteredCards}
groupExpansions={filters().sortBy === 'expansion-newest'}
render={(c) => <Card card={c} editable={!filters().deckbuildingMode && !isPublic} />}
groupExpansions={filters.sortBy === 'expansion-newest'}
render={(c) => <Card card={c} editable={!filters.deckbuildingMode && !isPublic} />}
/>
</div>
</div>
Expand Down
51 changes: 25 additions & 26 deletions frontend/src/pages/decks/Decks.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,29 @@
import { ChevronFirst, ChevronLeft, ChevronRight } from 'lucide-react'
import { useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router'
import { Link } from 'react-router'
import { z } from 'zod'
import ErrorAlert from '@/components/ErrorAlert'
import { DropdownFilter, TabsFilter, ToggleFilter } from '@/components/Filters'
import { Spinner } from '@/components/Spinner'
import { Button } from '@/components/ui/button'
import { showCardType } from '@/components/utils'
import useSearchState from '@/hooks/use-search-state'
import { capitalize } from '@/lib/utils'
import { type DeckFilters, deckKinds, deckOrder } from '@/services/decks/deckService'
import { deckKinds, deckOrder } from '@/services/decks/deckService'
import { useDecksSearch } from '@/services/decks/useDeck'
import { energies } from '@/types'
import { DeckItem } from './DeckItem'

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

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

const handleKindChange = (newKind: DeckFilters['kind']) => {
setFilters((prev) => ({ ...prev, kind: newKind, page: 0 }))
navigate(`/decks/filter/${newKind}`, { replace: true })
}

return (
<div className="flex gap-4 flex-col sm:flex-row sm:w-fit mx-auto px-1">
<div className="flex flex-col gap-2">
Expand All @@ -34,28 +33,28 @@ export default function Decks() {
<ChevronRight />
</Button>
</Link>
<TabsFilter className="w-full" options={deckKinds} value={filters.kind} onChange={handleKindChange} show={(kind) => `${capitalize(kind)} decks`} />
{filters.kind === 'community' && (
<DropdownFilter
options={deckOrder}
value={filters.orderby}
onChange={(orderby) => setFilters((prev) => ({ ...prev, orderby }))}
label="Sort by"
show={capitalize}
/>
<TabsFilter
className="w-full"
options={deckKinds}
value={filters.from}
onChange={(from) => setFilters({ from })}
show={(from) => `${capitalize(from)} decks`}
/>
{filters.from === 'community' && (
<DropdownFilter options={deckOrder} value={filters.orderby} onChange={(orderby) => setFilters({ orderby })} label="Sort by" show={capitalize} />
)}
<ToggleFilter options={energies} value={filters.energy} onChange={(energy) => setFilters((prev) => ({ ...prev, energy }))} show={showCardType} />
<ToggleFilter options={energies} value={filters.energy} onChange={(energy) => setFilters({ energy })} show={showCardType} />
</div>
<div className="flex flex-col gap-2 sm:w-xl">
<div className="flex items-center gap-2">
<span>Page {filters.page + 1}</span>
<Button variant="outline" onClick={() => setFilters((prev) => ({ ...prev, page: 0 }))} disabled={filters.page <= 0}>
<Button variant="outline" onClick={() => setFilters({ page: 0 })} disabled={filters.page <= 0}>
<ChevronFirst />
</Button>
<Button variant="outline" onClick={() => setFilters((prev) => ({ ...prev, page: Math.max(prev.page - 1, 0) }))} disabled={filters.page <= 0}>
<Button variant="outline" onClick={() => setFilters({ page: Math.max(filters.page - 1, 0) })} disabled={filters.page <= 0}>
<ChevronLeft />
</Button>
<Button variant="outline" onClick={() => setFilters((prev) => ({ ...prev, page: prev.page + 1 }))} disabled={!data?.hasNext}>
<Button variant="outline" onClick={() => setFilters({ page: filters.page + 1 })} disabled={!data?.hasNext}>
<ChevronRight />
</Button>
{data && <p className="italic text-neutral-400">Found {data.count} decks</p>}
Expand Down
Loading