snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
This commit is contained in:
150
apps/web/core/components/stickies/action-bar.tsx
Normal file
150
apps/web/core/components/stickies/action-bar.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane hooks
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
// plane ui
|
||||
import { AddOutline, CloseOutline, MultipleStickyOutline, StickyNoteOutline } from "@makeplane/propel/icons";
|
||||
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from "@makeplane/propel/components/preview-card";
|
||||
import { Tooltip } from "@makeplane/propel/components/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(function StickyActionBar() {
|
||||
// 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);
|
||||
});
|
||||
|
||||
const recentStickyButton = (
|
||||
<button
|
||||
className="btn btn--icon shadow-sm flex h-10 w-10 items-center justify-center rounded-full bg-surface-1"
|
||||
onClick={() => setShowRecentSticky(true)}
|
||||
style={{ color: recentStickyBackgroundColor }}
|
||||
>
|
||||
<StickyNoteOutline className={cn("size-5 rotate-90")} color={recentStickyBackgroundColor} />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="sticky-action-bar__item flex flex-col overflow-hidden rounded-full border-2 border-accent-strong/10 bg-surface-2 p-[2px]"
|
||||
>
|
||||
<div
|
||||
className={`flex origin-bottom flex-col gap-2 transition-all duration-300 ease-in-out ${isExpanded ? "mb-2 scale-y-100 opacity-100 " : "h-0 scale-y-0 opacity-0"}`}
|
||||
>
|
||||
<Tooltip label="All stickies" side="left">
|
||||
<button
|
||||
className="btn btn--icon shadow-sm flex h-10 w-10 items-center justify-center rounded-full bg-surface-1"
|
||||
onClick={() => toggleAllStickiesModal(true)}
|
||||
>
|
||||
<MultipleStickyOutline className="size-5 rotate-90 text-tertiary" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{recentStickyId &&
|
||||
(showRecentSticky ? (
|
||||
recentStickyButton
|
||||
) : (
|
||||
<PreviewCard>
|
||||
<PreviewCardTrigger render={recentStickyButton} />
|
||||
<PreviewCardContent side="left">
|
||||
<div className="relative max-h-[150px] overflow-hidden rounded-lg">
|
||||
<StickyNote
|
||||
className="w-full"
|
||||
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>
|
||||
</PreviewCardContent>
|
||||
</PreviewCard>
|
||||
))}
|
||||
<Tooltip label="Add sticky" side="left">
|
||||
<button
|
||||
className="btn btn--icon shadow-sm flex h-10 w-10 items-center justify-center rounded-full bg-surface-1"
|
||||
onClick={() => {
|
||||
updateActiveStickyId("");
|
||||
toggleShowNewSticky(true);
|
||||
setNewSticky(true);
|
||||
}}
|
||||
>
|
||||
<AddOutline className="size-5 rotate-90 text-tertiary" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`btn btn--icon shadow-sm flex h-10 w-10 items-center justify-center rounded-full bg-surface-1 transition-transform duration-300 ${isExpanded ? "rotate-180" : ""}`}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<CloseOutline className="size-5 text-tertiary" />
|
||||
) : (
|
||||
<StickyNoteOutline className="size-5 rotate-90 text-tertiary" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 bottom-16 z-[20]",
|
||||
"transform transition-all duration-300 ease-in-out",
|
||||
newSticky || showRecentSticky ? "min-h-[300px] translate-y-[0%]" : "h-0 translate-y-[100%]"
|
||||
)}
|
||||
>
|
||||
{(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>
|
||||
);
|
||||
});
|
||||
52
apps/web/core/components/stickies/delete-modal.tsx
Normal file
52
apps/web/core/components/stickies/delete-modal.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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 = observer(function StickyDeleteModal(props: IStickyDelete) {
|
||||
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")}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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(function StickiesInfinite() {
|
||||
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("box-border flex min-h-[300px] w-full p-2")}
|
||||
ref={setElementRef}
|
||||
id="intersection-element"
|
||||
>
|
||||
<div className="flex min-h-[300px] w-full rounded-sm">
|
||||
<Loader className="h-full w-full">
|
||||
<Loader.Item height="100%" width="100%" />
|
||||
</Loader>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
);
|
||||
});
|
||||
213
apps/web/core/components/stickies/layout/stickies-list.tsx
Normal file
213
apps/web/core/components/stickies/layout/stickies-list.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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 { useTheme } from "next-themes";
|
||||
import Masonry from "react-masonry-component";
|
||||
|
||||
// plane imports
|
||||
import { EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { AddOutline } from "@makeplane/propel/icons";
|
||||
import { EUserWorkspaceRoles } from "@plane/types";
|
||||
// assets
|
||||
import darkStickiesAsset from "@/app/assets/empty-state/stickies/stickies-dark.webp?url";
|
||||
import lightStickiesAsset from "@/app/assets/empty-state/stickies/stickies-light.webp?url";
|
||||
import darkStickiesSearchAsset from "@/app/assets/empty-state/stickies/stickies-search-dark.webp?url";
|
||||
import lightStickiesSearchAsset from "@/app/assets/empty-state/stickies/stickies-search-light.webp?url";
|
||||
// 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 { 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(function StickiesList(props: TProps) {
|
||||
const { workspaceSlug, intersectionElement, columnCount } = props;
|
||||
// navigation
|
||||
const pathname = usePathname();
|
||||
// theme hook
|
||||
const { resolvedTheme } = useTheme();
|
||||
// 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 = resolvedTheme === "light" ? lightStickiesAsset : darkStickiesAsset;
|
||||
const stickiesSearchResolvedPath = resolvedTheme === "light" ? lightStickiesSearchAsset : darkStickiesSearchAsset;
|
||||
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="grid size-full 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: <AddOutline 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 function 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>
|
||||
);
|
||||
}
|
||||
53
apps/web/core/components/stickies/layout/stickies-loader.tsx
Normal file
53
apps/web/core/components/stickies/layout/stickies-loader.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// plane ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export function StickiesLoader() {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4 overflow-scroll pb-2">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Loader key={index} className="space-y-5 rounded-sm border border-subtle p-3">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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(function StickiesTruncated(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(
|
||||
"w-full gap-1 bg-surface-2/20 text-13 font-medium text-accent-primary transition-opacity duration-300"
|
||||
)}
|
||||
onClick={handleClose}
|
||||
>
|
||||
{t("show_all")}
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<StickiesLayout workspaceSlug={workspaceSlug?.toString()} />
|
||||
</ContentOverflowWrapper>
|
||||
);
|
||||
});
|
||||
140
apps/web/core/components/stickies/layout/sticky-dnd-wrapper.tsx
Normal file
140
apps/web/core/components/stickies/layout/sticky-dnd-wrapper.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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";
|
||||
// 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(function StickyDNDWrapper(props: Props) {
|
||||
const { stickyId, workspaceSlug, itemWidth, isLastChild, 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="box-border flex flex-col 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>
|
||||
);
|
||||
});
|
||||
51
apps/web/core/components/stickies/layout/sticky.helpers.ts
Normal file
51
apps/web/core/components/stickies/layout/sticky.helpers.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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;
|
||||
};
|
||||
21
apps/web/core/components/stickies/modal/index.tsx
Normal file
21
apps/web/core/components/stickies/modal/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { EModalWidth, ModalCore } from "@plane/ui";
|
||||
import { Stickies } from "./stickies";
|
||||
|
||||
type TProps = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
};
|
||||
export function AllStickiesModal(props: TProps) {
|
||||
const { isOpen, handleClose } = props;
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} width={EModalWidth.VXL}>
|
||||
<Stickies handleClose={handleClose} />
|
||||
</ModalCore>
|
||||
);
|
||||
}
|
||||
105
apps/web/core/components/stickies/modal/search.tsx
Normal file
105
apps/web/core/components/stickies/modal/search.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { debounce } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane hooks
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
// helpers
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { CloseOutline, SearchOutline } from "@makeplane/propel/icons";
|
||||
import { cn } from "@plane/utils";
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
import { IconButton } from "@plane/propel/icon-button";
|
||||
|
||||
export const StickySearch = observer(function StickySearch() {
|
||||
// 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="my-auto mr-2 flex items-center">
|
||||
{!isSearchOpen && (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
className="-mr-2"
|
||||
icon={SearchOutline}
|
||||
onClick={() => {
|
||||
setIsSearchOpen(true);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto flex w-0 items-center justify-start gap-1 overflow-hidden rounded-md border border-transparent text-placeholder opacity-0 transition-[width] ease-linear",
|
||||
{
|
||||
"w-30 border-subtle px-2.5 py-1.5 opacity-100 md:w-64": isSearchOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<SearchOutline className="size-3.5 shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="w-full max-w-[234px] border-none bg-transparent text-13 text-primary placeholder:text-placeholder 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();
|
||||
}}
|
||||
>
|
||||
<CloseOutline className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
80
apps/web/core/components/stickies/modal/stickies.tsx
Normal file
80
apps/web/core/components/stickies/modal/stickies.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
// plane ui
|
||||
import { AddOutline, CloseOutline, MultipleStickyOutline } from "@makeplane/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(function Stickies(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="min-h-[620px] p-6 pb-0">
|
||||
{/* header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
{/* Title */}
|
||||
<div className="flex items-center gap-2 text-secondary">
|
||||
<MultipleStickyOutline className="size-5 flex-shrink-0 rotate-90" />
|
||||
<p className="text-18 font-medium">Your stickies</p>
|
||||
</div>
|
||||
{/* actions */}
|
||||
<div className="flex gap-2">
|
||||
<StickySearch />
|
||||
<button
|
||||
onClick={() => {
|
||||
toggleShowNewSticky(true);
|
||||
stickyOperations.create();
|
||||
}}
|
||||
className="my-auto flex gap-1 text-13 font-medium text-accent-primary"
|
||||
disabled={creatingSticky}
|
||||
>
|
||||
<AddOutline className="my-auto size-4" /> <span>Add sticky</span>
|
||||
{creatingSticky && (
|
||||
<div className="ml-2 flex items-center justify-center">
|
||||
<div
|
||||
className={`h-4 w-4 animate-spin rounded-full border-2 border-accent-strong border-t-transparent`}
|
||||
role="status"
|
||||
aria-label="loading"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{handleClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="my-auto grid flex-shrink-0 place-items-center rounded-sm p-1 text-tertiary transition-colors hover:bg-layer-1 hover:text-primary"
|
||||
>
|
||||
<CloseOutline className="size-4 text-placeholder" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* content */}
|
||||
<div className="mb-4 max-h-[625px] overflow-scroll">
|
||||
<StickiesTruncated handleClose={handleClose} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
7
apps/web/core/components/stickies/sticky/index.ts
Normal file
7
apps/web/core/components/stickies/sticky/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
106
apps/web/core/components/stickies/sticky/inputs.tsx
Normal file
106
apps/web/core/components/stickies/sticky/inputs.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
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 function 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(
|
||||
"vertical-scrollbar scrollbar-sm max-h-[540px] min-h-[256px] w-full overflow-y-scroll p-4 text-14",
|
||||
{
|
||||
"max-h-[588px]": isStickiesPage,
|
||||
}
|
||||
)}
|
||||
uploadFile={async () => ""}
|
||||
duplicateFile={async () => ""}
|
||||
showToolbar={showToolbar}
|
||||
parentClassName="border-none p-0"
|
||||
handleDelete={handleDelete}
|
||||
handleColorChange={handleChange}
|
||||
ref={editorRef}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
apps/web/core/components/stickies/sticky/root.tsx
Normal file
112
apps/web/core/components/stickies/sticky/root.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { debounce } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
import { ArrowCollapseOutline } from "@makeplane/propel/icons";
|
||||
// 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(function StickyNote(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("group/sticky flex h-fit w-full flex-col overflow-y-scroll rounded-sm", className)}
|
||||
style={{
|
||||
backgroundColor,
|
||||
}}
|
||||
>
|
||||
{/* {isStickiesPage && <StickyItemDragHandle isDragging={false} />}{" "} */}
|
||||
{onClose && (
|
||||
<button type="button" className="flex flex-shrink-0 justify-end p-2.5" onClick={onClose}>
|
||||
<ArrowCollapseOutline 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { DragHandle } from "@plane/ui";
|
||||
// helper
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
isDragging: boolean;
|
||||
};
|
||||
|
||||
export const StickyItemDragHandle = observer(function StickyItemDragHandle(props: Props) {
|
||||
const { isDragging } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-3 left-1/2 mr-2 hidden -translate-x-1/2 rotate-90 cursor-grab items-center justify-center rounded-sm text-placeholder group-hover/sticky:flex",
|
||||
{
|
||||
"cursor-grabbing": isDragging,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<DragHandle className="bg-transparent" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
154
apps/web/core/components/stickies/sticky/use-operations.tsx
Normal file
154
apps/web/core/components/stickies/sticky/use-operations.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
63
apps/web/core/components/stickies/widget.tsx
Normal file
63
apps/web/core/components/stickies/widget.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { AddOutline } from "@makeplane/propel/icons";
|
||||
// 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 = observer(function StickiesWidget() {
|
||||
// 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="mb-4 flex items-center justify-between">
|
||||
<div className="text-14 font-semibold text-tertiary">{t("stickies.title")}</div>
|
||||
{/* actions */}
|
||||
<div className="flex gap-2">
|
||||
<StickySearch />
|
||||
<button
|
||||
onClick={() => {
|
||||
toggleShowNewSticky(true);
|
||||
stickyOperations.create();
|
||||
}}
|
||||
className="my-auto flex gap-1 text-13 font-medium text-accent-primary"
|
||||
disabled={creatingSticky}
|
||||
>
|
||||
<AddOutline className="my-auto size-4" />
|
||||
<span>{t("stickies.add")}</span>
|
||||
{creatingSticky && (
|
||||
<div
|
||||
className="size-4 animate-spin rounded-full border-2 border-accent-strong border-t-transparent"
|
||||
role="status"
|
||||
aria-label="loading"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mx-2">
|
||||
<StickiesTruncated />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user