Compare commits
4
Commits
812503251b
...
f5df946e0b
| 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: 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": "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/" }
|
{ "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" } }
|
{ "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",
|
"name": "socials",
|
||||||
"icons": [
|
"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">
|
<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';
|
const hostName = 'anilist';
|
||||||
|
|
||||||
@@ -13,23 +13,18 @@
|
|||||||
DROPPED: 'red'
|
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 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>
|
</script>
|
||||||
|
|
||||||
<div class="anime-fetch">
|
<div class="anilist-fetch">
|
||||||
<a class="heading" href={anime.profileUrl} target="_blank" rel="noopener noreferrer">
|
<a class="heading" href={profile.listUrl} target="_blank" rel="noopener noreferrer">
|
||||||
<span class="user">{anime.name.toLowerCase()}</span><span class="at">@</span><span class="host">{hostName}</span>
|
<span class="user">{profile.name.toLowerCase()}</span><span class="at">@</span><span class="host">{hostName}</span>
|
||||||
</a>
|
</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)}
|
{#each profile.stats as stat (stat.label)}
|
||||||
<div class="row"><span class="label">{label}</span>: {value}</div>
|
<div class="row"><span class="label">{stat.label}</span>: {stat.value}</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#each shownStatuses as item (item.status)}
|
{#each shownStatuses as item (item.status)}
|
||||||
@@ -53,7 +48,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.anime-fetch {
|
.anilist-fetch {
|
||||||
line-height: 1.5;
|
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 Panel from '../../ui/Panel.svelte';
|
||||||
import ActivityPanel from './ActivityPanel.svelte';
|
import ActivityPanel from './ActivityPanel.svelte';
|
||||||
import ListeningFetch from './ListeningFetch.svelte';
|
import ListeningFetch from './ListeningFetch.svelte';
|
||||||
import RecentTracks from './RecentTracks.svelte';
|
|
||||||
import TopCharts from './TopCharts.svelte';
|
import TopCharts from './TopCharts.svelte';
|
||||||
import TrendingArtists from './TrendingArtists.svelte';
|
import TrendingArtists from './TrendingArtists.svelte';
|
||||||
|
|
||||||
@@ -16,18 +15,13 @@
|
|||||||
<Panel title="last.fm" icon="simple-icons:lastdotfm">
|
<Panel title="last.fm" icon="simple-icons:lastdotfm">
|
||||||
<ListeningFetch {listening} />
|
<ListeningFetch {listening} />
|
||||||
</Panel>
|
</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">
|
<Panel title="Trending this week" icon="mdi:trending-up">
|
||||||
<TrendingArtists artists={listening.trending} />
|
<TrendingArtists artists={listening.trending} />
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ActivityPanel {listening} />
|
||||||
|
|
||||||
<h3 class="subheading"><Icon icon="mdi:trophy-outline" width="16" /> Top charts</h3>
|
<h3 class="subheading"><Icon icon="mdi:trophy-outline" width="16" /> Top charts</h3>
|
||||||
<TopCharts top={listening.top} />
|
<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 { imageFileName } from '#lib/desktop/imageViewer';
|
||||||
import { defaultFloatingRect, floatingStyle, moveRect, resizeRect } from '#lib/desktop/floating';
|
import { defaultFloatingRect, floatingStyle, moveRect, resizeRect } from '#lib/desktop/floating';
|
||||||
import SystemMonitor from '../btop/SystemMonitor.svelte';
|
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 DesktopIcons from './DesktopIcons.svelte';
|
||||||
import Window from './Window.svelte';
|
import Window from './Window.svelte';
|
||||||
|
|
||||||
@@ -98,6 +102,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tiling-area" bind:clientWidth={areaWidth} bind:clientHeight={areaHeight}>
|
<div class="tiling-area" bind:clientWidth={areaWidth} bind:clientHeight={areaHeight}>
|
||||||
|
{#if !isNarrow}
|
||||||
|
<DesktopPet {areaWidth} {areaHeight} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if pageOpen}
|
{#if pageOpen}
|
||||||
<Window title="kitty" path={pageTitle} icon={pageIcon} scrollKey={pageKey} {...windowProps(pageWindowId)} onclose={closePage}>
|
<Window title="kitty" path={pageTitle} icon={pageIcon} scrollKey={pageKey} {...windowProps(pageWindowId)} onclose={closePage}>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
@@ -141,6 +149,35 @@
|
|||||||
<SystemMonitor />
|
<SystemMonitor />
|
||||||
</Window>
|
</Window>
|
||||||
{/each}
|
{/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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '@iconify/svelte';
|
import Icon from '@iconify/svelte';
|
||||||
import { desktop } from '#lib/content';
|
import { desktop, type DesktopSide } from '#lib/content';
|
||||||
import { runDesktopAction } from '#lib/desktop/actions';
|
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>
|
</script>
|
||||||
|
|
||||||
<nav class="desktop-icons" aria-label="Desktop">
|
<div class="desktop-icon-sides">
|
||||||
{#each icons as item (item.label)}
|
{#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)}>
|
<button class="desktop-icon" title={item.label} onclick={() => runDesktopAction(item.open)}>
|
||||||
<span class="icon-box">
|
<span class="icon-box">
|
||||||
<Icon icon={item.icon} width="36" />
|
<Icon icon={item.icon} width="36" />
|
||||||
@@ -16,8 +26,18 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.desktop-icon-sides {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 4px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.desktop-icons {
|
.desktop-icons {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-flow: column;
|
grid-auto-flow: column;
|
||||||
@@ -29,6 +49,10 @@
|
|||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.desktop-icons.right {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
.desktop-icon {
|
.desktop-icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -38,6 +62,7 @@
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
|
direction: ltr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.desktop-icon:focus-visible {
|
.desktop-icon:focus-visible {
|
||||||
@@ -86,6 +111,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
|
.desktop-icon-sides {
|
||||||
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.desktop-icons {
|
.desktop-icons {
|
||||||
grid-auto-flow: row;
|
grid-auto-flow: row;
|
||||||
grid-template-columns: repeat(auto-fill, 104px);
|
grid-template-columns: repeat(auto-fill, 104px);
|
||||||
@@ -94,5 +124,9 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.desktop-icons.right {
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
onfocusin={onfocus}
|
onfocusin={onfocus}
|
||||||
>
|
>
|
||||||
<div class="window-surface">
|
<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="title">
|
||||||
<span class="app-name"><Icon {icon} width="15" />{title}</span>
|
<span class="app-name"><Icon {icon} width="15" />{title}</span>
|
||||||
{#if path}
|
{#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 = {
|
const context: CommandContext = {
|
||||||
print: (...newBlocks) => blocks.push(...newBlocks),
|
print: (...newBlocks) => blocks.push(...newBlocks),
|
||||||
clear: () => (blocks = []),
|
clear: () => (blocks = []),
|
||||||
exit: onexit,
|
exit: () => onexit(),
|
||||||
openPage,
|
openPage,
|
||||||
openUrl,
|
openUrl,
|
||||||
setTheme: (themeId) => themeState.set(themeId),
|
setTheme: (themeId) => themeState.set(themeId),
|
||||||
|
|||||||
@@ -121,7 +121,7 @@
|
|||||||
'0': controls.fit,
|
'0': controls.fit,
|
||||||
r: controls.rotateRight,
|
r: controls.rotateRight,
|
||||||
R: controls.rotateLeft,
|
R: controls.rotateLeft,
|
||||||
Escape: onclose
|
Escape: () => onclose()
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import packagesData from '#content/projects/packages.json';
|
|||||||
import hackathonsData from '#content/projects/hackathons.json';
|
import hackathonsData from '#content/projects/hackathons.json';
|
||||||
import researchData from '#content/projects/research.json';
|
import researchData from '#content/projects/research.json';
|
||||||
import organizationsData from '#content/projects/organizations.json';
|
import organizationsData from '#content/projects/organizations.json';
|
||||||
|
import tcgDecksData from '#content/tcgDecks.json';
|
||||||
import type {
|
import type {
|
||||||
AboutContent,
|
AboutContent,
|
||||||
BlogSettings,
|
BlogSettings,
|
||||||
@@ -21,7 +22,8 @@ import type {
|
|||||||
Project,
|
Project,
|
||||||
ProjectCategory,
|
ProjectCategory,
|
||||||
ProjectCategoryId,
|
ProjectCategoryId,
|
||||||
ResearchGroup
|
ResearchGroup,
|
||||||
|
TcgDeck
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export type * from './types';
|
export type * from './types';
|
||||||
@@ -34,6 +36,7 @@ export const desktop: DesktopContent = desktopData;
|
|||||||
export const blogSettings: BlogSettings = blogData;
|
export const blogSettings: BlogSettings = blogData;
|
||||||
export const playlist: Playlist = musicData;
|
export const playlist: Playlist = musicData;
|
||||||
export const researchGroups: ResearchGroup[] = researchData;
|
export const researchGroups: ResearchGroup[] = researchData;
|
||||||
|
export const tcgDecks: TcgDeck[] = tcgDecksData as TcgDeck[];
|
||||||
|
|
||||||
function featuredFirst(projects: Project[]): Project[] {
|
function featuredFirst(projects: Project[]): Project[] {
|
||||||
return [...projects].sort((first, second) => Number(!!second.featured) - Number(!!first.featured));
|
return [...projects].sort((first, second) => Number(!!second.featured) - Number(!!first.featured));
|
||||||
|
|||||||
@@ -139,6 +139,17 @@ export interface RatedTitle {
|
|||||||
url: string;
|
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 {
|
export interface LabeledValue {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -161,14 +172,20 @@ export interface AboutContent {
|
|||||||
games: Game[];
|
games: Game[];
|
||||||
shows: RatedTitle[];
|
shows: RatedTitle[];
|
||||||
movies: 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 DesktopAction = { page: string } | { terminal: string } | { url: string } | { app: DesktopApp };
|
||||||
|
|
||||||
|
export type DesktopSide = 'left' | 'right';
|
||||||
|
|
||||||
export interface DesktopIconGroup {
|
export interface DesktopIconGroup {
|
||||||
name: string;
|
name: string;
|
||||||
|
side?: DesktopSide;
|
||||||
icons: DesktopIcon[];
|
icons: DesktopIcon[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,3 +217,18 @@ export interface BlogSettings {
|
|||||||
tagline: string;
|
tagline: string;
|
||||||
postsPerPage: number;
|
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 { goto } from '$app/navigation';
|
||||||
import { desktop, type DesktopAction, type DesktopApp, type Workspace } from '#lib/content';
|
import { desktop, type DesktopAction, type DesktopApp, type Workspace } from '#lib/content';
|
||||||
|
import { petState } from '#lib/pet/petState.svelte';
|
||||||
import { pageWindowId, windowManager } from './windows.svelte';
|
import { pageWindowId, windowManager } from './windows.svelte';
|
||||||
|
|
||||||
export function isExternalUrl(url: string): boolean {
|
export function isExternalUrl(url: string): boolean {
|
||||||
@@ -21,6 +22,9 @@ export function openPage(path: string) {
|
|||||||
|
|
||||||
export function openApp(app: DesktopApp) {
|
export function openApp(app: DesktopApp) {
|
||||||
if (app === 'btop') windowManager.openSystemMonitor();
|
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) {
|
export function runDesktopAction(action: DesktopAction) {
|
||||||
|
|||||||
@@ -38,6 +38,24 @@ export const imageViewerKeybinds: KeybindEntry[] = [
|
|||||||
{ keys: ['Esc'], action: 'Close the viewer' }
|
{ 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[] = [
|
export const terminalKeybinds: KeybindEntry[] = [
|
||||||
{ keys: ['Tab'], action: 'Complete a command or page name' },
|
{ keys: ['Tab'], action: 'Complete a command or page name' },
|
||||||
{ keys: ['Up'], action: 'Previous command in history' },
|
{ 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: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: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: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.' }
|
{ 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 const pageWindowId = 'page';
|
||||||
|
|
||||||
export interface TerminalWindow {
|
export interface TerminalWindow {
|
||||||
@@ -20,6 +22,15 @@ export interface SystemMonitorWindow {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TcgBrowserWindow {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameWindow {
|
||||||
|
id: string;
|
||||||
|
game: GameName;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FloatingRect {
|
export interface FloatingRect {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -31,6 +42,8 @@ class WindowManager {
|
|||||||
terminals = $state<TerminalWindow[]>([]);
|
terminals = $state<TerminalWindow[]>([]);
|
||||||
imageViewers = $state<ImageViewerWindow[]>([]);
|
imageViewers = $state<ImageViewerWindow[]>([]);
|
||||||
systemMonitors = $state<SystemMonitorWindow[]>([]);
|
systemMonitors = $state<SystemMonitorWindow[]>([]);
|
||||||
|
games = $state<GameWindow[]>([]);
|
||||||
|
tcgBrowsers = $state<TcgBrowserWindow[]>([]);
|
||||||
floatingRects = $state<Record<string, FloatingRect | null>>({});
|
floatingRects = $state<Record<string, FloatingRect | null>>({});
|
||||||
focusedId = $state<string>(pageWindowId);
|
focusedId = $state<string>(pageWindowId);
|
||||||
maximizedId = $state<string | null>(null);
|
maximizedId = $state<string | null>(null);
|
||||||
@@ -39,12 +52,16 @@ class WindowManager {
|
|||||||
private nextTerminalNumber = 1;
|
private nextTerminalNumber = 1;
|
||||||
private nextImageViewerNumber = 1;
|
private nextImageViewerNumber = 1;
|
||||||
private nextSystemMonitorNumber = 1;
|
private nextSystemMonitorNumber = 1;
|
||||||
|
private nextGameNumber = 1;
|
||||||
|
private nextTcgBrowserNumber = 1;
|
||||||
|
|
||||||
get extraWindowIds(): string[] {
|
get extraWindowIds(): string[] {
|
||||||
return [
|
return [
|
||||||
...this.terminals.map((terminal) => terminal.id),
|
...this.terminals.map((terminal) => terminal.id),
|
||||||
...this.imageViewers.map((viewer) => viewer.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);
|
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) {
|
closeWindow(id: string) {
|
||||||
this.terminals = this.terminals.filter((terminal) => terminal.id !== id);
|
this.terminals = this.terminals.filter((terminal) => terminal.id !== id);
|
||||||
this.imageViewers = this.imageViewers.filter((viewer) => viewer.id !== id);
|
this.imageViewers = this.imageViewers.filter((viewer) => viewer.id !== id);
|
||||||
this.systemMonitors = this.systemMonitors.filter((monitor) => monitor.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];
|
delete this.floatingRects[id];
|
||||||
if (this.maximizedId === id) this.maximizedId = null;
|
if (this.maximizedId === id) this.maximizedId = null;
|
||||||
if (this.focusedId === id) this.focusedId = this.extraWindowIds.at(-1) ?? pageWindowId;
|
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;
|
profileUrl: string;
|
||||||
registeredAt: number | null;
|
registeredAt: number | null;
|
||||||
totals: ListeningTotals;
|
totals: ListeningTotals;
|
||||||
nowPlaying: PlayedTrack | null;
|
|
||||||
recentTracks: PlayedTrack[];
|
|
||||||
daily: CountBucket[];
|
daily: CountBucket[];
|
||||||
monthly: CountBucket[];
|
monthly: CountBucket[];
|
||||||
hourly: 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 cacheDuration = 60 * 60 * 1000;
|
||||||
const minutesPerDay = 60 * 24;
|
const minutesPerDay = 60 * 24;
|
||||||
const topRatedMinimumScore = 9;
|
const topRatedMinimumScore = 9;
|
||||||
const statusLabels: Record<string, string> = {
|
|
||||||
COMPLETED: 'Completed',
|
interface MediaStatistics {
|
||||||
CURRENT: 'Watching',
|
count: number;
|
||||||
PLANNING: 'Planning',
|
episodesWatched?: number;
|
||||||
PAUSED: 'Paused',
|
minutesWatched?: number;
|
||||||
DROPPED: 'Dropped'
|
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 = `
|
interface ResponseEntry {
|
||||||
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 {
|
|
||||||
score: number;
|
score: number;
|
||||||
progress: number;
|
progress: number;
|
||||||
media: {
|
media: {
|
||||||
id: number;
|
id: number;
|
||||||
siteUrl: string;
|
siteUrl: string;
|
||||||
episodes: number | null;
|
episodes?: number | null;
|
||||||
|
chapters?: number | null;
|
||||||
title: { english: string | null; romaji: string };
|
title: { english: string | null; romaji: string };
|
||||||
coverImage: { large: string };
|
coverImage: { large: string };
|
||||||
};
|
};
|
||||||
@@ -48,15 +75,37 @@ interface AniListResponse {
|
|||||||
User: {
|
User: {
|
||||||
name: string;
|
name: string;
|
||||||
siteUrl: 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 {
|
return {
|
||||||
id: media.id,
|
id: media.id,
|
||||||
title: media.title.english ?? media.title.romaji,
|
title: media.title.english ?? media.title.romaji,
|
||||||
@@ -64,56 +113,59 @@ function toAnimeEntry({ score, progress, media }: AniListEntry): AnimeEntry {
|
|||||||
cover: media.coverImage.large,
|
cover: media.coverImage.large,
|
||||||
score,
|
score,
|
||||||
progress,
|
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);
|
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 { name, siteUrl, statistics } = response.data.User;
|
||||||
const completed = entriesWithStatus(response, 'COMPLETED');
|
const completed = entriesWithStatus(response, 'COMPLETED', config);
|
||||||
const statusCounts = Object.entries(statusLabels).map(([status, label]) => ({
|
|
||||||
status,
|
|
||||||
label,
|
|
||||||
count: entriesWithStatus(response, status).length
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
mediaType,
|
||||||
name,
|
name,
|
||||||
profileUrl: siteUrl,
|
profileUrl: siteUrl,
|
||||||
statusCounts,
|
listUrl: `${siteUrl}/${config.listPath}`,
|
||||||
stats: {
|
stats: config.stats(statistics[config.statisticsKey]),
|
||||||
count: statistics.anime.count,
|
statusCounts: Object.entries(config.statusLabels).map(([status, label]) => ({
|
||||||
episodesWatched: statistics.anime.episodesWatched,
|
status,
|
||||||
daysWatched: Math.round((statistics.anime.minutesWatched / minutesPerDay) * 10) / 10
|
label,
|
||||||
},
|
count: entriesWithStatus(response, status, config).length
|
||||||
watching: entriesWithStatus(response, 'CURRENT'),
|
})),
|
||||||
|
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),
|
topRated: completed.filter((entry) => entry.score >= topRatedMinimumScore).sort((first, second) => second.score - first.score),
|
||||||
|
planning: entriesWithStatus(response, 'PLANNING', config),
|
||||||
completedCount: completed.length
|
completedCount: completed.length
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAnimeProfile(userName: string): Promise<AnimeProfile | null> {
|
export async function getAniListProfile(userName: string, mediaType: AniListMediaType): Promise<AniListProfile | null> {
|
||||||
const cached = profileCache.get(userName);
|
const cacheKey = `${userName}:${mediaType}`;
|
||||||
|
const cached = profileCache.get(cacheKey);
|
||||||
if (cached && Date.now() - cached.fetchedAt < cacheDuration) return cached.profile;
|
if (cached && Date.now() - cached.fetchedAt < cacheDuration) return cached.profile;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('https://graphql.anilist.co', {
|
const response = await fetch('https://graphql.anilist.co', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
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}`);
|
if (!response.ok) throw new Error(`AniList returned ${response.status}`);
|
||||||
|
|
||||||
const profile = toAnimeProfile(await response.json());
|
const profile = toProfile(await response.json(), mediaType);
|
||||||
profileCache.set(userName, { profile, fetchedAt: Date.now() });
|
profileCache.set(cacheKey, { profile, fetchedAt: Date.now() });
|
||||||
return profile;
|
return profile;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`AniList lookup failed for ${userName}:`, error);
|
console.error(`AniList ${mediaType.toLowerCase()} lookup failed for ${userName}:`, error);
|
||||||
return cached?.profile ?? null;
|
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> {
|
export async function fetchTrackImage(artist: string, track: string): Promise<string | null> {
|
||||||
const { track: info } = await callLastfm<RawTrackInfo>('track.getinfo', { artist, track, autocorrect: '1' });
|
const { track: info } = await callLastfm<RawTrackInfo>('track.getinfo', { artist, track, autocorrect: '1' });
|
||||||
return largestImage(info?.album?.image);
|
return largestImage(info?.album?.image);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ListeningProfile, PlayedTrack } from '#lib/lastfm/types';
|
import type { ListeningProfile } from '#lib/lastfm/types';
|
||||||
import { fetchNowPlaying, fetchUserInfo, hasLastfmKey, type UserInfo } from './api';
|
import { fetchUserInfo, hasLastfmKey, type UserInfo } from './api';
|
||||||
import { loadScrobbleStore, storedScrobbles } from './store';
|
import { loadScrobbleStore, storedScrobbles } from './store';
|
||||||
import { coverFor, coversVersion, loadCovers, requestTrackCovers } from './covers';
|
import { coversVersion, loadCovers, requestTrackCovers } from './covers';
|
||||||
import { lastfmSyncedAt } from './sync';
|
import { lastfmSyncedAt } from './sync';
|
||||||
import {
|
import {
|
||||||
countsByDay,
|
countsByDay,
|
||||||
@@ -14,14 +14,11 @@ import {
|
|||||||
} from './trends';
|
} from './trends';
|
||||||
|
|
||||||
const userInfoDuration = 30 * 60 * 1000;
|
const userInfoDuration = 30 * 60 * 1000;
|
||||||
const nowPlayingDuration = 60 * 1000;
|
|
||||||
const summaryDuration = 5 * 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 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;
|
let summaryCache: { summary: Summary; scrobbleCount: number; coversVersion: number; builtAt: number } | null = null;
|
||||||
|
|
||||||
async function cachedUserInfo(userName: string): Promise<UserInfo | 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;
|
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 {
|
function buildSummary(): Summary {
|
||||||
const scrobbles = storedScrobbles();
|
const scrobbles = storedScrobbles();
|
||||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
@@ -63,7 +39,6 @@ function buildSummary(): Summary {
|
|||||||
const streaks = listeningStreaks(dayCounts);
|
const streaks = listeningStreaks(dayCounts);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
recentTracks: recentTracks(),
|
|
||||||
daily,
|
daily,
|
||||||
monthly: monthlyBuckets(dayCounts),
|
monthly: monthlyBuckets(dayCounts),
|
||||||
hourly: hourlyBuckets(scrobbles),
|
hourly: hourlyBuckets(scrobbles),
|
||||||
@@ -79,7 +54,6 @@ function buildSummary(): Summary {
|
|||||||
function tracksMissingCovers(summary: Summary) {
|
function tracksMissingCovers(summary: Summary) {
|
||||||
return Object.values(summary.top)
|
return Object.values(summary.top)
|
||||||
.flatMap((charts) => charts.tracks)
|
.flatMap((charts) => charts.tracks)
|
||||||
.concat(summary.recentTracks.map((track) => ({ ...track, name: track.track, count: 0 })))
|
|
||||||
.filter((item) => !item.image && item.artist)
|
.filter((item) => !item.image && item.artist)
|
||||||
.map((item) => ({ artist: item.artist!, track: item.name }));
|
.map((item) => ({ artist: item.artist!, track: item.name }));
|
||||||
}
|
}
|
||||||
@@ -105,7 +79,7 @@ export async function getListeningProfile(userName: string): Promise<ListeningPr
|
|||||||
if (!hasLastfmKey()) return null;
|
if (!hasLastfmKey()) return null;
|
||||||
|
|
||||||
await Promise.all([loadScrobbleStore(), loadCovers()]);
|
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;
|
if (!info && !storedScrobbles().length) return null;
|
||||||
|
|
||||||
const summary = cachedSummary();
|
const summary = cachedSummary();
|
||||||
@@ -119,7 +93,6 @@ export async function getListeningProfile(userName: string): Promise<ListeningPr
|
|||||||
albums: info?.albums ?? 0,
|
albums: info?.albums ?? 0,
|
||||||
tracks: info?.tracks ?? 0
|
tracks: info?.tracks ?? 0
|
||||||
},
|
},
|
||||||
nowPlaying,
|
|
||||||
syncedAt: lastfmSyncedAt(),
|
syncedAt: lastfmSyncedAt(),
|
||||||
...summary
|
...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 { infoCommands } from './info';
|
||||||
import { systemCommands } from './system';
|
import { systemCommands } from './system';
|
||||||
import { pacmanCommand } from './pacman';
|
import { pacmanCommand } from './pacman';
|
||||||
|
import { gameCommands } from './games';
|
||||||
|
import { petCommand } from './pet';
|
||||||
import type { Command, CommandContext } from './types';
|
import type { Command, CommandContext } from './types';
|
||||||
|
|
||||||
export type { Command, CommandContext } from './types';
|
export type { Command, CommandContext } from './types';
|
||||||
@@ -44,6 +46,8 @@ export const commands: Command[] = [
|
|||||||
...infoCommands,
|
...infoCommands,
|
||||||
...systemCommands,
|
...systemCommands,
|
||||||
pacmanCommand,
|
pacmanCommand,
|
||||||
|
...gameCommands,
|
||||||
|
petCommand,
|
||||||
...pageShortcuts,
|
...pageShortcuts,
|
||||||
...socialShortcuts
|
...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',
|
description: 'Open the system monitor',
|
||||||
run: (_args, context) => context.openApp('btop')
|
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',
|
name: 'pwd',
|
||||||
hidden: true,
|
hidden: true,
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import { about } from '#lib/content';
|
import { about } from '#lib/content';
|
||||||
import { getAnimeProfile } from '#lib/server/anilist';
|
import { getAniListProfile } from '#lib/server/anilist';
|
||||||
import { getMediaDetails } from '#lib/server/omdb';
|
import { getMediaDetails } from '#lib/server/omdb';
|
||||||
import { getListeningProfile } from '#lib/server/lastfm/profile';
|
import { getListeningProfile } from '#lib/server/lastfm/profile';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async () => {
|
||||||
const mediaUrls = [...about.shows, ...about.movies].map((item) => item.url);
|
const mediaUrls = [...about.shows, ...about.movies].map((item) => item.url);
|
||||||
|
const anilistUsername = about.anilistUsername;
|
||||||
|
|
||||||
const [anime, mediaDetails, listening] = await Promise.all([
|
const [anime, manga, mediaDetails, listening] = await Promise.all([
|
||||||
about.anilistUsername ? getAnimeProfile(about.anilistUsername) : null,
|
anilistUsername ? getAniListProfile(anilistUsername, 'ANIME') : null,
|
||||||
|
anilistUsername ? getAniListProfile(anilistUsername, 'MANGA') : null,
|
||||||
getMediaDetails(mediaUrls),
|
getMediaDetails(mediaUrls),
|
||||||
about.lastfmUsername ? getListeningProfile(about.lastfmUsername) : null
|
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 SectionNav from '#lib/components/ui/SectionNav.svelte';
|
||||||
import Section from '#lib/components/ui/Section.svelte';
|
import Section from '#lib/components/ui/Section.svelte';
|
||||||
import Panel from '#lib/components/ui/Panel.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 MediaGrid from '#lib/components/about/MediaGrid.svelte';
|
||||||
import GameCard from '#lib/components/about/GameCard.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 SetupSection from '#lib/components/about/setup/SetupSection.svelte';
|
||||||
import ListeningSection from '#lib/components/about/listening/ListeningSection.svelte';
|
import ListeningSection from '#lib/components/about/listening/ListeningSection.svelte';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
@@ -17,6 +19,7 @@
|
|||||||
const byRating = (first: { rating: number }, second: { rating: number }) => second.rating - first.rating;
|
const byRating = (first: { rating: number }, second: { rating: number }) => second.rating - first.rating;
|
||||||
const shows = [...about.shows].sort(byRating);
|
const shows = [...about.shows].sort(byRating);
|
||||||
const movies = [...about.movies].sort(byRating);
|
const movies = [...about.movies].sort(byRating);
|
||||||
|
const books = sortBooks(about.books ?? []);
|
||||||
const mediaGroups = [
|
const mediaGroups = [
|
||||||
{ id: 'shows', title: 'TV Shows', items: shows, kind: 'show' as const },
|
{ id: 'shows', title: 'TV Shows', items: shows, kind: 'show' as const },
|
||||||
{ id: 'movies', title: 'Movies', items: movies, kind: 'movie' as const }
|
{ id: 'movies', title: 'Movies', items: movies, kind: 'movie' as const }
|
||||||
@@ -28,8 +31,10 @@
|
|||||||
{ id: 'games', label: 'Games', color: 'orange' },
|
{ id: 'games', label: 'Games', color: 'orange' },
|
||||||
{ id: 'shows', label: 'Shows', color: 'yellow' },
|
{ id: 'shows', label: 'Shows', color: 'yellow' },
|
||||||
{ id: 'movies', label: 'Movies', color: 'green' },
|
{ id: 'movies', label: 'Movies', color: 'green' },
|
||||||
|
...(books.length ? [{ id: 'books', label: 'Books', color: 'cyan' }] : []),
|
||||||
...(data.listening ? [{ id: 'music', label: 'Music', color: 'red' }] : []),
|
...(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>
|
</script>
|
||||||
|
|
||||||
@@ -69,6 +74,12 @@
|
|||||||
</Section>
|
</Section>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
{#if books.length}
|
||||||
|
<Section title="Books" id="books">
|
||||||
|
<BookGrid {books} />
|
||||||
|
</Section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if data.listening}
|
{#if data.listening}
|
||||||
<Section title="Music" id="music">
|
<Section title="Music" id="music">
|
||||||
<ListeningSection listening={data.listening} />
|
<ListeningSection listening={data.listening} />
|
||||||
@@ -77,7 +88,13 @@
|
|||||||
|
|
||||||
{#if data.anime}
|
{#if data.anime}
|
||||||
<Section title="Anime" id="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>
|
</Section>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
desktopKeybinds,
|
desktopKeybinds,
|
||||||
imageViewerKeybinds,
|
imageViewerKeybinds,
|
||||||
navigationTips,
|
navigationTips,
|
||||||
|
snakeKeybinds,
|
||||||
|
breakoutKeybinds,
|
||||||
terminalKeybinds,
|
terminalKeybinds,
|
||||||
workspaceKeybinds
|
workspaceKeybinds
|
||||||
} from '#lib/desktop/keybindGuide';
|
} from '#lib/desktop/keybindGuide';
|
||||||
@@ -20,7 +22,8 @@
|
|||||||
{ id: 'navigation', label: 'Navigation', color: 'blue' },
|
{ id: 'navigation', label: 'Navigation', color: 'blue' },
|
||||||
{ id: 'desktop', label: 'Desktop', color: 'magenta' },
|
{ id: 'desktop', label: 'Desktop', color: 'magenta' },
|
||||||
{ id: 'terminal', label: 'Terminal', color: 'green' },
|
{ 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>
|
</script>
|
||||||
|
|
||||||
@@ -74,6 +77,20 @@
|
|||||||
<KeybindTable entries={imageViewerKeybinds} />
|
<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>
|
<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>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
Reference in new issue
Block a user