feat: colored avatar (#554)
* feat: colored avatar * fix: use id instead of name & remove unused * fix: remove unused * Allow empty ID to avoid empty string * Fix tests * Add person chip story --------- Co-authored-by: Emilien <emilien.chauvet.enpc@gmail.com>
This commit is contained in:
@ -10,7 +10,7 @@ import {
|
||||
|
||||
import { useHandleCheckableCommentThreadTargetChange } from '@/comments/hooks/useHandleCheckableCommentThreadTargetChange';
|
||||
import { CommentableEntityForSelect } from '@/comments/types/CommentableEntityForSelect';
|
||||
import CompanyChip from '@/companies/components/CompanyChip';
|
||||
import { CompanyChip } from '@/companies/components/CompanyChip';
|
||||
import { useScopedHotkeys } from '@/hotkeys/hooks/useScopedHotkeys';
|
||||
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
|
||||
import { PersonChip } from '@/people/components/PersonChip';
|
||||
|
||||
@ -8,7 +8,6 @@ import {
|
||||
beautifyExactDate,
|
||||
beautifyPastDateRelativeToNow,
|
||||
} from '@/utils/datetime/date-utils';
|
||||
import { isNonEmptyString } from '@/utils/type-guards/isNonEmptyString';
|
||||
|
||||
type OwnProps = {
|
||||
comment: Pick<CommentForDrawer, 'id' | 'author' | 'createdAt'>;
|
||||
@ -74,17 +73,14 @@ export function CommentHeader({ comment, actionBar }: OwnProps) {
|
||||
const avatarUrl = author.avatarUrl;
|
||||
const commentId = comment.id;
|
||||
|
||||
const capitalizedFirstUsernameLetter = isNonEmptyString(authorName)
|
||||
? authorName.toLocaleUpperCase()[0]
|
||||
: '';
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledLeftContainer>
|
||||
<Avatar
|
||||
avatarUrl={avatarUrl}
|
||||
size={theme.icon.size.md}
|
||||
placeholder={capitalizedFirstUsernameLetter}
|
||||
colorId={author.id}
|
||||
placeholder={author.displayName}
|
||||
/>
|
||||
<StyledName>{authorName}</StyledName>
|
||||
{showDate && (
|
||||
|
||||
@ -16,7 +16,10 @@ export function CompanyAccountOwnerCell({ company }: OwnProps) {
|
||||
editModeContent={<CompanyAccountOwnerPicker company={company} />}
|
||||
nonEditModeContent={
|
||||
company.accountOwner?.displayName ? (
|
||||
<PersonChip name={company.accountOwner?.displayName ?? ''} />
|
||||
<PersonChip
|
||||
id={company.accountOwner.id}
|
||||
name={company.accountOwner?.displayName ?? ''}
|
||||
/>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
|
||||
@ -5,7 +5,7 @@ import styled from '@emotion/styled';
|
||||
import { Avatar } from '@/users/components/Avatar';
|
||||
|
||||
export type CompanyChipPropsType = {
|
||||
id?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
picture?: string;
|
||||
};
|
||||
@ -50,7 +50,7 @@ const StyledContainerNoLink = styled.div`
|
||||
${baseStyle}
|
||||
`;
|
||||
|
||||
function CompanyChip({ id, name, picture }: CompanyChipPropsType) {
|
||||
export function CompanyChip({ id, name, picture }: CompanyChipPropsType) {
|
||||
const ContainerComponent = id ? StyledContainerLink : StyledContainerNoLink;
|
||||
|
||||
return (
|
||||
@ -58,6 +58,7 @@ function CompanyChip({ id, name, picture }: CompanyChipPropsType) {
|
||||
{picture && (
|
||||
<Avatar
|
||||
avatarUrl={picture?.toString()}
|
||||
colorId={id}
|
||||
placeholder={name}
|
||||
type="squared"
|
||||
size={14}
|
||||
@ -67,5 +68,3 @@ function CompanyChip({ id, name, picture }: CompanyChipPropsType) {
|
||||
</ContainerComponent>
|
||||
);
|
||||
}
|
||||
|
||||
export default CompanyChip;
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
useUpdateCompanyMutation,
|
||||
} from '~/generated/graphql';
|
||||
|
||||
import CompanyChip from './CompanyChip';
|
||||
import { CompanyChip } from './CompanyChip';
|
||||
|
||||
type OwnProps = {
|
||||
company: Pick<
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import styled from '@emotion/styled';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
|
||||
|
||||
import CompanyChip from '../CompanyChip';
|
||||
import { CompanyChip } from '../CompanyChip';
|
||||
|
||||
const meta: Meta<typeof CompanyChip> = {
|
||||
title: 'Modules/Companies/CompanyChip',
|
||||
@ -32,10 +33,13 @@ const TestCellContainer = styled.div`
|
||||
export const SmallName: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<TestCellContainer>
|
||||
<CompanyChip
|
||||
name="Airbnb"
|
||||
picture="https://api.faviconkit.com/airbnb.com/144"
|
||||
/>
|
||||
<BrowserRouter>
|
||||
<CompanyChip
|
||||
id="airbnb"
|
||||
name="Airbnb"
|
||||
picture="https://api.faviconkit.com/airbnb.com/144"
|
||||
/>
|
||||
</BrowserRouter>
|
||||
</TestCellContainer>,
|
||||
),
|
||||
};
|
||||
@ -43,10 +47,13 @@ export const SmallName: Story = {
|
||||
export const BigName: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<TestCellContainer>
|
||||
<CompanyChip
|
||||
name="Google with a real big name to overflow the cell"
|
||||
picture="https://api.faviconkit.com/google.com/144"
|
||||
/>
|
||||
<BrowserRouter>
|
||||
<CompanyChip
|
||||
id="google"
|
||||
name="Google with a real big name to overflow the cell"
|
||||
picture="https://api.faviconkit.com/google.com/144"
|
||||
/>
|
||||
</BrowserRouter>
|
||||
</TestCellContainer>,
|
||||
),
|
||||
};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import CompanyChip from '@/companies/components/CompanyChip';
|
||||
import { CompanyChip } from '@/companies/components/CompanyChip';
|
||||
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
|
||||
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
|
||||
import { EditableCell } from '@/ui/components/editable-cell/EditableCell';
|
||||
|
||||
@ -3,10 +3,10 @@ import { Link } from 'react-router-dom';
|
||||
import { Theme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import PersonPlaceholder from './person-placeholder.png';
|
||||
import { Avatar } from '@/users/components/Avatar';
|
||||
|
||||
export type PersonChipPropsType = {
|
||||
id?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
picture?: string;
|
||||
};
|
||||
@ -52,10 +52,12 @@ export function PersonChip({ id, name, picture }: PersonChipPropsType) {
|
||||
const ContainerComponent = id ? StyledContainerLink : StyledContainerNoLink;
|
||||
return (
|
||||
<ContainerComponent data-testid="person-chip" to={`/person/${id}`}>
|
||||
<img
|
||||
data-testid="person-chip-image"
|
||||
src={picture ? picture.toString() : PersonPlaceholder.toString()}
|
||||
alt="person"
|
||||
<Avatar
|
||||
avatarUrl={picture}
|
||||
colorId={id}
|
||||
placeholder={name}
|
||||
size={14}
|
||||
type="rounded"
|
||||
/>
|
||||
<StyledName>{name}</StyledName>
|
||||
</ContainerComponent>
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import styled from '@emotion/styled';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { getRenderWrapperForComponent } from '~/testing/renderWrappers';
|
||||
|
||||
import { PersonChip } from '../PersonChip';
|
||||
|
||||
const meta: Meta<typeof PersonChip> = {
|
||||
title: 'Modules/Companies/PersonChip',
|
||||
component: PersonChip,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PersonChip>;
|
||||
|
||||
const TestCellContainer = styled.div`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.primary};
|
||||
display: flex;
|
||||
height: fit-content;
|
||||
justify-content: space-between;
|
||||
max-width: 250px;
|
||||
min-width: 250px;
|
||||
overflow: hidden;
|
||||
text-wrap: nowrap;
|
||||
`;
|
||||
|
||||
export const SmallName: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<TestCellContainer>
|
||||
<BrowserRouter>
|
||||
<PersonChip id="tim_fake_id" name="Tim C." />
|
||||
</BrowserRouter>
|
||||
</TestCellContainer>,
|
||||
),
|
||||
};
|
||||
|
||||
export const BigName: Story = {
|
||||
render: getRenderWrapperForComponent(
|
||||
<TestCellContainer>
|
||||
<BrowserRouter>
|
||||
<PersonChip id="steve_fake_id" name="Steve J." />
|
||||
</BrowserRouter>
|
||||
</TestCellContainer>,
|
||||
),
|
||||
};
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
@ -71,6 +71,7 @@ export function MultipleEntitySelect<
|
||||
>
|
||||
<Avatar
|
||||
avatarUrl={entity.avatarUrl}
|
||||
colorId={entity.id}
|
||||
placeholder={entity.name}
|
||||
size={16}
|
||||
type={entity.avatarType ?? 'rounded'}
|
||||
|
||||
@ -69,6 +69,7 @@ export function SingleEntitySelectBase<
|
||||
>
|
||||
<Avatar
|
||||
avatarUrl={entity.avatarUrl}
|
||||
colorId={entity.id}
|
||||
placeholder={entity.name}
|
||||
size={16}
|
||||
type={entity.avatarType ?? 'rounded'}
|
||||
|
||||
@ -9,6 +9,13 @@ import {
|
||||
beautifyPastDateRelativeToNow,
|
||||
} from '@/utils/datetime/date-utils';
|
||||
|
||||
type OwnProps = {
|
||||
id?: string;
|
||||
logoOrAvatar?: string;
|
||||
title: string;
|
||||
date: string;
|
||||
};
|
||||
|
||||
const StyledShowPageSummaryCard = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
@ -46,14 +53,11 @@ const StyledTooltip = styled(Tooltip)`
|
||||
`;
|
||||
|
||||
export function ShowPageSummaryCard({
|
||||
id,
|
||||
logoOrAvatar,
|
||||
title,
|
||||
date,
|
||||
}: {
|
||||
logoOrAvatar?: string;
|
||||
title: string;
|
||||
date: string;
|
||||
}) {
|
||||
}: OwnProps) {
|
||||
const beautifiedCreatedAt =
|
||||
date !== '' ? beautifyPastDateRelativeToNow(date) : '';
|
||||
const exactCreatedAt = date !== '' ? beautifyExactDate(date) : '';
|
||||
@ -65,6 +69,7 @@ export function ShowPageSummaryCard({
|
||||
<Avatar
|
||||
avatarUrl={logoOrAvatar}
|
||||
size={theme.icon.size.xl}
|
||||
colorId={id}
|
||||
placeholder={title}
|
||||
/>
|
||||
<StyledInfoContainer>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { stringToHslColor } from '@/utils/string-to-hsl';
|
||||
import { isNonEmptyString } from '@/utils/type-guards/isNonEmptyString';
|
||||
|
||||
export type AvatarType = 'squared' | 'rounded';
|
||||
@ -8,26 +9,23 @@ type OwnProps = {
|
||||
avatarUrl: string | null | undefined;
|
||||
size: number;
|
||||
placeholder: string;
|
||||
colorId?: string;
|
||||
type?: AvatarType;
|
||||
};
|
||||
|
||||
export const StyledAvatar = styled.div<Omit<OwnProps, 'placeholder'>>`
|
||||
export const StyledAvatar = styled.div<OwnProps & { colorId: string }>`
|
||||
align-items: center;
|
||||
background-color: ${(props) =>
|
||||
!isNonEmptyString(props.avatarUrl)
|
||||
? props.theme.background.tertiary
|
||||
: 'none'};
|
||||
${(props) =>
|
||||
isNonEmptyString(props.avatarUrl)
|
||||
? `background-image: url(${props.avatarUrl});`
|
||||
: ''}
|
||||
background-color: ${({ avatarUrl, colorId }) =>
|
||||
!isNonEmptyString(avatarUrl) ? stringToHslColor(colorId, 75, 85) : 'none'};
|
||||
${({ avatarUrl }) =>
|
||||
isNonEmptyString(avatarUrl) ? `background-image: url(${avatarUrl});` : ''}
|
||||
background-size: cover;
|
||||
border-radius: ${(props) => (props.type === 'rounded' ? '50%' : '2px')};
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
color: ${({ colorId }) => stringToHslColor(colorId, 75, 25)};
|
||||
display: flex;
|
||||
|
||||
flex-shrink: 0;
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-size: ${({ theme }) => theme.font.size.xs};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
|
||||
height: ${(props) => props.size}px;
|
||||
@ -39,12 +37,19 @@ export function Avatar({
|
||||
avatarUrl,
|
||||
size,
|
||||
placeholder,
|
||||
colorId = placeholder,
|
||||
type = 'squared',
|
||||
}: OwnProps) {
|
||||
const noAvatarUrl = !isNonEmptyString(avatarUrl);
|
||||
|
||||
return (
|
||||
<StyledAvatar avatarUrl={avatarUrl} size={size} type={type}>
|
||||
<StyledAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
placeholder={placeholder}
|
||||
size={size}
|
||||
type={type}
|
||||
colorId={colorId}
|
||||
>
|
||||
{noAvatarUrl && placeholder[0]?.toLocaleUpperCase()}
|
||||
</StyledAvatar>
|
||||
);
|
||||
|
||||
13
front/src/modules/utils/string-to-hsl.ts
Normal file
13
front/src/modules/utils/string-to-hsl.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export function stringToHslColor(
|
||||
str: string,
|
||||
saturation: number,
|
||||
lightness: number,
|
||||
) {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
const h = hash % 360;
|
||||
return 'hsl(' + h + ', ' + saturation + '%, ' + lightness + '%)';
|
||||
}
|
||||
@ -33,7 +33,7 @@ const EmailText = styled.span`
|
||||
|
||||
type OwnProps = {
|
||||
workspaceMember: {
|
||||
user: Pick<User, 'firstName' | 'lastName' | 'avatarUrl' | 'email'>;
|
||||
user: Pick<User, 'id' | 'firstName' | 'lastName' | 'avatarUrl' | 'email'>;
|
||||
};
|
||||
accessory?: React.ReactNode;
|
||||
};
|
||||
@ -43,6 +43,7 @@ export function WorkspaceMemberCard({ workspaceMember, accessory }: OwnProps) {
|
||||
<StyledContainer>
|
||||
<Avatar
|
||||
avatarUrl={getImageAbsoluteURIOrBase64(workspaceMember.user.avatarUrl)}
|
||||
colorId={workspaceMember.user.id}
|
||||
placeholder={workspaceMember.user.firstName || ''}
|
||||
type="squared"
|
||||
size={40}
|
||||
|
||||
Reference in New Issue
Block a user