Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
f88b2c8
feat: implement cover image handling and static image selection
JayashTripathy Nov 26, 2025
e667a54
refactor: rename STATIC_COVER_IMAGES_ARRAY to STATIC_COVER_IMAGES for…
JayashTripathy Nov 26, 2025
e10b280
feat: enhance project creation and image handling
JayashTripathy Nov 27, 2025
88f28d3
refactor: simplify cover image type definition and clean up code
JayashTripathy Nov 27, 2025
4d0427d
refactor: update cover image type definitions and simplify logic
JayashTripathy Nov 27, 2025
ba9db3a
refactor: remove unused project cover image endpoint and update cover…
JayashTripathy Nov 27, 2025
f7f8d7a
refactor: update cover image imports to new asset structure
JayashTripathy Nov 27, 2025
d539aa6
feat: add additional cover images to the helper
JayashTripathy Nov 27, 2025
8fee5f2
refactor: remove ProjectPublicCoverImagesEndpoint from project URLs a…
prateekshourya29 Nov 27, 2025
9db8d9d
refactor: update cover image imports to include URL query parameter
JayashTripathy Nov 27, 2025
196e5ff
Merge branch 'feat/app-static-cover-images' of https://github.com/mak…
JayashTripathy Nov 27, 2025
d7b1229
refactor: extract default project form values into a utility function
JayashTripathy Nov 27, 2025
e3661aa
feat: integrate project update functionality in CreateProjectForm
JayashTripathy Nov 28, 2025
bf164b1
fix: update documentation for cover image handling
JayashTripathy Dec 1, 2025
801466e
feat: implement random cover image selection for project forms
JayashTripathy Dec 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 48 additions & 7 deletions apps/web/ce/components/projects/create/root.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
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";
Expand All @@ -28,6 +30,21 @@ export type TCreateProjectFormProps = {
updateCoverImageStatus: (projectId: string, coverImage: string) => Promise<void>;
};

const DEFAULT_PROJECT_FORM_VALUES: 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,
};

export const CreateProjectForm = observer(function CreateProjectForm(props: TCreateProjectFormProps) {
const { setToFavorite, workspaceSlug, data, onClose, handleNextStep, updateCoverImageStatus } = props;
// store
Expand Down Expand Up @@ -58,15 +75,39 @@ 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);
} else if (coverImage && coverImage.startsWith("http")) {
await updateCoverImageStatus(res.id, coverImage);
}
captureSuccess({
Expand Down
226 changes: 101 additions & 125 deletions apps/web/core/components/core/image-picker-popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { EFileAssetType } from "@plane/types";
import { Input, Loader } from "@plane/ui";
// helpers
import { getFileURL } from "@plane/utils";
import { STATIC_COVER_IMAGES } from "@/helpers/cover-image.helper";
// hooks
import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down";
// services
Expand Down Expand Up @@ -73,11 +74,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[]) => {
Expand All @@ -90,6 +86,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);
Expand Down Expand Up @@ -183,130 +184,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);
}}
>
<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>
)
) : (
<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);
<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}
alt={`Default project cover image- ${index}`}
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 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">
Expand Down
Loading
Loading