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,142 @@
import { useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
import { Plus, StickyNote as StickyIcon, X } from "lucide-react";
// plane hooks
import { useOutsideClickDetector } from "@plane/hooks";
// plane ui
import { RecentStickyIcon, StickyNoteIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
// plane utils
import { cn } from "@plane/utils";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useSticky } from "@/hooks/use-stickies";
// components
import { STICKY_COLORS_LIST } from "../editor/sticky-editor/color-palette";
import { AllStickiesModal } from "./modal";
import { StickyNote } from "./sticky";
export const StickyActionBar = observer(() => {
// states
const [isExpanded, setIsExpanded] = useState(false);
const [newSticky, setNewSticky] = useState(false);
const [showRecentSticky, setShowRecentSticky] = useState(false);
// navigation
const { workspaceSlug } = useParams();
// refs
const ref = useRef(null);
// store hooks
const { stickies, activeStickyId, recentStickyId, updateActiveStickyId, fetchRecentSticky, toggleShowNewSticky } =
useSticky();
const { toggleAllStickiesModal, allStickiesModal } = useCommandPalette();
// derived values
const recentStickyBackgroundColor = recentStickyId
? STICKY_COLORS_LIST.find((c) => c.key === stickies[recentStickyId].background_color)?.backgroundColor
: STICKY_COLORS_LIST[0].backgroundColor;
useSWR(
workspaceSlug ? `WORKSPACE_STICKIES_RECENT_${workspaceSlug}` : null,
workspaceSlug ? () => fetchRecentSticky(workspaceSlug.toString()) : null
);
useOutsideClickDetector(ref, () => {
setNewSticky(false);
setShowRecentSticky(false);
setIsExpanded(false);
});
return (
<div
ref={ref}
className="sticky-action-bar__item flex flex-col bg-custom-background-90 rounded-full p-[2px] border-2 border-custom-primary-100/10 overflow-hidden"
>
<div
className={`flex flex-col gap-2 transition-all duration-300 ease-in-out origin-bottom ${isExpanded ? "scale-y-100 opacity-100 mb-2 " : "scale-y-0 opacity-0 h-0"}`}
>
<Tooltip tooltipContent="All stickies" isMobile={false} position="left">
<button
className="btn btn--icon rounded-full w-10 h-10 flex items-center justify-center shadow-sm bg-custom-background-100"
onClick={() => toggleAllStickiesModal(true)}
>
<RecentStickyIcon className="size-5 rotate-90 text-custom-text-350" />
</button>
</Tooltip>
{recentStickyId && (
<Tooltip
className="scale-75 -mr-30 translate-x-10"
tooltipContent={
<div className="-m-2 max-h-[150px]">
<StickyNote
className={"w-[290px]"}
workspaceSlug={workspaceSlug.toString()}
stickyId={newSticky ? activeStickyId : recentStickyId || ""}
/>
<div
className="absolute top-0 right-0 h-full w-full"
style={{
background: `linear-gradient(to top, ${recentStickyBackgroundColor}, transparent)`,
}}
/>
</div>
}
isMobile={false}
position="left"
disabled={showRecentSticky}
>
<button
className="btn btn--icon rounded-full w-10 h-10 flex items-center justify-center shadow-sm bg-custom-background-100"
onClick={() => setShowRecentSticky(true)}
style={{ color: recentStickyBackgroundColor }}
>
<StickyNoteIcon className={cn("size-5 rotate-90")} color={recentStickyBackgroundColor} />
</button>
</Tooltip>
)}
<Tooltip tooltipContent="Add sticky" isMobile={false} position="left">
<button
className="btn btn--icon rounded-full w-10 h-10 flex items-center justify-center shadow-sm bg-custom-background-100"
onClick={() => {
updateActiveStickyId("");
toggleShowNewSticky(true);
setNewSticky(true);
}}
>
<Plus className="size-5 rotate-90 text-custom-text-350" />
</button>
</Tooltip>
</div>
<button
className={`btn btn--icon rounded-full w-10 h-10 flex items-center justify-center shadow-sm bg-custom-background-100 transition-transform duration-300 ${isExpanded ? "rotate-180" : ""}`}
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? (
<X className="size-5 text-custom-text-350" />
) : (
<StickyIcon className="size-5 rotate-90 text-custom-text-350" />
)}
</button>
<div
className={cn(
"absolute bottom-16 right-0 z-[20]",
"transform transition-all duration-300 ease-in-out",
newSticky || showRecentSticky ? "translate-y-[0%] min-h-[300px]" : "translate-y-[100%] h-0"
)}
>
{(newSticky || (showRecentSticky && recentStickyId)) && (
<StickyNote
className={"w-[290px]"}
onClose={() => (newSticky ? setNewSticky(false) : setShowRecentSticky(false))}
workspaceSlug={workspaceSlug.toString()}
stickyId={newSticky ? activeStickyId : recentStickyId || ""}
/>
)}
</div>
<AllStickiesModal isOpen={allStickiesModal} handleClose={() => toggleAllStickiesModal(false)} />
</div>
);
});

