f1a90e120e
System Health's per-user CPU/RAM card asked UserAvatar for the viewer's own picture with no user id, and the host stats never carried avatarAt, so every card fell back to letters. The groups now carry avatarAt and the card passes userId. The user editor's header, the pool board's member chips and the pool member picker drew hand-made initials; they use UserAvatar too.
62 lines
2.0 KiB
TypeScript
62 lines
2.0 KiB
TypeScript
/**
|
|
* @file UserAvatar.tsx
|
|
* @description An account's round picture: the signed-in account's own
|
|
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET
|
|
* /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one —
|
|
* or when the image fails to load — the first letters of its name. Used by the
|
|
* sidebar's account row, its workspace owner groups, Settings → Account, the
|
|
* Users page and its editor, the pool board and member picker, and System
|
|
* Health's per-user CPU/RAM card.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useState } from "react";
|
|
import { api } from "../../lib/api";
|
|
|
|
export function UserAvatar({
|
|
name,
|
|
avatarAt,
|
|
userId,
|
|
letters = 2,
|
|
className = "h-7 w-7 text-[11px]",
|
|
initialsClassName = "bg-accent/15 text-accent",
|
|
}: {
|
|
name: string;
|
|
avatarAt?: string | null;
|
|
/** Another account's (staff only); omitted = the signed-in account's own. */
|
|
userId?: number;
|
|
/** How many letters of the name stand in for a missing picture. */
|
|
letters?: number;
|
|
/** Size and font size: the parts that differ between call sites. */
|
|
className?: string;
|
|
/** The initials' colours. */
|
|
initialsClassName?: string;
|
|
}) {
|
|
const url = userId != null ? api.users.avatarUrl(userId, avatarAt) : api.me.avatarUrl(avatarAt);
|
|
// A broken image falls back to the initials, per URL.
|
|
const [failed, setFailed] = useState<string | null>(null);
|
|
const base = `flex flex-shrink-0 items-center justify-center overflow-hidden rounded-full ${className}`;
|
|
if (url && failed !== url) {
|
|
return (
|
|
<img
|
|
src={url}
|
|
alt=""
|
|
aria-hidden
|
|
draggable={false}
|
|
onError={() => setFailed(url)}
|
|
className={`${base} object-cover`}
|
|
data-testid="user-avatar-img"
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<span
|
|
aria-hidden
|
|
className={`${base} ${initialsClassName} font-semibold uppercase`}
|
|
data-testid="user-avatar-initials"
|
|
>
|
|
{name.slice(0, letters)}
|
|
</span>
|
|
);
|
|
}
|