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:
@@ -8,6 +8,7 @@ import { setCachedProfile } from "@/lib/userId";
|
||||
import type { UserProfile } from "@/types";
|
||||
import Modal from "@/components/Modal";
|
||||
import Button from "@/components/Button";
|
||||
import Input from "@/components/Input";
|
||||
|
||||
type Tab = "login" | "register";
|
||||
|
||||
@@ -147,7 +148,7 @@ export default function AuthModal({ open, onClose, onAuth, defaultTab = "login"
|
||||
|
||||
<div className="mt-5">
|
||||
<p className="text-xs font-medium text-muted">用户名</p>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
@@ -156,14 +157,15 @@ export default function AuthModal({ open, onClose, onAuth, defaultTab = "login"
|
||||
}}
|
||||
placeholder={tab === "register" ? "2-16 个字符" : "请输入用户名"}
|
||||
maxLength={16}
|
||||
className="mt-2 h-11 w-full rounded-xl border-none bg-elevated px-4 text-sm text-heading outline-none ring-1 ring-border transition-colors placeholder:text-dim focus:ring-2 focus:ring-accent/50"
|
||||
size="xl"
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-medium text-muted">密码</p>
|
||||
<div className="relative mt-2">
|
||||
<input
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
@@ -171,7 +173,8 @@ export default function AuthModal({ open, onClose, onAuth, defaultTab = "login"
|
||||
setError("");
|
||||
}}
|
||||
placeholder={tab === "register" ? "至少 6 个字符" : "请输入密码"}
|
||||
className="h-11 w-full rounded-xl border-none bg-elevated px-4 pr-10 text-sm text-heading outline-none ring-1 ring-border transition-colors placeholder:text-dim focus:ring-2 focus:ring-accent/50"
|
||||
size="xl"
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -186,7 +189,7 @@ export default function AuthModal({ open, onClose, onAuth, defaultTab = "login"
|
||||
{tab === "register" && (
|
||||
<div className="mt-4">
|
||||
<p className="text-xs font-medium text-muted">确认密码</p>
|
||||
<input
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => {
|
||||
@@ -194,7 +197,8 @@ export default function AuthModal({ open, onClose, onAuth, defaultTab = "login"
|
||||
setError("");
|
||||
}}
|
||||
placeholder="再次输入密码"
|
||||
className="mt-2 h-11 w-full rounded-xl border-none bg-elevated px-4 text-sm text-heading outline-none ring-1 ring-border transition-colors placeholder:text-dim focus:ring-2 focus:ring-accent/50"
|
||||
size="xl"
|
||||
className="mt-2"
|
||||
/>
|
||||
</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,
|
||||
} from "lucide-react";
|
||||
import { UserProfile } from "@/types";
|
||||
import { getAvatar, getAvatarBg } from "@/lib/avatars";
|
||||
import UserAvatar from "@/components/UserAvatar";
|
||||
import Modal from "@/components/Modal";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
|
||||
@@ -129,10 +129,7 @@ export default function RoomManageModal({
|
||||
</h3>
|
||||
<div className="mt-2 flex flex-col gap-1.5">
|
||||
{users.map((uid) => {
|
||||
const profile = userProfiles[uid];
|
||||
const emoji = profile?.avatar ?? getAvatar(uid).emoji;
|
||||
const bg = profile ? getAvatarBg(profile.avatar) : getAvatar(uid).bg;
|
||||
const displayName = profile?.username ?? uid.slice(0, 8);
|
||||
const displayName = userProfiles[uid]?.username ?? uid.slice(0, 8);
|
||||
const isCreator = uid === userId;
|
||||
const swiped = swipeCounts[uid] ?? 0;
|
||||
const finished = swiped >= totalCards;
|
||||
@@ -142,11 +139,7 @@ export default function RoomManageModal({
|
||||
key={uid}
|
||||
className="flex items-center gap-2.5 rounded-xl bg-elevated px-3 py-2.5"
|
||||
>
|
||||
<span
|
||||
className={`inline-flex h-8 w-8 items-center justify-center rounded-full text-base ${bg}`}
|
||||
>
|
||||
{emoji}
|
||||
</span>
|
||||
<UserAvatar userId={uid} profile={userProfiles[uid]} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isCreator && (
|
||||
|
||||
@@ -8,7 +8,7 @@ import MatchResult from "./MatchResult";
|
||||
import SwipeGuide from "./SwipeGuide";
|
||||
import { Restaurant, SwipeDirection, MatchType, RunnerUp, UserProfile, SceneType } from "@/types";
|
||||
import { Heart, Undo2, Check } from "lucide-react";
|
||||
import { getAvatar, getAvatarBg } from "@/lib/avatars";
|
||||
import UserAvatar from "@/components/UserAvatar";
|
||||
|
||||
function UserProgressBar({
|
||||
userId,
|
||||
@@ -27,17 +27,11 @@ function UserProgressBar({
|
||||
}) {
|
||||
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 (
|
||||
<div className="flex items-center gap-x-3">
|
||||
<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={`inline-flex h-4 w-4 items-center justify-center rounded-full ${myAvatarBg} text-[10px] leading-none`}>
|
||||
{myAvatar}
|
||||
</span>
|
||||
<UserAvatar userId={userId} profile={userProfiles[userId]} size="xs" />
|
||||
你
|
||||
<span className="rounded bg-accent/10 px-1 py-px tabular-nums font-medium">
|
||||
{localIndex}/{total}
|
||||
@@ -45,18 +39,13 @@ function UserProgressBar({
|
||||
</span>
|
||||
{others.map(([id, count]) => {
|
||||
const finished = count >= total;
|
||||
const profile = userProfiles[id];
|
||||
const emoji = profile?.avatar ?? getAvatar(id).emoji;
|
||||
const bg = profile ? getAvatarBg(profile.avatar) : getAvatar(id).bg;
|
||||
const label = profile?.username ?? "";
|
||||
const label = userProfiles[id]?.username ?? "";
|
||||
return (
|
||||
<span
|
||||
key={id}
|
||||
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`}>
|
||||
{emoji}
|
||||
</span>
|
||||
<UserAvatar userId={id} profile={userProfiles[id]} size="xs" />
|
||||
{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"}`}>
|
||||
{count}/{total}
|
||||
@@ -95,17 +84,11 @@ 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-surface px-4 py-3 ring-1 ring-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<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`}>
|
||||
{myEmoji}
|
||||
</span>
|
||||
<UserAvatar userId={userId} profile={userProfiles[userId]} size="sm" />
|
||||
你 {total}/{total}
|
||||
</span>
|
||||
<Check size={14} className="text-emerald-400" />
|
||||
@@ -114,17 +97,17 @@ function WaitingProgress({
|
||||
{others.map(([id, count]) => {
|
||||
const finished = count >= total;
|
||||
const pct = Math.min((count / total) * 100, 100);
|
||||
const profile = userProfiles[id];
|
||||
const emoji = profile?.avatar ?? getAvatar(id).emoji;
|
||||
const bg = profile ? getAvatarBg(profile.avatar) : getAvatar(id).bg;
|
||||
const label = profile?.username ?? "";
|
||||
const label = userProfiles[id]?.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-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}`}>
|
||||
{emoji}
|
||||
</span>
|
||||
<UserAvatar
|
||||
userId={id}
|
||||
profile={userProfiles[id]}
|
||||
size="sm"
|
||||
bg={finished ? "bg-emerald-500/20" : undefined}
|
||||
/>
|
||||
{label && <span className="max-w-12 truncate">{label}</span>}
|
||||
{count}/{total}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user