View File

@@ -0,0 +1,48 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
// ui
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { AlertModalCore } from "@plane/ui";
interface IStickyDelete {
isOpen: boolean;
handleSubmit: () => Promise<void>;
handleClose: () => void;
}
export const StickyDeleteModal: React.FC<IStickyDelete> = observer((props) => {
const { isOpen, handleClose, handleSubmit } = props;
// states
const [loader, setLoader] = useState(false);
// hooks
const { t } = useTranslation();
const formSubmit = async () => {
try {
setLoader(true);
await handleSubmit();
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: t("stickies.toasts.not_removed.title"),
message: t("stickies.toasts.not_removed.message"),
});
} finally {
setLoader(false);
}
};
return (
<AlertModalCore
handleClose={handleClose}
handleSubmit={formSubmit}
isSubmitting={loader}
isOpen={isOpen}
title={t("stickies.delete")}
content={t("stickies.delete_confirmation")}
/>
);
});

View File

@@ -0,0 +1,62 @@
import { useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
import { STICKIES_PER_PAGE } from "@plane/constants";
import { ContentWrapper, Loader } from "@plane/ui";
import { cn } from "@plane/utils";
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
import { useSticky } from "@/hooks/use-stickies";
import { StickiesLayout } from "./stickies-list";
export const StickiesInfinite = observer(() => {
const { workspaceSlug } = useParams();
// hooks
const { fetchWorkspaceStickies, fetchNextWorkspaceStickies, getWorkspaceStickyIds, loader, paginationInfo } =
useSticky();
//state
const [elementRef, setElementRef] = useState<HTMLDivElement | null>(null);
// ref
const containerRef = useRef<HTMLDivElement>(null);
useSWR(
workspaceSlug ? `WORKSPACE_STICKIES_${workspaceSlug}` : null,
workspaceSlug ? () => fetchWorkspaceStickies(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
const handleLoadMore = () => {
if (loader === "pagination") return;
fetchNextWorkspaceStickies(workspaceSlug?.toString());
};
const hasNextPage = paginationInfo?.next_page_results && paginationInfo?.next_cursor !== undefined;
const shouldObserve = hasNextPage && loader !== "pagination";
const workspaceStickies = getWorkspaceStickyIds(workspaceSlug?.toString());
useIntersectionObserver(containerRef, shouldObserve ? elementRef : null, handleLoadMore);
return (
<ContentWrapper ref={containerRef} className="space-y-4">
<StickiesLayout
workspaceSlug={workspaceSlug.toString()}
intersectionElement={
hasNextPage &&
workspaceStickies?.length >= STICKIES_PER_PAGE && (
<div
className={cn("flex min-h-[300px] box-border p-2 w-full")}
ref={setElementRef}
id="intersection-element"
>
<div className="flex w-full rounded min-h-[300px]">
<Loader className="w-full h-full">
<Loader.Item height="100%" width="100%" />
</Loader>
</div>
</div>
)
}
/>
</ContentWrapper>
);
});

View File

@@ -0,0 +1,201 @@
import { useEffect, useRef, useState } from "react";
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 { observer } from "mobx-react";
import { usePathname } from "next/navigation";
import Masonry from "react-masonry-component";
import { Plus } from "lucide-react";
// plane imports
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EUserWorkspaceRoles } from "@plane/types";
// components
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
import { StickiesEmptyState } from "@/components/home/widgets/empty-states/stickies";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import { useSticky } from "@/hooks/use-stickies";
// local imports
import { useStickyOperations } from "../sticky/use-operations";
import { StickiesLoader } from "./stickies-loader";
import { StickyDNDWrapper } from "./sticky-dnd-wrapper";
import { getInstructionFromPayload } from "./sticky.helpers";
type TStickiesLayout = {
workspaceSlug: string;
intersectionElement?: React.ReactNode | null;
};
type TProps = TStickiesLayout & {
columnCount: number;
};
export const StickiesList = observer((props: TProps) => {
const { workspaceSlug, intersectionElement, columnCount } = props;
// navigation
const pathname = usePathname();
// plane hooks
const { t } = useTranslation();
// store hooks
const { getWorkspaceStickyIds, toggleShowNewSticky, searchQuery, loader } = useSticky();
const { allowPermissions } = useUserPermissions();
// sticky operations
const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() });
// derived values
const workspaceStickyIds = getWorkspaceStickyIds(workspaceSlug?.toString());
const itemWidth = `${100 / columnCount}%`;
const totalRows = Math.ceil(workspaceStickyIds.length / columnCount);
const isStickiesPage = pathname?.includes("stickies");
const hasGuestLevelPermissions = allowPermissions(
[EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.GUEST],
EUserPermissionsLevel.WORKSPACE
);
const stickiesResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/stickies/stickies" });
const stickiesSearchResolvedPath = useResolvedAssetPath({
basePath: "/empty-state/stickies/stickies-search",
});
const masonryRef = useRef<any>(null);
const handleLayout = () => {
if (masonryRef.current) {
// Force reflow
masonryRef.current.performLayout();
}
};
// Function to determine if an item is in first or last row
const getRowPositions = (index: number) => {
const currentRow = Math.floor(index / columnCount);
return {
isInFirstRow: currentRow === 0,
isInLastRow: currentRow === totalRows - 1 || index >= workspaceStickyIds.length - columnCount,
};
};
const handleDrop = (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => {
const dropTargets = location?.current?.dropTargets ?? [];
if (!dropTargets || dropTargets.length <= 0) return;
const dropTarget = dropTargets[0];
if (!dropTarget?.data?.id || !source.data?.id) return;
const instruction = getInstructionFromPayload(dropTarget, source, location);
const droppedId = dropTarget.data.id;
const sourceId = source.data.id;
try {
if (!instruction || !droppedId || !sourceId) return;
stickyOperations.updatePosition(workspaceSlug, sourceId as string, droppedId as string, instruction);
} catch (error) {
console.error("Error reordering sticky:", error);
}
};
if (loader === "init-loader") {
return <StickiesLoader />;
}
if (loader === "loaded" && workspaceStickyIds.length === 0) {
return (
<div className="size-full grid place-items-center">
{isStickiesPage ? (
<>
{searchQuery ? (
<SimpleEmptyState
title={t("stickies.empty_state.search.title")}
description={t("stickies.empty_state.search.description")}
assetPath={stickiesSearchResolvedPath}
/>
) : (
<DetailedEmptyState
title={t("stickies.empty_state.general.title")}
description={t("stickies.empty_state.general.description")}
assetPath={stickiesResolvedPath}
primaryButton={{
prependIcon: <Plus className="size-4" />,
text: t("stickies.empty_state.general.primary_button.text"),
onClick: () => {
toggleShowNewSticky(true);
stickyOperations.create();
},
disabled: !hasGuestLevelPermissions,
}}
/>
)}
</>
) : (
<StickiesEmptyState />
)}
</div>
);
}
return (
<div className="transition-opacity duration-300 ease-in-out">
{/* @ts-expect-error type mismatch here */}
<Masonry elementType="div" ref={masonryRef}>
{workspaceStickyIds.map((stickyId, index) => {
const { isInFirstRow, isInLastRow } = getRowPositions(index);
return (
<StickyDNDWrapper
key={stickyId}
stickyId={stickyId}
workspaceSlug={workspaceSlug.toString()}
itemWidth={itemWidth}
handleDrop={handleDrop}
isLastChild={index === workspaceStickyIds.length - 1}
isInFirstRow={isInFirstRow}
isInLastRow={isInLastRow}
handleLayout={handleLayout}
/>
);
})}
{intersectionElement && <div style={{ width: itemWidth }}>{intersectionElement}</div>}
</Masonry>
</div>
);
});
export const StickiesLayout = (props: TStickiesLayout) => {
// states
const [containerWidth, setContainerWidth] = useState<number | null>(null);
// refs
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref?.current) return;
setContainerWidth(ref?.current.offsetWidth);
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerWidth(entry.contentRect.width);
}
});
resizeObserver.observe(ref?.current);
return () => resizeObserver.disconnect();
}, []);
const getColumnCount = (width: number | null): number => {
if (width === null) return 4;
if (width < 640) return 2; // sm
if (width < 850) return 3; // md
if (width < 1024) return 4; // lg
if (width < 1280) return 5; // xl
return 6; // 2xl and above
};
const columnCount = getColumnCount(containerWidth);
return (
<div ref={ref} className="size-full">
<StickiesList {...props} columnCount={columnCount} />
</div>
);
};

