Skip to content

Commit fbbfb30

Browse files
committed
Use blurhash for image loading status
1 parent 317e59d commit fbbfb30

File tree

9 files changed

+90
-6
lines changed

9 files changed

+90
-6
lines changed

packages/clerk-js/src/core/resources/OrganizationCreationDefaults.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ export class OrganizationCreationDefaults extends BaseResource implements Organi
1818
name: string;
1919
slug: string;
2020
logo: string | null;
21+
blurHash: string | null;
2122
} = {
2223
name: '',
2324
slug: '',
2425
logo: null,
26+
blurHash: null,
2527
};
2628

2729
public constructor(data: OrganizationCreationDefaultsJSON | OrganizationCreationDefaultsJSONSnapshot | null = null) {
@@ -42,6 +44,7 @@ export class OrganizationCreationDefaults extends BaseResource implements Organi
4244
this.form.name = this.withDefault(data.form.name, this.form.name);
4345
this.form.slug = this.withDefault(data.form.slug, this.form.slug);
4446
this.form.logo = this.withDefault(data.form.logo, this.form.logo);
47+
this.form.blurHash = this.withDefault(data.form.blur_hash, this.form.blurHash);
4548
}
4649

4750
return this;

packages/shared/src/types/organizationCreationDefaults.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface OrganizationCreationDefaultsJSON extends ClerkResourceJSON {
1515
name: string;
1616
slug: string;
1717
logo: string | null;
18+
blur_hash: string | null;
1819
};
1920
}
2021

@@ -28,5 +29,6 @@ export interface OrganizationCreationDefaultsResource extends ClerkResource {
2829
name: string;
2930
slug: string;
3031
logo: string | null;
32+
blurHash: string | null;
3133
};
3234
}

packages/ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
"@solana/wallet-adapter-react": "catalog:module-manager",
8080
"@solana/wallet-standard": "catalog:module-manager",
8181
"@swc/helpers": "catalog:repo",
82+
"blurhash": "^2.0.5",
8283
"copy-to-clipboard": "3.3.3",
8384
"core-js": "catalog:repo",
8485
"csstype": "3.1.3",

packages/ui/src/components/OrganizationProfile/OrganizationProfileAvatarUploader.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@ import { Col, descriptors, Text } from '../../customizables';
88
import { localizationKeys } from '../../localization';
99

