feat(home): add HeroBanner with PV video + ArtistFilters + 35-artist grid/list views
This commit is contained in:
parent
abce95aae8
commit
28447c2e65
270
src/app/page.tsx
270
src/app/page.tsx
@ -1,128 +1,127 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Heart, Play, Share2 } from "lucide-react";
|
import Link from "next/link";
|
||||||
import Button from "@/components/ui/Button";
|
import { ChevronRight } from "lucide-react";
|
||||||
import Countdown from "@/components/ui/Countdown";
|
import HeroBanner from "@/components/HeroBanner";
|
||||||
import ArtistCard from "@/components/cards/ArtistCard";
|
|
||||||
import Top12Bar from "@/components/Top12Bar";
|
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 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 type { Artist } from "@/types/artist";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const [voteTarget, setVoteTarget] = useState<Artist | null>(null);
|
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) => {
|
const endTime = useMemo(() => getActivityEndTime(), []);
|
||||||
// TODO: 接入实际投票 API(Phase 10)
|
|
||||||
|
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}`);
|
console.log(`Vote: ${artist.name} × ${count}`);
|
||||||
setVoteTarget(null);
|
setVoteTarget(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
<>
|
||||||
{/* Hero · Logo + slogan */}
|
{/* Hero Banner */}
|
||||||
<section className="py-12 sm:py-16 text-center">
|
<section className="px-4 sm:px-6 lg:px-8 pt-4 max-w-7xl mx-auto">
|
||||||
<p className="font-label text-[10px] sm:text-xs tracking-[0.4em] uppercase text-purple-300 mb-3">
|
<HeroBanner endTime={endTime} />
|
||||||
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>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Top12 实时榜条 */}
|
{/* Top12 实时榜条 */}
|
||||||
<section className="pb-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">
|
<div className="flex items-end justify-between mb-3 px-1">
|
||||||
<h2 className="font-display text-lg tracking-[0.2em] text-white uppercase">
|
<h2 className="font-display text-base sm:text-lg tracking-[0.2em] text-white uppercase flex items-center gap-2">
|
||||||
🏆 Top 12 · 实时出道位
|
<span className="text-purple-300">🏆</span>
|
||||||
|
Top 12 · 实时出道位
|
||||||
</h2>
|
</h2>
|
||||||
<a
|
<Link
|
||||||
href="/ranking"
|
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>
|
</div>
|
||||||
<Top12Bar artists={ARTISTS} />
|
<Top12Bar artists={ARTISTS} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 艺人卡片网格示例 */}
|
{/* 候选人阵容 */}
|
||||||
<section className="pb-16">
|
<section
|
||||||
<h2 className="font-display text-lg tracking-[0.2em] text-white uppercase mb-4">
|
id="artists"
|
||||||
✦ 候选人阵容
|
className="px-4 sm:px-6 lg:px-8 pt-12 pb-16 max-w-7xl mx-auto"
|
||||||
</h2>
|
>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 sm:gap-4">
|
<div className="flex items-end justify-between mb-4 px-1">
|
||||||
{ARTISTS.slice(0, 10).map((a) => (
|
<div>
|
||||||
<ArtistCard key={a.id} artist={a} onVote={setVoteTarget} />
|
<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>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Component sandbox */}
|
<ArtistFilters
|
||||||
<section className="pb-16 border-t border-white/[0.06] pt-12">
|
sortKey={sortKey}
|
||||||
<h2 className="font-display text-sm tracking-[0.2em] text-purple-300/70 uppercase mb-4">
|
onSortChange={setSortKey}
|
||||||
Phase 4 · Component Sandbox
|
tagFilter={tagFilter}
|
||||||
</h2>
|
onTagChange={setTagFilter}
|
||||||
<div className="grid gap-6 sm:grid-cols-2">
|
search={search}
|
||||||
<div className="bg-surface border border-white/[0.08] rounded-xl p-5">
|
onSearchChange={setSearch}
|
||||||
<p className="font-label text-[10px] tracking-widest text-purple-300 uppercase mb-3">
|
view={view}
|
||||||
Button Variants
|
onViewChange={setView}
|
||||||
</p>
|
/>
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
<Button variant="primary" size="sm">
|
{/* Artist list */}
|
||||||
Primary
|
{visibleArtists.length === 0 ? (
|
||||||
</Button>
|
<EmptyState />
|
||||||
<Button variant="outline" size="sm">
|
) : view === "grid" ? (
|
||||||
Outline
|
<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">
|
||||||
</Button>
|
{visibleArtists.map((a) => (
|
||||||
<Button variant="ghost" size="sm">
|
<ArtistCard key={a.id} artist={a} onVote={setVoteTarget} />
|
||||||
Ghost
|
))}
|
||||||
</Button>
|
|
||||||
<Button variant="danger" size="sm">
|
|
||||||
Danger
|
|
||||||
</Button>
|
|
||||||
<Button variant="primary" size="icon" aria-label="Share">
|
|
||||||
<Share2 size={14} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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">
|
<div className="mt-5 space-y-2">
|
||||||
Countdown · Compact
|
{visibleArtists.map((a) => (
|
||||||
</p>
|
<ArtistListRow key={a.id} artist={a} onVote={setVoteTarget} />
|
||||||
<Countdown endTime={endTime} compact />
|
))}
|
||||||
<p className="mt-3 text-[11px] text-white/40">
|
|
||||||
用于导航 / Hero 角标
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 投票弹窗 */}
|
{/* 投票弹窗 */}
|
||||||
@ -131,6 +130,83 @@ export default function Home() {
|
|||||||
onClose={() => setVoteTarget(null)}
|
onClose={() => setVoteTarget(null)}
|
||||||
onConfirm={handleVote}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
154
src/components/ArtistFilters.tsx
Normal file
154
src/components/ArtistFilters.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
src/components/HeroBanner.tsx
Normal file
215
src/components/HeroBanner.tsx
Normal 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 视频 URL(MP4) */
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user