feat(components): add Button, Countdown, ArtistCard, Top12Bar, VoteModal core components

This commit is contained in:
iye 2026-05-12 09:37:23 +08:00
parent c441ed7026
commit abce95aae8
12 changed files with 1133 additions and 64 deletions

View File

@ -9,9 +9,13 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"clsx": "^2.1.1",
"framer-motion": "^12.38.0",
"lucide-react": "^1.14.0",
"next": "16.2.6", "next": "16.2.6",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"tailwind-merge": "^3.6.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

67
pnpm-lock.yaml generated
View File

@ -8,6 +8,15 @@ importers:
.: .:
dependencies: dependencies:
clsx:
specifier: ^2.1.1
version: 2.1.1
framer-motion:
specifier: ^12.38.0
version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
lucide-react:
specifier: ^1.14.0
version: 1.14.0(react@19.2.4)
next: next:
specifier: 16.2.6 specifier: 16.2.6
version: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 16.2.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@ -17,6 +26,9 @@ importers:
react-dom: react-dom:
specifier: 19.2.4 specifier: 19.2.4
version: 19.2.4(react@19.2.4) version: 19.2.4(react@19.2.4)
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
devDependencies: devDependencies:
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4 specifier: ^4
@ -864,6 +876,10 @@ packages:
client-only@0.0.1: client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-convert@2.0.1: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@ -1167,6 +1183,20 @@ packages:
resolution: {integrity: sha512-lXeSPRCndWPaipZbtI4CkvTZpF6OPsy19dkvf7+5AHeJD+w+iAKPc9Q78xWBmX4SdR+8xrtY9jTXs/YDv8q+Ug==} resolution: {integrity: sha512-lXeSPRCndWPaipZbtI4CkvTZpF6OPsy19dkvf7+5AHeJD+w+iAKPc9Q78xWBmX4SdR+8xrtY9jTXs/YDv8q+Ug==}
engines: {node: '>=14'} engines: {node: '>=14'}
framer-motion@12.38.0:
resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
function-bind@1.1.2: function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@ -1553,6 +1583,11 @@ packages:
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@1.14.0:
resolution: {integrity: sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@ -1586,6 +1621,12 @@ packages:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
motion-dom@12.38.0:
resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==}
motion-utils@12.36.0:
resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==}
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@ -1932,6 +1973,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
tailwind-merge@3.6.0:
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
tailwindcss@4.3.0: tailwindcss@4.3.0:
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
@ -2829,6 +2873,8 @@ snapshots:
client-only@0.0.1: {} client-only@0.0.1: {}
clsx@2.1.1: {}
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@ -3276,6 +3322,15 @@ snapshots:
cross-spawn: 7.0.6 cross-spawn: 7.0.6
signal-exit: 4.1.0 signal-exit: 4.1.0
framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
motion-dom: 12.38.0
motion-utils: 12.36.0
tslib: 2.8.1
optionalDependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
function-bind@1.1.2: {} function-bind@1.1.2: {}
function.prototype.name@1.1.8: function.prototype.name@1.1.8:
@ -3640,6 +3695,10 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
lucide-react@1.14.0(react@19.2.4):
dependencies:
react: 19.2.4
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@ -3669,6 +3728,12 @@ snapshots:
minipass@7.1.3: {} minipass@7.1.3: {}
motion-dom@12.38.0:
dependencies:
motion-utils: 12.36.0
motion-utils@12.36.0: {}
ms@2.1.3: {} ms@2.1.3: {}
nanoid@3.3.12: {} nanoid@3.3.12: {}
@ -4089,6 +4154,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
tailwind-merge@3.6.0: {}
tailwindcss@4.3.0: {} tailwindcss@4.3.0: {}
tapable@2.3.3: {} tapable@2.3.3: {}

View File

