piclets / src /lib /components /Pages /Pictuary.svelte
Fraser's picture
BIG CHANGE
c703ea3
<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);
// Map roster positions for easy access
let rosterMap = $derived(() => {
const map = new Map<number, PicletInstance>();
rosterPiclets.forEach(piclet => {
if (piclet.rosterPosition !== undefined) {
map.set(piclet.rosterPosition, piclet);
}
});
return map;
});
// Get the most common type in the roster for background theming
let dominantType = $derived(() => {
if (rosterPiclets.length === 0) {
return PicletType.BEAST; // Default fallback
}
// Count type occurrences
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);
}
});
// Find the most common type
let maxCount = 0;
let mostCommonType = PicletType.BEAST;
typeCounts.forEach((count, type) => {
if (count > maxCount) {
maxCount = count;
mostCommonType = type;
}
});
return mostCommonType;
});
// Get background image path for the dominant type
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;
}
// Get trainer scan data for all piclets
const trainerScanData = await db.trainerScanProgress
.where('status').equals('completed')
.toArray();
// Create a map of piclet ID to trainer data
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
});
}
});
// Create export data for each piclet
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
};
})
};
// Create and download JSON file
const jsonString = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
// Create temporary download link
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">
<!-- Roster Section -->
<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>
<!-- Storage 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}
<!-- Discovered Section -->
{#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>