Compare commits

..

No commits in common. "v0.2.1" and "v0.2.0" have entirely different histories.

21 changed files with 61 additions and 433 deletions

View File

@ -6,8 +6,8 @@ npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
.env.local .env
.env.*.local .env.*
!.env.example !.env.example
.DS_Store .DS_Store
*.pem *.pem

3
.env
View File

@ -1,3 +0,0 @@
# Prisma CLI 默认读这个文件 (Next.js 也会读, .env.local 优先级更高)
# 该文件已 .gitignore, 不会进仓库
DATABASE_URL=mysql://zyc:Zyc188208@mysql-8351f937d637-public.rds.volces.com:3306/cyberstar?charset=utf8mb4

View File

@ -1,14 +0,0 @@
# 本地开发环境变量(已被 .gitignore 忽略)
AUTH_SECRET=9GhMXqoAASnh9qySSrkuZ14Cdi2BbyMPNwZeF3OSL/4=
AUTH_URL=http://localhost:3000
AUTH_TRUST_HOST=true
# 火山 TOS 静态资源前缀(不含末尾斜杠)
NEXT_PUBLIC_TOS_DOMAIN=https://cyber-star.tos-cn-shanghai.volces.com
# 阿里云短信dysmsapi.aliyuncs.com
SMS_SIGN_NAME=广州气元科技
SMS_TEMPLATE_CODE=SMS_506210397
SMS_ACCESS_KEY=LTAI5t7jGzFH4ExkJ9TSmQyd
SMS_SECRET_KEY=u0d3OyTWe9BjnNjK81bvEElky4xcHk

View File

@ -1,23 +0,0 @@
# =============================================================
# CYBER STAR · 生产环境变量
# 仅在 NODE_ENV=production 时被 Next.js 加载 (部署容器里)
# 本机开发请用 .env.local 覆盖
# =============================================================
# ── 数据库 · 火山 RDS VPC 内网 (从 K8s Pod 访问) ──
DATABASE_URL=mysql://zyc:Zyc188208@mysql8351f937d637.rds.ivolces.com:3306/cyberstar?charset=utf8mb4
# ── Auth.js JWT 签名密钥 ──
# 用 `openssl rand -base64 32` 重新生成 (不要复用本机 .env.local 那把)
AUTH_SECRET=eI6svHTg/Uj2EfyP5r0Dt0DbpJDhiX26lRqkC+EylUM=
AUTH_URL=https://cyberstar.airlabs.art
AUTH_TRUST_HOST=true
# ── 火山 TOS 静态资源前缀 (build 时需要,因为 NEXT_PUBLIC_* 会被烧进 client bundle) ──
NEXT_PUBLIC_TOS_DOMAIN=https://cyber-star.tos-cn-shanghai.volces.com
# ── 阿里云短信 ──
SMS_SIGN_NAME=广州气元科技
SMS_TEMPLATE_CODE=SMS_506210397
SMS_ACCESS_KEY=LTAI5t7jGzFH4ExkJ9TSmQyd
SMS_SECRET_KEY=u0d3OyTWe9BjnNjK81bvEElky4xcHk

View File

@ -94,12 +94,13 @@ jobs:
--docker-password="${{ env.CR_PASSWORD_ACTIVE }}" \ --docker-password="${{ env.CR_PASSWORD_ACTIVE }}" \
--dry-run=client -o yaml | kubectl apply -f - --dry-run=client -o yaml | kubectl apply -f -
# 2) 运行时 env 已通过 .env.production 烧入镜像,不再需要 K8s Secret 注入 # 2) 应用运行时 SecretDB 连接串 + 阿里云短信凭据)
# (Next.js standalone server 启动时从 cwd 自动加载 .env.production)
# 保留一个空的 cyberstar-env 占位,避免 web-deployment.yaml 的
# envFrom: optional=true 在首次部署时找不到引用而告警
kubectl create secret generic cyberstar-env \ kubectl create secret generic cyberstar-env \
--from-literal=_PLACEHOLDER='env values live in .env.production' \ --from-literal=DATABASE_URL='mysql://zyc:Zyc188208@mysql8351f937d637.rds.ivolces.com:3306/cyberstar?charset=utf8mb4' \
--from-literal=SMS_SIGN_NAME='广州气元科技' \
--from-literal=SMS_TEMPLATE_CODE='SMS_506210397' \
--from-literal=SMS_ACCESS_KEY='LTAI5t7jGzFH4ExkJ9TSmQyd' \
--from-literal=SMS_SECRET_KEY='u0d3OyTWe9BjnNjK81bvEElky4xcHk' \
--dry-run=client -o yaml | kubectl apply -f - --dry-run=client -o yaml | kubectl apply -f -
# 3) Apply manifests # 3) Apply manifests

