[WEB-5493] feat: static cover images (#4897)

* feat: implement cover image handling and static image selection

- Added functionality to handle cover image uploads and selections in project and profile forms.
- Introduced a new helper for managing cover images, including static images and uploaded assets.
- Updated components to utilize the new cover image helper for displaying and processing cover images.
- Added a set of static cover images for selection in the image picker.
- Enhanced error handling for image uploads and processing.

This update improves the user experience by providing a more robust cover image management system.

* refactor: rename STATIC_COVER_IMAGES_ARRAY to STATIC_COVER_IMAGES for consistency

- Updated the cover image helper to export STATIC_COVER_IMAGES instead of STATIC_COVER_IMAGES_ARRAY.
- Adjusted the ImagePickerPopover component to utilize the renamed export for rendering static cover images.

* feat: enhance project creation and image handling

- Introduced default project form values with a random emoji for logo props.
- Updated cover image handling in various components, ensuring consistent usage of the new cover image helper.
- Refactored image picker to improve search functionality and loading states.
- Removed unused constants and streamlined cover image type checks for better clarity and performance.

This update enhances the user experience in project creation and image selection processes.

* refactor: simplify cover image type definition and clean up code

- Removed duplicate type from TCoverImageType, streamlining the definition.
- Cleaned up whitespace in the cover image helper for improved readability.

This update enhances code clarity and maintains consistency in cover image handling.

* refactor: update cover image type definitions and simplify logic

- Changed ICoverImageResult and ICoverImagePayload interfaces to type aliases for better clarity.
- Simplified the logic in getCoverImageDisplayURL function to enhance readability and maintainability.

This update improves the structure and clarity of the cover image helper code.

* refactor: remove unused project cover image endpoint and update cover image handling

- Removed the ProjectPublicCoverImagesEndpoint and its associated URL from the project.
- Updated the cover image handling in the cover-image helper to utilize imported assets instead of static paths.
- Cleaned up the ProjectFavoritesViewSet and FileService by removing the now obsolete getProjectCoverImages method.

This update streamlines the cover image management and eliminates unnecessary code, enhancing overall maintainability.

* refactor: update cover image imports to new asset structure

- Replaced static path imports for cover images with updated paths to the new asset structure.
- This change improves organization and maintainability of cover image assets in the project.

This update aligns with recent refactoring efforts to streamline cover image handling.

* feat: add additional cover images to the helper

- Imported new cover images (24 to 29) into the cover-image helper.
- This update expands the available cover image options for use in the project, enhancing visual variety.

* refactor: remove ProjectPublicCoverImagesEndpoint from project URLs and views

* refactor: update cover image imports to include URL query parameter

- Modified cover image imports in the cover-image helper to append a URL query parameter for better asset handling.
- This change enhances the way cover images are processed and utilized within the project.

* refactor: extract default project form values into a utility function

- Created a new utility function `getProjectFormValues` to encapsulate the default project form values.
- Updated the `CreateProjectForm` component to use this utility function for setting default form values, improving code organization and maintainability.

* feat: integrate project update functionality in CreateProjectForm

- Added `updateProject` method to the `CreateProjectForm` component for updating project cover images after creation.
- Enhanced cover image handling by ensuring the correct URL is set for both uploaded and existing cover images.
This update improves the project creation workflow and ensures cover images are accurately updated.

* refactor: standardize cover image handling across components

- Introduced a consistent approach for managing project cover images by utilizing a default cover image URL.
- Updated various components to use the new `getCoverImageDisplayURL` function for improved image handling.
- Removed hardcoded default cover image URLs and replaced them with a centralized constant, enhancing maintainability and reducing redundancy.

* feat: add new cover image and update project details type

- Introduced a new cover image asset (`image_1.jpg`) for use in project components.
- Updated the `TProjectDetails` type to include `cover_image_url`, enhancing the structure for project cover image handling.
- Modified the `IssueForm` component to utilize the new cover image when a project cover image URL is not provided.

* fix: update cover image entity type in project template handling

- Changed the entity type for uploaded cover images from `EFileAssetType.PROJECT_COVER` to `EFileAssetType.TEMPLATE_ATTACHMENT` in the CreateUpdateProjectTemplate component.
- Updated documentation in the cover-image helper to clarify the handling of local static images.

---------

Co-authored-by: Prateek Shourya <[email protected]>
This commit is contained in:
Jayash Tripathy
2025-12-03 16:19:10 +05:30
committed by GitHub
co-authored by Prateek Shourya
parent 8367ec9701
commit 8a3b90ce51
58 changed files with 616 additions and 287 deletions
-6
View File
@@ -11,7 +11,6 @@ from plane.app.views import (
ProjectIdentifierEndpoint,
ProjectFavoritesViewSet,
UserProjectInvitationsViewset,
ProjectPublicCoverImagesEndpoint,
UserProjectRolesEndpoint,
ProjectArchiveUnarchiveEndpoint,
ProjectMemberPreferenceEndpoint,
@@ -111,11 +110,6 @@ urlpatterns = [
ProjectFavoritesViewSet.as_view({"delete": "destroy"}),
name="project-favorite",
),
path(
"project-covers/",
ProjectPublicCoverImagesEndpoint.as_view(),
name="project-covers",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-deploy-boards/",
DeployBoardViewSet.as_view({"get": "list", "post": "create"}),
-1
View File
@@ -3,7 +3,6 @@ from .project.base import (
ProjectIdentifierEndpoint,
ProjectUserViewsEndpoint,
ProjectFavoritesViewSet,
ProjectPublicCoverImagesEndpoint,
DeployBoardViewSet,
ProjectArchiveUnarchiveEndpoint,
)
-31
View File
@@ -876,37 +876,6 @@ class ProjectFavoritesViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class ProjectPublicCoverImagesEndpoint(BaseAPIView):
permission_classes = [AllowAny]
# Cache the below api for 24 hours
@cache_response(60 * 60 * 24, user=False)
def get(self, request):
files = [
"https://cover-images.plane.so/project-covers/f2ea49f1-1a23-46c3-99e4-1f6185bff8fc.webp",
"https://cover-images.plane.so/project-covers/0fec1f5e-3a54-4260-beb1-25eb5de8fd87.webp",
"https://cover-images.plane.so/project-covers/05a7e2d0-c846-44df-abc2-99e14043dfb9.webp",
"https://cover-images.plane.so/project-covers/8c561535-6be5-4fb8-8ec1-0cba19507938.webp",
"https://cover-images.plane.so/project-covers/11cde8b7-f051-4a9d-a35e-45b475d757a2.webp",
"https://cover-images.plane.so/project-covers/27b12e3a-5e24-4ea9-b5ac-32caaf81a1c3.webp",
"https://cover-images.plane.so/project-covers/32d808af-650a-4228-9386-253d1a7c2a13.webp",
"https://cover-images.plane.so/project-covers/71dbaf8f-fd3c-4f9a-b342-309cf4f22741.webp",
"https://cover-images.plane.so/project-covers/322a58cb-e019-4477-b3eb-e2679d4a2b47.webp",
"https://cover-images.plane.so/project-covers/061042d0-cf7b-42eb-8fb5-e967b07e9e57.webp",
"https://cover-images.plane.so/project-covers/683b5357-b5f1-42c7-9a87-e7ff6be0eea1.webp",
"https://cover-images.plane.so/project-covers/51495ec3-266f-41e8-9360-589903fd4f56.webp",
"https://cover-images.plane.so/project-covers/1031078f-28d7-496f-b92b-dec3ea83519d.webp",
"https://cover-images.plane.so/project-covers/a65e3aed-4a88-4ecf-a9f7-b74d0e4a1f03.webp",
"https://cover-images.plane.so/project-covers/ab31a6ba-51e2-44ad-a00d-e431b4cf865f.webp",
"https://cover-images.plane.so/project-covers/adb8a78f-da02-4b68-82ca-fa34ce40768b.webp",
"https://cover-images.plane.so/project-covers/c29d7097-12dc-4ae0-a785-582e2ceadc29.webp",
"https://cover-images.plane.so/project-covers/d7a7e86d-fe5b-4256-8625-d1c6a39cdde9.webp",
"https://cover-images.plane.so/project-covers/d27444ac-b76e-4c8f-b272-6a6b00865869.webp",
"https://cover-images.plane.so/project-covers/e7fb2595-987e-4f0c-b251-62d071f501fa.webp",
]
return Response(files, status=status.HTTP_200_OK)
class DeployBoardViewSet(BaseViewSet):
permission_classes = [ProjectMemberPermission]
serializer_class = DeployBoardSerializer
Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

@@ -7,6 +7,7 @@ import { PlaneLogo } from "@plane/propel/icons";
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
import type { TIntakeFormSettingsResponse, TIntakeFormSubmitPayload } from "@plane/types";
import { cn } from "@plane/utils";
import CoverImage1 from "@/app/assets/cover-images/image_1.jpg?url";
import GridBgDark from "@/app/assets/images/grid-bg-dark.svg?url";
import GridBgLight from "@/app/assets/images/grid-bg-light.svg?url";
import { RichTextEditor } from "@/components/editor/rich-text-editor";
@@ -152,6 +153,8 @@ const CreateTypeFormModal = ({ formSettings, anchor }: TProps) => {
projectName={projectDetails?.name || ""}
projectLogo={projectDetails?.logo_props}
projectCoverImage={projectDetails?.cover_image}
// CoverImage1 is the default cover image in web/helpers/cover-image.helper.ts
projectCoverImageFallback={CoverImage1}
formTitle={formSettings.name}
properties={formProperties}
isSubmitting={isSubmitting}
@@ -9,6 +9,7 @@ import type { IProject } from "@plane/types";
import { Input } from "@plane/ui";
import { getFileURL } from "@plane/utils";
// components
import CoverImage1 from "@/app/assets/cover-images/image_1.jpg?url";
import { ProjectLogo } from "@/components/common/project-logo";
import { RichTextEditor } from "@/components/editor/rich-text-editor";
// hooks
@@ -24,8 +25,6 @@ type TProps = {
anchor: string;
placeholder?: string | ((isFocused: boolean, value: string) => string);
};
const DEFAULT_COVER_IMAGE =
"https://images.unsplash.com/photo-1672243775941-10d763d9adef?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80";
const IssueForm = observer((props: TProps) => {
const { project, isSubmitting, descriptionEditorRef, anchor } = props;
@@ -44,7 +43,7 @@ const IssueForm = observer((props: TProps) => {
<div className="relative h-[133px] w-full">
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent rounded-md" />
<img
src={getFileURL(project_details?.cover_image ?? DEFAULT_COVER_IMAGE)}
src={project_details?.cover_image_url ? getFileURL(project_details?.cover_image_url) : CoverImage1}
alt="Project cover image"
className="h-[133px] w-full rounded-md object-cover"
/>
Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

@@ -1,22 +1,25 @@
import type { FC } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import { FormProvider, useForm } from "react-hook-form";
import { DEFAULT_PROJECT_FORM_VALUES, PROJECT_TRACKER_EVENTS } from "@plane/constants";
import { PROJECT_TRACKER_EVENTS, RANDOM_EMOJI_CODES } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// ui
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { EFileAssetType } from "@plane/types";
import type { IProject } from "@plane/types";
// constants
import ProjectCommonAttributes from "@/components/project/create/common-attributes";
import ProjectCreateHeader from "@/components/project/create/header";
import ProjectCreateButtons from "@/components/project/create/project-create-buttons";
// hooks
import { DEFAULT_COVER_IMAGE_URL, getCoverImageType, uploadCoverImage } from "@/helpers/cover-image.helper";
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useProject } from "@/hooks/store/use-project";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web types
import type { TProject } from "@/plane-web/types/projects";
import ProjectAttributes from "./attributes";
import { getProjectFormValues } from "./utils";
export type TCreateProjectFormProps = {
setToFavorite?: boolean;
@@ -32,12 +35,12 @@ export const CreateProjectForm = observer(function CreateProjectForm(props: TCre
const { setToFavorite, workspaceSlug, data, onClose, handleNextStep, updateCoverImageStatus } = props;
// store
const { t } = useTranslation();
const { addProjectToFavorites, createProject } = useProject();
const { addProjectToFavorites, createProject, updateProject } = useProject();
// states
const [isChangeInIdentifierRequired, setIsChangeInIdentifierRequired] = useState(true);
// form info
const methods = useForm<TProject>({
defaultValues: { ...DEFAULT_PROJECT_FORM_VALUES, ...data },
defaultValues: { ...getProjectFormValues(), ...data },
reValidateMode: "onChange",
});
const { handleSubmit, reset, setValue } = methods;
@@ -58,16 +61,42 @@ export const CreateProjectForm = observer(function CreateProjectForm(props: TCre
// Upper case identifier
formData.identifier = formData.identifier?.toUpperCase();
const coverImage = formData.cover_image_url;
// if unsplash or a pre-defined image is uploaded, delete the old uploaded asset
if (coverImage?.startsWith("http")) {
formData.cover_image = coverImage;
formData.cover_image_asset = null;
let uploadedAssetUrl: string | null = null;
if (coverImage) {
const imageType = getCoverImageType(coverImage);
if (imageType === "local_static") {
try {
uploadedAssetUrl = await uploadCoverImage(coverImage, {
workspaceSlug: workspaceSlug.toString(),
entityIdentifier: "",
entityType: EFileAssetType.PROJECT_COVER,
isUserAsset: false,
});
} catch (error) {
console.error("Error uploading cover image:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: error instanceof Error ? error.message : "Failed to upload cover image",
});
return Promise.reject(error);
}
} else {
formData.cover_image = coverImage;
formData.cover_image_asset = null;
}
}
return createProject(workspaceSlug.toString(), formData)
.then(async (res) => {
if (coverImage) {
if (uploadedAssetUrl) {
await updateCoverImageStatus(res.id, uploadedAssetUrl);
await updateProject(workspaceSlug.toString(), res.id, { cover_image_url: uploadedAssetUrl });
} else if (coverImage && coverImage.startsWith("http")) {
await updateCoverImageStatus(res.id, coverImage);
await updateProject(workspaceSlug.toString(), res.id, { cover_image_url: coverImage });
}
captureSuccess({
eventName: PROJECT_TRACKER_EVENTS.create,
@@ -0,0 +1,18 @@
import { RANDOM_EMOJI_CODES } from "@plane/constants";
import type { IProject } from "@plane/types";
import { DEFAULT_COVER_IMAGE_URL } from "@/helpers/cover-image.helper";
export const getProjectFormValues = (): Partial<IProject> => ({
cover_image_url: DEFAULT_COVER_IMAGE_URL,
description: "",
logo_props: {
in_use: "emoji",
emoji: {
value: RANDOM_EMOJI_CODES[Math.floor(Math.random() * RANDOM_EMOJI_CODES.length)],
},
},
identifier: "",
name: "",
network: 2,
project_lead: null,
});
@@ -14,7 +14,7 @@ import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { EFileAssetType } from "@plane/types";
import { Input, Loader } from "@plane/ui";
// helpers
import { getFileURL } from "@plane/utils";
import { STATIC_COVER_IMAGES, getCoverImageDisplayURL } from "@/helpers/cover-image.helper";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down";
@@ -89,11 +89,6 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr
}
);
const { data: projectCoverImages } = useSWR(`PROJECT_COVER_IMAGES`, () => fileService.getProjectCoverImages(), {
revalidateOnFocus: false,
revalidateOnReconnect: false,
});
const imagePickerRef = useRef<HTMLDivElement>(null);
const onDrop = useCallback((acceptedFiles: File[]) => {
@@ -106,6 +101,11 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr
maxSize: MAX_FILE_SIZE,
});
const handleStaticImageSelect = (imageUrl: string) => {
onChange(imageUrl);
setIsOpen(false);
};
const handleSubmit = async () => {
if (!image) return;
setIsImageUploading(true);
@@ -199,130 +199,105 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr
>
<Tab.Group>
<Tab.List as="span" className="inline-block rounded bg-custom-background-80 p-1">
{tabOptions.map((tab) => {
if (!unsplashImages && unsplashError && tab.key === "unsplash") return null;
if (projectCoverImages && projectCoverImages.length === 0 && tab.key === "images") return null;
return (
<Tab
key={tab.key}
className={({ selected }) =>
`rounded px-4 py-1 text-center text-sm outline-none transition-colors ${
selected ? "bg-custom-primary text-white" : "text-custom-text-100"
}`
}
>
{tab.title}
</Tab>
);
})}
{tabOptions.map((tab) => (
<Tab
key={tab.key}
className={({ selected }) =>
`rounded px-4 py-1 text-center text-sm outline-none transition-colors ${
selected ? "bg-custom-primary text-white" : "text-custom-text-100"
}`
}
>
{tab.title}
</Tab>
))}
</Tab.List>
<Tab.Panels className="vertical-scrollbar scrollbar-md h-full w-full flex-1 overflow-y-auto overflow-x-hidden">
{(unsplashImages || !unsplashError) && (
<Tab.Panel className="mt-4 h-full w-full space-y-4">
<div className="flex gap-x-2">
<Controller
control={control}
name="search"
render={({ field: { value, ref } }) => (
<Input
id="search"
name="search"
type="text"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
setSearchParams(formData.search);
}
}}
value={value}
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
ref={ref}
placeholder="Search for images"
className="w-full text-sm"
/>
)}
/>
<Button variant="primary" onClick={() => setSearchParams(formData.search)} size="sm">
Search
</Button>
</div>
{unsplashImages ? (
unsplashImages.length > 0 ? (
<div className="grid grid-cols-4 gap-4">
{unsplashImages.map((image) => (
<div
key={image.id}
className="relative col-span-2 aspect-video md:col-span-1"
onClick={() => {
setIsOpen(false);
onChange(image.urls.regular);
<Tab.Panel className="mt-4 h-full w-full space-y-4">
{(unsplashImages || !unsplashError) && (
<>
<div className="flex gap-x-2">
<Controller
control={control}
name="search"
render={({ field: { value, ref } }) => (
<Input
id="search"
name="search"
type="text"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
setSearchParams(formData.search);
}
}}
>
<img
src={image.urls.small}
alt={image.alt_description}
className="absolute left-0 top-0 h-full w-full cursor-pointer rounded object-cover"
/>
</div>
))}
</div>
value={value}
onChange={(e) => setFormData({ ...formData, search: e.target.value })}
ref={ref}
placeholder="Search for images"
className="w-full text-sm"
/>
)}
/>
<Button variant="primary" onClick={() => setSearchParams(formData.search)} size="sm">
Search
</Button>
</div>
{unsplashImages ? (
unsplashImages.length > 0 ? (
<div className="grid grid-cols-4 gap-4">
{unsplashImages.map((image) => (
<div
key={image.id}
className="relative col-span-2 aspect-video md:col-span-1"
onClick={() => {
setIsOpen(false);
onChange(image.urls.regular);
}}
>
<img
src={image.urls.small}
alt={image.alt_description}
className="absolute left-0 top-0 h-full w-full cursor-pointer rounded object-cover"
/>
</div>
))}
</div>
) : (
<p className="pt-7 text-center text-xs text-custom-text-300">No images found.</p>
)
) : (
<p className="pt-7 text-center text-xs text-custom-text-300">No images found.</p>
)
) : (
<Loader className="grid grid-cols-4 gap-4">
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
</Loader>
)}
</Tab.Panel>
)}
{(!projectCoverImages || projectCoverImages.length !== 0) && (
<Tab.Panel className="mt-4 h-full w-full space-y-4">
{projectCoverImages ? (
projectCoverImages.length > 0 ? (
<div className="grid grid-cols-4 gap-4">
{projectCoverImages.map((image, index) => (
<div
key={image}
className="relative col-span-2 aspect-video md:col-span-1"
onClick={() => {
setIsOpen(false);
onChange(image);
}}
>
<img
src={image}
alt={`Default project cover image- ${index}`}
className="absolute left-0 top-0 h-full w-full cursor-pointer rounded object-cover"
/>
</div>
))}
</div>
) : (
<p className="pt-7 text-center text-xs text-custom-text-300">No images found.</p>
)
) : (
<Loader className="grid grid-cols-4 gap-4 pt-4">
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
</Loader>
)}
</Tab.Panel>
)}
<Loader className="grid grid-cols-4 gap-4">
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
<Loader.Item height="80px" width="100%" />
</Loader>
)}
</>
)}
</Tab.Panel>
<Tab.Panel className="mt-4 h-full w-full space-y-4">
<div className="grid grid-cols-4 gap-4">
{Object.values(STATIC_COVER_IMAGES).map((imageUrl, index) => (
<div
key={imageUrl}
className="relative col-span-2 aspect-video md:col-span-1"
onClick={() => handleStaticImageSelect(imageUrl)}
>
<img
src={imageUrl}
alt={`Cover image ${index + 1}`}
className="absolute left-0 top-0 h-full w-full cursor-pointer rounded object-cover hover:opacity-80 transition-opacity"
/>
</div>
))}
</div>
</Tab.Panel>
<Tab.Panel className="mt-4 h-full w-full">
<div className="flex h-full w-full flex-col gap-y-2">
<div className="flex w-full flex-1 items-center gap-3">
@@ -343,7 +318,7 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr
{image !== null || (value && value !== "") ? (
<>
<img
src={image ? URL.createObjectURL(image) : value ? (getFileURL(value) ?? "") : ""}
src={image ? URL.createObjectURL(image) : getCoverImageDisplayURL(value, "")}
alt="image"
className="rounded-lg h-full w-full object-cover"
/>
+25 -8
View File
@@ -11,6 +11,7 @@ import { useTranslation } from "@plane/i18n";
import { Button, getButtonStyling } from "@plane/propel/button";
import { ChevronDownIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setPromiseToast, setToast } from "@plane/propel/toast";
import { EFileAssetType } from "@plane/types";
import type { IUser, TUserProfile } from "@plane/types";
import { Input } from "@plane/ui";
import { cn, getFileURL } from "@plane/utils";
@@ -20,6 +21,7 @@ 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";
// helpers
import { DEFAULT_COVER_IMAGE_URL, getCoverImageDisplayURL, handleCoverImageChange } from "@/helpers/cover-image.helper";
import { captureSuccess, captureError } from "@/helpers/event-tracker.helper";
// hooks
import { useInstance } from "@/hooks/store/use-instance";
@@ -118,11 +120,26 @@ export const ProfileForm = observer(function ProfileForm(props: TProfileFormProp
avatar_url: formData.avatar_url,
display_name: formData?.display_name,
};
// if unsplash or a pre-defined image is uploaded, delete the old uploaded asset
if (formData.cover_image_url?.startsWith("http")) {
userPayload.cover_image_url = formData.cover_image_url;
userPayload.cover_image = formData.cover_image_url;
userPayload.cover_image_asset = null;
try {
const coverImagePayload = await handleCoverImageChange(user.cover_image_url, formData.cover_image_url, {
entityIdentifier: "",
entityType: EFileAssetType.USER_COVER,
isUserAsset: true,
});
if (coverImagePayload) {
Object.assign(userPayload, coverImagePayload);
}
} catch (error) {
console.error("Error handling cover image:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: error instanceof Error ? error.message : "Failed to process cover image",
});
setIsLoading(false);
return;
}
const profilePayload: Partial<TUserProfile> = {
@@ -194,7 +211,7 @@ export const ProfileForm = observer(function ProfileForm(props: TProfileFormProp
<div className="flex w-full flex-col gap-6">
<div className="relative h-44 w-full">
<img
src={userCover ? getFileURL(userCover) : "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"}
src={getCoverImageDisplayURL(userCover, DEFAULT_COVER_IMAGE_URL)}
className="h-44 w-full rounded-lg object-cover"
alt={currentUser?.first_name ?? "Cover image"}
/>
@@ -228,9 +245,9 @@ export const ProfileForm = observer(function ProfileForm(props: TProfileFormProp
render={({ field: { value, onChange } }) => (
<ImagePickerPopover
label={t("change_cover")}
onChange={(imageUrl) => onChange(imageUrl)}
control={control}
value={value ?? "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"}
onChange={(imageUrl) => onChange(imageUrl)}
value={value}
isProfileCover
/>
)}
+4 -3
View File
@@ -17,6 +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";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useProject } from "@/hooks/store/use-project";
@@ -101,9 +103,8 @@ export const ProfileSidebar = observer(function ProfileSidebar(props: TProfileSi
)}
<img
src={
userData?.cover_image_url
? getFileURL(userData?.cover_image_url)
: "/users/user-profile-cover-default-img.png"
getCoverImageDisplayURL(userData?.cover_image_url, "/users/user-profile-cover-default-img.png") ||
"/users/user-profile-cover-default-img.png"
}
alt={userData?.display_name}
className="h-[110px] w-full object-cover"
+2 -4
View File
@@ -25,6 +25,7 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
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;
@@ -204,10 +205,7 @@ export const ProjectCard = observer(function ProjectCard(props: Props) {
<div className="absolute inset-0 z-[1] bg-gradient-to-t from-black/60 to-transparent" />
<img
src={getFileURL(
project.cover_image_url ??
"https://images.unsplash.com/photo-1672243775941-10d763d9adef?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80"
)}
src={getCoverImageDisplayURL(project.cover_image_url, DEFAULT_COVER_IMAGE_URL)}
alt={project.name}
className="absolute left-0 top-0 h-full w-full rounded-t object-cover"
/>
@@ -8,9 +8,11 @@ import { CloseIcon } from "@plane/propel/icons";
// plane types
import type { IProject } from "@plane/types";
// plane ui
import { getFileURL, getTabIndex } from "@plane/utils";
import { getTabIndex } from "@plane/utils";
// components
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,7 +35,7 @@ function ProjectCreateHeader(props: Props) {
<div className="group relative h-44 w-full rounded-lg bg-custom-background-80">
{coverImage && (
<img
src={getFileURL(coverImage)}
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")}
/>
@@ -53,8 +55,8 @@ function ProjectCreateHeader(props: Props) {
render={({ field: { value, onChange } }) => (
<ImagePickerPopover
label={t("change_cover")}
onChange={onChange}
control={control}
onChange={onChange}
value={value ?? null}
tabIndex={getIndex("cover_image")}
/>
+27 -9
View File
@@ -8,12 +8,14 @@ import { Button } from "@plane/propel/button";
import { EmojiPicker, EmojiIconPickerTypes, Logo } from "@plane/propel/emoji-icon-picker";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import { EFileAssetType } from "@plane/types";
import type { IProject, IWorkspace } from "@plane/types";
import { CustomSelect, Input, TextArea } from "@plane/ui";
import { renderFormattedDate, getFileURL } from "@plane/utils";
import { renderFormattedDate } from "@plane/utils";
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 { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// hooks
import { useProject } from "@/hooks/store/use-project";
@@ -30,6 +32,7 @@ export interface IProjectDetailsForm {
isAdmin: boolean;
}
const projectService = new ProjectService();
export function ProjectDetailsForm(props: IProjectDetailsForm) {
const { project, workspaceSlug, projectId, isAdmin } = props;
const { t } = useTranslation();
@@ -156,10 +159,28 @@ export function ProjectDetailsForm(props: IProjectDetailsForm) {
logo_props: formData.logo_props,
timezone: formData.timezone,
};
// if unsplash or a pre-defined image is uploaded, delete the old uploaded asset
if (formData.cover_image_url?.startsWith("http")) {
payload.cover_image = formData.cover_image_url;
payload.cover_image_asset = null;
// Handle cover image changes
try {
const coverImagePayload = await handleCoverImageChange(project.cover_image_url, formData.cover_image_url, {
workspaceSlug: workspaceSlug.toString(),
entityIdentifier: project.id,
entityType: EFileAssetType.PROJECT_COVER,
isUserAsset: false,
});
if (coverImagePayload) {
Object.assign(payload, coverImagePayload);
}
} catch (error) {
console.error("Error handling cover image:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: error instanceof Error ? error.message : "Failed to process cover image",
});
setIsLoading(false);
return;
}
if (project.identifier !== formData.identifier)
@@ -180,10 +201,7 @@ export function ProjectDetailsForm(props: IProjectDetailsForm) {
<div className="relative h-44 w-full">
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
<img
src={getFileURL(
coverImage ??
"https://images.unsplash.com/photo-1672243775941-10d763d9adef?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80"
)}
src={getCoverImageDisplayURL(coverImage, DEFAULT_COVER_IMAGE_URL)}
alt="Project cover image"
className="h-44 w-full rounded-md object-cover"
/>
-8
View File
@@ -372,14 +372,6 @@ export class FileService extends APIService {
});
}
async getProjectCoverImages(): Promise<string[]> {
return this.get(`/api/project-covers/`)
.then((res) => res?.data)
.catch((err) => {
throw err?.response?.data;
});
}
async duplicateAsset(
workspaceSlug: string,
assetId: string,
@@ -8,9 +8,9 @@ import { setToast, TOAST_TYPE } from "@plane/propel/toast";
import type { IProject, IWorkspace } from "@plane/types";
import { EUserProjectRoles } from "@plane/types";
// components
import { getFileURL } from "@plane/utils";
import { ImagePickerPopover } from "@/components/core/image-picker-popover";
// hooks
import { DEFAULT_COVER_IMAGE_URL, getCoverImageDisplayURL } from "@/helpers/cover-image.helper";
import { captureClick, captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
@@ -22,9 +22,6 @@ type THeroSection = {
workspaceSlug: string;
};
const DEFAULT_COVER_IMAGE =
"https://images.unsplash.com/photo-1672243775941-10d763d9adef?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80";
export const HeroSection = observer((props: THeroSection) => {
const { project, workspaceSlug } = props;
const [isOpen, setIsOpen] = useState(false);
@@ -100,7 +97,7 @@ export const HeroSection = observer((props: THeroSection) => {
<div>
<div className="relative h-[118px] w-full ">
<img
src={getFileURL(project.cover_image_url ?? DEFAULT_COVER_IMAGE)}
src={getCoverImageDisplayURL(project.cover_image_url, DEFAULT_COVER_IMAGE_URL)}
alt={project.name}
className="absolute left-0 top-0 h-full w-full object-cover"
/>
@@ -113,7 +110,7 @@ export const HeroSection = observer((props: THeroSection) => {
if (data === project.cover_image_url) return;
handleCoverChange({ cover_image_url: data });
}}
value={project.cover_image_url ?? DEFAULT_COVER_IMAGE}
value={project.cover_image_url ?? DEFAULT_COVER_IMAGE_URL}
disabled={!isAdmin}
projectId={project.id}
/>
@@ -1,9 +1,9 @@
import React, { useCallback, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { DEFAULT_PROJECT_FORM_VALUES } from "@plane/constants";
import { extractAndSanitizeProjectCreationFormData } from "@plane/utils";
// store hooks
import { getProjectFormValues } from "@/ce/components/projects/create/utils";
import { useMember } from "@/hooks/store/use-member";
// plane web imports
import type { THandleTemplateChangeProps } from "@/plane-web/components/projects/create/project-creation-context";
@@ -51,7 +51,7 @@ export const ProjectCreationProvider = observer((props: TProjectCreationProvider
// reset form values
reset({
...DEFAULT_PROJECT_FORM_VALUES,
...getProjectFormValues(),
...sanitizedWorkItemFormData,
});
}
@@ -5,17 +5,20 @@ import { useState, useEffect } from "react";
import { observer } from "mobx-react";
import { useForm, FormProvider } from "react-hook-form";
// plane imports
import { DEFAULT_PROJECT_FORM_VALUES, PROJECT_TRACKER_EVENTS } from "@plane/constants";
import { PROJECT_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
import { EFileAssetType } from "@plane/types";
import type { EUserProjectRoles, IProjectBulkAddFormData } from "@plane/types";
// types
import type { TCreateProjectFormProps } from "@/ce/components/projects/create/root";
// constants
import { getProjectFormValues } from "@/ce/components/projects/create/utils";
import ProjectCommonAttributes from "@/components/project/create/common-attributes";
import ProjectCreateHeader from "@/components/project/create/header";
// hooks
import { uploadCoverImage, getCoverImageType } from "@/helpers/cover-image.helper";
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
@@ -42,7 +45,7 @@ export const CreateProjectForm: FC<TCreateProjectFormProps> = observer((props) =
export const CreateProjectFormBase: FC<TCreateProjectFormProps> = observer((props) => {
const { setToFavorite, workspaceSlug, onClose, handleNextStep, data, updateCoverImageStatus } = props;
// store
const { addProjectToFavorites, createProject } = useProject();
const { addProjectToFavorites, createProject, updateProject } = useProject();
const { projectCreationLoader, createProjectUsingTemplate } = useProjectAdvanced();
// states
const [isChangeInIdentifierRequired, setIsChangeInIdentifierRequired] = useState(true);
@@ -60,7 +63,7 @@ export const CreateProjectFormBase: FC<TCreateProjectFormProps> = observer((prop
const { projectTemplateId, isApplyingTemplate, handleTemplateChange } = useProjectCreation();
// form info
const methods = useForm<TProject>({
defaultValues: { ...DEFAULT_PROJECT_FORM_VALUES, ...data },
defaultValues: { ...getProjectFormValues(), ...data },
reValidateMode: "onChange",
});
const {
@@ -116,10 +119,32 @@ export const CreateProjectFormBase: FC<TCreateProjectFormProps> = observer((prop
// Upper case identifier
formData.identifier = formData.identifier?.toUpperCase();
const coverImage = formData.cover_image_url;
// if unsplash or a pre-defined image is uploaded, delete the old uploaded asset
if (coverImage?.startsWith("http")) {
formData.cover_image = coverImage;
formData.cover_image_asset = null;
let uploadedAssetUrl: string | null = null;
if (coverImage) {
const imageType = getCoverImageType(coverImage);
if (imageType === "local_static") {
try {
uploadedAssetUrl = await uploadCoverImage(coverImage, {
workspaceSlug: workspaceSlug.toString(),
entityIdentifier: "",
entityType: EFileAssetType.PROJECT_COVER,
isUserAsset: false,
});
} catch (error) {
console.error("Error uploading cover image:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: error instanceof Error ? error.message : "Failed to upload cover image",
});
return Promise.reject(error);
}
} else {
formData.cover_image = coverImage;
formData.cover_image_asset = null;
}
}
const createProjectService = projectTemplateId
@@ -133,9 +158,18 @@ export const CreateProjectFormBase: FC<TCreateProjectFormProps> = observer((prop
return createProjectService(formData)
.then(async (res) => {
if (coverImage && allowAssetStatusUpdate) {
if (uploadedAssetUrl) {
await updateCoverImageStatus(res.id, uploadedAssetUrl);
await updateProject(workspaceSlug.toString(), res.id, {
cover_image_url: uploadedAssetUrl,
});
} else if (allowAssetStatusUpdate && coverImage && coverImage.startsWith("http")) {
await updateCoverImageStatus(res.id, coverImage);
await updateProject(workspaceSlug.toString(), res.id, {
cover_image_url: coverImage,
});
}
if (allowBulkAddMembers && membersPayload.length > 0) {
bulkAddMembersToProject(workspaceSlug.toString(), res.id, {
members: membersPayload,
@@ -18,6 +18,7 @@ import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import type { TProject } from "@/plane-web/types/projects";
import { DEFAULT_COVER_IMAGE_URL, getCoverImageDisplayURL } from "@/helpers/cover-image.helper";
type Props = {
project: TProject;
@@ -134,10 +135,7 @@ export const Details: React.FC<Props> = observer((props) => {
<div className="relative ">
<div>
<img
src={getFileURL(
project.cover_image_url ??
"https://images.unsplash.com/photo-1672243775941-10d763d9adef?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80"
)}
src={getCoverImageDisplayURL(project.cover_image_url, DEFAULT_COVER_IMAGE_URL)}
alt={project.name}
className="relative w-full rounded-t object-cover h-[120px]"
// ref={projectCardRef}
@@ -5,6 +5,7 @@ import type { EIssuePropertyType, TIssueProperty } from "@plane/types";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
import { useProject } from "@/hooks/store/use-project";
import { useIssueType } from "@/plane-web/hooks/store";
import { DEFAULT_COVER_IMAGE_URL } from "@/helpers/cover-image.helper";
type Props = {
isOpen: boolean;
@@ -60,6 +61,7 @@ export const IntakeFormPreviewModal = observer((props: Props) => {
projectName={currentProjectDetails.name || ""}
projectLogo={currentProjectDetails.logo_props}
projectCoverImage={currentProjectDetails.cover_image}
projectCoverImageFallback={DEFAULT_COVER_IMAGE_URL}
formTitle={formTitle || ""}
properties={formProperties}
onSubmit={async (data) => console.log("Form submitted:", data)}
@@ -4,8 +4,9 @@ import useSWR from "swr";
import { ETemplateLevel, PROJECT_TEMPLATE_TRACKER_EVENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { setToast, TOAST_TYPE } from "@plane/propel/toast";
import { ETemplateType } from "@plane/types";
import { EFileAssetType, ETemplateType } from "@plane/types";
import { getTemplateSettingsBasePath, getTemplateTypeI18nName, projectTemplateFormDataToData } from "@plane/utils";
import { getCoverImageType, uploadCoverImage } from "@/helpers/cover-image.helper";
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useMember } from "@/hooks/store/use-member";
import { useWorkspace } from "@/hooks/store/use-workspace";
@@ -61,8 +62,43 @@ export const CreateUpdateProjectTemplate = observer((props: TCreateUpdateProject
const currentWorkspace = getWorkspaceBySlug(workspaceSlug);
if (!currentWorkspace) return;
// Handle cover image upload if it's a local static image
let processedTemplateData = { ...templateData };
const coverImage = templateData.project.cover_image_url;
if (coverImage) {
const imageType = getCoverImageType(coverImage);
if (imageType === "local_static") {
try {
const uploadedUrl = await uploadCoverImage(coverImage, {
workspaceSlug: workspaceSlug,
entityIdentifier: templateData.template.id || "",
entityType: EFileAssetType.TEMPLATE_ATTACHMENT,
isUserAsset: false,
});
processedTemplateData = {
...processedTemplateData,
project: {
...processedTemplateData.project,
cover_image_url: uploadedUrl,
},
};
} catch (error) {
console.error("Error uploading cover image for template:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("toast.error"),
message: error instanceof Error ? error.message : "Failed to upload cover image",
});
return;
}
}
}
const payload = projectTemplateFormDataToData({
formData: templateData,
formData: processedTemplateData,
getUserDetails,
getWorkspaceProjectStateById: getProjectStateById,
workItemListCustomPropertyValues,
@@ -4,12 +4,7 @@ import { observer } from "mobx-react";
import { FormProvider, useForm } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
// plane imports
import {
EProjectPriority,
PROJECT_TEMPLATE_TRACKER_ELEMENTS,
PROJECT_UNSPLASH_COVERS,
RANDOM_EMOJI_CODES,
} from "@plane/constants";
import { EProjectPriority, PROJECT_TEMPLATE_TRACKER_ELEMENTS, RANDOM_EMOJI_CODES } from "@plane/constants";
import { usePreventOutsideClick } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
@@ -30,6 +25,7 @@ import {
projectTemplateDataToSanitizedFormData,
} from "@plane/utils";
// root store
import { DEFAULT_COVER_IMAGE_URL } from "@/helpers/cover-image.helper";
import { useMember } from "@/hooks/store/use-member";
import { useAppRouter } from "@/hooks/use-app-router";
import { rootStore } from "@/lib/store-context";
@@ -50,6 +46,7 @@ import { ProjectTemplateLoader } from "./loader";
import { ProjectDetails } from "./project-details";
import { ProjectStates } from "./states";
import { ProjectWorkItemTypes } from "./work-item-types/root";
import { getProjectFormValues } from "@/ce/components/projects/create/utils";
export enum EProjectFormOperation {
CREATE = "create",
@@ -91,7 +88,7 @@ export const DEFAULT_PROJECT_TEMPLATE_FORM_DATA: TProjectTemplateForm = {
value: RANDOM_EMOJI_CODES[Math.floor(Math.random() * RANDOM_EMOJI_CODES.length)],
},
},
cover_image_url: PROJECT_UNSPLASH_COVERS[Math.floor(Math.random() * PROJECT_UNSPLASH_COVERS.length)],
cover_image_url: DEFAULT_COVER_IMAGE_URL,
network: 2,
project_lead: "",
// attributes
@@ -277,6 +274,7 @@ export const ProjectTemplateFormRoot = observer((props: TProjectTemplateFormRoot
const additionalDefaultValueForReset = await generateAdditionalProjectTemplateFormData({
workspaceSlug: workspaceSlug?.toString(),
projectId: preloadedData?.template?.id ?? "",
coverImageUrl: DEFAULT_COVER_IMAGE_URL,
createWorkItemTypeInstance: (params) =>
new IssueType({
root: rootStore,
@@ -6,10 +6,10 @@ import { useTranslation } from "@plane/i18n";
import { EmojiPicker, EmojiIconPickerTypes, Logo } from "@plane/propel/emoji-icon-picker";
import type { TProjectTemplateForm } from "@plane/types";
import { Input, TextArea } from "@plane/ui";
import { getFileURL } from "@plane/utils";
// components
import { ImagePickerPopover } from "@/components/core/image-picker-popover";
// plane web components
import { DEFAULT_COVER_IMAGE_URL, getCoverImageDisplayURL } from "@/helpers/cover-image.helper";
import { validateWhitespaceI18n } from "@/plane-web/components/templates/settings/common";
export const ProjectDetails = observer(() => {
@@ -32,7 +32,7 @@ export const ProjectDetails = observer(() => {
{/* Cover Image */}
{coverImage && (
<img
src={getFileURL(coverImage)}
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")}
/>
+283
View File
@@ -0,0 +1,283 @@
import type { EFileAssetType } from "@plane/types";
import { getFileURL } from "@plane/utils";
import CoverImage1 from "@/app/assets/cover-images/image_1.jpg?url";
import CoverImage10 from "@/app/assets/cover-images/image_10.jpg?url";
import CoverImage11 from "@/app/assets/cover-images/image_11.jpg?url";
import CoverImage12 from "@/app/assets/cover-images/image_12.jpg?url";
import CoverImage13 from "@/app/assets/cover-images/image_13.jpg?url";
import CoverImage14 from "@/app/assets/cover-images/image_14.jpg?url";
import CoverImage15 from "@/app/assets/cover-images/image_15.jpg?url";
import CoverImage16 from "@/app/assets/cover-images/image_16.jpg?url";
import CoverImage17 from "@/app/assets/cover-images/image_17.jpg?url";
import CoverImage18 from "@/app/assets/cover-images/image_18.jpg?url";
import CoverImage19 from "@/app/assets/cover-images/image_19.jpg?url";
import CoverImage2 from "@/app/assets/cover-images/image_2.jpg?url";
import CoverImage20 from "@/app/assets/cover-images/image_20.jpg?url";
import CoverImage21 from "@/app/assets/cover-images/image_21.jpg?url";
import CoverImage22 from "@/app/assets/cover-images/image_22.jpg?url";
import CoverImage23 from "@/app/assets/cover-images/image_23.jpg?url";
import CoverImage24 from "@/app/assets/cover-images/image_24.jpg?url";
import CoverImage25 from "@/app/assets/cover-images/image_25.jpg?url";
import CoverImage26 from "@/app/assets/cover-images/image_26.jpg?url";
import CoverImage27 from "@/app/assets/cover-images/image_27.jpg?url";
import CoverImage28 from "@/app/assets/cover-images/image_28.jpg?url";
import CoverImage29 from "@/app/assets/cover-images/image_29.jpg?url";
import CoverImage3 from "@/app/assets/cover-images/image_3.jpg?url";
import CoverImage4 from "@/app/assets/cover-images/image_4.jpg?url";
import CoverImage5 from "@/app/assets/cover-images/image_5.jpg?url";
import CoverImage6 from "@/app/assets/cover-images/image_6.jpg?url";
import CoverImage7 from "@/app/assets/cover-images/image_7.jpg?url";
import CoverImage8 from "@/app/assets/cover-images/image_8.jpg?url";
import CoverImage9 from "@/app/assets/cover-images/image_9.jpg?url";
import { FileService } from "@/services/file.service";
const fileService = new FileService();
/**
* Map of all available static cover images
* These are pre-loaded images available in the assets/cover-images folder
*/
export const STATIC_COVER_IMAGES = {
IMAGE_1: CoverImage1,
IMAGE_2: CoverImage2,
IMAGE_3: CoverImage3,
IMAGE_4: CoverImage4,
IMAGE_5: CoverImage5,
IMAGE_6: CoverImage6,
IMAGE_7: CoverImage7,
IMAGE_8: CoverImage8,
IMAGE_9: CoverImage9,
IMAGE_10: CoverImage10,
IMAGE_11: CoverImage11,
IMAGE_12: CoverImage12,
IMAGE_13: CoverImage13,
IMAGE_14: CoverImage14,
IMAGE_15: CoverImage15,
IMAGE_16: CoverImage16,
IMAGE_17: CoverImage17,
IMAGE_18: CoverImage18,
IMAGE_19: CoverImage19,
IMAGE_20: CoverImage20,
IMAGE_21: CoverImage21,
IMAGE_22: CoverImage22,
IMAGE_23: CoverImage23,
IMAGE_24: CoverImage24,
IMAGE_25: CoverImage25,
IMAGE_26: CoverImage26,
IMAGE_27: CoverImage27,
IMAGE_28: CoverImage28,
IMAGE_29: CoverImage29,
} as const;
export const DEFAULT_COVER_IMAGE_URL = STATIC_COVER_IMAGES.IMAGE_1;
/**
* Set of static image URLs for fast O(1) lookup
*/
const STATIC_COVER_IMAGES_SET = new Set<string>(Object.values(STATIC_COVER_IMAGES));
export type TCoverImageType = "local_static" | "uploaded_asset";
export type TCoverImageResult = {
needsUpload: boolean;
imageType: TCoverImageType;
shouldUpdate: boolean;
};
export type TCoverImagePayload = {
cover_image?: string | null;
cover_image_url?: string | null;
cover_image_asset?: string | null;
};
/**
* Checks if a given URL is a valid static cover image
*/
export const isStaticCoverImage = (imageUrl: string | null | undefined): boolean => {
if (!imageUrl) return false;
return STATIC_COVER_IMAGES_SET.has(imageUrl);
};
/**
* Determines the type of cover image URL
* Uses explicit validation against known static images for better accuracy
*/
export const getCoverImageType = (imageUrl: string): TCoverImageType => {
// Check against the explicit set of static images
if (isStaticCoverImage(imageUrl)) return "local_static";
if (imageUrl.startsWith("http")) return "uploaded_asset";
return "uploaded_asset";
};
/**
* Gets the correct display URL for a cover image
* - Local static images: returned as-is (served from assets folder)
* - Uploaded assets: processed through getFileURL (adds backend URL)
*/
export function getCoverImageDisplayURL(imageUrl: string | null | undefined, fallbackUrl: string): string;
export function getCoverImageDisplayURL(imageUrl: string | null | undefined, fallbackUrl: null): string | null;
export function getCoverImageDisplayURL(
imageUrl: string | null | undefined,
fallbackUrl: string | null
): string | null {
if (!imageUrl) {
return fallbackUrl;
}
const imageType = getCoverImageType(imageUrl);
if (imageType === "local_static") {
return imageUrl;
}
if (imageType === "uploaded_asset") {
return getFileURL(imageUrl) || imageUrl;
}
return imageUrl;
}
/**
* Analyzes cover image change and determines what action to take
*/
export const analyzeCoverImageChange = (
currentImage: string | null | undefined,
newImage: string | null | undefined
): TCoverImageResult => {
const hasChanged = currentImage !== newImage;
if (!hasChanged) {
return {
needsUpload: false,
imageType: "uploaded_asset",
shouldUpdate: false,
};
}
const imageType = getCoverImageType(newImage ?? "");
return {
needsUpload: imageType === "local_static",
imageType,
shouldUpdate: hasChanged,
};
};
/**
* Uploads a local static image to S3
*/
export const uploadCoverImage = async (
imageUrl: string,
uploadConfig: {
workspaceSlug?: string;
entityIdentifier: string;
entityType: EFileAssetType;
isUserAsset?: boolean;
}
): Promise<string> => {
const { workspaceSlug, entityIdentifier, entityType, isUserAsset = false } = uploadConfig;
// Fetch the local image
const response = await fetch(imageUrl);
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.statusText}`);
}
const blob = await response.blob();
// Validate it's actually an image
if (!blob.type.startsWith("image/")) {
throw new Error("Invalid file type. Please select an image.");
}
const fileName = imageUrl.split("/").pop() || "cover.jpg";
const file = new File([blob], fileName, { type: blob.type });
// Upload based on context
if (isUserAsset) {
const uploadResult = await fileService.uploadUserAsset(
{
entity_identifier: entityIdentifier,
entity_type: entityType,
},
file
);
return uploadResult.asset_url;
} else {
if (!workspaceSlug) {
throw new Error("Workspace slug is required for workspace asset upload");
}
const uploadResult = await fileService.uploadWorkspaceAsset(
workspaceSlug,
{
entity_identifier: entityIdentifier,
entity_type: entityType,
},
file
);
return uploadResult.asset_url;
}
};
/**
* Main utility to handle cover image changes with upload
* Returns the payload fields that should be updated
*/
export const handleCoverImageChange = async (
currentImage: string | null | undefined,
newImage: string | null | undefined,
uploadConfig: {
workspaceSlug?: string;
entityIdentifier: string;
entityType: EFileAssetType;
isUserAsset?: boolean;
}
): Promise<TCoverImagePayload | null> => {
const analysis = analyzeCoverImageChange(currentImage, newImage);
// No change detected
if (!analysis.shouldUpdate) {
return null;
}
// Image removed
if (!newImage) {
return {
cover_image: null,
cover_image_url: null,
cover_image_asset: null,
};
}
// Local static image - needs upload
if (analysis.needsUpload) {
const uploadedUrl = await uploadCoverImage(newImage, uploadConfig);
// For BOTH user assets AND project assets:
// The backend auto-links when entity_identifier is set correctly
// So we don't need to return anything - the link happens server-side!
if (uploadConfig.isUserAsset) {
// User assets: return URL for display (optional, mostly for immediate feedback)
return {
cover_image_url: uploadedUrl,
};
} else {
// Project assets: Auto-linked server-side during upload!
// No need to return anything or call bulk API
return null;
}
}
// Already uploaded asset - no action needed
if (analysis.imageType === "uploaded_asset") {
return null;
}
return null;
};
+1 -36
View File
@@ -1,7 +1,6 @@
// plane imports
import type { IProject, TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types";
import type { TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types";
// local imports
import { RANDOM_EMOJI_CODES } from "./emoji";
export type TNetworkChoiceIconKey = "Lock" | "Globe2";
@@ -61,25 +60,6 @@ export const PROJECT_AUTOMATION_MONTHS = [
{ i18n_label: "workspace_projects.common.months_count", value: 12 },
];
export const PROJECT_UNSPLASH_COVERS = [
"https://images.unsplash.com/photo-1531045535792-b515d59c3d1f?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=870&q=80",
"https://images.unsplash.com/photo-1693027407934-e3aa8a54c7ae?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=870&q=80",
"https://images.unsplash.com/photo-1518837695005-2083093ee35b?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=870&q=80",
"https://images.unsplash.com/photo-1464925257126-6450e871c667?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80",
"https://images.unsplash.com/photo-1606768666853-403c90a981ad?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80",
"https://images.unsplash.com/photo-1627556592933-ffe99c1cd9eb?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80",
"https://images.unsplash.com/photo-1643330683233-ff2ac89b002c?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=870&q=80",
"https://images.unsplash.com/photo-1542202229-7d93c33f5d07?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80",
"https://images.unsplash.com/photo-1511497584788-876760111969?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=870&q=80",
"https://images.unsplash.com/photo-1475738972911-5b44ce984c42?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80",
"https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80",
"https://images.unsplash.com/photo-1673393058808-50e9baaf4d2c?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80",
"https://images.unsplash.com/photo-1696643830146-44a8755f1905?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=870&q=80",
"https://images.unsplash.com/photo-1693868769698-6c7440636a09?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80",
"https://images.unsplash.com/photo-1691230995681-480d86cbc135?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80",
"https://images.unsplash.com/photo-1675351066828-6fc770b90dd2?auto=format&fit=crop&q=80&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&w=870&q=80",
];
export const PROJECT_ORDER_BY_OPTIONS: {
key: TProjectOrderByOptions;
i18n_label: string;
@@ -135,21 +115,6 @@ export const PROJECT_ERROR_MESSAGES = {
},
};
export const DEFAULT_PROJECT_FORM_VALUES: Partial<IProject> = {
cover_image_url: PROJECT_UNSPLASH_COVERS[Math.floor(Math.random() * PROJECT_UNSPLASH_COVERS.length)],
description: "",
logo_props: {
in_use: "emoji",
emoji: {
value: RANDOM_EMOJI_CODES[Math.floor(Math.random() * RANDOM_EMOJI_CODES.length)],
},
},
identifier: "",
name: "",
network: 2,
project_lead: null,
};
export enum EProjectFeatureKey {
WORK_ITEMS = "work_items",
CYCLES = "cycles",
@@ -12,9 +12,6 @@ import { BooleanInput, DateSelect, NumberInput, OptionSelect, TextInput, UrlInpu
import { AttachmentPreviewList, extractFileExtension } from "../work-items";
import type { TAttachmentPreviewItem } from "../work-items";
const DEFAULT_COVER_IMAGE =
"https://images.unsplash.com/photo-1672243775941-10d763d9adef?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80";
export const IntakePublishForm: React.FC<TIntakePublishFormProps> = ({
isPreview = false,
projectName,
@@ -28,6 +25,7 @@ export const IntakePublishForm: React.FC<TIntakePublishFormProps> = ({
editorProps,
onFileUpload,
className = "",
projectCoverImageFallback,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -167,7 +165,7 @@ export const IntakePublishForm: React.FC<TIntakePublishFormProps> = ({
<div className="relative h-[133px] w-full">
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent rounded-md" />
<img
src={getFileURL(projectCoverImage ?? DEFAULT_COVER_IMAGE)}
src={getFileURL(projectCoverImage ?? projectCoverImageFallback)}
alt="Project cover image"
className="h-[133px] w-full rounded-md object-cover"
/>
@@ -44,6 +44,7 @@ export type TIntakePublishFormProps = {
projectName: string;
projectLogo?: TLogoProps;
projectCoverImage?: string;
projectCoverImageFallback: string;
// Form configuration
formTitle: string;
+2 -1
View File
@@ -13,7 +13,8 @@ export type TProjectPublishViewProps = {
spreadsheet?: boolean;
};
export type TProjectDetails = IProjectLite & Pick<IProject, "cover_image" | "logo_props" | "description">;
export type TProjectDetails = IProjectLite &
Pick<IProject, "cover_image" | "cover_image_url" | "logo_props" | "description">;
export type TPublishSettings = {
anchor: string | undefined;
+5 -3
View File
@@ -1,12 +1,14 @@
// plane imports
import { PROJECT_UNSPLASH_COVERS, RANDOM_EMOJI_CODES } from "@plane/constants";
import { RANDOM_EMOJI_CODES } from "@plane/constants";
import type { PartialDeep, TProjectTemplateForm } from "@plane/types";
// local imports
import { mockCreateDefaultProjectStates } from "./state";
import type { TMockCreateDefaultWorkItemTypeInstanceParams } from "./work-item-type";
import { mockCreateDefaultWorkItemTypeInstance, mockCreateProjectEpicWorkItemTypeInstance } from "./work-item-type";
type TGenerateAdditionalProjectTemplateFormDataParams = TMockCreateDefaultWorkItemTypeInstanceParams;
type TGenerateAdditionalProjectTemplateFormDataParams = TMockCreateDefaultWorkItemTypeInstanceParams & {
coverImageUrl: string;
};
/**
* Generate additional project template form data that are dynamic
@@ -35,7 +37,7 @@ export const generateAdditionalProjectTemplateFormData = async (
value: RANDOM_EMOJI_CODES[Math.floor(Math.random() * RANDOM_EMOJI_CODES.length)],
},
},
cover_image_url: PROJECT_UNSPLASH_COVERS[Math.floor(Math.random() * PROJECT_UNSPLASH_COVERS.length)],
cover_image_url: params.coverImageUrl,
states: projectStates,
workitem_types: workItemTypes,
epics: projectEpicWorkItemType,