04c7b547aa
- 新增 /api/auth/register 和 /api/auth/login 接口,使用 bcryptjs 哈希密码 - User 模型改为 username + passwordHash,id 自动生成 cuid - 新增 AuthModal 组件(登录/注册双标签页),替换旧的 ProfileSetupModal - 重写 /profile 页面:支持修改用户名、密码、头像、绑定邮箱、退出登录 - /api/user PUT 支持密码修改(需验证当前密码)和用户名唯一性校验 - 游客模式保留,右上角显示"登录"按钮;登录后显示头像和用户名 - 全局 nickname -> username 重命名(types、SwipeDeck、RoomManageModal、buildRoomStatus) - 新增 logout() 清除登录态并重新生成游客 UUID
281 lines
11 KiB
TypeScript
281 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef, useCallback } from "react";
|
||
import { motion, AnimatePresence } from "framer-motion";
|
||
import {
|
||
X,
|
||
Lock,
|
||
Unlock,
|
||
UserX,
|
||
Flag,
|
||
Crown,
|
||
Loader2,
|
||
} from "lucide-react";
|
||
import { UserProfile } from "@/types";
|
||
import { getAvatar, getAvatarBg } from "@/lib/avatars";
|
||
|
||
interface RoomManageModalProps {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
roomId: string;
|
||
userId: string;
|
||
users: string[];
|
||
locked: boolean;
|
||
swipeCounts: Record<string, number>;
|
||
totalCards: number;
|
||
userProfiles: Record<string, UserProfile>;
|
||
onToast: (msg: string) => void;
|
||
}
|
||
|
||
export default function RoomManageModal({
|
||
open,
|
||
onClose,
|
||
roomId,
|
||
userId,
|
||
users,
|
||
locked,
|
||
swipeCounts,
|
||
totalCards,
|
||
userProfiles,
|
||
onToast,
|
||
}: RoomManageModalProps) {
|
||
const backdropRef = useRef<HTMLDivElement>(null);
|
||
const [loading, setLoading] = useState<string | null>(null);
|
||
const [confirmKick, setConfirmKick] = useState<string | null>(null);
|
||
const [confirmEnd, setConfirmEnd] = useState(false);
|
||
|
||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||
if (e.target === backdropRef.current) onClose();
|
||
};
|
||
|
||
const manage = useCallback(
|
||
async (action: string, targetUserId?: string) => {
|
||
setLoading(action + (targetUserId ?? ""));
|
||
try {
|
||
const res = await fetch(`/api/room/${roomId}/manage`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ userId, action, targetUserId }),
|
||
});
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
onToast(data.error ?? "操作失败");
|
||
return;
|
||
}
|
||
switch (action) {
|
||
case "lock":
|
||
onToast("房间已锁定,其他人无法加入");
|
||
break;
|
||
case "unlock":
|
||
onToast("房间已解锁");
|
||
break;
|
||
case "kick":
|
||
onToast("已将该用户移出房间");
|
||
setConfirmKick(null);
|
||
break;
|
||
case "end_voting":
|
||
onToast("已结束投票,正在结算结果");
|
||
setConfirmEnd(false);
|
||
onClose();
|
||
break;
|
||
}
|
||
} catch {
|
||
onToast("操作失败,请重试");
|
||
} finally {
|
||
setLoading(null);
|
||
}
|
||
},
|
||
[roomId, userId, onToast, onClose],
|
||
);
|
||
|
||
const otherUsers = users.filter((u) => u !== userId);
|
||
|
||
return (
|
||
<AnimatePresence>
|
||
{open && (
|
||
<motion.div
|
||
ref={backdropRef}
|
||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm sm:items-center"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ duration: 0.2 }}
|
||
onClick={handleBackdropClick}
|
||
>
|
||
<motion.div
|
||
className="relative w-full max-w-sm rounded-t-3xl bg-white px-5 pb-8 pt-5 shadow-2xl sm:rounded-3xl sm:pb-6"
|
||
initial={{ y: "100%" }}
|
||
animate={{ y: 0 }}
|
||
exit={{ y: "100%" }}
|
||
transition={{ type: "spring", damping: 28, stiffness: 350 }}
|
||
>
|
||
<button
|
||
onClick={onClose}
|
||
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full bg-zinc-100 text-zinc-400 transition-colors active:bg-zinc-200"
|
||
>
|
||
<X size={16} />
|
||
</button>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<Crown size={18} className="text-amber-500" />
|
||
<h2 className="text-lg font-bold text-zinc-900">房间管理</h2>
|
||
</div>
|
||
<p className="mt-1 text-xs text-zinc-400">
|
||
房间号 {roomId}
|
||
</p>
|
||
|
||
{/* Lock/Unlock */}
|
||
<div className="mt-5">
|
||
<button
|
||
onClick={() => manage(locked ? "unlock" : "lock")}
|
||
disabled={loading !== null}
|
||
className={`flex h-11 w-full items-center justify-center gap-2 rounded-xl text-sm font-semibold transition-colors disabled:opacity-50 ${
|
||
locked
|
||
? "border border-emerald-200 bg-emerald-50 text-emerald-700 active:bg-emerald-100"
|
||
: "border border-zinc-200 bg-white text-zinc-700 active:bg-zinc-50"
|
||
}`}
|
||
>
|
||
{loading === "lock" || loading === "unlock" ? (
|
||
<Loader2 size={15} className="animate-spin" />
|
||
) : locked ? (
|
||
<Unlock size={15} />
|
||
) : (
|
||
<Lock size={15} />
|
||
)}
|
||
{locked ? "解锁房间(允许新人加入)" : "锁定房间(阻止新人加入)"}
|
||
</button>
|
||
</div>
|
||
|
||
{/* User list with kick */}
|
||
<div className="mt-5">
|
||
<h3 className="text-xs font-semibold text-zinc-500">
|
||
房间成员({users.length})
|
||
</h3>
|
||
<div className="mt-2 flex flex-col gap-1.5">
|
||
{users.map((uid) => {
|
||
const profile = userProfiles[uid];
|
||
const emoji = profile?.avatar ?? getAvatar(uid).emoji;
|
||
const bg = profile ? getAvatarBg(profile.avatar) : getAvatar(uid).bg;
|
||
const displayName = profile?.username ?? uid.slice(0, 8);
|
||
const isCreator = uid === userId;
|
||
const swiped = swipeCounts[uid] ?? 0;
|
||
const finished = swiped >= totalCards;
|
||
|
||
return (
|
||
<div
|
||
key={uid}
|
||
className="flex items-center gap-2.5 rounded-xl bg-zinc-50 px-3 py-2.5"
|
||
>
|
||
<span
|
||
className={`inline-flex h-8 w-8 items-center justify-center rounded-full text-base ${bg}`}
|
||
>
|
||
{emoji}
|
||
</span>
|
||
<div className="flex min-w-0 flex-1 flex-col">
|
||
<div className="flex items-center gap-1.5">
|
||
{isCreator && (
|
||
<span className="flex items-center gap-0.5 text-[10px] font-bold text-amber-500">
|
||
<Crown size={10} />
|
||
房主
|
||
</span>
|
||
)}
|
||
<span className="truncate text-xs font-medium text-zinc-500">
|
||
{displayName}
|
||
</span>
|
||
</div>
|
||
<span
|
||
className={`text-[11px] ${finished ? "text-emerald-500" : "text-zinc-400"}`}
|
||
>
|
||
{swiped}/{totalCards}
|
||
{finished ? " 已完成" : " 进行中"}
|
||
</span>
|
||
</div>
|
||
|
||
{!isCreator && (
|
||
<>
|
||
{confirmKick === uid ? (
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => manage("kick", uid)}
|
||
disabled={loading !== null}
|
||
className="rounded-lg bg-rose-500 px-2.5 py-1 text-[11px] font-semibold text-white transition-colors active:bg-rose-600 disabled:opacity-50"
|
||
>
|
||
{loading === "kick" + uid ? (
|
||
<Loader2
|
||
size={12}
|
||
className="animate-spin"
|
||
/>
|
||
) : (
|
||
"确认"
|
||
)}
|
||
</button>
|
||
<button
|
||
onClick={() => setConfirmKick(null)}
|
||
className="rounded-lg bg-zinc-200 px-2.5 py-1 text-[11px] font-semibold text-zinc-600 transition-colors active:bg-zinc-300"
|
||
>
|
||
取消
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
onClick={() => setConfirmKick(uid)}
|
||
className="flex items-center gap-0.5 rounded-lg px-2 py-1 text-[11px] font-medium text-zinc-400 transition-colors active:bg-zinc-100 active:text-rose-500"
|
||
>
|
||
<UserX size={13} />
|
||
移出
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* End voting */}
|
||
<div className="mt-5">
|
||
{confirmEnd ? (
|
||
<div className="flex flex-col gap-2 rounded-xl border border-amber-200 bg-amber-50 p-3">
|
||
<p className="text-xs font-medium text-amber-800">
|
||
确定要结束投票吗?将根据当前已有的投票结果直接结算。
|
||
</p>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => manage("end_voting")}
|
||
disabled={loading !== null}
|
||
className="flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg bg-amber-500 text-xs font-semibold text-white transition-colors active:bg-amber-600 disabled:opacity-50"
|
||
>
|
||
{loading === "end_voting" ? (
|
||
<Loader2 size={13} className="animate-spin" />
|
||
) : (
|
||
<Flag size={13} />
|
||
)}
|
||
确认结束
|
||
</button>
|
||
<button
|
||
onClick={() => setConfirmEnd(false)}
|
||
className="flex h-9 flex-1 items-center justify-center rounded-lg bg-white text-xs font-semibold text-zinc-600 transition-colors active:bg-zinc-50"
|
||
>
|
||
再等等
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<button
|
||
onClick={() => setConfirmEnd(true)}
|
||
disabled={loading !== null}
|
||
className="flex h-11 w-full items-center justify-center gap-2 rounded-xl border border-amber-200 bg-amber-50 text-sm font-semibold text-amber-700 transition-colors active:bg-amber-100 disabled:opacity-50"
|
||
>
|
||
<Flag size={15} />
|
||
结束投票(立即出结果)
|
||
</button>
|
||
)}
|
||
</div>
|
||
</motion.div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
);
|
||
}
|