0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat(console): initials avatar (#3372)

This commit is contained in:
Charles Zhao 2023-03-16 16:57:23 +08:00 committed by GitHub
parent 36a88abc9b
commit 7b394f77c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 208 additions and 146 deletions

View file

@ -1,4 +0,0 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" fill="#C9C5D0"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24 6.75C20.2721 6.75 17.25 9.77208 17.25 13.5V15C17.25 18.7279 20.2721 21.75 24 21.75C27.7279 21.75 30.75 18.7279 30.75 15V13.5C30.75 9.77208 27.7279 6.75 24 6.75ZM24 41.25C39 41.25 39 36.374 39 36.374C39 30.0495 35.861 24.1543 27.75 24C27.7156 23.9993 27.4814 24.2272 27.1419 24.5574C26.2507 25.4243 24.6339 26.9971 24 26.9971C23.3661 26.9971 21.7493 25.4243 20.8581 24.5574C20.5186 24.2272 20.2844 23.9993 20.25 24C12.139 24.1543 9 30.0495 9 36.374C9 36.374 9 41.25 24 41.25Z" fill="#FFFBFF"/>
</svg>

Before

Width:  |  Height:  |  Size: 695 B

View file

@ -1,4 +0,0 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" fill="#E5E1EC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24 6.75C20.2721 6.75 17.25 9.77208 17.25 13.5V15C17.25 18.7279 20.2721 21.75 24 21.75C27.7279 21.75 30.75 18.7279 30.75 15V13.5C30.75 9.77208 27.7279 6.75 24 6.75ZM24 41.25C39 41.25 39 36.374 39 36.374C39 30.0495 35.861 24.1543 27.75 24C27.7156 23.9993 27.4814 24.2272 27.1419 24.5574C26.2507 25.4243 24.6339 26.9971 24 26.9971C23.3661 26.9971 21.7493 25.4243 20.8581 24.5574C20.5186 24.2272 20.2844 23.9993 20.25 24C12.139 24.1543 9 30.0495 9 36.374C9 36.374 9 41.25 24 41.25Z" fill="#ADAAB4"/>
</svg>

Before

Width:  |  Height:  |  Size: 695 B

View file

@ -0,0 +1,6 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" fill="#F7F8F8"/>
<rect width="48" height="48" fill="#78767F" fill-opacity="0.02"/>
<rect width="48" height="48" fill="#5D34F2" fill-opacity="0.16"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.1738 10C21.1483 10 18.6956 12.4527 18.6956 15.4783V16.6957C18.6956 19.7212 21.1483 22.1739 24.1738 22.1739C27.1994 22.1739 29.6521 19.7212 29.6521 16.6957V15.4783C29.6521 12.4527 27.1994 10 24.1738 10ZM24.1739 38C36.3478 38 36.3478 34.0427 36.3478 34.0427C36.3478 28.9098 33.8002 24.1252 27.2174 24C27.1895 23.9995 26.9994 24.1844 26.7238 24.4524C26.0006 25.156 24.6884 26.4324 24.1739 26.4324C23.6595 26.4324 22.3473 25.156 21.624 24.4524C21.3484 24.1844 21.1583 23.9995 21.1304 24C14.5476 24.1252 12 28.9098 12 34.0427C12 34.0427 12 38 24.1739 38Z" fill="#947DFF"/>
</svg>

After

Width:  |  Height:  |  Size: 902 B

View file

@ -1,24 +1,38 @@
import { Theme } from '@logto/schemas';
import type { ImgHTMLAttributes } from 'react';
import { useState } from 'react';
import type { ImgHTMLAttributes, ReactElement } from 'react';
import { cloneElement, useState } from 'react';
import FallbackImageDark from '@/assets/images/broken-image-dark.svg';
import FallbackImageLight from '@/assets/images/broken-image-light.svg';
import useTheme from '@/hooks/use-theme';
type Props = { containerClassName?: string } & ImgHTMLAttributes<HTMLImageElement>;
type Props = {
containerClassName?: string;
fallbackElement?: ReactElement;
} & ImgHTMLAttributes<HTMLImageElement>;
const ImageWithErrorFallback = ({ src, alt, className, containerClassName, ...props }: Props) => {
const ImageWithErrorFallback = ({
src,
alt,
className,
containerClassName,
fallbackElement,
...props
}: Props) => {
const [hasError, setHasError] = useState(false);
const theme = useTheme();
const Fallback = theme === Theme.Light ? FallbackImageLight : FallbackImageDark;
const DefaultFallback = theme === Theme.Light ? FallbackImageLight : FallbackImageDark;
const errorHandler = () => {
setHasError(true);
};
if (!src || hasError) {
return <Fallback className={className} />;
return fallbackElement ? (
cloneElement(fallbackElement, { className })
) : (
<DefaultFallback className={className} />
);
}
return (

View file

@ -7,19 +7,11 @@
cursor: pointer;
user-select: none;
.avatar {
width: 20px;
height: 20px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
margin-right: _.unit(2);
}
.name {
flex: 1 1 0;
font: var(--font-body-2);
@include _.text-ellipsis;
margin-left: _.unit(2);
}
&:hover {

View file

@ -13,7 +13,7 @@ type Props = {
onSelect: () => void;
};
const SourceUserItem = ({ user: { avatar, name }, isSelected, onSelect }: Props) => {
const SourceUserItem = ({ user, isSelected, onSelect }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
@ -34,8 +34,8 @@ const SourceUserItem = ({ user: { avatar, name }, isSelected, onSelect }: Props)
onSelect();
}}
/>
<UserAvatar className={styles.avatar} url={avatar} />
<div className={styles.name}>{name ?? t('users.unnamed')}</div>
<UserAvatar user={user} size="micro" />
<div className={styles.name}>{user.name ?? t('users.unnamed')}</div>
</div>
);
};

View file

@ -6,19 +6,11 @@
padding: _.unit(2.5) _.unit(4);
user-select: none;
.avatar {
width: 20px;
height: 20px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
margin-right: _.unit(2);
}
.name {
flex: 1 1 0;
font: var(--font-body-2);
@include _.text-ellipsis;
margin-left: _.unit(2);
}
.icon {

View file

@ -12,13 +12,13 @@ type Props = {
onDelete: () => void;
};
const TargetUserItem = ({ user: { avatar, name }, onDelete }: Props) => {
const TargetUserItem = ({ user, onDelete }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<div className={styles.item}>
<UserAvatar className={styles.avatar} url={avatar} />
<div className={styles.name}>{name ?? t('users.unnamed')}</div>
<UserAvatar user={user} size="micro" />
<div className={styles.name}>{user.name ?? t('users.unnamed')}</div>
<IconButton
size="small"
iconClassName={styles.icon}

View file

@ -1,18 +1,67 @@
.avatar {
border-radius: 6px;
.wrapper {
position: relative;
width: 48px;
height: 48px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
&.micro {
width: 20px;
height: 20px;
border-radius: 6px;
}
&.small {
width: 32px;
height: 32px;
width: 24px;
height: 24px;
}
&.medium {
width: 36px;
height: 36px;
width: 32px;
height: 32px;
}
&.large {
width: 40px;
height: 40px;
}
&.xlarge {
width: 60px;
height: 60px;
}
}
.avatar {
display: flex;
align-items: center;
justify-content: center;
user-select: none;
color: #fff;
font: var(--font-headline-3);
width: 48px;
height: 48px;
object-fit: cover;
transform-origin: 0 0;
&.micro {
transform: scale(0.416);
}
&.small {
transform: scale(0.5);
}
&.medium {
transform: scale(0.667);
}
&.large {
transform: scale(0.833);
}
&.xlarge {
transform: scale(1.25);
}
}

View file

@ -1,42 +1,71 @@
import { Theme } from '@logto/schemas';
import type { Nullable } from '@silverhand/essentials';
import type { User } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import DarkAvatar from '@/assets/images/default-avatar-dark.svg';
import LightAvatar from '@/assets/images/default-avatar-light.svg';
import useTheme from '@/hooks/use-theme';
import DefaultAvatar from '@/assets/images/default-avatar.svg';
import ImageWithErrorFallback from '../ImageWithErrorFallback';
import * as styles from './index.module.scss';
type Props = {
className?: string;
url?: Nullable<string>;
size?: 'small' | 'medium' | 'large';
size?: 'micro' | 'small' | 'medium' | 'large' | 'xlarge';
user?: Partial<Pick<User, 'name' | 'username' | 'avatar' | 'primaryEmail'>>;
};
const UserAvatar = ({ className, url, size = 'medium' }: Props) => {
const theme = useTheme();
const DefaultAvatar = theme === Theme.Light ? LightAvatar : DarkAvatar;
const avatarClassName = classNames(styles.avatar, styles[size], className);
const UserAvatar = ({ className, size = 'medium', user }: Props) => {
const avatarClassName = classNames(styles.avatar, styles[size]);
const wrapperClassName = classNames(styles.wrapper, styles[size], className);
const defaultColorPalette = [
'#E74C3C',
'#865300',
'#FF8B64',
'#FFC651',
'#4EA254',
'#2FA0FD',
'#02C2E4',
'#41BEA6',
'#7958FF',
'#ED73A3',
'#DF96FA',
'#ADAAB4',
];
if (url) {
const { name, username, avatar, primaryEmail } = user ?? {};
if (avatar) {
return (
<ImageWithErrorFallback
className={avatarClassName}
src={url}
alt="avatar"
/**
* Some social connectors like Google will block the references to its image resource,
* without specifying the referrerPolicy attribute. Reference:
* https://stackoverflow.com/questions/40570117/http403-forbidden-error-when-trying-to-load-img-src-with-google-profile-pic
*/
referrerPolicy="no-referrer"
/>
<div className={wrapperClassName}>
<ImageWithErrorFallback
className={avatarClassName}
src={avatar}
alt="avatar"
/**
* Some social connectors like Google will block the references to its image resource,
* without specifying the referrerPolicy attribute. Reference:
* https://stackoverflow.com/questions/40570117/http403-forbidden-error-when-trying-to-load-img-src-with-google-profile-pic
*/
referrerPolicy="no-referrer"
fallbackElement={<DefaultAvatar />}
/>
</div>
);
}
return <DefaultAvatar className={avatarClassName} />;
const nameToDisplay = (name ?? username ?? primaryEmail)?.toLocaleUpperCase();
const color = conditional(
nameToDisplay &&
defaultColorPalette[(nameToDisplay.codePointAt(0) ?? 0) % defaultColorPalette.length]
);
return (
<div className={wrapperClassName}>
<div className={avatarClassName} style={{ backgroundColor: color }}>
{nameToDisplay ? nameToDisplay.charAt(0) : <DefaultAvatar />}
</div>
</div>
);
};
export default UserAvatar;

View file

@ -7,12 +7,6 @@
cursor: default;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 6px;
}
.nameWrapper {
display: flex;
flex-direction: column;

View file

@ -11,18 +11,21 @@ type Props = {
Pick<User, 'name' | 'username' | 'avatar' | 'primaryEmail'> &
Pick<IdTokenClaims, 'picture' | 'email'>
>;
avatarSize?: 'small' | 'medium' | 'large';
avatarSize?: 'medium' | 'large';
};
const UserInfoCard = ({ className, user, avatarSize = 'medium' }: Props) => {
const { name, username, avatar, picture, primaryEmail, email } = user ?? {};
const avatarToDisplay = avatar ?? picture;
const nameToDisplay = name ?? username ?? '-';
const emailToDisplay = primaryEmail ?? email ?? '-';
const nameToDisplay = name ?? username;
const emailToDisplay = primaryEmail ?? email;
return (
<div className={classNames(styles.userInfo, className)}>
<UserAvatar className={styles.avatar} url={avatarToDisplay} size={avatarSize} />
<UserAvatar
size={avatarSize}
user={{ name, username, avatar: avatarToDisplay, primaryEmail: emailToDisplay }}
/>
<div className={styles.nameWrapper}>
<div className={styles.name}>{nameToDisplay}</div>
{emailToDisplay && <div className={styles.email}>{emailToDisplay}</div>}

View file

@ -1,21 +1,29 @@
@use '@/scss/underscore' as _;
.container {
display: flex;
align-items: center;
padding: _.unit(2);
position: relative;
margin-left: _.unit(4);
border-radius: 8px;
transition: background-color 0.2s ease-in-out;
user-select: none;
cursor: pointer;
&:hover {
background-color: var(--color-hover-variant);
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
display: block;
border-radius: 8px;
width: 32px;
height: 32px;
z-index: 1;
transition: background 0.2s ease-in-out;
}
&.active {
background-color: var(--color-focused-variant);
&:hover::after {
background: var(--color-hover);
}
&.active::after {
background: var(--color-focused);
}
}

View file

@ -44,6 +44,8 @@ const UserInfo = () => {
return <UserInfoSkeleton />;
}
const { avatar, name } = user ?? {};
return (
<>
<div
@ -58,7 +60,7 @@ const UserInfo = () => {
setShowDropdown(true);
}}
>
<UserAvatar url={user?.avatar} />
<UserAvatar user={user} />
</div>
<Dropdown
hasOverflowContent
@ -70,7 +72,7 @@ const UserInfo = () => {
setShowDropdown(false);
}}
>
<UserInfoCard className={styles.userInfo} user={user} />
<UserInfoCard className={styles.userInfo} user={user} avatarSize="large" />
<Divider />
<DropdownItem
className={classNames(styles.dropdownItem, isLoading && styles.loading)}

View file

@ -55,7 +55,7 @@ const BasicUserInfoSection = ({ user, onUpdate }: Props) => {
key: 'avatar',
label: 'profile.settings.avatar',
value: avatar,
renderer: (value) => <UserAvatar url={value} size="large" />,
renderer: () => <UserAvatar size="large" user={user} />,
action: {
name: 'profile.change',
handler: () => {

View file

@ -70,7 +70,7 @@ const LinkAccountSection = ({ user, connectors, onUpdate }: Props) => {
icon: <ImageWithErrorFallback src={logoSrc} />,
label: <UnnamedTrans resource={name} />,
value: conditional(hasLinked && relatedUserDetails),
renderer: (user) => (user ? <UserInfoCard user={user} avatarSize="small" /> : <NotSet />),
renderer: (user) => (user ? <UserInfoCard user={user} /> : <NotSet />),
action: hasLinked
? {
name: 'profile.unlink',

View file

@ -17,12 +17,4 @@
@include _.text-ellipsis;
}
}
.avatar {
width: 28px;
height: 28px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
}
}

View file

@ -90,15 +90,19 @@ const RoleUsers = () => {
title: t('role_details.users.name_column'),
dataIndex: 'name',
colSpan: 5,
render: ({ id, name, avatar }) => (
<ItemPreview
title={name ?? t('users.unnamed')}
subtitle={id}
icon={<UserAvatar className={styles.avatar} url={avatar} />}
to={`/users/${id}`}
size="compact"
/>
),
render: (user) => {
const { id, name } = user;
return (
<ItemPreview
title={name ?? t('users.unnamed')}
subtitle={id}
icon={<UserAvatar user={user} />}
to={`/users/${id}`}
size="compact"
/>
);
},
},
{
title: t('role_details.users.app_column'),

View file

@ -11,12 +11,8 @@
.avatar {
position: relative;
width: 24px;
height: 24px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
border: 2px solid var(--color-layer-1);
box-sizing: content-box;
&:not(:last-child) {
margin-left: _.unit(-2);

View file

@ -5,7 +5,7 @@ import UserAvatar from '@/components/UserAvatar';
import * as styles from './index.module.scss';
type Props = {
users: Array<Pick<User, 'avatar' | 'id'>>;
users: Array<Pick<User, 'avatar' | 'id' | 'name'>>;
count: number;
};
@ -13,8 +13,8 @@ const AssignedUsers = ({ users, count }: Props) =>
count ? (
<div className={styles.users}>
<div className={styles.avatars}>
{users.map(({ id, avatar }) => (
<UserAvatar key={id} url={avatar} className={styles.avatar} />
{users.map((user) => (
<UserAvatar key={user.id} className={styles.avatar} user={user} size="small" />
))}
</div>
{count > 2 && <span className={styles.count}>{count.toLocaleString()}</span>}

View file

@ -10,7 +10,7 @@
}
.header {
padding: _.unit(6);
padding: _.unit(6) _.unit(8);
display: flex;
align-items: center;
justify-content: space-between;
@ -19,14 +19,6 @@
margin-left: _.unit(6);
}
.avatar {
margin-left: _.unit(2);
border-radius: 8px;
width: 60px;
height: 60px;
object-fit: cover;
}
.metadata {
flex: 1;

View file

@ -85,7 +85,7 @@ const UserDetails = () => {
{data && (
<>
<Card className={styles.header}>
<UserAvatar className={styles.avatar} url={data.avatar} />
<UserAvatar user={data} size="xlarge" />
<div className={styles.metadata}>
<div className={styles.name}>{data.name ?? '-'}</div>
<div>

View file

@ -1,11 +1,3 @@
.searchInput {
width: 338px;
}
.avatar {
width: 28px;
height: 28px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
}

View file

@ -93,15 +93,19 @@ const Users = () => {
title: t('users.user_name'),
dataIndex: 'name',
colSpan: 6,
render: ({ id, name, avatar }) => (
<ItemPreview
title={name ?? t('users.unnamed')}
subtitle={id}
icon={<UserAvatar className={styles.avatar} url={avatar} />}
to={buildDetailsPathname(id)}
size="compact"
/>
),
render: (user) => {
const { id, name } = user;
return (
<ItemPreview
title={name ?? t('users.unnamed')}
subtitle={id}
icon={<UserAvatar user={user} />}
to={buildDetailsPathname(id)}
size="compact"
/>
);
},
},
{
title: t('users.application_name'),

View file

@ -97,6 +97,7 @@ describe('role routes', () => {
{
id: mockUser.id,
avatar: mockUser.avatar,
name: mockUser.name,
},
],
},

View file

@ -67,7 +67,7 @@ export default function roleRoutes<T extends AuthedRouter>(
return {
...role,
usersCount: count,
featuredUsers: users.map(({ id, avatar }) => ({ id, avatar })),
featuredUsers: users.map(({ id, avatar, name }) => ({ id, avatar, name })),
};
})
);

View file

@ -2,5 +2,5 @@ import type { Role, User } from '../db-entries/index.js';
export type RoleResponse = Role & {
usersCount: number;
featuredUsers: Array<Pick<User, 'avatar' | 'id'>>;
featuredUsers: Array<Pick<User, 'avatar' | 'id' | 'name'>>;
};