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:
2026-02-26 19:43:07 +08:00
parent 9641acbcbd
commit 0c5676493e
9 changed files with 182 additions and 101 deletions
+17 -8
View File
@@ -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
View File
@@ -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
+10 -6
View File
@@ -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>
)} )}
+37
View File
@@ -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>;
}
+31
View File
@@ -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;
+3 -10
View File
@@ -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 && (
+12 -29
View File
@@ -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>
+34
View File
@@ -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>
);
}
+11
View File
@@ -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 };
}