@ -1,88 +1,136 @@
"use client";
import { useState } from "react";
import { Heart, Play, Share2 } from "lucide-react";
import Button from "@/components/ui/Button";
import Countdown from "@/components/ui/Countdown";
import ArtistCard from "@/components/cards/ArtistCard";
import Top12Bar from "@/components/Top12Bar";
import VoteModal from "@/components/VoteModal";
import { ARTISTS, getActivityEndTime } from "@/lib/mock-data";
import type { Artist } from "@/types/artist";
export default function Home() { export default function Home() {
const [voteTarget, setVoteTarget] = useState<Artist | null>(null);
const endTime = getActivityEndTime();
const handleVote = (artist: Artist, count: number) => {
// TODO: 接入实际投票 APIPhase 10
console.log(`Vote: ${artist.name} × ${count}`);
setVoteTarget(null);
};
return ( return (
<section className="px-6 py-20 sm:py-28"> <div className="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
<div className="max-w-5xl mx-auto flex flex-col items-center"> {/* Hero · Logo + slogan */}
{/* Logo */} <section className="py-12 sm:py-16 text-center">
<p className="font-label text-[10px] sm:text-xs tracking-[0.4em] uppercase text-purple-300 mb-3">
Top 12 · Virtual Idol Debut Project
</p>
<h1 <h1
className="font-logo text-6xl sm:text-8xl tracking-[0.4em] uppercase glow-text-purple text-center mb-4" 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.4em" }} style={{ paddingLeft: "0.35em" }}
> >
Cyber Cyber
<span className="text-purple-300 mx-2 sm:mx-3 text-4xl sm:text-6xl align-middle"> <span className="text-purple-300 mx-2 sm:mx-3 text-3xl sm:text-5xl lg:text-6xl">
</span> </span>
Star Star
</h1> </h1>
<p className="mt-3 text-white/55 text-sm sm:text-base tracking-wide">
{/* Subtitle */}
<p className="font-label text-xs sm:text-sm tracking-[0.4em] uppercase text-purple-300 mb-2">
Virtual Idol Debut Project
</p>
<p className="text-white/60 text-sm mb-10">
Top12
</p> </p>
{/* Status badge */} {/* Countdown */}
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-purple-500/10 border border-purple-500/30 mb-12"> <div className="mt-8 flex justify-center">
<span className="w-2 h-2 rounded-full bg-purple-400 animate-pulse-glow"></span> <Countdown endTime={endTime} />
<span className="font-label text-xs tracking-widest text-purple-300 uppercase">
Phase 3 · Layout Ready
</span>
</div> </div>
{/* Theme verification swatches */} {/* CTA */}
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3 max-w-2xl"> <div className="mt-8 flex justify-center gap-4 flex-wrap">
{[ <Button variant="outline" leftIcon={<Play size={16} />}>
{ name: "purple-300", className: "bg-purple-300" }, Play Debut PV
{ name: "purple-500", className: "bg-purple-500" }, </Button>
{ name: "purple-700", className: "bg-purple-700" }, <Button variant="primary" pulse leftIcon={<Heart size={14} />}>
{ name: "magenta", className: "bg-magenta" }, Vote Now
{ name: "pink-400", className: "bg-pink-400" }, </Button>
{ name: "cyan-400", className: "bg-cyan-400" }, </div>
].map((c) => ( </section>
<div key={c.name} className="text-center">
<div {/* Top12 实时榜条 */}
className={`${c.className} h-16 rounded-lg border border-white/10`} <section className="pb-12">
/> <div className="flex items-end justify-between mb-3">
<p className="text-[10px] text-white/40 mt-1 font-mono">{c.name}</p> <h2 className="font-display text-lg tracking-[0.2em] text-white uppercase">
</div> 🏆 Top 12 ·
</h2>
<a
href="/ranking"
className="font-label text-[11px] tracking-widest text-purple-300 hover:text-purple-200 uppercase"
>
</a>
</div>
<Top12Bar artists={ARTISTS} />
</section>
{/* 艺人卡片网格示例 */}
<section className="pb-16">
<h2 className="font-display text-lg tracking-[0.2em] text-white uppercase mb-4">
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 sm:gap-4">
{ARTISTS.slice(0, 10).map((a) => (
<ArtistCard key={a.id} artist={a} onVote={setVoteTarget} />
))} ))}
</div> </div>
</section>
{/* Font verification */} {/* Component sandbox */}
<div className="mt-12 grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-2xl w-full"> <section className="pb-16 border-t border-white/[0.06] pt-12">
<div className="bg-surface border border-white/10 rounded-xl p-5"> <h2 className="font-display text-sm tracking-[0.2em] text-purple-300/70 uppercase mb-4">
<p className="font-label text-[10px] tracking-widest text-purple-300 uppercase mb-2"> Phase 4 · Component Sandbox
Logo Font · Megrim </h2>
<div className="grid gap-6 sm:grid-cols-2">
<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">
Button Variants
</p> </p>
<p className="font-logo text-3xl tracking-widest">Cyber Star</p> <div className="flex flex-wrap gap-3">
<Button variant="primary" size="sm">
Primary
</Button>
<Button variant="outline" size="sm">
Outline
</Button>
<Button variant="ghost" size="sm">
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/10 rounded-xl p-5"> <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-2"> <p className="font-label text-[10px] tracking-widest text-purple-300 uppercase mb-3">
Display Font · Audiowide Countdown · Compact
</p> </p>
<p className="font-display text-3xl tracking-wider">VOTE 2026</p> <Countdown endTime={endTime} compact />
</div> <p className="mt-3 text-[11px] text-white/40">
<div className="bg-surface border border-white/10 rounded-xl p-5"> / Hero
<p className="font-label text-[10px] tracking-widest text-purple-300 uppercase mb-2">
Label Font · Cinzel
</p> </p>
<p className="font-label text-xl tracking-widest font-semibold">
DEBUT PROJECT
</p>
</div>
<div className="bg-surface border border-white/10 rounded-xl p-5">
<p className="font-label text-[10px] tracking-widest text-purple-300 uppercase mb-2">
Body Font · Inter
</p>
<p className="font-body text-lg"> · Aria</p>
</div> </div>
</div> </div>
</section>
<p className="mt-16 text-white/40 text-xs font-mono"> {/* 投票弹窗 */}
Phase 4 Core Components <VoteModal
</p> artist={voteTarget}
</div> onClose={() => setVoteTarget(null)}
</section> onConfirm={handleVote}
/>
</div>
); );
} }

