hensei-web/src/lib/components/sidebar/SearchContent.svelte

760 lines
20 KiB
Svelte

<svelte:options runes={true} />
<script lang="ts">
import { createInfiniteQuery, createQuery } from '@tanstack/svelte-query'
import { onDestroy } from 'svelte'
import type { SearchResult } from '$lib/api/adapters/search.adapter'
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 Icon from '../Icon.svelte'
import Input from '../ui/Input.svelte'
import CharacterTags from '$lib/components/tags/CharacterTags.svelte'
import ElementPicker from '../ui/element-picker/ElementPicker.svelte'
import { useInfiniteLoader } from '$lib/stores/loaderState.svelte'
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 {
type: 'weapon' | 'character' | 'summon'
onAddItems?: (items: AddItemResult[]) => void
canAddMore?: boolean
/** User ID to enable collection search mode */
authUserId?: string
/** Required proficiencies for mainhand weapon selection */
requiredProficiencies?: number[]
}
let {
type = 'weapon',
onAddItems = () => {},
canAddMore = true,
authUserId,
requiredProficiencies
}: Props = $props()
// Search state (local UI state)
let searchQuery = $state('')
let debouncedSearchQuery = $state('')
let debounceTimer: ReturnType<typeof setTimeout> | undefined
// Filter state
let elementFilters = $state<number[]>([])
let rarityFilters = $state<number[]>([])
let proficiencyFilters = $state<number[]>([])
// Search mode state (only available when authUserId is provided)
let searchMode = $state<SearchMode>('all')
// Refs
let sentinelEl = $state<HTMLElement>()
// Constants
const elements = [
{ value: 0, label: 'Null', color: 'var(--grey-50)' },
{ value: 1, label: 'Wind', color: 'var(--wind-bg)' },
{ value: 2, label: 'Fire', color: 'var(--fire-bg)' },
{ value: 3, label: 'Water', color: 'var(--water-bg)' },
{ value: 4, label: 'Earth', color: 'var(--earth-bg)' },
{ value: 5, label: 'Dark', color: 'var(--dark-bg)' },
{ value: 6, label: 'Light', color: 'var(--light-bg)' }
]
const rarities = [
{ value: 1, label: 'R' },
{ value: 2, label: 'SR' },
{ value: 3, label: 'SSR' }
]
const proficiencies = [
{ value: 1, label: 'Sabre' },
{ value: 2, label: 'Dagger' },
{ value: 3, label: 'Spear' },
{ value: 4, label: 'Axe' },
{ value: 5, label: 'Staff' },
{ value: 6, label: 'Gun' },
{ value: 7, label: 'Melee' },
{ value: 8, label: 'Bow' },
{ value: 9, label: 'Harp' },
{ value: 10, label: 'Katana' }
]
// Debounce search query changes
$effect(() => {
const query = searchQuery
if (debounceTimer) {
clearTimeout(debounceTimer)
}
debounceTimer = setTimeout(() => {
debouncedSearchQuery = query
}, 300)
return () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
}
})
// Build filters object for query
// Use requiredProficiencies for mainhand selection if set, otherwise use user-selected filters
const effectiveProficiencies = $derived(
requiredProficiencies ?? (proficiencyFilters.length > 0 ? proficiencyFilters : undefined)
)
const filters = $derived<SearchFilters>({
element: elementFilters.length > 0 ? elementFilters : undefined,
rarity: rarityFilters.length > 0 ? rarityFilters : undefined,
proficiency: type === 'weapon' && effectiveProficiencies ? effectiveProficiencies : 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
// Query automatically updates when type, debouncedSearchQuery, or filters change
// Note: Type assertion needed because different search types have different query keys
// but share the same SearchPageResult structure
const searchQueryResult = createInfiniteQuery(() => {
const query = debouncedSearchQuery
const currentFilters = filters
// Select the appropriate query based on type
// All query types return the same SearchPageResult structure
switch (type) {
case 'weapon':
return searchQueries.weapons(query, currentFilters)
case 'character':
return searchQueries.characters(query, currentFilters) as unknown as ReturnType<typeof searchQueries.weapons>
case 'summon':
return searchQueries.summons(query, currentFilters) as unknown as ReturnType<typeof searchQueries.weapons>
}
})
// Collection query - enabled when authUserId is provided
// Used both for collection mode AND for highlighting owned items in "all" mode
// 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>
}
// For collection mode, apply filters; for "all" mode, fetch all to build owned set
const currentFilters = searchMode === 'collection' ? {
element: elementFilters.length > 0 ? elementFilters : undefined,
rarity: rarityFilters.length > 0 ? rarityFilters : undefined
} : {}
switch (type) {
case 'weapon':
return {
...collectionQueries.weapons(authUserId, currentFilters),
enabled: true // Always enabled when authUserId exists
} as unknown as ReturnType<typeof collectionQueries.characters>
case 'character':
return {
...collectionQueries.characters(authUserId, currentFilters),
enabled: true
}
case 'summon':
return {
...collectionQueries.summons(authUserId, currentFilters),
enabled: true
} as unknown as ReturnType<typeof collectionQueries.characters>
}
})
// Flatten all pages into a single items array
const rawResults = $derived(
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)
})
// Set of owned item granblue IDs for fast lookup (used in "all" mode to highlight owned items)
const ownedItemIds = $derived.by(() => {
const pages = collectionQueryResult.data?.pages ?? []
const allItems = pages.flatMap((page) => page.results)
const ids = new Set<string>()
for (const item of allItems) {
// Type assertion needed because the query result type doesn't capture all variants
const anyItem = item as unknown as Record<string, unknown>
const entity = (anyItem.character ?? anyItem.weapon ?? anyItem.summon) as
| { granblueId?: string }
| undefined
if (entity?.granblueId) {
ids.add(String(entity.granblueId))
}
}
return ids
})
// Helper to check if an item is owned (in user's collection)
function isOwned(item: AddItemResult): boolean {
return ownedItemIds.has(String(item.granblueId))
}
// 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)
const searchResults = $derived.by<AddItemResult[]>(() => {
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[]
})
// Get the active query based on search mode
const activeQuery = $derived(
searchMode === 'collection' && authUserId ? collectionQueryResult : searchQueryResult
)
// State-gated infinite scroll
// Type assertion needed because activeQuery is a union of different query types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const loader = useInfiniteLoader(() => activeQuery as any, () => sentinelEl, { rootMargin: '200px' })
// Reset loader when search mode or filters change
$effect(() => {
void searchMode
void filters
loader.reset()
})
// Cleanup on destroy
onDestroy(() => loader.destroy())
// Computed states
const isEmpty = $derived(
searchResults.length === 0 && !activeQuery.isLoading && !activeQuery.isError
)
function handleItemClick(item: AddItemResult) {
if (canAddMore) {
onAddItems([item])
}
}
function handleElementChange(value: number | number[]) {
elementFilters = Array.isArray(value) ? value : value !== undefined ? [value] : []
}
function toggleRarityFilter(rarity: number) {
if (rarityFilters.includes(rarity)) {
rarityFilters = rarityFilters.filter(r => r !== rarity)
} else {
rarityFilters = [...rarityFilters, rarity]
}
}
function toggleProficiencyFilter(prof: number) {
if (proficiencyFilters.includes(prof)) {
proficiencyFilters = proficiencyFilters.filter(p => p !== prof)
} else {
proficiencyFilters = [...proficiencyFilters, prof]
}
}
function getImageUrl(item: AddItemResult): string {
const id = item.granblueId
if (!id) return `/images/placeholders/placeholder-${type}-square.png`
switch (type) {
case 'character':
return getCharacterImage(id, 'square', '01')
case 'weapon':
return getWeaponImage(id, 'square')
case 'summon':
return getSummonImage(id, 'square')
default:
return ''
}
}
function getItemName(item: AddItemResult): string {
const name = item.name
if (typeof name === 'string') return name
return name?.en || name?.ja || 'Unknown'
}
</script>
<div class="search-content">
<div class="search-section">
<Input
bind:value={searchQuery}
type="text"
placeholder="Search by name..."
leftIcon="search"
contained
fullWidth
class="search-input"
/>
</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">
<!-- Element filters -->
<div class="filter-group">
<label class="filter-label">Element</label>
<ElementPicker
value={elementFilters}
onValueChange={handleElementChange}
multiple={true}
includeAny={true}
contained={true}
/>
</div>
<!-- Rarity filters -->
<div class="filter-group">
<label class="filter-label">Rarity</label>
<div class="filter-buttons">
{#each rarities as rarity}
<button
class="filter-btn rarity-btn"
class:active={rarityFilters.includes(rarity.value)}
onclick={() => toggleRarityFilter(rarity.value)}
aria-pressed={rarityFilters.includes(rarity.value)}
>
{rarity.label}
</button>
{/each}
</div>
</div>
<!-- Proficiency filters (weapons only, hidden when required proficiencies set for mainhand) -->
{#if type === 'weapon' && !requiredProficiencies}
<div class="filter-group">
<label class="filter-label">Proficiency</label>
<div class="filter-buttons proficiency-grid">
{#each proficiencies as prof}
<button
class="filter-btn prof-btn"
class:active={proficiencyFilters.includes(prof.value)}
onclick={() => toggleProficiencyFilter(prof.value)}
aria-pressed={proficiencyFilters.includes(prof.value)}
>
{prof.label}
</button>
{/each}
</div>
</div>
{/if}
</div>
<!-- Results -->
<div class="results-section">
{#if activeQuery.isLoading}
<div class="loading">
<Icon name="loader-2" size={24} />
<span>Searching...</span>
</div>
{:else if activeQuery.isError}
<div class="error-state">
<Icon name="alert-circle" size={24} />
<p>{activeQuery.error?.message || 'Search failed'}</p>
<Button size="small" onclick={() => activeQuery.refetch()}>Retry</Button>
</div>
{:else if searchResults.length > 0}
<ul class="results-list">
{#each searchResults as item (item.id)}
{@const owned = searchMode === 'all' && authUserId && isOwned(item)}
<li class="result-item">
<button
class="result-button"
class:disabled={!canAddMore}
class:from-collection={item.collectionId}
class:owned={owned}
onclick={() => handleItemClick(item)}
aria-label="{canAddMore ? 'Add' : 'Grid full - cannot add'} {getItemName(item)}"
disabled={!canAddMore}
>
<img
src={getImageUrl(item)}
alt={getItemName(item)}
class="result-image"
loading="lazy"
/>
<span class="result-name">{getItemName(item)}</span>
{#if type === 'character'}
<CharacterTags character={item} />
{/if}
{#if item.collectionId}
<Icon name="bookmark" size={14} class="collection-indicator" />
{:else if owned}
<Icon name="check" size={14} class="owned-indicator" />
{/if}
{#if item.element !== undefined}
<span
class="result-element"
style:color={elements.find(e => e.value === item.element)?.color}
>
{elements.find(e => e.value === item.element)?.label}
</span>
{/if}
</button>
</li>
{/each}
</ul>
<div
class="load-more-sentinel"
bind:this={sentinelEl}
class:hidden={!activeQuery.hasNextPage}
></div>
{#if activeQuery.isFetchingNextPage}
<div class="loading-more">
<Icon name="loader-2" size={20} />
<span>Loading more...</span>
</div>
{/if}
{:else if isEmpty}
<div class="no-results">
{#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
{:else}
Start typing to search
{/if}
</div>
{/if}
</div>
</div>
<style lang="scss">
@use '$src/themes/spacing' as *;
@use '$src/themes/colors' as *;
@use '$src/themes/typography' as *;
@use '$src/themes/layout' as *;
.search-content {
display: flex;
flex-direction: column;
height: calc(100vh - 60px); // Account for sidebar header
overflow: hidden;
}
.search-section {
padding: 0 0 $unit-2x 0;
flex-shrink: 0;
:global(.search-input) {
border-radius: $card-corner;
}
}
.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 {
padding-bottom: $unit-2x;
border-bottom: 1px solid var(--border-primary);
flex-shrink: 0;
.filter-group {
margin-bottom: calc($unit * 1.5);
&:last-child {
margin-bottom: 0;
}
}
.filter-label {
display: block;
font-size: $font-tiny;
font-weight: $bold;
text-transform: uppercase;
color: var(--text-secondary);
margin-bottom: $unit;
letter-spacing: 0.5px;
}
.filter-buttons {
display: flex;
flex-wrap: wrap;
gap: $unit-half;
}
.proficiency-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: $unit-half;
}
.filter-btn {
padding: $unit-half $unit;
border: 1px solid var(--border-primary);
background: var(--bg-secondary);
border-radius: $input-corner;
font-size: $font-small;
cursor: pointer;
transition: all 0.2s;
color: var(--text-primary);
&:hover {
background: var(--bg-tertiary);
border-color: var(--border-secondary);
}
&.active {
background: var(--accent-blue);
color: white;
border-color: var(--accent-blue);
}
}
}
.results-section {
flex: 1;
overflow-y: auto;
padding: $unit-2x 0;
min-height: 0;
.loading,
.no-results {
text-align: center;
padding: $unit-3x;
color: var(--text-secondary);
font-size: $font-regular;
}
.results-list {
list-style: none;
padding: 0;
margin: 0;
}
.result-item {
margin-bottom: $unit-half;
.result-button {
width: 100%;
display: flex;
align-items: center;
gap: $unit;
padding: $unit;
border: 1px solid transparent;
border-radius: $input-corner;
background: var(--bg-secondary);
cursor: pointer;
transition: all 0.2s;
text-align: left;
&:hover {
background: var(--bg-tertiary);
border-color: var(--accent-blue);
}
&:active:not(:disabled) {
transform: scale(0.99);
}
&.disabled,
&:disabled {
opacity: 0.5;
cursor: not-allowed;
background: var(--bg-disabled);
&:hover {
background: var(--bg-disabled);
border-color: transparent;
}
}
}
.result-image {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 4px;
border: 1px solid var(--border-primary);
flex-shrink: 0;
}
.result-name {
flex: 1;
font-size: $font-regular;
color: var(--text-primary);
}
.result-element {
font-size: $font-small;
font-weight: $bold;
flex-shrink: 0;
}
:global(.collection-indicator) {
color: var(--accent-blue);
flex-shrink: 0;
}
:global(.owned-indicator) {
color: var(--success, #4caf50);
flex-shrink: 0;
opacity: 0.7;
}
// Subtle highlight for owned items in "all" mode
.result-button.owned {
background: var(--owned-bg, rgba(76, 175, 80, 0.08));
&:hover {
background: var(--owned-bg-hover, rgba(76, 175, 80, 0.15));
}
}
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
gap: $unit;
:global(svg) {
animation: spin 1s linear infinite;
}
}
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $unit;
padding: $unit-3x;
color: var(--text-secondary);
:global(svg) {
color: var(--text-tertiary);
}
p {
margin: 0;
font-size: $font-regular;
}
}
.load-more-sentinel {
height: 1px;
margin-top: $unit;
&.hidden {
display: none;
}
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: $unit;
padding: $unit-2x;
color: var(--text-secondary);
font-size: $font-regular;
:global(svg) {
animation: spin 1s linear infinite;
}
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>