[WEB-3048] feat: added-stickies (#6339)

* feat: added-stickies

* fix: recents empty state fixed

* fix: added border

* Change sort_order field

* fix: remvoved btn

* fix: sticky toolbar

* fix: build

* fix: sticky search

* fix: minor css fix

* fix: issue identifier css handled

* fix: issue type default icon

* fix: added tooltip for color palette and delete

---------

Co-authored-by: sangeethailango <sangeethailango21@gmail.com>
This commit is contained in:
Akshita Goyal 2025-01-07 20:30:42 +05:30 committed by GitHub
parent 24cc69fd7b
commit cb045abfe1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1621 additions and 100 deletions

View file

@ -2,3 +2,4 @@ export * from "./embeds";
export * from "./lite-text-editor";
export * from "./pdf";
export * from "./rich-text-editor";
export * from "./sticky-editor";

View file

@ -0,0 +1,36 @@
import { TSticky } from "@plane/types";
export const STICKY_COLORS = [
"#D4DEF7", // light periwinkle
"#B4E4FF", // light blue
"#FFF2B4", // light yellow
"#E3E3E3", // light gray
"#FFE2DD", // light pink
"#F5D1A5", // light orange
"#D1F7C4", // light green
"#E5D4FF", // light purple
];
type TProps = {
handleUpdate: (data: Partial<TSticky>) => Promise<void>;
};
export const ColorPalette = (props: TProps) => {
const { handleUpdate } = props;
return (
<div className="absolute z-10 bottom-5 left-0 w-56 shadow p-2 rounded-md bg-custom-background-100 mb-2">
<div className="text-sm font-semibold text-custom-text-400 mb-2">Background colors</div>
<div className="flex flex-wrap gap-2">
{STICKY_COLORS.map((color, index) => (
<button
key={index}
type="button"
onClick={() => handleUpdate({ color })}
className="h-6 w-6 rounded-md hover:ring-2 hover:ring-custom-primary focus:outline-none focus:ring-2 focus:ring-custom-primary transition-all"
style={{ backgroundColor: color }}
/>
))}
</div>
</div>
);
};

View file

@ -0,0 +1,109 @@
import React, { useState } from "react";
// plane constants
import { EIssueCommentAccessSpecifier } from "@plane/constants";
// plane editor
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor";
// components
import { TSticky } from "@plane/types";
// helpers
import { cn } from "@/helpers/common.helper";
import { getEditorFileHandlers } from "@/helpers/editor.helper";
// hooks
// plane web hooks
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
import { useFileSize } from "@/plane-web/hooks/use-file-size";
import { Toolbar } from "./toolbar";
interface StickyEditorWrapperProps
extends Omit<ILiteTextEditor, "disabledExtensions" | "fileHandler" | "mentionHandler"> {
workspaceSlug: string;
workspaceId: string;
projectId?: string;
accessSpecifier?: EIssueCommentAccessSpecifier;
handleAccessChange?: (accessKey: EIssueCommentAccessSpecifier) => void;
showAccessSpecifier?: boolean;
showSubmitButton?: boolean;
isSubmitting?: boolean;
showToolbarInitially?: boolean;
showToolbar?: boolean;
uploadFile: (file: File) => Promise<string>;
parentClassName?: string;
handleColorChange: (data: Partial<TSticky>) => Promise<void>;
handleDelete: () => Promise<void>;
}
export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperProps>((props, ref) => {
const {
containerClassName,
workspaceSlug,
workspaceId,
projectId,
handleDelete,
handleColorChange,
showToolbarInitially = true,
showToolbar = true,
parentClassName = "",
placeholder = "Add comment...",
uploadFile,
...rest
} = props;
// states
const [isFocused, setIsFocused] = useState(showToolbarInitially);
// editor flaggings
const { liteTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
// file size
const { maxFileSize } = useFileSize();
function isMutableRefObject<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> {
return !!ref && typeof ref === "object" && "current" in ref;
}
// derived values
const editorRef = isMutableRefObject<EditorRefApi>(ref) ? ref.current : null;
return (
<div
className={cn("relative border border-custom-border-200 rounded p-3", parentClassName)}
onFocus={() => !showToolbarInitially && setIsFocused(true)}
onBlur={() => !showToolbarInitially && setIsFocused(false)}
>
<LiteTextEditorWithRef
ref={ref}
disabledExtensions={[...disabledExtensions, "enter-key"]}
fileHandler={getEditorFileHandlers({
maxFileSize,
projectId,
uploadFile,
workspaceId,
workspaceSlug,
})}
mentionHandler={{
renderComponent: () => <></>,
}}
placeholder={placeholder}
containerClassName={cn(containerClassName, "relative")}
{...rest}
/>
<div
className={cn(
"transition-all duration-300 ease-out origin-top",
isFocused ? "max-h-[200px] opacity-100 scale-y-100 mt-3" : "max-h-0 opacity-0 scale-y-0 invisible"
)}
>
<Toolbar
executeCommand={(item) => {
// TODO: update this while toolbar homogenization
// @ts-expect-error type mismatch here
editorRef?.executeMenuItemCommand({
itemKey: item.itemKey,
...item.extraProps,
});
}}
handleDelete={handleDelete}
handleColorChange={handleColorChange}
editorRef={editorRef}
/>
</div>
</div>
);
});
StickyEditor.displayName = "StickyEditor";

View file

@ -0,0 +1,2 @@
export * from "./editor";
export * from "./toolbar";

View file

@ -0,0 +1,131 @@
"use client";
import React, { useEffect, useState, useCallback } from "react";
import { Palette, Trash2 } from "lucide-react";
// editor
import { EditorRefApi } from "@plane/editor";
// ui
import { useOutsideClickDetector } from "@plane/hooks";
import { TSticky } from "@plane/types";
import { Tooltip } from "@plane/ui";
// constants
import { TOOLBAR_ITEMS, ToolbarMenuItem } from "@/constants/editor";
// helpers
import { cn } from "@/helpers/common.helper";
import { ColorPalette } from "./color-pallete";
type Props = {
executeCommand: (item: ToolbarMenuItem) => void;
editorRef: EditorRefApi | null;
handleColorChange: (data: Partial<TSticky>) => Promise<void>;
handleDelete: () => void;
};
const toolbarItems = TOOLBAR_ITEMS.sticky;
export const Toolbar: React.FC<Props> = (props) => {
const { executeCommand, editorRef, handleColorChange, handleDelete } = props;
// State to manage active states of toolbar items
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
const [showColorPalette, setShowColorPalette] = useState(false);
const colorPaletteRef = React.useRef<HTMLDivElement>(null);
// Function to update active states
const updateActiveStates = useCallback(() => {
if (!editorRef) return;
const newActiveStates: Record<string, boolean> = {};
Object.values(toolbarItems)
.flat()
.forEach((item) => {
// TODO: update this while toolbar homogenization
// @ts-expect-error type mismatch here
newActiveStates[item.renderKey] = editorRef.isMenuItemActive({
itemKey: item.itemKey,
...item.extraProps,
});
});
setActiveStates(newActiveStates);
}, [editorRef]);
// useEffect to call updateActiveStates when isActive prop changes
useEffect(() => {
if (!editorRef) return;
const unsubscribe = editorRef.onStateChange(updateActiveStates);
updateActiveStates();
return () => unsubscribe();
}, [editorRef, updateActiveStates]);
useOutsideClickDetector(colorPaletteRef, () => setShowColorPalette(false));
return (
<div className="flex w-full justify-between mt-2 h-full">
<div className="flex my-auto gap-4" ref={colorPaletteRef}>
{/* color palette */}
{showColorPalette && <ColorPalette handleUpdate={handleColorChange} />}
<Tooltip
tooltipContent={
<p className="flex flex-col gap-1 text-center text-xs">
<span className="font-medium">Background color</span>
</p>
}
>
<button onClick={() => setShowColorPalette(!showColorPalette)} className="flex text-custom-text-300">
<Palette className="size-4 my-auto" />
</button>
</Tooltip>
<div className="flex w-fit items-stretch justify-between gap-4 rounded p-1 my-auto">
<div className="flex items-stretch my-auto gap-4">
{Object.keys(toolbarItems).map((key) => (
<div key={key} className={cn("flex items-stretch gap-4", {})}>
{toolbarItems[key].map((item) => {
const isItemActive = activeStates[item.renderKey];
return (
<Tooltip
key={item.renderKey}
tooltipContent={
<p className="flex flex-col gap-1 text-center text-xs">
<span className="font-medium">{item.name}</span>
{item.shortcut && <kbd className="text-custom-text-400">{item.shortcut.join(" + ")}</kbd>}
</p>
}
>
<button
type="button"
onClick={() => executeCommand(item)}
className={cn(
"grid place-items-center aspect-square rounded-sm p-0.5 text-custom-text-300",
{}
)}
>
<item.icon
className={cn("h-3.5 w-3.5", {
"font-extrabold": isItemActive,
})}
strokeWidth={2.5}
/>
</button>
</Tooltip>
);
})}
</div>
))}
</div>
</div>
</div>
{/* delete action */}
<Tooltip
tooltipContent={
<p className="flex flex-col gap-1 text-center text-xs">
<span className="font-medium">Delete</span>
</p>
}
>
<button onClick={handleDelete} className="my-auto text-custom-text-300">
<Trash2 className="size-4" />
</button>
</Tooltip>
</div>
);
};

View file

@ -6,7 +6,7 @@ import { THomeWidgetKeys, THomeWidgetProps } from "@plane/types";
import { useHome } from "@/hooks/store/use-home";
// components
import { HomePageHeader } from "@/plane-web/components/home/header";
import { StickiesWidget } from "@/plane-web/components/stickies";
import { StickiesWidget } from "../stickies";
import { RecentActivityWidget } from "./widgets";
import { DashboardQuickLinks } from "./widgets/links";
import { ManageWidgetsModal } from "./widgets/manage";
@ -37,19 +37,18 @@ export const DashboardWidgets = observer(() => {
isModalOpen={showWidgetSettings}
handleOnClose={() => toggleWidgetSettings(false)}
/>
{orderedWidgets.map((key) => {
const WidgetComponent = WIDGETS_LIST[key]?.component;
const isEnabled = widgetsMap[key]?.is_enabled;
if (!WidgetComponent || !isEnabled) return null;
if (WIDGETS_LIST[key]?.fullWidth)
<div className="flex flex-col divide-y-[1px] divide-custom-border-100">
{orderedWidgets.map((key) => {
const WidgetComponent = WIDGETS_LIST[key]?.component;
const isEnabled = widgetsMap[key]?.is_enabled;
if (!WidgetComponent || !isEnabled) return null;
return (
<div key={key} className="lg:col-span-2">
<div key={key} className="py-4">
<WidgetComponent workspaceSlug={workspaceSlug.toString()} />
</div>
);
else return <WidgetComponent key={key} workspaceSlug={workspaceSlug.toString()} />;
})}
})}
</div>
</div>
);
});

View file

@ -3,15 +3,13 @@ import { useParams } from "next/navigation";
// components
import useSWR from "swr";
import { ContentWrapper } from "@plane/ui";
import { EmptyState } from "@/components/empty-state";
import { TourRoot } from "@/components/onboarding";
// constants
import { EmptyStateType } from "@/constants/empty-state";
import { PRODUCT_TOUR_COMPLETED } from "@/constants/event-tracker";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useCommandPalette, useUserProfile, useEventTracker, useProject, useUser } from "@/hooks/store";
import { useUserProfile, useEventTracker, useUser } from "@/hooks/store";
import { useHome } from "@/hooks/store/use-home";
import useSize from "@/hooks/use-window-size";
import { IssuePeekOverview } from "../issues";
@ -20,17 +18,11 @@ import { UserGreetingsView } from "./user-greetings";
export const WorkspaceHomeView = observer(() => {
// store hooks
const {
// captureEvent,
setTrackElement,
} = useEventTracker();
const { toggleCreateProjectModal } = useCommandPalette();
const { workspaceSlug } = useParams();
const { data: currentUser } = useUser();
const { data: currentUserProfile, updateTourCompleted } = useUserProfile();
const { captureEvent } = useEventTracker();
const { toggleWidgetSettings, fetchWidgets } = useHome();
const { joinedProjectIds, loader } = useProject();
const [windowWidth] = useSize();
useSWR(
@ -64,34 +56,18 @@ export const WorkspaceHomeView = observer(() => {
<TourRoot onComplete={handleTourCompleted} />
</div>
)}
{joinedProjectIds && (
<>
{joinedProjectIds.length > 0 || loader ? (
<>
<IssuePeekOverview />
<ContentWrapper
className={cn("gap-7 bg-custom-background-90/20", {
"vertical-scrollbar scrollbar-lg": windowWidth >= 768,
})}
>
{currentUser && (
<UserGreetingsView user={currentUser} handleWidgetModal={() => toggleWidgetSettings(true)} />
)}
<>
<IssuePeekOverview />
<ContentWrapper
className={cn("gap-7 bg-custom-background-90/20", {
"vertical-scrollbar scrollbar-lg": windowWidth >= 768,
})}
>
{currentUser && <UserGreetingsView user={currentUser} handleWidgetModal={() => toggleWidgetSettings(true)} />}
<DashboardWidgets />
</ContentWrapper>
</>
) : (
<EmptyState
type={EmptyStateType.WORKSPACE_DASHBOARD}
primaryButtonOnClick={() => {
setTrackElement("Dashboard empty state");
toggleCreateProjectModal(true);
}}
/>
)}
</>
)}
<DashboardWidgets />
</ContentWrapper>
</>
</>
);
});

