Files
no-whatever/src/app/blindbox/page.tsx
T
kurihada 76349f0dcf feat: 接入全站图片资源 + 修复卡片滑动与房间轮询问题
图片资源接入:
- OG/Twitter 社交分享元数据 (og-image.png)
- 错误页插画替换图标 (error-robot.png)
- EmptyState 组件支持 image prop,空状态页面接入插画
- 餐厅图片 fallback 改用 restaurant-fallback.png
- 极速救场/周末契约页面添加 hero 装饰图
- 分享卡片添加背景图层 (share-bg-*.png),通过 base64 预加载
- 更新 App 图标 (apple-touch-icon, icon-192/512)

Bug 修复:
- SwipeDeck: swipe action 从 "nope" 改为 "pass",匹配 API 预期
- SwipeDeck: 用 ref 读取 currentIndex 避免竞态重置(本地滑动后
  被服务端旧 swipeCounts 立即清零)
- SwipeDeck: 卡片 key 加入 isTop 标识,强制 remount 解决
  framer-motion drag 手势在 isTop 切换时不重新初始化的问题
- SwipeableCard: initial 统一为背景位置,确保晋升为顶部卡片时
  有一致的放大动画
- useRoomPolling: roomId 为空时跳过 SWR 和 EventSource
- room page: joinRoom 前 guard roomId,消除退房时 404
- layout: 添加 metadataBase 消除 Next.js OG 图片警告
2026-02-27 16:08:38 +08:00

