refactor: 提取 Modal 基础组件,消除 4 个弹窗的重复样板代码
将 backdrop 遮罩、点击关闭、AnimatePresence 动画封装为 Modal 组件, 支持 sheet(底部弹出)和 dialog(居中缩放)两种变体,净减约 110 行。
This commit is contained in:
+157
-185
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
X,
|
||||
Lock,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { UserProfile } from "@/types";
|
||||
import { getAvatar, getAvatarBg } from "@/lib/avatars";
|
||||
import Modal from "@/components/Modal";
|
||||
|
||||
interface RoomManageModalProps {
|
||||
open: boolean;
|
||||
@@ -39,15 +39,10 @@ export default function RoomManageModal({
|
||||
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 ?? ""));
|
||||
@@ -91,190 +86,167 @@ export default function RoomManageModal({
|
||||
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}
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<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>
|
||||
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
{loading === "lock" || loading === "unlock" ? (
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
) : locked ? (
|
||||
<Unlock size={15} />
|
||||
) : (
|
||||
<Lock size={15} />
|
||||
)}
|
||||
{locked ? "解锁房间(允许新人加入)" : "锁定房间(阻止新人加入)"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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;
|
||||
|
||||
{/* 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"
|
||||
}`}
|
||||
return (
|
||||
<div
|
||||
key={uid}
|
||||
className="flex items-center gap-2.5 rounded-xl bg-elevated px-3 py-2.5"
|
||||
>
|
||||
{loading === "lock" || loading === "unlock" ? (
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
) : locked ? (
|
||||
<Unlock size={15} />
|
||||
) : (
|
||||
<Lock size={15} />
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{locked ? "解锁房间(允许新人加入)" : "锁定房间(阻止新人加入)"}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
</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>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user