refactor: 提取 useToast hook + Toast 组件,消除 4 处重复的通知逻辑
将 state + setTimeout 自动消失逻辑封装为 useToast hook, Toast UI 统一为组件支持 top/bottom 两种位置,净减约 12 行。
This commit is contained in:
@@ -23,6 +23,8 @@ import {
|
|||||||
import confetti from "canvas-confetti";
|
import confetti from "canvas-confetti";
|
||||||
import { getCachedProfile, isRegistered } from "@/lib/userId";
|
import { getCachedProfile, isRegistered } from "@/lib/userId";
|
||||||
import ShareCardModal from "@/components/ShareCardModal";
|
import ShareCardModal from "@/components/ShareCardModal";
|
||||||
|
import Toast from "@/components/Toast";
|
||||||
|
import { useToast } from "@/hooks/useToast";
|
||||||
import { BlindboxRoomSkeleton } from "@/components/Skeleton";
|
import { BlindboxRoomSkeleton } from "@/components/Skeleton";
|
||||||
import type { UserProfile } from "@/types";
|
import type { UserProfile } from "@/types";
|
||||||
|
|
||||||
@@ -182,7 +184,7 @@ export default function BlindboxRoomPage() {
|
|||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [showInvite, setShowInvite] = useState(false);
|
const [showInvite, setShowInvite] = useState(false);
|
||||||
const [showShareCard, setShowShareCard] = useState(false);
|
const [showShareCard, setShowShareCard] = useState(false);
|
||||||
const [toast, setToast] = useState("");
|
const toast = useToast();
|
||||||
const [confirmLeave, setConfirmLeave] = useState(false);
|
const [confirmLeave, setConfirmLeave] = useState(false);
|
||||||
const [leaving, setLeaving] = useState(false);
|
const [leaving, setLeaving] = useState(false);
|
||||||
|
|
||||||
@@ -314,8 +316,7 @@ export default function BlindboxRoomPage() {
|
|||||||
}
|
}
|
||||||
setMyIdeas((prev) => prev.map((i) => (i.id === ideaId ? { ...i, content: trimmed } : i)));
|
setMyIdeas((prev) => prev.map((i) => (i.id === ideaId ? { ...i, content: trimmed } : i)));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setToast(e instanceof Error ? e.message : "编辑失败");
|
toast.show(e instanceof Error ? e.message : "编辑失败");
|
||||||
setTimeout(() => setToast(""), 2200);
|
|
||||||
}
|
}
|
||||||
}, [profile]);
|
}, [profile]);
|
||||||
|
|
||||||
@@ -334,8 +335,7 @@ export default function BlindboxRoomPage() {
|
|||||||
setMyIdeas((prev) => prev.filter((i) => i.id !== ideaId));
|
setMyIdeas((prev) => prev.filter((i) => i.id !== ideaId));
|
||||||
setPoolCount((c) => Math.max(0, c - 1));
|
setPoolCount((c) => Math.max(0, c - 1));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setToast(e instanceof Error ? e.message : "删除失败");
|
toast.show(e instanceof Error ? e.message : "删除失败");
|
||||||
setTimeout(() => setToast(""), 2200);
|
|
||||||
}
|
}
|
||||||
}, [profile]);
|
}, [profile]);
|
||||||
|
|
||||||
@@ -395,8 +395,7 @@ export default function BlindboxRoomPage() {
|
|||||||
if (!room) return;
|
if (!room) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(room.code);
|
await navigator.clipboard.writeText(room.code);
|
||||||
setToast("房间号已复制");
|
toast.show("房间号已复制");
|
||||||
setTimeout(() => setToast(""), 2000);
|
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -441,8 +440,7 @@ export default function BlindboxRoomPage() {
|
|||||||
}
|
}
|
||||||
router.replace("/blindbox");
|
router.replace("/blindbox");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setToast(e instanceof Error ? e.message : "操作失败");
|
toast.show(e instanceof Error ? e.message : "操作失败");
|
||||||
setTimeout(() => setToast(""), 2200);
|
|
||||||
setConfirmLeave(false);
|
setConfirmLeave(false);
|
||||||
} finally {
|
} finally {
|
||||||
setLeaving(false);
|
setLeaving(false);
|
||||||
@@ -817,10 +815,7 @@ export default function BlindboxRoomPage() {
|
|||||||
drawer: revealedIdea.drawnBy ?? undefined,
|
drawer: revealedIdea.drawnBy ?? undefined,
|
||||||
roomName: room.name,
|
roomName: room.name,
|
||||||
}}
|
}}
|
||||||
onToast={(msg) => {
|
onToast={toast.show}
|
||||||
setToast(msg);
|
|
||||||
setTimeout(() => setToast(""), 2200);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -855,19 +850,7 @@ export default function BlindboxRoomPage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Toast */}
|
<Toast message={toast.message} position="bottom" />
|
||||||
<AnimatePresence>
|
|
||||||
{toast && (
|
|
||||||
<motion.div
|
|
||||||
className="fixed bottom-8 left-1/2 -translate-x-1/2 rounded-full bg-surface px-4 py-2 text-xs font-semibold text-secondary shadow-xl ring-1 ring-border"
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={{ opacity: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, y: 20 }}
|
|
||||||
>
|
|
||||||
{toast}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<div className="h-8 shrink-0" />
|
<div className="h-8 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+10
-25
@@ -24,6 +24,8 @@ import {
|
|||||||
Heart,
|
Heart,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import EmptyState from "@/components/EmptyState";
|
import EmptyState from "@/components/EmptyState";
|
||||||
|
import Toast from "@/components/Toast";
|
||||||
|
import { useToast } from "@/hooks/useToast";
|
||||||
import RestaurantImage from "@/components/RestaurantImage";
|
import RestaurantImage from "@/components/RestaurantImage";
|
||||||
import { ProfileCardSkeleton, RecordItemSkeleton } from "@/components/Skeleton";
|
import { ProfileCardSkeleton, RecordItemSkeleton } from "@/components/Skeleton";
|
||||||
import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId";
|
import { getUserId, getCachedProfile, setCachedProfile, setCachedPreferences, logout } from "@/lib/userId";
|
||||||
@@ -68,12 +70,7 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
const [showHistory, setShowHistory] = useState(true);
|
const [showHistory, setShowHistory] = useState(true);
|
||||||
const [showFavorites, setShowFavorites] = useState(true);
|
const [showFavorites, setShowFavorites] = useState(true);
|
||||||
const [toast, setToast] = useState("");
|
const toast = useToast();
|
||||||
|
|
||||||
const showToast = useCallback((msg: string) => {
|
|
||||||
setToast(msg);
|
|
||||||
setTimeout(() => setToast(""), 2200);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cached = getCachedProfile();
|
const cached = getCachedProfile();
|
||||||
@@ -141,7 +138,7 @@ export default function ProfilePage() {
|
|||||||
setProfile((prev) => prev ? { ...prev, username: trimmed } : prev);
|
setProfile((prev) => prev ? { ...prev, username: trimmed } : prev);
|
||||||
setCachedProfile({ id: userId, username: trimmed, avatar: profile!.avatar });
|
setCachedProfile({ id: userId, username: trimmed, avatar: profile!.avatar });
|
||||||
setEditingUsername(false);
|
setEditingUsername(false);
|
||||||
showToast("用户名已更新");
|
toast.show("用户名已更新");
|
||||||
} else {
|
} else {
|
||||||
setUsernameMsg(data.error ?? "更新失败");
|
setUsernameMsg(data.error ?? "更新失败");
|
||||||
}
|
}
|
||||||
@@ -180,7 +177,7 @@ export default function ProfilePage() {
|
|||||||
setCurrentPassword("");
|
setCurrentPassword("");
|
||||||
setNewPassword("");
|
setNewPassword("");
|
||||||
setConfirmPassword("");
|
setConfirmPassword("");
|
||||||
showToast("密码已更新");
|
toast.show("密码已更新");
|
||||||
} else {
|
} else {
|
||||||
setPasswordMsg(data.error ?? "更新失败");
|
setPasswordMsg(data.error ?? "更新失败");
|
||||||
}
|
}
|
||||||
@@ -202,10 +199,10 @@ export default function ProfilePage() {
|
|||||||
setProfile((prev) => prev ? { ...prev, avatar: emoji } : prev);
|
setProfile((prev) => prev ? { ...prev, avatar: emoji } : prev);
|
||||||
setCachedProfile({ id: userId, username: profile!.username, avatar: emoji });
|
setCachedProfile({ id: userId, username: profile!.username, avatar: emoji });
|
||||||
setEditingAvatar(false);
|
setEditingAvatar(false);
|
||||||
showToast("头像已更新");
|
toast.show("头像已更新");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
showToast("更新失败");
|
toast.show("更新失败");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -244,9 +241,9 @@ export default function ProfilePage() {
|
|||||||
body: JSON.stringify({ userId, favoriteId: favId }),
|
body: JSON.stringify({ userId, favoriteId: favId }),
|
||||||
});
|
});
|
||||||
setFavorites((f) => f.filter((x) => x.id !== favId));
|
setFavorites((f) => f.filter((x) => x.id !== favId));
|
||||||
showToast("已取消收藏");
|
toast.show("已取消收藏");
|
||||||
} catch {
|
} catch {
|
||||||
showToast("操作失败");
|
toast.show("操作失败");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -714,19 +711,7 @@ export default function ProfilePage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AnimatePresence>
|
<Toast message={toast.message} />
|
||||||
{toast && (
|
|
||||||
<motion.div
|
|
||||||
className="fixed left-1/2 top-10 z-60 -translate-x-1/2 rounded-xl bg-elevated px-4 py-2.5 text-xs font-medium text-heading shadow-lg ring-1 ring-subtle"
|
|
||||||
initial={{ opacity: 0, y: -12, x: "-50%" }}
|
|
||||||
animate={{ opacity: 1, y: 0, x: "-50%" }}
|
|
||||||
exit={{ opacity: 0, y: -12, x: "-50%" }}
|
|
||||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
|
||||||
>
|
|
||||||
{toast}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import { isRegistered } from "@/lib/userId";
|
|||||||
import ShareCardModal from "@/components/ShareCardModal";
|
import ShareCardModal from "@/components/ShareCardModal";
|
||||||
import RestaurantImage from "@/components/RestaurantImage";
|
import RestaurantImage from "@/components/RestaurantImage";
|
||||||
import AuthModal from "@/components/AuthModal";
|
import AuthModal from "@/components/AuthModal";
|
||||||
|
import Toast from "@/components/Toast";
|
||||||
|
import { useToast } from "@/hooks/useToast";
|
||||||
|
|
||||||
interface MatchResultProps {
|
interface MatchResultProps {
|
||||||
restaurant: Restaurant;
|
restaurant: Restaurant;
|
||||||
@@ -194,7 +196,7 @@ export default function MatchResult({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [showRunnerUps, setShowRunnerUps] = useState(false);
|
const [showRunnerUps, setShowRunnerUps] = useState(false);
|
||||||
const [showShareCard, setShowShareCard] = useState(false);
|
const [showShareCard, setShowShareCard] = useState(false);
|
||||||
const [toast, setToast] = useState("");
|
const toast = useToast();
|
||||||
const celebratedRef = useRef(false);
|
const celebratedRef = useRef(false);
|
||||||
const historySavedRef = useRef(false);
|
const historySavedRef = useRef(false);
|
||||||
const isSolo = userCount <= 1;
|
const isSolo = userCount <= 1;
|
||||||
@@ -204,11 +206,6 @@ export default function MatchResult({
|
|||||||
const [registered, setRegistered] = useState(() => isRegistered());
|
const [registered, setRegistered] = useState(() => isRegistered());
|
||||||
const [showAuth, setShowAuth] = useState(false);
|
const [showAuth, setShowAuth] = useState(false);
|
||||||
|
|
||||||
const showToast = useCallback((msg: string) => {
|
|
||||||
setToast(msg);
|
|
||||||
setTimeout(() => setToast(""), 2200);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isUnanimous && !celebratedRef.current) {
|
if (isUnanimous && !celebratedRef.current) {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -247,9 +244,9 @@ export default function MatchResult({
|
|||||||
(profile: UserProfile) => {
|
(profile: UserProfile) => {
|
||||||
setRegistered(true);
|
setRegistered(true);
|
||||||
setShowAuth(false);
|
setShowAuth(false);
|
||||||
showToast(`欢迎,${profile.username}!记录已保存`);
|
toast.show(`欢迎,${profile.username}!记录已保存`);
|
||||||
},
|
},
|
||||||
[showToast],
|
[toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFavorite = useCallback(async () => {
|
const handleFavorite = useCallback(async () => {
|
||||||
@@ -263,13 +260,13 @@ export default function MatchResult({
|
|||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setFavorited(true);
|
setFavorited(true);
|
||||||
showToast("已收藏");
|
toast.show("已收藏");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
setFavLoading(false);
|
setFavLoading(false);
|
||||||
}, [registered, userId, restaurant, favorited, favLoading, showToast]);
|
}, [registered, userId, restaurant, favorited, favLoading, toast]);
|
||||||
|
|
||||||
if (matchType === "no_match") {
|
if (matchType === "no_match") {
|
||||||
return <NoMatchResult onReset={onReset} resetting={resetting} />;
|
return <NoMatchResult onReset={onReset} resetting={resetting} />;
|
||||||
@@ -635,22 +632,10 @@ export default function MatchResult({
|
|||||||
userCount,
|
userCount,
|
||||||
scene,
|
scene,
|
||||||
}}
|
}}
|
||||||
onToast={showToast}
|
onToast={toast.show}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AnimatePresence>
|
<Toast message={toast.message} />
|
||||||
{toast && (
|
|
||||||
<motion.div
|
|
||||||
className="fixed left-1/2 top-10 z-60 -translate-x-1/2 rounded-xl bg-surface px-4 py-2.5 text-xs font-medium text-heading shadow-lg ring-1 ring-border"
|
|
||||||
initial={{ opacity: 0, y: -12, x: "-50%" }}
|
|
||||||
animate={{ opacity: 1, y: 0, x: "-50%" }}
|
|
||||||
exit={{ opacity: 0, y: -12, x: "-50%" }}
|
|
||||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
|
||||||
>
|
|
||||||
{toast}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
|
interface ToastProps {
|
||||||
|
message: string;
|
||||||
|
position?: "top" | "bottom";
|
||||||
|
}
|
||||||
|
|
||||||
|
const positionClass = {
|
||||||
|
top: "top-10",
|
||||||
|
bottom: "bottom-8",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Toast({ message, position = "top" }: ToastProps) {
|
||||||
|
const isTop = position === "top";
|
||||||
|
const y = isTop ? -12 : 12;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{message && (
|
||||||
|
<motion.div
|
||||||
|
className={`fixed left-1/2 z-60 -translate-x-1/2 rounded-xl bg-elevated px-4 py-2.5 text-xs font-medium text-heading shadow-lg ring-1 ring-subtle ${positionClass[position]}`}
|
||||||
|
initial={{ opacity: 0, y, x: "-50%" }}
|
||||||
|
animate={{ opacity: 1, y: 0, x: "-50%" }}
|
||||||
|
exit={{ opacity: 0, y, x: "-50%" }}
|
||||||
|
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState } from "react";
|
||||||
import { QrCode, LogOut, Crown, Lock } from "lucide-react";
|
import { QrCode, LogOut, Crown, Lock } from "lucide-react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import QrInviteModal from "./QrInviteModal";
|
import QrInviteModal from "./QrInviteModal";
|
||||||
import RoomManageModal from "./RoomManageModal";
|
import RoomManageModal from "./RoomManageModal";
|
||||||
|
import Toast from "@/components/Toast";
|
||||||
import type { UserProfile, SceneType } from "@/types";
|
import type { UserProfile, SceneType } from "@/types";
|
||||||
import { getSceneConfig } from "@/lib/sceneConfig";
|
import { getSceneConfig } from "@/lib/sceneConfig";
|
||||||
|
import { useToast } from "@/hooks/useToast";
|
||||||
|
|
||||||
interface TopNavProps {
|
interface TopNavProps {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@@ -36,15 +37,10 @@ export default function TopNav({
|
|||||||
scene = "eat",
|
scene = "eat",
|
||||||
}: TopNavProps) {
|
}: TopNavProps) {
|
||||||
const sceneConfig = getSceneConfig(scene);
|
const sceneConfig = getSceneConfig(scene);
|
||||||
const [toast, setToast] = useState("");
|
const toast = useToast();
|
||||||
const [showQr, setShowQr] = useState(false);
|
const [showQr, setShowQr] = useState(false);
|
||||||
const [showManage, setShowManage] = useState(false);
|
const [showManage, setShowManage] = useState(false);
|
||||||
|
|
||||||
const showToast = useCallback((msg: string) => {
|
|
||||||
setToast(msg);
|
|
||||||
setTimeout(() => setToast(""), 2200);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav className="relative z-10 flex h-14 items-center px-4">
|
<nav className="relative z-10 flex h-14 items-center px-4">
|
||||||
@@ -83,25 +79,13 @@ export default function TopNav({
|
|||||||
</h1>
|
</h1>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<AnimatePresence>
|
<Toast message={toast.message} />
|
||||||
{toast && (
|
|
||||||
<motion.div
|
|
||||||
className="fixed left-1/2 top-16 z-50 -translate-x-1/2 rounded-xl bg-elevated px-4 py-2.5 text-xs font-medium text-heading shadow-lg ring-1 ring-subtle"
|
|
||||||
initial={{ opacity: 0, y: -12, x: "-50%" }}
|
|
||||||
animate={{ opacity: 1, y: 0, x: "-50%" }}
|
|
||||||
exit={{ opacity: 0, y: -12, x: "-50%" }}
|
|
||||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
|
||||||
>
|
|
||||||
{toast}
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
<QrInviteModal
|
<QrInviteModal
|
||||||
open={showQr}
|
open={showQr}
|
||||||
onClose={() => setShowQr(false)}
|
onClose={() => setShowQr(false)}
|
||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
onToast={showToast}
|
onToast={toast.show}
|
||||||
scene={scene}
|
scene={scene}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -116,7 +100,7 @@ export default function TopNav({
|
|||||||
swipeCounts={swipeCounts}
|
swipeCounts={swipeCounts}
|
||||||
totalCards={totalCards}
|
totalCards={totalCards}
|
||||||
userProfiles={userProfiles}
|
userProfiles={userProfiles}
|
||||||
onToast={showToast}
|
onToast={toast.show}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useState, useCallback, useRef } from "react";
|
||||||
|
|
||||||
|
export function useToast(duration = 2200) {
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
|
const show = useCallback(
|
||||||
|
(msg: string) => {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
setMessage(msg);
|
||||||
|
timerRef.current = setTimeout(() => setMessage(""), duration);
|
||||||
|
},
|
||||||
|
[duration],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { message, show } as const;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user