feat(home): add HeroBanner with PV video + ArtistFilters + 35-artist grid/list views

This commit is contained in:
iye 2026-05-12 09:39:21 +08:00
parent abce95aae8
commit 28447c2e65
3 changed files with 542 additions and 97 deletions

View File

@ -1,128 +1,127 @@
"use client";
import { useState } from "react";
import { Heart, Play, Share2 } from "lucide-react";
import Button from "@/components/ui/Button";
import Countdown from "@/components/ui/Countdown";
import ArtistCard from "@/components/cards/ArtistCard";
import { useMemo, useState } from "react";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import HeroBanner from "@/components/HeroBanner";
import Top12Bar from "@/components/Top12Bar";
import ArtistCard from "@/components/cards/ArtistCard";
import ArtistFilters, {
type TagFilter,
type ViewMode,
} from "@/components/ArtistFilters";
import VoteModal from "@/components/VoteModal";
import { ARTISTS, getActivityEndTime } from "@/lib/mock-data";
import ArtistPortrait from "@/components/cards/ArtistPortrait";
import { ARTISTS, sortArtists, getActivityEndTime } from "@/lib/mock-data";
import type { SortKey } from "@/lib/mock-data";
import type { Artist } from "@/types/artist";
import { cn } from "@/lib/cn";
export default function Home() {
const [voteTarget, setVoteTarget] = useState<Artist | null>(null);
const endTime = getActivityEndTime();
const [sortKey, setSortKey] = useState<SortKey>("votes");
const [tagFilter, setTagFilter] = useState<TagFilter>("all");
const [search, setSearch] = useState("");
const [view, setView] = useState<ViewMode>("grid");
const handleVote = (artist: Artist, count: number) => {
// TODO: 接入实际投票 APIPhase 10
const endTime = useMemo(() => getActivityEndTime(), []);
const visibleArtists = useMemo(() => {
let list = [...ARTISTS];
if (tagFilter !== "all") {
list = list.filter((a) => a.tags.includes(tagFilter));
}
if (search.trim()) {
const q = search.trim().toLowerCase();
list = list.filter(
(a) =>
a.name.toLowerCase().includes(q) ||
a.enName.toLowerCase().includes(q) ||
a.no.includes(q),
);
}
return sortArtists(list, sortKey);
}, [sortKey, tagFilter, search]);
const handleVote = async (artist: Artist, count: number) => {
// TODO: Phase 10 接入真实投票 API
await new Promise((r) => setTimeout(r, 400));
console.log(`Vote: ${artist.name} × ${count}`);
setVoteTarget(null);
};
return (
<div className="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
{/* Hero · Logo + slogan */}
<section className="py-12 sm:py-16 text-center">
<p className="font-label text-[10px] sm:text-xs tracking-[0.4em] uppercase text-purple-300 mb-3">
Top 12 · Virtual Idol Debut Project
</p>
<h1
className="font-logo text-5xl sm:text-7xl lg:text-8xl tracking-[0.35em] uppercase glow-text-purple inline-flex items-baseline"
style={{ paddingLeft: "0.35em" }}
>
Cyber
<span className="text-purple-300 mx-2 sm:mx-3 text-3xl sm:text-5xl lg:text-6xl">
</span>
Star
</h1>
<p className="mt-3 text-white/55 text-sm sm:text-base tracking-wide">
</p>
{/* Countdown */}
<div className="mt-8 flex justify-center">
<Countdown endTime={endTime} />
</div>
{/* CTA */}
<div className="mt-8 flex justify-center gap-4 flex-wrap">
<Button variant="outline" leftIcon={<Play size={16} />}>
Play Debut PV
</Button>
<Button variant="primary" pulse leftIcon={<Heart size={14} />}>
Vote Now
</Button>
</div>
<>
{/* Hero Banner */}
<section className="px-4 sm:px-6 lg:px-8 pt-4 max-w-7xl mx-auto">
<HeroBanner endTime={endTime} />
</section>
{/* Top12 实时榜条 */}
<section className="pb-12">
<div className="flex items-end justify-between mb-3">
<h2 className="font-display text-lg tracking-[0.2em] text-white uppercase">
🏆 Top 12 ·
<section className="px-4 sm:px-6 lg:px-8 pt-10 max-w-7xl mx-auto">
<div className="flex items-end justify-between mb-3 px-1">
<h2 className="font-display text-base sm:text-lg tracking-[0.2em] text-white uppercase flex items-center gap-2">
<span className="text-purple-300">🏆</span>
Top 12 ·
</h2>
<a
<Link
href="/ranking"
className="font-label text-[11px] tracking-widest text-purple-300 hover:text-purple-200 uppercase"
className="font-label text-[11px] tracking-widest text-purple-300 hover:text-purple-200 uppercase inline-flex items-center gap-0.5"
>
</a>
<ChevronRight size={12} />
</Link>
</div>
<Top12Bar artists={ARTISTS} />
</section>
{/* 艺人卡片网格示例 */}
<section className="pb-16">
<h2 className="font-display text-lg tracking-[0.2em] text-white uppercase mb-4">
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 sm:gap-4">
{ARTISTS.slice(0, 10).map((a) => (
<ArtistCard key={a.id} artist={a} onVote={setVoteTarget} />
))}
{/* 候选人阵容 */}
<section
id="artists"
className="px-4 sm:px-6 lg:px-8 pt-12 pb-16 max-w-7xl mx-auto"
>
<div className="flex items-end justify-between mb-4 px-1">
<div>
<p className="font-label text-[10px] tracking-[0.3em] uppercase text-purple-300/80 mb-1">
Candidates · 35 Idols
</p>
<h2 className="font-display text-base sm:text-lg tracking-[0.2em] text-white uppercase">
</h2>
</div>
<p className="text-xs text-white/45 hidden sm:block">
<span className="text-purple-300">{visibleArtists.length}</span>{" "}
</p>
</div>
</section>
{/* Component sandbox */}
<section className="pb-16 border-t border-white/[0.06] pt-12">
<h2 className="font-display text-sm tracking-[0.2em] text-purple-300/70 uppercase mb-4">
Phase 4 · Component Sandbox
</h2>
<div className="grid gap-6 sm:grid-cols-2">
<div className="bg-surface border border-white/[0.08] rounded-xl p-5">
<p className="font-label text-[10px] tracking-widest text-purple-300 uppercase mb-3">
Button Variants
</p>
<div className="flex flex-wrap gap-3">
<Button variant="primary" size="sm">
Primary
</Button>
<Button variant="outline" size="sm">
Outline
</Button>
<Button variant="ghost" size="sm">
Ghost
</Button>
<Button variant="danger" size="sm">
Danger
</Button>
<Button variant="primary" size="icon" aria-label="Share">
<Share2 size={14} />
</Button>
</div>
<ArtistFilters
sortKey={sortKey}
onSortChange={setSortKey}
tagFilter={tagFilter}
onTagChange={setTagFilter}
search={search}
onSearchChange={setSearch}
view={view}
onViewChange={setView}
/>
{/* Artist list */}
{visibleArtists.length === 0 ? (
<EmptyState />
) : view === "grid" ? (
<div className="mt-5 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 sm:gap-4">
{visibleArtists.map((a) => (
<ArtistCard key={a.id} artist={a} onVote={setVoteTarget} />
))}
</div>
<div className="bg-surface border border-white/[0.08] rounded-xl p-5">
<p className="font-label text-[10px] tracking-widest text-purple-300 uppercase mb-3">
Countdown · Compact
</p>
<Countdown endTime={endTime} compact />
<p className="mt-3 text-[11px] text-white/40">
/ Hero
</p>
) : (
<div className="mt-5 space-y-2">
{visibleArtists.map((a) => (
<ArtistListRow key={a.id} artist={a} onVote={setVoteTarget} />
))}
</div>
</div>
)}
</section>
{/* 投票弹窗 */}
@ -131,6 +130,83 @@ export default function Home() {
onClose={() => setVoteTarget(null)}
onConfirm={handleVote}
/>
</>
);
}
/** 列表视图行 */
function ArtistListRow({
artist,
onVote,
}: {
artist: Artist;
onVote: (a: Artist) => void;
}) {
const inTop12 = artist.rank <= 12;
return (
<div
className={cn(
"flex items-center gap-3 sm:gap-4 p-3 rounded-xl border transition-all",
inTop12
? "border-purple-500/40 bg-purple-500/[0.04] hover:bg-purple-500/[0.08]"
: "border-white/[0.06] bg-surface/40 hover:bg-surface/60",
)}
>
<div className="w-12 text-center font-display text-lg text-purple-300 tabular-nums">
#{artist.rank}
</div>
<Link
href={`/artist/${artist.id}`}
className="flex items-center gap-3 flex-1 min-w-0"
>
<div className="w-12 h-12 rounded-full overflow-hidden border border-white/15 flex-shrink-0">
<ArtistPortrait
artist={artist}
rounded="rounded-full"
className="w-full h-full"
/>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm text-white font-semibold truncate">
{artist.name}{" "}
<span className="text-white/55 font-normal text-xs">
· {artist.enName}
</span>
</div>
<div className="text-[11px] text-white/40 truncate">
No.{artist.no} · {artist.slogan}
</div>
</div>
</Link>
<div className="hidden sm:block w-24 text-right">
<div className="font-display text-sm text-purple-300 tabular-nums">
{(artist.votes / 10000).toFixed(1)}w
</div>
<div className="text-[10px] text-white/40"></div>
</div>
<button
type="button"
onClick={() => onVote(artist)}
className={cn(
"px-4 py-1.5 rounded-full font-display text-[10px] tracking-widest uppercase transition-all flex-shrink-0",
inTop12
? "bg-grad-purple text-white shadow-[0_0_12px_rgba(139,92,246,0.4)] hover:brightness-110"
: "bg-elevated border border-white/15 text-white/70 hover:text-white",
)}
>
Vote
</button>
</div>
);
}
function EmptyState() {
return (
<div className="mt-8 py-16 text-center text-white/45 border border-dashed border-white/10 rounded-xl">
<p className="font-label text-xs tracking-widest uppercase text-purple-300 mb-2">
No Match
</p>
<p className="text-sm"></p>
</div>
);
}

View File

@ -0,0 +1,154 @@
"use client";
import { Search, LayoutGrid, List } from "lucide-react";
import { cn } from "@/lib/cn";
import type { ArtistTag } from "@/types/artist";
import type { SortKey } from "@/lib/mock-data";
export type ViewMode = "grid" | "list";
export type TagFilter = ArtistTag | "all";
interface ArtistFiltersProps {
sortKey: SortKey;
onSortChange: (key: SortKey) => void;
tagFilter: TagFilter;
onTagChange: (tag: TagFilter) => void;
search: string;
onSearchChange: (q: string) => void;
view: ViewMode;
onViewChange: (v: ViewMode) => void;
}
const SORT_OPTIONS: { key: SortKey; label: string }[] = [
{ key: "votes", label: "票数" },
{ key: "no", label: "编号" },
{ key: "recent", label: "最近活跃" },
];
const TAG_OPTIONS: { key: TagFilter; label: string }[] = [
{ key: "all", label: "全部" },
{ key: "dance", label: "舞蹈担当" },
{ key: "vocal", label: "声乐担当" },
{ key: "rap", label: "Rap 担当" },
{ key: "all-rounder", label: "全能型" },
];
export default function ArtistFilters({
sortKey,
onSortChange,
tagFilter,
onTagChange,
search,
onSearchChange,
view,
onViewChange,
}: ArtistFiltersProps) {
return (
<div className="bg-surface/60 backdrop-blur-md border border-white/[0.06] rounded-xl p-3 sm:p-4 flex flex-wrap items-center gap-2 sm:gap-3">
{/* 排序 */}
<span className="font-label text-[10px] tracking-widest text-white/45 uppercase">
</span>
<div className="flex gap-1.5">
{SORT_OPTIONS.map((opt) => (
<FilterTag
key={opt.key}
active={sortKey === opt.key}
onClick={() => onSortChange(opt.key)}
>
{opt.label}
</FilterTag>
))}
</div>
<span className="hidden sm:inline-block w-px h-5 bg-white/10 mx-1" />
{/* 标签 */}
<span className="font-label text-[10px] tracking-widest text-white/45 uppercase">
</span>
<div className="flex gap-1.5 flex-wrap">
{TAG_OPTIONS.map((opt) => (
<FilterTag
key={opt.key}
active={tagFilter === opt.key}
onClick={() => onTagChange(opt.key)}
>
{opt.label}
</FilterTag>
))}
</div>
{/* 搜索 + 视图切换:靠右 */}
<div className="ml-auto flex items-center gap-2 w-full sm:w-auto">
<label className="relative flex-1 sm:flex-initial">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-white/35"
/>
<input
type="search"
placeholder="搜索艺人名称..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 pr-3 h-9 w-full sm:w-56 rounded-full bg-deep/60 border border-white/10 text-sm text-white placeholder-white/35 focus:outline-none focus:border-purple-500/60 focus:shadow-[0_0_12px_rgba(139,92,246,0.2)] transition-all"
/>
</label>
<div className="flex items-center bg-deep/60 border border-white/10 rounded-full p-0.5">
<button
type="button"
onClick={() => onViewChange("grid")}
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center transition-colors",
view === "grid"
? "bg-purple-500/25 text-purple-200"
: "text-white/45 hover:text-white/75",
)}
aria-label="网格视图"
>
<LayoutGrid size={13} />
</button>
<button
type="button"
onClick={() => onViewChange("list")}
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center transition-colors",
view === "list"
? "bg-purple-500/25 text-purple-200"
: "text-white/45 hover:text-white/75",
)}
aria-label="列表视图"
>
<List size={13} />
</button>
</div>
</div>
</div>
);
}
function FilterTag({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"px-3 py-1 rounded-full text-xs transition-all",
active
? "bg-purple-500/20 border border-purple-400 text-purple-200 shadow-[0_0_10px_rgba(139,92,246,0.25)]"
: "bg-white/[0.04] border border-white/10 text-white/60 hover:text-white hover:border-white/25",
)}
>
{children}
</button>
);
}

