feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
export * from "./links";
export * from "./no-projects";
export * from "./recents";
export * from "./stickies";

View 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>
);
};

View File

@@ -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>
);
});

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1,4 @@
export * from "./empty-states";
export * from "./loaders";
export * from "./recents";
export * from "./empty-states";

View 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>
);
};

View File

@@ -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>
);
});

View File

@@ -0,0 +1,3 @@
export * from "./root";
export * from "./links";
export * from "./link-detail";

View 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}
/>
);
});

View 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>
);
});

View 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>
</>
);
});

View 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 };
};

View File

@@ -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>
))}
</>
);

View File

@@ -0,0 +1,2 @@
export * from "./loader";
export * from "./home-loader";

View 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];
};

View File

@@ -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>
);

View File

@@ -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>
);

View 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>
);
});

View File

@@ -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>
);
});

View 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>
);
});

View 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>
);
});

View File

@@ -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;
};

View 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>
);
});

View 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>
);
});

View 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
/>
);
});

View 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);
}}
/>
);
};

View 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);
}}
/>
);
};