From 957dd16e5eb8a8d957f3e7c7bdba82df97b5f003 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 3 Dec 2025 07:19:04 -0800 Subject: [PATCH] add quantity counter and selectable components for weapons/summons - QuantityCounter: +/- buttons for multi-copy selection - SelectableWeaponCard/Row: weapon selection with quantity - SelectableSummonCard/Row: summon selection with quantity - AddToCollectionModal: support entityType prop, Map for quantities - CollectionFilters: entityType-aware filter visibility --- src/lib/api/queries/collection.queries.ts | 7 +- .../collection/AddToCollectionModal.svelte | 309 ++++++++++++++---- .../collection/CollectionCharacterCard.svelte | 108 ++++++ .../collection/CollectionCharacterRow.svelte | 185 +++++++++++ .../collection/CollectionFilters.svelte | 172 ++++++++-- .../collection/QuantityCounter.svelte | 122 +++++++ .../collection/SelectableCharacterRow.svelte | 146 +++++++++ .../collection/SelectableSummonCard.svelte | 107 ++++++ .../collection/SelectableSummonRow.svelte | 143 ++++++++ .../collection/SelectableWeaponCard.svelte | 107 ++++++ .../collection/SelectableWeaponRow.svelte | 143 ++++++++ 11 files changed, 1454 insertions(+), 95 deletions(-) create mode 100644 src/lib/components/collection/CollectionCharacterCard.svelte create mode 100644 src/lib/components/collection/CollectionCharacterRow.svelte create mode 100644 src/lib/components/collection/QuantityCounter.svelte create mode 100644 src/lib/components/collection/SelectableCharacterRow.svelte create mode 100644 src/lib/components/collection/SelectableSummonCard.svelte create mode 100644 src/lib/components/collection/SelectableSummonRow.svelte create mode 100644 src/lib/components/collection/SelectableWeaponCard.svelte create mode 100644 src/lib/components/collection/SelectableWeaponRow.svelte diff --git a/src/lib/api/queries/collection.queries.ts b/src/lib/api/queries/collection.queries.ts index bb88f0cd..bc92b693 100644 --- a/src/lib/api/queries/collection.queries.ts +++ b/src/lib/api/queries/collection.queries.ts @@ -159,12 +159,15 @@ export const collectionQueries = { /** * Get IDs of characters already in a user's collection * Used to filter out owned characters in the add modal + * + * @param userId - The user whose collection to fetch + * @param enabled - Whether the query is enabled (default: true) */ - collectedCharacterIds: (userId: string) => + collectedCharacterIds: (userId: string, enabled: boolean = true) => queryOptions({ queryKey: ['collection', 'characters', 'ids', userId] as const, queryFn: () => collectionAdapter.getCollectedCharacterIds(userId), - enabled: !!userId, + enabled: !!userId && enabled, staleTime: 1000 * 60 * 5, // 5 minutes gcTime: 1000 * 60 * 30 // 30 minutes }), diff --git a/src/lib/components/collection/AddToCollectionModal.svelte b/src/lib/components/collection/AddToCollectionModal.svelte index 12014231..bdc3a463 100644 --- a/src/lib/components/collection/AddToCollectionModal.svelte +++ b/src/lib/components/collection/AddToCollectionModal.svelte @@ -6,7 +6,11 @@ type SearchFilters, type SearchPageResult } from '$lib/api/queries/search.queries' - import { useAddCharactersToCollection } from '$lib/api/mutations/collection.mutations' + import { + useAddCharactersToCollection, + useAddWeaponsToCollection, + useAddSummonsToCollection + } from '$lib/api/mutations/collection.mutations' import Dialog from '$lib/components/ui/Dialog.svelte' import Button from '$lib/components/ui/Button.svelte' import Icon from '$lib/components/Icon.svelte' @@ -14,17 +18,25 @@ type CollectionFilterState } from './CollectionFilters.svelte' import SelectableCharacterCard from './SelectableCharacterCard.svelte' + import SelectableCharacterRow from './SelectableCharacterRow.svelte' + import SelectableWeaponCard from './SelectableWeaponCard.svelte' + import SelectableWeaponRow from './SelectableWeaponRow.svelte' + import SelectableSummonCard from './SelectableSummonCard.svelte' + import SelectableSummonRow from './SelectableSummonRow.svelte' import { IsInViewport } from 'runed' + import { viewMode, type ViewMode } from '$lib/stores/viewMode.svelte' type SearchResultItem = SearchPageResult['results'][number] + type EntityType = 'character' | 'weapon' | 'summon' interface Props { userId: string + entityType?: EntityType open?: boolean onOpenChange?: (open: boolean) => void } - let { userId, open = $bindable(false), onOpenChange }: Props = $props() + let { userId, entityType = 'character', open = $bindable(false), onOpenChange }: Props = $props() // Search state let searchQuery = $state('') @@ -38,15 +50,25 @@ let proficiencyFilters = $state([]) let genderFilters = $state([]) - // Selection state + // Selection state - characters use Set, weapons/summons use Map for quantities let selectedIds = $state>(new Set()) + let selectedQuantities = $state>(new Map()) let showOnlySelected = $state(false) // Refs let sentinelEl = $state() - // Get IDs of characters already in collection - const collectedIdsQuery = createQuery(() => collectionQueries.collectedCharacterIds(userId)) + // Entity type display names + const entityNames: Record = { + character: { singular: 'character', plural: 'characters' }, + weapon: { singular: 'weapon', plural: 'weapons' }, + summon: { singular: 'summon', plural: 'summons' } + } + + // Get IDs of characters already in collection (only used for characters) + const collectedIdsQuery = createQuery(() => + collectionQueries.collectedCharacterIds(userId, entityType === 'character') + ) // Build filters for search (using SearchFilters type from search.queries) const searchFilters = $derived({ @@ -54,24 +76,32 @@ rarity: rarityFilters.length > 0 ? rarityFilters : undefined, season: seasonFilters.length > 0 ? seasonFilters : undefined, characterSeries: seriesFilters.length > 0 ? seriesFilters : undefined, - // Note: Race and gender filters would need API support proficiency: proficiencyFilters.length > 0 ? proficiencyFilters : undefined }) - // Search query with infinite scroll using the factory pattern - // No debouncing - TanStack Query's staleTime handles caching + // Search query with infinite scroll - dynamic based on entity type const searchResults = createInfiniteQuery(() => { - // Capture current reactive values synchronously for dependency tracking const query = searchQuery const filters = searchFilters - const excludeIds = collectedIdsQuery.data ?? [] - const isEnabled = open && !collectedIdsQuery.isLoading - return searchQueries.characters(query, filters, 'en', excludeIds, isEnabled) + if (entityType === 'character') { + const excludeIds = collectedIdsQuery.data ?? [] + const isEnabled = open && !collectedIdsQuery.isLoading + return searchQueries.characters(query, filters, 'en', excludeIds, isEnabled) + } else if (entityType === 'weapon') { + return { + ...searchQueries.weapons(query, filters, 'en'), + enabled: open + } + } else { + return { + ...searchQueries.summons(query, filters, 'en'), + enabled: open + } + } }) // Flatten results and deduplicate by ID - // (API may return duplicates across pages) const allResults = $derived.by(() => { const pages = searchResults.data?.pages ?? [] const seen = new Set() @@ -90,14 +120,29 @@ }) // Filter to show only selected if enabled - const displayedResults = $derived( - showOnlySelected - ? allResults.filter((r) => selectedIds.has(r.id)) - : allResults - ) + const displayedResults = $derived.by(() => { + if (!showOnlySelected) return allResults - // Add mutation - const addMutation = useAddCharactersToCollection() + if (entityType === 'character') { + return allResults.filter((r) => selectedIds.has(r.id)) + } else { + return allResults.filter((r) => (selectedQuantities.get(r.id) ?? 0) > 0) + } + }) + + // Add mutations + const addCharacterMutation = useAddCharactersToCollection() + const addWeaponMutation = useAddWeaponsToCollection() + const addSummonMutation = useAddSummonsToCollection() + + // Current mutation based on entity type + const currentMutation = $derived( + entityType === 'character' + ? addCharacterMutation + : entityType === 'weapon' + ? addWeaponMutation + : addSummonMutation + ) // Infinite scroll const inViewport = new IsInViewport(() => sentinelEl, { @@ -116,23 +161,29 @@ } }) - // Reset state when modal closes + // Reset state when modal closes or entity type changes $effect(() => { if (!open) { - selectedIds = new Set() - showOnlySelected = false - searchQuery = '' - elementFilters = [] - rarityFilters = [] - seasonFilters = [] - seriesFilters = [] - raceFilters = [] - proficiencyFilters = [] - genderFilters = [] + resetState() } }) - function toggleSelection(character: SearchResultItem) { + function resetState() { + selectedIds = new Set() + selectedQuantities = new Map() + showOnlySelected = false + searchQuery = '' + elementFilters = [] + rarityFilters = [] + seasonFilters = [] + seriesFilters = [] + raceFilters = [] + proficiencyFilters = [] + genderFilters = [] + } + + // Character toggle (binary selection) + function toggleCharacterSelection(character: SearchResultItem) { const newSet = new Set(selectedIds) if (newSet.has(character.id)) { newSet.delete(character.id) @@ -142,6 +193,17 @@ selectedIds = newSet } + // Weapon/Summon quantity change + function handleQuantityChange(item: SearchResultItem, quantity: number) { + const newMap = new Map(selectedQuantities) + if (quantity <= 0) { + newMap.delete(item.id) + } else { + newMap.set(item.id, quantity) + } + selectedQuantities = newMap + } + function handleFiltersChange(filters: CollectionFilterState) { elementFilters = filters.element rarityFilters = filters.rarity @@ -157,28 +219,89 @@ } async function handleAdd() { - if (selectedIds.size === 0) return - - const inputs = Array.from(selectedIds).map((characterId) => ({ - characterId, - uncapLevel: 0, - transcendenceStep: 0 - })) - try { - await addMutation.mutateAsync(inputs) + if (entityType === 'character') { + if (selectedIds.size === 0) return + + const inputs = Array.from(selectedIds).map((characterId) => ({ + characterId, + uncapLevel: 4, + transcendenceStep: 0 + })) + await addCharacterMutation.mutateAsync(inputs) + } else if (entityType === 'weapon') { + if (selectedQuantities.size === 0) return + + const inputs = Array.from(selectedQuantities.entries()).map(([weaponId, quantity]) => ({ + weaponId, + quantity, + uncapLevel: 3, + transcendenceStep: 0 + })) + await addWeaponMutation.mutateAsync(inputs) + } else { + if (selectedQuantities.size === 0) return + + const inputs = Array.from(selectedQuantities.entries()).map(([summonId, quantity]) => ({ + summonId, + quantity, + uncapLevel: 3, + transcendenceStep: 0 + })) + await addSummonMutation.mutateAsync(inputs) + } + open = false onOpenChange?.(false) } catch (error) { - console.error('Failed to add characters:', error) + console.error(`Failed to add ${entityNames[entityType].plural}:`, error) } } - const selectedCount = $derived(selectedIds.size) - const isLoading = $derived(searchResults.isLoading || collectedIdsQuery.isLoading) + // Selected count + const selectedCount = $derived( + entityType === 'character' + ? selectedIds.size + : Array.from(selectedQuantities.values()).reduce((sum, qty) => sum + qty, 0) + ) + + // Total items selected (for weapons/summons, this is unique items, not total quantity) + const selectedItemCount = $derived( + entityType === 'character' ? selectedIds.size : selectedQuantities.size + ) + + const isLoading = $derived( + searchResults.isLoading || (entityType === 'character' && collectedIdsQuery.isLoading) + ) + + // View mode from store + const currentViewMode = $derived(viewMode.modalView) + + function handleViewModeChange(mode: ViewMode) { + viewMode.setModalView(mode) + } + + // Dialog title based on entity type + const dialogTitle = $derived(`Add ${entityNames[entityType].plural.charAt(0).toUpperCase() + entityNames[entityType].plural.slice(1)} to Collection`) + + // Placeholder text based on entity type + const searchPlaceholder = $derived(`Search ${entityNames[entityType].plural} by name...`) + + // Footer text based on entity type + const selectedText = $derived.by(() => { + if (entityType === 'character') { + return `${selectedCount} ${selectedCount === 1 ? entityNames[entityType].singular : entityNames[entityType].plural} selected` + } else { + // For weapons/summons, show both item count and total quantity + if (selectedItemCount === 0) return '' + const itemText = `${selectedItemCount} ${selectedItemCount === 1 ? 'item' : 'items'}` + const qtyText = selectedCount > selectedItemCount ? ` (${selectedCount} total)` : '' + return `${itemText}${qtyText} selected` + } + }) - + {#snippet children()} @@ -195,6 +318,7 @@
- +
{#if isLoading}
-

Loading characters...

+

Loading {entityNames[entityType].plural}...

{:else if displayedResults.length === 0}
{#if showOnlySelected} -

No characters selected

+

No {entityNames[entityType].plural} selected

{:else if searchQuery || Object.values(searchFilters).some((v) => v)} -

No characters match your search

+

No {entityNames[entityType].plural} match your search

{:else} -

Start searching to find characters

+

Start searching to find {entityNames[entityType].plural}

+ {/if} +
+ {:else if currentViewMode === 'grid'} +
+ {#if entityType === 'character'} + {#each displayedResults as character (character.id)} + + {/each} + {:else if entityType === 'weapon'} + {#each displayedResults as weapon (weapon.id)} + + {/each} + {:else} + {#each displayedResults as summon (summon.id)} + + {/each} {/if}
{:else} -
- {#each displayedResults as character (character.id)} - - {/each} +
+ {#if entityType === 'character'} + {#each displayedResults as character (character.id)} + + {/each} + {:else if entityType === 'weapon'} + {#each displayedResults as weapon (weapon.id)} + + {/each} + {:else} + {#each displayedResults as summon (summon.id)} + + {/each} + {/if}
+ {/if} + {#if displayedResults.length > 0} {#if !showOnlySelected && searchResults.hasNextPage}
{/if} @@ -262,7 +438,7 @@ class:active={showOnlySelected} onclick={toggleShowSelected} > - {selectedCount} character{selectedCount === 1 ? '' : 's'} selected + {selectedText} {/if}
@@ -272,10 +448,10 @@ + + diff --git a/src/lib/components/collection/CollectionCharacterRow.svelte b/src/lib/components/collection/CollectionCharacterRow.svelte new file mode 100644 index 00000000..7bb5c4a8 --- /dev/null +++ b/src/lib/components/collection/CollectionCharacterRow.svelte @@ -0,0 +1,185 @@ + + + + + diff --git a/src/lib/components/collection/CollectionFilters.svelte b/src/lib/components/collection/CollectionFilters.svelte index f12fb9b7..3bb7f3b2 100644 --- a/src/lib/components/collection/CollectionFilters.svelte +++ b/src/lib/components/collection/CollectionFilters.svelte @@ -3,10 +3,16 @@ import { RACE_LABELS } from '$lib/utils/race' import { GENDER_LABELS } from '$lib/utils/gender' import type { CollectionSortKey } from '$lib/types/api/collection' + import type { ViewMode } from '$lib/stores/viewMode.svelte' import MultiSelect from '$lib/components/ui/MultiSelect.svelte' import Select from '$lib/components/ui/Select.svelte' + import Icon from '$lib/components/Icon.svelte' + + type EntityType = 'character' | 'weapon' | 'summon' interface Props { + /** Entity type to determine which filters to show */ + entityType?: EntityType elementFilters?: number[] rarityFilters?: number[] seasonFilters?: number[] @@ -17,7 +23,7 @@ sortBy?: CollectionSortKey onFiltersChange?: (filters: CollectionFilterState) => void onSortChange?: (sort: CollectionSortKey) => void - /** Which filter groups to show */ + /** Which filter groups to show (overrides entityType defaults) */ showFilters?: { element?: boolean rarity?: boolean @@ -29,6 +35,12 @@ } /** Whether to show the sort dropdown */ showSort?: boolean + /** Current view mode */ + viewMode?: ViewMode + /** Callback when view mode changes */ + onViewModeChange?: (mode: ViewMode) => void + /** Whether to show the view toggle */ + showViewToggle?: boolean } export interface CollectionFilterState { @@ -41,7 +53,39 @@ gender: number[] } + // Default filter visibility based on entity type + const defaultFiltersByEntity: Record = { + character: { + element: true, + rarity: true, + season: true, + series: true, + race: true, + proficiency: true, + gender: true + }, + weapon: { + element: true, + rarity: true, + season: false, + series: true, // Weapon series + race: false, + proficiency: true, // Weapon type + gender: false + }, + summon: { + element: true, + rarity: true, + season: false, + series: false, + race: false, + proficiency: false, + gender: false + } + } + let { + entityType = 'character', elementFilters = $bindable([]), rarityFilters = $bindable([]), seasonFilters = $bindable([]), @@ -52,18 +96,19 @@ sortBy = $bindable('name_asc'), onFiltersChange, onSortChange, - showFilters = { - element: true, - rarity: true, - season: true, - series: true, - race: true, - proficiency: true, - gender: true - }, - showSort = true + showFilters, + showSort = true, + viewMode = 'grid', + onViewModeChange, + showViewToggle = false }: Props = $props() + // Compute effective filter visibility (explicit showFilters overrides entityType defaults) + const effectiveShowFilters = $derived({ + ...defaultFiltersByEntity[entityType], + ...showFilters + }) + // Sort options const sortOptions: { value: CollectionSortKey; label: string }[] = [ { value: 'name_asc', label: 'Name A → Z' }, @@ -207,7 +252,7 @@
- {#if showFilters.element} + {#if effectiveShowFilters.element} {/if} - {#if showFilters.rarity} + {#if effectiveShowFilters.rarity} {/if} - {#if showFilters.season} + {#if effectiveShowFilters.season} {/if} - {#if showFilters.series} + {#if effectiveShowFilters.series} {/if} - {#if showFilters.race} + {#if effectiveShowFilters.race} {/if} - {#if showFilters.proficiency} + {#if effectiveShowFilters.proficiency} {/if} - {#if showFilters.gender} + {#if effectiveShowFilters.gender} - {#if showSort} -
- +
+ {/if} +
diff --git a/src/lib/components/collection/SelectableCharacterRow.svelte b/src/lib/components/collection/SelectableCharacterRow.svelte new file mode 100644 index 00000000..ed57902f --- /dev/null +++ b/src/lib/components/collection/SelectableCharacterRow.svelte @@ -0,0 +1,146 @@ + + + + + diff --git a/src/lib/components/collection/SelectableSummonCard.svelte b/src/lib/components/collection/SelectableSummonCard.svelte new file mode 100644 index 00000000..d902de26 --- /dev/null +++ b/src/lib/components/collection/SelectableSummonCard.svelte @@ -0,0 +1,107 @@ + + +
0} + role="button" + tabindex="0" + onclick={handleClick} + onkeydown={handleKeyDown} + aria-label="Select {name}, current quantity: {quantity}" +> + {name} +
{}}> + +
+
+ + diff --git a/src/lib/components/collection/SelectableSummonRow.svelte b/src/lib/components/collection/SelectableSummonRow.svelte new file mode 100644 index 00000000..76a25392 --- /dev/null +++ b/src/lib/components/collection/SelectableSummonRow.svelte @@ -0,0 +1,143 @@ + + +
0} + role="button" + tabindex="0" + onclick={handleClick} + onkeydown={handleKeyDown} + aria-label="Select {name}, current quantity: {quantity}" +> +
{}}> + +
+ +
+ {name} +
+ + {name} + +
+ +
+
+ + diff --git a/src/lib/components/collection/SelectableWeaponCard.svelte b/src/lib/components/collection/SelectableWeaponCard.svelte new file mode 100644 index 00000000..3f6f51c3 --- /dev/null +++ b/src/lib/components/collection/SelectableWeaponCard.svelte @@ -0,0 +1,107 @@ + + +
0} + role="button" + tabindex="0" + onclick={handleClick} + onkeydown={handleKeyDown} + aria-label="Select {name}, current quantity: {quantity}" +> + {name} +
{}}> + +
+
+ + diff --git a/src/lib/components/collection/SelectableWeaponRow.svelte b/src/lib/components/collection/SelectableWeaponRow.svelte new file mode 100644 index 00000000..c5b0dbf6 --- /dev/null +++ b/src/lib/components/collection/SelectableWeaponRow.svelte @@ -0,0 +1,143 @@ + + +
0} + role="button" + tabindex="0" + onclick={handleClick} + onkeydown={handleKeyDown} + aria-label="Select {name}, current quantity: {quantity}" +> +
{}}> + +
+ +
+ {name} +
+ + {name} + +
+ +
+
+ +