Skip to content

Commit 39728d4

Browse files
[WEB-5779] fix: handle loading state while fetching project cover image (#8419)
* refactor: replace cover image handling with CoverImage component across profile and project forms * fix: extend CoverImage component to accept additional img props * Update apps/web/core/components/common/cover-image.tsx Co-authored-by: Copilot <[email protected]> * fix: handle undefined cover image URL in ProfileSidebar component --------- Co-authored-by: Copilot <[email protected]>
1 parent 59f26a8 commit 39728d4

File tree

6 files changed

+68
-31
lines changed

6 files changed

+68
-31
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { cn } from "@plane/utils";
2+
// helpers
3+
import { getCoverImageDisplayURL, DEFAULT_COVER_IMAGE_URL } from "@/helpers/cover-image.helper";
4+
5+
type TCoverImageProps = {
6+
/** The cover image URL - can be static, uploaded, or external */
7+
src: string | null | undefined;
8+
/** Alt text for the image */
9+
alt?: string;
10+
/** Additional className for the image or skeleton */
11+
className?: string;
12+
/** Whether to show default image when src is null/undefined. If false, shows loading skeleton */
13+
showDefaultWhenEmpty?: boolean;
14+
/** Custom fallback URL to use instead of DEFAULT_COVER_IMAGE_URL */
15+
fallbackUrl?: string;
16+
} & React.ComponentProps<"img">;
17+
18+
/**
19+
* A reusable cover image component that handles:
20+
* - Loading states with skeleton
21+
* - Static images (local assets)
22+
* - Uploaded images (processed through getFileURL)
23+
* - External URLs
24+
* - Fallback to default cover image
25+
*/
26+
export function CoverImage(props: TCoverImageProps) {
27+
const {
28+
src,
29+
alt = "Cover image",
30+
className,
31+
showDefaultWhenEmpty = false,
32+
fallbackUrl = DEFAULT_COVER_IMAGE_URL,
33+
...restProps
34+
} = props;
35+
36+
// Show loading skeleton when src is undefined/null and we don't want to show default
37+
if (!src && !showDefaultWhenEmpty) {
38+
return <div className={cn("bg-layer-2 animate-pulse", className)} />;
39+
}
40+
41+
const displayUrl = getCoverImageDisplayURL(src, fallbackUrl);
42+
43+
return <img src={displayUrl} alt={alt} className={cn("object-cover", className)} {...restProps} />;
44+
}

apps/web/core/components/profile/form.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ import { DeactivateAccountModal } from "@/components/account/deactivate-account-
2020
import { ImagePickerPopover } from "@/components/core/image-picker-popover";
2121
import { ChangeEmailModal } from "@/components/core/modals/change-email-modal";
2222
import { UserImageUploadModal } from "@/components/core/modals/user-image-upload-modal";
23+
import { CoverImage } from "@/components/common/cover-image";
2324
// helpers
24-
import { DEFAULT_COVER_IMAGE_URL, getCoverImageDisplayURL, handleCoverImageChange } from "@/helpers/cover-image.helper";
25+
import { handleCoverImageChange } from "@/helpers/cover-image.helper";
2526
import { captureSuccess, captureError } from "@/helpers/event-tracker.helper";
2627
// hooks
2728
import { useInstance } from "@/hooks/store/use-instance";
@@ -210,9 +211,9 @@ export const ProfileForm = observer(function ProfileForm(props: TProfileFormProp
210211
<form onSubmit={handleSubmit(onSubmit)} className="w-full">
211212
<div className="flex w-full flex-col gap-6">
212213
<div className="relative h-44 w-full">
213-
<img
214-
src={getCoverImageDisplayURL(userCover, DEFAULT_COVER_IMAGE_URL)}
215-
className="h-44 w-full rounded-lg object-cover"
214+
<CoverImage
215+
src={userCover}
216+
className="h-44 w-full rounded-lg"
216217
alt={currentUser?.first_name ?? "Cover image"}
217218
/>
218219
<div className="absolute -bottom-6 left-6 flex items-end justify-between">

apps/web/core/components/profile/sidebar.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ import type { IUserProfileProjectSegregation } from "@plane/types";
1717
// plane ui
1818
import { Loader } from "@plane/ui";
1919
import { cn, renderFormattedDate, getFileURL } from "@plane/utils";
20-
// helpers
21-
import { getCoverImageDisplayURL } from "@/helpers/cover-image.helper";
20+
// components
21+
import { CoverImage } from "@/components/common/cover-image";
2222
// hooks
2323
import { useAppTheme } from "@/hooks/store/use-app-theme";
2424
import { useProject } from "@/hooks/store/use-project";
@@ -101,13 +101,11 @@ export const ProfileSidebar = observer(function ProfileSidebar(props: TProfileSi
101101
</Link>
102102
</div>
103103
)}
104-
<img
105-
src={
106-
getCoverImageDisplayURL(userData?.cover_image_url, "/users/user-profile-cover-default-img.png") ||
107-
"/users/user-profile-cover-default-img.png"
108-
}
104+
<CoverImage
105+
src={userData?.cover_image_url ?? undefined}
109106
alt={userData?.display_name}
110-
className="h-[110px] w-full object-cover"
107+
className="h-[110px] w-full"
108+
showDefaultWhenEmpty
111109
/>
112110
<div className="absolute -bottom-[26px] left-5 h-[52px] w-[52px] rounded-sm">
113111
{userData?.avatar_url && userData?.avatar_url !== "" ? (

apps/web/core/components/project/card.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ import { useUserPermissions } from "@/hooks/store/user";
2222
import { useAppRouter } from "@/hooks/use-app-router";
2323
import { usePlatformOS } from "@/hooks/use-platform-os";
2424
// local imports
25+
import { CoverImage } from "@/components/common/cover-image";
2526
import { DeleteProjectModal } from "./delete-project-modal";
2627
import { JoinProjectModal } from "./join-project-modal";
2728
import { ArchiveRestoreProjectModal } from "./settings/archive-project/archive-restore-modal";
28-
import { DEFAULT_COVER_IMAGE_URL, getCoverImageDisplayURL } from "@/helpers/cover-image.helper";
2929

3030
type Props = {
3131
project: IProject;
@@ -206,10 +206,10 @@ export const ProjectCard = observer(function ProjectCard(props: Props) {
206206
<div className="relative h-[118px] w-full rounded-t ">
207207
<div className="absolute inset-0 z-[1] bg-gradient-to-t from-black/60 to-transparent" />
208208

209-
<img
210-
src={getCoverImageDisplayURL(project.cover_image_url, DEFAULT_COVER_IMAGE_URL)}
209+
<CoverImage
210+
src={project.cover_image_url}
211211
alt={project.name}
212-
className="absolute left-0 top-0 h-full w-full rounded-t object-cover"
212+
className="absolute left-0 top-0 h-full w-full rounded-t"
213213
/>
214214

215215
<div className="absolute bottom-4 z-[1] flex h-10 w-full items-center justify-between gap-3 px-4">

apps/web/core/components/project/create/header.tsx

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,8 @@ import type { IProject } from "@plane/types";
1010
// plane ui
1111
import { getTabIndex } from "@plane/utils";
1212
// components
13+
import { CoverImage } from "@/components/common/cover-image";
1314
import { ImagePickerPopover } from "@/components/core/image-picker-popover";
14-
// helpers
15-
import { DEFAULT_COVER_IMAGE_URL, getCoverImageDisplayURL } from "@/helpers/cover-image.helper";
1615
// plane web imports
1716
import { ProjectTemplateSelect } from "@/plane-web/components/projects/create/template-select";
1817

@@ -33,13 +32,11 @@ function ProjectCreateHeader(props: Props) {
3332

3433
return (
3534
<div className="group relative h-44 w-full rounded-lg">
36-
{coverImage && (
37-
<img
38-
src={getCoverImageDisplayURL(coverImage, DEFAULT_COVER_IMAGE_URL)}
39-
className="absolute left-0 top-0 h-full w-full rounded-lg object-cover"
40-
alt={t("project_cover_image_alt")}
41-
/>
42-
)}
35+
<CoverImage
36+
src={coverImage}
37+
alt={t("project_cover_image_alt")}
38+
className="absolute left-0 top-0 h-full w-full rounded-lg"
39+
/>
4340
<div className="absolute left-2.5 top-2.5">
4441
<ProjectTemplateSelect handleModalClose={handleClose} />
4542
</div>

apps/web/core/components/project/form.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ import { EFileAssetType } from "@plane/types";
1212
import type { IProject, IWorkspace } from "@plane/types";
1313
import { CustomSelect, Input, TextArea } from "@plane/ui";
1414
import { renderFormattedDate } from "@plane/utils";
15+
import { CoverImage } from "@/components/common/cover-image";
1516
import { ImagePickerPopover } from "@/components/core/image-picker-popover";
1617
import { TimezoneSelect } from "@/components/global";
1718
// helpers
18-
import { DEFAULT_COVER_IMAGE_URL, getCoverImageDisplayURL, handleCoverImageChange } from "@/helpers/cover-image.helper";
19+
import { handleCoverImageChange } from "@/helpers/cover-image.helper";
1920
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
2021
// hooks
2122
import { useProject } from "@/hooks/store/use-project";
@@ -200,11 +201,7 @@ export function ProjectDetailsForm(props: IProjectDetailsForm) {
200201
<form onSubmit={handleSubmit(onSubmit)}>
201202
<div className="relative h-44 w-full">
202203
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
203-
<img
204-
src={getCoverImageDisplayURL(coverImage, DEFAULT_COVER_IMAGE_URL)}
205-
alt="Project cover image"
206-
className="h-44 w-full rounded-md object-cover"
207-
/>
204+
<CoverImage src={coverImage} alt="Project cover image" className="h-44 w-full rounded-md" />
208205
<div className="z-5 absolute bottom-4 flex w-full items-end justify-between gap-3 px-4">
209206
<div className="flex flex-grow gap-3 truncate">
210207
<Controller

0 commit comments

Comments
 (0)