diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 40d885204..29bf6f248 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -32,3 +32,4 @@ export * from "./tag"; export * from "./tabs"; export * from "./calendar"; export * from "./color-picker"; +export * from "./link"; diff --git a/packages/ui/src/link/block.tsx b/packages/ui/src/link/block.tsx new file mode 100644 index 000000000..a6ca71d7d --- /dev/null +++ b/packages/ui/src/link/block.tsx @@ -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 = (props) => { + // props + const { title, url, createdAt, menuItems, onClick } = props; + // icons + const Icon = getIconForLink(url); + return ( +
+
+ +
+
+
{title}
+ {createdAt &&
{calculateTimeAgo(createdAt)}
} +
+ {menuItems && ( +
+ + {menuItems.map((item) => ( + { + 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.title}
+ {item.description && ( +

+ {item.description} +

+ )} +
+
+ ))} +
+
+ )} +
+ ); +}; diff --git a/packages/ui/src/link/index.ts b/packages/ui/src/link/index.ts new file mode 100644 index 000000000..086dec913 --- /dev/null +++ b/packages/ui/src/link/index.ts @@ -0,0 +1 @@ +export * from "./block"; diff --git a/packages/utils/src/get-icon-for-link.ts b/packages/utils/src/get-icon-for-link.ts index d0f3d9bc2..0c703a81c 100644 --- a/packages/utils/src/get-icon-for-link.ts +++ b/packages/utils/src/get-icon-for-link.ts @@ -1,109 +1,64 @@ import { - Github, - Linkedin, - Twitter, - Facebook, - Instagram, - Youtube, - Dribbble, - Figma, - FileText, - FileImage, - FileVideo, - FileAudio, - FileArchive, - FileSpreadsheet, - FileCode, - Mail, - Chrome, - Link2, - } from "lucide-react"; + Github, + Linkedin, + Twitter, + Facebook, + Instagram, + Youtube, + Dribbble, + Figma, + FileText, + FileImage, + FileVideo, + FileAudio, + FileArchive, + FileSpreadsheet, + FileCode, + Mail, + Chrome, + Link2, +} from "lucide-react"; - - // get-icon-for-link.ts +type IconMatcher = { + pattern: RegExp; + icon: typeof Github; +}; - export const getIconForLink = (url: string) => { - const lowerUrl = url.toLowerCase(); - - // Social Media - 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 - if (lowerUrl.indexOf("figma.com") !== -1) return Figma; - - 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; - }; - \ No newline at end of file +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) => { + const lowerUrl = url.toLowerCase(); + + const allMatchers = [...SOCIAL_MEDIA_MATCHERS, ...PRODUCTIVITY_MATCHERS, ...FILE_TYPE_MATCHERS, ...OTHER_MATCHERS]; + + const matchedIcon = allMatchers.find(({ pattern }) => pattern.test(lowerUrl)); + return matchedIcon?.icon ?? Link2; +}; diff --git a/web/core/components/home/widgets/links/link-detail.tsx b/web/core/components/home/widgets/links/link-detail.tsx index 1b4953d07..3461dcc87 100644 --- a/web/core/components/home/widgets/links/link-detail.tsx +++ b/web/core/components/home/widgets/links/link-detail.tsx @@ -1,21 +1,12 @@ "use client"; -import { FC } from "react"; +import { FC, useCallback, useMemo } from "react"; import { observer } from "mobx-react"; -import { - Pencil, - ExternalLink, - Link, - Trash2 -} from "lucide-react"; +import { Pencil, ExternalLink, Link, Trash2 } from "lucide-react"; 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 -import { cn, copyTextToClipboard,getIconForLink } from "@plane/utils"; - - -// helpers -import { calculateTimeAgo } from "@/helpers/date-time.helper"; +import { copyTextToClipboard } from "@plane/utils"; // hooks import { useHome } from "@/hooks/store/use-home"; // types @@ -34,101 +25,76 @@ export const ProjectLinkDetail: FC = observer((props) => { quickLinks: { getLinkById, toggleLinkModal, setLinkData }, } = useHome(); const { t } = useTranslation(); - + // derived values const linkDetail = getLinkById(linkId); - if (!linkDetail) return <>; - const viewLink = linkDetail.url; - - const Icon = getIconForLink(linkDetail.url); + if (!linkDetail) return null; - const handleEdit = (modalToggle: boolean) => { - toggleLinkModal(modalToggle); - setLinkData(linkDetail); - }; + // handlers + const handleEdit = useCallback( + (modalToggle: boolean) => { + toggleLinkModal(modalToggle); + setLinkData(linkDetail); + }, + [linkDetail, setLinkData, toggleLinkModal] + ); - const handleCopyText = () => - copyTextToClipboard(viewLink).then(() => { + const handleCopyText = useCallback(() => { + copyTextToClipboard(linkDetail.url).then(() => { setToast({ type: TOAST_TYPE.SUCCESS, title: t("link_copied"), message: t("view_link_copied_to_clipboard"), }); }); - const handleOpenInNewTab = () => window.open(`${viewLink}`, "_blank"); - + }, [linkDetail.url, t]); - const MENU_ITEMS: TContextMenuItem[] = [ - { - key: "edit", - action: () => handleEdit(true), - title: t("edit"), - icon: Pencil, - }, - { - key: "open-new-tab", - action: handleOpenInNewTab, - title: t("open_in_new_tab"), - icon: ExternalLink, - }, - { - key: "copy-link", - action: handleCopyText, - title: t("copy_link"), - icon: Link, - }, - { - key: "delete", - action: () => linkOperations.remove(linkId), - title: t("delete"), - icon: Trash2, - }, - ]; + const handleOpenInNewTab = useCallback(() => { + window.open(linkDetail.url, "_blank", "noopener,noreferrer"); + }, [linkDetail.url]); + + const handleDelete = useCallback(() => { + linkOperations.remove(linkId); + }, [linkId, linkOperations]); + + // derived values + const menuItems = useMemo( + () => [ + { + key: "edit", + action: () => handleEdit(true), + title: t("edit"), + icon: Pencil, + }, + { + key: "open-new-tab", + action: handleOpenInNewTab, + title: t("open_in_new_tab"), + icon: ExternalLink, + }, + { + key: "copy-link", + action: handleCopyText, + title: t("copy_link"), + icon: Link, + }, + { + key: "delete", + action: handleDelete, + title: t("delete"), + icon: Trash2, + }, + ], + [handleEdit, handleOpenInNewTab, handleCopyText, handleDelete, t] + ); return ( -
-
- -
-
-
{linkDetail.title || linkDetail.url}
-
{calculateTimeAgo(linkDetail.created_at)}
-
-
- - {MENU_ITEMS.map((item) => ( - { - 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.title}
- {item.description && ( -

- {item.description} -

- )} -
-
- ))} -
-
-
+ /> ); }); diff --git a/web/core/components/issues/issue-detail/links/link-detail.tsx b/web/core/components/issues/issue-detail/links/link-detail.tsx index 286e501b7..673874bb1 100644 --- a/web/core/components/issues/issue-detail/links/link-detail.tsx +++ b/web/core/components/issues/issue-detail/links/link-detail.tsx @@ -3,8 +3,9 @@ import { FC } from "react"; // hooks // 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 { getIconForLink } from "@plane/utils"; // icons // types // helpers @@ -34,6 +35,8 @@ export const IssueLinkDetail: FC = (props) => { const linkDetail = getLinkById(linkId); if (!linkDetail) return <>; + const Icon = getIconForLink(linkDetail.url); + const toggleIssueLinkModal = (modalToggle: boolean) => { toggleIssueLinkModalStore(modalToggle); setIssueLinkData(linkDetail); @@ -57,7 +60,7 @@ export const IssueLinkDetail: FC = (props) => { >
- + = observer((props) => { const linkDetail = getLinkById(linkId); if (!linkDetail) return <>; + const Icon = getIconForLink(linkDetail.url); + const toggleIssueLinkModal = (modalToggle: boolean) => { toggleIssueLinkModalStore(modalToggle); setIssueLinkData(linkDetail); @@ -48,7 +50,7 @@ export const IssueLinkItem: FC = 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" >

- {calculateTimeAgoShort(linkDetail.created_at)} + {calculateTimeAgo(linkDetail.created_at)}

{ diff --git a/web/core/components/modules/links/list-item.tsx b/web/core/components/modules/links/list-item.tsx index 9f36e0f96..e816525c9 100644 --- a/web/core/components/modules/links/list-item.tsx +++ b/web/core/components/modules/links/list-item.tsx @@ -1,9 +1,10 @@ import { observer } from "mobx-react"; -import { Copy, LinkIcon, Pencil, Trash2 } from "lucide-react"; +import { Copy, Pencil, Trash2 } from "lucide-react"; // plane types import { ILinkDetails } from "@plane/types"; // plane ui import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui"; +import { getIconForLink } from "@plane/utils"; // helpers import { calculateTimeAgo } from "@/helpers/date-time.helper"; import { copyTextToClipboard } from "@/helpers/string.helper"; @@ -27,6 +28,8 @@ export const ModulesLinksListItem: React.FC = observer((props) => { // platform os const { isMobile } = usePlatformOS(); + const Icon = getIconForLink(link.url); + const copyToClipboard = (text: string) => { copyTextToClipboard(text).then(() => setToast({ @@ -42,7 +45,7 @@ export const ModulesLinksListItem: React.FC = observer((props) => {
-

- Added {calculateTimeAgo(link.created_at)} -
+

+ Added {calculateTimeAgo(link.created_at)}{" "} {createdByDetails && ( <>by {createdByDetails?.is_bot ? createdByDetails?.first_name + " Bot" : createdByDetails?.display_name} )}