Update WBBR frontend template collection
This commit is contained in:
300
app/anime/page.tsx
Normal file
300
app/anime/page.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user