hensei-web/src/lib/components/database/DatabaseGridWithProvider.svelte

762 lines
19 KiB
Svelte

<!--
DatabaseGridWithProvider component using SVAR DataGrid with RestDataProvider
Provides client-side pagination and data management with REST API integration
-->
<svelte:options runes={true} />
<script lang="ts">
import { Grid } from 'wx-svelte-grid'
import type { IColumn, IRow } from 'wx-svelte-grid'
import { DatabaseProvider } from '$lib/providers/DatabaseProvider'
import CollectionFilters from '$lib/components/collection/CollectionFilters.svelte'
import type { CollectionFilterState } from '$lib/components/collection/CollectionFilters.svelte'
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,
ELEMENT_TO_PARAM
} from '$lib/utils/filterParams'
import Button from '$lib/components/ui/Button.svelte'
import Icon from '$lib/components/Icon.svelte'
import { storeListUrl } from '$lib/utils/listNavigation'
import type { Snippet } from 'svelte'
interface Props {
resource: 'weapons' | 'characters' | 'summons'
columns: IColumn[]
pageSize?: number
leftActions?: Snippet
headerActions?: Snippet
}
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)
let currentPage = $state(1)
let totalPages = $state(1)
let total = $state(0)
let searchTerm = $state('')
let lastSearchTerm = $state('')
let pageSize = $state(initialPageSize)
let searchTimeout: ReturnType<typeof setTimeout> | undefined
// Sort state - tracks which column is sorted and in which direction
let sortMarks = $state<Record<string, { order: 'asc' | 'desc' }>>({})
// Filter state
let elementFilters = $state<number[]>([])
let rarityFilters = $state<number[]>([])
let seriesFilters = $state<(number | string)[]>([])
let proficiencyFilters = $state<number[]>([])
let seasonFilters = $state<number[]>([])
// Filter visibility state
let showFilters = $state(false)
// Check if any filters are active (for button indicator)
const hasActiveFilters = $derived(
elementFilters.length > 0 ||
rarityFilters.length > 0 ||
seriesFilters.length > 0 ||
proficiencyFilters.length > 0 ||
seasonFilters.length > 0
)
// Get selected element name for button styling (only when exactly one element is selected)
const selectedElement = $derived.by(() => {
if (elementFilters.length === 1) {
const elemId = elementFilters[0]
if (elemId !== undefined) {
const elemName = ELEMENT_TO_PARAM[elemId]
if (elemName && elemName !== 'null') {
return elemName as 'wind' | 'fire' | 'water' | 'earth' | 'dark' | 'light'
}
}
}
return undefined
})
// Handle filter changes from CollectionFilters component
function handleFiltersChange(filters: CollectionFilterState) {
// Convert series to string[] (weapon series are UUIDs, character series are numbers that need conversion)
const seriesAsStrings =
filters.series.length > 0 ? filters.series.map((s) => String(s)) : undefined
provider.setFilters({
element: filters.element.length > 0 ? filters.element : undefined,
rarity: filters.rarity.length > 0 ? filters.rarity : undefined,
series: seriesAsStrings,
proficiency1: filters.proficiency.length > 0 ? filters.proficiency : undefined,
season: filters.season.length > 0 ? filters.season : undefined,
// For characters, also pass series as characterSeries (they use number enum values)
characterSeries:
resource === 'characters' && filters.series.length > 0
? filters.series.filter((s): s is number => typeof s === 'number')
: undefined
})
loadData(1) // Reset to first page when filters change (this will update URL)
}
// Create provider
const provider = new DatabaseProvider({ resource, pageSize: initialPageSize })
// Grid API reference
let api: any
// 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, { replaceState: true, noScroll: true, keepFocus: true })
}
// Load data
async function loadData(pageNum: number = 1, updateUrlParam: boolean = true) {
loading = true
try {
const result = await provider.loadPage(pageNum)
data = result
currentPage = pageNum
// Get pagination metadata from provider
const meta = provider.getPaginationMeta()
if (meta) {
total = meta.total || 0
totalPages = meta.totalPages || 1
// Update pageSize if provider has a different value
if (meta.pageSize && meta.pageSize !== pageSize) {
pageSize = meta.pageSize
}
}
// Update URL to reflect current page
if (updateUrlParam) {
updateUrl(pageNum)
}
} catch (error) {
console.error('Failed to load data:', error)
} finally {
loading = false
}
}
// Initialize grid
const init = (apiRef: any) => {
api = apiRef
// Connect provider to grid
api.setNext(provider)
// Intercept sort-rows to prevent client-side sorting and do server-side instead
api.intercept('sort-rows', (ev: { key: string; add: boolean }) => {
const { key } = ev
const currentOrder = sortMarks[key]?.order
// Toggle: asc -> desc -> clear
let newSortKey: string | null = null
let newSortOrder: 'asc' | 'desc' = 'asc'
if (currentOrder === 'asc') {
sortMarks = { [key]: { order: 'desc' } }
newSortKey = key
newSortOrder = 'desc'
} else if (currentOrder === 'desc') {
sortMarks = {} // Clear sort
newSortKey = null
} else {
sortMarks = { [key]: { order: 'asc' } }
newSortKey = key
newSortOrder = 'asc'
}
// Update provider and reload from server
provider.setSort(newSortKey, newSortOrder)
loadData(1) // Reset to first page when sorting
return false // Prevent default client-side sorting
})
// Add row click handler
api.on('select-row', (ev: any) => {
const rowId = ev.id
if (rowId) {
// Find the row data to get the granblueId
const rowData = data.find((item: any) => item.id === rowId)
if (rowData && rowData.granblueId) {
// Store current list URL before navigating so Back button can return here
storeListUrl($page.url.href, resource)
goto(`/database/${resource}/${rowData.granblueId}`)
}
}
})
}
// Handle pagination
const handlePrevPage = () => {
if (currentPage > 1) {
loadData(currentPage - 1)
}
}
const handleNextPage = () => {
if (currentPage < totalPages) {
loadData(currentPage + 1)
}
}
const handlePageSizeChange = async (event: Event) => {
const target = event.target as HTMLSelectElement
const newPageSize = Number(target.value)
pageSize = newPageSize // Update local state immediately
await provider.setPageSize(newPageSize)
loadData(1)
}
// Handle search with debounce
const handleSearch = (term: string) => {
// Clear existing timeout
if (searchTimeout) {
clearTimeout(searchTimeout)
}
const trimmed = term.trim()
// Avoid triggering a fetch on initial mount when search is empty
// Only clear search and reload if we previously had a non-empty query
if (trimmed.length < 2) {
if (lastSearchTerm !== '') {
searchTimeout = setTimeout(() => {
provider.clearSearch()
lastSearchTerm = ''
loadData(1)
}, 300)
}
return
}
// Debounced search when user has typed enough characters
searchTimeout = setTimeout(() => {
lastSearchTerm = trimmed
provider.setSearchQuery(trimmed)
loadData(1) // Reset to first page when searching
}, 300)
}
// Watch for search term changes
$effect(() => {
handleSearch(searchTerm)
})
// Computed values
const startItem = $derived((currentPage - 1) * pageSize + 1)
const endItem = $derived(Math.min(currentPage * pageSize, total))
// 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
}
// Show filters panel if any filters are active from URL
if (
parsed.element.length > 0 ||
parsed.rarity.length > 0 ||
parsed.proficiency.length > 0 ||
parsed.season.length > 0 ||
parsed.series.length > 0
) {
showFilters = true
}
urlInitialized = true
loadData(parsed.page, false) // Don't update URL on initial load
}
// Load initial data from URL params
onMount(() => {
// 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
onDestroy(() => {
if (searchTimeout) {
clearTimeout(searchTimeout)
}
})
</script>
<svelte:head>
<link rel="stylesheet" href="https://cdn.svar.dev/fonts/wxi/wx-icons.css" />
</svelte:head>
<div class="grid">
<div class="controls">
{#if leftActions}
{@render leftActions()}
{/if}
<div class="controls-right">
{#if headerActions}
{@render headerActions()}
{/if}
<Button
variant="ghost"
size="small"
onclick={() => (showFilters = !showFilters)}
class="filter-toggle {hasActiveFilters ? 'has-active' : ''}"
>
Filters
{#if hasActiveFilters}
<span class="filter-count {selectedElement ?? ''}">
{elementFilters.length +
rarityFilters.length +
seriesFilters.length +
proficiencyFilters.length +
seasonFilters.length}
</span>
{/if}
</Button>
<input type="text" placeholder="Search..." bind:value={searchTerm} />
</div>
</div>
{#if showFilters}
<div class="filters-row">
<CollectionFilters
entityType={resource === 'characters'
? 'character'
: resource === 'summons'
? 'summon'
: 'weapon'}
bind:elementFilters
bind:rarityFilters
bind:seriesFilters
bind:proficiencyFilters
bind:seasonFilters
onFiltersChange={handleFiltersChange}
showSort={false}
contained={false}
/>
</div>
{/if}
<div class="grid-wrapper" class:loading>
{#if loading}
<div class="loading-overlay">
<div class="loading-spinner">Loading...</div>
</div>
{/if}
<Grid
{data}
{columns}
{init}
{sortMarks}
sizes={{ rowHeight: 80 }}
class="database-grid-theme"
/>
</div>
<div class="grid-footer">
<div class="pagination-info">
{#if total > 0}
Showing {startItem} to {endItem} of {total} entries
{:else}
No entries found
{/if}
</div>
<div class="pagination-controls">
<button class="pagination-button" onclick={handlePrevPage} disabled={currentPage <= 1}>
Previous
</button>
<span class="page-display">
Page {currentPage} of {totalPages}
</span>
<button
class="pagination-button"
onclick={handleNextPage}
disabled={currentPage >= totalPages}
>
Next
</button>
</div>
</div>
</div>
<style lang="scss">
@use '$src/themes/effects' as effects;
@use '$src/themes/layout' as layout;
@use '$src/themes/spacing' as spacing;
@use '$src/themes/typography' as typography;
.grid {
width: 100%;
background: var(--card-bg);
border: 0.5px solid rgba(0, 0, 0, 0.18);
border-radius: layout.$page-corner;
box-shadow: effects.$page-elevation;
overflow: hidden;
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
padding: spacing.$unit;
gap: spacing.$unit;
.controls-right {
display: flex;
align-items: center;
gap: spacing.$unit;
margin-left: auto;
:global(.filter-toggle) {
gap: spacing.$unit-half;
:global(svg) {
transition: transform 0.15s ease;
}
&:global(.has-active) {
color: var(--accent-color);
}
}
.filter-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
margin-left: spacing.$unit;
padding: 0 spacing.$unit-half;
background: var(--accent-color);
color: white;
font-size: 11px;
font-weight: typography.$medium;
border-radius: 9px;
// Element-colored badges
&:global(.wind) {
background: var(--wind-button-bg);
}
&:global(.fire) {
background: var(--fire-button-bg);
}
&:global(.water) {
background: var(--water-button-bg);
}
&:global(.earth) {
background: var(--earth-button-bg);
}
&:global(.dark) {
background: var(--dark-button-bg);
}
&:global(.light) {
background: var(--light-button-bg);
}
}
input {
padding: spacing.$unit spacing.$unit-2x;
background: var(--input-bound-bg);
border: none;
border-radius: layout.$item-corner;
font-family: 'AGrot', system-ui, sans-serif;
font-size: typography.$font-small;
width: 200px;
&:hover {
background: var(--input-bound-bg-hover);
}
&:focus {
outline: none;
border-color: #007bff;
}
}
}
}
.filters-row {
display: flex;
align-items: center;
padding: 0 spacing.$unit spacing.$unit spacing.$unit;
border-bottom: 1px solid #e5e5e5;
background: white;
:global(.filters-container) {
flex: 1;
min-width: 0;
// Override filter trigger padding
:global([data-select-trigger]) {
padding-top: 7px;
padding-bottom: 7px;
}
}
}
.grid-wrapper {
position: relative;
overflow-x: auto;
min-height: 400px;
&.loading {
opacity: 0.6;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
.loading-spinner {
font-size: typography.$font-medium;
color: #666;
}
}
}
.grid-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: spacing.$unit;
border-top: 1px solid #e5e5e5;
background: #f8f9fa;
.pagination-info {
font-size: typography.$font-small;
color: #6c757d;
}
.pagination-controls {
display: flex;
align-items: center;
gap: spacing.$unit;
.pagination-button {
padding: spacing.$unit * 0.5 spacing.$unit;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
font-size: typography.$font-small;
cursor: pointer;
transition: all 0.2s;
&:hover:not(:disabled) {
background: #e9ecef;
border-color: #adb5bd;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.page-display {
font-size: typography.$font-small;
color: #495057;
min-width: 100px;
text-align: center;
}
}
}
}
// Global styles for SVAR Grid elements
:global(.database-grid-theme) {
font-size: typography.$font-small;
width: 100%;
}
:global(.database-grid .wx-table-box) {
width: 100%;
max-width: 100%;
}
:global(.wx-grid .wx-header) {
background: #f8f9fa;
}
:global(.wx-grid .wx-h-row) {
background: #f8f9fa;
border-bottom: 1px solid #e5e5e5;
}
:global(.wx-grid .wx-header-cell) {
background: #f8f9fa;
font-weight: typography.$bold;
color: #495057;
border-bottom: 2px solid #dee2e6;
border-radius: layout.$item-corner;
transition: background-color 0.15s ease;
cursor: pointer;
&:hover {
background: #e9ecef;
}
}
:global(.wx-grid .wx-cell) {
padding: spacing.$unit * 0.5;
vertical-align: middle;
display: flex;
align-items: center;
border: none;
--wx-table-cell-border: none;
}
:global(.wx-grid .wx-cell:first-child) {
padding-left: spacing.$unit-2x;
}
:global(.wx-grid .wx-cell:not(:last-child)) {
border-right: none;
}
:global(.wx-grid .wx-row:hover) {
background: #f8f9fa;
cursor: pointer;
}
// Element color classes
:global(.element-fire) {
color: #ff6b6b;
}
:global(.element-water) {
color: #4dabf7;
}
:global(.element-earth) {
color: #51cf66;
}
:global(.element-wind) {
color: #69db7c;
}
:global(.element-light) {
color: #ffd43b;
}
:global(.element-dark) {
color: #845ef7;
}
// Database image styling - removed to allow cells to control sizing
</style>