Add books and manga to about page
This commit is contained in:
1 parent
87079dcb88
commit
90d61c48e2
13 files changed
+674
-348
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": []
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
+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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user