From 90d61c48e2c4686993c51dd68ccf522a258e5dfd Mon Sep 17 00:00:00 2001 From: SirBlob Date: Sun, 1 Nov 2026 18:42:16 -0500 Subject: [PATCH] Add books and manga to about page --- content/about.json | 3 +- src/lib/anilist/types.ts | 38 +++ src/lib/anime/types.ts | 31 --- src/lib/books/books.ts | 24 ++ .../components/about/AniListCoverGrid.svelte | 150 +++++++++++ ...der.svelte => AniListProfileHeader.svelte} | 25 +- .../components/about/AniListSection.svelte | 138 ++++++++++ src/lib/components/about/AnimeSection.svelte | 238 ------------------ src/lib/components/about/BookCard.svelte | 140 +++++++++++ src/lib/components/about/BookGrid.svelte | 36 +++ src/lib/server/anilist.ts | 164 +++++++----- src/routes/about/+page.server.ts | 12 +- src/routes/about/+page.svelte | 23 +- 13 files changed, 674 insertions(+), 348 deletions(-) create mode 100644 src/lib/anilist/types.ts delete mode 100644 src/lib/anime/types.ts create mode 100644 src/lib/books/books.ts create mode 100644 src/lib/components/about/AniListCoverGrid.svelte rename src/lib/components/about/{AnimeProfileHeader.svelte => AniListProfileHeader.svelte} (61%) create mode 100644 src/lib/components/about/AniListSection.svelte delete mode 100644 src/lib/components/about/AnimeSection.svelte create mode 100644 src/lib/components/about/BookCard.svelte create mode 100644 src/lib/components/about/BookGrid.svelte diff --git a/content/about.json b/content/about.json index d8f0dd0..098987d 100644 --- a/content/about.json +++ b/content/about.json @@ -75,5 +75,6 @@ { "name": "Avatar: The Way of Water", "rating": 10, "url": "https://www.imdb.com/title/tt1630029/" }, { "name": "Avatar: Fire and Ash", "rating": 8, "url": "https://www.imdb.com/title/tt1757678/" }, { "name": "Blade Runner 2049", "rating": 10, "url": "https://www.imdb.com/title/tt1856101/" } - ] + ], + "books": [] } diff --git a/src/lib/anilist/types.ts b/src/lib/anilist/types.ts new file mode 100644 index 0000000..8526ee1 --- /dev/null +++ b/src/lib/anilist/types.ts @@ -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; +} diff --git a/src/lib/anime/types.ts b/src/lib/anime/types.ts deleted file mode 100644 index 75c9482..0000000 --- a/src/lib/anime/types.ts +++ /dev/null @@ -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; -} diff --git a/src/lib/books/books.ts b/src/lib/books/books.ts new file mode 100644 index 0000000..89bba15 --- /dev/null +++ b/src/lib/books/books.ts @@ -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); + }); +} diff --git a/src/lib/components/about/AniListCoverGrid.svelte b/src/lib/components/about/AniListCoverGrid.svelte new file mode 100644 index 0000000..af135b4 --- /dev/null +++ b/src/lib/components/about/AniListCoverGrid.svelte @@ -0,0 +1,150 @@ + + +

{title} {entries.length}

