|
|
<script lang="ts"> |
|
|
import { onMount } from 'svelte'; |
|
|
import { fade, fly } from 'svelte/transition'; |
|
|
import type { ActivityEntry, PicletInstance, LeaderboardEntry } from '$lib/db/schema'; |
|
|
import { db } from '$lib/db'; |
|
|
import PullToRefresh from '../UI/PullToRefresh.svelte'; |
|
|
import { CanonicalService } from '$lib/services/canonicalService'; |
|
|
|
|
|
let activities: ActivityEntry[] = $state([]); |
|
|
let leaderboard: LeaderboardEntry[] = $state([]); |
|
|
let isLoading = $state(true); |
|
|
let isRefreshing = $state(false); |
|
|
let activeView: 'recent' | 'leaderboard' = $state('recent'); |
|
|
|
|
|
|
|
|
const mockActivities: ActivityEntry[] = [ |
|
|
{ |
|
|
id: 1, |
|
|
type: 'discovery', |
|
|
title: 'New Discovery!', |
|
|
description: 'Fraser discovered the first "Pillow" Piclet', |
|
|
picletTypeId: 'pillow_001', |
|
|
discovererName: 'Fraser', |
|
|
rarity: 'legendary', |
|
|
createdAt: new Date(Date.now() - 1000 * 60 * 5) |
|
|
}, |
|
|
{ |
|
|
id: 2, |
|
|
type: 'variation', |
|
|
title: 'Variation Found', |
|
|
description: 'Alex found a "Velvet Pillow" variation', |
|
|
picletTypeId: 'pillow_002', |
|
|
discovererName: 'Alex', |
|
|
rarity: 'rare', |
|
|
createdAt: new Date(Date.now() - 1000 * 60 * 30) |
|
|
}, |
|
|
{ |
|
|
id: 3, |
|
|
type: 'milestone', |
|
|
title: 'Milestone Reached!', |
|
|
description: 'Sam collected 100 unique Piclets!', |
|
|
picletTypeId: 'trophy', |
|
|
discovererName: 'Sam', |
|
|
rarity: 'epic', |
|
|
createdAt: new Date(Date.now() - 1000 * 60 * 60) |
|
|
} |
|
|
]; |
|
|
|
|
|
const mockLeaderboard: LeaderboardEntry[] = [ |
|
|
{ username: 'Fraser', totalDiscoveries: 156, uniqueDiscoveries: 45, rarityScore: 2340, rank: 1 }, |
|
|
{ username: 'Alex', totalDiscoveries: 134, uniqueDiscoveries: 38, rarityScore: 1890, rank: 2 }, |
|
|
{ username: 'Sam', totalDiscoveries: 98, uniqueDiscoveries: 31, rarityScore: 1560, rank: 3 }, |
|
|
{ username: 'Jordan', totalDiscoveries: 87, uniqueDiscoveries: 28, rarityScore: 1230, rank: 4 }, |
|
|
{ username: 'Casey', totalDiscoveries: 76, uniqueDiscoveries: 22, rarityScore: 980, rank: 5 } |
|
|
]; |
|
|
|
|
|
onMount(async () => { |
|
|
await loadActivityData(); |
|
|
}); |
|
|
|
|
|
async function loadActivityData() { |
|
|
isLoading = true; |
|
|
try { |
|
|
|
|
|
const playerPiclets = await db.picletInstances.toArray(); |
|
|
|
|
|
if (playerPiclets.length === 0) { |
|
|
|
|
|
activities = []; |
|
|
leaderboard = []; |
|
|
} else { |
|
|
|
|
|
|
|
|
activities = mockActivities; |
|
|
leaderboard = mockLeaderboard; |
|
|
|
|
|
|
|
|
const recentLocal = playerPiclets |
|
|
.filter(p => p.collectedAt) |
|
|
.sort((a, b) => (b.collectedAt?.getTime() || 0) - (a.collectedAt?.getTime() || 0)) |
|
|
.slice(0, 5) |
|
|
.map((p, index) => ({ |
|
|
id: 1000 + index, |
|
|
type: 'discovery' as const, |
|
|
title: p.isCanonical ? 'New Discovery!' : 'Variation Found', |
|
|
description: `You ${p.isCanonical ? 'discovered' : 'found a variation of'} "${p.objectName || p.typeId}"`, |
|
|
picletTypeId: p.typeId, |
|
|
discovererName: 'You', |
|
|
rarity: CanonicalService.calculateRarity(p.scanCount) as any, |
|
|
createdAt: p.collectedAt || new Date() |
|
|
})); |
|
|
|
|
|
|
|
|
activities = [...recentLocal, ...activities].slice(0, 10); |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Error loading activity data:', error); |
|
|
} |
|
|
isLoading = false; |
|
|
} |
|
|
|
|
|
async function handleRefresh() { |
|
|
isRefreshing = true; |
|
|
try { |
|
|
await loadActivityData(); |
|
|
} catch (error) { |
|
|
console.error('Error refreshing activity:', error); |
|
|
} |
|
|
isRefreshing = false; |
|
|
} |
|
|
|
|
|
function formatTime(date: Date): string { |
|
|
const now = Date.now(); |
|
|
const diff = now - date.getTime(); |
|
|
|
|
|
const minutes = Math.floor(diff / (1000 * 60)); |
|
|
const hours = Math.floor(diff / (1000 * 60 * 60)); |
|
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24)); |
|
|
|
|
|
if (minutes < 1) return 'just now'; |
|
|
if (minutes < 60) return `${minutes}m ago`; |
|
|
if (hours < 24) return `${hours}h ago`; |
|
|
if (days < 7) return `${days}d ago`; |
|
|
|
|
|
return date.toLocaleDateString(); |
|
|
} |
|
|
|
|
|
function getRarityColor(rarity: string): string { |
|
|
switch (rarity) { |
|
|
case 'legendary': return '#FFD700'; |
|
|
case 'epic': return '#9B59B6'; |
|
|
case 'rare': return '#3498DB'; |
|
|
case 'uncommon': return '#2ECC71'; |
|
|
default: return '#95A5A6'; |
|
|
} |
|
|
} |
|
|
|
|
|
function getActivityIcon(type: string): string { |
|
|
switch (type) { |
|
|
case 'discovery': return '✨'; |
|
|
case 'variation': return '🔄'; |
|
|
case 'milestone': return '🏆'; |
|
|
default: return '📍'; |
|
|
} |
|
|
} |
|
|
</script> |
|
|
|
|
|
<div class="activity-page"> |
|
|
<div class="view-selector"> |
|
|
<button |
|
|
class="view-tab" |
|
|
class:active={activeView === 'recent'} |
|
|
onclick={() => activeView = 'recent'} |
|
|
> |
|
|
Recent Activity |
|
|
</button> |
|
|
<button |
|
|
class="view-tab" |
|
|
class:active={activeView === 'leaderboard'} |
|
|
onclick={() => activeView = 'leaderboard'} |
|
|
> |
|
|
Leaderboard |
|
|
</button> |
|
|
</div> |
|
|
|
|
|
<PullToRefresh onRefresh={handleRefresh}> |
|
|
{#if isLoading} |
|
|
<div class="loading"> |
|
|
<div class="spinner"></div> |
|
|
<p>Loading activity...</p> |
|
|
</div> |
|
|
{:else if activeView === 'recent'} |
|
|
{#if activities.length === 0} |
|
|
<div class="empty-state"> |
|
|
<div class="empty-icon">🔍</div> |
|
|
<h2>No Discoveries Yet</h2> |
|
|
<p>Start scanning objects to discover Piclets!</p> |
|
|
<p class="hint">Every object in the world has a unique Piclet waiting to be discovered.</p> |
|
|
</div> |
|
|
{:else} |
|
|
<div class="activity-list"> |
|
|
{#each activities as activity, index (activity.id)} |
|
|
<div |
|
|
class="activity-card" |
|
|
in:fly={{ y: 20, delay: index * 50 }} |
|
|
> |
|
|
<div class="activity-icon"> |
|
|
<span class="type-icon">{getActivityIcon(activity.type)}</span> |
|
|
</div> |
|
|
|
|
|
<div class="activity-info"> |
|
|
<h3>{activity.title}</h3> |
|
|
<p>{activity.description}</p> |
|
|
<div class="activity-meta"> |
|
|
<span |
|
|
class="rarity-badge" |
|
|
style="background-color: {getRarityColor(activity.rarity)}" |
|
|
> |
|
|
{activity.rarity} |
|
|
</span> |
|
|
<span class="time">{formatTime(activity.createdAt)}</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
{/each} |
|
|
</div> |
|
|
{/if} |
|
|
{:else} |
|
|
{#if leaderboard.length === 0} |
|
|
<div class="empty-state"> |
|
|
<div class="empty-icon">🏆</div> |
|
|
<h2>No Rankings Yet</h2> |
|
|
<p>Be the first to discover Piclets!</p> |
|
|
</div> |
|
|
{:else} |
|
|
<div class="leaderboard-list"> |
|
|
{#each leaderboard as entry, index (entry.username)} |
|
|
<div |
|
|
class="leaderboard-card" |
|
|
in:fly={{ y: 20, delay: index * 50 }} |
|
|
> |
|
|
<div class="rank-badge" class:gold={entry.rank === 1} class:silver={entry.rank === 2} class:bronze={entry.rank === 3}> |
|
|
#{entry.rank} |
|
|
</div> |
|
|
|
|
|
<div class="player-info"> |
|
|
<h3>{entry.username}</h3> |
|
|
<div class="stats"> |
|
|
<span>🔍 {entry.uniqueDiscoveries} unique</span> |
|
|
<span>📊 {entry.totalDiscoveries} total</span> |
|
|
<span>⭐ {entry.rarityScore} points</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
{/each} |
|
|
</div> |
|
|
{/if} |
|
|
{/if} |
|
|
</PullToRefresh> |
|
|
</div> |
|
|
|
|
|
<style> |
|
|
.activity-page { |
|
|
height: 100%; |
|
|
overflow: hidden; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
} |
|
|
|
|
|
.view-selector { |
|
|
display: flex; |
|
|
gap: 0.5rem; |
|
|
padding: 1rem; |
|
|
background: #fff; |
|
|
border-bottom: 1px solid #e0e0e0; |
|
|
} |
|
|
|
|
|
.view-tab { |
|
|
flex: 1; |
|
|
padding: 0.75rem; |
|
|
border: 1px solid #e0e0e0; |
|
|
border-radius: 8px; |
|
|
background: #f5f5f5; |
|
|
color: #666; |
|
|
font-weight: 500; |
|
|
cursor: pointer; |
|
|
transition: all 0.2s; |
|
|
} |
|
|
|
|
|
.view-tab.active { |
|
|
background: #007bff; |
|
|
color: white; |
|
|
border-color: #007bff; |
|
|
} |
|
|
|
|
|
.loading, .empty-state { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
height: 60vh; |
|
|
text-align: center; |
|
|
padding: 1rem; |
|
|
} |
|
|
|
|
|
.spinner { |
|
|
width: 48px; |
|
|
height: 48px; |
|
|
border: 4px solid #f0f0f0; |
|
|
border-top-color: #007bff; |
|
|
border-radius: 50%; |
|
|
animation: spin 1s linear infinite; |
|
|
margin-bottom: 1rem; |
|
|
} |
|
|
|
|
|
@keyframes spin { |
|
|
to { transform: rotate(360deg); } |
|
|
} |
|
|
|
|
|
.empty-icon { |
|
|
font-size: 4rem; |
|
|
margin-bottom: 1rem; |
|
|
} |
|
|
|
|
|
.empty-state h2 { |
|
|
margin: 0 0 0.5rem; |
|
|
font-size: 1.25rem; |
|
|
color: #333; |
|
|
} |
|
|
|
|
|
.empty-state p { |
|
|
color: #666; |
|
|
font-size: 0.9rem; |
|
|
margin: 0.25rem 0; |
|
|
} |
|
|
|
|
|
.hint { |
|
|
font-style: italic; |
|
|
color: #999; |
|
|
margin-top: 1rem; |
|
|
} |
|
|
|
|
|
.activity-list, .leaderboard-list { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
gap: 1rem; |
|
|
padding: 1rem; |
|
|
padding-bottom: 5rem; |
|
|
} |
|
|
|
|
|
.activity-card, .leaderboard-card { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 1rem; |
|
|
background: #fff; |
|
|
border: 1px solid #e0e0e0; |
|
|
border-radius: 12px; |
|
|
padding: 1rem; |
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05); |
|
|
transition: all 0.2s ease; |
|
|
} |
|
|
|
|
|
.activity-card:hover, .leaderboard-card:hover { |
|
|
transform: translateY(-2px); |
|
|
box-shadow: 0 4px 8px rgba(0,0,0,0.1); |
|
|
} |
|
|
|
|
|
.activity-icon { |
|
|
width: 48px; |
|
|
height: 48px; |
|
|
flex-shrink: 0; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
background: #f0f7ff; |
|
|
border-radius: 50%; |
|
|
} |
|
|
|
|
|
.type-icon { |
|
|
font-size: 1.5rem; |
|
|
} |
|
|
|
|
|
.activity-info, .player-info { |
|
|
flex: 1; |
|
|
} |
|
|
|
|
|
.activity-info h3, .player-info h3 { |
|
|
margin: 0 0 0.25rem; |
|
|
font-size: 1rem; |
|
|
font-weight: 600; |
|
|
color: #1a1a1a; |
|
|
} |
|
|
|
|
|
.activity-info p { |
|
|
margin: 0 0 0.5rem; |
|
|
font-size: 0.875rem; |
|
|
color: #666; |
|
|
} |
|
|
|
|
|
.activity-meta { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 0.5rem; |
|
|
} |
|
|
|
|
|
.rarity-badge { |
|
|
padding: 0.125rem 0.5rem; |
|
|
border-radius: 12px; |
|
|
font-size: 0.75rem; |
|
|
font-weight: 600; |
|
|
color: white; |
|
|
text-transform: uppercase; |
|
|
} |
|
|
|
|
|
.time { |
|
|
font-size: 0.75rem; |
|
|
color: #999; |
|
|
} |
|
|
|
|
|
.rank-badge { |
|
|
width: 48px; |
|
|
height: 48px; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
border-radius: 50%; |
|
|
background: #f0f0f0; |
|
|
font-weight: bold; |
|
|
font-size: 1.25rem; |
|
|
flex-shrink: 0; |
|
|
} |
|
|
|
|
|
.rank-badge.gold { |
|
|
background: linear-gradient(135deg, #FFD700, #FFA500); |
|
|
color: white; |
|
|
} |
|
|
|
|
|
.rank-badge.silver { |
|
|
background: linear-gradient(135deg, #C0C0C0, #808080); |
|
|
color: white; |
|
|
} |
|
|
|
|
|
.rank-badge.bronze { |
|
|
background: linear-gradient(135deg, #CD7F32, #8B4513); |
|
|
color: white; |
|
|
} |
|
|
|
|
|
.stats { |
|
|
display: flex; |
|
|
gap: 1rem; |
|
|
margin-top: 0.5rem; |
|
|
font-size: 0.875rem; |
|
|
color: #666; |
|
|
} |
|
|
|
|
|
.stats span { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 0.25rem; |
|
|
} |
|
|
</style> |