Add anime, shows and movies to about page
This commit is contained in:
1 parent
d7476cff98
commit
7845cb5e6f
12 files changed
+754
-22
No files matched your search
@@ -1,3 +1,4 @@
|
|||||||
DISCORD_TOKEN=
|
DISCORD_TOKEN=
|
||||||
DISCORD_USER_ID=
|
DISCORD_USER_ID=
|
||||||
DISCORD_GUILD_ID=
|
DISCORD_GUILD_ID=
|
||||||
|
OMDB_API_KEY=
|
||||||
+2
-1
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"anilistUsername": "SirBlob",
|
||||||
"hobbies": [
|
"hobbies": [
|
||||||
{ "name": "Swimming", "icon": "proicons:person-swim", "description": "I enjoy swimming as a way to relax and stay active. It is my favorite sport." },
|
{ "name": "Swimming", "icon": "proicons:person-swim", "description": "I enjoy swimming as a way to relax and stay active. It is my favorite sport." },
|
||||||
{ "name": "Baking", "icon": "proicons:cake", "description": "I love baking pastries and experimenting with new dessert recipes." },
|
{ "name": "Baking", "icon": "proicons:cake", "description": "I love baking pastries and experimenting with new dessert recipes." },
|
||||||
@@ -23,6 +24,6 @@
|
|||||||
],
|
],
|
||||||
"movies": [
|
"movies": [
|
||||||
{ "name": "Interstellar", "rating": 10, "url": "https://www.imdb.com/title/tt0816692/" },
|
{ "name": "Interstellar", "rating": 10, "url": "https://www.imdb.com/title/tt0816692/" },
|
||||||
{ "name": "How to Train Your Dragon", "rating": 10, "url": "https://www.imdb.com/title/tt1621800/" }
|
{ "name": "How to Train Your Dragon", "rating": 10, "url": "https://www.imdb.com/title/tt0892769/" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -14,5 +14,9 @@ export const variables = defineEnvVars({
|
|||||||
DISCORD_GUILD_ID: {
|
DISCORD_GUILD_ID: {
|
||||||
schema: optional,
|
schema: optional,
|
||||||
description: 'Guild shared by the bot and the user, needed to read presence'
|
description: 'Guild shared by the bot and the user, needed to read presence'
|
||||||
|
},
|
||||||
|
OMDB_API_KEY: {
|
||||||
|
schema: optional,
|
||||||
|
description: 'OMDb API key used to load posters and details for shows and movies'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export interface MediaDetails {
|
||||||
|
poster: string | null;
|
||||||
|
year: string | null;
|
||||||
|
genre: string | null;
|
||||||
|
runtime: string | null;
|
||||||
|
plot: string | null;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { about } from '#lib/content';
|
||||||
|
import { getAnimeProfile } from '#lib/server/anilist';
|
||||||
|
import { getMediaDetails } from '#lib/server/omdb';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const mediaUrls = [...about.shows, ...about.movies].map((item) => item.url);
|
||||||
|
|
||||||
|
const [anime, mediaDetails] = await Promise.all([
|
||||||
|
about.anilistUsername ? getAnimeProfile(about.anilistUsername) : null,
|
||||||
|
getMediaDetails(mediaUrls)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { anime, mediaDetails };
|
||||||
|
};
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '@iconify/svelte';
|
|
||||||
import { about, profile } from '#lib/content';
|
import { about, profile } from '#lib/content';
|
||||||
import PageHeader from '#lib/components/ui/PageHeader.svelte';
|
import PageHeader from '#lib/components/ui/PageHeader.svelte';
|
||||||
import ProfileIntro from '#lib/components/ui/ProfileIntro.svelte';
|
import ProfileIntro from '#lib/components/ui/ProfileIntro.svelte';
|
||||||
@@ -7,21 +6,31 @@
|
|||||||
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 LinkButton from '#lib/components/ui/LinkButton.svelte';
|
import LinkButton from '#lib/components/ui/LinkButton.svelte';
|
||||||
|
import AnimeSection from '#lib/components/about/AnimeSection.svelte';
|
||||||
|
import MediaCard from '#lib/components/about/MediaCard.svelte';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
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 mediaGroups = [
|
||||||
|
{ id: 'shows', title: 'TV Shows', items: shows, kind: 'show' as const },
|
||||||
|
{ id: 'movies', title: 'Movies', items: movies, kind: 'movie' as const }
|
||||||
|
];
|
||||||
|
|
||||||
const sections = [
|
const sections = $derived([
|
||||||
{ id: 'hobbies', label: 'Hobbies', color: 'blue' },
|
{ id: 'hobbies', label: 'Hobbies', color: 'blue' },
|
||||||
{ 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' },
|
||||||
];
|
...(data.anime ? [{ id: 'anime', label: 'Anime', color: 'magenta' }] : [])
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<PageHeader command="cat ~/about.md" title="About" subtitle="About {profile.displayName}: hobbies, games, shows and movies." />
|
<PageHeader command="cat ~/about.md" title="About" />
|
||||||
|
|
||||||
<ProfileIntro heading={profile.displayName} subheading={profile.title} text={profile.bio} />
|
<ProfileIntro heading={profile.displayName} subheading={profile.title} text={profile.bio} />
|
||||||
<SectionNav {sections} />
|
<SectionNav {sections} />
|
||||||
@@ -37,7 +46,7 @@
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="Games" id="games">
|
<Section title="Games" id="games">
|
||||||
<div class="card-grid">
|
<div class="card-grid" data-image-group>
|
||||||
{#each about.games as game (game.name)}
|
{#each about.games as game (game.name)}
|
||||||
<Panel title={game.name}>
|
<Panel title={game.name}>
|
||||||
<img class="game-image" src={game.image} alt={game.name} loading="lazy" />
|
<img class="game-image" src={game.image} alt={game.name} loading="lazy" />
|
||||||
@@ -47,20 +56,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{#each [{ id: 'shows', title: 'TV Shows', items: shows }, { id: 'movies', title: 'Movies', items: movies }] as group (group.id)}
|
{#each mediaGroups as group (group.id)}
|
||||||
<Section title={group.title} id={group.id}>
|
<Section title={group.title} id={group.id}>
|
||||||
<div class="card-grid">
|
<div class="media-grid">
|
||||||
{#each group.items as item (item.name)}
|
{#each group.items as item (item.name)}
|
||||||
<Panel title={item.name} href={item.url}>
|
<MediaCard {item} details={data.mediaDetails[item.url]} kind={group.kind} />
|
||||||
<div class="rating">
|
|
||||||
<Icon icon="mdi:star" width="16" />
|
|
||||||
<span>{item.rating}/10</span>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
{#if data.anime}
|
||||||
|
<Section title="Anime" id="anime">
|
||||||
|
<AnimeSection anime={data.anime} />
|
||||||
|
</Section>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -68,16 +78,21 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.media-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 1rem 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 520px) {
|
||||||
|
.media-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.game-image {
|
.game-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rating {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
color: var(--yellow);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
Reference in new issue
Block a user