"use client"; import { useState, useEffect, useCallback, useRef } from "react"; import { useParams, useRouter } from "next/navigation"; import { motion, AnimatePresence, useAnimation } from "framer-motion"; import { ArrowLeft, Send, Loader2, Package, Flame, Trophy, Users, Share2, LogIn, Copy, Trash2, LogOut, Pencil, Check, X, } from "lucide-react"; import confetti from "canvas-confetti"; import { getCachedProfile, isRegistered } from "@/lib/userId"; import ShareCardModal from "@/components/ShareCardModal"; import Button from "@/components/Button"; import { useToast } from "@/hooks/useToast"; import { BlindboxRoomSkeleton } from "@/components/Skeleton"; import type { UserProfile } from "@/types"; interface RoomInfo { id: string; code: string; name: string; creatorId: string; poolCount: number; members: { id: string; username: string; avatar: string }[]; } interface MyIdea { id: string; content: string; createdAt: string; } interface DrawnIdea { id: string; content: string; createdAt: string; user?: { id: string; username: string; avatar: string }; drawnBy?: { id: string; username: string; avatar: string } | null; } type Phase = "pool" | "shaking" | "reveal"; function MyIdeaItem({ idea, onEdit, onDelete, }: { idea: MyIdea; onEdit: (id: string, content: string) => Promise; onDelete: (id: string) => Promise; }) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(idea.content); const [saving, setSaving] = useState(false); const handleSave = async () => { if (!draft.trim() || saving) return; setSaving(true); await onEdit(idea.id, draft); setSaving(false); setEditing(false); }; return ( {editing ? ( <> setDraft(e.target.value.slice(0, 200))} onKeyDown={(e) => { if (e.key === "Enter") handleSave(); if (e.key === "Escape") { setEditing(false); setDraft(idea.content); } }} maxLength={200} autoFocus className="h-8 min-w-0 flex-1 rounded-lg bg-elevated px-2.5 text-sm text-foreground outline-none ring-1 ring-border focus:ring-2 focus:ring-purple-600/50" /> ) : ( <> 💡

{idea.content}

)}
); } function MyIdeasSection({ ideas, onEdit, onDelete, }: { ideas: MyIdea[]; onEdit: (id: string, content: string) => Promise; onDelete: (id: string) => Promise; }) { return (

我投入的想法({ideas.length})

{ideas.map((idea) => ( ))}
); } export default function BlindboxRoomPage() { const { code } = useParams<{ code: string }>(); const router = useRouter(); const [profile, setProfile] = useState(null); const [room, setRoom] = useState(null); const [isMember, setIsMember] = useState(false); const [joiningRoom, setJoiningRoom] = useState(false); const [pageLoading, setPageLoading] = useState(true); const [input, setInput] = useState(""); const [submitting, setSubmitting] = useState(false); const [poolCount, setPoolCount] = useState(0); const [myIdeas, setMyIdeas] = useState([]); const [drawnHistory, setDrawnHistory] = useState([]); const [phase, setPhase] = useState("pool"); const [revealedIdea, setRevealedIdea] = useState(null); const [submitFlash, setSubmitFlash] = useState(false); const [error, setError] = useState(""); const [showInvite, setShowInvite] = useState(false); const [showShareCard, setShowShareCard] = useState(false); const toast = useToast(); const [confirmLeave, setConfirmLeave] = useState(false); const [leaving, setLeaving] = useState(false); const boxControls = useAnimation(); const inputRef = useRef(null); const confettiCanvasRef = useRef(null); useEffect(() => { if (!isRegistered()) { router.replace("/blindbox"); return; } setProfile(getCachedProfile()); }, [router]); const fetchRoom = useCallback(async () => { if (!code) return; try { const res = await fetch(`/api/blindbox/room/${code}`); if (!res.ok) { router.replace("/blindbox"); return; } const data: RoomInfo = await res.json(); setRoom(data); const p = getCachedProfile(); const memberCheck = data.members.some((m) => m.id === p?.id); setIsMember(memberCheck); setPoolCount(data.poolCount); } catch { router.replace("/blindbox"); } finally { setPageLoading(false); } }, [code, router]); useEffect(() => { fetchRoom(); }, [fetchRoom]); const fetchIdeas = useCallback(async () => { const p = getCachedProfile(); if (!room || !p) return; try { const res = await fetch(`/api/blindbox?roomId=${room.id}&userId=${p.id}`); if (res.ok) { const data = await res.json(); setPoolCount(data.poolCount ?? 0); setMyIdeas(data.myIdeas ?? []); setDrawnHistory(data.drawn ?? []); } } catch { /* ignore */ } }, [room]); useEffect(() => { if (isMember && room) fetchIdeas(); }, [isMember, room, fetchIdeas]); useEffect(() => { if (isMember && inputRef.current) { setTimeout(() => inputRef.current?.focus(), 300); } }, [isMember]); const handleJoinRoom = async () => { if (joiningRoom || !profile || !room) return; setJoiningRoom(true); try { const res = await fetch("/api/blindbox/room/join", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId: profile.id, code }), }); if (res.ok) { setIsMember(true); fetchRoom(); } } catch { /* ignore */ } finally { setJoiningRoom(false); } }; const handleSubmit = async () => { const text = input.trim(); if (!text || submitting || !profile || !room) return; setSubmitting(true); setError(""); try { const res = await fetch("/api/blindbox", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ roomId: room.id, userId: profile.id, content: text }), }); if (!res.ok) { const data = await res.json(); throw new Error(data.error || "提交失败"); } const { id } = await res.json(); setInput(""); setPoolCount((c) => c + 1); setMyIdeas((prev) => [{ id, content: text, createdAt: new Date().toISOString() }, ...prev]); setSubmitFlash(true); setTimeout(() => setSubmitFlash(false), 600); boxControls.start({ scale: [1, 1.08, 1], rotate: [0, -3, 3, 0], transition: { duration: 0.5 }, }); } catch (e) { setError(e instanceof Error ? e.message : "提交失败"); } finally { setSubmitting(false); } }; const handleEditIdea = useCallback(async (ideaId: string, newContent: string) => { if (!profile) return; const trimmed = newContent.trim(); if (!trimmed || trimmed.length > 200) return; try { const res = await fetch("/api/blindbox", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ideaId, userId: profile.id, content: trimmed }), }); if (!res.ok) { const data = await res.json(); throw new Error(data.error || "编辑失败"); } setMyIdeas((prev) => prev.map((i) => (i.id === ideaId ? { ...i, content: trimmed } : i))); } catch (e) { toast.show(e instanceof Error ? e.message : "编辑失败"); } }, [profile]); const handleDeleteIdea = useCallback(async (ideaId: string) => { if (!profile) return; try { const res = await fetch("/api/blindbox", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ideaId, userId: profile.id }), }); if (!res.ok) { const data = await res.json(); throw new Error(data.error || "删除失败"); } setMyIdeas((prev) => prev.filter((i) => i.id !== ideaId)); setPoolCount((c) => Math.max(0, c - 1)); } catch (e) { toast.show(e instanceof Error ? e.message : "删除失败"); } }, [profile]); const handleDraw = async () => { if (poolCount === 0 || !profile || !room) { setError("盒子是空的,先往里面塞点想法吧!"); return; } setPhase("shaking"); setError(""); await boxControls.start({ rotate: [0, -8, 8, -10, 10, -12, 12, -8, 8, -4, 4, 0], scale: [1, 1.05, 0.95, 1.08, 0.92, 1.1, 0.9, 1.05, 0.95, 1], transition: { duration: 2.5, ease: "easeInOut" }, }); try { const res = await fetch("/api/blindbox/draw", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ roomId: room.id, userId: profile.id }), }); if (!res.ok) { const data = await res.json(); throw new Error(data.error || "抽取失败"); } const idea = await res.json(); setRevealedIdea(idea); setPhase("reveal"); setPoolCount((c) => Math.max(0, c - 1)); setDrawnHistory((prev) => [idea, ...prev]); fireConfetti(); } catch (e) { setError(e instanceof Error ? e.message : "抽取失败"); setPhase("pool"); } }; const fireConfetti = () => { const colors = ["#a855f7", "#6366f1", "#ec4899", "#f59e0b", "#10b981"]; confetti({ particleCount: 100, spread: 120, origin: { y: 0.4 }, colors, startVelocity: 45, ticks: 250 }); const end = Date.now() + 3000; const frame = () => { if (Date.now() > end) return; confetti({ particleCount: 3, angle: 60, spread: 55, origin: { x: 0, y: 0.6 }, colors, startVelocity: 35, ticks: 150 }); confetti({ particleCount: 3, angle: 120, spread: 55, origin: { x: 1, y: 0.6 }, colors, startVelocity: 35, ticks: 150 }); requestAnimationFrame(frame); }; setTimeout(frame, 200); }; const handleCopyCode = async () => { if (!room) return; try { await navigator.clipboard.writeText(room.code); toast.show("房间号已复制"); } catch { /* ignore */ } }; const handleShare = async () => { if (!room) return; const url = typeof window !== "undefined" ? `${window.location.origin}/blindbox/${room.code}` : ""; const shareData = { title: `周末契约 · ${room.name}`, text: `来和我一起玩周末盲盒吧!房间号:${room.code}`, url, }; try { if (navigator.share && navigator.canShare?.(shareData)) { await navigator.share(shareData); return; } } catch (e) { if (e instanceof Error && e.name === "AbortError") return; } handleCopyCode(); }; const isCreator = profile?.id === room?.creatorId; const handleLeaveOrDelete = async () => { if (!confirmLeave) { setConfirmLeave(true); setTimeout(() => setConfirmLeave(false), 3000); return; } if (leaving || !profile || !room) return; setLeaving(true); try { const res = await fetch(`/api/blindbox/room/${room.code}`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId: profile.id }), }); if (!res.ok) { const data = await res.json(); throw new Error(data.error || "操作失败"); } router.replace("/blindbox"); } catch (e) { toast.show(e instanceof Error ? e.message : "操作失败"); setConfirmLeave(false); } finally { setLeaving(false); } }; if (pageLoading) { return ; } if (!room) return null; return (
{/* Header */}

{room.name}

房间 {room.code}

{/* Members */}
{room.members.slice(0, 4).map((m) => (
{m.avatar}
))} {room.members.length > 4 && (
+{room.members.length - 4}
)}
{/* Invite panel */} {showInvite && (
房间号 {room.code}
)} {/* Non-member state */} {!isMember ? (

你还不是这个房间的成员

) : ( <> {/* Blind Box Visual */}
盒子里已有{" "} {poolCount}{" "} 个想法
{/* Pool / Shaking / Reveal phases */} {phase === "pool" && (
{ setInput(e.target.value); setError(""); }} onKeyDown={(e) => { if (e.key === "Enter") handleSubmit(); }} maxLength={200} disabled={submitting} className="h-12 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 disabled:opacity-50" />
开启周末盲盒(绝不反悔) {error && ( {error} )} )} {phase === "shaking" && (

命运正在决定...

{[0, 1, 2].map((i) => ( ))}
)} {phase === "reveal" && revealedIdea && (

✦ 周末契约 ✦

{revealedIdea.content}
{/* Attribution */}
{revealedIdea.user && ( {revealedIdea.user.avatar} {revealedIdea.user.username} 投入 )} {revealedIdea.drawnBy && ( <> · {revealedIdea.drawnBy.avatar} {revealedIdea.drawnBy.username} 抽中 )}

此契约一旦开启,绝不反悔

)} {/* My ideas in pool */} {myIdeas.length > 0 && phase === "pool" && ( )} {/* History */} {drawnHistory.length > 0 && phase !== "shaking" && (

履约记录

{drawnHistory.map((item, i) => ( 🏆

{item.content}

{item.user && ( {item.user.avatar} {item.user.username} 投入 )} {item.drawnBy && ( <> · {item.drawnBy.avatar} {item.drawnBy.username} 抽中 )} · {new Date(item.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric", weekday: "short", })}
))}
)} )} {revealedIdea && room && ( setShowShareCard(false)} data={{ type: "blindbox", idea: revealedIdea.content, submitter: revealedIdea.user ?? undefined, drawer: revealedIdea.drawnBy ?? undefined, roomName: room.name, }} /> )} {/* Leave / Delete */} {isMember && room && ( )}
); }