feat: init
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
export * from "./links";
|
||||
export * from "./no-projects";
|
||||
export * from "./recents";
|
||||
export * from "./stickies";
|
||||
14
apps/web/core/components/home/widgets/empty-states/links.tsx
Normal file
14
apps/web/core/components/home/widgets/empty-states/links.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Link2 } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
|
||||
export const LinksEmptyState = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="min-h-[110px] flex w-full justify-center py-6 bg-custom-border-100 rounded">
|
||||
<div className="m-auto flex gap-2">
|
||||
<Link2 size={30} className="text-custom-text-400/40 -rotate-45" />
|
||||
<div className="text-custom-text-400 text-sm text-center my-auto">{t("home.quick_links.empty")}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,209 @@
|
||||
import React from "react";
|
||||
// mobx
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Check, Hotel, Users, X } from "lucide-react";
|
||||
// plane ui
|
||||
import { EUserPermissions, EUserPermissionsLevel, PROJECT_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useLocalStorage } from "@plane/hooks";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ProjectIcon } from "@plane/propel/icons";
|
||||
import { cn, getFileURL } from "@plane/utils";
|
||||
// helpers
|
||||
// hooks
|
||||
import { captureClick } from "@/helpers/event-tracker.helper";
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web constants
|
||||
|
||||
export const NoProjectsEmptyState = observer(() => {
|
||||
// navigation
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
const { data: currentUser } = useUser();
|
||||
const { joinedProjectIds } = useProject();
|
||||
const { currentWorkspace: activeWorkspace } = useWorkspace();
|
||||
// local storage
|
||||
const { storedValue, setValue } = useLocalStorage(`quickstart-guide-${workspaceSlug}`, {
|
||||
hide: false,
|
||||
visited_members: false,
|
||||
visited_workspace: false,
|
||||
visited_profile: false,
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const canCreateProject = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
|
||||
const EMPTY_STATE_DATA = [
|
||||
{
|
||||
id: "create-project",
|
||||
title: "home.empty.create_project.title",
|
||||
description: "home.empty.create_project.description",
|
||||
icon: <ProjectIcon className="size-4" />,
|
||||
flag: "projects",
|
||||
cta: {
|
||||
text: "home.empty.create_project.cta",
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
if (!canCreateProject) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleCreateProjectModal(true);
|
||||
captureClick({ elementName: PROJECT_TRACKER_ELEMENTS.EMPTY_STATE_CREATE_PROJECT_BUTTON });
|
||||
},
|
||||
disabled: !canCreateProject,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "invite-team",
|
||||
title: "home.empty.invite_team.title",
|
||||
description: "home.empty.invite_team.description",
|
||||
icon: <Users className="size-4" />,
|
||||
flag: "visited_members",
|
||||
cta: {
|
||||
text: "home.empty.invite_team.cta",
|
||||
link: `/${workspaceSlug}/settings/members`,
|
||||
disabled: !isWorkspaceAdmin,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "configure-workspace",
|
||||
title: "home.empty.configure_workspace.title",
|
||||
description: "home.empty.configure_workspace.description",
|
||||
icon: <Hotel className="size-4" />,
|
||||
flag: "visited_workspace",
|
||||
cta: {
|
||||
text: "home.empty.configure_workspace.cta",
|
||||
link: "settings",
|
||||
disabled: !isWorkspaceAdmin,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "personalize-account",
|
||||
title: "home.empty.personalize_account.title",
|
||||
description: "home.empty.personalize_account.description",
|
||||
icon:
|
||||
currentUser?.avatar_url && currentUser?.avatar_url.trim() !== "" ? (
|
||||
<Link href={`/${workspaceSlug}/profile/${currentUser?.id}`}>
|
||||
<span className="relative flex size-4 items-center justify-center rounded-full p-4 capitalize text-white">
|
||||
<img
|
||||
src={getFileURL(currentUser?.avatar_url)}
|
||||
className="absolute left-0 top-0 h-full w-full rounded-full object-cover"
|
||||
alt={currentUser?.display_name || currentUser?.email}
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href={`/${workspaceSlug}/profile/${currentUser?.id}`}>
|
||||
<span className="relative flex size-4 items-center justify-center rounded-full bg-gray-700 p-4 capitalize text-white text-sm">
|
||||
{(currentUser?.email ?? currentUser?.display_name ?? "?")[0]}
|
||||
</span>
|
||||
</Link>
|
||||
),
|
||||
flag: "visited_profile",
|
||||
cta: {
|
||||
text: "home.empty.personalize_account.cta",
|
||||
link: `/${workspaceSlug}/settings/account`,
|
||||
disabled: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
const isComplete = (type: string) => {
|
||||
switch (type) {
|
||||
case "projects":
|
||||
return joinedProjectIds?.length > 0;
|
||||
case "visited_members":
|
||||
return (activeWorkspace?.total_members || 0) >= 2;
|
||||
case "visited_workspace":
|
||||
return storedValue?.visited_workspace;
|
||||
case "visited_profile":
|
||||
return storedValue?.visited_profile;
|
||||
}
|
||||
};
|
||||
|
||||
if (storedValue?.hide || (joinedProjectIds?.length > 0 && (activeWorkspace?.total_members || 0) >= 2)) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-base font-semibold text-custom-text-350">{t("home.empty.quickstart_guide")}</div>
|
||||
<button
|
||||
className="text-custom-text-300 font-medium text-sm flex items-center gap-1"
|
||||
onClick={() => {
|
||||
if (!storedValue) return;
|
||||
setValue({ ...storedValue, hide: true });
|
||||
}}
|
||||
>
|
||||
<X className="size-4" />
|
||||
{t("home.empty.not_right_now")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{EMPTY_STATE_DATA.map((item) => {
|
||||
const isStateComplete = isComplete(item.flag);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-col p-4 bg-custom-background-100 rounded-xl border border-custom-border-200/40"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"grid place-items-center bg-custom-background-90 rounded-full size-9 mb-3 text-custom-text-400",
|
||||
{
|
||||
"text-custom-primary-100 bg-custom-primary-100/10": !isStateComplete,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span className="text-3xl my-auto">{item.icon}</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-custom-text-100 mb-2">{t(item.title)}</h3>
|
||||
<p className="text-[11px] text-custom-text-300 mb-2">{t(item.description)}</p>
|
||||
{isStateComplete ? (
|
||||
<div className="flex items-center gap-2 bg-[#17a34a] rounded-full p-1 w-fit">
|
||||
<Check className="size-3 text-custom-primary-100 text-white" />
|
||||
</div>
|
||||
) : (
|
||||
!item.cta.disabled &&
|
||||
(item.cta.link ? (
|
||||
<Link
|
||||
href={item.cta.link}
|
||||
onClick={(e) => {
|
||||
if (!storedValue) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
setValue({
|
||||
...storedValue,
|
||||
[item.flag]: true,
|
||||
});
|
||||
}}
|
||||
className={cn("text-custom-primary-100 hover:text-custom-primary-200 text-sm font-medium", {})}
|
||||
>
|
||||
{t(item.cta.text)}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="text-custom-primary-100 hover:text-custom-primary-200 text-sm font-medium"
|
||||
onClick={item.cta.onClick}
|
||||
>
|
||||
{t(item.cta.text)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { History } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PageIcon, ProjectIcon, WorkItemsIcon } from "@plane/propel/icons";
|
||||
|
||||
const getDisplayContent = (type: string) => {
|
||||
switch (type) {
|
||||
case "project":
|
||||
return {
|
||||
icon: <ProjectIcon height={30} width={30} className="text-custom-text-400/40" />,
|
||||
text: "home.recents.empty.project",
|
||||
};
|
||||
case "page":
|
||||
return {
|
||||
icon: <PageIcon height={30} width={30} className="text-custom-text-400/40" />,
|
||||
text: "home.recents.empty.page",
|
||||
};
|
||||
case "issue":
|
||||
return {
|
||||
icon: <WorkItemsIcon className="text-custom-text-400/40 w-[30px] h-[30px]" />,
|
||||
text: "home.recents.empty.issue",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <History height={30} width={30} className="text-custom-text-400/40" />,
|
||||
text: "home.recents.empty.default",
|
||||
};
|
||||
}
|
||||
};
|
||||
export const RecentsEmptyState = ({ type }: { type: string }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { icon, text } = getDisplayContent(type);
|
||||
|
||||
return (
|
||||
<div className="min-h-[120px] flex w-full justify-center py-6 bg-custom-border-100 rounded">
|
||||
<div className="m-auto flex gap-2">
|
||||
{icon} <div className="text-custom-text-400 text-sm text-center my-auto">{t(text)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
// plane ui
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { RecentStickyIcon } from "@plane/propel/icons";
|
||||
|
||||
export const StickiesEmptyState = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="min-h-[110px] flex w-full justify-center py-6 bg-custom-border-100 rounded">
|
||||
<div className="m-auto flex gap-2">
|
||||
<RecentStickyIcon className="h-[30px] w-[30px] text-custom-text-400/40" />
|
||||
<div className="text-custom-text-400 text-sm text-center my-auto">{t("stickies.empty_state.simple")}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
4
apps/web/core/components/home/widgets/index.ts
Normal file
4
apps/web/core/components/home/widgets/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./empty-states";
|
||||
export * from "./loaders";
|
||||
export * from "./recents";
|
||||
export * from "./empty-states";
|
||||
22
apps/web/core/components/home/widgets/links/action.tsx
Normal file
22
apps/web/core/components/home/widgets/links/action.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
|
||||
type TProps = {
|
||||
onClick: () => void;
|
||||
};
|
||||
export const AddLink = (props: TProps) => {
|
||||
const { onClick } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<button
|
||||
className="btn btn-primary flex bg-custom-background-100 px-4 w-[230px] h-[56px] border-[0.5px] border-custom-border-200 rounded-md gap-4"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="rounded p-2 bg-custom-background-80/40 w-8 h-8 my-auto">
|
||||
<PlusIcon className="h-4 w-4 stroke-2 text-custom-text-350" />
|
||||
</div>
|
||||
<div className="text-sm font-medium my-auto">{t("home.quick_links.add")}</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// plane types
|
||||
// plane ui
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import type { TLinkEditableFields } from "@plane/types";
|
||||
import { TLink } from "@plane/types";
|
||||
import { Input, ModalCore } from "@plane/ui";
|
||||
import type { TLinkOperations } from "./use-links";
|
||||
|
||||
export type TLinkOperationsModal = Exclude<TLinkOperations, "remove">;
|
||||
|
||||
export type TLinkCreateFormFieldOptions = TLinkEditableFields & {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type TLinkCreateEditModal = {
|
||||
isModalOpen: boolean;
|
||||
handleOnClose?: () => void;
|
||||
linkOperations: TLinkOperationsModal;
|
||||
preloadedData?: TLinkCreateFormFieldOptions;
|
||||
};
|
||||
|
||||
const defaultValues: TLinkCreateFormFieldOptions = {
|
||||
title: "",
|
||||
url: "",
|
||||
};
|
||||
|
||||
export const LinkCreateUpdateModal: FC<TLinkCreateEditModal> = observer((props) => {
|
||||
// props
|
||||
const { isModalOpen, handleOnClose, linkOperations, preloadedData } = props;
|
||||
// react hook form
|
||||
const {
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
} = useForm<TLinkCreateFormFieldOptions>({
|
||||
defaultValues,
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onClose = () => {
|
||||
if (handleOnClose) handleOnClose();
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: TLinkCreateFormFieldOptions) => {
|
||||
const parsedUrl = formData.url.startsWith("http") ? formData.url : `http://${formData.url}`;
|
||||
try {
|
||||
if (!formData || !formData.id) await linkOperations.create({ title: formData.title, url: parsedUrl });
|
||||
else await linkOperations.update(formData.id, { title: formData.title, url: parsedUrl });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("error", error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpen) reset({ ...defaultValues, ...preloadedData });
|
||||
return () => reset(defaultValues);
|
||||
}, [preloadedData, reset, isModalOpen]);
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isModalOpen} handleClose={onClose}>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="space-y-5 p-5">
|
||||
<h3 className="text-xl font-medium text-custom-text-200">
|
||||
{preloadedData?.id ? t("update") : t("add")} {t("home.quick_links.title")}
|
||||
</h3>
|
||||
<div className="mt-2 space-y-3">
|
||||
<div>
|
||||
<label htmlFor="url" className="mb-2 text-custom-text-200 text-base font-medium">
|
||||
{t("link.modal.url.text")}
|
||||
<span className="text-[10px] block">{t("required")}</span>
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="url"
|
||||
rules={{
|
||||
required: t("link.modal.url.required"),
|
||||
}}
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="url"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.url)}
|
||||
placeholder={t("link.modal.url.placeholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.url && <span className="text-xs text-red-500">{t("link.modal.url.required")}</span>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="title" className="mb-2 text-custom-text-200 text-base font-medium">
|
||||
{t("link.modal.title.text")}
|
||||
<span className="text-[10px] block">{t("optional")}</span>
|
||||
</label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="title"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="title"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.title)}
|
||||
placeholder={t("link.modal.title.placeholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
|
||||
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{preloadedData?.id ? (isSubmitting ? t("updating") : t("update")) : isSubmitting ? t("adding") : t("add")}{" "}
|
||||
{t("home.quick_links.title")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
3
apps/web/core/components/home/widgets/links/index.ts
Normal file
3
apps/web/core/components/home/widgets/links/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./root";
|
||||
export * from "./links";
|
||||
export * from "./link-detail";
|
||||
103
apps/web/core/components/home/widgets/links/link-detail.tsx
Normal file
103
apps/web/core/components/home/widgets/links/link-detail.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Pencil, ExternalLink, Link, Trash2 } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TContextMenuItem } from "@plane/ui";
|
||||
import { LinkItemBlock } from "@plane/ui";
|
||||
// plane utils
|
||||
import { copyTextToClipboard } from "@plane/utils";
|
||||
// hooks
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
// types
|
||||
import type { TLinkOperations } from "./use-links";
|
||||
|
||||
export type TProjectLinkDetail = {
|
||||
linkId: string;
|
||||
linkOperations: TLinkOperations;
|
||||
};
|
||||
|
||||
export const ProjectLinkDetail: FC<TProjectLinkDetail> = observer((props) => {
|
||||
// props
|
||||
const { linkId, linkOperations } = props;
|
||||
// hooks
|
||||
const {
|
||||
quickLinks: { getLinkById, toggleLinkModal, setLinkData },
|
||||
} = useHome();
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const linkDetail = getLinkById(linkId);
|
||||
|
||||
if (!linkDetail) return null;
|
||||
|
||||
// handlers
|
||||
const handleEdit = useCallback(
|
||||
(modalToggle: boolean) => {
|
||||
toggleLinkModal(modalToggle);
|
||||
setLinkData(linkDetail);
|
||||
},
|
||||
[linkDetail, setLinkData, toggleLinkModal]
|
||||
);
|
||||
|
||||
const handleCopyText = useCallback(() => {
|
||||
copyTextToClipboard(linkDetail.url).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("link_copied"),
|
||||
message: t("view_link_copied_to_clipboard"),
|
||||
});
|
||||
});
|
||||
}, [linkDetail.url, t]);
|
||||
|
||||
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<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: handleDelete,
|
||||
title: t("delete"),
|
||||
icon: Trash2,
|
||||
},
|
||||
],
|
||||
[handleEdit, handleOpenInNewTab, handleCopyText, handleDelete, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<LinkItemBlock
|
||||
title={linkDetail.title || linkDetail.url}
|
||||
url={linkDetail.url}
|
||||
createdAt={linkDetail.created_at}
|
||||
menuItems={menuItems}
|
||||
onClick={handleOpenInNewTab}
|
||||
/>
|
||||
);
|
||||
});
|
||||
48
apps/web/core/components/home/widgets/links/links.tsx
Normal file
48
apps/web/core/components/home/widgets/links/links.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// computed
|
||||
import { ContentOverflowWrapper } from "@/components/core/content-overflow-HOC";
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
import { LinksEmptyState } from "../empty-states/links";
|
||||
import { EWidgetKeys, WidgetLoader } from "../loaders";
|
||||
import { ProjectLinkDetail } from "./link-detail";
|
||||
import type { TLinkOperations } from "./use-links";
|
||||
|
||||
export type TLinkOperationsModal = Exclude<TLinkOperations, "create">;
|
||||
|
||||
export type TProjectLinkList = {
|
||||
linkOperations: TLinkOperationsModal;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const ProjectLinkList: FC<TProjectLinkList> = observer((props) => {
|
||||
// props
|
||||
const { linkOperations, workspaceSlug } = props;
|
||||
// hooks
|
||||
const {
|
||||
quickLinks: { getLinksByWorkspaceId },
|
||||
} = useHome();
|
||||
|
||||
const links = getLinksByWorkspaceId(workspaceSlug);
|
||||
|
||||
if (links === undefined) return <WidgetLoader widgetKey={EWidgetKeys.QUICK_LINKS} />;
|
||||
|
||||
if (links.length === 0) return <LinksEmptyState />;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<ContentOverflowWrapper
|
||||
maxHeight={150}
|
||||
containerClassName="box-border min-h-[30px] flex flex-col"
|
||||
fallback={<></>}
|
||||
buttonClassName="bg-custom-background-90/20"
|
||||
>
|
||||
<div className="flex gap-2 mb-2 flex-wrap flex-1">
|
||||
{links.map((linkId) => (
|
||||
<ProjectLinkDetail key={linkId} linkId={linkId} linkOperations={linkOperations} />
|
||||
))}
|
||||
</div>
|
||||
</ContentOverflowWrapper>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
59
apps/web/core/components/home/widgets/links/root.tsx
Normal file
59
apps/web/core/components/home/widgets/links/root.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { THomeWidgetProps } from "@plane/types";
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
import { LinkCreateUpdateModal } from "./create-update-link-modal";
|
||||
import { ProjectLinkList } from "./links";
|
||||
import { useLinks } from "./use-links";
|
||||
|
||||
export const DashboardQuickLinks = observer((props: THomeWidgetProps) => {
|
||||
const { workspaceSlug } = props;
|
||||
const { linkOperations } = useLinks(workspaceSlug);
|
||||
const {
|
||||
quickLinks: { isLinkModalOpen, toggleLinkModal, linkData, setLinkData, fetchLinks },
|
||||
} = useHome();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleCreateLinkModal = useCallback(() => {
|
||||
toggleLinkModal(true);
|
||||
setLinkData(undefined);
|
||||
}, []);
|
||||
|
||||
useSWR(
|
||||
workspaceSlug ? `HOME_LINKS_${workspaceSlug}` : null,
|
||||
workspaceSlug ? () => fetchLinks(workspaceSlug.toString()) : null,
|
||||
{
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
}
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<LinkCreateUpdateModal
|
||||
isModalOpen={isLinkModalOpen}
|
||||
handleOnClose={() => toggleLinkModal(false)}
|
||||
linkOperations={linkOperations}
|
||||
preloadedData={linkData}
|
||||
/>
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-base font-semibold text-custom-text-350">{t("home.quick_links.title_plural")}</div>
|
||||
<button
|
||||
onClick={handleCreateLinkModal}
|
||||
className="flex gap-1 text-sm font-medium text-custom-primary-100 my-auto"
|
||||
>
|
||||
<Plus className="size-4 my-auto" /> <span>{t("home.quick_links.add")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap w-full">
|
||||
{/* rendering links */}
|
||||
<ProjectLinkList workspaceSlug={workspaceSlug} linkOperations={linkOperations} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
99
apps/web/core/components/home/widgets/links/use-links.tsx
Normal file
99
apps/web/core/components/home/widgets/links/use-links.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TProjectLink } from "@plane/types";
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
|
||||
export type TLinkOperations = {
|
||||
create: (data: Partial<TProjectLink>) => Promise<void>;
|
||||
update: (linkId: string, data: Partial<TProjectLink>) => Promise<void>;
|
||||
remove: (linkId: string) => Promise<void>;
|
||||
};
|
||||
export type TProjectLinkRoot = {
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const useLinks = (workspaceSlug: string) => {
|
||||
// hooks
|
||||
const {
|
||||
quickLinks: {
|
||||
createLink,
|
||||
updateLink,
|
||||
removeLink,
|
||||
isLinkModalOpen,
|
||||
toggleLinkModal,
|
||||
linkData,
|
||||
setLinkData,
|
||||
fetchLinks,
|
||||
},
|
||||
} = useHome();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const linkOperations: TLinkOperations = useMemo(
|
||||
() => ({
|
||||
create: async (data: Partial<TProjectLink>) => {
|
||||
try {
|
||||
if (!workspaceSlug) throw new Error("Missing required fields");
|
||||
await createLink(workspaceSlug, data);
|
||||
setToast({
|
||||
message: t("links.toasts.created.message"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("links.toasts.created.title"),
|
||||
});
|
||||
toggleLinkModal(false);
|
||||
} catch (error: any) {
|
||||
console.error("error", error?.data?.error);
|
||||
setToast({
|
||||
message: error?.data?.error ?? t("links.toasts.not_created.message"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("links.toasts.not_created.title"),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
update: async (linkId: string, data: Partial<TProjectLink>) => {
|
||||
try {
|
||||
if (!workspaceSlug) throw new Error("Missing required fields");
|
||||
await updateLink(workspaceSlug, linkId, data);
|
||||
setToast({
|
||||
message: t("links.toasts.updated.message"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("links.toasts.updated.title"),
|
||||
});
|
||||
toggleLinkModal(false);
|
||||
} catch (error: any) {
|
||||
setToast({
|
||||
message: error?.data?.error ?? t("links.toasts.not_updated.message"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("links.toasts.not_updated.title"),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
remove: async (linkId: string) => {
|
||||
try {
|
||||
if (!workspaceSlug) throw new Error("Missing required fields");
|
||||
await removeLink(workspaceSlug, linkId);
|
||||
setToast({
|
||||
message: t("links.toasts.removed.message"),
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("links.toasts.removed.message"),
|
||||
});
|
||||
} catch (error: any) {
|
||||
setToast({
|
||||
message: error?.data?.error ?? t("links.toasts.not_removed.message"),
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("links.toasts.not_removed.title"),
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
[workspaceSlug]
|
||||
);
|
||||
|
||||
const handleOnClose = () => {
|
||||
toggleLinkModal(false);
|
||||
};
|
||||
|
||||
return { linkOperations, handleOnClose, isLinkModalOpen, toggleLinkModal, linkData, setLinkData, fetchLinks };
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { range } from "lodash-es";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const HomeLoader = () => (
|
||||
<>
|
||||
{range(3).map((index) => (
|
||||
<div key={index}>
|
||||
<div className="mb-2">
|
||||
<div className="text-base font-semibold text-custom-text-350 mb-4">
|
||||
<Loader.Item height="20px" width="100px" />
|
||||
</div>
|
||||
<Loader className="h-[110px] w-full flex items-center justify-center gap-2 text-custom-text-400 rounded">
|
||||
<Loader.Item height="100%" width="100%" />
|
||||
</Loader>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
2
apps/web/core/components/home/widgets/loaders/index.ts
Normal file
2
apps/web/core/components/home/widgets/loaders/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./loader";
|
||||
export * from "./home-loader";
|
||||
25
apps/web/core/components/home/widgets/loaders/loader.tsx
Normal file
25
apps/web/core/components/home/widgets/loaders/loader.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
// components
|
||||
import { QuickLinksWidgetLoader } from "./quick-links";
|
||||
import { RecentActivityWidgetLoader } from "./recent-activity";
|
||||
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
widgetKey: EWidgetKeys;
|
||||
};
|
||||
|
||||
export enum EWidgetKeys {
|
||||
RECENT_ACTIVITY = "recent_activity",
|
||||
QUICK_LINKS = "quick_links",
|
||||
}
|
||||
|
||||
export const WidgetLoader: React.FC<Props> = (props) => {
|
||||
const { widgetKey } = props;
|
||||
|
||||
const loaders = {
|
||||
[EWidgetKeys.RECENT_ACTIVITY]: <RecentActivityWidgetLoader />,
|
||||
[EWidgetKeys.QUICK_LINKS]: <QuickLinksWidgetLoader />,
|
||||
};
|
||||
|
||||
return loaders[widgetKey];
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { range } from "lodash-es";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const QuickLinksWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl gap-2 flex flex-wrap">
|
||||
{range(4).map((index) => (
|
||||
<Loader.Item key={index} height="56px" width="230px" />
|
||||
))}
|
||||
</Loader>
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { range } from "lodash-es";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const RecentActivityWidgetLoader = () => (
|
||||
<Loader className="bg-custom-background-100 rounded-xl px-2 space-y-6">
|
||||
{range(5).map((index) => (
|
||||
<div key={index} className="flex items-start gap-3.5">
|
||||
<div className="flex-shrink-0">
|
||||
<Loader.Item height="32px" width="32px" />
|
||||
</div>
|
||||
<div className="space-y-3 flex-shrink-0 w-full my-auto">
|
||||
<Loader.Item height="15px" width="70%" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Loader>
|
||||
);
|
||||
30
apps/web/core/components/home/widgets/manage/index.tsx
Normal file
30
apps/web/core/components/home/widgets/manage/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane types
|
||||
// plane ui
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EModalWidth, ModalCore } from "@plane/ui";
|
||||
import { WidgetList } from "./widget-list";
|
||||
|
||||
export type TProps = {
|
||||
workspaceSlug: string;
|
||||
isModalOpen: boolean;
|
||||
handleOnClose?: () => void;
|
||||
};
|
||||
|
||||
export const ManageWidgetsModal: FC<TProps> = observer((props) => {
|
||||
// props
|
||||
const { workspaceSlug, isModalOpen, handleOnClose } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isModalOpen} handleClose={handleOnClose} width={EModalWidth.MD}>
|
||||
<div className="p-4">
|
||||
<div className="font-medium text-xl"> {t("home.manage_widgets")}</div>
|
||||
<WidgetList workspaceSlug={workspaceSlug} />
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
import type { FC } from "react";
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { DragHandle } from "@plane/ui";
|
||||
// helper
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
sort_order: number | null;
|
||||
isDragging: boolean;
|
||||
};
|
||||
|
||||
export const WidgetItemDragHandle: FC<Props> = observer((props) => {
|
||||
const { isDragging } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-center rounded text-custom-sidebar-text-400 cursor-grab mr-2", {
|
||||
"cursor-grabbing": isDragging,
|
||||
})}
|
||||
>
|
||||
<DragHandle className="bg-transparent" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
144
apps/web/core/components/home/widgets/manage/widget-item.tsx
Normal file
144
apps/web/core/components/home/widgets/manage/widget-item.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import type {
|
||||
DropTargetRecord,
|
||||
DragLocationHistory,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
|
||||
import type { ElementDragPayload } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
|
||||
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
|
||||
import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { createRoot } from "react-dom/client";
|
||||
// plane types
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { InstructionType, TWidgetEntityData } from "@plane/types";
|
||||
// plane ui
|
||||
import { DropIndicator, ToggleSwitch } from "@plane/ui";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
import { HOME_WIDGETS_LIST } from "../../home-dashboard-widgets";
|
||||
import { WidgetItemDragHandle } from "./widget-item-drag-handle";
|
||||
import { getCanDrop, getInstructionFromPayload } from "./widget.helpers";
|
||||
|
||||
type Props = {
|
||||
widgetId: string;
|
||||
isLastChild: boolean;
|
||||
handleDrop: (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => void;
|
||||
handleToggle: (workspaceSlug: string, widgetKey: string, is_enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export const WidgetItem: FC<Props> = observer((props) => {
|
||||
// props
|
||||
const { widgetId, isLastChild, handleDrop, handleToggle } = props;
|
||||
const { workspaceSlug } = useParams();
|
||||
//state
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
|
||||
//ref
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
// hooks
|
||||
const { widgetsMap } = useHome();
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const widget = widgetsMap[widgetId] as TWidgetEntityData;
|
||||
const widgetTitle = HOME_WIDGETS_LIST[widget.key]?.title;
|
||||
|
||||
// drag and drop
|
||||
useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
|
||||
if (!element) return;
|
||||
const initialData = { id: widget.key, isGroup: false };
|
||||
return combine(
|
||||
draggable({
|
||||
element,
|
||||
dragHandle: elementRef.current,
|
||||
getInitialData: () => initialData,
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: () => {
|
||||
setIsDragging(false);
|
||||
},
|
||||
onGenerateDragPreview: ({ nativeSetDragImage }) => {
|
||||
setCustomNativeDragPreview({
|
||||
getOffset: pointerOutsideOfPreview({ x: "0px", y: "0px" }),
|
||||
render: ({ container }) => {
|
||||
const root = createRoot(container);
|
||||
root.render(<div className="rounded bg-custom-background-100 text-sm p-1 pr-2">{widget.key}</div>);
|
||||
return () => root.unmount();
|
||||
},
|
||||
nativeSetDragImage,
|
||||
});
|
||||
},
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element,
|
||||
canDrop: ({ source }) => getCanDrop(source, widget),
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
getData: ({ input, element }) => {
|
||||
const blockedStates: InstructionType[] = ["make-child"];
|
||||
if (!isLastChild) {
|
||||
blockedStates.push("reorder-below");
|
||||
}
|
||||
|
||||
return attachInstruction(initialData, {
|
||||
input,
|
||||
element,
|
||||
currentLevel: 1,
|
||||
indentPerLevel: 0,
|
||||
mode: isLastChild ? "last-in-group" : "standard",
|
||||
block: blockedStates,
|
||||
});
|
||||
},
|
||||
onDrag: ({ self, source, location }) => {
|
||||
const instruction = getInstructionFromPayload(self, source, location);
|
||||
setInstruction(instruction);
|
||||
},
|
||||
onDragLeave: () => {
|
||||
setInstruction(undefined);
|
||||
},
|
||||
onDrop: ({ self, source, location }) => {
|
||||
setInstruction(undefined);
|
||||
handleDrop(self, source, location);
|
||||
},
|
||||
})
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [elementRef?.current, isDragging, isLastChild, widget.key]);
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<DropIndicator isVisible={instruction === "reorder-above"} />
|
||||
<div
|
||||
ref={elementRef}
|
||||
className={cn(
|
||||
"px-2 relative flex items-center py-2 font-medium text-sm group/widget-item rounded hover:bg-custom-background-80 justify-between",
|
||||
{
|
||||
"cursor-grabbing bg-custom-background-80": isDragging,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<WidgetItemDragHandle sort_order={widget.sort_order} isDragging={isDragging} />
|
||||
<div>{t(widgetTitle, { count: 1 })}</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={widget.is_enabled}
|
||||
onChange={() => handleToggle(workspaceSlug.toString(), widget.key, !widget.is_enabled)}
|
||||
/>
|
||||
</div>
|
||||
{isLastChild && <DropIndicator isVisible={instruction === "reorder-below"} />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
64
apps/web/core/components/home/widgets/manage/widget-list.tsx
Normal file
64
apps/web/core/components/home/widgets/manage/widget-list.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import type {
|
||||
DragLocationHistory,
|
||||
DropTargetRecord,
|
||||
ElementDragPayload,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
import { WidgetItem } from "./widget-item";
|
||||
import type { TargetData } from "./widget.helpers";
|
||||
import { getInstructionFromPayload } from "./widget.helpers";
|
||||
|
||||
export const WidgetList = observer(({ workspaceSlug }: { workspaceSlug: string }) => {
|
||||
const { orderedWidgets, reorderWidget, toggleWidget } = useHome();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleDrop = (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => {
|
||||
const dropTargets = location?.current?.dropTargets ?? [];
|
||||
if (!dropTargets || dropTargets.length <= 0) return;
|
||||
const dropTarget =
|
||||
dropTargets.length > 1 ? dropTargets.find((target: DropTargetRecord) => target?.data?.isChild) : dropTargets[0];
|
||||
|
||||
const dropTargetData = dropTarget?.data as TargetData;
|
||||
|
||||
if (!dropTarget || !dropTargetData) return;
|
||||
const instruction = getInstructionFromPayload(dropTarget, source, location);
|
||||
const droppedId = dropTargetData.id;
|
||||
const sourceData = source.data as TargetData;
|
||||
|
||||
if (!sourceData.id) return;
|
||||
if (droppedId) {
|
||||
reorderWidget(workspaceSlug, sourceData.id, droppedId, instruction)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("toast.success"),
|
||||
message: t("home.widget.reordered_successfully"),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("home.widget.reordering_failed"),
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="my-4">
|
||||
{orderedWidgets.map((widget, index) => (
|
||||
<WidgetItem
|
||||
key={widget}
|
||||
widgetId={widget}
|
||||
isLastChild={index === orderedWidgets.length - 1}
|
||||
handleDrop={handleDrop}
|
||||
handleToggle={toggleWidget}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
|
||||
import type { InstructionType, IPragmaticPayloadLocation, TDropTarget, TWidgetEntityData } from "@plane/types";
|
||||
|
||||
export type TargetData = {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
isGroup: boolean;
|
||||
isChild: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* extracts the Payload and translates the instruction for the current dropTarget based on drag and drop payload
|
||||
* @param dropTarget dropTarget for which the instruction is required
|
||||
* @param source the dragging widget data that is being dragged on the dropTarget
|
||||
* @param location location includes the data of all the dropTargets the source is being dragged on
|
||||
* @returns Instruction for dropTarget
|
||||
*/
|
||||
export const getInstructionFromPayload = (
|
||||
dropTarget: TDropTarget,
|
||||
source: TDropTarget,
|
||||
location: IPragmaticPayloadLocation
|
||||
): InstructionType | undefined => {
|
||||
const dropTargetData = dropTarget?.data as TargetData;
|
||||
const sourceData = source?.data as TargetData;
|
||||
const allDropTargets = location?.current?.dropTargets;
|
||||
|
||||
// if all the dropTargets are greater than 1 meaning the source is being dragged on a group and its child at the same time
|
||||
// and also if the dropTarget in question is also a group then, it should be a child of the current Droptarget
|
||||
if (allDropTargets?.length > 1 && dropTargetData?.isGroup) return "make-child";
|
||||
|
||||
if (!dropTargetData || !sourceData) return undefined;
|
||||
|
||||
let instruction = extractInstruction(dropTargetData)?.type;
|
||||
|
||||
// If the instruction is blocked then set an instruction based on if dropTarget it is a child or not
|
||||
if (instruction === "instruction-blocked") {
|
||||
instruction = dropTargetData.isChild ? "reorder-above" : "make-child";
|
||||
}
|
||||
|
||||
// if source that is being dragged is a group. A group cannon be a child of any other widget,
|
||||
// hence if current instruction is to be a child of dropTarget then reorder-above instead
|
||||
if (instruction === "make-child" && sourceData.isGroup) instruction = "reorder-above";
|
||||
|
||||
return instruction;
|
||||
};
|
||||
|
||||
/**
|
||||
* This provides a boolean to indicate if the widget can be dropped onto the droptarget
|
||||
* @param source
|
||||
* @param widget
|
||||
* @returns
|
||||
*/
|
||||
export const getCanDrop = (source: TDropTarget, widget: TWidgetEntityData | undefined) => {
|
||||
const sourceData = source?.data;
|
||||
|
||||
if (!sourceData) return false;
|
||||
|
||||
// a widget cannot be dropped on to itself
|
||||
if (sourceData.id === widget?.key) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
53
apps/web/core/components/home/widgets/recents/filters.tsx
Normal file
53
apps/web/core/components/home/widgets/recents/filters.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TRecentActivityFilterKeys } from "@plane/types";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
export type TFiltersDropdown = {
|
||||
className?: string;
|
||||
activeFilter: TRecentActivityFilterKeys;
|
||||
setActiveFilter: (filter: TRecentActivityFilterKeys) => void;
|
||||
filters: { name: TRecentActivityFilterKeys; icon?: React.ReactNode; i18n_key: string }[];
|
||||
};
|
||||
|
||||
export const FiltersDropdown: FC<TFiltersDropdown> = observer((props) => {
|
||||
const { className, activeFilter, setActiveFilter, filters } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const DropdownOptions = () =>
|
||||
filters?.map((filter) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={filter.name}
|
||||
className="flex items-center gap-2 truncate text-custom-text-200"
|
||||
onClick={() => {
|
||||
setActiveFilter(filter.name);
|
||||
}}
|
||||
>
|
||||
<div className="truncate font-medium text-xs capitalize">{t(filter.i18n_key)}</div>
|
||||
</CustomMenu.MenuItem>
|
||||
));
|
||||
|
||||
const title = activeFilter ? filters?.find((filter) => filter.name === activeFilter)?.i18n_key : "";
|
||||
return (
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className={cn("flex justify-center text-xs text-custom-text-200 w-fit ", className)}
|
||||
placement="bottom-start"
|
||||
customButton={
|
||||
<button className="flex hover:bg-custom-background-80 px-2 py-1 rounded gap-1 capitalize border border-custom-border-200">
|
||||
<span className="font-medium text-sm my-auto"> {t(title || "")}</span>
|
||||
<ChevronDown className={cn("size-3 my-auto text-custom-text-300 hover:text-custom-text-200 duration-300")} />
|
||||
</button>
|
||||
}
|
||||
customButtonClassName="flex justify-center"
|
||||
closeOnSelect
|
||||
>
|
||||
<DropdownOptions />
|
||||
</CustomMenu>
|
||||
);
|
||||
});
|
||||
107
apps/web/core/components/home/widgets/recents/index.tsx
Normal file
107
apps/web/core/components/home/widgets/recents/index.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// plane types
|
||||
import { PageIcon, ProjectIcon, WorkItemsIcon } from "@plane/propel/icons";
|
||||
import type { TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types";
|
||||
// plane ui
|
||||
// components
|
||||
import { ContentOverflowWrapper } from "@/components/core/content-overflow-HOC";
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
import { RecentsEmptyState } from "../empty-states";
|
||||
import { EWidgetKeys, WidgetLoader } from "../loaders";
|
||||
import { FiltersDropdown } from "./filters";
|
||||
import { RecentIssue } from "./issue";
|
||||
import { RecentPage } from "./page";
|
||||
import { RecentProject } from "./project";
|
||||
|
||||
const WIDGET_KEY = EWidgetKeys.RECENT_ACTIVITY;
|
||||
const workspaceService = new WorkspaceService();
|
||||
const filters: { name: TRecentActivityFilterKeys; icon?: React.ReactNode; i18n_key: string }[] = [
|
||||
{ name: "all item", i18n_key: "home.recents.filters.all" },
|
||||
{ name: "issue", icon: <WorkItemsIcon className="w-4 h-4" />, i18n_key: "home.recents.filters.issues" },
|
||||
{ name: "page", icon: <PageIcon height={16} width={16} />, i18n_key: "home.recents.filters.pages" },
|
||||
{ name: "project", icon: <ProjectIcon height={16} width={16} />, i18n_key: "home.recents.filters.projects" },
|
||||
];
|
||||
|
||||
type TRecentWidgetProps = THomeWidgetProps & {
|
||||
presetFilter?: TRecentActivityFilterKeys;
|
||||
showFilterSelect?: boolean;
|
||||
};
|
||||
|
||||
export const RecentActivityWidget: React.FC<TRecentWidgetProps> = observer((props) => {
|
||||
const { presetFilter, showFilterSelect = true, workspaceSlug } = props;
|
||||
// states
|
||||
const [filter, setFilter] = useState<TRecentActivityFilterKeys>(presetFilter ?? filters[0].name);
|
||||
const { t } = useTranslation();
|
||||
// ref
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: recents, isLoading } = useSWR(
|
||||
workspaceSlug ? `WORKSPACE_RECENT_ACTIVITY_${workspaceSlug}_${filter}` : null,
|
||||
workspaceSlug
|
||||
? () =>
|
||||
workspaceService.fetchWorkspaceRecents(
|
||||
workspaceSlug.toString(),
|
||||
filter === filters[0].name ? undefined : filter
|
||||
)
|
||||
: null,
|
||||
{
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
}
|
||||
);
|
||||
|
||||
const resolveRecent = (activity: TActivityEntityData) => {
|
||||
switch (activity.entity_name) {
|
||||
case "page":
|
||||
case "workspace_page":
|
||||
return <RecentPage activity={activity} ref={ref} workspaceSlug={workspaceSlug} />;
|
||||
case "project":
|
||||
return <RecentProject activity={activity} ref={ref} workspaceSlug={workspaceSlug} />;
|
||||
case "issue":
|
||||
return <RecentIssue activity={activity} ref={ref} workspaceSlug={workspaceSlug} />;
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
if (!isLoading && recents?.length === 0)
|
||||
return (
|
||||
<div ref={ref} className="max-h-[500px] overflow-y-scroll">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-base font-semibold text-custom-text-350">{t("home.recents.title")}</div>
|
||||
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />}
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<RecentsEmptyState type={filter} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ContentOverflowWrapper
|
||||
maxHeight={415}
|
||||
containerClassName="box-border min-h-[250px]"
|
||||
fallback={<></>}
|
||||
buttonClassName="bg-custom-background-90/20"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-base font-semibold text-custom-text-350">{t("home.recents.title")}</div>
|
||||
{showFilterSelect && <FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />}
|
||||
</div>
|
||||
<div className="min-h-[250px] flex flex-col">
|
||||
{isLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
|
||||
{!isLoading &&
|
||||
recents
|
||||
?.filter((recent) => recent.entity_data)
|
||||
.map((activity) => <div key={activity.id}>{resolveRecent(activity)}</div>)}
|
||||
</div>
|
||||
</ContentOverflowWrapper>
|
||||
);
|
||||
});
|
||||
139
apps/web/core/components/home/widgets/recents/issue.tsx
Normal file
139
apps/web/core/components/home/widgets/recents/issue.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { observer } from "mobx-react";
|
||||
// plane types
|
||||
import { PriorityIcon, StateGroupIcon, WorkItemsIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { TActivityEntityData, TIssueEntityData } from "@plane/types";
|
||||
import { EIssueServiceType } from "@plane/types";
|
||||
// plane ui
|
||||
import { calculateTimeAgo, generateWorkItemLink } from "@plane/utils";
|
||||
// components
|
||||
import { ListItem } from "@/components/core/list";
|
||||
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
||||
// helpers
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
// plane web components
|
||||
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
|
||||
|
||||
type BlockProps = {
|
||||
activity: TActivityEntityData;
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
export const RecentIssue = observer((props: BlockProps) => {
|
||||
const { activity, ref, workspaceSlug } = props;
|
||||
// hooks
|
||||
const { getStateById } = useProjectState();
|
||||
const { setPeekIssue } = useIssueDetail();
|
||||
const { setPeekIssue: setPeekEpic } = useIssueDetail(EIssueServiceType.EPICS);
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
// derived values
|
||||
const issueDetails: TIssueEntityData = activity.entity_data as TIssueEntityData;
|
||||
const projectIdentifier = getProjectIdentifierById(issueDetails?.project_id);
|
||||
|
||||
if (!issueDetails) return <></>;
|
||||
|
||||
const state = getStateById(issueDetails?.state);
|
||||
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
projectId: issueDetails?.project_id,
|
||||
issueId: issueDetails?.id,
|
||||
projectIdentifier,
|
||||
sequenceId: issueDetails?.sequence_id,
|
||||
isEpic: issueDetails?.is_epic,
|
||||
});
|
||||
|
||||
const handlePeekOverview = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const peekDetails = {
|
||||
workspaceSlug,
|
||||
projectId: issueDetails?.project_id,
|
||||
issueId: activity.entity_data.id,
|
||||
};
|
||||
if (issueDetails?.is_epic) setPeekEpic(peekDetails);
|
||||
else setPeekIssue(peekDetails);
|
||||
};
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={activity.id}
|
||||
id={`issue-${issueDetails?.id}`}
|
||||
itemLink={workItemLink}
|
||||
title={issueDetails?.name}
|
||||
prependTitleElement={
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
{issueDetails.type ? (
|
||||
<IssueIdentifier
|
||||
size="lg"
|
||||
issueTypeId={issueDetails?.type}
|
||||
projectId={issueDetails?.project_id || ""}
|
||||
projectIdentifier={issueDetails?.project_identifier || ""}
|
||||
issueSequenceId={issueDetails?.sequence_id || ""}
|
||||
textContainerClassName="text-custom-sidebar-text-400 text-sm whitespace-nowrap"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex gap-2 items-center justify-center">
|
||||
<div className="flex-shrink-0 grid place-items-center rounded bg-custom-background-80 size-8">
|
||||
<WorkItemsIcon className="size-4 text-custom-text-350" />
|
||||
</div>
|
||||
<div className="font-medium text-custom-text-400 text-sm whitespace-nowrap">
|
||||
{issueDetails?.project_identifier}-{issueDetails?.sequence_id}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
appendTitleElement={
|
||||
<div className="flex-shrink-0 font-medium text-xs text-custom-text-400">
|
||||
{calculateTimeAgo(activity.visited_at)}
|
||||
</div>
|
||||
}
|
||||
quickActionElement={
|
||||
<div className="flex gap-4">
|
||||
<Tooltip tooltipHeading="State" tooltipContent={state?.name ?? "State"}>
|
||||
<div>
|
||||
<StateGroupIcon
|
||||
stateGroup={state?.group ?? "backlog"}
|
||||
color={state?.color}
|
||||
className="h-4 w-4 my-auto"
|
||||
percentage={state?.order}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip tooltipHeading="Priority" tooltipContent={issueDetails?.priority ?? "Priority"}>
|
||||
<div>
|
||||
<PriorityIcon priority={issueDetails?.priority} withContainer size={12} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
{issueDetails?.assignees?.length > 0 && (
|
||||
<div className="h-5">
|
||||
<MemberDropdown
|
||||
projectId={issueDetails?.project_id}
|
||||
value={issueDetails?.assignees}
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
multiple
|
||||
buttonVariant={issueDetails?.assignees?.length > 0 ? "transparent-without-text" : "border-without-text"}
|
||||
buttonClassName={issueDetails?.assignees?.length > 0 ? "hover:bg-transparent px-0" : ""}
|
||||
showTooltip={issueDetails?.assignees?.length === 0}
|
||||
placeholder="Assignees"
|
||||
optionsClassName="z-10"
|
||||
tooltipContent=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
parentRef={ref}
|
||||
disableLink={false}
|
||||
className="bg-transparent my-auto !px-2 border-none py-3"
|
||||
itemClassName="my-auto"
|
||||
onItemClick={handlePeekOverview}
|
||||
preventDefaultProgress
|
||||
/>
|
||||
);
|
||||
});
|
||||
77
apps/web/core/components/home/widgets/recents/page.tsx
Normal file
77
apps/web/core/components/home/widgets/recents/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PageIcon } from "@plane/propel/icons";
|
||||
// plane import
|
||||
import type { TActivityEntityData, TPageEntityData } from "@plane/types";
|
||||
import { Avatar } from "@plane/ui";
|
||||
import { calculateTimeAgo, getFileURL, getPageName } from "@plane/utils";
|
||||
// components
|
||||
import { Logo } from "@/components/common/logo";
|
||||
import { ListItem } from "@/components/core/list";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
|
||||
type BlockProps = {
|
||||
activity: TActivityEntityData;
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const RecentPage = (props: BlockProps) => {
|
||||
const { activity, ref, workspaceSlug } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const pageDetails = activity.entity_data as TPageEntityData;
|
||||
|
||||
if (!pageDetails) return <></>;
|
||||
|
||||
const ownerDetails = getUserDetails(pageDetails?.owned_by);
|
||||
const pageLink = pageDetails.project_id
|
||||
? `/${workspaceSlug}/projects/${pageDetails.project_id}/pages/${pageDetails.id}`
|
||||
: `/${workspaceSlug}/pages/${pageDetails.id}`;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={activity.id}
|
||||
itemLink={pageLink}
|
||||
title={getPageName(pageDetails?.name)}
|
||||
prependTitleElement={
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
<div className="flex-shrink-0 grid place-items-center rounded bg-custom-background-80 size-8">
|
||||
{pageDetails?.logo_props?.in_use ? (
|
||||
<Logo logo={pageDetails?.logo_props} size={16} type="lucide" />
|
||||
) : (
|
||||
<PageIcon className="size-4 text-custom-text-350" />
|
||||
)}
|
||||
</div>
|
||||
{pageDetails?.project_identifier && (
|
||||
<div className="font-medium text-custom-text-400 text-sm whitespace-nowrap">
|
||||
{pageDetails?.project_identifier}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
appendTitleElement={
|
||||
<div className="flex-shrink-0 font-medium text-xs text-custom-text-400">
|
||||
{calculateTimeAgo(activity.visited_at)}
|
||||
</div>
|
||||
}
|
||||
quickActionElement={
|
||||
<div className="flex gap-4">
|
||||
<Avatar src={getFileURL(ownerDetails?.avatar_url ?? "")} name={ownerDetails?.display_name} />
|
||||
</div>
|
||||
}
|
||||
parentRef={ref}
|
||||
disableLink={false}
|
||||
className="bg-transparent my-auto !px-2 border-none py-3"
|
||||
itemClassName="my-auto"
|
||||
onItemClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
router.push(pageLink);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
79
apps/web/core/components/home/widgets/recents/project.tsx
Normal file
79
apps/web/core/components/home/widgets/recents/project.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
// plane types
|
||||
import type { TActivityEntityData, TProjectEntityData } from "@plane/types";
|
||||
import { calculateTimeAgo } from "@plane/utils";
|
||||
// components
|
||||
import { Logo } from "@/components/common/logo";
|
||||
import { ListItem } from "@/components/core/list";
|
||||
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
||||
// helpers
|
||||
|
||||
type BlockProps = {
|
||||
activity: TActivityEntityData;
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
export const RecentProject = (props: BlockProps) => {
|
||||
const { activity, ref, workspaceSlug } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
// derived values
|
||||
const projectDetails: TProjectEntityData = activity.entity_data as TProjectEntityData;
|
||||
|
||||
if (!projectDetails) return <></>;
|
||||
|
||||
const projectLink = `/${workspaceSlug}/projects/${projectDetails?.id}/issues`;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={activity.id}
|
||||
itemLink={projectLink}
|
||||
title={projectDetails?.name}
|
||||
prependTitleElement={
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
<div className="flex-shrink-0 grid place-items-center rounded bg-custom-background-80 size-8">
|
||||
<Logo logo={projectDetails?.logo_props} size={16} />
|
||||
</div>
|
||||
<div className="font-medium text-custom-text-400 text-sm whitespace-nowrap">{projectDetails?.identifier}</div>
|
||||
</div>
|
||||
}
|
||||
appendTitleElement={
|
||||
<div className="flex-shrink-0 font-medium text-xs text-custom-text-400">
|
||||
{calculateTimeAgo(activity.visited_at)}
|
||||
</div>
|
||||
}
|
||||
quickActionElement={
|
||||
<div className="flex gap-4">
|
||||
{projectDetails?.project_members?.length > 0 && (
|
||||
<div className="h-5">
|
||||
<MemberDropdown
|
||||
projectId={projectDetails?.id}
|
||||
value={projectDetails?.project_members}
|
||||
onChange={() => {}}
|
||||
disabled
|
||||
multiple
|
||||
buttonVariant={
|
||||
projectDetails?.project_members?.length > 0 ? "transparent-without-text" : "border-without-text"
|
||||
}
|
||||
buttonClassName={projectDetails?.project_members?.length > 0 ? "hover:bg-transparent px-0" : ""}
|
||||
showTooltip={projectDetails?.project_members?.length === 0}
|
||||
placeholder="Assignees"
|
||||
optionsClassName="z-10"
|
||||
tooltipContent=""
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
parentRef={ref}
|
||||
disableLink={false}
|
||||
className="bg-transparent my-auto !px-2 border-none py-3"
|
||||
itemClassName="my-auto"
|
||||
onItemClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
router.push(projectLink);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user