mirror of
https://github.com/makeplane/plane.git
synced 2025-12-24 23:59:40 +01:00
[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 <175728472+Copilot@users.noreply.github.com> * fix: handle undefined cover image URL in ProfileSidebar component --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
44
apps/web/core/components/common/cover-image.tsx
Normal file
44
apps/web/core/components/common/cover-image.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { cn } from "@plane/utils";
|
||||
// helpers
|
||||
import { getCoverImageDisplayURL, DEFAULT_COVER_IMAGE_URL } from "@/helpers/cover-image.helper";
|
||||
|
||||
type TCoverImageProps = {
|
||||
/** The cover image URL - can be static, uploaded, or external */
|
||||
src: string | null | undefined;
|
||||
/** Alt text for the image */
|
||||
alt?: string;
|
||||
/** Additional className for the image or skeleton */
|
||||
className?: string;
|
||||
/** Whether to show default image when src is null/undefined. If false, shows loading skeleton */
|
||||
showDefaultWhenEmpty?: boolean;
|
||||
/** Custom fallback URL to use instead of DEFAULT_COVER_IMAGE_URL */
|
||||
fallbackUrl?: string;
|
||||
} & React.ComponentProps<"img">;
|
||||
|
||||
/**
|
||||
* A reusable cover image component that handles:
|
||||
* - Loading states with skeleton
|
||||
* - Static images (local assets)
|
||||
* - Uploaded images (processed through getFileURL)
|
||||
* - External URLs
|
||||
* - Fallback to default cover image
|
||||
*/
|
||||
export function CoverImage(props: TCoverImageProps) {
|
||||
const {
|
||||
src,
|
||||
alt = "Cover image",
|
||||
className,
|
||||
showDefaultWhenEmpty = false,
|
||||
fallbackUrl = DEFAULT_COVER_IMAGE_URL,
|
||||
...restProps
|
||||
} = props;
|
||||
|
||||
// Show loading skeleton when src is undefined/null and we don't want to show default
|
||||
if (!src && !showDefaultWhenEmpty) {
|
||||
return <div className={cn("bg-layer-2 animate-pulse", className)} />;
|
||||
}
|
||||
|
||||
const displayUrl = getCoverImageDisplayURL(src, fallbackUrl);
|
||||
|
||||
return <img src={displayUrl} alt={alt} className={cn("object-cover", className)} {...restProps} />;
|
||||
}
|
||||
@@ -20,8 +20,9 @@ import { DeactivateAccountModal } from "@/components/account/deactivate-account-
|
||||
import { ImagePickerPopover } from "@/components/core/image-picker-popover";
|
||||
import { ChangeEmailModal } from "@/components/core/modals/change-email-modal";
|
||||
import { UserImageUploadModal } from "@/components/core/modals/user-image-upload-modal";
|
||||
import { CoverImage } from "@/components/common/cover-image";
|
||||
// helpers
|
||||
import { DEFAULT_COVER_IMAGE_URL, getCoverImageDisplayURL, handleCoverImageChange } from "@/helpers/cover-image.helper";
|
||||
import { handleCoverImageChange } from "@/helpers/cover-image.helper";
|
||||
import { captureSuccess, captureError } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useInstance } from "@/hooks/store/use-instance";
|
||||
@@ -210,9 +211,9 @@ export const ProfileForm = observer(function ProfileForm(props: TProfileFormProp
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="w-full">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
<div className="relative h-44 w-full">
|
||||
<img
|
||||
src={getCoverImageDisplayURL(userCover, DEFAULT_COVER_IMAGE_URL)}
|
||||
className="h-44 w-full rounded-lg object-cover"
|
||||
<CoverImage
|
||||
src={userCover}
|
||||
className="h-44 w-full rounded-lg"
|
||||
alt={currentUser?.first_name ?? "Cover image"}
|
||||
/>
|
||||
<div className="absolute -bottom-6 left-6 flex items-end justify-between">
|
||||
|
||||
@@ -17,8 +17,8 @@ import type { IUserProfileProjectSegregation } from "@plane/types";
|
||||
// plane ui
|
||||
import { Loader } from "@plane/ui";
|
||||
import { cn, renderFormattedDate, getFileURL } from "@plane/utils";
|
||||
// helpers
|
||||
import { getCoverImageDisplayURL } from "@/helpers/cover-image.helper";
|
||||
// components
|
||||
import { CoverImage } from "@/components/common/cover-image";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store/use-app-theme";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
@@ -101,13 +101,11 @@ export const ProfileSidebar = observer(function ProfileSidebar(props: TProfileSi
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={
|
||||
getCoverImageDisplayURL(userData?.cover_image_url, "/users/user-profile-cover-default-img.png") ||
|
||||
"/users/user-profile-cover-default-img.png"
|
||||
}
|
||||
<CoverImage
|
||||
src={userData?.cover_image_url ?? undefined}
|
||||
alt={userData?.display_name}
|
||||
className="h-[110px] w-full object-cover"
|
||||
className="h-[110px] w-full"
|
||||
showDefaultWhenEmpty
|
||||
/>
|
||||
<div className="absolute -bottom-[26px] left-5 h-[52px] w-[52px] rounded-sm">
|
||||
{userData?.avatar_url && userData?.avatar_url !== "" ? (
|
||||
|
||||
@@ -22,10 +22,10 @@ import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// local imports
|
||||
import { CoverImage } from "@/components/common/cover-image";
|
||||
import { DeleteProjectModal } from "./delete-project-modal";
|
||||
import { JoinProjectModal } from "./join-project-modal";
|
||||
import { ArchiveRestoreProjectModal } from "./settings/archive-project/archive-restore-modal";
|
||||
import { DEFAULT_COVER_IMAGE_URL, getCoverImageDisplayURL } from "@/helpers/cover-image.helper";
|
||||
|
||||
type Props = {
|
||||
project: IProject;
|
||||
@@ -206,10 +206,10 @@ export const ProjectCard = observer(function ProjectCard(props: Props) {
|
||||
<div className="relative h-[118px] w-full rounded-t ">
|
||||
<div className="absolute inset-0 z-[1] bg-gradient-to-t from-black/60 to-transparent" />
|
||||
|
||||
<img
|
||||
src={getCoverImageDisplayURL(project.cover_image_url, DEFAULT_COVER_IMAGE_URL)}
|
||||
<CoverImage
|
||||
src={project.cover_image_url}
|
||||
alt={project.name}
|
||||
className="absolute left-0 top-0 h-full w-full rounded-t object-cover"
|
||||
className="absolute left-0 top-0 h-full w-full rounded-t"
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-4 z-[1] flex h-10 w-full items-center justify-between gap-3 px-4">
|
||||
|
||||
@@ -10,9 +10,8 @@ import type { IProject } from "@plane/types";
|
||||
// plane ui
|
||||
import { getTabIndex } from "@plane/utils";
|
||||
// components
|
||||
import { CoverImage } from "@/components/common/cover-image";
|
||||
import { ImagePickerPopover } from "@/components/core/image-picker-popover";
|
||||
// helpers
|
||||
import { DEFAULT_COVER_IMAGE_URL, getCoverImageDisplayURL } from "@/helpers/cover-image.helper";
|
||||
// plane web imports
|
||||
import { ProjectTemplateSelect } from "@/plane-web/components/projects/create/template-select";
|
||||
|
||||
@@ -33,13 +32,11 @@ function ProjectCreateHeader(props: Props) {
|
||||
|
||||
return (
|
||||
<div className="group relative h-44 w-full rounded-lg">
|
||||
{coverImage && (
|
||||
<img
|
||||
src={getCoverImageDisplayURL(coverImage, DEFAULT_COVER_IMAGE_URL)}
|
||||
className="absolute left-0 top-0 h-full w-full rounded-lg object-cover"
|
||||
alt={t("project_cover_image_alt")}
|
||||
/>
|
||||
)}
|
||||
<CoverImage
|
||||
src={coverImage}
|
||||
alt={t("project_cover_image_alt")}
|
||||
className="absolute left-0 top-0 h-full w-full rounded-lg"
|
||||
/>
|
||||
<div className="absolute left-2.5 top-2.5">
|
||||
<ProjectTemplateSelect handleModalClose={handleClose} />
|
||||
</div>
|
||||
|
||||
@@ -12,10 +12,11 @@ import { EFileAssetType } from "@plane/types";
|
||||
import type { IProject, IWorkspace } from "@plane/types";
|
||||
import { CustomSelect, Input, TextArea } from "@plane/ui";
|
||||
import { renderFormattedDate } from "@plane/utils";
|
||||
import { CoverImage } from "@/components/common/cover-image";
|
||||
import { ImagePickerPopover } from "@/components/core/image-picker-popover";
|
||||
import { TimezoneSelect } from "@/components/global";
|
||||
// helpers
|
||||
import { DEFAULT_COVER_IMAGE_URL, getCoverImageDisplayURL, handleCoverImageChange } from "@/helpers/cover-image.helper";
|
||||
import { handleCoverImageChange } from "@/helpers/cover-image.helper";
|
||||
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
@@ -200,11 +201,7 @@ export function ProjectDetailsForm(props: IProjectDetailsForm) {
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="relative h-44 w-full">
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
||||
<img
|
||||
src={getCoverImageDisplayURL(coverImage, DEFAULT_COVER_IMAGE_URL)}
|
||||
alt="Project cover image"
|
||||
className="h-44 w-full rounded-md object-cover"
|
||||
/>
|
||||
<CoverImage src={coverImage} alt="Project cover image" className="h-44 w-full rounded-md" />
|
||||
<div className="z-5 absolute bottom-4 flex w-full items-end justify-between gap-3 px-4">
|
||||
<div className="flex flex-grow gap-3 truncate">
|
||||
<Controller
|
||||
|
||||
Reference in New Issue
Block a user