135
src/components/Top12Bar.tsx Normal file
View File

@ -0,0 +1,135 @@
"use client";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import type { Artist } from "@/types/artist";
import { getRankCategory } from "@/types/artist";
import { cn } from "@/lib/cn";
import ArtistPortrait from "./cards/ArtistPortrait";
interface Top12BarProps {
artists: Artist[];
/** 点击 VOTE NOW 触发的回调(不传则跳转 /vote */
onVoteNow?: () => void;
}
const RANK_BORDER = {
gold: "border-[#fcd34d] shadow-[0_0_16px_rgba(252,211,77,0.5)]",
silver: "border-[#c4ccd8] shadow-[0_0_12px_rgba(196,204,216,0.4)]",
bronze: "border-[#cd7f32] shadow-[0_0_12px_rgba(205,127,50,0.4)]",
top12: "border-purple-500 shadow-[0_0_10px_rgba(139,92,246,0.3)]",
candidate: "border-white/15",
} as const;
const BADGE_BG = {
gold: "bg-[#fcd34d] text-black",
silver: "bg-[#c4ccd8] text-black",
bronze: "bg-[#cd7f32] text-white",
top12: "bg-purple-600 text-white",
candidate: "bg-white/10 text-white/70",
} as const;
export default function Top12Bar({ artists, onVoteNow }: Top12BarProps) {
return (
<div className="bg-[rgba(13,10,36,0.95)] border border-white/[0.06] rounded-xl p-4 grid gap-3 grid-cols-1 lg:grid-cols-[1fr_180px]">
{/* 头像横滚 */}
<div className="flex gap-2.5 sm:gap-3 overflow-x-auto pb-1 -mx-1 px-1 scroll-px-1 snap-x snap-mandatory">
{artists.slice(0, 12).map((artist) => {
const cat = getRankCategory(artist.rank);
return (
<Link
key={artist.id}
href={`/artist/${artist.id}`}
className="flex-shrink-0 w-[68px] sm:w-[76px] snap-start group"
>
<div
className={cn(
"relative w-full aspect-[4/5] rounded-lg overflow-hidden border-[1.5px] transition-transform group-hover:-translate-y-0.5",
RANK_BORDER[cat],
)}
>
<ArtistPortrait
artist={artist}
rounded="rounded-none"
className="absolute inset-0"
/>
{/* 编号 */}
<div className="absolute bottom-1 left-1 font-display text-[10px] text-white bg-black/65 px-1.5 py-px rounded tracking-wider tabular-nums">
{artist.no.slice(-2)}
</div>
{/* 排名小角标 */}
<div
className={cn(
"absolute top-1 right-1 w-4 h-4 rounded-full font-display text-[9px] flex items-center justify-center",
BADGE_BG[cat],
)}
>
{artist.rank}
</div>
</div>
<p className="mt-1.5 font-label text-[10px] tracking-widest text-white/65 truncate uppercase text-center">
{artist.enName}
</p>
</Link>
);
})}
</div>
{/* VOTE NOW 侧栏面板 */}
<VotePanel onClick={onVoteNow} />
</div>
);
}
function VotePanel({ onClick }: { onClick?: () => void }) {
const content = (
<div className="relative h-full bg-grad-purple rounded-xl p-4 sm:p-5 flex flex-col justify-between shadow-purple-glow overflow-hidden cursor-pointer transition-all hover:brightness-110 hover:shadow-[0_0_36px_rgba(139,92,246,0.7)] animate-pulse-glow min-h-[140px]">
{/* 高光装饰 */}
<div
aria-hidden
className="absolute inset-0 pointer-events-none"
style={{
background:
"radial-gradient(circle at 80% 20%, rgba(255,255,255,0.2) 0%, transparent 50%)",
}}
/>
{/* 装饰星点 */}
<span className="absolute top-3 left-3 text-white/30 text-xs"></span>
<div className="relative">
<div className="font-display text-2xl tracking-[0.2em] text-white leading-none">
VOTE
<br />
NOW
</div>
<div className="mt-2 text-[11px] text-white/85 tracking-wider">
</div>
</div>
<div className="relative flex justify-end">
<span className="w-9 h-9 rounded-full bg-white/20 border border-white/35 backdrop-blur-md flex items-center justify-center text-white">
<ArrowRight size={16} />
</span>
</div>
</div>
);
if (onClick) {
return (
<button
type="button"
onClick={onClick}
aria-label="Vote now"
className="text-left p-0 border-0 bg-transparent"
>
{content}
</button>
);
}
return (
<Link href="/vote" aria-label="Vote now">
{content}
</Link>
);
}

