mantimon-tcg/frontend/src/pages/CollectionPage.vue
Cal Corum 059536a42b Implement frontend phases F1-F3: auth, deck management, Phaser integration
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
2026-01-31 15:43:56 -06:00

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>