This repository has been archived on 2026-08-27. You can view files and clone it, but cannot push or open issues or pull requests.
Files
RECS-WEB-MODLE-REACT/app/anime/page.tsx
2026-08-27 20:15:24 +08:00

301 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import animeData from "../data/anime-detail.json";
import styles from "./AnimeDetail.module.css";
type Episode = (typeof animeData.episodes)[number];
type Product = (typeof animeData.products)[number];
type Reply = { id: number; author: string; initial: string; content: string; time: string; likes: number };
type Comment = {
id: number;
author: string;
initial: string;
tone: string;
score: number;
time: string;
episode: string;
content: string;
likes: number;
spoiler: boolean;
replies: Reply[];
};
const work = animeData.work;
export default function AnimeDetailPage() {
const [favorite, setFavorite] = useState(false);
const [cartCount, setCartCount] = useState(0);
const [selectedEpisode, setSelectedEpisode] = useState(2);
const [comments, setComments] = useState<Comment[]>(animeData.comments as Comment[]);
const [likedComments, setLikedComments] = useState<number[]>([]);
const [commentText, setCommentText] = useState("");
const [commentScore, setCommentScore] = useState(5);
const [sort, setSort] = useState<"hot" | "new">("hot");
const [toast, setToast] = useState("");
const [activeSection, setActiveSection] = useState("overview");
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
try {
setFavorite(window.localStorage.getItem("star-echoes-favorite") === "1");
setCartCount(Number(window.localStorage.getItem("star-echoes-cart") || 0));
const savedComments = window.localStorage.getItem("star-echoes-comments");
const savedLikes = window.localStorage.getItem("star-echoes-likes");
if (savedComments) setComments(JSON.parse(savedComments));
if (savedLikes) setLikedComments(JSON.parse(savedLikes));
} catch {
// Invalid demo cache falls back to the bundled JSON data.
}
setHydrated(true);
}, []);
useEffect(() => {
if (!hydrated) return;
window.localStorage.setItem("star-echoes-favorite", favorite ? "1" : "0");
window.localStorage.setItem("star-echoes-cart", String(cartCount));
window.localStorage.setItem("star-echoes-comments", JSON.stringify(comments));
window.localStorage.setItem("star-echoes-likes", JSON.stringify(likedComments));
}, [favorite, cartCount, comments, likedComments, hydrated]);
const visibleComments = useMemo(
() => [...comments].sort((a, b) => (sort === "hot" ? b.likes - a.likes : b.id - a.id)),
[comments, sort],
);
const notify = (message: string) => {
setToast(message);
window.setTimeout(() => setToast(""), 2200);
};
const jumpTo = (id: string) => {
setActiveSection(id);
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const playEpisode = (episode: Episode) => {
if (!episode.released) return notify("该剧集尚未更新,已为你开启更新提醒");
setSelectedEpisode(episode.id);
notify(`正在播放:第 ${episode.id}${episode.title}`);
};
const addProduct = (product: Product) => {
setCartCount((count) => count + 1);
notify(`已将「${product.name}」加入购物车`);
};
const publishComment = () => {
if (commentText.trim().length < 8) return notify("评论至少需要 8 个字");
const newComment: Comment = {
id: Date.now(),
author: "林知夏",
initial: "林",
tone: "blue",
score: commentScore,
time: "刚刚",
episode: `看到第 ${selectedEpisode}`,
content: commentText.trim(),
likes: 0,
spoiler: false,
replies: [],
};
setComments((current) => [newComment, ...current]);
setCommentText("");
setSort("new");
notify("评论发布成功,已保存在当前浏览器");
};
const toggleLike = (id: number) => {
setLikedComments((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id]);
};
return (
<div className={styles.page}>
<header className={styles.header}>
<Link href="/" className={styles.logo} aria-label="返回模板首页">
<span className={styles.logoMark}>S</span>
<span><strong>STELLAR</strong><small>ANIME ARCHIVE</small></span>
</Link>
<nav className={styles.primaryNav} aria-label="主导航">
<button className={styles.currentNav} onClick={() => jumpTo("overview")}></button>
<button onClick={() => jumpTo("episodes")}></button>
<button onClick={() => jumpTo("products")}></button>
<button onClick={() => jumpTo("comments")}></button>
</nav>
<div className={styles.headerActions}>
<button aria-label="搜索" onClick={() => notify("搜索面板为模板交互占位")}></button>
<button className={styles.cartButton} aria-label={`购物车,共 ${cartCount} 件商品`} onClick={() => notify(`购物车中有 ${cartCount} 件商品`)}>
<span>{cartCount}</span>
</button>
<span className={styles.headerAvatar}></span>
</div>
</header>
<section className={styles.hero} aria-labelledby="work-title">
<div className={styles.heroImage} />
<div className={styles.heroShade} />
<div className={styles.heroContent}>
<div className={styles.breadcrumb}><Link href="/"></Link><span>/</span><span></span><span>/</span><strong></strong></div>
<div className={styles.heroCopy}>
<div className={styles.statusLine}>
<span className={styles.onAir}><i />{work.status}</span>
<span>{work.year}</span><span>{work.region}</span><span>{work.age}</span>
</div>
<p className={styles.englishTitle}>{work.titleEn}</p>
<h1 id="work-title">{work.title}</h1>
<p className={styles.heroSubtitle}>{work.subtitle}</p>
<div className={styles.tags}>{work.tags.map((tag) => <span key={tag}>{tag}</span>)}</div>
<div className={styles.heroButtons}>
<button className={styles.playButton} onClick={() => playEpisode(animeData.episodes[selectedEpisode - 1])}><span></span> {selectedEpisode} </button>
<button className={favorite ? styles.favoritedButton : styles.favoriteButton} onClick={() => { setFavorite((value) => !value); notify(favorite ? "已取消追番" : "已加入追番列表"); }}>
{favorite ? "✓ 已追番" : "+ 追番"}
</button>
<button className={styles.roundButton} aria-label="分享" onClick={() => notify("分享链接已复制(演示)")}></button>
</div>
</div>
<aside className={styles.heroRating}>
<small>STELLAR </small>
<div><strong>{work.rating}</strong><span><b></b><small>{work.ratingCount.toLocaleString()} </small></span></div>
<i />
<p><span></span><b>{work.rank}</b></p>
</aside>
<div className={styles.heroMetrics}>
<div><small></small><strong>{work.watchCount}</strong></div>
<div><small></small><strong>{work.followCount}</strong></div>
<div><small></small><strong>{work.updatedEpisode}<em> / {work.episodes}</em></strong></div>
</div>
</div>
</section>
<nav className={styles.sectionNav} aria-label="页面内容导航">
<div>
{[["overview", "作品详情"], ["episodes", `剧集 ${work.updatedEpisode}/${work.episodes}`], ["products", "官方周边"], ["comments", `评论 ${comments.length}`]].map(([id, label]) => (
<button key={id} className={activeSection === id ? styles.activeTab : ""} onClick={() => jumpTo(id)}>{label}</button>
))}
</div>
</nav>
<main className={styles.content}>
<section id="overview" className={styles.anchorSection}>
<div className={styles.sectionHeading}>
<div><span>ABOUT THE SERIES</span><h2></h2></div>
<p>{work.copyright}</p>
</div>
<div className={styles.overviewGrid}>
<article className={styles.synopsisCard}>
<div className={styles.posterCrop} role="img" aria-label="星海回响作品海报" />
<div className={styles.synopsisBody}>
<span className={styles.kicker}>STORY</span>
<h3><br /></h3>
<p>{work.description}</p>
<div className={styles.quote}></div>
</div>
</article>
<aside className={styles.infoCard}>
<h3></h3>
<dl>
<div><dt></dt><dd>{work.studio}</dd></div>
<div><dt></dt><dd>{work.director}</dd></div>
<div><dt></dt><dd>{work.release}</dd></div>
<div><dt></dt><dd>{work.schedule}</dd></div>
<div><dt></dt><dd> {work.episodes} </dd></div>
<div><dt></dt><dd>{work.age}</dd></div>
</dl>
<div className={styles.notice}><span>i</span><p><strong></strong>{work.announcement}</p></div>
</aside>
</div>
</section>
<section id="episodes" className={styles.anchorSection}>
<div className={styles.sectionHeading}>
<div><span>EPISODE GUIDE</span><h2></h2></div>
<button className={styles.textButton} onClick={() => notify("已为你开启自动连播")}> </button>
</div>
<div className={styles.episodeFeature}>
<div className={styles.episodeVisual}><span></span><button onClick={() => playEpisode(animeData.episodes[selectedEpisode - 1])}></button><small> {selectedEpisode} </small></div>
<div><span className={styles.kicker}>CONTINUE WATCHING</span><h3> {selectedEpisode}  {animeData.episodes[selectedEpisode - 1].title}</h3><p>{animeData.episodes[selectedEpisode - 1].summary}</p><div className={styles.watchProgress}><span><i style={{ width: `${animeData.episodes[selectedEpisode - 1].progress}%` }} /></span><small>{animeData.episodes[selectedEpisode - 1].progress}%</small></div></div>
</div>
<div className={styles.episodeGrid}>
{animeData.episodes.map((episode) => (
<button key={episode.id} className={`${styles.episodeCard} ${selectedEpisode === episode.id ? styles.selectedEpisode : ""} ${!episode.released ? styles.lockedEpisode : ""}`} onClick={() => playEpisode(episode)}>
<span className={styles.episodeNumber}>{String(episode.id).padStart(2, "0")}</span>
<span className={styles.episodeInfo}><strong>{episode.title}</strong><small>{episode.date} · {episode.duration}</small></span>
<i>{episode.released ? "▶" : "⌕"}</i>
</button>
))}
</div>
</section>
<section id="products" className={styles.anchorSection}>
<div className={styles.sectionHeading}>
<div><span>OFFICIAL MERCHANDISE</span><h2></h2></div>
<button className={styles.textButton} onClick={() => notify("全部商品页为模板交互占位")}> </button>
</div>
<div className={styles.productBanner}><div><span></span><h3></h3><p> · 299 </p></div><button onClick={() => jumpTo("products")}></button></div>
<div className={styles.productGrid}>
{animeData.products.map((product) => (
<article className={styles.productCard} key={product.id}>
<div className={`${styles.productImage} ${styles[product.imagePosition]}`}><span>{product.badge}</span><button aria-label={`收藏 ${product.name}`} onClick={() => notify(`已收藏「${product.name}`)}></button></div>
<div className={styles.productBody}>
<small>{product.category}</small><h3>{product.name}</h3>
<div className={styles.productRating}><span> {product.rating}</span><small>{product.stock}</small></div>
<div className={styles.productFooter}><p><strong>¥{product.price}</strong><del>¥{product.originalPrice}</del></p><button onClick={() => addProduct(product)}></button></div>
</div>
</article>
))}
</div>
</section>
<section id="comments" className={styles.anchorSection}>
<div className={styles.sectionHeading}>
<div><span>COMMUNITY REVIEWS</span><h2></h2></div>
<p></p>
</div>
<div className={styles.reviewSummary}>
<div className={styles.scoreBlock}><strong>{work.rating}</strong><span></span><small>{work.ratingCount.toLocaleString()} </small></div>
<div className={styles.distribution}>
{animeData.ratingDistribution.map((row) => <div key={row.stars}><span>{row.stars} </span><i><b style={{ width: `${row.percent}%` }} /></i><small>{row.percent}%</small></div>)}
</div>
<div className={styles.reviewHighlights}><div><strong>96%</strong><small></small></div><div><strong>8.8</strong><small></small></div><div><strong>9.6</strong><small></small></div></div>
</div>
<div className={styles.commentLayout}>
<div className={styles.commentMain}>
<div className={styles.commentToolbar}><div><button className={sort === "hot" ? styles.sortActive : ""} onClick={() => setSort("hot")}></button><button className={sort === "new" ? styles.sortActive : ""} onClick={() => setSort("new")}></button></div><span> {comments.length} </span></div>
<div className={styles.commentList}>
{visibleComments.map((comment) => <CommentItem key={comment.id} comment={comment} liked={likedComments.includes(comment.id)} onLike={() => toggleLike(comment.id)} onReply={() => notify("回复编辑器为模板交互占位")} />)}
</div>
</div>
<aside className={styles.composer}>
<span className={styles.composerEyebrow}>SHARE YOUR THOUGHTS</span><h3></h3><p>{work.title} {selectedEpisode} </p>
<div className={styles.scorePicker}><span></span><div>{[1, 2, 3, 4, 5].map((score) => <button key={score} className={score <= commentScore ? styles.starOn : ""} onClick={() => setCommentScore(score)} aria-label={`${score}`}></button>)}</div></div>
<textarea value={commentText} onChange={(event) => setCommentText(event.target.value)} maxLength={500} placeholder="聊聊最打动你的剧情、角色或画面……" />
<div className={styles.composerMeta}><label><input type="checkbox" /> </label><span>{commentText.length}/500</span></div>
<button className={styles.publishButton} onClick={publishComment}></button>
<small></small>
</aside>
</div>
</section>
</main>
<footer className={styles.footer}><div><span className={styles.logoMark}>S</span><p><strong>STELLAR ANIME ARCHIVE</strong><small> · JSON </small></p></div><Link href="/"> WBBR  </Link></footer>
{toast && <div className={styles.toast}><span></span>{toast}</div>}
</div>
);
}
function CommentItem({ comment, liked, onLike, onReply }: { comment: Comment; liked: boolean; onLike: () => void; onReply: () => void }) {
return (
<article className={styles.commentCard}>
<span className={`${styles.commentAvatar} ${styles[comment.tone]}`}>{comment.initial}</span>
<div className={styles.commentBody}>
<header><div><strong>{comment.author}</strong><span>{"★".repeat(comment.score)}<i>{"★".repeat(5 - comment.score)}</i></span></div><small>{comment.time} · {comment.episode}</small></header>
<p>{comment.content}</p>
<div className={styles.commentActions}><button className={liked ? styles.liked : ""} onClick={onLike}> {comment.likes + (liked ? 1 : 0)}</button><button onClick={onReply}></button><button></button></div>
{comment.replies.map((reply) => <div className={styles.reply} key={reply.id}><span>{reply.initial}</span><p><strong>{reply.author}</strong>{reply.content}<small>{reply.time}  {reply.likes}</small></p></div>)}
</div>
</article>
);
}