mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(console): add hover tooltip for micro user avatars (#3861)
This commit is contained in:
parent
0ebaec520e
commit
c6821aab77
13 changed files with 102 additions and 40 deletions
|
@ -1,5 +1,4 @@
|
|||
import type { User } from '@logto/schemas';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Checkbox from '@/components/Checkbox';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
|
@ -16,8 +15,6 @@ type Props = {
|
|||
};
|
||||
|
||||
function SourceUserItem({ user, isSelected, onSelect }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
|
@ -36,8 +33,8 @@ function SourceUserItem({ user, isSelected, onSelect }: Props) {
|
|||
onSelect();
|
||||
}}
|
||||
/>
|
||||
<UserAvatar user={user} size="micro" />
|
||||
<div className={styles.title}>{getUserTitle(user) ?? t('users.unnamed')}</div>
|
||||
<UserAvatar hasTooltip user={user} size="micro" />
|
||||
<div className={styles.title}>{getUserTitle(user)}</div>
|
||||
{user.isSuspended && <SuspendedTag className={styles.suspended} />}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import type { User } from '@logto/schemas';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import Close from '@/assets/images/close.svg';
|
||||
import IconButton from '@/components/IconButton';
|
||||
|
@ -15,13 +14,11 @@ type Props = {
|
|||
};
|
||||
|
||||
function TargetUserItem({ user, onDelete }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
return (
|
||||
<div className={styles.item}>
|
||||
<div className={styles.meta}>
|
||||
<UserAvatar user={user} size="micro" />
|
||||
<div className={styles.title}>{getUserTitle(user) ?? t('users.unnamed')}</div>
|
||||
<UserAvatar hasTooltip user={user} size="micro" />
|
||||
<div className={styles.title}>{getUserTitle(user)}</div>
|
||||
{user.isSuspended && <SuspendedTag className={styles.suspended} />}
|
||||
</div>
|
||||
<IconButton
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
padding: _.unit(2) _.unit(3);
|
||||
font: var(--font-body-2);
|
||||
max-width: 300px;
|
||||
z-index: 200;
|
||||
|
||||
&.successful {
|
||||
background: var(--color-success-60);
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
|
@ -65,3 +67,29 @@
|
|||
transform: scale(1.25);
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
padding: _.unit(2.5);
|
||||
font: var(--font-body-2);
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
|
||||
+ .row {
|
||||
margin-top: _.unit(1);
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
// Fixed font color should used in Tooltip component as the color does not change when theme changes.
|
||||
color: #a9acac;
|
||||
}
|
||||
|
||||
.value {
|
||||
// Fixed font color should used in Tooltip component as the color does not change when theme changes.
|
||||
color: #f7f8f8;
|
||||
margin-left: _.unit(1);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,20 +1,52 @@
|
|||
import type { User } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import DefaultAvatar from '@/assets/images/default-avatar.svg';
|
||||
import { formatToInternationalPhoneNumber } from '@/utils/phone';
|
||||
|
||||
import ImageWithErrorFallback from '../ImageWithErrorFallback';
|
||||
import { Tooltip } from '../Tip';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type UserInfo = Pick<User, 'name' | 'username' | 'avatar' | 'primaryEmail' | 'primaryPhone'>;
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
size?: 'micro' | 'small' | 'medium' | 'large' | 'xlarge';
|
||||
user?: Partial<Pick<User, 'name' | 'username' | 'avatar' | 'primaryEmail'>>;
|
||||
user?: Partial<UserInfo>;
|
||||
hasTooltip?: boolean;
|
||||
};
|
||||
|
||||
function UserAvatar({ className, size = 'medium', user }: Props) {
|
||||
function UserInfoTipContent({ user }: { user: Partial<UserInfo> }) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { name, primaryEmail, primaryPhone, username } = user;
|
||||
const itemsToDisplay = [
|
||||
{ label: t('user_details.field_name'), value: name },
|
||||
{ label: t('user_details.field_email'), value: primaryEmail },
|
||||
{
|
||||
label: t('user_details.field_phone'),
|
||||
value: conditional(primaryPhone && formatToInternationalPhoneNumber(primaryPhone)),
|
||||
},
|
||||
{ label: t('user_details.field_username'), value: username },
|
||||
];
|
||||
return (
|
||||
<>
|
||||
{itemsToDisplay
|
||||
.filter(({ value }) => Boolean(value))
|
||||
.map(({ label, value }) => (
|
||||
<div key={label} className={styles.row}>
|
||||
<span className={styles.label}>{label}:</span>
|
||||
<span className={styles.value}>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserAvatar({ className, size = 'medium', user, hasTooltip = false }: Props) {
|
||||
const avatarClassName = classNames(styles.avatar, styles[size]);
|
||||
const wrapperClassName = classNames(styles.wrapper, styles[size], className);
|
||||
const defaultColorPalette = [
|
||||
|
@ -61,11 +93,16 @@ function UserAvatar({ className, size = 'medium', user }: Props) {
|
|||
);
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<div className={avatarClassName} style={{ backgroundColor: color }}>
|
||||
{nameToDisplay ? nameToDisplay.charAt(0) : <DefaultAvatar />}
|
||||
<Tooltip
|
||||
className={styles.tooltip}
|
||||
content={conditional(hasTooltip && user && <UserInfoTipContent user={user} />)}
|
||||
>
|
||||
<div className={wrapperClassName}>
|
||||
<div className={avatarClassName} style={{ backgroundColor: color }}>
|
||||
{nameToDisplay ? nameToDisplay.charAt(0) : <DefaultAvatar />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -5,21 +5,18 @@
|
|||
color: var(--color-text);
|
||||
|
||||
.title {
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
@include _.text-ellipsis;
|
||||
margin-right: _.unit(1);
|
||||
}
|
||||
|
||||
.id {
|
||||
display: inline-block;
|
||||
font: var(--body-small);
|
||||
color: var(--color-text-secondary);
|
||||
@include _.text-ellipsis;
|
||||
span {
|
||||
margin-left: _.unit(2);
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
text-decoration: none;
|
||||
color: var(--color-text-link);
|
||||
}
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import type { User } from '@logto/schemas';
|
||||
import { conditionalString } from '@silverhand/essentials';
|
||||
import classNames from 'classnames';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import type { RequestError } from '@/hooks/use-api';
|
||||
import { getUserTitle } from '@/utils/user';
|
||||
|
||||
import UserAvatar from '../UserAvatar';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
|
@ -15,10 +18,8 @@ type Props = {
|
|||
|
||||
function UserName({ userId, isLink = false }: Props) {
|
||||
const { data, error } = useSWR<User, RequestError>(`api/users/${userId}`);
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const isLoading = !data && !error;
|
||||
const name = data?.name ?? t('users.unnamed');
|
||||
const name = conditionalString(data && getUserTitle(data));
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
|
@ -28,12 +29,12 @@ function UserName({ userId, isLink = false }: Props) {
|
|||
<div className={styles.userName}>
|
||||
{isLink ? (
|
||||
<Link to={`/users/${userId}`} className={classNames(styles.title, styles.link)}>
|
||||
{name}
|
||||
<UserAvatar hasTooltip size="micro" user={data} />
|
||||
<span>{name}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className={styles.title}>{name}</div>
|
||||
)}
|
||||
<div className={styles.id}>{userId}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -43,7 +43,7 @@ function AuditLogDetails() {
|
|||
conditional(
|
||||
userId &&
|
||||
t('log_details.back_to', {
|
||||
name: conditional(userData && getUserTitle(userData)) ?? t('users.unnamed'),
|
||||
name: getUserTitle(userData),
|
||||
})
|
||||
) ??
|
||||
conditional(
|
||||
|
|
|
@ -98,7 +98,7 @@ function RoleUsers() {
|
|||
|
||||
return (
|
||||
<ItemPreview
|
||||
title={getUserTitle(user) ?? t('users.unnamed')}
|
||||
title={getUserTitle(user)}
|
||||
subtitle={getUserSubtitle(user)}
|
||||
icon={<UserAvatar size="large" user={user} />}
|
||||
to={`/users/${id}`}
|
||||
|
|
|
@ -10,6 +10,7 @@ import ModalLayout from '@/components/ModalLayout';
|
|||
import UserRolesTransfer from '@/components/UserRolesTransfer';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
import { getUserTitle } from '@/utils/user';
|
||||
|
||||
type Props = {
|
||||
user: User;
|
||||
|
@ -19,7 +20,7 @@ type Props = {
|
|||
function AssignRolesModal({ user, onClose }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
const userName = user.name ?? t('users.unnamed');
|
||||
const userName = getUserTitle(user);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [roles, setRoles] = useState<RoleResponse[]>([]);
|
||||
|
|
|
@ -114,7 +114,7 @@ function UserDetails() {
|
|||
<Card className={styles.header}>
|
||||
<UserAvatar user={data} size="xlarge" />
|
||||
<div className={styles.metadata}>
|
||||
<div className={styles.title}>{getUserTitle(data) ?? t('users.unnamed')}</div>
|
||||
<div className={styles.title}>{getUserTitle(data)}</div>
|
||||
<div>
|
||||
{isSuspendedUser && <SuspendedTag />}
|
||||
{userSubtitle && (
|
||||
|
|
|
@ -84,7 +84,7 @@ function Users() {
|
|||
|
||||
return (
|
||||
<ItemPreview
|
||||
title={getUserTitle(user) ?? t('users.unnamed')}
|
||||
title={getUserTitle(user)}
|
||||
subtitle={getUserSubtitle(user)}
|
||||
icon={<UserAvatar size="large" user={user} />}
|
||||
to={buildDetailsPathname(id)}
|
||||
|
|
|
@ -1,16 +1,19 @@
|
|||
import type { User } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { formatToInternationalPhoneNumber } from './phone';
|
||||
|
||||
const getUserIdentity = (user: User) => {
|
||||
const { primaryEmail, primaryPhone, username } = user;
|
||||
const getSecondaryUserInfo = (user?: User) => {
|
||||
const { primaryEmail, primaryPhone, username } = user ?? {};
|
||||
const formattedPhoneNumber = conditional(
|
||||
primaryPhone && formatToInternationalPhoneNumber(primaryPhone)
|
||||
);
|
||||
return primaryEmail ?? formattedPhoneNumber ?? username;
|
||||
};
|
||||
|
||||
export const getUserTitle = (user: User) => user.name ?? getUserIdentity(user);
|
||||
export const getUserTitle = (user?: User) =>
|
||||
user?.name ?? getSecondaryUserInfo(user) ?? t('admin_console.users.unnamed');
|
||||
|
||||
export const getUserSubtitle = (user: User) => conditional(user.name && getUserIdentity(user));
|
||||
export const getUserSubtitle = (user?: User) =>
|
||||
conditional(user?.name && getSecondaryUserInfo(user));
|
||||
|
|
Loading…
Reference in a new issue