View File

@@ -0,0 +1,45 @@
// plane ui
import { Loader } from "@plane/ui";
export const StickiesLoader = () => (
<div className="overflow-scroll pb-2 grid grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, index) => (
<Loader key={index} className="space-y-5 border border-custom-border-200 p-3 rounded">
<div className="space-y-2">
<Loader.Item height="20px" />
<Loader.Item height="15px" width="75%" />
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Loader.Item height="15px" width="15px" className="flex-shrink-0" />
<Loader.Item height="15px" width="100%" />
</div>
<div className="flex items-center gap-2">
<Loader.Item height="15px" width="15px" className="flex-shrink-0" />
<Loader.Item height="15px" width="75%" />
</div>
<div className="flex items-center gap-2">
<Loader.Item height="15px" width="15px" className="flex-shrink-0" />
<Loader.Item height="15px" width="90%" />
</div>
<div className="flex items-center gap-2">
<Loader.Item height="15px" width="15px" className="flex-shrink-0" />
<Loader.Item height="15px" width="60%" />
</div>
<div className="flex items-center gap-2">
<Loader.Item height="15px" width="15px" className="flex-shrink-0" />
<Loader.Item height="15px" width="50%" />
</div>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Loader.Item height="25px" width="25px" />
<Loader.Item height="25px" width="25px" />
<Loader.Item height="25px" width="25px" />
</div>
<Loader.Item height="25px" width="25px" className="flex-shrink-0" />
</div>
</Loader>
))}
</div>
);

