refactor: 提取 UserAvatar、Input、Card 三个可复用 UI 组件
- UserAvatar: 统一头像渲染(5 种尺寸),新增 resolveAvatar 工具函数,替换 SwipeDeck 和 RoomManageModal 中的重复逻辑 - Input: 统一表单输入样式(4 种尺寸 + default/purple 变体),替换 AuthModal、ProfilePage、BlindboxPage 共 12 处 - Card: 统一卡片容器样式 + 可选淡入动画和延迟,替换 ProfilePage 中 7 处重复的 motion.div
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
|||||||
import { getCachedProfile, isRegistered } from "@/lib/userId";
|
import { getCachedProfile, isRegistered } from "@/lib/userId";
|
||||||
import AuthModal from "@/components/AuthModal";
|
import AuthModal from "@/components/AuthModal";
|
||||||
import Button from "@/components/Button";
|
import Button from "@/components/Button";
|
||||||
|
import Input from "@/components/Input";
|
||||||
import { BlindboxListSkeleton } from "@/components/Skeleton";
|
import { BlindboxListSkeleton } from "@/components/Skeleton";
|
||||||
import type { UserProfile } from "@/types";
|
import type { UserProfile } from "@/types";
|
||||||
|
|
||||||
@@ -244,7 +245,7 @@ export default function BlindboxLobbyPage() {
|
|||||||
{/* Inline create form */}
|
{/* Inline create form */}
|
||||||
<div className="mt-7 w-full max-w-xs">
|
<div className="mt-7 w-full max-w-xs">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="我们的周末"
|
placeholder="我们的周末"
|
||||||
value={createName}
|
value={createName}
|
||||||
@@ -254,7 +255,9 @@ export default function BlindboxLobbyPage() {
|
|||||||
}}
|
}}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }}
|
onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }}
|
||||||
maxLength={30}
|
maxLength={30}
|
||||||
className="h-11 flex-1 rounded-xl border-none bg-surface px-4 text-sm text-foreground outline-none ring-1 ring-border transition-all placeholder:text-dim focus:ring-2 focus:ring-purple-600"
|
size="xl"
|
||||||
|
variant="purple"
|
||||||
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
@@ -275,7 +278,7 @@ export default function BlindboxLobbyPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="6 位房间号"
|
placeholder="6 位房间号"
|
||||||
value={joinCode}
|
value={joinCode}
|
||||||
@@ -285,7 +288,9 @@ export default function BlindboxLobbyPage() {
|
|||||||
}}
|
}}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") handleJoin(); }}
|
onKeyDown={(e) => { if (e.key === "Enter") handleJoin(); }}
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
className="h-11 flex-1 rounded-xl border-none bg-surface px-4 text-center font-mono text-sm tracking-[0.15em] text-foreground outline-none ring-1 ring-border transition-all placeholder:text-dim focus:ring-2 focus:ring-purple-600"
|
size="xl"
|
||||||
|
variant="purple"
|
||||||
|
className="flex-1 text-center font-mono tracking-[0.15em]"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleJoin}
|
onClick={handleJoin}
|
||||||
@@ -321,7 +326,7 @@ export default function BlindboxLobbyPage() {
|
|||||||
>
|
>
|
||||||
{/* Create row */}
|
{/* Create row */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="新房间名称"
|
placeholder="新房间名称"
|
||||||
value={createName}
|
value={createName}
|
||||||
@@ -331,7 +336,9 @@ export default function BlindboxLobbyPage() {
|
|||||||
}}
|
}}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }}
|
onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }}
|
||||||
maxLength={30}
|
maxLength={30}
|
||||||
className="h-10 flex-1 rounded-xl border-none bg-surface px-3 text-sm text-foreground outline-none ring-1 ring-border transition-all placeholder:text-dim focus:ring-2 focus:ring-purple-600"
|
size="lg"
|
||||||
|
variant="purple"
|
||||||
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
@@ -345,7 +352,7 @@ export default function BlindboxLobbyPage() {
|
|||||||
|
|
||||||
{/* Join row */}
|
{/* Join row */}
|
||||||
<div className="mt-2 flex gap-2">
|
<div className="mt-2 flex gap-2">
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="输入 6 位房间号加入"
|
placeholder="输入 6 位房间号加入"
|
||||||
value={joinCode}
|
value={joinCode}
|
||||||
@@ -355,7 +362,9 @@ export default function BlindboxLobbyPage() {
|
|||||||
}}
|
}}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") handleJoin(); }}
|
onKeyDown={(e) => { if (e.key === "Enter") handleJoin(); }}
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
className="h-10 flex-1 rounded-xl border-none bg-surface px-3 text-center font-mono text-sm tracking-[0.15em] text-foreground outline-none ring-1 ring-border transition-all placeholder:font-sans placeholder:tracking-normal placeholder:text-dim focus:ring-2 focus:ring-purple-600"
|
size="lg"
|
||||||
|
variant="purple"
|
||||||
|
className="flex-1 text-center font-mono tracking-[0.15em] placeholder:font-sans placeholder:tracking-normal"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleJoin}
|
onClick={handleJoin}
|
||||||
|
|||||||
+27
-48
@@ -24,6 +24,8 @@ import {
|
|||||||
Heart,
|
Heart,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import EmptyState from "@/components/EmptyState";
|
import EmptyState from "@/components/EmptyState";
|
||||||
|
import Card from "@/components/Card";
|
||||||
|
import Input from "@/components/Input";
|
||||||
import { useToast } from "@/hooks/useToast";
|
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";
|
||||||
@@ -261,18 +263,18 @@ export default function ProfilePage() {
|
|||||||
</nav>
|
</nav>
|
||||||
<div className="mx-auto max-w-sm px-5">
|
<div className="mx-auto max-w-sm px-5">
|
||||||
<ProfileCardSkeleton />
|
<ProfileCardSkeleton />
|
||||||
<div className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-border">
|
<Card className="mt-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<RecordItemSkeleton />
|
<RecordItemSkeleton />
|
||||||
<RecordItemSkeleton />
|
<RecordItemSkeleton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
<div className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-border">
|
<Card className="mt-4">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<RecordItemSkeleton />
|
<RecordItemSkeleton />
|
||||||
<RecordItemSkeleton />
|
<RecordItemSkeleton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -295,11 +297,7 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
<div className="mx-auto max-w-sm px-5">
|
<div className="mx-auto max-w-sm px-5">
|
||||||
{/* Profile card */}
|
{/* Profile card */}
|
||||||
<motion.div
|
<Card animated>
|
||||||
className="rounded-2xl bg-surface p-4 ring-1 ring-border"
|
|
||||||
initial={{ y: 10, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingAvatar(!editingAvatar)}
|
onClick={() => setEditingAvatar(!editingAvatar)}
|
||||||
@@ -313,7 +311,7 @@ export default function ProfilePage() {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{editingUsername ? (
|
{editingUsername ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={newUsername}
|
value={newUsername}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -322,7 +320,8 @@ export default function ProfilePage() {
|
|||||||
}}
|
}}
|
||||||
maxLength={16}
|
maxLength={16}
|
||||||
autoFocus
|
autoFocus
|
||||||
className="h-8 flex-1 rounded-lg border-none bg-elevated px-2 text-sm text-foreground outline-none ring-1 ring-border focus:ring-2 focus:ring-accent/50"
|
size="sm"
|
||||||
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveUsername}
|
onClick={handleSaveUsername}
|
||||||
@@ -387,15 +386,10 @@ export default function ProfilePage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.div>
|
</Card>
|
||||||
|
|
||||||
{/* Change password */}
|
{/* Change password */}
|
||||||
<motion.div
|
<Card animated className="mt-4" delay={0.05}>
|
||||||
className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-border"
|
|
||||||
initial={{ y: 10, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
transition={{ delay: 0.05 }}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { setEditingPassword(!editingPassword); setPasswordMsg(""); }}
|
onClick={() => { setEditingPassword(!editingPassword); setPasswordMsg(""); }}
|
||||||
className="flex w-full items-center gap-2"
|
className="flex w-full items-center gap-2"
|
||||||
@@ -417,11 +411,11 @@ export default function ProfilePage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted">当前密码</p>
|
<p className="text-xs text-muted">当前密码</p>
|
||||||
<div className="relative mt-1">
|
<div className="relative mt-1">
|
||||||
<input
|
<Input
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
value={currentPassword}
|
value={currentPassword}
|
||||||
onChange={(e) => { setCurrentPassword(e.target.value); setPasswordMsg(""); }}
|
onChange={(e) => { setCurrentPassword(e.target.value); setPasswordMsg(""); }}
|
||||||
className="h-9 w-full rounded-lg border-none bg-elevated px-3 pr-9 text-sm text-heading outline-none ring-1 ring-border focus:ring-2 focus:ring-accent/50"
|
className="pr-9"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -434,22 +428,22 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted">新密码</p>
|
<p className="text-xs text-muted">新密码</p>
|
||||||
<input
|
<Input
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => { setNewPassword(e.target.value); setPasswordMsg(""); }}
|
onChange={(e) => { setNewPassword(e.target.value); setPasswordMsg(""); }}
|
||||||
placeholder="至少 6 个字符"
|
placeholder="至少 6 个字符"
|
||||||
className="mt-1 h-9 w-full rounded-lg border-none bg-elevated px-3 text-sm text-heading outline-none ring-1 ring-border placeholder:text-dim focus:ring-2 focus:ring-accent/50"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted">确认新密码</p>
|
<p className="text-xs text-muted">确认新密码</p>
|
||||||
<input
|
<Input
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => { setConfirmPassword(e.target.value); setPasswordMsg(""); }}
|
onChange={(e) => { setConfirmPassword(e.target.value); setPasswordMsg(""); }}
|
||||||
placeholder="再次输入新密码"
|
placeholder="再次输入新密码"
|
||||||
className="mt-1 h-9 w-full rounded-lg border-none bg-elevated px-3 text-sm text-heading outline-none ring-1 ring-border placeholder:text-dim focus:ring-2 focus:ring-accent/50"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -470,22 +464,17 @@ export default function ProfilePage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.div>
|
</Card>
|
||||||
|
|
||||||
{/* Email binding */}
|
{/* Email binding */}
|
||||||
<motion.div
|
<Card animated className="mt-4" delay={0.1}>
|
||||||
className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-border"
|
|
||||||
initial={{ y: 10, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
transition={{ delay: 0.1 }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Mail size={15} className="text-muted" />
|
<Mail size={15} className="text-muted" />
|
||||||
<h3 className="text-sm font-semibold text-secondary">绑定邮箱</h3>
|
<h3 className="text-sm font-semibold text-secondary">绑定邮箱</h3>
|
||||||
<span className="text-[10px] text-dim">(可选)</span>
|
<span className="text-[10px] text-dim">(可选)</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="your@email.com"
|
placeholder="your@email.com"
|
||||||
value={email}
|
value={email}
|
||||||
@@ -493,7 +482,7 @@ export default function ProfilePage() {
|
|||||||
setEmail(e.target.value);
|
setEmail(e.target.value);
|
||||||
setEmailMsg("");
|
setEmailMsg("");
|
||||||
}}
|
}}
|
||||||
className="h-9 flex-1 rounded-lg border-none bg-elevated px-3 text-sm text-heading outline-none ring-1 ring-border placeholder:text-dim focus:ring-2 focus:ring-accent/50"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveEmail}
|
onClick={handleSaveEmail}
|
||||||
@@ -508,15 +497,10 @@ export default function ProfilePage() {
|
|||||||
{emailMsg}
|
{emailMsg}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</Card>
|
||||||
|
|
||||||
{/* Decision History */}
|
{/* Decision History */}
|
||||||
<motion.div
|
<Card animated className="mt-4" delay={0.15}>
|
||||||
className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-border"
|
|
||||||
initial={{ y: 10, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
transition={{ delay: 0.15 }}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowHistory((v) => !v)}
|
onClick={() => setShowHistory((v) => !v)}
|
||||||
className="flex w-full items-center justify-between"
|
className="flex w-full items-center justify-between"
|
||||||
@@ -591,15 +575,10 @@ export default function ProfilePage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.div>
|
</Card>
|
||||||
|
|
||||||
{/* Favorites */}
|
{/* Favorites */}
|
||||||
<motion.div
|
<Card animated className="mt-4" delay={0.2}>
|
||||||
className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-border"
|
|
||||||
initial={{ y: 10, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
transition={{ delay: 0.2 }}
|
|
||||||
>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowFavorites((v) => !v)}
|
onClick={() => setShowFavorites((v) => !v)}
|
||||||
className="flex w-full items-center justify-between"
|
className="flex w-full items-center justify-between"
|
||||||
@@ -688,7 +667,7 @@ export default function ProfilePage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.div>
|
</Card>
|
||||||
|
|
||||||
{/* Logout */}
|
{/* Logout */}
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { setCachedProfile } from "@/lib/userId";
|
|||||||
import type { UserProfile } from "@/types";
|
import type { UserProfile } from "@/types";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import Button from "@/components/Button";
|
import Button from "@/components/Button";
|
||||||
|
import Input from "@/components/Input";
|
||||||
|
|
||||||
type Tab = "login" | "register";
|
type Tab = "login" | "register";
|
||||||
|
|
||||||
@@ -147,7 +148,7 @@ export default function AuthModal({ open, onClose, onAuth, defaultTab = "login"
|
|||||||
|
|
||||||
<div className="mt-5">
|
<div className="mt-5">
|
||||||
<p className="text-xs font-medium text-muted">用户名</p>
|
<p className="text-xs font-medium text-muted">用户名</p>
|
||||||
<input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -156,14 +157,15 @@ export default function AuthModal({ open, onClose, onAuth, defaultTab = "login"
|
|||||||
}}
|
}}
|
||||||
placeholder={tab === "register" ? "2-16 个字符" : "请输入用户名"}
|
placeholder={tab === "register" ? "2-16 个字符" : "请输入用户名"}
|
||||||
maxLength={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"
|
size="xl"
|
||||||
|
className="mt-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<p className="text-xs font-medium text-muted">密码</p>
|
<p className="text-xs font-medium text-muted">密码</p>
|
||||||
<div className="relative mt-2">
|
<div className="relative mt-2">
|
||||||
<input
|
<Input
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -171,7 +173,8 @@ export default function AuthModal({ open, onClose, onAuth, defaultTab = "login"
|
|||||||
setError("");
|
setError("");
|
||||||
}}
|
}}
|
||||||
placeholder={tab === "register" ? "至少 6 个字符" : "请输入密码"}
|
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"
|
size="xl"
|
||||||
|
className="pr-10"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -186,7 +189,7 @@ export default function AuthModal({ open, onClose, onAuth, defaultTab = "login"
|
|||||||
{tab === "register" && (
|
{tab === "register" && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<p className="text-xs font-medium text-muted">确认密码</p>
|
<p className="text-xs font-medium text-muted">确认密码</p>
|
||||||
<input
|
<Input
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -194,7 +197,8 @@ export default function AuthModal({ open, onClose, onAuth, defaultTab = "login"
|
|||||||
setError("");
|
setError("");
|
||||||
}}
|
}}
|
||||||
placeholder="再次输入密码"
|
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"
|
size="xl"
|
||||||
|
className="mt-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
animated?: boolean;
|
||||||
|
delay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fadeUp = {
|
||||||
|
initial: { y: 10, opacity: 0 },
|
||||||
|
animate: { y: 0, opacity: 1 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default function Card({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
animated = false,
|
||||||
|
delay,
|
||||||
|
}: CardProps) {
|
||||||
|
const cls = `rounded-2xl bg-surface p-4 ring-1 ring-border ${className}`;
|
||||||
|
|
||||||
|
if (animated) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={cls}
|
||||||
|
{...fadeUp}
|
||||||
|
transition={delay ? { delay } : undefined}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cls}>{children}</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { type ComponentPropsWithoutRef, forwardRef } from "react";
|
||||||
|
|
||||||
|
const sizeStyles = {
|
||||||
|
sm: "h-8 rounded-lg px-2",
|
||||||
|
md: "h-9 rounded-lg px-3",
|
||||||
|
lg: "h-10 rounded-xl px-3",
|
||||||
|
xl: "h-11 rounded-xl px-4",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const variantStyles = {
|
||||||
|
default: "bg-elevated text-heading focus:ring-accent/50",
|
||||||
|
purple: "bg-surface text-foreground focus:ring-purple-600",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface InputProps extends Omit<ComponentPropsWithoutRef<"input">, "size"> {
|
||||||
|
size?: keyof typeof sizeStyles;
|
||||||
|
variant?: keyof typeof variantStyles;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ size = "md", variant = "default", className = "", ...rest }, ref) => (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className={`w-full border-none text-sm outline-none ring-1 ring-border placeholder:text-dim focus:ring-2 ${sizeStyles[size]} ${variantStyles[variant]} ${className}`}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = "Input";
|
||||||
|
export default Input;
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { UserProfile } from "@/types";
|
import { UserProfile } from "@/types";
|
||||||
import { getAvatar, getAvatarBg } from "@/lib/avatars";
|
import UserAvatar from "@/components/UserAvatar";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { useToast } from "@/hooks/useToast";
|
import { useToast } from "@/hooks/useToast";
|
||||||
|
|
||||||
@@ -129,10 +129,7 @@ export default function RoomManageModal({
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="mt-2 flex flex-col gap-1.5">
|
<div className="mt-2 flex flex-col gap-1.5">
|
||||||
{users.map((uid) => {
|
{users.map((uid) => {
|
||||||
const profile = userProfiles[uid];
|
const displayName = userProfiles[uid]?.username ?? uid.slice(0, 8);
|
||||||
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 isCreator = uid === userId;
|
||||||
const swiped = swipeCounts[uid] ?? 0;
|
const swiped = swipeCounts[uid] ?? 0;
|
||||||
const finished = swiped >= totalCards;
|
const finished = swiped >= totalCards;
|
||||||
@@ -142,11 +139,7 @@ export default function RoomManageModal({
|
|||||||
key={uid}
|
key={uid}
|
||||||
className="flex items-center gap-2.5 rounded-xl bg-elevated px-3 py-2.5"
|
className="flex items-center gap-2.5 rounded-xl bg-elevated px-3 py-2.5"
|
||||||
>
|
>
|
||||||
<span
|
<UserAvatar userId={uid} profile={userProfiles[uid]} />
|
||||||
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 min-w-0 flex-1 flex-col">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{isCreator && (
|
{isCreator && (
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import MatchResult from "./MatchResult";
|
|||||||
import SwipeGuide from "./SwipeGuide";
|
import SwipeGuide from "./SwipeGuide";
|
||||||
import { Restaurant, SwipeDirection, MatchType, RunnerUp, UserProfile, SceneType } from "@/types";
|
import { Restaurant, SwipeDirection, MatchType, RunnerUp, UserProfile, SceneType } from "@/types";
|
||||||
import { Heart, Undo2, Check } from "lucide-react";
|
import { Heart, Undo2, Check } from "lucide-react";
|
||||||
import { getAvatar, getAvatarBg } from "@/lib/avatars";
|
import UserAvatar from "@/components/UserAvatar";
|
||||||
|
|
||||||
function UserProgressBar({
|
function UserProgressBar({
|
||||||
userId,
|
userId,
|
||||||
@@ -27,17 +27,11 @@ function UserProgressBar({
|
|||||||
}) {
|
}) {
|
||||||
const others = Object.entries(swipeCounts).filter(([id]) => id !== userId);
|
const others = Object.entries(swipeCounts).filter(([id]) => id !== userId);
|
||||||
|
|
||||||
const myProfile = userProfiles[userId];
|
|
||||||
const myAvatar = myProfile?.avatar ?? getAvatar(userId).emoji;
|
|
||||||
const myAvatarBg = myProfile ? getAvatarBg(myProfile.avatar) : "bg-emerald-500/20";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-x-3">
|
<div className="flex items-center gap-x-3">
|
||||||
<div className="flex flex-1 flex-wrap items-center gap-x-3 gap-y-1">
|
<div className="flex flex-1 flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
<span className="flex items-center gap-1.5 text-[11px] text-accent">
|
<span className="flex items-center gap-1.5 text-[11px] text-accent">
|
||||||
<span className={`inline-flex h-4 w-4 items-center justify-center rounded-full ${myAvatarBg} text-[10px] leading-none`}>
|
<UserAvatar userId={userId} profile={userProfiles[userId]} size="xs" />
|
||||||
{myAvatar}
|
|
||||||
</span>
|
|
||||||
你
|
你
|
||||||
<span className="rounded bg-accent/10 px-1 py-px tabular-nums font-medium">
|
<span className="rounded bg-accent/10 px-1 py-px tabular-nums font-medium">
|
||||||
{localIndex}/{total}
|
{localIndex}/{total}
|
||||||
@@ -45,18 +39,13 @@ function UserProgressBar({
|
|||||||
</span>
|
</span>
|
||||||
{others.map(([id, count]) => {
|
{others.map(([id, count]) => {
|
||||||
const finished = count >= total;
|
const finished = count >= total;
|
||||||
const profile = userProfiles[id];
|
const label = userProfiles[id]?.username ?? "";
|
||||||
const emoji = profile?.avatar ?? getAvatar(id).emoji;
|
|
||||||
const bg = profile ? getAvatarBg(profile.avatar) : getAvatar(id).bg;
|
|
||||||
const label = profile?.username ?? "";
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={id}
|
key={id}
|
||||||
className={`flex items-center gap-1.5 text-[11px] ${finished ? "text-emerald-400" : "text-muted"}`}
|
className={`flex items-center gap-1.5 text-[11px] ${finished ? "text-emerald-400" : "text-muted"}`}
|
||||||
>
|
>
|
||||||
<span className={`inline-flex h-4 w-4 items-center justify-center rounded-full ${bg} text-[10px] leading-none`}>
|
<UserAvatar userId={id} profile={userProfiles[id]} size="xs" />
|
||||||
{emoji}
|
|
||||||
</span>
|
|
||||||
{label && <span className="max-w-12 truncate">{label}</span>}
|
{label && <span className="max-w-12 truncate">{label}</span>}
|
||||||
<span className={`rounded px-1 py-px tabular-nums font-medium ${finished ? "bg-emerald-500/10" : "bg-elevated"}`}>
|
<span className={`rounded px-1 py-px tabular-nums font-medium ${finished ? "bg-emerald-500/10" : "bg-elevated"}`}>
|
||||||
{count}/{total}
|
{count}/{total}
|
||||||
@@ -95,17 +84,11 @@ function WaitingProgress({
|
|||||||
const others = entries.filter(([id]) => id !== userId);
|
const others = entries.filter(([id]) => id !== userId);
|
||||||
const finishedCount = others.filter(([, c]) => c >= total).length;
|
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 (
|
return (
|
||||||
<div className="flex w-60 flex-col gap-2.5 rounded-2xl bg-surface px-4 py-3 ring-1 ring-border">
|
<div className="flex w-60 flex-col gap-2.5 rounded-2xl bg-surface px-4 py-3 ring-1 ring-border">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="flex items-center gap-1.5 text-xs font-medium text-accent">
|
<span className="flex items-center gap-1.5 text-xs font-medium text-accent">
|
||||||
<span className={`inline-flex h-5 w-5 items-center justify-center rounded-full ${myBg} text-sm leading-none`}>
|
<UserAvatar userId={userId} profile={userProfiles[userId]} size="sm" />
|
||||||
{myEmoji}
|
|
||||||
</span>
|
|
||||||
你 {total}/{total}
|
你 {total}/{total}
|
||||||
</span>
|
</span>
|
||||||
<Check size={14} className="text-emerald-400" />
|
<Check size={14} className="text-emerald-400" />
|
||||||
@@ -114,17 +97,17 @@ function WaitingProgress({
|
|||||||
{others.map(([id, count]) => {
|
{others.map(([id, count]) => {
|
||||||
const finished = count >= total;
|
const finished = count >= total;
|
||||||
const pct = Math.min((count / total) * 100, 100);
|
const pct = Math.min((count / total) * 100, 100);
|
||||||
const profile = userProfiles[id];
|
const label = userProfiles[id]?.username ?? "";
|
||||||
const emoji = profile?.avatar ?? getAvatar(id).emoji;
|
|
||||||
const bg = profile ? getAvatarBg(profile.avatar) : getAvatar(id).bg;
|
|
||||||
const label = profile?.username ?? "";
|
|
||||||
return (
|
return (
|
||||||
<div key={id} className="flex flex-col gap-1">
|
<div key={id} className="flex flex-col gap-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className={`flex items-center gap-1.5 text-xs font-medium ${finished ? "text-accent" : "text-muted"}`}>
|
<span className={`flex items-center gap-1.5 text-xs font-medium ${finished ? "text-accent" : "text-muted"}`}>
|
||||||
<span className={`inline-flex h-5 w-5 items-center justify-center rounded-full text-sm leading-none ${finished ? "bg-emerald-500/20" : bg}`}>
|
<UserAvatar
|
||||||
{emoji}
|
userId={id}
|
||||||
</span>
|
profile={userProfiles[id]}
|
||||||
|
size="sm"
|
||||||
|
bg={finished ? "bg-emerald-500/20" : undefined}
|
||||||
|
/>
|
||||||
{label && <span className="max-w-12 truncate">{label}</span>}
|
{label && <span className="max-w-12 truncate">{label}</span>}
|
||||||
{count}/{total}
|
{count}/{total}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { resolveAvatar } from "@/lib/avatars";
|
||||||
|
|
||||||
|
const sizeStyles = {
|
||||||
|
xs: "h-4 w-4 text-[10px]",
|
||||||
|
sm: "h-5 w-5 text-sm",
|
||||||
|
md: "h-8 w-8 text-base",
|
||||||
|
lg: "h-11 w-11 text-xl",
|
||||||
|
xl: "h-14 w-14 text-2xl",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface UserAvatarProps {
|
||||||
|
userId: string;
|
||||||
|
profile?: { avatar: string } | null;
|
||||||
|
size?: keyof typeof sizeStyles;
|
||||||
|
bg?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserAvatar({
|
||||||
|
userId,
|
||||||
|
profile,
|
||||||
|
size = "md",
|
||||||
|
bg,
|
||||||
|
className = "",
|
||||||
|
}: UserAvatarProps) {
|
||||||
|
const avatar = resolveAvatar(userId, profile);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center justify-center rounded-full leading-none ${sizeStyles[size]} ${bg ?? avatar.bg} ${className}`}
|
||||||
|
>
|
||||||
|
{avatar.emoji}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,3 +25,14 @@ export function getAvatarBg(emoji: string): string {
|
|||||||
const found = AVATARS.find((a) => a.emoji === emoji);
|
const found = AVATARS.find((a) => a.emoji === emoji);
|
||||||
return found?.bg ?? "bg-zinc-100";
|
return found?.bg ?? "bg-zinc-100";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveAvatar(
|
||||||
|
userId: string,
|
||||||
|
profile?: { avatar: string } | null,
|
||||||
|
): { emoji: string; bg: string } {
|
||||||
|
if (profile) {
|
||||||
|
return { emoji: profile.avatar, bg: getAvatarBg(profile.avatar) };
|
||||||
|
}
|
||||||
|
const fallback = getAvatar(userId);
|
||||||
|
return { emoji: fallback.emoji, bg: fallback.bg };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user