hensei-web/src/lib/components/panels/SearchSidebar.svelte

599 lines
15 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<svelte:options runes={true} />
<script lang="ts">
import { onMount } from 'svelte'
import { searchAdapter, type SearchResult } from '$lib/api/adapters'
import { getCharacterImage, getWeaponImage, getSummonImage, getPlaceholderImage } from '$lib/utils/images'
interface Props {
open?: boolean
type: 'weapon' | 'character' | 'summon'
onClose?: () => void
onAddItems?: (items: SearchResult[]) => void
canAddMore?: boolean
requiredProficiencies?: number[] // For mainhand: restricts to job's proficiencies
}
let {
open = false,
type = 'weapon',
onClose = () => {},
onAddItems = () => {},
canAddMore = true,
requiredProficiencies
}: Props = $props()
// Search state
let searchQuery = $state('')
let searchResults = $state<SearchResult[]>([])
let isLoading = $state(false)
let currentPage = $state(1)
let totalPages = $state(1)
let hasInitialLoad = $state(false)
// Filter state
let elementFilters = $state<number[]>([])
let rarityFilters = $state<number[]>([])
let proficiencyFilters = $state<number[]>([])
// Refs
let searchInput: HTMLInputElement
let resultsContainer: HTMLDivElement
// Constants
const elements = [
{ value: 0, label: 'Null', color: '#888' },
{ value: 1, label: 'Wind', color: '#4A9B3F' },
{ value: 2, label: 'Fire', color: '#D94444' },
{ value: 3, label: 'Water', color: '#4A7FB8' },
{ value: 4, label: 'Earth', color: '#9B6E3F' },
{ value: 5, label: 'Dark', color: '#6B3E9B' },
{ value: 6, label: 'Light', color: '#F4B643' }
]
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' }
]
// Focus search input and load recent items when opened
$effect(() => {
if (open && searchInput) {
searchInput.focus()
}
// Load recent items when opening if we haven't already
if (open && !hasInitialLoad) {
hasInitialLoad = true
performSearch()
}
})
// Search when query or filters change
$effect(() => {
// Always search if we have filters or a search query
// If no query and no filters, still search to show recent items
if (searchQuery.length >= 2 || elementFilters.length > 0 || rarityFilters.length > 0 || proficiencyFilters.length > 0) {
performSearch()
} else if (searchQuery.length === 1) {
// Don't search with just 1 character
return
} else if (searchQuery.length === 0 && elementFilters.length === 0 && rarityFilters.length === 0 && proficiencyFilters.length === 0) {
// Load recent items when no search criteria
if (hasInitialLoad) {
performSearch()
}
}
})
async function performSearch() {
isLoading = true
try {
const params: any = {
page: currentPage,
filters: {}
}
// Only add query if not empty
if (searchQuery) {
params.query = searchQuery
}
// Only add filters if they have values
if (elementFilters.length > 0) {
params.filters.element = elementFilters
}
if (rarityFilters.length > 0) {
params.filters.rarity = rarityFilters
}
if (type === 'weapon') {
// Use required proficiencies (for mainhand) if set, otherwise use user-selected filters
const profs = requiredProficiencies ?? (proficiencyFilters.length > 0 ? proficiencyFilters : undefined)
if (profs && profs.length > 0) {
params.filters.proficiency1 = profs
}
}
let response
switch (type) {
case 'weapon':
response = await searchAdapter.searchWeapons(params)
break
case 'character':
response = await searchAdapter.searchCharacters(params)
break
case 'summon':
response = await searchAdapter.searchSummons(params)
break
}
searchResults = response?.results ?? []
totalPages = response?.totalPages ?? 1
} catch (error) {
console.error('Search failed:', error)
searchResults = []
} finally {
isLoading = false
}
}
function handleItemClick(item: SearchResult) {
// Only add if we can add more items
if (canAddMore) {
onAddItems([item])
}
}
function toggleElementFilter(element: number) {
if (elementFilters.includes(element)) {
elementFilters = elementFilters.filter(e => e !== element)
} else {
elementFilters = [...elementFilters, element]
}
}
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 handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose()
}
}
function getImageUrl(item: SearchResult): string {
const id = item.granblueId
if (!id) return getPlaceholderImage(type, 'grid')
switch (type) {
case 'character':
return getCharacterImage(id, 'grid', '01')
case 'weapon':
return getWeaponImage(id, 'grid')
case 'summon':
return getSummonImage(id, 'grid')
default:
return getPlaceholderImage(type, 'grid')
}
}
function getItemName(item: SearchResult): string {
const name = item.name
if (typeof name === 'string') return name
return name?.en || name?.ja || 'Unknown'
}
</script>
<aside
class="sidebar"
class:open={open}
aria-hidden={!open}
aria-label="Search {type}s"
on:keydown={handleKeyDown}
>
<header class="sidebar-header">
<h2>Search {type}s</h2>
<button class="close-btn" on:click={onClose} aria-label="Close">×</button>
</header>
<div class="search-section">
<input
bind:this={searchInput}
bind:value={searchQuery}
type="text"
placeholder="Search by name..."
aria-label="Search"
class="search-input"
/>
</div>
<div class="filters-section">
<!-- Element filters -->
<div class="filter-group">
<label class="filter-label">Element</label>
<div class="filter-buttons">
{#each elements as element}
<button
class="filter-btn element-btn"
class:active={elementFilters.includes(element.value)}
style="--element-color: {element.color}"
on:click={() => toggleElementFilter(element.value)}
aria-pressed={elementFilters.includes(element.value)}
>
{element.label}
</button>
{/each}
</div>
</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)}
on:click={() => toggleRarityFilter(rarity.value)}
aria-pressed={rarityFilters.includes(rarity.value)}
>
{rarity.label}
</button>
{/each}
</div>
</div>
<!-- Proficiency filters (weapons only, hidden when required proficiencies set) -->
{#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)}
on:click={() => toggleProficiencyFilter(prof.value)}
aria-pressed={proficiencyFilters.includes(prof.value)}
>
{prof.label}
</button>
{/each}
</div>
</div>
{/if}
</div>
<!-- Results -->
<div class="results-section" bind:this={resultsContainer}>
{#if isLoading}
<div class="loading">Searching...</div>
{:else if searchResults.length > 0}
<ul class="results-list">
{#each searchResults as item (item.id)}
<li class="result-item">
<button
class="result-button"
class:disabled={!canAddMore}
on:click={() => handleItemClick(item)}
aria-label="{canAddMore ? 'Add' : 'Grid full - cannot add'} {getItemName(item)}"
disabled={!canAddMore}
>
<img
src={getImageUrl(item)}
alt={getItemName(item)}
class="result-image"
/>
<span class="result-name">{getItemName(item)}</span>
{#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>
{#if totalPages > 1}
<div class="pagination">
<button
on:click={() => currentPage = Math.max(1, currentPage - 1)}
disabled={currentPage === 1}
>
Previous
</button>
<span>Page {currentPage} of {totalPages}</span>
<button
on:click={() => currentPage = Math.min(totalPages, currentPage + 1)}
disabled={currentPage === totalPages}
>
Next
</button>
</div>
{/if}
{:else if searchQuery.length > 0}
<div class="no-results">No results found</div>
{:else if !hasInitialLoad}
<div class="empty-state">Loading recent items...</div>
{:else}
<div class="no-results">No items found</div>
{/if}
</div>
</aside>
<style lang="scss">
.sidebar {
width: 320px;
height: 100vh;
background: var(--app-bg, #fff);
display: none;
flex-direction: column;
box-shadow: -2px 0 8px rgba(0,0,0,0.1);
border-left: 1px solid #e0e0e0;
position: sticky;
top: 0;
flex-shrink: 0;
}
.sidebar.open {
display: flex;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #e0e0e0;
background: #fafafa;
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.close-btn {
background: transparent;
border: none;
font-size: 24px;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
&:hover {
background: rgba(0,0,0,0.05);
}
}
}
.search-section {
padding: 12px;
border-bottom: 1px solid #e0e0e0;
.search-input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
&:focus {
outline: none;
border-color: #3366ff;
box-shadow: 0 0 0 2px rgba(51, 102, 255, 0.1);
}
}
}
.filters-section {
padding: 12px;
border-bottom: 1px solid #e0e0e0;
max-height: 280px;
overflow-y: auto;
.filter-group {
margin-bottom: 12px;
&:last-child {
margin-bottom: 0;
}
}
.filter-label {
display: block;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: #666;
margin-bottom: 6px;
}
.filter-buttons {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.proficiency-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
.filter-btn {
padding: 4px 8px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #f5f5f5;
}
&.active {
background: #3366ff;
color: white;
border-color: #3366ff;
}
&.element-btn.active {
background: var(--element-color);
border-color: var(--element-color);
}
}
}
.results-section {
flex: 1;
overflow-y: auto;
padding: 12px;
.loading, .no-results, .empty-state {
text-align: center;
padding: 24px;
color: #666;
font-size: 14px;
}
.results-list {
list-style: none;
padding: 0;
margin: 0;
}
.result-item {
margin-bottom: 4px;
.result-button {
width: 100%;
display: flex;
align-items: center;
padding: 8px;
border: 1px solid transparent;
border-radius: 6px;
background: white;
cursor: pointer;
transition: all 0.2s;
text-align: left;
&:hover {
background: #f5f5f5;
border-color: #3366ff;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
&:active:not(:disabled) {
transform: scale(0.98);
}
&.disabled,
&:disabled {
opacity: 0.5;
cursor: not-allowed;
background: #f9f9f9;
border-color: #ddd;
&:hover {
background: #f9f9f9;
border-color: #ddd;
box-shadow: none;
}
}
}
.result-image {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 4px;
margin-right: 12px;
border: 1px solid #e0e0e0;
}
.result-name {
flex: 1;
font-size: 14px;
color: #333;
}
.result-element {
font-size: 12px;
font-weight: 600;
margin-left: 8px;
}
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #e0e0e0;
button {
padding: 4px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
&:hover:not(:disabled) {
background: #f0f0f0;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
span {
font-size: 13px;
color: #666;
}
}
}
</style>