feat: 房间创建者管理权限——锁定房间、踢人、结束投票
This commit is contained in:
@@ -15,6 +15,12 @@ export async function POST(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updated = await atomicUpdateRoom(id, (data) => {
|
const updated = await atomicUpdateRoom(id, (data) => {
|
||||||
|
if (data.kickedUsers.includes(userId)) {
|
||||||
|
throw new Error("KICKED");
|
||||||
|
}
|
||||||
|
if (data.locked && !data.users.includes(userId)) {
|
||||||
|
throw new Error("LOCKED");
|
||||||
|
}
|
||||||
if (!data.users.includes(userId)) {
|
if (!data.users.includes(userId)) {
|
||||||
data.users.push(userId);
|
data.users.push(userId);
|
||||||
}
|
}
|
||||||
@@ -35,6 +41,20 @@ export async function POST(
|
|||||||
userCount: updated.users.length,
|
userCount: updated.users.length,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
if (e.message === "LOCKED") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "房间已锁定,无法加入" },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (e.message === "KICKED") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "你已被移出该房间" },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
console.error("Failed to join room:", e);
|
console.error("Failed to join room:", e);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "加入房间失败" },
|
{ error: "加入房间失败" },
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { atomicUpdateRoom } from "@/lib/store";
|
||||||
|
import { notify } from "@/lib/roomEvents";
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { userId, action, targetUserId } = await req.json();
|
||||||
|
if (!userId || !action) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "userId and action required" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await atomicUpdateRoom(id, (data) => {
|
||||||
|
if (data.creatorId !== userId) {
|
||||||
|
throw new Error("FORBIDDEN");
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "lock":
|
||||||
|
data.locked = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "unlock":
|
||||||
|
data.locked = false;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "kick":
|
||||||
|
if (!targetUserId || targetUserId === userId) {
|
||||||
|
throw new Error("INVALID_TARGET");
|
||||||
|
}
|
||||||
|
data.users = data.users.filter((u) => u !== targetUserId);
|
||||||
|
if (!data.kickedUsers.includes(targetUserId)) {
|
||||||
|
data.kickedUsers.push(targetUserId);
|
||||||
|
}
|
||||||
|
delete data.swipeCounts[targetUserId];
|
||||||
|
for (const rid of Object.keys(data.likes)) {
|
||||||
|
data.likes[rid] = data.likes[rid].filter(
|
||||||
|
(u) => u !== targetUserId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
data.match &&
|
||||||
|
data.likes[data.match]?.length !== data.users.length
|
||||||
|
) {
|
||||||
|
data.match = null;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "end_voting":
|
||||||
|
for (const u of data.users) {
|
||||||
|
data.swipeCounts[u] = data.restaurants.length;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error("UNKNOWN_ACTION");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "房间不存在或已过期" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
notify(id);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
if (e.message === "FORBIDDEN") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "只有房主可以执行此操作" },
|
||||||
|
{ status: 403 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (e.message === "INVALID_TARGET") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "无效的操作对象" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (e.message === "UNKNOWN_ACTION") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "未知操作" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error("Failed to manage room:", e);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "操作失败" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,6 +96,7 @@ function filterByPrice(
|
|||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
let restaurants: Restaurant[] = fallbackRestaurants;
|
let restaurants: Restaurant[] = fallbackRestaurants;
|
||||||
|
let creatorId = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
@@ -105,7 +106,9 @@ export async function POST(req: Request) {
|
|||||||
radius = 3000,
|
radius = 3000,
|
||||||
priceRange = "any",
|
priceRange = "any",
|
||||||
cuisine = "不限",
|
cuisine = "不限",
|
||||||
|
userId = "",
|
||||||
} = body;
|
} = body;
|
||||||
|
creatorId = userId;
|
||||||
|
|
||||||
if (lat && lng) {
|
if (lat && lng) {
|
||||||
const apiKey = process.env.AMAP_API_KEY;
|
const apiKey = process.env.AMAP_API_KEY;
|
||||||
@@ -142,7 +145,7 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const roomId = await createRoom(restaurants);
|
const roomId = await createRoom(restaurants, creatorId);
|
||||||
return NextResponse.json({ roomId, restaurants });
|
return NextResponse.json({ roomId, restaurants });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to create room:", e);
|
console.error("Failed to create room:", e);
|
||||||
|
|||||||
+1
-1
@@ -150,7 +150,7 @@ export default function LandingPage() {
|
|||||||
const res = await fetch("/api/room/create", {
|
const res = await fetch("/api/room/create", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ...coords, radius, priceRange, cuisine }),
|
body: JSON.stringify({ ...coords, radius, priceRange, cuisine, userId: getUserId() }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export default function RoomPage() {
|
|||||||
const leavingRef = useRef(false);
|
const leavingRef = useRef(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userCount, match, matchType, matchLikes, runnerUps, likeCounts, swipeCounts, restaurants, notFound, mutate,
|
userCount, match, matchType, matchLikes, runnerUps, likeCounts, swipeCounts,
|
||||||
|
restaurants, notFound, mutate, creatorId, locked, users,
|
||||||
} = useRoomPolling(roomId);
|
} = useRoomPolling(roomId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -117,7 +118,17 @@ export default function RoomPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-dvh flex-col bg-background">
|
<div className="flex h-dvh flex-col bg-background">
|
||||||
<TopNav roomId={roomId} userCount={userCount} onExit={handleExitRequest} />
|
<TopNav
|
||||||
|
roomId={roomId}
|
||||||
|
userCount={userCount}
|
||||||
|
onExit={handleExitRequest}
|
||||||
|
isCreator={userId === creatorId}
|
||||||
|
userId={userId}
|
||||||
|
users={users}
|
||||||
|
locked={locked}
|
||||||
|
swipeCounts={swipeCounts}
|
||||||
|
totalCards={restaurants.length}
|
||||||
|
/>
|
||||||
<SwipeDeck
|
<SwipeDeck
|
||||||
restaurants={restaurants}
|
restaurants={restaurants}
|
||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
"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";
|
||||||
|
|
||||||
|
const AVATARS = [
|
||||||
|
{ emoji: "🐱", bg: "bg-amber-100" },
|
||||||
|
{ emoji: "🐶", bg: "bg-orange-100" },
|
||||||
|
{ emoji: "🦊", bg: "bg-red-100" },
|
||||||
|
{ emoji: "🐰", bg: "bg-pink-100" },
|
||||||
|
{ emoji: "🐼", bg: "bg-zinc-100" },
|
||||||
|
{ emoji: "🐨", bg: "bg-sky-100" },
|
||||||
|
{ emoji: "🦁", bg: "bg-yellow-100" },
|
||||||
|
{ emoji: "🐸", bg: "bg-lime-100" },
|
||||||
|
{ emoji: "🐵", bg: "bg-stone-100" },
|
||||||
|
{ emoji: "🐷", bg: "bg-rose-100" },
|
||||||
|
{ emoji: "🐙", bg: "bg-purple-100" },
|
||||||
|
{ emoji: "🦄", bg: "bg-violet-100" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function getAvatar(uid: string) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < uid.length; i++) {
|
||||||
|
hash = (hash * 31 + uid.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return AVATARS[((hash % AVATARS.length) + AVATARS.length) % AVATARS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoomManageModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
roomId: string;
|
||||||
|
userId: string;
|
||||||
|
users: string[];
|
||||||
|
locked: boolean;
|
||||||
|
swipeCounts: Record<string, number>;
|
||||||
|
totalCards: number;
|
||||||
|
onToast: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RoomManageModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
roomId,
|
||||||
|
userId,
|
||||||
|
users,
|
||||||
|
locked,
|
||||||
|
swipeCounts,
|
||||||
|
totalCards,
|
||||||
|
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 avatar = getAvatar(uid);
|
||||||
|
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 ${avatar.bg}`}
|
||||||
|
>
|
||||||
|
{avatar.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">
|
||||||
|
{uid.slice(0, 8)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,37 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback } from "react";
|
||||||
import { Users, QrCode, LogOut } from "lucide-react";
|
import { Users, QrCode, LogOut, Crown, Lock } from "lucide-react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import QrInviteModal from "./QrInviteModal";
|
import QrInviteModal from "./QrInviteModal";
|
||||||
|
import RoomManageModal from "./RoomManageModal";
|
||||||
|
|
||||||
interface TopNavProps {
|
interface TopNavProps {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
onExit?: () => void;
|
onExit?: () => void;
|
||||||
|
isCreator?: boolean;
|
||||||
|
userId?: string;
|
||||||
|
users?: string[];
|
||||||
|
locked?: boolean;
|
||||||
|
swipeCounts?: Record<string, number>;
|
||||||
|
totalCards?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TopNav({ roomId, userCount, onExit }: TopNavProps) {
|
export default function TopNav({
|
||||||
|
roomId,
|
||||||
|
userCount,
|
||||||
|
onExit,
|
||||||
|
isCreator = false,
|
||||||
|
userId = "",
|
||||||
|
users = [],
|
||||||
|
locked = false,
|
||||||
|
swipeCounts = {},
|
||||||
|
totalCards = 0,
|
||||||
|
}: TopNavProps) {
|
||||||
const [toast, setToast] = useState("");
|
const [toast, setToast] = useState("");
|
||||||
const [showQr, setShowQr] = useState(false);
|
const [showQr, setShowQr] = useState(false);
|
||||||
|
const [showManage, setShowManage] = useState(false);
|
||||||
|
|
||||||
const showToast = useCallback((msg: string) => {
|
const showToast = useCallback((msg: string) => {
|
||||||
setToast(msg);
|
setToast(msg);
|
||||||
@@ -23,14 +41,23 @@ export default function TopNav({ roomId, userCount, onExit }: TopNavProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav className="relative z-10 flex h-14 items-center justify-between px-4">
|
<nav className="relative z-10 flex h-14 items-center justify-between px-4">
|
||||||
<div className="w-24">
|
<div className="flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowQr(true)}
|
onClick={() => setShowQr(true)}
|
||||||
className="flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-600 transition-colors active:bg-emerald-100"
|
className="flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-600 transition-colors active:bg-emerald-100"
|
||||||
>
|
>
|
||||||
<QrCode size={13} />
|
<QrCode size={13} />
|
||||||
邀请饭搭子
|
邀请
|
||||||
</button>
|
</button>
|
||||||
|
{isCreator && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowManage(true)}
|
||||||
|
className="flex items-center gap-1 rounded-full bg-amber-50 px-2.5 py-1 text-xs font-semibold text-amber-600 transition-colors active:bg-amber-100"
|
||||||
|
>
|
||||||
|
<Crown size={13} />
|
||||||
|
管理
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-center text-base font-bold tracking-tight text-zinc-900">
|
<h1 className="text-center text-base font-bold tracking-tight text-zinc-900">
|
||||||
@@ -41,6 +68,9 @@ export default function TopNav({ roomId, userCount, onExit }: TopNavProps) {
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-1.5 text-xs text-zinc-500">
|
<div className="flex items-center justify-end gap-1.5 text-xs text-zinc-500">
|
||||||
|
{locked && (
|
||||||
|
<Lock size={12} className="text-amber-500" />
|
||||||
|
)}
|
||||||
<span className="rounded-full bg-zinc-100 px-2 py-0.5 font-medium">
|
<span className="rounded-full bg-zinc-100 px-2 py-0.5 font-medium">
|
||||||
{roomId}
|
{roomId}
|
||||||
</span>
|
</span>
|
||||||
@@ -78,6 +108,20 @@ export default function TopNav({ roomId, userCount, onExit }: TopNavProps) {
|
|||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
onToast={showToast}
|
onToast={showToast}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isCreator && (
|
||||||
|
<RoomManageModal
|
||||||
|
open={showManage}
|
||||||
|
onClose={() => setShowManage(false)}
|
||||||
|
roomId={roomId}
|
||||||
|
userId={userId}
|
||||||
|
users={users}
|
||||||
|
locked={locked}
|
||||||
|
swipeCounts={swipeCounts}
|
||||||
|
totalCards={totalCards}
|
||||||
|
onToast={showToast}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ export function useRoomPolling(roomId: string) {
|
|||||||
likeCounts: data?.likeCounts ?? {},
|
likeCounts: data?.likeCounts ?? {},
|
||||||
swipeCounts: data?.swipeCounts ?? {},
|
swipeCounts: data?.swipeCounts ?? {},
|
||||||
restaurants: data?.restaurants ?? [],
|
restaurants: data?.restaurants ?? [],
|
||||||
|
creatorId: data?.creatorId ?? "",
|
||||||
|
locked: data?.locked ?? false,
|
||||||
|
users: data?.users ?? [],
|
||||||
notFound,
|
notFound,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ export async function buildRoomStatus(
|
|||||||
likeCounts,
|
likeCounts,
|
||||||
swipeCounts: data.swipeCounts,
|
swipeCounts: data.swipeCounts,
|
||||||
restaurants: data.restaurants,
|
restaurants: data.restaurants,
|
||||||
|
creatorId: data.creatorId,
|
||||||
|
locked: data.locked,
|
||||||
|
users: data.users,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -9,6 +9,9 @@ export interface RoomData {
|
|||||||
likes: Record<string, string[]>;
|
likes: Record<string, string[]>;
|
||||||
swipeCounts: Record<string, number>;
|
swipeCounts: Record<string, number>;
|
||||||
match: string | null;
|
match: string | null;
|
||||||
|
creatorId: string;
|
||||||
|
locked: boolean;
|
||||||
|
kickedUsers: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateRoomId(): string {
|
function generateRoomId(): string {
|
||||||
@@ -22,6 +25,9 @@ function normalize(raw: Partial<RoomData>): RoomData {
|
|||||||
likes: raw.likes ?? {},
|
likes: raw.likes ?? {},
|
||||||
swipeCounts: raw.swipeCounts ?? {},
|
swipeCounts: raw.swipeCounts ?? {},
|
||||||
match: raw.match ?? null,
|
match: raw.match ?? null,
|
||||||
|
creatorId: raw.creatorId ?? "",
|
||||||
|
locked: raw.locked ?? false,
|
||||||
|
kickedUsers: raw.kickedUsers ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +51,7 @@ async function cleanupExpiredRooms() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createRoom(restaurants: Restaurant[]): Promise<string> {
|
export async function createRoom(restaurants: Restaurant[], creatorId: string): Promise<string> {
|
||||||
await cleanupExpiredRooms();
|
await cleanupExpiredRooms();
|
||||||
|
|
||||||
const data: RoomData = {
|
const data: RoomData = {
|
||||||
@@ -54,6 +60,9 @@ export async function createRoom(restaurants: Restaurant[]): Promise<string> {
|
|||||||
likes: {},
|
likes: {},
|
||||||
swipeCounts: {},
|
swipeCounts: {},
|
||||||
match: null,
|
match: null,
|
||||||
|
creatorId,
|
||||||
|
locked: false,
|
||||||
|
kickedUsers: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const expiresAt = new Date(Date.now() + ROOM_TTL_MS);
|
const expiresAt = new Date(Date.now() + ROOM_TTL_MS);
|
||||||
|
|||||||
@@ -32,4 +32,7 @@ export interface RoomStatus {
|
|||||||
likeCounts: Record<string, number>;
|
likeCounts: Record<string, number>;
|
||||||
swipeCounts: Record<string, number>;
|
swipeCounts: Record<string, number>;
|
||||||
restaurants: Restaurant[];
|
restaurants: Restaurant[];
|
||||||
|
creatorId: string;
|
||||||
|
locked: boolean;
|
||||||
|
users: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user