8
.gitignore vendored
View File

@ -30,11 +30,9 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
# env files # env files (can opt-in for committing if needed)
# 提交: .env (开发默认 / 占位)、.env.production (部署用真实值)、.env.example .env*
# 不提交: .env.local 系列 —— 本机个人覆盖,避免顶掉提交值 !.env.example
.env.local
.env.*.local
# vercel # vercel
.vercel .vercel

View File

@ -61,10 +61,6 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
# 运行时 env: Next.js standalone server.js 启动时从 cwd 加载 .env.production
# (next build 已经把 NEXT_PUBLIC_* 烧进 bundle, 这里管的是服务端 env 如 DATABASE_URL / AUTH_SECRET)
COPY --from=builder --chown=nextjs:nodejs /app/.env.production ./.env.production
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000

View File

@ -57,7 +57,7 @@ export default function RootLayout({
<body className="min-h-full flex flex-col"> <body className="min-h-full flex flex-col">
<Providers> <Providers>
<Navigation /> <Navigation />
<main className="flex-1 pt-20">{children}</main> <main className="flex-1">{children}</main>
<Footer /> <Footer />
</Providers> </Providers>
</body> </body>

View File

@ -71,12 +71,7 @@ export default function LoginForm() {
redirect: false, redirect: false,
}); });
if (result?.error) { if (result?.error) {
console.error("[login] signIn 返回错误:", result); setError("验证码错误或已失效");
setError(
result.error === "CredentialsSignin"
? "验证码错误或已失效"
: `登录失败:${result.error}`,
);
} else { } else {
router.push(callbackUrl); router.push(callbackUrl);
router.refresh(); router.refresh();

View File

@ -10,8 +10,6 @@ import VoteModal from "@/components/VoteModal";
import { getActivityEndTime, sortArtists, type SortKey } from "@/lib/mock-data"; import { getActivityEndTime, sortArtists, type SortKey } from "@/lib/mock-data";
import { useVoteStore } from "@/lib/store"; import { useVoteStore } from "@/lib/store";
import { useVoteAction } from "@/hooks/useVoteAction"; import { useVoteAction } from "@/hooks/useVoteAction";
import { useScrollRestore } from "@/hooks/useScrollRestore";
import { useUIStore } from "@/lib/ui-store";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { tosUrl } from "@/lib/tos"; import { tosUrl } from "@/lib/tos";
@ -24,13 +22,9 @@ export default function Home() {
const [sortKey, setSortKey] = useState<SortKey>("votes"); const [sortKey, setSortKey] = useState<SortKey>("votes");
const [filterStuck, setFilterStuck] = useState(false); const [filterStuck, setFilterStuck] = useState(false);
const filterSentinelRef = useRef<HTMLDivElement>(null); const filterSentinelRef = useRef<HTMLDivElement>(null);
const setStoreFilterStuck = useUIStore((s) => s.setFilterStuck);
const endTime = useMemo(() => getActivityEndTime(), []); const endTime = useMemo(() => getActivityEndTime(), []);
// 首页滚动位置 per-tab 记忆:从艺人详情点 ← 返回时恢复到上次浏览位置
useScrollRestore("home");
const visibleArtists = useMemo(() => { const visibleArtists = useMemo(() => {
let list = [...artists]; let list = [...artists];
if (tagFilter !== "all") { if (tagFilter !== "all") {
@ -39,13 +33,12 @@ export default function Home() {
return sortArtists(list, sortKey); return sortArtists(list, sortKey);
}, [artists, tagFilter, sortKey]); }, [artists, tagFilter, sortKey]);
// 仅在首页启用 scroll-snap用户接近 Hero/Top12/候选区时自然吸附。 // 仅在首页启用 scroll-snap mandatory用户下滑就立即切换到下一个 snap 点
// 用 proximity 而不是 mandatory —— mandatory 会把"滚到底部"强制吸回到最后一个 // (Hero → Top12 → 候选区)。卸载时还原。
// snap 点的 start候选区顶部表现为回弹proximity 只在靠近时吸,远离不干预。
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
const prev = root.style.scrollSnapType; const prev = root.style.scrollSnapType;
root.style.scrollSnapType = "y proximity"; root.style.scrollSnapType = "y mandatory";
return () => { return () => {
root.style.scrollSnapType = prev; root.style.scrollSnapType = prev;
}; };
@ -69,21 +62,13 @@ export default function Home() {
}; };
}, []); }, []);
// 把 filterStuck 同步到全局 UI store —— 让导航栏感知,在吸顶时关掉自己的玻璃,
// 让筛选条延伸出的"共享玻璃带"成为唯一的 backdrop-filter,消除接缝
useEffect(() => {
setStoreFilterStuck(filterStuck);
return () => setStoreFilterStuck(false);
}, [filterStuck, setStoreFilterStuck]);
return ( return (
<> <>
{/* Hero · · snap {/* Hero · 全屏沉浸式视频 · 作为第一个 snap 点 */}
-mt-20 Hero main padding */}
<div <div
className="-mt-20"
style={{ style={{
scrollSnapAlign: "start", scrollSnapAlign: "start",
scrollMarginTop: "80px",
}} }}
> >
<HeroBanner endTime={endTime} videoSrc={tosUrl("videos/hero-pv.mp4")} /> <HeroBanner endTime={endTime} videoSrc={tosUrl("videos/hero-pv.mp4")} />
@ -129,25 +114,16 @@ export default function Home() {
{/* 哨兵:用于检测筛选条是否吸顶 */} {/* 哨兵:用于检测筛选条是否吸顶 */}
<div ref={filterSentinelRef} aria-hidden style={{ height: 0 }} /> <div ref={filterSentinelRef} aria-hidden style={{ height: 0 }} />
{/* · , {/* 筛选条 · 外层铺满(与导航栏同宽),吸顶后启用毛玻璃;内层版心承载文案 */}
,absolute -top-20
backdrop-filter, nav + filter ,
y=80 */}
<div <div
className="sticky z-30 transition-colors duration-200" className={cn(
"sticky z-30 transition-colors duration-200",
filterStuck &&
"backdrop-blur-xl bg-[rgba(13,10,36,0.85)] border-b border-white/[0.08]",
)}
style={{ top: "80px" }} style={{ top: "80px" }}
> >
{/* 共享玻璃带:absolute,吸顶时延伸到 nav 顶部,opacity 平滑过渡 */} <div className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8">
<div
aria-hidden
className={cn(
"absolute inset-x-0 -top-20 bottom-0 pointer-events-none",
"bg-surface/40 backdrop-blur-xl backdrop-saturate-150 border-b border-white/[0.06]",
"transition-opacity duration-300",
filterStuck ? "opacity-100" : "opacity-0",
)}
/>
<div className="relative max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8">
<ArtistFilters <ArtistFilters
tagFilter={tagFilter} tagFilter={tagFilter}
onTagChange={setTagFilter} onTagChange={setTagFilter}

View File

@ -36,8 +36,8 @@ export default function ArtistFilters({
}: ArtistFiltersProps) { }: ArtistFiltersProps) {
return ( return (
<div className="flex items-center gap-3 py-3 border-b border-white/[0.06]"> <div className="flex items-center gap-3 py-3 border-b border-white/[0.06]">
{/* 左:标签筛选 · 隐藏横向滚动条 thumb,避免在毛玻璃栏里出现深色小矩形 */} {/* 左:标签筛选 */}
<div className="flex items-center gap-1 overflow-x-auto flex-1 min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> <div className="flex items-center gap-1 overflow-x-auto flex-1 min-w-0">
{TAG_OPTIONS.map((opt) => ( {TAG_OPTIONS.map((opt) => (
<TagPill <TagPill
key={opt.key} key={opt.key}

View File

@ -1,80 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronLeft } from "lucide-react";
import { cn } from "@/lib/cn";
import { useFooterPush } from "@/hooks/useFooterPush";
interface FloatingBackButtonProps {
/** 显示前的滚动阈值(px),默认 300 与投票按钮一致 */
threshold?: number;
/** 备用 href:历史栈为空时(直接进入详情页)回退到该地址 */
fallbackHref?: string;
className?: string;
}
/**
*
* - ,
* - router.back() ( scroll restore)
* - sessionStorage 标记:如果是直接打开/,history.back() , fallbackHref
* - ,
*/
export default function FloatingBackButton({
threshold = 300,
fallbackHref = "/",
className,
}: FloatingBackButtonProps) {
const router = useRouter();
const [visible, setVisible] = useState(false);
const [hasInternalHistory, setHasInternalHistory] = useState(false);
// footer 进视口时按钮向上平移,始终漂浮在 footer 顶部上方
const footerPush = useFooterPush();
useEffect(() => {
// Navigation 组件每次路由变化会把 nav:count +1。
// count > 1 → 当前会话内已经经过至少一次跳转,router.back() 安全。
// count <= 1 → 直接打开/刷新本页,history 栈外是站外,改用 fallbackHref。
const count = parseInt(sessionStorage.getItem("nav:count") || "0", 10);
setHasInternalHistory(count > 1);
const handler = () => setVisible(window.scrollY > threshold);
handler();
window.addEventListener("scroll", handler, { passive: true });
return () => window.removeEventListener("scroll", handler);
}, [threshold]);
const goBack = () => {
if (hasInternalHistory) {
router.back();
} else {
router.push(fallbackHref);
}
};
return (
<button
type="button"
onClick={goBack}
aria-label="返回上一页"
style={{
// 隐藏态:下移 12px;显示态:基础位置 - footerPush(避让 footer)
transform: visible
? `translateY(-${footerPush}px)`
: "translateY(12px)",
}}
className={cn(
"fixed bottom-6 left-6 sm:bottom-8 sm:left-8 z-40 w-14 h-14 rounded-full",
"bg-surface/55 backdrop-blur-xl backdrop-saturate-150 border border-white/[0.08]",
"shadow-[0_8px_28px_rgba(0,0,0,0.55)] text-white/85 hover:text-white hover:bg-surface/75",
"flex items-center justify-center transition-all duration-300",
visible ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none",
"hover:scale-105 active:scale-95",
className,
)}
>
<ChevronLeft size={22} strokeWidth={2.2} />
</button>
);
}

View File

@ -3,7 +3,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Heart } from "lucide-react"; import { Heart } from "lucide-react";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { useFooterPush } from "@/hooks/useFooterPush";
interface FloatingVoteButtonProps { interface FloatingVoteButtonProps {
onClick: () => void; onClick: () => void;
@ -18,8 +17,6 @@ export default function FloatingVoteButton({
className, className,
}: FloatingVoteButtonProps) { }: FloatingVoteButtonProps) {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
// footer 进视口时按钮向上平移,始终漂浮在 footer 顶部上方
const footerPush = useFooterPush();
useEffect(() => { useEffect(() => {
const handler = () => setVisible(window.scrollY > threshold); const handler = () => setVisible(window.scrollY > threshold);
@ -33,15 +30,11 @@ export default function FloatingVoteButton({
type="button" type="button"
onClick={onClick} onClick={onClick}
aria-label="立即投票" aria-label="立即投票"
style={{
// 隐藏态:下移 12px;显示态:基础位置 - footerPush(避让 footer)
transform: visible
? `translateY(-${footerPush}px)`
: "translateY(12px)",
}}
className={cn( className={cn(
"fixed bottom-6 right-6 sm:bottom-8 sm:right-8 z-40 w-14 h-14 rounded-full bg-grad-purple text-white flex flex-col items-center justify-center font-display text-[9px] tracking-widest shadow-purple-glow animate-pulse-glow transition-all duration-300", "fixed bottom-6 right-6 sm:bottom-8 sm:right-8 z-40 w-14 h-14 rounded-full bg-grad-purple text-white flex flex-col items-center justify-center font-display text-[9px] tracking-widest shadow-purple-glow animate-pulse-glow transition-all",
visible ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none", visible
? "opacity-100 translate-y-0 pointer-events-auto"
: "opacity-0 translate-y-3 pointer-events-none",
"hover:scale-105", "hover:scale-105",
className, className,
)} )}

View File

@ -50,14 +50,13 @@ export default function HeroBanner({
return ( return (
<section <section
data-hero-banner
className={cn( className={cn(
"relative w-full overflow-hidden bg-deepest", "relative w-full overflow-hidden bg-deepest",
"min-h-[560px]", "min-h-[560px]",
className, className,
)} )}
style={{ style={{
height: "100svh", height: "calc(100svh - 80px)",
minHeight: "560px", minHeight: "560px",
}} }}
> >
@ -84,24 +83,38 @@ export default function HeroBanner({
className="absolute inset-0 pointer-events-none" className="absolute inset-0 pointer-events-none"
style={{ style={{
background: background:
"linear-gradient(180deg, rgba(8,5,26,0.45) 0%, rgba(8,5,26,0.12) 40%, rgba(8,5,26,0.08) 100%)", "linear-gradient(180deg, rgba(8,5,26,0.45) 0%, rgba(8,5,26,0.12) 40%, rgba(8,5,26,0.85) 100%)",
}} }}
/> />
{/* 版心容器1500px max-width所有文案 / 倒计时 / 声音按钮全部在内 */} {/* 版心容器1500px max-width所有文案 / 倒计时 / 声音按钮全部在内 */}
<div className="absolute inset-0 max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8"> <div className="absolute inset-0 max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8">
{/* Eyebrow 左上 · 紧贴导航下方 */} {/* Eyebrow 左上 */}
<div className="absolute top-[6.5rem] sm:top-[7.5rem] left-4 sm:left-6 lg:left-8 z-10"> <div className="absolute top-6 sm:top-10 left-4 sm:left-6 lg:left-8 z-10">
<p className="font-label text-[10px] sm:text-xs tracking-[0.4em] uppercase text-purple-200/90"> <p className="font-label text-[10px] sm:text-xs tracking-[0.4em] uppercase text-purple-200/90">
Top 12 · Virtual Idol Debut Project Top 12 · Virtual Idol Debut Project
</p> </p>
</div> </div>
{/* 浅紫边框倒计时 右上 · 紧贴导航下方 */} {/* 浅紫边框倒计时 右上 */}
<div className="absolute top-[6.25rem] sm:top-[7rem] right-4 sm:right-6 lg:right-8 z-10"> <div className="absolute top-5 sm:top-8 right-4 sm:right-6 lg:right-8 z-10">
<Countdown endTime={endTime} compact /> <Countdown endTime={endTime} compact />
</div> </div>
{/* 中央 CYBER ✦ STAR */}
<div className="absolute inset-0 flex flex-col items-center justify-center z-10 px-6 text-center">
<h1 className="font-logo text-6xl sm:text-8xl lg:text-9xl tracking-[0.2em] glow-text-purple inline-flex items-baseline text-white">
CYBER
<span className="text-purple-300 mx-2 sm:mx-4 text-4xl sm:text-6xl lg:text-7xl">
</span>
STAR
</h1>
<p className="mt-6 text-white/80 text-base sm:text-lg tracking-[0.4em]">
</p>
</div>
{/* 声音按钮 右下 */} {/* 声音按钮 右下 */}
<button <button
type="button" type="button"
@ -117,7 +130,7 @@ export default function HeroBanner({
{/* 底部渐隐 */} {/* 底部渐隐 */}
<div <div
aria-hidden aria-hidden
className="absolute inset-x-0 bottom-0 h-16 pointer-events-none" className="absolute inset-x-0 bottom-0 h-24 pointer-events-none"
style={{ style={{
background: background:
"linear-gradient(180deg, transparent 0%, var(--color-deepest) 100%)", "linear-gradient(180deg, transparent 0%, var(--color-deepest) 100%)",

View File

@ -1,93 +1,19 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import Logo from "./Logo"; import Logo from "./Logo";
import NavLinks from "./NavLinks"; import NavLinks from "./NavLinks";
import SearchTrigger from "./SearchTrigger"; import SearchTrigger from "./SearchTrigger";
import AuthMenu from "./auth/AuthMenu"; import AuthMenu from "./auth/AuthMenu";
import RemainingVotesBadge from "./auth/RemainingVotesBadge"; import RemainingVotesBadge from "./auth/RemainingVotesBadge";
import { cn } from "@/lib/cn";
import { useUIStore } from "@/lib/ui-store";
/**
* ·
* - / Hero 之上时:完全透明,(线)
* - nav 下方时:渐显为毛玻璃(blur + saturate + surface ),
* - absolute + opacity , backdrop-filter class
*
* :
* - [data-hero-banner] IntersectionObserver Hero()
* - ,scrollY <= 0 ,
*/
export default function Navigation() { export default function Navigation() {
const pathname = usePathname();
// 初始乐观:任何页面首屏都假设在顶部/Hero 上,透明。effect 挂载后会立刻校正。
const [isTransparent, setIsTransparent] = useState(true);
// 筛选条吸顶时,nav 也变透明 —— 此时筛选条的"共享玻璃带"已延伸到 nav 顶部,
// nav 关掉自己的玻璃,避免双重 backdrop-filter 在 y=80 处出现拼接线。
const filterStuck = useUIStore((s) => s.filterStuck);
const glassOff = isTransparent || filterStuck;
// 维护一个站内导航计数器(per-tab),供 FloatingBackButton 判断 router.back() 是否安全
useEffect(() => {
const prev = parseInt(sessionStorage.getItem("nav:count") || "0", 10);
sessionStorage.setItem("nav:count", String(prev + 1));
}, [pathname]);
useEffect(() => {
const heroEl = document.querySelector("[data-hero-banner]");
// 1) 有 Hero 的页面:观察 Hero 是否还在视口顶部下方
if (heroEl) {
setIsTransparent(true);
const observer = new IntersectionObserver(
([entry]) => setIsTransparent(entry.isIntersecting),
{
// 视口顶部下移 80px(导航高度),让 Hero 完全滚出 nav 下方时触发
rootMargin: "-80px 0px 0px 0px",
threshold: 0,
},
);
observer.observe(heroEl);
return () => observer.disconnect();
}
// 2) 无 Hero 的页面:scrollY === 0 时透明,任何向下滚动即玻璃
const onScroll = () => setIsTransparent(window.scrollY <= 0);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, [pathname]);
return ( return (
<header className="fixed top-0 inset-x-0 z-50"> <header className="sticky top-0 z-50 backdrop-blur-xl bg-[rgba(13,10,36,0.85)] border-b border-white/[0.08]">
{/* · opacity · bg-surface <nav className="max-w-[1500px] mx-auto h-20 px-4 sm:px-6 lg:px-8 flex items-center gap-8">
glassOff = isTransparent || filterStuck:
- isTransparent: Hero / ,
- filterStuck: 筛选条已伸出共享玻璃,nav */}
<div
aria-hidden
className={cn(
"absolute inset-0 bg-surface/40 backdrop-blur-xl backdrop-saturate-150 transition-opacity duration-300",
glassOff ? "opacity-0" : "opacity-100",
)}
/>
{/* 顶部 1px 高光 · 仅玻璃态下显示 */}
<div
aria-hidden
className={cn(
"absolute inset-x-0 top-0 h-px bg-white/[0.06] transition-opacity duration-300",
glassOff ? "opacity-0" : "opacity-100",
)}
/>
<nav className="relative max-w-[1500px] mx-auto h-20 px-4 sm:px-6 lg:px-8 flex items-center gap-8">
<Logo size="md" /> <Logo size="md" />
{/* 中部:首页 / 排行榜 / 我的 */} {/* 中部:首页 / 排行榜 / 我的 */}
<NavLinks className="hidden md:flex" /> <NavLinks className="hidden md:flex" />
{/* 右侧:搜索 + 今日余票 + 登录/注册 (或 头像+下拉) */} {/* 右侧:搜索 + 今日余票 + 登录/注册 (或 头像+下拉) */}
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
<SearchTrigger /> <SearchTrigger />
<RemainingVotesBadge /> <RemainingVotesBadge />
@ -95,14 +21,9 @@ export default function Navigation() {
</div> </div>
</nav> </nav>
{/* 移动端:单独一行 nav links · 顶部分割线只在玻璃态显示 */} {/* 移动端:单独一行 nav links */}
<NavLinks <NavLinks
className={cn( className="md:hidden border-t border-white/[0.05] overflow-x-auto"
"relative md:hidden overflow-x-auto transition-colors duration-300",
glassOff
? "border-t border-transparent"
: "border-t border-white/[0.05]",
)}
mobile mobile
/> />
</header> </header>

View File

@ -22,7 +22,6 @@ import RankCard from "./RankCard";
import PerformanceVideo from "./PerformanceVideo"; import PerformanceVideo from "./PerformanceVideo";
import PerformanceGallery from "./PerformanceGallery"; import PerformanceGallery from "./PerformanceGallery";
import FloatingVoteButton from "@/components/FloatingVoteButton"; import FloatingVoteButton from "@/components/FloatingVoteButton";
import FloatingBackButton from "@/components/FloatingBackButton";
import { useVoteStore, selectArtist } from "@/lib/store"; import { useVoteStore, selectArtist } from "@/lib/store";
import { useVoteAction } from "@/hooks/useVoteAction"; import { useVoteAction } from "@/hooks/useVoteAction";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
@ -145,7 +144,6 @@ export default function ArtistDetailContent({
</section> </section>
)} )}
<FloatingBackButton fallbackHref="/" />
<FloatingVoteButton onClick={() => openVote(artist)} /> <FloatingVoteButton onClick={() => openVote(artist)} />
<VoteModal <VoteModal
@ -183,7 +181,7 @@ function HeroPanel({ artist, allArtists, onVote }: HeroPanelProps) {
<ArtistPortrait <ArtistPortrait
artist={artist} artist={artist}
rounded="rounded-xl" rounded="rounded-xl"
className="w-full aspect-[4/5]" className="w-full aspect-[4/5] shadow-card"
/> />
</div> </div>

View File

@ -136,8 +136,7 @@ export default function PerformanceVideo({
<div <div
className={cn( className={cn(
"relative w-full bg-black rounded-xl overflow-hidden border border-white/[0.08] group", "relative w-full bg-black rounded-xl overflow-hidden border border-white/[0.08] group",
// 16:9 比例 + 高度封顶 85svh:在小屏(笔记本)上等比缩窄宽度,确保视频可全显 "aspect-video",
"aspect-video max-w-[calc(85svh*16/9)] mx-auto",
className, className,
)} )}
onClick={src ? togglePlay : undefined} onClick={src ? togglePlay : undefined}

View File

@ -109,14 +109,7 @@ export default function LoginModal({ open, onClose, onSuccess }: LoginModalProps
redirect: false, redirect: false,
}); });
if (result?.error) { if (result?.error) {
console.error("[login] signIn 返回错误:", result); setError("验证码错误或已失效");
// 把 NextAuth 真实错误透出来,避免被"验证码错误或已失效"一刀切掩盖
// (例如 server config 错误时,会显示 Configuration 而不是误导成验证码问题)
setError(
result.error === "CredentialsSignin"
? "验证码错误或已失效"
: `登录失败:${result.error}`,
);
} else { } else {
onClose(); onClose();
if (onSuccess) onSuccess(); if (onSuccess) onSuccess();

View File

@ -1,54 +0,0 @@
"use client";
import { useEffect, useState } from "react";
/**
* <footer>
*
* push (px):
* - footer 0,
* - footer / footer + gap,
* footer gap
*
* :
* const push = useFooterPush();
* <button style={{ transform: `translateY(-${push}px)` }} ... />
*
* 实现:scroll + resize + rAF ; IntersectionObserver ,
* push (IO )
*/
export function useFooterPush(gap = 16): number {
const [push, setPush] = useState(0);
useEffect(() => {
const footer = document.querySelector("footer");
if (!footer) return;
let raf = 0;
const update = () => {
const rect = footer.getBoundingClientRect();
// overlap = footer 顶部进入视口的深度。footer 未入视口时为负数,clamp 到 0
const overlap = window.innerHeight - rect.top + gap;
setPush(Math.max(0, overlap));
};
const schedule = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
update();
raf = 0;
});
};
update();
window.addEventListener("scroll", schedule, { passive: true });
window.addEventListener("resize", schedule);
return () => {
window.removeEventListener("scroll", schedule);
window.removeEventListener("resize", schedule);
if (raf) cancelAnimationFrame(raf);
};
}, [gap]);
return push;
}

View File

@ -1,63 +0,0 @@
"use client";
import { useEffect } from "react";
/**
* sessionStorage /
*
* 用法:在希望被记忆的页面顶层 useScrollRestore("home")
* - mount ,, window y
* - , scrollY
* - mount
*
* :
* - sessionStorage ,"漏读"
* - rAF ,
* - requestAnimationFrame , DOM
*/
export function useScrollRestore(key: string) {
useEffect(() => {
const storageKey = `scroll:${key}`;
// 恢复 · 用 rAF 等 DOM 真正布局完成后再滚
const saved = sessionStorage.getItem(storageKey);
if (saved) {
const y = parseInt(saved, 10);
if (!Number.isNaN(y) && y > 0) {
// 两次 rAF 跨过 next.js hydration/挂载首帧,DOM 高度才稳定
requestAnimationFrame(() => {
requestAnimationFrame(() => {
// 临时禁用 scroll-snap-type:首页用 "y mandatory",
// 直接 scrollTo 到 snap 点之外的位置会被浏览器强制吸到最近 snap 点。
const html = document.documentElement;
const prevSnap = html.style.scrollSnapType;
html.style.scrollSnapType = "none";
window.scrollTo({ top: y, behavior: "auto" });
// 多等一帧让 scrollTo 落定再恢复 snap
requestAnimationFrame(() => {
html.style.scrollSnapType = prevSnap;
});
});
});
}
}
// 保存 · scroll 事件高频,用 rAF 合并
let raf = 0;
const onScroll = () => {
if (raf) return;
raf = requestAnimationFrame(() => {
sessionStorage.setItem(storageKey, String(window.scrollY));
raf = 0;
});
};
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
window.removeEventListener("scroll", onScroll);
if (raf) cancelAnimationFrame(raf);
// unmount 时再保存一次最终值
sessionStorage.setItem(storageKey, String(window.scrollY));
};
}, [key]);
}

View File

@ -1,18 +0,0 @@
import { create } from "zustand";
/**
* UI
* 主要用途:首页筛选条吸顶时通知导航关掉自己的玻璃,
* y=0 , backdrop-filter,
* y=80
*/
interface UIStore {
/** 当前页面有 sticky 筛选条且已经吸顶 */
filterStuck: boolean;
setFilterStuck: (stuck: boolean) => void;
}
export const useUIStore = create<UIStore>((set) => ({
filterStuck: false,
setFilterStuck: (stuck) => set({ filterStuck: stuck }),
}));