UI-UX/src/components/VoteModal.tsx
iye d5ed43acbd feat(ui): design overhaul, global login modal, design spec
- nav: center links (首页/排行榜/我的), right-side AuthMenu + RemainingVotesBadge; image logo with responsive sizing
- auth: replace /login route with global LoginModal triggered anywhere; "我的" intercepts unauth users with post-login redirect
- home: full-screen Hero, redesigned Top12 (12 pill cards, top-3 glow), scroll-snap mandatory between Hero/Top12/candidates
- home: candidates section with sticky filter that gains frosted-glass bg when stuck (matches nav)
- filter: simplified tags (全部/舞蹈/声乐/rap/全能型); ArtistCard uniform purple vote button
- ranking/me: remove Top12Bar; me header stacks 编辑资料/退出登录 vertically
- typography: font-logo set to Orbitron; ✦ glyph in CYBER ✦ STAR preserved
- layout: max-w-[1500px] unified across pages
- docs: add design-spec.md + design-spec.html with full visual spec (lucide SVG, zero emoji policy)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 18:59:30 +08:00

215 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

"use client";
import { 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,
);
}