|
<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'); |
|
|
|
|
|
const updatedInstance = $derived(recalculatePicletStats(instance)); |
|
|
|
|
|
const battleDefinition = $derived(picletInstanceToBattleDefinition(updatedInstance)); |
|
|
|
|
|
const xpProgress = $derived(getXpProgress(updatedInstance.xp, updatedInstance.level, updatedInstance.tier)); |
|
const xpTowardsNext = $derived(getXpTowardsNextLevel(updatedInstance.xp, updatedInstance.level, updatedInstance.tier)); |
|
|
|
|
|
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 handleShare() { |
|
console.log("placeholder"); |
|
} |
|
</script> |
|
|
|
<div class="detail-page"> |
|
<div class="content-scroll"> |
|
|
|
<div class="header-card"> |
|
<div class="card-background" style="--type-color: {typeColor}; --type-logo: url('{typeLogoPath}')"> |
|
|
|
<div class="logo-background"></div> |
|
|
|
|
|
<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="share-button" |
|
onclick={handleShare} |
|
aria-label="Share Piclet" |
|
> |
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
|
<circle cx="18" cy="5" r="3"></circle> |
|
<circle cx="6" cy="12" r="3"></circle> |
|
<circle cx="18" cy="19" r="3"></circle> |
|
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"></line> |
|
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"></line> |
|
</svg> |
|
</button> |
|
</div> |
|
|
|
|
|
<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> |
|
|
|
|
|
<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 { |
|
flex: 1; |
|
overflow-y: auto; |
|
-webkit-overflow-scrolling: touch; |
|
} |
|
|
|
|
|
.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); |
|
} |
|
|
|
.share-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; |
|
} |
|
|
|
.share-button:hover { |
|
background: rgba(255, 255, 255, 0.3); |
|
transform: scale(1.05); |
|
} |
|
|
|
.share-button:active { |
|
transform: scale(0.95); |
|
} |
|
|
|
.share-button:disabled { |
|
opacity: 0.5; |
|
cursor: not-allowed; |
|
} |
|
|
|
.share-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 { |
|
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 { |
|
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-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 { |
|
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%; |
|
} |
|
|
|
|
|
.moves-list { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 4px; |
|
} |
|
|
|
|
|
.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-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-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 { |
|
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 { |
|
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> |