View File

@ -0,0 +1,216 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { X, Heart, Gem } from "lucide-react";
import type { Artist } from "@/types/artist";
import { cn } from "@/lib/cn";
import Button from "./ui/Button";
import ArtistPortrait from "./cards/ArtistPortrait";
interface VoteModalProps {
/** 当前要投票的艺人,传 null 关闭弹窗 */
artist: Artist | null;
/** 今日剩余票数 */
remainingVotes?: number;
/** 今日已用票数 */
usedVotes?: number;
/** 单艺人每日上限 */
perArtistLimit?: number;
/** 关闭弹窗 */
onClose: () => void;
/** 确认投票 */
onConfirm: (artist: Artist, count: number) => void | Promise<void>;
}
const VOTE_OPTIONS: Array<number | "ALL"> = [1, 3, 5, "ALL"];
export default function VoteModal({
artist,
remainingVotes = 12,
usedVotes = 0,
perArtistLimit = 3,
onClose,
onConfirm,
}: VoteModalProps) {
const open = artist != null;
const [selected, setSelected] = useState<number | "ALL">(3);
const [loading, setLoading] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
// 打开时重置默认选择
useEffect(() => {
if (open) {
setSelected(3);
setLoading(false);
}
}, [open]);
// ESC 关闭 + body 锁滚
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
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;
};
}, [open, onClose]);
const maxVotes = Math.min(remainingVotes, perArtistLimit);
const actualCount = selected === "ALL" ? maxVotes : Math.min(selected, maxVotes);
const canVote = actualCount > 0 && !loading;
const handleConfirm = useCallback(async () => {
if (!artist || !canVote) return;
setLoading(true);
try {
await onConfirm(artist, actualCount);
} finally {
setLoading(false);
}
}, [artist, actualCount, canVote, onConfirm]);
if (!mounted) return null;
return createPortal(
<AnimatePresence>
{open && artist && (
<motion.div
key="vote-modal-root"
className="fixed inset-0 z-[100] flex items-center justify-center px-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
{/* 遮罩 */}
<button
type="button"
aria-label="关闭弹窗"
onClick={onClose}
className="absolute inset-0 bg-black/75 backdrop-blur-md cursor-default"
/>
{/* 弹窗主体 */}
<motion.div
role="dialog"
aria-modal="true"
aria-labelledby="vote-modal-title"
className="relative w-full max-w-sm bg-elevated border border-white/14 rounded-2xl p-7 shadow-[0_24px_80px_rgba(0,0,0,0.7),0_0_40px_rgba(139,92,246,0.12)]"
initial={{ opacity: 0, scale: 0.94, y: 16 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
>
{/* 顶部紫色光条 */}
<div className="absolute top-0 left-0 right-0 h-0.5 bg-grad-purple rounded-t-2xl" />
{/* 关闭按钮 */}
<button
type="button"
onClick={onClose}
className="absolute top-3.5 right-4 w-7 h-7 flex items-center justify-center text-white/55 hover:text-white transition-colors"
aria-label="关闭"
>
<X size={18} />
</button>
{/* 头像 */}
<div className="w-20 h-20 mx-auto mb-3.5 rounded-full overflow-hidden border-2 border-purple-500 shadow-[0_0_16px_rgba(139,92,246,0.4)]">
<ArtistPortrait
artist={artist}
rounded="rounded-full"
className="w-full h-full"
/>
</div>
{/* 标题 */}
<div className="text-center mb-5">
<div
id="vote-modal-title"
className="text-lg font-bold text-white mb-1"
>
{artist.name}
</div>
<div className="font-label text-[11px] tracking-widest text-white/45 uppercase">
No.{artist.no} · Current Rank #{artist.rank}
</div>
</div>
{/* 票数选择 */}
<div className="text-xs text-white/55 mb-2.5"></div>
<div className="flex gap-2.5 justify-center mb-4">
{VOTE_OPTIONS.map((opt) => {
const isAll = opt === "ALL";
const optNum = isAll ? maxVotes : (opt as number);
const disabled = optNum > maxVotes || maxVotes === 0;
const active = selected === opt;
return (
<button
type="button"
key={String(opt)}
disabled={disabled}
onClick={() => setSelected(opt)}
className={cn(
"rounded-lg font-display flex items-center justify-center transition-all",
isAll ? "w-16 text-[11px]" : "w-13 text-base",
"h-13 py-3.5 px-3",
disabled && "opacity-30 cursor-not-allowed",
!active &&
!disabled &&
"bg-surface border border-white/14 text-white/65 hover:border-white/30",
active &&
"bg-purple-500/12 border border-purple-500 text-purple-300 shadow-[0_0_16px_rgba(139,92,246,0.35)]",
)}
>
{opt}
</button>
);
})}
</div>
{/* 票数余额 */}
<div className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg bg-purple-500/8 border border-purple-500/25 text-xs text-purple-300 mb-4">
<Gem size={14} />
<span>
<b className="text-white">{remainingVotes} </b>
</span>
<span className="text-white/30 mx-1">|</span>
<span>{usedVotes} </span>
</div>
{/* 确认按钮 */}
<Button
variant="primary"
className="w-full h-12 text-sm"
onClick={handleConfirm}
loading={loading}
disabled={!canVote}
leftIcon={<Heart size={14} />}
>
{canVote
? `确认投出 ${actualCount}`
: maxVotes === 0
? "今日已无可用票数"
: "请选择有效票数"}
</Button>
{/* 提示 */}
<p className="text-[11px] text-white/40 mt-3 text-center">
12 · {perArtistLimit}
</p>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body,
);
}

View File

@ -0,0 +1,106 @@
import Link from "next/link";
import type { Artist } from "@/types/artist";
import { cn } from "@/lib/cn";
import ArtistPortrait from "./ArtistPortrait";
interface ArtistCardProps {
artist: Artist;
/** 触发投票(不传则跳详情页) */
onVote?: (artist: Artist) => void;
className?: string;
}
function formatVotes(v: number): string {
if (v >= 10_000) return `${(v / 10_000).toFixed(1)}w`;
return v.toLocaleString();
}
export default function ArtistCard({
artist,
onVote,
className,
}: ArtistCardProps) {
const inTop12 = artist.rank <= 12;
return (
<div
className={cn(
"group relative rounded-xl overflow-hidden bg-grad-card border transition-all shadow-card",
inTop12
? "border-purple-500 shadow-[0_8px_32px_rgba(0,0,0,0.65),0_0_18px_rgba(139,92,246,0.35)]"
: "border-white/[0.06]",
"hover:-translate-y-1 hover:shadow-[0_12px_36px_rgba(0,0,0,0.7),0_0_24px_rgba(139,92,246,0.25)]",
className,
)}
>
<Link
href={`/artist/${artist.id}`}
className="block"
aria-label={`查看 ${artist.name} 详情`}
>
{/* 立绘区 */}
<div className="relative aspect-[4/5]">
<ArtistPortrait artist={artist} rounded="rounded-none" className="absolute inset-0" />
{/* 编号徽章(左上) */}
<div className="absolute top-2 left-2 font-display text-[10px] tracking-widest text-white bg-black/65 backdrop-blur-md px-2 py-0.5 rounded-full">
No.{artist.no}
</div>
{/* 排名徽章(右上) */}
<div
className={cn(
"absolute top-2 right-2 w-7 h-7 rounded-full font-display text-xs flex items-center justify-center text-white",
inTop12
? "bg-purple-600 shadow-[0_0_12px_rgba(139,92,246,0.7)]"
: "bg-elevated border border-white/15 text-white/55",
)}
>
{artist.rank}
</div>
{/* 顶部渐变蒙层(让编号更清晰) */}
<div className="absolute inset-x-0 top-0 h-12 bg-gradient-to-b from-black/45 to-transparent pointer-events-none" />
</div>
{/* 信息区 */}
<div className="p-3">
<div className="font-semibold text-sm text-white truncate">
{artist.name}{" "}
<span className="text-white/60 font-normal">· {artist.enName}</span>
</div>
<div className="text-[10px] text-white/45 truncate mt-0.5">
{artist.slogan}
</div>
<div
className={cn(
"mt-2 font-display text-xs tracking-wider tabular-nums",
inTop12 ? "text-purple-300" : "text-white/45",
)}
>
{formatVotes(artist.votes)}
</div>
</div>
</Link>
{/* 投票按钮 */}
<div className="px-3 pb-3">
<button
type="button"
onClick={(e) => {
e.preventDefault();
onVote?.(artist);
}}
className={cn(
"w-full h-8 rounded font-display text-[10px] tracking-[2px] uppercase transition-all",
inTop12
? "bg-grad-purple text-white hover:brightness-110 shadow-[0_0_12px_rgba(139,92,246,0.35)]"
: "bg-elevated border border-white/15 text-white/70 hover:bg-white/10 hover:text-white",
)}
>
Vote
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,76 @@
import Image from "next/image";
import type { Artist } from "@/types/artist";
import { cn } from "@/lib/cn";
interface ArtistPortraitProps {
artist: Artist;
className?: string;
/** 圆角覆盖 */
rounded?: string;
}
/**
*
* Image themeColor
*/
export default function ArtistPortrait({
artist,
className,
rounded = "rounded-lg",
}: ArtistPortraitProps) {
const initial =
artist.enName?.charAt(0).toUpperCase() ||
artist.name?.charAt(0) ||
"?";
if (artist.portrait) {
return (
<div
className={cn(
"relative overflow-hidden bg-deep",
rounded,
className,
)}
>
<Image
src={artist.portrait}
alt={`${artist.name} · ${artist.enName}`}
fill
sizes="(max-width: 768px) 50vw, 240px"
className="object-cover"
/>
</div>
);
}
return (
<div
className={cn(
"relative overflow-hidden flex items-center justify-center",
rounded,
className,
)}
style={{
background: `linear-gradient(155deg, ${artist.themeColor}33 0%, #1a1638 60%, #0d0a24 100%)`,
}}
>
{/* 装饰光晕 */}
<div
className="absolute inset-0"
style={{
background: `radial-gradient(circle at 50% 30%, ${artist.themeColor}55 0%, transparent 55%)`,
}}
/>
{/* 装饰星标 */}
<span className="absolute top-2 right-2 text-white/15 text-sm"></span>
<span className="absolute bottom-3 left-3 text-white/10 text-xs"></span>
{/* 首字母 */}
<span
className="font-logo text-5xl text-white/85 glow-text-purple tracking-wider relative z-10"
aria-hidden
>
{initial}
</span>
</div>
);
}

View File

@ -0,0 +1,90 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react";
import { cn } from "@/lib/cn";
type Variant = "primary" | "outline" | "ghost" | "danger";
type Size = "sm" | "md" | "lg" | "icon";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
pulse?: boolean;
leftIcon?: ReactNode;
rightIcon?: ReactNode;
loading?: boolean;
}
const BASE =
"relative inline-flex items-center justify-center gap-2 rounded font-display uppercase transition-all overflow-hidden cursor-pointer disabled:opacity-35 disabled:cursor-not-allowed disabled:pointer-events-none";
const VARIANTS: Record<Variant, string> = {
primary:
"bg-grad-purple text-white shadow-purple-glow hover:brightness-110 hover:-translate-y-0.5 active:brightness-95 active:translate-y-0",
outline:
"bg-transparent text-purple-300 border border-[var(--border-purple)] shadow-[0_0_12px_rgba(139,92,246,0.12)] hover:bg-purple-500/10 hover:shadow-[0_0_20px_rgba(139,92,246,0.3)] hover:text-purple-200",
ghost:
"bg-white/5 text-white/70 border border-white/14 hover:bg-white/10 hover:text-white",
danger:
"bg-pink-500 text-white shadow-[0_0_20px_rgba(236,72,153,0.4)] hover:brightness-110",
};
const SIZES: Record<Size, string> = {
sm: "h-8 px-3.5 text-[10px] tracking-[1.5px]",
md: "h-11 px-6 text-xs tracking-[2.5px]",
lg: "h-14 px-10 text-sm tracking-[2.5px]",
icon: "h-11 w-11 p-0 text-base tracking-normal",
};
const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
className,
variant = "primary",
size = "md",
pulse = false,
leftIcon,
rightIcon,
loading = false,
disabled,
children,
...props
},
ref,
) {
return (
<button
ref={ref}
disabled={disabled || loading}
className={cn(
BASE,
VARIANTS[variant],
SIZES[size],
pulse && "animate-pulse-glow",
className,
)}
{...props}
>
{/* 微光扫过效果 */}
<span
aria-hidden
className="absolute inset-0 -translate-x-full bg-[linear-gradient(105deg,transparent_40%,rgba(255,255,255,0.06)_50%,transparent_60%)] pointer-events-none group-hover:translate-x-full transition-transform duration-700"
/>
{loading ? (
<svg
className="animate-spin h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<circle cx="12" cy="12" r="10" opacity="0.25" />
<path d="M22 12a10 10 0 0 1-10 10" />
</svg>
) : (
leftIcon
)}
{children && <span className="relative z-10">{children}</span>}
{!loading && rightIcon}
</button>
);
});
export default Button;

View File

@ -0,0 +1,121 @@
"use client";
import { useEffect, useState } from "react";
import { cn } from "@/lib/cn";
interface CountdownProps {
/** 截止时间 */
endTime: Date | string | number;
/** 紧凑模式(横向小尺寸) */
compact?: boolean;
className?: string;
}
interface TimeLeft {
days: number;
hours: number;
minutes: number;
seconds: number;
finished: boolean;
}
function computeTimeLeft(end: number): TimeLeft {
const diff = Math.max(0, end - Date.now());
const days = Math.floor(diff / 86_400_000);
const hours = Math.floor((diff % 86_400_000) / 3_600_000);
const minutes = Math.floor((diff % 3_600_000) / 60_000);
const seconds = Math.floor((diff % 60_000) / 1_000);
return { days, hours, minutes, seconds, finished: diff <= 0 };
}
const UNIT_LABELS = [
["days", "Days"],
["hours", "Hours"],
["minutes", "Min"],
["seconds", "Sec"],
] as const;
export default function Countdown({
endTime,
compact = false,
className,
}: CountdownProps) {
const end = new Date(endTime).getTime();
const [time, setTime] = useState<TimeLeft | null>(null);
// 客户端首次渲染后再启动 tick避免 SSR 与客户端时间不一致
useEffect(() => {
setTime(computeTimeLeft(end));
const id = setInterval(() => setTime(computeTimeLeft(end)), 1000);
return () => clearInterval(id);
}, [end]);
if (!time) {
// 占位高度避免布局抖动
return (
<div
className={cn(
"flex items-center gap-3",
compact ? "h-9" : "h-20",
className,
)}
aria-hidden
/>
);
}
if (time.finished) {
return (
<div
className={cn(
"font-display tracking-widest text-pink-400 uppercase",
className,
)}
>
Activity Ended
</div>
);
}
const values = [time.days, time.hours, time.minutes, time.seconds];
if (compact) {
return (
<div
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-purple-500/15 border border-purple-500/30 backdrop-blur-md",
className,
)}
>
<span className="text-purple-200 text-[10px] font-label tracking-widest uppercase">
</span>
<span className="font-display text-xs text-purple-200 tracking-wider tabular-nums">
{time.days}d {String(time.hours).padStart(2, "0")}:
{String(time.minutes).padStart(2, "0")}:
{String(time.seconds).padStart(2, "0")}
</span>
</div>
);
}
return (
<div className={cn("flex items-center gap-3", className)}>
{values.map((v, i) => (
<div key={UNIT_LABELS[i]![0]} className="flex items-center gap-3">
<div className="bg-surface border border-[var(--border-purple)] rounded-lg px-4 sm:px-5 py-3 text-center shadow-[0_0_16px_rgba(139,92,246,0.15)] min-w-[64px]">
<div className="font-display text-2xl sm:text-3xl text-purple-300 glow-text-purple tabular-nums leading-none tracking-wider">
{String(v).padStart(2, "0")}
</div>
<div className="font-label text-[10px] tracking-[0.2em] uppercase text-white/40 mt-1.5">
{UNIT_LABELS[i]![1]}
</div>
</div>
{i < 3 && (
<span className="text-2xl text-white/30 font-light pb-3.5">:</span>
)}
</div>
))}
</div>
);
}

