[WEB-3892] chore: link item improvements (#6944)

* chore: code refactor

* chore: global link block component added

* chore: link item improvement and code refactor
This commit is contained in:
Anmol Singh Bhatia
2025-04-17 20:08:53 +05:30
committed by GitHub
parent 18fb3b8450
commit be5d77d978
8 changed files with 210 additions and 212 deletions

View File

@@ -32,3 +32,4 @@ export * from "./tag";
export * from "./tabs"; export * from "./tabs";
export * from "./calendar"; export * from "./calendar";
export * from "./color-picker"; export * from "./color-picker";
export * from "./link";

View File

@@ -0,0 +1,68 @@
import React, { FC } from "react";
// plane utils
import { calculateTimeAgo, cn, getIconForLink } from "@plane/utils";
// plane ui
import { CustomMenu, TContextMenuItem } from "@plane/ui";
export type TLinkItemBlockProps = {
title: string;
url: string;
createdAt?: Date | string;
menuItems?: TContextMenuItem[];
onClick?: () => void;
};
export const LinkItemBlock: FC<TLinkItemBlockProps> = (props) => {
// props
const { title, url, createdAt, menuItems, onClick } = props;
// icons
const Icon = getIconForLink(url);
return (
<div
onClick={onClick}
className="cursor-pointer group flex items-center bg-custom-background-100 px-4 w-[230px] h-[56px] border-[0.5px] border-custom-border-200 rounded-md gap-4"
>
<div className="flex-shrink-0 size-8 rounded p-2 bg-custom-background-90 grid place-items-center">
<Icon className="size-4 stroke-2 text-custom-text-350 group-hover:text-custom-text-100" />
</div>
<div className="flex-1 truncate">
<div className="text-sm font-medium truncate">{title}</div>
{createdAt && <div className="text-xs font-medium text-custom-text-400">{calculateTimeAgo(createdAt)}</div>}
</div>
{menuItems && (
<div className="hidden group-hover:block">
<CustomMenu placement="bottom-end" menuItemsClassName="z-20" closeOnSelect verticalEllipsis>
{menuItems.map((item) => (
<CustomMenu.MenuItem
key={item.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action();
}}
className={cn("flex items-center gap-2 w-full ", {
"text-custom-text-400": item.disabled,
})}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1 @@
export * from "./block";

View File

@@ -19,91 +19,46 @@ import {
Link2, Link2,
} from "lucide-react"; } from "lucide-react";
type IconMatcher = {
pattern: RegExp;
icon: typeof Github;
};
// get-icon-for-link.ts const SOCIAL_MEDIA_MATCHERS: IconMatcher[] = [
{ pattern: /github\.com/, icon: Github },
{ pattern: /linkedin\.com/, icon: Linkedin },
{ pattern: /(twitter\.com|x\.com)/, icon: Twitter },
{ pattern: /facebook\.com/, icon: Facebook },
{ pattern: /instagram\.com/, icon: Instagram },
{ pattern: /youtube\.com/, icon: Youtube },
{ pattern: /dribbble\.com/, icon: Dribbble },
];
const PRODUCTIVITY_MATCHERS: IconMatcher[] = [
{ pattern: /figma\.com/, icon: Figma },
{ pattern: /(google\.com|docs\.|doc\.)/, icon: FileText },
];
const FILE_TYPE_MATCHERS: IconMatcher[] = [
{ pattern: /\.(jpg|jpeg|png|gif|bmp|svg|webp)$/, icon: FileImage },
{ pattern: /\.(mp4|mov|avi|wmv|flv|mkv)$/, icon: FileVideo },
{ pattern: /\.(mp3|wav|ogg)$/, icon: FileAudio },
{ pattern: /\.(zip|rar|7z|tar|gz)$/, icon: FileArchive },
{ pattern: /\.(xls|xlsx|csv)$/, icon: FileSpreadsheet },
{ pattern: /\.(pdf|doc|docx|txt)$/, icon: FileText },
{ pattern: /\.(html|js|ts|jsx|tsx|css|scss)$/, icon: FileCode },
];
const OTHER_MATCHERS: IconMatcher[] = [
{ pattern: /^mailto:/, icon: Mail },
{ pattern: /^http/, icon: Chrome },
];
export const getIconForLink = (url: string) => { export const getIconForLink = (url: string) => {
const lowerUrl = url.toLowerCase(); const lowerUrl = url.toLowerCase();
// Social Media const allMatchers = [...SOCIAL_MEDIA_MATCHERS, ...PRODUCTIVITY_MATCHERS, ...FILE_TYPE_MATCHERS, ...OTHER_MATCHERS];
if (lowerUrl.indexOf("github.com") !== -1) return Github;
if (lowerUrl.indexOf("linkedin.com") !== -1) return Linkedin;
if (lowerUrl.indexOf("twitter.com") !== -1 || lowerUrl.indexOf("x.com") !== -1) return Twitter;
if (lowerUrl.indexOf("facebook.com") !== -1) return Facebook;
if (lowerUrl.indexOf("instagram.com") !== -1) return Instagram;
if (lowerUrl.indexOf("youtube.com") !== -1 || lowerUrl.indexOf("youtu.be") !== -1) return Youtube;
if (lowerUrl.indexOf("dribbble.com") !== -1) return Dribbble;
// Productivity / Tools const matchedIcon = allMatchers.find(({ pattern }) => pattern.test(lowerUrl));
if (lowerUrl.indexOf("figma.com") !== -1) return Figma; return matchedIcon?.icon ?? Link2;
if (
lowerUrl.indexOf("google.com") !== -1 ||
lowerUrl.indexOf("docs.") !== -1 ||
lowerUrl.indexOf("doc.") !== -1
) return FileText;
// File types
if (
lowerUrl.indexOf(".jpg") !== -1 ||
lowerUrl.indexOf(".jpeg") !== -1 ||
lowerUrl.indexOf(".png") !== -1 ||
lowerUrl.indexOf(".gif") !== -1 ||
lowerUrl.indexOf(".bmp") !== -1 ||
lowerUrl.indexOf(".svg") !== -1 ||
lowerUrl.indexOf(".webp") !== -1
) return FileImage;
if (
lowerUrl.indexOf(".mp4") !== -1 ||
lowerUrl.indexOf(".mov") !== -1 ||
lowerUrl.indexOf(".avi") !== -1 ||
lowerUrl.indexOf(".wmv") !== -1 ||
lowerUrl.indexOf(".flv") !== -1 ||
lowerUrl.indexOf(".mkv") !== -1
) return FileVideo;
if (
lowerUrl.indexOf(".mp3") !== -1 ||
lowerUrl.indexOf(".wav") !== -1 ||
lowerUrl.indexOf(".ogg") !== -1
) return FileAudio;
if (
lowerUrl.indexOf(".zip") !== -1 ||
lowerUrl.indexOf(".rar") !== -1 ||
lowerUrl.indexOf(".7z") !== -1 ||
lowerUrl.indexOf(".tar") !== -1 ||
lowerUrl.indexOf(".gz") !== -1
) return FileArchive;
if (
lowerUrl.indexOf(".xls") !== -1 ||
lowerUrl.indexOf(".xlsx") !== -1 ||
lowerUrl.indexOf(".csv") !== -1
) return FileSpreadsheet;
if (
lowerUrl.indexOf(".pdf") !== -1 ||
lowerUrl.indexOf(".doc") !== -1 ||
lowerUrl.indexOf(".docx") !== -1 ||
lowerUrl.indexOf(".txt") !== -1
) return FileText;
if (
lowerUrl.indexOf(".html") !== -1 ||
lowerUrl.indexOf(".js") !== -1 ||
lowerUrl.indexOf(".ts") !== -1 ||
lowerUrl.indexOf(".jsx") !== -1 ||
lowerUrl.indexOf(".tsx") !== -1 ||
lowerUrl.indexOf(".css") !== -1 ||
lowerUrl.indexOf(".scss") !== -1
) return FileCode;
// Other
if (lowerUrl.indexOf("mailto:") !== -1) return Mail;
if (lowerUrl.indexOf("http") === 0) return Chrome;
return Link2;
}; };

View File

@@ -1,21 +1,12 @@
"use client"; "use client";
import { FC } from "react"; import { FC, useCallback, useMemo } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { import { Pencil, ExternalLink, Link, Trash2 } from "lucide-react";
Pencil,
ExternalLink,
Link,
Trash2
} from "lucide-react";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast, CustomMenu, TContextMenuItem } from "@plane/ui"; import { TOAST_TYPE, setToast, TContextMenuItem, LinkItemBlock } from "@plane/ui";
// plane utils // plane utils
import { cn, copyTextToClipboard,getIconForLink } from "@plane/utils"; import { copyTextToClipboard } from "@plane/utils";
// helpers
import { calculateTimeAgo } from "@/helpers/date-time.helper";
// hooks // hooks
import { useHome } from "@/hooks/store/use-home"; import { useHome } from "@/hooks/store/use-home";
// types // types
@@ -34,31 +25,41 @@ export const ProjectLinkDetail: FC<TProjectLinkDetail> = observer((props) => {
quickLinks: { getLinkById, toggleLinkModal, setLinkData }, quickLinks: { getLinkById, toggleLinkModal, setLinkData },
} = useHome(); } = useHome();
const { t } = useTranslation(); const { t } = useTranslation();
// derived values
const linkDetail = getLinkById(linkId); const linkDetail = getLinkById(linkId);
if (!linkDetail) return <></>;
const viewLink = linkDetail.url; if (!linkDetail) return null;
const Icon = getIconForLink(linkDetail.url); // handlers
const handleEdit = useCallback(
const handleEdit = (modalToggle: boolean) => { (modalToggle: boolean) => {
toggleLinkModal(modalToggle); toggleLinkModal(modalToggle);
setLinkData(linkDetail); setLinkData(linkDetail);
}; },
[linkDetail, setLinkData, toggleLinkModal]
);
const handleCopyText = () => const handleCopyText = useCallback(() => {
copyTextToClipboard(viewLink).then(() => { copyTextToClipboard(linkDetail.url).then(() => {
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: t("link_copied"), title: t("link_copied"),
message: t("view_link_copied_to_clipboard"), message: t("view_link_copied_to_clipboard"),
}); });
}); });
const handleOpenInNewTab = () => window.open(`${viewLink}`, "_blank"); }, [linkDetail.url, t]);
const handleOpenInNewTab = useCallback(() => {
window.open(linkDetail.url, "_blank", "noopener,noreferrer");
}, [linkDetail.url]);
const MENU_ITEMS: TContextMenuItem[] = [ const handleDelete = useCallback(() => {
linkOperations.remove(linkId);
}, [linkId, linkOperations]);
// derived values
const menuItems = useMemo<TContextMenuItem[]>(
() => [
{ {
key: "edit", key: "edit",
action: () => handleEdit(true), action: () => handleEdit(true),
@@ -79,56 +80,21 @@ export const ProjectLinkDetail: FC<TProjectLinkDetail> = observer((props) => {
}, },
{ {
key: "delete", key: "delete",
action: () => linkOperations.remove(linkId), action: handleDelete,
title: t("delete"), title: t("delete"),
icon: Trash2, icon: Trash2,
}, },
]; ],
[handleEdit, handleOpenInNewTab, handleCopyText, handleDelete, t]
);
return ( return (
<div <LinkItemBlock
title={linkDetail.title || linkDetail.url}
url={linkDetail.url}
createdAt={linkDetail.created_at}
menuItems={menuItems}
onClick={handleOpenInNewTab} onClick={handleOpenInNewTab}
className="cursor-pointer group flex items-center bg-custom-background-100 px-4 w-[230px] h-[56px] border-[0.5px] border-custom-border-200 rounded-md gap-4 hover:shadow-md transition-shadow" />
>
<div className="flex-shrink-0 size-8 rounded p-2 bg-custom-background-80 grid place-items-center">
<Icon className="size-4 stroke-2 text-custom-text-350" />
</div>
<div className="flex-1 truncate">
<div className="text-sm font-medium truncate">{linkDetail.title || linkDetail.url}</div>
<div className="text-xs font-medium text-custom-text-400">{calculateTimeAgo(linkDetail.created_at)}</div>
</div>
<div className="hidden group-hover:block">
<CustomMenu placement="bottom-end" menuItemsClassName="z-20" closeOnSelect verticalEllipsis>
{MENU_ITEMS.map((item) => (
<CustomMenu.MenuItem
key={item.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action();
}}
className={cn("flex items-center gap-2 w-full ", {
"text-custom-text-400": item.disabled,
})}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
</div>
</div>
); );
}); });

