mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
[WEB-3096] feat: stickies page (#6380)
* feat: added independent stickies page * chore: randomized sticky color * chore: search in stickies * feat: dnd * fix: quick links * fix: stickies abrupt rendering * fix: handled edge cases for dnd * fix: empty states * fix: build and lint * fix: handled new sticky when last sticky is emoty * fix: new sticky condition * refactor: stickies empty states, store * chore: update stickies empty states * fix: random sticky color * fix: header * refactor: better error handling --------- Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
This commit is contained in:
@@ -39,9 +39,9 @@ class WorkspaceStickyViewSet(BaseViewSet):
|
||||
)
|
||||
def list(self, request, slug):
|
||||
query = request.query_params.get("query", False)
|
||||
stickies = self.get_queryset()
|
||||
stickies = self.get_queryset().order_by("-sort_order")
|
||||
if query:
|
||||
stickies = stickies.filter(name__icontains=query)
|
||||
stickies = stickies.filter(description_stripped__icontains=query)
|
||||
|
||||
return self.paginate(
|
||||
request=request,
|
||||
@@ -49,7 +49,7 @@ class WorkspaceStickyViewSet(BaseViewSet):
|
||||
on_results=lambda stickies: StickySerializer(stickies, many=True).data,
|
||||
default_per_page=20,
|
||||
)
|
||||
|
||||
|
||||
@allow_permission(allowed_roles=[], creator=True, model=Sticky, level="WORKSPACE")
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
@@ -5,6 +5,9 @@ from django.db import models
|
||||
# Module imports
|
||||
from .base import BaseModel
|
||||
|
||||
# Third party imports
|
||||
from plane.utils.html_processor import strip_tags
|
||||
|
||||
|
||||
class Sticky(BaseModel):
|
||||
name = models.TextField(null=True, blank=True)
|
||||
@@ -33,6 +36,12 @@ class Sticky(BaseModel):
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Strip the html tags using html parser
|
||||
self.description_stripped = (
|
||||
None
|
||||
if (self.description_html == "" or self.description_html is None)
|
||||
else strip_tags(self.description_html)
|
||||
)
|
||||
if self._state.adding:
|
||||
# Get the maximum sequence value from the database
|
||||
last_id = Sticky.objects.filter(workspace=self.workspace).aggregate(
|
||||
|
||||
@@ -13,3 +13,4 @@ export * from "./state";
|
||||
export * from "./swr";
|
||||
export * from "./user";
|
||||
export * from "./workspace";
|
||||
export * from "./stickies";
|
||||
|
||||
1
packages/constants/src/stickies.ts
Normal file
1
packages/constants/src/stickies.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const STICKIES_PER_PAGE = 30;
|
||||
@@ -145,7 +145,7 @@ ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||
position: relative;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-color: rgb(var(--color-background-100));
|
||||
background-color: transparent;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
width: 0.8rem;
|
||||
|
||||
@@ -1,41 +1,3 @@
|
||||
:root {
|
||||
/* text colors */
|
||||
--editor-colors-gray-text: #5c5e63;
|
||||
--editor-colors-peach-text: #ff5b59;
|
||||
--editor-colors-pink-text: #f65385;
|
||||
--editor-colors-orange-text: #fd9038;
|
||||
--editor-colors-green-text: #0fc27b;
|
||||
--editor-colors-light-blue-text: #17bee9;
|
||||
--editor-colors-dark-blue-text: #266df0;
|
||||
--editor-colors-purple-text: #9162f9;
|
||||
/* end text colors */
|
||||
}
|
||||
|
||||
/* text background colors */
|
||||
[data-theme="light"],
|
||||
[data-theme="light-contrast"] {
|
||||
--editor-colors-gray-background: #d6d6d8;
|
||||
--editor-colors-peach-background: #ffd5d7;
|
||||
--editor-colors-pink-background: #fdd4e3;
|
||||
--editor-colors-orange-background: #ffe3cd;
|
||||
--editor-colors-green-background: #c3f0de;
|
||||
--editor-colors-light-blue-background: #c5eff9;
|
||||
--editor-colors-dark-blue-background: #c9dafb;
|
||||
--editor-colors-purple-background: #e3d8fd;
|
||||
}
|
||||
[data-theme="dark"],
|
||||
[data-theme="dark-contrast"] {
|
||||
--editor-colors-gray-background: #404144;
|
||||
--editor-colors-peach-background: #593032;
|
||||
--editor-colors-pink-background: #562e3d;
|
||||
--editor-colors-orange-background: #583e2a;
|
||||
--editor-colors-green-background: #1d4a3b;
|
||||
--editor-colors-light-blue-background: #1f495c;
|
||||
--editor-colors-dark-blue-background: #223558;
|
||||
--editor-colors-purple-background: #3d325a;
|
||||
}
|
||||
/* end text background colors */
|
||||
|
||||
.editor-container {
|
||||
/* font sizes and line heights */
|
||||
&.large-font {
|
||||
|
||||
16
packages/types/src/stickies.d.ts
vendored
16
packages/types/src/stickies.d.ts
vendored
@@ -1,8 +1,16 @@
|
||||
import { TLogoProps } from "./common";
|
||||
|
||||
export type TSticky = {
|
||||
created_at?: string | undefined;
|
||||
created_by?: string | undefined;
|
||||
background_color?: string | null | undefined;
|
||||
description?: object | undefined;
|
||||
description_html?: string | undefined;
|
||||
id: string;
|
||||
logo_props: TLogoProps | undefined;
|
||||
name?: string;
|
||||
description_html?: string;
|
||||
color?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
sort_order: number | undefined;
|
||||
updated_at?: string | undefined;
|
||||
updated_by?: string | undefined;
|
||||
workspace: string | undefined;
|
||||
};
|
||||
|
||||
59
web/app/[workspaceSlug]/(projects)/stickies/header.tsx
Normal file
59
web/app/[workspaceSlug]/(projects)/stickies/header.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { useParams } from "next/navigation";
|
||||
import { Breadcrumbs, Button, Header, RecentStickyIcon } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
|
||||
// hooks
|
||||
import { StickySearch } from "@/components/stickies/modal/search";
|
||||
import { useStickyOperations } from "@/components/stickies/sticky/use-operations";
|
||||
// plane-web
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
|
||||
export const WorkspaceStickyHeader = observer(() => {
|
||||
const { workspaceSlug } = useParams();
|
||||
// hooks
|
||||
const { creatingSticky, toggleShowNewSticky } = useSticky();
|
||||
const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={`Stickies`}
|
||||
icon={<RecentStickyIcon className="size-5 rotate-90 text-custom-text-200" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</Header.LeftItem>
|
||||
|
||||
<Header.RightItem>
|
||||
<StickySearch />
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="items-center gap-1"
|
||||
onClick={() => {
|
||||
toggleShowNewSticky(true);
|
||||
stickyOperations.create();
|
||||
}}
|
||||
loading={creatingSticky}
|
||||
>
|
||||
Add sticky
|
||||
</Button>
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
</>
|
||||
);
|
||||
});
|
||||
13
web/app/[workspaceSlug]/(projects)/stickies/layout.tsx
Normal file
13
web/app/[workspaceSlug]/(projects)/stickies/layout.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
import { WorkspaceStickyHeader } from "./header";
|
||||
|
||||
export default function WorkspaceStickiesLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<WorkspaceStickyHeader />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
web/app/[workspaceSlug]/(projects)/stickies/page.tsx
Normal file
16
web/app/[workspaceSlug]/(projects)/stickies/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { StickiesInfinite } from "@/components/stickies";
|
||||
|
||||
export default function WorkspaceStickiesPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Your stickies" />
|
||||
<div className="relative h-full w-full overflow-hidden overflow-y-auto">
|
||||
<StickiesInfinite />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ interface IContentOverflowWrapper {
|
||||
buttonClassName?: string;
|
||||
containerClassName?: string;
|
||||
fallback?: ReactNode;
|
||||
customButton?: ReactNode;
|
||||
}
|
||||
|
||||
export const ContentOverflowWrapper = observer((props: IContentOverflowWrapper) => {
|
||||
@@ -18,6 +19,7 @@ export const ContentOverflowWrapper = observer((props: IContentOverflowWrapper)
|
||||
buttonClassName = "text-sm font-medium text-custom-primary-100",
|
||||
containerClassName,
|
||||
fallback = null,
|
||||
customButton,
|
||||
} = props;
|
||||
|
||||
// states
|
||||
@@ -131,16 +133,18 @@ export const ContentOverflowWrapper = observer((props: IContentOverflowWrapper)
|
||||
pointerEvents: isTransitioning ? "none" : "auto",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
"gap-1 w-full text-custom-primary-100 text-sm font-medium transition-opacity duration-300",
|
||||
buttonClassName
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
disabled={isTransitioning}
|
||||
>
|
||||
{showAll ? "Show less" : "Show all"}
|
||||
</button>
|
||||
{customButton || (
|
||||
<button
|
||||
className={cn(
|
||||
"gap-1 w-full text-custom-primary-100 text-sm font-medium transition-opacity duration-300",
|
||||
buttonClassName
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
disabled={isTransitioning}
|
||||
>
|
||||
{showAll ? "Show less" : "Show all"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
78
web/core/components/editor/sticky-editor/color-palette.tsx
Normal file
78
web/core/components/editor/sticky-editor/color-palette.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { TSticky } from "@plane/types";
|
||||
|
||||
export const STICKY_COLORS_LIST: {
|
||||
key: string;
|
||||
label: string;
|
||||
backgroundColor: string;
|
||||
}[] = [
|
||||
{
|
||||
key: "gray",
|
||||
label: "Gray",
|
||||
backgroundColor: "var(--editor-colors-gray-background)",
|
||||
},
|
||||
{
|
||||
key: "peach",
|
||||
label: "Peach",
|
||||
backgroundColor: "var(--editor-colors-peach-background)",
|
||||
},
|
||||
{
|
||||
key: "pink",
|
||||
label: "Pink",
|
||||
backgroundColor: "var(--editor-colors-pink-background)",
|
||||
},
|
||||
{
|
||||
key: "orange",
|
||||
label: "Orange",
|
||||
backgroundColor: "var(--editor-colors-orange-background)",
|
||||
},
|
||||
{
|
||||
key: "green",
|
||||
label: "Green",
|
||||
backgroundColor: "var(--editor-colors-green-background)",
|
||||
},
|
||||
{
|
||||
key: "light-blue",
|
||||
label: "Light blue",
|
||||
backgroundColor: "var(--editor-colors-light-blue-background)",
|
||||
},
|
||||
{
|
||||
key: "dark-blue",
|
||||
label: "Dark blue",
|
||||
backgroundColor: "var(--editor-colors-dark-blue-background)",
|
||||
},
|
||||
{
|
||||
key: "purple",
|
||||
label: "Purple",
|
||||
backgroundColor: "var(--editor-colors-purple-background)",
|
||||
},
|
||||
];
|
||||
|
||||
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_LIST.map((color) => (
|
||||
<button
|
||||
key={color.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleUpdate({
|
||||
background_color: color.key,
|
||||
});
|
||||
}}
|
||||
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.backgroundColor,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -82,26 +82,28 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
|
||||
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>
|
||||
{showToolbar && (
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Tooltip } from "@plane/ui";
|
||||
import { TOOLBAR_ITEMS, ToolbarMenuItem } from "@/constants/editor";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { ColorPalette } from "./color-pallete";
|
||||
import { ColorPalette } from "./color-palette";
|
||||
|
||||
type Props = {
|
||||
executeCommand: (item: ToolbarMenuItem) => void;
|
||||
|
||||
@@ -8,7 +8,7 @@ import Link from "next/link";
|
||||
import { useTheme } from "next-themes";
|
||||
// hooks
|
||||
// components
|
||||
import { Button, TButtonVariant } from "@plane/ui";
|
||||
import { Button, TButtonSizes, TButtonVariant } from "@plane/ui";
|
||||
// constant
|
||||
import { EMPTY_STATE_DETAILS, EmptyStateType } from "@/constants/empty-state";
|
||||
// helpers
|
||||
@@ -18,10 +18,14 @@ import { EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
|
||||
import { ComicBoxButton } from "./comic-box-button";
|
||||
|
||||
export type EmptyStateProps = {
|
||||
size?: TButtonSizes;
|
||||
type: EmptyStateType;
|
||||
size?: "sm" | "md" | "lg";
|
||||
layout?: "screen-detailed" | "screen-simple";
|
||||
additionalPath?: string;
|
||||
primaryButtonConfig?: {
|
||||
size?: TButtonSizes;
|
||||
variant?: TButtonVariant;
|
||||
};
|
||||
primaryButtonOnClick?: () => void;
|
||||
primaryButtonLink?: string;
|
||||
secondaryButtonOnClick?: () => void;
|
||||
@@ -29,10 +33,14 @@ export type EmptyStateProps = {
|
||||
|
||||
export const EmptyState: React.FC<EmptyStateProps> = observer((props) => {
|
||||
const {
|
||||
type,
|
||||
size = "lg",
|
||||
type,
|
||||
layout = "screen-detailed",
|
||||
additionalPath = "",
|
||||
primaryButtonConfig = {
|
||||
size: "lg",
|
||||
variant: "primary",
|
||||
},
|
||||
primaryButtonOnClick,
|
||||
primaryButtonLink,
|
||||
secondaryButtonOnClick,
|
||||
@@ -67,8 +75,8 @@ export const EmptyState: React.FC<EmptyStateProps> = observer((props) => {
|
||||
if (!primaryButton) return null;
|
||||
|
||||
const commonProps = {
|
||||
size: size,
|
||||
variant: "primary" as TButtonVariant,
|
||||
size: primaryButtonConfig.size,
|
||||
variant: primaryButtonConfig.variant,
|
||||
prependIcon: primaryButton.icon,
|
||||
onClick: primaryButtonOnClick ? primaryButtonOnClick : undefined,
|
||||
disabled: !isEditingAllowed,
|
||||
@@ -145,12 +153,10 @@ export const EmptyState: React.FC<EmptyStateProps> = observer((props) => {
|
||||
)}
|
||||
|
||||
{anyButton && (
|
||||
<>
|
||||
<div className="relative flex items-center justify-center gap-2 flex-shrink-0 w-full">
|
||||
{renderPrimaryButton()}
|
||||
{renderSecondaryButton()}
|
||||
</div>
|
||||
</>
|
||||
<div className="relative flex items-center justify-center gap-2 flex-shrink-0 w-full">
|
||||
{renderPrimaryButton()}
|
||||
{renderSecondaryButton()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,6 +181,12 @@ export const EmptyState: React.FC<EmptyStateProps> = observer((props) => {
|
||||
) : (
|
||||
<h3 className="text-sm font-medium text-custom-text-400 whitespace-pre-line">{title}</h3>
|
||||
)}
|
||||
{anyButton && (
|
||||
<div className="relative flex items-center justify-center gap-2 flex-shrink-0 w-full">
|
||||
{renderPrimaryButton()}
|
||||
{renderSecondaryButton()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,54 +1,96 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// types
|
||||
// plane types
|
||||
import { THomeWidgetKeys, THomeWidgetProps } from "@plane/types";
|
||||
// components
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
// hooks
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
// components
|
||||
// plane web components
|
||||
import { HomePageHeader } from "@/plane-web/components/home/header";
|
||||
import { StickiesWidget } from "../stickies";
|
||||
import { RecentActivityWidget } from "./widgets";
|
||||
import { DashboardQuickLinks } from "./widgets/links";
|
||||
import { ManageWidgetsModal } from "./widgets/manage";
|
||||
|
||||
const WIDGETS_LIST: {
|
||||
[key in THomeWidgetKeys]: { component: React.FC<THomeWidgetProps> | null; fullWidth: boolean };
|
||||
export const HOME_WIDGETS_LIST: {
|
||||
[key in THomeWidgetKeys]: {
|
||||
component: React.FC<THomeWidgetProps> | null;
|
||||
fullWidth: boolean;
|
||||
title: string;
|
||||
};
|
||||
} = {
|
||||
quick_links: { component: DashboardQuickLinks, fullWidth: false },
|
||||
recents: { component: RecentActivityWidget, fullWidth: false },
|
||||
my_stickies: { component: StickiesWidget, fullWidth: false },
|
||||
new_at_plane: { component: null, fullWidth: false },
|
||||
quick_tutorial: { component: null, fullWidth: false },
|
||||
quick_links: {
|
||||
component: DashboardQuickLinks,
|
||||
fullWidth: false,
|
||||
title: "Quick links",
|
||||
},
|
||||
recents: {
|
||||
component: RecentActivityWidget,
|
||||
fullWidth: false,
|
||||
title: "Recents",
|
||||
},
|
||||
my_stickies: {
|
||||
component: StickiesWidget,
|
||||
fullWidth: false,
|
||||
title: "Your stickies",
|
||||
},
|
||||
new_at_plane: {
|
||||
component: null,
|
||||
fullWidth: false,
|
||||
title: "New at Plane",
|
||||
},
|
||||
quick_tutorial: {
|
||||
component: null,
|
||||
fullWidth: false,
|
||||
title: "Quick tutorial",
|
||||
},
|
||||
};
|
||||
|
||||
export const DashboardWidgets = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { toggleWidgetSettings, widgetsMap, showWidgetSettings, orderedWidgets } = useHome();
|
||||
const { toggleWidgetSettings, widgetsMap, showWidgetSettings, orderedWidgets, isAnyWidgetEnabled } = useHome();
|
||||
|
||||
if (!workspaceSlug) return null;
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col gap-7">
|
||||
<div className="h-full w-full relative flex flex-col gap-7">
|
||||
<HomePageHeader />
|
||||
<ManageWidgetsModal
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
isModalOpen={showWidgetSettings}
|
||||
handleOnClose={() => toggleWidgetSettings(false)}
|
||||
/>
|
||||
<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="py-4">
|
||||
<WidgetComponent workspaceSlug={workspaceSlug.toString()} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{isAnyWidgetEnabled ? (
|
||||
<div className="flex flex-col divide-y-[1px] divide-custom-border-100">
|
||||
{orderedWidgets.map((key) => {
|
||||
const WidgetComponent = HOME_WIDGETS_LIST[key]?.component;
|
||||
const isEnabled = widgetsMap[key]?.is_enabled;
|
||||
if (!WidgetComponent || !isEnabled) return null;
|
||||
return (
|
||||
<div key={key} className="py-4">
|
||||
<WidgetComponent workspaceSlug={workspaceSlug.toString()} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full w-full grid place-items-center">
|
||||
<EmptyState
|
||||
type={EmptyStateType.HOME_WIDGETS}
|
||||
layout="screen-simple"
|
||||
primaryButtonOnClick={() => toggleWidgetSettings(true)}
|
||||
primaryButtonConfig={{
|
||||
size: "sm",
|
||||
variant: "neutral-primary",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -59,12 +59,11 @@ export const WorkspaceHomeView = observer(() => {
|
||||
<>
|
||||
<IssuePeekOverview />
|
||||
<ContentWrapper
|
||||
className={cn("gap-7 bg-custom-background-90/20", {
|
||||
className={cn("gap-6 bg-custom-background-90/20", {
|
||||
"vertical-scrollbar scrollbar-lg": windowWidth >= 768,
|
||||
})}
|
||||
>
|
||||
{currentUser && <UserGreetingsView user={currentUser} handleWidgetModal={() => toggleWidgetSettings(true)} />}
|
||||
|
||||
<DashboardWidgets />
|
||||
</ContentWrapper>
|
||||
</>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { FC } from "react";
|
||||
// hooks
|
||||
import { Shapes } from "lucide-react";
|
||||
// plane types
|
||||
import { IUser } from "@plane/types";
|
||||
// plane ui
|
||||
import { Button } from "@plane/ui";
|
||||
// hooks
|
||||
import { useCurrentTime } from "@/hooks/use-current-time";
|
||||
// types
|
||||
|
||||
export interface IUserGreetingsView {
|
||||
user: IUser;
|
||||
@@ -51,13 +53,10 @@ export const UserGreetingsView: FC<IUserGreetingsView> = (props) => {
|
||||
</div>
|
||||
</h6>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleWidgetModal}
|
||||
className="flex items-center gap-2 font-medium text-custom-text-300 justify-center border border-custom-border-200 rounded p-2 my-auto mb-0"
|
||||
>
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleWidgetModal} className="my-auto mb-0">
|
||||
<Shapes size={16} />
|
||||
<div className="text-xs font-medium">Manage widgets</div>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,27 +1,12 @@
|
||||
import { Link2, Plus } from "lucide-react";
|
||||
import { Button } from "@plane/ui";
|
||||
import { Link2 } from "lucide-react";
|
||||
|
||||
type TProps = {
|
||||
handleCreate: () => void;
|
||||
};
|
||||
export const LinksEmptyState = (props: TProps) => {
|
||||
const { handleCreate } = props;
|
||||
return (
|
||||
<div className="min-h-[200px] flex w-full justify-center py-6 border-[1.5px] border-custom-border-100 rounded">
|
||||
<div className="m-auto">
|
||||
<div
|
||||
className={`mb-2 rounded-full mx-auto last:rounded-full w-[50px] h-[50px] flex items-center justify-center bg-custom-background-80/40 transition-transform duration-300`}
|
||||
>
|
||||
<Link2 size={30} className="text-custom-text-400 -rotate-45" />
|
||||
export const LinksEmptyState = () => (
|
||||
<div className="min-h-[110px] flex w-full justify-center py-6 bg-custom-border-100 rounded">
|
||||
<div className="m-auto flex gap-2">
|
||||
<Link2 size={30} className="text-custom-text-400/40 -rotate-45" />
|
||||
<div className="text-custom-text-400 text-sm text-center my-auto">
|
||||
Add any links you need for quick access to your work.
|
||||
</div>
|
||||
<div className="text-custom-text-100 font-medium text-base text-center mb-1">No quick links yet</div>
|
||||
<div className="text-custom-text-300 text-sm text-center mb-2">
|
||||
Add any links you need for quick access to your work.{" "}
|
||||
</div>
|
||||
<Button variant="accent-primary" size="sm" onClick={handleCreate} className="mx-auto">
|
||||
<Plus className="size-4 my-auto" /> <span>Add quick link</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
import { History } from "lucide-react";
|
||||
import { Briefcase, FileText, History } from "lucide-react";
|
||||
import { LayersIcon } from "@plane/ui";
|
||||
|
||||
export const RecentsEmptyState = () => (
|
||||
<div className="h-[200px] flex w-full justify-center py-6 border-[1.5px] border-custom-border-100 rounded">
|
||||
<div className="m-auto">
|
||||
<div
|
||||
className={`mb-2 rounded-full mx-auto last:rounded-full w-[50px] h-[50px] flex items-center justify-center bg-custom-background-80/40 transition-transform duration-300`}
|
||||
>
|
||||
<History size={30} className="text-custom-text-400 -rotate-45" />
|
||||
export const RecentsEmptyState = ({ type }: { type: string }) => {
|
||||
const getDisplayContent = () => {
|
||||
switch (type) {
|
||||
case "project":
|
||||
return {
|
||||
icon: <Briefcase size={30} className="text-custom-text-400/40" />,
|
||||
text: "Your recent projects will appear here once you visit one.",
|
||||
};
|
||||
case "page":
|
||||
return {
|
||||
icon: <FileText size={30} className="text-custom-text-400/40" />,
|
||||
text: "Your recent pages will appear here once you visit one.",
|
||||
};
|
||||
case "issue":
|
||||
return {
|
||||
icon: <LayersIcon className="text-custom-text-400/40 w-[30px] h-[30px]" />,
|
||||
text: "Your recent issues will appear here once you visit one.",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <History size={30} className="text-custom-text-400/40" />,
|
||||
text: "You don’t have any recent items yet.",
|
||||
};
|
||||
}
|
||||
};
|
||||
const { icon, text } = getDisplayContent();
|
||||
|
||||
return (
|
||||
<div className="min-h-[120px] flex w-full justify-center py-6 bg-custom-border-100 rounded">
|
||||
<div className="m-auto flex gap-2">
|
||||
{icon} <div className="text-custom-text-400 text-sm text-center my-auto">{text}</div>
|
||||
</div>
|
||||
<div className="text-custom-text-100 font-medium text-base text-center mb-1">No recent items yet</div>
|
||||
<div className="text-custom-text-300 text-sm text-center mb-2">You don’t have any recent items yet. </div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,17 +2,22 @@ import React from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Briefcase, Hotel, Users } from "lucide-react";
|
||||
// helpers
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
import { useCommandPalette, useEventTracker, useUser, useUserPermissions } from "@/hooks/store";
|
||||
// plane web constants
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants";
|
||||
|
||||
export const EmptyWorkspace = () => {
|
||||
// navigation
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { data: currentUser } = useUser();
|
||||
|
||||
// derived values
|
||||
const canCreateProject = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
@@ -83,6 +88,7 @@ export const EmptyWorkspace = () => {
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{EMPTY_STATE_DATA.map((item) => (
|
||||
|
||||
13
web/core/components/home/widgets/empty-states/stickies.tsx
Normal file
13
web/core/components/home/widgets/empty-states/stickies.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
// plane ui
|
||||
import { RecentStickyIcon } from "@plane/ui";
|
||||
|
||||
export const StickiesEmptyState = () => (
|
||||
<div className="min-h-[110px] flex w-full justify-center py-6 bg-custom-border-100 rounded">
|
||||
<div className="m-auto flex gap-2">
|
||||
<RecentStickyIcon className="h-[30px] w-[30px] text-custom-text-400/40" />
|
||||
<div className="text-custom-text-400 text-sm text-center my-auto">
|
||||
No stickies yet. Add one to start making quick notes.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -4,12 +4,11 @@ import { FC } from "react";
|
||||
// hooks
|
||||
// ui
|
||||
import { observer } from "mobx-react";
|
||||
import { Pencil, Trash2, ExternalLink, EllipsisVertical, Link, Link2 } from "lucide-react";
|
||||
import { Pencil, Trash2, ExternalLink, EllipsisVertical, Link2, Link } from "lucide-react";
|
||||
import { TOAST_TYPE, setToast, CustomMenu, TContextMenuItem } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
import { cn, copyTextToClipboard } from "@plane/utils";
|
||||
import { calculateTimeAgo } from "@/helpers/date-time.helper";
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
import { TLinkOperations } from "./use-links";
|
||||
|
||||
@@ -37,7 +36,7 @@ export const ProjectLinkDetail: FC<TProjectLinkDetail> = observer((props) => {
|
||||
};
|
||||
|
||||
const handleCopyText = () =>
|
||||
copyUrlToClipboard(viewLink).then(() => {
|
||||
copyTextToClipboard(viewLink).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link Copied!",
|
||||
@@ -74,7 +73,10 @@ export const ProjectLinkDetail: FC<TProjectLinkDetail> = observer((props) => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="group btn btn-primary flex bg-custom-background-100 px-4 w-[230px] h-[56px] border-[0.5px] border-custom-border-200 rounded-md gap-4 hover:shadow-md">
|
||||
<div
|
||||
onClick={handleOpenInNewTab}
|
||||
className="cursor-pointer group btn btn-primary flex bg-custom-background-100 px-4 w-[230px] h-[56px] border-[0.5px] border-custom-border-200 rounded-md gap-4 hover:shadow-md"
|
||||
>
|
||||
<div className="rounded p-2 bg-custom-background-80/40 w-8 h-8 my-auto">
|
||||
<Link2 className="h-4 w-4 stroke-2 text-custom-text-350 -rotate-45" />
|
||||
</div>
|
||||
|
||||
@@ -20,14 +20,14 @@ export const ProjectLinkList: FC<TProjectLinkList> = observer((props) => {
|
||||
const { linkOperations, workspaceSlug } = props;
|
||||
// hooks
|
||||
const {
|
||||
quickLinks: { getLinksByWorkspaceId, toggleLinkModal },
|
||||
quickLinks: { getLinksByWorkspaceId },
|
||||
} = useHome();
|
||||
|
||||
const links = getLinksByWorkspaceId(workspaceSlug);
|
||||
|
||||
if (links === undefined) return <WidgetLoader widgetKey={EWidgetKeys.QUICK_LINKS} />;
|
||||
|
||||
if (links.length === 0) return <LinksEmptyState handleCreate={() => toggleLinkModal(true)} />;
|
||||
if (links.length === 0) return <LinksEmptyState />;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
|
||||
@@ -32,7 +32,6 @@ export const useLinks = (workspaceSlug: string) => {
|
||||
create: async (data: Partial<TProjectLink>) => {
|
||||
try {
|
||||
if (!workspaceSlug) throw new Error("Missing required fields");
|
||||
console.log("data", data, workspaceSlug);
|
||||
await createLink(workspaceSlug, data);
|
||||
setToast({
|
||||
message: "The link has been successfully created",
|
||||
|
||||
@@ -11,18 +11,18 @@ import {
|
||||
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
|
||||
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
|
||||
import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane helpers
|
||||
import { useParams } from "next/navigation";
|
||||
import { createRoot } from "react-dom/client";
|
||||
// ui
|
||||
// plane types
|
||||
import { InstructionType, TWidgetEntityData } from "@plane/types";
|
||||
// components
|
||||
// plane ui
|
||||
import { DropIndicator, ToggleSwitch } from "@plane/ui";
|
||||
// helpers
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useHome } from "@/hooks/store/use-home";
|
||||
import { HOME_WIDGETS_LIST } from "../../home-dashboard-widgets";
|
||||
import { WidgetItemDragHandle } from "./widget-item-drag-handle";
|
||||
import { getCanDrop, getInstructionFromPayload } from "./widget.helpers";
|
||||
|
||||
@@ -46,6 +46,7 @@ export const WidgetItem: FC<Props> = observer((props) => {
|
||||
const { widgetsMap } = useHome();
|
||||
// derived values
|
||||
const widget = widgetsMap[widgetId] as TWidgetEntityData;
|
||||
const widgetTitle = HOME_WIDGETS_LIST[widget.key]?.title;
|
||||
|
||||
// drag and drop
|
||||
useEffect(() => {
|
||||
@@ -119,7 +120,7 @@ export const WidgetItem: FC<Props> = observer((props) => {
|
||||
<div
|
||||
ref={elementRef}
|
||||
className={cn(
|
||||
"px-2 relative flex items-center py-2 font-medium text-sm capitalize group/widget-item rounded hover:bg-custom-background-80 justify-between",
|
||||
"px-2 relative flex items-center py-2 font-medium text-sm group/widget-item rounded hover:bg-custom-background-80 justify-between",
|
||||
{
|
||||
"cursor-grabbing bg-custom-background-80": isDragging,
|
||||
}
|
||||
@@ -127,7 +128,7 @@ export const WidgetItem: FC<Props> = observer((props) => {
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<WidgetItemDragHandle sort_order={widget.sort_order} isDragging={isDragging} />
|
||||
<div>{widget.key.replaceAll("_", " ")}</div>
|
||||
<div>{widgetTitle}</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={widget.is_enabled}
|
||||
|
||||
@@ -74,7 +74,7 @@ export const RecentActivityWidget: React.FC<THomeWidgetProps> = observer((props)
|
||||
<FiltersDropdown filters={filters} activeFilter={filter} setActiveFilter={setFilter} />
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<RecentsEmptyState />
|
||||
<RecentsEmptyState type={filter} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,25 +3,37 @@ import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { Plus, StickyNote as StickyIcon, X } from "lucide-react";
|
||||
// plane hooks
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
// plane ui
|
||||
import { RecentStickyIcon, StickyNoteIcon, Tooltip } from "@plane/ui";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useCommandPalette } from "@/hooks/store";
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
// components
|
||||
import { STICKY_COLORS_LIST } from "../editor/sticky-editor/color-palette";
|
||||
import { AllStickiesModal } from "./modal";
|
||||
import { StickyNote } from "./sticky";
|
||||
|
||||
export const StickyActionBar = observer(() => {
|
||||
const { workspaceSlug } = useParams();
|
||||
// states
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [newSticky, setNewSticky] = useState(false);
|
||||
const [showRecentSticky, setShowRecentSticky] = useState(false);
|
||||
// navigation
|
||||
const { workspaceSlug } = useParams();
|
||||
// refs
|
||||
const ref = useRef(null);
|
||||
|
||||
// hooks
|
||||
// store hooks
|
||||
const { stickies, activeStickyId, recentStickyId, updateActiveStickyId, fetchRecentSticky, toggleShowNewSticky } =
|
||||
useSticky();
|
||||
const { toggleAllStickiesModal, allStickiesModal } = useCommandPalette();
|
||||
// derived values
|
||||
const recentStickyBackgroundColor = recentStickyId
|
||||
? STICKY_COLORS_LIST.find((c) => c.key === stickies[recentStickyId].background_color)?.backgroundColor
|
||||
: STICKY_COLORS_LIST[0].backgroundColor;
|
||||
|
||||
useSWR(
|
||||
workspaceSlug ? `WORKSPACE_STICKIES_RECENT_${workspaceSlug}` : null,
|
||||
@@ -63,7 +75,7 @@ export const StickyActionBar = observer(() => {
|
||||
<div
|
||||
className="absolute top-0 right-0 h-full w-full"
|
||||
style={{
|
||||
background: `linear-gradient(to top, ${stickies[recentStickyId]?.color}, transparent)`,
|
||||
background: `linear-gradient(to top, ${recentStickyBackgroundColor}, transparent)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -75,9 +87,9 @@ export const StickyActionBar = observer(() => {
|
||||
<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 }}
|
||||
style={{ color: recentStickyBackgroundColor }}
|
||||
>
|
||||
<StickyNoteIcon className={cn("size-5 rotate-90")} color={stickies[recentStickyId]?.color} />
|
||||
<StickyNoteIcon className={cn("size-5 rotate-90")} color={recentStickyBackgroundColor} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Plus, StickyNote as StickyIcon } from "lucide-react";
|
||||
import { Button } from "@plane/ui";
|
||||
|
||||
type TProps = {
|
||||
handleCreate: () => void;
|
||||
creatingSticky?: boolean;
|
||||
};
|
||||
export const EmptyState = (props: TProps) => {
|
||||
const { handleCreate, creatingSticky } = props;
|
||||
return (
|
||||
<div className="flex justify-center h-[500px] rounded border-[1.5px] border-custom-border-100 mx-2">
|
||||
<div className="m-auto">
|
||||
<div
|
||||
className={`mb-2 rounded-full mx-auto last:rounded-full w-[50px] h-[50px] flex items-center justify-center bg-custom-background-80/40 transition-transform duration-300`}
|
||||
>
|
||||
<StickyIcon className="size-[30px] rotate-90 text-custom-text-350/20" />
|
||||
</div>
|
||||
<div className="text-custom-text-100 font-medium text-lg text-center mb-1">No stickies yet</div>
|
||||
<div className="text-custom-text-300 text-sm text-center mb-2">
|
||||
All your stickies in this workspace will appear here.
|
||||
</div>
|
||||
<Button size="sm" variant="accent-primary" className="mx-auto" onClick={handleCreate} 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>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./action-bar";
|
||||
export * from "./widget";
|
||||
export * from "./layout";
|
||||
|
||||
3
web/core/components/stickies/layout/index.ts
Normal file
3
web/core/components/stickies/layout/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./stickies-infinite";
|
||||
export * from "./stickies-list";
|
||||
export * from "./stickies-truncated";
|
||||
62
web/core/components/stickies/layout/stickies-infinite.tsx
Normal file
62
web/core/components/stickies/layout/stickies-infinite.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { STICKIES_PER_PAGE } from "@plane/constants";
|
||||
import { ContentWrapper, Loader } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
import { StickiesLayout } from "./stickies-list";
|
||||
|
||||
export const StickiesInfinite = observer(() => {
|
||||
const { workspaceSlug } = useParams();
|
||||
// hooks
|
||||
const { fetchWorkspaceStickies, fetchNextWorkspaceStickies, getWorkspaceStickyIds, loader, paginationInfo } =
|
||||
useSticky();
|
||||
//state
|
||||
const [elementRef, setElementRef] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
// ref
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useSWR(
|
||||
workspaceSlug ? `WORKSPACE_STICKIES_${workspaceSlug}` : null,
|
||||
workspaceSlug ? () => fetchWorkspaceStickies(workspaceSlug.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (loader === "pagination") return;
|
||||
fetchNextWorkspaceStickies(workspaceSlug?.toString());
|
||||
};
|
||||
|
||||
const hasNextPage = paginationInfo?.next_page_results && paginationInfo?.next_cursor !== undefined;
|
||||
const shouldObserve = hasNextPage && loader !== "pagination";
|
||||
const workspaceStickies = getWorkspaceStickyIds(workspaceSlug?.toString());
|
||||
useIntersectionObserver(containerRef, shouldObserve ? elementRef : null, handleLoadMore);
|
||||
|
||||
return (
|
||||
<ContentWrapper ref={containerRef} className="space-y-4">
|
||||
<StickiesLayout
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
intersectionElement={
|
||||
hasNextPage &&
|
||||
workspaceStickies?.length >= STICKIES_PER_PAGE && (
|
||||
<div
|
||||
className={cn("flex min-h-[300px] box-border p-2 w-full")}
|
||||
ref={setElementRef}
|
||||
id="intersection-element"
|
||||
>
|
||||
<div className="flex w-full rounded min-h-[300px]">
|
||||
<Loader className="w-full h-full">
|
||||
<Loader.Item height="100%" width="100%" />
|
||||
</Loader>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
);
|
||||
});
|
||||
168
web/core/components/stickies/layout/stickies-list.tsx
Normal file
168
web/core/components/stickies/layout/stickies-list.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type {
|
||||
DropTargetRecord,
|
||||
DragLocationHistory,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
|
||||
import type { ElementDragPayload } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Masonry from "react-masonry-component";
|
||||
// plane ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
// hooks
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
import { useStickyOperations } from "../sticky/use-operations";
|
||||
import { StickyDNDWrapper } from "./sticky-dnd-wrapper";
|
||||
import { getInstructionFromPayload } from "./sticky.helpers";
|
||||
import { StickiesEmptyState } from "@/components/home/widgets/empty-states/stickies";
|
||||
|
||||
type TStickiesLayout = {
|
||||
workspaceSlug: string;
|
||||
intersectionElement?: React.ReactNode | null;
|
||||
};
|
||||
|
||||
type TProps = TStickiesLayout & {
|
||||
columnCount: number;
|
||||
};
|
||||
|
||||
export const StickiesList = observer((props: TProps) => {
|
||||
const { workspaceSlug, intersectionElement, columnCount } = props;
|
||||
// navigation
|
||||
const pathname = usePathname();
|
||||
// store hooks
|
||||
const { getWorkspaceStickyIds, toggleShowNewSticky, searchQuery, loader } = useSticky();
|
||||
// sticky operations
|
||||
const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() });
|
||||
// derived values
|
||||
const workspaceStickyIds = getWorkspaceStickyIds(workspaceSlug?.toString());
|
||||
const itemWidth = `${100 / columnCount}%`;
|
||||
const totalRows = Math.ceil(workspaceStickyIds.length / columnCount);
|
||||
const isStickiesPage = pathname?.includes("stickies");
|
||||
|
||||
// Function to determine if an item is in first or last row
|
||||
const getRowPositions = (index: number) => {
|
||||
const currentRow = Math.floor(index / columnCount);
|
||||
return {
|
||||
isInFirstRow: currentRow === 0,
|
||||
isInLastRow: currentRow === totalRows - 1 || index >= workspaceStickyIds.length - columnCount,
|
||||
};
|
||||
};
|
||||
|
||||
const handleDrop = (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => {
|
||||
const dropTargets = location?.current?.dropTargets ?? [];
|
||||
if (!dropTargets || dropTargets.length <= 0) return;
|
||||
|
||||
const dropTarget = dropTargets[0];
|
||||
if (!dropTarget?.data?.id || !source.data?.id) return;
|
||||
|
||||
const instruction = getInstructionFromPayload(dropTarget, source, location);
|
||||
const droppedId = dropTarget.data.id;
|
||||
const sourceId = source.data.id;
|
||||
|
||||
try {
|
||||
if (!instruction || !droppedId || !sourceId) return;
|
||||
stickyOperations.updatePosition(workspaceSlug, sourceId as string, droppedId as string, instruction);
|
||||
} catch (error) {
|
||||
console.error("Error reordering sticky:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loader === "init-loader") {
|
||||
return (
|
||||
<div className="min-h-[500px] overflow-scroll pb-2">
|
||||
<Loader>
|
||||
<Loader.Item height="300px" width="255px" />
|
||||
</Loader>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loader === "loaded" && workspaceStickyIds.length === 0) {
|
||||
return (
|
||||
<div className="size-full grid place-items-center">
|
||||
{isStickiesPage ? (
|
||||
<EmptyState
|
||||
type={searchQuery ? EmptyStateType.STICKIES_SEARCH : EmptyStateType.STICKIES}
|
||||
layout={searchQuery ? "screen-simple" : "screen-detailed"}
|
||||
primaryButtonOnClick={() => {
|
||||
toggleShowNewSticky(true);
|
||||
stickyOperations.create();
|
||||
}}
|
||||
primaryButtonConfig={{
|
||||
size: "sm",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<StickiesEmptyState />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="transition-opacity duration-300 ease-in-out">
|
||||
{/* @ts-expect-error type mismatch here */}
|
||||
<Masonry elementType="div">
|
||||
{workspaceStickyIds.map((stickyId, index) => {
|
||||
const { isInFirstRow, isInLastRow } = getRowPositions(index);
|
||||
return (
|
||||
<StickyDNDWrapper
|
||||
key={stickyId}
|
||||
stickyId={stickyId}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
itemWidth={itemWidth}
|
||||
handleDrop={handleDrop}
|
||||
isLastChild={index === workspaceStickyIds.length - 1}
|
||||
isInFirstRow={isInFirstRow}
|
||||
isInLastRow={isInLastRow}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{intersectionElement && <div style={{ width: itemWidth }}>{intersectionElement}</div>}
|
||||
</Masonry>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const StickiesLayout = (props: TStickiesLayout) => {
|
||||
// states
|
||||
const [containerWidth, setContainerWidth] = useState<number | null>(null);
|
||||
// refs
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref?.current) return;
|
||||
|
||||
setContainerWidth(ref?.current.offsetWidth);
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(ref?.current);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
const getColumnCount = (width: number | null): number => {
|
||||
if (width === null) return 4;
|
||||
|
||||
if (width < 640) return 2; // sm
|
||||
if (width < 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} className="size-full min-h-[500px]">
|
||||
<StickiesList {...props} columnCount={columnCount} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
44
web/core/components/stickies/layout/stickies-truncated.tsx
Normal file
44
web/core/components/stickies/layout/stickies-truncated.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
// components
|
||||
import { ContentOverflowWrapper } from "../../core/content-overflow-HOC";
|
||||
import { StickiesLayout } from "./stickies-list";
|
||||
|
||||
export const StickiesTruncated = observer(() => {
|
||||
// navigation
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { fetchWorkspaceStickies } = useSticky();
|
||||
|
||||
useSWR(
|
||||
workspaceSlug ? `WORKSPACE_STICKIES_${workspaceSlug}` : null,
|
||||
workspaceSlug ? () => fetchWorkspaceStickies(workspaceSlug.toString()) : null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
return (
|
||||
<ContentOverflowWrapper
|
||||
maxHeight={620}
|
||||
containerClassName="pb-2 box-border"
|
||||
fallback={null}
|
||||
customButton={
|
||||
<Link
|
||||
href={`/${workspaceSlug}/stickies`}
|
||||
className={cn(
|
||||
"gap-1 w-full text-custom-primary-100 text-sm font-medium transition-opacity duration-300 bg-custom-background-90/20"
|
||||
)}
|
||||
>
|
||||
Show all
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<StickiesLayout workspaceSlug={workspaceSlug?.toString()} />
|
||||
</ContentOverflowWrapper>
|
||||
);
|
||||
});
|
||||
137
web/core/components/stickies/layout/sticky-dnd-wrapper.tsx
Normal file
137
web/core/components/stickies/layout/sticky-dnd-wrapper.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import type {
|
||||
DropTargetRecord,
|
||||
DragLocationHistory,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
|
||||
import {
|
||||
draggable,
|
||||
dropTargetForElements,
|
||||
ElementDragPayload,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/element/pointer-outside-of-preview";
|
||||
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
|
||||
import { attachInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { InstructionType } from "@plane/types";
|
||||
import { DropIndicator } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
import { StickyNote } from "../sticky";
|
||||
import { getInstructionFromPayload } from "./sticky.helpers";
|
||||
|
||||
// Draggable Sticky Wrapper Component
|
||||
export const StickyDNDWrapper = observer(
|
||||
({
|
||||
stickyId,
|
||||
workspaceSlug,
|
||||
itemWidth,
|
||||
isLastChild,
|
||||
isInFirstRow,
|
||||
isInLastRow,
|
||||
handleDrop,
|
||||
}: {
|
||||
stickyId: string;
|
||||
workspaceSlug: string;
|
||||
itemWidth: string;
|
||||
isLastChild: boolean;
|
||||
isInFirstRow: boolean;
|
||||
isInLastRow: boolean;
|
||||
handleDrop: (self: DropTargetRecord, source: ElementDragPayload, location: DragLocationHistory) => void;
|
||||
}) => {
|
||||
const pathName = usePathname();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [instruction, setInstruction] = useState<InstructionType | undefined>(undefined);
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
if (!element) return;
|
||||
|
||||
const initialData = { id: stickyId, type: "sticky" };
|
||||
|
||||
if (pathName.includes("stickies"))
|
||||
return combine(
|
||||
draggable({
|
||||
element,
|
||||
dragHandle: element,
|
||||
getInitialData: () => initialData,
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: () => {
|
||||
setIsDragging(false);
|
||||
},
|
||||
onGenerateDragPreview: ({ nativeSetDragImage }) => {
|
||||
setCustomNativeDragPreview({
|
||||
getOffset: pointerOutsideOfPreview({ x: "-200px", y: "0px" }),
|
||||
render: ({ container }) => {
|
||||
const root = createRoot(container);
|
||||
root.render(
|
||||
<div className="scale-50">
|
||||
<div className="-m-2 max-h-[150px]">
|
||||
<StickyNote
|
||||
className={"w-[290px]"}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
stickyId={stickyId}
|
||||
showToolbar={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return () => root.unmount();
|
||||
},
|
||||
nativeSetDragImage,
|
||||
});
|
||||
},
|
||||
}),
|
||||
dropTargetForElements({
|
||||
element,
|
||||
canDrop: ({ source }) => source.data?.type === "sticky",
|
||||
getData: ({ input, element }) => {
|
||||
const blockedStates: InstructionType[] = ["make-child"];
|
||||
if (!isLastChild) {
|
||||
blockedStates.push("reorder-below");
|
||||
}
|
||||
|
||||
return attachInstruction(initialData, {
|
||||
input,
|
||||
element,
|
||||
currentLevel: 1,
|
||||
indentPerLevel: 0,
|
||||
mode: isLastChild ? "last-in-group" : "standard",
|
||||
block: blockedStates,
|
||||
});
|
||||
},
|
||||
onDrag: ({ self, source, location }) => {
|
||||
const instruction = getInstructionFromPayload(self, source, location);
|
||||
setInstruction(instruction);
|
||||
},
|
||||
onDragLeave: () => {
|
||||
setInstruction(undefined);
|
||||
},
|
||||
onDrop: ({ self, source, location }) => {
|
||||
setInstruction(undefined);
|
||||
handleDrop(self, source, location);
|
||||
},
|
||||
})
|
||||
);
|
||||
}, [stickyId, isDragging]);
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: itemWidth }}>
|
||||
{!isInFirstRow && <DropIndicator isVisible={instruction === "reorder-above"} />}
|
||||
<div
|
||||
ref={elementRef}
|
||||
className={cn("flex min-h-[300px] box-border p-2", {
|
||||
"opacity-50": isDragging,
|
||||
})}
|
||||
>
|
||||
<StickyNote key={stickyId || "new"} workspaceSlug={workspaceSlug} stickyId={stickyId} />
|
||||
</div>
|
||||
{!isInLastRow && <DropIndicator isVisible={instruction === "reorder-below"} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
45
web/core/components/stickies/layout/sticky.helpers.ts
Normal file
45
web/core/components/stickies/layout/sticky.helpers.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
|
||||
import { InstructionType, IPragmaticPayloadLocation, TDropTarget } from "@plane/types";
|
||||
|
||||
export type TargetData = {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
isGroup: boolean;
|
||||
isChild: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* extracts the Payload and translates the instruction for the current dropTarget based on drag and drop payload
|
||||
* @param dropTarget dropTarget for which the instruction is required
|
||||
* @param source the dragging sticky data that is being dragged on the dropTarget
|
||||
* @param location location includes the data of all the dropTargets the source is being dragged on
|
||||
* @returns Instruction for dropTarget
|
||||
*/
|
||||
export const getInstructionFromPayload = (
|
||||
dropTarget: TDropTarget,
|
||||
source: TDropTarget,
|
||||
location: IPragmaticPayloadLocation
|
||||
): InstructionType | undefined => {
|
||||
const dropTargetData = dropTarget?.data as TargetData;
|
||||
const sourceData = source?.data as TargetData;
|
||||
const allDropTargets = location?.current?.dropTargets;
|
||||
|
||||
// if all the dropTargets are greater than 1 meaning the source is being dragged on a group and its child at the same time
|
||||
// and also if the dropTarget in question is also a group then, it should be a child of the current Droptarget
|
||||
if (allDropTargets?.length > 1 && dropTargetData?.isGroup) return "make-child";
|
||||
|
||||
if (!dropTargetData || !sourceData) return undefined;
|
||||
|
||||
let instruction = extractInstruction(dropTargetData)?.type;
|
||||
|
||||
// If the instruction is blocked then set an instruction based on if dropTarget it is a child or not
|
||||
if (instruction === "instruction-blocked") {
|
||||
instruction = dropTargetData.isChild ? "reorder-above" : "make-child";
|
||||
}
|
||||
|
||||
// if source that is being dragged is a group. A group cannon be a child of any other sticky,
|
||||
// hence if current instruction is to be a child of dropTarget then reorder-above instead
|
||||
if (instruction === "make-child" && sourceData.isGroup) instruction = "reorder-above";
|
||||
|
||||
return instruction;
|
||||
};
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { FC, useRef, useState } from "react";
|
||||
import { FC, useCallback, useRef, useState } from "react";
|
||||
import { debounce } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Search, X } from "lucide-react";
|
||||
// plane hooks
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
@@ -10,25 +12,41 @@ import { cn } from "@/helpers/common.helper";
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
|
||||
export const StickySearch: FC = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// hooks
|
||||
const { searchQuery, updateSearchQuery } = useSticky();
|
||||
const { searchQuery, updateSearchQuery, fetchWorkspaceStickies } = 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);
|
||||
if (searchQuery && searchQuery.trim() !== "") {
|
||||
updateSearchQuery("");
|
||||
fetchStickies();
|
||||
} else setIsSearchOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStickies = async () => {
|
||||
await fetchWorkspaceStickies(workspaceSlug.toString());
|
||||
};
|
||||
|
||||
const debouncedSearch = useCallback(
|
||||
debounce(async () => {
|
||||
await fetchStickies();
|
||||
}, 500),
|
||||
[fetchWorkspaceStickies]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center mr-2">
|
||||
<div className="flex items-center mr-2 my-auto">
|
||||
{!isSearchOpen && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -55,7 +73,10 @@ export const StickySearch: FC = observer(() => {
|
||||
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)}
|
||||
onChange={(e) => {
|
||||
updateSearchQuery(e.target.value);
|
||||
debouncedSearch();
|
||||
}}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
{isSearchOpen && (
|
||||
@@ -65,6 +86,7 @@ export const StickySearch: FC = observer(() => {
|
||||
onClick={() => {
|
||||
updateSearchQuery("");
|
||||
setIsSearchOpen(false);
|
||||
fetchStickies();
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
|
||||
@@ -3,8 +3,7 @@ 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 { StickiesTruncated } from "../layout/stickies-truncated";
|
||||
import { useStickyOperations } from "../sticky/use-operations";
|
||||
import { StickySearch } from "./search";
|
||||
|
||||
@@ -19,13 +18,13 @@ export const Stickies = observer((props: TProps) => {
|
||||
const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() });
|
||||
|
||||
return (
|
||||
<div className="p-6 pb-0">
|
||||
<div className="p-6 pb-0 min-h-[620px]">
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{/* Title */}
|
||||
<div className="text-custom-text-100 flex gap-2">
|
||||
<RecentStickyIcon className="size-5 rotate-90" />
|
||||
<p className="text-lg font-medium">My Stickies</p>
|
||||
<p className="text-lg font-medium">Your stickies</p>
|
||||
</div>
|
||||
{/* actions */}
|
||||
<div className="flex gap-2">
|
||||
@@ -33,7 +32,7 @@ export const Stickies = observer((props: TProps) => {
|
||||
<button
|
||||
onClick={() => {
|
||||
toggleShowNewSticky(true);
|
||||
stickyOperations.create({ color: STICKY_COLORS[0] });
|
||||
stickyOperations.create();
|
||||
}}
|
||||
className="flex gap-1 text-sm font-medium text-custom-primary-100 my-auto"
|
||||
disabled={creatingSticky}
|
||||
@@ -61,7 +60,7 @@ export const Stickies = observer((props: TProps) => {
|
||||
</div>
|
||||
{/* content */}
|
||||
<div className="mb-4 max-h-[625px] overflow-scroll">
|
||||
<StickiesLayout />
|
||||
<StickiesTruncated />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
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 { ContentOverflowWrapper } from "../core/content-overflow-HOC";
|
||||
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 [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]);
|
||||
|
||||
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}>
|
||||
<ContentOverflowWrapper
|
||||
maxHeight={650}
|
||||
containerClassName="pb-2 box-border"
|
||||
fallback={<></>}
|
||||
buttonClassName="bg-custom-background-90/20"
|
||||
>
|
||||
{/* @ts-expect-error type mismatch here */}
|
||||
<Masonry elementType="div">{childElements}</Masonry>
|
||||
</ContentOverflowWrapper>
|
||||
</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,10 +1,13 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { DebouncedFunc } from "lodash";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// plane editor
|
||||
import { EditorRefApi } from "@plane/editor";
|
||||
// plane types
|
||||
import { TSticky } from "@plane/types";
|
||||
import { TextArea } from "@plane/ui";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
// components
|
||||
import { StickyEditor } from "../../editor";
|
||||
|
||||
type TProps = {
|
||||
@@ -12,73 +15,45 @@ type TProps = {
|
||||
workspaceSlug: string;
|
||||
handleUpdate: DebouncedFunc<(payload: Partial<TSticky>) => Promise<void>>;
|
||||
stickyId: string | undefined;
|
||||
showToolbar?: boolean;
|
||||
handleChange: (data: Partial<TSticky>) => Promise<void>;
|
||||
handleDelete: () => void;
|
||||
};
|
||||
|
||||
export const StickyInput = (props: TProps) => {
|
||||
const { stickyData, workspaceSlug, handleUpdate, stickyId, handleDelete, handleChange } = props;
|
||||
//refs
|
||||
const { stickyData, workspaceSlug, handleUpdate, stickyId, handleDelete, handleChange, showToolbar } = props;
|
||||
// refs
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
// store hooks
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
// derived values
|
||||
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id?.toString() ?? "";
|
||||
// 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;
|
||||
|
||||
// handle description update
|
||||
const handleFormSubmit = useCallback(
|
||||
async (formdata: Partial<TSticky>) => {
|
||||
await handleUpdate({
|
||||
description_html: formdata.description_html ?? "<p></p>",
|
||||
});
|
||||
},
|
||||
[handleUpdate]
|
||||
);
|
||||
// reset form values
|
||||
useEffect(() => {
|
||||
if (!stickyId) return;
|
||||
reset({
|
||||
id: stickyId,
|
||||
description_html: stickyData?.description_html === "" ? "<p></p>" : stickyData?.description_html,
|
||||
name: stickyData?.name,
|
||||
description_html: stickyData?.description_html?.trim() === "" ? "<p></p>" : stickyData?.description_html,
|
||||
});
|
||||
}, [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]
|
||||
);
|
||||
}, [stickyData, stickyId, reset]);
|
||||
|
||||
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}
|
||||
@@ -89,14 +64,14 @@ export const StickyInput = (props: TProps) => {
|
||||
value={null}
|
||||
workspaceSlug={workspaceSlug}
|
||||
workspaceId={workspaceId}
|
||||
onChange={(_description: object, description_html: string) => {
|
||||
onChange={(_description, description_html) => {
|
||||
onChange(description_html);
|
||||
handleSubmit(handleFormSubmit)();
|
||||
}}
|
||||
placeholder={"Click to type here"}
|
||||
containerClassName={"px-0 text-base min-h-[200px] w-full text-[#455068]"}
|
||||
placeholder="Click to type here"
|
||||
containerClassName="px-0 text-base min-h-[250px] w-full"
|
||||
uploadFile={async () => ""}
|
||||
showToolbar={false}
|
||||
showToolbar={showToolbar}
|
||||
parentClassName={"border-none p-0"}
|
||||
handleDelete={handleDelete}
|
||||
handleColorChange={handleChange}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { debounce } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Minimize2 } from "lucide-react";
|
||||
// plane types
|
||||
import { TSticky } from "@plane/types";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
import { STICKY_COLORS } from "../../editor/sticky-editor/color-pallete";
|
||||
// components
|
||||
import { STICKY_COLORS_LIST } from "../../editor/sticky-editor/color-palette";
|
||||
import { StickyDeleteModal } from "../delete-modal";
|
||||
import { StickyInput } from "./inputs";
|
||||
import { StickyItemDragHandle } from "./sticky-item-drag-handle";
|
||||
import { useStickyOperations } from "./use-operations";
|
||||
|
||||
type TProps = {
|
||||
@@ -15,25 +21,34 @@ type TProps = {
|
||||
workspaceSlug: string;
|
||||
className?: string;
|
||||
stickyId: string | undefined;
|
||||
showToolbar?: boolean;
|
||||
};
|
||||
export const StickyNote = observer((props: TProps) => {
|
||||
const { onClose, workspaceSlug, className = "", stickyId } = props;
|
||||
//state
|
||||
const { onClose, workspaceSlug, className = "", stickyId, showToolbar } = props;
|
||||
// navigation
|
||||
const pathName = usePathname();
|
||||
// states
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
// hooks
|
||||
const { stickyOperations } = useStickyOperations({ workspaceSlug });
|
||||
// store hooks
|
||||
const { stickies } = useSticky();
|
||||
// sticky operations
|
||||
const { stickyOperations } = useStickyOperations({ workspaceSlug });
|
||||
// derived values
|
||||
const stickyData: TSticky | undefined = stickyId ? stickies[stickyId] : undefined;
|
||||
const stickyData = stickyId ? stickies[stickyId] : undefined;
|
||||
const isStickiesPage = pathName?.includes("stickies");
|
||||
const backgroundColor =
|
||||
STICKY_COLORS_LIST.find((c) => c.key === stickyData?.background_color)?.backgroundColor ||
|
||||
STICKY_COLORS_LIST[0].backgroundColor;
|
||||
|
||||
const handleChange = useCallback(
|
||||
async (payload: Partial<TSticky>) => {
|
||||
stickyId
|
||||
? await stickyOperations.update(stickyId, payload)
|
||||
: await stickyOperations.create({
|
||||
color: payload.color || STICKY_COLORS[0],
|
||||
...payload,
|
||||
});
|
||||
if (stickyId) {
|
||||
await stickyOperations.update(stickyId, payload);
|
||||
} else {
|
||||
await stickyOperations.create({
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
},
|
||||
[stickyId, stickyOperations]
|
||||
);
|
||||
@@ -60,10 +75,13 @@ export const StickyNote = observer((props: TProps) => {
|
||||
/>
|
||||
<div
|
||||
className={cn("w-full flex flex-col h-fit rounded p-4 group/sticky", className)}
|
||||
style={{ backgroundColor: stickyData?.color || STICKY_COLORS[0] }}
|
||||
style={{
|
||||
backgroundColor,
|
||||
}}
|
||||
>
|
||||
{isStickiesPage && <StickyItemDragHandle isDragging={false} />}{" "}
|
||||
{onClose && (
|
||||
<button className="flex w-full" onClick={onClose}>
|
||||
<button type="button" className="flex w-full" onClick={onClose}>
|
||||
<Minimize2 className="size-4 m-auto mr-0" />
|
||||
</button>
|
||||
)}
|
||||
@@ -73,11 +91,9 @@ export const StickyNote = observer((props: TProps) => {
|
||||
workspaceSlug={workspaceSlug}
|
||||
handleUpdate={debouncedFormSave}
|
||||
stickyId={stickyId}
|
||||
handleDelete={() => {
|
||||
if (!stickyId) return;
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
handleDelete={() => setIsDeleteModalOpen(true)}
|
||||
handleChange={handleChange}
|
||||
showToolbar={showToolbar}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
import React, { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { DragHandle } from "@plane/ui";
|
||||
// helper
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
type Props = {
|
||||
isDragging: boolean;
|
||||
};
|
||||
|
||||
export const StickyItemDragHandle: FC<Props> = observer((props) => {
|
||||
const { isDragging } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"hidden group-hover/sticky:flex absolute top-3 left-1/2 -translate-x-1/2 items-center justify-center rounded text-custom-sidebar-text-400 cursor-grab mr-2 rotate-90",
|
||||
{
|
||||
"cursor-grabbing": isDragging,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<DragHandle className="bg-transparent" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,28 +1,48 @@
|
||||
import { useMemo } from "react";
|
||||
import { TSticky } from "@plane/types";
|
||||
// plane types
|
||||
import { InstructionType, TSticky } from "@plane/types";
|
||||
// plane ui
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// plane utils
|
||||
import { isCommentEmpty } from "@plane/utils";
|
||||
// components
|
||||
import { STICKY_COLORS_LIST } from "@/components/editor/sticky-editor/color-palette";
|
||||
// hooks
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
|
||||
export type TOperations = {
|
||||
create: (data: Partial<TSticky>) => Promise<void>;
|
||||
create: (data?: Partial<TSticky>) => Promise<void>;
|
||||
update: (stickyId: string, data: Partial<TSticky>) => Promise<void>;
|
||||
remove: (stickyId: string) => Promise<void>;
|
||||
updatePosition: (
|
||||
workspaceSlug: string,
|
||||
sourceId: string,
|
||||
droppedId: string,
|
||||
instruction: InstructionType
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
type TProps = {
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const getRandomStickyColor = (): string => {
|
||||
const randomIndex = Math.floor(Math.random() * STICKY_COLORS_LIST.length);
|
||||
return STICKY_COLORS_LIST[randomIndex].key;
|
||||
};
|
||||
|
||||
export const useStickyOperations = (props: TProps) => {
|
||||
const { workspaceSlug } = props;
|
||||
const { createSticky, updateSticky, deleteSticky } = useSticky();
|
||||
// store hooks
|
||||
const { stickies, getWorkspaceStickyIds, createSticky, updateSticky, deleteSticky, updateStickyPosition } =
|
||||
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",
|
||||
message: "The sticky name cannot be longer than 100 characters.",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
@@ -31,23 +51,40 @@ export const useStickyOperations = (props: TProps) => {
|
||||
|
||||
const stickyOperations: TOperations = useMemo(
|
||||
() => ({
|
||||
create: async (data: Partial<TSticky>) => {
|
||||
create: async (data?: Partial<TSticky>) => {
|
||||
try {
|
||||
const payload: Partial<TSticky> = {
|
||||
background_color: getRandomStickyColor(),
|
||||
...data,
|
||||
};
|
||||
const workspaceStickIds = getWorkspaceStickyIds(workspaceSlug);
|
||||
// check if latest sticky is empty
|
||||
if (workspaceStickIds && workspaceStickIds.length >= 0) {
|
||||
const latestSticky = stickies[workspaceStickIds[0]];
|
||||
if (latestSticky && (!latestSticky.description_html || isCommentEmpty(latestSticky.description_html))) {
|
||||
setToast({
|
||||
message: "There already exists a sticky with no description",
|
||||
type: TOAST_TYPE.WARNING,
|
||||
title: "Sticky already created",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!workspaceSlug) throw new Error("Missing required fields");
|
||||
if (!isValid(data)) return;
|
||||
await createSticky(workspaceSlug, data);
|
||||
if (!isValid(payload)) return;
|
||||
await createSticky(workspaceSlug, payload);
|
||||
setToast({
|
||||
message: "The sticky has been successfully created",
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Sticky created",
|
||||
message: "The sticky has been successfully created.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("Error in creating sticky:", error);
|
||||
setToast({
|
||||
message: error?.data?.error ?? "The sticky could not be created",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Sticky not created",
|
||||
message: error?.data?.error ?? "The sticky could not be created.",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
update: async (stickyId: string, data: Partial<TSticky>) => {
|
||||
@@ -56,12 +93,12 @@ export const useStickyOperations = (props: TProps) => {
|
||||
if (!isValid(data)) return;
|
||||
await updateSticky(workspaceSlug, stickyId, data);
|
||||
} catch (error) {
|
||||
console.error("Error in updating sticky:", error);
|
||||
setToast({
|
||||
message: "The sticky could not be updated",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Sticky not updated",
|
||||
message: "The sticky could not be updated.",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
remove: async (stickyId: string) => {
|
||||
@@ -69,21 +106,39 @@ export const useStickyOperations = (props: TProps) => {
|
||||
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",
|
||||
message: "The sticky has been removed successfully.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error in removing sticky:", error);
|
||||
setToast({
|
||||
message: "The sticky could not be removed",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Sticky not removed",
|
||||
message: "The sticky could not be removed.",
|
||||
});
|
||||
}
|
||||
},
|
||||
updatePosition: async (
|
||||
workspaceSlug: string,
|
||||
sourceId: string,
|
||||
droppedId: string,
|
||||
instruction: InstructionType
|
||||
) => {
|
||||
try {
|
||||
if (!workspaceSlug) throw new Error("Missing required fields");
|
||||
await updateStickyPosition(workspaceSlug, sourceId, droppedId, instruction);
|
||||
} catch (error) {
|
||||
console.error("Error in updating sticky position:", error);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Sticky not updated",
|
||||
message: "The sticky could not be updated.",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
}),
|
||||
[workspaceSlug]
|
||||
[createSticky, deleteSticky, getWorkspaceStickyIds, stickies, updateSticky, updateStickyPosition, workspaceSlug]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,46 +1,52 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Plus } from "lucide-react";
|
||||
// hooks
|
||||
import { useSticky } from "@/hooks/use-stickies";
|
||||
import { STICKY_COLORS } from "../editor/sticky-editor/color-pallete";
|
||||
import { StickiesTruncated } from "./layout";
|
||||
import { StickySearch } from "./modal/search";
|
||||
import { StickiesLayout } from "./stickies-layout";
|
||||
import { useStickyOperations } from "./sticky/use-operations";
|
||||
|
||||
export const StickiesWidget = () => {
|
||||
export const StickiesWidget: React.FC = observer(() => {
|
||||
// params
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { creatingSticky, toggleShowNewSticky } = useSticky();
|
||||
const { stickyOperations } = useStickyOperations({ workspaceSlug: workspaceSlug?.toString() });
|
||||
// sticky operations
|
||||
const { stickyOperations } = useStickyOperations({
|
||||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-base font-semibold text-custom-text-350">My Stickies </div>
|
||||
<div className="text-base font-semibold text-custom-text-350">Your stickies</div>
|
||||
{/* actions */}
|
||||
<div className="flex gap-2">
|
||||
<StickySearch />
|
||||
<button
|
||||
onClick={() => {
|
||||
toggleShowNewSticky(true);
|
||||
stickyOperations.create({ color: STICKY_COLORS[0] });
|
||||
stickyOperations.create();
|
||||
}}
|
||||
className="flex gap-1 text-sm font-medium text-custom-primary-100 my-auto"
|
||||
disabled={creatingSticky}
|
||||
>
|
||||
<Plus className="size-4 my-auto" /> <span>Add sticky</span>
|
||||
<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>
|
||||
<div
|
||||
className="size-4 border-2 border-t-transparent border-custom-primary-100 rounded-full animate-spin"
|
||||
role="status"
|
||||
aria-label="loading"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mx-2">
|
||||
<StickiesLayout />
|
||||
<StickiesTruncated />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EUserPermissions } from "ee/constants/user-permissions";
|
||||
import { Plus, Shapes } from "lucide-react";
|
||||
|
||||
export interface EmptyStateDetails {
|
||||
key: EmptyStateType;
|
||||
@@ -122,9 +123,14 @@ export enum EmptyStateType {
|
||||
TEAM_EMPTY_FILTER = "team-empty-filter",
|
||||
TEAM_VIEW = "team-view",
|
||||
TEAM_PAGE = "team-page",
|
||||
// stickies
|
||||
STICKIES = "stickies",
|
||||
STICKIES_SEARCH = "stickies-search",
|
||||
// home widgets
|
||||
HOME_WIDGETS = "home-widgets",
|
||||
}
|
||||
|
||||
const emptyStateDetails = {
|
||||
const emptyStateDetails: Record<EmptyStateType, EmptyStateDetails> = {
|
||||
// workspace
|
||||
[EmptyStateType.WORKSPACE_DASHBOARD]: {
|
||||
key: EmptyStateType.WORKSPACE_DASHBOARD,
|
||||
@@ -912,6 +918,43 @@ const emptyStateDetails = {
|
||||
"Write a note, a doc, or a full knowledge base. Get Galileo, Plane’s AI assistant, to help you get started. Pages are thoughts potting space in Plane. Take down meeting notes, format them easily, embed issues, lay them out using a library of components, and keep them all in your project’s context. To make short work of any doc, invoke Galileo, Plane’s AI, with a shortcut or the click of a button.",
|
||||
path: "/empty-state/onboarding/pages",
|
||||
},
|
||||
[EmptyStateType.STICKIES]: {
|
||||
key: EmptyStateType.STICKIES,
|
||||
title: "Stickies are quick notes and to-dos you take down on the fly.",
|
||||
description:
|
||||
"Capture your thoughts and ideas effortlessly by creating stickies that you can access anytime and from anywhere.",
|
||||
path: "/empty-state/stickies/stickies",
|
||||
primaryButton: {
|
||||
icon: <Plus className="size-4" />,
|
||||
text: "Add sticky",
|
||||
},
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
|
||||
accessType: "workspace",
|
||||
},
|
||||
[EmptyStateType.STICKIES_SEARCH]: {
|
||||
key: EmptyStateType.STICKIES_SEARCH,
|
||||
title: "That doesn't match any of your stickies.",
|
||||
description: "Try a different term or let us know\nif you are sure your search is right. ",
|
||||
path: "/empty-state/stickies/stickies-search",
|
||||
primaryButton: {
|
||||
icon: <Plus className="size-4" />,
|
||||
text: "Add sticky",
|
||||
},
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
|
||||
accessType: "workspace",
|
||||
},
|
||||
[EmptyStateType.HOME_WIDGETS]: {
|
||||
key: EmptyStateType.HOME_WIDGETS,
|
||||
title: "It's Quiet Without Widgets, Turn Them On",
|
||||
description: "It looks like all your widgets are turned off. Enable them\nnow to enhance your experience!",
|
||||
path: "/empty-state/dashboard/widgets",
|
||||
primaryButton: {
|
||||
icon: <Shapes className="size-4" />,
|
||||
text: "Manage widgets",
|
||||
},
|
||||
access: [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
|
||||
accessType: "workspace",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const EMPTY_STATE_DETAILS: Record<EmptyStateType, EmptyStateDetails> = emptyStateDetails;
|
||||
@@ -1,4 +1,5 @@
|
||||
// helpers
|
||||
import { STICKIES_PER_PAGE } from "@plane/constants";
|
||||
import { TSticky } from "@plane/types";
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
// services
|
||||
@@ -19,13 +20,14 @@ export class StickyService extends APIService {
|
||||
|
||||
async getStickies(
|
||||
workspaceSlug: string,
|
||||
cursor?: string,
|
||||
per_page?: number
|
||||
cursor: string,
|
||||
query?: string
|
||||
): Promise<{ results: TSticky[]; total_pages: number }> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/stickies/`, {
|
||||
params: {
|
||||
cursor: cursor || `5:0:0`,
|
||||
per_page: per_page || 5,
|
||||
cursor,
|
||||
per_page: STICKIES_PER_PAGE,
|
||||
query,
|
||||
},
|
||||
})
|
||||
.then((res) => res?.data)
|
||||
|
||||
@@ -1,45 +1,50 @@
|
||||
import { orderBy, set } from "lodash";
|
||||
import { observable, action, makeObservable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
import { TSticky } from "@plane/types";
|
||||
import { STICKIES_PER_PAGE } from "@plane/constants";
|
||||
import { InstructionType, TLoader, TPaginationInfo, TSticky } from "@plane/types";
|
||||
import { StickyService } from "@/services/sticky.service";
|
||||
|
||||
export interface IStickyStore {
|
||||
creatingSticky: boolean;
|
||||
fetchingWorkspaceStickies: boolean;
|
||||
loader: TLoader;
|
||||
workspaceStickies: Record<string, string[]>; // workspaceId -> stickyIds
|
||||
stickies: Record<string, TSticky>; // stickyId -> sticky
|
||||
searchQuery: string;
|
||||
activeStickyId: string | undefined;
|
||||
recentStickyId: string | undefined;
|
||||
showAddNewSticky: boolean;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
|
||||
paginationInfo: TPaginationInfo | undefined;
|
||||
// computed
|
||||
getWorkspaceStickies: (workspaceSlug: string) => string[];
|
||||
|
||||
getWorkspaceStickyIds: (workspaceSlug: string) => string[];
|
||||
// actions
|
||||
toggleShowNewSticky: (value: boolean) => void;
|
||||
updateSearchQuery: (query: string) => void;
|
||||
fetchWorkspaceStickies: (workspaceSlug: string, cursor?: string, per_page?: number) => void;
|
||||
createSticky: (workspaceSlug: string, sticky: Partial<TSticky>) => void;
|
||||
updateSticky: (workspaceSlug: string, id: string, updates: Partial<TSticky>) => void;
|
||||
deleteSticky: (workspaceSlug: string, id: string) => void;
|
||||
fetchWorkspaceStickies: (workspaceSlug: string) => void;
|
||||
createSticky: (workspaceSlug: string, sticky: Partial<TSticky>) => Promise<void>;
|
||||
updateSticky: (workspaceSlug: string, id: string, updates: Partial<TSticky>) => Promise<void>;
|
||||
deleteSticky: (workspaceSlug: string, id: string) => Promise<void>;
|
||||
updateActiveStickyId: (id: string | undefined) => void;
|
||||
fetchRecentSticky: (workspaceSlug: string) => void;
|
||||
incrementPage: () => void;
|
||||
fetchRecentSticky: (workspaceSlug: string) => Promise<void>;
|
||||
fetchNextWorkspaceStickies: (workspaceSlug: string) => Promise<void>;
|
||||
updateStickyPosition: (
|
||||
workspaceSlug: string,
|
||||
stickyId: string,
|
||||
destinationId: string,
|
||||
edge: InstructionType
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export class StickyStore implements IStickyStore {
|
||||
loader: TLoader = "init-loader";
|
||||
creatingSticky = false;
|
||||
fetchingWorkspaceStickies = true;
|
||||
workspaceStickies: Record<string, string[]> = {};
|
||||
stickies: Record<string, TSticky> = {};
|
||||
recentStickyId: string | undefined = undefined;
|
||||
searchQuery = "";
|
||||
activeStickyId: string | undefined = undefined;
|
||||
showAddNewSticky = false;
|
||||
currentPage = 0;
|
||||
totalPages = 0;
|
||||
paginationInfo: TPaginationInfo | undefined = undefined;
|
||||
|
||||
// services
|
||||
stickyService;
|
||||
@@ -48,33 +53,35 @@ export class StickyStore implements IStickyStore {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
creatingSticky: observable,
|
||||
fetchingWorkspaceStickies: observable,
|
||||
loader: observable,
|
||||
activeStickyId: observable,
|
||||
showAddNewSticky: observable,
|
||||
recentStickyId: observable,
|
||||
workspaceStickies: observable,
|
||||
stickies: observable,
|
||||
searchQuery: observable,
|
||||
currentPage: observable,
|
||||
totalPages: observable,
|
||||
// actions
|
||||
updateSearchQuery: action,
|
||||
updateSticky: action,
|
||||
deleteSticky: action,
|
||||
incrementPage: action,
|
||||
fetchNextWorkspaceStickies: action,
|
||||
fetchWorkspaceStickies: action,
|
||||
createSticky: action,
|
||||
updateActiveStickyId: action,
|
||||
toggleShowNewSticky: action,
|
||||
fetchRecentSticky: action,
|
||||
updateStickyPosition: action,
|
||||
});
|
||||
this.stickyService = new StickyService();
|
||||
}
|
||||
|
||||
getWorkspaceStickies = computedFn((workspaceSlug: string) => {
|
||||
let filteredStickies = (this.workspaceStickies[workspaceSlug] || []).map((stickyId) => this.stickies[stickyId]);
|
||||
if (this.searchQuery) {
|
||||
filteredStickies = filteredStickies.filter(
|
||||
(sticky) => sticky.name && sticky.name.toLowerCase().includes(this.searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
return filteredStickies.map((sticky) => sticky.id);
|
||||
});
|
||||
getWorkspaceStickyIds = computedFn((workspaceSlug: string) =>
|
||||
orderBy(
|
||||
(this.workspaceStickies[workspaceSlug] || []).map((stickyId) => this.stickies[stickyId]),
|
||||
["sort_order"],
|
||||
["desc"]
|
||||
).map((sticky) => sticky.id)
|
||||
);
|
||||
|
||||
toggleShowNewSticky = (value: boolean) => {
|
||||
this.showAddNewSticky = value;
|
||||
@@ -88,34 +95,77 @@ export class StickyStore implements IStickyStore {
|
||||
this.activeStickyId = id;
|
||||
};
|
||||
|
||||
incrementPage = () => {
|
||||
this.currentPage += 1;
|
||||
};
|
||||
|
||||
fetchRecentSticky = async (workspaceSlug: string) => {
|
||||
const response = await this.stickyService.getStickies(workspaceSlug, "1:0:0", 1);
|
||||
const response = await this.stickyService.getStickies(workspaceSlug, "1:0:0");
|
||||
runInAction(() => {
|
||||
this.recentStickyId = response.results[0]?.id;
|
||||
this.stickies[response.results[0]?.id] = response.results[0];
|
||||
});
|
||||
};
|
||||
fetchWorkspaceStickies = async (workspaceSlug: string, cursor?: string, per_page?: number) => {
|
||||
fetchNextWorkspaceStickies = async (workspaceSlug: string) => {
|
||||
try {
|
||||
const response = await this.stickyService.getStickies(workspaceSlug, cursor, per_page);
|
||||
if (!this.paginationInfo?.next_cursor || !this.paginationInfo.next_page_results || this.loader === "pagination") {
|
||||
return;
|
||||
}
|
||||
this.loader = "pagination";
|
||||
const response = await this.stickyService.getStickies(
|
||||
workspaceSlug,
|
||||
this.paginationInfo.next_cursor,
|
||||
this.searchQuery
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
response.results.forEach((sticky) => {
|
||||
const { results, ...paginationInfo } = response;
|
||||
|
||||
// Add new stickies to store
|
||||
results.forEach((sticky) => {
|
||||
if (!this.workspaceStickies[workspaceSlug]?.includes(sticky.id)) {
|
||||
this.workspaceStickies[workspaceSlug] = [...(this.workspaceStickies[workspaceSlug] || []), sticky.id];
|
||||
}
|
||||
this.stickies[sticky.id] = sticky;
|
||||
});
|
||||
this.totalPages = response.total_pages;
|
||||
this.fetchingWorkspaceStickies = false;
|
||||
|
||||
// Update pagination info directly from backend
|
||||
set(this, "paginationInfo", paginationInfo);
|
||||
set(this, "loader", "loaded");
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.fetchingWorkspaceStickies = false;
|
||||
runInAction(() => {
|
||||
this.loader = "loaded";
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
fetchWorkspaceStickies = async (workspaceSlug: string) => {
|
||||
try {
|
||||
if (this.workspaceStickies[workspaceSlug]) {
|
||||
this.loader = "mutation";
|
||||
} else {
|
||||
this.loader = "init-loader";
|
||||
}
|
||||
|
||||
const response = await this.stickyService.getStickies(
|
||||
workspaceSlug,
|
||||
`${STICKIES_PER_PAGE}:0:0`,
|
||||
this.searchQuery
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
const { results, ...paginationInfo } = response;
|
||||
|
||||
results.forEach((sticky) => {
|
||||
this.stickies[sticky.id] = sticky;
|
||||
});
|
||||
this.workspaceStickies[workspaceSlug] = results.map((sticky) => sticky.id);
|
||||
set(this, "paginationInfo", paginationInfo);
|
||||
this.loader = "loaded";
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
runInAction(() => {
|
||||
this.loader = "loaded";
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -138,16 +188,18 @@ export class StickyStore implements IStickyStore {
|
||||
const sticky = this.stickies[id];
|
||||
if (!sticky) return;
|
||||
try {
|
||||
this.stickies[id] = {
|
||||
...sticky,
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
runInAction(() => {
|
||||
Object.keys(updates).forEach((key) => {
|
||||
const currentStickyKey = key as keyof TSticky;
|
||||
set(this.stickies[id], key, updates[currentStickyKey] || undefined);
|
||||
});
|
||||
});
|
||||
this.recentStickyId = id;
|
||||
await this.stickyService.updateSticky(workspaceSlug, id, updates);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
} catch (error) {
|
||||
console.error("Error in updating sticky:", error);
|
||||
this.stickies[id] = sticky;
|
||||
throw new Error();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -167,4 +219,53 @@ export class StickyStore implements IStickyStore {
|
||||
this.stickies[id] = sticky;
|
||||
}
|
||||
};
|
||||
|
||||
updateStickyPosition = async (
|
||||
workspaceSlug: string,
|
||||
stickyId: string,
|
||||
destinationId: string,
|
||||
edge: InstructionType
|
||||
) => {
|
||||
const previousSortOrder = this.stickies[stickyId].sort_order;
|
||||
try {
|
||||
let resultSequence = 10000;
|
||||
const workspaceStickies = this.workspaceStickies[workspaceSlug] || [];
|
||||
const stickies = workspaceStickies.map((id) => this.stickies[id]);
|
||||
const sortedStickies = orderBy(stickies, "sort_order", "desc").map((sticky) => sticky.id);
|
||||
const destinationSequence = this.stickies[destinationId]?.sort_order || undefined;
|
||||
|
||||
if (destinationSequence) {
|
||||
const destinationIndex = sortedStickies.findIndex((id) => id === destinationId);
|
||||
|
||||
if (edge === "reorder-above") {
|
||||
const prevSequence = this.stickies[sortedStickies[destinationIndex - 1]]?.sort_order || undefined;
|
||||
if (prevSequence) {
|
||||
resultSequence = (destinationSequence + prevSequence) / 2;
|
||||
} else {
|
||||
resultSequence = destinationSequence + resultSequence;
|
||||
}
|
||||
} else {
|
||||
// reorder-below
|
||||
resultSequence = destinationSequence - resultSequence;
|
||||
}
|
||||
}
|
||||
|
||||
runInAction(() => {
|
||||
this.stickies[stickyId] = {
|
||||
...this.stickies[stickyId],
|
||||
sort_order: resultSequence,
|
||||
};
|
||||
});
|
||||
|
||||
await this.stickyService.updateSticky(workspaceSlug, stickyId, {
|
||||
sort_order: resultSequence,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to move sticky");
|
||||
runInAction(() => {
|
||||
this.stickies[stickyId].sort_order = previousSortOrder;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface IHomeStore {
|
||||
widgetsMap: Record<string, TWidgetEntityData>;
|
||||
widgets: THomeWidgetKeys[];
|
||||
// computed
|
||||
isAnyWidgetEnabled: boolean;
|
||||
orderedWidgets: THomeWidgetKeys[];
|
||||
//stores
|
||||
quickLinks: IWorkspaceLinkStore;
|
||||
@@ -38,6 +39,7 @@ export class HomeStore implements IHomeStore {
|
||||
widgetsMap: observable,
|
||||
widgets: observable,
|
||||
// computed
|
||||
isAnyWidgetEnabled: computed,
|
||||
orderedWidgets: computed,
|
||||
// actions
|
||||
toggleWidgetSettings: action,
|
||||
@@ -52,6 +54,10 @@ export class HomeStore implements IHomeStore {
|
||||
this.quickLinks = new WorkspaceLinkStore();
|
||||
}
|
||||
|
||||
get isAnyWidgetEnabled() {
|
||||
return Object.values(this.widgetsMap).some((widget) => widget.is_enabled);
|
||||
}
|
||||
|
||||
get orderedWidgets() {
|
||||
return orderBy(Object.values(this.widgetsMap), "sort_order", "desc").map((widget) => widget.key);
|
||||
}
|
||||
|
||||
BIN
web/public/empty-state/dashboard/widgets-dark.webp
Normal file
BIN
web/public/empty-state/dashboard/widgets-dark.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
web/public/empty-state/dashboard/widgets-light.webp
Normal file
BIN
web/public/empty-state/dashboard/widgets-light.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
web/public/empty-state/stickies/stickies-dark.webp
Normal file
BIN
web/public/empty-state/stickies/stickies-dark.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
BIN
web/public/empty-state/stickies/stickies-light.webp
Normal file
BIN
web/public/empty-state/stickies/stickies-light.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
BIN
web/public/empty-state/stickies/stickies-search-dark.webp
Normal file
BIN
web/public/empty-state/stickies/stickies-search-dark.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
BIN
web/public/empty-state/stickies/stickies-search-light.webp
Normal file
BIN
web/public/empty-state/stickies/stickies-search-light.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
@@ -379,6 +379,53 @@
|
||||
--color-sidebar-border-300: var(--color-border-300); /* strong sidebar border- 1 */
|
||||
--color-sidebar-border-400: var(--color-border-400); /* strong sidebar border- 2 */
|
||||
}
|
||||
|
||||
/* stickies and editor colors */
|
||||
:root {
|
||||
/* text colors */
|
||||
--editor-colors-gray-text: #5c5e63;
|
||||
--editor-colors-peach-text: #ff5b59;
|
||||
--editor-colors-pink-text: #f65385;
|
||||
--editor-colors-orange-text: #fd9038;
|
||||
--editor-colors-green-text: #0fc27b;
|
||||
--editor-colors-light-blue-text: #17bee9;
|
||||
--editor-colors-dark-blue-text: #266df0;
|
||||
--editor-colors-purple-text: #9162f9;
|
||||
/* end text colors */
|
||||
|
||||
/* background colors */
|
||||
--editor-colors-gray-background: #d6d6d8;
|
||||
--editor-colors-peach-background: #ffd5d7;
|
||||
--editor-colors-pink-background: #fdd4e3;
|
||||
--editor-colors-orange-background: #ffe3cd;
|
||||
--editor-colors-green-background: #c3f0de;
|
||||
--editor-colors-light-blue-background: #c5eff9;
|
||||
--editor-colors-dark-blue-background: #c9dafb;
|
||||
--editor-colors-purple-background: #e3d8fd;
|
||||
/* end background colors */
|
||||
}
|
||||
/* background colors */
|
||||
[data-theme*="light"] {
|
||||
--editor-colors-gray-background: #d6d6d8;
|
||||
--editor-colors-peach-background: #ffd5d7;
|
||||
--editor-colors-pink-background: #fdd4e3;
|
||||
--editor-colors-orange-background: #ffe3cd;
|
||||
--editor-colors-green-background: #c3f0de;
|
||||
--editor-colors-light-blue-background: #c5eff9;
|
||||
--editor-colors-dark-blue-background: #c9dafb;
|
||||
--editor-colors-purple-background: #e3d8fd;
|
||||
}
|
||||
[data-theme*="dark"] {
|
||||
--editor-colors-gray-background: #404144;
|
||||
--editor-colors-peach-background: #593032;
|
||||
--editor-colors-pink-background: #562e3d;
|
||||
--editor-colors-orange-background: #583e2a;
|
||||
--editor-colors-green-background: #1d4a3b;
|
||||
--editor-colors-light-blue-background: #1f495c;
|
||||
--editor-colors-dark-blue-background: #223558;
|
||||
--editor-colors-purple-background: #3d325a;
|
||||
}
|
||||
/* end background colors */
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
Reference in New Issue
Block a user