feat: persist database filters in URL query params

- Add filterParams.ts with human-readable URL mappings
- Parse filters from URL on page load
- Update URL when filters/search/page change
- Support element, rarity, proficiency, season, series, search
- Handle weapon series slug <-> UUID mapping
This commit is contained in:
Justin Edmund 2025-12-22 13:52:22 -08:00
parent 94c7a3b799
commit ce495a9145
2 changed files with 451 additions and 13 deletions

View file

@ -13,6 +13,13 @@
import { onMount, onDestroy } from 'svelte'
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { createQuery, queryOptions } from '@tanstack/svelte-query'
import { entityAdapter } from '$lib/api/adapters/entity.adapter'
import {
parseFiltersFromUrl,
buildUrlFromFilters,
type ParsedFilters
} from '$lib/utils/filterParams'
import type { Snippet } from 'svelte'
@ -26,6 +33,22 @@
const { resource, columns, pageSize: initialPageSize = 20, leftActions, headerActions }: Props = $props()
// Derive entity type from resource
const entityType = $derived(
resource === 'characters' ? 'character' : resource === 'summons' ? 'summon' : 'weapon'
)
// Fetch weapon series list for URL slug mapping (only for weapons)
const weaponSeriesQuery = createQuery(() =>
queryOptions({
queryKey: ['weaponSeries', 'list'] as const,
queryFn: () => entityAdapter.getWeaponSeriesList(),
enabled: resource === 'weapons',
staleTime: 1000 * 60 * 60, // 1 hour
gcTime: 1000 * 60 * 60 * 24 // 24 hours
})
)
// State
let data = $state<any[]>([])
let loading = $state(true)
@ -65,7 +88,7 @@
? filters.series.filter((s): s is number => typeof s === 'number')
: undefined
})
loadData(1) // Reset to first page when filters change
loadData(1) // Reset to first page when filters change (this will update URL)
}
// Create provider
@ -74,16 +97,32 @@
// Grid API reference
let api: any
// Update URL with current page (without triggering navigation)
function updateUrl(pageNum: number) {
const url = new URL($page.url)
if (pageNum === 1) {
url.searchParams.delete('page')
} else {
url.searchParams.set('page', String(pageNum))
// Build current filter state for URL building
function getCurrentFilterState(): CollectionFilterState {
return {
element: elementFilters,
rarity: rarityFilters,
proficiency: proficiencyFilters,
season: seasonFilters,
series: seriesFilters,
race: [],
gender: []
}
}
// Update URL with current filters, search, and page (without triggering navigation)
function updateUrl(pageNum: number) {
const params = buildUrlFromFilters(
getCurrentFilterState(),
searchTerm,
pageNum,
entityType,
weaponSeriesQuery.data
)
const search = params.toString()
const url = search ? `${$page.url.pathname}?${search}` : $page.url.pathname
// Use replaceState to update URL without adding history entry
goto(url.pathname + url.search, { replaceState: true, noScroll: true, keepFocus: true })
goto(url, { replaceState: true, noScroll: true, keepFocus: true })
}
// Load data
@ -224,11 +263,76 @@
const startItem = $derived((currentPage - 1) * pageSize + 1)
const endItem = $derived(Math.min(currentPage * pageSize, total))
// Load initial data from URL page param
// Track if we've initialized from URL
let urlInitialized = $state(false)
// Initialize filters from URL (for weapons, wait for series list)
function initializeFromUrl() {
if (urlInitialized) return
if (resource === 'weapons' && !weaponSeriesQuery.data) return // Wait for weapon series
const parsed = parseFiltersFromUrl(
$page.url.searchParams,
entityType,
weaponSeriesQuery.data
)
// Set filter state
elementFilters = parsed.element
rarityFilters = parsed.rarity
proficiencyFilters = parsed.proficiency
seasonFilters = parsed.season
seriesFilters = parsed.series
searchTerm = parsed.searchQuery
// Apply filters to provider
if (
parsed.element.length > 0 ||
parsed.rarity.length > 0 ||
parsed.proficiency.length > 0 ||
parsed.season.length > 0 ||
parsed.series.length > 0
) {
const seriesAsStrings =
parsed.series.length > 0 ? parsed.series.map((s) => String(s)) : undefined
provider.setFilters({
element: parsed.element.length > 0 ? parsed.element : undefined,
rarity: parsed.rarity.length > 0 ? parsed.rarity : undefined,
series: seriesAsStrings,
proficiency1: parsed.proficiency.length > 0 ? parsed.proficiency : undefined,
season: parsed.season.length > 0 ? parsed.season : undefined,
characterSeries:
resource === 'characters' && parsed.series.length > 0
? parsed.series.filter((s): s is number => typeof s === 'number')
: undefined
})
}
// Apply search query to provider
if (parsed.searchQuery.length >= 2) {
provider.setSearchQuery(parsed.searchQuery)
lastSearchTerm = parsed.searchQuery
}
urlInitialized = true
loadData(parsed.page, false) // Don't update URL on initial load
}
// Load initial data from URL params
onMount(() => {
const pageParam = $page.url.searchParams.get('page')
const initialPage = pageParam ? Math.max(1, parseInt(pageParam, 10) || 1) : 1
loadData(initialPage, false) // Don't update URL on initial load
// For non-weapon resources, initialize immediately
// For weapons, wait for series query to complete
if (resource !== 'weapons') {
initializeFromUrl()
}
})
// For weapons, initialize once series list is loaded
$effect(() => {
if (resource === 'weapons' && weaponSeriesQuery.data && !urlInitialized) {
initializeFromUrl()
}
})
// Clean up timeout on destroy

View file

@ -0,0 +1,334 @@
/**
* URL Filter Parameter Utilities
*
* Bidirectional mappings for human-readable URL parameters
* and functions to parse/build filter URLs.
*/
import type { CollectionFilterState } from '$lib/components/collection/CollectionFilters.svelte'
import type { WeaponSeries } from '$lib/types/api/weaponSeries'
// ============================================================================
// Element Mapping (0-6)
// ============================================================================
export const ELEMENT_TO_PARAM: Record<number, string> = {
0: 'null',
1: 'wind',
2: 'fire',
3: 'water',
4: 'earth',
5: 'dark',
6: 'light'
}
export const PARAM_TO_ELEMENT: Record<string, number> = {
null: 0,
wind: 1,
fire: 2,
water: 3,
earth: 4,
dark: 5,
light: 6
}
// ============================================================================
// Rarity Mapping (1-3)
// ============================================================================
export const RARITY_TO_PARAM: Record<number, string> = {
1: 'r',
2: 'sr',
3: 'ssr'
}
export const PARAM_TO_RARITY: Record<string, number> = {
r: 1,
sr: 2,
ssr: 3
}
// ============================================================================
// Proficiency Mapping (1-10)
// ============================================================================
export const PROFICIENCY_TO_PARAM: Record<number, string> = {
1: 'sabre',
2: 'dagger',
3: 'axe',
4: 'spear',
5: 'bow',
6: 'staff',
7: 'melee',
8: 'harp',
9: 'gun',
10: 'katana'
}
export const PARAM_TO_PROFICIENCY: Record<string, number> = {
sabre: 1,
dagger: 2,
axe: 3,
spear: 4,
bow: 5,
staff: 6,
melee: 7,
harp: 8,
gun: 9,
katana: 10
}
// ============================================================================
// Season Mapping (1-5) - Characters only
// ============================================================================
export const SEASON_TO_PARAM: Record<number, string> = {
1: 'valentine',
2: 'formal',
3: 'summer',
4: 'halloween',
5: 'holiday'
}
export const PARAM_TO_SEASON: Record<string, number> = {
valentine: 1,
formal: 2,
summer: 3,
halloween: 4,
holiday: 5
}
// ============================================================================
// Character Series Mapping (1-15) - Characters only
// ============================================================================
export const CHARACTER_SERIES_TO_PARAM: Record<number, string> = {
1: 'grand',
2: 'zodiac',
3: 'promo',
4: 'collab',
5: 'eternal',
6: 'evoker',
7: 'saint',
8: 'fantasy',
9: 'summer',
10: 'yukata',
11: 'valentine',
12: 'halloween',
13: 'formal',
14: 'holiday',
15: 'event'
}
export const PARAM_TO_CHARACTER_SERIES: Record<string, number> = {
grand: 1,
zodiac: 2,
promo: 3,
collab: 4,
eternal: 5,
evoker: 6,
saint: 7,
fantasy: 8,
summer: 9,
yukata: 10,
valentine: 11,
halloween: 12,
formal: 13,
holiday: 14,
event: 15
}
// ============================================================================
// Parsed Filters Type
// ============================================================================
export interface ParsedFilters {
element: number[]
rarity: number[]
proficiency: number[]
season: number[]
series: (number | string)[] // numbers for characters, UUIDs for weapons
searchQuery: string
page: number
}
// ============================================================================
// Parse Functions
// ============================================================================
/**
* Parse a comma-separated URL param into an array of values using a mapping
*/
function parseParamArray<T>(
searchParams: URLSearchParams,
paramName: string,
mapping: Record<string, T>
): T[] {
const param = searchParams.get(paramName)
if (!param) return []
return param
.split(',')
.map((v) => v.trim().toLowerCase())
.map((v) => mapping[v])
.filter((v): v is T => v !== undefined)
}
/**
* Parse URL search params into filter state
*
* For weapons, pass the weaponSeriesList to resolve slugs to UUIDs
*/
export function parseFiltersFromUrl(
searchParams: URLSearchParams,
entityType: 'character' | 'weapon' | 'summon',
weaponSeriesList?: WeaponSeries[]
): ParsedFilters {
const element = parseParamArray(searchParams, 'element', PARAM_TO_ELEMENT)
const rarity = parseParamArray(searchParams, 'rarity', PARAM_TO_RARITY)
const proficiency = parseParamArray(searchParams, 'proficiency', PARAM_TO_PROFICIENCY)
const season =
entityType === 'character' ? parseParamArray(searchParams, 'season', PARAM_TO_SEASON) : []
// Parse series based on entity type
let series: (number | string)[] = []
const seriesParam = searchParams.get('series')
if (seriesParam) {
const seriesSlugs = seriesParam.split(',').map((v) => v.trim().toLowerCase())
if (entityType === 'character') {
// Characters use numeric series enum
series = seriesSlugs
.map((slug) => PARAM_TO_CHARACTER_SERIES[slug])
.filter((v): v is number => v !== undefined)
} else if (entityType === 'weapon' && weaponSeriesList) {
// Weapons use UUIDs, need to look up by slug
series = seriesSlugs
.map((slug) => weaponSeriesList.find((ws) => ws.slug === slug)?.id)
.filter((id): id is string => id !== undefined)
}
}
// Parse search query
const searchQuery = searchParams.get('q') ?? ''
// Parse page
const pageParam = searchParams.get('page')
const page = pageParam ? Math.max(1, parseInt(pageParam, 10) || 1) : 1
return {
element,
rarity,
proficiency,
season,
series,
searchQuery,
page
}
}
// ============================================================================
// Build Functions
// ============================================================================
/**
* Convert an array of values to a comma-separated URL param string
*/
function buildParamString<T extends string | number>(
values: T[],
mapping: Record<T, string>
): string | null {
if (values.length === 0) return null
const params = values.map((v) => mapping[v]).filter(Boolean)
return params.length > 0 ? params.join(',') : null
}
/**
* Build URL search params from filter state
*
* For weapons, pass the weaponSeriesList to resolve UUIDs to slugs
*/
export function buildUrlFromFilters(
filters: CollectionFilterState,
searchQuery: string,
page: number,
entityType: 'character' | 'weapon' | 'summon',
weaponSeriesList?: WeaponSeries[]
): URLSearchParams {
const params = new URLSearchParams()
// Element
const elementParam = buildParamString(
filters.element,
ELEMENT_TO_PARAM as Record<number, string>
)
if (elementParam) params.set('element', elementParam)
// Rarity
const rarityParam = buildParamString(filters.rarity, RARITY_TO_PARAM as Record<number, string>)
if (rarityParam) params.set('rarity', rarityParam)
// Proficiency
const proficiencyParam = buildParamString(
filters.proficiency,
PROFICIENCY_TO_PARAM as Record<number, string>
)
if (proficiencyParam) params.set('proficiency', proficiencyParam)
// Season (characters only)
if (entityType === 'character' && filters.season.length > 0) {
const seasonParam = buildParamString(
filters.season,
SEASON_TO_PARAM as Record<number, string>
)
if (seasonParam) params.set('season', seasonParam)
}
// Series
if (filters.series.length > 0) {
if (entityType === 'character') {
// Characters use numeric series, convert to slugs
const numericSeries = filters.series.filter((s): s is number => typeof s === 'number')
const seriesParam = buildParamString(
numericSeries,
CHARACTER_SERIES_TO_PARAM as Record<number, string>
)
if (seriesParam) params.set('series', seriesParam)
} else if (entityType === 'weapon' && weaponSeriesList) {
// Weapons use UUIDs, look up slugs
const slugs = filters.series
.filter((s): s is string => typeof s === 'string')
.map((uuid) => weaponSeriesList.find((ws) => ws.id === uuid)?.slug)
.filter((slug): slug is string => slug !== undefined)
if (slugs.length > 0) params.set('series', slugs.join(','))
}
}
// Search query
if (searchQuery.trim()) {
params.set('q', searchQuery.trim())
}
// Page (only include if > 1)
if (page > 1) {
params.set('page', String(page))
}
return params
}
/**
* Check if any filters are active
*/
export function hasActiveFilters(filters: CollectionFilterState, searchQuery: string): boolean {
return (
filters.element.length > 0 ||
filters.rarity.length > 0 ||
filters.proficiency.length > 0 ||
filters.season.length > 0 ||
filters.series.length > 0 ||
filters.race.length > 0 ||
filters.gender.length > 0 ||
searchQuery.trim().length > 0
)
}