From 948274bcb9826bb0ee5a5adeaec8d63ce75e8640 Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 26 Feb 2026 17:45:52 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=8F=90=E5=8F=96=20Modal=20?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E7=BB=84=E4=BB=B6=EF=BC=8C=E6=B6=88=E9=99=A4?= =?UTF-8?q?=204=20=E4=B8=AA=E5=BC=B9=E7=AA=97=E7=9A=84=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E6=A0=B7=E6=9D=BF=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 backdrop 遮罩、点击关闭、AnimatePresence 动画封装为 Modal 组件, 支持 sheet(底部弹出)和 dialog(居中缩放)两种变体,净减约 110 行。 --- src/components/AuthModal.tsx | 297 +++++++++++------------ src/components/LeaveConfirmModal.tsx | 85 +++---- src/components/Modal.tsx | 75 ++++++ src/components/QrInviteModal.tsx | 131 +++++----- src/components/RoomManageModal.tsx | 342 ++++++++++++--------------- 5 files changed, 448 insertions(+), 482 deletions(-) create mode 100644 src/components/Modal.tsx diff --git a/src/components/AuthModal.tsx b/src/components/AuthModal.tsx index 52c9119..0e39765 100644 --- a/src/components/AuthModal.tsx +++ b/src/components/AuthModal.tsx @@ -1,11 +1,12 @@ "use client"; -import { useState, useRef } from "react"; -import { motion, AnimatePresence } from "framer-motion"; +import { useState } from "react"; +import { motion } from "framer-motion"; import { X, Loader2, Eye, EyeOff } from "lucide-react"; import { AVATARS } from "@/lib/avatars"; import { setCachedProfile } from "@/lib/userId"; import type { UserProfile } from "@/types"; +import Modal from "@/components/Modal"; type Tab = "login" | "register"; @@ -17,7 +18,6 @@ interface AuthModalProps { } export default function AuthModal({ open, onClose, onAuth, defaultTab = "login" }: AuthModalProps) { - const backdropRef = useRef(null); const [tab, setTab] = useState(defaultTab); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); @@ -27,10 +27,6 @@ export default function AuthModal({ open, onClose, onAuth, defaultTab = "login" const [loading, setLoading] = useState(false); const [error, setError] = useState(""); - const handleBackdropClick = (e: React.MouseEvent) => { - if (e.target === backdropRef.current) onClose(); - }; - const resetForm = () => { setUsername(""); setPassword(""); @@ -114,169 +110,144 @@ export default function AuthModal({ open, onClose, onAuth, defaultTab = "login" }; return ( - - {open && ( - +
+ 欢迎 + +
+ +
+ {(["login", "register"] as const).map((t) => ( + -
- - {/* Tabs */} -
- {(["login", "register"] as const).map((t) => ( - - ))} -
- - {/* Username */} -
-

用户名

- { - setUsername(e.target.value.slice(0, 16)); - setError(""); - }} - placeholder={tab === "register" ? "2-16 个字符" : "请输入用户名"} - maxLength={16} - className="mt-2 h-11 w-full rounded-xl border-none bg-elevated px-4 text-sm text-heading outline-none ring-1 ring-border transition-colors placeholder:text-dim focus:ring-2 focus:ring-accent/50" + {tab === t && ( + -
- - {/* Password */} -
-

密码

-
- { - setPassword(e.target.value); - setError(""); - }} - placeholder={tab === "register" ? "至少 6 个字符" : "请输入密码"} - className="h-11 w-full rounded-xl border-none bg-elevated px-4 pr-10 text-sm text-heading outline-none ring-1 ring-border transition-colors placeholder:text-dim focus:ring-2 focus:ring-accent/50" - /> - -
-
- - {/* Confirm password (register only) */} - {tab === "register" && ( -
-

确认密码

- { - setConfirmPassword(e.target.value); - setError(""); - }} - placeholder="再次输入密码" - className="mt-2 h-11 w-full rounded-xl border-none bg-elevated px-4 text-sm text-heading outline-none ring-1 ring-border transition-colors placeholder:text-dim focus:ring-2 focus:ring-accent/50" - /> -
)} + + {t === "login" ? "登录" : "注册"} + + + ))} + - {/* Avatar picker (register only) */} - {tab === "register" && ( -
-

- 选择头像 - (可选) -

-
- {AVATARS.map((a) => ( - - ))} -
-
- )} +
+

用户名

+ { + setUsername(e.target.value.slice(0, 16)); + setError(""); + }} + placeholder={tab === "register" ? "2-16 个字符" : "请输入用户名"} + maxLength={16} + className="mt-2 h-11 w-full rounded-xl border-none bg-elevated px-4 text-sm text-heading outline-none ring-1 ring-border transition-colors placeholder:text-dim focus:ring-2 focus:ring-accent/50" + /> +
- {error && ( - - {error} - - )} +
+

密码

+
+ { + setPassword(e.target.value); + setError(""); + }} + placeholder={tab === "register" ? "至少 6 个字符" : "请输入密码"} + className="h-11 w-full rounded-xl border-none bg-elevated px-4 pr-10 text-sm text-heading outline-none ring-1 ring-border transition-colors placeholder:text-dim focus:ring-2 focus:ring-accent/50" + /> + +
+
- -
- + {tab === "register" && ( +
+

确认密码

+ { + setConfirmPassword(e.target.value); + setError(""); + }} + placeholder="再次输入密码" + className="mt-2 h-11 w-full rounded-xl border-none bg-elevated px-4 text-sm text-heading outline-none ring-1 ring-border transition-colors placeholder:text-dim focus:ring-2 focus:ring-accent/50" + /> +
)} -
+ + {tab === "register" && ( +
+

+ 选择头像 + (可选) +

+
+ {AVATARS.map((a) => ( + + ))} +
+
+ )} + + {error && ( + + {error} + + )} + + + ); } diff --git a/src/components/LeaveConfirmModal.tsx b/src/components/LeaveConfirmModal.tsx index db8d566..030ccbb 100644 --- a/src/components/LeaveConfirmModal.tsx +++ b/src/components/LeaveConfirmModal.tsx @@ -1,8 +1,7 @@ "use client"; -import { useRef } from "react"; -import { motion, AnimatePresence } from "framer-motion"; import { LogOut } from "lucide-react"; +import Modal from "@/components/Modal"; interface LeaveConfirmModalProps { open: boolean; @@ -15,61 +14,35 @@ export default function LeaveConfirmModal({ onConfirm, onCancel, }: LeaveConfirmModalProps) { - const backdropRef = useRef(null); - - const handleBackdropClick = (e: React.MouseEvent) => { - if (e.target === backdropRef.current) onCancel(); - }; - return ( - - {open && ( - - +
+
+ +
+ +

+ 确定要退出房间吗? +

+

+ 退出后你的滑卡进度不会丢失,可以用房间号重新加入 +

+ +
+ - -
-
-
-
- )} -
+ 继续滑卡 + + + + + ); } diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx new file mode 100644 index 0000000..5da8018 --- /dev/null +++ b/src/components/Modal.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useRef } from "react"; +import { motion, AnimatePresence } from "framer-motion"; + +type ModalVariant = "sheet" | "dialog"; + +interface ModalProps { + open: boolean; + onClose: () => void; + children: React.ReactNode; + variant?: ModalVariant; +} + +const sheet = { + backdrop: + "fixed inset-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-sm sm:items-center", + content: + "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" as const, damping: 28, stiffness: 350 }, +}; + +const dialog = { + backdrop: + "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm", + content: + "mx-6 w-full max-w-xs rounded-2xl bg-surface px-6 py-6 shadow-2xl ring-1 ring-border", + initial: { scale: 0.9, opacity: 0 }, + animate: { scale: 1, opacity: 1 }, + exit: { scale: 0.9, opacity: 0 }, + transition: { type: "spring" as const, damping: 25, stiffness: 350 }, +}; + +const variants = { sheet, dialog }; + +export default function Modal({ + open, + onClose, + children, + variant = "sheet", +}: ModalProps) { + const backdropRef = useRef(null); + const v = variants[variant]; + + return ( + + {open && ( + { + if (e.target === backdropRef.current) onClose(); + }} + > + + {children} + + + )} + + ); +} diff --git a/src/components/QrInviteModal.tsx b/src/components/QrInviteModal.tsx index 0e3d77d..467362f 100644 --- a/src/components/QrInviteModal.tsx +++ b/src/components/QrInviteModal.tsx @@ -1,11 +1,11 @@ "use client"; -import { useCallback, useRef } from "react"; -import { motion, AnimatePresence } from "framer-motion"; +import { useCallback } from "react"; import { QRCodeSVG } from "qrcode.react"; import { X, Copy, Share2, QrCode } from "lucide-react"; import type { SceneType } from "@/types"; import { getSceneConfig } from "@/lib/sceneConfig"; +import Modal from "@/components/Modal"; interface QrInviteModalProps { open: boolean; @@ -27,11 +27,6 @@ export default function QrInviteModal({ typeof window !== "undefined" ? `${window.location.origin}/invite/${roomId}` : ""; - const backdropRef = useRef(null); - - const handleBackdropClick = (e: React.MouseEvent) => { - if (e.target === backdropRef.current) onClose(); - }; const handleCopy = useCallback(async () => { try { @@ -62,77 +57,57 @@ export default function QrInviteModal({ }, [inviteUrl, handleCopy, sceneConfig]); return ( - - {open && ( - - + + +
+
+ +

邀请饭搭子

+
+

+ {sceneConfig.qrSubtitle} +

+ +
+ +
+ +
+ 房间号 + + {roomId} + +
+ +
+ - -
-
- -

邀请饭搭子

-
-

- {sceneConfig.qrSubtitle} -

- -
- -
- -
- 房间号 - - {roomId} - -
- -
- - -
-
- - - )} - + + 复制链接 + + +
+
+ ); } diff --git a/src/components/RoomManageModal.tsx b/src/components/RoomManageModal.tsx index dbc155a..f700c5b 100644 --- a/src/components/RoomManageModal.tsx +++ b/src/components/RoomManageModal.tsx @@ -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(null); const [loading, setLoading] = useState(null); const [confirmKick, setConfirmKick] = useState(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 ( - - {open && ( - + + +
+ +

房间管理

+
+

+ 房间号 {roomId} +

+ +
+ + {loading === "lock" || loading === "unlock" ? ( + + ) : locked ? ( + + ) : ( + + )} + {locked ? "解锁房间(允许新人加入)" : "锁定房间(阻止新人加入)"} + +
-
- -

房间管理

-
-

- 房间号 {roomId} -

+
+

+ 房间成员({users.length}) +

+
+ {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 */} -
- + +
+ ) : ( + + )} + )} - {locked ? "解锁房间(允许新人加入)" : "锁定房间(阻止新人加入)"} +
+ ); + })} +
+ + +
+ {confirmEnd ? ( +
+

+ 确定要结束投票吗?将根据当前已有的投票结果直接结算。 +

+
+ +
- - {/* User list with kick */} -
-

- 房间成员({users.length}) -

-
- {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 ( -
- - {emoji} - -
-
- {isCreator && ( - - - 房主 - - )} - - {displayName} - -
- - {swiped}/{totalCards} - {finished ? " 已完成" : " 进行中"} - -
- - {!isCreator && ( - <> - {confirmKick === uid ? ( -
- - -
- ) : ( - - )} - - )} -
- ); - })} -
-
- - {/* End voting */} -
- {confirmEnd ? ( -
-

- 确定要结束投票吗?将根据当前已有的投票结果直接结算。 -

-
- - -
-
- ) : ( - - )} -
- - - )} - +
+ ) : ( + + )} +
+ ); }