Phase F1 - Authentication: - OAuth callback handling with token management - Auth guards for protected routes - Account linking composable - Profile page updates Phase F2 - Deck Management: - Collection page with card filtering and display - Decks page with CRUD operations - Deck builder with drag-drop support - Collection and deck Pinia stores Phase F3 - Phaser Integration: - Game bridge composable for Vue-Phaser communication - Game page with Phaser canvas mounting - Socket.io event types for real-time gameplay - Game store with match state management - Phaser scene scaffolding and type definitions Also includes: - New UI components (ConfirmDialog, EmptyState, FilterBar, etc.) - Toast notification system - Game config composable for dynamic rule loading - Comprehensive test coverage for new features
341 lines
9.3 KiB
Vue
341 lines
9.3 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* Collection page component for viewing and filtering owned cards.
|
|
*
|
|
* Displays the user's card collection in a responsive grid with:
|
|
* - Filter bar with search, type, category, and rarity filters
|
|
* - Card count display showing filtered vs total cards
|
|
* - Responsive card grid (2-6 columns based on screen size)
|
|
* - Click-to-view card detail modal
|
|
* - Loading skeleton grid while fetching
|
|
* - Empty state when collection is empty or no matches
|
|
* - Error state with retry capability
|
|
*
|
|
* Filters persist in URL query params for bookmarking/sharing.
|
|
* Search input is debounced for performance.
|
|
*/
|
|
import { computed, onMounted, ref, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useDebounceFn } from '@vueuse/core'
|
|
|
|
import { useCollection } from '@/composables/useCollection'
|
|
import type { CardType, CardCategory, CardDefinition, CollectionCard } from '@/types'
|
|
|
|
import CardDisplay from '@/components/cards/CardDisplay.vue'
|
|
import CardDetailModal from '@/components/cards/CardDetailModal.vue'
|
|
import EmptyState from '@/components/ui/EmptyState.vue'
|
|
import FilterBar from '@/components/ui/FilterBar.vue'
|
|
import SkeletonCard from '@/components/ui/SkeletonCard.vue'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
const {
|
|
cards,
|
|
totalCards,
|
|
uniqueCards,
|
|
isLoading,
|
|
error,
|
|
fetchCollection,
|
|
clearError,
|
|
getQuantity,
|
|
} = useCollection()
|
|
|
|
// Filter state - initialized from URL query params
|
|
const searchQuery = ref(typeof route.query.q === 'string' ? route.query.q : '')
|
|
const selectedType = ref<CardType | null>(
|
|
typeof route.query.type === 'string' ? (route.query.type as CardType) : null
|
|
)
|
|
const selectedCategory = ref<CardCategory | null>(
|
|
typeof route.query.category === 'string' ? (route.query.category as CardCategory) : null
|
|
)
|
|
const selectedRarity = ref<CardDefinition['rarity'] | null>(
|
|
typeof route.query.rarity === 'string' ? (route.query.rarity as CardDefinition['rarity']) : null
|
|
)
|
|
|
|
// Internal search state for debouncing
|
|
const internalSearch = ref(searchQuery.value)
|
|
|
|
// Modal state
|
|
const selectedCard = ref<CardDefinition | null>(null)
|
|
const isModalOpen = ref(false)
|
|
|
|
/** Number of skeleton cards to show in loading grid */
|
|
const SKELETON_COUNT = 12
|
|
|
|
/**
|
|
* Debounced search update - waits 300ms after typing stops.
|
|
*/
|
|
const debouncedSearchUpdate = useDebounceFn((value: string) => {
|
|
searchQuery.value = value
|
|
}, 300)
|
|
|
|
/**
|
|
* Handle search input changes - immediately update internal state,
|
|
* debounce the actual filter update.
|
|
*/
|
|
function onSearchInput(value: string) {
|
|
internalSearch.value = value
|
|
debouncedSearchUpdate(value)
|
|
}
|
|
|
|
/**
|
|
* Filtered cards based on current filter state.
|
|
*/
|
|
const filteredCards = computed<CollectionCard[]>(() => {
|
|
let result = cards.value
|
|
|
|
// Filter by search query (case-insensitive partial match)
|
|
if (searchQuery.value) {
|
|
const query = searchQuery.value.toLowerCase()
|
|
result = result.filter(c =>
|
|
c.card.name.toLowerCase().includes(query)
|
|
)
|
|
}
|
|
|
|
// Filter by type
|
|
if (selectedType.value) {
|
|
result = result.filter(c => c.card.type === selectedType.value)
|
|
}
|
|
|
|
// Filter by category
|
|
if (selectedCategory.value) {
|
|
result = result.filter(c => c.card.category === selectedCategory.value)
|
|
}
|
|
|
|
// Filter by rarity
|
|
if (selectedRarity.value) {
|
|
result = result.filter(c => c.card.rarity === selectedRarity.value)
|
|
}
|
|
|
|
return result
|
|
})
|
|
|
|
/**
|
|
* Count display text showing filtered vs total.
|
|
*/
|
|
const cardCountText = computed(() => {
|
|
const filtered = filteredCards.value.length
|
|
const total = uniqueCards.value
|
|
|
|
if (filtered === total) {
|
|
return `${total} unique cards (${totalCards.value} total)`
|
|
}
|
|
return `Showing ${filtered} of ${total} unique cards`
|
|
})
|
|
|
|
/**
|
|
* Update URL query params when filters change.
|
|
*/
|
|
watch(
|
|
[searchQuery, selectedType, selectedCategory, selectedRarity],
|
|
([q, type, category, rarity]) => {
|
|
const query: Record<string, string> = {}
|
|
if (q) query.q = q
|
|
if (type) query.type = type
|
|
if (category) query.category = category
|
|
if (rarity) query.rarity = rarity
|
|
|
|
router.replace({ query })
|
|
},
|
|
{ deep: true }
|
|
)
|
|
|
|
/**
|
|
* Open the card detail modal.
|
|
*/
|
|
function openCardModal(cardId: string) {
|
|
const collectionCard = cards.value.find(c => c.card.id === cardId)
|
|
if (collectionCard) {
|
|
selectedCard.value = collectionCard.card
|
|
isModalOpen.value = true
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Close the card detail modal.
|
|
*/
|
|
function closeCardModal() {
|
|
isModalOpen.value = false
|
|
// Delay clearing card to allow exit animation
|
|
setTimeout(() => {
|
|
selectedCard.value = null
|
|
}, 200)
|
|
}
|
|
|
|
/**
|
|
* Retry fetching collection after error.
|
|
*/
|
|
async function retryFetch() {
|
|
clearError()
|
|
await fetchCollection()
|
|
}
|
|
|
|
// Fetch collection on mount
|
|
onMounted(async () => {
|
|
await fetchCollection()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="container mx-auto px-4 py-8">
|
|
<!-- Page header -->
|
|
<div class="mb-6">
|
|
<h1 class="text-2xl font-bold text-text mb-2">
|
|
Collection
|
|
</h1>
|
|
<p
|
|
v-if="!isLoading && !error"
|
|
class="text-text-muted"
|
|
>
|
|
{{ cardCountText }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Filter bar -->
|
|
<FilterBar
|
|
:search="internalSearch"
|
|
:type="selectedType"
|
|
:category="selectedCategory"
|
|
:rarity="selectedRarity"
|
|
search-placeholder="Search cards..."
|
|
@update:search="onSearchInput"
|
|
@update:type="selectedType = $event"
|
|
@update:category="selectedCategory = $event"
|
|
@update:rarity="selectedRarity = $event"
|
|
/>
|
|
|
|
<!-- Loading state: skeleton grid -->
|
|
<div
|
|
v-if="isLoading"
|
|
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 md:gap-4"
|
|
>
|
|
<SkeletonCard
|
|
v-for="i in SKELETON_COUNT"
|
|
:key="i"
|
|
size="md"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Error state -->
|
|
<div
|
|
v-else-if="error"
|
|
class="flex items-start gap-3 p-4 bg-error/10 border border-error/20 rounded-xl"
|
|
>
|
|
<svg
|
|
class="w-5 h-5 text-error flex-shrink-0 mt-0.5"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
/>
|
|
</svg>
|
|
<div class="flex-1">
|
|
<p class="text-error font-medium">
|
|
Failed to load collection
|
|
</p>
|
|
<p class="text-error/80 text-sm mt-1">
|
|
{{ error }}
|
|
</p>
|
|
</div>
|
|
<button
|
|
class="px-4 py-2 rounded-lg font-medium bg-error text-white hover:bg-error/90 active:scale-95 transition-all duration-150"
|
|
@click="retryFetch"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Empty state: no cards in collection -->
|
|
<EmptyState
|
|
v-else-if="cards.length === 0"
|
|
title="Your collection is empty"
|
|
description="Win matches to earn booster packs and grow your collection."
|
|
>
|
|
<template #icon>
|
|
<svg
|
|
class="w-full h-full"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="1.5"
|
|
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
|
/>
|
|
</svg>
|
|
</template>
|
|
<template #action>
|
|
<router-link
|
|
to="/play"
|
|
class="px-4 py-2 rounded-lg font-medium bg-primary text-white hover:bg-primary-dark active:scale-95 transition-all duration-150"
|
|
>
|
|
Find a Match
|
|
</router-link>
|
|
</template>
|
|
</EmptyState>
|
|
|
|
<!-- Empty state: no cards match filters -->
|
|
<EmptyState
|
|
v-else-if="filteredCards.length === 0"
|
|
title="No cards found"
|
|
description="Try adjusting your filters or search query."
|
|
>
|
|
<template #icon>
|
|
<svg
|
|
class="w-full h-full"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="1.5"
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
/>
|
|
</svg>
|
|
</template>
|
|
</EmptyState>
|
|
|
|
<!-- Card grid -->
|
|
<Transition
|
|
enter-active-class="duration-200 ease-out"
|
|
enter-from-class="opacity-0"
|
|
enter-to-class="opacity-100"
|
|
>
|
|
<div
|
|
v-if="!isLoading && !error && filteredCards.length > 0"
|
|
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 md:gap-4"
|
|
>
|
|
<CardDisplay
|
|
v-for="collectionCard in filteredCards"
|
|
:key="collectionCard.cardDefinitionId"
|
|
:card="collectionCard.card"
|
|
:quantity="collectionCard.quantity"
|
|
size="md"
|
|
@click="openCardModal"
|
|
/>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- Card detail modal -->
|
|
<CardDetailModal
|
|
:card="selectedCard"
|
|
:is-open="isModalOpen"
|
|
:owned-quantity="selectedCard ? getQuantity(selectedCard.id) : 0"
|
|
@close="closeCardModal"
|
|
/>
|
|
</div>
|
|
</template>
|