Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5df946e0b | ||
|
|
90d61c48e2 | ||
|
|
87079dcb88 | ||
|
|
c664f573b8 |
No files matched your search
+2
-1
@@ -75,5 +75,6 @@
|
||||
{ "name": "Avatar: The Way of Water", "rating": 10, "url": "https://www.imdb.com/title/tt1630029/" },
|
||||
{ "name": "Avatar: Fire and Ash", "rating": 8, "url": "https://www.imdb.com/title/tt1757678/" },
|
||||
{ "name": "Blade Runner 2049", "rating": 10, "url": "https://www.imdb.com/title/tt1856101/" }
|
||||
]
|
||||
],
|
||||
"books": []
|
||||
}
|
||||
@@ -33,6 +33,22 @@
|
||||
{ "label": "btop", "icon": "ph:chart-line-duotone", "open": { "app": "btop" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "apps",
|
||||
"side": "right",
|
||||
"icons": [
|
||||
{ "label": "tcg", "icon": "mdi:pokeball", "open": { "app": "tcg" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "games",
|
||||
"side": "right",
|
||||
"icons": [
|
||||
{ "label": "snake", "icon": "mdi:snake", "open": { "app": "snake" } },
|
||||
{ "label": "breakout", "icon": "mdi:wall", "open": { "app": "breakout" } },
|
||||
{ "label": "robot", "icon": "ph:robot-duotone", "open": { "app": "pet" } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "socials",
|
||||
"icons": [
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
[
|
||||
{
|
||||
"id": "cynthias-garchomp",
|
||||
"name": "Cynthia's Garchomp",
|
||||
"cards": [
|
||||
{ "count": 4, "name": "Cynthia's Gible", "set": "DRI", "category": "pokemon" },
|
||||
{ "count": 4, "name": "Cynthia's Gabite", "set": "DRI", "category": "pokemon" },
|
||||
{ "count": 3, "name": "Cynthia's Garchomp ex", "set": "DRI", "category": "pokemon" },
|
||||
{ "count": 2, "name": "Cynthia's Roselia", "set": "DRI", "category": "pokemon" },
|
||||
{ "count": 2, "name": "Cynthia's Roserade", "set": "DRI", "category": "pokemon" },
|
||||
{ "count": 1, "name": "Munkidori", "set": "TWM", "category": "pokemon" },
|
||||
{ "count": 1, "name": "Cynthia's Spiritomb", "set": "DRI", "category": "pokemon" },
|
||||
{ "count": 4, "name": "Arven", "category": "trainer" },
|
||||
{ "count": 4, "name": "Iono", "category": "trainer" },
|
||||
{ "count": 4, "name": "Buddy-Buddy Poffin", "category": "trainer" },
|
||||
{ "count": 4, "name": "Cynthia's Power Weight", "category": "trainer" },
|
||||
{ "count": 3, "name": "Boss's Orders", "category": "trainer" },
|
||||
{ "count": 2, "name": "Technical Machine: Evolution", "category": "trainer" },
|
||||
{ "count": 2, "name": "Team Rocket's Watchtower", "category": "trainer" },
|
||||
{ "count": 2, "name": "Counter Catcher", "category": "trainer" },
|
||||
{ "count": 2, "name": "Earthen Vessel", "category": "trainer" },
|
||||
{ "count": 1, "name": "Night Stretcher", "category": "trainer" },
|
||||
{ "count": 1, "name": "Crispin", "category": "trainer" },
|
||||
{ "count": 1, "name": "Super Rod", "category": "trainer" },
|
||||
{ "count": 1, "name": "Team Rocket's Petrel", "category": "trainer" },
|
||||
{ "count": 1, "name": "Artazon", "category": "trainer" },
|
||||
{ "count": 1, "name": "Unfair Stamp", "category": "trainer" },
|
||||
{ "count": 1, "name": "Professor's Research", "category": "trainer" },
|
||||
{ "count": 1, "name": "Rare Candy", "category": "trainer" },
|
||||
{ "count": 6, "name": "Fighting Energy", "category": "energy" },
|
||||
{ "count": 1, "name": "Luminous Energy", "category": "energy" },
|
||||
{ "count": 1, "name": "Darkness Energy", "category": "energy" }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
export type AniListMediaType = 'ANIME' | 'MANGA';
|
||||
|
||||
export interface AniListEntry {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
cover: string;
|
||||
score: number;
|
||||
progress: number;
|
||||
total: number | null;
|
||||
}
|
||||
|
||||
export interface AniListStat {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface AniListStatusCount {
|
||||
status: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AniListProfile {
|
||||
mediaType: AniListMediaType;
|
||||
name: string;
|
||||
profileUrl: string;
|
||||
listUrl: string;
|
||||
stats: AniListStat[];
|
||||
statusCounts: AniListStatusCount[];
|
||||
current: AniListEntry[];
|
||||
currentLabel: string;
|
||||
progressUnit: string;
|
||||
totalUnit: string;
|
||||
topRated: AniListEntry[];
|
||||
planning: AniListEntry[];
|
||||
completedCount: number;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
export interface AnimeEntry {
|
||||
id: number;
|
||||
title: string;
|
||||
url: string;
|
||||
cover: string;
|
||||
score: number;
|
||||
progress: number;
|
||||
episodes: number | null;
|
||||
}
|
||||
|
||||
export interface AnimeStats {
|
||||
count: number;
|
||||
episodesWatched: number;
|
||||
daysWatched: number;
|
||||
}
|
||||
|
||||
export interface AnimeStatusCount {
|
||||
status: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AnimeProfile {
|
||||
name: string;
|
||||
profileUrl: string;
|
||||
stats: AnimeStats;
|
||||
statusCounts: AnimeStatusCount[];
|
||||
watching: AnimeEntry[];
|
||||
topRated: AnimeEntry[];
|
||||
completedCount: number;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Book } from '#lib/content';
|
||||
|
||||
const coverBaseUrl = 'https://covers.openlibrary.org/b/isbn';
|
||||
const bookPageBaseUrl = 'https://openlibrary.org/isbn';
|
||||
|
||||
function cleanIsbn(isbn: string): string {
|
||||
return isbn.replace(/[^0-9X]/gi, '');
|
||||
}
|
||||
|
||||
export function bookCover(book: Book): string | null {
|
||||
return book.isbn ? `${coverBaseUrl}/${cleanIsbn(book.isbn)}-L.jpg?default=false` : null;
|
||||
}
|
||||
|
||||
export function bookLink(book: Book): string | null {
|
||||
if (book.url) return book.url;
|
||||
return book.isbn ? `${bookPageBaseUrl}/${cleanIsbn(book.isbn)}` : null;
|
||||
}
|
||||
|
||||
export function sortBooks(books: Book[]): Book[] {
|
||||
return [...books].sort((first, second) => {
|
||||
const readingOrder = Number(second.status === 'reading') - Number(first.status === 'reading');
|
||||
return readingOrder || (second.rating ?? 0) - (first.rating ?? 0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import type { AniListEntry } from '#lib/anilist/types';
|
||||
|
||||
let {
|
||||
title,
|
||||
icon,
|
||||
entries,
|
||||
badge,
|
||||
totalUnit = ''
|
||||
}: { title: string; icon: string; entries: AniListEntry[]; badge: 'score' | 'total'; totalUnit?: string } = $props();
|
||||
|
||||
const collapsedCount = 12;
|
||||
|
||||
let showAll = $state(false);
|
||||
|
||||
const shownEntries = $derived(showAll ? entries : entries.slice(0, collapsedCount));
|
||||
</script>
|
||||
|
||||
<h3 class="subheading"><Icon {icon} width="16" /> {title} <span class="muted">{entries.length}</span></h3>
|
||||
<div class="cover-grid">
|
||||
{#each shownEntries as entry (entry.id)}
|
||||
<a class="cover-card" href={entry.url} target="_blank" rel="noopener noreferrer" title={entry.title}>
|
||||
<div class="cover">
|
||||
<img src={entry.cover} alt={entry.title} loading="lazy" />
|
||||
{#if badge === 'score'}
|
||||
<span class="badge score"><Icon icon="mdi:star" width="13" />{entry.score}</span>
|
||||
{:else if entry.total}
|
||||
<span class="badge">{entry.total} {totalUnit}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="cover-title">{entry.title}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if entries.length > collapsedCount}
|
||||
<button class="toggle" onclick={() => (showAll = !showAll)}>
|
||||
<Icon icon={showAll ? 'mdi:chevron-up' : 'mdi:chevron-down'} width="16" />
|
||||
{showAll ? 'Show less' : `Show all ${entries.length}`}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.subheading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 1.25rem 0 0.6rem;
|
||||
color: var(--foreground);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.cover-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 0.9rem 0.75rem;
|
||||
}
|
||||
|
||||
.cover-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cover {
|
||||
position: relative;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.cover-card:hover .cover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.cover-card:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
right: 0.35rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
padding: 0 0.35rem;
|
||||
background: var(--background);
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.badge.score {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.cover-title {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.3;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.cover-card:hover .cover-title {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
background: none;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 30%, transparent);
|
||||
color: var(--foreground);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.toggle:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@container (max-width: 520px) {
|
||||
.cover-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cover,
|
||||
.cover img {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+10
-15
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { AnimeProfile } from '#lib/anime/types';
|
||||
import type { AniListProfile } from '#lib/anilist/types';
|
||||
|
||||
let { anime }: { anime: AnimeProfile } = $props();
|
||||
let { profile }: { profile: AniListProfile } = $props();
|
||||
|
||||
const hostName = 'anilist';
|
||||
|
||||
@@ -13,23 +13,18 @@
|
||||
DROPPED: 'red'
|
||||
};
|
||||
|
||||
const shownStatuses = $derived(anime.statusCounts.filter((item) => item.count > 0));
|
||||
const shownStatuses = $derived(profile.statusCounts.filter((item) => item.count > 0));
|
||||
const listTotal = $derived(shownStatuses.reduce((total, item) => total + item.count, 0));
|
||||
const rows = $derived([
|
||||
['Anime', `${anime.stats.count}`],
|
||||
['Episodes', `${anime.stats.episodesWatched}`],
|
||||
['Watch time', `${anime.stats.daysWatched} days`]
|
||||
]);
|
||||
</script>
|
||||
|
||||
<div class="anime-fetch">
|
||||
<a class="heading" href={anime.profileUrl} target="_blank" rel="noopener noreferrer">
|
||||
<span class="user">{anime.name.toLowerCase()}</span><span class="at">@</span><span class="host">{hostName}</span>
|
||||
<div class="anilist-fetch">
|
||||
<a class="heading" href={profile.listUrl} target="_blank" rel="noopener noreferrer">
|
||||
<span class="user">{profile.name.toLowerCase()}</span><span class="at">@</span><span class="host">{hostName}</span>
|
||||
</a>
|
||||
<div class="separator">{'-'.repeat(anime.name.length + hostName.length + 1)}</div>
|
||||
<div class="separator">{'-'.repeat(profile.name.length + hostName.length + 1)}</div>
|
||||
|
||||
{#each rows as [label, value] (label)}
|
||||
<div class="row"><span class="label">{label}</span>: {value}</div>
|
||||
{#each profile.stats as stat (stat.label)}
|
||||
<div class="row"><span class="label">{stat.label}</span>: {stat.value}</div>
|
||||
{/each}
|
||||
|
||||
{#each shownStatuses as item (item.status)}
|
||||
@@ -53,7 +48,7 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.anime-fetch {
|
||||
.anilist-fetch {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import type { AniListProfile } from '#lib/anilist/types';
|
||||
import LinkButton from '#lib/components/ui/LinkButton.svelte';
|
||||
import Panel from '#lib/components/ui/Panel.svelte';
|
||||
import AniListProfileHeader from './AniListProfileHeader.svelte';
|
||||
import AniListCoverGrid from './AniListCoverGrid.svelte';
|
||||
|
||||
let { profile }: { profile: AniListProfile } = $props();
|
||||
|
||||
const currentIcon = $derived(profile.mediaType === 'MANGA' ? 'mdi:book-open-variant' : 'mdi:play-circle-outline');
|
||||
|
||||
function progressPercent(progress: number, total: number | null): number {
|
||||
return total ? Math.min(100, (progress / total) * 100) : 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overview" class:single={!profile.current.length}>
|
||||
<Panel title="anilist" icon="simple-icons:anilist">
|
||||
<AniListProfileHeader {profile} />
|
||||
</Panel>
|
||||
|
||||
{#if profile.current.length}
|
||||
<Panel title={profile.currentLabel} icon={currentIcon}>
|
||||
<div class="current">
|
||||
{#each profile.current as entry (entry.id)}
|
||||
<a class="current-row" href={entry.url} target="_blank" rel="noopener noreferrer">
|
||||
<img src={entry.cover} alt={entry.title} loading="lazy" />
|
||||
<div class="current-text">
|
||||
<span class="title">{entry.title}</span>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" style:width="{progressPercent(entry.progress, entry.total)}%"></div>
|
||||
</div>
|
||||
<span class="muted progress-label">{profile.progressUnit} {entry.progress} / {entry.total ?? '?'}</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</Panel>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if profile.topRated.length}
|
||||
<AniListCoverGrid title="Top rated" icon="mdi:star" entries={profile.topRated} badge="score" />
|
||||
{/if}
|
||||
|
||||
{#if profile.planning.length}
|
||||
<AniListCoverGrid
|
||||
title="Planning"
|
||||
icon="mdi:calendar-clock"
|
||||
entries={profile.planning}
|
||||
badge="total"
|
||||
totalUnit={profile.totalUnit}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="actions">
|
||||
<LinkButton href={profile.listUrl} icon="simple-icons:anilist">Full list on AniList ({profile.completedCount} completed)</LinkButton>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overview {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.overview.single {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.current {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.current-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.current-row:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.current-row img {
|
||||
flex-shrink: 0;
|
||||
width: 52px;
|
||||
height: 74px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.current-text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 4px;
|
||||
background: color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
@container (max-width: 800px) {
|
||||
.overview {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,238 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import type { AnimeProfile } from '#lib/anime/types';
|
||||
import LinkButton from '#lib/components/ui/LinkButton.svelte';
|
||||
import Panel from '#lib/components/ui/Panel.svelte';
|
||||
import AnimeProfileHeader from './AnimeProfileHeader.svelte';
|
||||
|
||||
let { anime }: { anime: AnimeProfile } = $props();
|
||||
|
||||
const collapsedCount = 12;
|
||||
|
||||
let showAll = $state(false);
|
||||
|
||||
const shownTopRated = $derived(showAll ? anime.topRated : anime.topRated.slice(0, collapsedCount));
|
||||
|
||||
function progressPercent(progress: number, episodes: number | null): number {
|
||||
return episodes ? Math.min(100, (progress / episodes) * 100) : 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overview" class:single={!anime.watching.length}>
|
||||
<Panel title="anilist" icon="simple-icons:anilist">
|
||||
<AnimeProfileHeader {anime} />
|
||||
</Panel>
|
||||
|
||||
{#if anime.watching.length}
|
||||
<Panel title="Currently watching" icon="mdi:play-circle-outline">
|
||||
<div class="watching">
|
||||
{#each anime.watching as entry (entry.id)}
|
||||
<a class="watching-row" href={entry.url} target="_blank" rel="noopener noreferrer">
|
||||
<img src={entry.cover} alt={entry.title} loading="lazy" />
|
||||
<div class="watching-text">
|
||||
<span class="title">{entry.title}</span>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" style:width="{progressPercent(entry.progress, entry.episodes)}%"></div>
|
||||
</div>
|
||||
<span class="muted episode">Episode {entry.progress} / {entry.episodes ?? '?'}</span>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</Panel>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<h3 class="subheading"><Icon icon="mdi:star" width="16" /> Top rated</h3>
|
||||
<div class="cover-grid">
|
||||
{#each shownTopRated as entry (entry.id)}
|
||||
<a class="cover-card" href={entry.url} target="_blank" rel="noopener noreferrer" title={entry.title}>
|
||||
<div class="cover">
|
||||
<img src={entry.cover} alt={entry.title} loading="lazy" />
|
||||
<span class="score"><Icon icon="mdi:star" width="13" />{entry.score}</span>
|
||||
</div>
|
||||
<span class="cover-title">{entry.title}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
{#if anime.topRated.length > collapsedCount}
|
||||
<button class="toggle" onclick={() => (showAll = !showAll)}>
|
||||
<Icon icon={showAll ? 'mdi:chevron-up' : 'mdi:chevron-down'} width="16" />
|
||||
{showAll ? 'Show less' : `Show all ${anime.topRated.length}`}
|
||||
</button>
|
||||
{/if}
|
||||
<LinkButton href={anime.profileUrl} icon="simple-icons:anilist">Full list on AniList ({anime.completedCount} completed)</LinkButton>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.subheading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 1.25rem 0 0.6rem;
|
||||
color: var(--foreground);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.overview {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.overview.single {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.watching {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.watching-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.watching-row:hover,
|
||||
.cover-card:hover .cover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.watching-row img {
|
||||
flex-shrink: 0;
|
||||
width: 52px;
|
||||
height: 74px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.watching-text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
overflow: hidden;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 4px;
|
||||
background: color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.episode {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.cover-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 0.9rem 0.75rem;
|
||||
}
|
||||
|
||||
.cover-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cover {
|
||||
position: relative;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.cover-card:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.score {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
right: 0.35rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
padding: 0 0.35rem;
|
||||
background: var(--background);
|
||||
color: var(--yellow);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.cover-title {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.3;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.cover-card:hover .cover-title {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
background: none;
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 30%, transparent);
|
||||
color: var(--foreground);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.toggle:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@container (max-width: 800px) {
|
||||
.overview {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 520px) {
|
||||
.cover-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import type { Book } from '#lib/content';
|
||||
import { bookCover, bookLink } from '#lib/books/books';
|
||||
|
||||
let { book }: { book: Book } = $props();
|
||||
|
||||
let coverFailed = $state(false);
|
||||
|
||||
const cover = $derived(bookCover(book));
|
||||
const link = $derived(bookLink(book));
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
this={link ? 'a' : 'div'}
|
||||
class="book-card"
|
||||
href={link}
|
||||
target={link ? '_blank' : undefined}
|
||||
rel={link ? 'noopener noreferrer' : undefined}
|
||||
title="{book.title} by {book.author}"
|
||||
>
|
||||
<div class="cover">
|
||||
{#if cover && !coverFailed}
|
||||
<img src={cover} alt="" loading="lazy" data-no-viewer onerror={() => (coverFailed = true)} />
|
||||
{:else}
|
||||
<span class="placeholder">
|
||||
<Icon icon="mdi:book-open-page-variant-outline" width="36" />
|
||||
<span class="placeholder-title">{book.title}</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if book.status === 'reading'}
|
||||
<span class="badge reading"><Icon icon="mdi:bookmark" width="13" />reading</span>
|
||||
{:else if book.rating}
|
||||
<span class="badge"><Icon icon="mdi:star" width="13" />{book.rating}/10</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="text">
|
||||
<span class="title">{book.title}</span>
|
||||
<span class="author">{book.author}</span>
|
||||
</div>
|
||||
</svelte:element>
|
||||
|
||||
<style>
|
||||
.book-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cover {
|
||||
position: relative;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
background: var(--dark-background);
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
a.book-card:hover .cover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
a.book-card:hover .cover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
height: 100%;
|
||||
padding: 0.75rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.placeholder-title {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 0.4rem;
|
||||
right: 0.4rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0 0.4rem;
|
||||
background: var(--background);
|
||||
color: var(--yellow);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.badge.reading {
|
||||
background: var(--green);
|
||||
color: var(--background);
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title,
|
||||
.author {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.author {
|
||||
color: var(--muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cover img {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import type { Book } from '#lib/content';
|
||||
import Pagination from '#lib/components/ui/Pagination.svelte';
|
||||
import BookCard from './BookCard.svelte';
|
||||
|
||||
let { books }: { books: Book[] } = $props();
|
||||
|
||||
const booksPerPage = 12;
|
||||
|
||||
let currentPage = $state(1);
|
||||
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(books.length / booksPerPage)));
|
||||
const visibleBooks = $derived(books.slice((currentPage - 1) * booksPerPage, currentPage * booksPerPage));
|
||||
</script>
|
||||
|
||||
<div class="book-grid">
|
||||
{#each visibleBooks as book (`${book.title}-${book.author}`)}
|
||||
<BookCard {book} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Pagination bind:currentPage {totalPages} />
|
||||
|
||||
<style>
|
||||
.book-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 1rem 0.85rem;
|
||||
}
|
||||
|
||||
@container (max-width: 520px) {
|
||||
.book-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,6 @@
|
||||
import Panel from '../../ui/Panel.svelte';
|
||||
import ActivityPanel from './ActivityPanel.svelte';
|
||||
import ListeningFetch from './ListeningFetch.svelte';
|
||||
import RecentTracks from './RecentTracks.svelte';
|
||||
import TopCharts from './TopCharts.svelte';
|
||||
import TrendingArtists from './TrendingArtists.svelte';
|
||||
|
||||
@@ -16,18 +15,13 @@
|
||||
<Panel title="last.fm" icon="simple-icons:lastdotfm">
|
||||
<ListeningFetch {listening} />
|
||||
</Panel>
|
||||
<Panel title={listening.nowPlaying ? 'Now playing' : 'Recently played'} icon="mdi:headphones">
|
||||
<RecentTracks nowPlaying={listening.nowPlaying} recentTracks={listening.recentTracks} />
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div class="overview">
|
||||
<ActivityPanel {listening} />
|
||||
<Panel title="Trending this week" icon="mdi:trending-up">
|
||||
<TrendingArtists artists={listening.trending} />
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<ActivityPanel {listening} />
|
||||
|
||||
<h3 class="subheading"><Icon icon="mdi:trophy-outline" width="16" /> Top charts</h3>
|
||||
<TopCharts top={listening.top} />
|
||||
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import type { PlayedTrack } from '#lib/lastfm/types';
|
||||
|
||||
let { nowPlaying, recentTracks }: { nowPlaying: PlayedTrack | null; recentTracks: PlayedTrack[] } = $props();
|
||||
|
||||
const shownCount = 5;
|
||||
|
||||
const tracks = $derived(
|
||||
[...(nowPlaying ? [nowPlaying] : []), ...recentTracks].slice(0, shownCount)
|
||||
);
|
||||
|
||||
function timeAgo(playedAt: number | null): string {
|
||||
if (playedAt === null) return 'now';
|
||||
const minutes = Math.floor((Date.now() / 1000 - playedAt) / 60);
|
||||
if (minutes < 60) return `${Math.max(1, minutes)}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if tracks.length}
|
||||
<ul class="tracks">
|
||||
{#each tracks as track, index (`${track.playedAt}-${index}`)}
|
||||
<li>
|
||||
<a class="track" class:playing={track.playedAt === null} href={track.url} target="_blank" rel="noopener noreferrer">
|
||||
{#if track.image}
|
||||
<img src={track.image} alt={track.album} loading="lazy" />
|
||||
{:else}
|
||||
<span class="cover-fallback"><Icon icon="mdi:music-note" width="18" /></span>
|
||||
{/if}
|
||||
<span class="text">
|
||||
<span class="name">{track.track}</span>
|
||||
<span class="muted artist">{track.artist}</span>
|
||||
</span>
|
||||
<span class="when">
|
||||
{#if track.playedAt === null}
|
||||
<span class="equalizer" aria-label="Now playing"><span></span><span></span><span></span></span>
|
||||
{:else}
|
||||
<span class="muted">{timeAgo(track.playedAt)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="muted">No scrobbles yet.</p>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.tracks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.3rem;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.track:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.track.playing {
|
||||
border-color: color-mix(in srgb, var(--green) 50%, transparent);
|
||||
}
|
||||
|
||||
img,
|
||||
.cover-fallback {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover-fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name,
|
||||
.artist {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.artist,
|
||||
.when {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.when {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.equalizer {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.equalizer span {
|
||||
width: 3px;
|
||||
background: var(--green);
|
||||
animation: equalize 0.9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.equalizer span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.equalizer span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes equalize {
|
||||
0%,
|
||||
100% {
|
||||
height: 30%;
|
||||
}
|
||||
50% {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.equalizer span {
|
||||
animation: none;
|
||||
height: 70%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,10 @@
|
||||
import { imageFileName } from '#lib/desktop/imageViewer';
|
||||
import { defaultFloatingRect, floatingStyle, moveRect, resizeRect } from '#lib/desktop/floating';
|
||||
import SystemMonitor from '../btop/SystemMonitor.svelte';
|
||||
import Snake from '../games/Snake.svelte';
|
||||
import Breakout from '../games/Breakout.svelte';
|
||||
import DesktopPet from '../pet/DesktopPet.svelte';
|
||||
import TcgBrowser from '../tcg/TcgBrowser.svelte';
|
||||
import DesktopIcons from './DesktopIcons.svelte';
|
||||
import Window from './Window.svelte';
|
||||
|
||||
@@ -98,6 +102,10 @@
|
||||
</div>
|
||||
|
||||
<div class="tiling-area" bind:clientWidth={areaWidth} bind:clientHeight={areaHeight}>
|
||||
{#if !isNarrow}
|
||||
<DesktopPet {areaWidth} {areaHeight} />
|
||||
{/if}
|
||||
|
||||
{#if pageOpen}
|
||||
<Window title="kitty" path={pageTitle} icon={pageIcon} scrollKey={pageKey} {...windowProps(pageWindowId)} onclose={closePage}>
|
||||
{@render children()}
|
||||
@@ -141,6 +149,35 @@
|
||||
<SystemMonitor />
|
||||
</Window>
|
||||
{/each}
|
||||
|
||||
{#each windowManager.games as gameWindow (gameWindow.id)}
|
||||
<Window
|
||||
title={gameWindow.game}
|
||||
path="games"
|
||||
icon="ph:game-controller-duotone"
|
||||
{...windowProps(gameWindow.id)}
|
||||
onclose={() => windowManager.closeWindow(gameWindow.id)}
|
||||
>
|
||||
{#if gameWindow.game === 'snake'}
|
||||
<Snake focused={windowManager.focusedId === gameWindow.id} />
|
||||
{:else if gameWindow.game === 'breakout'}
|
||||
<Breakout focused={windowManager.focusedId === gameWindow.id} />
|
||||
{/if}
|
||||
</Window>
|
||||
{/each}
|
||||
|
||||
{#each windowManager.tcgBrowsers as browser (browser.id)}
|
||||
<Window
|
||||
title="tcg"
|
||||
path="card browser"
|
||||
icon="mdi:pokeball"
|
||||
{...windowProps(browser.id)}
|
||||
onclose={() => windowManager.closeWindow(browser.id)}
|
||||
>
|
||||
<TcgBrowser />
|
||||
</Window>
|
||||
{/each}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,23 +1,43 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { desktop } from '#lib/content';
|
||||
import { desktop, type DesktopSide } from '#lib/content';
|
||||
import { runDesktopAction } from '#lib/desktop/actions';
|
||||
|
||||
const icons = desktop.iconGroups.flatMap((group) => group.icons);
|
||||
function iconsOnSide(side: DesktopSide) {
|
||||
return desktop.iconGroups.filter((group) => (group.side ?? 'left') === side).flatMap((group) => group.icons);
|
||||
}
|
||||
|
||||
const iconSides = [
|
||||
{ side: 'left', label: 'Desktop', icons: iconsOnSide('left') },
|
||||
{ side: 'right', label: 'Desktop games', icons: iconsOnSide('right') }
|
||||
];
|
||||
</script>
|
||||
|
||||
<nav class="desktop-icons" aria-label="Desktop">
|
||||
{#each icons as item (item.label)}
|
||||
<button class="desktop-icon" title={item.label} onclick={() => runDesktopAction(item.open)}>
|
||||
<span class="icon-box">
|
||||
<Icon icon={item.icon} width="36" />
|
||||
</span>
|
||||
<span class="icon-label">{item.label}</span>
|
||||
</button>
|
||||
<div class="desktop-icon-sides">
|
||||
{#each iconSides as iconSide (iconSide.side)}
|
||||
{#if iconSide.icons.length}
|
||||
<nav class="desktop-icons {iconSide.side}" aria-label={iconSide.label}>
|
||||
{#each iconSide.icons as item (item.label)}
|
||||
<button class="desktop-icon" title={item.label} onclick={() => runDesktopAction(item.open)}>
|
||||
<span class="icon-box">
|
||||
<Icon icon={item.icon} width="36" />
|
||||
</span>
|
||||
<span class="icon-label">{item.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.desktop-icon-sides {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.desktop-icons {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
@@ -29,6 +49,10 @@
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.desktop-icons.right {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.desktop-icon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -38,6 +62,7 @@
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--foreground);
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.desktop-icon:focus-visible {
|
||||
@@ -86,6 +111,11 @@
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.desktop-icon-sides {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.desktop-icons {
|
||||
grid-auto-flow: row;
|
||||
grid-template-columns: repeat(auto-fill, 104px);
|
||||
@@ -94,5 +124,9 @@
|
||||
justify-content: center;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.desktop-icons.right {
|
||||
direction: ltr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -80,7 +80,7 @@
|
||||
onfocusin={onfocus}
|
||||
>
|
||||
<div class="window-surface">
|
||||
<header class="title-bar" onpointerdown={(event) => trackPointer(event, ondrag)}>
|
||||
<header class="title-bar" role="presentation" onpointerdown={(event) => trackPointer(event, ondrag)}>
|
||||
<span class="title">
|
||||
<span class="app-name"><Icon {icon} width="15" />{title}</span>
|
||||
{#if path}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
advanceBreakout,
|
||||
createBreakoutGame,
|
||||
fieldHeight,
|
||||
fieldWidth,
|
||||
launchBall,
|
||||
movePaddleTo,
|
||||
type BreakoutStatus
|
||||
} from '#lib/games/breakout';
|
||||
import { loadHighScore, saveHighScore } from '#lib/games/highScore';
|
||||
import GameShell from './GameShell.svelte';
|
||||
import BreakoutCanvas from './BreakoutCanvas.svelte';
|
||||
|
||||
let { focused }: { focused: boolean } = $props();
|
||||
|
||||
const gameName = 'breakout';
|
||||
const boardFrameSize = 6;
|
||||
const longestFrameSeconds = 0.05;
|
||||
|
||||
const steeringKeys: Record<string, number> = {
|
||||
ArrowLeft: -1,
|
||||
a: -1,
|
||||
h: -1,
|
||||
ArrowRight: 1,
|
||||
d: 1,
|
||||
l: 1
|
||||
};
|
||||
|
||||
const launchKeys = ['ArrowUp', 'w', 'k', 'Enter'];
|
||||
|
||||
const statusMessages: Record<BreakoutStatus, string> = {
|
||||
serving: 'press space or click to launch',
|
||||
running: '',
|
||||
paused: 'paused, press space to resume',
|
||||
over: 'game over, press r to restart'
|
||||
};
|
||||
|
||||
let game = $state(createBreakoutGame());
|
||||
let highScore = $state(0);
|
||||
let boardElement = $state<HTMLDivElement>();
|
||||
let areaWidth = $state(0);
|
||||
let areaHeight = $state(0);
|
||||
|
||||
const heldKeys = new Set<string>();
|
||||
|
||||
const canvasWidth = $derived(
|
||||
Math.max(0, Math.floor(Math.min(areaWidth - boardFrameSize, ((areaHeight - boardFrameSize) * fieldWidth) / fieldHeight)))
|
||||
);
|
||||
const canvasHeight = $derived(Math.round((canvasWidth * fieldHeight) / fieldWidth));
|
||||
const isActive = $derived(game.status === 'serving' || game.status === 'running');
|
||||
|
||||
function normalizedKey(event: KeyboardEvent): string {
|
||||
return event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
||||
}
|
||||
|
||||
function steering(): number {
|
||||
let direction = 0;
|
||||
heldKeys.forEach((key) => (direction += steeringKeys[key] ?? 0));
|
||||
return Math.sign(direction);
|
||||
}
|
||||
|
||||
function restart() {
|
||||
game = createBreakoutGame();
|
||||
}
|
||||
|
||||
function primaryAction() {
|
||||
if (game.status === 'serving') launchBall(game);
|
||||
else if (game.status === 'paused') game.status = 'running';
|
||||
else if (game.status === 'over') restart();
|
||||
}
|
||||
|
||||
function togglePause() {
|
||||
if (game.status === 'running') game.status = 'paused';
|
||||
else primaryAction();
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
||||
const key = normalizedKey(event);
|
||||
|
||||
if (key in steeringKeys) heldKeys.add(key);
|
||||
else if (key === ' ' || key === 'p') togglePause();
|
||||
else if (launchKeys.includes(key)) primaryAction();
|
||||
else if (key === 'r') restart();
|
||||
else return;
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function handleKeyup(event: KeyboardEvent) {
|
||||
heldKeys.delete(normalizedKey(event));
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (!isActive || !boardElement || canvasWidth === 0) return;
|
||||
const boardLeft = boardElement.getBoundingClientRect().left + boardFrameSize / 2;
|
||||
movePaddleTo(game, ((event.clientX - boardLeft) / canvasWidth) * fieldWidth);
|
||||
}
|
||||
|
||||
function handleBoardClick() {
|
||||
primaryAction();
|
||||
boardElement?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function updateHighScore() {
|
||||
if (game.score <= highScore) return;
|
||||
highScore = game.score;
|
||||
saveHighScore(gameName, highScore);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!isActive) return;
|
||||
let frameId = 0;
|
||||
let lastTime = performance.now();
|
||||
|
||||
const frame = (time: number) => {
|
||||
const seconds = Math.min(longestFrameSeconds, (time - lastTime) / 1000);
|
||||
lastTime = time;
|
||||
advanceBreakout(game, seconds, steering());
|
||||
updateHighScore();
|
||||
frameId = requestAnimationFrame(frame);
|
||||
};
|
||||
|
||||
frameId = requestAnimationFrame(frame);
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (focused) {
|
||||
boardElement?.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
heldKeys.clear();
|
||||
if (game.status === 'running') game.status = 'paused';
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
highScore = loadHighScore(gameName);
|
||||
});
|
||||
</script>
|
||||
|
||||
<GameShell
|
||||
title="breakout"
|
||||
stats={[
|
||||
{ label: 'score', value: game.score },
|
||||
{ label: 'lives', value: game.lives },
|
||||
{ label: 'level', value: game.level },
|
||||
{ label: 'best', value: highScore }
|
||||
]}
|
||||
hint="arrows / ad / hl or mouse to move, space to launch or pause, r to restart"
|
||||
touchHint="drag to move the paddle, tap to launch"
|
||||
>
|
||||
<div class="board-area" bind:clientWidth={areaWidth} bind:clientHeight={areaHeight}>
|
||||
<div
|
||||
class="board"
|
||||
bind:this={boardElement}
|
||||
tabindex="-1"
|
||||
role="presentation"
|
||||
onkeydown={handleKeydown}
|
||||
onkeyup={handleKeyup}
|
||||
onpointermove={handlePointerMove}
|
||||
onpointerdown={handlePointerMove}
|
||||
onclick={handleBoardClick}
|
||||
>
|
||||
<BreakoutCanvas paddleX={game.paddleX} ball={game.ball} bricks={game.bricks} width={canvasWidth} height={canvasHeight} />
|
||||
|
||||
{#if statusMessages[game.status]}
|
||||
<div class="overlay {game.status}">{statusMessages[game.status]}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</GameShell>
|
||||
|
||||
<style>
|
||||
.board-area {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.board {
|
||||
position: relative;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--border-inactive);
|
||||
outline: none;
|
||||
cursor: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.board:focus-visible {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: color-mix(in srgb, var(--background) 70%, transparent);
|
||||
color: var(--foreground);
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.overlay.serving {
|
||||
align-items: end;
|
||||
padding-bottom: 22%;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
ballRadius,
|
||||
brickHeight,
|
||||
brickWidth,
|
||||
fieldWidth,
|
||||
paddleHeight,
|
||||
paddleWidth,
|
||||
paddleY,
|
||||
type Ball,
|
||||
type Brick
|
||||
} from '#lib/games/breakout';
|
||||
import { themeState } from '#lib/theme/theme.svelte';
|
||||
|
||||
let {
|
||||
paddleX,
|
||||
ball,
|
||||
bricks,
|
||||
width,
|
||||
height
|
||||
}: { paddleX: number; ball: Ball; bricks: Brick[]; width: number; height: number } = $props();
|
||||
|
||||
const brickRowColors = ['--red', '--orange', '--yellow', '--green', '--cyan', '--blue'];
|
||||
|
||||
let canvasElement = $state<HTMLCanvasElement>();
|
||||
let pixelRatio = $state(1);
|
||||
|
||||
const canvasWidth = $derived(Math.round(width * pixelRatio));
|
||||
const canvasHeight = $derived(Math.round(height * pixelRatio));
|
||||
|
||||
function drawField(context: CanvasRenderingContext2D) {
|
||||
const style = getComputedStyle(canvasElement!);
|
||||
const themeColor = (name: string) => style.getPropertyValue(name).trim();
|
||||
const scale = canvasWidth / fieldWidth;
|
||||
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
context.setTransform(scale, 0, 0, scale, 0, 0);
|
||||
|
||||
bricks.forEach((brick) => {
|
||||
if (brick.broken) return;
|
||||
context.fillStyle = themeColor(brickRowColors[brick.row % brickRowColors.length]);
|
||||
context.fillRect(brick.x, brick.y, brickWidth, brickHeight);
|
||||
});
|
||||
|
||||
context.fillStyle = themeColor('--accent');
|
||||
context.fillRect(paddleX, paddleY, paddleWidth, paddleHeight);
|
||||
|
||||
context.fillStyle = themeColor('--foreground');
|
||||
context.beginPath();
|
||||
context.arc(ball.x, ball.y, ballRadius, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
width;
|
||||
pixelRatio = window.devicePixelRatio || 1;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
themeState.currentId;
|
||||
const context = canvasElement?.getContext('2d');
|
||||
if (context && canvasWidth > 0) drawField(context);
|
||||
});
|
||||
</script>
|
||||
|
||||
<canvas bind:this={canvasElement} width={canvasWidth} height={canvasHeight} style="width: {width}px; height: {height}px"></canvas>
|
||||
|
||||
<style>
|
||||
canvas {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import type { Direction } from '#lib/games/snake';
|
||||
|
||||
let { onsteer }: { onsteer: (direction: Direction) => void } = $props();
|
||||
|
||||
const buttons: { direction: Direction; icon: string }[] = [
|
||||
{ direction: 'up', icon: 'mdi:chevron-up' },
|
||||
{ direction: 'left', icon: 'mdi:chevron-left' },
|
||||
{ direction: 'down', icon: 'mdi:chevron-down' },
|
||||
{ direction: 'right', icon: 'mdi:chevron-right' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="direction-pad">
|
||||
{#each buttons as button (button.direction)}
|
||||
<button class={button.direction} aria-label="Move {button.direction}" onpointerdown={() => onsteer(button.direction)}>
|
||||
<Icon icon={button.icon} width="26" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.direction-pad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 52px);
|
||||
grid-template-rows: repeat(2, 52px);
|
||||
grid-template-areas:
|
||||
'. up .'
|
||||
'left down right';
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
background: var(--background);
|
||||
border: 2px solid var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
button:active {
|
||||
background: var(--accent);
|
||||
color: var(--background);
|
||||
}
|
||||
|
||||
.up {
|
||||
grid-area: up;
|
||||
}
|
||||
|
||||
.left {
|
||||
grid-area: left;
|
||||
}
|
||||
|
||||
.down {
|
||||
grid-area: down;
|
||||
}
|
||||
|
||||
.right {
|
||||
grid-area: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface GameStat {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
stats,
|
||||
hint,
|
||||
touchHint = '',
|
||||
children
|
||||
}: { title: string; stats: GameStat[]; hint: string; touchHint?: string; children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
<div class="game">
|
||||
<header class="score-line">
|
||||
<span class="title">{title}</span>
|
||||
{#each stats as stat (stat.label)}
|
||||
<span>{stat.label} <span class="value">{stat.value}</span></span>
|
||||
{/each}
|
||||
</header>
|
||||
|
||||
{@render children()}
|
||||
|
||||
<footer class="hint muted">
|
||||
<span class="keyboard-hint">{hint}</span>
|
||||
{#if touchHint}
|
||||
<span class="touch-hint">{touchHint}</span>
|
||||
{/if}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.game {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 0.5rem 0.75rem 0.75rem;
|
||||
}
|
||||
|
||||
.score-line {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: var(--yellow);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hint {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.touch-hint {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.keyboard-hint {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.touch-hint {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
advanceSnake,
|
||||
createSnakeGame,
|
||||
queueDirection,
|
||||
tickDelay,
|
||||
type Direction,
|
||||
type SnakeStatus
|
||||
} from '#lib/games/snake';
|
||||
import { loadHighScore, saveHighScore } from '#lib/games/highScore';
|
||||
import GameShell from './GameShell.svelte';
|
||||
import DirectionPad from './DirectionPad.svelte';
|
||||
import SnakeCanvas from './SnakeCanvas.svelte';
|
||||
|
||||
let { focused }: { focused: boolean } = $props();
|
||||
|
||||
const gameName = 'snake';
|
||||
const boardFrameSize = 6;
|
||||
|
||||
const keyDirections: Record<string, Direction> = {
|
||||
ArrowUp: 'up',
|
||||
ArrowDown: 'down',
|
||||
ArrowLeft: 'left',
|
||||
ArrowRight: 'right',
|
||||
w: 'up',
|
||||
s: 'down',
|
||||
a: 'left',
|
||||
d: 'right',
|
||||
k: 'up',
|
||||
j: 'down',
|
||||
h: 'left',
|
||||
l: 'right'
|
||||
};
|
||||
|
||||
const statusMessages: Record<SnakeStatus, string> = {
|
||||
ready: 'press space or an arrow key to start',
|
||||
running: '',
|
||||
paused: 'paused, press space to resume',
|
||||
over: 'game over, press r to restart',
|
||||
won: 'you win, press r to play again'
|
||||
};
|
||||
|
||||
let game = $state(createSnakeGame());
|
||||
let highScore = $state(0);
|
||||
let boardElement = $state<HTMLDivElement>();
|
||||
let areaWidth = $state(0);
|
||||
let areaHeight = $state(0);
|
||||
|
||||
const canvasSize = $derived(Math.max(0, Math.floor(Math.min(areaWidth, areaHeight)) - boardFrameSize));
|
||||
const isFinished = $derived(game.status === 'over' || game.status === 'won');
|
||||
|
||||
function restart() {
|
||||
game = createSnakeGame();
|
||||
game.status = 'running';
|
||||
}
|
||||
|
||||
function steer(direction: Direction) {
|
||||
if (isFinished) return;
|
||||
queueDirection(game, direction);
|
||||
game.status = 'running';
|
||||
}
|
||||
|
||||
function togglePause() {
|
||||
if (isFinished) restart();
|
||||
else game.status = game.status === 'running' ? 'paused' : 'running';
|
||||
}
|
||||
|
||||
function handleOverlayClick() {
|
||||
togglePause();
|
||||
boardElement?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
function tick() {
|
||||
advanceSnake(game);
|
||||
if (game.score <= highScore) return;
|
||||
highScore = game.score;
|
||||
saveHighScore(gameName, highScore);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
||||
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
||||
const direction = keyDirections[key];
|
||||
|
||||
if (direction) steer(direction);
|
||||
else if (key === ' ' || key === 'p') togglePause();
|
||||
else if (key === 'r' || key === 'Enter') restart();
|
||||
else return;
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (game.status !== 'running') return;
|
||||
const timer = setInterval(tick, tickDelay(game.score));
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (focused) boardElement?.focus({ preventScroll: true });
|
||||
else if (game.status === 'running') game.status = 'paused';
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
highScore = loadHighScore(gameName);
|
||||
});
|
||||
</script>
|
||||
|
||||
<GameShell
|
||||
title="snake"
|
||||
stats={[
|
||||
{ label: 'score', value: game.score },
|
||||
{ label: 'best', value: highScore }
|
||||
]}
|
||||
hint="arrows / wasd / hjkl to move, space to pause, r to restart"
|
||||
>
|
||||
<div class="board-area" bind:clientWidth={areaWidth} bind:clientHeight={areaHeight}>
|
||||
<div
|
||||
class="board"
|
||||
bind:this={boardElement}
|
||||
tabindex="-1"
|
||||
role="presentation"
|
||||
onkeydown={handleKeydown}
|
||||
>
|
||||
<SnakeCanvas snake={game.snake} food={game.food} size={canvasSize} />
|
||||
|
||||
{#if statusMessages[game.status]}
|
||||
<button class="overlay" tabindex="-1" onclick={handleOverlayClick}>{statusMessages[game.status]}</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="touch-controls">
|
||||
<DirectionPad onsteer={steer} />
|
||||
</div>
|
||||
</GameShell>
|
||||
|
||||
<style>
|
||||
.board-area {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.board {
|
||||
position: relative;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--border-inactive);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.board:focus-visible {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: color-mix(in srgb, var(--background) 70%, transparent);
|
||||
border: none;
|
||||
color: var(--foreground);
|
||||
font: inherit;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.touch-controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.touch-controls {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { boardSize, type Point } from '#lib/games/snake';
|
||||
import { themeState } from '#lib/theme/theme.svelte';
|
||||
|
||||
let { snake, food, size }: { snake: Point[]; food: Point | null; size: number } = $props();
|
||||
|
||||
const emptyCellOpacity = 0.06;
|
||||
|
||||
let canvasElement = $state<HTMLCanvasElement>();
|
||||
let pixelRatio = $state(1);
|
||||
|
||||
const canvasSize = $derived(Math.round(size * pixelRatio));
|
||||
|
||||
function themeColor(name: string): string {
|
||||
return getComputedStyle(canvasElement!).getPropertyValue(name).trim();
|
||||
}
|
||||
|
||||
function drawBoard(context: CanvasRenderingContext2D) {
|
||||
const gap = Math.max(1, Math.round(pixelRatio));
|
||||
const cellSpan = (canvasSize - gap) / boardSize;
|
||||
|
||||
const fillCell = (point: Point) => {
|
||||
const left = gap + Math.round(point.x * cellSpan);
|
||||
const top = gap + Math.round(point.y * cellSpan);
|
||||
const right = Math.round((point.x + 1) * cellSpan);
|
||||
const bottom = Math.round((point.y + 1) * cellSpan);
|
||||
context.fillRect(left, top, right - left, bottom - top);
|
||||
};
|
||||
|
||||
context.clearRect(0, 0, canvasSize, canvasSize);
|
||||
|
||||
context.globalAlpha = emptyCellOpacity;
|
||||
context.fillStyle = themeColor('--foreground');
|
||||
for (let y = 0; y < boardSize; y++) {
|
||||
for (let x = 0; x < boardSize; x++) fillCell({ x, y });
|
||||
}
|
||||
context.globalAlpha = 1;
|
||||
|
||||
if (food) {
|
||||
context.fillStyle = themeColor('--red');
|
||||
fillCell(food);
|
||||
}
|
||||
|
||||
context.fillStyle = themeColor('--green');
|
||||
snake.slice(1).forEach(fillCell);
|
||||
|
||||
context.fillStyle = themeColor('--accent');
|
||||
if (snake[0]) fillCell(snake[0]);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
size;
|
||||
pixelRatio = window.devicePixelRatio || 1;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
themeState.currentId;
|
||||
const context = canvasElement?.getContext('2d');
|
||||
if (context && canvasSize > 0) drawBoard(context);
|
||||
});
|
||||
</script>
|
||||
|
||||
<canvas bind:this={canvasElement} width={canvasSize} height={canvasSize} style="width: {size}px; height: {size}px"></canvas>
|
||||
|
||||
<style>
|
||||
canvas {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,485 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
commandPet,
|
||||
createPet,
|
||||
dropPet,
|
||||
holdPet,
|
||||
hopPet,
|
||||
petExpression,
|
||||
placePet,
|
||||
stepPet,
|
||||
type Pet,
|
||||
type PetAction
|
||||
} from '#lib/pet/behavior';
|
||||
import { petState } from '#lib/pet/petState.svelte';
|
||||
import { randomVoiceLine, type VoiceMoment } from '#lib/pet/voiceLines';
|
||||
import type { Rect } from '#lib/pet/world';
|
||||
import RobotSprite from './RobotSprite.svelte';
|
||||
import MoveMarker from './MoveMarker.svelte';
|
||||
import PixelIcon from './PixelIcon.svelte';
|
||||
|
||||
let { areaWidth, areaHeight }: { areaWidth: number; areaHeight: number } = $props();
|
||||
|
||||
interface SpeechBubble {
|
||||
id: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface Marker {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const dragThreshold = 4;
|
||||
const longestFrameSeconds = 0.05;
|
||||
const bubbleMilliseconds = 1800;
|
||||
const markerMilliseconds = 600;
|
||||
const commandReplyChance = 0.4;
|
||||
const arriveReplyChance = 0.5;
|
||||
const activityReplyChance = 0.5;
|
||||
const chatterChancePerSecond = 0.03;
|
||||
|
||||
let pet = $state<Pet | null>(null);
|
||||
let bubble = $state<SpeechBubble | null>(null);
|
||||
let markers = $state<Marker[]>([]);
|
||||
let obstacles = $state<Rect[]>([]);
|
||||
let layerElement = $state<HTMLDivElement>();
|
||||
let petElement = $state<HTMLButtonElement>();
|
||||
|
||||
let nextId = 0;
|
||||
let bubbleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let dragStart: { x: number; y: number } | null = null;
|
||||
let grabOffset = { x: 0, y: 0 };
|
||||
|
||||
const area = $derived({ width: areaWidth, height: areaHeight, obstacles });
|
||||
const expression = $derived(pet ? petExpression(pet) : 'awake');
|
||||
|
||||
const noteRows = ['..XX', '..X.', '..X.', 'XXX.', 'XX..'];
|
||||
const boltRows = ['..XX', '.XX.', 'XXXX', '.XX.', 'XX..'];
|
||||
|
||||
function say(moment: VoiceMoment) {
|
||||
clearTimeout(bubbleTimer);
|
||||
bubble = { id: nextId++, text: randomVoiceLine(moment) };
|
||||
bubbleTimer = setTimeout(() => (bubble = null), bubbleMilliseconds);
|
||||
}
|
||||
|
||||
function reactToActionChange(previousAction: PetAction, currentPet: Pet) {
|
||||
if (previousAction === currentPet.action) return;
|
||||
if (currentPet.activity && Math.random() < activityReplyChance) say(currentPet.activity);
|
||||
else if (currentPet.action === 'sleep') say('sleep');
|
||||
else if (previousAction === 'sleep') say('wake');
|
||||
else if (previousAction === 'walk' && currentPet.commanded && Math.random() < arriveReplyChance) say('arrive');
|
||||
}
|
||||
|
||||
function maybeChatter(currentPet: Pet, seconds: number) {
|
||||
if (bubble || currentPet.action !== 'idle') return;
|
||||
if (Math.random() < chatterChancePerSecond * seconds) say('chatter');
|
||||
}
|
||||
|
||||
function measureObstacles() {
|
||||
const desktopElement = layerElement?.closest('.desktop');
|
||||
if (!layerElement || !desktopElement) return;
|
||||
const layerRect = layerElement.getBoundingClientRect();
|
||||
obstacles = [...desktopElement.querySelectorAll('.desktop-icon')].map((iconElement) => {
|
||||
const iconRect = iconElement.getBoundingClientRect();
|
||||
return {
|
||||
left: iconRect.left - layerRect.left,
|
||||
top: iconRect.top - layerRect.top,
|
||||
right: iconRect.right - layerRect.left,
|
||||
bottom: iconRect.bottom - layerRect.top
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
areaWidth;
|
||||
areaHeight;
|
||||
measureObstacles();
|
||||
});
|
||||
|
||||
function showMarker(x: number, y: number) {
|
||||
const marker = { id: nextId++, x, y };
|
||||
markers.push(marker);
|
||||
setTimeout(() => (markers = markers.filter((item) => item.id !== marker.id)), markerMilliseconds);
|
||||
}
|
||||
|
||||
function isDesktopBackground(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element) || !layerElement) return false;
|
||||
const desktopElement = layerElement.closest('.desktop');
|
||||
return !!desktopElement?.contains(target) && !target.closest('.window');
|
||||
}
|
||||
|
||||
function handleContextMenu(event: MouseEvent) {
|
||||
if (!pet || !petState.enabled || !layerElement || !isDesktopBackground(event.target)) return;
|
||||
event.preventDefault();
|
||||
const layerRect = layerElement.getBoundingClientRect();
|
||||
const pointX = event.clientX - layerRect.left;
|
||||
const pointY = event.clientY - layerRect.top;
|
||||
commandPet(pet, pointX, pointY, area);
|
||||
showMarker(pointX, pointY);
|
||||
if (Math.random() < commandReplyChance) say('command');
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (event.button !== 0 || !petElement) return;
|
||||
event.preventDefault();
|
||||
petElement.setPointerCapture(event.pointerId);
|
||||
const petRect = petElement.getBoundingClientRect();
|
||||
dragStart = { x: event.clientX, y: event.clientY };
|
||||
grabOffset = { x: event.clientX - petRect.left, y: event.clientY - petRect.top };
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (!pet || !dragStart || !layerElement) return;
|
||||
const movedDistance = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
|
||||
if (pet.action !== 'held' && movedDistance < dragThreshold) return;
|
||||
|
||||
if (pet.action !== 'held') {
|
||||
holdPet(pet);
|
||||
say('pickup');
|
||||
}
|
||||
const layerRect = layerElement.getBoundingClientRect();
|
||||
placePet(pet, event.clientX - layerRect.left - grabOffset.x, event.clientY - layerRect.top - grabOffset.y, area);
|
||||
}
|
||||
|
||||
function handlePointerUp() {
|
||||
if (!pet || !dragStart) return;
|
||||
dragStart = null;
|
||||
if (pet.action === 'held') {
|
||||
dropPet(pet);
|
||||
say('drop');
|
||||
return;
|
||||
}
|
||||
hopPet(pet);
|
||||
say('click');
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
petState.load();
|
||||
let frameId = 0;
|
||||
let lastTime = performance.now();
|
||||
|
||||
const frame = (time: number) => {
|
||||
const seconds = Math.min(longestFrameSeconds, (time - lastTime) / 1000);
|
||||
lastTime = time;
|
||||
if (!pet && areaWidth > 0) pet = createPet(area);
|
||||
if (pet && petState.enabled) {
|
||||
const previousAction = pet.action;
|
||||
stepPet(pet, seconds, area);
|
||||
reactToActionChange(previousAction, pet);
|
||||
maybeChatter(pet, seconds);
|
||||
}
|
||||
frameId = requestAnimationFrame(frame);
|
||||
};
|
||||
|
||||
frameId = requestAnimationFrame(frame);
|
||||
return () => cancelAnimationFrame(frameId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window oncontextmenu={handleContextMenu} />
|
||||
|
||||
<div class="pet-layer" bind:this={layerElement}>
|
||||
{#if pet && petState.enabled}
|
||||
{#each markers as marker (marker.id)}
|
||||
<MoveMarker x={marker.x} y={marker.y} />
|
||||
{/each}
|
||||
|
||||
<button
|
||||
class="pet {pet.action} {pet.activity ?? ''}"
|
||||
style="left: {pet.x}px; top: {pet.y}px"
|
||||
bind:this={petElement}
|
||||
aria-label="Desktop robot, click to say hi, drag to move or right click the desktop to send it somewhere"
|
||||
onmousedown={(event) => event.preventDefault()}
|
||||
onpointerdown={handlePointerDown}
|
||||
onpointermove={handlePointerMove}
|
||||
onpointerup={handlePointerUp}
|
||||
onpointercancel={handlePointerUp}
|
||||
>
|
||||
<span class="shadow"></span>
|
||||
<span class="facing" style="transform: scaleX({pet.facing})">
|
||||
<span class="motion">
|
||||
<RobotSprite {expression} activity={pet.activity} />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{#if pet.action === 'sleep'}
|
||||
<span class="snore">zZ</span>
|
||||
{:else if pet.activity === 'dance'}
|
||||
<span class="floating-icon note"><PixelIcon rows={noteRows} pixelSize={4} /></span>
|
||||
{:else if pet.activity === 'charge'}
|
||||
<span class="floating-icon bolt"><PixelIcon rows={boltRows} pixelSize={4} /></span>
|
||||
{/if}
|
||||
|
||||
{#if bubble}
|
||||
{#key bubble.id}
|
||||
<span class="bubble">{bubble.text}</span>
|
||||
{/key}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pet-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pet {
|
||||
position: absolute;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: grab;
|
||||
pointer-events: auto;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pet.held {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.shadow {
|
||||
position: absolute;
|
||||
bottom: -3px;
|
||||
left: 50%;
|
||||
width: 52px;
|
||||
height: 12px;
|
||||
background: rgb(0 0 0 / 0.35);
|
||||
border-radius: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.held .shadow {
|
||||
bottom: -18px;
|
||||
width: 36px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.facing,
|
||||
.motion {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.motion {
|
||||
transform-origin: bottom center;
|
||||
}
|
||||
|
||||
.walk .motion {
|
||||
animation: robot-step 0.3s steps(2) infinite;
|
||||
}
|
||||
|
||||
.idle .motion {
|
||||
animation: robot-hum 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sleep .motion {
|
||||
transform: translateY(2px) scaleY(0.94);
|
||||
}
|
||||
|
||||
.hop .motion {
|
||||
animation: robot-hop 0.4s ease-out;
|
||||
}
|
||||
|
||||
.held .motion {
|
||||
animation: robot-wiggle 0.3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.wave .motion,
|
||||
.scan .motion,
|
||||
.compute .motion {
|
||||
animation: robot-hum 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dance .motion {
|
||||
animation: robot-dance 0.5s infinite;
|
||||
}
|
||||
|
||||
.spin .motion {
|
||||
animation: robot-spin 0.8s infinite;
|
||||
}
|
||||
|
||||
.charge .motion {
|
||||
animation: robot-buzz 0.15s steps(2) infinite;
|
||||
}
|
||||
|
||||
.floating-icon {
|
||||
position: absolute;
|
||||
bottom: calc(100% - 6px);
|
||||
left: 70%;
|
||||
pointer-events: none;
|
||||
animation: icon-float 1.2s ease-out infinite;
|
||||
}
|
||||
|
||||
.note {
|
||||
color: var(--magenta);
|
||||
}
|
||||
|
||||
.bolt {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
@keyframes robot-dance {
|
||||
0% {
|
||||
transform: translateY(0) rotate(-8deg);
|
||||
animation-timing-function: steps(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-4px) rotate(8deg);
|
||||
animation-timing-function: steps(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes robot-spin {
|
||||
0% {
|
||||
transform: scaleX(1);
|
||||
animation-timing-function: steps(1);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: scaleX(0.3);
|
||||
animation-timing-function: steps(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scaleX(-1);
|
||||
animation-timing-function: steps(1);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: scaleX(-0.3);
|
||||
animation-timing-function: steps(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes robot-buzz {
|
||||
from {
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(1px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes icon-float {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translate(8px, -18px);
|
||||
}
|
||||
}
|
||||
|
||||
.snore,
|
||||
.bubble {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 2px);
|
||||
left: 50%;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.snore {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
animation: snore-float 2s ease-out infinite;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
padding: 1px 6px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--foreground);
|
||||
font-size: 12px;
|
||||
transform: translateX(-50%);
|
||||
animation: bubble-rise 1.8s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes robot-step {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes robot-hum {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes robot-hop {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translateY(-14px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes robot-wiggle {
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(-6deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(6deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes snore-float {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translate(10px, -14px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bubble-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 4px);
|
||||
}
|
||||
|
||||
15% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
80% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -10px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import { pixelRuns } from '#lib/pet/pixelArt';
|
||||
|
||||
let { x, y }: { x: number; y: number } = $props();
|
||||
|
||||
const pixelSize = 3;
|
||||
const frameSeconds = 0.2;
|
||||
|
||||
const frameRows = [
|
||||
[
|
||||
'...XXXXX...',
|
||||
'.XX.....XX.',
|
||||
'X.........X',
|
||||
'X.........X',
|
||||
'X.........X',
|
||||
'.XX.....XX.',
|
||||
'...XXXXX...'
|
||||
],
|
||||
['..XXX..', '.X...X.', 'X.....X', '.X...X.', '..XXX..'],
|
||||
['.X.', 'X.X', '.X.']
|
||||
];
|
||||
|
||||
const frames = frameRows.map((rows, index) => ({
|
||||
index,
|
||||
width: rows[0].length,
|
||||
height: rows.length,
|
||||
runs: pixelRuns(rows)
|
||||
}));
|
||||
</script>
|
||||
|
||||
<span class="move-marker" style="left: {x}px; top: {y}px">
|
||||
{#each frames as frame (frame.index)}
|
||||
<svg
|
||||
class="frame"
|
||||
viewBox="0 0 {frame.width} {frame.height}"
|
||||
width={frame.width * pixelSize}
|
||||
height={frame.height * pixelSize}
|
||||
shape-rendering="crispEdges"
|
||||
style="animation-delay: {frame.index * frameSeconds}s; animation-duration: {frameSeconds}s"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#each frame.runs as run (`${run.x}-${run.y}`)}
|
||||
<rect x={run.x} y={run.y} width={run.width} height="1" />
|
||||
{/each}
|
||||
</svg>
|
||||
{/each}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.move-marker {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.frame {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
fill: var(--green);
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
animation-name: show-frame;
|
||||
animation-timing-function: steps(1, end);
|
||||
}
|
||||
|
||||
@keyframes show-frame {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { pixelRuns } from '#lib/pet/pixelArt';
|
||||
|
||||
let { rows, pixelSize = 2 }: { rows: string[]; pixelSize?: number } = $props();
|
||||
|
||||
const width = $derived(rows[0]?.length ?? 0);
|
||||
const height = $derived(rows.length);
|
||||
const runs = $derived(pixelRuns(rows));
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class="pixel-icon"
|
||||
viewBox="0 0 {width} {height}"
|
||||
width={width * pixelSize}
|
||||
height={height * pixelSize}
|
||||
shape-rendering="crispEdges"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{#each runs as run (`${run.x}-${run.y}`)}
|
||||
<rect x={run.x} y={run.y} width={run.width} height="1" />
|
||||
{/each}
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.pixel-icon {
|
||||
display: block;
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,266 @@
|
||||
<script lang="ts">
|
||||
import { pixelRuns } from '#lib/pet/pixelArt';
|
||||
import { petHeight, petWidth, robotGridSize, type Point } from '#lib/pet/world';
|
||||
import type { PetActivity, PetExpression } from '#lib/pet/behavior';
|
||||
|
||||
let { expression, activity }: { expression: PetExpression; activity: PetActivity | null } = $props();
|
||||
|
||||
const bodyRows = [
|
||||
'............',
|
||||
'.....XX.....',
|
||||
'.XXXXXXXXXX.',
|
||||
'.X........X.',
|
||||
'.X........X.',
|
||||
'.X........X.',
|
||||
'.XXXXXXXXXX.',
|
||||
'...XXXXXX...',
|
||||
'...XXXXXX...',
|
||||
'...XXXXXX...',
|
||||
'...X....X...',
|
||||
'..XX....XX..'
|
||||
];
|
||||
|
||||
const bodyRuns = pixelRuns(bodyRows);
|
||||
|
||||
const leftArmDown: Point[] = [{ x: 0, y: 8 }, { x: 1, y: 8 }];
|
||||
const rightArmDown: Point[] = [{ x: 10, y: 8 }, { x: 11, y: 8 }];
|
||||
const leftArmUp: Point[] = [{ x: 1, y: 7 }, { x: 0, y: 6 }];
|
||||
const rightArmUp: Point[] = [{ x: 10, y: 7 }, { x: 11, y: 6 }, { x: 11, y: 5 }];
|
||||
const rightArmOut: Point[] = [{ x: 10, y: 7 }, { x: 11, y: 7 }, { x: 11, y: 6 }];
|
||||
|
||||
const armPoses: Partial<Record<PetActivity, Point[][]>> = {
|
||||
wave: [
|
||||
[...leftArmDown, ...rightArmUp],
|
||||
[...leftArmDown, ...rightArmOut]
|
||||
],
|
||||
dance: [
|
||||
[...leftArmUp, ...rightArmDown],
|
||||
[...leftArmDown, ...rightArmUp]
|
||||
]
|
||||
};
|
||||
|
||||
const happyEyePixels: Point[] = [
|
||||
{ x: 2, y: 4 },
|
||||
{ x: 3, y: 3 },
|
||||
{ x: 4, y: 4 },
|
||||
{ x: 7, y: 4 },
|
||||
{ x: 8, y: 3 },
|
||||
{ x: 9, y: 4 }
|
||||
];
|
||||
|
||||
const codeFrames = [
|
||||
[
|
||||
{ x: 3, y: 3, width: 4 },
|
||||
{ x: 3, y: 4, width: 2 },
|
||||
{ x: 4, y: 5, width: 4 }
|
||||
],
|
||||
[
|
||||
{ x: 3, y: 3, width: 2 },
|
||||
{ x: 4, y: 4, width: 4 },
|
||||
{ x: 3, y: 5, width: 3 }
|
||||
]
|
||||
];
|
||||
|
||||
const armFrames = $derived((activity && armPoses[activity]) || [[...leftArmDown, ...rightArmDown]]);
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class="robot activity-{activity ?? 'none'}"
|
||||
viewBox="0 0 {robotGridSize} {robotGridSize}"
|
||||
width={petWidth}
|
||||
height={petHeight}
|
||||
shape-rendering="crispEdges"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect class="antenna-light" class:powered-down={expression === 'sleeping'} x="5" y="0" width="2" height="1" />
|
||||
|
||||
{#each bodyRuns as run (`${run.x}-${run.y}`)}
|
||||
<rect class="frame" x={run.x} y={run.y} width={run.width} height="1" />
|
||||
{/each}
|
||||
|
||||
{#each armFrames as armPixels, frameIndex (frameIndex)}
|
||||
<g class="frame arm-frame" class:alternating={armFrames.length > 1} class:second-frame={frameIndex === 1}>
|
||||
{#each armPixels as pixel (`${pixel.x}-${pixel.y}`)}
|
||||
<rect x={pixel.x} y={pixel.y} width="1" height="1" />
|
||||
{/each}
|
||||
</g>
|
||||
{/each}
|
||||
|
||||
<rect class="screen" x="2" y="3" width="8" height="3" />
|
||||
<rect class="chest-light" x="5" y="8" width="2" height="1" />
|
||||
|
||||
{#if activity === 'compute'}
|
||||
{#each codeFrames as lines, frameIndex (frameIndex)}
|
||||
<g class="code alternating" class:second-frame={frameIndex === 1}>
|
||||
{#each lines as codeLine (`${codeLine.x}-${codeLine.y}`)}
|
||||
<rect x={codeLine.x} y={codeLine.y} width={codeLine.width} height="1" />
|
||||
{/each}
|
||||
</g>
|
||||
{/each}
|
||||
{:else if activity === 'charge'}
|
||||
<rect class="battery-track" x="3" y="4" width="6" height="1" />
|
||||
<rect class="battery-fill" x="3" y="4" width="6" height="1" />
|
||||
{:else if expression === 'awake'}
|
||||
<g class="eyes" class:blinking={activity !== 'scan'} class:scanning={activity === 'scan'}>
|
||||
<rect x="3" y="3" width="2" height="2" />
|
||||
<rect x="7" y="3" width="2" height="2" />
|
||||
</g>
|
||||
{:else if expression === 'sleeping'}
|
||||
<g class="eyes powered-down">
|
||||
<rect x="3" y="4" width="2" height="1" />
|
||||
<rect x="7" y="4" width="2" height="1" />
|
||||
</g>
|
||||
{:else}
|
||||
<g class="eyes">
|
||||
{#each happyEyePixels as pixel (`${pixel.x}-${pixel.y}`)}
|
||||
<rect x={pixel.x} y={pixel.y} width="1" height="1" />
|
||||
{/each}
|
||||
</g>
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.robot {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.frame {
|
||||
fill: var(--accent);
|
||||
}
|
||||
|
||||
.screen {
|
||||
fill: var(--dark-background, var(--background));
|
||||
}
|
||||
|
||||
.eyes {
|
||||
fill: var(--cyan);
|
||||
}
|
||||
|
||||
.code {
|
||||
fill: var(--green);
|
||||
}
|
||||
|
||||
.battery-track {
|
||||
fill: var(--muted);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.battery-fill {
|
||||
fill: var(--green);
|
||||
transform-box: fill-box;
|
||||
transform-origin: left center;
|
||||
animation: battery-charge 1.8s steps(6, jump-start) infinite;
|
||||
}
|
||||
|
||||
.chest-light {
|
||||
fill: var(--yellow);
|
||||
}
|
||||
|
||||
.activity-charge .chest-light {
|
||||
fill: var(--green);
|
||||
animation: light-flash 0.5s steps(1) infinite;
|
||||
}
|
||||
|
||||
.antenna-light {
|
||||
fill: var(--red);
|
||||
animation: light-flash 1.6s steps(1) infinite;
|
||||
}
|
||||
|
||||
.activity-scan .antenna-light {
|
||||
fill: var(--green);
|
||||
animation-duration: 0.4s;
|
||||
}
|
||||
|
||||
.powered-down {
|
||||
opacity: 0.35;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.blinking {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: blink 4s infinite;
|
||||
}
|
||||
|
||||
.scanning {
|
||||
animation: scan-eyes 1.4s steps(1) infinite;
|
||||
}
|
||||
|
||||
.alternating {
|
||||
animation: show-first-frame 0.5s steps(1) infinite;
|
||||
}
|
||||
|
||||
.alternating.second-frame {
|
||||
animation-name: show-second-frame;
|
||||
}
|
||||
|
||||
@keyframes show-first-frame {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes show-second-frame {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes light-flash {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scan-eyes {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translateX(1px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes battery-charge {
|
||||
from {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
94%,
|
||||
100% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
|
||||
97% {
|
||||
transform: scaleY(0.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,251 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { deckSizeLimit } from '#lib/tcg/cardKind';
|
||||
import { deckBuilder } from '#lib/tcg/deckBuilder.svelte';
|
||||
import { copyText, downloadDeckJson, downloadDeckText } from '#lib/tcg/deckExport';
|
||||
import BtopBox from '../btop/BtopBox.svelte';
|
||||
|
||||
const statusMilliseconds = 1600;
|
||||
|
||||
let exportStatus = $state('');
|
||||
let statusTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const fillPercent = $derived(Math.min(100, (deckBuilder.total / deckSizeLimit) * 100));
|
||||
const isEmpty = $derived(deckBuilder.total === 0);
|
||||
|
||||
function showStatus(message: string) {
|
||||
clearTimeout(statusTimer);
|
||||
exportStatus = message;
|
||||
statusTimer = setTimeout(() => (exportStatus = ''), statusMilliseconds);
|
||||
}
|
||||
|
||||
async function copyDeck() {
|
||||
const copied = await copyText(deckBuilder.exportText());
|
||||
showStatus(copied ? 'copied for PTCG Live' : 'copy failed');
|
||||
}
|
||||
|
||||
function clearDeck() {
|
||||
if (window.confirm(`Remove all ${deckBuilder.total} cards from ${deckBuilder.name}?`)) deckBuilder.clear();
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="deck-panel">
|
||||
<BtopBox title="deck" number={3} color="green">
|
||||
<input
|
||||
class="deck-name"
|
||||
value={deckBuilder.name}
|
||||
oninput={(event) => deckBuilder.rename(event.currentTarget.value)}
|
||||
aria-label="Deck name"
|
||||
spellcheck="false"
|
||||
/>
|
||||
|
||||
<div class="progress" class:full={deckBuilder.total === deckSizeLimit}>
|
||||
<span class="progress-label">{deckBuilder.total}/{deckSizeLimit} cards</span>
|
||||
<span class="progress-track"><span class="progress-fill" style="width: {fillPercent}%"></span></span>
|
||||
</div>
|
||||
|
||||
{#if isEmpty}
|
||||
<p class="empty muted">Hover a card and press + to add it. Up to 4 copies of a card, any number of basic energy.</p>
|
||||
{:else}
|
||||
<div class="sections">
|
||||
{#each deckBuilder.sections as section (section.category)}
|
||||
<section>
|
||||
<h4>{section.title} <span class="muted">{section.total}</span></h4>
|
||||
<ul>
|
||||
{#each section.entries as entry (entry.card.id)}
|
||||
<li class="entry">
|
||||
<img src={entry.card.image} alt="" data-no-viewer />
|
||||
<span class="entry-count">{entry.count}</span>
|
||||
<span class="entry-name" title={entry.card.name}>{entry.card.name}</span>
|
||||
<span class="entry-buttons">
|
||||
<button aria-label="Remove one {entry.card.name}" onclick={() => deckBuilder.remove(entry.card.id)}>
|
||||
<Icon icon="mdi:minus" width="14" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Add one {entry.card.name}"
|
||||
disabled={!deckBuilder.canAdd(entry.card)}
|
||||
onclick={() => deckBuilder.increase(entry.card.id)}
|
||||
>
|
||||
<Icon icon="mdi:plus" width="14" />
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</BtopBox>
|
||||
|
||||
<BtopBox title="export" number={4} color="yellow">
|
||||
<div class="actions">
|
||||
<button disabled={isEmpty} onclick={copyDeck}><Icon icon="mdi:content-copy" width="15" /> copy list</button>
|
||||
<button disabled={isEmpty} onclick={() => downloadDeckText(deckBuilder.name, deckBuilder.exportText())}>
|
||||
<Icon icon="mdi:file-document-outline" width="15" /> .txt
|
||||
</button>
|
||||
<button disabled={isEmpty} onclick={() => downloadDeckJson(deckBuilder.name, deckBuilder.entries)}>
|
||||
<Icon icon="mdi:code-json" width="15" /> .json
|
||||
</button>
|
||||
<button class="danger" disabled={isEmpty} onclick={clearDeck}><Icon icon="mdi:delete-outline" width="15" /> clear</button>
|
||||
</div>
|
||||
{#if exportStatus}
|
||||
<p class="status">{exportStatus}</p>
|
||||
{/if}
|
||||
</BtopBox>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.deck-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.deck-name {
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.2rem 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px dashed var(--border-inactive);
|
||||
outline: none;
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.deck-name:focus {
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 0.6rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 6px;
|
||||
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--yellow);
|
||||
transition: width 0.15s;
|
||||
}
|
||||
|
||||
.progress.full .progress-fill {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0 0 0.3rem;
|
||||
color: var(--orange);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: grid;
|
||||
grid-template-columns: 22px 1.4rem minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.15rem 0;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.entry img {
|
||||
width: 22px;
|
||||
aspect-ratio: 63 / 88;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.entry-count {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.entry-name {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.entry-buttons {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.entry-buttons button,
|
||||
.actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: 1px solid var(--border-inactive);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.entry-buttons button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.entry-buttons button:hover:not(:disabled),
|
||||
.actions button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
gap: 0.3rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.actions .danger:hover:not(:disabled) {
|
||||
border-color: var(--red);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--green);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,194 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { cardApiUrl } from '#lib/tcg/api';
|
||||
import type { TcgCard } from '#lib/tcg/types';
|
||||
import BtopBox from '../btop/BtopBox.svelte';
|
||||
import EnergyTag from './EnergyTag.svelte';
|
||||
|
||||
let { card }: { card: TcgCard } = $props();
|
||||
|
||||
const cardKind = $derived([card.supertype, ...(card.subtypes ?? [])].filter(Boolean).join(' / '));
|
||||
const setNumber = $derived(card.set.printedTotal ? `${card.number}/${card.set.printedTotal}` : card.number);
|
||||
const hasMoves = $derived((card.abilities?.length ?? 0) + (card.attacks?.length ?? 0) > 0);
|
||||
</script>
|
||||
|
||||
<article class="card-detail">
|
||||
<div class="image-column">
|
||||
<img class="card-image" src={card.images.large} alt={card.name} />
|
||||
<p class="muted zoom-hint">click the card to zoom</p>
|
||||
</div>
|
||||
|
||||
<div class="info-column">
|
||||
<BtopBox title="info" number={1}>
|
||||
<dl class="facts">
|
||||
{#if cardKind}<dt>type</dt><dd>{cardKind}</dd>{/if}
|
||||
{#if card.hp}<dt>hp</dt><dd class="hp">{card.hp}</dd>{/if}
|
||||
{#if card.types?.length}
|
||||
<dt>energy</dt>
|
||||
<dd class="tags">{#each card.types as type (type)}<EnergyTag {type} />{/each}</dd>
|
||||
{/if}
|
||||
<dt>set</dt><dd>{card.set.name} <span class="muted">{card.set.series}</span></dd>
|
||||
{#if setNumber}<dt>number</dt><dd>{setNumber}</dd>{/if}
|
||||
{#if card.rarity}<dt>rarity</dt><dd>{card.rarity}</dd>{/if}
|
||||
{#if card.weaknesses?.length}
|
||||
<dt>weakness</dt>
|
||||
<dd>{card.weaknesses.map((weakness) => `${weakness.type} ${weakness.value}`).join(', ')}</dd>
|
||||
{/if}
|
||||
{#if card.resistances?.length}
|
||||
<dt>resistance</dt>
|
||||
<dd>{card.resistances.map((resistance) => `${resistance.type} ${resistance.value}`).join(', ')}</dd>
|
||||
{/if}
|
||||
{#if card.artist}<dt>artist</dt><dd>{card.artist}</dd>{/if}
|
||||
</dl>
|
||||
</BtopBox>
|
||||
|
||||
{#if hasMoves}
|
||||
<BtopBox title="moves" number={2} color="red">
|
||||
<div class="moves">
|
||||
{#each card.abilities ?? [] as ability (ability.name)}
|
||||
<section class="move">
|
||||
<h3><span class="ability-label">{ability.type ?? 'Ability'}</span> {ability.name}</h3>
|
||||
{#if ability.text}<p>{ability.text}</p>{/if}
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
{#each card.attacks ?? [] as attack (attack.name)}
|
||||
<section class="move">
|
||||
<h3>
|
||||
<span class="tags">{#each attack.cost ?? [] as cost, index (index)}<EnergyTag type={cost} />{/each}</span>
|
||||
{attack.name}
|
||||
{#if attack.damage}<span class="damage">{attack.damage}</span>{/if}
|
||||
</h3>
|
||||
{#if attack.text}<p>{attack.text}</p>{/if}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
</BtopBox>
|
||||
{/if}
|
||||
|
||||
{#if card.flavorText}
|
||||
<p class="flavor muted">"{card.flavorText}"</p>
|
||||
{/if}
|
||||
|
||||
<a class="api-link" href={cardApiUrl(card.id)} target="_blank" rel="noopener noreferrer">
|
||||
<Icon icon="mdi:code-json" width="16" /> {card.id}.json
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.card-detail {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 300px) minmax(0, 1fr);
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.image-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
width: 100%;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.zoom-hint {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.info-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
padding-top: 0.6rem;
|
||||
}
|
||||
|
||||
.facts {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.3rem 1rem;
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hp {
|
||||
color: var(--red);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.moves {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.move + .move {
|
||||
padding-top: 0.6rem;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||
}
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.ability-label {
|
||||
color: var(--magenta);
|
||||
}
|
||||
|
||||
.damage {
|
||||
margin-left: auto;
|
||||
color: var(--yellow);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.flavor {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.api-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
@container (max-width: 640px) {
|
||||
.card-detail {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.card-image {
|
||||
max-width: 260px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import type { TcgCard } from '#lib/tcg/types';
|
||||
|
||||
let {
|
||||
cards,
|
||||
countOf,
|
||||
canAdd,
|
||||
onopen,
|
||||
onadd
|
||||
}: {
|
||||
cards: TcgCard[];
|
||||
countOf: (card: TcgCard) => number;
|
||||
canAdd: (card: TcgCard) => boolean;
|
||||
onopen: (card: TcgCard) => void;
|
||||
onadd: (card: TcgCard) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<ul class="card-grid">
|
||||
{#each cards as card (card.id)}
|
||||
{@const count = countOf(card)}
|
||||
<li class="card" class:in-deck={count > 0}>
|
||||
<button class="art" title="{card.name} ({card.set.name})" onclick={() => onopen(card)}>
|
||||
<img src={card.images.small} alt={card.name} loading="lazy" data-no-viewer />
|
||||
</button>
|
||||
{#if count > 0}
|
||||
<span class="count">x{count}</span>
|
||||
{/if}
|
||||
<button class="add" disabled={!canAdd(card)} aria-label="Add {card.name} to deck" onclick={() => onadd(card)}>
|
||||
<Icon icon="mdi:plus" width="18" />
|
||||
</button>
|
||||
<span class="card-name">{card.name}</span>
|
||||
<span class="card-set muted">{card.set.name}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 1rem 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.art {
|
||||
display: block;
|
||||
padding: 0;
|
||||
background: color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
border: 2px solid transparent;
|
||||
transition:
|
||||
transform 0.12s,
|
||||
border-color 0.12s;
|
||||
}
|
||||
|
||||
.art:hover,
|
||||
.art:focus-visible {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.in-deck .art {
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 63 / 88;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.count {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
padding: 0 0.35rem;
|
||||
background: var(--green);
|
||||
color: var(--background);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.add {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--accent);
|
||||
color: var(--accent);
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
|
||||
.card:hover .add,
|
||||
.add:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.add:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
color: var(--background);
|
||||
}
|
||||
|
||||
.add:disabled {
|
||||
border-color: var(--border-inactive);
|
||||
color: var(--muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.card-name,
|
||||
.card-set {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
margin-top: 0.3rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.card-set {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.add {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { deckBuilder } from '#lib/tcg/deckBuilder.svelte';
|
||||
import type { TcgCard } from '#lib/tcg/types';
|
||||
import CardDetail from './CardDetail.svelte';
|
||||
|
||||
let { card, canBuild, onclose }: { card: TcgCard; canBuild: boolean; onclose: () => void } = $props();
|
||||
|
||||
let dialogElement = $state<HTMLDivElement>();
|
||||
|
||||
const countInDeck = $derived(deckBuilder.countOf(card.id));
|
||||
|
||||
$effect(() => {
|
||||
dialogElement?.focus({ preventScroll: true });
|
||||
});
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
onclose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="modal-layer">
|
||||
<button class="backdrop" aria-label="Close card" onclick={onclose}></button>
|
||||
<div class="dialog" role="dialog" aria-label={card.name} tabindex="-1" bind:this={dialogElement} onkeydown={handleKeydown}>
|
||||
<header class="dialog-header">
|
||||
<h2>{card.name}</h2>
|
||||
<div class="header-actions">
|
||||
{#if canBuild}
|
||||
<span class="in-deck muted">{countInDeck} in deck</span>
|
||||
<button class="deck-button" disabled={countInDeck === 0} onclick={() => deckBuilder.remove(card.id)} aria-label="Remove one from deck">
|
||||
<Icon icon="mdi:minus" width="16" />
|
||||
</button>
|
||||
<button class="deck-button add" disabled={!deckBuilder.canAdd(card)} onclick={() => deckBuilder.add(card)}>
|
||||
<Icon icon="mdi:plus" width="16" /> add to deck
|
||||
</button>
|
||||
{/if}
|
||||
<button class="close-button" aria-label="Close card" onclick={onclose}>
|
||||
<Icon icon="mdi:close" width="18" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="dialog-body">
|
||||
<CardDetail {card} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: color-mix(in srgb, var(--dark-background, var(--background)) 75%, transparent);
|
||||
border: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(880px, 100%);
|
||||
max-height: 100%;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--accent);
|
||||
box-shadow: 0 16px 40px rgb(0 0 0 / 0.5);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 1rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.in-deck {
|
||||
margin-right: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.deck-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
height: 28px;
|
||||
padding: 0 0.5rem;
|
||||
background: none;
|
||||
border: 1px solid var(--border-inactive);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.deck-button.add {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.deck-button:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--background);
|
||||
}
|
||||
|
||||
.deck-button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 1rem 1.1rem 1.25rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,244 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { buildCardQuery, cardsPerPage, searchCards } from '#lib/tcg/api';
|
||||
import type { TcgBrowserState } from '#lib/tcg/browserState.svelte';
|
||||
import { deckBuilder } from '#lib/tcg/deckBuilder.svelte';
|
||||
import type { TcgCard } from '#lib/tcg/types';
|
||||
import CardGrid from './CardGrid.svelte';
|
||||
import TcgPager from './TcgPager.svelte';
|
||||
|
||||
let { browser }: { browser: TcgBrowserState } = $props();
|
||||
|
||||
const searchDelayMilliseconds = 350;
|
||||
|
||||
let nameInput = $state(untrack(() => browser.searchedName));
|
||||
let cards = $state<TcgCard[]>([]);
|
||||
let totalCount = $state(0);
|
||||
let isLoading = $state(true);
|
||||
let errorMessage = $state('');
|
||||
let resultsElement = $state<HTMLDivElement>();
|
||||
|
||||
const search = $derived({
|
||||
name: browser.searchedName,
|
||||
supertype: browser.supertype,
|
||||
energyType: browser.energyType,
|
||||
setId: browser.setId,
|
||||
page: browser.page
|
||||
});
|
||||
const queryPreview = $derived(buildCardQuery(search) || '*');
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(totalCount / cardsPerPage)));
|
||||
|
||||
function handleSearchKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') browser.setSearchedName(nameInput);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const name = nameInput;
|
||||
const timer = setTimeout(() => browser.setSearchedName(name), searchDelayMilliseconds);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const currentSearch = search;
|
||||
const controller = new AbortController();
|
||||
isLoading = true;
|
||||
errorMessage = '';
|
||||
|
||||
searchCards(currentSearch, controller.signal)
|
||||
.then((result) => {
|
||||
cards = result.data;
|
||||
totalCount = result.totalCount;
|
||||
isLoading = false;
|
||||
resultsElement?.scrollTo({ top: 0 });
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
if (controller.signal.aborted) return;
|
||||
errorMessage = error.message;
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="card-search">
|
||||
<header class="toolbar">
|
||||
<label class="prompt">
|
||||
<span class="prompt-symbol">tcg></span>
|
||||
<input bind:value={nameInput} onkeydown={handleSearchKeydown} placeholder="search cards by name" spellcheck="false" />
|
||||
</label>
|
||||
<div class="status">
|
||||
<code class="query muted" title="Query sent to the API">q={queryPreview}</code>
|
||||
<span class="count muted">{isLoading ? 'loading...' : `${totalCount.toLocaleString()} cards`}</span>
|
||||
<TcgPager bind:page={browser.page} {totalPages} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="results" bind:this={resultsElement}>
|
||||
{#if browser.selectedSet}
|
||||
{@const set = browser.selectedSet}
|
||||
<div class="set-banner">
|
||||
{#if set.images?.logo}
|
||||
<img class="set-logo" src={set.images.logo} alt={set.name} data-no-viewer />
|
||||
{/if}
|
||||
<div class="set-info">
|
||||
<span class="set-name">{set.name}</span>
|
||||
<span class="muted">
|
||||
{set.series}{set.releaseDate ? `, released ${set.releaseDate}` : ''}{set.printedTotal ? `, ${set.printedTotal} cards` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<button class="clear-set" onclick={() => browser.toggleSet(null)}>clear set</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if errorMessage}
|
||||
<p class="message error">error: {errorMessage}</p>
|
||||
{:else if !isLoading && cards.length === 0}
|
||||
<p class="message muted">No cards match that search.</p>
|
||||
{:else}
|
||||
<div class:faded={isLoading}>
|
||||
<CardGrid
|
||||
{cards}
|
||||
countOf={(card) => deckBuilder.countOf(card.id)}
|
||||
canAdd={(card) => deckBuilder.canAdd(card)}
|
||||
onopen={(card) => browser.openCard(card)}
|
||||
onadd={(card) => deckBuilder.add(card)}
|
||||
/>
|
||||
</div>
|
||||
<div class="bottom-pager">
|
||||
<TcgPager bind:page={browser.page} {totalPages} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem 1rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
.prompt {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.prompt-symbol {
|
||||
color: var(--green);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.prompt input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.2rem 0;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
color: var(--foreground);
|
||||
font: inherit;
|
||||
caret-color: var(--accent);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem 1rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.query {
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.results {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.9rem 0.75rem;
|
||||
}
|
||||
|
||||
.set-banner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--cyan);
|
||||
}
|
||||
|
||||
.set-logo {
|
||||
max-width: 140px;
|
||||
max-height: 48px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.set-info {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.set-name {
|
||||
color: var(--cyan);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.clear-set {
|
||||
padding: 0.2rem 0.6rem;
|
||||
background: none;
|
||||
border: 1px solid var(--border-inactive);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.clear-set:hover {
|
||||
border-color: var(--red);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.bottom-pager {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.faded {
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin: 1rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--red);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { TcgBrowserState } from '#lib/tcg/browserState.svelte';
|
||||
import { deckBuilder } from '#lib/tcg/deckBuilder.svelte';
|
||||
import FilterSidebar from './FilterSidebar.svelte';
|
||||
import CardSearchView from './CardSearchView.svelte';
|
||||
import BuilderDeckPanel from './BuilderDeckPanel.svelte';
|
||||
|
||||
let { browser }: { browser: TcgBrowserState } = $props();
|
||||
|
||||
onMount(() => deckBuilder.load());
|
||||
</script>
|
||||
|
||||
<div class="deck-builder">
|
||||
<div class="side filters-column">
|
||||
<FilterSidebar {browser} />
|
||||
</div>
|
||||
<div class="search-column">
|
||||
<CardSearchView {browser} />
|
||||
</div>
|
||||
<div class="side deck-column">
|
||||
<BuilderDeckPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.deck-builder {
|
||||
display: grid;
|
||||
grid-template-columns: 250px minmax(0, 1fr) 290px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.side {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.filters-column {
|
||||
border-right: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
.deck-column {
|
||||
border-left: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
.search-column {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@container (max-width: 980px) {
|
||||
.deck-builder {
|
||||
grid-template-columns: minmax(0, 1fr) 270px;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.filters-column {
|
||||
grid-column: 1 / -1;
|
||||
max-height: 240px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@container (max-width: 640px) {
|
||||
.deck-builder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.filters-column {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.search-column {
|
||||
height: 70vh;
|
||||
}
|
||||
|
||||
.deck-column {
|
||||
border-left: none;
|
||||
border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import type { DeckCard } from '#lib/content';
|
||||
import type { TcgCard } from '#lib/tcg/types';
|
||||
|
||||
let {
|
||||
deckCard,
|
||||
card,
|
||||
onopen
|
||||
}: {
|
||||
deckCard: DeckCard;
|
||||
card: TcgCard | null | undefined;
|
||||
onopen: (card: TcgCard) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<button class="tile" disabled={!card} title={deckCard.name} onclick={() => card && onopen(card)}>
|
||||
<span class="art">
|
||||
{#if card}
|
||||
<img src={card.images.small} alt={deckCard.name} loading="lazy" data-no-viewer />
|
||||
{:else}
|
||||
<span class="placeholder muted">{card === null ? 'not found' : 'loading...'}</span>
|
||||
{/if}
|
||||
<span class="count">x{deckCard.count}</span>
|
||||
</span>
|
||||
<span class="name">{deckCard.name}</span>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tile:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.art {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
aspect-ratio: 63 / 88;
|
||||
background: color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
border: 2px solid transparent;
|
||||
transition:
|
||||
transform 0.12s,
|
||||
border-color 0.12s;
|
||||
}
|
||||
|
||||
.tile:hover:not(:disabled) .art,
|
||||
.tile:focus-visible .art {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.tile:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
padding: 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.count {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
padding: 0 0.4rem;
|
||||
background: var(--accent);
|
||||
color: var(--background);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
font-size: 0.78rem;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import type { TcgDeck } from '#lib/content';
|
||||
import { deckCardKey, deckSections, deckTotal, lookUpDeckCard } from '#lib/tcg/decks';
|
||||
import type { TcgCard } from '#lib/tcg/types';
|
||||
import DeckCardTile from './DeckCardTile.svelte';
|
||||
|
||||
let { deck, onopen }: { deck: TcgDeck; onopen: (card: TcgCard) => void } = $props();
|
||||
|
||||
const lookupConcurrency = 4;
|
||||
|
||||
let foundCards = $state<Record<string, TcgCard | null>>({});
|
||||
|
||||
const sections = $derived(deckSections(deck));
|
||||
|
||||
$effect(() => {
|
||||
const currentDeck = deck;
|
||||
let cancelled = false;
|
||||
|
||||
untrack(() => {
|
||||
const queue = currentDeck.cards.filter((card) => !(deckCardKey(card) in foundCards));
|
||||
const lookUpNext = async () => {
|
||||
while (!cancelled && queue.length > 0) {
|
||||
const card = queue.shift()!;
|
||||
foundCards[deckCardKey(card)] = await lookUpDeckCard(card).catch(() => null);
|
||||
}
|
||||
};
|
||||
for (let worker = 0; worker < lookupConcurrency; worker++) lookUpNext();
|
||||
});
|
||||
|
||||
return () => (cancelled = true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="deck-view">
|
||||
<header class="deck-header">
|
||||
<h2>{deck.name}</h2>
|
||||
<div class="summary">
|
||||
<span class="total">{deckTotal(deck)} cards</span>
|
||||
{#each sections as section (section.category)}
|
||||
<span class="summary-item {section.category}">{section.title} {section.total}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#each sections as section (section.category)}
|
||||
<section class="deck-section">
|
||||
<h3 class={section.category}>{section.title} <span class="muted">{section.total}</span></h3>
|
||||
<ul class="tile-grid">
|
||||
{#each section.cards as deckCard (deckCardKey(deckCard))}
|
||||
<li>
|
||||
<DeckCardTile {deckCard} card={foundCards[deckCardKey(deckCard)]} {onopen} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.deck-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.deck-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem 1rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.total {
|
||||
color: var(--foreground);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.pokemon {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.trainer {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
.energy {
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.tile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
gap: 0.9rem 0.75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { energyColor } from '#lib/tcg/energy';
|
||||
|
||||
let { type }: { type: string } = $props();
|
||||
</script>
|
||||
|
||||
<span class="energy-tag" style="--energy-color: {energyColor(type)}">{type}</span>
|
||||
|
||||
<style>
|
||||
.energy-tag {
|
||||
display: inline-block;
|
||||
padding: 0 0.4rem;
|
||||
border: 1px solid var(--energy-color);
|
||||
color: var(--energy-color);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchEnergyTypes, fetchSupertypes } from '#lib/tcg/api';
|
||||
import type { TcgBrowserState } from '#lib/tcg/browserState.svelte';
|
||||
import { energyColor } from '#lib/tcg/energy';
|
||||
import BtopBox from '../btop/BtopBox.svelte';
|
||||
import SetFilter from './SetFilter.svelte';
|
||||
|
||||
let { browser }: { browser: TcgBrowserState } = $props();
|
||||
|
||||
let supertypes = $state<string[]>([]);
|
||||
let energyTypes = $state<string[]>([]);
|
||||
|
||||
onMount(() => {
|
||||
const controller = new AbortController();
|
||||
fetchSupertypes(controller.signal)
|
||||
.then((values) => (supertypes = values))
|
||||
.catch(() => (supertypes = []));
|
||||
fetchEnergyTypes(controller.signal)
|
||||
.then((values) => (energyTypes = values))
|
||||
.catch(() => (energyTypes = []));
|
||||
return () => controller.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<aside class="filters">
|
||||
<BtopBox title="kind" number={1}>
|
||||
<div class="chips">
|
||||
{#each supertypes as supertype (supertype)}
|
||||
<button class="chip" class:active={browser.supertype === supertype} onclick={() => browser.toggleSupertype(supertype)}>
|
||||
{supertype}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</BtopBox>
|
||||
|
||||
<BtopBox title="energy" number={2} color="magenta">
|
||||
<div class="chips">
|
||||
{#each energyTypes as energyType (energyType)}
|
||||
<button
|
||||
class="chip energy"
|
||||
class:active={browser.energyType === energyType}
|
||||
style="--chip-color: {energyColor(energyType)}"
|
||||
onclick={() => browser.toggleEnergyType(energyType)}
|
||||
>
|
||||
<span class="dot"></span>{energyType}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</BtopBox>
|
||||
|
||||
<SetFilter {browser} />
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
--chip-color: var(--accent);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.1rem 0.5rem;
|
||||
background: none;
|
||||
border: 1px solid var(--border-inactive);
|
||||
color: var(--foreground);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.chip:hover {
|
||||
border-color: var(--chip-color);
|
||||
}
|
||||
|
||||
.chip.active {
|
||||
background: var(--chip-color);
|
||||
border-color: var(--chip-color);
|
||||
color: var(--background);
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: var(--chip-color);
|
||||
}
|
||||
|
||||
.chip.active .dot {
|
||||
background: var(--background);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { tcgDecks } from '#lib/content';
|
||||
import type { TcgBrowserState } from '#lib/tcg/browserState.svelte';
|
||||
import { deckBuilder } from '#lib/tcg/deckBuilder.svelte';
|
||||
import { deckBuilderEntries, deckTotal } from '#lib/tcg/decks';
|
||||
import BtopBox from '../btop/BtopBox.svelte';
|
||||
import DeckView from './DeckView.svelte';
|
||||
|
||||
let { browser }: { browser: TcgBrowserState } = $props();
|
||||
|
||||
let isCopying = $state(false);
|
||||
|
||||
const selectedDeck = $derived(tcgDecks.find((deck) => deck.id === browser.deckId));
|
||||
|
||||
async function openInBuilder() {
|
||||
if (!selectedDeck || isCopying) return;
|
||||
deckBuilder.load();
|
||||
if (deckBuilder.total > 0 && !window.confirm(`Replace ${deckBuilder.name} in the deck builder with ${selectedDeck.name}?`)) return;
|
||||
isCopying = true;
|
||||
const entries = await deckBuilderEntries(selectedDeck);
|
||||
deckBuilder.replace(selectedDeck.name, entries);
|
||||
isCopying = false;
|
||||
browser.tab = 'builder';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="my-decks">
|
||||
<aside class="deck-list">
|
||||
<BtopBox title="decks" number={1}>
|
||||
{#if tcgDecks.length === 0}
|
||||
<p class="muted">No decks yet.</p>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each tcgDecks as deck (deck.id)}
|
||||
<li>
|
||||
<button class:active={deck.id === browser.deckId} onclick={() => (browser.deckId = deck.id)}>
|
||||
<span class="deck-name">{deck.name}</span>
|
||||
<span class="muted">{deckTotal(deck)}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</BtopBox>
|
||||
|
||||
{#if selectedDeck}
|
||||
<BtopBox title="actions" number={2} color="yellow">
|
||||
<button class="action" disabled={isCopying} onclick={openInBuilder}>
|
||||
<Icon icon="mdi:hammer-wrench" width="15" />
|
||||
{isCopying ? 'loading cards...' : 'open in deck builder'}
|
||||
</button>
|
||||
</BtopBox>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<div class="deck-main">
|
||||
{#if selectedDeck}
|
||||
<DeckView deck={selectedDeck} onopen={(card) => browser.openCard(card)} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.my-decks {
|
||||
display: grid;
|
||||
grid-template-columns: 230px minmax(0, 1fr);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.deck-list,
|
||||
.deck-main {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.deck-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
padding-top: 1.25rem;
|
||||
border-right: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
.deck-main {
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li button {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.25rem 0.4rem;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
li button:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
li button.active {
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.deck-name {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: none;
|
||||
border: 1px solid var(--border-inactive);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.action:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
@container (max-width: 640px) {
|
||||
.my-decks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.deck-list {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,180 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { fetchSets } from '#lib/tcg/api';
|
||||
import type { TcgBrowserState } from '#lib/tcg/browserState.svelte';
|
||||
import type { TcgSet } from '#lib/tcg/types';
|
||||
import BtopBox from '../btop/BtopBox.svelte';
|
||||
|
||||
let { browser }: { browser: TcgBrowserState } = $props();
|
||||
|
||||
let sets = $state<TcgSet[]>([]);
|
||||
let setQuery = $state('');
|
||||
let isLoading = $state(true);
|
||||
let hasFailed = $state(false);
|
||||
|
||||
const seriesGroups = $derived.by(() => {
|
||||
const query = setQuery.trim().toLowerCase();
|
||||
const groups: { series: string; sets: TcgSet[] }[] = [];
|
||||
sets
|
||||
.filter((set) => !query || `${set.name} ${set.series} ${set.ptcgoCode ?? ''} ${set.id}`.toLowerCase().includes(query))
|
||||
.forEach((set) => {
|
||||
const group = groups.find((item) => item.series === set.series);
|
||||
if (group) group.sets.push(set);
|
||||
else groups.push({ series: set.series, sets: [set] });
|
||||
});
|
||||
return groups;
|
||||
});
|
||||
|
||||
function releaseYear(set: TcgSet): string {
|
||||
return set.releaseDate?.slice(0, 4) ?? '';
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const controller = new AbortController();
|
||||
fetchSets(controller.signal)
|
||||
.then((result) => (sets = result))
|
||||
.catch(() => (hasFailed = !controller.signal.aborted))
|
||||
.finally(() => (isLoading = false));
|
||||
return () => controller.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<BtopBox title="sets" number={3} color="cyan">
|
||||
<label class="set-search">
|
||||
<Icon icon="mdi:magnify" width="15" />
|
||||
<input bind:value={setQuery} placeholder="filter sets" spellcheck="false" />
|
||||
</label>
|
||||
|
||||
<div class="set-list">
|
||||
{#if isLoading}
|
||||
<p class="muted">loading sets...</p>
|
||||
{:else if hasFailed}
|
||||
<p class="error">could not load sets</p>
|
||||
{:else if seriesGroups.length === 0}
|
||||
<p class="muted">no sets match</p>
|
||||
{:else}
|
||||
{#each seriesGroups as group (group.series)}
|
||||
<section>
|
||||
<h4>{group.series}</h4>
|
||||
<ul>
|
||||
{#each group.sets as set (set.id)}
|
||||
<li>
|
||||
<button class="set-row" class:active={browser.setId === set.id} title="{set.name} ({set.id})" onclick={() => browser.toggleSet(set)}>
|
||||
<span class="symbol">
|
||||
{#if set.images?.symbol}
|
||||
<img src={set.images.symbol} alt="" loading="lazy" data-no-viewer />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="set-name">{set.name}</span>
|
||||
<span class="set-code muted">{set.ptcgoCode ?? releaseYear(set)}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</BtopBox>
|
||||
|
||||
<style>
|
||||
.set-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.25rem;
|
||||
border-bottom: 1px dashed var(--border-inactive);
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.set-search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
color: var(--foreground);
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.set-list {
|
||||
max-height: 340px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
h4 {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
margin: 0.4rem 0 0.15rem;
|
||||
padding: 0.1rem 0;
|
||||
background: var(--background);
|
||||
color: var(--orange);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.set-row {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0.15rem 0.3rem;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.set-row:hover {
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.set-row.active {
|
||||
background: color-mix(in srgb, var(--cyan) 20%, transparent);
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.symbol {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.symbol img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.set-name {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.set-code {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--red);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { TcgBrowserState, type BrowserTab } from '#lib/tcg/browserState.svelte';
|
||||
import { deckBuilder } from '#lib/tcg/deckBuilder.svelte';
|
||||
import MyDecksTab from './MyDecksTab.svelte';
|
||||
import DeckBuilderTab from './DeckBuilderTab.svelte';
|
||||
import CardModal from './CardModal.svelte';
|
||||
|
||||
const browser = new TcgBrowserState();
|
||||
|
||||
const tabs: { id: BrowserTab; label: string; icon: string }[] = [
|
||||
{ id: 'decks', label: 'my decks', icon: 'ph:cards-duotone' },
|
||||
{ id: 'builder', label: 'deck builder', icon: 'mdi:hammer-wrench' }
|
||||
];
|
||||
|
||||
onMount(() => deckBuilder.load());
|
||||
</script>
|
||||
|
||||
<div class="tcg-browser">
|
||||
<nav class="tabs" aria-label="Card browser sections">
|
||||
{#each tabs as tab (tab.id)}
|
||||
<button class:active={browser.tab === tab.id} onclick={() => (browser.tab = tab.id)}>
|
||||
<Icon icon={tab.icon} width="16" />
|
||||
{tab.label}
|
||||
{#if tab.id === 'builder' && deckBuilder.total > 0}
|
||||
<span class="badge">{deckBuilder.total}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<span class="source muted">tcg.sirblob.co</span>
|
||||
</nav>
|
||||
|
||||
<div class="tab-content">
|
||||
{#if browser.tab === 'decks'}
|
||||
<MyDecksTab {browser} />
|
||||
{:else}
|
||||
<DeckBuilderTab {browser} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if browser.openedCard}
|
||||
<CardModal card={browser.openedCard} canBuild={browser.tab === 'builder'} onclose={() => browser.openCard(null)} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tcg-browser {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.25rem;
|
||||
padding: 0 0.75rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.tabs button:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
border-bottom-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0 0.35rem;
|
||||
background: var(--accent);
|
||||
color: var(--background);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.source {
|
||||
align-self: center;
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@container (max-width: 640px) {
|
||||
.source {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let { page = $bindable(), totalPages }: { page: number; totalPages: number } = $props();
|
||||
</script>
|
||||
|
||||
<nav class="pager" aria-label="Pages">
|
||||
<button disabled={page <= 1} aria-label="Previous page" onclick={() => page--}>
|
||||
<Icon icon="mdi:chevron-left" width="18" />
|
||||
</button>
|
||||
<span class="position"><span class="current">{page}</span><span class="muted">/{totalPages}</span></span>
|
||||
<button disabled={page >= totalPages} aria-label="Next page" onclick={() => page++}>
|
||||
<Icon icon="mdi:chevron-right" width="18" />
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.pager {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: 1px solid var(--border-inactive);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.position {
|
||||
min-width: 4.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.current {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -25,7 +25,7 @@
|
||||
const context: CommandContext = {
|
||||
print: (...newBlocks) => blocks.push(...newBlocks),
|
||||
clear: () => (blocks = []),
|
||||
exit: onexit,
|
||||
exit: () => onexit(),
|
||||
openPage,
|
||||
openUrl,
|
||||
setTheme: (themeId) => themeState.set(themeId),
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
'0': controls.fit,
|
||||
r: controls.rotateRight,
|
||||
R: controls.rotateLeft,
|
||||
Escape: onclose
|
||||
Escape: () => onclose()
|
||||
};
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import packagesData from '#content/projects/packages.json';
|
||||
import hackathonsData from '#content/projects/hackathons.json';
|
||||
import researchData from '#content/projects/research.json';
|
||||
import organizationsData from '#content/projects/organizations.json';
|
||||
import tcgDecksData from '#content/tcgDecks.json';
|
||||
import type {
|
||||
AboutContent,
|
||||
BlogSettings,
|
||||
@@ -21,7 +22,8 @@ import type {
|
||||
Project,
|
||||
ProjectCategory,
|
||||
ProjectCategoryId,
|
||||
ResearchGroup
|
||||
ResearchGroup,
|
||||
TcgDeck
|
||||
} from './types';
|
||||
|
||||
export type * from './types';
|
||||
@@ -34,6 +36,7 @@ export const desktop: DesktopContent = desktopData;
|
||||
export const blogSettings: BlogSettings = blogData;
|
||||
export const playlist: Playlist = musicData;
|
||||
export const researchGroups: ResearchGroup[] = researchData;
|
||||
export const tcgDecks: TcgDeck[] = tcgDecksData as TcgDeck[];
|
||||
|
||||
function featuredFirst(projects: Project[]): Project[] {
|
||||
return [...projects].sort((first, second) => Number(!!second.featured) - Number(!!first.featured));
|
||||
|
||||
@@ -139,6 +139,17 @@ export interface RatedTitle {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type BookStatus = 'read' | 'reading';
|
||||
|
||||
export interface Book {
|
||||
title: string;
|
||||
author: string;
|
||||
rating?: number;
|
||||
isbn?: string;
|
||||
url?: string;
|
||||
status?: BookStatus;
|
||||
}
|
||||
|
||||
export interface LabeledValue {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -161,14 +172,20 @@ export interface AboutContent {
|
||||
games: Game[];
|
||||
shows: RatedTitle[];
|
||||
movies: RatedTitle[];
|
||||
books?: Book[];
|
||||
}
|
||||
|
||||
export type DesktopApp = 'btop';
|
||||
export type GameName = 'snake' | 'breakout';
|
||||
|
||||
export type DesktopApp = 'btop' | 'tcg' | 'pet' | GameName;
|
||||
|
||||
export type DesktopAction = { page: string } | { terminal: string } | { url: string } | { app: DesktopApp };
|
||||
|
||||
export type DesktopSide = 'left' | 'right';
|
||||
|
||||
export interface DesktopIconGroup {
|
||||
name: string;
|
||||
side?: DesktopSide;
|
||||
icons: DesktopIcon[];
|
||||
}
|
||||
|
||||
@@ -200,3 +217,18 @@ export interface BlogSettings {
|
||||
tagline: string;
|
||||
postsPerPage: number;
|
||||
}
|
||||
|
||||
export type DeckCategory = 'pokemon' | 'trainer' | 'energy';
|
||||
|
||||
export interface DeckCard {
|
||||
count: number;
|
||||
name: string;
|
||||
set?: string;
|
||||
category: DeckCategory;
|
||||
}
|
||||
|
||||
export interface TcgDeck {
|
||||
id: string;
|
||||
name: string;
|
||||
cards: DeckCard[];
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { desktop, type DesktopAction, type DesktopApp, type Workspace } from '#lib/content';
|
||||
import { petState } from '#lib/pet/petState.svelte';
|
||||
import { pageWindowId, windowManager } from './windows.svelte';
|
||||
|
||||
export function isExternalUrl(url: string): boolean {
|
||||
@@ -21,6 +22,9 @@ export function openPage(path: string) {
|
||||
|
||||
export function openApp(app: DesktopApp) {
|
||||
if (app === 'btop') windowManager.openSystemMonitor();
|
||||
else if (app === 'tcg') windowManager.openTcgBrowser();
|
||||
else if (app === 'pet') petState.toggle();
|
||||
else windowManager.openGame(app);
|
||||
}
|
||||
|
||||
export function runDesktopAction(action: DesktopAction) {
|
||||
|
||||
@@ -38,6 +38,24 @@ export const imageViewerKeybinds: KeybindEntry[] = [
|
||||
{ keys: ['Esc'], action: 'Close the viewer' }
|
||||
];
|
||||
|
||||
export const snakeKeybinds: KeybindEntry[] = [
|
||||
{ keys: ['Arrows'], action: 'Steer the snake' },
|
||||
{ keys: ['WASD'], action: 'Steer the snake' },
|
||||
{ keys: ['HJKL'], action: 'Steer the snake (vim style)' },
|
||||
{ keys: ['Space'], action: 'Start or pause' },
|
||||
{ keys: ['R'], action: 'Restart' }
|
||||
];
|
||||
|
||||
export const breakoutKeybinds: KeybindEntry[] = [
|
||||
{ keys: ['Left'], action: 'Move the paddle left' },
|
||||
{ keys: ['Right'], action: 'Move the paddle right' },
|
||||
{ keys: ['AD'], action: 'Move the paddle' },
|
||||
{ keys: ['HL'], action: 'Move the paddle (vim style)' },
|
||||
{ keys: ['Mouse'], action: 'Move the paddle' },
|
||||
{ keys: ['Space'], action: 'Launch or pause' },
|
||||
{ keys: ['R'], action: 'Restart' }
|
||||
];
|
||||
|
||||
export const terminalKeybinds: KeybindEntry[] = [
|
||||
{ keys: ['Tab'], action: 'Complete a command or page name' },
|
||||
{ keys: ['Up'], action: 'Previous command in history' },
|
||||
@@ -52,5 +70,6 @@ export const navigationTips: NavigationTip[] = [
|
||||
{ icon: 'ph:terminal-window-duotone', title: 'Terminal', description: 'Click the clock or press Alt + Enter. Type help to see every command, or cd <page> to open one.' },
|
||||
{ icon: 'ph:music-notes-duotone', title: 'Bar widgets', description: 'The music, Discord, battery and theme widgets on the right of the bar open more details when clicked.' },
|
||||
{ icon: 'ph:image-duotone', title: 'Images', description: 'Click any image to open it in the image viewer. Scroll or pinch to zoom and drag to pan.' },
|
||||
{ icon: 'ph:robot-duotone', title: 'Desktop robot', description: 'Right click the desktop to send the robot there, click it to say hi or drag it around. The robot icon or the pet command turns it on and off.' },
|
||||
{ icon: 'ph:device-mobile-duotone', title: 'Mobile', description: 'Windows open fullscreen. Use the tabs above the window to switch between them.' }
|
||||
];
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { GameName } from '#lib/content';
|
||||
|
||||
export const pageWindowId = 'page';
|
||||
|
||||
export interface TerminalWindow {
|
||||
@@ -20,6 +22,15 @@ export interface SystemMonitorWindow {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface TcgBrowserWindow {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface GameWindow {
|
||||
id: string;
|
||||
game: GameName;
|
||||
}
|
||||
|
||||
export interface FloatingRect {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -31,6 +42,8 @@ class WindowManager {
|
||||
terminals = $state<TerminalWindow[]>([]);
|
||||
imageViewers = $state<ImageViewerWindow[]>([]);
|
||||
systemMonitors = $state<SystemMonitorWindow[]>([]);
|
||||
games = $state<GameWindow[]>([]);
|
||||
tcgBrowsers = $state<TcgBrowserWindow[]>([]);
|
||||
floatingRects = $state<Record<string, FloatingRect | null>>({});
|
||||
focusedId = $state<string>(pageWindowId);
|
||||
maximizedId = $state<string | null>(null);
|
||||
@@ -39,12 +52,16 @@ class WindowManager {
|
||||
private nextTerminalNumber = 1;
|
||||
private nextImageViewerNumber = 1;
|
||||
private nextSystemMonitorNumber = 1;
|
||||
private nextGameNumber = 1;
|
||||
private nextTcgBrowserNumber = 1;
|
||||
|
||||
get extraWindowIds(): string[] {
|
||||
return [
|
||||
...this.terminals.map((terminal) => terminal.id),
|
||||
...this.imageViewers.map((viewer) => viewer.id),
|
||||
...this.systemMonitors.map((monitor) => monitor.id)
|
||||
...this.systemMonitors.map((monitor) => monitor.id),
|
||||
...this.games.map((gameWindow) => gameWindow.id),
|
||||
...this.tcgBrowsers.map((browser) => browser.id)
|
||||
];
|
||||
}
|
||||
|
||||
@@ -71,10 +88,25 @@ class WindowManager {
|
||||
this.focus(monitor.id);
|
||||
}
|
||||
|
||||
openGame(game: GameName) {
|
||||
const gameWindow = { id: `${game}-${this.nextGameNumber++}`, game };
|
||||
this.games.push(gameWindow);
|
||||
this.floatingRects[gameWindow.id] = null;
|
||||
this.focus(gameWindow.id);
|
||||
}
|
||||
|
||||
openTcgBrowser() {
|
||||
const browser = { id: `tcg-${this.nextTcgBrowserNumber++}` };
|
||||
this.tcgBrowsers.push(browser);
|
||||
this.focus(browser.id);
|
||||
}
|
||||
|
||||
closeWindow(id: string) {
|
||||
this.terminals = this.terminals.filter((terminal) => terminal.id !== id);
|
||||
this.imageViewers = this.imageViewers.filter((viewer) => viewer.id !== id);
|
||||
this.systemMonitors = this.systemMonitors.filter((monitor) => monitor.id !== id);
|
||||
this.games = this.games.filter((gameWindow) => gameWindow.id !== id);
|
||||
this.tcgBrowsers = this.tcgBrowsers.filter((browser) => browser.id !== id);
|
||||
delete this.floatingRects[id];
|
||||
if (this.maximizedId === id) this.maximizedId = null;
|
||||
if (this.focusedId === id) this.focusedId = this.extraWindowIds.at(-1) ?? pageWindowId;
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
export type BreakoutStatus = 'serving' | 'running' | 'paused' | 'over';
|
||||
|
||||
export interface Brick {
|
||||
x: number;
|
||||
y: number;
|
||||
row: number;
|
||||
broken: boolean;
|
||||
}
|
||||
|
||||
export interface Ball {
|
||||
x: number;
|
||||
y: number;
|
||||
velocityX: number;
|
||||
velocityY: number;
|
||||
}
|
||||
|
||||
export interface BreakoutGame {
|
||||
paddleX: number;
|
||||
ball: Ball;
|
||||
bricks: Brick[];
|
||||
score: number;
|
||||
lives: number;
|
||||
level: number;
|
||||
status: BreakoutStatus;
|
||||
}
|
||||
|
||||
export const fieldWidth = 480;
|
||||
export const fieldHeight = 360;
|
||||
export const paddleWidth = 72;
|
||||
export const paddleHeight = 8;
|
||||
export const paddleY = fieldHeight - 24;
|
||||
export const ballRadius = 5;
|
||||
export const brickRows = 6;
|
||||
export const brickColumns = 10;
|
||||
export const brickHeight = 14;
|
||||
|
||||
const brickGap = 4;
|
||||
const brickTop = 40;
|
||||
const fieldPadding = 8;
|
||||
const startingLives = 3;
|
||||
const startingBallSpeed = 260;
|
||||
const ballSpeedPerLevel = 30;
|
||||
const paddleSpeed = 420;
|
||||
const maxBounceAngle = Math.PI / 3;
|
||||
const maxStepDistance = ballRadius / 2;
|
||||
|
||||
export const brickWidth = (fieldWidth - fieldPadding * 2 - brickGap * (brickColumns - 1)) / brickColumns;
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(Math.max(value, minimum), maximum);
|
||||
}
|
||||
|
||||
function createBricks(): Brick[] {
|
||||
const bricks: Brick[] = [];
|
||||
for (let row = 0; row < brickRows; row++) {
|
||||
for (let column = 0; column < brickColumns; column++) {
|
||||
bricks.push({
|
||||
x: fieldPadding + column * (brickWidth + brickGap),
|
||||
y: brickTop + row * (brickHeight + brickGap),
|
||||
row,
|
||||
broken: false
|
||||
});
|
||||
}
|
||||
}
|
||||
return bricks;
|
||||
}
|
||||
|
||||
function ballSpeedForLevel(level: number): number {
|
||||
return startingBallSpeed + (level - 1) * ballSpeedPerLevel;
|
||||
}
|
||||
|
||||
function placeBallOnPaddle(game: BreakoutGame) {
|
||||
game.ball.x = game.paddleX + paddleWidth / 2;
|
||||
game.ball.y = paddleY - ballRadius;
|
||||
game.ball.velocityX = 0;
|
||||
game.ball.velocityY = 0;
|
||||
}
|
||||
|
||||
export function createBreakoutGame(): BreakoutGame {
|
||||
const game: BreakoutGame = {
|
||||
paddleX: (fieldWidth - paddleWidth) / 2,
|
||||
ball: { x: 0, y: 0, velocityX: 0, velocityY: 0 },
|
||||
bricks: createBricks(),
|
||||
score: 0,
|
||||
lives: startingLives,
|
||||
level: 1,
|
||||
status: 'serving'
|
||||
};
|
||||
placeBallOnPaddle(game);
|
||||
return game;
|
||||
}
|
||||
|
||||
export function movePaddleTo(game: BreakoutGame, centerX: number) {
|
||||
game.paddleX = clamp(centerX - paddleWidth / 2, 0, fieldWidth - paddleWidth);
|
||||
if (game.status === 'serving') placeBallOnPaddle(game);
|
||||
}
|
||||
|
||||
export function launchBall(game: BreakoutGame) {
|
||||
if (game.status !== 'serving') return;
|
||||
const speed = ballSpeedForLevel(game.level);
|
||||
const angle = (Math.random() - 0.5) * maxBounceAngle;
|
||||
game.ball.velocityX = speed * Math.sin(angle);
|
||||
game.ball.velocityY = -speed * Math.cos(angle);
|
||||
game.status = 'running';
|
||||
}
|
||||
|
||||
function bounceOffWalls(ball: Ball) {
|
||||
if (ball.x < ballRadius) {
|
||||
ball.x = ballRadius;
|
||||
ball.velocityX = Math.abs(ball.velocityX);
|
||||
} else if (ball.x > fieldWidth - ballRadius) {
|
||||
ball.x = fieldWidth - ballRadius;
|
||||
ball.velocityX = -Math.abs(ball.velocityX);
|
||||
}
|
||||
if (ball.y < ballRadius) {
|
||||
ball.y = ballRadius;
|
||||
ball.velocityY = Math.abs(ball.velocityY);
|
||||
}
|
||||
}
|
||||
|
||||
function bounceOffPaddle(game: BreakoutGame) {
|
||||
const ball = game.ball;
|
||||
const touchesPaddle =
|
||||
ball.velocityY > 0 &&
|
||||
ball.y + ballRadius >= paddleY &&
|
||||
ball.y < paddleY + paddleHeight &&
|
||||
ball.x >= game.paddleX - ballRadius &&
|
||||
ball.x <= game.paddleX + paddleWidth + ballRadius;
|
||||
if (!touchesPaddle) return;
|
||||
|
||||
const paddleCenter = game.paddleX + paddleWidth / 2;
|
||||
const hitOffset = clamp((ball.x - paddleCenter) / (paddleWidth / 2), -1, 1);
|
||||
const angle = hitOffset * maxBounceAngle;
|
||||
const speed = Math.hypot(ball.velocityX, ball.velocityY);
|
||||
ball.velocityX = speed * Math.sin(angle);
|
||||
ball.velocityY = -speed * Math.cos(angle);
|
||||
ball.y = paddleY - ballRadius;
|
||||
}
|
||||
|
||||
function overlapsBrick(ball: Ball, brick: Brick): boolean {
|
||||
return (
|
||||
ball.x + ballRadius > brick.x &&
|
||||
ball.x - ballRadius < brick.x + brickWidth &&
|
||||
ball.y + ballRadius > brick.y &&
|
||||
ball.y - ballRadius < brick.y + brickHeight
|
||||
);
|
||||
}
|
||||
|
||||
function startNextLevel(game: BreakoutGame) {
|
||||
game.level += 1;
|
||||
game.bricks = createBricks();
|
||||
game.status = 'serving';
|
||||
placeBallOnPaddle(game);
|
||||
}
|
||||
|
||||
function breakBricks(game: BreakoutGame) {
|
||||
const ball = game.ball;
|
||||
const brick = game.bricks.find((candidate) => !candidate.broken && overlapsBrick(ball, candidate));
|
||||
if (!brick) return;
|
||||
|
||||
brick.broken = true;
|
||||
game.score += brickRows - brick.row;
|
||||
|
||||
const overlapX = Math.min(ball.x + ballRadius - brick.x, brick.x + brickWidth - (ball.x - ballRadius));
|
||||
const overlapY = Math.min(ball.y + ballRadius - brick.y, brick.y + brickHeight - (ball.y - ballRadius));
|
||||
if (overlapX < overlapY) ball.velocityX = -ball.velocityX;
|
||||
else ball.velocityY = -ball.velocityY;
|
||||
|
||||
if (game.bricks.every((candidate) => candidate.broken)) startNextLevel(game);
|
||||
}
|
||||
|
||||
function loseLife(game: BreakoutGame) {
|
||||
game.lives -= 1;
|
||||
game.status = game.lives > 0 ? 'serving' : 'over';
|
||||
placeBallOnPaddle(game);
|
||||
}
|
||||
|
||||
function moveBall(game: BreakoutGame, seconds: number) {
|
||||
const ball = game.ball;
|
||||
ball.x += ball.velocityX * seconds;
|
||||
ball.y += ball.velocityY * seconds;
|
||||
bounceOffWalls(ball);
|
||||
bounceOffPaddle(game);
|
||||
breakBricks(game);
|
||||
if (ball.y - ballRadius > fieldHeight) loseLife(game);
|
||||
}
|
||||
|
||||
export function advanceBreakout(game: BreakoutGame, seconds: number, steering: number) {
|
||||
if (steering !== 0) movePaddleTo(game, game.paddleX + paddleWidth / 2 + steering * paddleSpeed * seconds);
|
||||
if (game.status !== 'running') return;
|
||||
|
||||
const distance = Math.hypot(game.ball.velocityX, game.ball.velocityY) * seconds;
|
||||
const steps = Math.max(1, Math.ceil(distance / maxStepDistance));
|
||||
for (let step = 0; step < steps && game.status === 'running'; step++) moveBall(game, seconds / steps);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
function storageKey(gameName: string): string {
|
||||
return `${gameName}-high-score`;
|
||||
}
|
||||
|
||||
export function loadHighScore(gameName: string): number {
|
||||
try {
|
||||
return Number(localStorage.getItem(storageKey(gameName))) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveHighScore(gameName: string, score: number) {
|
||||
try {
|
||||
localStorage.setItem(storageKey(gameName), String(score));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
export type Direction = 'up' | 'down' | 'left' | 'right';
|
||||
|
||||
export type SnakeStatus = 'ready' | 'running' | 'paused' | 'over' | 'won';
|
||||
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface SnakeGame {
|
||||
snake: Point[];
|
||||
direction: Direction;
|
||||
queuedDirections: Direction[];
|
||||
food: Point | null;
|
||||
score: number;
|
||||
status: SnakeStatus;
|
||||
}
|
||||
|
||||
export const boardSize = 20;
|
||||
|
||||
const maxQueuedDirections = 2;
|
||||
const startingDelay = 130;
|
||||
const fastestDelay = 60;
|
||||
const delayDropPerFood = 3;
|
||||
|
||||
const directionVectors: Record<Direction, Point> = {
|
||||
up: { x: 0, y: -1 },
|
||||
down: { x: 0, y: 1 },
|
||||
left: { x: -1, y: 0 },
|
||||
right: { x: 1, y: 0 }
|
||||
};
|
||||
|
||||
const oppositeDirections: Record<Direction, Direction> = {
|
||||
up: 'down',
|
||||
down: 'up',
|
||||
left: 'right',
|
||||
right: 'left'
|
||||
};
|
||||
|
||||
export function samePoint(first: Point, second: Point): boolean {
|
||||
return first.x === second.x && first.y === second.y;
|
||||
}
|
||||
|
||||
function isOutsideBoard(point: Point): boolean {
|
||||
return point.x < 0 || point.y < 0 || point.x >= boardSize || point.y >= boardSize;
|
||||
}
|
||||
|
||||
function randomFreeCell(snake: Point[]): Point | null {
|
||||
const freeCells: Point[] = [];
|
||||
for (let y = 0; y < boardSize; y++) {
|
||||
for (let x = 0; x < boardSize; x++) {
|
||||
if (!snake.some((part) => part.x === x && part.y === y)) freeCells.push({ x, y });
|
||||
}
|
||||
}
|
||||
return freeCells[Math.floor(Math.random() * freeCells.length)] ?? null;
|
||||
}
|
||||
|
||||
export function createSnakeGame(): SnakeGame {
|
||||
const middle = Math.floor(boardSize / 2);
|
||||
const snake = [
|
||||
{ x: middle, y: middle },
|
||||
{ x: middle - 1, y: middle },
|
||||
{ x: middle - 2, y: middle }
|
||||
];
|
||||
return { snake, direction: 'right', queuedDirections: [], food: randomFreeCell(snake), score: 0, status: 'ready' };
|
||||
}
|
||||
|
||||
export function queueDirection(game: SnakeGame, direction: Direction) {
|
||||
const lastDirection = game.queuedDirections.at(-1) ?? game.direction;
|
||||
if (direction === lastDirection || direction === oppositeDirections[lastDirection]) return;
|
||||
if (game.queuedDirections.length < maxQueuedDirections) game.queuedDirections.push(direction);
|
||||
}
|
||||
|
||||
export function advanceSnake(game: SnakeGame) {
|
||||
game.direction = game.queuedDirections.shift() ?? game.direction;
|
||||
const head = game.snake[0];
|
||||
const vector = directionVectors[game.direction];
|
||||
const nextHead = { x: head.x + vector.x, y: head.y + vector.y };
|
||||
const ateFood = game.food !== null && samePoint(nextHead, game.food);
|
||||
const body = ateFood ? game.snake : game.snake.slice(0, -1);
|
||||
|
||||
if (isOutsideBoard(nextHead) || body.some((part) => samePoint(part, nextHead))) {
|
||||
game.status = 'over';
|
||||
return;
|
||||
}
|
||||
|
||||
game.snake = [nextHead, ...body];
|
||||
if (!ateFood) return;
|
||||
|
||||
game.score += 1;
|
||||
game.food = randomFreeCell(game.snake);
|
||||
if (!game.food) game.status = 'won';
|
||||
}
|
||||
|
||||
export function tickDelay(score: number): number {
|
||||
return Math.max(fastestDelay, startingDelay - score * delayDropPerFood);
|
||||
}
|
||||
@@ -48,8 +48,6 @@ export interface ListeningProfile {
|
||||
profileUrl: string;
|
||||
registeredAt: number | null;
|
||||
totals: ListeningTotals;
|
||||
nowPlaying: PlayedTrack | null;
|
||||
recentTracks: PlayedTrack[];
|
||||
daily: CountBucket[];
|
||||
monthly: CountBucket[];
|
||||
hourly: CountBucket[];
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { findPath, nearestFreePoint } from './pathfinding';
|
||||
import {
|
||||
clampToWorld,
|
||||
furthestX,
|
||||
furthestY,
|
||||
isSpotBlocked,
|
||||
petHeight,
|
||||
petWidth,
|
||||
type PetWorld,
|
||||
type Point
|
||||
} from './world';
|
||||
|
||||
export type PetAction = 'idle' | 'walk' | 'sleep' | 'hop' | 'held' | 'activity';
|
||||
|
||||
export type PetActivity = 'wave' | 'scan' | 'dance' | 'spin' | 'charge' | 'compute';
|
||||
|
||||
export type PetExpression = 'awake' | 'sleeping' | 'happy';
|
||||
|
||||
export interface Pet {
|
||||
x: number;
|
||||
y: number;
|
||||
facing: 1 | -1;
|
||||
action: PetAction;
|
||||
activity: PetActivity | null;
|
||||
actionTimeLeft: number;
|
||||
path: Point[];
|
||||
commanded: boolean;
|
||||
}
|
||||
|
||||
interface SecondsRange {
|
||||
minimum: number;
|
||||
maximum: number;
|
||||
}
|
||||
|
||||
const wanderSpeed = 45;
|
||||
const commandedSpeed = 140;
|
||||
const hopSeconds = 0.4;
|
||||
const idleSeconds = { minimum: 2, maximum: 5 };
|
||||
const sleepSeconds = { minimum: 8, maximum: 16 };
|
||||
|
||||
const activitySeconds: Record<PetActivity, SecondsRange> = {
|
||||
wave: { minimum: 2, maximum: 3 },
|
||||
scan: { minimum: 3, maximum: 5 },
|
||||
dance: { minimum: 4, maximum: 7 },
|
||||
spin: { minimum: 1.6, maximum: 1.6 },
|
||||
charge: { minimum: 4, maximum: 6 },
|
||||
compute: { minimum: 3, maximum: 6 }
|
||||
};
|
||||
|
||||
const activities = Object.keys(activitySeconds) as PetActivity[];
|
||||
const happyActivities: PetActivity[] = ['dance', 'spin'];
|
||||
|
||||
const nextActionChances = { walk: 0.5, activity: 0.3, idle: 0.1 };
|
||||
|
||||
function randomBetween(minimum: number, maximum: number): number {
|
||||
return minimum + Math.random() * (maximum - minimum);
|
||||
}
|
||||
|
||||
function setAction(pet: Pet, action: PetAction, seconds: SecondsRange | number = 0) {
|
||||
pet.action = action;
|
||||
pet.activity = null;
|
||||
pet.actionTimeLeft = typeof seconds === 'number' ? seconds : randomBetween(seconds.minimum, seconds.maximum);
|
||||
}
|
||||
|
||||
function startActivity(pet: Pet) {
|
||||
const activity = activities[Math.floor(Math.random() * activities.length)];
|
||||
setAction(pet, 'activity', activitySeconds[activity]);
|
||||
pet.activity = activity;
|
||||
}
|
||||
|
||||
function startWalk(pet: Pet, goal: Point, world: PetWorld, commanded: boolean) {
|
||||
const isStuck = isSpotBlocked(pet, world);
|
||||
const origin = isStuck ? nearestFreePoint(pet, world) : { x: pet.x, y: pet.y };
|
||||
const destination = nearestFreePoint(goal, world);
|
||||
const path = findPath(origin, destination, world);
|
||||
if (isStuck) path.unshift(origin);
|
||||
|
||||
if (path.length === 0) {
|
||||
setAction(pet, 'idle', idleSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
setAction(pet, 'walk');
|
||||
pet.path = path;
|
||||
pet.commanded = commanded;
|
||||
}
|
||||
|
||||
function chooseNextAction(pet: Pet, world: PetWorld) {
|
||||
const roll = Math.random();
|
||||
const randomSpot = { x: randomBetween(0, furthestX(world)), y: randomBetween(0, furthestY(world)) };
|
||||
if (roll < nextActionChances.walk) startWalk(pet, randomSpot, world, false);
|
||||
else if (roll < nextActionChances.walk + nextActionChances.activity) startActivity(pet);
|
||||
else if (roll < nextActionChances.walk + nextActionChances.activity + nextActionChances.idle) setAction(pet, 'idle', idleSeconds);
|
||||
else setAction(pet, 'sleep', sleepSeconds);
|
||||
}
|
||||
|
||||
export function createPet(world: PetWorld): Pet {
|
||||
const spawn = nearestFreePoint({ x: furthestX(world) / 2, y: furthestY(world) / 2 }, world);
|
||||
return { ...spawn, facing: 1, action: 'idle', activity: null, actionTimeLeft: 1, path: [], commanded: false };
|
||||
}
|
||||
|
||||
export function petExpression(pet: Pet): PetExpression {
|
||||
if (pet.action === 'sleep') return 'sleeping';
|
||||
if (pet.action === 'hop' || pet.action === 'held') return 'happy';
|
||||
if (pet.activity && happyActivities.includes(pet.activity)) return 'happy';
|
||||
return 'awake';
|
||||
}
|
||||
|
||||
export function commandPet(pet: Pet, pointX: number, pointY: number, world: PetWorld) {
|
||||
if (pet.action === 'held') return;
|
||||
startWalk(pet, { x: pointX - petWidth / 2, y: pointY - petHeight }, world, true);
|
||||
}
|
||||
|
||||
export function hopPet(pet: Pet) {
|
||||
if (pet.action === 'held') return;
|
||||
setAction(pet, 'hop', hopSeconds);
|
||||
}
|
||||
|
||||
export function holdPet(pet: Pet) {
|
||||
setAction(pet, 'held');
|
||||
pet.path = [];
|
||||
}
|
||||
|
||||
export function dropPet(pet: Pet) {
|
||||
setAction(pet, 'hop', hopSeconds);
|
||||
}
|
||||
|
||||
export function placePet(pet: Pet, x: number, y: number, world: PetWorld) {
|
||||
const spot = clampToWorld({ x, y }, world);
|
||||
pet.x = spot.x;
|
||||
pet.y = spot.y;
|
||||
}
|
||||
|
||||
function followPath(pet: Pet, seconds: number) {
|
||||
const waypoint = pet.path[0];
|
||||
if (!waypoint) {
|
||||
setAction(pet, 'idle', idleSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
const distanceX = waypoint.x - pet.x;
|
||||
const distanceY = waypoint.y - pet.y;
|
||||
const remainingDistance = Math.hypot(distanceX, distanceY);
|
||||
const stepDistance = (pet.commanded ? commandedSpeed : wanderSpeed) * seconds;
|
||||
if (Math.abs(distanceX) > 1) pet.facing = distanceX > 0 ? 1 : -1;
|
||||
|
||||
if (remainingDistance <= stepDistance) {
|
||||
pet.x = waypoint.x;
|
||||
pet.y = waypoint.y;
|
||||
pet.path.shift();
|
||||
if (pet.path.length === 0) setAction(pet, 'idle', idleSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
pet.x += (distanceX / remainingDistance) * stepDistance;
|
||||
pet.y += (distanceY / remainingDistance) * stepDistance;
|
||||
}
|
||||
|
||||
export function stepPet(pet: Pet, seconds: number, world: PetWorld) {
|
||||
placePet(pet, pet.x, pet.y, world);
|
||||
if (pet.action === 'held') return;
|
||||
if (pet.action === 'walk') {
|
||||
followPath(pet, seconds);
|
||||
return;
|
||||
}
|
||||
|
||||
pet.actionTimeLeft -= seconds;
|
||||
if (pet.actionTimeLeft > 0) return;
|
||||
if (isSpotBlocked(pet, world)) startWalk(pet, pet, world, false);
|
||||
else if (pet.action === 'idle') chooseNextAction(pet, world);
|
||||
else setAction(pet, 'idle', idleSeconds);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { clampToWorld, furthestX, furthestY, isPathClear, isSpotBlocked, type PetWorld, type Point } from './world';
|
||||
|
||||
interface Grid {
|
||||
columns: number;
|
||||
rows: number;
|
||||
blocked: boolean[];
|
||||
}
|
||||
|
||||
const cellSize = 16;
|
||||
|
||||
const neighborOffsets = [
|
||||
{ column: 1, row: 0 },
|
||||
{ column: -1, row: 0 },
|
||||
{ column: 0, row: 1 },
|
||||
{ column: 0, row: -1 },
|
||||
{ column: 1, row: 1 },
|
||||
{ column: 1, row: -1 },
|
||||
{ column: -1, row: 1 },
|
||||
{ column: -1, row: -1 }
|
||||
];
|
||||
|
||||
function buildGrid(world: PetWorld): Grid {
|
||||
const columns = Math.floor(furthestX(world) / cellSize) + 1;
|
||||
const rows = Math.floor(furthestY(world) / cellSize) + 1;
|
||||
const blocked: boolean[] = [];
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let column = 0; column < columns; column++) {
|
||||
blocked.push(isSpotBlocked({ x: column * cellSize, y: row * cellSize }, world));
|
||||
}
|
||||
}
|
||||
return { columns, rows, blocked };
|
||||
}
|
||||
|
||||
function cellIndexOf(point: Point, grid: Grid): number {
|
||||
const column = Math.min(grid.columns - 1, Math.max(0, Math.round(point.x / cellSize)));
|
||||
const row = Math.min(grid.rows - 1, Math.max(0, Math.round(point.y / cellSize)));
|
||||
return row * grid.columns + column;
|
||||
}
|
||||
|
||||
function pointOfCell(index: number, grid: Grid): Point {
|
||||
return { x: (index % grid.columns) * cellSize, y: Math.floor(index / grid.columns) * cellSize };
|
||||
}
|
||||
|
||||
function distanceBetween(first: Point, second: Point): number {
|
||||
return Math.hypot(first.x - second.x, first.y - second.y);
|
||||
}
|
||||
|
||||
export function nearestFreePoint(point: Point, world: PetWorld): Point {
|
||||
const spot = clampToWorld(point, world);
|
||||
if (!isSpotBlocked(spot, world)) return spot;
|
||||
|
||||
const grid = buildGrid(world);
|
||||
let nearest = spot;
|
||||
let nearestDistance = Infinity;
|
||||
grid.blocked.forEach((isBlocked, index) => {
|
||||
if (isBlocked) return;
|
||||
const cellPoint = pointOfCell(index, grid);
|
||||
const distance = distanceBetween(cellPoint, spot);
|
||||
if (distance < nearestDistance) {
|
||||
nearest = cellPoint;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
});
|
||||
return nearest;
|
||||
}
|
||||
|
||||
function neighborsOf(index: number, grid: Grid): { index: number; cost: number }[] {
|
||||
const column = index % grid.columns;
|
||||
const row = Math.floor(index / grid.columns);
|
||||
const isOpen = (targetColumn: number, targetRow: number) =>
|
||||
targetColumn >= 0 &&
|
||||
targetRow >= 0 &&
|
||||
targetColumn < grid.columns &&
|
||||
targetRow < grid.rows &&
|
||||
!grid.blocked[targetRow * grid.columns + targetColumn];
|
||||
|
||||
return neighborOffsets
|
||||
.filter((offset) => isOpen(column + offset.column, row + offset.row))
|
||||
.filter((offset) => offset.column === 0 || offset.row === 0 || (isOpen(column + offset.column, row) && isOpen(column, row + offset.row)))
|
||||
.map((offset) => ({
|
||||
index: (row + offset.row) * grid.columns + column + offset.column,
|
||||
cost: offset.column !== 0 && offset.row !== 0 ? Math.SQRT2 : 1
|
||||
}));
|
||||
}
|
||||
|
||||
function searchCells(startIndex: number, goalIndex: number, grid: Grid): number[] {
|
||||
const goalPoint = pointOfCell(goalIndex, grid);
|
||||
const estimate = (index: number) => distanceBetween(pointOfCell(index, grid), goalPoint) / cellSize;
|
||||
const costs = new Map<number, number>([[startIndex, 0]]);
|
||||
const scores = new Map<number, number>([[startIndex, estimate(startIndex)]]);
|
||||
const cameFrom = new Map<number, number>();
|
||||
const openCells = new Set<number>([startIndex]);
|
||||
|
||||
while (openCells.size > 0) {
|
||||
let current = -1;
|
||||
openCells.forEach((index) => {
|
||||
if (current === -1 || scores.get(index)! < scores.get(current)!) current = index;
|
||||
});
|
||||
|
||||
if (current === goalIndex) {
|
||||
const route = [current];
|
||||
while (cameFrom.has(route[0])) route.unshift(cameFrom.get(route[0])!);
|
||||
return route;
|
||||
}
|
||||
|
||||
openCells.delete(current);
|
||||
neighborsOf(current, grid).forEach((neighbor) => {
|
||||
const cost = costs.get(current)! + neighbor.cost;
|
||||
if (cost >= (costs.get(neighbor.index) ?? Infinity)) return;
|
||||
cameFrom.set(neighbor.index, current);
|
||||
costs.set(neighbor.index, cost);
|
||||
scores.set(neighbor.index, cost + estimate(neighbor.index));
|
||||
openCells.add(neighbor.index);
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function smoothRoute(start: Point, route: Point[], world: PetWorld): Point[] {
|
||||
const smoothed: Point[] = [];
|
||||
let anchor = start;
|
||||
let index = 0;
|
||||
|
||||
while (index < route.length) {
|
||||
let furthest = index;
|
||||
while (furthest + 1 < route.length && isPathClear(anchor, route[furthest + 1], world)) furthest++;
|
||||
smoothed.push(route[furthest]);
|
||||
anchor = route[furthest];
|
||||
index = furthest + 1;
|
||||
}
|
||||
|
||||
return smoothed;
|
||||
}
|
||||
|
||||
export function findPath(start: Point, goal: Point, world: PetWorld): Point[] {
|
||||
if (isPathClear(start, goal, world)) return [goal];
|
||||
|
||||
const grid = buildGrid(world);
|
||||
const cellRoute = searchCells(cellIndexOf(start, grid), cellIndexOf(goal, grid), grid);
|
||||
if (cellRoute.length === 0) return [];
|
||||
|
||||
const route = cellRoute.slice(1).map((index) => pointOfCell(index, grid));
|
||||
route.push(goal);
|
||||
return smoothRoute(start, route, world);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
const storageKey = 'desktop-pet-enabled';
|
||||
|
||||
class PetState {
|
||||
enabled = $state(true);
|
||||
|
||||
load() {
|
||||
try {
|
||||
const storedValue = localStorage.getItem(storageKey);
|
||||
if (storedValue !== null) this.enabled = storedValue === 'true';
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean) {
|
||||
this.enabled = enabled;
|
||||
try {
|
||||
localStorage.setItem(storageKey, String(enabled));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.setEnabled(!this.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
export const petState = new PetState();
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface PixelRun {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export function pixelRuns(rows: string[]): PixelRun[] {
|
||||
return rows.flatMap((row, y) =>
|
||||
[...row.matchAll(/X+/g)].map((match) => ({ x: match.index ?? 0, y, width: match[0].length }))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { PetActivity } from './behavior';
|
||||
|
||||
export type VoiceMoment = 'click' | 'command' | 'arrive' | 'pickup' | 'drop' | 'sleep' | 'wake' | 'chatter' | PetActivity;
|
||||
|
||||
export const voiceLines: Record<VoiceMoment, string[]> = {
|
||||
wave: ['hi there!', 'hello, visitor!', 'o/', 'welcome!', 'hey you!'],
|
||||
scan: ['scanning...', 'area secure', 'running diagnostics', 'no bugs detected', 'searching for wifi'],
|
||||
dance: ['beep boop bop', 'dance mode on', 'feel the bass', 'groove.exe', 'robot dance!'],
|
||||
spin: ['wheee', 'barrel roll!', 'spinning up', '360 no scope'],
|
||||
charge: ['charging...', 'battery low', 'need juice', 'plugging in', 'recharging cells'],
|
||||
compute: ['computing...', 'processing', 'crunching numbers', 'thinking very hard', 'solving p = np'],
|
||||
click: [
|
||||
'beep!',
|
||||
'boop',
|
||||
'hello!',
|
||||
'01101000 01101001',
|
||||
'bzzt',
|
||||
'that tickles',
|
||||
'systems nominal',
|
||||
'need something?',
|
||||
'ping received',
|
||||
'pong!',
|
||||
'hey, watch the antenna',
|
||||
'hello, world!',
|
||||
'running at 60 fps',
|
||||
'i am not a virus, promise',
|
||||
'did you try turning me off and on?',
|
||||
'sudo make me a sandwich',
|
||||
'all cores happy',
|
||||
'404: joke not found'
|
||||
],
|
||||
command: [
|
||||
'on my way',
|
||||
'moving out',
|
||||
'roger',
|
||||
'affirmative',
|
||||
'copy that',
|
||||
'en route',
|
||||
'calculating path...',
|
||||
'rerouting...',
|
||||
'as you wish',
|
||||
'target acquired',
|
||||
'executing order',
|
||||
'beep boop, going'
|
||||
],
|
||||
arrive: ['destination reached', 'made it', 'arrived', 'here!', 'what now?', 'awaiting orders', 'path complete'],
|
||||
pickup: ['whoa!', 'put me down!', 'wheee', 'gyro error', 'i can fly!', 'hey!', 'motion sickness...'],
|
||||
drop: ['landed', 'oof', 'stuck the landing', 'gravity online', 'recalibrating...', 'thanks for the lift'],
|
||||
sleep: ['powering down...', 'entering sleep mode', 'low power mode', 'saving state...'],
|
||||
wake: ['rebooting...', 'back online', 'good morning!', 'boot complete', 'systems restored'],
|
||||
chatter: [
|
||||
'scanning desktop...',
|
||||
'nice wallpaper',
|
||||
'checking for updates',
|
||||
'defragmenting...',
|
||||
'try right clicking the desktop',
|
||||
'i like it here',
|
||||
'compiling thoughts...',
|
||||
'have you played snake yet?',
|
||||
'try alt + space',
|
||||
'hmm...',
|
||||
'*whirr*',
|
||||
'cache cleared',
|
||||
'pinging localhost',
|
||||
'type help in a terminal'
|
||||
]
|
||||
};
|
||||
|
||||
export function randomVoiceLine(moment: VoiceMoment): string {
|
||||
const lines = voiceLines[moment];
|
||||
return lines[Math.floor(Math.random() * lines.length)];
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Rect {
|
||||
left: number;
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
}
|
||||
|
||||
export interface PetWorld {
|
||||
width: number;
|
||||
height: number;
|
||||
obstacles: Rect[];
|
||||
}
|
||||
|
||||
export const robotGridSize = 12;
|
||||
export const robotPixelSize = 6;
|
||||
export const petWidth = robotGridSize * robotPixelSize;
|
||||
export const petHeight = robotGridSize * robotPixelSize;
|
||||
|
||||
const obstacleMargin = 2;
|
||||
const lineCheckStep = 8;
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(Math.max(value, minimum), maximum);
|
||||
}
|
||||
|
||||
export function furthestX(world: PetWorld): number {
|
||||
return Math.max(0, world.width - petWidth);
|
||||
}
|
||||
|
||||
export function furthestY(world: PetWorld): number {
|
||||
return Math.max(0, world.height - petHeight);
|
||||
}
|
||||
|
||||
export function clampToWorld(point: Point, world: PetWorld): Point {
|
||||
return { x: clamp(point.x, 0, furthestX(world)), y: clamp(point.y, 0, furthestY(world)) };
|
||||
}
|
||||
|
||||
export function isSpotBlocked(spot: Point, world: PetWorld): boolean {
|
||||
return world.obstacles.some(
|
||||
(obstacle) =>
|
||||
spot.x < obstacle.right + obstacleMargin &&
|
||||
spot.x + petWidth > obstacle.left - obstacleMargin &&
|
||||
spot.y < obstacle.bottom + obstacleMargin &&
|
||||
spot.y + petHeight > obstacle.top - obstacleMargin
|
||||
);
|
||||
}
|
||||
|
||||
export function isPathClear(from: Point, to: Point, world: PetWorld): boolean {
|
||||
const distance = Math.hypot(to.x - from.x, to.y - from.y);
|
||||
const steps = Math.max(1, Math.ceil(distance / lineCheckStep));
|
||||
for (let step = 0; step <= steps; step++) {
|
||||
const progress = step / steps;
|
||||
const spot = { x: from.x + (to.x - from.x) * progress, y: from.y + (to.y - from.y) * progress };
|
||||
if (isSpotBlocked(spot, world)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
+108
-56
@@ -1,43 +1,70 @@
|
||||
import type { AnimeEntry, AnimeProfile } from '#lib/anime/types';
|
||||
import type { AniListEntry, AniListMediaType, AniListProfile, AniListStat } from '#lib/anilist/types';
|
||||
|
||||
const cacheDuration = 60 * 60 * 1000;
|
||||
const minutesPerDay = 60 * 24;
|
||||
const topRatedMinimumScore = 9;
|
||||
const statusLabels: Record<string, string> = {
|
||||
COMPLETED: 'Completed',
|
||||
CURRENT: 'Watching',
|
||||
PLANNING: 'Planning',
|
||||
PAUSED: 'Paused',
|
||||
DROPPED: 'Dropped'
|
||||
|
||||
interface MediaStatistics {
|
||||
count: number;
|
||||
episodesWatched?: number;
|
||||
minutesWatched?: number;
|
||||
chaptersRead?: number;
|
||||
volumesRead?: number;
|
||||
}
|
||||
|
||||
interface MediaConfig {
|
||||
statisticsKey: 'anime' | 'manga';
|
||||
statisticsFields: string;
|
||||
totalField: 'episodes' | 'chapters';
|
||||
listPath: string;
|
||||
currentLabel: string;
|
||||
progressUnit: string;
|
||||
totalUnit: string;
|
||||
statusLabels: Record<string, string>;
|
||||
stats: (statistics: MediaStatistics) => AniListStat[];
|
||||
}
|
||||
|
||||
const mediaConfigs: Record<AniListMediaType, MediaConfig> = {
|
||||
ANIME: {
|
||||
statisticsKey: 'anime',
|
||||
statisticsFields: 'count episodesWatched minutesWatched',
|
||||
totalField: 'episodes',
|
||||
listPath: 'animelist',
|
||||
currentLabel: 'Currently watching',
|
||||
progressUnit: 'Episode',
|
||||
totalUnit: 'eps',
|
||||
statusLabels: { COMPLETED: 'Completed', CURRENT: 'Watching', PLANNING: 'Planning', PAUSED: 'Paused', DROPPED: 'Dropped' },
|
||||
stats: (statistics) => [
|
||||
{ label: 'Anime', value: `${statistics.count}` },
|
||||
{ label: 'Episodes', value: `${statistics.episodesWatched ?? 0}` },
|
||||
{ label: 'Watch time', value: `${Math.round(((statistics.minutesWatched ?? 0) / minutesPerDay) * 10) / 10} days` }
|
||||
]
|
||||
},
|
||||
MANGA: {
|
||||
statisticsKey: 'manga',
|
||||
statisticsFields: 'count chaptersRead volumesRead',
|
||||
totalField: 'chapters',
|
||||
listPath: 'mangalist',
|
||||
currentLabel: 'Currently reading',
|
||||
progressUnit: 'Chapter',
|
||||
totalUnit: 'ch',
|
||||
statusLabels: { COMPLETED: 'Completed', CURRENT: 'Reading', PLANNING: 'Planning', PAUSED: 'Paused', DROPPED: 'Dropped' },
|
||||
stats: (statistics) => [
|
||||
{ label: 'Manga', value: `${statistics.count}` },
|
||||
{ label: 'Chapters', value: `${statistics.chaptersRead ?? 0}` },
|
||||
{ label: 'Volumes', value: `${statistics.volumesRead ?? 0}` }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const animeQuery = `
|
||||
query ($userName: String) {
|
||||
User(name: $userName) {
|
||||
name
|
||||
siteUrl
|
||||
statistics { anime { count episodesWatched minutesWatched } }
|
||||
}
|
||||
MediaListCollection(userName: $userName, type: ANIME) {
|
||||
lists {
|
||||
status
|
||||
entries {
|
||||
score(format: POINT_10)
|
||||
progress
|
||||
media { id siteUrl episodes title { english romaji } coverImage { large } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface AniListEntry {
|
||||
interface ResponseEntry {
|
||||
score: number;
|
||||
progress: number;
|
||||
media: {
|
||||
id: number;
|
||||
siteUrl: string;
|
||||
episodes: number | null;
|
||||
episodes?: number | null;
|
||||
chapters?: number | null;
|
||||
title: { english: string | null; romaji: string };
|
||||
coverImage: { large: string };
|
||||
};
|
||||
@@ -48,15 +75,37 @@ interface AniListResponse {
|
||||
User: {
|
||||
name: string;
|
||||
siteUrl: string;
|
||||
statistics: { anime: { count: number; episodesWatched: number; minutesWatched: number } };
|
||||
statistics: Record<string, MediaStatistics>;
|
||||
};
|
||||
MediaListCollection: { lists: { status: string; entries: AniListEntry[] }[] };
|
||||
MediaListCollection: { lists: { status: string | null; entries: ResponseEntry[] }[] };
|
||||
};
|
||||
}
|
||||
|
||||
const profileCache = new Map<string, { profile: AnimeProfile; fetchedAt: number }>();
|
||||
const profileCache = new Map<string, { profile: AniListProfile; fetchedAt: number }>();
|
||||
|
||||
function toAnimeEntry({ score, progress, media }: AniListEntry): AnimeEntry {
|
||||
function buildQuery(config: MediaConfig): string {
|
||||
return `
|
||||
query ($userName: String, $type: MediaType) {
|
||||
User(name: $userName) {
|
||||
name
|
||||
siteUrl
|
||||
statistics { ${config.statisticsKey} { ${config.statisticsFields} } }
|
||||
}
|
||||
MediaListCollection(userName: $userName, type: $type) {
|
||||
lists {
|
||||
status
|
||||
entries {
|
||||
score(format: POINT_10)
|
||||
progress
|
||||
media { id siteUrl ${config.totalField} title { english romaji } coverImage { large } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function toEntry({ score, progress, media }: ResponseEntry, config: MediaConfig): AniListEntry {
|
||||
return {
|
||||
id: media.id,
|
||||
title: media.title.english ?? media.title.romaji,
|
||||
@@ -64,56 +113,59 @@ function toAnimeEntry({ score, progress, media }: AniListEntry): AnimeEntry {
|
||||
cover: media.coverImage.large,
|
||||
score,
|
||||
progress,
|
||||
episodes: media.episodes
|
||||
total: media[config.totalField] ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function entriesWithStatus(response: AniListResponse, status: string): AnimeEntry[] {
|
||||
function entriesWithStatus(response: AniListResponse, status: string, config: MediaConfig): AniListEntry[] {
|
||||
const list = response.data.MediaListCollection.lists.find((item) => item.status === status);
|
||||
return (list?.entries ?? []).map(toAnimeEntry);
|
||||
return (list?.entries ?? []).map((entry) => toEntry(entry, config));
|
||||
}
|
||||
|
||||
function toAnimeProfile(response: AniListResponse): AnimeProfile {
|
||||
function toProfile(response: AniListResponse, mediaType: AniListMediaType): AniListProfile {
|
||||
const config = mediaConfigs[mediaType];
|
||||
const { name, siteUrl, statistics } = response.data.User;
|
||||
const completed = entriesWithStatus(response, 'COMPLETED');
|
||||
const statusCounts = Object.entries(statusLabels).map(([status, label]) => ({
|
||||
status,
|
||||
label,
|
||||
count: entriesWithStatus(response, status).length
|
||||
}));
|
||||
const completed = entriesWithStatus(response, 'COMPLETED', config);
|
||||
|
||||
return {
|
||||
mediaType,
|
||||
name,
|
||||
profileUrl: siteUrl,
|
||||
statusCounts,
|
||||
stats: {
|
||||
count: statistics.anime.count,
|
||||
episodesWatched: statistics.anime.episodesWatched,
|
||||
daysWatched: Math.round((statistics.anime.minutesWatched / minutesPerDay) * 10) / 10
|
||||
},
|
||||
watching: entriesWithStatus(response, 'CURRENT'),
|
||||
listUrl: `${siteUrl}/${config.listPath}`,
|
||||
stats: config.stats(statistics[config.statisticsKey]),
|
||||
statusCounts: Object.entries(config.statusLabels).map(([status, label]) => ({
|
||||
status,
|
||||
label,
|
||||
count: entriesWithStatus(response, status, config).length
|
||||
})),
|
||||
current: entriesWithStatus(response, 'CURRENT', config),
|
||||
currentLabel: config.currentLabel,
|
||||
progressUnit: config.progressUnit,
|
||||
totalUnit: config.totalUnit,
|
||||
topRated: completed.filter((entry) => entry.score >= topRatedMinimumScore).sort((first, second) => second.score - first.score),
|
||||
planning: entriesWithStatus(response, 'PLANNING', config),
|
||||
completedCount: completed.length
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAnimeProfile(userName: string): Promise<AnimeProfile | null> {
|
||||
const cached = profileCache.get(userName);
|
||||
export async function getAniListProfile(userName: string, mediaType: AniListMediaType): Promise<AniListProfile | null> {
|
||||
const cacheKey = `${userName}:${mediaType}`;
|
||||
const cached = profileCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.fetchedAt < cacheDuration) return cached.profile;
|
||||
|
||||
try {
|
||||
const response = await fetch('https://graphql.anilist.co', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ query: animeQuery, variables: { userName } })
|
||||
body: JSON.stringify({ query: buildQuery(mediaConfigs[mediaType]), variables: { userName, type: mediaType } })
|
||||
});
|
||||
if (!response.ok) throw new Error(`AniList returned ${response.status}`);
|
||||
|
||||
const profile = toAnimeProfile(await response.json());
|
||||
profileCache.set(userName, { profile, fetchedAt: Date.now() });
|
||||
const profile = toProfile(await response.json(), mediaType);
|
||||
profileCache.set(cacheKey, { profile, fetchedAt: Date.now() });
|
||||
return profile;
|
||||
} catch (error) {
|
||||
console.error(`AniList lookup failed for ${userName}:`, error);
|
||||
console.error(`AniList ${mediaType.toLowerCase()} lookup failed for ${userName}:`, error);
|
||||
return cached?.profile ?? null;
|
||||
}
|
||||
}
|
||||
@@ -121,13 +121,6 @@ export async function fetchRecentTracksPage(
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchNowPlaying(userName: string): Promise<PlayedTrack | null> {
|
||||
const { recenttracks } = await callLastfm<RawRecentTracks>('user.getrecenttracks', { user: userName, limit: '1' });
|
||||
const rawTracks = Array.isArray(recenttracks.track) ? recenttracks.track : [recenttracks.track];
|
||||
const nowPlayingRaw = rawTracks.find((raw) => raw['@attr']?.nowplaying === 'true');
|
||||
return nowPlayingRaw ? toPlayedTrack(nowPlayingRaw) : null;
|
||||
}
|
||||
|
||||
export async function fetchTrackImage(artist: string, track: string): Promise<string | null> {
|
||||
const { track: info } = await callLastfm<RawTrackInfo>('track.getinfo', { artist, track, autocorrect: '1' });
|
||||
return largestImage(info?.album?.image);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ListeningProfile, PlayedTrack } from '#lib/lastfm/types';
|
||||
import { fetchNowPlaying, fetchUserInfo, hasLastfmKey, type UserInfo } from './api';
|
||||
import type { ListeningProfile } from '#lib/lastfm/types';
|
||||
import { fetchUserInfo, hasLastfmKey, type UserInfo } from './api';
|
||||
import { loadScrobbleStore, storedScrobbles } from './store';
|
||||
import { coverFor, coversVersion, loadCovers, requestTrackCovers } from './covers';
|
||||
import { coversVersion, loadCovers, requestTrackCovers } from './covers';
|
||||
import { lastfmSyncedAt } from './sync';
|
||||
import {
|
||||
countsByDay,
|
||||
@@ -14,14 +14,11 @@ import {
|
||||
} from './trends';
|
||||
|
||||
const userInfoDuration = 30 * 60 * 1000;
|
||||
const nowPlayingDuration = 60 * 1000;
|
||||
const summaryDuration = 5 * 60 * 1000;
|
||||
const recentCount = 8;
|
||||
|
||||
type Summary = Omit<ListeningProfile, 'userName' | 'profileUrl' | 'registeredAt' | 'totals' | 'nowPlaying' | 'syncedAt'>;
|
||||
type Summary = Omit<ListeningProfile, 'userName' | 'profileUrl' | 'registeredAt' | 'totals' | 'syncedAt'>;
|
||||
|
||||
let userInfoCache: { info: UserInfo; fetchedAt: number } | null = null;
|
||||
let nowPlayingCache: { track: PlayedTrack | null; fetchedAt: number } | null = null;
|
||||
let summaryCache: { summary: Summary; scrobbleCount: number; coversVersion: number; builtAt: number } | null = null;
|
||||
|
||||
async function cachedUserInfo(userName: string): Promise<UserInfo | null> {
|
||||
@@ -34,27 +31,6 @@ async function cachedUserInfo(userName: string): Promise<UserInfo | null> {
|
||||
return userInfoCache?.info ?? null;
|
||||
}
|
||||
|
||||
async function cachedNowPlaying(userName: string): Promise<PlayedTrack | null> {
|
||||
if (nowPlayingCache && Date.now() - nowPlayingCache.fetchedAt < nowPlayingDuration) return nowPlayingCache.track;
|
||||
try {
|
||||
nowPlayingCache = { track: await fetchNowPlaying(userName), fetchedAt: Date.now() };
|
||||
} catch (error) {
|
||||
console.error('Last.fm now playing failed:', error instanceof Error ? error.message : error);
|
||||
}
|
||||
return nowPlayingCache?.track ?? null;
|
||||
}
|
||||
|
||||
function recentTracks(): PlayedTrack[] {
|
||||
return storedScrobbles()
|
||||
.slice(-recentCount)
|
||||
.reverse()
|
||||
.map((scrobble) => ({
|
||||
...scrobble,
|
||||
url: `https://www.last.fm/music/${encodeURIComponent(scrobble.artist)}/_/${encodeURIComponent(scrobble.track)}`,
|
||||
image: coverFor(scrobble.artist, scrobble.album, scrobble.track)
|
||||
}));
|
||||
}
|
||||
|
||||
function buildSummary(): Summary {
|
||||
const scrobbles = storedScrobbles();
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
@@ -63,7 +39,6 @@ function buildSummary(): Summary {
|
||||
const streaks = listeningStreaks(dayCounts);
|
||||
|
||||
return {
|
||||
recentTracks: recentTracks(),
|
||||
daily,
|
||||
monthly: monthlyBuckets(dayCounts),
|
||||
hourly: hourlyBuckets(scrobbles),
|
||||
@@ -79,7 +54,6 @@ function buildSummary(): Summary {
|
||||
function tracksMissingCovers(summary: Summary) {
|
||||
return Object.values(summary.top)
|
||||
.flatMap((charts) => charts.tracks)
|
||||
.concat(summary.recentTracks.map((track) => ({ ...track, name: track.track, count: 0 })))
|
||||
.filter((item) => !item.image && item.artist)
|
||||
.map((item) => ({ artist: item.artist!, track: item.name }));
|
||||
}
|
||||
@@ -105,7 +79,7 @@ export async function getListeningProfile(userName: string): Promise<ListeningPr
|
||||
if (!hasLastfmKey()) return null;
|
||||
|
||||
await Promise.all([loadScrobbleStore(), loadCovers()]);
|
||||
const [info, nowPlaying] = await Promise.all([cachedUserInfo(userName), cachedNowPlaying(userName)]);
|
||||
const info = await cachedUserInfo(userName);
|
||||
if (!info && !storedScrobbles().length) return null;
|
||||
|
||||
const summary = cachedSummary();
|
||||
@@ -119,7 +93,6 @@ export async function getListeningProfile(userName: string): Promise<ListeningPr
|
||||
albums: info?.albums ?? 0,
|
||||
tracks: info?.tracks ?? 0
|
||||
},
|
||||
nowPlaying,
|
||||
syncedAt: lastfmSyncedAt(),
|
||||
...summary
|
||||
};
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { CardSearch, TcgCard, TcgPage, TcgSet } from './types';
|
||||
|
||||
export const tcgApiUrl = 'https://tcg.sirblob.co';
|
||||
export const cardsPerPage = 24;
|
||||
|
||||
const cardFields = [
|
||||
'id',
|
||||
'name',
|
||||
'supertype',
|
||||
'subtypes',
|
||||
'hp',
|
||||
'types',
|
||||
'rarity',
|
||||
'number',
|
||||
'artist',
|
||||
'flavorText',
|
||||
'images',
|
||||
'set',
|
||||
'abilities',
|
||||
'attacks',
|
||||
'weaknesses',
|
||||
'resistances'
|
||||
].join(',');
|
||||
|
||||
export function buildCardQuery(search: CardSearch): string {
|
||||
const clauses: string[] = [];
|
||||
const name = search.name.trim().replace(/["*]/g, '');
|
||||
if (name) clauses.push(name.includes(' ') ? `name:"${name}"` : `name:${name}*`);
|
||||
if (search.supertype) clauses.push(`supertype:"${search.supertype}"`);
|
||||
if (search.energyType) clauses.push(`types:${search.energyType.toLowerCase()}`);
|
||||
if (search.setId) clauses.push(`set.id:${search.setId}`);
|
||||
return clauses.join(' ');
|
||||
}
|
||||
|
||||
export function cardApiUrl(cardId: string): string {
|
||||
return `${tcgApiUrl}/cards/${encodeURIComponent(cardId)}`;
|
||||
}
|
||||
|
||||
async function getJson<Result>(path: string, params: Record<string, string>, signal?: AbortSignal): Promise<Result> {
|
||||
const url = new URL(path, tcgApiUrl);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) url.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const response = await fetch(url, { signal });
|
||||
if (response.status === 429) {
|
||||
const retrySeconds = response.headers.get('Retry-After') ?? 'a few';
|
||||
throw new Error(`rate limited, try again in ${retrySeconds} seconds`);
|
||||
}
|
||||
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(body?.error?.message ?? `request failed with status ${response.status}`);
|
||||
return body as Result;
|
||||
}
|
||||
|
||||
export function searchCards(search: CardSearch, signal?: AbortSignal): Promise<TcgPage<TcgCard>> {
|
||||
return getJson(
|
||||
'/cards',
|
||||
{
|
||||
q: buildCardQuery(search),
|
||||
page: String(search.page),
|
||||
pageSize: String(cardsPerPage),
|
||||
select: cardFields
|
||||
},
|
||||
signal
|
||||
);
|
||||
}
|
||||
|
||||
export async function findCardByName(name: string, setCode?: string): Promise<TcgCard | null> {
|
||||
const nameClause = `name:"${name.replace(/"/g, '')}"`;
|
||||
const queries = setCode ? [`${nameClause} set.ptcgoCode:${setCode}`, nameClause] : [nameClause];
|
||||
|
||||
for (const query of queries) {
|
||||
const result = await getJson<TcgPage<TcgCard>>('/cards', { q: query, pageSize: '1', select: cardFields });
|
||||
if (result.data[0]) return result.data[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function fetchEnergyTypes(signal?: AbortSignal): Promise<string[]> {
|
||||
const body = await getJson<{ data: string[] }>('/types', {}, signal);
|
||||
return body.data;
|
||||
}
|
||||
|
||||
const setFields = 'id,name,series,releaseDate,ptcgoCode,printedTotal,total,images';
|
||||
const setsPerPage = 250;
|
||||
|
||||
export async function fetchSets(signal?: AbortSignal): Promise<TcgSet[]> {
|
||||
const sets: TcgSet[] = [];
|
||||
let page = 1;
|
||||
let totalCount = Infinity;
|
||||
|
||||
while (sets.length < totalCount) {
|
||||
const result = await getJson<TcgPage<TcgSet>>(
|
||||
'/sets',
|
||||
{ page: String(page), pageSize: String(setsPerPage), select: setFields, orderBy: '-releaseDate' },
|
||||
signal
|
||||
);
|
||||
sets.push(...result.data);
|
||||
totalCount = result.totalCount;
|
||||
if (result.data.length === 0) break;
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return sets;
|
||||
}
|
||||
|
||||
export async function fetchSupertypes(signal?: AbortSignal): Promise<string[]> {
|
||||
const body = await getJson<{ data: string[] }>('/supertypes', {}, signal);
|
||||
return body.data;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { tcgDecks } from '#lib/content';
|
||||
import type { TcgCard, TcgSet } from './types';
|
||||
|
||||
export type BrowserTab = 'decks' | 'builder';
|
||||
|
||||
export class TcgBrowserState {
|
||||
tab = $state<BrowserTab>('decks');
|
||||
deckId = $state(tcgDecks[0]?.id ?? '');
|
||||
searchedName = $state('');
|
||||
supertype = $state('');
|
||||
energyType = $state('');
|
||||
selectedSet = $state<TcgSet | null>(null);
|
||||
setId = $derived(this.selectedSet?.id ?? '');
|
||||
page = $state(1);
|
||||
openedCard = $state<TcgCard | null>(null);
|
||||
|
||||
setSearchedName(name: string) {
|
||||
if (name === this.searchedName) return;
|
||||
this.searchedName = name;
|
||||
this.page = 1;
|
||||
}
|
||||
|
||||
toggleSupertype(supertype: string) {
|
||||
this.supertype = this.supertype === supertype ? '' : supertype;
|
||||
this.page = 1;
|
||||
}
|
||||
|
||||
toggleEnergyType(energyType: string) {
|
||||
this.energyType = this.energyType === energyType ? '' : energyType;
|
||||
this.page = 1;
|
||||
}
|
||||
|
||||
toggleSet(set: TcgSet | null) {
|
||||
this.selectedSet = set && set.id !== this.setId ? set : null;
|
||||
this.page = 1;
|
||||
}
|
||||
|
||||
openCard(card: TcgCard | null) {
|
||||
this.openedCard = card;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { DeckCategory } from '#lib/content';
|
||||
import type { TcgCard } from './types';
|
||||
|
||||
export const deckSizeLimit = 60;
|
||||
export const copyLimit = 4;
|
||||
|
||||
export const categoryTitles: Record<DeckCategory, string> = {
|
||||
pokemon: 'Pokemon',
|
||||
trainer: 'Trainer',
|
||||
energy: 'Energy'
|
||||
};
|
||||
|
||||
export const categoryOrder = Object.keys(categoryTitles) as DeckCategory[];
|
||||
|
||||
function plainText(text: string): string {
|
||||
return text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
export function cardCategory(card: Pick<TcgCard, 'supertype'>): DeckCategory {
|
||||
const supertype = plainText(card.supertype ?? '');
|
||||
if (supertype.startsWith('trainer')) return 'trainer';
|
||||
if (supertype.startsWith('energy')) return 'energy';
|
||||
return 'pokemon';
|
||||
}
|
||||
|
||||
export function isBasicEnergy(card: Pick<TcgCard, 'supertype' | 'subtypes'>): boolean {
|
||||
return cardCategory(card) === 'energy' && (card.subtypes ?? []).some((subtype) => plainText(subtype) === 'basic');
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { DeckCategory } from '#lib/content';
|
||||
import { cardCategory, categoryOrder, categoryTitles, copyLimit, deckSizeLimit, isBasicEnergy } from './cardKind';
|
||||
import type { TcgCard } from './types';
|
||||
|
||||
export interface BuilderCard {
|
||||
id: string;
|
||||
name: string;
|
||||
supertype?: string;
|
||||
subtypes?: string[];
|
||||
image: string;
|
||||
setCode?: string;
|
||||
number?: string;
|
||||
}
|
||||
|
||||
export interface BuilderEntry {
|
||||
card: BuilderCard;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface BuilderSection {
|
||||
category: DeckCategory;
|
||||
title: string;
|
||||
total: number;
|
||||
entries: BuilderEntry[];
|
||||
}
|
||||
|
||||
const storageKey = 'tcg-deck-builder';
|
||||
const defaultDeckName = 'my deck';
|
||||
|
||||
export function toBuilderCard(card: TcgCard): BuilderCard {
|
||||
return {
|
||||
id: card.id,
|
||||
name: card.name,
|
||||
supertype: card.supertype,
|
||||
subtypes: card.subtypes,
|
||||
image: card.images.small,
|
||||
setCode: card.set.ptcgoCode,
|
||||
number: card.number
|
||||
};
|
||||
}
|
||||
|
||||
class DeckBuilderState {
|
||||
name = $state(defaultDeckName);
|
||||
entries = $state<BuilderEntry[]>([]);
|
||||
|
||||
private loaded = false;
|
||||
|
||||
total = $derived(this.entries.reduce((sum, entry) => sum + entry.count, 0));
|
||||
|
||||
sections = $derived<BuilderSection[]>(
|
||||
categoryOrder
|
||||
.map((category) => {
|
||||
const entries = this.entries.filter((entry) => cardCategory(entry.card) === category);
|
||||
return {
|
||||
category,
|
||||
title: categoryTitles[category],
|
||||
total: entries.reduce((sum, entry) => sum + entry.count, 0),
|
||||
entries
|
||||
};
|
||||
})
|
||||
.filter((section) => section.entries.length > 0)
|
||||
);
|
||||
|
||||
load() {
|
||||
if (this.loaded) return;
|
||||
this.loaded = true;
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(storageKey) ?? 'null');
|
||||
if (!stored) return;
|
||||
this.name = typeof stored.name === 'string' ? stored.name : defaultDeckName;
|
||||
this.entries = Array.isArray(stored.entries) ? stored.entries : [];
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private save() {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ name: this.name, entries: this.entries }));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
countOf(cardId: string): number {
|
||||
return this.entries.find((entry) => entry.card.id === cardId)?.count ?? 0;
|
||||
}
|
||||
|
||||
private copiesNamed(name: string): number {
|
||||
return this.entries.filter((entry) => entry.card.name === name).reduce((sum, entry) => sum + entry.count, 0);
|
||||
}
|
||||
|
||||
canAdd(card: Pick<TcgCard, 'name' | 'supertype' | 'subtypes'>): boolean {
|
||||
if (this.total >= deckSizeLimit) return false;
|
||||
return isBasicEnergy(card) || this.copiesNamed(card.name) < copyLimit;
|
||||
}
|
||||
|
||||
add(card: TcgCard) {
|
||||
if (!this.canAdd(card)) return;
|
||||
const entry = this.entries.find((item) => item.card.id === card.id);
|
||||
if (entry) entry.count += 1;
|
||||
else this.entries.push({ card: toBuilderCard(card), count: 1 });
|
||||
this.save();
|
||||
}
|
||||
|
||||
increase(cardId: string) {
|
||||
const entry = this.entries.find((item) => item.card.id === cardId);
|
||||
if (!entry || !this.canAdd(entry.card)) return;
|
||||
entry.count += 1;
|
||||
this.save();
|
||||
}
|
||||
|
||||
remove(cardId: string) {
|
||||
const entry = this.entries.find((item) => item.card.id === cardId);
|
||||
if (!entry) return;
|
||||
entry.count -= 1;
|
||||
if (entry.count <= 0) this.entries = this.entries.filter((item) => item.card.id !== cardId);
|
||||
this.save();
|
||||
}
|
||||
|
||||
rename(name: string) {
|
||||
this.name = name;
|
||||
this.save();
|
||||
}
|
||||
|
||||
replace(name: string, entries: BuilderEntry[]) {
|
||||
this.name = name;
|
||||
this.entries = entries;
|
||||
this.save();
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.entries = [];
|
||||
this.save();
|
||||
}
|
||||
|
||||
exportText(): string {
|
||||
const lines = this.sections.flatMap((section) => [
|
||||
`${section.title}: ${section.total}`,
|
||||
...section.entries.map((entry) => [entry.count, entry.card.name, entry.card.setCode, entry.card.number].filter(Boolean).join(' ')),
|
||||
''
|
||||
]);
|
||||
return [...lines, `Total Cards: ${this.total}`].join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
export const deckBuilder = new DeckBuilderState();
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { BuilderEntry } from './deckBuilder.svelte';
|
||||
|
||||
function fileSafeName(name: string): string {
|
||||
return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'deck';
|
||||
}
|
||||
|
||||
export function downloadFile(fileName: string, content: string, mimeType: string) {
|
||||
const url = URL.createObjectURL(new Blob([content], { type: mimeType }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function downloadDeckText(deckName: string, text: string) {
|
||||
downloadFile(`${fileSafeName(deckName)}.txt`, text, 'text/plain');
|
||||
}
|
||||
|
||||
export function downloadDeckJson(deckName: string, entries: BuilderEntry[]) {
|
||||
const deck = {
|
||||
name: deckName,
|
||||
total: entries.reduce((sum, entry) => sum + entry.count, 0),
|
||||
cards: entries.map((entry) => ({
|
||||
count: entry.count,
|
||||
id: entry.card.id,
|
||||
name: entry.card.name,
|
||||
set: entry.card.setCode,
|
||||
number: entry.card.number
|
||||
}))
|
||||
};
|
||||
downloadFile(`${fileSafeName(deckName)}.json`, JSON.stringify(deck, null, '\t'), 'application/json');
|
||||
}
|
||||
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { DeckCard, DeckCategory, TcgDeck } from '#lib/content';
|
||||
import { findCardByName } from './api';
|
||||
import { categoryOrder, categoryTitles } from './cardKind';
|
||||
import { toBuilderCard, type BuilderEntry } from './deckBuilder.svelte';
|
||||
import type { TcgCard } from './types';
|
||||
|
||||
export interface DeckSection {
|
||||
category: DeckCategory;
|
||||
title: string;
|
||||
total: number;
|
||||
cards: DeckCard[];
|
||||
}
|
||||
|
||||
const lookups = new Map<string, Promise<TcgCard | null>>();
|
||||
|
||||
export function deckCardKey(card: DeckCard): string {
|
||||
return `${card.name}|${card.set ?? ''}`;
|
||||
}
|
||||
|
||||
export function deckSections(deck: TcgDeck): DeckSection[] {
|
||||
return categoryOrder
|
||||
.map((category) => {
|
||||
const cards = deck.cards.filter((card) => card.category === category);
|
||||
return {
|
||||
category,
|
||||
title: categoryTitles[category],
|
||||
total: cards.reduce((sum, card) => sum + card.count, 0),
|
||||
cards
|
||||
};
|
||||
})
|
||||
.filter((section) => section.cards.length > 0);
|
||||
}
|
||||
|
||||
export function deckTotal(deck: TcgDeck): number {
|
||||
return deck.cards.reduce((sum, card) => sum + card.count, 0);
|
||||
}
|
||||
|
||||
export function lookUpDeckCard(card: DeckCard): Promise<TcgCard | null> {
|
||||
const key = deckCardKey(card);
|
||||
const existing = lookups.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const lookup = findCardByName(card.name, card.set).catch((error) => {
|
||||
lookups.delete(key);
|
||||
throw error;
|
||||
});
|
||||
lookups.set(key, lookup);
|
||||
return lookup;
|
||||
}
|
||||
|
||||
export async function deckBuilderEntries(deck: TcgDeck): Promise<BuilderEntry[]> {
|
||||
const entries: BuilderEntry[] = [];
|
||||
for (const deckCard of deck.cards) {
|
||||
const card = await lookUpDeckCard(deckCard).catch(() => null);
|
||||
if (card) entries.push({ card: toBuilderCard(card), count: deckCard.count });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
const energyColors: Record<string, string> = {
|
||||
fire: 'var(--red)',
|
||||
water: 'var(--blue)',
|
||||
grass: 'var(--green)',
|
||||
lightning: 'var(--yellow)',
|
||||
psychic: 'var(--magenta)',
|
||||
fairy: 'var(--magenta)',
|
||||
fighting: 'var(--orange)',
|
||||
dragon: 'var(--orange)',
|
||||
darkness: 'var(--muted)',
|
||||
metal: 'var(--cyan)',
|
||||
colorless: 'var(--foreground)'
|
||||
};
|
||||
|
||||
export function energyColor(type: string): string {
|
||||
return energyColors[type.toLowerCase()] ?? 'var(--foreground)';
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
export interface TcgImages {
|
||||
small: string;
|
||||
large: string;
|
||||
}
|
||||
|
||||
export interface TcgSet {
|
||||
id: string;
|
||||
name: string;
|
||||
series: string;
|
||||
printedTotal?: number;
|
||||
total?: number;
|
||||
ptcgoCode?: string;
|
||||
releaseDate?: string;
|
||||
images?: {
|
||||
symbol?: string;
|
||||
logo?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TcgAttack {
|
||||
name: string;
|
||||
cost?: string[];
|
||||
damage?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface TcgAbility {
|
||||
name: string;
|
||||
type?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface TcgTypeValue {
|
||||
type: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface TcgCard {
|
||||
id: string;
|
||||
name: string;
|
||||
supertype?: string;
|
||||
subtypes?: string[];
|
||||
hp?: string;
|
||||
types?: string[];
|
||||
rarity?: string;
|
||||
number?: string;
|
||||
artist?: string;
|
||||
flavorText?: string;
|
||||
images: TcgImages;
|
||||
set: TcgSet;
|
||||
abilities?: TcgAbility[];
|
||||
attacks?: TcgAttack[];
|
||||
weaknesses?: TcgTypeValue[];
|
||||
resistances?: TcgTypeValue[];
|
||||
}
|
||||
|
||||
export interface TcgPage<Item> {
|
||||
data: Item[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
count: number;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface CardSearch {
|
||||
name: string;
|
||||
supertype: string;
|
||||
energyType: string;
|
||||
setId: string;
|
||||
page: number;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { GameName } from '#lib/content';
|
||||
import { line, span } from '../output';
|
||||
import type { Command } from './types';
|
||||
|
||||
const gameDescriptions: Record<GameName, string> = {
|
||||
snake: 'Eat the food and avoid your own tail',
|
||||
breakout: 'Bounce the ball and clear every brick'
|
||||
};
|
||||
|
||||
const gameNames = Object.keys(gameDescriptions) as GameName[];
|
||||
|
||||
const launchCommands: Command[] = gameNames.map((game) => ({
|
||||
name: game,
|
||||
description: gameDescriptions[game],
|
||||
hidden: true,
|
||||
run: (_args, context) => context.openApp(game)
|
||||
}));
|
||||
|
||||
const gamesCommand: Command = {
|
||||
name: 'games',
|
||||
description: 'List or launch the games',
|
||||
usage: 'games [name]',
|
||||
argumentOptions: () => gameNames,
|
||||
run: (args, context) => {
|
||||
const requestedGame = gameNames.find((game) => game === args[0]?.toLowerCase());
|
||||
if (requestedGame) {
|
||||
context.openApp(requestedGame);
|
||||
return;
|
||||
}
|
||||
gameNames.forEach((game) =>
|
||||
context.print(line(span(game.padEnd(12), 'accent', { bold: true }), gameDescriptions[game]))
|
||||
);
|
||||
context.print(line(span('Run ', 'muted'), span('games <name>', 'accent'), span(' to play', 'muted')));
|
||||
}
|
||||
};
|
||||
|
||||
export const gameCommands: Command[] = [gamesCommand, ...launchCommands];
|
||||
@@ -4,6 +4,8 @@ import { blankLine, errorLine, line, span } from '../output';
|
||||
import { infoCommands } from './info';
|
||||
import { systemCommands } from './system';
|
||||
import { pacmanCommand } from './pacman';
|
||||
import { gameCommands } from './games';
|
||||
import { petCommand } from './pet';
|
||||
import type { Command, CommandContext } from './types';
|
||||
|
||||
export type { Command, CommandContext } from './types';
|
||||
@@ -44,6 +46,8 @@ export const commands: Command[] = [
|
||||
...infoCommands,
|
||||
...systemCommands,
|
||||
pacmanCommand,
|
||||
...gameCommands,
|
||||
petCommand,
|
||||
...pageShortcuts,
|
||||
...socialShortcuts
|
||||
];
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { petState } from '#lib/pet/petState.svelte';
|
||||
import { line, span } from '../output';
|
||||
import type { Command } from './types';
|
||||
|
||||
export const petCommand: Command = {
|
||||
name: 'pet',
|
||||
aliases: ['robot'],
|
||||
description: 'Show or hide the desktop robot',
|
||||
usage: 'pet [on|off]',
|
||||
argumentOptions: () => ['on', 'off'],
|
||||
run: (args, context) => {
|
||||
const choice = args[0]?.toLowerCase();
|
||||
if (choice === 'on' || choice === 'off') petState.setEnabled(choice === 'on');
|
||||
else petState.toggle();
|
||||
|
||||
const status = petState.enabled ? 'robot booted up' : 'robot powered down';
|
||||
context.print(line(span('pet: ', 'muted'), span(status, 'accent')));
|
||||
}
|
||||
};
|
||||
@@ -134,6 +134,12 @@ export const systemCommands: Command[] = [
|
||||
description: 'Open the system monitor',
|
||||
run: (_args, context) => context.openApp('btop')
|
||||
},
|
||||
{
|
||||
name: 'tcg',
|
||||
aliases: ['cards', 'pokemon'],
|
||||
description: 'Open the Pokemon TCG card browser',
|
||||
run: (_args, context) => context.openApp('tcg')
|
||||
},
|
||||
{
|
||||
name: 'pwd',
|
||||
hidden: true,
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { about } from '#lib/content';
|
||||
import { getAnimeProfile } from '#lib/server/anilist';
|
||||
import { getAniListProfile } from '#lib/server/anilist';
|
||||
import { getMediaDetails } from '#lib/server/omdb';
|
||||
import { getListeningProfile } from '#lib/server/lastfm/profile';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const mediaUrls = [...about.shows, ...about.movies].map((item) => item.url);
|
||||
const anilistUsername = about.anilistUsername;
|
||||
|
||||
const [anime, mediaDetails, listening] = await Promise.all([
|
||||
about.anilistUsername ? getAnimeProfile(about.anilistUsername) : null,
|
||||
const [anime, manga, mediaDetails, listening] = await Promise.all([
|
||||
anilistUsername ? getAniListProfile(anilistUsername, 'ANIME') : null,
|
||||
anilistUsername ? getAniListProfile(anilistUsername, 'MANGA') : null,
|
||||
getMediaDetails(mediaUrls),
|
||||
about.lastfmUsername ? getListeningProfile(about.lastfmUsername) : null
|
||||
]);
|
||||
|
||||
return { anime, mediaDetails, listening };
|
||||
const hasManga = !!manga?.statusCounts.some((item) => item.count > 0);
|
||||
|
||||
return { anime, manga: hasManga ? manga : null, mediaDetails, listening };
|
||||
};
|
||||
@@ -5,9 +5,11 @@
|
||||
import SectionNav from '#lib/components/ui/SectionNav.svelte';
|
||||
import Section from '#lib/components/ui/Section.svelte';
|
||||
import Panel from '#lib/components/ui/Panel.svelte';
|
||||
import AnimeSection from '#lib/components/about/AnimeSection.svelte';
|
||||
import AniListSection from '#lib/components/about/AniListSection.svelte';
|
||||
import MediaGrid from '#lib/components/about/MediaGrid.svelte';
|
||||
import GameCard from '#lib/components/about/GameCard.svelte';
|
||||
import BookGrid from '#lib/components/about/BookGrid.svelte';
|
||||
import { sortBooks } from '#lib/books/books';
|
||||
import SetupSection from '#lib/components/about/setup/SetupSection.svelte';
|
||||
import ListeningSection from '#lib/components/about/listening/ListeningSection.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
@@ -17,6 +19,7 @@
|
||||
const byRating = (first: { rating: number }, second: { rating: number }) => second.rating - first.rating;
|
||||
const shows = [...about.shows].sort(byRating);
|
||||
const movies = [...about.movies].sort(byRating);
|
||||
const books = sortBooks(about.books ?? []);
|
||||
const mediaGroups = [
|
||||
{ id: 'shows', title: 'TV Shows', items: shows, kind: 'show' as const },
|
||||
{ id: 'movies', title: 'Movies', items: movies, kind: 'movie' as const }
|
||||
@@ -28,8 +31,10 @@
|
||||
{ id: 'games', label: 'Games', color: 'orange' },
|
||||
{ id: 'shows', label: 'Shows', color: 'yellow' },
|
||||
{ id: 'movies', label: 'Movies', color: 'green' },
|
||||
...(books.length ? [{ id: 'books', label: 'Books', color: 'cyan' }] : []),
|
||||
...(data.listening ? [{ id: 'music', label: 'Music', color: 'red' }] : []),
|
||||
...(data.anime ? [{ id: 'anime', label: 'Anime', color: 'magenta' }] : [])
|
||||
...(data.anime ? [{ id: 'anime', label: 'Anime', color: 'magenta' }] : []),
|
||||
...(data.manga ? [{ id: 'manga', label: 'Manga', color: 'blue' }] : [])
|
||||
]);
|
||||
</script>
|
||||
|
||||
@@ -69,6 +74,12 @@
|
||||
</Section>
|
||||
{/each}
|
||||
|
||||
{#if books.length}
|
||||
<Section title="Books" id="books">
|
||||
<BookGrid {books} />
|
||||
</Section>
|
||||
{/if}
|
||||
|
||||
{#if data.listening}
|
||||
<Section title="Music" id="music">
|
||||
<ListeningSection listening={data.listening} />
|
||||
@@ -77,7 +88,13 @@
|
||||
|
||||
{#if data.anime}
|
||||
<Section title="Anime" id="anime">
|
||||
<AnimeSection anime={data.anime} />
|
||||
<AniListSection profile={data.anime} />
|
||||
</Section>
|
||||
{/if}
|
||||
|
||||
{#if data.manga}
|
||||
<Section title="Manga" id="manga">
|
||||
<AniListSection profile={data.manga} />
|
||||
</Section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
desktopKeybinds,
|
||||
imageViewerKeybinds,
|
||||
navigationTips,
|
||||
snakeKeybinds,
|
||||
breakoutKeybinds,
|
||||
terminalKeybinds,
|
||||
workspaceKeybinds
|
||||
} from '#lib/desktop/keybindGuide';
|
||||
@@ -20,7 +22,8 @@
|
||||
{ id: 'navigation', label: 'Navigation', color: 'blue' },
|
||||
{ id: 'desktop', label: 'Desktop', color: 'magenta' },
|
||||
{ id: 'terminal', label: 'Terminal', color: 'green' },
|
||||
{ id: 'viewer', label: 'Image viewer', color: 'orange' }
|
||||
{ id: 'viewer', label: 'Image viewer', color: 'orange' },
|
||||
{ id: 'games', label: 'Games', color: 'yellow' }
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -74,6 +77,20 @@
|
||||
<KeybindTable entries={imageViewerKeybinds} />
|
||||
<p class="muted hint"><Icon icon="mdi:gesture-pinch" width="16" /> Scroll or pinch to zoom, drag to pan and swipe to change images.</p>
|
||||
</Section>
|
||||
|
||||
<Section title="Games" id="games">
|
||||
<div class="columns">
|
||||
<div>
|
||||
<h3>Snake</h3>
|
||||
<KeybindTable entries={snakeKeybinds} />
|
||||
</div>
|
||||
<div>
|
||||
<h3>Breakout</h3>
|
||||
<KeybindTable entries={breakoutKeybinds} />
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted hint"><Icon icon="mdi:gesture-tap" width="16" /> On touch screens use the arrow pad in snake, and drag to move the paddle in breakout.</p>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
Reference in new issue
Block a user