feat(avatars): show the uploaded profile photo everywhere
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:
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user