View File

@@ -0,0 +1,52 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import useSWR from "swr";
// plane utils
import { useTranslation } from "@plane/i18n";
import { cn } from "@plane/utils";
// hooks
import { useSticky } from "@/hooks/use-stickies";
// components
import { ContentOverflowWrapper } from "../../core/content-overflow-HOC";
import { StickiesLayout } from "./stickies-list";
type StickiesTruncatedProps = {
handleClose?: () => void;
};
export const StickiesTruncated = observer((props: StickiesTruncatedProps) => {
const { handleClose = () => {} } = props;
// navigation
const { workspaceSlug } = useParams();
// store hooks
const { fetchWorkspaceStickies } = useSticky();
const { t } = useTranslation();
useSWR(
workspaceSlug ? `WORKSPACE_STICKIES_${workspaceSlug}` : null,
workspaceSlug ? () => fetchWorkspaceStickies(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
return (
<ContentOverflowWrapper
maxHeight={620}
containerClassName="pb-2 box-border"
fallback={null}
customButton={
<Link
href={`/${workspaceSlug}/stickies`}
className={cn(
"gap-1 w-full text-custom-primary-100 text-sm font-medium transition-opacity duration-300 bg-custom-background-90/20"
)}
onClick={handleClose}
>
{t("show_all")}
</Link>
}
>
<StickiesLayout workspaceSlug={workspaceSlug?.toString()} />
</ContentOverflowWrapper>
);
});

View File

@@ -0,0 +1,137 @@
import { 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 { usePathname } from "next/navigation";
import { createRoot } from "react-dom/client";
// plane types
import type { InstructionType } from "@plane/types";
// plane ui
import { DropIndicator } from "@plane/ui";
// components
import { StickyNote } from "../sticky";
// helpers
import { getInstructionFromPayload } from "./sticky.helpers";
type Props = {
stickyId: string;
workspaceSlug: string;
itemWidth: string;
isLastChild: boolean;
isInFirstRow: boolean;
isInLastRow: boolean;
handleDrop: (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => void;
handleLayout: () => void;
};
export const StickyDNDWrapper = observer((props: Props) => {
const { stickyId, workspaceSlug, itemWidth, isLastChild, isInFirstRow, isInLastRow, handleDrop, handleLayout } =
props;
// states
const [isDragging, setIsDragging] = useState(false);
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
// refs
const elementRef = useRef<HTMLDivElement>(null);
// navigation
const pathname = usePathname();
useEffect(() => {
const element = elementRef.current;
if (!element) return;
const initialData = { id: stickyId, type: "sticky" };
if (pathname.includes("stickies"))
return combine(
draggable({
element,
dragHandle: element,
getInitialData: () => initialData,
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
onGenerateDragPreview: ({ nativeSetDragImage }) => {
setCustomNativeDragPreview({
getOffset: pointerOutsideOfPreview({ x: "-200px", y: "0px" }),
render: ({ container }) => {
const root = createRoot(container);
root.render(
<div className="scale-50">
<div className="-m-2 max-h-[150px]">
<StickyNote
className={"w-[290px]"}
workspaceSlug={workspaceSlug.toString()}
stickyId={stickyId}
showToolbar={false}
/>
</div>
</div>
);
return () => root.unmount();
},
nativeSetDragImage,
});
},
}),
dropTargetForElements({
element,
canDrop: ({ source }) => source.data?.type === "sticky",
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);
},
})
);
}, [handleDrop, isDragging, isLastChild, pathname, stickyId, workspaceSlug]);
return (
<div
className="flex flex-col box-border p-[8px]"
style={{
width: itemWidth,
}}
>
{/* {!isInFirstRow && <DropIndicator isVisible={instruction === "reorder-above"} />} */}
<StickyNote
key={stickyId || "new"}
workspaceSlug={workspaceSlug}
stickyId={stickyId}
handleLayout={handleLayout}
/>
{/* {!isInLastRow && <DropIndicator isVisible={instruction === "reorder-below"} />} */}
</div>
);
});

View File

@@ -0,0 +1,45 @@
import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import type { InstructionType, IPragmaticPayloadLocation, TDropTarget } 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 sticky 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 sticky,
// 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;
};

View File

@@ -0,0 +1,15 @@
import { EModalWidth, ModalCore } from "@plane/ui";
import { Stickies } from "./stickies";
type TProps = {
isOpen: boolean;
handleClose: () => void;
};
export const AllStickiesModal = (props: TProps) => {
const { isOpen, handleClose } = props;
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} width={EModalWidth.VXL}>
<Stickies handleClose={handleClose} />
</ModalCore>
);
};

