feat(artist): dynamic /artist/[id] page with hero, 15s video, gallery lightbox, bio and floating vote

This commit is contained in:
iye 2026-05-12 09:42:01 +08:00
parent 28447c2e65
commit 5f06b5122b
6 changed files with 745 additions and 0 deletions

View File

@ -0,0 +1,32 @@
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { ARTISTS, getArtist } from "@/lib/mock-data";
import ArtistDetailContent from "@/components/artist/ArtistDetailContent";
interface ArtistPageProps {
params: Promise<{ id: string }>;
}
export async function generateStaticParams() {
return ARTISTS.map((a) => ({ id: a.id }));
}
export async function generateMetadata({
params,
}: ArtistPageProps): Promise<Metadata> {
const { id } = await params;
const artist = getArtist(id);
if (!artist) return { title: "艺人不存在 · CYBER STAR" };
return {
title: `${artist.name} · ${artist.enName} · CYBER STAR`,
description: artist.bio.slice(0, 120),
};
}
export default async function ArtistPage({ params }: ArtistPageProps) {
const { id } = await params;
const artist = getArtist(id);
if (!artist) notFound();
return <ArtistDetailContent artist={artist} allArtists={ARTISTS} />;
}

View File

@ -0,0 +1,46 @@
"use client";
import { useEffect, useState } from "react";
import { Heart } from "lucide-react";
import { cn } from "@/lib/cn";
interface FloatingVoteButtonProps {
onClick: () => void;
/** 显示前的滚动阈值px */
threshold?: number;
className?: string;
}
export default function FloatingVoteButton({
onClick,
threshold = 300,
className,
}: FloatingVoteButtonProps) {
const [visible, setVisible] = useState(false);
useEffect(() => {
const handler = () => setVisible(window.scrollY > threshold);
handler();
window.addEventListener("scroll", handler, { passive: true });
return () => window.removeEventListener("scroll", handler);
}, [threshold]);
return (
<button
type="button"
onClick={onClick}
aria-label="立即投票"
className={cn(
"fixed bottom-6 right-6 sm:bottom-8 sm:right-8 z-40 w-14 h-14 rounded-full bg-grad-purple text-white flex flex-col items-center justify-center font-display text-[9px] tracking-widest shadow-purple-glow animate-pulse-glow transition-all",
visible
? "opacity-100 translate-y-0 pointer-events-auto"
: "opacity-0 translate-y-3 pointer-events-none",
"hover:scale-105",
className,
)}
>
<Heart size={16} fill="white" className="mb-0.5" />
<span>VOTE</span>
</button>
);
}

View File

