Files
no-whatever/src/app/blindbox/page.tsx
T
kurihada 7aa6c7f792 feat: 全局用户头像徽章,所有页面右上角统一显示
- 新增 GlobalUserBadge 组件,固定在右上角,已登录显示头像+用户名,未登录显示登录按钮
- 通过 layout.tsx 全局挂载,仅在个人中心页隐藏
- userId.ts 登录/登出时派发 nowhatever_auth 事件,组件实时响应
- 移除各页面重复的用户指示器(首页、极速救场、周末契约大厅、个人中心顶栏退出按钮)
- TopNav 右侧留出空间避免与全局徽章重叠
- 头像徽章采用暗色主题风格(bg-surface/80)
2026-02-26 14:42:40 +08:00

458 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,
Loader2,
ChevronRight,
} from "lucide-react";
import { getCachedProfile, isRegistered } from "@/lib/userId";
import AuthModal from "@/components/AuthModal";
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 [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("");
useEffect(() => {
const registered = isRegistered();
setLoggedIn(registered);
if (registered) {
setProfile(getCachedProfile());
}
}, []);
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);
try {
const res = await fetch(`/api/blindbox/rooms?userId=${p.id}`);
const data = await res.json();
setRooms(data.rooms ?? []);
} catch {
/* ignore */
} 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 bg-background px-5 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" />
{/* Header */}
<div className="flex w-full max-w-sm items-center gap-3">
<button
onClick={() => router.push("/")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-surface ring-1 ring-border transition-colors active:bg-elevated"
>
<ArrowLeft size={16} className="text-muted" />
</button>
<div className="flex-1">
<h1 className="text-lg font-black text-white">🎁 </h1>
<p className="text-[10px] font-medium tracking-wider text-purple-400/60">
ADVENTURE ROULETTE
</p>
</div>
</div>
<AnimatePresence mode="wait">
{!loggedIn ? (
/* ============ Layer 1: Unauthenticated — Feature intro ============ */
<motion.div
key="intro"
className="mt-10 flex flex-1 flex-col items-center"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
{/* Hero icon */}
<motion.div
className="relative flex h-28 w-28 items-center justify-center"
animate={{ y: [0, -6, 0] }}
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut" }}
>
<div className="absolute inset-0 rounded-3xl bg-purple-600/20 blur-xl" />
<div className="relative flex h-24 w-24 items-center justify-center rounded-2xl bg-linear-to-br from-indigo-900 to-purple-900 shadow-2xl ring-1 ring-purple-700/30">
<Package size={36} className="text-purple-300/70" strokeWidth={1.5} />
</div>
<motion.span
className="absolute -right-1 -top-1 text-lg"
animate={{ rotate: [0, 15, -15, 0], scale: [1, 1.2, 1] }}
transition={{ duration: 2, repeat: Infinity, repeatDelay: 3 }}
>
</motion.span>
</motion.div>
<h2 className="mt-6 text-xl font-black text-white">
TA
</h2>
<p className="mt-2 max-w-72 text-center text-sm leading-relaxed text-gray-400">
"想做但一直没做"
</p>
{/* Steps */}
<div className="mt-8 flex w-full max-w-xs flex-col gap-4">
{[
{ step: "1", icon: Plus, text: "创建专属房间,邀请 TA 加入" },
{ step: "2", icon: Package, text: "平时随时塞入疯狂想法" },
{ step: "3", icon: Sparkles, text: "周末一起盲抽,绝不反悔" },
].map((s) => (
<div key={s.step} className="flex items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-purple-600/15 text-sm font-black text-purple-400 ring-1 ring-purple-500/20">
{s.step}
</div>
<p className="text-sm font-medium text-gray-300">{s.text}</p>
</div>
))}
</div>
{/* CTA */}
<motion.button
onClick={() => setShowAuth(true)}
className="mt-10 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>
) : loading ? (
/* ============ Loading ============ */
<motion.div
key="loading"
className="mt-20 flex flex-col items-center gap-3"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<Loader2 size={24} className="animate-spin text-purple-400" />
<p className="text-xs text-muted">...</p>
</motion.div>
) : rooms.length === 0 ? (
/* ============ Layer 2: Logged in, no rooms — Create first ============ */
<motion.div
key="empty"
className="mt-10 flex flex-1 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-white"></h2>
<p className="mt-1.5 text-sm text-gray-400">
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}
className="h-11 flex-1 rounded-xl border-none bg-surface px-4 text-sm text-foreground outline-none ring-1 ring-border transition-all placeholder:text-dim focus:ring-2 focus:ring-purple-600"
/>
<button
onClick={handleCreate}
disabled={creating}
className="flex h-11 items-center gap-1.5 rounded-xl bg-purple-600 px-4 text-sm font-bold text-white transition-colors hover:bg-purple-500 disabled:opacity-50"
>
{creating ? <Loader2 size={16} className="animate-spin" /> : <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}
className="h-11 flex-1 rounded-xl border-none bg-surface px-4 text-center font-mono text-sm tracking-[0.15em] text-foreground outline-none ring-1 ring-border transition-all placeholder:text-dim focus:ring-2 focus:ring-purple-600"
/>
<button
onClick={handleJoin}
disabled={joining || joinCode.trim().length < 6}
className="flex h-11 items-center gap-1.5 rounded-xl bg-surface px-4 text-sm font-semibold text-gray-300 ring-1 ring-border transition-colors hover:bg-elevated disabled:opacity-40"
>
{joining ? <Loader2 size={16} className="animate-spin" /> : <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-1 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}
className="h-10 flex-1 rounded-xl border-none bg-surface px-3 text-sm text-foreground outline-none ring-1 ring-border transition-all placeholder:text-dim focus:ring-2 focus:ring-purple-600"
/>
<button
onClick={handleCreate}
disabled={creating}
className="flex h-10 items-center gap-1.5 rounded-xl bg-purple-600 px-4 text-xs font-bold text-white transition-colors hover:bg-purple-500 disabled:opacity-50"
>
{creating ? <Loader2 size={14} className="animate-spin" /> : <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}
className="h-10 flex-1 rounded-xl border-none bg-surface px-3 text-center font-mono text-sm tracking-[0.15em] text-foreground outline-none ring-1 ring-border transition-all placeholder:font-sans placeholder:tracking-normal placeholder:text-dim focus:ring-2 focus:ring-purple-600"
/>
<button
onClick={handleJoin}
disabled={joining || joinCode.trim().length < 6}
className="flex h-10 items-center gap-1.5 rounded-xl bg-surface px-4 text-xs font-semibold text-gray-300 ring-1 ring-border transition-colors hover:bg-elevated disabled:opacity-40"
>
{joining ? <Loader2 size={14} className="animate-spin" /> : <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-white">{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>
);
}