convert collection filters from buttons to dropdowns

This commit is contained in:
Justin Edmund 2025-12-02 17:19:31 -08:00
parent 9f18ff0a4d
commit cc2b2c1f95
4 changed files with 209 additions and 278 deletions

View file

@ -2,6 +2,9 @@
import { CHARACTER_SEASON_NAMES, CHARACTER_SERIES_NAMES } from '$lib/types/enums' import { CHARACTER_SEASON_NAMES, CHARACTER_SERIES_NAMES } from '$lib/types/enums'
import { RACE_LABELS } from '$lib/utils/race' import { RACE_LABELS } from '$lib/utils/race'
import { GENDER_LABELS } from '$lib/utils/gender' import { GENDER_LABELS } from '$lib/utils/gender'
import type { CollectionSortKey } from '$lib/types/api/collection'
import MultiSelect from '$lib/components/ui/MultiSelect.svelte'
import Select from '$lib/components/ui/Select.svelte'
interface Props { interface Props {
elementFilters?: number[] elementFilters?: number[]
@ -11,7 +14,9 @@
raceFilters?: number[] raceFilters?: number[]
proficiencyFilters?: number[] proficiencyFilters?: number[]
genderFilters?: number[] genderFilters?: number[]
sortBy?: CollectionSortKey
onFiltersChange?: (filters: CollectionFilterState) => void onFiltersChange?: (filters: CollectionFilterState) => void
onSortChange?: (sort: CollectionSortKey) => void
/** Which filter groups to show */ /** Which filter groups to show */
showFilters?: { showFilters?: {
element?: boolean element?: boolean
@ -22,8 +27,8 @@
proficiency?: boolean proficiency?: boolean
gender?: boolean gender?: boolean
} }
/** Layout mode */ /** Whether to show the sort dropdown */
layout?: 'horizontal' | 'vertical' showSort?: boolean
} }
export interface CollectionFilterState { export interface CollectionFilterState {
@ -44,7 +49,9 @@
raceFilters = $bindable([]), raceFilters = $bindable([]),
proficiencyFilters = $bindable([]), proficiencyFilters = $bindable([]),
genderFilters = $bindable([]), genderFilters = $bindable([]),
sortBy = $bindable<CollectionSortKey>('name_asc'),
onFiltersChange, onFiltersChange,
onSortChange,
showFilters = { showFilters = {
element: true, element: true,
rarity: true, rarity: true,
@ -54,9 +61,19 @@
proficiency: true, proficiency: true,
gender: true gender: true
}, },
layout = 'horizontal' showSort = true
}: Props = $props() }: Props = $props()
// Sort options
const sortOptions: { value: CollectionSortKey; label: string }[] = [
{ value: 'name_asc', label: 'Name A → Z' },
{ value: 'name_desc', label: 'Name Z → A' },
{ value: 'element_asc', label: 'Element ↑' },
{ value: 'element_desc', label: 'Element ↓' },
{ value: 'proficiency_asc', label: 'Proficiency ↑' },
{ value: 'proficiency_desc', label: 'Proficiency ↓' }
]
// Constants // Constants
const elements = [ const elements = [
{ value: 0, label: 'Null', color: '#888' }, { value: 0, label: 'Null', color: '#888' },
@ -77,13 +94,13 @@
const proficiencies = [ const proficiencies = [
{ value: 1, label: 'Sabre' }, { value: 1, label: 'Sabre' },
{ value: 2, label: 'Dagger' }, { value: 2, label: 'Dagger' },
{ value: 3, label: 'Spear' }, { value: 3, label: 'Axe' },
{ value: 4, label: 'Axe' }, { value: 4, label: 'Spear' },
{ value: 5, label: 'Staff' }, { value: 5, label: 'Bow' },
{ value: 6, label: 'Gun' }, { value: 6, label: 'Staff' },
{ value: 7, label: 'Melee' }, { value: 7, label: 'Melee' },
{ value: 8, label: 'Bow' }, { value: 8, label: 'Harp' },
{ value: 9, label: 'Harp' }, { value: 9, label: 'Gun' },
{ value: 10, label: 'Katana' } { value: 10, label: 'Katana' }
] ]
@ -124,45 +141,46 @@
}) })
} }
function toggleFilter( function handleElementChange(value: number[]) {
current: number[], elementFilters = value
value: number,
setter: (val: number[]) => void
) {
if (current.includes(value)) {
setter(current.filter((v) => v !== value))
} else {
setter([...current, value])
}
emitChange() emitChange()
} }
function toggleElement(value: number) { function handleRarityChange(value: number[]) {
toggleFilter(elementFilters, value, (v) => (elementFilters = v)) rarityFilters = value
emitChange()
} }
function toggleRarity(value: number) { function handleSeasonChange(value: number[]) {
toggleFilter(rarityFilters, value, (v) => (rarityFilters = v)) seasonFilters = value
emitChange()
} }
function toggleSeason(value: number) { function handleSeriesChange(value: number[]) {
toggleFilter(seasonFilters, value, (v) => (seasonFilters = v)) seriesFilters = value
emitChange()
} }
function toggleSeries(value: number) { function handleRaceChange(value: number[]) {
toggleFilter(seriesFilters, value, (v) => (seriesFilters = v)) raceFilters = value
emitChange()
} }
function toggleRace(value: number) { function handleProficiencyChange(value: number[]) {
toggleFilter(raceFilters, value, (v) => (raceFilters = v)) proficiencyFilters = value
emitChange()
} }
function toggleProficiency(value: number) { function handleGenderChange(value: number[]) {
toggleFilter(proficiencyFilters, value, (v) => (proficiencyFilters = v)) genderFilters = value
emitChange()
} }
function toggleGender(value: number) { function handleSortChange(value: CollectionSortKey | undefined) {
toggleFilter(genderFilters, value, (v) => (genderFilters = v)) if (value) {
sortBy = value
onSortChange?.(value)
}
} }
function clearAll() { function clearAll() {
@ -187,231 +205,123 @@
) )
</script> </script>
<div class="filters" class:horizontal={layout === 'horizontal'} class:vertical={layout === 'vertical'}> <div class="filters-container">
{#if showFilters.element} <div class="filters">
<div class="filter-group" role="group" aria-label="Element filters"> {#if showFilters.element}
<span class="filter-label">Element</span> <MultiSelect
<div class="filter-buttons"> options={elements}
{#each elements as element} bind:value={elementFilters}
<button onValueChange={handleElementChange}
type="button" placeholder="Element"
class="filter-btn element-btn" />
class:active={elementFilters.includes(element.value)} {/if}
style="--element-color: {element.color}"
onclick={() => toggleElement(element.value)}
aria-pressed={elementFilters.includes(element.value)}
>
{element.label}
</button>
{/each}
</div>
</div>
{/if}
{#if showFilters.rarity} {#if showFilters.rarity}
<div class="filter-group" role="group" aria-label="Rarity filters"> <MultiSelect
<span class="filter-label">Rarity</span> options={rarities}
<div class="filter-buttons"> bind:value={rarityFilters}
{#each rarities as rarity} onValueChange={handleRarityChange}
<button placeholder="Rarity"
type="button" />
class="filter-btn" {/if}
class:active={rarityFilters.includes(rarity.value)}
onclick={() => toggleRarity(rarity.value)}
aria-pressed={rarityFilters.includes(rarity.value)}
>
{rarity.label}
</button>
{/each}
</div>
</div>
{/if}
{#if showFilters.season} {#if showFilters.season}
<div class="filter-group" role="group" aria-label="Season filters"> <MultiSelect
<span class="filter-label">Season</span> options={seasons}
<div class="filter-buttons"> bind:value={seasonFilters}
{#each seasons as season} onValueChange={handleSeasonChange}
<button placeholder="Season"
type="button" />
class="filter-btn" {/if}
class:active={seasonFilters.includes(season.value)}
onclick={() => toggleSeason(season.value)}
aria-pressed={seasonFilters.includes(season.value)}
>
{season.label}
</button>
{/each}
</div>
</div>
{/if}
{#if showFilters.series} {#if showFilters.series}
<div class="filter-group" role="group" aria-label="Series filters"> <MultiSelect
<span class="filter-label">Series</span> options={series}
<div class="filter-buttons wrap"> bind:value={seriesFilters}
{#each series as s} onValueChange={handleSeriesChange}
<button placeholder="Series"
type="button" />
class="filter-btn" {/if}
class:active={seriesFilters.includes(s.value)}
onclick={() => toggleSeries(s.value)}
aria-pressed={seriesFilters.includes(s.value)}
>
{s.label}
</button>
{/each}
</div>
</div>
{/if}
{#if showFilters.race} {#if showFilters.race}
<div class="filter-group" role="group" aria-label="Race filters"> <MultiSelect
<span class="filter-label">Race</span> options={races}
<div class="filter-buttons"> bind:value={raceFilters}
{#each races as race} onValueChange={handleRaceChange}
<button placeholder="Race"
type="button" />
class="filter-btn" {/if}
class:active={raceFilters.includes(race.value)}
onclick={() => toggleRace(race.value)}
aria-pressed={raceFilters.includes(race.value)}
>
{race.label}
</button>
{/each}
</div>
</div>
{/if}
{#if showFilters.proficiency} {#if showFilters.proficiency}
<div class="filter-group" role="group" aria-label="Proficiency filters"> <MultiSelect
<span class="filter-label">Proficiency</span> options={proficiencies}
<div class="filter-buttons proficiency-grid"> bind:value={proficiencyFilters}
{#each proficiencies as prof} onValueChange={handleProficiencyChange}
<button placeholder="Proficiency"
type="button" />
class="filter-btn" {/if}
class:active={proficiencyFilters.includes(prof.value)}
onclick={() => toggleProficiency(prof.value)}
aria-pressed={proficiencyFilters.includes(prof.value)}
>
{prof.label}
</button>
{/each}
</div>
</div>
{/if}
{#if showFilters.gender} {#if showFilters.gender}
<div class="filter-group" role="group" aria-label="Gender filters"> <MultiSelect
<span class="filter-label">Gender</span> options={genders}
<div class="filter-buttons"> bind:value={genderFilters}
{#each genders as gender} onValueChange={handleGenderChange}
<button placeholder="Gender"
type="button" />
class="filter-btn" {/if}
class:active={genderFilters.includes(gender.value)}
onclick={() => toggleGender(gender.value)}
aria-pressed={genderFilters.includes(gender.value)}
>
{gender.label}
</button>
{/each}
</div>
</div>
{/if}
{#if hasActiveFilters} {#if hasActiveFilters}
<button type="button" class="clear-btn" onclick={clearAll}> <button type="button" class="clear-btn" onclick={clearAll}> Clear </button>
Clear filters {/if}
</button> </div>
{#if showSort}
<div class="sort">
<Select
options={sortOptions}
bind:value={sortBy}
onValueChange={handleSortChange}
size="small"
/>
</div>
{/if} {/if}
</div> </div>
<style lang="scss"> <style lang="scss">
@use '$src/themes/spacing' as *; @use '$src/themes/spacing' as *;
.filters { .filters-container {
display: flex;
gap: $unit-2x;
&.horizontal {
flex-wrap: wrap;
align-items: flex-start;
}
&.vertical {
flex-direction: column;
}
}
.filter-group {
display: flex;
flex-direction: column;
gap: $unit-half;
}
.filter-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: var(--text-secondary, #666);
letter-spacing: 0.5px;
}
.filter-buttons {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; align-items: center;
justify-content: space-between;
&.wrap { gap: $unit;
max-width: 400px; width: 100%;
}
&.proficiency-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
}
} }
.filter-btn { .filters {
padding: 4px 8px; display: flex;
border: 1px solid var(--border-color, #ddd); flex-wrap: wrap;
background: var(--button-bg, white); align-items: center;
border-radius: 4px; gap: $unit;
font-size: 12px; }
cursor: pointer;
transition: all 0.15s ease;
color: var(--text-primary, #333);
white-space: nowrap;
&:hover { .sort {
background: var(--button-bg-hover, #f5f5f5); flex-shrink: 0;
}
&.active { :global([data-select-trigger]) {
background: var(--accent-color, #3366ff); min-width: 128px;
color: white;
border-color: var(--accent-color, #3366ff);
}
&.element-btn.active {
background: var(--element-color);
border-color: var(--element-color);
} }
} }
.clear-btn { .clear-btn {
padding: 4px 12px; padding: $unit-half $unit;
border: 1px solid var(--border-color, #ddd); border: 1px solid var(--border-color, #ddd);
background: transparent; background: transparent;
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
color: var(--text-secondary, #666); color: var(--text-secondary, #666);
align-self: flex-end;
&:hover { &:hover {
background: var(--button-bg-hover, #f5f5f5); background: var(--button-bg-hover, #f5f5f5);

View file

@ -144,6 +144,17 @@ export interface CollectionJobAccessoryInput {
jobAccessoryId: string jobAccessoryId: string
} }
/**
* Sort options for collection items
*/
export type CollectionSortKey =
| 'name_asc'
| 'name_desc'
| 'element_asc'
| 'element_desc'
| 'proficiency_asc'
| 'proficiency_desc'
/** /**
* Filters for listing collection items * Filters for listing collection items
*/ */
@ -153,6 +164,7 @@ export interface CollectionFilters {
race?: number[] race?: number[]
proficiency?: number[] proficiency?: number[]
gender?: number[] gender?: number[]
sort?: CollectionSortKey
page?: number page?: number
limit?: number limit?: number
} }

View file

@ -5,9 +5,14 @@
import ProfileHeader from '$lib/components/profile/ProfileHeader.svelte' import ProfileHeader from '$lib/components/profile/ProfileHeader.svelte'
import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte' import SegmentedControl from '$lib/components/ui/segmented-control/SegmentedControl.svelte'
import Segment from '$lib/components/ui/segmented-control/Segment.svelte' import Segment from '$lib/components/ui/segmented-control/Segment.svelte'
import Button from '$lib/components/ui/Button.svelte'
import Icon from '$lib/components/Icon.svelte'
import AddToCollectionModal from '$lib/components/collection/AddToCollectionModal.svelte'
let { data, children }: { data: LayoutData; children: any } = $props() let { data, children }: { data: LayoutData; children: any } = $props()
let addModalOpen = $state(false)
// Determine active entity type from URL path // Determine active entity type from URL path
const activeEntityType = $derived.by(() => { const activeEntityType = $derived.by(() => {
const path = $page.url.pathname const path = $page.url.pathname
@ -24,31 +29,42 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{username}'s Collection</title> <title>{username}</title>
</svelte:head> </svelte:head>
<section class="collection"> <section class="collection">
<ProfileHeader <ProfileHeader
{username} {username}
avatarPicture={data.user?.avatar?.picture} avatarPicture={data.user?.avatar?.picture}
title="{username}'s Collection" title={username}
activeTab="collection" activeTab="collection"
isOwner={data.isOwner} isOwner={data.isOwner}
/> />
<!-- Entity type segmented control --> <!-- Entity type segmented control -->
<nav class="entity-nav" aria-label="Collection type"> <nav class="entity-nav" aria-label="Collection type">
<SegmentedControl value={activeEntityType} onValueChange={handleTabChange} gap={true}> <SegmentedControl
<Segment value="characters"> value={activeEntityType}
Characters onValueChange={handleTabChange}
</Segment> variant="blended"
<Segment value="weapons" disabled> size="small"
Weapons >
</Segment> <Segment value="characters">Characters</Segment>
<Segment value="summons" disabled> <Segment value="weapons" disabled>Weapons</Segment>
Summons <Segment value="summons" disabled>Summons</Segment>
</Segment>
</SegmentedControl> </SegmentedControl>
{#if data.isOwner}
<Button
variant="primary"
size="small"
onclick={() => (addModalOpen = true)}
icon="plus"
iconPosition="left"
>
Add characters
</Button>
{/if}
</nav> </nav>
<div class="content"> <div class="content">
@ -56,6 +72,10 @@
</div> </div>
</section> </section>
{#if data.isOwner}
<AddToCollectionModal userId={data.user.id} bind:open={addModalOpen} />
{/if}
<style lang="scss"> <style lang="scss">
@use '$src/themes/spacing' as *; @use '$src/themes/spacing' as *;
@ -64,8 +84,15 @@
} }
.entity-nav { .entity-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: $unit-2x;
margin-bottom: $unit-2x; margin-bottom: $unit-2x;
max-width: 500px;
:global(.button) {
align-self: stretch;
}
} }
.content { .content {

View file

@ -1,14 +1,12 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types' import type { PageData } from './$types'
import type { CollectionCharacter } from '$lib/types/api/collection' import type { CollectionCharacter, CollectionSortKey } from '$lib/types/api/collection'
import { createInfiniteQuery } from '@tanstack/svelte-query' import { createInfiniteQuery } from '@tanstack/svelte-query'
import { collectionQueries } from '$lib/api/queries/collection.queries' import { collectionQueries } from '$lib/api/queries/collection.queries'
import CollectionFilters, { import CollectionFilters, {
type CollectionFilterState type CollectionFilterState
} from '$lib/components/collection/CollectionFilters.svelte' } from '$lib/components/collection/CollectionFilters.svelte'
import AddToCollectionModal from '$lib/components/collection/AddToCollectionModal.svelte'
import CollectionCharacterPane from '$lib/components/collection/CollectionCharacterPane.svelte' import CollectionCharacterPane from '$lib/components/collection/CollectionCharacterPane.svelte'
import Button from '$lib/components/ui/Button.svelte'
import Icon from '$lib/components/Icon.svelte' import Icon from '$lib/components/Icon.svelte'
import { IsInViewport } from 'runed' import { IsInViewport } from 'runed'
import { getCharacterImageWithPose } from '$lib/utils/images' import { getCharacterImageWithPose } from '$lib/utils/images'
@ -25,8 +23,8 @@
let proficiencyFilters = $state<number[]>([]) let proficiencyFilters = $state<number[]>([])
let genderFilters = $state<number[]>([]) let genderFilters = $state<number[]>([])
// Modal state // Sort state
let addModalOpen = $state(false) let sortBy = $state<CollectionSortKey>('name_asc')
// Sentinel for infinite scroll // Sentinel for infinite scroll
let sentinelEl = $state<HTMLElement>() let sentinelEl = $state<HTMLElement>()
@ -37,7 +35,8 @@
rarity: rarityFilters.length > 0 ? rarityFilters : undefined, rarity: rarityFilters.length > 0 ? rarityFilters : undefined,
race: raceFilters.length > 0 ? raceFilters : undefined, race: raceFilters.length > 0 ? raceFilters : undefined,
proficiency: proficiencyFilters.length > 0 ? proficiencyFilters : undefined, proficiency: proficiencyFilters.length > 0 ? proficiencyFilters : undefined,
gender: genderFilters.length > 0 ? genderFilters : undefined gender: genderFilters.length > 0 ? genderFilters : undefined,
sort: sortBy
}) })
// Unified query for any user's collection (privacy enforced server-side) // Unified query for any user's collection (privacy enforced server-side)
@ -122,6 +121,7 @@
bind:raceFilters bind:raceFilters
bind:proficiencyFilters bind:proficiencyFilters
bind:genderFilters bind:genderFilters
bind:sortBy
onFiltersChange={handleFiltersChange} onFiltersChange={handleFiltersChange}
showFilters={{ showFilters={{
element: true, element: true,
@ -132,15 +132,7 @@
proficiency: true, proficiency: true,
gender: true gender: true
}} }}
layout="horizontal"
/> />
{#if data.isOwner}
<Button variant="primary" onclick={() => (addModalOpen = true)}>
<Icon name="plus" size={16} />
Add to Collection
</Button>
{/if}
</div> </div>
<!-- Collection grid --> <!-- Collection grid -->
@ -155,11 +147,7 @@
{#if data.isOwner} {#if data.isOwner}
<Icon name="users" size={48} /> <Icon name="users" size={48} />
<h3>Your collection is empty</h3> <h3>Your collection is empty</h3>
<p>Add characters to start building your collection</p> <p>Use the "Add to Collection" button above to get started</p>
<Button variant="primary" onclick={() => (addModalOpen = true)}>
<Icon name="plus" size={16} />
Add Characters
</Button>
{:else} {:else}
<Icon name="lock" size={48} /> <Icon name="lock" size={48} />
<p>This collection is empty or private</p> <p>This collection is empty or private</p>
@ -216,18 +204,17 @@
{#if !collectionQuery.hasNextPage && allCharacters.length > 0} {#if !collectionQuery.hasNextPage && allCharacters.length > 0}
<div class="end-message"> <div class="end-message">
<p>{allCharacters.length} character{allCharacters.length === 1 ? '' : 's'} in {data.isOwner ? 'your' : 'this'} collection</p> <p>
{allCharacters.length} character{allCharacters.length === 1 ? '' : 's'} in {data.isOwner
? 'your'
: 'this'} collection
</p>
</div> </div>
{/if} {/if}
{/if} {/if}
</div> </div>
</div> </div>
<!-- Add to Collection Modal -->
{#if data.isOwner}
<AddToCollectionModal userId={data.user.id} bind:open={addModalOpen} />
{/if}
<style lang="scss"> <style lang="scss">
@use '$src/themes/spacing' as *; @use '$src/themes/spacing' as *;
@use '$src/themes/colors' as *; @use '$src/themes/colors' as *;
@ -252,9 +239,10 @@
} }
.character-grid { .character-grid {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: repeat(5, 128px);
gap: $unit; justify-content: space-between;
gap: $unit-4x;
} }
.character-card { .character-card {
@ -282,7 +270,7 @@
.card-image { .card-image {
position: relative; position: relative;
// Character grid images are 280x160 (7:4 ratio) // Character grid images are 280x160 (7:4 ratio)
width: 100px; width: 100%;
aspect-ratio: 280 / 160; aspect-ratio: 280 / 160;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
@ -306,13 +294,7 @@
} }
.character-name { .character-name {
font-size: $font-small; display: none;
text-align: center;
color: $grey-50;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.loading-state, .loading-state,