1010
export const OrganizationProfileAvatarUploader = (
11-
props: Omit<AvatarUploaderProps, 'avatarPreview' | 'title'> & { organization: Partial<OrganizationResource> },
11+
props: Omit<AvatarUploaderProps, 'avatarPreview' | 'title'> & {
12+
organization: Partial<OrganizationResource>;
13+
/** A data URL to show as a placeholder while the image is loading (e.g., decoded blurhash) */
14+
blurHashPlaceholder?: string;
15+
},
1216
) => {
13-
const { organization, ...rest } = props;
17+
const { organization, blurHashPlaceholder, ...rest } = props;
1418

1519
return (
1620
<Col elementDescriptor={descriptors.organizationAvatarUploaderContainer}>
@@ -28,6 +32,7 @@ export const OrganizationProfileAvatarUploader = (
2832
avatarPreview={
2933
<OrganizationAvatar
3034
size={theme => theme.sizes.$16}
35+
blurHashPlaceholder={blurHashPlaceholder}
3136
{...organization}
3237
/>
3338
}

packages/ui/src/components/SessionTasks/tasks/TaskChooseOrganization/CreateOrganizationScreen.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useOrganizationList } from '@clerk/shared/react';
22
import type { CreateOrganizationParams, OrganizationCreationDefaultsResource } from '@clerk/shared/types';
3-
import { useState } from 'react';
3+
import { useMemo, useState } from 'react';
44

55
import { useEnvironment } from '@/ui/contexts';
66
import { useSessionTasksContext, useTaskChooseOrganizationContext } from '@/ui/contexts/components/SessionTasks';
@@ -12,6 +12,7 @@ import { FormContainer } from '@/ui/elements/FormContainer';
1212
import { Header } from '@/ui/elements/Header';
1313
import { IconButton } from '@/ui/elements/IconButton';
1414
import { Upload } from '@/ui/icons';
15+
import { decodeBlurHash } from '@/ui/utils/blurhash';
1516
import { createSlug } from '@/ui/utils/createSlug';
1617
import { handleError } from '@/ui/utils/errorHandler';
1718
import { useFormControl } from '@/ui/utils/useFormControl';
@@ -100,6 +101,10 @@ export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) =
100101

101102
const isSubmitButtonDisabled = !nameField.value || !isLoaded;
102103
const defaultLogoUrl = file === undefined ? props.organizationCreationDefaults?.form.logo : undefined;
104+
const blurHashPlaceholder = useMemo(
105+
() => decodeBlurHash(props.organizationCreationDefaults?.form.blurHash),
106+
[props.organizationCreationDefaults?.form.blurHash],
107+
);
103108

104109
return (
105110
<>
@@ -118,6 +123,7 @@ export const CreateOrganizationScreen = (props: CreateOrganizationScreenProps) =
118123
organization={{ name: nameField.value, imageUrl: defaultLogoUrl ?? undefined }}
119124
onAvatarChange={async file => await setFile(file)}
120125
onAvatarRemove={file || defaultLogoUrl ? onAvatarRemove : null}
126+
blurHashPlaceholder={defaultLogoUrl ? blurHashPlaceholder : undefined}
121127
avatarPreviewPlaceholder={
122128
<IconButton
123129
variant='ghost'

packages/ui/src/elements/Avatar.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ type AvatarProps = PropsOfComponent<typeof Flex> & {
1515
rounded?: boolean;
1616
boxElementDescriptor?: ElementDescriptor;
1717
imageElementDescriptor?: ElementDescriptor;
18+
/** A data URL to show as a placeholder while the image is loading (e.g., decoded blurhash) */
19+
blurHashPlaceholder?: string;
1820
};
1921

2022
export const Avatar = (props: AvatarProps) => {
@@ -28,8 +30,16 @@ export const Avatar = (props: AvatarProps) => {
2830
sx,
2931
boxElementDescriptor,
3032
imageElementDescriptor,
33+
blurHashPlaceholder,
3134
} = props;
3235
const [error, setError] = React.useState(false);
36+
const [loaded, setLoaded] = React.useState(false);
37+
38+
// Reset loaded state when imageUrl changes
39+
React.useEffect(() => {
40+
setLoaded(false);
41+
setError(false);
42+
}, [imageUrl]);
3343

3444
const ImgOrFallback =
3545
initials && (!imageUrl || error) ? (
@@ -40,8 +50,15 @@ export const Avatar = (props: AvatarProps) => {
4050
title={title}
4151
alt={`${title}'s logo`}
4252
src={imageUrl || ''}
43-
sx={{ objectFit: 'cover', width: '100%', height: '100%' }}
53+
sx={{
54+
objectFit: 'cover',
55+
width: '100%',
56+
height: '100%',
57+
opacity: loaded ? 1 : 0,
58+
transition: 'opacity 0.2s ease-in-out',
59+
}}
4460
onError={() => setError(true)}
61+
onLoad={() => setLoaded(true)}
4562
size={imageFetchSize}
4663
/>
4764
);
@@ -61,6 +78,13 @@ export const Avatar = (props: AvatarProps) => {
6178
backgroundColor: t.colors.$avatarBackground,
6279
backgroundClip: 'padding-box',
6380
position: 'relative',
81+
...(blurHashPlaceholder && !loaded && imageUrl
82+
? {
83+
backgroundImage: `url(${blurHashPlaceholder})`,
84+
backgroundSize: 'cover',
85+
backgroundPosition: 'center',
86+
}
87+
: {}),
6488
}),
6589
sx,
6690
]}

packages/ui/src/elements/OrganizationAvatar.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,19 @@ import type { PropsOfComponent } from '../styledSystem';
44
import { Avatar } from './Avatar';
55

66
type OrganizationAvatarProps = PropsOfComponent<typeof Avatar> &
7-
Partial<Pick<OrganizationResource, 'name' | 'imageUrl'>>;
7+
Partial<Pick<OrganizationResource, 'name' | 'imageUrl'>> & {
8+
/** A data URL to show as a placeholder while the image is loading (e.g., decoded blurhash) */
9+
blurHashPlaceholder?: string;
10+
};
811

912
export const OrganizationAvatar = (props: OrganizationAvatarProps) => {
10-
const { name = '', imageUrl, ...rest } = props;
13+
const { name = '', imageUrl, blurHashPlaceholder, ...rest } = props;
1114
return (
1215
<Avatar
1316
title={name}
1417
initials={(name || ' ')[0]}
1518
imageUrl={imageUrl}
19+
blurHashPlaceholder={blurHashPlaceholder}
1620
rounded={false}
1721
{...rest}
1822
/>

packages/ui/src/utils/blurhash.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { decode } from 'blurhash';
2+
3+
/**
4+
* Decodes a blurhash string to a data URL that can be used as an image src or background
5+
* @internal
6+
*/
7+
export function decodeBlurHash(blurHash: string | null | undefined, width = 32, height = 32): string | undefined {
8+
if (!blurHash) {
9+
return undefined;
10+
}
11+
12+
try {
13+
const pixels = decode(blurHash, width, height);
14+
const canvas = document.createElement('canvas');
15+
canvas.width = width;
16+
canvas.height = height;
17+
const ctx = canvas.getContext('2d');
18+
19+
if (!ctx) {
20+
return undefined;
21+
}
22+
23+
const imageData = ctx.createImageData(width, height);
24+
imageData.data.set(pixels);
25+
ctx.putImageData(imageData, 0, 0);
26+
27+
return canvas.toDataURL();
28+
} catch {
29+
return undefined;
30+
}
31+
}

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)