piclets / src /lib /components /Piclets /PicletDetail.svelte
Fraser's picture
download piclet json
0d548ea
raw
history blame
21.8 kB
<script lang="ts">
import { onMount } from 'svelte';
import type { PicletInstance } from '$lib/db/schema';
import { deletePicletInstance } from '$lib/db/piclets';
import { uiStore } from '$lib/stores/ui';
import { TYPE_DATA } from '$lib/types/picletTypes';
import AbilityDisplay from './AbilityDisplay.svelte';
import MoveDisplay from './MoveDisplay.svelte';
import { picletInstanceToBattleDefinition } from '$lib/utils/battleConversion';
import { recalculatePicletStats, getXpProgress, getXpTowardsNextLevel } from '$lib/services/levelingService';
import { isSpecialAbilityUnlocked } from '$lib/services/unlockLevels';
interface Props {
instance: PicletInstance;
onClose: () => void;
onDeleted?: () => void;
}
let { instance, onClose, onDeleted }: Props = $props();
let selectedTab = $state<'about' | 'abilities'>('about');
// Ensure stats are up-to-date with current level and nature
const updatedInstance = $derived(recalculatePicletStats(instance));
// Convert to battle definition to get enhanced ability data
const battleDefinition = $derived(picletInstanceToBattleDefinition(updatedInstance));
// XP and level calculations
const xpProgress = $derived(getXpProgress(updatedInstance.xp, updatedInstance.level, updatedInstance.tier));
const xpTowardsNext = $derived(getXpTowardsNextLevel(updatedInstance.xp, updatedInstance.level, updatedInstance.tier));
// Type-based styling
const typeData = $derived(TYPE_DATA[instance.primaryType]);
const typeColor = $derived(typeData.color);
const typeLogoPath = $derived(`/classes/${instance.primaryType}.png`);
onMount(() => {
uiStore.openDetailPage();
return () => {
uiStore.closeDetailPage();
};
});
async function handleDelete() {
if (!instance.id) return;
const confirmed = confirm(`Are you sure you want to release ${instance.nickname || instance.typeId}? This action cannot be undone.`);
if (!confirmed) return;
try {
await deletePicletInstance(instance.id);
onDeleted?.();
onClose();
} catch (err) {
console.error('Failed to delete piclet:', err);
}
}
function getStatPercentage(value: number, max: number = 255): number {
return Math.round((value / max) * 100);
}
function getHpColor(current: number, max: number): string {
const ratio = current / max;
if (ratio < 0.2) return '#ff3b30';
if (ratio < 0.5) return '#ff9500';
return '#34c759';
}
function handleDownloadJSON() {
try {
// Create comprehensive export data
const exportData = {
exportVersion: "1.0",
exportedAt: new Date().toISOString(),
piclet: {
name: updatedInstance.nickname || updatedInstance.typeId,
typeId: updatedInstance.typeId,
imageData: updatedInstance.imageData,
stats: {
// Core identification
id: updatedInstance.id,
typeId: updatedInstance.typeId,
nickname: updatedInstance.nickname,
// Type information
primaryType: updatedInstance.primaryType,
secondaryType: updatedInstance.secondaryType,
// Current stats
currentHp: updatedInstance.currentHp,
maxHp: updatedInstance.maxHp,
level: updatedInstance.level,
xp: updatedInstance.xp,
attack: updatedInstance.attack,
defense: updatedInstance.defense,
fieldAttack: updatedInstance.fieldAttack,
fieldDefense: updatedInstance.fieldDefense,
speed: updatedInstance.speed,
// Base stats
baseHp: updatedInstance.baseHp,
baseAttack: updatedInstance.baseAttack,
baseDefense: updatedInstance.baseDefense,
baseFieldAttack: updatedInstance.baseFieldAttack,
baseFieldDefense: updatedInstance.baseFieldDefense,
baseSpeed: updatedInstance.baseSpeed,
// Additional properties
nature: updatedInstance.nature,
tier: updatedInstance.tier,
bst: updatedInstance.bst,
caught: updatedInstance.caught,
caughtAt: updatedInstance.caughtAt,
isInRoster: updatedInstance.isInRoster,
rosterPosition: updatedInstance.rosterPosition
},
battleData: {
moves: updatedInstance.moves,
specialAbility: updatedInstance.specialAbility,
specialAbilityUnlockLevel: updatedInstance.specialAbilityUnlockLevel,
types: [updatedInstance.primaryType, updatedInstance.secondaryType].filter(Boolean)
},
generationData: {
imageUrl: updatedInstance.imageUrl,
imageCaption: updatedInstance.imageCaption || null,
concept: updatedInstance.concept || null,
imagePrompt: updatedInstance.imagePrompt || null
},
metadata: {
level: updatedInstance.level,
tier: updatedInstance.tier,
createdAt: updatedInstance.caughtAt || new Date().toISOString(),
exportSource: "Pictuary Game"
}
}
};
// 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 = `piclet-${(updatedInstance.nickname || updatedInstance.typeId).replace(/[^a-zA-Z0-9-_]/g, '_')}-${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('Piclet JSON exported successfully');
} catch (error) {
console.error('Failed to export Piclet JSON:', error);
alert('Failed to export Piclet data. Please try again.');
}
}
</script>
<div class="detail-page">
<div class="content-scroll">
<!-- Header Card -->
<div class="header-card">
<div class="card-background" style="--type-color: {typeColor}; --type-logo: url('{typeLogoPath}')">
<!-- Faded Logo Background -->
<div class="logo-background"></div>
<!-- Card Header -->
<div class="card-header">
<button
class="back-btn-card"
onclick={onClose}
aria-label="Go back"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5m0 0l7 7m-7-7l7-7"></path>
</svg>
</button>
<h1 class="card-title">{updatedInstance.nickname || updatedInstance.typeId}</h1>
<button
class="download-button"
onclick={handleDownloadJSON}
aria-label="Download JSON"
>
<svg width="24" height="24" 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>
</button>
</div>
<!-- Large Image Section -->
<div class="large-image-section">
<div class="large-image-container">
<img
src={updatedInstance.imageData || updatedInstance.imageUrl}
alt={updatedInstance.nickname || updatedInstance.typeId}
class="large-piclet-image"
/>
</div>
</div>
</div>
</div>
<!-- Level and Status Progress -->
<div class="level-xp-section">
<div class="level-info">
<span class="level-label">Level {updatedInstance.level}</span>
{#if updatedInstance.level < 100}
<span class="xp-label">{xpTowardsNext.current}/{xpTowardsNext.needed} to next level</span>
{:else}
<span class="xp-label">MAX LEVEL</span>
{/if}
</div>
<!-- HP Section -->
<div class="stat-row">
<div class="stat-label">HP</div>
<div class="hp-bar">
<div
class="hp-fill"
style="width: {(updatedInstance.currentHp / updatedInstance.maxHp) * 100}%; background-color: {getHpColor(updatedInstance.currentHp, updatedInstance.maxHp)}"
></div>
</div>
<div class="stat-value">{updatedInstance.currentHp}/{updatedInstance.maxHp}</div>
</div>
<!-- XP Section -->
{#if updatedInstance.level < 100}
<div class="stat-row">
<div class="stat-label">XP</div>
<div class="xp-bar">
<div
class="xp-fill"
style="width: {xpTowardsNext.percentage}%"
></div>
</div>
<div class="stat-value">{xpTowardsNext.current}/{xpTowardsNext.needed}</div>
</div>
{:else}
<div class="stat-row">
<div class="stat-label">XP</div>
<div class="max-level-indicator">MAX LEVEL</div>
</div>
{/if}
</div>
<!-- Tab Bar -->
<div class="tab-bar" style="--type-color: {typeColor}">
<button
class="tab-button"
class:active={selectedTab === 'about'}
onclick={() => selectedTab = 'about'}
>
About
</button>
<button
class="tab-button"
class:active={selectedTab === 'abilities'}
onclick={() => selectedTab = 'abilities'}
>
Abilities
</button>
</div>
<!-- Tab Content -->
<div class="tab-content">
{#if selectedTab === 'about'}
<div class="content-card">
<p class="description">{instance.description}</p>
<div class="divider"></div>
<h3 class="section-heading">Stats</h3>
<div class="stats-list">
<div class="stat-row">
<span>Attack</span>
<span class="stat-value">{updatedInstance.attack}</span>
</div>
<div class="stat-row">
<span>Defense</span>
<span class="stat-value">{updatedInstance.defense}</span>
</div>
<div class="stat-row">
<span>Field Attack</span>
<span class="stat-value">{updatedInstance.fieldAttack}</span>
</div>
<div class="stat-row">
<span>Field Defense</span>
<span class="stat-value">{updatedInstance.fieldDefense}</span>
</div>
<div class="stat-row">
<span>Speed</span>
<span class="stat-value">{updatedInstance.speed}</span>
</div>
</div>
<div class="divider"></div>
<div class="stat-summary">
<div class="summary-item">
<span class="summary-label">BST</span>
<span class="summary-value">{updatedInstance.bst}</span>
</div>
<div class="summary-item">
<span class="summary-label">Tier</span>
<span class="summary-value">{updatedInstance.tier.toUpperCase()}</span>
</div>
</div>
</div>
{:else if selectedTab === 'abilities'}
<div class="content-card">
<h3 class="section-heading">Special Ability</h3>
{#if isSpecialAbilityUnlocked(updatedInstance.specialAbilityUnlockLevel, updatedInstance.level)}
<AbilityDisplay
ability={updatedInstance.specialAbility}
expanded={true}
/>
{:else}
<div class="locked-ability">
<div class="lock-header">
<span class="lock-icon">🔒</span>
<span class="lock-text">Unlocks at Level {updatedInstance.specialAbilityUnlockLevel}</span>
</div>
<div class="locked-content">
<h4>{updatedInstance.specialAbility.name}</h4>
<p>This special ability will be unlocked when {updatedInstance.nickname} reaches level {updatedInstance.specialAbilityUnlockLevel}.</p>
</div>
</div>
{/if}
<div class="divider"></div>
<h3 class="section-heading">Moves</h3>
<div class="moves-list">
{#each updatedInstance.moves as move, index}
{#if move.unlockLevel <= updatedInstance.level}
<MoveDisplay
{move}
expanded={true}
/>
{:else}
<div class="locked-move">
<div class="lock-header">
<span class="lock-icon">🔒</span>
<span class="lock-text">Unlocks at Level {move.unlockLevel}</span>
</div>
<div class="locked-content">
<h4>{move.name}</h4>
<p>This move will be unlocked when {updatedInstance.nickname} reaches level {move.unlockLevel}.</p>
</div>
</div>
{/if}
{/each}
</div>
</div>
{/if}
</div>
<!-- Actions -->
<div class="bottom-actions">
<button class="btn btn-danger" onclick={handleDelete}>Release Piclet</button>
</div>
</div>
</div>
<style>
.detail-page {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #f2f2f7;
z-index: 1000;
display: flex;
flex-direction: column;
}
/* Content Scroll */
.content-scroll {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
/* Header Card */
.header-card {
margin-bottom: 16px;
overflow: hidden;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
position: relative;
}
.card-background {
background: linear-gradient(135deg, var(--type-color, #4CAF50) 0%, color-mix(in srgb, var(--type-color, #4CAF50) 80%, white) 100%);
padding: 24px;
padding-top: calc(24px + env(safe-area-inset-top, 0));
position: relative;
overflow: hidden;
}
.logo-background {
position: absolute;
bottom: 5px;
right: 5px;
width: 120px;
height: 120px;
background-image: var(--type-logo);
background-size: contain;
background-repeat: no-repeat;
background-position: center;
opacity: 0.15;
pointer-events: none;
z-index: 1;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
position: relative;
z-index: 2;
}
.card-title {
margin: 0;
font-size: 24px;
font-weight: bold;
color: white;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
flex: 1;
text-align: center;
}
.back-btn-card {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
cursor: pointer;
padding: 8px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.back-btn-card:hover {
background: rgba(255, 255, 255, 0.3);
}
.download-button {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
cursor: pointer;
padding: 8px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.download-button:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.05);
}
.download-button:active {
transform: scale(0.95);
}
.download-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.download-button svg {
width: 20px;
height: 20px;
}
.large-image-section {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 2;
}
.large-image-container {
display: flex;
align-items: center;
justify-content: center;
width: 360px;
height: 360px;
}
.large-piclet-image {
width: 360px;
height: 360px;
object-fit: contain;
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.2));
}
/* Tab Bar */
.tab-bar {
margin: 0 16px 16px;
height: 36px;
background: #e5e5ea;
border-radius: 12px;
display: flex;
padding: 2px;
}
.tab-button {
flex: 1;
background: none;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
color: #8e8e93;
cursor: pointer;
transition: all 0.2s;
}
.tab-button.active {
background: var(--type-color, #4CAF50);
color: white;
box-shadow: 0 2px 4px color-mix(in srgb, var(--type-color, #4CAF50) 30%, transparent);
}
/* Tab Content */
.tab-content {
margin: 0 16px 16px;
}
.content-card {
background: white;
border-radius: 12px;
padding: 16px;
border: 0.5px solid #c6c6c8;
}
.description {
margin: 0 0 16px;
font-size: 16px;
line-height: 1.4;
color: #000;
}
/* Stats Tab */
.stats-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.stat-row {
display: flex;
justify-content: space-between;
font-size: 15px;
}
.stat-value {
font-weight: 600;
}
.divider {
height: 1px;
background: #e5e5ea;
margin: 16px 0;
}
.stat-summary {
display: flex;
justify-content: space-around;
}
.summary-item {
text-align: center;
}
.summary-label {
display: block;
font-size: 14px;
color: #8e8e93;
margin-bottom: 4px;
}
.summary-value {
font-size: 16px;
font-weight: bold;
color: #000;
}
/* Bottom Actions */
.bottom-actions {
padding: 16px;
background: white;
border-top: 0.5px solid #c6c6c8;
text-align: center;
}
.section-heading {
font-size: 18px;
font-weight: 600;
color: #495057;
margin: 0 0 12px 0;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s;
}
.btn:active {
transform: scale(0.95);
}
.btn-danger {
background: #ff3b30;
color: white;
width: 100%;
}
/* Enhanced ability and move display styles */
.moves-list {
display: flex;
flex-direction: column;
gap: 4px;
}
/* Locked content styles */
.locked-ability,
.locked-move {
background: #f8f9fa;
border: 1px dashed #dee2e6;
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
opacity: 0.7;
}
.lock-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.lock-icon {
font-size: 16px;
}
.lock-text {
font-size: 12px;
font-weight: 600;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.locked-content h4 {
margin: 0 0 4px 0;
font-size: 16px;
font-weight: 600;
color: #495057;
}
.locked-content p {
margin: 0;
font-size: 14px;
color: #6c757d;
font-style: italic;
}
/* Level and Status Section */
.level-xp-section {
background: white;
margin: 0 16px 16px;
border-radius: 12px;
padding: 16px;
border: 0.5px solid #c6c6c8;
}
.level-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.level-label {
font-size: 18px;
font-weight: 700;
color: #1a1a1a;
}
.xp-label {
font-size: 14px;
font-weight: 500;
color: #8e8e93;
}
/* Stat Rows (HP and XP bars) */
.stat-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.stat-row:last-child {
margin-bottom: 0;
}
.stat-label {
font-size: 12px;
font-weight: 600;
color: #666;
text-transform: uppercase;
letter-spacing: 0.3px;
width: 24px;
flex-shrink: 0;
}
/* HP Bar */
.hp-bar {
height: 8px;
background: #e0e0e0;
border-radius: 4px;
overflow: hidden;
flex: 1;
min-width: 80px;
}
.hp-fill {
height: 100%;
transition: width 0.5s ease, background-color 0.3s ease;
}
/* XP Bar */
.xp-bar {
height: 8px;
background: #e0e0e0;
border-radius: 4px;
overflow: hidden;
flex: 1;
min-width: 80px;
}
.xp-fill {
height: 100%;
background: #2196f3;
transition: width 1.2s ease-out;
}
.stat-value {
font-size: 12px;
font-weight: 600;
color: #666;
min-width: 60px;
text-align: right;
flex-shrink: 0;
}
.max-level-indicator {
font-size: 12px;
font-weight: 600;
color: #ff6f00;
background: rgba(255, 111, 0, 0.1);
border: 1px solid #ff6f00;
border-radius: 4px;
padding: 2px 8px;
flex: 1;
text-align: center;
}
@media (min-width: 768px) {
.detail-page {
position: relative;
max-width: 600px;
margin: 0 auto;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
}
}
</style>