feat: 用户名密码登录注册系统
- 新增 /api/auth/register 和 /api/auth/login 接口,使用 bcryptjs 哈希密码 - User 模型改为 username + passwordHash,id 自动生成 cuid - 新增 AuthModal 组件(登录/注册双标签页),替换旧的 ProfileSetupModal - 重写 /profile 页面:支持修改用户名、密码、头像、绑定邮箱、退出登录 - /api/user PUT 支持密码修改(需验证当前密码)和用户名唯一性校验 - 游客模式保留,右上角显示"登录"按钮;登录后显示头像和用户名 - 全局 nickname -> username 重命名(types、SwipeDeck、RoomManageModal、buildRoomStatus) - 新增 logout() 清除登录态并重新生成游客 UUID
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
import { motion, AnimatePresence } 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";
|
||||
|
||||
type Tab = "login" | "register";
|
||||
|
||||
interface AuthModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onAuth: (profile: UserProfile) => void;
|
||||
}
|
||||
|
||||
export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
const [tab, setTab] = useState<Tab>("login");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [avatar, setAvatar] = useState<string>(AVATARS[0].emoji);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === backdropRef.current) onClose();
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setConfirmPassword("");
|
||||
setAvatar(AVATARS[0].emoji);
|
||||
setShowPassword(false);
|
||||
setError("");
|
||||
};
|
||||
|
||||
const switchTab = (t: Tab) => {
|
||||
setTab(t);
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmedUsername = username.trim();
|
||||
if (!trimmedUsername) {
|
||||
setError("请输入用户名");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
setError("请输入密码");
|
||||
return;
|
||||
}
|
||||
|
||||
if (tab === "register") {
|
||||
if (trimmedUsername.length < 2 || trimmedUsername.length > 16) {
|
||||
setError("用户名需要 2-16 个字符");
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
setError("密码至少 6 个字符");
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError("两次密码不一致");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const endpoint = tab === "login" ? "/api/auth/login" : "/api/auth/register";
|
||||
const payload =
|
||||
tab === "login"
|
||||
? { username: trimmedUsername, password }
|
||||
: { username: trimmedUsername, password, avatar };
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "操作失败");
|
||||
return;
|
||||
}
|
||||
|
||||
const profile: UserProfile = {
|
||||
id: data.id,
|
||||
username: data.username,
|
||||
avatar: data.avatar,
|
||||
};
|
||||
|
||||
localStorage.setItem("nowhatever_user_id", profile.id);
|
||||
setCachedProfile(profile);
|
||||
onAuth(profile);
|
||||
onClose();
|
||||
resetForm();
|
||||
} catch {
|
||||
setError("网络错误,请重试");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 }}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-lg font-bold text-zinc-900">欢迎</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="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>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 rounded-xl bg-zinc-100 p-1">
|
||||
{(["login", "register"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => switchTab(t)}
|
||||
className={`relative flex-1 rounded-lg py-2 text-sm font-semibold transition-colors ${
|
||||
tab === t ? "text-zinc-900" : "text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{tab === t && (
|
||||
<motion.div
|
||||
layoutId="auth-tab"
|
||||
className="absolute inset-0 rounded-lg bg-white shadow-sm"
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">
|
||||
{t === "login" ? "登录" : "注册"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div className="mt-5">
|
||||
<p className="text-xs font-medium text-zinc-500">用户名</p>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
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 border-zinc-200 bg-white px-4 text-sm text-zinc-800 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-medium text-zinc-500">密码</p>
|
||||
<div className="relative mt-2">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
placeholder={tab === "register" ? "至少 6 个字符" : "请输入密码"}
|
||||
className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-4 pr-10 text-sm text-zinc-800 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 transition-colors active:text-zinc-600"
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm password (register only) */}
|
||||
{tab === "register" && (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-medium text-zinc-500">确认密码</p>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => {
|
||||
setConfirmPassword(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
placeholder="再次输入密码"
|
||||
className="mt-2 h-11 w-full rounded-xl border border-zinc-200 bg-white px-4 text-sm text-zinc-800 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Avatar picker (register only) */}
|
||||
{tab === "register" && (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-medium text-zinc-500">
|
||||
选择头像
|
||||
<span className="ml-1 text-zinc-300">(可选)</span>
|
||||
</p>
|
||||
<div className="mt-2 grid grid-cols-6 gap-2">
|
||||
{AVATARS.map((a) => (
|
||||
<button
|
||||
key={a.emoji}
|
||||
onClick={() => setAvatar(a.emoji)}
|
||||
className={`flex h-11 w-11 items-center justify-center rounded-xl text-xl transition-all ${
|
||||
avatar === a.emoji
|
||||
? `${a.bg} scale-110 ring-2 ring-emerald-400 ring-offset-1`
|
||||
: "bg-zinc-50 hover:bg-zinc-100"
|
||||
}`}
|
||||
>
|
||||
{a.emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<motion.p
|
||||
className="mt-3 text-center text-xs font-medium text-rose-500"
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
{error}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="mt-5 flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-emerald-500 text-sm font-bold text-white shadow-md shadow-emerald-200 transition-colors hover:bg-emerald-600 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
{tab === "login" ? "登录中..." : "注册中..."}
|
||||
</>
|
||||
) : tab === "login" ? (
|
||||
"登录"
|
||||
) : (
|
||||
"注册"
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Restaurant, MatchType, RunnerUp } from "@/types";
|
||||
import { fireCelebration, playChime } from "@/lib/celebrate";
|
||||
import { isRegistered } from "@/lib/userId";
|
||||
|
||||
interface MatchResultProps {
|
||||
restaurant: Restaurant;
|
||||
@@ -30,6 +31,8 @@ interface MatchResultProps {
|
||||
runnerUps: RunnerUp[];
|
||||
allRestaurants: Restaurant[];
|
||||
userCount: number;
|
||||
roomId: string;
|
||||
userId: string;
|
||||
onReset: () => Promise<void>;
|
||||
onNarrow: (restaurantIds: string[]) => Promise<void>;
|
||||
resetting: boolean;
|
||||
@@ -168,6 +171,8 @@ export default function MatchResult({
|
||||
runnerUps,
|
||||
allRestaurants,
|
||||
userCount,
|
||||
roomId,
|
||||
userId,
|
||||
onReset,
|
||||
onNarrow,
|
||||
resetting,
|
||||
@@ -176,6 +181,7 @@ export default function MatchResult({
|
||||
const [showRunnerUps, setShowRunnerUps] = useState(false);
|
||||
const [toast, setToast] = useState("");
|
||||
const celebratedRef = useRef(false);
|
||||
const historySavedRef = useRef(false);
|
||||
const isUnanimous = matchType === "unanimous";
|
||||
|
||||
const showToast = useCallback((msg: string) => {
|
||||
@@ -194,6 +200,25 @@ export default function MatchResult({
|
||||
}
|
||||
}, [isUnanimous]);
|
||||
|
||||
useEffect(() => {
|
||||
if (historySavedRef.current) return;
|
||||
if (!isRegistered()) return;
|
||||
if (matchType === "no_match") return;
|
||||
|
||||
historySavedRef.current = true;
|
||||
fetch("/api/user/history", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
roomId,
|
||||
restaurant,
|
||||
matchType,
|
||||
participants: userCount,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}, [userId, roomId, restaurant, matchType, userCount]);
|
||||
|
||||
const handleShare = useCallback(async () => {
|
||||
const lines = [
|
||||
isUnanimous
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Star, MapPin, Clock, ExternalLink, Flame } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Star, MapPin, Clock, ExternalLink, Flame, Bookmark } from "lucide-react";
|
||||
import { Restaurant } from "@/types";
|
||||
import { getUserId, isRegistered } from "@/lib/userId";
|
||||
|
||||
interface RestaurantCardProps {
|
||||
restaurant: Restaurant;
|
||||
@@ -14,6 +15,22 @@ function stopAll(e: React.SyntheticEvent) {
|
||||
}
|
||||
|
||||
export default function RestaurantCard({ restaurant, likeCount = 0 }: RestaurantCardProps) {
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
|
||||
const handleFavorite = useCallback(async (e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (favorited) return;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/user/favorite", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: getUserId(), restaurant }),
|
||||
});
|
||||
if (res.ok) setFavorited(true);
|
||||
} catch {}
|
||||
}, [restaurant, favorited]);
|
||||
const openLink = useCallback(
|
||||
(url: string) => (e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -59,6 +76,20 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
|
||||
{restaurant.name}
|
||||
</h2>
|
||||
<div className="mt-0.5 flex shrink-0 gap-1.5">
|
||||
{isRegistered() && (
|
||||
<button
|
||||
onClick={handleFavorite}
|
||||
onPointerDown={stopAll}
|
||||
onTouchStart={stopAll}
|
||||
className={`flex items-center justify-center rounded-full p-1 transition-colors ${
|
||||
favorited
|
||||
? "bg-amber-100 text-amber-500"
|
||||
: "bg-zinc-50 text-zinc-400 active:bg-amber-50 active:text-amber-500"
|
||||
}`}
|
||||
>
|
||||
<Bookmark size={13} className={favorited ? "fill-amber-400" : ""} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={openLink(amapUrl)}
|
||||
onPointerDown={stopAll}
|
||||
|
||||
@@ -11,29 +11,8 @@ import {
|
||||
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];
|
||||
}
|
||||
import { UserProfile } from "@/types";
|
||||
import { getAvatar, getAvatarBg } from "@/lib/avatars";
|
||||
|
||||
interface RoomManageModalProps {
|
||||
open: boolean;
|
||||
@@ -44,6 +23,7 @@ interface RoomManageModalProps {
|
||||
locked: boolean;
|
||||
swipeCounts: Record<string, number>;
|
||||
totalCards: number;
|
||||
userProfiles: Record<string, UserProfile>;
|
||||
onToast: (msg: string) => void;
|
||||
}
|
||||
|
||||
@@ -56,6 +36,7 @@ export default function RoomManageModal({
|
||||
locked,
|
||||
swipeCounts,
|
||||
totalCards,
|
||||
userProfiles,
|
||||
onToast,
|
||||
}: RoomManageModalProps) {
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
@@ -172,7 +153,10 @@ export default function RoomManageModal({
|
||||
</h3>
|
||||
<div className="mt-2 flex flex-col gap-1.5">
|
||||
{users.map((uid) => {
|
||||
const avatar = getAvatar(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;
|
||||
@@ -183,9 +167,9 @@ export default function RoomManageModal({
|
||||
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}`}
|
||||
className={`inline-flex h-8 w-8 items-center justify-center rounded-full text-base ${bg}`}
|
||||
>
|
||||
{avatar.emoji}
|
||||
{emoji}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -196,7 +180,7 @@ export default function RoomManageModal({
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-xs font-medium text-zinc-500">
|
||||
{uid.slice(0, 8)}
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
|
||||
@@ -6,65 +6,53 @@ import SwipeableCard from "./SwipeableCard";
|
||||
import ActionButtons from "./ActionButtons";
|
||||
import MatchResult from "./MatchResult";
|
||||
import SwipeGuide from "./SwipeGuide";
|
||||
import { Restaurant, SwipeDirection, MatchType, RunnerUp } from "@/types";
|
||||
import { Restaurant, SwipeDirection, MatchType, RunnerUp, UserProfile } from "@/types";
|
||||
import { Heart, Undo2, Check } 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];
|
||||
}
|
||||
import { getAvatar, getAvatarBg } from "@/lib/avatars";
|
||||
|
||||
function UserProgressBar({
|
||||
userId,
|
||||
swipeCounts,
|
||||
localIndex,
|
||||
total,
|
||||
userProfiles,
|
||||
}: {
|
||||
userId: string;
|
||||
swipeCounts: Record<string, number>;
|
||||
localIndex: number;
|
||||
total: number;
|
||||
userProfiles: Record<string, UserProfile>;
|
||||
}) {
|
||||
const others = Object.entries(swipeCounts).filter(([id]) => id !== userId);
|
||||
if (others.length === 0) return null;
|
||||
|
||||
const myProfile = userProfiles[userId];
|
||||
const myAvatar = myProfile?.avatar ?? getAvatar(userId).emoji;
|
||||
const myAvatarBg = myProfile ? getAvatarBg(myProfile.avatar) : "bg-emerald-100";
|
||||
|
||||
return (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="flex items-center gap-1 text-[11px] tabular-nums text-emerald-500">
|
||||
<span className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-emerald-100 text-[10px] leading-none">
|
||||
{getAvatar(userId).emoji}
|
||||
<span className={`inline-flex h-4 w-4 items-center justify-center rounded-full ${myAvatarBg} text-[10px] leading-none`}>
|
||||
{myAvatar}
|
||||
</span>
|
||||
你 {localIndex}/{total}
|
||||
</span>
|
||||
{others.map(([id, count]) => {
|
||||
const finished = count >= total;
|
||||
const avatar = getAvatar(id);
|
||||
const profile = userProfiles[id];
|
||||
const emoji = profile?.avatar ?? getAvatar(id).emoji;
|
||||
const bg = profile ? getAvatarBg(profile.avatar) : getAvatar(id).bg;
|
||||
const label = profile?.username ?? "";
|
||||
return (
|
||||
<span
|
||||
key={id}
|
||||
className={`flex items-center gap-1 text-[11px] tabular-nums ${finished ? "text-emerald-400" : "text-zinc-400"}`}
|
||||
>
|
||||
<span className={`inline-flex h-4 w-4 items-center justify-center rounded-full ${avatar.bg} text-[10px] leading-none`}>
|
||||
{avatar.emoji}
|
||||
<span className={`inline-flex h-4 w-4 items-center justify-center rounded-full ${bg} text-[10px] leading-none`}>
|
||||
{emoji}
|
||||
</span>
|
||||
{label && <span className="max-w-[3rem] truncate">{label}</span>}
|
||||
{count}/{total}
|
||||
{finished && <Check size={10} className="text-emerald-400" />}
|
||||
</span>
|
||||
@@ -78,10 +66,12 @@ function WaitingProgress({
|
||||
userId,
|
||||
swipeCounts,
|
||||
total,
|
||||
userProfiles,
|
||||
}: {
|
||||
userId: string;
|
||||
swipeCounts: Record<string, number>;
|
||||
total: number;
|
||||
userProfiles: Record<string, UserProfile>;
|
||||
}) {
|
||||
const entries = Object.entries(swipeCounts);
|
||||
if (entries.length <= 1) return null;
|
||||
@@ -89,12 +79,16 @@ function WaitingProgress({
|
||||
const others = entries.filter(([id]) => id !== userId);
|
||||
const finishedCount = others.filter(([, c]) => c >= total).length;
|
||||
|
||||
const myProfile = userProfiles[userId];
|
||||
const myEmoji = myProfile?.avatar ?? getAvatar(userId).emoji;
|
||||
const myBg = myProfile ? getAvatarBg(myProfile.avatar) : "bg-emerald-100";
|
||||
|
||||
return (
|
||||
<div className="flex w-60 flex-col gap-2.5 rounded-2xl bg-zinc-50 px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-emerald-600">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-emerald-100 text-sm leading-none">
|
||||
{getAvatar(userId).emoji}
|
||||
<span className={`inline-flex h-5 w-5 items-center justify-center rounded-full ${myBg} text-sm leading-none`}>
|
||||
{myEmoji}
|
||||
</span>
|
||||
你 {total}/{total}
|
||||
</span>
|
||||
@@ -104,14 +98,18 @@ function WaitingProgress({
|
||||
{others.map(([id, count]) => {
|
||||
const finished = count >= total;
|
||||
const pct = Math.min((count / total) * 100, 100);
|
||||
const avatar = getAvatar(id);
|
||||
const profile = userProfiles[id];
|
||||
const emoji = profile?.avatar ?? getAvatar(id).emoji;
|
||||
const bg = profile ? getAvatarBg(profile.avatar) : getAvatar(id).bg;
|
||||
const label = profile?.username ?? "";
|
||||
return (
|
||||
<div key={id} className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`flex items-center gap-1.5 text-xs font-medium ${finished ? "text-emerald-600" : "text-zinc-500"}`}>
|
||||
<span className={`inline-flex h-5 w-5 items-center justify-center rounded-full text-sm leading-none ${finished ? "bg-emerald-100" : avatar.bg}`}>
|
||||
{avatar.emoji}
|
||||
<span className={`inline-flex h-5 w-5 items-center justify-center rounded-full text-sm leading-none ${finished ? "bg-emerald-100" : bg}`}>
|
||||
{emoji}
|
||||
</span>
|
||||
{label && <span className="max-w-[3rem] truncate">{label}</span>}
|
||||
{count}/{total}
|
||||
</span>
|
||||
{finished && <Check size={14} className="text-emerald-400" />}
|
||||
@@ -148,6 +146,7 @@ interface SwipeDeckProps {
|
||||
likeCounts: Record<string, number>;
|
||||
swipeCounts: Record<string, number>;
|
||||
userCount: number;
|
||||
userProfiles: Record<string, UserProfile>;
|
||||
onReset: () => Promise<void>;
|
||||
onNarrow: (restaurantIds: string[]) => Promise<void>;
|
||||
}
|
||||
@@ -164,6 +163,7 @@ export default function SwipeDeck({
|
||||
likeCounts,
|
||||
swipeCounts,
|
||||
userCount,
|
||||
userProfiles,
|
||||
onReset,
|
||||
onNarrow,
|
||||
}: SwipeDeckProps) {
|
||||
@@ -351,6 +351,7 @@ export default function SwipeDeck({
|
||||
swipeCounts={swipeCounts}
|
||||
localIndex={currentIndex}
|
||||
total={restaurants.length}
|
||||
userProfiles={userProfiles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -388,6 +389,7 @@ export default function SwipeDeck({
|
||||
userId={userId}
|
||||
swipeCounts={swipeCounts}
|
||||
total={restaurants.length}
|
||||
userProfiles={userProfiles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -419,6 +421,8 @@ export default function SwipeDeck({
|
||||
runnerUps={runnerUps}
|
||||
allRestaurants={restaurants}
|
||||
userCount={userCount}
|
||||
roomId={roomId}
|
||||
userId={userId}
|
||||
onReset={handleReset}
|
||||
onNarrow={handleNarrow}
|
||||
resetting={resetting}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Users, QrCode, LogOut, Crown, Lock } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import QrInviteModal from "./QrInviteModal";
|
||||
import RoomManageModal from "./RoomManageModal";
|
||||
import type { UserProfile } from "@/types";
|
||||
|
||||
interface TopNavProps {
|
||||
roomId: string;
|
||||
@@ -16,6 +17,7 @@ interface TopNavProps {
|
||||
locked?: boolean;
|
||||
swipeCounts?: Record<string, number>;
|
||||
totalCards?: number;
|
||||
userProfiles?: Record<string, UserProfile>;
|
||||
}
|
||||
|
||||
export default function TopNav({
|
||||
@@ -28,6 +30,7 @@ export default function TopNav({
|
||||
locked = false,
|
||||
swipeCounts = {},
|
||||
totalCards = 0,
|
||||
userProfiles = {},
|
||||
}: TopNavProps) {
|
||||
const [toast, setToast] = useState("");
|
||||
const [showQr, setShowQr] = useState(false);
|
||||
@@ -119,6 +122,7 @@ export default function TopNav({
|
||||
locked={locked}
|
||||
swipeCounts={swipeCounts}
|
||||
totalCards={totalCards}
|
||||
userProfiles={userProfiles}
|
||||
onToast={showToast}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user