- nav: center links (首页/排行榜/我的), right-side AuthMenu + RemainingVotesBadge; image logo with responsive sizing - auth: replace /login route with global LoginModal triggered anywhere; "我的" intercepts unauth users with post-login redirect - home: full-screen Hero, redesigned Top12 (12 pill cards, top-3 glow), scroll-snap mandatory between Hero/Top12/candidates - home: candidates section with sticky filter that gains frosted-glass bg when stuck (matches nav) - filter: simplified tags (全部/舞蹈/声乐/rap/全能型); ArtistCard uniform purple vote button - ranking/me: remove Top12Bar; me header stacks 编辑资料/退出登录 vertically - typography: font-logo set to Orbitron; ✦ glyph in CYBER ✦ STAR preserved - layout: max-w-[1500px] unified across pages - docs: add design-spec.md + design-spec.html with full visual spec (lucide SVG, zero emoji policy) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
264 lines
9.0 KiB
TypeScript
264 lines
9.0 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { ChevronLeft, Heart, Share2 } from "lucide-react";
|
||
import toast from "react-hot-toast";
|
||
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";
|
||
import { useVoteStore, selectArtist } from "@/lib/store";
|
||
import { useVoteAction } from "@/hooks/useVoteAction";
|
||
|
||
interface ArtistDetailContentProps {
|
||
artist: Artist;
|
||
allArtists: Artist[];
|
||
}
|
||
|
||
export default function ArtistDetailContent({
|
||
artist: initialArtist,
|
||
allArtists: initialAll,
|
||
}: ArtistDetailContentProps) {
|
||
// 用 store 数据覆盖(这样投票后票数能马上变)
|
||
const storeArtist = useVoteStore(selectArtist(initialArtist.id));
|
||
const storeAll = useVoteStore((s) => s.artists);
|
||
|
||
const artist = storeArtist ?? initialArtist;
|
||
const allArtists = storeAll.length ? storeAll : initialAll;
|
||
|
||
const { target, remaining, dailyQuota, openVote, closeVote, confirmVote } =
|
||
useVoteAction();
|
||
|
||
const handleShare = async () => {
|
||
const url =
|
||
typeof window !== "undefined"
|
||
? `${window.location.origin}/artist/${artist.id}`
|
||
: `/artist/${artist.id}`;
|
||
const shareData = {
|
||
title: `${artist.name} · CYBER STAR`,
|
||
text: `为 ${artist.name}(${artist.enName})打 Call!${artist.slogan}`,
|
||
url,
|
||
};
|
||
|
||
// 优先用 Web Share API(移动端 / Safari 支持)
|
||
if (typeof navigator !== "undefined" && navigator.share) {
|
||
try {
|
||
await navigator.share(shareData);
|
||
return;
|
||
} catch {
|
||
// 用户取消分享:不报错
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 兜底:复制到剪贴板
|
||
try {
|
||
await navigator.clipboard.writeText(url);
|
||
toast.success("链接已复制,去粘贴给朋友吧~");
|
||
} catch {
|
||
toast.error("复制失败,请手动复制地址栏");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<div className="max-w-[1500px] 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/85">艺人详情</span>
|
||
<button
|
||
type="button"
|
||
onClick={handleShare}
|
||
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>
|
||
|
||
<section className="max-w-[1500px] 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} />
|
||
|
||
<div className="flex gap-3 pt-1">
|
||
<Button
|
||
variant="primary"
|
||
size="lg"
|
||
pulse
|
||
className="flex-1"
|
||
leftIcon={<Heart size={16} fill="currentColor" />}
|
||
onClick={() => openVote(artist)}
|
||
>
|
||
投票
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="lg"
|
||
leftIcon={<Share2 size={14} />}
|
||
aria-label="分享"
|
||
onClick={handleShare}
|
||
>
|
||
<span className="hidden sm:inline">关注</span>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="max-w-[1500px] 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-[1500px] 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-[1500px] 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={() => openVote(artist)} />
|
||
|
||
<VoteModal
|
||
artist={target}
|
||
remaining={remaining}
|
||
dailyQuota={dailyQuota}
|
||
onClose={closeVote}
|
||
onConfirm={confirmVote}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
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 flex items-baseline gap-3">
|
||
<span aria-hidden className="w-1 h-4 rounded-full bg-purple-400 shadow-[0_0_8px_rgba(167,139,250,0.7)]" />
|
||
<h2 className="font-display text-base sm:text-lg text-white tracking-[0.2em]">
|
||
{title}
|
||
</h2>
|
||
<span className="font-label text-[10px] tracking-[0.3em] uppercase text-purple-300/70">
|
||
{subtitle}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|