diff --git a/apps/api/plane/app/urls/project.py b/apps/api/plane/app/urls/project.py index 61d30f91662..88babebed06 100644 --- a/apps/api/plane/app/urls/project.py +++ b/apps/api/plane/app/urls/project.py @@ -11,7 +11,6 @@ ProjectIdentifierEndpoint, ProjectFavoritesViewSet, UserProjectInvitationsViewset, - ProjectPublicCoverImagesEndpoint, UserProjectRolesEndpoint, ProjectArchiveUnarchiveEndpoint, ) @@ -105,11 +104,6 @@ ProjectFavoritesViewSet.as_view({"delete": "destroy"}), name="project-favorite", ), - path( - "project-covers/", - ProjectPublicCoverImagesEndpoint.as_view(), - name="project-covers", - ), path( "workspaces//projects//project-deploy-boards/", DeployBoardViewSet.as_view({"get": "list", "post": "create"}), diff --git a/apps/api/plane/app/views/__init__.py b/apps/api/plane/app/views/__init__.py index 87ad0e8cc1c..12200652789 100644 --- a/apps/api/plane/app/views/__init__.py +++ b/apps/api/plane/app/views/__init__.py @@ -3,7 +3,6 @@ ProjectIdentifierEndpoint, ProjectUserViewsEndpoint, ProjectFavoritesViewSet, - ProjectPublicCoverImagesEndpoint, DeployBoardViewSet, ProjectArchiveUnarchiveEndpoint, ) diff --git a/apps/api/plane/app/views/project/base.py b/apps/api/plane/app/views/project/base.py index 84b2a5629ab..78583ab2de5 100644 --- a/apps/api/plane/app/views/project/base.py +++ b/apps/api/plane/app/views/project/base.py @@ -553,49 +553,6 @@ def destroy(self, request, slug, project_id): 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 = [] - if settings.USE_MINIO: - s3 = boto3.client( - "s3", - endpoint_url=settings.AWS_S3_ENDPOINT_URL, - aws_access_key_id=settings.AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, - ) - else: - s3 = boto3.client( - "s3", - aws_access_key_id=settings.AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, - ) - params = { - "Bucket": settings.AWS_STORAGE_BUCKET_NAME, - "Prefix": "static/project-cover/", - } - - try: - response = s3.list_objects_v2(**params) - # Extracting file keys from the response - if "Contents" in response: - for content in response["Contents"]: - if not content["Key"].endswith( - "/" - ): # This line ensures we're only getting files, not "sub-folders" - files.append( - f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}" - ) - - return Response(files, status=status.HTTP_200_OK) - except Exception as e: - log_exception(e) - return Response([], status=status.HTTP_200_OK) - - class DeployBoardViewSet(BaseViewSet): permission_classes = [ProjectMemberPermission] serializer_class = DeployBoardSerializer diff --git a/apps/web/app/assets/cover-images/image_1.jpg b/apps/web/app/assets/cover-images/image_1.jpg new file mode 100644 index 00000000000..3565e8890c1 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_1.jpg differ diff --git a/apps/web/app/assets/cover-images/image_10.jpg b/apps/web/app/assets/cover-images/image_10.jpg new file mode 100644 index 00000000000..cecad95d48d Binary files /dev/null and b/apps/web/app/assets/cover-images/image_10.jpg differ diff --git a/apps/web/app/assets/cover-images/image_11.jpg b/apps/web/app/assets/cover-images/image_11.jpg new file mode 100644 index 00000000000..ebe34f24ded Binary files /dev/null and b/apps/web/app/assets/cover-images/image_11.jpg differ diff --git a/apps/web/app/assets/cover-images/image_12.jpg b/apps/web/app/assets/cover-images/image_12.jpg new file mode 100644 index 00000000000..cae254d468e Binary files /dev/null and b/apps/web/app/assets/cover-images/image_12.jpg differ diff --git a/apps/web/app/assets/cover-images/image_13.jpg b/apps/web/app/assets/cover-images/image_13.jpg new file mode 100644 index 00000000000..a3f4d408bf4 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_13.jpg differ diff --git a/apps/web/app/assets/cover-images/image_14.jpg b/apps/web/app/assets/cover-images/image_14.jpg new file mode 100644 index 00000000000..3b3da8801ed Binary files /dev/null and b/apps/web/app/assets/cover-images/image_14.jpg differ diff --git a/apps/web/app/assets/cover-images/image_15.jpg b/apps/web/app/assets/cover-images/image_15.jpg new file mode 100644 index 00000000000..3e44b7f8d15 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_15.jpg differ diff --git a/apps/web/app/assets/cover-images/image_16.jpg b/apps/web/app/assets/cover-images/image_16.jpg new file mode 100644 index 00000000000..f31335bf282 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_16.jpg differ diff --git a/apps/web/app/assets/cover-images/image_17.jpg b/apps/web/app/assets/cover-images/image_17.jpg new file mode 100644 index 00000000000..d2a50342d2f Binary files /dev/null and b/apps/web/app/assets/cover-images/image_17.jpg differ diff --git a/apps/web/app/assets/cover-images/image_18.jpg b/apps/web/app/assets/cover-images/image_18.jpg new file mode 100644 index 00000000000..10cf37c3076 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_18.jpg differ diff --git a/apps/web/app/assets/cover-images/image_19.jpg b/apps/web/app/assets/cover-images/image_19.jpg new file mode 100644 index 00000000000..dca56194356 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_19.jpg differ diff --git a/apps/web/app/assets/cover-images/image_2.jpg b/apps/web/app/assets/cover-images/image_2.jpg new file mode 100644 index 00000000000..a1adad52637 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_2.jpg differ diff --git a/apps/web/app/assets/cover-images/image_20.jpg b/apps/web/app/assets/cover-images/image_20.jpg new file mode 100644 index 00000000000..a8daf9772d1 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_20.jpg differ diff --git a/apps/web/app/assets/cover-images/image_21.jpg b/apps/web/app/assets/cover-images/image_21.jpg new file mode 100644 index 00000000000..57c094ebd70 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_21.jpg differ diff --git a/apps/web/app/assets/cover-images/image_22.jpg b/apps/web/app/assets/cover-images/image_22.jpg new file mode 100644 index 00000000000..9efc564b9ba Binary files /dev/null and b/apps/web/app/assets/cover-images/image_22.jpg differ diff --git a/apps/web/app/assets/cover-images/image_23.jpg b/apps/web/app/assets/cover-images/image_23.jpg new file mode 100644 index 00000000000..fec33d99d70 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_23.jpg differ diff --git a/apps/web/app/assets/cover-images/image_24.jpg b/apps/web/app/assets/cover-images/image_24.jpg new file mode 100644 index 00000000000..54c74a69202 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_24.jpg differ diff --git a/apps/web/app/assets/cover-images/image_25.jpg b/apps/web/app/assets/cover-images/image_25.jpg new file mode 100644 index 00000000000..66841c06bc3 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_25.jpg differ diff --git a/apps/web/app/assets/cover-images/image_26.jpg b/apps/web/app/assets/cover-images/image_26.jpg new file mode 100644 index 00000000000..d0c2766024f Binary files /dev/null and b/apps/web/app/assets/cover-images/image_26.jpg differ diff --git a/apps/web/app/assets/cover-images/image_27.jpg b/apps/web/app/assets/cover-images/image_27.jpg new file mode 100644 index 00000000000..84abce2ef3e Binary files /dev/null and b/apps/web/app/assets/cover-images/image_27.jpg differ diff --git a/apps/web/app/assets/cover-images/image_28.jpg b/apps/web/app/assets/cover-images/image_28.jpg new file mode 100644 index 00000000000..0ce78e34f99 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_28.jpg differ diff --git a/apps/web/app/assets/cover-images/image_29.jpg b/apps/web/app/assets/cover-images/image_29.jpg new file mode 100644 index 00000000000..9df7aa0e0f6 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_29.jpg differ diff --git a/apps/web/app/assets/cover-images/image_3.jpg b/apps/web/app/assets/cover-images/image_3.jpg new file mode 100644 index 00000000000..451849288ab Binary files /dev/null and b/apps/web/app/assets/cover-images/image_3.jpg differ diff --git a/apps/web/app/assets/cover-images/image_4.jpg b/apps/web/app/assets/cover-images/image_4.jpg new file mode 100644 index 00000000000..04d11093142 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_4.jpg differ diff --git a/apps/web/app/assets/cover-images/image_5.jpg b/apps/web/app/assets/cover-images/image_5.jpg new file mode 100644 index 00000000000..6dcdb25a3c8 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_5.jpg differ diff --git a/apps/web/app/assets/cover-images/image_6.jpg b/apps/web/app/assets/cover-images/image_6.jpg new file mode 100644 index 00000000000..f1cb9bf0182 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_6.jpg differ diff --git a/apps/web/app/assets/cover-images/image_7.jpg b/apps/web/app/assets/cover-images/image_7.jpg new file mode 100644 index 00000000000..c70602ca9ce Binary files /dev/null and b/apps/web/app/assets/cover-images/image_7.jpg differ diff --git a/apps/web/app/assets/cover-images/image_8.jpg b/apps/web/app/assets/cover-images/image_8.jpg new file mode 100644 index 00000000000..508bd557853 Binary files /dev/null and b/apps/web/app/assets/cover-images/image_8.jpg differ diff --git a/apps/web/app/assets/cover-images/image_9.jpg b/apps/web/app/assets/cover-images/image_9.jpg new file mode 100644 index 00000000000..d5267f9616d Binary files /dev/null and b/apps/web/app/assets/cover-images/image_9.jpg differ diff --git a/apps/web/ce/components/projects/create/root.tsx b/apps/web/ce/components/projects/create/root.tsx index cc702fdbb9c..dce8102940b 100644 --- a/apps/web/ce/components/projects/create/root.tsx +++ b/apps/web/ce/components/projects/create/root.tsx @@ -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({ - 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, diff --git a/apps/web/ce/components/projects/create/utils.ts b/apps/web/ce/components/projects/create/utils.ts new file mode 100644 index 00000000000..4605fa2bf82 --- /dev/null +++ b/apps/web/ce/components/projects/create/utils.ts @@ -0,0 +1,18 @@ +import { RANDOM_EMOJI_CODES } from "@plane/constants"; +import type { IProject } from "@plane/types"; +import { getRandomCoverImage } from "@/helpers/cover-image.helper"; + +export const getProjectFormValues = (): Partial => ({ + cover_image_url: getRandomCoverImage(), + 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, +}); diff --git a/apps/web/core/components/core/image-picker-popover.tsx b/apps/web/core/components/core/image-picker-popover.tsx index 7eefa0d3ab0..e0a15e6fc26 100644 --- a/apps/web/core/components/core/image-picker-popover.tsx +++ b/apps/web/core/components/core/image-picker-popover.tsx @@ -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 { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down"; // services @@ -73,11 +73,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(null); const onDrop = useCallback((acceptedFiles: File[]) => { @@ -90,6 +85,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); @@ -183,130 +183,105 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr > - {tabOptions.map((tab) => { - if (!unsplashImages && unsplashError && tab.key === "unsplash") return null; - if (projectCoverImages && projectCoverImages.length === 0 && tab.key === "images") return null; - - return ( - - `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} - - ); - })} + {tabOptions.map((tab) => ( + + `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} + + ))} - {(unsplashImages || !unsplashError) && ( - -
- ( - { - 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" - /> - )} - /> - -
- {unsplashImages ? ( - unsplashImages.length > 0 ? ( -
- {unsplashImages.map((image) => ( -
{ - setIsOpen(false); - onChange(image.urls.regular); - }} - > - {image.alt_description} -
- ))} -
- ) : ( -

No images found.

- ) - ) : ( - - - - - - - - - - - )} -
- )} - {(!projectCoverImages || projectCoverImages.length !== 0) && ( - - {projectCoverImages ? ( - projectCoverImages.length > 0 ? ( -
- {projectCoverImages.map((image, index) => ( -
{ - setIsOpen(false); - onChange(image); + + {(unsplashImages || !unsplashError) && ( + <> +
+ ( + { + if (e.key === "Enter") { + e.preventDefault(); + setSearchParams(formData.search); + } }} - > - {`Default -
- ))} -
+ value={value} + onChange={(e) => setFormData({ ...formData, search: e.target.value })} + ref={ref} + placeholder="Search for images" + className="w-full text-sm" + /> + )} + /> + +
+ {unsplashImages ? ( + unsplashImages.length > 0 ? ( +
+ {unsplashImages.map((image) => ( +
{ + setIsOpen(false); + onChange(image.urls.regular); + }} + > + {image.alt_description} +
+ ))} +
+ ) : ( +

No images found.

+ ) ) : ( -

No images found.

- ) - ) : ( - - - - - - - - - - - )} -
- )} + + + + + + + + + + + )} + + )} + + +
+ {Object.values(STATIC_COVER_IMAGES).map((imageUrl, index) => ( +
handleStaticImageSelect(imageUrl)} + > + {`Cover +
+ ))} +
+
@@ -327,7 +302,7 @@ export const ImagePickerPopover = observer(function ImagePickerPopover(props: Pr {image !== null || (value && value !== "") ? ( <> image diff --git a/apps/web/core/components/profile/form.tsx b/apps/web/core/components/profile/form.tsx index 95166242549..4f794d95513 100644 --- a/apps/web/core/components/profile/form.tsx +++ b/apps/web/core/components/profile/form.tsx @@ -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"; @@ -19,6 +20,7 @@ import { DeactivateAccountModal } from "@/components/account/deactivate-account- import { ImagePickerPopover } from "@/components/core/image-picker-popover"; 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 { useUser, useUserProfile } from "@/hooks/store/user"; @@ -112,11 +114,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 = { @@ -187,7 +204,7 @@ export const ProfileForm = observer(function ProfileForm(props: TProfileFormProp
{currentUser?.first_name @@ -221,9 +238,9 @@ export const ProfileForm = observer(function ProfileForm(props: TProfileFormProp render={({ field: { value, onChange } }) => ( onChange(imageUrl)} control={control} - value={value ?? "https://images.unsplash.com/photo-1506383796573-caf02b4a79ab"} + onChange={(imageUrl) => onChange(imageUrl)} + value={value} isProfileCover /> )} diff --git a/apps/web/core/components/profile/sidebar.tsx b/apps/web/core/components/profile/sidebar.tsx index 655207829df..91b2a9851f4 100644 --- a/apps/web/core/components/profile/sidebar.tsx +++ b/apps/web/core/components/profile/sidebar.tsx @@ -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 )} {userData?.display_name} {project.name} diff --git a/apps/web/core/components/project/create/header.tsx b/apps/web/core/components/project/create/header.tsx index 2e9c2a72b25..cd41927959f 100644 --- a/apps/web/core/components/project/create/header.tsx +++ b/apps/web/core/components/project/create/header.tsx @@ -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) {
{coverImage && ( {t("project_cover_image_alt")} @@ -53,8 +55,8 @@ function ProjectCreateHeader(props: Props) { render={({ field: { value, onChange } }) => ( diff --git a/apps/web/core/components/project/form.tsx b/apps/web/core/components/project/form.tsx index 0ac99e4c112..6161f4b699d 100644 --- a/apps/web/core/components/project/form.tsx +++ b/apps/web/core/components/project/form.tsx @@ -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) {
Project cover image diff --git a/apps/web/core/services/file.service.ts b/apps/web/core/services/file.service.ts index 0d2551fb9ca..cfa367376a4 100644 --- a/apps/web/core/services/file.service.ts +++ b/apps/web/core/services/file.service.ts @@ -274,13 +274,6 @@ export class FileService extends APIService { throw err?.response?.data; }); } - async getProjectCoverImages(): Promise { - return this.get(`/api/project-covers/`) - .then((res) => res?.data) - .catch((err) => { - throw err?.response?.data; - }); - } async duplicateAsset( workspaceSlug: string, diff --git a/apps/web/helpers/cover-image.helper.ts b/apps/web/helpers/cover-image.helper.ts new file mode 100644 index 00000000000..23928107dcc --- /dev/null +++ b/apps/web/helpers/cover-image.helper.ts @@ -0,0 +1,284 @@ +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(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 => { + 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 => { + 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 + // For project assets: auto-linked server-side, no payload needed + // For user assets: return URL for immediate UI feedback + + if (uploadConfig.isUserAsset) { + return { + cover_image_url: uploadedUrl, + }; + } else { + return null; + } + } + + return null; +}; + +/** + * Returns a random cover image from the STATIC_COVER_IMAGES object + * @returns {string} A random cover image URL + */ +export const getRandomCoverImage = (): string => + Object.values(STATIC_COVER_IMAGES)[Math.floor(Math.random() * Object.keys(STATIC_COVER_IMAGES).length)]; diff --git a/packages/constants/src/project.ts b/packages/constants/src/project.ts index b7dd9d6644e..f28f5c93365 100644 --- a/packages/constants/src/project.ts +++ b/packages/constants/src/project.ts @@ -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 = { - 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",