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:
parent
94c7a3b799
commit
ce495a9145
2 changed files with 451 additions and 13 deletions
|
|
@ -13,6 +13,13 @@
|
||||||
import { onMount, onDestroy } from 'svelte'
|
import { onMount, onDestroy } from 'svelte'
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import { page } from '$app/stores'
|
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'
|
import type { Snippet } from 'svelte'
|
||||||
|
|
||||||
|
|
@ -26,6 +33,22 @@
|
||||||
|
|
||||||
const { resource, columns, pageSize: initialPageSize = 20, leftActions, headerActions }: Props = $props()
|
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
|
// State
|
||||||
let data = $state<any[]>([])
|
let data = $state<any[]>([])
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
|
|
@ -65,7 +88,7 @@
|
||||||
? filters.series.filter((s): s is number => typeof s === 'number')
|
? filters.series.filter((s): s is number => typeof s === 'number')
|
||||||
: undefined
|
: 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
|
// Create provider
|
||||||
|
|
@ -74,16 +97,32 @@
|
||||||
// Grid API reference
|
// Grid API reference
|
||||||
let api: any
|
let api: any
|
||||||
|
|
||||||
// Update URL with current page (without triggering navigation)
|
// Build current filter state for URL building
|
||||||
function updateUrl(pageNum: number) {
|
function getCurrentFilterState(): CollectionFilterState {
|
||||||
const url = new URL($page.url)
|
return {
|
||||||
if (pageNum === 1) {
|
element: elementFilters,
|
||||||
url.searchParams.delete('page')
|
rarity: rarityFilters,
|
||||||
} else {
|
proficiency: proficiencyFilters,
|
||||||
url.searchParams.set('page', String(pageNum))
|
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
|
// 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
|
// Load data
|
||||||
|
|
@ -224,11 +263,76 @@
|
||||||
const startItem = $derived((currentPage - 1) * pageSize + 1)
|
const startItem = $derived((currentPage - 1) * pageSize + 1)
|
||||||
const endItem = $derived(Math.min(currentPage * pageSize, total))
|
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(() => {
|
onMount(() => {
|
||||||
const pageParam = $page.url.searchParams.get('page')
|
// For non-weapon resources, initialize immediately
|
||||||
const initialPage = pageParam ? Math.max(1, parseInt(pageParam, 10) || 1) : 1
|
// For weapons, wait for series query to complete
|
||||||
loadData(initialPage, false) // Don't update URL on initial load
|
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
|
// Clean up timeout on destroy
|
||||||
|
|
|
||||||
334
src/lib/utils/filterParams.ts
Normal file
334
src/lib/utils/filterParams.ts
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue