feat(avatars): show the uploaded profile photo everywhere
CI/CD / CI - API (dotnet build + engine sim) (push) Successful in 2m35s
CI/CD / CI - Web (tsc + next build) (push) Successful in 1m17s
CI/CD / Deploy - local stack (db + server + web) (push) Successful in 1m12s

Previously the uploaded profile photo only appeared in a few places (profile,
top bar, leaderboard, public profile); chat, friends, game table, match intro,
post-match roster and private rooms showed the emoji avatar only.

- carry avatarImage end-to-end:
  - server DTOs: FriendDto, SeatPlayerDto, RoomPlayerDto, MatchmakeRequest +
    Player/SeatSlot/PSeat; ResolveProfile now returns avatarImage; FriendDtoFor
    fills it from the profile.
  - client types: Friend, RoomSeat.player, MatchmakingState.players,
    ServerSeatPlayer, SeatPlayer (adds avatarId + avatarImage).
  - signalr-service: send my avatarImage on StartMatchmaking/CreatePrivateRoom;
    carry it through mapRoom.
  - game-store: applyServerState + newOnlineMatch + offline match now populate
    avatarId/avatarImage (seat 0 uses your own profile photo).
- render every avatar through the shared <Avatar> component (image → emoji
  fallback): ChatScreen, FriendsScreen (requests/friends/chats), GameTable
  seats, MatchIntroOverlay, MatchPlayersList, RoomScreen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-17 08:17:27 +03:30
parent e5b48ecb26
commit 4739018488
16 changed files with 85 additions and 47 deletions
+6 -1
View File
@@ -5,6 +5,7 @@ import { Crown, Flag, LogOut, SmilePlus, Volume2, VolumeX, Zap } from "lucide-re
import { useEffect, useMemo, useState } from "react";
import { useGameStore } from "@/lib/game-store";
import { useSoundStore } from "@/lib/sound-store";
import { Avatar } from "@/components/online/Avatar";
import { legalMoves } from "@/lib/hokm/engine";
import { sortHand } from "@/lib/hokm/deck";
import {
@@ -341,7 +342,11 @@ function SeatAvatar({ seat, className }: { seat: Seat; className?: string }) {
)}
style={!active ? { boxShadow: "0 0 0 1px rgba(212,175,55,0.2)" } : undefined}
>
{sp?.avatar ?? name.charAt(0)}
{sp?.avatarId || sp?.avatarImage ? (
<Avatar id={sp.avatarId ?? "a-fox"} image={sp.avatarImage} size={44} />
) : (
sp?.avatar ?? name.charAt(0)
)}
{isHakem && (
<Crown className="absolute -top-4 size-5 text-gold-400 fill-gold-500 drop-shadow" />
)}
+4 -1
View File
@@ -6,6 +6,7 @@ import { useGameStore } from "@/lib/game-store";
import { useI18n } from "@/lib/i18n";
import { teamOf, type Seat } from "@/lib/hokm/types";
import { titleById } from "@/lib/online/gamification";
import { Avatar } from "./Avatar";
import { cn } from "@/lib/cn";
// Where each seat sits around the table + which edge it slides in from.
@@ -39,7 +40,9 @@ function Seat({ seat }: { seat: Seat }) {
style={{ boxShadow: team === 0 ? "0 0 18px rgba(45,212,191,0.4)" : "0 0 18px rgba(251,113,133,0.4)" }}
>
{sp ? (
sp.avatar
sp.avatarId || sp.avatarImage
? <Avatar id={sp.avatarId ?? "a-fox"} image={sp.avatarImage} size={48} />
: sp.avatar
) : (
<motion.span
className="text-cream/30 text-xl"
+7 -2
View File
@@ -2,6 +2,7 @@
import { Check, UserPlus } from "lucide-react";
import { useState } from "react";
import { Avatar } from "./Avatar";
import { useGameStore } from "@/lib/game-store";
import { useSessionStore } from "@/lib/session-store";
import { useUIStore } from "@/lib/ui-store";
@@ -47,8 +48,12 @@ export function MatchPlayersList() {
(canAdd ? "active:scale-[0.97] transition" : "cursor-default")
}
>
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-navy-900 text-lg">
{p.avatar}
<span className="grid size-9 shrink-0 place-items-center overflow-hidden rounded-lg bg-navy-900 text-lg">
{p.avatarId || p.avatarImage ? (
<Avatar id={p.avatarId ?? "a-fox"} image={p.avatarImage} size={30} />
) : (
p.avatar
)}
</span>
<span className="w-full truncate text-[11px] font-semibold text-cream">{p.name}</span>
<span className="text-[9px] text-cream/45 leading-tight">
+2 -2
View File
@@ -10,7 +10,7 @@ import { sound } from "@/lib/sound";
import { getService } from "@/lib/online/service";
import { pushNotification } from "@/lib/notification-store";
import { ownedReactions } from "@/lib/online/gamification";
import { avatarEmoji } from "@/lib/online/types";
import { Avatar } from "@/components/online/Avatar";
import { cn } from "@/lib/cn";
export function ChatScreen() {
@@ -126,7 +126,7 @@ export function ChatScreen() {
onClick={() => viewProfile(friend.id)}
className="flex items-center gap-3 min-w-0 flex-1 text-start active:scale-[0.99] transition"
>
<span className="text-2xl shrink-0">{avatarEmoji(friend.avatar)}</span>
<span className="shrink-0"><Avatar id={friend.avatar} image={friend.avatarImage} size={34} /></span>
<div className="min-w-0">
<div className="text-sm font-bold text-cream truncate">{friend.displayName}</div>
<div className={cn("text-[11px]", peerTyping ? "text-gold-300" : "text-teal-300")}>
+4 -5
View File
@@ -26,7 +26,6 @@ import {
Friend,
PlayerSummary,
PresenceStatus,
avatarEmoji,
} from "@/lib/online/types";
import { GENDER_META } from "@/lib/social";
import { titleById } from "@/lib/online/gamification";
@@ -130,8 +129,8 @@ function FriendsTab() {
<div className="grid gap-2 lg:grid-cols-2">
{requests.map((r) => (
<div key={r.id} className="glass rounded-xl p-2.5 flex items-center gap-3">
<button onClick={() => viewProfile(r.from.id)} className="text-2xl active:scale-95 transition">
{avatarEmoji(r.from.avatar)}
<button onClick={() => viewProfile(r.from.id)} className="active:scale-95 transition">
<Avatar id={r.from.avatar} image={r.from.avatarImage} size={34} />
</button>
<span className="flex-1 text-sm font-semibold text-cream">{r.from.displayName}</span>
<button onClick={() => accept(r.id)} className="size-8 rounded-lg bg-teal-600/80 grid place-items-center hover:bg-teal-600">
@@ -156,7 +155,7 @@ function FriendsTab() {
<div key={f.id} className="glass rounded-xl p-2.5 flex items-center gap-3">
<button onClick={() => viewProfile(f.id)} className="flex items-center gap-3 flex-1 min-w-0 text-start active:scale-[0.99] transition">
<div className="relative shrink-0">
<span className="text-2xl">{avatarEmoji(f.avatar)}</span>
<Avatar id={f.avatar} image={f.avatarImage} size={34} />
<span className={cn("absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900", STATUS_COLOR[f.status])} />
</div>
<div className="flex-1 min-w-0">
@@ -418,7 +417,7 @@ function MessagesTab() {
>
<button onClick={() => open(c)} className="flex items-center gap-3 flex-1 min-w-0 text-start active:scale-[0.99] transition">
<div className="relative shrink-0">
<span className="text-2xl">{avatarEmoji(c.friend.avatar)}</span>
<Avatar id={c.friend.avatar} image={c.friend.avatarImage} size={34} />
<span className={cn("absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900", STATUS_COLOR[c.friend.status])} />
</div>
<div className="flex-1 min-w-0">
+4 -3
View File
@@ -9,7 +9,8 @@ import { useOnlineStore } from "@/lib/online-store";
import { useUIStore } from "@/lib/ui-store";
import { useI18n } from "@/lib/i18n";
import { getService } from "@/lib/online/service";
import { Friend, PresenceStatus, RoomSeat, avatarEmoji } from "@/lib/online/types";
import { Friend, PresenceStatus, RoomSeat } from "@/lib/online/types";
import { Avatar } from "@/components/online/Avatar";
import { cn } from "@/lib/cn";
const STATUS_COLOR: Record<PresenceStatus, string> = {
@@ -210,7 +211,7 @@ export function RoomScreen() {
className="w-full glass rounded-xl p-2.5 flex items-center gap-3 hover:bg-navy-800/80 transition text-start"
>
<span className="relative shrink-0">
<span className="text-2xl">{avatarEmoji(f.avatar)}</span>
<Avatar id={f.avatar} image={f.avatarImage} size={34} />
<span className={cn("absolute -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3 rounded-full border-2 border-navy-900", STATUS_COLOR[f.status])} />
</span>
<span className="flex-1 min-w-0">
@@ -263,7 +264,7 @@ function SeatCard({
<span className="text-[10px] text-cream/50">{label}</span>
{filled ? (
<>
<span className="text-3xl">{avatarEmoji(seat.player?.avatar ?? "a-fox")}</span>
<Avatar id={seat.player?.avatar ?? "a-fox"} image={seat.player?.avatarImage} size={48} />
<span className="text-sm font-bold text-cream text-center max-w-full truncate">
{seat.player?.displayName}
{seat.kind === "bot" && <span className="text-cream/40"> 🤖</span>}
+29 -15
View File
@@ -39,7 +39,9 @@ export type GameMode = "ai" | "online";
export interface SeatPlayer {
name: string;
avatar: string; // emoji
avatar: string; // emoji (legacy/fallback)
avatarId?: string; // avatar id (for the shared <Avatar> component)
avatarImage?: string | null; // uploaded profile photo — shown everywhere when present
level: number;
id?: string; // real player's user id (for add-friend); absent for bots/you
isBot?: boolean;
@@ -54,7 +56,7 @@ export interface GameSettings {
}
export interface OnlineMatchConfig {
players: { displayName: string; avatar: string; level: number }[]; // index = seat
players: { displayName: string; avatar: string; level: number; avatarImage?: string | null }[]; // index = seat
targetScore: number;
stake: number;
ranked: boolean;
@@ -338,13 +340,18 @@ export const useGameStore = create<GameStore>((set, get) => {
turnDeadline: null,
disconnectedSeat: null,
reconnectDeadline: null,
seatPlayers: settings.names.map((name, i) => ({
name,
avatar: AI_AVATARS[i],
level: 0,
isBot: i > 0, // seat 0 is you
title: i === 0 ? useSessionStore.getState().profile?.title ?? null : null,
})),
seatPlayers: settings.names.map((name, i) => {
const prof = i === 0 ? useSessionStore.getState().profile : null;
return {
name,
avatar: AI_AVATARS[i],
avatarId: prof?.avatar, // you → your avatar; bots fall back to the emoji
avatarImage: prof?.avatarImage ?? null,
level: 0,
isBot: i > 0, // seat 0 is you
title: i === 0 ? prof?.title ?? null : null,
};
}),
});
scheduleAuto();
},
@@ -368,12 +375,17 @@ export const useGameStore = create<GameStore>((set, get) => {
turnDeadline: null,
disconnectedSeat: null,
reconnectDeadline: null,
seatPlayers: cfg.players.map((p, i) => ({
name: p.displayName,
avatar: avatarEmoji(p.avatar),
level: p.level,
title: i === 0 ? useSessionStore.getState().profile?.title ?? null : null,
})),
seatPlayers: cfg.players.map((p, i) => {
const prof = i === 0 ? useSessionStore.getState().profile : null;
return {
name: p.displayName,
avatar: avatarEmoji(p.avatar),
avatarId: p.avatar,
avatarImage: p.avatarImage ?? prof?.avatarImage ?? null,
level: p.level,
title: i === 0 ? prof?.title ?? null : null,
};
}),
});
scheduleAuto();
},
@@ -416,6 +428,8 @@ export const useGameStore = create<GameStore>((set, get) => {
.map((sp) => ({
name: sp.name,
avatar: avatarEmoji(sp.avatar),
avatarId: sp.avatar,
avatarImage: sp.avatarImage ?? null,
level: sp.level,
id: sp.userId,
isBot: sp.isBot,
+3 -3
View File
@@ -45,7 +45,7 @@ interface ServerRoom {
targetScore: number;
stake: number;
ranked: boolean;
seats: { seat: number; kind: string; player?: { id: string; displayName: string; avatar: string; level: number } }[];
seats: { seat: number; kind: string; player?: { id: string; displayName: string; avatar: string; avatarImage?: string; level: number } }[];
}
const EMPTY_ROOM: Room = {
@@ -318,7 +318,7 @@ export class SignalrService implements OnlineService {
const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile()));
this.emitMM("searching");
await this.conn?.invoke("StartMatchmaking", {
name: p.displayName, avatar: p.avatar, level: p.level, plan: p.plan,
name: p.displayName, avatar: p.avatar, level: p.level, plan: p.plan, avatarImage: p.avatarImage,
});
}
@@ -497,7 +497,7 @@ export class SignalrService implements OnlineService {
await this.connect();
const p = this.cachedProfile ?? (await this.getProfile().catch(() => this.mock.getProfile()));
await this.conn?.invoke("CreatePrivateRoom",
{ name: p.displayName, avatar: p.avatar, level: p.level, plan: p.plan }, o.stake, o.targetScore);
{ name: p.displayName, avatar: p.avatar, level: p.level, plan: p.plan, avatarImage: p.avatarImage }, o.stake, o.targetScore);
return this.waitRoom();
}
async setPartner(_roomId: string, friendId: string | null) {
+4
View File
@@ -315,6 +315,7 @@ export interface Friend {
username: string;
displayName: string;
avatar: string;
avatarImage?: string; // uploaded photo (overrides the emoji avatar)
level: number;
rating: number;
status: PresenceStatus;
@@ -340,6 +341,7 @@ export interface RoomSeat {
id: string;
displayName: string;
avatar: string;
avatarImage?: string;
level: number;
};
}
@@ -381,6 +383,7 @@ export interface MatchmakingState {
id: string;
displayName: string;
avatar: string;
avatarImage?: string;
level: number;
rating: number;
}[];
@@ -565,6 +568,7 @@ export interface ServerSeatPlayer {
seat: number;
name: string;
avatar: string;
avatarImage?: string | null;
level: number;
connected: boolean;
isBot: boolean;