View File

@ -0,0 +1,215 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
Play,
Pause,
Volume2,
VolumeX,
Maximize2,
ChevronDown,
} from "lucide-react";
import Countdown from "./ui/Countdown";
import { cn } from "@/lib/cn";
interface HeroBannerProps {
/** PV 视频 URLMP4 */
videoSrc?: string;
/** 视频封面图 */
poster?: string;
/** 活动结束时间 */
endTime: Date | string | number;
/** 标题主行(默认 CYBER STAR */
title?: string;
/** 副标题 */
subtitle?: string;
/** 上标小字 */
eyebrow?: string;
className?: string;
}
export default function HeroBanner({
videoSrc,
poster,
endTime,
title = "CYBER STAR",
subtitle = "虚拟偶像出道企划",
eyebrow = "Top 12 · Virtual Idol Debut Project",
className,
}: HeroBannerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(true);
const [isMuted, setIsMuted] = useState(true);
const [hasInteracted, setHasInteracted] = useState(false);
// 自动播放(静音)
useEffect(() => {
const v = videoRef.current;
if (!v || !videoSrc) return;
v.play().catch(() => setIsPlaying(false));
}, [videoSrc]);
const togglePlay = () => {
const v = videoRef.current;
if (!v) return;
if (v.paused) {
v.play();
setIsPlaying(true);
} else {
v.pause();
setIsPlaying(false);
}
setHasInteracted(true);
};
const toggleMute = () => {
const v = videoRef.current;
if (!v) return;
v.muted = !v.muted;
setIsMuted(v.muted);
setHasInteracted(true);
};
const goFullscreen = () => {
videoRef.current?.requestFullscreen?.();
};
const scrollToContent = () => {
window.scrollBy({ top: window.innerHeight * 0.85, behavior: "smooth" });
};
return (
<section
className={cn(
"relative w-full overflow-hidden rounded-2xl border border-white/[0.06]",
"h-[70vh] min-h-[480px] max-h-[720px]",
className,
)}
>
{/* 背景视频 / 渐变占位 */}
{videoSrc ? (
<video
ref={videoRef}
src={videoSrc}
poster={poster}
autoPlay
muted
loop
playsInline
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<div className="absolute inset-0 bg-grad-hero" />
)}
{/* 装饰:能量光环 */}
<div
aria-hidden
className="absolute -top-32 -right-32 w-[480px] h-[480px] rounded-full pointer-events-none animate-spin-slow"
style={{
background:
"conic-gradient(from 0deg, transparent, rgba(196,181,253,0.18), transparent 60%)",
maskImage:
"radial-gradient(circle, transparent 50%, black 51%, black 70%, transparent 71%)",
WebkitMaskImage:
"radial-gradient(circle, transparent 50%, black 51%, black 70%, transparent 71%)",
}}
/>
{/* 蒙层渐变 */}
<div
aria-hidden
className="absolute inset-0 pointer-events-none"
style={{
background:
"linear-gradient(180deg, rgba(8,5,26,0.45) 0%, rgba(8,5,26,0.15) 35%, rgba(8,5,26,0.75) 100%)",
}}
/>
{/* 顶部:左侧 PV 标 + 右侧倒计时 */}
<div className="absolute inset-x-0 top-0 p-5 sm:p-6 flex items-start justify-between z-10">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-black/55 backdrop-blur-md border border-white/10 text-[11px] text-white/85 tracking-wider font-label">
Debut PV {videoSrc ? "自动播放" : "敬请期待"}
</div>
<Countdown endTime={endTime} compact />
</div>
{/* 中央内容Logo + 副标题 + Play 按钮 */}
<div className="absolute inset-0 flex flex-col items-center justify-center z-10 px-6 text-center">
<p className="font-label text-[10px] sm:text-xs tracking-[0.4em] uppercase text-purple-200/90 mb-4">
{eyebrow}
</p>
<h1
className="font-logo text-5xl sm:text-7xl lg:text-8xl tracking-[0.35em] uppercase glow-text-purple inline-flex items-baseline"
style={{ paddingLeft: "0.35em" }}
>
{title.split("STAR").map((part, i) =>
i === 0 ? (
<span key={i}>{part}</span>
) : (
<span key={i} className="inline-flex items-baseline">
<span className="text-purple-300 mx-2 sm:mx-3 text-3xl sm:text-5xl lg:text-6xl">
</span>
<span>STAR</span>
{part.replace("STAR", "")}
</span>
),
)}
</h1>
<p className="mt-4 text-white/65 text-sm sm:text-base">{subtitle}</p>
{/* 播放按钮 */}
<button
type="button"
onClick={togglePlay}
className="mt-8 inline-flex items-center gap-3 px-5 py-3 rounded-full bg-white/10 hover:bg-white/15 backdrop-blur-md border border-white/20 transition-all group"
>
<span className="w-11 h-11 rounded-full bg-purple-500 flex items-center justify-center text-white shadow-purple-glow group-hover:scale-105 transition-transform">
{isPlaying ? <Pause size={18} /> : <Play size={18} fill="white" />}
</span>
<span className="font-display text-xs tracking-[0.25em] uppercase text-white pr-2">
Play Debut PV
</span>
</button>
</div>
{/* 底部右侧:音量 / 全屏 */}
<div className="absolute bottom-5 right-5 flex items-center gap-2 z-10">
<button
type="button"
onClick={toggleMute}
aria-label={isMuted ? "取消静音" : "静音"}
className="w-9 h-9 rounded-full bg-black/55 backdrop-blur-md border border-white/10 flex items-center justify-center text-white/85 hover:text-white hover:bg-black/70 transition-colors"
>
{isMuted ? <VolumeX size={14} /> : <Volume2 size={14} />}
</button>
<button
type="button"
onClick={goFullscreen}
aria-label="全屏"
className="w-9 h-9 rounded-full bg-black/55 backdrop-blur-md border border-white/10 flex items-center justify-center text-white/85 hover:text-white hover:bg-black/70 transition-colors"
>
<Maximize2 size={14} />
</button>
</div>
{/* 底部左侧:滚动提示 */}
<button
type="button"
onClick={scrollToContent}
className="absolute bottom-5 left-1/2 -translate-x-1/2 z-10 inline-flex items-center gap-2 text-white/55 hover:text-white/90 transition-colors animate-float"
>
<span className="font-label text-[10px] tracking-[0.3em] uppercase">
Scroll to Explore
</span>
<ChevronDown size={14} />
</button>
{/* 隐藏的"用户已交互"标志,用于满足可访问性 */}
<span aria-hidden className="sr-only">
{hasInteracted ? "interacted" : "auto"}
</span>
</section>
);
}