Files
no-whatever/src/app/blindbox/[code]/page.tsx
T
kurihada 455b9e04d8 refactor: 提取 Button 组件,统一按钮变体、尺寸和加载状态
新增 Button.tsx 支持 5 种变体(primary/secondary/danger/ghost/purple)、
3 种尺寸(sm/md/lg)、pill/rounded 形状及内置 loading 状态,
替换 8 个文件中 16 处重复的按钮样板代码。
2026-02-26 18:39:14 +08:00

857 lines
31 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, 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<void>;
onDelete: (id: string) => Promise<void>;
}) {
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 (
<motion.div
layout
className="flex items-center gap-2 rounded-xl bg-surface/60 px-3 py-2.5 ring-1 ring-border/80"
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -20 }}
>
{editing ? (
<>
<input
type="text"
value={draft}
onChange={(e) => 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"
/>
<button
onClick={handleSave}
disabled={saving || !draft.trim()}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-purple-600 text-white disabled:opacity-40"
>
{saving ? <Loader2 size={12} className="animate-spin" /> : <Check size={12} />}
</button>
<button
onClick={() => { setEditing(false); setDraft(idea.content); }}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-elevated text-muted"
>
<X size={12} />
</button>
</>
) : (
<>
<span className="text-sm">💡</span>
<p className="min-w-0 flex-1 truncate text-sm text-secondary">{idea.content}</p>
<button
onClick={() => setEditing(true)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-muted transition-colors active:bg-elevated active:text-purple-400"
>
<Pencil size={12} />
</button>
<button
onClick={() => onDelete(idea.id)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-muted transition-colors active:bg-elevated active:text-rose-400"
>
<Trash2 size={12} />
</button>
</>
)}
</motion.div>
);
}
function MyIdeasSection({
ideas,
onEdit,
onDelete,
}: {
ideas: MyIdea[];
onEdit: (id: string, content: string) => Promise<void>;
onDelete: (id: string) => Promise<void>;
}) {
return (
<motion.div
className="mt-6 w-full max-w-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
<div className="mb-2 flex items-center gap-2">
<Package size={13} className="text-purple-400" />
<h3 className="text-xs font-bold tracking-wider text-muted">
{ideas.length}
</h3>
<div className="h-px flex-1 bg-border" />
</div>
<div className="flex flex-col gap-1.5">
<AnimatePresence>
{ideas.map((idea) => (
<MyIdeaItem key={idea.id} idea={idea} onEdit={onEdit} onDelete={onDelete} />
))}
</AnimatePresence>
</div>
</motion.div>
);
}
export default function BlindboxRoomPage() {
const { code } = useParams<{ code: string }>();
const router = useRouter();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [room, setRoom] = useState<RoomInfo | null>(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<MyIdea[]>([]);
const [drawnHistory, setDrawnHistory] = useState<DrawnIdea[]>([]);
const [phase, setPhase] = useState<Phase>("pool");
const [revealedIdea, setRevealedIdea] = useState<DrawnIdea | null>(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<HTMLInputElement>(null);
const confettiCanvasRef = useRef<HTMLCanvasElement>(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 <BlindboxRoomSkeleton />;
}
if (!room) return null;
return (
<div className="relative flex min-h-dvh flex-col items-center bg-background px-5 py-6 overflow-y-auto scrollbar-none">
<canvas ref={confettiCanvasRef} className="pointer-events-none fixed inset-0 z-50" />
{/* Header */}
<div className="flex w-full max-w-sm items-center gap-3">
<button
onClick={() => router.push("/blindbox")}
className="flex h-8 w-8 shrink-0 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="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-heading">{room.name}</p>
<p className="text-[10px] text-dim"> {room.code}</p>
</div>
{/* Members */}
<div className="flex -space-x-1.5">
{room.members.slice(0, 4).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-background"
title={m.username}
>
{m.avatar}
</div>
))}
{room.members.length > 4 && (
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-elevated text-[10px] font-bold text-muted ring-2 ring-background">
+{room.members.length - 4}
</div>
)}
</div>
<button
onClick={() => setShowInvite(!showInvite)}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-purple-600/15 text-purple-400 ring-1 ring-purple-500/20 transition-colors active:bg-purple-600/25"
>
<Share2 size={14} />
</button>
</div>
{/* Invite panel */}
<AnimatePresence>
{showInvite && (
<motion.div
className="mt-3 w-full max-w-sm overflow-hidden rounded-xl bg-surface ring-1 ring-border"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
>
<div className="flex items-center gap-3 p-3">
<span className="text-xs text-muted"></span>
<span className="font-mono text-base font-bold tracking-[0.2em] text-purple-400">
{room.code}
</span>
<div className="flex-1" />
<button
onClick={handleCopyCode}
className="flex h-8 items-center gap-1 rounded-lg bg-elevated px-3 text-xs font-medium text-secondary ring-1 ring-border transition-colors active:bg-subtle"
>
<Copy size={12} />
</button>
<button
onClick={handleShare}
className="flex h-8 items-center gap-1 rounded-lg bg-purple-600 px-3 text-xs font-medium text-white transition-colors active:bg-purple-500"
>
<Share2 size={12} />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Non-member state */}
{!isMember ? (
<motion.div
className="mt-16 flex flex-col items-center gap-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<Package size={40} className="text-purple-400/50" strokeWidth={1.5} />
<p className="text-sm text-tertiary"></p>
<Button
onClick={handleJoinRoom}
variant="purple"
size="lg"
loading={joiningRoom}
icon={<LogIn size={16} />}
>
</Button>
</motion.div>
) : (
<>
{/* Blind Box Visual */}
<div className="mt-8 flex flex-col items-center">
<motion.div
animate={boxControls}
className="relative flex h-36 w-36 items-center justify-center"
>
<div className="absolute inset-0 rounded-3xl bg-linear-to-br from-purple-600/20 to-indigo-600/20 blur-xl" />
<div className="relative flex h-32 w-32 flex-col items-center justify-center rounded-2xl bg-linear-to-br from-indigo-900 to-purple-900 shadow-2xl shadow-purple-900/50 ring-1 ring-purple-700/30">
<div className="absolute -top-2 left-1/2 h-5 w-[90%] -translate-x-1/2 rounded-t-xl bg-linear-to-r from-purple-700 to-indigo-700 shadow-md" />
<div className="absolute top-3 h-full w-3 bg-linear-to-b from-amber-400/60 to-amber-400/10" />
<div className="absolute top-6 h-3 w-full bg-linear-to-r from-amber-400/0 via-amber-400/60 to-amber-400/0" />
<motion.div
animate={submitFlash ? { scale: [1, 1.3, 1] } : {}}
transition={{ duration: 0.3 }}
>
<Package size={40} className="relative z-10 text-purple-300/60" strokeWidth={1.5} />
</motion.div>
<motion.div
className="absolute -right-2 -top-2 text-lg"
animate={{ rotate: [0, 15, -15, 0], scale: [1, 1.2, 1] }}
transition={{ duration: 2, repeat: Infinity, repeatDelay: 3 }}
>
</motion.div>
</div>
</motion.div>
<motion.p
className="mt-4 text-sm font-semibold text-muted"
key={poolCount}
initial={{ scale: 1.2, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
>
{" "}
<span className="text-lg font-black text-purple-400">{poolCount}</span>{" "}
</motion.p>
</div>
{/* Pool / Shaking / Reveal phases */}
<AnimatePresence mode="wait">
{phase === "pool" && (
<motion.div
key="pool"
className="mt-8 flex w-full max-w-sm flex-col items-center gap-5"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<div className="flex w-full gap-2">
<input
ref={inputRef}
type="text"
placeholder="塞入一个疯狂的周末想法..."
value={input}
onChange={(e) => { 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"
/>
<button
onClick={handleSubmit}
disabled={!input.trim() || submitting}
className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-600 text-white transition-colors hover:bg-purple-500 disabled:opacity-30"
>
{submitting ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button>
</div>
<motion.button
onClick={handleDraw}
disabled={poolCount === 0}
className="relative flex h-14 w-full items-center justify-center gap-2 overflow-hidden rounded-2xl bg-linear-to-r from-red-600 to-rose-500 text-base font-black text-white shadow-lg shadow-red-900/40 transition-shadow hover:shadow-xl hover:shadow-red-900/50 disabled:opacity-40 disabled:shadow-none"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.97 }}
>
<div className="absolute inset-0 bg-linear-to-r from-transparent via-white/10 to-transparent -translate-x-full animate-[shimmer_3s_infinite]" />
<Flame size={20} />
</motion.button>
{error && (
<motion.p
className="text-center text-xs font-medium text-rose-400"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
>
{error}
</motion.p>
)}
</motion.div>
)}
{phase === "shaking" && (
<motion.div
key="shaking"
className="mt-8 flex flex-col items-center gap-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<p className="text-sm font-bold text-purple-300 animate-pulse">
...
</p>
<div className="flex gap-1">
{[0, 1, 2].map((i) => (
<motion.div
key={i}
className="h-2 w-2 rounded-full bg-purple-400"
animate={{ y: [0, -8, 0] }}
transition={{ duration: 0.6, repeat: Infinity, delay: i * 0.15 }}
/>
))}
</div>
</motion.div>
)}
{phase === "reveal" && revealedIdea && (
<motion.div
key="reveal"
className="mt-8 flex w-full max-w-sm flex-col items-center gap-5"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", damping: 15, stiffness: 200 }}
>
<div className="relative w-full overflow-hidden rounded-2xl bg-linear-to-br from-purple-900 via-indigo-900 to-purple-950 p-6 shadow-2xl shadow-purple-900/50 ring-1 ring-purple-600/30">
<div className="absolute left-3 top-3 h-6 w-6 border-l-2 border-t-2 border-purple-400/30 rounded-tl-sm" />
<div className="absolute right-3 top-3 h-6 w-6 border-r-2 border-t-2 border-purple-400/30 rounded-tr-sm" />
<div className="absolute bottom-3 left-3 h-6 w-6 border-b-2 border-l-2 border-purple-400/30 rounded-bl-sm" />
<div className="absolute bottom-3 right-3 h-6 w-6 border-b-2 border-r-2 border-purple-400/30 rounded-br-sm" />
<div className="relative z-10 text-center">
<p className="text-xs font-bold tracking-[0.3em] text-purple-400/70">
</p>
<motion.p
className="mt-4 text-xl font-black leading-relaxed text-white"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
{revealedIdea.content}
</motion.p>
<div className="mx-auto mt-4 h-px w-16 bg-linear-to-r from-transparent via-purple-400/50 to-transparent" />
{/* Attribution */}
<div className="mt-3 flex items-center justify-center gap-2 text-[11px] text-purple-400/50">
{revealedIdea.user && (
<span>
{revealedIdea.user.avatar} {revealedIdea.user.username}
</span>
)}
{revealedIdea.drawnBy && (
<>
<span>·</span>
<span>
{revealedIdea.drawnBy.avatar} {revealedIdea.drawnBy.username}
</span>
</>
)}
</div>
<p className="mt-2 text-[10px] font-medium text-purple-400/40">
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Button
onClick={() => setShowShareCard(true)}
variant="purple"
shape="pill"
icon={<Share2 size={14} />}
>
</Button>
<Button
onClick={() => { setPhase("pool"); setRevealedIdea(null); setShowShareCard(false); }}
variant="secondary"
shape="pill"
>
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* My ideas in pool */}
{myIdeas.length > 0 && phase === "pool" && (
<MyIdeasSection
ideas={myIdeas}
onEdit={handleEditIdea}
onDelete={handleDeleteIdea}
/>
)}
{/* History */}
{drawnHistory.length > 0 && phase !== "shaking" && (
<motion.div
className="mt-10 w-full max-w-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
>
<div className="mb-3 flex items-center gap-2">
<Trophy size={13} className="text-amber-400" />
<h3 className="text-xs font-bold tracking-wider text-muted">
</h3>
<div className="h-px flex-1 bg-border" />
</div>
<div className="flex flex-col gap-2">
{drawnHistory.map((item, i) => (
<motion.div
key={item.id}
className="flex items-start gap-3 rounded-xl bg-surface/60 px-4 py-3 ring-1 ring-border/80"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.06 }}
>
<span className="mt-0.5 text-sm">🏆</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-secondary">
{item.content}
</p>
<div className="mt-1 flex items-center gap-2 text-[10px] text-dim">
{item.user && (
<span>{item.user.avatar} {item.user.username} </span>
)}
{item.drawnBy && (
<>
<span>·</span>
<span>{item.drawnBy.avatar} {item.drawnBy.username} </span>
</>
)}
<span>·</span>
<span>
{new Date(item.createdAt).toLocaleDateString("zh-CN", {
month: "short",
day: "numeric",
weekday: "short",
})}
</span>
</div>
</div>
</motion.div>
))}
</div>
</motion.div>
)}
</>
)}
{revealedIdea && room && (
<ShareCardModal
open={showShareCard}
onClose={() => setShowShareCard(false)}
data={{
type: "blindbox",
idea: revealedIdea.content,
submitter: revealedIdea.user ?? undefined,
drawer: revealedIdea.drawnBy ?? undefined,
roomName: room.name,
}}
/>
)}
{/* Leave / Delete */}
{isMember && room && (
<motion.div
className="mt-12 w-full max-w-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
>
<button
onClick={handleLeaveOrDelete}
disabled={leaving}
className={`flex w-full items-center justify-center gap-2 rounded-xl py-2.5 text-xs font-medium transition-colors ${
confirmLeave
? "bg-rose-600/15 text-rose-400 ring-1 ring-rose-500/30"
: "text-muted hover:text-rose-400/80"
}`}
>
{leaving ? (
<Loader2 size={13} className="animate-spin" />
) : isCreator ? (
<Trash2 size={13} />
) : (
<LogOut size={13} />
)}
{confirmLeave
? isCreator ? "确认删除房间?所有想法将被清除" : "确认退出房间?"
: isCreator ? "删除房间" : "退出房间"}
</button>
</motion.div>
)}
<div className="h-8 shrink-0" />
</div>
);
}