feat(components): add Button, Countdown, ArtistCard, Top12Bar, VoteModal core components
This commit is contained in:
parent
c441ed7026
commit
abce95aae8
@ -9,9 +9,13 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.38.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react-dom": "19.2.4",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
67
pnpm-lock.yaml
generated
67
pnpm-lock.yaml
generated
@ -8,6 +8,15 @@ importers:
|
||||
|
||||
.:
|
||||
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:
|
||||
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)
|
||||
@ -17,6 +26,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: 19.2.4
|
||||
version: 19.2.4(react@19.2.4)
|
||||
tailwind-merge:
|
||||
specifier: ^3.6.0
|
||||
version: 3.6.0
|
||||
devDependencies:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4
|
||||
@ -864,6 +876,10 @@ packages:
|
||||
client-only@0.0.1:
|
||||
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@ -1167,6 +1183,20 @@ packages:
|
||||
resolution: {integrity: sha512-lXeSPRCndWPaipZbtI4CkvTZpF6OPsy19dkvf7+5AHeJD+w+iAKPc9Q78xWBmX4SdR+8xrtY9jTXs/YDv8q+Ug==}
|
||||
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:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
@ -1553,6 +1583,11 @@ packages:
|
||||
lru-cache@5.1.1:
|
||||
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:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
@ -1586,6 +1621,12 @@ packages:
|
||||
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
||||
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:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@ -1932,6 +1973,9 @@ packages:
|
||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
tailwind-merge@3.6.0:
|
||||
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
|
||||
|
||||
tailwindcss@4.3.0:
|
||||
resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
|
||||
|
||||
@ -2829,6 +2873,8 @@ snapshots:
|
||||
|
||||
client-only@0.0.1: {}
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@ -3276,6 +3322,15 @@ snapshots:
|
||||
cross-spawn: 7.0.6
|
||||
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.prototype.name@1.1.8:
|
||||
@ -3640,6 +3695,10 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
lucide-react@1.14.0(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
@ -3669,6 +3728,12 @@ snapshots:
|
||||
|
||||
minipass@7.1.3: {}
|
||||
|
||||
motion-dom@12.38.0:
|
||||
dependencies:
|
||||
motion-utils: 12.36.0
|
||||
|
||||
motion-utils@12.36.0: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
nanoid@3.3.12: {}
|
||||
@ -4089,6 +4154,8 @@ snapshots:
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
tailwind-merge@3.6.0: {}
|
||||
|
||||
tailwindcss@4.3.0: {}
|
||||
|
||||
tapable@2.3.3: {}
|
||||
|
||||
174
src/app/page.tsx
174
src/app/page.tsx
@ -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() {
|
||||
const [voteTarget, setVoteTarget] = useState<Artist | null>(null);
|
||||
const endTime = getActivityEndTime();
|
||||
|
||||
const handleVote = (artist: Artist, count: number) => {
|
||||
// TODO: 接入实际投票 API(Phase 10)
|
||||
console.log(`Vote: ${artist.name} × ${count}`);
|
||||
setVoteTarget(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="px-6 py-20 sm:py-28">
|
||||
<div className="max-w-5xl mx-auto flex flex-col items-center">
|
||||
{/* Logo */}
|
||||
<div className="px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto">
|
||||
{/* Hero · Logo + slogan */}
|
||||
<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
|
||||
className="font-logo text-6xl sm:text-8xl tracking-[0.4em] uppercase glow-text-purple text-center mb-4"
|
||||
style={{ paddingLeft: "0.4em" }}
|
||||
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.35em" }}
|
||||
>
|
||||
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>
|
||||
Star
|
||||
</h1>
|
||||
|
||||
{/* 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 className="mt-3 text-white/55 text-sm sm:text-base tracking-wide">
|
||||
虚拟偶像出道企划
|
||||
</p>
|
||||
|
||||
{/* Status badge */}
|
||||
<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">
|
||||
<span className="w-2 h-2 rounded-full bg-purple-400 animate-pulse-glow"></span>
|
||||
<span className="font-label text-xs tracking-widest text-purple-300 uppercase">
|
||||
Phase 3 · Layout Ready
|
||||
</span>
|
||||
{/* Countdown */}
|
||||
<div className="mt-8 flex justify-center">
|
||||
<Countdown endTime={endTime} />
|
||||
</div>
|
||||
|
||||
{/* Theme verification swatches */}
|
||||
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3 max-w-2xl">
|
||||
{[
|
||||
{ name: "purple-300", className: "bg-purple-300" },
|
||||
{ name: "purple-500", className: "bg-purple-500" },
|
||||
{ name: "purple-700", className: "bg-purple-700" },
|
||||
{ name: "magenta", className: "bg-magenta" },
|
||||
{ name: "pink-400", className: "bg-pink-400" },
|
||||
{ name: "cyan-400", className: "bg-cyan-400" },
|
||||
].map((c) => (
|
||||
<div key={c.name} className="text-center">
|
||||
<div
|
||||
className={`${c.className} h-16 rounded-lg border border-white/10`}
|
||||
/>
|
||||
<p className="text-[10px] text-white/40 mt-1 font-mono">{c.name}</p>
|
||||
</div>
|
||||
{/* CTA */}
|
||||
<div className="mt-8 flex justify-center gap-4 flex-wrap">
|
||||
<Button variant="outline" leftIcon={<Play size={16} />}>
|
||||
Play Debut PV
|
||||
</Button>
|
||||
<Button variant="primary" pulse leftIcon={<Heart size={14} />}>
|
||||
Vote Now
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Top12 实时榜条 */}
|
||||
<section className="pb-12">
|
||||
<div className="flex items-end justify-between mb-3">
|
||||
<h2 className="font-display text-lg tracking-[0.2em] text-white uppercase">
|
||||
🏆 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>
|
||||
</section>
|
||||
|
||||
{/* Font verification */}
|
||||
<div className="mt-12 grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-2xl w-full">
|
||||
<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">
|
||||
Logo Font · Megrim
|
||||
{/* Component sandbox */}
|
||||
<section className="pb-16 border-t border-white/[0.06] pt-12">
|
||||
<h2 className="font-display text-sm tracking-[0.2em] text-purple-300/70 uppercase mb-4">
|
||||
Phase 4 · Component Sandbox
|
||||
</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 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 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">
|
||||
Display Font · Audiowide
|
||||
<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">
|
||||
Countdown · Compact
|
||||
</p>
|
||||
<p className="font-display text-3xl tracking-wider">VOTE 2026</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">
|
||||
Label Font · Cinzel
|
||||
<Countdown endTime={endTime} compact />
|
||||
<p className="mt-3 text-[11px] text-white/40">
|
||||
用于导航 / Hero 角标
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<p className="mt-16 text-white/40 text-xs font-mono">
|
||||
Phase 4 → Core Components 即将开始
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
{/* 投票弹窗 */}
|
||||
<VoteModal
|
||||
artist={voteTarget}
|
||||
onClose={() => setVoteTarget(null)}
|
||||
onConfirm={handleVote}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
135
src/components/Top12Bar.tsx
Normal file
135
src/components/Top12Bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
216
src/components/VoteModal.tsx
Normal file
216
src/components/VoteModal.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
106
src/components/cards/ArtistCard.tsx
Normal file
106
src/components/cards/ArtistCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
src/components/cards/ArtistPortrait.tsx
Normal file
76
src/components/cards/ArtistPortrait.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
src/components/ui/Button.tsx
Normal file
90
src/components/ui/Button.tsx
Normal 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;
|
||||
121
src/components/ui/Countdown.tsx
Normal file
121
src/components/ui/Countdown.tsx
Normal 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
6
src/lib/cn.ts
Normal 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
135
src/lib/mock-data.ts
Normal 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
65
src/types/artist.ts
Normal 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";
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user