Files
no-whatever/src/components/RoomManageModal.tsx
T
kurihada 04c7b547aa feat: 用户名密码登录注册系统
- 新增 /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
2026-02-25 00:21:03 +08:00

281 lines
11 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, 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>
);
}