UI-UX/src/components/artist/ArtistDetailContent.tsx
iye d5ed43acbd feat(ui): design overhaul, global login modal, design spec
- 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>
2026-05-12 18:59:30 +08:00

264 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}