- nav: center links (首页/排行榜/我的), right-side AuthMenu + RemainingVotesBadge; image logo with responsive sizing - auth: replace /login route with global LoginModal triggered anywhere; "我的" intercepts unauth users with post-login redirect - home: full-screen Hero, redesigned Top12 (12 pill cards, top-3 glow), scroll-snap mandatory between Hero/Top12/candidates - home: candidates section with sticky filter that gains frosted-glass bg when stuck (matches nav) - filter: simplified tags (全部/舞蹈/声乐/rap/全能型); ArtistCard uniform purple vote button - ranking/me: remove Top12Bar; me header stacks 编辑资料/退出登录 vertically - typography: font-logo set to Orbitron; ✦ glyph in CYBER ✦ STAR preserved - layout: max-w-[1500px] unified across pages - docs: add design-spec.md + design-spec.html with full visual spec (lucide SVG, zero emoji policy) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
215 lines
7.3 KiB
TypeScript
215 lines
7.3 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState, useCallback } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import { AnimatePresence, motion } from "framer-motion";
|
||
import { X, Heart } from "lucide-react";
|
||
import type { Artist } from "@/types/artist";
|
||
import { cn } from "@/lib/cn";
|
||
import Button from "./ui/Button";
|
||
import ArtistPortrait from "./cards/ArtistPortrait";
|
||
|
||
type VoteOption = number | "ALL";
|
||
|
||
interface VoteModalProps {
|
||
/** 当前要投票的艺人,传 null 关闭弹窗 */
|
||
artist: Artist | null;
|
||
/** 今日剩余票数(ALL 即投出该数值) */
|
||
remaining: number;
|
||
/** 每日总额度(用于副文案展示) */
|
||
dailyQuota: number;
|
||
/** 关闭弹窗 */
|
||
onClose: () => void;
|
||
/** 确认投票(count 为最终实际投票数,ALL 会被解析为 remaining) */
|
||
onConfirm: (artist: Artist, count: number) => void | Promise<void>;
|
||
}
|
||
|
||
const VOTE_OPTIONS: VoteOption[] = [1, 3, 5, "ALL"];
|
||
|
||
function defaultOption(remaining: number): VoteOption {
|
||
if (remaining >= 3) return 3;
|
||
if (remaining >= 1) return remaining as VoteOption;
|
||
return "ALL";
|
||
}
|
||
|
||
function resolveCount(opt: VoteOption, remaining: number): number {
|
||
return opt === "ALL" ? remaining : opt;
|
||
}
|
||
|
||
export default function VoteModal({
|
||
artist,
|
||
remaining,
|
||
dailyQuota,
|
||
onClose,
|
||
onConfirm,
|
||
}: VoteModalProps) {
|
||
const open = artist != null;
|
||
const [selected, setSelected] = useState<VoteOption>(defaultOption(remaining));
|
||
const [loading, setLoading] = useState(false);
|
||
const [mounted, setMounted] = useState(false);
|
||
|
||
useEffect(() => setMounted(true), []);
|
||
|
||
// 打开时重置默认选择
|
||
useEffect(() => {
|
||
if (open) {
|
||
setSelected(defaultOption(remaining));
|
||
setLoading(false);
|
||
}
|
||
}, [open, remaining]);
|
||
|
||
// 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 actualCount = resolveCount(selected, remaining);
|
||
const canSubmit = remaining > 0 && actualCount > 0 && actualCount <= remaining;
|
||
|
||
const handleConfirm = useCallback(async () => {
|
||
if (!artist || loading || !canSubmit) return;
|
||
setLoading(true);
|
||
try {
|
||
await onConfirm(artist, actualCount);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [artist, actualCount, canSubmit, loading, 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] }}
|
||
>
|
||
{/* 关闭按钮 */}
|
||
<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-4">
|
||
<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="flex items-center justify-between text-xs mb-2.5">
|
||
<span className="text-white/55">选择投票数:</span>
|
||
<span className="text-purple-300 tabular-nums">
|
||
今日剩余 {remaining} / {dailyQuota}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 票数选择 */}
|
||
<div className="flex gap-2.5 justify-center mb-5">
|
||
{VOTE_OPTIONS.map((opt) => {
|
||
const active = selected === opt;
|
||
const optValue = resolveCount(opt, remaining);
|
||
const disabled =
|
||
remaining === 0 ||
|
||
optValue === 0 ||
|
||
optValue > remaining ||
|
||
(opt === "ALL" && remaining === 0);
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={String(opt)}
|
||
onClick={() => !disabled && setSelected(opt)}
|
||
disabled={disabled}
|
||
className={cn(
|
||
"rounded-lg font-display text-base flex items-center justify-center transition-all w-14 h-13 py-3.5 px-3",
|
||
disabled &&
|
||
"bg-surface/40 border border-white/8 text-white/25 cursor-not-allowed",
|
||
!disabled &&
|
||
!active &&
|
||
"bg-surface border border-white/14 text-white/65 hover:border-white/30",
|
||
!disabled &&
|
||
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>
|
||
|
||
{/* 确认按钮 */}
|
||
<Button
|
||
variant="primary"
|
||
className="w-full h-12 text-sm"
|
||
onClick={handleConfirm}
|
||
loading={loading}
|
||
disabled={!canSubmit}
|
||
leftIcon={<Heart size={14} />}
|
||
>
|
||
{remaining === 0
|
||
? "今日票数已用完"
|
||
: `确认投出 ${actualCount} 票`}
|
||
</Button>
|
||
</motion.div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>,
|
||
document.body,
|
||
);
|
||
}
|