Add search sidebar and panel components
This commit is contained in:
parent
e8fa1545f8
commit
efd13206e0
2 changed files with 637 additions and 0 deletions
572
src/lib/components/panels/SearchSidebar.svelte
Normal file
572
src/lib/components/panels/SearchSidebar.svelte
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
<svelte:options runes={true} />
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { searchWeapons, searchCharacters, searchSummons, type SearchResult } from '$lib/api/resources/search'
|
||||
|
||||
interface Props {
|
||||
open?: boolean
|
||||
type: 'weapon' | 'character' | 'summon'
|
||||
onClose?: () => void
|
||||
onAddItems?: (items: SearchResult[]) => void
|
||||
canAddMore?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
open = false,
|
||||
type = 'weapon',
|
||||
onClose = () => {},
|
||||
onAddItems = () => {},
|
||||
canAddMore = true
|
||||
}: 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 = {
|
||||
query: searchQuery || undefined, // Don't send empty string
|
||||
page: currentPage,
|
||||
filters: {
|
||||
element: elementFilters.length > 0 ? elementFilters : undefined,
|
||||
rarity: rarityFilters.length > 0 ? rarityFilters : undefined,
|
||||
proficiency1: type === 'weapon' && proficiencyFilters.length > 0 ? proficiencyFilters : undefined
|
||||
}
|
||||
}
|
||||
|
||||
let response
|
||||
switch (type) {
|
||||
case 'weapon':
|
||||
response = await searchWeapons(params)
|
||||
break
|
||||
case 'character':
|
||||
response = await searchCharacters(params)
|
||||
break
|
||||
case 'summon':
|
||||
response = await searchSummons(params)
|
||||
break
|
||||
}
|
||||
|
||||
searchResults = response.results
|
||||
totalPages = response.total_pages
|
||||
} 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 {
|
||||
if (!item.granblue_id) return '/images/placeholders/placeholder-' + type + '.png'
|
||||
|
||||
const folder = type === 'weapon' ? 'weapon-grid' : type
|
||||
return `/images/${folder}/${item.granblue_id}.jpg`
|
||||
}
|
||||
|
||||
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) -->
|
||||
{#if type === 'weapon'}
|
||||
<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>
|
||||
65
src/lib/components/panels/SidePanel.svelte
Normal file
65
src/lib/components/panels/SidePanel.svelte
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<script lang="ts">
|
||||
export let open = false
|
||||
export let title: string = 'Search'
|
||||
export let onClose: () => void = () => {}
|
||||
export let inline = false // when true, renders as an inline flex item instead of fixed drawer
|
||||
</script>
|
||||
|
||||
<aside class="panel" class:open={open} class:inline={inline} aria-hidden={!open} aria-label={title}>
|
||||
<header class="panel-header">
|
||||
<h2>{title}</h2>
|
||||
<button class="close" on:click={onClose} aria-label="Close">×</button>
|
||||
</header>
|
||||
|
||||
<div class="panel-body">
|
||||
<div class="search">
|
||||
<input type="text" placeholder="Search..." aria-label="Search" />
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
width: var(--panel-w, 380px);
|
||||
max-width: 92vw;
|
||||
background: var(--app-bg, #fff);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* Fixed mode (default) */
|
||||
.panel:not(.inline) {
|
||||
position: fixed;
|
||||
top: 0; right: 0; bottom: 0;
|
||||
box-shadow: -2px 0 18px rgba(0,0,0,0.25);
|
||||
transform: translateX(100%);
|
||||
transition: transform 200ms ease;
|
||||
z-index: 50;
|
||||
}
|
||||
.panel:not(.inline).open { transform: translateX(0); }
|
||||
/* Inline mode (used in grid pages so content doesn't shrink) */
|
||||
.panel.inline {
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100dvh;
|
||||
box-shadow: -2px 0 18px rgba(0,0,0,0.1);
|
||||
}
|
||||
.panel.inline:not(.open) { display: none; }
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.panel-body { padding: 12px; overflow: auto; }
|
||||
.close { background: transparent; border: none; font-size: 22px; cursor: pointer; }
|
||||
.search input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in a new issue