jira/apps/web/core/components/project/card.tsx
Anmol Singh Bhatia 6712389766
refactor: migrate web icons to @makeplane/propel (#9731)
* refactor: migrate web icons to @makeplane/propel

Replace confirmed icon symbols across apps/web with their audited
@makeplane/propel/icons targets. Unresolved symbols and the State,
Priority and Brand icons stay on their existing imports.

Lucide size props become explicit width/height, including the two
relation icons and the inbox status icon that forward a size variable.
Two aliased imports resolve to targets the file already imported, so
the aliases are dropped rather than importing the same symbol twice.

Widen the member dropdown chain and the AI menu from LucideIcon to
ComponentType<SVGProps<SVGSVGElement>> so both Lucide and Propel icons
satisfy them; both only ever render the icon with a className.

CycleGroupIcon is deliberately not migrated: it dispatches a different
glyph and color per cycle status, which CyclesOutline cannot express.

apps/web already declared @makeplane/propel, so no dependency change
was needed.

* fix: address icon migration review on web

Restore the six user-facing strings the identifier rename overwrote.
The migration replaced every occurrence of a source symbol name,
including ones inside string literals and JSX text, so the GitHub
empty state read "StarOutline us on GitHub", the exporter column key,
header and button label became "DownloadOutline", the project card
action title became "SettingsOutline" and a tooltip became "PinOutline".

Drop 122 strokeWidth props and 10 text-2 classes. Propel glyphs paint
with fill="currentColor", so strokeWidth never changed weight, and
text-2 was a bad conversion of stroke-2, which is a stroke width rather
than a colour and has no meaning on a fill path.

Use StarFilled for the two favorited sidebar stars. Lucide's Star with
fill-yellow-500 was a solid star, and StarOutline is a ring path, so
filling it does not reproduce that shape.
2026-09-02 20:36:43 +05:30

388 lines
14 KiB
TypeScript

/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useRef, useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import {
DeleteOutline,
LinkOutline,
LockOutline,
NewTabOutline,
RestoreOutline,
SettingsOutline,
TickOutline,
UserPlusOutline,
} from "@makeplane/propel/icons";
// plane imports
import { EUserPermissions, EUserPermissionsLevel, IS_FAVORITE_MENU_OPEN } from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
import { Avatar } from "@makeplane/propel/components/avatar";
import { Button } from "@plane/propel/button";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { setPromiseToast, setToast, TOAST_TYPE } from "@plane/propel/toast";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import type { IProject } from "@plane/types";
import type { TContextMenuItem } from "@plane/ui";
import { ContextMenu, FavoriteStar } from "@plane/ui";
import { copyUrlToClipboard, cn, getFileURL, renderFormattedDate } from "@plane/utils";
// components
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local imports
import { AvatarGroupOverflow } from "@/components/common/avatar-group-overflow";
import { CoverImage } from "@/components/common/cover-image";
import { DeleteProjectModal } from "./delete-project-modal";
import { JoinProjectModal } from "./join-project-modal";
import { ArchiveRestoreProjectModal } from "./archive-restore-modal";
type Props = {
project: IProject;
};
export const ProjectCard = observer(function ProjectCard(props: Props) {
const { project } = props;
// states
const [deleteProjectModalOpen, setDeleteProjectModal] = useState(false);
const [joinProjectModalOpen, setJoinProjectModal] = useState(false);
const [restoreProject, setRestoreProject] = useState(false);
// refs
const projectCardRef = useRef(null);
// router
const router = useAppRouter();
const { workspaceSlug } = useParams();
// store hooks
const { getUserDetails } = useMember();
const { addProjectToFavorites, removeProjectFromFavorites } = useProject();
const { allowPermissions } = useUserPermissions();
// hooks
const { isMobile } = usePlatformOS();
// derived values
const projectMembersIds = project.members;
const shouldRenderFavorite = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
// auth
const isMemberOfProject = !!project.member_role;
const hasAdminRole = project.member_role === EUserPermissions.ADMIN;
const hasMemberRole = project.member_role === EUserPermissions.MEMBER;
// archive
const isArchived = !!project.archived_at;
// local storage
const { setValue: toggleFavoriteMenu, storedValue: isFavoriteMenuOpen } = useLocalStorage<boolean>(
IS_FAVORITE_MENU_OPEN,
false
);
const handleAddToFavorites = () => {
if (!workspaceSlug) return;
const addToFavoritePromise = addProjectToFavorites(workspaceSlug.toString(), project.id);
setPromiseToast(addToFavoritePromise, {
loading: "Adding project to favorites...",
success: {
title: "Success!",
message: () => "Project added to favorites.",
actionItems: () => {
if (!isFavoriteMenuOpen) toggleFavoriteMenu(true);
return <></>;
},
},
error: {
title: "Error!",
message: () => "Couldn't add the project to favorites. Please try again.",
},
});
};
const handleRemoveFromFavorites = () => {
if (!workspaceSlug) return;
const removeFromFavoritePromise = removeProjectFromFavorites(workspaceSlug.toString(), project.id);
setPromiseToast(removeFromFavoritePromise, {
loading: "Removing project from favorites...",
success: {
title: "Success!",
message: () => "Project removed from favorites.",
},
error: {
title: "Error!",
message: () => "Couldn't remove the project from favorites. Please try again.",
},
});
};
const projectLink = `${workspaceSlug}/projects/${project.id}/issues`;
const handleCopyText = () =>
copyUrlToClipboard(projectLink).then(() =>
setToast({
type: TOAST_TYPE.INFO,
title: "Link Copied!",
message: "Project link copied to clipboard.",
})
);
const handleOpenInNewTab = () => window.open(`/${projectLink}`, "_blank");
const MENU_ITEMS: TContextMenuItem[] = [
{
key: "settings",
action: () => router.push(`/${workspaceSlug}/settings/projects/${project.id}`),
title: "Settings",
icon: SettingsOutline,
shouldRender: !isArchived && (hasAdminRole || hasMemberRole),
},
{
key: "join",
action: () => setJoinProjectModal(true),
title: "Join",
icon: UserPlusOutline,
shouldRender: !isMemberOfProject && !isArchived,
},
{
key: "open-new-tab",
action: handleOpenInNewTab,
title: "Open in new tab",
icon: NewTabOutline,
shouldRender: !isMemberOfProject && !isArchived,
},
{
key: "copy-link",
action: handleCopyText,
title: "Copy link",
icon: LinkOutline,
shouldRender: !isArchived,
},
{
key: "restore",
action: () => setRestoreProject(true),
title: "Restore",
icon: RestoreOutline,
shouldRender: isArchived && hasAdminRole,
},
{
key: "delete",
action: () => setDeleteProjectModal(true),
title: "Delete",
icon: DeleteOutline,
shouldRender: isArchived && hasAdminRole,
},
];
return (
<>
{/* Delete Project Modal */}
<DeleteProjectModal
project={project}
isOpen={deleteProjectModalOpen}
onClose={() => setDeleteProjectModal(false)}
/>
{/* Join Project Modal */}
{workspaceSlug && (
<JoinProjectModal
workspaceSlug={workspaceSlug.toString()}
project={project}
isOpen={joinProjectModalOpen}
handleClose={() => setJoinProjectModal(false)}
/>
)}
{/* Restore project modal */}
{workspaceSlug && project && (
<ArchiveRestoreProjectModal
workspaceSlug={workspaceSlug.toString()}
projectId={project.id}
isOpen={restoreProject}
onClose={() => setRestoreProject(false)}
archive={false}
/>
)}
<Link
ref={projectCardRef}
href={`/${workspaceSlug}/projects/${project.id}/issues`}
onClick={(e) => {
if (!isMemberOfProject || isArchived) {
e.preventDefault();
e.stopPropagation();
if (!isArchived) setJoinProjectModal(true);
}
}}
data-prevent-progress={!isMemberOfProject || isArchived}
className={cn(
"group/project-card flex w-full flex-col justify-between overflow-hidden rounded-lg border border-subtle bg-layer-2 transition-all duration-300 hover:border-strong hover:shadow-raised-200"
)}
>
<ContextMenu parentRef={projectCardRef} items={MENU_ITEMS} />
<div className="relative h-[118px] w-full rounded-t">
<div className="absolute inset-0 z-[1] bg-gradient-to-t from-black/60 to-transparent" />
<CoverImage
src={project.cover_image_url}
alt={project.name}
className="absolute top-0 left-0 h-full w-full rounded-t"
/>
<div className="absolute bottom-4 z-[1] flex h-10 w-full items-center justify-between gap-3 px-4">
<div className="flex flex-grow items-center gap-2.5 truncate">
<div className="grid h-9 w-9 flex-shrink-0 place-items-center rounded-sm bg-white/10">
<Logo logo={project.logo_props} size={18} />
</div>
<div className="flex w-full flex-col justify-between gap-0.5 truncate">
<h3 className="truncate font-semibold text-on-color">{project.name}</h3>
<span className="flex items-center gap-1.5">
<p className="text-11 font-medium text-on-color">{project.identifier} </p>
{project.network === 0 && <LockOutline className="h-2.5 w-2.5 text-on-color" />}
</span>
</div>
</div>
{!isArchived && (
<div data-prevent-progress className="flex h-full flex-shrink-0 items-center gap-2">
<button
className="flex h-6 w-6 items-center justify-center rounded-sm bg-white/10"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleCopyText();
}}
>
<LinkOutline className="h-3 w-3 text-on-color" />
</button>
{shouldRenderFavorite && (
<FavoriteStar
buttonClassName="h-6 w-6 bg-white/10 rounded-sm"
iconClassName={cn("h-3 w-3", {
"text-on-color": !project.is_favorite,
})}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (project.is_favorite) handleRemoveFromFavorites();
else handleAddToFavorites();
}}
selected={!!project.is_favorite}
/>
)}
</div>
)}
</div>
</div>
<div
className={cn("flex h-[104px] w-full flex-col justify-between rounded-b-sm p-4", {
"opacity-90": isArchived,
})}
>
<p className="line-clamp-2 text-13 break-words text-tertiary">
{project.description && project.description.trim() !== ""
? project.description
: `Created on ${renderFormattedDate(project.created_at)}`}
</p>
<div className="item-center flex justify-between">
<div className="flex items-center justify-center gap-2">
<Tooltip
label={project.members?.length ? `Members: ${project.members.length}` : "No members"}
layout="stacked"
disabled={isMobile}
>
{projectMembersIds && projectMembersIds.length > 0 ? (
<div className="flex cursor-pointer items-center gap-2 text-secondary">
<AvatarGroupOverflow size="xs">
{projectMembersIds.map((memberId) => {
const member = getUserDetails(memberId);
if (!member) return null;
return (
<Avatar
key={member.id}
alt={member.display_name}
fallback={member.display_name?.[0]?.toUpperCase()}
src={getFileURL(member.avatar_url)}
/>
);
})}
</AvatarGroupOverflow>
</div>
) : (
<span className="text-13 text-placeholder italic">No Member Yet</span>
)}
</Tooltip>
{isArchived && <div className="text-11 font-medium text-placeholder">Archived</div>}
</div>
{isArchived ? (
hasAdminRole && (
<div className="flex items-center justify-center gap-2">
<div
className="flex items-center justify-center text-11 font-medium text-placeholder hover:text-secondary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setRestoreProject(true);
}}
>
<div className="flex items-center gap-1.5">
<RestoreOutline className="h-3.5 w-3.5" />
Restore
</div>
</div>
<div
className="flex items-center justify-center text-11 font-medium text-placeholder hover:text-secondary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDeleteProjectModal(true);
}}
>
<DeleteOutline className="h-3.5 w-3.5" />
</div>
</div>
)
) : (
<>
{isMemberOfProject &&
(hasAdminRole || hasMemberRole ? (
<Link
className="flex items-center justify-center rounded-sm p-1 text-placeholder hover:bg-layer-1 hover:text-secondary"
onClick={(e) => {
e.stopPropagation();
}}
href={`/${workspaceSlug}/settings/projects/${project.id}`}
>
<SettingsOutline className="h-3.5 w-3.5" />
</Link>
) : (
<span className="flex items-center gap-1 text-13 text-placeholder">
<TickOutline className="h-3.5 w-3.5" />
Joined
</span>
))}
{!isMemberOfProject && (
<div className="flex items-center">
<Button
variant="link"
className="!p-0 font-semibold"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setJoinProjectModal(true);
}}
>
Join
</Button>
</div>
)}
</>
)}
</div>
</div>
</Link>
</>
);
});