@ -0,0 +1,241 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { ChevronLeft, Heart, Share2 } from "lucide-react";
import type { Artist } from "@/types/artist";
import { TAG_LABEL } from "@/types/artist";
import ArtistPortrait from "@/components/cards/ArtistPortrait";
import VoteModal from "@/components/VoteModal";
import Button from "@/components/ui/Button";
import RankCard from "./RankCard";
import PerformanceVideo from "./PerformanceVideo";
import PerformanceGallery from "./PerformanceGallery";
import FloatingVoteButton from "@/components/FloatingVoteButton";
interface ArtistDetailContentProps {
artist: Artist;
allArtists: Artist[];
}
export default function ArtistDetailContent({
artist,
allArtists,
}: ArtistDetailContentProps) {
const [voteOpen, setVoteOpen] = useState(false);
const handleVote = async (a: Artist, count: number) => {
await new Promise((r) => setTimeout(r, 400));
console.log(`Vote: ${a.name} × ${count}`);
setVoteOpen(false);
};
return (
<>
{/* 简化面包屑 */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-4">
<div className="h-12 flex items-center gap-3 text-sm">
<Link
href="/"
className="inline-flex items-center gap-1 text-white/65 hover:text-purple-300 transition-colors"
>
<ChevronLeft size={14} />
</Link>
<span className="text-white/30">/</span>
<span className="text-white/45"></span>
<span className="text-white/30">/</span>
<span className="text-white/45"></span>
<span className="text-white/30">/</span>
<span className="text-white/85">{artist.name}</span>
<button
type="button"
className="ml-auto inline-flex items-center gap-1.5 text-purple-300 hover:text-purple-200 text-xs"
>
<Share2 size={14} />
<span className="hidden sm:inline"></span>
</button>
</div>
</div>
{/* Hero 区 */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-12">
<div
className="rounded-2xl border border-white/[0.06] overflow-hidden p-5 sm:p-8 grid gap-6 lg:grid-cols-[340px_1fr] lg:gap-8"
style={{
background:
"linear-gradient(135deg, rgba(139,92,246,0.06) 0%, rgba(13,10,36,0.6) 100%)",
}}
>
{/* 立绘 */}
<div className="relative">
<ArtistPortrait
artist={artist}
rounded="rounded-xl"
className="w-full aspect-[4/5] shadow-card"
/>
{/* 应援色装饰条 */}
<div className="mt-3 flex items-center gap-2 px-3 py-2 rounded-lg bg-black/35 border border-white/10">
<span
className="w-4 h-4 rounded-full ring-2 ring-white/20"
style={{ background: artist.themeColor }}
/>
<span className="font-label text-[10px] tracking-widest uppercase text-white/55">
</span>
<span className="font-mono text-[10px] text-white/45 ml-auto">
{artist.themeColor}
</span>
</div>
</div>
{/* 信息区 */}
<div className="flex flex-col gap-4">
<div>
<div className="font-label text-[10px] tracking-[0.3em] uppercase text-purple-300/80 mb-2">
No.{artist.no}
</div>
<h1 className="text-3xl sm:text-4xl font-bold text-white tracking-tight mb-1">
{artist.name}
</h1>
<p className="font-display text-lg tracking-[0.2em] uppercase text-purple-300 glow-text-purple">
{artist.enName}
</p>
</div>
{/* 标签 */}
<div className="flex gap-2 flex-wrap">
{artist.tags.map((t) => (
<span
key={t}
className="px-2.5 py-1 rounded-full bg-purple-500/12 border border-purple-500/30 text-purple-300 text-[11px]"
>
{TAG_LABEL[t]}
</span>
))}
</div>
{/* 元信息 */}
<div className="grid grid-cols-3 gap-2">
<MetaCell label="生日" value={artist.birthday} />
<MetaCell label="身高" value={`${artist.height} cm`} />
<MetaCell label="CV" value={artist.cv ?? "未公开"} />
</div>
{/* 排名卡 */}
<RankCard artist={artist} allArtists={allArtists} />
{/* CTA */}
<div className="flex gap-3 pt-1">
<Button
variant="primary"
size="lg"
pulse
className="flex-1"
leftIcon={<Heart size={16} fill="currentColor" />}
onClick={() => setVoteOpen(true)}
>
TA
</Button>
<Button
variant="outline"
size="lg"
leftIcon={<Share2 size={14} />}
aria-label="分享"
>
<span className="hidden sm:inline"></span>
</Button>
</div>
</div>
</div>
</section>
{/* 15s 表演视频 */}
<section className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 pb-12">
<SectionHeading title="表演视频" subtitle="15s Performance" />
<PerformanceVideo themeColor={artist.themeColor} duration="00:15" />
<p className="text-xs text-white/40 mt-3">
</p>
</section>
{/* 表演图片 */}
<section className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 pb-12">
<SectionHeading title="表演图片" subtitle="Performance Gallery" />
<PerformanceGallery
images={artist.gallery}
themeColor={artist.themeColor}
/>
</section>
{/* 详细简介 */}
<section className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<SectionHeading title="艺人简介" subtitle="Biography" />
<div className="bg-surface/50 backdrop-blur-md border border-white/[0.08] rounded-xl p-5 sm:p-7">
<p className="text-sm sm:text-base text-white/75 leading-[1.85]">
{artist.bio}
</p>
<div className="mt-5 pt-5 border-t border-white/[0.06] grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
<BioMeta label="性格关键词" value={artist.slogan} />
<BioMeta label="主推楼曲" value={`${artist.enName}'s Song》`} />
<BioMeta
label="训练经历"
value={`${Math.floor(artist.height / 30)} 年声乐 + 舞台`}
/>
</div>
</div>
</section>
{/* 浮动投票按钮 */}
<FloatingVoteButton onClick={() => setVoteOpen(true)} />
{/* 投票弹窗 */}
<VoteModal
artist={voteOpen ? artist : null}
onClose={() => setVoteOpen(false)}
onConfirm={handleVote}
/>
</>
);
}
function MetaCell({ label, value }: { label: string; value: string }) {
return (
<div className="bg-deep/60 border border-white/[0.06] rounded-lg px-3 py-2">
<div className="font-label text-[9px] tracking-widest uppercase text-white/40 mb-0.5">
{label}
</div>
<div className="text-sm text-white/85 truncate">{value}</div>
</div>
);
}
function BioMeta({ label, value }: { label: string; value: string }) {
return (
<div>
<span className="font-label text-[10px] tracking-widest uppercase text-purple-300/70 block mb-1">
{label}
</span>
<span className="text-white/85">{value}</span>
</div>
);
}
function SectionHeading({
title,
subtitle,
}: {
title: string;
subtitle: string;
}) {
return (
<div className="mb-4">
<p className="font-label text-[10px] tracking-[0.3em] uppercase text-purple-300/80 mb-1">
{subtitle}
</p>
<h2 className="font-display text-base sm:text-lg text-white tracking-[0.2em] uppercase">
{title}
</h2>
</div>
);
}

View File

@ -0,0 +1,198 @@
"use client";
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { X, ChevronLeft, ChevronRight } from "lucide-react";
import { AnimatePresence, motion } from "framer-motion";
import Image from "next/image";
import { cn } from "@/lib/cn";
interface PerformanceGalleryProps {
images: string[];
/** 当无真实图时,用此颜色生成占位 */
themeColor?: string;
/** 占位标签(如 "定妆照"、"表演中" */
placeholderLabels?: string[];
}
const DEFAULT_LABELS = ["定妆照", "表演中", "幕后花絮", "舞台 1", "舞台 2", "未公开"];
export default function PerformanceGallery({
images,
themeColor = "#8b5cf6",
placeholderLabels = DEFAULT_LABELS,
}: PerformanceGalleryProps) {
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
// ESC 关闭
useEffect(() => {
if (lightboxIndex == null) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") setLightboxIndex(null);
if (e.key === "ArrowLeft")
setLightboxIndex((i) => (i! > 0 ? i! - 1 : images.length - 1));
if (e.key === "ArrowRight")
setLightboxIndex((i) => (i! < images.length - 1 ? i! + 1 : 0));
};
window.addEventListener("keydown", handler);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", handler);
document.body.style.overflow = prev;
};
}, [lightboxIndex, images.length]);
return (
<>
<div className="grid grid-cols-3 sm:grid-cols-3 lg:grid-cols-6 gap-2 sm:gap-3">
{images.map((src, i) => (
<button
type="button"
key={i}
onClick={() => setLightboxIndex(i)}
className="group relative aspect-[4/3] rounded-lg overflow-hidden border border-white/[0.08] hover:border-purple-500/50 hover:shadow-[0_0_16px_rgba(139,92,246,0.25)] transition-all"
>
{src ? (
<Image
src={src}
alt={`表演图 ${i + 1}`}
fill
sizes="(max-width: 768px) 33vw, 200px"
className="object-cover group-hover:scale-105 transition-transform duration-500"
/>
) : (
<div
className="absolute inset-0 flex items-center justify-center text-white/55 text-[11px]"
style={{
background: `linear-gradient(135deg, ${themeColor}25 0%, #1a1638 70%)`,
}}
>
<span className="font-label tracking-widest uppercase text-[10px]">
{placeholderLabels[i] || `Image ${i + 1}`}
</span>
<span className="absolute top-1.5 right-1.5 text-white/15 text-[10px]">
</span>
</div>
)}
</button>
))}
</div>
<p className="text-xs text-white/40 mt-3"></p>
{/* Lightbox */}
{mounted &&
createPortal(
<AnimatePresence>
{lightboxIndex !== null && (
<motion.div
key="lightbox"
className="fixed inset-0 z-[110] flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<button
type="button"
aria-label="关闭"
onClick={() => setLightboxIndex(null)}
className="absolute inset-0 bg-black/90 backdrop-blur-lg cursor-default"
/>
{/* 顶部关闭 */}
<button
type="button"
onClick={() => setLightboxIndex(null)}
aria-label="关闭"
className="absolute top-5 right-5 w-10 h-10 rounded-full bg-white/10 border border-white/15 backdrop-blur-md flex items-center justify-center text-white hover:bg-white/20 z-20"
>
<X size={18} />
</button>
{/* 左右切换 */}
{images.length > 1 && (
<>
<button
type="button"
onClick={() =>
setLightboxIndex(
(i) => (i! > 0 ? i! - 1 : images.length - 1),
)
}
aria-label="上一张"
className="absolute left-5 top-1/2 -translate-y-1/2 w-11 h-11 rounded-full bg-white/10 border border-white/15 backdrop-blur-md flex items-center justify-center text-white hover:bg-white/20 z-20"
>
<ChevronLeft size={22} />
</button>
<button
type="button"
onClick={() =>
setLightboxIndex(
(i) => (i! < images.length - 1 ? i! + 1 : 0),
)
}
aria-label="下一张"
className="absolute right-5 top-1/2 -translate-y-1/2 w-11 h-11 rounded-full bg-white/10 border border-white/15 backdrop-blur-md flex items-center justify-center text-white hover:bg-white/20 z-20"
>
<ChevronRight size={22} />
</button>
</>
)}
{/* 图片本体 */}
<motion.div
key={lightboxIndex}
initial={{ opacity: 0, scale: 0.97 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
className="relative max-w-5xl w-full mx-6 max-h-[85vh] aspect-[4/3] z-10"
>
{images[lightboxIndex] ? (
<Image
src={images[lightboxIndex]!}
alt={`表演图 ${lightboxIndex + 1}`}
fill
sizes="100vw"
className="object-contain"
/>
) : (
<div
className={cn(
"absolute inset-0 rounded-xl flex items-center justify-center",
"border border-white/10",
)}
style={{
background: `linear-gradient(135deg, ${themeColor}30 0%, #1a1638 70%)`,
}}
>
<div className="text-center">
<span className="font-logo text-6xl text-white/30 tracking-widest">
</span>
<p className="font-label text-xs tracking-widest text-white/55 uppercase mt-3">
{placeholderLabels[lightboxIndex] ||
`Image ${lightboxIndex + 1}`}
</p>
</div>
</div>
)}
</motion.div>
{/* 索引 */}
<div className="absolute bottom-5 left-1/2 -translate-x-1/2 font-display text-xs text-white/55 tracking-widest z-20 tabular-nums">
{lightboxIndex + 1} / {images.length}
</div>
</motion.div>
)}
</AnimatePresence>,
document.body,
)}
</>
);
}

View File

@ -0,0 +1,146 @@
"use client";
import { useRef, useState } from "react";
import { Play, Pause, Volume2, VolumeX, Maximize2 } from "lucide-react";
import { cn } from "@/lib/cn";
interface PerformanceVideoProps {
src?: string;
poster?: string;
duration?: string;
themeColor?: string;
className?: string;
}
export default function PerformanceVideo({
src,
poster,
duration = "00:15",
themeColor = "#8b5cf6",
className,
}: PerformanceVideoProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
const [muted, setMuted] = useState(false);
const togglePlay = () => {
const v = videoRef.current;
if (!v) return;
if (v.paused) {
v.play();
setPlaying(true);
} else {
v.pause();
setPlaying(false);
}
};
const toggleMute = () => {
const v = videoRef.current;
if (!v) return;
v.muted = !v.muted;
setMuted(v.muted);
};
const goFullscreen = () => {
videoRef.current?.requestFullscreen?.();
};
return (
<div
className={cn(
"relative w-full aspect-video bg-deep rounded-xl overflow-hidden border border-white/[0.08] group",
className,
)}
>
{src ? (
<video
ref={videoRef}
src={src}
poster={poster}
playsInline
onEnded={() => setPlaying(false)}
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<div
className="absolute inset-0"
style={{
background: `radial-gradient(circle at 50% 40%, ${themeColor}55 0%, #1a1638 55%, #08051a 100%)`,
}}
>
{/* 装饰星点 */}
<div className="absolute inset-0 opacity-50">
<span className="absolute top-[15%] left-[20%] text-white text-xs">
</span>
<span className="absolute top-[70%] left-[80%] text-purple-300 text-sm">
</span>
<span className="absolute top-[30%] left-[70%] text-white/60 text-[10px]">
</span>
</div>
</div>
)}
{/* 顶部 15s 标签 */}
<div className="absolute top-3 left-3 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-purple-600/90 backdrop-blur-md text-white text-[10px] font-display tracking-widest uppercase shadow-purple-glow z-10">
15s Performance
</div>
{/* 时长徽章 */}
<div className="absolute bottom-3 right-3 px-2 py-0.5 rounded bg-black/65 backdrop-blur-md text-white text-[10px] font-display tracking-wider tabular-nums z-10">
{duration}
</div>
{/* 中央播放按钮(未播放时) */}
{!playing && (
<button
type="button"
onClick={togglePlay}
className="absolute inset-0 flex items-center justify-center z-10 group/play"
aria-label="播放视频"
>
<span className="w-16 h-16 rounded-full bg-white/15 backdrop-blur-md border-2 border-white/40 flex items-center justify-center text-white group-hover/play:scale-110 transition-transform">
<Play size={22} fill="white" />
</span>
</button>
)}
{/* 暂停按钮 + 控制条(播放中) */}
{playing && (
<div className="absolute inset-0 flex items-center justify-center z-10 opacity-0 group-hover:opacity-100 transition-opacity bg-black/30">
<button
type="button"
onClick={togglePlay}
className="w-14 h-14 rounded-full bg-white/15 backdrop-blur-md border-2 border-white/40 flex items-center justify-center text-white hover:scale-105 transition-transform"
aria-label="暂停"
>
<Pause size={20} />
</button>
</div>
)}
{/* 右下:音量 / 全屏 */}
<div className="absolute bottom-3 left-3 flex items-center gap-1.5 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={toggleMute}
aria-label={muted ? "取消静音" : "静音"}
className="w-7 h-7 rounded-full bg-black/55 backdrop-blur-md flex items-center justify-center text-white/85 hover:text-white"
>
{muted ? <VolumeX size={12} /> : <Volume2 size={12} />}
</button>
<button
type="button"
onClick={goFullscreen}
aria-label="全屏"
className="w-7 h-7 rounded-full bg-black/55 backdrop-blur-md flex items-center justify-center text-white/85 hover:text-white"
>
<Maximize2 size={12} />
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,82 @@
import type { Artist } from "@/types/artist";
import { cn } from "@/lib/cn";
interface RankCardProps {
artist: Artist;
/** 全榜单(用于计算与上下名的差距) */
allArtists: Artist[];
className?: string;
}
export default function RankCard({ artist, allArtists, className }: RankCardProps) {
// 按当前票数排序后定位艺人
const sorted = [...allArtists].sort((a, b) => b.votes - a.votes);
const idx = sorted.findIndex((a) => a.id === artist.id);
const prev = idx > 0 ? sorted[idx - 1] : undefined;
const next = idx < sorted.length - 1 ? sorted[idx + 1] : undefined;
const leadOver = next ? artist.votes - next.votes : null;
const trailBehind = prev ? prev.votes - artist.votes : null;
const isFirst = artist.rank === 1;
const inTop12 = artist.rank <= 12;
return (
<div
className={cn(
"bg-elevated/60 backdrop-blur-md border border-white/10 rounded-xl p-4 sm:p-5 grid grid-cols-2 gap-4",
className,
)}
>
{/* 当前排名 */}
<div>
<div className="font-label text-[10px] tracking-widest uppercase text-white/40 mb-1">
</div>
<div className="flex items-end gap-2">
<span
className={cn(
"font-display text-3xl sm:text-4xl tabular-nums leading-none",
inTop12 ? "text-purple-300 glow-text-purple" : "text-white/70",
)}
>
#{artist.rank}
</span>
<span className="text-xs text-white/45 pb-1 tabular-nums">
{(artist.votes / 10000).toFixed(1)}w
</span>
</div>
{inTop12 && (
<span className="inline-block mt-2 font-label text-[10px] tracking-widest text-pink-400 uppercase">
</span>
)}
</div>
{/* 差距信息 */}
<div className="text-right">
{isFirst && leadOver != null ? (
<>
<div className="font-label text-[10px] tracking-widest uppercase text-white/40 mb-1">
</div>
<div className="font-display text-lg sm:text-xl text-pink-400 tabular-nums">
+{leadOver.toLocaleString()}
</div>
<div className="text-[10px] text-white/40"></div>
</>
) : trailBehind != null ? (
<>
<div className="font-label text-[10px] tracking-widest uppercase text-white/40 mb-1">
</div>
<div className="font-display text-lg sm:text-xl text-purple-300 tabular-nums">
{trailBehind.toLocaleString()}
</div>
<div className="text-[10px] text-white/40"></div>
</>
) : null}
</div>
</div>
);
}