feat(artist): dynamic /artist/[id] page with hero, 15s video, gallery lightbox, bio and floating vote
This commit is contained in:
parent
28447c2e65
commit
5f06b5122b
32
src/app/artist/[id]/page.tsx
Normal file
32
src/app/artist/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
46
src/components/FloatingVoteButton.tsx
Normal file
46
src/components/FloatingVoteButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
src/components/artist/ArtistDetailContent.tsx
Normal file
241
src/components/artist/ArtistDetailContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
198
src/components/artist/PerformanceGallery.tsx
Normal file
198
src/components/artist/PerformanceGallery.tsx
Normal 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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
146
src/components/artist/PerformanceVideo.tsx
Normal file
146
src/components/artist/PerformanceVideo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
src/components/artist/RankCard.tsx
Normal file
82
src/components/artist/RankCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user