503 lines
18 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 { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { motion, AnimatePresence } from "framer-motion";
import {
ArrowLeft,
Package,
Plus,
LogIn,
Users,
Sparkles,
ChevronRight,
} from "lucide-react";
import { getCachedProfile, isRegistered } from "@/lib/userId";
import AuthModal from "@/components/AuthModal";
import Button from "@/components/Button";
import Input from "@/components/Input";
import { BlindboxListSkeleton } from "@/components/Skeleton";
import type { UserProfile } from "@/types";
interface RoomSummary {
id: string;
code: string;
name: string;
memberCount: number;
poolCount: number;
members: { id: string; username: string; avatar: string }[];
lastDrawn: { content: string; createdAt: string } | null;
}
export default function BlindboxLobbyPage() {
const router = useRouter();
const [hydrated, setHydrated] = useState(false);
const [loggedIn, setLoggedIn] = useState(false);
const [profile, setProfile] = useState<UserProfile | null>(null);
const [showAuth, setShowAuth] = useState(false);
const [rooms, setRooms] = useState<RoomSummary[]>([]);
const [loading, setLoading] = useState(true);
const [createName, setCreateName] = useState("");
const [creating, setCreating] = useState(false);
const [joinCode, setJoinCode] = useState("");
const [joining, setJoining] = useState(false);
const [error, setError] = useState("");
const [loadError, setLoadError] = useState(false);
useEffect(() => {
const registered = isRegistered();
setLoggedIn(registered);
if (registered) {
setProfile(getCachedProfile());
}
setHydrated(true);
}, []);
useEffect(() => {
const handler = () => {
const registered = isRegistered();
setLoggedIn(registered);
setProfile(registered ? getCachedProfile() : null);
};
window.addEventListener("nowhatever_auth", handler);
return () => window.removeEventListener("nowhatever_auth", handler);
}, []);
const fetchRooms = useCallback(async () => {
const p = getCachedProfile();
if (!p) return;
setLoading(true);
setLoadError(false);
try {
const res = await fetch(`/api/blindbox/rooms?userId=${p.id}`);
if (!res.ok) throw new Error();
const data = await res.json();
setRooms(Array.isArray(data.rooms) ? data.rooms : []);
} catch {
setLoadError(true);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (loggedIn) fetchRooms();
else setLoading(false);
}, [loggedIn, fetchRooms]);
const handleAuth = (p: UserProfile) => {
setProfile(p);
setLoggedIn(true);
setShowAuth(false);
};
const handleCreate = async () => {
if (creating || !profile) return;
setCreating(true);
setError("");
try {
const res = await fetch("/api/blindbox/room", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: profile.id, name: createName.trim() || undefined }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
router.push(`/blindbox/${data.code}`);
} catch (e) {
setError(e instanceof Error ? e.message : "创建失败");
} finally {
setCreating(false);
}
};
const handleJoin = async () => {
if (joining || !profile || !joinCode.trim()) return;
setJoining(true);
setError("");
try {
const res = await fetch("/api/blindbox/room/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: profile.id, code: joinCode.trim() }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
router.push(`/blindbox/${data.code}`);
} catch (e) {
setError(e instanceof Error ? e.message : "加入失败");
} finally {
setJoining(false);
}
};
return (
<div className="relative flex min-h-dvh flex-col items-center justify-center bg-background px-6 py-6 overflow-y-auto scrollbar-none">
{/* Ambient */}
<div className="pointer-events-none fixed left-1/3 top-0 h-80 w-80 -translate-y-1/3 rounded-full bg-purple-600/8 blur-3xl" />
<div className="pointer-events-none fixed right-0 top-1/2 h-60 w-60 rounded-full bg-indigo-500/5 blur-3xl" />
{/* Back button */}
<button
onClick={() => router.push("/")}
className="absolute left-4 top-3 flex h-8 items-center gap-1 rounded-full bg-surface px-3 text-xs font-medium text-muted ring-1 ring-border transition-colors active:bg-elevated"
>
<ArrowLeft size={14} />
</button>
{/* Header */}
<motion.div
className="flex items-center gap-3"
initial={{ y: -20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5 }}
>
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-linear-to-br from-indigo-900 to-purple-700 shadow-lg shadow-purple-900/30 ring-1 ring-purple-500/20">
<Package size={22} className="text-purple-300" />
</div>
<div>
<h1 className="text-2xl font-black tracking-tight text-heading">
</h1>
<p className="text-xs font-medium tracking-widest text-muted">
ADVENTURE ROULETTE
</p>
</div>
</motion.div>
<motion.img
src="/blindbox-hero.png"
alt="周末契约"
className="mt-3 h-28 w-auto"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 0.05 }}
/>
<motion.p
className="mt-2 max-w-xs text-center text-xs leading-relaxed text-muted"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.08 }}
>
"想做但一直没做"
</motion.p>
<motion.div
className="mt-4 flex items-center justify-center gap-4"
initial={{ y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.1 }}
>
<div className="flex flex-col items-center gap-1 w-16">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-purple-500/15">
<Plus size={14} className="text-purple-400" />
</div>
<span className="text-[10px] font-semibold text-muted"></span>
</div>
<ChevronRight size={12} className="shrink-0 text-dim" />
<div className="flex flex-col items-center gap-1 w-16">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-indigo-500/15">
<Package size={14} className="text-indigo-400" />
</div>
<span className="text-[10px] font-semibold text-muted"></span>
</div>
<ChevronRight size={12} className="shrink-0 text-dim" />
<div className="flex flex-col items-center gap-1 w-16">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-violet-500/15">
<Sparkles size={14} className="text-violet-400" />
</div>
<span className="text-[10px] font-semibold text-muted"></span>
</div>
</motion.div>
<AnimatePresence mode="wait">
{!hydrated || (loggedIn && loading) ? (
<BlindboxListSkeleton />
) : !loggedIn ? (
/* ============ Layer 1: Unauthenticated — Login CTA ============ */
<motion.div
key="intro"
className="mt-10 flex flex-col items-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<motion.button
onClick={() => setShowAuth(true)}
className="flex h-12 w-full max-w-xs items-center justify-center gap-2 rounded-2xl bg-linear-to-r from-purple-600 to-indigo-600 text-sm font-bold text-white shadow-lg shadow-purple-900/40 transition-shadow hover:shadow-xl hover:shadow-purple-900/50"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
>
<LogIn size={18} />
/
</motion.button>
<p className="mt-3 text-[11px] text-dim">
10
</p>
</motion.div>
) : loadError ? (
<motion.div
key="load-error"
className="mt-16 flex flex-col items-center gap-3"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<Package size={36} className="text-purple-400/30" strokeWidth={1.5} />
<p className="text-sm text-muted"></p>
<button
onClick={fetchRooms}
className="mt-1 text-xs font-medium text-purple-400 active:text-purple-300"
>
</button>
</motion.div>
) : rooms.length === 0 ? (
/* ============ Layer 2: Logged in, no rooms — Create first ============ */
<motion.div
key="empty"
className="mt-10 flex flex-col items-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<motion.div
className="relative flex h-20 w-20 items-center justify-center"
animate={{ y: [0, -4, 0] }}
transition={{ duration: 2.5, repeat: Infinity, ease: "easeInOut" }}
>
<div className="absolute inset-0 rounded-2xl bg-purple-600/15 blur-lg" />
<Package size={32} className="relative text-purple-400/60" strokeWidth={1.5} />
</motion.div>
<h2 className="mt-5 text-lg font-bold text-heading"></h2>
<p className="mt-1.5 text-sm text-tertiary">
TA
</p>
{/* Inline create form */}
<div className="mt-7 w-full max-w-xs">
<div className="flex gap-2">
<Input
type="text"
placeholder="我们的周末"
value={createName}
onChange={(e) => {
setCreateName(e.target.value.slice(0, 30));
setError("");
}}
onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }}
maxLength={30}
size="xl"
variant="purple"
className="flex-1"
/>
<Button
onClick={handleCreate}
variant="purple"
size="lg"
loading={creating}
icon={<Plus size={16} />}
>
</Button>
</div>
{/* Join alternative */}
<div className="mt-5 flex items-center gap-3">
<div className="h-px flex-1 bg-border" />
<span className="text-[10px] font-medium text-dim"></span>
<div className="h-px flex-1 bg-border" />
</div>
<div className="mt-3 flex gap-2">
<Input
type="text"
placeholder="6 位房间号"
value={joinCode}
onChange={(e) => {
setJoinCode(e.target.value.toUpperCase().slice(0, 6));
setError("");
}}
onKeyDown={(e) => { if (e.key === "Enter") handleJoin(); }}
maxLength={6}
size="xl"
variant="purple"
className="flex-1 text-center font-mono tracking-[0.15em]"
/>
<Button
onClick={handleJoin}
variant="secondary"
size="lg"
disabled={joinCode.trim().length < 6}
loading={joining}
icon={<LogIn size={16} />}
>
</Button>
</div>
{error && (
<motion.p
className="mt-3 text-center text-xs font-medium text-rose-400"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
>
{error}
</motion.p>
)}
</div>
</motion.div>
) : (
/* ============ Layer 3: Logged in, has rooms — Room list ============ */
<motion.div
key="rooms"
className="mt-6 flex w-full max-w-sm flex-col"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
{/* Create row */}
<div className="flex gap-2">
<Input
type="text"
placeholder="新房间名称"
value={createName}
onChange={(e) => {
setCreateName(e.target.value.slice(0, 30));
setError("");
}}
onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }}
maxLength={30}
size="lg"
variant="purple"
className="flex-1"
/>
<Button
onClick={handleCreate}
variant="purple"
loading={creating}
icon={<Plus size={14} />}
>
</Button>
</div>
{/* Join row */}
<div className="mt-2 flex gap-2">
<Input
type="text"
placeholder="输入 6 位房间号加入"
value={joinCode}
onChange={(e) => {
setJoinCode(e.target.value.toUpperCase().slice(0, 6));
setError("");
}}
onKeyDown={(e) => { if (e.key === "Enter") handleJoin(); }}
maxLength={6}
size="lg"
variant="purple"
className="flex-1 text-center font-mono tracking-[0.15em] placeholder:font-sans placeholder:tracking-normal"
/>
<Button
onClick={handleJoin}
variant="secondary"
disabled={joinCode.trim().length < 6}
loading={joining}
icon={<LogIn size={14} />}
>
</Button>
</div>
{error && (
<motion.p
className="mt-2 text-center text-xs font-medium text-rose-400"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
>
{error}
</motion.p>
)}
{/* Room list */}
<div className="mt-5 flex flex-col gap-3">
{rooms.map((room, i) => (
<motion.button
key={room.id}
onClick={() => router.push(`/blindbox/${room.code}`)}
className="group flex w-full items-center gap-3 rounded-2xl bg-surface p-4 text-left ring-1 ring-border transition-all hover:bg-elevated hover:ring-purple-500/30"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.06 }}
whileTap={{ scale: 0.98 }}
>
{/* Icon */}
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-purple-600/15 ring-1 ring-purple-500/20">
<Package size={20} className="text-purple-400" strokeWidth={1.5} />
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-heading">{room.name}</p>
<div className="mt-1 flex items-center gap-3 text-[11px] text-muted">
<span className="flex items-center gap-1">
<Users size={11} />
{room.memberCount}
</span>
<span className="flex items-center gap-1">
<Package size={11} />
{room.poolCount}
</span>
</div>
{room.lastDrawn && (
<p className="mt-1 truncate text-[11px] text-purple-400/60">
{room.lastDrawn.content}
</p>
)}
</div>
{/* Members preview */}
<div className="flex shrink-0 -space-x-1.5">
{room.members.slice(0, 3).map((m) => (
<div
key={m.id}
className="flex h-7 w-7 items-center justify-center rounded-full bg-elevated text-xs ring-2 ring-surface"
title={m.username}
>
{m.avatar}
</div>
))}
{room.memberCount > 3 && (
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-elevated text-[10px] font-bold text-muted ring-2 ring-surface">
+{room.memberCount - 3}
</div>
)}
</div>
<ChevronRight size={16} className="shrink-0 text-muted/50 transition-colors group-hover:text-purple-400" />
</motion.button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Auth Modal */}
<AuthModal
open={showAuth}
onClose={() => setShowAuth(false)}
onAuth={handleAuth}
defaultTab="register"
/>
</div>
);
}