Skip to content

Commit 815a211

Browse files
authored
feat: add anime tracking page (#134)
This PR introduces the **Anime List** feature #133 , integrating AniList API to fetch and display my personal anime tracking data under `/about/anime`. It also includes a new API endpoint (`GET /api/anime`) to handle data retrieval. The header menu now highlights the current page dynamically, improving navigation clarity. Additionally, I refactored the scroll progress bar into its own component (`ScrollPositionBar.tsx`) for better maintainability. Fixed an issue where the scroll bar was hidden behind headers. The overall UI/UX could use some refinement, especially for submenu interactions and hover effects on different devices. Also, styling details need to be revisited to better align with the rest of the site.
1 parent e4f9fbb commit 815a211

File tree

23 files changed

+601
-60
lines changed

23 files changed

+601
-60
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
- **Link Preloading**: Improves navigation speed and user experience.
2828
- **SEO Optimized**: Automatically generates sitemap.xml, robots.txt, manifest.json, Open Graph, Twitter Cards, and more.
2929
- **Multi-Language Support**: Built-in support for English, Chinese, Japanese, etc., automatically configured via `config.yml`.
30+
- **Anime List Feature**: Fetching and displaying anime information from AniList API.
3031
- **Adaptive Light/Dark Themes**: Provides a seamless dark mode experience based on system preferences.
3132
- **RSS Feed**: Automatically generated RSS feed for your blog.
3233
- **Accessibility (A11Y) Enhanced**:

README_JA.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
- **リンク事前読み込み**: スムーズなナビゲーションを実現。
2828
- **SEO 最適化**: sitemap.xml、robots.txt、manifest.json、Open Graph、Twitter Cards などを自動生成。
2929
- **多言語サポート**: 英語、中国語、日本語などに対応。`config.yml` の設定に基づいて自動切替。
30+
- **アニメリスト機能**: AniList API からアニメ情報を取得・表示。
3031
- **ライト/ダークテーマ対応**: システム設定に基づき、自動的にダークモードを適用。
3132
- **RSS フィード**: ブログ用の RSS フィードを自動生成。
3233
- **アクセシビリティ(A11Y)対応**:

README_ZH.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
- **链接预加载**:提升用户体验。
2828
- **SEO 优化**:自动生成 sitemap.xml、robots.txt、manifest.json、Open Graph、Twitter Card 等。
2929
- **多语言支持**:内置中文、英文、日语等,根据 `config.yml` 中的语言配置自动切换。
30+
- **动漫列表功能**:从 AniList API 获取并展示动漫信息。
3031
- **自适应亮暗色主题**:支持深色模式,用户体验更优。
3132
- **RSS 订阅**:自动生成博客 RSS 订阅。
3233
- **无障碍(A11Y)优化**:提供语义化的 HTML 和符合 ARIA 标准的组件。

config.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ travellings: false
4646
# If you want to have a start year of your blog, set the year here.
4747
startYear: 2017 # Leave empty if you do not want to display a start year.
4848

49+
# ######################
50+
# PAGES SETTINGS
51+
# ######################
52+
# Add your AniList username if you want to display your AniList profile.
53+
# Leave the field empty if you do not want to display your AniList profile.
54+
anilist_username: zlasica # Your AniList username. https://anilist.co/user/username
55+
4956
# ######################
5057
# SOCIAL MEDIA SETTINGS
5158
# ######################

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@
4545
"remark-math": "^6.0.0",
4646
"slugify": "^1.6.6",
4747
"twikoo": "^1.6.41",
48-
"yaml": "^2.7.0"
48+
"yaml": "^2.7.0",
49+
"zod": "^3.24.2"
4950
},
5051
"devDependencies": {
5152
"@antfu/eslint-config": "^4.3.0",

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/about/anime/loading.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
const AnimeListSkeleton = () => {
2+
return (
3+
<div className="container mx-auto animate-fadeInDown p-6 pb-2 mt-5">
4+
{/* Header */}
5+
<div className="h-10 w-48 bg-gray-700 rounded-md animate-pulse mb-2"></div>
6+
<div className="h-6 w-72 bg-gray-700 rounded-md animate-pulse mb-4"></div>
7+
8+
{/* Sections */}
9+
{['Watching', 'Completed', 'Paused', 'Dropped', 'Planning'].map(section => (
10+
<div key={section} className="mt-10">
11+
{/* Section Title */}
12+
<div className="h-8 w-64 bg-gray-700 rounded-md animate-pulse mb-4"></div>
13+
14+
{/* Anime Grid */}
15+
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8 gap-6 mt-4">
16+
{Array.from({ length: 8 }).map((_, i) => (
17+
<div key={i} className="relative bg-gray-800 rounded-lg overflow-hidden shadow-md animate-pulse">
18+
{/* Cover Image Placeholder */}
19+
<div className="w-full aspect-[9/16] bg-gray-700"></div>
20+
21+
{/* Title & Progress Placeholder */}
22+
<div className="absolute bottom-0 left-0 w-full bg-gradient-to-t from-black/90 to-transparent p-2">
23+
<div className="h-6 w-32 bg-gray-700 rounded-md animate-pulse mb-1"></div>
24+
<div className="h-4 w-20 bg-gray-700 rounded-md animate-pulse"></div>
25+
</div>
26+
27+
{/* Rating Placeholder */}
28+
<div className="absolute bottom-2 right-2 flex items-center bg-black/60 px-2 py-1 rounded-lg">
29+
<div className="h-4 w-4 bg-gray-700 rounded-full animate-pulse"></div>
30+
<div className="h-4 w-8 bg-gray-700 rounded-md animate-pulse ml-1"></div>
31+
</div>
32+
</div>
33+
))}
34+
</div>
35+
</div>
36+
))}
37+
</div>
38+
)
39+
}
40+
41+
export default AnimeListSkeleton

src/app/about/anime/page.tsx

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { Metadata } from 'next'
2+
import process from 'node:process'
3+
import AnimeList from '@/components/anime/AnimeList'
4+
import { AnimeResponseSchema } from '@/schemas/anime'
5+
6+
import { getConfig } from '@/services/config'
7+
import Head from 'next/head'
8+
import { notFound } from 'next/navigation'
9+
10+
export async function generateMetadata(): Promise<Metadata> {
11+
const config = getConfig()
12+
const animeTranslation = config.translation.anime
13+
14+
return {
15+
title: `${animeTranslation.title} - ${config.title}`,
16+
description: `${config.title}${animeTranslation.description} - ${config.description}`,
17+
alternates: { canonical: `${config.siteUrl}/about/anime` },
18+
openGraph: {
19+
siteName: config.title,
20+
title: `${animeTranslation.title} - ${config.title}`,
21+
description: `${config.title}${animeTranslation.description} - ${config.description}`,
22+
url: '/about/anime',
23+
images: config.avatar,
24+
type: 'website',
25+
locale: config.lang,
26+
},
27+
twitter: {
28+
card: 'summary',
29+
title: `${animeTranslation.title} - ${config.title}`,
30+
description: `${config.title}${animeTranslation.description} - ${config.description}`,
31+
images: config.avatar,
32+
},
33+
}
34+
}
35+
36+
export default async function AnimePage() {
37+
const config = getConfig()
38+
const anilist_username = config.anilist_username
39+
40+
if (anilist_username === undefined || anilist_username === null) {
41+
return notFound()
42+
}
43+
44+
const API_BASE_URL = process.env.NODE_ENV === 'production'
45+
? config.siteUrl
46+
: 'http://localhost:3000'
47+
48+
const response = await fetch(`${API_BASE_URL}/api/anime?userName=${anilist_username}`, {
49+
cache: 'force-cache',
50+
next: { tags: ['anime'], revalidate: 120 }, // Cache for 2 minutes
51+
})
52+
53+
if (!response.ok) {
54+
console.error(`Failed to fetch anime data: ${response.statusText}`)
55+
return notFound()
56+
}
57+
58+
const data = await response.json() as unknown
59+
60+
const parsedAnimeData = AnimeResponseSchema.safeParse(data)
61+
62+
if (parsedAnimeData.success === false) {
63+
console.error(`Zod validation failed: ${JSON.stringify(parsedAnimeData.error.format())}`)
64+
return notFound()
65+
}
66+
67+
const animeData = parsedAnimeData.data
68+
69+
const animeTranslation = config.translation.anime
70+
const jsonLd = {
71+
'@context': 'https://schema.org',
72+
'@type': 'WebSite',
73+
'name': config.author.name,
74+
'description': `${config.title}${animeTranslation.description} - ${config.description}`,
75+
'url': `${config.siteUrl}/about/anime`,
76+
'image': config.avatar,
77+
'sameAs': config.author.link,
78+
}
79+
80+
return (
81+
<>
82+
<Head>
83+
<script
84+
type="application/ld+json"
85+
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
86+
/>
87+
</Head>
88+
<AnimeList
89+
animeData={animeData}
90+
userName={anilist_username}
91+
author={config.author.name}
92+
lang={config.lang}
93+
translation={config.translation}
94+
/>
95+
</>
96+
)
97+
}

src/app/api/anime/route.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { AnimeRequestSchema, AnimeResponseSchema } from '@/schemas/anime'
2+
import { NextResponse } from 'next/server'
3+
import { z } from 'zod'
4+
5+
export async function GET(req: Request) {
6+
try {
7+
const { searchParams } = new URL(req.url)
8+
const userName = searchParams.get('userName')
9+
10+
const parsedParams = AnimeRequestSchema.parse({ userName })
11+
12+
const query = `
13+
query ($userName: String) {
14+
MediaListCollection(userName: $userName, type: ANIME) {
15+
lists {
16+
name
17+
entries {
18+
media {
19+
id
20+
title {
21+
romaji
22+
english
23+
native
24+
}
25+
coverImage {
26+
extraLarge
27+
large
28+
medium
29+
}
30+
status
31+
episodes
32+
format
33+
averageScore
34+
}
35+
score
36+
progress
37+
status
38+
notes
39+
}
40+
}
41+
}
42+
}
43+
`
44+
45+
const response = await fetch('https://graphql.anilist.co', {
46+
method: 'POST',
47+
headers: {
48+
'Content-Type': 'application/json',
49+
'Accept': 'application/json',
50+
},
51+
body: JSON.stringify({ query, variables: { userName: parsedParams.userName } }),
52+
})
53+
54+
const data = await response.json() as unknown
55+
56+
const parsedResponse = AnimeResponseSchema.parse(data)
57+
58+
return NextResponse.json(parsedResponse)
59+
}
60+
catch (error) {
61+
if (error instanceof z.ZodError) {
62+
return NextResponse.json({ error: error.errors }, { status: 400 })
63+
}
64+
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
65+
}
66+
}

src/app/globals.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
--foreground: #171717;
9191
--skyblue: #5bcefa;
9292
--sakuraPink: #f6a8b8;
93+
--sakuraPinkDark: #d9778b;
9394
--lightGray: #f9fafb;
9495
--gray: #1f2937;
9596
}
@@ -99,6 +100,7 @@
99100
--foreground: #d4d4d4;
100101
--skyblue: #4aa7e0;
101102
--sakuraPink: #d890a2;
103+
--sakuraPinkDark: #d9778b;
102104
--lightGray: #1f2937;
103105
--gray: #d1d5db;
104106
}

0 commit comments

Comments
 (0)