6
src/lib/cn.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

135
src/lib/mock-data.ts Normal file
View File

@ -0,0 +1,135 @@
import type { Artist, ArtistTag } from "@/types/artist";
const STAGE_NAMES: Array<[string, string, string]> = [
["艺奈", "AURORA", "破晓极光"],
["路米", "LUMI", "暖光治愈"],
["星澪", "NEBULA", "星云吟唱"],
["凯", "KAI", "海岸少年"],
["回音", "ECHO", "声波女王"],
["薇尔", "VEIL", "薄雾低语"],
["艾莉雅", "ARIA", "咏叹之声"],
["怜", "REN", "莲华少女"],
["米拉", "MIRA", "镜面舞者"],
["诺娃", "NOVA", "超新星"],
["纪罗", "KIRO", "Rap 制造机"],
["瑞", "ZUI", "醉月夜"],
["阳", "SOL", "阳光少年"],
["凛", "LIN", "学院偶像"],
["律", "LYRA", "竖琴公主"],
["昕", "DAWN", "晨曦少女"],
["天", "SKY", "天空之翼"],
["语", "ARIE", "诗与远方"],
["翼", "WING", "飞翔之翼"],
["铃", "CHIME", "风铃声"],
["夜", "NYX", "暗夜女神"],
["晴", "SUNNY", "晴空万里"],
["月", "LUNA", "月光女神"],
["岚", "STORM", "暴风之子"],
["雷", "BOLT", "雷霆速度"],
["焰", "FLARE", "火焰之心"],
["雪", "FROST", "霜花少女"],
["林", "LEAF", "森林精灵"],
["渊", "ABYSS", "深渊之声"],
["瑶", "JADE", "翡翠少女"],
["晨", "AURIA", "金色晨光"],
["岩", "ROCK", "硬核摇滚"],
["翔", "SOAR", "翱翔天际"],
["茉", "MOLLY", "茉莉芬芳"],
["梓", "AZUR", "蓝调诗人"],
];
const TAG_POOL: ArtistTag[][] = [
["vocal", "visual"],
["dance", "all-rounder"],
["rap", "leader"],
["all-rounder"],
["vocal", "leader"],
["dance", "visual"],
["vocal"],
["rap", "all-rounder"],
["dance"],
["visual", "all-rounder"],
["rap"],
["vocal", "dance"],
];
const THEME_COLORS = [
"#8b5cf6",
"#ec4899",
"#06b6d4",
"#f59e0b",
"#10b981",
"#ef4444",
"#a78bfa",
"#f472b6",
"#38bdf8",
"#fbbf24",
"#34d399",
"#fb7185",
];
/** 生成确定性 35 位艺人 mock 数据 */
function buildArtists(): Artist[] {
return STAGE_NAMES.map(([name, enName, slogan], idx) => {
const no = String(idx + 1).padStart(3, "0");
const rank = idx + 1;
// 票数采用反比例衰减:第一名最多,往后递减
const votes = Math.round(125000 / Math.sqrt(rank) + Math.random() * 3000);
return {
id: no,
no,
name,
enName,
slogan,
bio: `来自虚拟星域的偶像候选人 ${enName},从小热爱音乐与舞蹈。性格${
rank % 2 === 0 ? "温柔" : "活泼"
}${
idx % 3 === 0 ? "抒情曲" : idx % 3 === 1 ? "舞台表演" : "Rap 创作"
} Top12 `,
portrait: "",
avatar: "",
gallery: ["", "", "", "", ""],
videoUrl: undefined,
videoPoster: "",
tags: TAG_POOL[idx % TAG_POOL.length]!,
birthday: `${String(((idx * 7) % 12) + 1).padStart(2, "0")}-${String(
((idx * 13) % 28) + 1
).padStart(2, "0")}`,
height: 158 + (idx % 12),
cv: idx < 12 ? `CV 配音 #${idx + 1}` : undefined,
themeColor: THEME_COLORS[idx % THEME_COLORS.length]!,
votes,
rank,
};
});
}
export const ARTISTS: Artist[] = buildArtists();
export const TOP_12 = ARTISTS.slice(0, 12);
export const CANDIDATES = ARTISTS.slice(12);
/** 按当前排序方式获取艺人列表 */
export type SortKey = "votes" | "no" | "recent";
export function sortArtists(list: Artist[], key: SortKey = "votes"): Artist[] {
const sorted = [...list];
if (key === "votes") sorted.sort((a, b) => b.votes - a.votes);
else if (key === "no") sorted.sort((a, b) => a.no.localeCompare(b.no));
return sorted;
}
/** 按 ID 获取艺人 */
export function getArtist(id: string): Artist | undefined {
return ARTISTS.find((a) => a.id === id);
}
/** 活动结束时间mock当前日期 + 12 天) */
export function getActivityEndTime(): Date {
const end = new Date();
end.setDate(end.getDate() + 12);
end.setHours(end.getHours() + 3);
end.setMinutes(end.getMinutes() + 24);
end.setSeconds(end.getSeconds() + 18);
return end;
}

