add search mode toggle for collection items

This commit is contained in:
Justin Edmund 2025-12-03 22:58:46 -08:00
parent 43f9f37ccc
commit bf2bf8663f
3 changed files with 222 additions and 25 deletions

View file

@ -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 {

View file

@ -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
}) })
} }

View 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'
}