From ce495a91456ec261402bbd71d1a2eca96dffa5e0 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Mon, 22 Dec 2025 13:52:22 -0800 Subject: [PATCH] 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 --- .../database/DatabaseGridWithProvider.svelte | 130 ++++++- src/lib/utils/filterParams.ts | 334 ++++++++++++++++++ 2 files changed, 451 insertions(+), 13 deletions(-) create mode 100644 src/lib/utils/filterParams.ts diff --git a/src/lib/components/database/DatabaseGridWithProvider.svelte b/src/lib/components/database/DatabaseGridWithProvider.svelte index 865bdf54..99af5b8e 100644 --- a/src/lib/components/database/DatabaseGridWithProvider.svelte +++ b/src/lib/components/database/DatabaseGridWithProvider.svelte @@ -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([]) 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 diff --git a/src/lib/utils/filterParams.ts b/src/lib/utils/filterParams.ts new file mode 100644 index 00000000..ea3730f3 --- /dev/null +++ b/src/lib/utils/filterParams.ts @@ -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 = { + 0: 'null', + 1: 'wind', + 2: 'fire', + 3: 'water', + 4: 'earth', + 5: 'dark', + 6: 'light' +} + +export const PARAM_TO_ELEMENT: Record = { + null: 0, + wind: 1, + fire: 2, + water: 3, + earth: 4, + dark: 5, + light: 6 +} + +// ============================================================================ +// Rarity Mapping (1-3) +// ============================================================================ + +export const RARITY_TO_PARAM: Record = { + 1: 'r', + 2: 'sr', + 3: 'ssr' +} + +export const PARAM_TO_RARITY: Record = { + r: 1, + sr: 2, + ssr: 3 +} + +// ============================================================================ +// Proficiency Mapping (1-10) +// ============================================================================ + +export const PROFICIENCY_TO_PARAM: Record = { + 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 = { + 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 = { + 1: 'valentine', + 2: 'formal', + 3: 'summer', + 4: 'halloween', + 5: 'holiday' +} + +export const PARAM_TO_SEASON: Record = { + valentine: 1, + formal: 2, + summer: 3, + halloween: 4, + holiday: 5 +} + +// ============================================================================ +// Character Series Mapping (1-15) - Characters only +// ============================================================================ + +export const CHARACTER_SERIES_TO_PARAM: Record = { + 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 = { + 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( + searchParams: URLSearchParams, + paramName: string, + mapping: Record +): 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( + values: T[], + mapping: Record +): 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 + ) + if (elementParam) params.set('element', elementParam) + + // Rarity + const rarityParam = buildParamString(filters.rarity, RARITY_TO_PARAM as Record) + if (rarityParam) params.set('rarity', rarityParam) + + // Proficiency + const proficiencyParam = buildParamString( + filters.proficiency, + PROFICIENCY_TO_PARAM as Record + ) + 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 + ) + 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 + ) + 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 + ) +}