|
<script lang="ts"> |
|
import { onMount } from 'svelte'; |
|
import { getCaughtPiclets, getRosterPiclets, moveToRoster, swapRosterPositions, moveToStorage, getUncaughtPiclets } from '$lib/db/piclets'; |
|
import type { PicletInstance } from '$lib/db/schema'; |
|
import { db } from '$lib/db'; |
|
import PicletCard from '../Piclets/PicletCard.svelte'; |
|
import EmptySlotCard from '../Piclets/EmptySlotCard.svelte'; |
|
import DraggablePicletCard from '../Piclets/DraggablePicletCard.svelte'; |
|
import RosterSlot from '../Piclets/RosterSlot.svelte'; |
|
import PicletDetail from '../Piclets/PicletDetail.svelte'; |
|
import AddToRosterDialog from '../Piclets/AddToRosterDialog.svelte'; |
|
import ViewAll from './ViewAll.svelte'; |
|
import { PicletType } from '$lib/types/picletTypes'; |
|
|
|
let rosterPiclets: PicletInstance[] = $state([]); |
|
let storagePiclets: PicletInstance[] = $state([]); |
|
let discoveredPiclets: PicletInstance[] = $state([]); |
|
let isLoading = $state(true); |
|
let currentlyDragging: PicletInstance | null = $state(null); |
|
let selectedPiclet: PicletInstance | null = $state(null); |
|
let addToRosterPosition: number | null = $state(null); |
|
let viewAllMode: 'storage' | 'discovered' | null = $state(null); |
|
|
|
|
|
let rosterMap = $derived(() => { |
|
const map = new Map<number, PicletInstance>(); |
|
rosterPiclets.forEach(piclet => { |
|
if (piclet.rosterPosition !== undefined) { |
|
map.set(piclet.rosterPosition, piclet); |
|
} |
|
}); |
|
return map; |
|
}); |
|
|
|
|
|
let dominantType = $derived(() => { |
|
if (rosterPiclets.length === 0) { |
|
return PicletType.BEAST; // Default fallback |
|
} |
|
|
|
|
|
const typeCounts = new Map<PicletType, number>(); |
|
rosterPiclets.forEach(piclet => { |
|
if (piclet.primaryType) { |
|
const count = typeCounts.get(piclet.primaryType) || 0; |
|
typeCounts.set(piclet.primaryType, count + 1); |
|
} |
|
}); |
|
|
|
|
|
let maxCount = 0; |
|
let mostCommonType = PicletType.BEAST; |
|
typeCounts.forEach((count, type) => { |
|
if (count > maxCount) { |
|
maxCount = count; |
|
mostCommonType = type; |
|
} |
|
}); |
|
|
|
return mostCommonType; |
|
}); |
|
|
|
|
|
let backgroundImagePath = $derived(`/classes/${dominantType}.png`); |
|
|
|
async function loadPiclets() { |
|
try { |
|
// Run type migration first time to fix any invalid types |
|
|
|
const allInstances = await getCaughtPiclets(); |
|
|
|
// Filter based on rosterPosition instead of isInRoster |
|
rosterPiclets = allInstances.filter(p => |
|
p.rosterPosition !== undefined && |
|
p.rosterPosition !== null && |
|
p.rosterPosition >= 0 && |
|
p.rosterPosition <= 5 |
|
); |
|
storagePiclets = allInstances.filter(p => |
|
p.rosterPosition === undefined || |
|
p.rosterPosition === null || |
|
p.rosterPosition < 0 || |
|
p.rosterPosition > 5 |
|
); |
|
|
|
// Get all uncaught piclets (discovered but not caught) |
|
discoveredPiclets = await getUncaughtPiclets(); |
|
} catch (err) { |
|
console.error('Failed to load piclets:', err); |
|
} finally { |
|
isLoading = false; |
|
} |
|
} |
|
|
|
async function handleBulkDownload() { |
|
try { |
|
console.log('Starting bulk download of all Piclets...'); |
|
|
|
// Get all piclets (roster + storage + discovered) |
|
const allPiclets = [...rosterPiclets, ...storagePiclets, ...discoveredPiclets]; |
|
|
|
if (allPiclets.length === 0) { |
|
console.log('No Piclets to export'); |
|
return; |
|
} |
|
|
|
|
|
const trainerScanData = await db.trainerScanProgress |
|
.where('status').equals('completed') |
|
.toArray(); |
|
|
|
|
|
const trainerDataMap = new Map(); |
|
trainerScanData.forEach(scan => { |
|
if (scan.picletInstanceId) { |
|
trainerDataMap.set(scan.picletInstanceId, { |
|
trainerName: scan.trainerName, |
|
imagePath: scan.imagePath, |
|
imageIndex: scan.imageIndex, |
|
completedAt: scan.completedAt, |
|
remoteUrl: scan.remoteUrl |
|
}); |
|
} |
|
}); |
|
|
|
|
|
const exportData = { |
|
exportInfo: { |
|
totalPiclets: allPiclets.length, |
|
exportedAt: new Date().toISOString(), |
|
exportSource: "Pictuary Game - Bulk Export", |
|
version: "1.0" |
|
}, |
|
piclets: allPiclets.map(piclet => { |
|
const trainerInfo = trainerDataMap.get(piclet.id!); |
|
|
|
return { |
|
// Core piclet data (complete dataset) |
|
id: piclet.id, |
|
typeId: piclet.typeId, |
|
nickname: piclet.nickname, |
|
primaryType: piclet.primaryType, |
|
|
|
// Current Stats |
|
currentHp: piclet.currentHp, |
|
maxHp: piclet.maxHp, |
|
level: piclet.level, |
|
xp: piclet.xp, |
|
attack: piclet.attack, |
|
defense: piclet.defense, |
|
fieldAttack: piclet.fieldAttack, |
|
fieldDefense: piclet.fieldDefense, |
|
speed: piclet.speed, |
|
|
|
// Base Stats |
|
baseHp: piclet.baseHp, |
|
baseAttack: piclet.baseAttack, |
|
baseDefense: piclet.baseDefense, |
|
baseFieldAttack: piclet.baseFieldAttack, |
|
baseFieldDefense: piclet.baseFieldDefense, |
|
baseSpeed: piclet.baseSpeed, |
|
|
|
// Battle data |
|
moves: piclet.moves, |
|
nature: piclet.nature, |
|
specialAbility: piclet.specialAbility, |
|
specialAbilityUnlockLevel: piclet.specialAbilityUnlockLevel, |
|
|
|
// Roster info |
|
isInRoster: piclet.isInRoster, |
|
rosterPosition: piclet.rosterPosition, |
|
|
|
// Metadata |
|
caught: piclet.caught, |
|
caughtAt: piclet.caughtAt, |
|
bst: piclet.bst, |
|
tier: piclet.tier, |
|
role: piclet.role, |
|
variance: piclet.variance, |
|
|
|
// Original generation data |
|
imageUrl: piclet.imageUrl, |
|
imageData: piclet.imageData, |
|
imageCaption: piclet.imageCaption, |
|
concept: piclet.concept, |
|
description: piclet.description, |
|
imagePrompt: piclet.imagePrompt, |
|
|
|
// Trainer scanner data (if available) |
|
trainerInfo: trainerInfo || null |
|
}; |
|
}) |
|
}; |
|
|
|
|
|
const jsonString = JSON.stringify(exportData, null, 2); |
|
const blob = new Blob([jsonString], { type: 'application/json' }); |
|
const url = URL.createObjectURL(blob); |
|
|
|
|
|
const link = document.createElement('a'); |
|
link.href = url; |
|
link.download = `pictuary-collection-${Date.now()}.json`; |
|
|
|
// Trigger download |
|
document.body.appendChild(link); |
|
link.click(); |
|
document.body.removeChild(link); |
|
|
|
// Clean up the blob URL |
|
URL.revokeObjectURL(url); |
|
|
|
console.log(`Successfully exported ${allPiclets.length} Piclets with trainer data`); |
|
} catch (error) { |
|
console.error('Failed to export Piclet collection:', error); |
|
} |
|
} |
|
|
|
onMount(() => { |
|
loadPiclets(); |
|
}); |
|
|
|
function handleRosterClick(position: number) { |
|
const piclet = rosterMap().get(position); |
|
if (piclet) { |
|
selectedPiclet = piclet; |
|
} else { |
|
addToRosterPosition = position; |
|
} |
|
} |
|
|
|
function handleStorageClick(piclet: PicletInstance) { |
|
selectedPiclet = piclet; |
|
} |
|
|
|
|
|
function handleDragStart(instance: PicletInstance) { |
|
currentlyDragging = instance; |
|
} |
|
|
|
function handleDragEnd() { |
|
currentlyDragging = null; |
|
} |
|
|
|
async function handleRosterDrop(position: number, dragData: any) { |
|
if (!dragData.instanceId) return; |
|
|
|
try { |
|
const draggedPiclet = [...rosterPiclets, ...storagePiclets].find(p => p.id === dragData.instanceId); |
|
if (!draggedPiclet) return; |
|
|
|
const targetPiclet = rosterMap().get(position); |
|
|
|
if (dragData.fromRoster && targetPiclet) { |
|
// Swap two roster positions |
|
await swapRosterPositions( |
|
dragData.instanceId, |
|
dragData.fromPosition, |
|
targetPiclet.id!, |
|
position |
|
); |
|
} else { |
|
// Move to roster (possibly replacing existing) |
|
await moveToRoster(dragData.instanceId, position); |
|
} |
|
|
|
await loadPiclets(); |
|
} catch (err) { |
|
console.error('Failed to handle drop:', err); |
|
} |
|
} |
|
</script> |
|
|
|
{#if viewAllMode === 'storage'} |
|
<ViewAll |
|
title="Storage" |
|
type="storage" |
|
items={storagePiclets} |
|
onBack={() => viewAllMode = null} |
|
onItemsChanged={loadPiclets} |
|
onDragStart={handleDragStart} |
|
onDragEnd={handleDragEnd} |
|
/> |
|
{:else if viewAllMode === 'discovered'} |
|
<ViewAll |
|
title="Discovered" |
|
type="discovered" |
|
items={discoveredPiclets} |
|
onBack={() => viewAllMode = null} |
|
/> |
|
{:else} |
|
<div class="pictuary-page" style="--bg-image: url('{backgroundImagePath}')"> |
|
{#if isLoading} |
|
<div class="loading-state"> |
|
<div class="spinner"></div> |
|
<p>Loading collection...</p> |
|
</div> |
|
{:else if rosterPiclets.length === 0 && storagePiclets.length === 0 && discoveredPiclets.length === 0} |
|
<div class="empty-state"> |
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
|
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"></path> |
|
<circle cx="12" cy="13" r="4"></circle> |
|
</svg> |
|
<h3>No Piclets Yet</h3> |
|
<p>Take photos to discover new Piclets!</p> |
|
</div> |
|
{:else} |
|
<div class="content"> |
|
|
|
<section class="roster-section"> |
|
<h2>Roster</h2> |
|
<div class="roster-grid"> |
|
{#each Array(6) as _, position} |
|
<RosterSlot |
|
{position} |
|
piclet={rosterMap().get(position)} |
|
size={110} |
|
onDrop={handleRosterDrop} |
|
onPicletClick={(piclet) => handleRosterClick(position)} |
|
onEmptyClick={handleRosterClick} |
|
onDragStart={handleDragStart} |
|
onDragEnd={handleDragEnd} |
|
/> |
|
{/each} |
|
</div> |
|
</section> |
|
|
|
|
|
{#if storagePiclets.length > 0} |
|
<section class="storage-section"> |
|
<div class="section-header"> |
|
<h2>Storage ({storagePiclets.length})</h2> |
|
{#if storagePiclets.length > 10} |
|
<button class="view-all-btn">View All</button> |
|
{/if} |
|
</div> |
|
<div class="horizontal-scroll"> |
|
{#each storagePiclets.slice(0, 10) as piclet} |
|
<DraggablePicletCard |
|
instance={piclet} |
|
size={110} |
|
onClick={() => handleStorageClick(piclet)} |
|
onDragStart={handleDragStart} |
|
onDragEnd={handleDragEnd} |
|
/> |
|
{/each} |
|
</div> |
|
</section> |
|
{/if} |
|
|
|
|
|
{#if discoveredPiclets.length > 0} |
|
<section class="discovered-section"> |
|
<div class="section-header"> |
|
<h2>Discovered ({discoveredPiclets.length})</h2> |
|
<button |
|
class="download-button" |
|
onclick={handleBulkDownload} |
|
title="Download all Piclets with trainer data" |
|
> |
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path> |
|
<polyline points="7,10 12,15 17,10"></polyline> |
|
<line x1="12" y1="15" x2="12" y2="3"></line> |
|
</svg> |
|
Download |
|
</button> |
|
</div> |
|
<div class="discovered-grid"> |
|
{#each discoveredPiclets as piclet} |
|
<PicletCard |
|
piclet={piclet} |
|
size={100} |
|
onClick={() => selectedPiclet = piclet} |
|
/> |
|
{/each} |
|
</div> |
|
</section> |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
{#if selectedPiclet} |
|
<PicletDetail |
|
instance={selectedPiclet} |
|
onClose={() => selectedPiclet = null} |
|
onDeleted={loadPiclets} |
|
/> |
|
{/if} |
|
|
|
{#if addToRosterPosition !== null} |
|
<AddToRosterDialog |
|
position={addToRosterPosition} |
|
availablePiclets={storagePiclets} |
|
onClose={() => addToRosterPosition = null} |
|
onAdded={loadPiclets} |
|
/> |
|
{/if} |
|
</div> |
|
{/if} |
|
|
|
<style> |
|
.pictuary-page { |
|
height: 100%; |
|
overflow-y: auto; |
|
-webkit-overflow-scrolling: touch; |
|
background: white; |
|
position: relative; |
|
} |
|
|
|
.pictuary-page::before { |
|
content: ''; |
|
position: fixed; |
|
top: 0; |
|
left: 0; |
|
right: 0; |
|
bottom: 0; |
|
background-image: var(--bg-image); |
|
background-size: 300px 300px; |
|
background-repeat: no-repeat; |
|
background-position: center bottom; |
|
opacity: 0.03; |
|
pointer-events: none; |
|
z-index: 0; |
|
} |
|
|
|
|
|
.loading-state, |
|
.empty-state { |
|
display: flex; |
|
flex-direction: column; |
|
align-items: center; |
|
justify-content: center; |
|
height: calc(100% - 100px); |
|
padding: 2rem; |
|
text-align: center; |
|
position: relative; |
|
z-index: 1; |
|
} |
|
|
|
.spinner { |
|
width: 40px; |
|
height: 40px; |
|
border: 3px solid #f3f3f3; |
|
border-top: 3px solid #007bff; |
|
border-radius: 50%; |
|
animation: spin 1s linear infinite; |
|
margin-bottom: 1rem; |
|
} |
|
|
|
.empty-state svg { |
|
color: #8e8e93; |
|
margin-bottom: 1rem; |
|
} |
|
|
|
.empty-state h3 { |
|
margin: 0 0 0.5rem; |
|
font-size: 1.25rem; |
|
font-weight: 600; |
|
color: #333; |
|
} |
|
|
|
.empty-state p { |
|
margin: 0; |
|
color: #666; |
|
} |
|
|
|
.content { |
|
padding: 0 1rem 100px; |
|
position: relative; |
|
z-index: 1; |
|
} |
|
|
|
section { |
|
margin-bottom: 2rem; |
|
} |
|
|
|
section h2 { |
|
font-size: 1.5rem; |
|
font-weight: bold; |
|
color: #8e8e93; |
|
margin: 0 0 0.75rem; |
|
} |
|
|
|
.section-header { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
margin-bottom: 0.75rem; |
|
} |
|
|
|
.section-header h2 { |
|
margin: 0; |
|
} |
|
|
|
.view-all-btn { |
|
background: none; |
|
border: none; |
|
color: #007bff; |
|
font-size: 1rem; |
|
cursor: pointer; |
|
padding: 0; |
|
} |
|
|
|
.roster-grid { |
|
display: grid; |
|
grid-template-columns: repeat(3, 1fr); |
|
grid-template-rows: repeat(2, 1fr); |
|
gap: 12px; |
|
} |
|
|
|
.horizontal-scroll { |
|
display: flex; |
|
gap: 8px; |
|
overflow-x: auto; |
|
-webkit-overflow-scrolling: touch; |
|
padding-bottom: 8px; |
|
} |
|
|
|
.horizontal-scroll::-webkit-scrollbar { |
|
height: 4px; |
|
} |
|
|
|
.horizontal-scroll::-webkit-scrollbar-track { |
|
background: #f1f1f1; |
|
border-radius: 2px; |
|
} |
|
|
|
.horizontal-scroll::-webkit-scrollbar-thumb { |
|
background: #888; |
|
border-radius: 2px; |
|
} |
|
|
|
.discovered-grid { |
|
display: grid; |
|
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); |
|
gap: 12px; |
|
} |
|
|
|
.download-button { |
|
display: flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
background: linear-gradient(135deg, #007bff, #0056b3); |
|
border: none; |
|
color: white; |
|
padding: 0.5rem 1rem; |
|
border-radius: 8px; |
|
font-size: 0.9rem; |
|
font-weight: 500; |
|
cursor: pointer; |
|
transition: all 0.2s ease; |
|
box-shadow: 0 2px 4px rgba(0, 123, 255, 0.2); |
|
} |
|
|
|
.download-button:hover { |
|
background: linear-gradient(135deg, #0056b3, #004085); |
|
transform: translateY(-1px); |
|
box-shadow: 0 4px 8px rgba(0, 123, 255, 0.3); |
|
} |
|
|
|
.download-button:active { |
|
transform: translateY(0); |
|
box-shadow: 0 2px 4px rgba(0, 123, 255, 0.2); |
|
} |
|
|
|
.download-button svg { |
|
width: 16px; |
|
height: 16px; |
|
stroke-width: 2.5; |
|
} |
|
|
|
@keyframes spin { |
|
to { transform: rotate(360deg); } |
|
} |
|
</style> |