[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:
parent
24cc69fd7b
commit
cb045abfe1
42 changed files with 1621 additions and 100 deletions
|
|
@ -2,3 +2,4 @@ export * from "./embeds";
|
|||
export * from "./lite-text-editor";
|
||||
export * from "./pdf";
|
||||
export * from "./rich-text-editor";
|
||||
export * from "./sticky-editor";
|
||||
|
|
|
|||
36
web/core/components/editor/sticky-editor/color-pallete.tsx
Normal file
36
web/core/components/editor/sticky-editor/color-pallete.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
109
web/core/components/editor/sticky-editor/editor.tsx
Normal file
109
web/core/components/editor/sticky-editor/editor.tsx
Normal 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";
|
||||
2
web/core/components/editor/sticky-editor/index.ts
Normal file
2
web/core/components/editor/sticky-editor/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./editor";
|
||||
export * from "./toolbar";
|
||||
131
web/core/components/editor/sticky-editor/toolbar.tsx
Normal file
131
web/core/components/editor/sticky-editor/toolbar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
21
web/core/components/home/widgets/empty-states/issues.tsx
Normal file
21
web/core/components/home/widgets/empty-states/issues.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
129
web/core/components/stickies/action-bar.tsx
Normal file
129
web/core/components/stickies/action-bar.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
40
web/core/components/stickies/empty.tsx
Normal file
40
web/core/components/stickies/empty.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
2
web/core/components/stickies/index.ts
Normal file
2
web/core/components/stickies/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./action-bar";
|
||||
export * from "./widget";
|
||||
15
web/core/components/stickies/modal/index.tsx
Normal file
15
web/core/components/stickies/modal/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
76
web/core/components/stickies/modal/search.tsx
Normal file
76
web/core/components/stickies/modal/search.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
68
web/core/components/stickies/modal/stickies.tsx
Normal file
68
web/core/components/stickies/modal/stickies.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
209
web/core/components/stickies/stickies-layout.tsx
Normal file
209
web/core/components/stickies/stickies-layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1
web/core/components/stickies/sticky/index.ts
Normal file
1
web/core/components/stickies/sticky/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./root";
|
||||
109
web/core/components/stickies/sticky/inputs.tsx
Normal file
109
web/core/components/stickies/sticky/inputs.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
72
web/core/components/stickies/sticky/root.tsx
Normal file
72
web/core/components/stickies/sticky/root.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
92
web/core/components/stickies/sticky/use-operations.tsx
Normal file
92
web/core/components/stickies/sticky/use-operations.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
46
web/core/components/stickies/widget.tsx
Normal file
46
web/core/components/stickies/widget.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue