+
| null; fullWidth: boolean };
+} = {
+ quick_links: { component: DashboardQuickLinks, fullWidth: false },
+ recents: { component: RecentActivityWidget, fullWidth: false },
+ my_stickies: { component: StickiesWidget, fullWidth: false },
+ new_at_plane: { component: null, fullWidth: false },
+ quick_tutorial: { component: null, fullWidth: false },
+};
+
+export const DashboardWidgets = observer(() => {
+ // router
+ const { workspaceSlug } = useParams();
+ // store hooks
+ const { toggleWidgetSettings, widgetsMap, showWidgetSettings, orderedWidgets } = useHome();
+
+ if (!workspaceSlug) return null;
+
+ return (
+
+
+
toggleWidgetSettings(false)}
+ />
+
+ {orderedWidgets.map((key) => {
+ const WidgetComponent = WIDGETS_LIST[key]?.component;
+ const isEnabled = widgetsMap[key]?.is_enabled;
+ if (!WidgetComponent || !isEnabled) return null;
+ if (WIDGETS_LIST[key]?.fullWidth)
+ return (
+
+
+
+ );
+ else return ;
+ })}
+
+ );
+});
diff --git a/web/core/components/home/index.ts b/web/core/components/home/index.ts
new file mode 100644
index 00000000000..d783e089a0e
--- /dev/null
+++ b/web/core/components/home/index.ts
@@ -0,0 +1,4 @@
+export * from "./widgets";
+export * from "./home-dashboard-widgets";
+export * from "./project-empty-state";
+export * from "./root";
diff --git a/web/core/components/home/project-empty-state.tsx b/web/core/components/home/project-empty-state.tsx
new file mode 100644
index 00000000000..c8cba817a37
--- /dev/null
+++ b/web/core/components/home/project-empty-state.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { observer } from "mobx-react";
+import Image from "next/image";
+// ui
+import { Button } from "@plane/ui";
+// hooks
+import { useCommandPalette, useEventTracker, useUserPermissions } from "@/hooks/store";
+import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
+// assets
+import ProjectEmptyStateImage from "@/public/empty-state/onboarding/dashboard-light.webp";
+
+export const DashboardProjectEmptyState = observer(() => {
+ // store hooks
+ const { toggleCreateProjectModal } = useCommandPalette();
+ const { setTrackElement } = useEventTracker();
+ const { allowPermissions } = useUserPermissions();
+
+ // derived values
+ const canCreateProject = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
+
+ return (
+
+
Overview of your projects, activity, and metrics
+
+ Welcome to Plane, we are excited to have you here. Create your first project and track your issues, and this
+ page will transform into a space that helps you progress. Admins will also see items which help their team
+ progress.
+
+
+ {canCreateProject && (
+
+ {
+ setTrackElement("Project empty state");
+ toggleCreateProjectModal(true);
+ }}
+ >
+ Build your first project
+
+
+ )}
+
+ );
+});
diff --git a/web/core/components/home/root.tsx b/web/core/components/home/root.tsx
new file mode 100644
index 00000000000..44cd9356614
--- /dev/null
+++ b/web/core/components/home/root.tsx
@@ -0,0 +1,97 @@
+import { observer } from "mobx-react";
+import { useParams } from "next/navigation";
+// components
+import useSWR from "swr";
+import { ContentWrapper } from "@plane/ui";
+import { EmptyState } from "@/components/empty-state";
+import { TourRoot } from "@/components/onboarding";
+// constants
+import { EmptyStateType } from "@/constants/empty-state";
+import { PRODUCT_TOUR_COMPLETED } from "@/constants/event-tracker";
+// helpers
+import { cn } from "@/helpers/common.helper";
+// hooks
+import { useCommandPalette, useUserProfile, useEventTracker, useProject, useUser } from "@/hooks/store";
+import { useHome } from "@/hooks/store/use-home";
+import useSize from "@/hooks/use-window-size";
+import { IssuePeekOverview } from "../issues";
+import { DashboardWidgets } from "./home-dashboard-widgets";
+import { UserGreetingsView } from "./user-greetings";
+
+export const WorkspaceHomeView = observer(() => {
+ // store hooks
+ const {
+ // captureEvent,
+ setTrackElement,
+ } = useEventTracker();
+ const { toggleCreateProjectModal } = useCommandPalette();
+ const { workspaceSlug } = useParams();
+ const { data: currentUser } = useUser();
+ const { data: currentUserProfile, updateTourCompleted } = useUserProfile();
+ const { captureEvent } = useEventTracker();
+ const { toggleWidgetSettings, fetchWidgets } = useHome();
+ const { joinedProjectIds, loader } = useProject();
+ const [windowWidth] = useSize();
+
+ useSWR(
+ workspaceSlug ? `HOME_DASHBOARD_WIDGETS_${workspaceSlug}` : null,
+ workspaceSlug ? () => fetchWidgets(workspaceSlug?.toString()) : null,
+ {
+ revalidateIfStale: true,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: true,
+ }
+ );
+
+ const handleTourCompleted = () => {
+ updateTourCompleted()
+ .then(() => {
+ captureEvent(PRODUCT_TOUR_COMPLETED, {
+ user_id: currentUser?.id,
+ state: "SUCCESS",
+ });
+ })
+ .catch((error) => {
+ console.error(error);
+ });
+ };
+
+ // TODO: refactor loader implementation
+ return (
+ <>
+ {currentUserProfile && !currentUserProfile.is_tour_completed && (
+
+
+
+ )}
+ {joinedProjectIds && (
+ <>
+ {joinedProjectIds.length > 0 || loader ? (
+ <>
+
+ = 768,
+ })}
+ >
+ {currentUser && (
+ toggleWidgetSettings(true)} />
+ )}
+
+
+
+ >
+ ) : (
+ {
+ setTrackElement("Dashboard empty state");
+ toggleCreateProjectModal(true);
+ }}
+ />
+ )}
+ >
+ )}
+ >
+ );
+});
diff --git a/web/core/components/home/user-greetings.tsx b/web/core/components/home/user-greetings.tsx
new file mode 100644
index 00000000000..d9e68880123
--- /dev/null
+++ b/web/core/components/home/user-greetings.tsx
@@ -0,0 +1,63 @@
+import { FC } from "react";
+// hooks
+import { Shapes } from "lucide-react";
+import { IUser } from "@plane/types";
+import { useCurrentTime } from "@/hooks/use-current-time";
+// types
+
+export interface IUserGreetingsView {
+ user: IUser;
+ handleWidgetModal: () => void;
+}
+
+export const UserGreetingsView: FC = (props) => {
+ const { user, handleWidgetModal } = props;
+ // current time hook
+ const { currentTime } = useCurrentTime();
+
+ const hour = new Intl.DateTimeFormat("en-US", {
+ hour12: false,
+ hour: "numeric",
+ }).format(currentTime);
+
+ const date = new Intl.DateTimeFormat("en-US", {
+ month: "short",
+ day: "numeric",
+ }).format(currentTime);
+
+ const weekDay = new Intl.DateTimeFormat("en-US", {
+ weekday: "long",
+ }).format(currentTime);
+
+ const timeString = new Intl.DateTimeFormat("en-US", {
+ timeZone: user?.user_timezone,
+ hour12: false, // Use 24-hour format
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(currentTime);
+
+ const greeting = parseInt(hour, 10) < 12 ? "morning" : parseInt(hour, 10) < 18 ? "afternoon" : "evening";
+
+ return (
+
+
+
+ Good {greeting}, {user?.first_name} {user?.last_name}
+
+
+ {greeting === "morning" ? "🌤️" : greeting === "afternoon" ? "🌥️" : "🌙️"}
+
+ {weekDay}, {date} {timeString}
+
+
+
+
+
+ Manage widgets
+
+
+ );
+};
diff --git a/web/core/components/home/widgets/empty-states/index.ts b/web/core/components/home/widgets/empty-states/index.ts
new file mode 100644
index 00000000000..1efe34c51ec
--- /dev/null
+++ b/web/core/components/home/widgets/empty-states/index.ts
@@ -0,0 +1 @@
+export * from "./root";
diff --git a/web/core/components/home/widgets/empty-states/root.tsx b/web/core/components/home/widgets/empty-states/root.tsx
new file mode 100644
index 00000000000..06606f367d0
--- /dev/null
+++ b/web/core/components/home/widgets/empty-states/root.tsx
@@ -0,0 +1,118 @@
+import React from "react";
+import Link from "next/link";
+import { useParams } from "next/navigation";
+import { Briefcase, Hotel, Users } from "lucide-react";
+import { getFileURL } from "@/helpers/file.helper";
+import { useCommandPalette, useEventTracker, useUser, useUserPermissions } from "@/hooks/store";
+import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants";
+
+export const EmptyWorkspace = () => {
+ const { workspaceSlug } = useParams();
+ const { allowPermissions } = useUserPermissions();
+ const { toggleCreateProjectModal } = useCommandPalette();
+ const { setTrackElement } = useEventTracker();
+ const { data: currentUser } = useUser();
+
+ const canCreateProject = allowPermissions(
+ [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
+ EUserPermissionsLevel.WORKSPACE
+ );
+
+ const EMPTY_STATE_DATA = [
+ {
+ id: "create-project",
+ title: "Create a project",
+ description: "Create your first project now to get started",
+ icon: ,
+ cta: {
+ text: "Create Project",
+ onClick: (e: React.MouseEvent) => {
+ if (!canCreateProject) return;
+ e.preventDefault();
+ e.stopPropagation();
+ setTrackElement("Sidebar");
+ toggleCreateProjectModal(true);
+ },
+ },
+ },
+ {
+ id: "invite-team",
+ title: "Invite your team",
+ description: "The sub text will be of two lines and that will be placed.",
+ icon: ,
+ cta: {
+ text: "Invite now",
+ link: `/${workspaceSlug}/settings/members`,
+ },
+ },
+ {
+ id: "configure-workspace",
+ title: "Configure your workspace",
+ description: "The sub text will be of two lines and that will be placed.",
+ icon: ,
+ cta: {
+ text: "Configure workspace",
+ link: "settings",
+ },
+ },
+ {
+ id: "personalize-account",
+ title: "Personalize your account",
+ description: "The sub text will be of two lines and that will be placed.",
+ icon:
+ currentUser?.avatar_url && currentUser?.avatar_url.trim() !== "" ? (
+
+
+
+
+
+ ) : (
+
+
+ {(currentUser?.email ?? currentUser?.display_name ?? "?")[0]}
+
+
+ ),
+ cta: {
+ text: "Personalize account",
+ link: "/profile",
+ },
+ },
+ ];
+ return (
+
+ {EMPTY_STATE_DATA.map((item) => (
+
+
+ {item.icon}
+
+
{item.title}
+
{item.description}
+
+ {item.cta.link ? (
+
+ {item.cta.text}
+
+ ) : (
+
+ {item.cta.text}
+
+ )}
+
+ ))}
+
+ );
+};
diff --git a/web/core/components/home/widgets/index.ts b/web/core/components/home/widgets/index.ts
new file mode 100644
index 00000000000..19d9aba92f5
--- /dev/null
+++ b/web/core/components/home/widgets/index.ts
@@ -0,0 +1,4 @@
+export * from "./empty-states";
+export * from "./loaders";
+export * from "./recents";
+export * from "./empty-states";
diff --git a/web/core/components/home/widgets/links/action.tsx b/web/core/components/home/widgets/links/action.tsx
new file mode 100644
index 00000000000..7d23f43ffcf
--- /dev/null
+++ b/web/core/components/home/widgets/links/action.tsx
@@ -0,0 +1,20 @@
+import { PlusIcon } from "lucide-react";
+
+type TProps = {
+ onClick: () => void;
+};
+export const AddLink = (props: TProps) => {
+ const { onClick } = props;
+
+ return (
+
+
+ Add quick Link
+
+ );
+};
diff --git a/web/core/components/home/widgets/links/create-update-link-modal.tsx b/web/core/components/home/widgets/links/create-update-link-modal.tsx
new file mode 100644
index 00000000000..0c25747842d
--- /dev/null
+++ b/web/core/components/home/widgets/links/create-update-link-modal.tsx
@@ -0,0 +1,133 @@
+"use client";
+
+import { FC, useEffect } from "react";
+import { observer } from "mobx-react";
+import { Controller, useForm } from "react-hook-form";
+// plane types
+// plane ui
+import { TLink, TLinkEditableFields } from "@plane/types";
+import { Button, Input, ModalCore } from "@plane/ui";
+import { TLinkOperations } from "./use-links";
+
+export type TLinkOperationsModal = Exclude;
+
+export type TLinkCreateFormFieldOptions = TLinkEditableFields & {
+ id?: string;
+};
+
+export type TLinkCreateEditModal = {
+ isModalOpen: boolean;
+ handleOnClose?: () => void;
+ linkOperations: TLinkOperationsModal;
+ preloadedData?: TLinkCreateFormFieldOptions;
+ setLinkData: (link: TLink | undefined) => void;
+};
+
+const defaultValues: TLinkCreateFormFieldOptions = {
+ title: "",
+ url: "",
+};
+
+export const LinkCreateUpdateModal: FC = observer((props) => {
+ // props
+ const { setLinkData, isModalOpen, handleOnClose, linkOperations, preloadedData } = props;
+ // react hook form
+ const {
+ formState: { errors, isSubmitting },
+ handleSubmit,
+ control,
+ reset,
+ } = useForm({
+ defaultValues,
+ });
+
+ const onClose = () => {
+ if (handleOnClose) handleOnClose();
+ setLinkData(undefined);
+ };
+
+ 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 (
+
+
+
+ );
+});
diff --git a/web/core/components/home/widgets/links/index.ts b/web/core/components/home/widgets/links/index.ts
new file mode 100644
index 00000000000..380f7763c16
--- /dev/null
+++ b/web/core/components/home/widgets/links/index.ts
@@ -0,0 +1,3 @@
+export * from "./root";
+export * from "./links";
+export * from "./link-detail";
diff --git a/web/core/components/home/widgets/links/link-detail.tsx b/web/core/components/home/widgets/links/link-detail.tsx
new file mode 100644
index 00000000000..26aa4f9c8b4
--- /dev/null
+++ b/web/core/components/home/widgets/links/link-detail.tsx
@@ -0,0 +1,126 @@
+"use client";
+
+import { FC } from "react";
+// hooks
+// ui
+import { observer } from "mobx-react";
+import { Pencil, Trash2, ExternalLink, EllipsisVertical, Link, Link2 } from "lucide-react";
+import { TOAST_TYPE, setToast, CustomMenu, TContextMenuItem } from "@plane/ui";
+// helpers
+import { cn } from "@plane/utils";
+import { calculateTimeAgo } from "@/helpers/date-time.helper";
+import { copyUrlToClipboard } from "@/helpers/string.helper";
+import { useHome } from "@/hooks/store/use-home";
+import { TLinkOperations } from "./use-links";
+
+export type TProjectLinkDetail = {
+ linkId: string;
+ linkOperations: TLinkOperations;
+};
+
+export const ProjectLinkDetail: FC = observer((props) => {
+ // props
+ const { linkId, linkOperations } = props;
+ // hooks
+ const {
+ quickLinks: { getLinkById, toggleLinkModal, setLinkData },
+ } = useHome();
+
+ const linkDetail = getLinkById(linkId);
+ if (!linkDetail) return <>>;
+
+ const viewLink = linkDetail.url;
+
+ const handleEdit = (modalToggle: boolean) => {
+ toggleLinkModal(modalToggle);
+ setLinkData(linkDetail);
+ };
+
+ const handleCopyText = () =>
+ copyUrlToClipboard(viewLink).then(() => {
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Link Copied!",
+ message: "View link copied to clipboard.",
+ });
+ });
+ const handleOpenInNewTab = () => window.open(`${viewLink}`, "_blank");
+
+ const MENU_ITEMS: TContextMenuItem[] = [
+ {
+ key: "edit",
+ action: () => handleEdit(true),
+ title: "Edit",
+ icon: Pencil,
+ },
+ {
+ key: "open-new-tab",
+ action: handleOpenInNewTab,
+ title: "Open in new tab",
+ icon: ExternalLink,
+ },
+ {
+ key: "copy-link",
+ action: handleCopyText,
+ title: "Copy link",
+ icon: Link,
+ },
+ {
+ key: "delete",
+ action: () => linkOperations.remove(linkId),
+ title: "Delete",
+ icon: Trash2,
+ },
+ ];
+
+ return (
+
+
+
+
+
+
{linkDetail.title || linkDetail.url}
+
{calculateTimeAgo(linkDetail.created_at)}
+
+
+
+ }
+ placement="bottom-end"
+ menuItemsClassName="z-20"
+ closeOnSelect
+ className=" my-auto"
+ >
+ {MENU_ITEMS.map((item) => (
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ item.action();
+ }}
+ className={cn("flex items-center gap-2 w-full ", {
+ "text-custom-text-400": item.disabled,
+ })}
+ disabled={item.disabled}
+ >
+ {item.icon && }
+
+
{item.title}
+ {item.description && (
+
+ {item.description}
+
+ )}
+
+
+ ))}
+
+
+ );
+});
diff --git a/web/core/components/home/widgets/links/links.tsx b/web/core/components/home/widgets/links/links.tsx
new file mode 100644
index 00000000000..194b1dfc121
--- /dev/null
+++ b/web/core/components/home/widgets/links/links.tsx
@@ -0,0 +1,77 @@
+import { FC, useEffect, useState } from "react";
+import { observer } from "mobx-react";
+// computed
+import { useHome } from "@/hooks/store/use-home";
+import { EWidgetKeys, WidgetLoader } from "../loaders";
+import { AddLink } from "./action";
+import { ProjectLinkDetail } from "./link-detail";
+import { TLinkOperations } from "./use-links";
+
+export type TLinkOperationsModal = Exclude;
+
+export type TProjectLinkList = {
+ linkOperations: TLinkOperationsModal;
+ workspaceSlug: string;
+};
+
+export const ProjectLinkList: FC = observer((props) => {
+ // props
+ const { linkOperations, workspaceSlug } = props;
+ // states
+ const [columnCount, setColumnCount] = useState(4);
+ const [showAll, setShowAll] = useState(false);
+ // hooks
+ const {
+ quickLinks: { getLinksByWorkspaceId, toggleLinkModal },
+ } = useHome();
+
+ const links = getLinksByWorkspaceId(workspaceSlug);
+
+ useEffect(() => {
+ const updateColumnCount = () => {
+ if (window.matchMedia("(min-width: 1024px)").matches) {
+ setColumnCount(4); // lg screens
+ } else if (window.matchMedia("(min-width: 768px)").matches) {
+ setColumnCount(3); // md screens
+ } else if (window.matchMedia("(min-width: 640px)").matches) {
+ setColumnCount(2); // sm screens
+ } else {
+ setColumnCount(1); // mobile
+ }
+ };
+
+ // Initial check
+ updateColumnCount();
+
+ // Add event listener for window resize
+ window.addEventListener("resize", updateColumnCount);
+
+ // Cleanup
+ return () => window.removeEventListener("resize", updateColumnCount);
+ }, []);
+
+ if (links === undefined) return ;
+
+ return (
+
+
+ {links &&
+ links.length > 0 &&
+ (showAll ? links : links.slice(0, 2 * columnCount - 1)).map((linkId) => (
+
+ ))}
+
+ {/* Add new link */}
+
toggleLinkModal(true)} />
+
+ {links.length > 2 * columnCount - 1 && (
+
setShowAll((state) => !state)}
+ >
+ {showAll ? "Show less" : "Show more"}
+
+ )}
+
+ );
+});
diff --git a/web/core/components/home/widgets/links/root.tsx b/web/core/components/home/widgets/links/root.tsx
new file mode 100644
index 00000000000..b5d96678fc7
--- /dev/null
+++ b/web/core/components/home/widgets/links/root.tsx
@@ -0,0 +1,40 @@
+import { observer } from "mobx-react";
+import useSWR from "swr";
+import { 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();
+
+ useSWR(
+ workspaceSlug ? `HOME_LINKS_${workspaceSlug}` : null,
+ workspaceSlug ? () => fetchLinks(workspaceSlug.toString()) : null,
+ {
+ revalidateIfStale: false,
+ revalidateOnFocus: false,
+ revalidateOnReconnect: false,
+ }
+ );
+ return (
+ <>
+ toggleLinkModal(false)}
+ linkOperations={linkOperations}
+ preloadedData={linkData}
+ setLinkData={setLinkData}
+ />
+
+ {/* rendering links */}
+
+
+ >
+ );
+});
diff --git a/web/core/components/home/widgets/links/use-links.tsx b/web/core/components/home/widgets/links/use-links.tsx
new file mode 100644
index 00000000000..fe107fd06a9
--- /dev/null
+++ b/web/core/components/home/widgets/links/use-links.tsx
@@ -0,0 +1,98 @@
+import { useMemo } from "react";
+import { TProjectLink } from "@plane/types";
+import { setToast, TOAST_TYPE } from "@plane/ui";
+import { useHome } from "@/hooks/store/use-home";
+
+export type TLinkOperations = {
+ create: (data: Partial) => Promise;
+ update: (linkId: string, data: Partial) => Promise;
+ remove: (linkId: string) => Promise;
+};
+export type TProjectLinkRoot = {
+ workspaceSlug: string;
+};
+
+export const useLinks = (workspaceSlug: string) => {
+ // hooks
+ const {
+ quickLinks: {
+ createLink,
+ updateLink,
+ removeLink,
+ isLinkModalOpen,
+ toggleLinkModal,
+ linkData,
+ setLinkData,
+ fetchLinks,
+ },
+ } = useHome();
+
+ const linkOperations: TLinkOperations = useMemo(
+ () => ({
+ create: async (data: Partial) => {
+ try {
+ if (!workspaceSlug) throw new Error("Missing required fields");
+ console.log("data", data, workspaceSlug);
+ await createLink(workspaceSlug, data);
+ setToast({
+ message: "The link has been successfully created",
+ type: TOAST_TYPE.SUCCESS,
+ title: "Link created",
+ });
+ toggleLinkModal(false);
+ } catch (error: any) {
+ console.error("error", error);
+ setToast({
+ message: error?.data?.error ?? "The link could not be created",
+ type: TOAST_TYPE.ERROR,
+ title: "Link not created",
+ });
+ throw error;
+ }
+ },
+ update: async (linkId: string, data: Partial) => {
+ try {
+ if (!workspaceSlug) throw new Error("Missing required fields");
+ await updateLink(workspaceSlug, linkId, data);
+ setToast({
+ message: "The link has been successfully updated",
+ type: TOAST_TYPE.SUCCESS,
+ title: "Link updated",
+ });
+ toggleLinkModal(false);
+ } catch (error) {
+ setToast({
+ message: "The link could not be updated",
+ type: TOAST_TYPE.ERROR,
+ title: "Link not updated",
+ });
+ throw error;
+ }
+ },
+ remove: async (linkId: string) => {
+ try {
+ if (!workspaceSlug) throw new Error("Missing required fields");
+ await removeLink(workspaceSlug, linkId);
+ setToast({
+ message: "The link has been successfully removed",
+ type: TOAST_TYPE.SUCCESS,
+ title: "Link removed",
+ });
+ } catch (error) {
+ setToast({
+ message: "The link could not be removed",
+ type: TOAST_TYPE.ERROR,
+ title: "Link not removed",
+ });
+ }
+ },
+ }),
+ [workspaceSlug]
+ );
+
+ const handleOnClose = () => {
+ toggleLinkModal(false);
+ };
+
+ return { linkOperations, handleOnClose, isLinkModalOpen, toggleLinkModal, linkData, setLinkData, fetchLinks };
+};
diff --git a/web/core/components/home/widgets/loaders/index.ts b/web/core/components/home/widgets/loaders/index.ts
new file mode 100644
index 00000000000..ee5286f0fbf
--- /dev/null
+++ b/web/core/components/home/widgets/loaders/index.ts
@@ -0,0 +1 @@
+export * from "./loader";
diff --git a/web/core/components/home/widgets/loaders/loader.tsx b/web/core/components/home/widgets/loaders/loader.tsx
new file mode 100644
index 00000000000..9fed9cafaaf
--- /dev/null
+++ b/web/core/components/home/widgets/loaders/loader.tsx
@@ -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) => {
+ const { widgetKey } = props;
+
+ const loaders = {
+ [EWidgetKeys.RECENT_ACTIVITY]: ,
+ [EWidgetKeys.QUICK_LINKS]: ,
+ };
+
+ return loaders[widgetKey];
+};
diff --git a/web/core/components/home/widgets/loaders/quick-links.tsx b/web/core/components/home/widgets/loaders/quick-links.tsx
new file mode 100644
index 00000000000..e4b8deafcb8
--- /dev/null
+++ b/web/core/components/home/widgets/loaders/quick-links.tsx
@@ -0,0 +1,13 @@
+"use client";
+
+import range from "lodash/range";
+// ui
+import { Loader } from "@plane/ui";
+
+export const QuickLinksWidgetLoader = () => (
+
+ {range(4).map((index) => (
+
+ ))}
+
+);
diff --git a/web/core/components/home/widgets/loaders/recent-activity.tsx b/web/core/components/home/widgets/loaders/recent-activity.tsx
new file mode 100644
index 00000000000..2f78db64a0a
--- /dev/null
+++ b/web/core/components/home/widgets/loaders/recent-activity.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import range from "lodash/range";
+// ui
+import { Loader } from "@plane/ui";
+
+export const RecentActivityWidgetLoader = () => (
+
+ {range(7).map((index) => (
+
+ ))}
+
+);
diff --git a/web/core/components/home/widgets/manage/index.tsx b/web/core/components/home/widgets/manage/index.tsx
new file mode 100644
index 00000000000..670212c6f59
--- /dev/null
+++ b/web/core/components/home/widgets/manage/index.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { FC } from "react";
+import { observer } from "mobx-react";
+// plane types
+// plane ui
+import { Button, EModalWidth, ModalCore } from "@plane/ui";
+import { WidgetList } from "./widget-list";
+
+export type TProps = {
+ workspaceSlug: string;
+ isModalOpen: boolean;
+ handleOnClose?: () => void;
+};
+
+export const ManageWidgetsModal: FC = observer((props) => {
+ // props
+ const { workspaceSlug, isModalOpen, handleOnClose } = props;
+
+ return (
+
+
+
Manage widgets
+
+
+
+ Cancel
+
+
+ Save changes
+
+
+
+
+ );
+});
diff --git a/web/core/components/home/widgets/manage/widget-item-drag-handle.tsx b/web/core/components/home/widgets/manage/widget-item-drag-handle.tsx
new file mode 100644
index 00000000000..2995ce08651
--- /dev/null
+++ b/web/core/components/home/widgets/manage/widget-item-drag-handle.tsx
@@ -0,0 +1,26 @@
+"use client";
+import React, { FC } from "react";
+import { observer } from "mobx-react";
+// ui
+import { DragHandle } from "@plane/ui";
+// helper
+import { cn } from "@/helpers/common.helper";
+
+type Props = {
+ sort_order: number | null;
+ isDragging: boolean;
+};
+
+export const WidgetItemDragHandle: FC = observer((props) => {
+ const { isDragging } = props;
+
+ return (
+
+
+
+ );
+});
diff --git a/web/core/components/home/widgets/manage/widget-item.tsx b/web/core/components/home/widgets/manage/widget-item.tsx
new file mode 100644
index 00000000000..453a96588c0
--- /dev/null
+++ b/web/core/components/home/widgets/manage/widget-item.tsx
@@ -0,0 +1,140 @@
+"use client";
+
+import React, { FC, useEffect, useRef, useState } from "react";
+import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
+import { DropTargetRecord, DragLocationHistory } from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
+import {
+ draggable,
+ dropTargetForElements,
+ ElementDragPayload,
+} 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";
+// plane helpers
+import { useParams } from "next/navigation";
+import { createRoot } from "react-dom/client";
+// ui
+import { InstructionType, TWidgetEntityData } from "@plane/types";
+// components
+import { DropIndicator, ToggleSwitch } from "@plane/ui";
+// helpers
+import { cn } from "@plane/utils";
+import { useHome } from "@/hooks/store/use-home";
+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 = observer((props) => {
+ // props
+ const { widgetId, isLastChild, handleDrop, handleToggle } = props;
+ const { workspaceSlug } = useParams();
+ //state
+ const [isDragging, setIsDragging] = useState(false);
+ const [instruction, setInstruction] = useState(undefined);
+ //ref
+ const elementRef = useRef(null);
+ // hooks
+ const { widgetsMap } = useHome();
+ // derived values
+ const widget = widgetsMap[widgetId] as TWidgetEntityData;
+
+ // 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({widget.key}
);
+ 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 (
+
+
+
+
+
+
{widget.key.replaceAll("_", " ")}
+
+
handleToggle(workspaceSlug.toString(), widget.key, !widget.is_enabled)}
+ />
+
+ {isLastChild &&
}
+
+ );
+});
diff --git a/web/core/components/home/widgets/manage/widget-list.tsx b/web/core/components/home/widgets/manage/widget-list.tsx
new file mode 100644
index 00000000000..526d51ba913
--- /dev/null
+++ b/web/core/components/home/widgets/manage/widget-list.tsx
@@ -0,0 +1,60 @@
+import {
+ DragLocationHistory,
+ DropTargetRecord,
+ ElementDragPayload,
+} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
+import { observer } from "mobx-react";
+import { setToast, TOAST_TYPE } from "@plane/ui";
+import { useHome } from "@/hooks/store/use-home";
+import { WidgetItem } from "./widget-item";
+import { getInstructionFromPayload, TargetData } from "./widget.helpers";
+
+export const WidgetList = observer(({ workspaceSlug }: { workspaceSlug: string }) => {
+ const { orderedWidgets, reorderWidget, toggleWidget } = useHome();
+
+ 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) {
+ try {
+ reorderWidget(workspaceSlug, sourceData.id, droppedId, instruction); /** sequence */
+ setToast({
+ type: TOAST_TYPE.SUCCESS,
+ title: "Success!",
+ message: "Widget reordered successfully.",
+ });
+ } catch {
+ setToast({
+ type: TOAST_TYPE.ERROR,
+ title: "Error!",
+ message: "Error occurred while reordering widget.",
+ });
+ }
+ }
+ };
+
+ return (
+
+ {orderedWidgets.map((widget, index) => (
+
+ ))}
+
+ );
+});
diff --git a/web/core/components/home/widgets/manage/widget.helpers.ts b/web/core/components/home/widgets/manage/widget.helpers.ts
new file mode 100644
index 00000000000..a72ee8028ae
--- /dev/null
+++ b/web/core/components/home/widgets/manage/widget.helpers.ts
@@ -0,0 +1,62 @@
+import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
+import { 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;
+};
diff --git a/web/core/components/home/widgets/recents/filters.tsx b/web/core/components/home/widgets/recents/filters.tsx
new file mode 100644
index 00000000000..da7e9e39ab3
--- /dev/null
+++ b/web/core/components/home/widgets/recents/filters.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import { FC } from "react";
+import { observer } from "mobx-react";
+import { ChevronDown } from "lucide-react";
+import { 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 }[];
+};
+
+export const FiltersDropdown: FC = observer((props) => {
+ const { className, activeFilter, setActiveFilter, filters } = props;
+
+ const DropdownOptions = () =>
+ filters?.map((filter) => (
+ {
+ setActiveFilter(filter.name);
+ }}
+ >
+ {filter.icon && {filter.icon}
}
+ {`${filter.name}s`}
+
+ ));
+
+ return (
+
+ {activeFilter && `${activeFilter}s`}
+
+
+ }
+ customButtonClassName="flex justify-center"
+ closeOnSelect
+ >
+
+
+ );
+});
diff --git a/web/core/components/home/widgets/recents/index.tsx b/web/core/components/home/widgets/recents/index.tsx
new file mode 100644
index 00000000000..4db8633961f
--- /dev/null
+++ b/web/core/components/home/widgets/recents/index.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { useRef, useState } from "react";
+import { observer } from "mobx-react";
+// types
+import useSWR from "swr";
+import { Briefcase, FileText } from "lucide-react";
+import { TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types";
+// components
+import { LayersIcon } from "@plane/ui";
+import { WorkspaceService } from "@/plane-web/services";
+import { EmptyWorkspace } 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 }[] = [
+ { name: "all item" },
+ { name: "issue", icon: },
+ { name: "page", icon: },
+ { name: "project", icon: },
+];
+
+export const RecentActivityWidget: React.FC = observer((props) => {
+ const { workspaceSlug } = props;
+ // state
+ const [filter, setFilter] = useState(filters[0].name);
+ // ref
+ const ref = useRef(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":
+ return ;
+ case "project":
+ return ;
+ case "issue":
+ return ;
+ default:
+ return <>>;
+ }
+ };
+
+ if (!isLoading && recents?.length === 0) return ;
+
+ return (
+
+
+ {isLoading &&
}
+ {!isLoading &&
+ recents?.length > 0 &&
+ recents.map((activity: TActivityEntityData) =>
{resolveRecent(activity)}
)}
+
+ );
+});
diff --git a/web/core/components/home/widgets/recents/issue.tsx b/web/core/components/home/widgets/recents/issue.tsx
new file mode 100644
index 00000000000..c5f76b09303
--- /dev/null
+++ b/web/core/components/home/widgets/recents/issue.tsx
@@ -0,0 +1,83 @@
+import { TActivityEntityData, TIssueEntityData } from "@plane/types";
+import { PriorityIcon, StateGroupIcon, Tooltip } from "@plane/ui";
+import { ListItem } from "@/components/core/list";
+import { MemberDropdown } from "@/components/dropdowns";
+import { calculateTimeAgo } from "@/helpers/date-time.helper";
+import { useIssueDetail, useProjectState } from "@/hooks/store";
+import { IssueIdentifier } from "@/plane-web/components/issues";
+
+type BlockProps = {
+ activity: TActivityEntityData;
+ ref: React.RefObject;
+ workspaceSlug: string;
+};
+export const RecentIssue = (props: BlockProps) => {
+ const { activity, ref, workspaceSlug } = props;
+ // hooks
+ const { getStateById } = useProjectState();
+ const { setPeekIssue } = useIssueDetail();
+ // derived values
+ const issueDetails: TIssueEntityData = activity.entity_data as TIssueEntityData;
+ const state = getStateById(issueDetails?.state);
+
+ return (
+
+
+ {issueDetails?.name}
+ {calculateTimeAgo(activity.visited_at)}
+
+ }
+ quickActionElement={
+
+
+
+
+
+
+
+
+
+ {issueDetails?.assignees?.length > 0 && (
+
+ {}}
+ 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=""
+ />
+
+ )}
+
+ }
+ parentRef={ref}
+ disableLink={false}
+ className="bg-transparent my-auto !px-2 border-none py-3"
+ itemClassName="my-auto"
+ onItemClick={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setPeekIssue({ workspaceSlug, projectId: issueDetails?.project_id, issueId: activity.entity_data.id });
+ }}
+ />
+ );
+};
diff --git a/web/core/components/home/widgets/recents/page.tsx b/web/core/components/home/widgets/recents/page.tsx
new file mode 100644
index 00000000000..91a350c9e89
--- /dev/null
+++ b/web/core/components/home/widgets/recents/page.tsx
@@ -0,0 +1,63 @@
+import { useRouter } from "next/navigation";
+import { FileText } from "lucide-react";
+import { TActivityEntityData, TPageEntityData } from "@plane/types";
+import { Avatar, Logo } from "@plane/ui";
+import { getFileURL } from "@plane/utils";
+import { ListItem } from "@/components/core/list";
+import { calculateTimeAgo } from "@/helpers/date-time.helper";
+import { useMember } from "@/hooks/store";
+
+type BlockProps = {
+ activity: TActivityEntityData;
+ ref: React.RefObject
;
+ workspaceSlug: string;
+};
+export const RecentPage = (props: BlockProps) => {
+ const { activity, ref, workspaceSlug } = props;
+ // router
+ const router = useRouter();
+ // hooks
+ const { getUserDetails } = useMember();
+ // derived values
+ const pageDetails: TPageEntityData = activity.entity_data as TPageEntityData;
+ const ownerDetails = getUserDetails(pageDetails?.owned_by);
+ return (
+
+
+ <>
+ {pageDetails?.logo_props?.in_use ? (
+
+ ) : (
+
+ )}
+ >
+
+
+ {pageDetails?.project_identifier}
+
+ {pageDetails?.name}
+ {calculateTimeAgo(activity.visited_at)}
+
+ }
+ quickActionElement={
+