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"
|
"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
67
pnpm-lock.yaml
generated
@ -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: {}
|
||||||
|
|||||||
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() {
|
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 (
|
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
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