Files
no-whatever/src/components/RoomManageModal.tsx
T
kurihada 12279117f3 feat: 全局主题切换(浅色/深色/跟随系统)
- CSS 变量驱动的主题系统,所有颜色响应 data-theme 属性
- 新增语义化色彩 heading/secondary/tertiary,替换硬编码 text-white/text-gray-*
- 右上角三态主题按钮(自动/浅色/深色),全局可用无需登录
- layout.tsx 内联脚本防闪烁
- 修复个人中心页面溢出无法滚动
2026-02-26 15:15:32 +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/60 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-surface px-5 pb-8 pt-5 shadow-2xl ring-1 ring-border 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-elevated text-muted transition-colors active:bg-subtle"
>
<X size={16} />
</button>
<div className="flex items-center gap-2">
<Crown size={18} className="text-amber-400" />
<h2 className="text-lg font-bold text-heading"></h2>
</div>
<p className="mt-1 text-xs text-muted">
{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
? "bg-accent/15 text-accent ring-1 ring-accent/30 active:bg-accent/25"
: "bg-elevated text-secondary ring-1 ring-border active:bg-subtle"
}`}
>
{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-muted">
{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-elevated 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-400">
<Crown size={10} />
</span>
)}
<span className="truncate text-xs font-medium text-tertiary">
{displayName}
</span>
</div>
<span
className={`text-[11px] ${finished ? "text-accent" : "text-muted"}`}
>
{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-subtle px-2.5 py-1 text-[11px] font-semibold text-tertiary transition-colors active:bg-elevated"
>
</button>
</div>
) : (
<button
onClick={() => setConfirmKick(uid)}
className="flex items-center gap-0.5 rounded-lg px-2 py-1 text-[11px] font-medium text-muted transition-colors active:bg-subtle active:text-rose-400"
>
<UserX size={13} />
</button>
)}
</>
)}
</div>
);
})}
</div>
</div>
{/* End voting */}
<div className="mt-5">
{confirmEnd ? (
<div className="flex flex-col gap-2 rounded-xl bg-amber-500/10 p-3 ring-1 ring-amber-500/30">
<p className="text-xs font-medium text-amber-300">
</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-elevated text-xs font-semibold text-tertiary transition-colors active:bg-subtle"
>
</button>
</div>
</div>
) : (
<button
onClick={() => setConfirmEnd(true)}
disabled={loading !== null}
className="flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-amber-500/10 text-sm font-semibold text-amber-400 ring-1 ring-amber-500/30 transition-colors active:bg-amber-500/20 disabled:opacity-50"
>
<Flag size={15} />
</button>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}