Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7168e50a6e | |||
| f6177fc542 | |||
| 6759d6a689 | |||
| 3f5d33c422 | |||
| ed222d1c5f |
@ -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
|
.env.local
|
||||||
.env.*
|
.env.*.local
|
||||||
!.env.example
|
!.env.example
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
|
|||||||
3
.env
Normal file
3
.env
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Prisma CLI 默认读这个文件 (Next.js 也会读, .env.local 优先级更高)
|
||||||
|
# 该文件已 .gitignore, 不会进仓库
|
||||||
|
DATABASE_URL=mysql://zyc:Zyc188208@mysql-8351f937d637-public.rds.volces.com:3306/cyberstar?charset=utf8mb4
|
||||||
14
.env.local
Normal file
14
.env.local
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# 本地开发环境变量(已被 .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
|
||||||
|
|
||||||
23
.env.production
Normal file
23
.env.production
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# =============================================================
|
||||||
|
# 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
|
||||||
@ -94,13 +94,12 @@ 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) 应用运行时 Secret(DB 连接串 + 阿里云短信凭据)
|
# 2) 运行时 env 已通过 .env.production 烧入镜像,不再需要 K8s Secret 注入
|
||||||
|
# (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=DATABASE_URL='mysql://zyc:Zyc188208@mysql8351f937d637.rds.ivolces.com:3306/cyberstar?charset=utf8mb4' \
|
--from-literal=_PLACEHOLDER='env values live in .env.production' \
|
||||||
--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
8
.gitignore
vendored
@ -30,9 +30,11 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files
|
||||||
.env*
|
# 提交: .env (开发默认 / 占位)、.env.production (部署用真实值)、.env.example
|
||||||
!.env.example
|
# 不提交: .env.local 系列 —— 本机个人覆盖,避免顶掉提交值
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@ -61,6 +61,10 @@ 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
|
||||||
|
|
||||||
|
|||||||
@ -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">{children}</main>
|
<main className="flex-1 pt-20">{children}</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</Providers>
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -71,7 +71,12 @@ export default function LoginForm() {
|
|||||||
redirect: false,
|
redirect: false,
|
||||||
});
|
});
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
setError("验证码错误或已失效");
|
console.error("[login] signIn 返回错误:", result);
|
||||||
|
setError(
|
||||||
|
result.error === "CredentialsSignin"
|
||||||
|
? "验证码错误或已失效"
|
||||||
|
: `登录失败:${result.error}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
router.push(callbackUrl);
|
router.push(callbackUrl);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|||||||
@ -10,6 +10,8 @@ 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";
|
||||||
|
|
||||||
@ -22,9 +24,13 @@ 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") {
|
||||||
@ -33,12 +39,13 @@ export default function Home() {
|
|||||||
return sortArtists(list, sortKey);
|
return sortArtists(list, sortKey);
|
||||||
}, [artists, tagFilter, sortKey]);
|
}, [artists, tagFilter, sortKey]);
|
||||||
|
|
||||||
// 仅在首页启用 scroll-snap mandatory:用户下滑就立即切换到下一个 snap 点
|
// 仅在首页启用 scroll-snap:用户接近 Hero/Top12/候选区时自然吸附。
|
||||||
// (Hero → Top12 → 候选区)。卸载时还原。
|
// 用 proximity 而不是 mandatory —— mandatory 会把"滚到底部"强制吸回到最后一个
|
||||||
|
// 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 mandatory";
|
root.style.scrollSnapType = "y proximity";
|
||||||
return () => {
|
return () => {
|
||||||
root.style.scrollSnapType = prev;
|
root.style.scrollSnapType = prev;
|
||||||
};
|
};
|
||||||
@ -62,13 +69,21 @@ 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")} />
|
||||||
@ -114,16 +129,25 @@ 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={cn(
|
className="sticky z-30 transition-colors duration-200"
|
||||||
"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" }}
|
||||||
>
|
>
|
||||||
<div className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8">
|
{/* 共享玻璃带:absolute,吸顶时延伸到 nav 顶部,opacity 平滑过渡 */}
|
||||||
|
<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}
|
||||||
|
|||||||
@ -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">
|
<div className="flex items-center gap-1 overflow-x-auto flex-1 min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
{TAG_OPTIONS.map((opt) => (
|
{TAG_OPTIONS.map((opt) => (
|
||||||
<TagPill
|
<TagPill
|
||||||
key={opt.key}
|
key={opt.key}
|
||||||
|
|||||||
80
src/components/FloatingBackButton.tsx
Normal file
80
src/components/FloatingBackButton.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@
|
|||||||
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;
|
||||||
@ -17,6 +18,8 @@ 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);
|
||||||
@ -30,11 +33,15 @@ 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",
|
"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",
|
||||||
visible
|
visible ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none",
|
||||||
? "opacity-100 translate-y-0 pointer-events-auto"
|
|
||||||
: "opacity-0 translate-y-3 pointer-events-none",
|
|
||||||
"hover:scale-105",
|
"hover:scale-105",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -50,13 +50,14 @@ 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: "calc(100svh - 80px)",
|
height: "100svh",
|
||||||
minHeight: "560px",
|
minHeight: "560px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -83,38 +84,24 @@ 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.85) 100%)",
|
"linear-gradient(180deg, rgba(8,5,26,0.45) 0%, rgba(8,5,26,0.12) 40%, rgba(8,5,26,0.08) 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 sm:top-10 left-4 sm:left-6 lg:left-8 z-10">
|
<div className="absolute top-[6.5rem] sm:top-[7.5rem] 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-5 sm:top-8 right-4 sm:right-6 lg:right-8 z-10">
|
<div className="absolute top-[6.25rem] sm:top-[7rem] 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"
|
||||||
@ -130,7 +117,7 @@ export default function HeroBanner({
|
|||||||
{/* 底部渐隐 */}
|
{/* 底部渐隐 */}
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="absolute inset-x-0 bottom-0 h-24 pointer-events-none"
|
className="absolute inset-x-0 bottom-0 h-16 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%)",
|
||||||
|
|||||||
@ -1,19 +1,93 @@
|
|||||||
|
"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="sticky top-0 z-50 backdrop-blur-xl bg-[rgba(13,10,36,0.85)] border-b border-white/[0.08]">
|
<header className="fixed top-0 inset-x-0 z-50">
|
||||||
<nav className="max-w-[1500px] mx-auto h-20 px-4 sm:px-6 lg:px-8 flex items-center gap-8">
|
{/* 玻璃层 · 通过 opacity 渐显渐隐 · bg-surface 与全站卡片同色
|
||||||
|
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 />
|
||||||
@ -21,9 +95,14 @@ export default function Navigation() {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* 移动端:单独一行 nav links */}
|
{/* 移动端:单独一行 nav links · 顶部分割线只在玻璃态显示 */}
|
||||||
<NavLinks
|
<NavLinks
|
||||||
className="md:hidden border-t border-white/[0.05] overflow-x-auto"
|
className={cn(
|
||||||
|
"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>
|
||||||
|
|||||||
@ -22,6 +22,7 @@ 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";
|
||||||
@ -144,6 +145,7 @@ export default function ArtistDetailContent({
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<FloatingBackButton fallbackHref="/" />
|
||||||
<FloatingVoteButton onClick={() => openVote(artist)} />
|
<FloatingVoteButton onClick={() => openVote(artist)} />
|
||||||
|
|
||||||
<VoteModal
|
<VoteModal
|
||||||
@ -181,7 +183,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] shadow-card"
|
className="w-full aspect-[4/5]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -136,7 +136,8 @@ 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",
|
||||||
"aspect-video",
|
// 16:9 比例 + 高度封顶 85svh:在小屏(笔记本)上等比缩窄宽度,确保视频可全显
|
||||||
|
"aspect-video max-w-[calc(85svh*16/9)] mx-auto",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
onClick={src ? togglePlay : undefined}
|
onClick={src ? togglePlay : undefined}
|
||||||
|
|||||||
@ -109,7 +109,14 @@ export default function LoginModal({ open, onClose, onSuccess }: LoginModalProps
|
|||||||
redirect: false,
|
redirect: false,
|
||||||
});
|
});
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
setError("验证码错误或已失效");
|
console.error("[login] signIn 返回错误:", result);
|
||||||
|
// 把 NextAuth 真实错误透出来,避免被"验证码错误或已失效"一刀切掩盖
|
||||||
|
// (例如 server config 错误时,会显示 Configuration 而不是误导成验证码问题)
|
||||||
|
setError(
|
||||||
|
result.error === "CredentialsSignin"
|
||||||
|
? "验证码错误或已失效"
|
||||||
|
: `登录失败:${result.error}`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
onClose();
|
onClose();
|
||||||
if (onSuccess) onSuccess();
|
if (onSuccess) onSuccess();
|
||||||
|
|||||||
54
src/hooks/useFooterPush.ts
Normal file
54
src/hooks/useFooterPush.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"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;
|
||||||
|
}
|
||||||
63
src/hooks/useScrollRestore.ts
Normal file
63
src/hooks/useScrollRestore.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
"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]);
|
||||||
|
}
|
||||||
18
src/lib/ui-store.ts
Normal file
18
src/lib/ui-store.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
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 }),
|
||||||
|
}));
|
||||||
Loading…
x
Reference in New Issue
Block a user