View file

@ -0,0 +1,21 @@
import Image from "next/image";
import { useTheme } from "next-themes";
import UpcomingIssuesDark from "@/public/empty-state/dashboard/dark/upcoming-issues.svg";
import UpcomingIssuesLight from "@/public/empty-state/dashboard/light/upcoming-issues.svg";
export const IssuesEmptyState = () => {
// next-themes
const { resolvedTheme } = useTheme();
const image = resolvedTheme === "dark" ? UpcomingIssuesDark : UpcomingIssuesLight;
// TODO: update empty state logic to use a general component
return (
<div className="text-center space-y-6 flex flex-col items-center justify-center">
<div className="h-24 w-24">
<Image src={image} className="w-full h-full" alt="Assigned issues" />
</div>
<p className="text-sm font-medium text-custom-text-300 whitespace-pre-line">No activity to display</p>
</div>
);
};

View file

@ -31,7 +31,7 @@ export const DashboardQuickLinks = observer((props: THomeWidgetProps) => {
preloadedData={linkData}
setLinkData={setLinkData}
/>
<div className="flex mx-auto flex-wrap border-b border-custom-border-100 pb-4 w-full justify-center">
<div className="flex mx-auto flex-wrap pb-4 w-full justify-center">
{/* rendering links */}
<ProjectLinkList workspaceSlug={workspaceSlug} linkOperations={linkOperations} />
</div>

View file

@ -4,7 +4,7 @@ import { FC } from "react";
import { observer } from "mobx-react";
// plane types
// plane ui
import { Button, EModalWidth, ModalCore } from "@plane/ui";
import { EModalWidth, ModalCore } from "@plane/ui";
import { WidgetList } from "./widget-list";
export type TProps = {
@ -22,14 +22,6 @@ export const ManageWidgetsModal: FC<TProps> = observer((props) => {
<div className="p-4">
<div className="font-medium text-xl">Manage widgets</div>
<WidgetList workspaceSlug={workspaceSlug} />
<div className="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
<Button variant="neutral-primary" size="md" onClick={handleOnClose}>
Cancel
</Button>
<Button variant="primary" size="md" type="submit">
Save changes
</Button>
</div>
</div>
</ModalCore>
);

View file

@ -8,8 +8,10 @@ import { Briefcase, FileText } from "lucide-react";
import { TActivityEntityData, THomeWidgetProps, TRecentActivityFilterKeys } from "@plane/types";
// components
import { LayersIcon } from "@plane/ui";
import { useProject } from "@/hooks/store";
import { WorkspaceService } from "@/plane-web/services";
import { EmptyWorkspace } from "../empty-states";
import { IssuesEmptyState } from "../empty-states/issues";
import { EWidgetKeys, WidgetLoader } from "../loaders";
import { FiltersDropdown } from "./filters";
import { RecentIssue } from "./issue";
@ -31,6 +33,7 @@ export const RecentActivityWidget: React.FC<THomeWidgetProps> = observer((props)
const [filter, setFilter] = useState<TRecentActivityFilterKeys>(filters[0].name);
// ref
const ref = useRef<HTMLDivElement>(null);
const { joinedProjectIds, loader } = useProject();
const { data: recents, isLoading } = useSWR(
workspaceSlug ? `WORKSPACE_RECENT_ACTIVITY_${workspaceSlug}_${filter}` : null,
@ -61,19 +64,33 @@ export const RecentActivityWidget: React.FC<THomeWidgetProps> = observer((props)
}
};
if (!isLoading && recents?.length === 0) return <EmptyWorkspace />;
if (!loader && joinedProjectIds?.length === 0) return <EmptyWorkspace />;
if (!isLoading && recents?.length === 0)
return (
<div ref={ref} className=" max-h-[500px] overflow-y-scroll">
<div className="flex items-center justify-between mb-2">
<div className="text-base font-semibold text-custom-text-350">Recents</div>
<FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />
</div>
<div className="min-h-[400px] flex flex-col items-center justify-center">
<IssuesEmptyState />
</div>
</div>
);
return (
<div ref={ref} className=" max-h-[500px] overflow-y-scroll">
<div ref={ref} className=" max-h-[500px] min-h-[400px] overflow-y-scroll">
<div className="flex items-center justify-between mb-2">
<div className="text-base font-semibold text-custom-text-350 hover:underline">Recents</div>
<div className="text-base font-semibold text-custom-text-350">Recents</div>
<FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />
</div>
{isLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
{!isLoading &&
recents?.length > 0 &&
recents.map((activity: TActivityEntityData) => <div key={activity.id}>{resolveRecent(activity)}</div>)}
<div className="min-h-[400px] flex flex-col">
{isLoading && <WidgetLoader widgetKey={WIDGET_KEY} />}
{!isLoading &&
recents?.length > 0 &&
recents.map((activity: TActivityEntityData) => <div key={activity.id}>{resolveRecent(activity)}</div>)}
</div>
</div>
);
});

View file

@ -1,5 +1,5 @@
import { TActivityEntityData, TIssueEntityData } from "@plane/types";
import { PriorityIcon, StateGroupIcon, Tooltip } from "@plane/ui";
import { LayersIcon, PriorityIcon, StateGroupIcon, Tooltip } from "@plane/ui";
import { ListItem } from "@/components/core/list";
import { MemberDropdown } from "@/components/dropdowns";
import { calculateTimeAgo } from "@/helpers/date-time.helper";
@ -27,13 +27,25 @@ export const RecentIssue = (props: BlockProps) => {
title={""}
prependTitleElement={
<div className="flex flex-shrink-0 items-center justify-center rounded-md gap-4 ">
<IssueIdentifier
issueTypeId={issueDetails?.type}
projectId={issueDetails?.project_id || ""}
projectIdentifier={issueDetails?.project_identifier || ""}
issueSequenceId={issueDetails?.sequence_id || ""}
textContainerClassName="text-custom-sidebar-text-400 text-sm whitespace-nowrap"
/>
{issueDetails.type ? (
<IssueIdentifier
size="lg"
issueTypeId={issueDetails?.type}
projectId={issueDetails?.project_id || ""}
projectIdentifier={issueDetails?.project_identifier || ""}
issueSequenceId={issueDetails?.sequence_id || ""}
textContainerClassName="text-custom-sidebar-text-400 text-sm whitespace-nowrap"
/>
) : (
<div className="flex gap-2 items-center justify-center">
<div className="flex flex-shrink-0 items-center justify-center rounded gap-4 bg-custom-background-80 w-[25.5px] h-[25.5px]">
<LayersIcon className="w-4 h-4 text-custom-text-350" />
</div>
<div className="font-medium text-custom-sidebar-text-400 text-sm whitespace-nowrap">
{issueDetails?.project_identifier}-{issueDetails?.sequence_id}
</div>
</div>
)}
<div className="text-custom-text-200 font-medium text-sm whitespace-nowrap">{issueDetails?.name}</div>
<div className="font-medium text-xs text-custom-text-400">{calculateTimeAgo(activity.visited_at)}</div>
</div>

View file

@ -28,17 +28,19 @@ export const RecentPage = (props: BlockProps) => {
title={""}
prependTitleElement={
<div className="flex flex-shrink-0 items-center justify-center rounded-md gap-4 ">
<div className="flex flex-shrink-0 items-center justify-center rounded gap-4 bg-custom-background-80 w-8 h-8">
<>
{pageDetails?.logo_props?.in_use ? (
<Logo logo={pageDetails?.logo_props} size={16} type="lucide" />
) : (
<FileText className="h-4 w-4 text-custom-text-300" />
)}
</>
</div>
<div className="font-medium text-custom-sidebar-text-400 text-sm whitespace-nowrap">
{pageDetails?.project_identifier}
<div className="flex gap-2 items-center justify-center">
<div className="flex flex-shrink-0 items-center justify-center rounded gap-2 bg-custom-background-80 w-[25.5px] h-[25.5px]">
<>
{pageDetails?.logo_props?.in_use ? (
<Logo logo={pageDetails?.logo_props} size={16} type="lucide" />
) : (
<FileText className="h-4 w-4 text-custom-text-350" />
)}
</>
</div>
<div className="font-medium text-custom-sidebar-text-400 text-sm whitespace-nowrap">
{pageDetails?.project_identifier}
</div>
</div>
<div className="text-custom-text-200 font-medium text-sm whitespace-nowrap">{pageDetails?.name}</div>
<div className="font-medium text-xs text-custom-text-400">{calculateTimeAgo(activity.visited_at)}</div>

View file

@ -24,11 +24,13 @@ export const RecentProject = (props: BlockProps) => {
title={""}
prependTitleElement={
<div className="flex flex-shrink-0 items-center justify-center rounded-md gap-4 ">
<div className="flex flex-shrink-0 items-center justify-center rounded gap-4 bg-custom-background-80 w-8 h-8">
<Logo logo={projectDetails?.logo_props} size={16} />
</div>
<div className="font-medium text-custom-sidebar-text-400 text-sm whitespace-nowrap">
{projectDetails?.identifier}
<div className="flex gap-2 items-center justify-center">
<div className="flex flex-shrink-0 items-center justify-center rounded gap-4 bg-custom-background-80 w-[25.5px] h-[25.5px]">
<Logo logo={projectDetails?.logo_props} size={16} />
</div>
<div className="font-medium text-custom-sidebar-text-400 text-sm whitespace-nowrap">
{projectDetails?.identifier}
</div>
</div>
<div className="text-custom-text-200 font-medium text-sm whitespace-nowrap">{projectDetails?.name}</div>
<div className="font-medium text-xs text-custom-text-400">{calculateTimeAgo(activity.visited_at)}</div>

View file

@ -0,0 +1,129 @@
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";
import { useOutsideClickDetector } from "@plane/hooks";
import { RecentStickyIcon, StickyNoteIcon, Tooltip } from "@plane/ui";
import { cn } from "@plane/utils";
import { useCommandPalette } from "@/hooks/store";
import { useSticky } from "@/hooks/use-stickies";
import { AllStickiesModal } from "./modal";
import { StickyNote } from "./sticky";
export const StickyActionBar = observer(() => {
const { workspaceSlug } = useParams();
const [isExpanded, setIsExpanded] = useState(false);
const [newSticky, setNewSticky] = useState(false);
const [showRecentSticky, setShowRecentSticky] = useState(false);
const ref = useRef(null);
// hooks
const { stickies, activeStickyId, recentStickyId, updateActiveStickyId, fetchRecentSticky, toggleShowNewSticky } =
useSticky();
const { toggleAllStickiesModal, allStickiesModal } = useCommandPalette();
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, ${stickies[recentStickyId]?.color}, 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: stickies[recentStickyId]?.color }}
>
<StickyNoteIcon className={cn("size-5 rotate-90")} color={stickies[recentStickyId]?.color} />
</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,40 @@
import { Plus, StickyNote as StickyIcon, X } from "lucide-react";
type TProps = {
handleCreate: () => void;
creatingSticky?: boolean;
};
export const EmptyState = (props: TProps) => {
const { handleCreate, creatingSticky } = props;
return (
<div className="flex justify-center h-[500px]">
<div className="m-auto">
<div
className={`mb-4 rounded-full mx-auto last:rounded-full w-[98px] h-[98px] flex items-center justify-center bg-custom-background-80/40 transition-transform duration-300`}
>
<StickyIcon className="size-[60px] rotate-90 text-custom-text-350/20" />
</div>
<div className="text-custom-text-100 font-medium text-lg text-center">No stickies yet</div>
<div className="text-custom-text-300 text-sm text-center my-2">
All your stickies in this workspace will appear here.
</div>
<button
onClick={handleCreate}
className="mx-auto 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>
</div>
</div>
);
};

View file

@ -0,0 +1,2 @@
export * from "./action-bar";
export * from "./widget";

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,76 @@
"use client";
import { FC, useRef, useState } from "react";
import { observer } from "mobx-react";
import { Search, X } from "lucide-react";
// plane hooks
import { useOutsideClickDetector } from "@plane/hooks";
// helpers
import { cn } from "@/helpers/common.helper";
import { useSticky } from "@/hooks/use-stickies";
export const StickySearch: FC = observer(() => {
// hooks
const { searchQuery, updateSearchQuery } = useSticky();
// 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("");
else setIsSearchOpen(false);
}
};
return (
<div className="flex items-center mr-2">
{!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="Search by title"
value={searchQuery}
onChange={(e) => updateSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
updateSearchQuery("");
setIsSearchOpen(false);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
);
});

View file

@ -0,0 +1,68 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Plus, X } from "lucide-react";
import { RecentStickyIcon } from "@plane/ui";
import { useSticky } from "@/hooks/use-stickies";
import { STICKY_COLORS } from "../../editor/sticky-editor/color-pallete";
import { StickiesLayout } from "../stickies-layout";
import { useStickyOperations } from "../sticky/use-operations";
import { StickySearch } from "./search";
type TProps = {
handleClose?: () => void;
};
export const Stickies = observer((props: TProps) => {
const { handleClose } = props;
const { workspaceSlug } = useParams();
const { creatingSticky, toggleShowNewSticky } = useSticky();
const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() });
return (
<div className="p-6 pb-0">
{/* header */}
<div className="flex items-center justify-between mb-6">
{/* Title */}
<div className="text-custom-text-100 flex gap-2">
<RecentStickyIcon className="size-5 rotate-90" />
<p className="text-lg font-medium">My Stickies</p>
</div>
{/* actions */}
<div className="flex gap-2">
<StickySearch />
<button
onClick={() => {
toggleShowNewSticky(true);
stickyOperations.create({ color: STICKY_COLORS[0] });
}}
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
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">
<StickiesLayout />
</div>
</div>
);
});

View file

@ -0,0 +1,209 @@
import React, { useState, useEffect, useRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import Masonry from "react-masonry-component";
import useSWR from "swr";
import { Loader } from "@plane/ui";
import { cn } from "@plane/utils";
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
import { useSticky } from "@/hooks/use-stickies";
import { STICKY_COLORS } from "../editor/sticky-editor/color-pallete";
import { EmptyState } from "./empty";
import { StickyNote } from "./sticky";
import { useStickyOperations } from "./sticky/use-operations";
const PER_PAGE = 10;
type TProps = {
columnCount: number;
};
export const StickyAll = observer((props: TProps) => {
const { columnCount } = props;
// refs
const masonryRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// states
const [containerHeight, setContainerHeight] = useState(0);
const [showAllStickies, setShowAllStickies] = useState(false);
const [intersectionElement, setIntersectionElement] = useState<HTMLDivElement | null>(null);
// router
const { workspaceSlug } = useParams();
// hooks
const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() });
const {
fetchingWorkspaceStickies,
toggleShowNewSticky,
getWorkspaceStickies,
fetchWorkspaceStickies,
currentPage,
totalPages,
incrementPage,
creatingSticky,
} = useSticky();
const workspaceStickies = getWorkspaceStickies(workspaceSlug?.toString());
const itemWidth = `${100 / columnCount}%`;
useSWR(
workspaceSlug ? `WORKSPACE_STICKIES_${workspaceSlug}_${PER_PAGE}:${currentPage}:0` : null,
workspaceSlug
? () => fetchWorkspaceStickies(workspaceSlug.toString(), `${PER_PAGE}:${currentPage}:0`, PER_PAGE)
: null
);
useEffect(() => {
if (!fetchingWorkspaceStickies && workspaceStickies.length === 0) {
toggleShowNewSticky(true);
}
}, [fetchingWorkspaceStickies, workspaceStickies, toggleShowNewSticky]);
// Update this useEffect to correctly track height
useEffect(() => {
if (!masonryRef?.current) return;
const updateHeight = () => {
if (masonryRef.current) {
const height = masonryRef.current.getBoundingClientRect().height;
setContainerHeight(parseInt(height.toString()));
}
};
// Initial height measurement
updateHeight();
// Create ResizeObserver
const resizeObserver = new ResizeObserver(() => {
updateHeight();
});
resizeObserver.observe(masonryRef.current);
// Also update height when Masonry content changes
const mutationObserver = new MutationObserver(() => {
updateHeight();
});
mutationObserver.observe(masonryRef.current, {
childList: true,
subtree: true,
attributes: true,
});
return () => {
resizeObserver.disconnect();
mutationObserver.disconnect();
};
}, [masonryRef?.current]);
useIntersectionObserver(containerRef, fetchingWorkspaceStickies ? null : intersectionElement, incrementPage, "20%");
if (fetchingWorkspaceStickies && workspaceStickies.length === 0) {
return (
<div className="min-h-[500px] overflow-scroll pb-2">
<Loader>
<Loader.Item height="300px" width="255px" />
</Loader>
</div>
);
}
const getStickiesToRender = () => {
let stickies: (string | undefined)[] = workspaceStickies;
if (currentPage + 1 < totalPages && stickies.length >= PER_PAGE) {
stickies = [...stickies, undefined];
}
return stickies;
};
const stickyIds = getStickiesToRender();
const childElements = stickyIds.map((stickyId, index) => (
<div key={stickyId} className={cn("flex min-h-[300px] box-border p-2")} style={{ width: itemWidth }}>
{index === stickyIds.length - 1 && currentPage + 1 < totalPages ? (
<div ref={setIntersectionElement} className="flex w-full rounded min-h-[300px]">
<Loader className="w-full h-full">
<Loader.Item height="100%" width="100%" />
</Loader>
</div>
) : (
<StickyNote key={stickyId || "new"} workspaceSlug={workspaceSlug.toString()} stickyId={stickyId} />
)}
</div>
));
if (!fetchingWorkspaceStickies && workspaceStickies.length === 0)
return (
<EmptyState
creatingSticky={creatingSticky}
handleCreate={() => {
toggleShowNewSticky(true);
stickyOperations.create({ color: STICKY_COLORS[0] });
}}
/>
);
return (
<div
ref={containerRef}
className={cn("relative max-h-[625px] overflow-hidden pb-2 box-border", {
"max-h-full overflow-scroll": showAllStickies,
})}
>
<div className="h-full w-full" ref={masonryRef}>
{/* @ts-expect-error type mismatch here */}
<Masonry elementType="div">{childElements}</Masonry>
</div>
{containerHeight > 632.9 && (
<div className="absolute bottom-0 left-0 bg-gradient-to-t from-custom-background-100 to-transparent w-full h-[100px] text-center text-sm font-medium text-custom-primary-100">
<button
className="flex flex-col items-center justify-end gap-1 h-full m-auto w-full"
onClick={() => setShowAllStickies((state) => !state)}
>
{showAllStickies ? "Show less" : "Show all"}
</button>
</div>
)}
</div>
);
});
export const StickiesLayout = () => {
// 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 < 768) 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}>
<StickyAll columnCount={columnCount} />
</div>
);
};

View file

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

View file

@ -0,0 +1,109 @@
import { useCallback, useEffect, useRef } from "react";
import { DebouncedFunc } from "lodash";
import { Controller, useForm } from "react-hook-form";
import { EditorRefApi } from "@plane/editor";
import { TSticky } from "@plane/types";
import { TextArea } from "@plane/ui";
import { useWorkspace } from "@/hooks/store";
import { StickyEditor } from "../../editor";
type TProps = {
stickyData: TSticky | undefined;
workspaceSlug: string;
handleUpdate: DebouncedFunc<(payload: Partial<TSticky>) => Promise<void>>;
stickyId: string | undefined;
handleChange: (data: Partial<TSticky>) => Promise<void>;
handleDelete: () => Promise<void>;
};
export const StickyInput = (props: TProps) => {
const { stickyData, workspaceSlug, handleUpdate, stickyId, handleDelete, handleChange } = props;
//refs
const editorRef = useRef<EditorRefApi>(null);
// store hooks
const { getWorkspaceBySlug } = useWorkspace();
// form info
const { handleSubmit, reset, control } = useForm<TSticky>({
defaultValues: {
description_html: stickyData?.description_html,
name: stickyData?.name,
},
});
// computed values
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id as string;
// reset form values
useEffect(() => {
if (!stickyId) return;
reset({
id: stickyId,
description_html: stickyData?.description_html === "" ? "<p></p>" : stickyData?.description_html,
name: stickyData?.name,
});
}, [stickyData, reset]);
const handleFormSubmit = useCallback(
async (formdata: Partial<TSticky>) => {
if (formdata.name !== undefined) {
await handleUpdate({
description_html: formdata.description_html ?? "<p></p>",
name: formdata.name,
});
} else {
await handleUpdate({
description_html: formdata.description_html ?? "<p></p>",
});
}
},
[handleUpdate, workspaceSlug]
);
return (
<div className="flex-1">
{/* name */}
<Controller
name="name"
control={control}
render={({ field: { value, onChange } }) => (
<TextArea
value={value}
id="name"
name="name"
onChange={(e) => {
onChange(e.target.value);
handleSubmit(handleFormSubmit)();
}}
placeholder="Title"
className="text-lg font-medium text-[#455068] mb-2 w-full p-0 border-none min-h-[22px]"
/>
)}
/>
{/* description */}
<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: object, description_html: string) => {
onChange(description_html);
handleSubmit(handleFormSubmit)();
}}
placeholder={"Click to type here"}
containerClassName={"px-0 text-base min-h-[200px] w-full text-[#455068]"}
uploadFile={async () => ""}
showToolbar={false}
parentClassName={"border-none p-0"}
handleDelete={handleDelete}
handleColorChange={handleChange}
ref={editorRef}
/>
)}
/>
</div>
);
};

View file

@ -0,0 +1,72 @@
import { useCallback } from "react";
import { debounce } from "lodash";
import { observer } from "mobx-react";
import { Minimize2 } from "lucide-react";
import { TSticky } from "@plane/types";
import { cn } from "@plane/utils";
import { useSticky } from "@/hooks/use-stickies";
import { STICKY_COLORS } from "../../editor/sticky-editor/color-pallete";
import { StickyInput } from "./inputs";
import { useStickyOperations } from "./use-operations";
type TProps = {
onClose?: () => void;
workspaceSlug: string;
className?: string;
stickyId: string | undefined;
};
export const StickyNote = observer((props: TProps) => {
const { onClose, workspaceSlug, className = "", stickyId } = props;
// hooks
const { stickyOperations } = useStickyOperations({ workspaceSlug });
const { stickies } = useSticky();
// derived values
const stickyData: TSticky | undefined = stickyId ? stickies[stickyId] : undefined;
const handleChange = useCallback(
async (payload: Partial<TSticky>) => {
stickyId
? await stickyOperations.update(stickyId, payload)
: await stickyOperations.create({
color: payload.color || STICKY_COLORS[0],
...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 (
<div
className={cn("w-full flex flex-col h-fit rounded p-4 group/sticky", className)}
style={{ backgroundColor: stickyData?.color || STICKY_COLORS[0] }}
>
{onClose && (
<button className="flex w-full" onClick={onClose}>
<Minimize2 className="size-4 m-auto mr-0" />
</button>
)}
{/* inputs */}
<StickyInput
stickyData={stickyData}
workspaceSlug={workspaceSlug}
handleUpdate={debouncedFormSave}
stickyId={stickyId}
handleDelete={handleDelete}
handleChange={handleChange}
/>
</div>
);
});

View file

@ -0,0 +1,92 @@
import { useMemo } from "react";
import { TSticky } from "@plane/types";
import { setToast, TOAST_TYPE } from "@plane/ui";
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>;
};
type TProps = {
workspaceSlug: string;
};
export const useStickyOperations = (props: TProps) => {
const { workspaceSlug } = props;
const { createSticky, updateSticky, deleteSticky } = useSticky();
const isValid = (data: Partial<TSticky>) => {
if (data.name && data.name.length > 100) {
setToast({
message: "The sticky name cannot be longer than 100 characters",
type: TOAST_TYPE.ERROR,
title: "Sticky not updated",
});
return false;
}
return true;
};
const stickyOperations: TOperations = useMemo(
() => ({
create: async (data: Partial<TSticky>) => {
try {
if (!workspaceSlug) throw new Error("Missing required fields");
if (!isValid(data)) return;
await createSticky(workspaceSlug, data);
setToast({
message: "The sticky has been successfully created",
type: TOAST_TYPE.SUCCESS,
title: "Sticky created",
});
} catch (error: any) {
setToast({
message: error?.data?.error ?? "The sticky could not be created",
type: TOAST_TYPE.ERROR,
title: "Sticky not created",
});
throw error;
}
},
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) {
setToast({
message: "The sticky could not be updated",
type: TOAST_TYPE.ERROR,
title: "Sticky not updated",
});
throw error;
}
},
remove: async (stickyId: string) => {
try {
if (!workspaceSlug) throw new Error("Missing required fields");
await deleteSticky(workspaceSlug, stickyId);
setToast({
message: "The sticky has been successfully removed",
type: TOAST_TYPE.SUCCESS,
title: "Sticky removed",
});
} catch (error) {
setToast({
message: "The sticky could not be removed",
type: TOAST_TYPE.ERROR,
title: "Sticky not removed",
});
throw error;
}
},
}),
[workspaceSlug]
);
return {
stickyOperations,
};
};

View file

@ -0,0 +1,46 @@
import { useParams } from "next/navigation";
import { Plus } from "lucide-react";
import { useSticky } from "@/hooks/use-stickies";
import { STICKY_COLORS } from "../editor/sticky-editor/color-pallete";
import { StickySearch } from "./modal/search";
import { StickiesLayout } from "./stickies-layout";
import { useStickyOperations } from "./sticky/use-operations";
export const StickiesWidget = () => {
const { workspaceSlug } = useParams();
const { creatingSticky, toggleShowNewSticky } = useSticky();
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">My Stickies </div>
{/* actions */}
<div className="flex gap-2">
<StickySearch />
<button
onClick={() => {
toggleShowNewSticky(true);
stickyOperations.create({ color: STICKY_COLORS[0] });
}}
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>
</div>
</div>
<div className="-mx-2">
<StickiesLayout />
</div>
</div>
);
};