View File

@@ -0,0 +1,101 @@
"use client";
import type { FC } from "react";
import { useCallback, useRef, useState } from "react";
import { debounce } from "lodash-es";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Search, X } from "lucide-react";
// plane hooks
import { useOutsideClickDetector } from "@plane/hooks";
// helpers
import { useTranslation } from "@plane/i18n";
import { cn } from "@plane/utils";
import { useSticky } from "@/hooks/use-stickies";
export const StickySearch: FC = observer(() => {
// router
const { workspaceSlug } = useParams();
// hooks
const { searchQuery, updateSearchQuery, fetchWorkspaceStickies } = useSticky();
const { t } = useTranslation();
// refs
const inputRef = useRef<HTMLInputElement>(null);
// states
const [isSearchOpen, setIsSearchOpen] = useState(false);
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
});
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (searchQuery && searchQuery.trim() !== "") {
updateSearchQuery("");
fetchStickies();
} else setIsSearchOpen(false);
}
};
const fetchStickies = async () => {
await fetchWorkspaceStickies(workspaceSlug.toString());
};
const debouncedSearch = useCallback(
debounce(async () => {
await fetchStickies();
}, 500),
[fetchWorkspaceStickies]
);
return (
<div className="flex items-center mr-2 my-auto">
{!isSearchOpen && (
<button
type="button"
className="-mr-1 p-1 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<Search className=" size-4 " />
</button>
)}
<div
className={cn(
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
{
"w-30 md:w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
placeholder={t("stickies.search_placeholder")}
value={searchQuery}
onChange={(e) => {
updateSearchQuery(e.target.value);
debouncedSearch();
}}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
updateSearchQuery("");
setIsSearchOpen(false);
fetchStickies();
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,74 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Plus, X } from "lucide-react";
// plane ui
import { RecentStickyIcon } from "@plane/propel/icons";
// hooks
import { useSticky } from "@/hooks/use-stickies";
// components
import { StickiesTruncated } from "../layout/stickies-truncated";
import { useStickyOperations } from "../sticky/use-operations";
import { StickySearch } from "./search";
type TProps = {
handleClose?: () => void;
};
export const Stickies = observer((props: TProps) => {
const { handleClose } = props;
// navigation
const { workspaceSlug } = useParams();
// store hooks
const { creatingSticky, toggleShowNewSticky } = useSticky();
// sticky operations
const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() });
return (
<div className="p-6 pb-0 min-h-[620px]">
{/* header */}
<div className="flex items-center justify-between mb-6">
{/* Title */}
<div className="text-custom-text-200 flex items-center gap-2">
<RecentStickyIcon className="size-5 rotate-90 flex-shrink-0" />
<p className="text-xl font-medium">Your stickies</p>
</div>
{/* actions */}
<div className="flex gap-2">
<StickySearch />
<button
onClick={() => {
toggleShowNewSticky(true);
stickyOperations.create();
}}
className="flex gap-1 text-sm font-medium text-custom-primary-100 my-auto"
disabled={creatingSticky}
>
<Plus className="size-4 my-auto" /> <span>Add sticky</span>
{creatingSticky && (
<div className="flex items-center justify-center ml-2">
<div
className={`w-4 h-4 border-2 border-t-transparent rounded-full animate-spin border-custom-primary-100`}
role="status"
aria-label="loading"
/>
</div>
)}
</button>
{handleClose && (
<button
type="button"
onClick={handleClose}
className="flex-shrink-0 grid place-items-center text-custom-text-300 hover:text-custom-text-100 hover:bg-custom-background-80 rounded p-1 transition-colors my-auto"
>
<X className="text-custom-text-400 size-4" />
</button>
)}
</div>
</div>
{/* content */}
<div className="mb-4 max-h-[625px] overflow-scroll">
<StickiesTruncated handleClose={handleClose} />
</div>
</div>
);
});

View File

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

View File

@@ -0,0 +1,100 @@
import { useCallback, useEffect, useRef } from "react";
// import dynamic from "next/dynamic";
import { usePathname } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import type { TSticky } from "@plane/types";
import { cn, isCommentEmpty } from "@plane/utils";
import { StickyEditor } from "@/components/editor/sticky-editor";
// hooks
import { useWorkspace } from "@/hooks/store/use-workspace";
// const StickyEditor = dynamic(() => import("../../editor/sticky-editor").then((mod) => mod.StickyEditor), {
// ssr: false,
// });
type TProps = {
stickyData: Partial<TSticky> | undefined;
workspaceSlug: string;
handleUpdate: (payload: Partial<TSticky>) => void;
stickyId: string | undefined;
showToolbar?: boolean;
handleChange: (data: Partial<TSticky>) => Promise<void>;
handleDelete: () => void;
};
export const StickyInput = (props: TProps) => {
const { stickyData, workspaceSlug, handleUpdate, stickyId, handleDelete, handleChange, showToolbar } = props;
// refs
const editorRef = useRef<EditorRefApi>(null);
// navigation
const pathname = usePathname();
// store hooks
const { getWorkspaceBySlug } = useWorkspace();
// derived values
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id?.toString() ?? "";
const isStickiesPage = pathname?.includes("stickies");
// form info
const { handleSubmit, reset, control } = useForm<TSticky>({
defaultValues: {
description_html: stickyData?.description_html,
},
});
// handle description update
const handleFormSubmit = useCallback(
async (formdata: Partial<TSticky>) => {
await handleUpdate({
description_html: formdata.description_html ?? "<p></p>",
});
},
[handleUpdate]
);
// reset form values
useEffect(() => {
if (!stickyId) return;
reset({
id: stickyId,
description_html: stickyData?.description_html?.trim() === "" ? "<p></p>" : stickyData?.description_html,
});
}, [stickyData, stickyId, reset]);
return (
<div className="flex-1">
<Controller
name="description_html"
control={control}
render={({ field: { onChange } }) => (
<StickyEditor
id={`description-${stickyId}`}
initialValue={stickyData?.description_html ?? ""}
value={null}
workspaceSlug={workspaceSlug}
workspaceId={workspaceId}
onChange={(_description, description_html) => {
onChange(description_html);
handleSubmit(handleFormSubmit)();
}}
placeholder={(_, value) => {
const isContentEmpty = isCommentEmpty(value);
if (!isContentEmpty) return "";
return "Click to type here";
}}
containerClassName={cn(
"w-full min-h-[256px] max-h-[540px] overflow-y-scroll vertical-scrollbar scrollbar-sm p-4 text-base",
{
"max-h-[588px]": isStickiesPage,
}
)}
uploadFile={async () => ""}
showToolbar={showToolbar}
parentClassName="border-none p-0"
handleDelete={handleDelete}
handleColorChange={handleChange}
ref={editorRef}
/>
)}
/>
</div>
);
};

View File

@@ -0,0 +1,106 @@
import { useCallback, useState } from "react";
import { debounce } from "lodash-es";
import { observer } from "mobx-react";
import { Minimize2 } from "lucide-react";
// plane types
import type { TSticky } from "@plane/types";
// plane utils
import { cn } from "@plane/utils";
// hooks
import { useSticky } from "@/hooks/use-stickies";
// components
import { STICKY_COLORS_LIST } from "../../editor/sticky-editor/color-palette";
import { StickyDeleteModal } from "../delete-modal";
import { StickyInput } from "./inputs";
import { getRandomStickyColor, useStickyOperations } from "./use-operations";
type TProps = {
onClose?: () => void;
workspaceSlug: string;
className?: string;
stickyId: string | undefined;
showToolbar?: boolean;
handleLayout?: () => void;
};
export const StickyNote = observer((props: TProps) => {
const { onClose, workspaceSlug, className = "", stickyId, showToolbar, handleLayout } = props;
// navigation
// const pathName = usePathname();
// states
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
// store hooks
const { stickies } = useSticky();
// sticky operations
const { stickyOperations } = useStickyOperations({ workspaceSlug });
// derived values
const stickyData: Partial<TSticky> = stickyId ? stickies[stickyId] : { background_color: getRandomStickyColor() };
// const isStickiesPage = pathName?.includes("stickies");
const backgroundColor =
STICKY_COLORS_LIST.find((c) => c.key === stickyData?.background_color)?.backgroundColor ||
STICKY_COLORS_LIST[0].backgroundColor;
const handleChange = useCallback(
async (payload: Partial<TSticky>) => {
if (stickyId) {
await stickyOperations.update(stickyId, payload);
} else {
await stickyOperations.create({
...stickyData,
...payload,
});
}
},
[stickyId, stickyOperations]
);
const debouncedFormSave = useCallback(
debounce(async (payload: Partial<TSticky>) => {
await handleChange(payload);
}, 500),
[stickyOperations, stickyData, handleChange]
);
const handleDelete = async () => {
if (!stickyId) return;
onClose?.();
stickyOperations.remove(stickyId);
};
return (
<>
<StickyDeleteModal
isOpen={isDeleteModalOpen}
handleSubmit={handleDelete}
handleClose={() => setIsDeleteModalOpen(false)}
/>
<div
className={cn("w-full h-fit flex flex-col rounded group/sticky overflow-y-scroll", className)}
style={{
backgroundColor,
}}
>
{/* {isStickiesPage && <StickyItemDragHandle isDragging={false} />}{" "} */}
{onClose && (
<button type="button" className="flex-shrink-0 flex justify-end p-2.5" onClick={onClose}>
<Minimize2 className="size-4" />
</button>
)}
{/* inputs */}
<div className="-mt-2">
<StickyInput
stickyData={stickyData}
workspaceSlug={workspaceSlug}
handleUpdate={(payload) => {
handleLayout?.();
debouncedFormSave(payload);
}}
stickyId={stickyId}
handleDelete={() => setIsDeleteModalOpen(true)}
handleChange={handleChange}
showToolbar={showToolbar}
/>
</div>
</div>
</>
);
});

View File

@@ -0,0 +1,29 @@
"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 = {
isDragging: boolean;
};
export const StickyItemDragHandle: FC<Props> = observer((props) => {
const { isDragging } = props;
return (
<div
className={cn(
"hidden group-hover/sticky:flex absolute top-3 left-1/2 -translate-x-1/2 items-center justify-center rounded text-custom-sidebar-text-400 cursor-grab mr-2 rotate-90",
{
"cursor-grabbing": isDragging,
}
)}
>
<DragHandle className="bg-transparent" />
</div>
);
});

View File

@@ -0,0 +1,148 @@
import { useMemo } from "react";
// plane types
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { InstructionType, TSticky } from "@plane/types";
// plane utils
import { isCommentEmpty } from "@plane/utils";
// components
import { STICKY_COLORS_LIST } from "@/components/editor/sticky-editor/color-palette";
// hooks
import { useSticky } from "@/hooks/use-stickies";
export type TOperations = {
create: (data?: Partial<TSticky>) => Promise<void>;
update: (stickyId: string, data: Partial<TSticky>) => Promise<void>;
remove: (stickyId: string) => Promise<void>;
updatePosition: (
workspaceSlug: string,
sourceId: string,
droppedId: string,
instruction: InstructionType
) => Promise<void>;
};
type TProps = {
workspaceSlug: string;
};
export const getRandomStickyColor = (): string => {
const randomIndex = Math.floor(Math.random() * STICKY_COLORS_LIST.length);
return STICKY_COLORS_LIST[randomIndex].key;
};
export const useStickyOperations = (props: TProps) => {
const { workspaceSlug } = props;
// store hooks
const { stickies, getWorkspaceStickyIds, createSticky, updateSticky, deleteSticky, updateStickyPosition } =
useSticky();
const { t } = useTranslation();
const isValid = (data: Partial<TSticky>) => {
if (data.name && data.name.length > 100) {
setToast({
type: TOAST_TYPE.ERROR,
title: t("stickies.toasts.not_updated.title"),
message: t("stickies.toasts.errors.wrong_name"),
});
return false;
}
return true;
};
const stickyOperations: TOperations = useMemo(
() => ({
create: async (data?: Partial<TSticky>) => {
try {
const payload: Partial<TSticky> = {
background_color: getRandomStickyColor(),
...data,
};
const workspaceStickIds = getWorkspaceStickyIds(workspaceSlug);
// check if latest sticky is empty
if (workspaceStickIds && workspaceStickIds.length >= 0) {
const latestSticky = stickies[workspaceStickIds[0]];
if (latestSticky && (!latestSticky.description_html || isCommentEmpty(latestSticky.description_html))) {
setToast({
message: t("stickies.toasts.errors.already_exists"),
type: TOAST_TYPE.WARNING,
title: t("stickies.toasts.not_created.title"),
});
return;
}
}
if (!workspaceSlug) throw new Error("Missing required fields");
if (!isValid(payload)) return;
await createSticky(workspaceSlug, payload);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("stickies.toasts.created.title"),
message: t("stickies.toasts.created.message"),
});
} catch (error: any) {
console.error("Error in creating sticky:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("stickies.toasts.not_created.title"),
message: error?.data?.error ?? t("stickies.toasts.not_created.message"),
});
}
},
update: async (stickyId: string, data: Partial<TSticky>) => {
try {
if (!workspaceSlug) throw new Error("Missing required fields");
if (!isValid(data)) return;
await updateSticky(workspaceSlug, stickyId, data);
} catch (error) {
console.error("Error in updating sticky:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("stickies.toasts.not_updated.title"),
message: t("stickies.toasts.not_updated.message"),
});
}
},
remove: async (stickyId: string) => {
try {
if (!workspaceSlug) throw new Error("Missing required fields");
await deleteSticky(workspaceSlug, stickyId);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("stickies.toasts.removed.title"),
message: t("stickies.toasts.removed.message"),
});
} catch (error) {
console.error("Error in removing sticky:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("stickies.toasts.not_removed.title"),
message: t("stickies.toasts.not_removed.message"),
});
}
},
updatePosition: async (
workspaceSlug: string,
sourceId: string,
droppedId: string,
instruction: InstructionType
) => {
try {
if (!workspaceSlug) throw new Error("Missing required fields");
await updateStickyPosition(workspaceSlug, sourceId, droppedId, instruction);
} catch (error) {
console.error("Error in updating sticky position:", error);
setToast({
type: TOAST_TYPE.ERROR,
title: t("stickies.toasts.not_updated.title"),
message: t("stickies.toasts.not_updated.message"),
});
}
},
}),
[createSticky, deleteSticky, getWorkspaceStickyIds, stickies, updateSticky, updateStickyPosition, workspaceSlug]
);
return {
stickyOperations,
};
};

View File

@@ -0,0 +1,56 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Plus } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
// hooks
import { useSticky } from "@/hooks/use-stickies";
// local imports
import { StickiesTruncated } from "./layout/stickies-truncated";
import { StickySearch } from "./modal/search";
import { useStickyOperations } from "./sticky/use-operations";
export const StickiesWidget: React.FC = observer(() => {
// params
const { workspaceSlug } = useParams();
// store hooks
const { creatingSticky, toggleShowNewSticky } = useSticky();
const { t } = useTranslation();
// sticky operations
const { stickyOperations } = useStickyOperations({
workspaceSlug: workspaceSlug?.toString() ?? "",
});
return (
<div>
<div className="flex items-center justify-between mb-4">
<div className="text-base font-semibold text-custom-text-350">{t("stickies.title")}</div>
{/* actions */}
<div className="flex gap-2">
<StickySearch />
<button
onClick={() => {
toggleShowNewSticky(true);
stickyOperations.create();
}}
className="flex gap-1 text-sm font-medium text-custom-primary-100 my-auto"
disabled={creatingSticky}
>
<Plus className="size-4 my-auto" />
<span>{t("stickies.add")}</span>
{creatingSticky && (
<div
className="size-4 border-2 border-t-transparent border-custom-primary-100 rounded-full animate-spin"
role="status"
aria-label="loading"
/>
)}
</button>
</div>
</div>
<div className="-mx-2">
<StickiesTruncated />
</div>
</div>
);
});