Add books and manga to about page

This commit is contained in:
SirBlob committed 2026-11-01 18:42:28 -05:00
1 parent 87079dcb88
commit 90d61c48e2
13 files changed
+674 -348

No files matched your search

+108 -56
View File
@@ -1,43 +1,70 @@
import type { AnimeEntry, AnimeProfile } from '#lib/anime/types';
import type { AniListEntry, AniListMediaType, AniListProfile, AniListStat } from '#lib/anilist/types';
const cacheDuration = 60 * 60 * 1000;
const minutesPerDay = 60 * 24;
const topRatedMinimumScore = 9;
const statusLabels: Record<string, string> = {
COMPLETED: 'Completed',
CURRENT: 'Watching',
PLANNING: 'Planning',
PAUSED: 'Paused',
DROPPED: 'Dropped'
interface MediaStatistics {
count: number;
episodesWatched?: number;
minutesWatched?: number;
chaptersRead?: number;
volumesRead?: number;
}
interface MediaConfig {
statisticsKey: 'anime' | 'manga';
statisticsFields: string;
totalField: 'episodes' | 'chapters';
listPath: string;
currentLabel: string;
progressUnit: string;
totalUnit: string;
statusLabels: Record<string, string>;
stats: (statistics: MediaStatistics) => AniListStat[];
}
const mediaConfigs: Record<AniListMediaType, MediaConfig> = {
ANIME: {
statisticsKey: 'anime',
statisticsFields: 'count episodesWatched minutesWatched',
totalField: 'episodes',
listPath: 'animelist',
currentLabel: 'Currently watching',
progressUnit: 'Episode',
totalUnit: 'eps',
statusLabels: { COMPLETED: 'Completed', CURRENT: 'Watching', PLANNING: 'Planning', PAUSED: 'Paused', DROPPED: 'Dropped' },
stats: (statistics) => [
{ label: 'Anime', value: `${statistics.count}` },
{ label: 'Episodes', value: `${statistics.episodesWatched ?? 0}` },
{ label: 'Watch time', value: `${Math.round(((statistics.minutesWatched ?? 0) / minutesPerDay) * 10) / 10} days` }
]
},
MANGA: {
statisticsKey: 'manga',
statisticsFields: 'count chaptersRead volumesRead',
totalField: 'chapters',
listPath: 'mangalist',
currentLabel: 'Currently reading',
progressUnit: 'Chapter',
totalUnit: 'ch',
statusLabels: { COMPLETED: 'Completed', CURRENT: 'Reading', PLANNING: 'Planning', PAUSED: 'Paused', DROPPED: 'Dropped' },
stats: (statistics) => [
{ label: 'Manga', value: `${statistics.count}` },
{ label: 'Chapters', value: `${statistics.chaptersRead ?? 0}` },
{ label: 'Volumes', value: `${statistics.volumesRead ?? 0}` }
]
}
};
const animeQuery = `
query ($userName: String) {
User(name: $userName) {
name
siteUrl
statistics { anime { count episodesWatched minutesWatched } }
}
MediaListCollection(userName: $userName, type: ANIME) {
lists {
status
entries {
score(format: POINT_10)
progress
media { id siteUrl episodes title { english romaji } coverImage { large } }
}
}
}
}
`;
interface AniListEntry {
interface ResponseEntry {
score: number;
progress: number;
media: {
id: number;
siteUrl: string;
episodes: number | null;
episodes?: number | null;
chapters?: number | null;
title: { english: string | null; romaji: string };
coverImage: { large: string };
};
@@ -48,15 +75,37 @@ interface AniListResponse {
User: {
name: string;
siteUrl: string;
statistics: { anime: { count: number; episodesWatched: number; minutesWatched: number } };
statistics: Record<string, MediaStatistics>;
};
MediaListCollection: { lists: { status: string; entries: AniListEntry[] }[] };
MediaListCollection: { lists: { status: string | null; entries: ResponseEntry[] }[] };
};
}
const profileCache = new Map<string, { profile: AnimeProfile; fetchedAt: number }>();
const profileCache = new Map<string, { profile: AniListProfile; fetchedAt: number }>();
function toAnimeEntry({ score, progress, media }: AniListEntry): AnimeEntry {
function buildQuery(config: MediaConfig): string {
return `
query ($userName: String, $type: MediaType) {
User(name: $userName) {
name
siteUrl
statistics { ${config.statisticsKey} { ${config.statisticsFields} } }
}
MediaListCollection(userName: $userName, type: $type) {
lists {
status
entries {
score(format: POINT_10)
progress
media { id siteUrl ${config.totalField} title { english romaji } coverImage { large } }
}
}
}
}
`;
}
function toEntry({ score, progress, media }: ResponseEntry, config: MediaConfig): AniListEntry {
return {
id: media.id,
title: media.title.english ?? media.title.romaji,
@@ -64,56 +113,59 @@ function toAnimeEntry({ score, progress, media }: AniListEntry): AnimeEntry {
cover: media.coverImage.large,
score,
progress,
episodes: media.episodes
total: media[config.totalField] ?? null
};
}
function entriesWithStatus(response: AniListResponse, status: string): AnimeEntry[] {
function entriesWithStatus(response: AniListResponse, status: string, config: MediaConfig): AniListEntry[] {
const list = response.data.MediaListCollection.lists.find((item) => item.status === status);
return (list?.entries ?? []).map(toAnimeEntry);
return (list?.entries ?? []).map((entry) => toEntry(entry, config));
}
function toAnimeProfile(response: AniListResponse): AnimeProfile {
function toProfile(response: AniListResponse, mediaType: AniListMediaType): AniListProfile {
const config = mediaConfigs[mediaType];
const { name, siteUrl, statistics } = response.data.User;
const completed = entriesWithStatus(response, 'COMPLETED');
const statusCounts = Object.entries(statusLabels).map(([status, label]) => ({
status,
label,
count: entriesWithStatus(response, status).length
}));
const completed = entriesWithStatus(response, 'COMPLETED', config);
return {
mediaType,
name,
profileUrl: siteUrl,
statusCounts,
stats: {
count: statistics.anime.count,
episodesWatched: statistics.anime.episodesWatched,
daysWatched: Math.round((statistics.anime.minutesWatched / minutesPerDay) * 10) / 10
},
watching: entriesWithStatus(response, 'CURRENT'),
listUrl: `${siteUrl}/${config.listPath}`,
stats: config.stats(statistics[config.statisticsKey]),
statusCounts: Object.entries(config.statusLabels).map(([status, label]) => ({
status,
label,
count: entriesWithStatus(response, status, config).length
})),
current: entriesWithStatus(response, 'CURRENT', config),
currentLabel: config.currentLabel,
progressUnit: config.progressUnit,
totalUnit: config.totalUnit,
topRated: completed.filter((entry) => entry.score >= topRatedMinimumScore).sort((first, second) => second.score - first.score),
planning: entriesWithStatus(response, 'PLANNING', config),
completedCount: completed.length
};
}
export async function getAnimeProfile(userName: string): Promise<AnimeProfile | null> {
const cached = profileCache.get(userName);
export async function getAniListProfile(userName: string, mediaType: AniListMediaType): Promise<AniListProfile | null> {
const cacheKey = `${userName}:${mediaType}`;
const cached = profileCache.get(cacheKey);
if (cached && Date.now() - cached.fetchedAt < cacheDuration) return cached.profile;
try {
const response = await fetch('https://graphql.anilist.co', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ query: animeQuery, variables: { userName } })
body: JSON.stringify({ query: buildQuery(mediaConfigs[mediaType]), variables: { userName, type: mediaType } })
});
if (!response.ok) throw new Error(`AniList returned ${response.status}`);
const profile = toAnimeProfile(await response.json());
profileCache.set(userName, { profile, fetchedAt: Date.now() });
const profile = toProfile(await response.json(), mediaType);
profileCache.set(cacheKey, { profile, fetchedAt: Date.now() });
return profile;
} catch (error) {
console.error(`AniList lookup failed for ${userName}:`, error);
console.error(`AniList ${mediaType.toLowerCase()} lookup failed for ${userName}:`, error);
return cached?.profile ?? null;
}
}