snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
This commit is contained in:
223
apps/web/core/components/pages/dropdowns/actions.tsx
Normal file
223
apps/web/core/components/pages/dropdowns/actions.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import {
|
||||
ArchiveOutline,
|
||||
CopyOutline,
|
||||
DeleteOutline,
|
||||
ExportOutline,
|
||||
GlobeOutline,
|
||||
LinkOutline,
|
||||
LockOutline,
|
||||
LockedOutline,
|
||||
NewTabOutline,
|
||||
RestoreOutline,
|
||||
UnlockedOutline,
|
||||
} from "@makeplane/propel/icons";
|
||||
// constants
|
||||
import { EPageAccess } from "@plane/constants";
|
||||
// plane editor
|
||||
// plane ui
|
||||
import type { TContextMenuItem } from "@plane/ui";
|
||||
import { ContextMenu, CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { cn } from "@plane/utils";
|
||||
import { DeletePageModal } from "@/components/pages/modals/delete-page-modal";
|
||||
// hooks
|
||||
import { usePageOperations } from "@/hooks/use-page-operations";
|
||||
// plane web hooks
|
||||
import type { EPageStoreType } from "@/hooks/store";
|
||||
import { usePageFlag } from "@/hooks/use-page-flag";
|
||||
// store types
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
export type TPageActions =
|
||||
| "full-screen"
|
||||
| "sticky-toolbar"
|
||||
| "copy-markdown"
|
||||
| "toggle-lock"
|
||||
| "toggle-access"
|
||||
| "open-in-new-tab"
|
||||
| "copy-link"
|
||||
| "make-a-copy"
|
||||
| "archive-restore"
|
||||
| "delete"
|
||||
| "version-history"
|
||||
| "export"
|
||||
| "move";
|
||||
|
||||
type Props = {
|
||||
extraOptions?: (TContextMenuItem & { key: TPageActions })[];
|
||||
optionsOrder: TPageActions[];
|
||||
page: TPageInstance;
|
||||
parentRef?: React.RefObject<HTMLElement | null>;
|
||||
storeType: EPageStoreType;
|
||||
};
|
||||
|
||||
export const PageActions = observer(function PageActions(props: Props) {
|
||||
const { extraOptions, optionsOrder, page, parentRef, storeType } = props;
|
||||
// states
|
||||
const [deletePageModal, setDeletePageModal] = useState(false);
|
||||
const [movePageModal, setMovePageModal] = useState(false);
|
||||
// params
|
||||
const { workspaceSlug } = useParams();
|
||||
// page flag
|
||||
const { isMovePageEnabled } = usePageFlag({
|
||||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
||||
});
|
||||
// page operations
|
||||
const { pageOperations } = usePageOperations({
|
||||
page,
|
||||
});
|
||||
// derived values
|
||||
const {
|
||||
access,
|
||||
archived_at,
|
||||
is_locked,
|
||||
canCurrentUserArchivePage,
|
||||
canCurrentUserChangeAccess,
|
||||
canCurrentUserDeletePage,
|
||||
canCurrentUserDuplicatePage,
|
||||
canCurrentUserLockPage,
|
||||
canCurrentUserMovePage,
|
||||
} = page;
|
||||
// menu items
|
||||
const MENU_ITEMS = useMemo(
|
||||
function MENU_ITEMS() {
|
||||
const menuItems: (TContextMenuItem & { key: TPageActions })[] = [
|
||||
{
|
||||
key: "toggle-lock",
|
||||
action: () => {
|
||||
pageOperations.toggleLock();
|
||||
},
|
||||
title: is_locked ? "Unlock" : "Lock",
|
||||
icon: is_locked ? UnlockedOutline : LockedOutline,
|
||||
shouldRender: canCurrentUserLockPage,
|
||||
},
|
||||
{
|
||||
key: "toggle-access",
|
||||
action: () => {
|
||||
pageOperations.toggleAccess();
|
||||
},
|
||||
title: access === EPageAccess.PUBLIC ? "Make private" : "Make public",
|
||||
icon: access === EPageAccess.PUBLIC ? LockOutline : GlobeOutline,
|
||||
shouldRender: canCurrentUserChangeAccess && !archived_at,
|
||||
},
|
||||
{
|
||||
key: "open-in-new-tab",
|
||||
action: pageOperations.openInNewTab,
|
||||
title: "Open in new tab",
|
||||
icon: NewTabOutline,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
action: pageOperations.copyLink,
|
||||
title: "Copy link",
|
||||
icon: LinkOutline,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "make-a-copy",
|
||||
action: () => {
|
||||
pageOperations.duplicate();
|
||||
},
|
||||
title: "Make a copy",
|
||||
icon: CopyOutline,
|
||||
shouldRender: canCurrentUserDuplicatePage,
|
||||
},
|
||||
{
|
||||
key: "archive-restore",
|
||||
action: () => {
|
||||
pageOperations.toggleArchive();
|
||||
},
|
||||
title: archived_at ? "Restore" : "Archive",
|
||||
icon: archived_at ? RestoreOutline : ArchiveOutline,
|
||||
shouldRender: canCurrentUserArchivePage,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
action: () => {
|
||||
setDeletePageModal(true);
|
||||
},
|
||||
title: "Delete",
|
||||
icon: DeleteOutline,
|
||||
shouldRender: canCurrentUserDeletePage && !!archived_at,
|
||||
},
|
||||
{
|
||||
key: "move",
|
||||
action: () => setMovePageModal(true),
|
||||
title: "Move",
|
||||
icon: ExportOutline,
|
||||
shouldRender: canCurrentUserMovePage && isMovePageEnabled,
|
||||
},
|
||||
];
|
||||
if (extraOptions) {
|
||||
menuItems.push(...extraOptions);
|
||||
}
|
||||
return menuItems;
|
||||
},
|
||||
[
|
||||
extraOptions,
|
||||
is_locked,
|
||||
canCurrentUserLockPage,
|
||||
access,
|
||||
canCurrentUserChangeAccess,
|
||||
archived_at,
|
||||
canCurrentUserDuplicatePage,
|
||||
canCurrentUserArchivePage,
|
||||
canCurrentUserDeletePage,
|
||||
canCurrentUserMovePage,
|
||||
isMovePageEnabled,
|
||||
pageOperations,
|
||||
]
|
||||
);
|
||||
// arrange options
|
||||
const arrangedOptions = useMemo<(TContextMenuItem & { key: TPageActions })[]>(
|
||||
() =>
|
||||
optionsOrder
|
||||
.map((key) => MENU_ITEMS.find((item) => item.key === key))
|
||||
.filter((item): item is TContextMenuItem & { key: TPageActions } => !!item),
|
||||
[optionsOrder, MENU_ITEMS]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeletePageModal
|
||||
isOpen={deletePageModal}
|
||||
onClose={() => setDeletePageModal(false)}
|
||||
page={page}
|
||||
storeType={storeType}
|
||||
/>
|
||||
{parentRef && <ContextMenu parentRef={parentRef} items={arrangedOptions} />}
|
||||
<CustomMenu placement="bottom-end" optionsClassName="max-h-[90vh]" ellipsis closeOnSelect>
|
||||
{arrangedOptions.map((item) => {
|
||||
if (item.shouldRender === false) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={() => {
|
||||
item.action?.();
|
||||
}}
|
||||
className={cn("flex items-center gap-2", item.className)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.customContent ?? (
|
||||
<>
|
||||
{item.icon && <item.icon className="size-3" />}
|
||||
{item.title}
|
||||
</>
|
||||
)}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
7
apps/web/core/components/pages/dropdowns/index.ts
Normal file
7
apps/web/core/components/pages/dropdowns/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./actions";
|
||||
120
apps/web/core/components/pages/editor/ai/ask-pi-menu.tsx
Normal file
120
apps/web/core/components/pages/editor/ai/ask-pi-menu.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { CircleArrowUp } from "lucide-react";
|
||||
import { AiStar1Outline, CornerRightDownOutline, RefreshOutline } from "@makeplane/propel/icons";
|
||||
// ui
|
||||
import { Tooltip } from "@makeplane/propel/components/tooltip";
|
||||
// components
|
||||
import { cn } from "@plane/utils";
|
||||
import { RichTextEditor } from "@/components/editor/rich-text";
|
||||
// helpers
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
|
||||
type Props = {
|
||||
handleInsertText: (insertOnNextLine: boolean) => void;
|
||||
handleRegenerate: () => Promise<void>;
|
||||
isRegenerating: boolean;
|
||||
response: string | undefined;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export function AskPiMenu(props: Props) {
|
||||
const { handleInsertText, handleRegenerate, isRegenerating, response, workspaceSlug } = props;
|
||||
// states
|
||||
const [query, setQuery] = useState("");
|
||||
// store hooks
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
// derived values
|
||||
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn("flex items-center gap-3 px-4 py-3.5", {
|
||||
"items-start": response,
|
||||
})}
|
||||
>
|
||||
<span className="grid size-7 flex-shrink-0 place-items-center rounded-full border border-subtle text-secondary">
|
||||
<AiStar1Outline className="size-3" />
|
||||
</span>
|
||||
{response ? (
|
||||
<div>
|
||||
<RichTextEditor
|
||||
editable={false}
|
||||
displayConfig={{
|
||||
fontSize: "small-font",
|
||||
}}
|
||||
id="editor-ai-response"
|
||||
initialValue={response}
|
||||
containerClassName="!p-0 border-none"
|
||||
editorClassName="!pl-0"
|
||||
workspaceId={workspaceId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
<div className="mt-3 flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-sm p-1 text-13 font-medium text-tertiary outline-none hover:bg-layer-1"
|
||||
onClick={() => handleInsertText(false)}
|
||||
>
|
||||
Replace selection
|
||||
</button>
|
||||
<Tooltip label="Add to next line">
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-6 flex-shrink-0 place-items-center rounded-sm outline-none hover:bg-layer-1"
|
||||
onClick={() => handleInsertText(true)}
|
||||
>
|
||||
<CornerRightDownOutline className="size-4 text-tertiary" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Re-generate response">
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-6 flex-shrink-0 place-items-center rounded-sm outline-none hover:bg-layer-1"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleRegenerate();
|
||||
}}
|
||||
disabled={isRegenerating}
|
||||
>
|
||||
<RefreshOutline
|
||||
className={cn("size-4 text-tertiary", {
|
||||
"animate-spin": isRegenerating,
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-13 text-secondary">AI is answering...</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 rounded-md border border-subtle p-2">
|
||||
<span className="grid size-3 flex-shrink-0 place-items-center">
|
||||
<AiStar1Outline className="size-3 text-secondary" />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full border-none bg-transparent text-13 outline-none placeholder:text-placeholder"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Tell AI what to do..."
|
||||
/>
|
||||
<span className="grid size-4 flex-shrink-0 place-items-center">
|
||||
<CircleArrowUp className="size-4 text-secondary" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
8
apps/web/core/components/pages/editor/ai/index.ts
Normal file
8
apps/web/core/components/pages/editor/ai/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./ask-pi-menu";
|
||||
export * from "./menu";
|
||||
313
apps/web/core/components/pages/editor/ai/menu.tsx
Normal file
313
apps/web/core/components/pages/editor/ai/menu.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
AiStar1Outline,
|
||||
ChevronRightOutline,
|
||||
CornerRightDownOutline,
|
||||
RefreshOutline,
|
||||
WarningTriangleOutline,
|
||||
} from "@makeplane/propel/icons";
|
||||
// plane editor
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
// plane ui
|
||||
import { Tooltip } from "@makeplane/propel/components/tooltip";
|
||||
// components
|
||||
import { cn } from "@plane/utils";
|
||||
import { RichTextEditor } from "@/components/editor/rich-text";
|
||||
// plane web constants
|
||||
import { AI_EDITOR_TASKS, LOADING_TEXTS } from "@plane/constants";
|
||||
// plane web services
|
||||
import type { TTaskPayload } from "@/services/ai.service";
|
||||
import { AIService } from "@/services/ai.service";
|
||||
import { AskPiMenu } from "./ask-pi-menu";
|
||||
const aiService = new AIService();
|
||||
|
||||
type Props = {
|
||||
editorRef: EditorRefApi | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
workspaceId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
const MENU_ITEMS: {
|
||||
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||
key: AI_EDITOR_TASKS;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
key: AI_EDITOR_TASKS.ASK_ANYTHING,
|
||||
icon: AiStar1Outline,
|
||||
label: "Ask Pi",
|
||||
},
|
||||
];
|
||||
|
||||
const TONES_LIST = [
|
||||
{
|
||||
key: "default",
|
||||
label: "Default",
|
||||
casual_score: 5,
|
||||
formal_score: 5,
|
||||
},
|
||||
{
|
||||
key: "professional",
|
||||
label: "💼 Professional",
|
||||
casual_score: 0,
|
||||
formal_score: 10,
|
||||
},
|
||||
{
|
||||
key: "casual",
|
||||
label: "😃 Casual",
|
||||
casual_score: 10,
|
||||
formal_score: 0,
|
||||
},
|
||||
];
|
||||
|
||||
export function EditorAIMenu(props: Props) {
|
||||
const { editorRef, isOpen, onClose, workspaceId, workspaceSlug } = props;
|
||||
// states
|
||||
const [activeTask, setActiveTask] = useState<AI_EDITOR_TASKS | null>(null);
|
||||
const [response, setResponse] = useState<string | undefined>(undefined);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
// refs
|
||||
const responseContainerRef = useRef<HTMLDivElement>(null);
|
||||
// params
|
||||
const handleGenerateResponse = async (payload: TTaskPayload) => {
|
||||
if (!workspaceSlug) return;
|
||||
await aiService.performEditorTask(workspaceSlug.toString(), payload).then((res) => setResponse(res.response));
|
||||
};
|
||||
// handle task click
|
||||
const handleClick = async (key: AI_EDITOR_TASKS) => {
|
||||
const selection = editorRef?.getSelectedText();
|
||||
if (!selection || activeTask === key) return;
|
||||
setActiveTask(key);
|
||||
if (key === AI_EDITOR_TASKS.ASK_ANYTHING) return;
|
||||
setResponse(undefined);
|
||||
setIsRegenerating(false);
|
||||
await handleGenerateResponse({
|
||||
task: key,
|
||||
text_input: selection,
|
||||
});
|
||||
};
|
||||
// handle re-generate response
|
||||
const handleRegenerate = async () => {
|
||||
const selection = editorRef?.getSelectedText();
|
||||
if (!selection || !activeTask) return;
|
||||
setIsRegenerating(true);
|
||||
await handleGenerateResponse({
|
||||
task: activeTask,
|
||||
text_input: selection,
|
||||
})
|
||||
.then(() =>
|
||||
responseContainerRef.current?.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
})
|
||||
)
|
||||
.finally(() => setIsRegenerating(false));
|
||||
};
|
||||
// handle re-generate response
|
||||
const handleToneChange = async (key: string) => {
|
||||
const selectedTone = TONES_LIST.find((t) => t.key === key);
|
||||
const selection = editorRef?.getSelectedText();
|
||||
if (!selectedTone || !selection || !activeTask) return;
|
||||
setResponse(undefined);
|
||||
setIsRegenerating(false);
|
||||
await handleGenerateResponse({
|
||||
casual_score: selectedTone.casual_score,
|
||||
formal_score: selectedTone.formal_score,
|
||||
task: activeTask,
|
||||
text_input: selection,
|
||||
}).then(() =>
|
||||
responseContainerRef.current?.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
})
|
||||
);
|
||||
};
|
||||
// handle replace selected text with the response
|
||||
const handleInsertText = (insertOnNextLine: boolean) => {
|
||||
if (!response) return;
|
||||
editorRef?.insertText(response, insertOnNextLine);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// reset on close
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setActiveTask(null);
|
||||
setResponse(undefined);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-[210px] flex-col rounded-md border-[0.5px] border-strong bg-surface-1 shadow-raised-200 transition-all",
|
||||
{
|
||||
"w-[700px]": activeTask,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn("flex max-h-72 w-full", {
|
||||
"divide-x divide-subtle-1": activeTask,
|
||||
})}
|
||||
>
|
||||
<div className="w-[210px] flex-shrink-0 overflow-y-auto px-2 py-2.5 transition-all">
|
||||
{MENU_ITEMS.map((item) => {
|
||||
const isActiveTask = activeTask === item.key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 text-11 text-secondary transition-colors hover:bg-layer-1",
|
||||
{
|
||||
"bg-layer-1": isActiveTask,
|
||||
}
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleClick(item.key);
|
||||
}}
|
||||
>
|
||||
<span className="flex flex-shrink-0 items-center gap-2 truncate">
|
||||
<item.icon className="size-3 flex-shrink-0" />
|
||||
{item.label}
|
||||
</span>
|
||||
<ChevronRightOutline
|
||||
className={cn("pointer-events-none size-3 flex-shrink-0 opacity-0 transition-opacity", {
|
||||
"pointer-events-auto opacity-100": isActiveTask,
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
ref={responseContainerRef}
|
||||
className={cn("w-0 flex-shrink-0 overflow-hidden transition-all", {
|
||||
"vertical-scrollbar scrollbar-sm w-[490px] overflow-auto": activeTask,
|
||||
})}
|
||||
>
|
||||
{activeTask === AI_EDITOR_TASKS.ASK_ANYTHING ? (
|
||||
<AskPiMenu
|
||||
handleInsertText={handleInsertText}
|
||||
handleRegenerate={handleRegenerate}
|
||||
isRegenerating={isRegenerating}
|
||||
response={response}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={cn("flex items-center gap-3 px-4 py-3.5", {
|
||||
"items-start": response,
|
||||
})}
|
||||
>
|
||||
<span className="grid size-7 flex-shrink-0 place-items-center rounded-full border border-subtle text-secondary">
|
||||
<AiStar1Outline className="size-3" />
|
||||
</span>
|
||||
{response ? (
|
||||
<div>
|
||||
<RichTextEditor
|
||||
displayConfig={{
|
||||
fontSize: "small-font",
|
||||
}}
|
||||
editable={false}
|
||||
id="editor-ai-response"
|
||||
initialValue={response}
|
||||
containerClassName="!p-0 border-none"
|
||||
editorClassName="!pl-0"
|
||||
workspaceId={workspaceId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
<div className="mt-3 flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-sm p-1 text-13 font-medium text-tertiary outline-none hover:bg-layer-1"
|
||||
onClick={() => handleInsertText(false)}
|
||||
>
|
||||
Replace selection
|
||||
</button>
|
||||
<Tooltip label="Add to next line">
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-6 flex-shrink-0 place-items-center rounded-sm outline-none hover:bg-layer-1"
|
||||
onClick={() => handleInsertText(true)}
|
||||
>
|
||||
<CornerRightDownOutline className="size-4 text-tertiary" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Re-generate response">
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-6 flex-shrink-0 place-items-center rounded-sm outline-none hover:bg-layer-1"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleRegenerate();
|
||||
}}
|
||||
disabled={isRegenerating}
|
||||
>
|
||||
<RefreshOutline
|
||||
className={cn("size-4 text-tertiary", {
|
||||
"animate-spin": isRegenerating,
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-13 text-secondary">
|
||||
{activeTask ? LOADING_TEXTS[activeTask] : "Pi is writing"}...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="sticky bottom-0 flex w-full items-center gap-2 bg-surface-1 py-2 pl-[54.8px]">
|
||||
{TONES_LIST.map((tone) => (
|
||||
<button
|
||||
key={tone.key}
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded-sm bg-layer-1 p-1 text-11 font-medium text-secondary transition-colors outline-none",
|
||||
{
|
||||
"bg-accent-primary/20 text-accent-primary": tone.key === "default",
|
||||
}
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleToneChange(tone.key);
|
||||
}}
|
||||
>
|
||||
{tone.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{activeTask && (
|
||||
<div className="flex items-center gap-2 rounded-b-md border-t border-subtle bg-surface-2 px-4 py-2 text-tertiary">
|
||||
<span className="grid size-4 flex-shrink-0 place-items-center">
|
||||
<WarningTriangleOutline className="size-3" />
|
||||
</span>
|
||||
<p className="flex-shrink-0 text-11 font-medium">
|
||||
By using this feature, you consent to sharing the message with a 3rd party service.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
312
apps/web/core/components/pages/editor/editor-body.tsx
Normal file
312
apps/web/core/components/pages/editor/editor-body.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { LIVE_BASE_PATH, LIVE_BASE_URL } from "@plane/constants";
|
||||
import { CollaborativeDocumentEditorWithRef } from "@plane/editor";
|
||||
import type {
|
||||
CollaborationState,
|
||||
EditorRefApi,
|
||||
EditorTitleRefApi,
|
||||
TAIMenuProps,
|
||||
TDisplayConfig,
|
||||
TFileHandler,
|
||||
TRealtimeConfig,
|
||||
TServerHandler,
|
||||
} from "@plane/editor";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TSearchEntityRequestPayload, TSearchResponse, TWebhookConnectionQueryParams } from "@plane/types";
|
||||
import { ERowVariant, Row } from "@plane/ui";
|
||||
import { cn, generateRandomColor, hslToHex } from "@plane/utils";
|
||||
// components
|
||||
import { EditorMentionsRoot } from "@/components/editor/embeds/mentions";
|
||||
// hooks
|
||||
import { useEditorMention } from "@/hooks/editor";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
|
||||
// plane web imports
|
||||
import type { TCustomEventHandlers } from "@/hooks/use-realtime-page-events";
|
||||
import { useRealtimePageEvents } from "@/hooks/use-realtime-page-events";
|
||||
import type { TExtendedEditorExtensionsConfig } from "@/hooks/pages";
|
||||
import type { EPageStoreType } from "@/hooks/store";
|
||||
import { useEditorFlagging } from "@/hooks/use-editor-flagging";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
// local imports
|
||||
import { PageContentLoader } from "../loaders/page-content-loader";
|
||||
import { PageEditorHeaderRoot } from "./header";
|
||||
import { PageContentBrowser } from "./summary";
|
||||
import { EditorAIMenu } from "./ai/menu";
|
||||
|
||||
export type TEditorBodyConfig = {
|
||||
fileHandler: TFileHandler;
|
||||
};
|
||||
|
||||
export type TEditorBodyHandlers = {
|
||||
fetchEntity: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
|
||||
getRedirectionLink: (pageId?: string) => string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
config: TEditorBodyConfig;
|
||||
editorReady: boolean;
|
||||
editorForwardRef: React.RefObject<EditorRefApi | null>;
|
||||
handleEditorReady: (status: boolean) => void;
|
||||
handleOpenNavigationPane: () => void;
|
||||
handlers: TEditorBodyHandlers;
|
||||
isNavigationPaneOpen: boolean;
|
||||
page: TPageInstance;
|
||||
webhookConnectionParams: TWebhookConnectionQueryParams;
|
||||
projectId?: string;
|
||||
workspaceSlug: string;
|
||||
storeType: EPageStoreType;
|
||||
customRealtimeEventHandlers?: TCustomEventHandlers;
|
||||
extendedEditorProps: TExtendedEditorExtensionsConfig;
|
||||
isFetchingFallbackBinary?: boolean;
|
||||
onCollaborationStateChange?: (state: CollaborationState) => void;
|
||||
};
|
||||
|
||||
export const PageEditorBody = observer(function PageEditorBody(props: Props) {
|
||||
const {
|
||||
config,
|
||||
editorForwardRef,
|
||||
handleEditorReady,
|
||||
handleOpenNavigationPane,
|
||||
handlers,
|
||||
isNavigationPaneOpen,
|
||||
page,
|
||||
storeType,
|
||||
webhookConnectionParams,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
extendedEditorProps,
|
||||
isFetchingFallbackBinary,
|
||||
onCollaborationStateChange,
|
||||
} = props;
|
||||
// refs
|
||||
const titleEditorRef = useRef<EditorTitleRefApi>(null);
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const {
|
||||
id: pageId,
|
||||
isContentEditable,
|
||||
editor: { editorRef, updateAssetsList },
|
||||
setSyncingStatus,
|
||||
} = page;
|
||||
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? "";
|
||||
// use editor mention
|
||||
const { fetchMentions } = useEditorMention({
|
||||
enableAdvancedMentions: true,
|
||||
searchEntity: handlers.fetchEntity,
|
||||
});
|
||||
// editor flaggings
|
||||
const { document: documentEditorExtensions } = useEditorFlagging({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
storeType,
|
||||
});
|
||||
// parse content
|
||||
const { getEditorMetaData } = useParseEditorContent({
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
});
|
||||
// page filters
|
||||
const { fontSize, fontStyle, isFullWidth } = usePageFilters();
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const displayConfig: TDisplayConfig = useMemo(
|
||||
() => ({
|
||||
fontSize,
|
||||
fontStyle,
|
||||
wideLayout: isFullWidth,
|
||||
}),
|
||||
[fontSize, fontStyle, isFullWidth]
|
||||
);
|
||||
|
||||
// Use the new hook to handle page events
|
||||
const { updatePageProperties } = useRealtimePageEvents({
|
||||
storeType,
|
||||
page,
|
||||
getUserDetails,
|
||||
handlers,
|
||||
});
|
||||
|
||||
// Set syncing status when page changes and reset collaboration state
|
||||
useEffect(() => {
|
||||
setSyncingStatus("syncing");
|
||||
onCollaborationStateChange?.({
|
||||
stage: { kind: "connecting" },
|
||||
isServerSynced: false,
|
||||
isServerDisconnected: false,
|
||||
});
|
||||
}, [pageId, setSyncingStatus, onCollaborationStateChange]);
|
||||
|
||||
const getAIMenu = useCallback(
|
||||
({ isOpen, onClose }: TAIMenuProps) => (
|
||||
<EditorAIMenu
|
||||
editorRef={editorRef}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
workspaceId={workspaceId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
),
|
||||
[editorRef, workspaceId, workspaceSlug]
|
||||
);
|
||||
|
||||
const serverHandler: TServerHandler = useMemo(
|
||||
() => ({
|
||||
onStateChange: (state) => {
|
||||
// Pass full state to parent
|
||||
onCollaborationStateChange?.(state);
|
||||
|
||||
// Map collaboration stage to UI syncing status
|
||||
// Stage → UI mapping: disconnected → error | synced → synced | all others → syncing
|
||||
if (state.stage.kind === "disconnected") {
|
||||
setSyncingStatus("error");
|
||||
} else if (state.stage.kind === "synced") {
|
||||
setSyncingStatus("synced");
|
||||
} else {
|
||||
// initial, connecting, awaiting-sync, reconnecting → show as syncing
|
||||
setSyncingStatus("syncing");
|
||||
}
|
||||
},
|
||||
}),
|
||||
[setSyncingStatus, onCollaborationStateChange]
|
||||
);
|
||||
|
||||
const realtimeConfig: TRealtimeConfig | undefined = useMemo(() => {
|
||||
// The collaboration URL is derived from window.location, so it can only be
|
||||
// built on the client. There is no socket to connect to during SSR anyway —
|
||||
// the config is recomputed on hydration.
|
||||
if (typeof window === "undefined") return undefined;
|
||||
|
||||
// Construct the WebSocket Collaboration URL
|
||||
try {
|
||||
const LIVE_SERVER_BASE_URL = LIVE_BASE_URL?.trim() || window.location.origin;
|
||||
const WS_LIVE_URL = new URL(LIVE_SERVER_BASE_URL);
|
||||
const isSecureEnvironment = window.location.protocol === "https:";
|
||||
WS_LIVE_URL.protocol = isSecureEnvironment ? "wss" : "ws";
|
||||
WS_LIVE_URL.pathname = `${LIVE_BASE_PATH}/collaboration`;
|
||||
|
||||
// Append query parameters to the URL
|
||||
Object.entries(webhookConnectionParams)
|
||||
.filter(([_, value]) => value !== undefined && value !== null)
|
||||
.forEach(([key, value]) => {
|
||||
WS_LIVE_URL.searchParams.set(key, String(value));
|
||||
});
|
||||
|
||||
// Construct realtime config
|
||||
return {
|
||||
url: WS_LIVE_URL.toString(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error creating realtime config", error);
|
||||
return undefined;
|
||||
}
|
||||
}, [webhookConnectionParams]);
|
||||
|
||||
const userConfig = useMemo(
|
||||
() => ({
|
||||
id: currentUser?.id ?? "",
|
||||
name: currentUser?.display_name ?? "",
|
||||
color: hslToHex(generateRandomColor(currentUser?.id ?? "")),
|
||||
}),
|
||||
[currentUser?.display_name, currentUser?.id]
|
||||
);
|
||||
|
||||
const blockWidthClassName = cn(
|
||||
"mx-auto block w-full max-w-[720px] bg-transparent transition-all duration-200 ease-in-out",
|
||||
{
|
||||
"max-w-[1152px]": isFullWidth,
|
||||
}
|
||||
);
|
||||
|
||||
const isPageLoading = pageId === undefined || !realtimeConfig;
|
||||
|
||||
if (isPageLoading) return <PageContentLoader className={blockWidthClassName} />;
|
||||
|
||||
return (
|
||||
<Row
|
||||
className="vertical-scrollbar relative scrollbar-md flex size-full flex-col overflow-x-hidden overflow-y-auto duration-200"
|
||||
variant={ERowVariant.HUGGING}
|
||||
>
|
||||
<div id="page-content-container" className="relative w-full flex-shrink-0">
|
||||
{/* table of content */}
|
||||
{!isNavigationPaneOpen && (
|
||||
<div className="page-summary-container absolute top-[64px] right-0 z-[5] h-full">
|
||||
<div className="sticky top-[72px]">
|
||||
<div className="group/page-toc relative px-page-x">
|
||||
{/* oxlint-disable-next-line jsx_a11y/click-events-have-key-events */}
|
||||
<div
|
||||
className="max-h-[50vh] !cursor-pointer overflow-hidden"
|
||||
// oxlint-disable-next-line jsx_a11y/prefer-tag-over-role
|
||||
role="button"
|
||||
aria-label={t("page_navigation_pane.outline_floating_button")}
|
||||
onClick={handleOpenNavigationPane}
|
||||
>
|
||||
<PageContentBrowser className="overflow-y-auto" editorRef={editorRef} showOutline />
|
||||
</div>
|
||||
<div className="vertical-scrollbar pointer-events-none absolute top-0 right-0 scrollbar-sm max-h-[70vh] w-52 translate-x-1/2 overflow-y-scroll rounded-sm bg-surface-2 p-4 whitespace-nowrap opacity-0 transition-all duration-300 group-hover/page-toc:pointer-events-auto group-hover/page-toc:-translate-x-1/4 group-hover/page-toc:opacity-100">
|
||||
<PageContentBrowser className="overflow-y-auto" editorRef={editorRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="page-header-container group/page-header">
|
||||
<div className={blockWidthClassName}>
|
||||
<PageEditorHeaderRoot page={page} projectId={projectId} />
|
||||
</div>
|
||||
</div>
|
||||
<CollaborativeDocumentEditorWithRef
|
||||
editable={isContentEditable}
|
||||
id={pageId}
|
||||
fileHandler={config.fileHandler}
|
||||
handleEditorReady={handleEditorReady}
|
||||
ref={editorForwardRef}
|
||||
titleRef={titleEditorRef}
|
||||
containerClassName="h-full p-0 pb-64"
|
||||
displayConfig={displayConfig}
|
||||
getEditorMetaData={getEditorMetaData}
|
||||
mentionHandler={{
|
||||
searchCallback: async (query) => {
|
||||
const res = await fetchMentions(query);
|
||||
if (!res) throw new Error("Failed in fetching mentions");
|
||||
return res;
|
||||
},
|
||||
// oxlint-disable-next-line no-shadow
|
||||
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
||||
getMentionedEntityDetails: (id: string) => ({ display_name: getUserDetails(id)?.display_name ?? "" }),
|
||||
}}
|
||||
updatePageProperties={updatePageProperties}
|
||||
realtimeConfig={realtimeConfig}
|
||||
serverHandler={serverHandler}
|
||||
user={userConfig}
|
||||
disabledExtensions={documentEditorExtensions.disabled}
|
||||
flaggedExtensions={documentEditorExtensions.flagged}
|
||||
aiHandler={{
|
||||
menu: getAIMenu,
|
||||
}}
|
||||
onAssetChange={updateAssetsList}
|
||||
extendedEditorProps={extendedEditorProps}
|
||||
isFetchingFallbackBinary={isFetchingFallbackBinary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
);
|
||||
});
|
||||
7
apps/web/core/components/pages/editor/header/index.ts
Normal file
7
apps/web/core/components/pages/editor/header/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
57
apps/web/core/components/pages/editor/header/logo-picker.tsx
Normal file
57
apps/web/core/components/pages/editor/header/logo-picker.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { EmojiPicker, EmojiIconPickerTypes, Logo } from "@plane/propel/emoji-icon-picker";
|
||||
import { cn } from "@plane/utils";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
export const PageEditorHeaderLogoPicker = observer(function PageEditorHeaderLogoPicker(props: Props) {
|
||||
const { className, page } = props;
|
||||
// states
|
||||
const [isLogoPickerOpen, setIsLogoPickerOpen] = useState(false);
|
||||
// derived values
|
||||
const { logo_props, isContentEditable, updatePageLogo } = page;
|
||||
const isLogoSelected = !!logo_props?.in_use;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(className, "pointer-events-none max-h-0 transition-all duration-300 ease-linear", {
|
||||
"pointer-events-auto max-h-[56px]": isLogoSelected,
|
||||
})}
|
||||
>
|
||||
<EmojiPicker
|
||||
isOpen={isLogoPickerOpen}
|
||||
handleToggle={(val) => setIsLogoPickerOpen(val)}
|
||||
className="flex items-center justify-center"
|
||||
buttonClassName="flex items-center justify-center"
|
||||
label={
|
||||
<div
|
||||
className={cn("-ml-[8px] grid size-[56px] place-items-center rounded-sm transition-colors", {
|
||||
"hover:bg-layer-1": isContentEditable,
|
||||
})}
|
||||
>
|
||||
{isLogoSelected && <Logo logo={logo_props} size={48} type="lucide" />}
|
||||
</div>
|
||||
}
|
||||
onChange={updatePageLogo}
|
||||
defaultIconColor={logo_props?.in_use && logo_props.in_use === "icon" ? logo_props?.icon?.color : undefined}
|
||||
defaultOpen={
|
||||
logo_props?.in_use && logo_props?.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON
|
||||
}
|
||||
disabled={!isContentEditable}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
77
apps/web/core/components/pages/editor/header/root.tsx
Normal file
77
apps/web/core/components/pages/editor/header/root.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ReactionOutline } from "@makeplane/propel/icons";
|
||||
// plane imports
|
||||
import { EmojiPicker, EmojiIconPickerTypes } from "@plane/propel/emoji-icon-picker";
|
||||
import { cn } from "@plane/utils";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
// local imports
|
||||
import { PageEditorHeaderLogoPicker } from "./logo-picker";
|
||||
|
||||
type Props = {
|
||||
page: TPageInstance;
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
export const PageEditorHeaderRoot = observer(function PageEditorHeaderRoot(props: Props) {
|
||||
const { page } = props;
|
||||
// states
|
||||
const [isLogoPickerOpen, setIsLogoPickerOpen] = useState(false);
|
||||
// derived values
|
||||
const { isContentEditable, logo_props, name, updatePageLogo } = page;
|
||||
const isLogoSelected = !!logo_props?.in_use;
|
||||
const isTitleEmpty = !name || name.trim() === "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-[48px] items-end text-left">
|
||||
{!isLogoSelected && (
|
||||
<div
|
||||
className={cn("opacity-0 transition-all duration-200 group-hover/page-header:opacity-100", {
|
||||
"opacity-100": isTitleEmpty,
|
||||
})}
|
||||
>
|
||||
<EmojiPicker
|
||||
isOpen={isLogoPickerOpen}
|
||||
handleToggle={(val) => setIsLogoPickerOpen(val)}
|
||||
className="flex items-center justify-center"
|
||||
buttonClassName="flex items-center justify-center"
|
||||
label={
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-sm p-1 text-13 font-medium text-tertiary transition-colors outline-none hover:bg-layer-1",
|
||||
{
|
||||
"bg-layer-1": isLogoPickerOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<ReactionOutline className="size-4 flex-shrink-0" />
|
||||
Icon
|
||||
</button>
|
||||
}
|
||||
onChange={updatePageLogo}
|
||||
defaultIconColor={
|
||||
logo_props?.in_use && logo_props.in_use === "icon" ? logo_props?.icon?.color : undefined
|
||||
}
|
||||
defaultOpen={
|
||||
logo_props?.in_use && logo_props?.in_use === "emoji"
|
||||
? EmojiIconPickerTypes.EMOJI
|
||||
: EmojiIconPickerTypes.ICON
|
||||
}
|
||||
disabled={!isContentEditable}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PageEditorHeaderLogoPicker className="mt-2 flex w-full flex-shrink-0" page={page} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
213
apps/web/core/components/pages/editor/page-root.tsx
Normal file
213
apps/web/core/components/pages/editor/page-root.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { CollaborationState, EditorRefApi } from "@plane/editor";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TDocumentPayload, TPage, TPageVersion, TWebhookConnectionQueryParams } from "@plane/types";
|
||||
// hooks
|
||||
import { usePageFallback } from "@/hooks/use-page-fallback";
|
||||
import type { PageUpdateHandler, TCustomEventHandlers } from "@/hooks/use-realtime-page-events";
|
||||
import { usePagesPaneExtensions, useExtendedEditorProps } from "@/hooks/pages";
|
||||
import type { EPageStoreType } from "@/hooks/store";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
// ui
|
||||
import { Banner } from "@makeplane/propel/components/banner";
|
||||
// local imports
|
||||
import { PageNavigationPaneRoot } from "../navigation-pane";
|
||||
import { PageVersionsOverlay } from "../version";
|
||||
import { PagesVersionEditor } from "../version/editor";
|
||||
import { PageEditorBody } from "./editor-body";
|
||||
import type { TEditorBodyConfig, TEditorBodyHandlers } from "./editor-body";
|
||||
import { PageEditorToolbarRoot } from "./toolbar";
|
||||
|
||||
export type TPageRootHandlers = {
|
||||
create: (payload: Partial<TPage>) => Promise<Partial<TPage> | undefined>;
|
||||
fetchAllVersions: (pageId: string) => Promise<TPageVersion[] | undefined>;
|
||||
fetchDescriptionBinary: () => Promise<ArrayBuffer>;
|
||||
fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>;
|
||||
restoreVersion: (pageId: string, versionId: string) => Promise<void>;
|
||||
updateDescription: (document: TDocumentPayload) => Promise<void>;
|
||||
} & TEditorBodyHandlers;
|
||||
|
||||
export type TPageRootConfig = TEditorBodyConfig;
|
||||
|
||||
type TPageRootProps = {
|
||||
config: TPageRootConfig;
|
||||
handlers: TPageRootHandlers;
|
||||
page: TPageInstance;
|
||||
storeType: EPageStoreType;
|
||||
webhookConnectionParams: TWebhookConnectionQueryParams;
|
||||
projectId?: string;
|
||||
workspaceSlug: string;
|
||||
customRealtimeEventHandlers?: TCustomEventHandlers;
|
||||
};
|
||||
|
||||
export const PageRoot = observer(function PageRoot(props: TPageRootProps) {
|
||||
const {
|
||||
config,
|
||||
handlers,
|
||||
page,
|
||||
projectId,
|
||||
storeType,
|
||||
webhookConnectionParams,
|
||||
workspaceSlug,
|
||||
customRealtimeEventHandlers,
|
||||
} = props;
|
||||
// states
|
||||
const [editorReady, setEditorReady] = useState(false);
|
||||
const [collaborationState, setCollaborationState] = useState<CollaborationState | null>(null);
|
||||
const [showContentTooLargeBanner, setShowContentTooLargeBanner] = useState(false);
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// refs
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
// derived values
|
||||
const {
|
||||
isContentEditable,
|
||||
editor: { setEditorRef },
|
||||
} = page;
|
||||
// page fallback
|
||||
const { isFetchingFallbackBinary } = usePageFallback({
|
||||
editorRef,
|
||||
fetchPageDescription: handlers.fetchDescriptionBinary,
|
||||
page,
|
||||
collaborationState,
|
||||
updatePageDescription: handlers.updateDescription,
|
||||
});
|
||||
|
||||
const handleEditorReady = useCallback(
|
||||
(status: boolean) => {
|
||||
setEditorReady(status);
|
||||
if (editorRef.current && !page.editor.editorRef) {
|
||||
setEditorRef(editorRef.current);
|
||||
}
|
||||
},
|
||||
[page.editor.editorRef, setEditorRef]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setEditorRef(editorRef.current), 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isContentEditable, setEditorRef]);
|
||||
|
||||
// Get extensions and navigation logic from hook
|
||||
const {
|
||||
editorExtensionHandlers,
|
||||
navigationPaneExtensions,
|
||||
handleOpenNavigationPane,
|
||||
handleCloseNavigationPane,
|
||||
isNavigationPaneOpen,
|
||||
} = usePagesPaneExtensions({
|
||||
page,
|
||||
editorRef,
|
||||
});
|
||||
|
||||
// Type-safe error handler for content too large errors
|
||||
const errorHandler: PageUpdateHandler<"error"> = (params) => {
|
||||
const { data } = params;
|
||||
|
||||
// Check if it's content too large error
|
||||
if (data.error_code === "content_too_large") {
|
||||
setShowContentTooLargeBanner(true);
|
||||
}
|
||||
|
||||
// Call original error handler if exists
|
||||
customRealtimeEventHandlers?.error?.(params);
|
||||
};
|
||||
|
||||
const mergedCustomEventHandlers: TCustomEventHandlers = {
|
||||
...customRealtimeEventHandlers,
|
||||
error: errorHandler,
|
||||
};
|
||||
|
||||
// Get extended editor extensions configuration
|
||||
const extendedEditorProps = useExtendedEditorProps({
|
||||
workspaceSlug,
|
||||
page,
|
||||
storeType,
|
||||
fetchEntity: handlers.fetchEntity,
|
||||
getRedirectionLink: handlers.getRedirectionLink,
|
||||
extensionHandlers: editorExtensionHandlers,
|
||||
projectId,
|
||||
});
|
||||
|
||||
const handleRestoreVersion = useCallback(
|
||||
async (descriptionHTML: string) => {
|
||||
editorRef.current?.clearEditor();
|
||||
editorRef.current?.setEditorValue(descriptionHTML);
|
||||
},
|
||||
[editorRef]
|
||||
);
|
||||
|
||||
// reset editor ref on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
setEditorRef(null);
|
||||
},
|
||||
[setEditorRef]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex size-full overflow-hidden transition-all duration-300 ease-in-out">
|
||||
<div className="flex size-full flex-col overflow-hidden">
|
||||
<PageVersionsOverlay
|
||||
editorComponent={PagesVersionEditor}
|
||||
fetchVersionDetails={handlers.fetchVersionDetails}
|
||||
handleRestore={handleRestoreVersion}
|
||||
pageId={page.id ?? ""}
|
||||
restoreEnabled={isContentEditable}
|
||||
storeType={storeType}
|
||||
/>
|
||||
<PageEditorToolbarRoot
|
||||
handleOpenNavigationPane={handleOpenNavigationPane}
|
||||
isNavigationPaneOpen={isNavigationPaneOpen}
|
||||
page={page}
|
||||
/>
|
||||
{showContentTooLargeBanner && (
|
||||
<Banner
|
||||
placement="page"
|
||||
variant="warning"
|
||||
title={t("page_content_limit_banner.message")}
|
||||
render={<div className="px-page-x" />}
|
||||
/>
|
||||
)}
|
||||
<PageEditorBody
|
||||
config={config}
|
||||
customRealtimeEventHandlers={mergedCustomEventHandlers}
|
||||
editorReady={editorReady}
|
||||
editorForwardRef={editorRef}
|
||||
handleEditorReady={handleEditorReady}
|
||||
handleOpenNavigationPane={handleOpenNavigationPane}
|
||||
handlers={handlers}
|
||||
isNavigationPaneOpen={isNavigationPaneOpen}
|
||||
page={page}
|
||||
projectId={projectId}
|
||||
storeType={storeType}
|
||||
webhookConnectionParams={webhookConnectionParams}
|
||||
workspaceSlug={workspaceSlug}
|
||||
extendedEditorProps={extendedEditorProps}
|
||||
isFetchingFallbackBinary={isFetchingFallbackBinary}
|
||||
onCollaborationStateChange={setCollaborationState}
|
||||
/>
|
||||
</div>
|
||||
<PageNavigationPaneRoot
|
||||
storeType={storeType}
|
||||
handleClose={handleCloseNavigationPane}
|
||||
isNavigationPaneOpen={isNavigationPaneOpen}
|
||||
page={page}
|
||||
versionHistory={{
|
||||
fetchAllVersions: handlers.fetchAllVersions,
|
||||
fetchVersionDetails: handlers.fetchVersionDetails,
|
||||
}}
|
||||
extensions={navigationPaneExtensions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
// plane imports
|
||||
import type { EditorRefApi, IMarking } from "@plane/editor";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import type { THeadingComponentProps } from "./heading-components";
|
||||
import { OutlineHeading1, OutlineHeading2, OutlineHeading3 } from "./heading-components";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
emptyState?: React.ReactNode;
|
||||
editorRef: EditorRefApi | null;
|
||||
setSidePeekVisible?: (sidePeekState: boolean) => void;
|
||||
showOutline?: boolean;
|
||||
};
|
||||
|
||||
export function PageContentBrowser(props: Props) {
|
||||
const { className, editorRef, emptyState, setSidePeekVisible, showOutline = false } = props;
|
||||
// states
|
||||
const [headings, setHeadings] = useState<IMarking[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = editorRef?.onHeadingChange(setHeadings);
|
||||
// for initial render of this component to get the editor headings
|
||||
setHeadings(editorRef?.getHeadings() ?? []);
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [editorRef]);
|
||||
|
||||
const handleOnClick = useCallback(
|
||||
(marking: IMarking) => {
|
||||
editorRef?.scrollSummary(marking);
|
||||
setSidePeekVisible?.(false);
|
||||
},
|
||||
[editorRef, setSidePeekVisible]
|
||||
);
|
||||
|
||||
const HeadingComponent: {
|
||||
[key: number]: React.FC<THeadingComponentProps>;
|
||||
} = {
|
||||
1: OutlineHeading1,
|
||||
2: OutlineHeading2,
|
||||
3: OutlineHeading3,
|
||||
};
|
||||
|
||||
if (headings.length === 0) return emptyState ?? null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-2 flex h-full flex-col items-start gap-y-1",
|
||||
{
|
||||
"gap-y-2": showOutline,
|
||||
},
|
||||
className
|
||||
)}
|
||||
>
|
||||
{headings.map((marking) => {
|
||||
const Component = HeadingComponent[marking.level];
|
||||
if (!Component) return null;
|
||||
if (showOutline === true)
|
||||
return (
|
||||
<div
|
||||
key={`${marking.level}-${marking.sequence}`}
|
||||
className="h-0.5 flex-shrink-0 self-end rounded-xs bg-layer-3"
|
||||
style={{
|
||||
width: marking.level === 1 ? "20px" : marking.level === 2 ? "18px" : "14px",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Component
|
||||
key={`${marking.level}-${marking.sequence}`}
|
||||
marking={marking}
|
||||
onClick={() => handleOnClick(marking)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// plane imports
|
||||
import type { IMarking } from "@plane/editor";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
export type THeadingComponentProps = {
|
||||
marking: IMarking;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
};
|
||||
|
||||
const COMMON_CLASSNAME =
|
||||
"flex-shrink-0 w-full py-1 text-left font-medium text-tertiary hover:text-accent-primary truncate transition-colors";
|
||||
|
||||
export function OutlineHeading1({ marking, onClick }: THeadingComponentProps) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={cn(COMMON_CLASSNAME, "pl-1 text-13")}>
|
||||
{marking.text}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function OutlineHeading2({ marking, onClick }: THeadingComponentProps) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={cn(COMMON_CLASSNAME, "pl-2 text-11")}>
|
||||
{marking.text}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function OutlineHeading3({ marking, onClick }: THeadingComponentProps) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={cn(COMMON_CLASSNAME, "pl-4 text-11")}>
|
||||
{marking.text}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
7
apps/web/core/components/pages/editor/summary/index.ts
Normal file
7
apps/web/core/components/pages/editor/summary/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./content-browser";
|
||||
124
apps/web/core/components/pages/editor/toolbar/color-dropdown.tsx
Normal file
124
apps/web/core/components/pages/editor/toolbar/color-dropdown.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Ban } from "lucide-react";
|
||||
import { TextOutline } from "@makeplane/propel/icons";
|
||||
import { Popover } from "@headlessui/react";
|
||||
// plane editor
|
||||
import { COLORS_LIST } from "@plane/editor";
|
||||
import type { TEditorCommands } from "@plane/editor";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
handleColorSelect: (
|
||||
key: Extract<TEditorCommands, "text-color" | "background-color">,
|
||||
color: string | undefined
|
||||
) => void;
|
||||
isColorActive: (
|
||||
key: Extract<TEditorCommands, "text-color" | "background-color">,
|
||||
color: string | undefined
|
||||
) => boolean;
|
||||
};
|
||||
|
||||
export const ColorDropdown = memo(function ColorDropdown(props: Props) {
|
||||
const { handleColorSelect, isColorActive } = props;
|
||||
|
||||
const activeTextColor = COLORS_LIST.find((c) => isColorActive("text-color", c.key));
|
||||
const activeBackgroundColor = COLORS_LIST.find((c) => isColorActive("background-color", c.key));
|
||||
|
||||
return (
|
||||
<Popover as="div" className="h-7 px-2">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-7 items-center gap-1.5 rounded-sm px-2 text-13 outline-none",
|
||||
"text-tertiary hover:bg-layer-1",
|
||||
{
|
||||
"bg-layer-1 text-primary": open,
|
||||
}
|
||||
)}
|
||||
>
|
||||
Color
|
||||
<span
|
||||
className={cn("grid size-6 shrink-0 place-items-center rounded-sm border-[0.5px] border-strong", {
|
||||
"bg-surface-1": !activeBackgroundColor,
|
||||
})}
|
||||
style={{
|
||||
backgroundColor: activeBackgroundColor ? activeBackgroundColor.backgroundColor : "transparent",
|
||||
}}
|
||||
>
|
||||
<TextOutline
|
||||
className={cn("size-3.5", {
|
||||
"text-primary": !activeTextColor,
|
||||
})}
|
||||
style={{
|
||||
color: activeTextColor ? activeTextColor.textColor : "inherit",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</Popover.Button>
|
||||
<Popover.Panel
|
||||
as="div"
|
||||
className="fixed z-20 mt-1 space-y-2 rounded-md border-[0.5px] border-strong bg-surface-1 p-2 shadow-raised-200"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-11 font-semibold text-tertiary">Text colors</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{COLORS_LIST.map((color) => (
|
||||
<button
|
||||
key={color.key}
|
||||
type="button"
|
||||
className="size-6 flex-shrink-0 rounded-sm border-[0.5px] border-strong-1 transition-opacity hover:opacity-60"
|
||||
style={{
|
||||
backgroundColor: color.textColor,
|
||||
}}
|
||||
onClick={() => handleColorSelect("text-color", color.key)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-6 flex-shrink-0 place-items-center rounded-sm border-[0.5px] border-strong-1 text-tertiary transition-colors hover:bg-layer-1"
|
||||
onClick={() => handleColorSelect("text-color", undefined)}
|
||||
>
|
||||
<Ban className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-11 font-semibold text-tertiary">Background colors</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{COLORS_LIST.map((color) => (
|
||||
<button
|
||||
key={color.key}
|
||||
type="button"
|
||||
className="size-6 flex-shrink-0 rounded-sm border-[0.5px] border-strong-1 transition-opacity hover:opacity-60"
|
||||
style={{
|
||||
backgroundColor: color.backgroundColor,
|
||||
}}
|
||||
onClick={() => handleColorSelect("background-color", color.key)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-6 flex-shrink-0 place-items-center rounded-sm border-[0.5px] border-strong-1 text-tertiary transition-colors hover:bg-layer-1"
|
||||
onClick={() => handleColorSelect("background-color", undefined)}
|
||||
>
|
||||
<Ban className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
ColorDropdown.displayName = "ColorDropdown";
|
||||
10
apps/web/core/components/pages/editor/toolbar/index.ts
Normal file
10
apps/web/core/components/pages/editor/toolbar/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./color-dropdown";
|
||||
export * from "./options-dropdown";
|
||||
export * from "./root";
|
||||
export * from "./toolbar";
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ClipboardOutline, ExportOutline, HistoryOutline } from "@makeplane/propel/icons";
|
||||
// plane imports
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Switch } from "@makeplane/propel/components/switch";
|
||||
// hooks
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
import { useQueryParams } from "@/hooks/use-query-params";
|
||||
// plane web imports
|
||||
import type { TPageNavigationPaneTab } from "@/components/pages/navigation-pane/tab-panels";
|
||||
import type { EPageStoreType } from "@/hooks/store";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
// local imports
|
||||
import { PageActions } from "../../dropdowns";
|
||||
import { ExportPageModal } from "../../modals/export-page-modal";
|
||||
import { PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM } from "../../navigation-pane";
|
||||
|
||||
type Props = {
|
||||
page: TPageInstance;
|
||||
storeType: EPageStoreType;
|
||||
};
|
||||
|
||||
export const PageOptionsDropdown = observer(function PageOptionsDropdown(props: Props) {
|
||||
const { page, storeType } = props;
|
||||
// states
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
// navigation
|
||||
const router = useAppRouter();
|
||||
// store values
|
||||
const {
|
||||
name,
|
||||
isContentEditable,
|
||||
editor: { editorRef },
|
||||
} = page;
|
||||
// page filters
|
||||
const { isFullWidth, handleFullWidth, isStickyToolbarEnabled, handleStickyToolbar } = usePageFilters();
|
||||
// query params
|
||||
const { updateQueryParams } = useQueryParams();
|
||||
// menu items list
|
||||
const EXTRA_MENU_OPTIONS = useMemo(
|
||||
function EXTRA_MENU_OPTIONS(): React.ComponentProps<typeof PageActions>["extraOptions"] {
|
||||
return [
|
||||
{
|
||||
key: "full-screen",
|
||||
action: () => handleFullWidth(!isFullWidth),
|
||||
customContent: (
|
||||
<>
|
||||
Full width
|
||||
<Switch size="sm" checked={isFullWidth} onCheckedChange={() => {}} aria-label="Full width" />
|
||||
</>
|
||||
),
|
||||
className: "flex items-center justify-between gap-2",
|
||||
},
|
||||
{
|
||||
key: "sticky-toolbar",
|
||||
action: () => handleStickyToolbar(!isStickyToolbarEnabled),
|
||||
customContent: (
|
||||
<>
|
||||
Sticky toolbar
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={isStickyToolbarEnabled}
|
||||
onCheckedChange={() => {}}
|
||||
aria-label="Sticky toolbar"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
className: "flex items-center justify-between gap-2",
|
||||
shouldRender: isContentEditable,
|
||||
},
|
||||
{
|
||||
key: "copy-markdown",
|
||||
action: () => {
|
||||
if (!editorRef) return;
|
||||
editorRef.copyMarkdownToClipboard();
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Markdown copied to clipboard.",
|
||||
});
|
||||
},
|
||||
title: "Copy markdown",
|
||||
icon: ClipboardOutline,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "version-history",
|
||||
action: () => {
|
||||
// update query param to show info tab in navigation pane
|
||||
const updatedRoute = updateQueryParams({
|
||||
paramsToAdd: {
|
||||
[PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM]: "info" satisfies TPageNavigationPaneTab,
|
||||
},
|
||||
});
|
||||
router.push(updatedRoute);
|
||||
},
|
||||
title: "Version history",
|
||||
icon: HistoryOutline,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
action: () => setIsExportModalOpen(true),
|
||||
title: "Export",
|
||||
icon: ExportOutline,
|
||||
shouldRender: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
[
|
||||
handleFullWidth,
|
||||
isFullWidth,
|
||||
handleStickyToolbar,
|
||||
isStickyToolbarEnabled,
|
||||
isContentEditable,
|
||||
editorRef,
|
||||
updateQueryParams,
|
||||
router,
|
||||
setIsExportModalOpen,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExportPageModal
|
||||
editorRef={editorRef}
|
||||
isOpen={isExportModalOpen}
|
||||
onClose={() => setIsExportModalOpen(false)}
|
||||
pageTitle={name ?? ""}
|
||||
/>
|
||||
<PageActions
|
||||
extraOptions={EXTRA_MENU_OPTIONS}
|
||||
optionsOrder={[
|
||||
"full-screen",
|
||||
"sticky-toolbar",
|
||||
"copy-markdown",
|
||||
"version-history",
|
||||
"make-a-copy",
|
||||
"archive-restore",
|
||||
"delete",
|
||||
"toggle-access",
|
||||
"export",
|
||||
]}
|
||||
page={page}
|
||||
storeType={storeType}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
90
apps/web/core/components/pages/editor/toolbar/root.tsx
Normal file
90
apps/web/core/components/pages/editor/toolbar/root.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { RightSidePaneOutline } from "@makeplane/propel/icons";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Tooltip } from "@makeplane/propel/components/tooltip";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { PageToolbar } from "@/components/pages/editor/toolbar";
|
||||
// hooks
|
||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type Props = {
|
||||
handleOpenNavigationPane: () => void;
|
||||
isNavigationPaneOpen: boolean;
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
export const PageEditorToolbarRoot = observer(function PageEditorToolbarRoot(props: Props) {
|
||||
const { handleOpenNavigationPane, isNavigationPaneOpen, page } = props;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const {
|
||||
isContentEditable,
|
||||
editor: { editorRef },
|
||||
} = page;
|
||||
// page filters
|
||||
const { isFullWidth, isStickyToolbarEnabled } = usePageFilters();
|
||||
// derived values
|
||||
const shouldHideToolbar = !isStickyToolbarEnabled || !isContentEditable;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
id="page-toolbar-container"
|
||||
className={cn("max-h-[52px] overflow-auto transition-all duration-300 ease-linear", {
|
||||
"max-h-0 overflow-hidden": shouldHideToolbar,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"page-toolbar-content relative hidden min-h-[52px] items-center px-page-x transition-all duration-200 ease-in-out md:flex",
|
||||
{
|
||||
"wide-layout": isFullWidth,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full max-w-full items-center justify-between">
|
||||
<div className="flex-1">{editorRef && <PageToolbar editorRef={editorRef} />}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isNavigationPaneOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-6 shrink-0 place-items-center rounded-sm text-secondary transition-colors hover:bg-layer-transparent-hover hover:text-primary"
|
||||
onClick={handleOpenNavigationPane}
|
||||
>
|
||||
<RightSidePaneOutline className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{shouldHideToolbar && (
|
||||
<div className="absolute top-0 right-0 z-10 flex h-[52px] items-center px-page-x">
|
||||
{!isNavigationPaneOpen && (
|
||||
<Tooltip label={t("page_navigation_pane.open_button")}>
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-6 shrink-0 place-items-center rounded-sm text-secondary transition-colors hover:bg-layer-transparent-hover hover:text-primary"
|
||||
onClick={handleOpenNavigationPane}
|
||||
aria-label={t("page_navigation_pane.open_button")}
|
||||
>
|
||||
<RightSidePaneOutline className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
189
apps/web/core/components/pages/editor/toolbar/toolbar.tsx
Normal file
189
apps/web/core/components/pages/editor/toolbar/toolbar.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
// plane imports
|
||||
import { ChevronDownOutline, TickOutline } from "@makeplane/propel/icons";
|
||||
import { Tooltip } from "@makeplane/propel/components/tooltip";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import type { ToolbarMenuItem } from "@plane/editor";
|
||||
import { TOOLBAR_ITEMS, TYPOGRAPHY_ITEMS } from "@plane/editor";
|
||||
// local imports
|
||||
import { ColorDropdown } from "./color-dropdown";
|
||||
|
||||
type Props = {
|
||||
editorRef: EditorRefApi;
|
||||
};
|
||||
|
||||
type ToolbarButtonProps = {
|
||||
item: ToolbarMenuItem;
|
||||
isActive: boolean;
|
||||
executeCommand: EditorRefApi["executeMenuItemCommand"];
|
||||
};
|
||||
|
||||
const ToolbarButton = React.memo(function ToolbarButton(props: ToolbarButtonProps) {
|
||||
const { item, isActive, executeCommand } = props;
|
||||
|
||||
return (
|
||||
<Tooltip label={item.name} shortcut={item.shortcut?.join(" + ")}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
// TODO: update this while toolbar homogenization
|
||||
// @ts-expect-error type mismatch here
|
||||
executeCommand({
|
||||
itemKey: item.itemKey,
|
||||
...item.extraProps,
|
||||
})
|
||||
}
|
||||
className={cn("grid size-7 shrink-0 place-items-center rounded-sm text-tertiary", {
|
||||
"bg-layer-transparent-selected text-primary hover:bg-layer-transparent-selected": isActive,
|
||||
"hover:bg-layer-transparent-hover": !isActive,
|
||||
})}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("size-4 transition-transform duration-200", {
|
||||
"text-primary": isActive,
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
ToolbarButton.displayName = "ToolbarButton";
|
||||
|
||||
const toolbarItems = TOOLBAR_ITEMS.document;
|
||||
|
||||
export function PageToolbar(props: Props) {
|
||||
const { editorRef } = props;
|
||||
// states
|
||||
const [activeStates, setActiveStates] = useState<Record<string, boolean>>(() => {
|
||||
const initialStates: Record<string, boolean> = {};
|
||||
Object.values(toolbarItems)
|
||||
.flat()
|
||||
.forEach((item) => {
|
||||
// TODO: update this while toolbar homogenization
|
||||
// @ts-expect-error type mismatch here
|
||||
initialStates[item.renderKey] = editorRef.isMenuItemActive({
|
||||
itemKey: item.itemKey,
|
||||
...item.extraProps,
|
||||
});
|
||||
});
|
||||
return initialStates;
|
||||
});
|
||||
|
||||
const [isTypographyMenuOpen, setIsTypographyMenuOpen] = useState(false);
|
||||
|
||||
const updateActiveStates = useCallback(() => {
|
||||
const newActiveStates: Record<string, boolean> = {};
|
||||
Object.values(toolbarItems)
|
||||
.flat()
|
||||
.forEach((item) => {
|
||||
// TODO: update this while toolbar homogenization
|
||||
// @ts-expect-error type mismatch here
|
||||
newActiveStates[item.renderKey] = editorRef.isMenuItemActive({
|
||||
itemKey: item.itemKey,
|
||||
...item.extraProps,
|
||||
});
|
||||
});
|
||||
setActiveStates(newActiveStates);
|
||||
}, [editorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = editorRef.onStateChange(updateActiveStates);
|
||||
return () => unsubscribe();
|
||||
}, [editorRef, updateActiveStates]);
|
||||
|
||||
const activeTypography = TYPOGRAPHY_ITEMS.find((item) =>
|
||||
editorRef.isMenuItemActive({
|
||||
itemKey: item.itemKey,
|
||||
...item.extraProps,
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="animate-in fade-in flex items-center divide-x divide-subtle-1 overflow-x-scroll duration-200">
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-7 w-24 items-center justify-between gap-2 rounded-sm border-[0.5px] border-strong px-2 text-left text-13 whitespace-nowrap",
|
||||
{
|
||||
"bg-layer-1-selected text-primary": isTypographyMenuOpen,
|
||||
"text-tertiary hover:bg-layer-1-hover": !isTypographyMenuOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{activeTypography?.name || "Text"}
|
||||
<ChevronDownOutline className="size-3 shrink-0" />
|
||||
</span>
|
||||
}
|
||||
className="pr-2"
|
||||
placement="bottom-start"
|
||||
closeOnSelect
|
||||
maxHeight="lg"
|
||||
menuButtonOnClick={() => setIsTypographyMenuOpen((prev) => !prev)}
|
||||
onMenuClose={() => setIsTypographyMenuOpen(false)}
|
||||
>
|
||||
{TYPOGRAPHY_ITEMS.map((item) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.renderKey}
|
||||
className={cn("flex items-center justify-between gap-2", {
|
||||
"bg-layer-transparent-selected text-primary": activeTypography?.itemKey === item.itemKey,
|
||||
"hover:bg-layer-transparent-hover": !(activeTypography?.itemKey === item.itemKey),
|
||||
})}
|
||||
onClick={() => {
|
||||
if (activeTypography?.itemKey !== item.itemKey) {
|
||||
editorRef.executeMenuItemCommand({
|
||||
itemKey: item.itemKey,
|
||||
...item.extraProps,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<item.icon className="size-3" />
|
||||
{item.name}
|
||||
</span>
|
||||
{activeTypography?.itemKey === item.itemKey && <TickOutline className="size-3 shrink-0 text-tertiary" />}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
<div className="shrink-0">
|
||||
<ColorDropdown
|
||||
handleColorSelect={(key, color) =>
|
||||
editorRef.executeMenuItemCommand({
|
||||
itemKey: key,
|
||||
color,
|
||||
})
|
||||
}
|
||||
isColorActive={(key, color) =>
|
||||
editorRef.isMenuItemActive({
|
||||
itemKey: key,
|
||||
color,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{Object.keys(toolbarItems).map((key) => (
|
||||
<div key={key} className="flex items-center gap-0.5 px-2 first:pl-0 last:pr-0">
|
||||
{toolbarItems[key].map((item) => (
|
||||
<ToolbarButton
|
||||
key={item.renderKey}
|
||||
item={item}
|
||||
isActive={activeStates[item.renderKey]}
|
||||
executeCommand={editorRef.executeMenuItemCommand}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
apps/web/core/components/pages/header/actions.tsx
Normal file
38
apps/web/core/components/pages/header/actions.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane web hooks
|
||||
import type { EPageStoreType } from "@/hooks/store";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
// local imports
|
||||
import { PageOptionsDropdown } from "../editor/toolbar";
|
||||
import { PageArchivedBadge } from "./archived-badge";
|
||||
import { PageCopyLinkControl } from "./copy-link-control";
|
||||
import { PageFavoriteControl } from "./favorite-control";
|
||||
import { PageOfflineBadge } from "./offline-badge";
|
||||
import { PageLockControl } from "./lock-control";
|
||||
|
||||
type Props = {
|
||||
page: TPageInstance;
|
||||
storeType: EPageStoreType;
|
||||
};
|
||||
|
||||
export const PageHeaderActions = observer(function PageHeaderActions(props: Props) {
|
||||
const { page, storeType } = props;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<PageArchivedBadge page={page} />
|
||||
<PageOfflineBadge page={page} />
|
||||
<PageLockControl page={page} />
|
||||
<PageCopyLinkControl page={page} />
|
||||
<PageFavoriteControl page={page} />
|
||||
<PageOptionsDropdown page={page} storeType={storeType} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
27
apps/web/core/components/pages/header/archived-badge.tsx
Normal file
27
apps/web/core/components/pages/header/archived-badge.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { ArchiveOutline } from "@makeplane/propel/icons";
|
||||
import { renderFormattedDate } from "@plane/utils";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type Props = {
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
export const PageArchivedBadge = observer(function PageArchivedBadge({ page }: Props) {
|
||||
if (!page.archived_at) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-6 flex-shrink-0 items-center gap-1 rounded-sm bg-accent-primary/20 px-2 text-accent-primary">
|
||||
<ArchiveOutline className="size-3.5 flex-shrink-0" />
|
||||
<span className="text-11 font-medium">Archived at {renderFormattedDate(page.archived_at)}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
71
apps/web/core/components/pages/header/copy-link-control.tsx
Normal file
71
apps/web/core/components/pages/header/copy-link-control.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
import { LinkOutline, TickOutline } from "@makeplane/propel/icons";
|
||||
// plane imports
|
||||
import { Tooltip } from "@makeplane/propel/components/tooltip";
|
||||
import { IconButton } from "@plane/propel/icon-button";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { usePageOperations } from "@/hooks/use-page-operations";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type Props = {
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
export const PageCopyLinkControl = observer(function PageCopyLinkControl({ page }: Props) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// page operations
|
||||
const { pageOperations } = usePageOperations({
|
||||
page,
|
||||
});
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
pageOperations.copyLink();
|
||||
setIsCopied(true);
|
||||
|
||||
// Clear previous timer if it exists
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
// Set new timer
|
||||
timerRef.current = setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
timerRef.current = null;
|
||||
}, 1000);
|
||||
}, [pageOperations]);
|
||||
|
||||
return (
|
||||
<Tooltip label={isCopied ? "Copied!" : "Copy link"} side="bottom">
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
icon={isCopied ? TickOutline : LinkOutline}
|
||||
onClick={handleCopy}
|
||||
aria-label={isCopied ? "Copied link" : "Copy link"}
|
||||
className={cn(isCopied && "text-success-primary")}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
44
apps/web/core/components/pages/header/favorite-control.tsx
Normal file
44
apps/web/core/components/pages/header/favorite-control.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { StarOutline } from "@makeplane/propel/icons";
|
||||
// ui
|
||||
import { IconButton } from "@plane/propel/icon-button";
|
||||
// hooks
|
||||
import { usePageOperations } from "@/hooks/use-page-operations";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type Props = {
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
export const PageFavoriteControl = observer(function PageFavoriteControl({ page }: Props) {
|
||||
// derived values
|
||||
const { is_favorite, canCurrentUserFavoritePage } = page;
|
||||
// page operations
|
||||
const { pageOperations } = usePageOperations({
|
||||
page,
|
||||
});
|
||||
|
||||
if (!canCurrentUserFavoritePage) return null;
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
icon={StarOutline}
|
||||
onClick={() => {
|
||||
pageOperations.toggleFavorite();
|
||||
}}
|
||||
aria-label={is_favorite ? "Remove favorite" : "Add to favorites"}
|
||||
className={
|
||||
is_favorite ? "[&_svg]:fill-(--color-label-yellow-icon) [&_svg]:stroke-(--color-label-yellow-icon)" : ""
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
7
apps/web/core/components/pages/header/index.ts
Normal file
7
apps/web/core/components/pages/header/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
120
apps/web/core/components/pages/header/lock-control.tsx
Normal file
120
apps/web/core/components/pages/header/lock-control.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { LockedOutline, UnlockedOutline } from "@makeplane/propel/icons";
|
||||
// plane imports
|
||||
import { Tooltip } from "@makeplane/propel/components/tooltip";
|
||||
// hooks
|
||||
import { usePageOperations } from "@/hooks/use-page-operations";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
// Define our lock display states, renaming "icon-only" to "neutral"
|
||||
type LockDisplayState = "neutral" | "locked" | "unlocked";
|
||||
|
||||
type Props = {
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
export const PageLockControl = observer(function PageLockControl({ page }: Props) {
|
||||
// Initial state: if locked, then "locked", otherwise default to "neutral"
|
||||
const [displayState, setDisplayState] = useState<LockDisplayState>(page.is_locked ? "locked" : "neutral");
|
||||
// derived values
|
||||
const { canCurrentUserLockPage, is_locked } = page;
|
||||
// Ref for the transition timer
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Ref to store the previous value of isLocked for detecting transitions
|
||||
const prevLockedRef = useRef(is_locked);
|
||||
// page operations
|
||||
const {
|
||||
pageOperations: { toggleLock },
|
||||
} = usePageOperations({
|
||||
page,
|
||||
});
|
||||
|
||||
// Cleanup any running timer on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Update display state when isLocked changes
|
||||
useEffect(() => {
|
||||
// Clear any previous timer to avoid overlapping transitions
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
// Transition logic:
|
||||
// If locked, ensure the display state is "locked"
|
||||
// If unlocked after being locked, show "unlocked" briefly then revert to "neutral"
|
||||
if (is_locked) {
|
||||
setDisplayState("locked");
|
||||
} else if (prevLockedRef.current === true) {
|
||||
setDisplayState("unlocked");
|
||||
timerRef.current = setTimeout(() => {
|
||||
setDisplayState("neutral");
|
||||
timerRef.current = null;
|
||||
}, 600);
|
||||
} else {
|
||||
setDisplayState("neutral");
|
||||
}
|
||||
|
||||
// Update the previous locked state
|
||||
prevLockedRef.current = is_locked;
|
||||
}, [is_locked]);
|
||||
|
||||
if (!canCurrentUserLockPage) return null;
|
||||
|
||||
// Render different UI based on the current display state
|
||||
return (
|
||||
<>
|
||||
{displayState === "neutral" && (
|
||||
<Tooltip label="Lock" side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLock}
|
||||
className="grid size-6 flex-shrink-0 place-items-center rounded-sm text-secondary transition-colors hover:bg-layer-1 hover:text-primary"
|
||||
aria-label="Lock"
|
||||
>
|
||||
<LockedOutline className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{displayState === "locked" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLock}
|
||||
className="flex h-6 items-center gap-1 rounded-sm bg-accent-primary/20 px-2 text-accent-primary transition-colors hover:bg-accent-primary/30"
|
||||
aria-label="Locked"
|
||||
>
|
||||
<LockedOutline className="animate-lock-icon size-3.5 flex-shrink-0" />
|
||||
<span className="animate-text-slide-in overflow-hidden text-11 font-medium whitespace-nowrap transition-all duration-500 ease-out">
|
||||
Locked
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{displayState === "unlocked" && (
|
||||
<div
|
||||
className="flex h-6 animate-fade-out items-center gap-1 rounded-sm px-2 text-secondary"
|
||||
aria-label="Unlocked"
|
||||
>
|
||||
<UnlockedOutline className="animate-unlock-icon size-3.5 flex-shrink-0" />
|
||||
<span className="animate-text-slide-in animate-text-fade-out overflow-hidden text-11 font-medium whitespace-nowrap transition-all duration-500 ease-out">
|
||||
Unlocked
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
36
apps/web/core/components/pages/header/offline-badge.tsx
Normal file
36
apps/web/core/components/pages/header/offline-badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { Tooltip } from "@makeplane/propel/components/tooltip";
|
||||
// hooks
|
||||
import useOnlineStatus from "@/hooks/use-online-status";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type Props = {
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
export const PageOfflineBadge = observer(function PageOfflineBadge({ page }: Props) {
|
||||
// use online status
|
||||
const { isOnline } = useOnlineStatus();
|
||||
|
||||
if (!page.isContentEditable || isOnline) return null;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label="You are offline. You can continue making changes. They will be synced when you are back online."
|
||||
layout="stacked"
|
||||
>
|
||||
<div className="flex h-7 flex-shrink-0 items-center gap-2 rounded-full bg-layer-1 px-3 py-0.5 text-11 font-medium text-tertiary">
|
||||
<span className="size-1.5 flex-shrink-0 rounded-full bg-layer-1" />
|
||||
<span>Offline</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
106
apps/web/core/components/pages/header/root.tsx
Normal file
106
apps/web/core/components/pages/header/root.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { FilterOutline } from "@makeplane/propel/icons";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TPageFilterProps, TPageNavigationTabs } from "@plane/types";
|
||||
import { Header, EHeaderVariant } from "@plane/ui";
|
||||
import { calculateTotalFilters } from "@plane/utils";
|
||||
// components
|
||||
import { FiltersDropdown } from "@/components/issues/issue-layouts/filters";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { usePageStore } from "@/hooks/store";
|
||||
import type { EPageStoreType } from "@/hooks/store";
|
||||
// local imports
|
||||
import { PageAppliedFiltersList } from "../list/applied-filters";
|
||||
import { PageFiltersSelection } from "../list/filters";
|
||||
import { PageOrderByDropdown } from "../list/order-by";
|
||||
import { PageSearchInput } from "../list/search-input";
|
||||
import { PageTabNavigation } from "../list/tab-navigation";
|
||||
|
||||
type Props = {
|
||||
pageType: TPageNavigationTabs;
|
||||
projectId: string;
|
||||
storeType: EPageStoreType;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const PagesListHeaderRoot = observer(function PagesListHeaderRoot(props: Props) {
|
||||
const { pageType, projectId, storeType, workspaceSlug } = props;
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { filters, updateFilters, clearAllFilters } = usePageStore(storeType);
|
||||
const {
|
||||
workspace: { workspaceMemberIds },
|
||||
} = useMember();
|
||||
|
||||
const handleRemoveFilter = useCallback(
|
||||
(key: keyof TPageFilterProps, value: string | null) => {
|
||||
let newValues = filters.filters?.[key];
|
||||
|
||||
if (key === "favorites") newValues = !!value;
|
||||
if (Array.isArray(newValues)) {
|
||||
if (!value) newValues = [];
|
||||
else newValues = newValues.filter((val) => val !== value);
|
||||
}
|
||||
|
||||
updateFilters("filters", { [key]: newValues });
|
||||
},
|
||||
[filters.filters, updateFilters]
|
||||
);
|
||||
|
||||
const isFiltersApplied = calculateTotalFilters(filters?.filters ?? {}) !== 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header variant={EHeaderVariant.SECONDARY}>
|
||||
<Header.LeftItem>
|
||||
<PageTabNavigation workspaceSlug={workspaceSlug} projectId={projectId} pageType={pageType} />
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem className="items-center">
|
||||
<PageSearchInput
|
||||
searchQuery={filters.searchQuery}
|
||||
updateSearchQuery={(val) => updateFilters("searchQuery", val)}
|
||||
/>
|
||||
<PageOrderByDropdown
|
||||
sortBy={filters.sortBy}
|
||||
sortKey={filters.sortKey}
|
||||
onChange={(val) => {
|
||||
if (val.key) updateFilters("sortKey", val.key);
|
||||
if (val.order) updateFilters("sortBy", val.order);
|
||||
}}
|
||||
/>
|
||||
<FiltersDropdown
|
||||
icon={<FilterOutline className="h-3 w-3" />}
|
||||
title={t("common.filters")}
|
||||
placement="bottom-end"
|
||||
isFiltersApplied={isFiltersApplied}
|
||||
>
|
||||
<PageFiltersSelection
|
||||
filters={filters}
|
||||
handleFiltersUpdate={updateFilters}
|
||||
memberIds={workspaceMemberIds ?? undefined}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
{calculateTotalFilters(filters?.filters ?? {}) !== 0 && (
|
||||
<Header variant={EHeaderVariant.TERNARY}>
|
||||
<PageAppliedFiltersList
|
||||
appliedFilters={filters.filters ?? {}}
|
||||
handleClearAllFilters={clearAllFilters}
|
||||
handleRemoveFilter={handleRemoveFilter}
|
||||
alwaysAllowEditing
|
||||
/>
|
||||
</Header>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
65
apps/web/core/components/pages/header/syncing-badge.tsx
Normal file
65
apps/web/core/components/pages/header/syncing-badge.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { CloudOff, Dot } from "lucide-react";
|
||||
import { Tooltip } from "@makeplane/propel/components/tooltip";
|
||||
import { Badge } from "@plane/propel/badge";
|
||||
|
||||
type Props = {
|
||||
syncStatus: "syncing" | "synced" | "error";
|
||||
};
|
||||
|
||||
const BADGE_CONTENT = {
|
||||
syncing: {
|
||||
label: "Syncing...",
|
||||
tooltipLabel: "Syncing... Your changes are being synced with the server. You can continue making changes.",
|
||||
},
|
||||
error: {
|
||||
label: "Connection lost",
|
||||
tooltipLabel:
|
||||
"Connection lost. We're having trouble connecting to the websocket server. Your changes will be synced and saved every 10 seconds.",
|
||||
},
|
||||
};
|
||||
|
||||
export function PageSyncingBadge({ syncStatus }: Props) {
|
||||
const [prevSyncStatus, setPrevSyncStatus] = useState<"syncing" | "synced" | "error" | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(syncStatus !== "synced");
|
||||
|
||||
useEffect(() => {
|
||||
// Only handle transitions when there's a change
|
||||
if (prevSyncStatus !== syncStatus) {
|
||||
if (syncStatus === "synced") {
|
||||
// Delay hiding to allow exit animation to complete
|
||||
setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
}, 300); // match animation duration
|
||||
} else {
|
||||
setIsVisible(true);
|
||||
}
|
||||
setPrevSyncStatus(syncStatus);
|
||||
}
|
||||
}, [syncStatus, prevSyncStatus]);
|
||||
|
||||
if (!isVisible || syncStatus === "synced") return null;
|
||||
|
||||
// The synced early-return above guarantees this key exists
|
||||
const content = BADGE_CONTENT[syncStatus];
|
||||
|
||||
return (
|
||||
<Tooltip label={content.tooltipLabel} layout="stacked">
|
||||
<span className="animate-quickFadeIn">
|
||||
<Badge
|
||||
variant={syncStatus === "syncing" ? "brand" : "danger"}
|
||||
size="lg"
|
||||
prependIcon={syncStatus === "syncing" ? <Dot /> : <CloudOff />}
|
||||
>
|
||||
{content.label}
|
||||
</Badge>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
90
apps/web/core/components/pages/list/applied-filters/root.tsx
Normal file
90
apps/web/core/components/pages/list/applied-filters/root.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PillButton } from "@makeplane/propel/components/pill";
|
||||
import { CloseOutline } from "@makeplane/propel/icons";
|
||||
// plane imports
|
||||
import type { TPageFilterProps } from "@plane/types";
|
||||
import { replaceUnderscoreIfSnakeCase } from "@plane/utils";
|
||||
// components
|
||||
import { AppliedDateFilters } from "@/components/common/applied-filters/date";
|
||||
import { AppliedMembersFilters } from "@/components/common/applied-filters/members";
|
||||
|
||||
type Props = {
|
||||
appliedFilters: TPageFilterProps;
|
||||
handleClearAllFilters: () => void;
|
||||
handleRemoveFilter: (key: keyof TPageFilterProps, value: string | null) => void;
|
||||
alwaysAllowEditing?: boolean;
|
||||
};
|
||||
|
||||
const MEMBERS_FILTERS = ["created_by"];
|
||||
const DATE_FILTERS = ["created_at"];
|
||||
|
||||
export function PageAppliedFiltersList(props: Props) {
|
||||
const { appliedFilters, handleClearAllFilters, handleRemoveFilter, alwaysAllowEditing } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!appliedFilters) return null;
|
||||
if (Object.keys(appliedFilters).length === 0) return null;
|
||||
|
||||
const isEditingAllowed = alwaysAllowEditing;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-stretch gap-2">
|
||||
{Object.entries(appliedFilters).map(([key, value]) => {
|
||||
const filterKey = key as keyof TPageFilterProps;
|
||||
|
||||
if (!value) return;
|
||||
if (Array.isArray(value) && value.length === 0) return;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={filterKey}
|
||||
className="my-auto flex min-h-9 cursor-pointer flex-wrap items-center gap-1.5 rounded-md border border-subtle p-1.5 text-11 text-tertiary capitalize hover:text-secondary"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-11 text-tertiary">{replaceUnderscoreIfSnakeCase(filterKey)}</span>
|
||||
{DATE_FILTERS.includes(filterKey) && (
|
||||
<AppliedDateFilters
|
||||
editable={isEditingAllowed}
|
||||
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
|
||||
values={Array.isArray(value) ? value : []}
|
||||
/>
|
||||
)}
|
||||
{MEMBERS_FILTERS.includes(filterKey) && (
|
||||
<AppliedMembersFilters
|
||||
editable={isEditingAllowed}
|
||||
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
|
||||
values={Array.isArray(value) ? value : []}
|
||||
/>
|
||||
)}
|
||||
{isEditingAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center text-tertiary hover:text-secondary"
|
||||
onClick={() => handleRemoveFilter(filterKey, null)}
|
||||
>
|
||||
<CloseOutline height={12} width={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isEditingAllowed && (
|
||||
<PillButton
|
||||
type="button"
|
||||
size="md"
|
||||
variant="outline"
|
||||
label={t("common.clear_all")}
|
||||
endIcon={<CloseOutline height={12} />}
|
||||
onClick={handleClearAllFilters}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
apps/web/core/components/pages/list/block-item-action.tsx
Normal file
99
apps/web/core/components/pages/list/block-item-action.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { GlobeOutline, InfoOutline, LockOutline, MinusOutline } from "@makeplane/propel/icons";
|
||||
// plane imports
|
||||
import { Avatar } from "@makeplane/propel/components/avatar";
|
||||
import { Tooltip } from "@makeplane/propel/components/tooltip";
|
||||
import { FavoriteStar } from "@plane/ui";
|
||||
import { renderFormattedDate, getFileURL } from "@plane/utils";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { usePageOperations } from "@/hooks/use-page-operations";
|
||||
// plane web hooks
|
||||
import type { EPageStoreType } from "@/hooks/store";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
// local imports
|
||||
import { PageActions } from "../dropdowns";
|
||||
|
||||
type Props = {
|
||||
page: TPageInstance;
|
||||
parentRef: React.RefObject<HTMLElement | null>;
|
||||
storeType: EPageStoreType;
|
||||
};
|
||||
|
||||
export const BlockItemAction = observer(function BlockItemAction(props: Props) {
|
||||
const { page, parentRef, storeType } = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
// page operations
|
||||
const { pageOperations } = usePageOperations({
|
||||
page,
|
||||
});
|
||||
// derived values
|
||||
const { access, created_at, is_favorite, owned_by, canCurrentUserFavoritePage } = page;
|
||||
const ownerDetails = owned_by ? getUserDetails(owned_by) : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* page details */}
|
||||
<div className="cursor-default">
|
||||
<Tooltip label={`Owned by: ${ownerDetails?.display_name ?? ""}`} layout="stacked">
|
||||
<Avatar
|
||||
alt={ownerDetails?.display_name}
|
||||
fallback={ownerDetails?.display_name?.[0]?.toUpperCase()}
|
||||
src={getFileURL(ownerDetails?.avatar_url ?? "")}
|
||||
size="xs"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="cursor-default text-tertiary">
|
||||
<Tooltip label={access === 0 ? "Public" : "Private"}>
|
||||
{access === 0 ? <GlobeOutline className="h-4 w-4" /> : <LockOutline className="h-4 w-4" />}
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* vertical divider */}
|
||||
<MinusOutline className="-mx-3 h-5 w-5 rotate-90 text-placeholder" />
|
||||
|
||||
{/* page info */}
|
||||
<Tooltip label={`Created on ${renderFormattedDate(created_at)}`} layout="stacked">
|
||||
<span className="grid h-4 w-4 cursor-default place-items-center">
|
||||
<InfoOutline className="h-4 w-4 text-tertiary" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
{/* favorite/unfavorite */}
|
||||
{canCurrentUserFavoritePage && (
|
||||
<FavoriteStar
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
pageOperations.toggleFavorite();
|
||||
}}
|
||||
selected={is_favorite}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* quick actions dropdown */}
|
||||
<PageActions
|
||||
optionsOrder={[
|
||||
"open-in-new-tab",
|
||||
"copy-link",
|
||||
"make-a-copy",
|
||||
"toggle-lock",
|
||||
"toggle-access",
|
||||
"archive-restore",
|
||||
"delete",
|
||||
]}
|
||||
page={page}
|
||||
parentRef={parentRef}
|
||||
storeType={storeType}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
60
apps/web/core/components/pages/list/block.tsx
Normal file
60
apps/web/core/components/pages/list/block.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Logo } from "@plane/propel/emoji-icon-picker";
|
||||
import { PagesOutline } from "@makeplane/propel/icons";
|
||||
// plane imports
|
||||
import { getPageName } from "@plane/utils";
|
||||
// components
|
||||
import { ListItem } from "@/components/core/list";
|
||||
import { BlockItemAction } from "@/components/pages/list/block-item-action";
|
||||
// hooks
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web hooks
|
||||
import type { EPageStoreType } from "@/hooks/store";
|
||||
import { usePage } from "@/hooks/store";
|
||||
|
||||
type TPageListBlock = {
|
||||
pageId: string;
|
||||
storeType: EPageStoreType;
|
||||
};
|
||||
|
||||
export const PageListBlock = observer(function PageListBlock(props: TPageListBlock) {
|
||||
const { pageId, storeType } = props;
|
||||
// refs
|
||||
const parentRef = useRef(null);
|
||||
// hooks
|
||||
const page = usePage({
|
||||
pageId,
|
||||
storeType,
|
||||
});
|
||||
const { isMobile } = usePlatformOS();
|
||||
// handle page check
|
||||
if (!page) return null;
|
||||
// derived values
|
||||
const { name, logo_props, getRedirectionLink } = page;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
prependTitleElement={
|
||||
<>
|
||||
{logo_props?.in_use ? (
|
||||
<Logo logo={logo_props} size={16} type="lucide" />
|
||||
) : (
|
||||
<PagesOutline className="h-4 w-4 text-tertiary" />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
title={getPageName(name)}
|
||||
itemLink={getRedirectionLink()}
|
||||
actionableItems={<BlockItemAction page={page} parentRef={parentRef} storeType={storeType} />}
|
||||
isMobile={isMobile}
|
||||
parentRef={parentRef}
|
||||
/>
|
||||
);
|
||||
});
|
||||
7
apps/web/core/components/pages/list/filters/index.ts
Normal file
7
apps/web/core/components/pages/list/filters/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
114
apps/web/core/components/pages/list/filters/root.tsx
Normal file
114
apps/web/core/components/pages/list/filters/root.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseOutline, SearchOutline } from "@makeplane/propel/icons";
|
||||
import type { TPageFilterProps, TPageFilters } from "@plane/types";
|
||||
// components
|
||||
import { FilterCreatedDate } from "@/components/common/filters/created-at";
|
||||
import { FilterCreatedBy } from "@/components/common/filters/created-by";
|
||||
import { FilterOption } from "@/components/issues/issue-layouts/filters";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
type Props = {
|
||||
filters: TPageFilters;
|
||||
handleFiltersUpdate: <T extends keyof TPageFilters>(filterKey: T, filterValue: TPageFilters[T]) => void;
|
||||
memberIds?: string[] | undefined;
|
||||
};
|
||||
|
||||
export const PageFiltersSelection = observer(function PageFiltersSelection(props: Props) {
|
||||
const { filters, handleFiltersUpdate, memberIds } = props;
|
||||
// states
|
||||
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
const handleFilters = (key: keyof TPageFilterProps, value: boolean | string | string[]) => {
|
||||
const newValues = filters.filters?.[key] ?? [];
|
||||
|
||||
if (typeof newValues === "boolean" && typeof value === "boolean") return;
|
||||
|
||||
if (Array.isArray(newValues)) {
|
||||
if (Array.isArray(value))
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
else if (typeof value === "string") {
|
||||
if (newValues?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
handleFiltersUpdate("filters", {
|
||||
...filters.filters,
|
||||
[key]: newValues,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="bg-layer-transparent p-2.5 pb-0">
|
||||
<div className="flex items-center gap-1.5 rounded-sm border-[0.5px] border-subtle bg-surface-2 px-1.5 py-1 text-11">
|
||||
<SearchOutline className="text-placeholder" width={12} height={12} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="w-full outline-none placeholder:text-placeholder"
|
||||
placeholder="Search"
|
||||
value={filtersSearchQuery}
|
||||
onChange={(e) => setFiltersSearchQuery(e.target.value)}
|
||||
/>
|
||||
{filtersSearchQuery !== "" && (
|
||||
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
|
||||
<CloseOutline className="text-tertiary" height={12} width={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="vertical-scrollbar scrollbar-sm h-full w-full divide-y divide-subtle-1 overflow-y-auto px-2.5">
|
||||
<div className="py-2">
|
||||
<FilterOption
|
||||
isChecked={!!filters.filters?.favorites}
|
||||
onClick={() =>
|
||||
handleFiltersUpdate("filters", {
|
||||
...filters.filters,
|
||||
favorites: !filters.filters?.favorites,
|
||||
})
|
||||
}
|
||||
title="Favorites"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* created date */}
|
||||
<div className="py-2">
|
||||
<FilterCreatedDate
|
||||
appliedFilters={filters.filters?.created_at ?? null}
|
||||
handleUpdate={(val) => handleFilters("created_at", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* created by */}
|
||||
<div className="py-2">
|
||||
<FilterCreatedBy
|
||||
appliedFilters={filters.filters?.created_by ?? null}
|
||||
handleUpdate={(val) => handleFilters("created_by", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
memberIds={memberIds}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
7
apps/web/core/components/pages/list/index.ts
Normal file
7
apps/web/core/components/pages/list/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
88
apps/web/core/components/pages/list/order-by.tsx
Normal file
88
apps/web/core/components/pages/list/order-by.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { SortAscendingOutline, SortDescendingOutline, TickOutline } from "@makeplane/propel/icons";
|
||||
// plane imports
|
||||
import { getButtonStyling } from "@plane/propel/button";
|
||||
// types
|
||||
import type { TPageFiltersSortBy, TPageFiltersSortKey } from "@plane/types";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
onChange: (value: { key?: TPageFiltersSortKey; order?: TPageFiltersSortBy }) => void;
|
||||
sortBy: TPageFiltersSortBy;
|
||||
sortKey: TPageFiltersSortKey;
|
||||
};
|
||||
|
||||
const PAGE_SORTING_KEY_OPTIONS: {
|
||||
key: TPageFiltersSortKey;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ key: "name", label: "Name" },
|
||||
{ key: "created_at", label: "Date created" },
|
||||
{ key: "updated_at", label: "Date modified" },
|
||||
];
|
||||
|
||||
export function PageOrderByDropdown(props: Props) {
|
||||
const { onChange, sortBy, sortKey } = props;
|
||||
|
||||
const orderByDetails = PAGE_SORTING_KEY_OPTIONS.find((option) => sortKey === option.key);
|
||||
const isDescending = sortBy === "desc";
|
||||
|
||||
return (
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<div className={getButtonStyling("secondary", "lg")}>
|
||||
{!isDescending ? <SortAscendingOutline className="size-3" /> : <SortDescendingOutline className="size-3" />}
|
||||
{orderByDetails?.label}
|
||||
</div>
|
||||
}
|
||||
placement="bottom-end"
|
||||
maxHeight="lg"
|
||||
closeOnSelect
|
||||
>
|
||||
{PAGE_SORTING_KEY_OPTIONS.map((option) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={option.key}
|
||||
className="flex items-center justify-between gap-2"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
key: option.key,
|
||||
})
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
{sortKey === option.key && <TickOutline className="h-3 w-3" />}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
<hr className="my-2 border-subtle" />
|
||||
<CustomMenu.MenuItem
|
||||
className="flex items-center justify-between gap-2"
|
||||
onClick={() => {
|
||||
if (isDescending)
|
||||
onChange({
|
||||
order: "asc",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Ascending
|
||||
{!isDescending && <TickOutline className="h-3 w-3" />}
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
className="flex items-center justify-between gap-2"
|
||||
onClick={() => {
|
||||
if (!isDescending)
|
||||
onChange({
|
||||
order: "desc",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Descending
|
||||
{isDescending && <TickOutline className="h-3 w-3" />}
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
);
|
||||
}
|
||||
38
apps/web/core/components/pages/list/root.tsx
Normal file
38
apps/web/core/components/pages/list/root.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import type { TPageNavigationTabs } from "@plane/types";
|
||||
// components
|
||||
import { ListLayout } from "@/components/core/list";
|
||||
// plane web hooks
|
||||
import type { EPageStoreType } from "@/hooks/store";
|
||||
import { usePageStore } from "@/hooks/store";
|
||||
// local imports
|
||||
import { PageListBlock } from "./block";
|
||||
|
||||
type TPagesListRoot = {
|
||||
pageType: TPageNavigationTabs;
|
||||
storeType: EPageStoreType;
|
||||
};
|
||||
|
||||
export const PagesListRoot = observer(function PagesListRoot(props: TPagesListRoot) {
|
||||
const { pageType, storeType } = props;
|
||||
// store hooks
|
||||
const { getCurrentProjectFilteredPageIdsByTab } = usePageStore(storeType);
|
||||
// derived values
|
||||
const filteredPageIds = getCurrentProjectFilteredPageIdsByTab(pageType);
|
||||
|
||||
if (!filteredPageIds) return <></>;
|
||||
return (
|
||||
<ListLayout>
|
||||
{filteredPageIds.map((pageId) => (
|
||||
<PageListBlock key={pageId} pageId={pageId} storeType={storeType} />
|
||||
))}
|
||||
</ListLayout>
|
||||
);
|
||||
});
|
||||
87
apps/web/core/components/pages/list/search-input.tsx
Normal file
87
apps/web/core/components/pages/list/search-input.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
// plane imports
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { IconButton } from "@plane/propel/icon-button";
|
||||
import { CloseOutline, SearchOutline } from "@makeplane/propel/icons";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
searchQuery: string;
|
||||
updateSearchQuery: (val: string) => void;
|
||||
};
|
||||
|
||||
export function PageSearchInput(props: Props) {
|
||||
const { searchQuery, updateSearchQuery } = props;
|
||||
// states
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
// refs
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 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);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
{!isSearchOpen && (
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
className="my-auto -mr-1 shrink-0"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(true);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
icon={SearchOutline}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-0 items-center justify-start overflow-hidden rounded-md border border-transparent text-placeholder opacity-0 transition-[width] ease-linear",
|
||||
{
|
||||
"w-64 border-subtle px-2.5 py-1.5 opacity-100": isSearchOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<SearchOutline className="h-3.5 w-3.5" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="ml-2 w-full max-w-[234px] border-none bg-transparent text-13 text-primary placeholder:text-placeholder focus:outline-none"
|
||||
placeholder="Search pages"
|
||||
value={searchQuery}
|
||||
onChange={(e) => updateSearchQuery(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
{isSearchOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center"
|
||||
onClick={() => {
|
||||
updateSearchQuery("");
|
||||
setIsSearchOpen(false);
|
||||
}}
|
||||
>
|
||||
<CloseOutline className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
apps/web/core/components/pages/list/tab-navigation.tsx
Normal file
67
apps/web/core/components/pages/list/tab-navigation.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
// types
|
||||
import type { TPageNavigationTabs } from "@plane/types";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type TPageTabNavigation = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
pageType: TPageNavigationTabs;
|
||||
};
|
||||
|
||||
// pages tab options
|
||||
const pageTabs: { key: TPageNavigationTabs; label: string }[] = [
|
||||
{
|
||||
key: "public",
|
||||
label: "Public",
|
||||
},
|
||||
{
|
||||
key: "private",
|
||||
label: "Private",
|
||||
},
|
||||
{
|
||||
key: "archived",
|
||||
label: "Archived",
|
||||
},
|
||||
];
|
||||
|
||||
export function PageTabNavigation(props: TPageTabNavigation) {
|
||||
const { workspaceSlug, projectId, pageType } = props;
|
||||
|
||||
const handleTabClick = (e: React.MouseEvent<HTMLAnchorElement>, tabKey: TPageNavigationTabs) => {
|
||||
if (tabKey === pageType) e.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full items-center">
|
||||
{pageTabs.map((tab) => (
|
||||
<Link
|
||||
key={tab.key}
|
||||
href={`/${workspaceSlug}/projects/${projectId}/pages?type=${tab.key}`}
|
||||
onClick={(e) => handleTabClick(e, tab.key)}
|
||||
className="flex h-full flex-col"
|
||||
>
|
||||
<div
|
||||
className={cn(`flex flex-1 items-center justify-center px-4 text-13 font-medium transition-all`, {
|
||||
"text-accent-primary": tab.key === pageType,
|
||||
})}
|
||||
>
|
||||
{tab.label}
|
||||
</div>
|
||||
<div
|
||||
className={cn(`w-full rounded-t border-t-2 border-transparent transition-all`, {
|
||||
"border-accent-strong": tab.key === pageType,
|
||||
})}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// plane imports
|
||||
import { Loader } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PageContentLoader(props: Props) {
|
||||
const { className } = props;
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex size-full flex-col", className)}>
|
||||
{/* header */}
|
||||
<div className="relative flex h-12 w-full flex-shrink-0 items-center divide-x divide-subtle border-b border-subtle">
|
||||
<Loader className="relative flex items-center gap-1 pr-2">
|
||||
<Loader.Item width="26px" height="26px" />
|
||||
<Loader.Item width="26px" height="26px" />
|
||||
<Loader.Item width="26px" height="26px" />
|
||||
<Loader.Item width="26px" height="26px" />
|
||||
<Loader.Item width="26px" height="26px" />
|
||||
<Loader.Item width="26px" height="26px" />
|
||||
<Loader.Item width="26px" height="26px" />
|
||||
</Loader>
|
||||
<Loader className="relative flex items-center gap-1 px-2">
|
||||
<Loader.Item width="26px" height="26px" />
|
||||
<Loader.Item width="26px" height="26px" />
|
||||
</Loader>
|
||||
<Loader className="relative flex items-center gap-1 px-2">
|
||||
<Loader.Item width="26px" height="26px" />
|
||||
<Loader.Item width="26px" height="26px" />
|
||||
</Loader>
|
||||
<Loader className="relative flex items-center gap-1 pl-2">
|
||||
<Loader.Item width="26px" height="26px" />
|
||||
<Loader.Item width="26px" height="26px" />
|
||||
</Loader>
|
||||
</div>
|
||||
|
||||
{/* content */}
|
||||
<div className="relative flex size-full overflow-hidden pt-[64px]">
|
||||
{/* editor loader */}
|
||||
<div className="size-full py-5">
|
||||
<Loader className="relative space-y-4">
|
||||
<Loader.Item width="50%" height="36px" />
|
||||
<div className="space-y-2">
|
||||
<div className="py-2">
|
||||
<Loader.Item width="100%" height="36px" />
|
||||
</div>
|
||||
<Loader.Item width="80%" height="22px" />
|
||||
<div className="relative flex items-center gap-2">
|
||||
<Loader.Item width="30px" height="30px" />
|
||||
<Loader.Item width="30%" height="22px" />
|
||||
</div>
|
||||
<div className="py-2">
|
||||
<Loader.Item width="60%" height="36px" />
|
||||
</div>
|
||||
<Loader.Item width="70%" height="22px" />
|
||||
<Loader.Item width="30%" height="22px" />
|
||||
<div className="relative flex items-center gap-2">
|
||||
<Loader.Item width="30px" height="30px" />
|
||||
<Loader.Item width="30%" height="22px" />
|
||||
</div>
|
||||
<div className="py-2">
|
||||
<Loader.Item width="50%" height="30px" />
|
||||
</div>
|
||||
<Loader.Item width="100%" height="22px" />
|
||||
<div className="py-2">
|
||||
<Loader.Item width="30%" height="30px" />
|
||||
</div>
|
||||
<Loader.Item width="30%" height="22px" />
|
||||
<div className="relative flex items-center gap-2">
|
||||
<div className="py-2">
|
||||
<Loader.Item width="30px" height="30px" />
|
||||
</div>
|
||||
<Loader.Item width="30%" height="22px" />
|
||||
</div>
|
||||
</div>
|
||||
</Loader>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
apps/web/core/components/pages/loaders/page-loader.tsx
Normal file
37
apps/web/core/components/pages/loaders/page-loader.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { range } from "lodash-es";
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export function PageLoader() {
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col">
|
||||
<div className="border-b border-subtle px-3 py-3">
|
||||
<Loader className="relative flex items-center gap-2">
|
||||
<Loader.Item width="200px" height="30px" />
|
||||
<div className="relative ml-auto flex items-center gap-2">
|
||||
<Loader.Item width="100px" height="30px" />
|
||||
<Loader.Item width="100px" height="30px" />
|
||||
</div>
|
||||
</Loader>
|
||||
</div>
|
||||
<div>
|
||||
{range(10).map((i) => (
|
||||
<Loader key={i} className="relative flex items-center gap-2 border-b border-subtle p-3 py-4">
|
||||
<Loader.Item width={`${250 + 10 * Math.floor(Math.random() * 10)}px`} height="22px" />
|
||||
<div className="relative ml-auto flex items-center gap-2">
|
||||
<Loader.Item width="60px" height="22px" />
|
||||
<Loader.Item width="22px" height="22px" />
|
||||
<Loader.Item width="22px" height="22px" />
|
||||
<Loader.Item width="22px" height="22px" />
|
||||
</div>
|
||||
</Loader>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
apps/web/core/components/pages/modals/create-page-modal.tsx
Normal file
93
apps/web/core/components/pages/modals/create-page-modal.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
// constants
|
||||
import type { EPageAccess } from "@plane/constants";
|
||||
import type { TPage } from "@plane/types";
|
||||
// ui
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
// hooks
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web hooks
|
||||
import type { EPageStoreType } from "@/hooks/store";
|
||||
import { usePageStore } from "@/hooks/store";
|
||||
// local imports
|
||||
import { PageForm } from "./page-form";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
isModalOpen: boolean;
|
||||
pageAccess?: EPageAccess;
|
||||
handleModalClose: () => void;
|
||||
redirectionEnabled?: boolean;
|
||||
storeType: EPageStoreType;
|
||||
};
|
||||
|
||||
export function CreatePageModal(props: Props) {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
isModalOpen,
|
||||
pageAccess,
|
||||
handleModalClose,
|
||||
redirectionEnabled = false,
|
||||
storeType,
|
||||
} = props;
|
||||
// states
|
||||
const [pageFormData, setPageFormData] = useState<Partial<TPage>>({
|
||||
id: undefined,
|
||||
name: "",
|
||||
logo_props: undefined,
|
||||
});
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { createPage } = usePageStore(storeType);
|
||||
const handlePageFormData = <T extends keyof TPage>(key: T, value: TPage[T]) =>
|
||||
setPageFormData((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
// update page access in form data when page access from the store changes
|
||||
useEffect(() => {
|
||||
setPageFormData((prev) => ({ ...prev, access: pageAccess }));
|
||||
}, [pageAccess]);
|
||||
|
||||
const handleStateClear = () => {
|
||||
setPageFormData({ id: undefined, name: "", access: pageAccess });
|
||||
handleModalClose();
|
||||
};
|
||||
|
||||
const handleFormSubmit = async () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
try {
|
||||
const pageData = await createPage(pageFormData);
|
||||
if (pageData) {
|
||||
handleStateClear();
|
||||
if (redirectionEnabled) router.push(`/${workspaceSlug}/projects/${projectId}/pages/${pageData.id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalCore
|
||||
isOpen={isModalOpen}
|
||||
handleClose={handleModalClose}
|
||||
position={EModalPosition.TOP}
|
||||
width={EModalWidth.XXL}
|
||||
>
|
||||
<PageForm
|
||||
formData={pageFormData}
|
||||
handleFormData={handlePageFormData}
|
||||
handleModalClose={handleStateClear}
|
||||
handleFormSubmit={handleFormSubmit}
|
||||
/>
|
||||
</ModalCore>
|
||||
);
|
||||
}
|
||||
92
apps/web/core/components/pages/modals/delete-page-modal.tsx
Normal file
92
apps/web/core/components/pages/modals/delete-page-modal.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { useParams } from "next/navigation";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { AlertModalCore } from "@plane/ui";
|
||||
import { getPageName } from "@plane/utils";
|
||||
// constants
|
||||
// plane web hooks
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import type { EPageStoreType } from "@/hooks/store";
|
||||
import { usePageStore } from "@/hooks/store";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type TConfirmPageDeletionProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
page: TPageInstance;
|
||||
storeType: EPageStoreType;
|
||||
};
|
||||
|
||||
export const DeletePageModal = observer(function DeletePageModal(props: TConfirmPageDeletionProps) {
|
||||
const { isOpen, onClose, page, storeType } = props;
|
||||
// states
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
// store hooks
|
||||
const { removePage } = usePageStore(storeType);
|
||||
|
||||
// derived values
|
||||
const { id: pageId, name } = page;
|
||||
|
||||
const handleClose = () => {
|
||||
setIsDeleting(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const router = useAppRouter();
|
||||
const { pageId: routePageId } = useParams();
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!pageId) return;
|
||||
setIsDeleting(true);
|
||||
await removePage({ pageId })
|
||||
.then(() => {
|
||||
handleClose();
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page deleted successfully.",
|
||||
});
|
||||
|
||||
if (routePageId) {
|
||||
router.back();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Page could not be deleted. Please try again.",
|
||||
});
|
||||
});
|
||||
|
||||
setIsDeleting(false);
|
||||
};
|
||||
|
||||
if (!page || !page.id) return null;
|
||||
|
||||
return (
|
||||
<AlertModalCore
|
||||
handleClose={handleClose}
|
||||
handleSubmit={handleDelete}
|
||||
isSubmitting={isDeleting}
|
||||
isOpen={isOpen}
|
||||
title="Delete page"
|
||||
content={
|
||||
<>
|
||||
Are you sure you want to delete page-{" "}
|
||||
<span className="font-medium break-words break-all text-primary">{getPageName(name)}</span> ? The Page will be
|
||||
deleted permanently. This action cannot be undone.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
295
apps/web/core/components/pages/modals/export-page-modal.tsx
Normal file
295
apps/web/core/components/pages/modals/export-page-modal.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import type { PageProps } from "@react-pdf/renderer";
|
||||
import { pdf } from "@react-pdf/renderer";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useParams } from "react-router";
|
||||
// plane editor
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
// plane ui
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { CustomSelect, EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
// components
|
||||
import { PDFDocument } from "@/components/editor/pdf";
|
||||
// hooks
|
||||
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
|
||||
|
||||
type Props = {
|
||||
editorRef: EditorRefApi | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
pageTitle: string;
|
||||
};
|
||||
|
||||
type TExportFormats = "pdf" | "markdown";
|
||||
type TPageFormats = Exclude<PageProps["size"], undefined>;
|
||||
type TContentVariety = "everything" | "no-assets";
|
||||
|
||||
type TFormValues = {
|
||||
export_format: TExportFormats;
|
||||
page_format: TPageFormats;
|
||||
content_variety: TContentVariety;
|
||||
};
|
||||
|
||||
const EXPORT_FORMATS: {
|
||||
key: TExportFormats;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
key: "pdf",
|
||||
label: "PDF",
|
||||
},
|
||||
{
|
||||
key: "markdown",
|
||||
label: "Markdown",
|
||||
},
|
||||
];
|
||||
|
||||
const PAGE_FORMATS: {
|
||||
key: TPageFormats;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
key: "A4",
|
||||
label: "A4",
|
||||
},
|
||||
{
|
||||
key: "A3",
|
||||
label: "A3",
|
||||
},
|
||||
{
|
||||
key: "A2",
|
||||
label: "A2",
|
||||
},
|
||||
{
|
||||
key: "LETTER",
|
||||
label: "Letter",
|
||||
},
|
||||
{
|
||||
key: "LEGAL",
|
||||
label: "Legal",
|
||||
},
|
||||
{
|
||||
key: "TABLOID",
|
||||
label: "Tabloid",
|
||||
},
|
||||
];
|
||||
|
||||
const CONTENT_VARIETY: {
|
||||
key: TContentVariety;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
key: "everything",
|
||||
label: "Everything",
|
||||
},
|
||||
{
|
||||
key: "no-assets",
|
||||
label: "No images",
|
||||
},
|
||||
];
|
||||
|
||||
const defaultValues: TFormValues = {
|
||||
export_format: "pdf",
|
||||
page_format: "A4",
|
||||
content_variety: "everything",
|
||||
};
|
||||
|
||||
export function ExportPageModal(props: Props) {
|
||||
const { editorRef, isOpen, onClose, pageTitle } = props;
|
||||
// states
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
// params
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// form info
|
||||
const { control, reset, watch } = useForm<TFormValues>({
|
||||
defaultValues,
|
||||
});
|
||||
// parse editor content
|
||||
const { replaceCustomComponentsFromHTMLContent, replaceCustomComponentsFromMarkdownContent } = useParseEditorContent({
|
||||
projectId,
|
||||
workspaceSlug: workspaceSlug ?? "",
|
||||
});
|
||||
// derived values
|
||||
const selectedExportFormat = watch("export_format");
|
||||
const selectedPageFormat = watch("page_format");
|
||||
const selectedContentVariety = watch("content_variety");
|
||||
const isPDFSelected = selectedExportFormat === "pdf";
|
||||
const fileName = pageTitle
|
||||
?.toLowerCase()
|
||||
?.replace(/[^a-z0-9-_]/g, "-")
|
||||
.replace(/-+/g, "-");
|
||||
// handle modal close
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setTimeout(() => {
|
||||
reset();
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const initiateDownload = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// handle export as a PDF
|
||||
const handleExportAsPDF = async () => {
|
||||
try {
|
||||
const pageContent = `<h1 class="page-title">${pageTitle}</h1>${editorRef?.getDocument().html ?? "<p></p>"}`;
|
||||
const parsedPageContent = await replaceCustomComponentsFromHTMLContent({
|
||||
htmlContent: pageContent,
|
||||
noAssets: selectedContentVariety === "no-assets",
|
||||
});
|
||||
|
||||
const blob = await pdf(<PDFDocument content={parsedPageContent} pageFormat={selectedPageFormat} />).toBlob();
|
||||
initiateDownload(blob, `${fileName}-${selectedPageFormat.toString().toLowerCase()}.pdf`);
|
||||
} catch (error) {
|
||||
throw new Error(`Error in exporting as a PDF: ${error}`);
|
||||
}
|
||||
};
|
||||
// handle export as markdown
|
||||
const handleExportAsMarkdown = async () => {
|
||||
try {
|
||||
const markdownContent = editorRef?.getMarkDown() ?? "";
|
||||
const parsedMarkdownContent = replaceCustomComponentsFromMarkdownContent({
|
||||
markdownContent,
|
||||
noAssets: selectedContentVariety === "no-assets",
|
||||
});
|
||||
|
||||
const blob = new Blob([parsedMarkdownContent], { type: "text/markdown" });
|
||||
initiateDownload(blob, `${fileName}.md`);
|
||||
} catch (error) {
|
||||
throw new Error(`Error in exporting as markdown: ${error}`);
|
||||
}
|
||||
};
|
||||
// handle export
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
if (selectedExportFormat === "pdf") {
|
||||
await handleExportAsPDF();
|
||||
}
|
||||
if (selectedExportFormat === "markdown") {
|
||||
await handleExportAsMarkdown();
|
||||
}
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page exported successfully.",
|
||||
});
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error("Error in exporting page:", error);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Page could not be exported. Please try again later.",
|
||||
});
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.SM}>
|
||||
<div>
|
||||
<div className="space-y-5 p-5">
|
||||
<h3 className="text-18 font-medium text-secondary">Export page</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h6 className="flex-shrink-0 text-13 text-secondary">Export format</h6>
|
||||
<Controller
|
||||
control={control}
|
||||
name="export_format"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<CustomSelect
|
||||
label={EXPORT_FORMATS.find((format) => format.key === value)?.label}
|
||||
buttonClassName="border-none"
|
||||
value={value}
|
||||
onChange={(val: TExportFormats) => onChange(val)}
|
||||
className="flex-shrink-0"
|
||||
placement="bottom-end"
|
||||
>
|
||||
{EXPORT_FORMATS.map((format) => (
|
||||
<CustomSelect.Option key={format.key} value={format.key}>
|
||||
{format.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h6 className="flex-shrink-0 text-13 text-secondary">Include content</h6>
|
||||
<Controller
|
||||
control={control}
|
||||
name="content_variety"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<CustomSelect
|
||||
label={CONTENT_VARIETY.find((variety) => variety.key === value)?.label}
|
||||
buttonClassName="border-none"
|
||||
value={value}
|
||||
onChange={(val: TContentVariety) => onChange(val)}
|
||||
className="flex-shrink-0"
|
||||
placement="bottom-end"
|
||||
>
|
||||
{CONTENT_VARIETY.map((variety) => (
|
||||
<CustomSelect.Option key={variety.key} value={variety.key}>
|
||||
{variety.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{isPDFSelected && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h6 className="flex-shrink-0 text-13 text-secondary">Page format</h6>
|
||||
<Controller
|
||||
control={control}
|
||||
name="page_format"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<CustomSelect
|
||||
label={PAGE_FORMATS.find((format) => format.key === value)?.label}
|
||||
buttonClassName="border-none"
|
||||
value={value}
|
||||
onChange={(val: TPageFormats) => onChange(val)}
|
||||
className="flex-shrink-0"
|
||||
placement="bottom-end"
|
||||
>
|
||||
{PAGE_FORMATS.map((format) => (
|
||||
<CustomSelect.Option key={format.key.toString()} value={format.key}>
|
||||
{format.label}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 border-t-[0.5px] border-subtle px-5 py-4">
|
||||
<Button variant="secondary" size="lg" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="lg" loading={isExporting} onClick={handleExport}>
|
||||
{isExporting ? "Exporting" : "Export"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
);
|
||||
}
|
||||
167
apps/web/core/components/pages/modals/page-form.tsx
Normal file
167
apps/web/core/components/pages/modals/page-form.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { FormEvent } from "react";
|
||||
import { useState } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
// plane imports
|
||||
import { Input, InputGroup } from "@makeplane/propel/components/input";
|
||||
import { ETabIndices, EPageAccess } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { EmojiPicker, EmojiIconPickerTypes, Logo } from "@plane/propel/emoji-icon-picker";
|
||||
import { GlobeOutline, LockOutline, PagesOutline } from "@makeplane/propel/icons";
|
||||
import type { ISvgIcons } from "@plane/propel/icons";
|
||||
import type { TPage } from "@plane/types";
|
||||
|
||||
import { getTabIndex } from "@plane/utils";
|
||||
// components
|
||||
import { AccessField } from "@/components/common/access-field";
|
||||
// hooks
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
type Props = {
|
||||
formData: Partial<TPage>;
|
||||
handleFormData: <T extends keyof TPage>(key: T, value: TPage[T]) => void;
|
||||
handleModalClose: () => void;
|
||||
handleFormSubmit: () => Promise<void>;
|
||||
};
|
||||
|
||||
const PAGE_ACCESS_SPECIFIERS: {
|
||||
key: EPageAccess;
|
||||
i18n_label: string;
|
||||
icon: LucideIcon | React.FC<ISvgIcons>;
|
||||
}[] = [
|
||||
{ key: EPageAccess.PUBLIC, i18n_label: "common.access.public", icon: GlobeOutline },
|
||||
{ key: EPageAccess.PRIVATE, i18n_label: "common.access.private", icon: LockOutline },
|
||||
];
|
||||
|
||||
export function PageForm(props: Props) {
|
||||
const { formData, handleFormData, handleModalClose, handleFormSubmit } = props;
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { t } = useTranslation();
|
||||
// state
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const i18n_access_label = PAGE_ACCESS_SPECIFIERS.find((access) => access.key === formData.access)?.i18n_label;
|
||||
|
||||
const { getIndex } = getTabIndex(ETabIndices.PROJECT_PAGE, isMobile);
|
||||
|
||||
const handlePageFormSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await handleFormSubmit();
|
||||
setIsSubmitting(false);
|
||||
} catch {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isTitleLengthMoreThan255Character = formData.name ? formData.name.length > 255 : false;
|
||||
|
||||
return (
|
||||
<form onSubmit={handlePageFormSubmit}>
|
||||
<div className="space-y-5 p-5">
|
||||
<h3 className="text-18 font-medium text-secondary">Create page</h3>
|
||||
<div className="flex h-9 w-full items-start gap-2">
|
||||
<EmojiPicker
|
||||
isOpen={isOpen}
|
||||
handleToggle={(val: boolean) => setIsOpen(val)}
|
||||
className="flex-shrink0 flex items-center justify-center"
|
||||
buttonClassName="flex items-center justify-center bg-layer-2 hover:bg-layer-2-hover rounded-md"
|
||||
label={
|
||||
<span className="grid h-9 w-9 place-items-center rounded-md">
|
||||
<>
|
||||
{formData?.logo_props?.in_use ? (
|
||||
<Logo logo={formData?.logo_props} size={18} type="lucide" />
|
||||
) : (
|
||||
<PagesOutline className="h-4 w-4 text-tertiary" />
|
||||
)}
|
||||
</>
|
||||
</span>
|
||||
}
|
||||
onChange={(val: any) => {
|
||||
let logoValue = {};
|
||||
|
||||
if (val?.type === "emoji")
|
||||
logoValue = {
|
||||
value: val.value,
|
||||
url: undefined,
|
||||
};
|
||||
else if (val?.type === "icon") logoValue = val.value;
|
||||
|
||||
handleFormData("logo_props", {
|
||||
in_use: val?.type,
|
||||
[val?.type]: logoValue,
|
||||
});
|
||||
setIsOpen(false);
|
||||
}}
|
||||
defaultIconColor={
|
||||
formData?.logo_props?.in_use && formData?.logo_props?.in_use === "icon"
|
||||
? formData?.logo_props?.icon?.color
|
||||
: undefined
|
||||
}
|
||||
defaultOpen={
|
||||
formData?.logo_props?.in_use && formData?.logo_props?.in_use === "emoji"
|
||||
? EmojiIconPickerTypes.EMOJI
|
||||
: EmojiIconPickerTypes.ICON
|
||||
}
|
||||
/>
|
||||
<div className="flew-grow w-full space-y-1">
|
||||
<InputGroup size="2xl">
|
||||
<Input
|
||||
size="2xl"
|
||||
id="name"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleFormData("name", e.target.value)}
|
||||
placeholder="Title"
|
||||
tabIndex={getIndex("name")}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</InputGroup>
|
||||
{isTitleLengthMoreThan255Character && (
|
||||
<span className="text-11 text-danger-primary">
|
||||
Max length of the name should be less than 255 characters
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 border-t-[0.5px] border-subtle px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AccessField
|
||||
onChange={(access) => handleFormData("access", access)}
|
||||
value={formData?.access ?? EPageAccess.PUBLIC}
|
||||
accessSpecifiers={PAGE_ACCESS_SPECIFIERS}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
<h6 className="text-11 font-medium">{t(i18n_access_label || "")}</h6>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="secondary" size="lg" onClick={handleModalClose} tabIndex={getIndex("cancel")}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
type="submit"
|
||||
loading={isSubmitting}
|
||||
disabled={isTitleLengthMoreThan255Character}
|
||||
tabIndex={getIndex("submit")}
|
||||
>
|
||||
{isSubmitting ? "Creating" : "Create Page"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
18
apps/web/core/components/pages/navigation-pane/index.ts
Normal file
18
apps/web/core/components/pages/navigation-pane/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// plane web imports
|
||||
import { ORDERED_PAGE_NAVIGATION_TABS_LIST } from "@/components/pages/navigation-pane/tab-panels";
|
||||
|
||||
export * from "./root";
|
||||
export * from "./types";
|
||||
|
||||
export const PAGE_NAVIGATION_PANE_WIDTH = 294;
|
||||
|
||||
export const PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM = "paneTab";
|
||||
export const PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM = "version";
|
||||
|
||||
export const PAGE_NAVIGATION_PANE_TAB_KEYS = ORDERED_PAGE_NAVIGATION_TABS_LIST.map((tab) => tab.key);
|
||||
127
apps/web/core/components/pages/navigation-pane/root.tsx
Normal file
127
apps/web/core/components/pages/navigation-pane/root.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ArrowRightCircle } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Tabs } from "@makeplane/propel/components/tabs";
|
||||
import { Tooltip } from "@makeplane/propel/components/tooltip";
|
||||
// hooks
|
||||
import { useQueryParams } from "@/hooks/use-query-params";
|
||||
// plane web components
|
||||
import type { TPageNavigationPaneTab } from "@/components/pages/navigation-pane/tab-panels";
|
||||
// store
|
||||
import type { EPageStoreType } from "@/hooks/store";
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
// local imports
|
||||
import type { TPageRootHandlers } from "../editor/page-root";
|
||||
import { PageNavigationPaneTabPanelsRoot } from "./tab-panels/root";
|
||||
import { PageNavigationPaneTabsList } from "./tabs-list";
|
||||
import type { INavigationPaneExtension } from "./types/extensions";
|
||||
|
||||
import {
|
||||
PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM,
|
||||
PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM,
|
||||
PAGE_NAVIGATION_PANE_WIDTH,
|
||||
} from "./index";
|
||||
|
||||
type Props = {
|
||||
handleClose: () => void;
|
||||
isNavigationPaneOpen: boolean;
|
||||
page: TPageInstance;
|
||||
versionHistory: Pick<TPageRootHandlers, "fetchAllVersions" | "fetchVersionDetails">;
|
||||
// Generic extension system for additional navigation pane content
|
||||
extensions?: INavigationPaneExtension[];
|
||||
storeType: EPageStoreType;
|
||||
};
|
||||
|
||||
export const PageNavigationPaneRoot = observer(function PageNavigationPaneRoot(props: Props) {
|
||||
const { handleClose, isNavigationPaneOpen, page, versionHistory, extensions = [], storeType } = props;
|
||||
|
||||
// navigation
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
// query params
|
||||
const { updateQueryParams } = useQueryParams();
|
||||
// derived values
|
||||
const navigationPaneQueryParam = searchParams.get(
|
||||
PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM
|
||||
) as TPageNavigationPaneTab | null;
|
||||
const activeTab: TPageNavigationPaneTab = navigationPaneQueryParam || "outline";
|
||||
|
||||
// Check if any extension is currently active based on query parameters
|
||||
const ActiveExtension = extensions.find((extension) => {
|
||||
const paneTabValue = searchParams.get(PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM);
|
||||
const hasVersionParam = searchParams.get(PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM);
|
||||
|
||||
// Extension is active ONLY when paneTab matches AND no regular navigation params are present
|
||||
return paneTabValue === extension.triggerParam && !hasVersionParam;
|
||||
});
|
||||
|
||||
// Don't show tabs when an extension is active
|
||||
const showNavigationTabs = !ActiveExtension && isNavigationPaneOpen;
|
||||
|
||||
// Use extension width if available, otherwise fall back to default
|
||||
const paneWidth = ActiveExtension?.width ?? PAGE_NAVIGATION_PANE_WIDTH;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(value: string) => {
|
||||
const updatedTab = value as TPageNavigationPaneTab;
|
||||
const isUpdatedTabInfo = updatedTab === "info";
|
||||
const updatedRoute = updateQueryParams({
|
||||
paramsToAdd: { [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM]: updatedTab },
|
||||
paramsToRemove: !isUpdatedTabInfo ? [PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM] : undefined,
|
||||
});
|
||||
router.push(updatedRoute);
|
||||
},
|
||||
[router, updateQueryParams]
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="flex h-full shrink-0 flex-col border-l border-subtle bg-surface-1 pt-3.5 transition-all duration-300 ease-out"
|
||||
style={{
|
||||
width: `${paneWidth}px`,
|
||||
marginRight: isNavigationPaneOpen ? "0px" : `-${paneWidth}px`,
|
||||
}}
|
||||
>
|
||||
<div className="mb-3.5 px-3.5">
|
||||
<Tooltip label={t("page_navigation_pane.close_button")}>
|
||||
<button
|
||||
type="button"
|
||||
className="grid size-3.5 place-items-center text-secondary transition-colors hover:text-primary"
|
||||
onClick={handleClose}
|
||||
aria-label={t("page_navigation_pane.close_button")}
|
||||
>
|
||||
<ArrowRightCircle className="size-3.5" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="animate-slide-in-right flex flex-1 flex-col overflow-hidden">
|
||||
{ActiveExtension ? (
|
||||
<ActiveExtension.component page={page} extensionData={ActiveExtension.data} storeType={storeType} />
|
||||
) : showNavigationTabs ? (
|
||||
// Row wrapper: Propel's Tabs root omits className, so it takes its full height by
|
||||
// stretching as a flex item.
|
||||
<div className="flex h-full w-full">
|
||||
<Tabs variant="contained" value={activeTab} onValueChange={handleTabChange}>
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<PageNavigationPaneTabsList />
|
||||
<PageNavigationPaneTabPanelsRoot page={page} versionHistory={versionHistory} />
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { DownloadOutline } from "@makeplane/propel/icons";
|
||||
// plane imports
|
||||
import { CORE_EXTENSIONS } from "@plane/editor";
|
||||
import type { TEditorAsset } from "@plane/editor";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { getEditorAssetDownloadSrc, getEditorAssetSrc } from "@plane/utils";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
// local import
|
||||
import { PageNavigationPaneAssetsTabEmptyState } from "./empty-state/assets";
|
||||
|
||||
type Props = {
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
type AssetItemProps = {
|
||||
asset: TEditorAsset;
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
const AssetItem = observer(function AssetItem(props: AssetItemProps) {
|
||||
const { asset, page } = props;
|
||||
// navigation
|
||||
const { workspaceSlug } = useParams();
|
||||
// derived values
|
||||
const { project_ids } = page;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
const assetSrc: string = useMemo(() => {
|
||||
if (!asset.src || !workspaceSlug) return "";
|
||||
if (asset.src.startsWith("http")) {
|
||||
return asset.src;
|
||||
} else {
|
||||
return (
|
||||
getEditorAssetSrc({
|
||||
assetId: asset.src,
|
||||
projectId: project_ids?.[0],
|
||||
workspaceSlug: workspaceSlug.toString(),
|
||||
}) ?? ""
|
||||
);
|
||||
}
|
||||
}, [asset.src, project_ids, workspaceSlug]);
|
||||
|
||||
const assetDownloadSrc: string = useMemo(() => {
|
||||
if (!asset.src || !workspaceSlug) return "";
|
||||
if (asset.src.startsWith("http")) {
|
||||
return asset.src;
|
||||
} else {
|
||||
return (
|
||||
getEditorAssetDownloadSrc({
|
||||
assetId: asset.src,
|
||||
projectId: project_ids?.[0],
|
||||
workspaceSlug: workspaceSlug.toString(),
|
||||
}) ?? ""
|
||||
);
|
||||
}
|
||||
}, [asset.src, project_ids, workspaceSlug]);
|
||||
|
||||
if ([CORE_EXTENSIONS.IMAGE, CORE_EXTENSIONS.CUSTOM_IMAGE].includes(asset.type as CORE_EXTENSIONS))
|
||||
return (
|
||||
<a
|
||||
href={asset.href}
|
||||
className="group/asset-item relative flex h-12 items-center gap-2 rounded-sm border border-subtle pr-2 transition-colors hover:bg-layer-1"
|
||||
>
|
||||
<div
|
||||
className="h-12 w-11 flex-shrink-0 rounded-l-sm bg-cover bg-center bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url('${assetSrc}')`,
|
||||
}}
|
||||
/>
|
||||
<div className="flex-1 space-y-0.5 truncate">
|
||||
<p className="truncate text-13 font-medium">{asset.name}</p>
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<p className="shrink-0 text-11 text-secondary" />
|
||||
<a
|
||||
href={assetDownloadSrc}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="pointer-events-none flex shrink-0 items-center gap-1 rounded-sm px-1 py-0.5 text-secondary opacity-0 transition-opacity group-hover/asset-item:pointer-events-auto group-hover/asset-item:opacity-100 hover:text-primary"
|
||||
>
|
||||
<DownloadOutline className="size-3 shrink-0" />
|
||||
<span className="text-11 font-medium">{t("page_navigation_pane.tabs.assets.download_button")}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
export const PageNavigationPaneAssetsTabPanel = observer(function PageNavigationPaneAssetsTabPanel(props: Props) {
|
||||
const { page } = props;
|
||||
// derived values
|
||||
const {
|
||||
editor: { assetsList },
|
||||
} = page;
|
||||
|
||||
if (assetsList.length === 0) return <PageNavigationPaneAssetsTabEmptyState />;
|
||||
|
||||
return (
|
||||
<div className="mt-5 space-y-4 px-4">
|
||||
{assetsList?.map((asset) => (
|
||||
<AssetItem key={asset.id} asset={asset} page={page} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// assets
|
||||
import darkAssetsAsset from "@/app/assets/empty-state/wiki/navigation-pane/assets-dark.webp?url";
|
||||
import lightAssetsAsset from "@/app/assets/empty-state/wiki/navigation-pane/assets-light.webp?url";
|
||||
|
||||
export function PageNavigationPaneAssetsTabEmptyState() {
|
||||
// theme hook
|
||||
const { resolvedTheme } = useTheme();
|
||||
// asset resolved path
|
||||
const resolvedPath = resolvedTheme === "light" ? lightAssetsAsset : darkAssetsAsset;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="grid size-full place-items-center">
|
||||
<div className="flex flex-col items-center gap-y-6 text-center">
|
||||
<img src={resolvedPath} className="size-40 object-contain" alt="depicts the assets of a page" />
|
||||
<div className="space-y-2.5">
|
||||
<h4 className="text-14 font-medium">{t("page_navigation_pane.tabs.assets.empty_state.title")}</h4>
|
||||
<p className="text-13 font-medium text-secondary">
|
||||
{t("page_navigation_pane.tabs.assets.empty_state.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// assets
|
||||
import darkOutlineAsset from "@/app/assets/empty-state/wiki/navigation-pane/outline-dark.webp?url";
|
||||
import lightOutlineAsset from "@/app/assets/empty-state/wiki/navigation-pane/outline-light.webp?url";
|
||||
|
||||
export function PageNavigationPaneOutlineTabEmptyState() {
|
||||
// theme hook
|
||||
const { resolvedTheme } = useTheme();
|
||||
// asset resolved path
|
||||
const resolvedPath = resolvedTheme === "light" ? lightOutlineAsset : darkOutlineAsset;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="grid size-full place-items-center">
|
||||
<div className="flex flex-col items-center gap-y-6 text-center">
|
||||
<img src={resolvedPath} className="size-40 object-contain" alt="depicts the outline of a page" />
|
||||
<div className="space-y-2.5">
|
||||
<h4 className="text-14 font-medium">{t("page_navigation_pane.tabs.outline.empty_state.title")}</h4>
|
||||
<p className="text-13 font-medium text-secondary">
|
||||
{t("page_navigation_pane.tabs.outline.empty_state.description")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export type TPageNavigationPaneTab = "outline" | "info" | "assets";
|
||||
|
||||
export const PAGE_NAVIGATION_PANE_TABS_LIST: Record<
|
||||
TPageNavigationPaneTab,
|
||||
{
|
||||
key: TPageNavigationPaneTab;
|
||||
i18n_label: string;
|
||||
}
|
||||
> = {
|
||||
outline: {
|
||||
key: "outline",
|
||||
i18n_label: "page_navigation_pane.tabs.outline.label",
|
||||
},
|
||||
info: {
|
||||
key: "info",
|
||||
i18n_label: "page_navigation_pane.tabs.info.label",
|
||||
},
|
||||
assets: {
|
||||
key: "assets",
|
||||
i18n_label: "page_navigation_pane.tabs.assets.label",
|
||||
},
|
||||
};
|
||||
|
||||
export const ORDERED_PAGE_NAVIGATION_TABS_LIST: {
|
||||
key: TPageNavigationPaneTab;
|
||||
i18n_label: string;
|
||||
}[] = [
|
||||
PAGE_NAVIGATION_PANE_TABS_LIST.outline,
|
||||
PAGE_NAVIGATION_PANE_TABS_LIST.info,
|
||||
PAGE_NAVIGATION_PANE_TABS_LIST.assets,
|
||||
];
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { Avatar } from "@makeplane/propel/components/avatar";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { calculateTimeAgoShort, getFileURL, renderFormattedDate } from "@plane/utils";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type Props = {
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
export const PageNavigationPaneInfoTabActorsInfo = observer(function PageNavigationPaneInfoTabActorsInfo(props: Props) {
|
||||
const { page } = props;
|
||||
// navigation
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const { owned_by, updated_by } = page;
|
||||
const editorInformation = updated_by ? getUserDetails(updated_by) : undefined;
|
||||
const creatorInformation = owned_by ? getUserDetails(owned_by) : undefined;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<p className="text-11 font-medium text-tertiary">{t("page_navigation_pane.tabs.info.actors_info.edited_by")}</p>
|
||||
<div className="mt-2 flex items-center justify-between gap-2 text-13 font-medium">
|
||||
<Link href={`/${workspaceSlug?.toString()}/profile/${page.updated_by}`} className="flex items-center gap-1">
|
||||
<Avatar
|
||||
alt={editorInformation?.display_name}
|
||||
fallback={editorInformation?.display_name?.[0]?.toUpperCase()}
|
||||
src={getFileURL(editorInformation?.avatar_url ?? "")}
|
||||
size="2xs"
|
||||
/>
|
||||
<span>{editorInformation?.display_name ?? t("common.deactivated_user")}</span>
|
||||
</Link>
|
||||
<span className="flex-shrink-0 text-tertiary">{calculateTimeAgoShort(page.updated_at ?? "")} ago</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-11 font-medium text-tertiary">
|
||||
{t("page_navigation_pane.tabs.info.actors_info.created_by")}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center justify-between gap-2 text-13 font-medium">
|
||||
<Link href={`/${workspaceSlug?.toString()}/profile/${page.created_by}`} className="flex items-center gap-1">
|
||||
<Avatar
|
||||
alt={creatorInformation?.display_name}
|
||||
fallback={creatorInformation?.display_name?.[0]?.toUpperCase()}
|
||||
src={getFileURL(creatorInformation?.avatar_url ?? "")}
|
||||
size="2xs"
|
||||
/>
|
||||
<span>{creatorInformation?.display_name ?? t("common.deactivated_user")}</span>
|
||||
</Link>
|
||||
<span className="flex-shrink-0 text-tertiary">{renderFormattedDate(page.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import type { TDocumentInfo } from "@plane/editor";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { getReadTimeFromWordsCount } from "@plane/utils";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type Props = {
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
const DEFAULT_DOCUMENT_INFO: TDocumentInfo = {
|
||||
words: 0,
|
||||
characters: 0,
|
||||
paragraphs: 0,
|
||||
};
|
||||
|
||||
export const PageNavigationPaneInfoTabDocumentInfo = observer(function PageNavigationPaneInfoTabDocumentInfo(
|
||||
props: Props
|
||||
) {
|
||||
const { page } = props;
|
||||
// states
|
||||
const [documentInfo, setDocumentInfo] = useState<TDocumentInfo>(DEFAULT_DOCUMENT_INFO);
|
||||
// derived values
|
||||
const {
|
||||
editor: { editorRef },
|
||||
} = page;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// subscribe to asset changes
|
||||
useEffect(() => {
|
||||
const unsubscribe = editorRef?.onDocumentInfoChange(setDocumentInfo);
|
||||
// for initial render of this component to get the editor assets
|
||||
setDocumentInfo(editorRef?.getDocumentInfo() ?? DEFAULT_DOCUMENT_INFO);
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [editorRef]);
|
||||
|
||||
const secondsToReadableTime = useCallback(() => {
|
||||
const wordsCount = documentInfo.words;
|
||||
const readTimeInSeconds = Number(getReadTimeFromWordsCount(wordsCount).toFixed(0));
|
||||
return readTimeInSeconds < 60 ? `${readTimeInSeconds}s` : `${Math.ceil(readTimeInSeconds / 60)}m`;
|
||||
}, [documentInfo.words]);
|
||||
|
||||
const documentInfoCards = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "words-count",
|
||||
title: t("page_navigation_pane.tabs.info.document_info.words"),
|
||||
info: documentInfo.words,
|
||||
},
|
||||
{
|
||||
key: "characters-count",
|
||||
title: t("page_navigation_pane.tabs.info.document_info.characters"),
|
||||
info: documentInfo.characters,
|
||||
},
|
||||
{
|
||||
key: "paragraphs-count",
|
||||
title: t("page_navigation_pane.tabs.info.document_info.paragraphs"),
|
||||
info: documentInfo.paragraphs,
|
||||
},
|
||||
{
|
||||
key: "read-time",
|
||||
title: t("page_navigation_pane.tabs.info.document_info.read_time"),
|
||||
info: secondsToReadableTime(),
|
||||
},
|
||||
],
|
||||
[documentInfo, secondsToReadableTime, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{documentInfoCards.map((card) => (
|
||||
<div key={card.key} className="rounded-sm bg-layer-2 p-2">
|
||||
<h6 className="text-14 font-semibold">{card.info}</h6>
|
||||
<p className="mt-1.5 text-13 font-medium text-tertiary">{card.title}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import type { TPageRootHandlers } from "@/components/pages/editor/page-root";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
// local imports
|
||||
import { PageNavigationPaneInfoTabActorsInfo } from "./actors-info";
|
||||
import { PageNavigationPaneInfoTabDocumentInfo } from "./document-info";
|
||||
import { PageNavigationPaneInfoTabVersionHistory } from "./version-history";
|
||||
|
||||
type Props = {
|
||||
page: TPageInstance;
|
||||
versionHistory: Pick<TPageRootHandlers, "fetchAllVersions" | "fetchVersionDetails">;
|
||||
};
|
||||
|
||||
export const PageNavigationPaneInfoTabPanel = observer(function PageNavigationPaneInfoTabPanel(props: Props) {
|
||||
const { page, versionHistory } = props;
|
||||
return (
|
||||
<div className="flex h-full flex-col px-4">
|
||||
<div className="mt-5 flex-1 overflow-y-auto">
|
||||
<PageNavigationPaneInfoTabDocumentInfo page={page} />
|
||||
<PageNavigationPaneInfoTabActorsInfo page={page} />
|
||||
<div className="my-3 h-px flex-shrink-0 bg-layer-1" />
|
||||
<PageNavigationPaneInfoTabVersionHistory page={page} versionHistory={versionHistory} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { Avatar } from "@makeplane/propel/components/avatar";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TPageVersion } from "@plane/types";
|
||||
import { cn, getFileURL, renderFormattedDate, renderFormattedTime } from "@plane/utils";
|
||||
// components
|
||||
import type { TPageRootHandlers } from "@/components/pages/editor/page-root";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useQueryParams } from "@/hooks/use-query-params";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
// local imports
|
||||
import { PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM } from "../..";
|
||||
|
||||
type Props = {
|
||||
page: TPageInstance;
|
||||
versionHistory: Pick<TPageRootHandlers, "fetchAllVersions" | "fetchVersionDetails">;
|
||||
};
|
||||
|
||||
type VersionHistoryItemProps = {
|
||||
getVersionLink: (versionID: string) => string;
|
||||
isVersionActive: boolean;
|
||||
version: TPageVersion;
|
||||
};
|
||||
|
||||
const VersionHistoryItem = observer(function VersionHistoryItem(props: VersionHistoryItemProps) {
|
||||
const { getVersionLink, isVersionActive, version } = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const versionCreator = getUserDetails(version.owned_by);
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<li className="relative flex items-center gap-x-4 text-11 font-medium">
|
||||
{/* timeline icon */}
|
||||
<div className="relative grid size-6 flex-none place-items-center">
|
||||
<div className="size-2 rounded-full bg-layer-3" />
|
||||
</div>
|
||||
{/* end timeline icon */}
|
||||
<Link
|
||||
href={getVersionLink(version.id)}
|
||||
className={cn("block flex-1 rounded-md px-1 py-2 hover:bg-layer-transparent-hover", {
|
||||
"bg-layer-transparent-selected hover:bg-layer-transparent-selected": isVersionActive,
|
||||
})}
|
||||
>
|
||||
<p className="text-tertiary">
|
||||
{renderFormattedDate(version.last_saved_at)}, {renderFormattedTime(version.last_saved_at)}
|
||||
</p>
|
||||
<p className="mt-1 flex items-center gap-1">
|
||||
<Avatar
|
||||
alt={versionCreator?.display_name}
|
||||
fallback={versionCreator?.display_name?.[0]?.toUpperCase()}
|
||||
size="2xs"
|
||||
src={getFileURL(versionCreator?.avatar_url ?? "")}
|
||||
/>
|
||||
<span>{versionCreator?.display_name ?? t("common.deactivated_user")}</span>
|
||||
</p>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
export const PageNavigationPaneInfoTabVersionHistory = observer(function PageNavigationPaneInfoTabVersionHistory(
|
||||
props: Props
|
||||
) {
|
||||
const { page, versionHistory } = props;
|
||||
// navigation
|
||||
const searchParams = useSearchParams();
|
||||
const activeVersion = searchParams.get(PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM);
|
||||
// derived values
|
||||
const { id } = page;
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
// query params
|
||||
const { updateQueryParams } = useQueryParams();
|
||||
// fetch all versions
|
||||
const { data: versionsList } = useSWR(
|
||||
id ? `PAGE_VERSIONS_LIST_${id}` : null,
|
||||
id ? () => versionHistory.fetchAllVersions(id) : null
|
||||
);
|
||||
|
||||
const getVersionLink = useCallback(
|
||||
(versionID?: string) => {
|
||||
if (versionID) {
|
||||
return updateQueryParams({
|
||||
paramsToAdd: { [PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM]: versionID },
|
||||
});
|
||||
} else {
|
||||
return updateQueryParams({
|
||||
paramsToRemove: [PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM],
|
||||
});
|
||||
}
|
||||
},
|
||||
[updateQueryParams]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-11 font-medium text-secondary">{t("page_navigation_pane.tabs.info.version_history.label")}</p>
|
||||
<div className="mt-3">
|
||||
<ul className="relative">
|
||||
{/* timeline line */}
|
||||
<div className={cn("absolute top-0 left-0 flex h-full w-6 justify-center")}>
|
||||
<div className="w-px bg-layer-3" />
|
||||
</div>
|
||||
{/* end timeline line */}
|
||||
<li className="relative flex items-center gap-x-4 text-11 font-medium">
|
||||
{/* timeline icon */}
|
||||
<div className="relative grid size-6 flex-none place-items-center rounded-full bg-accent-primary/20">
|
||||
<div className="size-2.5 rounded-full bg-accent-primary/40" />
|
||||
</div>
|
||||
{/* end timeline icon */}
|
||||
<Link
|
||||
href={getVersionLink()}
|
||||
className={cn("flex-1 rounded-md bg-layer-transparent px-1 py-2 hover:bg-layer-transparent-hover", {
|
||||
"bg-layer-transparent-selected hover:bg-layer-transparent-selected": !activeVersion,
|
||||
})}
|
||||
>
|
||||
{t("page_navigation_pane.tabs.info.version_history.current_version")}
|
||||
</Link>
|
||||
</li>
|
||||
{versionsList?.map((version) => (
|
||||
<VersionHistoryItem
|
||||
key={version.id}
|
||||
getVersionLink={getVersionLink}
|
||||
isVersionActive={activeVersion === version.id}
|
||||
version={version}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// plane imports
|
||||
import { ScrollArea } from "@plane/propel/scrollarea";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
// local imports
|
||||
import { PageContentBrowser } from "../../editor/summary";
|
||||
import { PageNavigationPaneOutlineTabEmptyState } from "./empty-state/outline";
|
||||
|
||||
type Props = {
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
export function PageNavigationPaneOutlineTabPanel(props: Props) {
|
||||
const { page } = props;
|
||||
// derived values
|
||||
const {
|
||||
editor: { editorRef },
|
||||
} = page;
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
orientation="vertical"
|
||||
size="sm"
|
||||
scrollType="hover"
|
||||
className="hide-scrollbar size-full overflow-y-auto"
|
||||
viewportClassName="px-4"
|
||||
>
|
||||
<PageContentBrowser
|
||||
className="mt-0"
|
||||
editorRef={editorRef}
|
||||
emptyState={<PageNavigationPaneOutlineTabEmptyState />}
|
||||
/>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// plane imports
|
||||
import { TabsPanel } from "@makeplane/propel/components/tabs";
|
||||
// components
|
||||
import type { TPageRootHandlers } from "@/components/pages/editor/page-root";
|
||||
// store
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
// local imports
|
||||
import { PageNavigationPaneAssetsTabPanel } from "./assets";
|
||||
import { PageNavigationPaneInfoTabPanel } from "./info/root";
|
||||
import { PageNavigationPaneOutlineTabPanel } from "./outline";
|
||||
import { ORDERED_PAGE_NAVIGATION_TABS_LIST } from ".";
|
||||
|
||||
type Props = {
|
||||
page: TPageInstance;
|
||||
versionHistory: Pick<TPageRootHandlers, "fetchAllVersions" | "fetchVersionDetails">;
|
||||
};
|
||||
|
||||
export function PageNavigationPaneTabPanelsRoot(props: Props) {
|
||||
const { page, versionHistory } = props;
|
||||
|
||||
return (
|
||||
// Grid wrapper: Propel's TabsPanel omits className, so the single mounted panel gets its
|
||||
// fill height from a one-row grid instead.
|
||||
<div className="grid min-h-0 w-full flex-1 grid-rows-1 overflow-hidden py-2">
|
||||
{ORDERED_PAGE_NAVIGATION_TABS_LIST.map((tab) => (
|
||||
<TabsPanel key={tab.key} value={tab.key}>
|
||||
{tab.key === "outline" && <PageNavigationPaneOutlineTabPanel page={page} />}
|
||||
{tab.key === "info" && <PageNavigationPaneInfoTabPanel page={page} versionHistory={versionHistory} />}
|
||||
{tab.key === "assets" && <PageNavigationPaneAssetsTabPanel page={page} />}
|
||||
</TabsPanel>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
apps/web/core/components/pages/navigation-pane/tabs-list.tsx
Normal file
26
apps/web/core/components/pages/navigation-pane/tabs-list.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// plane imports
|
||||
import { Tab, TabsList } from "@makeplane/propel/components/tabs";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// plane web components
|
||||
import { ORDERED_PAGE_NAVIGATION_TABS_LIST } from "@/components/pages/navigation-pane/tab-panels";
|
||||
|
||||
export function PageNavigationPaneTabsList() {
|
||||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="mx-3.5">
|
||||
<TabsList>
|
||||
{ORDERED_PAGE_NAVIGATION_TABS_LIST.map((tab) => (
|
||||
<Tab key={tab.key} value={tab.key} label={t(tab.i18n_label)} />
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { EPageStoreType } from "@/hooks/store";
|
||||
import type { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
export interface INavigationPaneExtensionProps<T = Record<string, unknown>> {
|
||||
page: TPageInstance;
|
||||
extensionData?: T;
|
||||
storeType: EPageStoreType;
|
||||
}
|
||||
|
||||
export interface INavigationPaneExtensionComponent<T = Record<string, unknown>> {
|
||||
(props: INavigationPaneExtensionProps<T>): ReactNode;
|
||||
}
|
||||
|
||||
export interface INavigationPaneExtension<T = Record<string, unknown>> {
|
||||
id: string;
|
||||
triggerParam: string;
|
||||
component: INavigationPaneExtensionComponent<T>;
|
||||
data?: T;
|
||||
width?: number;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
// Export generic extension system interfaces
|
||||
export type {
|
||||
INavigationPaneExtensionProps,
|
||||
INavigationPaneExtensionComponent,
|
||||
INavigationPaneExtension,
|
||||
} from "./extensions";
|
||||
154
apps/web/core/components/pages/pages-list-main-content.tsx
Normal file
154
apps/web/core/components/pages/pages-list-main-content.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { EUserPermissionsLevel, EPageAccess } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { EmptyStateDetailed } from "@plane/propel/empty-state";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TPage, TPageNavigationTabs } from "@plane/types";
|
||||
import { EUserProjectRoles } from "@plane/types";
|
||||
// components
|
||||
import { PageLoader } from "@/components/pages/loaders/page-loader";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web hooks
|
||||
import { EPageStoreType, usePageStore } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
pageType: TPageNavigationTabs;
|
||||
storeType: EPageStoreType;
|
||||
};
|
||||
|
||||
export const PagesListMainContent = observer(function PagesListMainContent(props: Props) {
|
||||
const { children, pageType, storeType } = props;
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { isAnyPageAvailable, getCurrentProjectFilteredPageIdsByTab, getCurrentProjectPageIdsByTab, loader } =
|
||||
usePageStore(storeType);
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { createPage } = usePageStore(EPageStoreType.PROJECT);
|
||||
// states
|
||||
const [isCreatingPage, setIsCreatingPage] = useState(false);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = useParams();
|
||||
// derived values
|
||||
const pageIds = getCurrentProjectPageIdsByTab(pageType);
|
||||
const filteredPageIds = getCurrentProjectFilteredPageIdsByTab(pageType);
|
||||
const canPerformEmptyStateActions = allowPermissions(
|
||||
[EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT
|
||||
);
|
||||
|
||||
// handle page create
|
||||
const handleCreatePage = async () => {
|
||||
setIsCreatingPage(true);
|
||||
|
||||
const payload: Partial<TPage> = {
|
||||
access: pageType === "private" ? EPageAccess.PRIVATE : EPageAccess.PUBLIC,
|
||||
};
|
||||
|
||||
await createPage(payload)
|
||||
.then((res) => {
|
||||
const pageId = `/${workspaceSlug}/projects/${currentProjectDetails?.id}/pages/${res?.id}`;
|
||||
router.push(pageId);
|
||||
})
|
||||
.catch((err) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: err?.data?.error || "Page could not be created. Please try again.",
|
||||
});
|
||||
})
|
||||
.finally(() => setIsCreatingPage(false));
|
||||
};
|
||||
|
||||
if (loader === "init-loader") return <PageLoader />;
|
||||
// if no pages exist in the active page type
|
||||
if (!isAnyPageAvailable || pageIds?.length === 0) {
|
||||
if (!isAnyPageAvailable) {
|
||||
return (
|
||||
<EmptyStateDetailed
|
||||
assetKey="page"
|
||||
title={t("project_empty_state.pages.title")}
|
||||
description={t("project_empty_state.pages.description")}
|
||||
actions={[
|
||||
{
|
||||
label: t("project_empty_state.pages.cta_primary"),
|
||||
onClick: () => {
|
||||
handleCreatePage();
|
||||
},
|
||||
variant: "primary",
|
||||
disabled: !canPerformEmptyStateActions || isCreatingPage,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (pageType === "public")
|
||||
return (
|
||||
<EmptyStateDetailed
|
||||
assetKey="page"
|
||||
title={t("project_empty_state.pages.title")}
|
||||
description={t("project_empty_state.pages.description")}
|
||||
actions={[
|
||||
{
|
||||
label: t("project_empty_state.pages.cta_primary"),
|
||||
onClick: () => {
|
||||
handleCreatePage();
|
||||
},
|
||||
variant: "primary",
|
||||
disabled: !canPerformEmptyStateActions || isCreatingPage,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
if (pageType === "private")
|
||||
return (
|
||||
<EmptyStateDetailed
|
||||
assetKey="page"
|
||||
title={t("project_empty_state.pages.title")}
|
||||
description={t("project_empty_state.pages.description")}
|
||||
actions={[
|
||||
{
|
||||
label: t("project_empty_state.pages.cta_primary"),
|
||||
onClick: () => {
|
||||
handleCreatePage();
|
||||
},
|
||||
variant: "primary",
|
||||
disabled: !canPerformEmptyStateActions || isCreatingPage,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
if (pageType === "archived")
|
||||
return (
|
||||
<EmptyStateDetailed
|
||||
assetKey="page"
|
||||
title={t("project_empty_state.archive_pages.title")}
|
||||
description={t("project_empty_state.archive_pages.description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// if no pages match the filter criteria
|
||||
if (filteredPageIds?.length === 0)
|
||||
return (
|
||||
<EmptyStateDetailed
|
||||
assetKey="search"
|
||||
title={t("common_empty_state.search.title")}
|
||||
description={t("common_empty_state.search.description")}
|
||||
/>
|
||||
);
|
||||
|
||||
return <div className="h-full w-full overflow-hidden">{children}</div>;
|
||||
});
|
||||
52
apps/web/core/components/pages/pages-list-view.tsx
Normal file
52
apps/web/core/components/pages/pages-list-view.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import type { TPageNavigationTabs } from "@plane/types";
|
||||
// hooks
|
||||
import type { EPageStoreType } from "@/hooks/store";
|
||||
import { usePageStore } from "@/hooks/store";
|
||||
// local imports
|
||||
import { PagesListHeaderRoot } from "./header";
|
||||
import { PagesListMainContent } from "./pages-list-main-content";
|
||||
|
||||
type TPageView = {
|
||||
children: React.ReactNode;
|
||||
pageType: TPageNavigationTabs;
|
||||
projectId: string;
|
||||
storeType: EPageStoreType;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const PagesListView = observer(function PagesListView(props: TPageView) {
|
||||
const { children, pageType, projectId, storeType, workspaceSlug } = props;
|
||||
// store hooks
|
||||
const { isAnyPageAvailable, fetchPagesList } = usePageStore(storeType);
|
||||
// fetching pages list
|
||||
useSWR(
|
||||
workspaceSlug && projectId && pageType ? `PROJECT_PAGES_${projectId}` : null,
|
||||
workspaceSlug && projectId && pageType ? () => fetchPagesList(workspaceSlug, projectId, pageType) : null
|
||||
);
|
||||
|
||||
// pages loader
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
{/* tab header */}
|
||||
{isAnyPageAvailable && (
|
||||
<PagesListHeaderRoot
|
||||
pageType={pageType}
|
||||
projectId={projectId}
|
||||
storeType={storeType}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
)}
|
||||
<PagesListMainContent pageType={pageType} storeType={storeType}>
|
||||
{children}
|
||||
</PagesListMainContent>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
107
apps/web/core/components/pages/version/editor.tsx
Normal file
107
apps/web/core/components/pages/version/editor.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import type { TDisplayConfig } from "@plane/editor";
|
||||
import type { JSONContent, TPageVersion } from "@plane/types";
|
||||
import { Loader } from "@plane/ui";
|
||||
import { isJSONContentEmpty } from "@plane/utils";
|
||||
// components
|
||||
import { DocumentEditor } from "@/components/editor/document/editor";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
// plane web hooks
|
||||
import type { EPageStoreType } from "@/hooks/store";
|
||||
|
||||
export type TVersionEditorProps = {
|
||||
activeVersion: string | null;
|
||||
versionDetails: TPageVersion | undefined;
|
||||
storeType: EPageStoreType;
|
||||
};
|
||||
|
||||
export const PagesVersionEditor = observer(function PagesVersionEditor(props: TVersionEditorProps) {
|
||||
const { activeVersion, versionDetails } = props;
|
||||
// params
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
// derived values
|
||||
const workspaceDetails = getWorkspaceBySlug(workspaceSlug?.toString() ?? "");
|
||||
// page filters
|
||||
const { fontSize, fontStyle } = usePageFilters();
|
||||
|
||||
const displayConfig: TDisplayConfig = {
|
||||
fontSize,
|
||||
fontStyle,
|
||||
wideLayout: true,
|
||||
};
|
||||
|
||||
if (!versionDetails)
|
||||
return (
|
||||
<div className="size-full px-5">
|
||||
<Loader className="relative space-y-4">
|
||||
<Loader.Item width="50%" height="36px" />
|
||||
<div className="space-y-2">
|
||||
<div className="py-2">
|
||||
<Loader.Item width="100%" height="36px" />
|
||||
</div>
|
||||
<Loader.Item width="80%" height="22px" />
|
||||
<div className="relative flex items-center gap-2">
|
||||
<Loader.Item width="30px" height="30px" />
|
||||
<Loader.Item width="30%" height="22px" />
|
||||
</div>
|
||||
<div className="py-2">
|
||||
<Loader.Item width="60%" height="36px" />
|
||||
</div>
|
||||
<Loader.Item width="70%" height="22px" />
|
||||
<Loader.Item width="30%" height="22px" />
|
||||
<div className="relative flex items-center gap-2">
|
||||
<Loader.Item width="30px" height="30px" />
|
||||
<Loader.Item width="30%" height="22px" />
|
||||
</div>
|
||||
<div className="py-2">
|
||||
<Loader.Item width="50%" height="30px" />
|
||||
</div>
|
||||
<Loader.Item width="100%" height="22px" />
|
||||
<div className="py-2">
|
||||
<Loader.Item width="30%" height="30px" />
|
||||
</div>
|
||||
<Loader.Item width="30%" height="22px" />
|
||||
<div className="relative flex items-center gap-2">
|
||||
<div className="py-2">
|
||||
<Loader.Item width="30px" height="30px" />
|
||||
</div>
|
||||
<Loader.Item width="30%" height="22px" />
|
||||
</div>
|
||||
</div>
|
||||
</Loader>
|
||||
</div>
|
||||
);
|
||||
|
||||
const description = isJSONContentEmpty(versionDetails?.description_json as JSONContent)
|
||||
? versionDetails?.description_html
|
||||
: versionDetails?.description_json;
|
||||
|
||||
if (!description) return null;
|
||||
|
||||
return (
|
||||
<DocumentEditor
|
||||
key={activeVersion ?? ""}
|
||||
editable={false}
|
||||
id={activeVersion ?? ""}
|
||||
value={description}
|
||||
containerClassName="p-0 pb-64 border-none"
|
||||
displayConfig={displayConfig}
|
||||
editorClassName="pl-10"
|
||||
projectId={projectId?.toString()}
|
||||
workspaceId={workspaceDetails?.id ?? ""}
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
/>
|
||||
);
|
||||
});
|
||||
7
apps/web/core/components/pages/version/index.ts
Normal file
7
apps/web/core/components/pages/version/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
export * from "./root";
|
||||
128
apps/web/core/components/pages/version/main-content.tsx
Normal file
128
apps/web/core/components/pages/version/main-content.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import useSWR from "swr";
|
||||
import { ShowOutline, WarningTriangleOutline } from "@makeplane/propel/icons";
|
||||
// plane imports
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TPageVersion } from "@plane/types";
|
||||
import { renderFormattedDate, renderFormattedTime } from "@plane/utils";
|
||||
// helpers
|
||||
import type { EPageStoreType } from "@/hooks/store";
|
||||
// local imports
|
||||
import type { TVersionEditorProps } from "./editor";
|
||||
|
||||
type Props = {
|
||||
activeVersion: string | null;
|
||||
editorComponent: React.FC<TVersionEditorProps>;
|
||||
fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>;
|
||||
handleClose: () => void;
|
||||
handleRestore: (descriptionHTML: string) => Promise<void>;
|
||||
pageId: string;
|
||||
restoreEnabled: boolean;
|
||||
storeType: EPageStoreType;
|
||||
};
|
||||
|
||||
export const PageVersionsMainContent = observer(function PageVersionsMainContent(props: Props) {
|
||||
const {
|
||||
activeVersion,
|
||||
editorComponent,
|
||||
fetchVersionDetails,
|
||||
handleClose,
|
||||
handleRestore,
|
||||
pageId,
|
||||
restoreEnabled,
|
||||
storeType,
|
||||
} = props;
|
||||
// states
|
||||
const [isRestoring, setIsRestoring] = useState(false);
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
|
||||
const {
|
||||
data: versionDetails,
|
||||
error: versionDetailsError,
|
||||
mutate: mutateVersionDetails,
|
||||
} = useSWR(
|
||||
pageId && activeVersion ? `PAGE_VERSION_${activeVersion}` : null,
|
||||
pageId && activeVersion ? () => fetchVersionDetails(pageId, activeVersion) : null
|
||||
);
|
||||
|
||||
const handleRestoreVersion = async () => {
|
||||
if (!restoreEnabled) return;
|
||||
setIsRestoring(true);
|
||||
await handleRestore(versionDetails?.description_html ?? "<p></p>")
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Page version restored.",
|
||||
});
|
||||
handleClose();
|
||||
})
|
||||
.catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Failed to restore page version.",
|
||||
})
|
||||
)
|
||||
.finally(() => setIsRestoring(false));
|
||||
};
|
||||
|
||||
const handleRetry = async () => {
|
||||
setIsRetrying(true);
|
||||
await mutateVersionDetails();
|
||||
setIsRetrying(false);
|
||||
};
|
||||
|
||||
const VersionEditor = editorComponent;
|
||||
|
||||
return (
|
||||
<div className="flex flex-grow flex-col overflow-hidden">
|
||||
{versionDetailsError ? (
|
||||
<div className="grid flex-grow place-items-center">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<span className="grid size-11 flex-shrink-0 place-items-center text-tertiary">
|
||||
<WarningTriangleOutline className="size-10" />
|
||||
</span>
|
||||
<div>
|
||||
<h6 className="text-16 font-semibold">Something went wrong!</h6>
|
||||
<p className="text-13 text-tertiary">The version could not be loaded, please try again.</p>
|
||||
</div>
|
||||
<Button variant="link" onClick={handleRetry} loading={isRetrying}>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex min-h-14 items-center justify-between gap-2 border-b border-subtle px-5 py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<h6 className="text-14 font-medium">
|
||||
{versionDetails
|
||||
? `${renderFormattedDate(versionDetails.last_saved_at)} ${renderFormattedTime(versionDetails.last_saved_at)}`
|
||||
: "Loading version details"}
|
||||
</h6>
|
||||
<span className="flex flex-shrink-0 items-center gap-1 rounded-sm bg-accent-primary/20 px-1.5 py-1 text-11 font-medium text-accent-primary">
|
||||
<ShowOutline className="size-3 flex-shrink-0" />
|
||||
View only
|
||||
</span>
|
||||
</div>
|
||||
{restoreEnabled && (
|
||||
<Button variant="primary" className="flex-shrink-0" onClick={handleRestoreVersion} loading={isRestoring}>
|
||||
{isRestoring ? "Restoring" : "Restore"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="vertical-scrollbar scrollbar-sm h-full overflow-y-scroll pt-8">
|
||||
<VersionEditor activeVersion={activeVersion} storeType={storeType} versionDetails={versionDetails} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
73
apps/web/core/components/pages/version/root.tsx
Normal file
73
apps/web/core/components/pages/version/root.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
// plane imports
|
||||
import type { TPageVersion } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useQueryParams } from "@/hooks/use-query-params";
|
||||
// plane web imports
|
||||
import type { EPageStoreType } from "@/hooks/store";
|
||||
// local imports
|
||||
import { PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM, PAGE_NAVIGATION_PANE_WIDTH } from "../navigation-pane";
|
||||
import type { TVersionEditorProps } from "./editor";
|
||||
import { PageVersionsMainContent } from "./main-content";
|
||||
|
||||
type Props = {
|
||||
editorComponent: React.FC<TVersionEditorProps>;
|
||||
fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>;
|
||||
handleRestore: (descriptionHTML: string) => Promise<void>;
|
||||
pageId: string;
|
||||
restoreEnabled: boolean;
|
||||
storeType: EPageStoreType;
|
||||
};
|
||||
|
||||
export const PageVersionsOverlay = observer(function PageVersionsOverlay(props: Props) {
|
||||
const { editorComponent, fetchVersionDetails, handleRestore, pageId, restoreEnabled, storeType } = props;
|
||||
// navigation
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
// query params
|
||||
const { updateQueryParams } = useQueryParams();
|
||||
// derived values
|
||||
const activeVersion = searchParams.get(PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM);
|
||||
const isOpen = !!activeVersion;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
const updatedRoute = updateQueryParams({
|
||||
paramsToRemove: [PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM],
|
||||
});
|
||||
router.push(updatedRoute);
|
||||
}, [router, updateQueryParams]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 z-[16] flex h-full overflow-hidden bg-surface-1 opacity-0 transition-opacity",
|
||||
{
|
||||
"pointer-events-auto opacity-100": isOpen,
|
||||
}
|
||||
)}
|
||||
style={{
|
||||
width: `calc(100% - ${PAGE_NAVIGATION_PANE_WIDTH}px)`,
|
||||
}}
|
||||
>
|
||||
<PageVersionsMainContent
|
||||
activeVersion={activeVersion}
|
||||
editorComponent={editorComponent}
|
||||
fetchVersionDetails={fetchVersionDetails}
|
||||
handleClose={handleClose}
|
||||
handleRestore={handleRestore}
|
||||
pageId={pageId}
|
||||
restoreEnabled={restoreEnabled}
|
||||
storeType={storeType}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user