add search mode toggle for collection items
This commit is contained in:
parent
43f9f37ccc
commit
bf2bf8663f
3 changed files with 222 additions and 25 deletions
|
|
@ -1,24 +1,30 @@
|
||||||
<svelte:options runes={true} />
|
<svelte:options runes={true} />
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createInfiniteQuery } from '@tanstack/svelte-query'
|
import { createInfiniteQuery, createQuery } from '@tanstack/svelte-query'
|
||||||
import type { SearchResult } from '$lib/api/adapters/search.adapter'
|
import type { SearchResult } from '$lib/api/adapters/search.adapter'
|
||||||
import { searchQueries, type SearchFilters } from '$lib/api/queries/search.queries'
|
import { searchQueries, type SearchFilters } from '$lib/api/queries/search.queries'
|
||||||
|
import { collectionQueries } from '$lib/api/queries/collection.queries'
|
||||||
import Button from '../ui/Button.svelte'
|
import Button from '../ui/Button.svelte'
|
||||||
import Icon from '../Icon.svelte'
|
import Icon from '../Icon.svelte'
|
||||||
import { IsInViewport } from 'runed'
|
import { IsInViewport } from 'runed'
|
||||||
import { getCharacterImage, getWeaponImage, getSummonImage } from '$lib/features/database/detail/image'
|
import { getCharacterImage, getWeaponImage, getSummonImage } from '$lib/features/database/detail/image'
|
||||||
|
import type { AddItemResult, SearchMode } from '$lib/types/api/search'
|
||||||
|
import type { CollectionCharacter, CollectionWeapon, CollectionSummon } from '$lib/types/api/collection'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
type: 'weapon' | 'character' | 'summon'
|
type: 'weapon' | 'character' | 'summon'
|
||||||
onAddItems?: (items: SearchResult[]) => void
|
onAddItems?: (items: AddItemResult[]) => void
|
||||||
canAddMore?: boolean
|
canAddMore?: boolean
|
||||||
|
/** User ID to enable collection search mode */
|
||||||
|
authUserId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
type = 'weapon',
|
type = 'weapon',
|
||||||
onAddItems = () => {},
|
onAddItems = () => {},
|
||||||
canAddMore = true
|
canAddMore = true,
|
||||||
|
authUserId
|
||||||
}: Props = $props()
|
}: Props = $props()
|
||||||
|
|
||||||
// Search state (local UI state)
|
// Search state (local UI state)
|
||||||
|
|
@ -31,6 +37,9 @@
|
||||||
let rarityFilters = $state<number[]>([])
|
let rarityFilters = $state<number[]>([])
|
||||||
let proficiencyFilters = $state<number[]>([])
|
let proficiencyFilters = $state<number[]>([])
|
||||||
|
|
||||||
|
// Search mode state (only available when authUserId is provided)
|
||||||
|
let searchMode = $state<SearchMode>('all')
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
let searchInput: HTMLInputElement
|
let searchInput: HTMLInputElement
|
||||||
let sentinelEl = $state<HTMLElement>()
|
let sentinelEl = $state<HTMLElement>()
|
||||||
|
|
@ -91,6 +100,37 @@
|
||||||
proficiency: type === 'weapon' && proficiencyFilters.length > 0 ? proficiencyFilters : undefined
|
proficiency: type === 'weapon' && proficiencyFilters.length > 0 ? proficiencyFilters : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Helper to map collection items to search result format with collectionId
|
||||||
|
function mapCollectionToSearchResult(
|
||||||
|
item: CollectionCharacter | CollectionWeapon | CollectionSummon
|
||||||
|
): AddItemResult {
|
||||||
|
const entity = 'character' in item ? item.character : 'weapon' in item ? item.weapon : item.summon
|
||||||
|
return {
|
||||||
|
id: entity.id,
|
||||||
|
granblueId: entity.granblueId,
|
||||||
|
name: entity.name,
|
||||||
|
element: entity.element,
|
||||||
|
rarity: entity.rarity,
|
||||||
|
collectionId: item.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter collection items by search query (client-side)
|
||||||
|
function filterCollectionByQuery<T extends CollectionCharacter | CollectionWeapon | CollectionSummon>(
|
||||||
|
items: T[],
|
||||||
|
query: string
|
||||||
|
): T[] {
|
||||||
|
if (!query.trim()) return items
|
||||||
|
const lowerQuery = query.toLowerCase()
|
||||||
|
return items.filter((item) => {
|
||||||
|
const entity = 'character' in item ? item.character : 'weapon' in item ? item.weapon : item.summon
|
||||||
|
const name = entity.name
|
||||||
|
const nameEn = typeof name === 'string' ? name : name?.en || ''
|
||||||
|
const nameJa = typeof name === 'string' ? '' : name?.ja || ''
|
||||||
|
return nameEn.toLowerCase().includes(lowerQuery) || nameJa.toLowerCase().includes(lowerQuery)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// TanStack Query v6: Use createInfiniteQuery with thunk pattern for reactivity
|
// TanStack Query v6: Use createInfiniteQuery with thunk pattern for reactivity
|
||||||
// Query automatically updates when type, debouncedSearchQuery, or filters change
|
// Query automatically updates when type, debouncedSearchQuery, or filters change
|
||||||
// Note: Type assertion needed because different search types have different query keys
|
// Note: Type assertion needed because different search types have different query keys
|
||||||
|
|
@ -111,40 +151,94 @@
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Collection query - only enabled when in collection mode and authUserId is provided
|
||||||
|
// Type assertion needed because different types have different query result types
|
||||||
|
// but they all share the same structure with different content types
|
||||||
|
const collectionQueryResult = createInfiniteQuery(() => {
|
||||||
|
if (!authUserId) {
|
||||||
|
// Return a disabled query config
|
||||||
|
return {
|
||||||
|
...collectionQueries.characters(authUserId ?? '', {}, false),
|
||||||
|
enabled: false
|
||||||
|
} as ReturnType<typeof collectionQueries.characters>
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentFilters = {
|
||||||
|
element: elementFilters.length > 0 ? elementFilters : undefined,
|
||||||
|
rarity: rarityFilters.length > 0 ? rarityFilters : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'weapon':
|
||||||
|
return {
|
||||||
|
...collectionQueries.weapons(authUserId, currentFilters),
|
||||||
|
enabled: searchMode === 'collection'
|
||||||
|
} as unknown as ReturnType<typeof collectionQueries.characters>
|
||||||
|
case 'character':
|
||||||
|
return {
|
||||||
|
...collectionQueries.characters(authUserId, currentFilters),
|
||||||
|
enabled: searchMode === 'collection'
|
||||||
|
}
|
||||||
|
case 'summon':
|
||||||
|
return {
|
||||||
|
...collectionQueries.summons(authUserId, currentFilters),
|
||||||
|
enabled: searchMode === 'collection'
|
||||||
|
} as unknown as ReturnType<typeof collectionQueries.characters>
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Flatten all pages into a single items array
|
// Flatten all pages into a single items array
|
||||||
const rawResults = $derived(
|
const rawResults = $derived(
|
||||||
searchQueryResult.data?.pages.flatMap((page) => page.results) ?? []
|
searchQueryResult.data?.pages.flatMap((page) => page.results) ?? []
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Collection results (filtered client-side by search query)
|
||||||
|
const rawCollectionResults = $derived.by(() => {
|
||||||
|
const pages = collectionQueryResult.data?.pages ?? []
|
||||||
|
const allItems = pages.flatMap((page) => page.results)
|
||||||
|
return filterCollectionByQuery(allItems, debouncedSearchQuery)
|
||||||
|
})
|
||||||
|
|
||||||
// Deduplicate by id - needed because the API may return the same item across pages
|
// Deduplicate by id - needed because the API may return the same item across pages
|
||||||
// (e.g., due to items being added/removed between page fetches)
|
// (e.g., due to items being added/removed between page fetches)
|
||||||
const searchResults = $derived(
|
const searchResults = $derived.by<AddItemResult[]>(() => {
|
||||||
Array.from(new Map(rawResults.map((item) => [item.id, item])).values())
|
if (searchMode === 'collection' && authUserId) {
|
||||||
)
|
// Map collection items to AddItemResult format
|
||||||
|
return rawCollectionResults.map(mapCollectionToSearchResult)
|
||||||
|
}
|
||||||
|
// Regular search results - cast to AddItemResult[] since they're compatible
|
||||||
|
const deduped = Array.from(new Map(rawResults.map((item) => [item.id, item])).values())
|
||||||
|
return deduped as AddItemResult[]
|
||||||
|
})
|
||||||
|
|
||||||
// Use runed's IsInViewport for viewport detection
|
// Use runed's IsInViewport for viewport detection
|
||||||
const inViewport = new IsInViewport(() => sentinelEl, {
|
const inViewport = new IsInViewport(() => sentinelEl, {
|
||||||
rootMargin: '200px'
|
rootMargin: '200px'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Get the active query based on search mode
|
||||||
|
const activeQuery = $derived(
|
||||||
|
searchMode === 'collection' && authUserId ? collectionQueryResult : searchQueryResult
|
||||||
|
)
|
||||||
|
|
||||||
// Auto-fetch next page when sentinel is visible
|
// Auto-fetch next page when sentinel is visible
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (
|
if (
|
||||||
inViewport.current &&
|
inViewport.current &&
|
||||||
searchQueryResult.hasNextPage &&
|
activeQuery.hasNextPage &&
|
||||||
!searchQueryResult.isFetchingNextPage &&
|
!activeQuery.isFetchingNextPage &&
|
||||||
!searchQueryResult.isLoading
|
!activeQuery.isLoading
|
||||||
) {
|
) {
|
||||||
searchQueryResult.fetchNextPage()
|
activeQuery.fetchNextPage()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Computed states
|
// Computed states
|
||||||
const isEmpty = $derived(
|
const isEmpty = $derived(
|
||||||
searchResults.length === 0 && !searchQueryResult.isLoading && !searchQueryResult.isError
|
searchResults.length === 0 && !activeQuery.isLoading && !activeQuery.isError
|
||||||
)
|
)
|
||||||
const showSentinel = $derived(
|
const showSentinel = $derived(
|
||||||
!searchQueryResult.isLoading && searchQueryResult.hasNextPage && searchResults.length > 0
|
!activeQuery.isLoading && activeQuery.hasNextPage && searchResults.length > 0
|
||||||
)
|
)
|
||||||
|
|
||||||
// Focus search input on mount
|
// Focus search input on mount
|
||||||
|
|
@ -154,7 +248,7 @@
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleItemClick(item: SearchResult) {
|
function handleItemClick(item: AddItemResult) {
|
||||||
if (canAddMore) {
|
if (canAddMore) {
|
||||||
onAddItems([item])
|
onAddItems([item])
|
||||||
}
|
}
|
||||||
|
|
@ -184,7 +278,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getImageUrl(item: SearchResult): string {
|
function getImageUrl(item: AddItemResult): string {
|
||||||
const id = item.granblueId
|
const id = item.granblueId
|
||||||
if (!id) return `/images/placeholders/placeholder-${type}-square.png`
|
if (!id) return `/images/placeholders/placeholder-${type}-square.png`
|
||||||
|
|
||||||
|
|
@ -200,7 +294,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getItemName(item: SearchResult): string {
|
function getItemName(item: AddItemResult): string {
|
||||||
const name = item.name
|
const name = item.name
|
||||||
if (typeof name === 'string') return name
|
if (typeof name === 'string') return name
|
||||||
return name?.en || name?.ja || 'Unknown'
|
return name?.en || name?.ja || 'Unknown'
|
||||||
|
|
@ -219,6 +313,25 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if authUserId}
|
||||||
|
<div class="mode-toggle">
|
||||||
|
<button
|
||||||
|
class="mode-btn"
|
||||||
|
class:active={searchMode === 'all'}
|
||||||
|
onclick={() => searchMode = 'all'}
|
||||||
|
>
|
||||||
|
All Items
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mode-btn"
|
||||||
|
class:active={searchMode === 'collection'}
|
||||||
|
onclick={() => searchMode = 'collection'}
|
||||||
|
>
|
||||||
|
My Collection
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="filters-section">
|
<div class="filters-section">
|
||||||
<!-- Element filters -->
|
<!-- Element filters -->
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
|
|
@ -277,16 +390,16 @@
|
||||||
|
|
||||||
<!-- Results -->
|
<!-- Results -->
|
||||||
<div class="results-section">
|
<div class="results-section">
|
||||||
{#if searchQueryResult.isLoading}
|
{#if activeQuery.isLoading}
|
||||||
<div class="loading">
|
<div class="loading">
|
||||||
<Icon name="loader-2" size={24} />
|
<Icon name="loader-2" size={24} />
|
||||||
<span>Searching...</span>
|
<span>Searching...</span>
|
||||||
</div>
|
</div>
|
||||||
{:else if searchQueryResult.isError}
|
{:else if activeQuery.isError}
|
||||||
<div class="error-state">
|
<div class="error-state">
|
||||||
<Icon name="alert-circle" size={24} />
|
<Icon name="alert-circle" size={24} />
|
||||||
<p>{searchQueryResult.error?.message || 'Search failed'}</p>
|
<p>{activeQuery.error?.message || 'Search failed'}</p>
|
||||||
<Button size="small" onclick={() => searchQueryResult.refetch()}>Retry</Button>
|
<Button size="small" onclick={() => activeQuery.refetch()}>Retry</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else if searchResults.length > 0}
|
{:else if searchResults.length > 0}
|
||||||
<ul class="results-list">
|
<ul class="results-list">
|
||||||
|
|
@ -295,6 +408,7 @@
|
||||||
<button
|
<button
|
||||||
class="result-button"
|
class="result-button"
|
||||||
class:disabled={!canAddMore}
|
class:disabled={!canAddMore}
|
||||||
|
class:from-collection={item.collectionId}
|
||||||
onclick={() => handleItemClick(item)}
|
onclick={() => handleItemClick(item)}
|
||||||
aria-label="{canAddMore ? 'Add' : 'Grid full - cannot add'} {getItemName(item)}"
|
aria-label="{canAddMore ? 'Add' : 'Grid full - cannot add'} {getItemName(item)}"
|
||||||
disabled={!canAddMore}
|
disabled={!canAddMore}
|
||||||
|
|
@ -306,6 +420,9 @@
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<span class="result-name">{getItemName(item)}</span>
|
<span class="result-name">{getItemName(item)}</span>
|
||||||
|
{#if item.collectionId}
|
||||||
|
<Icon name="bookmark" size={14} class="collection-indicator" />
|
||||||
|
{/if}
|
||||||
{#if item.element !== undefined}
|
{#if item.element !== undefined}
|
||||||
<span
|
<span
|
||||||
class="result-element"
|
class="result-element"
|
||||||
|
|
@ -323,7 +440,7 @@
|
||||||
<div class="load-more-sentinel" bind:this={sentinelEl}></div>
|
<div class="load-more-sentinel" bind:this={sentinelEl}></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if searchQueryResult.isFetchingNextPage}
|
{#if activeQuery.isFetchingNextPage}
|
||||||
<div class="loading-more">
|
<div class="loading-more">
|
||||||
<Icon name="loader-2" size={20} />
|
<Icon name="loader-2" size={20} />
|
||||||
<span>Loading more...</span>
|
<span>Loading more...</span>
|
||||||
|
|
@ -331,7 +448,13 @@
|
||||||
{/if}
|
{/if}
|
||||||
{:else if isEmpty}
|
{:else if isEmpty}
|
||||||
<div class="no-results">
|
<div class="no-results">
|
||||||
{#if searchQuery.length > 0}
|
{#if searchMode === 'collection'}
|
||||||
|
{#if searchQuery.length > 0}
|
||||||
|
No items match your search
|
||||||
|
{:else}
|
||||||
|
Your collection is empty
|
||||||
|
{/if}
|
||||||
|
{:else if searchQuery.length > 0}
|
||||||
No results found
|
No results found
|
||||||
{:else}
|
{:else}
|
||||||
Start typing to search
|
Start typing to search
|
||||||
|
|
@ -379,6 +502,37 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mode-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: $unit-half;
|
||||||
|
padding-bottom: $unit-2x;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
.mode-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: $unit calc($unit * 1.5);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: $input-corner;
|
||||||
|
font-size: $font-small;
|
||||||
|
font-weight: $medium;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-color: var(--border-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--accent-blue);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent-blue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.filters-section {
|
.filters-section {
|
||||||
padding-bottom: $unit-2x;
|
padding-bottom: $unit-2x;
|
||||||
border-bottom: 1px solid var(--border-primary);
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
|
@ -521,6 +675,11 @@
|
||||||
font-weight: $bold;
|
font-weight: $bold;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.collection-indicator) {
|
||||||
|
color: var(--accent-blue);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,25 @@
|
||||||
import { sidebar } from '$lib/stores/sidebar.svelte'
|
import { sidebar } from '$lib/stores/sidebar.svelte'
|
||||||
import SearchContent from '$lib/components/sidebar/SearchContent.svelte'
|
import SearchContent from '$lib/components/sidebar/SearchContent.svelte'
|
||||||
import type { SearchResult } from '$lib/api/adapters/search.adapter'
|
import type { AddItemResult } from '$lib/types/api/search'
|
||||||
|
|
||||||
interface SearchSidebarOptions {
|
interface SearchSidebarOptions {
|
||||||
type: 'weapon' | 'character' | 'summon'
|
type: 'weapon' | 'character' | 'summon'
|
||||||
onAddItems?: (items: SearchResult[]) => void
|
onAddItems?: (items: AddItemResult[]) => void
|
||||||
canAddMore?: boolean
|
canAddMore?: boolean
|
||||||
|
/** User ID to enable collection search mode. If not provided, only "All Items" mode is available. */
|
||||||
|
authUserId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openSearchSidebar(options: SearchSidebarOptions) {
|
export function openSearchSidebar(options: SearchSidebarOptions) {
|
||||||
const { type, onAddItems, canAddMore = true } = options
|
const { type, onAddItems, canAddMore = true, authUserId } = options
|
||||||
|
|
||||||
// Open the sidebar with the search component
|
// Open the sidebar with the search component
|
||||||
const title = `Search ${type.charAt(0).toUpperCase() + type.slice(1)}s`
|
const title = `Search ${type.charAt(0).toUpperCase() + type.slice(1)}s`
|
||||||
sidebar.openWithComponent(title, SearchContent, {
|
sidebar.openWithComponent(title, SearchContent, {
|
||||||
type,
|
type,
|
||||||
onAddItems,
|
onAddItems,
|
||||||
canAddMore
|
canAddMore,
|
||||||
|
authUserId
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
35
src/lib/types/api/search.ts
Normal file
35
src/lib/types/api/search.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
/**
|
||||||
|
* Search-related types for collection-to-grid linking
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { SearchResult } from '$lib/api/adapters/search.adapter'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search mode for toggling between all items and user's collection
|
||||||
|
*/
|
||||||
|
export type SearchMode = 'all' | 'collection'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result passed back when adding items from search
|
||||||
|
* Contains the essential fields needed to create a grid item
|
||||||
|
* Can include collectionId if the item was selected from user's collection
|
||||||
|
*/
|
||||||
|
export interface AddItemResult {
|
||||||
|
/** Unique entity ID (character, weapon, or summon ID) */
|
||||||
|
id: string
|
||||||
|
/** Granblue game ID */
|
||||||
|
granblueId: string
|
||||||
|
/** Localized names */
|
||||||
|
name: {
|
||||||
|
en?: string
|
||||||
|
ja?: string
|
||||||
|
}
|
||||||
|
/** Element type (1-6 for different elements) */
|
||||||
|
element?: number
|
||||||
|
/** Rarity level */
|
||||||
|
rarity?: number
|
||||||
|
/** Collection ID if the item was selected from user's collection */
|
||||||
|
collectionId?: string
|
||||||
|
/** Type of entity */
|
||||||
|
searchableType?: 'Weapon' | 'Character' | 'Summon'
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue