Add anime, shows and movies to about page

This commit is contained in:
SirBlob committed 2026-10-31 21:17:42 -04:00
1 parent d7476cff98
commit 7845cb5e6f
12 files changed
+754 -22

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
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,91 @@
<script lang="ts">
import type { AnimeProfile } from '#lib/anime/types';
let { anime }: { anime: AnimeProfile } = $props();
const hostName = 'anilist';
const statusColors: Record<string, string> = {
COMPLETED: 'green',
CURRENT: 'blue',
PLANNING: 'magenta',
PAUSED: 'yellow',
DROPPED: 'red'
};
const shownStatuses = $derived(anime.statusCounts.filter((item) => item.count > 0));
const listTotal = $derived(shownStatuses.reduce((total, item) => total + item.count, 0));
const rows = $derived([
['Anime', `${anime.stats.count}`],
['Episodes', `${anime.stats.episodesWatched}`],
['Watch time', `${anime.stats.daysWatched} days`]
]);
</script>
<div class="anime-fetch">
<a class="heading" href={anime.profileUrl} target="_blank" rel="noopener noreferrer">
<span class="user">{anime.name.toLowerCase()}</span><span class="at">@</span><span class="host">{hostName}</span>
</a>
<div class="separator">{'-'.repeat(anime.name.length + hostName.length + 1)}</div>
{#each rows as [label, value] (label)}
<div class="row"><span class="label">{label}</span>: {value}</div>
{/each}
{#each shownStatuses as item (item.status)}
<div class="row">
<span class="label">{item.label}</span>: <span style:color="var(--{statusColors[item.status]})">{item.count}</span>
</div>
{/each}
{#if listTotal > 0}
<div class="breakdown" role="img" aria-label="List breakdown by status">
{#each shownStatuses as item (item.status)}
<span
class="block"
style:flex-grow={item.count}
style:background="var(--{statusColors[item.status]})"
title="{item.label}: {item.count}"
></span>
{/each}
</div>
{/if}
</div>
<style>
.anime-fetch {
line-height: 1.5;
}
.user,
.host {
color: var(--accent);
font-weight: 700;
}
.heading:hover .user,
.heading:hover .host {
text-decoration: underline;
}
.at,
.separator {
color: var(--muted);
}
.label {
color: var(--blue);
font-weight: 700;
}
.breakdown {
display: flex;
margin-top: 0.75rem;
}
.block {
flex-basis: 0;
min-width: 0.5rem;
height: 1rem;
}
</style>
@@ -0,0 +1,238 @@
<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>
+140
View File
@@ -0,0 +1,140 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { RatedTitle } from '#lib/content';
import type { MediaDetails } from '#lib/media/types';
let { item, details, kind }: { item: RatedTitle; details?: MediaDetails; kind: 'show' | 'movie' } = $props();
const placeholderIcon = $derived(kind === 'show' ? 'mdi:television-classic' : 'mdi:movie-open-outline');
const metaParts = $derived([details?.year, details?.runtime].filter(Boolean));
</script>
<a class="media-card" href={item.url} target="_blank" rel="noopener noreferrer" title={item.name}>
<div class="poster">
{#if details?.poster}
<img src={details.poster} alt="" loading="lazy" />
{:else}
<span class="placeholder"><Icon icon={placeholderIcon} width="36" /></span>
{/if}
<span class="rating"><Icon icon="mdi:star" width="13" />{item.rating}/10</span>
{#if details?.plot}
<p class="plot">{details.plot}</p>
{/if}
</div>
<div class="text">
<span class="title">{item.name}</span>
{#if metaParts.length}
<span class="meta">{metaParts.join(' · ')}</span>
{/if}
{#if details?.genre}
<span class="genre">{details.genre}</span>
{/if}
</div>
</a>
<style>
.media-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.poster {
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;
}
.media-card:hover .poster {
border-color: var(--accent);
}
.poster img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.media-card:hover .poster img {
transform: scale(1.05);
}
.placeholder {
display: grid;
place-items: center;
height: 100%;
color: var(--muted);
}
.rating {
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;
}
.plot {
position: absolute;
right: 0;
bottom: 0;
left: 0;
margin: 0;
padding: 0.6rem;
background: color-mix(in srgb, var(--background) 92%, transparent);
border-top: 1px solid var(--accent);
font-size: 0.75rem;
line-height: 1.45;
transform: translateY(100%);
transition: transform 0.25s ease;
}
.media-card:hover .plot,
.media-card:focus-visible .plot {
transform: translateY(0);
}
.text {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.title {
overflow: hidden;
color: var(--accent);
font-weight: 700;
white-space: nowrap;
text-overflow: ellipsis;
}
.meta,
.genre {
overflow: hidden;
color: var(--muted);
font-size: 0.75rem;
white-space: nowrap;
text-overflow: ellipsis;
}
@media (prefers-reduced-motion: reduce) {
.poster img,
.plot {
transition: none;
}
}
</style>
+7
View File
@@ -0,0 +1,7 @@
export interface MediaDetails {
poster: string | null;
year: string | null;
genre: string | null;
runtime: string | null;
plot: string | null;
}
+119
View File
@@ -0,0 +1,119 @@
import type { AnimeEntry, AnimeProfile } from '#lib/anime/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'
};
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 {
score: number;
progress: number;
media: {
id: number;
siteUrl: string;
episodes: number | null;
title: { english: string | null; romaji: string };
coverImage: { large: string };
};
}
interface AniListResponse {
data: {
User: {
name: string;
siteUrl: string;
statistics: { anime: { count: number; episodesWatched: number; minutesWatched: number } };
};
MediaListCollection: { lists: { status: string; entries: AniListEntry[] }[] };
};
}
const profileCache = new Map<string, { profile: AnimeProfile; fetchedAt: number }>();
function toAnimeEntry({ score, progress, media }: AniListEntry): AnimeEntry {
return {
id: media.id,
title: media.title.english ?? media.title.romaji,
url: media.siteUrl,
cover: media.coverImage.large,
score,
progress,
episodes: media.episodes
};
}
function entriesWithStatus(response: AniListResponse, status: string): AnimeEntry[] {
const list = response.data.MediaListCollection.lists.find((item) => item.status === status);
return (list?.entries ?? []).map(toAnimeEntry);
}
function toAnimeProfile(response: AniListResponse): AnimeProfile {
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
}));
return {
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'),
topRated: completed.filter((entry) => entry.score >= topRatedMinimumScore).sort((first, second) => second.score - first.score),
completedCount: completed.length
};
}
export async function getAnimeProfile(userName: string): Promise<AnimeProfile | null> {
const cached = profileCache.get(userName);
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 } })
});
if (!response.ok) throw new Error(`AniList returned ${response.status}`);
const profile = toAnimeProfile(await response.json());
profileCache.set(userName, { profile, fetchedAt: Date.now() });
return profile;
} catch (error) {
console.error(`AniList lookup failed for ${userName}:`, error);
return cached?.profile ?? null;
}
}
+70
View File
@@ -0,0 +1,70 @@
import { OMDB_API_KEY } from '$app/env/private';
import type { MediaDetails } from '#lib/media/types';
const cacheDuration = 24 * 60 * 60 * 1000;
const imdbIdPattern = /tt\d+/;
const missingValue = 'N/A';
interface OmdbResponse {
Response: 'True' | 'False';
Error?: string;
Year?: string;
Genre?: string;
Runtime?: string;
Plot?: string;
Poster?: string;
}
const detailsCache = new Map<string, { details: MediaDetails | null; fetchedAt: number }>();
function imdbIdFromUrl(url: string): string | null {
return url.match(imdbIdPattern)?.[0] ?? null;
}
function presentValue(value: string | undefined): string | null {
return value && value !== missingValue ? value : null;
}
function toMediaDetails(result: OmdbResponse): MediaDetails {
return {
poster: presentValue(result.Poster),
year: presentValue(result.Year),
genre: presentValue(result.Genre),
runtime: presentValue(result.Runtime),
plot: presentValue(result.Plot)
};
}
async function fetchDetails(imdbId: string): Promise<MediaDetails | null> {
const cached = detailsCache.get(imdbId);
if (cached && Date.now() - cached.fetchedAt < cacheDuration) return cached.details;
try {
const params = new URLSearchParams({ apikey: OMDB_API_KEY ?? '', i: imdbId, plot: 'short' });
const response = await fetch(`https://www.omdbapi.com/?${params}`);
if (!response.ok) throw new Error(`OMDb returned ${response.status}`);
const result: OmdbResponse = await response.json();
if (result.Response === 'False') throw new Error(result.Error ?? 'OMDb found no result');
const details = toMediaDetails(result);
detailsCache.set(imdbId, { details, fetchedAt: Date.now() });
return details;
} catch (error) {
console.error(`OMDb lookup failed for ${imdbId}:`, error);
return cached?.details ?? null;
}
}
export async function getMediaDetails(urls: string[]): Promise<Record<string, MediaDetails>> {
if (!OMDB_API_KEY) return {};
const results = await Promise.all(
urls.map(async (url) => {
const imdbId = imdbIdFromUrl(url);
const details = imdbId ? await fetchDetails(imdbId) : null;
return [url, details] as const;
})
);
return Object.fromEntries(results.filter((result): result is readonly [string, MediaDetails] => result[1] !== null));
}