Update WBBR frontend template collection

This commit is contained in:
Codex
2026-08-27 20:15:24 +08:00
parent bd4d54a110
commit e444605e5d
85 changed files with 6322 additions and 115 deletions

300
app/anime/page.tsx Normal file
View 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>
);
}