View File

@@ -3,8 +3,9 @@
import { FC } from "react"; import { FC } from "react";
// hooks // hooks
// ui // ui
import { Pencil, Trash2, LinkIcon, ExternalLink } from "lucide-react"; import { Pencil, Trash2, ExternalLink } from "lucide-react";
import { Tooltip, TOAST_TYPE, setToast } from "@plane/ui"; import { Tooltip, TOAST_TYPE, setToast } from "@plane/ui";
import { getIconForLink } from "@plane/utils";
// icons // icons
// types // types
// helpers // helpers
@@ -34,6 +35,8 @@ export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => {
const linkDetail = getLinkById(linkId); const linkDetail = getLinkById(linkId);
if (!linkDetail) return <></>; if (!linkDetail) return <></>;
const Icon = getIconForLink(linkDetail.url);
const toggleIssueLinkModal = (modalToggle: boolean) => { const toggleIssueLinkModal = (modalToggle: boolean) => {
toggleIssueLinkModalStore(modalToggle); toggleIssueLinkModalStore(modalToggle);
setIssueLinkData(linkDetail); setIssueLinkData(linkDetail);
@@ -57,7 +60,7 @@ export const IssueLinkDetail: FC<TIssueLinkDetail> = (props) => {
> >
<div className="flex items-start gap-2 truncate"> <div className="flex items-start gap-2 truncate">
<span className="py-1"> <span className="py-1">
<LinkIcon className="h-3 w-3 flex-shrink-0" /> <Icon className="size-3 stroke-2 text-custom-text-350 group-hover:text-custom-text-100 flex-shrink-0" />
</span> </span>
<Tooltip <Tooltip
tooltipContent={linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url} tooltipContent={linkDetail.title && linkDetail.title !== "" ? linkDetail.title : linkDetail.url}

View File

@@ -2,14 +2,14 @@
import { FC } from "react"; import { FC } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Pencil, Trash2, LinkIcon, Copy } from "lucide-react"; import { Pencil, Trash2, Copy } from "lucide-react";
import { EIssueServiceType } from "@plane/constants"; import { EIssueServiceType } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { TIssueServiceType } from "@plane/types"; import { TIssueServiceType } from "@plane/types";
// ui // ui
import { Tooltip, TOAST_TYPE, setToast, CustomMenu } from "@plane/ui"; import { Tooltip, TOAST_TYPE, setToast, CustomMenu } from "@plane/ui";
import { calculateTimeAgo, getIconForLink } from "@plane/utils";
// helpers // helpers
import { calculateTimeAgoShort } from "@/helpers/date-time.helper";
import { copyTextToClipboard } from "@/helpers/string.helper"; import { copyTextToClipboard } from "@/helpers/string.helper";
// hooks // hooks
import { useIssueDetail } from "@/hooks/store"; import { useIssueDetail } from "@/hooks/store";
@@ -37,6 +37,8 @@ export const IssueLinkItem: FC<TIssueLinkItem> = observer((props) => {
const linkDetail = getLinkById(linkId); const linkDetail = getLinkById(linkId);
if (!linkDetail) return <></>; if (!linkDetail) return <></>;
const Icon = getIconForLink(linkDetail.url);
const toggleIssueLinkModal = (modalToggle: boolean) => { const toggleIssueLinkModal = (modalToggle: boolean) => {
toggleIssueLinkModalStore(modalToggle); toggleIssueLinkModalStore(modalToggle);
setIssueLinkData(linkDetail); setIssueLinkData(linkDetail);
@@ -48,7 +50,7 @@ export const IssueLinkItem: FC<TIssueLinkItem> = observer((props) => {
className="group col-span-12 lg:col-span-6 xl:col-span-4 2xl:col-span-3 3xl:col-span-2 flex items-center justify-between gap-3 h-10 flex-shrink-0 px-3 bg-custom-background-90 hover:bg-custom-background-80 border-[0.5px] border-custom-border-200 rounded" className="group col-span-12 lg:col-span-6 xl:col-span-4 2xl:col-span-3 3xl:col-span-2 flex items-center justify-between gap-3 h-10 flex-shrink-0 px-3 bg-custom-background-90 hover:bg-custom-background-80 border-[0.5px] border-custom-border-200 rounded"
> >
<div className="flex items-center gap-2.5 truncate flex-grow"> <div className="flex items-center gap-2.5 truncate flex-grow">
<LinkIcon className="size-4 flex-shrink-0 text-custom-text-400 group-hover:text-custom-text-200" /> <Icon className="size-4 flex-shrink-0 stroke-2 text-custom-text-350 group-hover:text-custom-text-100" />
<Tooltip tooltipContent={linkDetail.url} isMobile={isMobile}> <Tooltip tooltipContent={linkDetail.url} isMobile={isMobile}>
<a <a
href={linkDetail.url} href={linkDetail.url}
@@ -62,7 +64,7 @@ export const IssueLinkItem: FC<TIssueLinkItem> = observer((props) => {
</div> </div>
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-1 flex-shrink-0">
<p className="p-1 text-xs align-bottom leading-5 text-custom-text-400 group-hover-text-custom-text-200"> <p className="p-1 text-xs align-bottom leading-5 text-custom-text-400 group-hover-text-custom-text-200">
{calculateTimeAgoShort(linkDetail.created_at)} {calculateTimeAgo(linkDetail.created_at)}
</p> </p>
<span <span
onClick={() => { onClick={() => {

View File

@@ -1,9 +1,10 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Copy, LinkIcon, Pencil, Trash2 } from "lucide-react"; import { Copy, Pencil, Trash2 } from "lucide-react";
// plane types // plane types
import { ILinkDetails } from "@plane/types"; import { ILinkDetails } from "@plane/types";
// plane ui // plane ui
import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui"; import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
import { getIconForLink } from "@plane/utils";
// helpers // helpers
import { calculateTimeAgo } from "@/helpers/date-time.helper"; import { calculateTimeAgo } from "@/helpers/date-time.helper";
import { copyTextToClipboard } from "@/helpers/string.helper"; import { copyTextToClipboard } from "@/helpers/string.helper";
@@ -27,6 +28,8 @@ export const ModulesLinksListItem: React.FC<Props> = observer((props) => {
// platform os // platform os
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const Icon = getIconForLink(link.url);
const copyToClipboard = (text: string) => { const copyToClipboard = (text: string) => {
copyTextToClipboard(text).then(() => copyTextToClipboard(text).then(() =>
setToast({ setToast({
@@ -42,7 +45,7 @@ export const ModulesLinksListItem: React.FC<Props> = observer((props) => {
<div className="flex w-full items-start justify-between gap-2"> <div className="flex w-full items-start justify-between gap-2">
<div className="flex items-start gap-2 truncate"> <div className="flex items-start gap-2 truncate">
<span className="py-1"> <span className="py-1">
<LinkIcon className="h-3 w-3 flex-shrink-0" /> <Icon className="size-3 stroke-2 text-custom-text-350 group-hover:text-custom-text-100 flex-shrink-0" />
</span> </span>
<Tooltip tooltipContent={link.title && link.title !== "" ? link.title : link.url} isMobile={isMobile}> <Tooltip tooltipContent={link.title && link.title !== "" ? link.title : link.url} isMobile={isMobile}>
<a href={link.url} target="_blank" rel="noopener noreferrer" className="cursor-pointer truncate text-xs"> <a href={link.url} target="_blank" rel="noopener noreferrer" className="cursor-pointer truncate text-xs">
@@ -87,9 +90,8 @@ export const ModulesLinksListItem: React.FC<Props> = observer((props) => {
</div> </div>
</div> </div>
<div className="px-5"> <div className="px-5">
<p className="mt-0.5 stroke-[1.5] text-xs text-custom-text-300"> <p className="flex items-center gap-1.5 mt-0.5 stroke-[1.5] text-xs text-custom-text-300">
Added {calculateTimeAgo(link.created_at)} Added {calculateTimeAgo(link.created_at)}{" "}
<br />
{createdByDetails && ( {createdByDetails && (
<>by {createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name}</> <>by {createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name}</>
)} )}