65
src/types/artist.ts Normal file
View File

@ -0,0 +1,65 @@
export type ArtistTag =
| "vocal"
| "dance"
| "rap"
| "all-rounder"
| "visual"
| "leader";
export interface Artist {
/** 唯一 ID如 001 ~ 035 */
id: string;
/** 编号字符串(如 "001" */
no: string;
/** 中文名 */
name: string;
/** 英文名 / 艺名 */
enName: string;
/** Slogan 短宣传语 */
slogan: string;
/** 长简介 (200-500 字) */
bio: string;
/** 立绘主图 URL */
portrait: string;
/** 圆形头像 URL */
avatar: string;
/** 表演视频 URL (15s) */
videoUrl?: string;
/** 视频封面图 */
videoPoster?: string;
/** 表演图片轮播 */
gallery: string[];
/** 标签 */
tags: ArtistTag[];
/** 生日 MM-DD */
birthday: string;
/** 身高 cm */
height: number;
/** CV / 声优 */
cv?: string;
/** 应援色 hex */
themeColor: string;
/** 当前票数 */
votes: number;
/** 当前排名 (1-35) */
rank: number;
}
export const TAG_LABEL: Record<ArtistTag, string> = {
vocal: "声乐担当",
dance: "舞蹈担当",
rap: "Rap 担当",
"all-rounder": "全能型",
visual: "颜值担当",
leader: "队长担当",
};
export type RankCategory = "gold" | "silver" | "bronze" | "top12" | "candidate";
export function getRankCategory(rank: number): RankCategory {
if (rank === 1) return "gold";
if (rank === 2) return "silver";
if (rank === 3) return "bronze";
if (rank <= 12) return "top12";
return "candidate";
}