+
+ {#each shownEntries as entry (entry.id)} + +
+ {entry.title} + {#if badge === 'score'} + {entry.score} + {:else if entry.total} + {entry.total} {totalUnit} + {/if} +
+ {entry.title} +
+ {/each} +
+ +{#if entries.length > collapsedCount} + +{/if} + + diff --git a/src/lib/components/about/AnimeProfileHeader.svelte b/src/lib/components/about/AniListProfileHeader.svelte similarity index 61% rename from src/lib/components/about/AnimeProfileHeader.svelte rename to src/lib/components/about/AniListProfileHeader.svelte index 5bd527a..e948b4c 100644 --- a/src/lib/components/about/AnimeProfileHeader.svelte +++ b/src/lib/components/about/AniListProfileHeader.svelte @@ -1,7 +1,7 @@ -
- - {anime.name.toLowerCase()}@{hostName} +
+ + {profile.name.toLowerCase()}@{hostName} -
{'-'.repeat(anime.name.length + hostName.length + 1)}
+
{'-'.repeat(profile.name.length + hostName.length + 1)}
- {#each rows as [label, value] (label)} -
{label}: {value}
+ {#each profile.stats as stat (stat.label)} +
{stat.label}: {stat.value}
{/each} {#each shownStatuses as item (item.status)} @@ -53,7 +48,7 @@
diff --git a/src/lib/components/about/AnimeSection.svelte b/src/lib/components/about/AnimeSection.svelte deleted file mode 100644 index 4838a4b..0000000 --- a/src/lib/components/about/AnimeSection.svelte +++ /dev/null @@ -1,238 +0,0 @@ - - -
- - - - - {#if anime.watching.length} - -
- {#each anime.watching as entry (entry.id)} - - {entry.title} -
- {entry.title} -
-
-
- Episode {entry.progress} / {entry.episodes ?? '?'} -
-
- {/each} -
-
- {/if} -
- -

Top rated

-
- {#each shownTopRated as entry (entry.id)} - -
- {entry.title} - {entry.score} -
- {entry.title} -
- {/each} -
- -
- {#if anime.topRated.length > collapsedCount} - - {/if} - Full list on AniList ({anime.completedCount} completed) -
- - diff --git a/src/lib/components/about/BookCard.svelte b/src/lib/components/about/BookCard.svelte new file mode 100644 index 0000000..ee6b1d3 --- /dev/null +++ b/src/lib/components/about/BookCard.svelte @@ -0,0 +1,140 @@ + + + +
+ {#if cover && !coverFailed} + (coverFailed = true)} /> + {:else} + + + {book.title} + + {/if} + + {#if book.status === 'reading'} + reading + {:else if book.rating} + {book.rating}/10 + {/if} +
+ +
+ {book.title} + {book.author} +
+
+ + diff --git a/src/lib/components/about/BookGrid.svelte b/src/lib/components/about/BookGrid.svelte new file mode 100644 index 0000000..67cd1ba --- /dev/null +++ b/src/lib/components/about/BookGrid.svelte @@ -0,0 +1,36 @@ + + +
+ {#each visibleBooks as book (`${book.title}-${book.author}`)} + + {/each} +
+ + + + diff --git a/src/lib/server/anilist.ts b/src/lib/server/anilist.ts index 6db544e..f253f34 100644 --- a/src/lib/server/anilist.ts +++ b/src/lib/server/anilist.ts @@ -1,43 +1,70 @@ -import type { AnimeEntry, AnimeProfile } from '#lib/anime/types'; +import type { AniListEntry, AniListMediaType, AniListProfile, AniListStat } from '#lib/anilist/types'; const cacheDuration = 60 * 60 * 1000; const minutesPerDay = 60 * 24; const topRatedMinimumScore = 9; -const statusLabels: Record = { - COMPLETED: 'Completed', - CURRENT: 'Watching', - PLANNING: 'Planning', - PAUSED: 'Paused', - DROPPED: 'Dropped' + +interface MediaStatistics { + count: number; + episodesWatched?: number; + minutesWatched?: number; + chaptersRead?: number; + volumesRead?: number; +} + +interface MediaConfig { + statisticsKey: 'anime' | 'manga'; + statisticsFields: string; + totalField: 'episodes' | 'chapters'; + listPath: string; + currentLabel: string; + progressUnit: string; + totalUnit: string; + statusLabels: Record; + stats: (statistics: MediaStatistics) => AniListStat[]; +} + +const mediaConfigs: Record = { + ANIME: { + statisticsKey: 'anime', + statisticsFields: 'count episodesWatched minutesWatched', + totalField: 'episodes', + listPath: 'animelist', + currentLabel: 'Currently watching', + progressUnit: 'Episode', + totalUnit: 'eps', + statusLabels: { COMPLETED: 'Completed', CURRENT: 'Watching', PLANNING: 'Planning', PAUSED: 'Paused', DROPPED: 'Dropped' }, + stats: (statistics) => [ + { label: 'Anime', value: `${statistics.count}` }, + { label: 'Episodes', value: `${statistics.episodesWatched ?? 0}` }, + { label: 'Watch time', value: `${Math.round(((statistics.minutesWatched ?? 0) / minutesPerDay) * 10) / 10} days` } + ] + }, + MANGA: { + statisticsKey: 'manga', + statisticsFields: 'count chaptersRead volumesRead', + totalField: 'chapters', + listPath: 'mangalist', + currentLabel: 'Currently reading', + progressUnit: 'Chapter', + totalUnit: 'ch', + statusLabels: { COMPLETED: 'Completed', CURRENT: 'Reading', PLANNING: 'Planning', PAUSED: 'Paused', DROPPED: 'Dropped' }, + stats: (statistics) => [ + { label: 'Manga', value: `${statistics.count}` }, + { label: 'Chapters', value: `${statistics.chaptersRead ?? 0}` }, + { label: 'Volumes', value: `${statistics.volumesRead ?? 0}` } + ] + } }; -const animeQuery = ` - query ($userName: String) { - User(name: $userName) { - name - siteUrl - statistics { anime { count episodesWatched minutesWatched } } - } - MediaListCollection(userName: $userName, type: ANIME) { - lists { - status - entries { - score(format: POINT_10) - progress - media { id siteUrl episodes title { english romaji } coverImage { large } } - } - } - } - } -`; - -interface AniListEntry { +interface ResponseEntry { score: number; progress: number; media: { id: number; siteUrl: string; - episodes: number | null; + episodes?: number | null; + chapters?: number | null; title: { english: string | null; romaji: string }; coverImage: { large: string }; }; @@ -48,15 +75,37 @@ interface AniListResponse { User: { name: string; siteUrl: string; - statistics: { anime: { count: number; episodesWatched: number; minutesWatched: number } }; + statistics: Record; }; - MediaListCollection: { lists: { status: string; entries: AniListEntry[] }[] }; + MediaListCollection: { lists: { status: string | null; entries: ResponseEntry[] }[] }; }; } -const profileCache = new Map(); +const profileCache = new Map(); -function toAnimeEntry({ score, progress, media }: AniListEntry): AnimeEntry { +function buildQuery(config: MediaConfig): string { + return ` + query ($userName: String, $type: MediaType) { + User(name: $userName) { + name + siteUrl + statistics { ${config.statisticsKey} { ${config.statisticsFields} } } + } + MediaListCollection(userName: $userName, type: $type) { + lists { + status + entries { + score(format: POINT_10) + progress + media { id siteUrl ${config.totalField} title { english romaji } coverImage { large } } + } + } + } + } + `; +} + +function toEntry({ score, progress, media }: ResponseEntry, config: MediaConfig): AniListEntry { return { id: media.id, title: media.title.english ?? media.title.romaji, @@ -64,56 +113,59 @@ function toAnimeEntry({ score, progress, media }: AniListEntry): AnimeEntry { cover: media.coverImage.large, score, progress, - episodes: media.episodes + total: media[config.totalField] ?? null }; } -function entriesWithStatus(response: AniListResponse, status: string): AnimeEntry[] { +function entriesWithStatus(response: AniListResponse, status: string, config: MediaConfig): AniListEntry[] { const list = response.data.MediaListCollection.lists.find((item) => item.status === status); - return (list?.entries ?? []).map(toAnimeEntry); + return (list?.entries ?? []).map((entry) => toEntry(entry, config)); } -function toAnimeProfile(response: AniListResponse): AnimeProfile { +function toProfile(response: AniListResponse, mediaType: AniListMediaType): AniListProfile { + const config = mediaConfigs[mediaType]; const { name, siteUrl, statistics } = response.data.User; - const completed = entriesWithStatus(response, 'COMPLETED'); - const statusCounts = Object.entries(statusLabels).map(([status, label]) => ({ - status, - label, - count: entriesWithStatus(response, status).length - })); + const completed = entriesWithStatus(response, 'COMPLETED', config); return { + mediaType, name, profileUrl: siteUrl, - statusCounts, - stats: { - count: statistics.anime.count, - episodesWatched: statistics.anime.episodesWatched, - daysWatched: Math.round((statistics.anime.minutesWatched / minutesPerDay) * 10) / 10 - }, - watching: entriesWithStatus(response, 'CURRENT'), + listUrl: `${siteUrl}/${config.listPath}`, + stats: config.stats(statistics[config.statisticsKey]), + statusCounts: Object.entries(config.statusLabels).map(([status, label]) => ({ + status, + label, + count: entriesWithStatus(response, status, config).length + })), + current: entriesWithStatus(response, 'CURRENT', config), + currentLabel: config.currentLabel, + progressUnit: config.progressUnit, + totalUnit: config.totalUnit, topRated: completed.filter((entry) => entry.score >= topRatedMinimumScore).sort((first, second) => second.score - first.score), + planning: entriesWithStatus(response, 'PLANNING', config), completedCount: completed.length }; } -export async function getAnimeProfile(userName: string): Promise { - const cached = profileCache.get(userName); +export async function getAniListProfile(userName: string, mediaType: AniListMediaType): Promise { + const cacheKey = `${userName}:${mediaType}`; + const cached = profileCache.get(cacheKey); if (cached && Date.now() - cached.fetchedAt < cacheDuration) return cached.profile; try { const response = await fetch('https://graphql.anilist.co', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body: JSON.stringify({ query: animeQuery, variables: { userName } }) + body: JSON.stringify({ query: buildQuery(mediaConfigs[mediaType]), variables: { userName, type: mediaType } }) }); if (!response.ok) throw new Error(`AniList returned ${response.status}`); - const profile = toAnimeProfile(await response.json()); - profileCache.set(userName, { profile, fetchedAt: Date.now() }); + const profile = toProfile(await response.json(), mediaType); + profileCache.set(cacheKey, { profile, fetchedAt: Date.now() }); return profile; } catch (error) { - console.error(`AniList lookup failed for ${userName}:`, error); + console.error(`AniList ${mediaType.toLowerCase()} lookup failed for ${userName}:`, error); return cached?.profile ?? null; } } diff --git a/src/routes/about/+page.server.ts b/src/routes/about/+page.server.ts index 6210c59..032e605 100644 --- a/src/routes/about/+page.server.ts +++ b/src/routes/about/+page.server.ts @@ -1,17 +1,21 @@ import { about } from '#lib/content'; -import { getAnimeProfile } from '#lib/server/anilist'; +import { getAniListProfile } from '#lib/server/anilist'; import { getMediaDetails } from '#lib/server/omdb'; import { getListeningProfile } from '#lib/server/lastfm/profile'; import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async () => { const mediaUrls = [...about.shows, ...about.movies].map((item) => item.url); + const anilistUsername = about.anilistUsername; - const [anime, mediaDetails, listening] = await Promise.all([ - about.anilistUsername ? getAnimeProfile(about.anilistUsername) : null, + const [anime, manga, mediaDetails, listening] = await Promise.all([ + anilistUsername ? getAniListProfile(anilistUsername, 'ANIME') : null, + anilistUsername ? getAniListProfile(anilistUsername, 'MANGA') : null, getMediaDetails(mediaUrls), about.lastfmUsername ? getListeningProfile(about.lastfmUsername) : null ]); - return { anime, mediaDetails, listening }; + const hasManga = !!manga?.statusCounts.some((item) => item.count > 0); + + return { anime, manga: hasManga ? manga : null, mediaDetails, listening }; }; diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index f1eb9bd..a87d8ba 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -5,9 +5,11 @@ import SectionNav from '#lib/components/ui/SectionNav.svelte'; import Section from '#lib/components/ui/Section.svelte'; import Panel from '#lib/components/ui/Panel.svelte'; - import AnimeSection from '#lib/components/about/AnimeSection.svelte'; + import AniListSection from '#lib/components/about/AniListSection.svelte'; import MediaGrid from '#lib/components/about/MediaGrid.svelte'; import GameCard from '#lib/components/about/GameCard.svelte'; + import BookGrid from '#lib/components/about/BookGrid.svelte'; + import { sortBooks } from '#lib/books/books'; import SetupSection from '#lib/components/about/setup/SetupSection.svelte'; import ListeningSection from '#lib/components/about/listening/ListeningSection.svelte'; import type { PageProps } from './$types'; @@ -17,6 +19,7 @@ const byRating = (first: { rating: number }, second: { rating: number }) => second.rating - first.rating; const shows = [...about.shows].sort(byRating); const movies = [...about.movies].sort(byRating); + const books = sortBooks(about.books ?? []); const mediaGroups = [ { id: 'shows', title: 'TV Shows', items: shows, kind: 'show' as const }, { id: 'movies', title: 'Movies', items: movies, kind: 'movie' as const } @@ -28,8 +31,10 @@ { id: 'games', label: 'Games', color: 'orange' }, { id: 'shows', label: 'Shows', color: 'yellow' }, { id: 'movies', label: 'Movies', color: 'green' }, + ...(books.length ? [{ id: 'books', label: 'Books', color: 'cyan' }] : []), ...(data.listening ? [{ id: 'music', label: 'Music', color: 'red' }] : []), - ...(data.anime ? [{ id: 'anime', label: 'Anime', color: 'magenta' }] : []) + ...(data.anime ? [{ id: 'anime', label: 'Anime', color: 'magenta' }] : []), + ...(data.manga ? [{ id: 'manga', label: 'Manga', color: 'blue' }] : []) ]); @@ -69,6 +74,12 @@ {/each} + {#if books.length} +
+ +
+ {/if} + {#if data.listening}
@@ -77,7 +88,13 @@ {#if data.anime}
- + +
+ {/if} + + {#if data.manga} +
+
{/if}