feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

View File

@@ -0,0 +1,237 @@
"use client";
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import {
ArchiveRestoreIcon,
Copy,
ExternalLink,
FileOutput,
Globe2,
Link,
Lock,
LockKeyhole,
LockKeyholeOpen,
Trash2,
} from "lucide-react";
// constants
import { EPageAccess, PROJECT_PAGE_TRACKER_ELEMENTS } from "@plane/constants";
// plane editor
import type { EditorRefApi } from "@plane/editor";
// plane ui
import { ArchiveIcon } from "@plane/propel/icons";
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";
// helpers
// hooks
import { captureClick } from "@/helpers/event-tracker.helper";
import { usePageOperations } from "@/hooks/use-page-operations";
// plane web components
import { MovePageModal } from "@/plane-web/components/pages";
// plane web hooks
import type { EPageStoreType } from "@/plane-web/hooks/store";
import { usePageFlag } from "@/plane-web/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>;
storeType: EPageStoreType;
};
export const PageActions: React.FC<Props> = observer((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: (TContextMenuItem & { key: TPageActions })[] = useMemo(() => {
const menuItems: (TContextMenuItem & { key: TPageActions })[] = [
{
key: "toggle-lock",
action: () => {
captureClick({
elementName: PROJECT_PAGE_TRACKER_ELEMENTS.LOCK_BUTTON,
});
pageOperations.toggleLock();
},
title: is_locked ? "Unlock" : "Lock",
icon: is_locked ? LockKeyholeOpen : LockKeyhole,
shouldRender: canCurrentUserLockPage,
},
{
key: "toggle-access",
action: () => {
captureClick({
elementName: PROJECT_PAGE_TRACKER_ELEMENTS.ACCESS_TOGGLE,
});
pageOperations.toggleAccess();
},
title: access === EPageAccess.PUBLIC ? "Make private" : "Make public",
icon: access === EPageAccess.PUBLIC ? Lock : Globe2,
shouldRender: canCurrentUserChangeAccess && !archived_at,
},
{
key: "open-in-new-tab",
action: pageOperations.openInNewTab,
title: "Open in new tab",
icon: ExternalLink,
shouldRender: true,
},
{
key: "copy-link",
action: pageOperations.copyLink,
title: "Copy link",
icon: Link,
shouldRender: true,
},
{
key: "make-a-copy",
action: () => {
captureClick({
elementName: PROJECT_PAGE_TRACKER_ELEMENTS.DUPLICATE_BUTTON,
});
pageOperations.duplicate();
},
title: "Make a copy",
icon: Copy,
shouldRender: canCurrentUserDuplicatePage,
},
{
key: "archive-restore",
action: () => {
captureClick({
elementName: PROJECT_PAGE_TRACKER_ELEMENTS.ARCHIVE_BUTTON,
});
pageOperations.toggleArchive();
},
title: archived_at ? "Restore" : "Archive",
icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon,
shouldRender: canCurrentUserArchivePage,
},
{
key: "delete",
action: () => {
captureClick({
elementName: PROJECT_PAGE_TRACKER_ELEMENTS.CONTEXT_MENU,
});
setDeletePageModal(true);
},
title: "Delete",
icon: Trash2,
shouldRender: canCurrentUserDeletePage && !!archived_at,
},
{
key: "move",
action: () => setMovePageModal(true),
title: "Move",
icon: FileOutput,
shouldRender: canCurrentUserMovePage && isMovePageEnabled,
},
];
if (extraOptions) {
menuItems.push(...extraOptions);
}
return menuItems;
}, [
access,
archived_at,
extraOptions,
is_locked,
isMovePageEnabled,
canCurrentUserArchivePage,
canCurrentUserChangeAccess,
canCurrentUserDeletePage,
canCurrentUserDuplicatePage,
canCurrentUserLockPage,
canCurrentUserMovePage,
pageOperations,
]);
// arrange options
const arrangedOptions = useMemo(
() =>
optionsOrder
.map((key) => MENU_ITEMS.find((item) => item.key === key))
.filter((item) => !!item) as (TContextMenuItem & { key: TPageActions })[],
[optionsOrder, MENU_ITEMS]
);
return (
<>
<MovePageModal isOpen={movePageModal} onClose={() => setMovePageModal(false)} page={page} />
<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>
</>
);
});

View File

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

View File

@@ -0,0 +1,260 @@
import type { Dispatch, SetStateAction } from "react";
import { useCallback, useMemo } 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 {
EditorRefApi,
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";
// plane web imports
import { EditorAIMenu } from "@/plane-web/components/pages";
import type { TExtendedEditorExtensionsConfig } from "@/plane-web/hooks/pages";
import type { EPageStoreType } from "@/plane-web/hooks/store";
import { useEditorFlagging } from "@/plane-web/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 { PageEditorTitle } from "./title";
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>;
handleConnectionStatus: Dispatch<SetStateAction<boolean>>;
handleEditorReady: (status: boolean) => void;
handleOpenNavigationPane: () => void;
handlers: TEditorBodyHandlers;
isNavigationPaneOpen: boolean;
page: TPageInstance;
webhookConnectionParams: TWebhookConnectionQueryParams;
projectId: string;
workspaceSlug: string;
storeType: EPageStoreType;
extendedEditorProps: TExtendedEditorExtensionsConfig;
};
export const PageEditorBody: React.FC<Props> = observer((props) => {
const {
config,
editorForwardRef,
handleConnectionStatus,
handleEditorReady,
handleOpenNavigationPane,
handlers,
isNavigationPaneOpen,
page,
storeType,
webhookConnectionParams,
projectId,
workspaceSlug,
extendedEditorProps,
} = props;
// store hooks
const { data: currentUser } = useUser();
const { getWorkspaceBySlug } = useWorkspace();
const { getUserDetails } = useMember();
// derived values
const {
id: pageId,
name: pageTitle,
isContentEditable,
updateTitle,
editor: { editorRef, updateAssetsList },
} = page;
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? "";
// use editor mention
const { fetchMentions } = useEditorMention({
searchEntity: handlers.fetchEntity,
});
// editor flaggings
const { document: documentEditorExtensions } = useEditorFlagging({
workspaceSlug,
storeType,
});
// page filters
const { fontSize, fontStyle, isFullWidth } = usePageFilters();
// translation
const { t } = useTranslation();
// derived values
const displayConfig: TDisplayConfig = useMemo(
() => ({
fontSize,
fontStyle,
wideLayout: isFullWidth,
}),
[fontSize, fontStyle, isFullWidth]
);
const getAIMenu = useCallback(
({ isOpen, onClose }: TAIMenuProps) => (
<EditorAIMenu
editorRef={editorRef}
isOpen={isOpen}
onClose={onClose}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
/>
),
[editorRef, workspaceId, workspaceSlug]
);
const handleServerConnect = useCallback(() => {
handleConnectionStatus(false);
}, [handleConnectionStatus]);
const handleServerError = useCallback(() => {
handleConnectionStatus(true);
}, [handleConnectionStatus]);
const serverHandler: TServerHandler = useMemo(
() => ({
onConnect: handleServerConnect,
onServerError: handleServerError,
}),
[handleServerConnect, handleServerError]
);
const realtimeConfig: TRealtimeConfig | undefined = useMemo(() => {
// 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(
"block bg-transparent w-full max-w-[720px] mx-auto transition-all duration-200 ease-in-out",
{
"max-w-[1152px]": isFullWidth,
}
);
if (pageId === undefined || !realtimeConfig) return <PageContentLoader className={blockWidthClassName} />;
return (
<Row
className="relative size-full flex flex-col overflow-y-auto overflow-x-hidden vertical-scrollbar scrollbar-md 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 h-full right-0 top-[64px] z-[5]">
<div className="sticky top-[72px]">
<div className="group/page-toc relative px-page-x">
<div
className="!cursor-pointer max-h-[50vh] overflow-hidden"
role="button"
aria-label={t("page_navigation_pane.outline_floating_button")}
onClick={handleOpenNavigationPane}
>
<PageContentBrowser className="overflow-y-auto" editorRef={editorRef} showOutline />
</div>
<div className="absolute top-0 right-0 opacity-0 translate-x-1/2 pointer-events-none group-hover/page-toc:opacity-100 group-hover/page-toc:-translate-x-1/4 group-hover/page-toc:pointer-events-auto transition-all duration-300 w-52 max-h-[70vh] overflow-y-scroll vertical-scrollbar scrollbar-sm whitespace-nowrap bg-custom-background-90 p-4 rounded">
<PageContentBrowser className="overflow-y-auto" editorRef={editorRef} />
</div>
</div>
</div>
</div>
)}
<div className="page-header-container group/page-header">
<div className={blockWidthClassName}>
<PageEditorHeaderRoot page={page} projectId={projectId} />
<PageEditorTitle
editorRef={editorRef}
readOnly={!isContentEditable}
title={pageTitle}
updateTitle={updateTitle}
/>
</div>
</div>
<CollaborativeDocumentEditorWithRef
editable={isContentEditable}
id={pageId}
fileHandler={config.fileHandler}
handleEditorReady={handleEditorReady}
ref={editorForwardRef}
containerClassName="h-full p-0 pb-64"
displayConfig={displayConfig}
mentionHandler={{
searchCallback: async (query) => {
const res = await fetchMentions(query);
if (!res) throw new Error("Failed in fetching mentions");
return res;
},
renderComponent: (props) => <EditorMentionsRoot {...props} />,
getMentionedEntityDetails: (id: string) => ({ display_name: getUserDetails(id)?.display_name ?? "" }),
}}
realtimeConfig={realtimeConfig}
serverHandler={serverHandler}
user={userConfig}
disabledExtensions={documentEditorExtensions.disabled}
flaggedExtensions={documentEditorExtensions.flagged}
aiHandler={{
menu: getAIMenu,
}}
onAssetChange={updateAssetsList}
extendedEditorProps={extendedEditorProps}
/>
</div>
</Row>
);
});

View File

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

View File

@@ -0,0 +1,53 @@
import { useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { EmojiIconPicker, EmojiIconPickerTypes } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
// store
import type { TPageInstance } from "@/store/pages/base-page";
type Props = {
className?: string;
page: TPageInstance;
};
export const PageEditorHeaderLogoPicker: React.FC<Props> = observer((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, "max-h-0 pointer-events-none transition-all ease-linear duration-300", {
"max-h-[56px] pointer-events-auto": isLogoSelected,
})}
>
<EmojiIconPicker
isOpen={isLogoPickerOpen}
handleToggle={(val) => setIsLogoPickerOpen(val)}
className="flex items-center justify-center"
buttonClassName="flex items-center justify-center"
label={
<div
className={cn("-ml-[8px] size-[56px] grid place-items-center rounded transition-colors", {
"hover:bg-custom-background-80": 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>
);
});

View File

@@ -0,0 +1,71 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { SmilePlus } from "lucide-react";
// plane imports
import { EmojiIconPicker, EmojiIconPickerTypes } from "@plane/ui";
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: React.FC<Props> = observer((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="h-[48px] flex items-end text-left">
{!isLogoSelected && (
<div
className={cn("opacity-0 group-hover/page-header:opacity-100 transition-all duration-200", {
"opacity-100": isTitleEmpty,
})}
>
<EmojiIconPicker
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 p-1 rounded font-medium text-sm hover:bg-custom-background-80 text-custom-text-300 outline-none transition-colors",
{
"bg-custom-background-80": isLogoPickerOpen,
}
)}
>
<SmilePlus className="flex-shrink-0 size-4" />
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="flex-shrink-0 w-full mt-2 flex" page={page} />
</>
);
});

View File

@@ -0,0 +1,168 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import type { TDocumentPayload, TPage, TPageVersion, TWebhookConnectionQueryParams } from "@plane/types";
// hooks
import { useAppRouter } from "@/hooks/use-app-router";
import { usePageFallback } from "@/hooks/use-page-fallback";
// plane web import
import { PageModals } from "@/plane-web/components/pages";
import { usePagesPaneExtensions, useExtendedEditorProps } from "@/plane-web/hooks/pages";
import type { EPageStoreType } from "@/plane-web/hooks/store";
// store
import type { TPageInstance } from "@/store/pages/base-page";
// 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<any>;
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;
};
export const PageRoot = observer((props: TPageRootProps) => {
const { config, handlers, page, projectId, storeType, webhookConnectionParams, workspaceSlug } = props;
// states
const [editorReady, setEditorReady] = useState(false);
const [hasConnectionFailed, setHasConnectionFailed] = useState(false);
// refs
const editorRef = useRef<EditorRefApi>(null);
// router
const router = useAppRouter();
// derived values
const {
isContentEditable,
editor: { setEditorRef },
} = page;
// page fallback
usePageFallback({
editorRef,
fetchPageDescription: handlers.fetchDescriptionBinary,
hasConnectionFailed,
updatePageDescription: handlers.updateDescription,
});
const handleEditorReady = useCallback(
(status: boolean) => {
setEditorReady(status);
if (editorRef.current && !page.editor.editorRef) {
setEditorRef(editorRef.current);
}
},
[page.editor.editorRef, setEditorRef]
);
useEffect(() => {
setTimeout(() => {
setEditorRef(editorRef.current);
}, 0);
}, [isContentEditable, setEditorRef]);
// Get extensions and navigation logic from hook
const {
editorExtensionHandlers,
navigationPaneExtensions,
handleOpenNavigationPane,
handleCloseNavigationPane,
isNavigationPaneOpen,
} = usePagesPaneExtensions({
page,
editorRef,
});
// 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 size-full overflow-hidden flex transition-all duration-300 ease-in-out">
<div className="size-full flex 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}
/>
<PageEditorBody
config={config}
editorReady={editorReady}
editorForwardRef={editorRef}
handleConnectionStatus={setHasConnectionFailed}
handleEditorReady={handleEditorReady}
handleOpenNavigationPane={handleOpenNavigationPane}
handlers={handlers}
isNavigationPaneOpen={isNavigationPaneOpen}
page={page}
projectId={projectId}
storeType={storeType}
webhookConnectionParams={webhookConnectionParams}
workspaceSlug={workspaceSlug}
extendedEditorProps={extendedEditorProps}
/>
</div>
<PageNavigationPaneRoot
storeType={storeType}
handleClose={handleCloseNavigationPane}
isNavigationPaneOpen={isNavigationPaneOpen}
page={page}
versionHistory={{
fetchAllVersions: handlers.fetchAllVersions,
fetchVersionDetails: handlers.fetchVersionDetails,
}}
extensions={navigationPaneExtensions}
/>
<PageModals page={page} storeType={storeType} />
</div>
);
});

View File

@@ -0,0 +1,82 @@
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 const PageContentBrowser: React.FC<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(
"h-full flex flex-col items-start gap-y-1 mt-2",
{
"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="flex-shrink-0 h-0.5 bg-custom-border-400 self-end rounded-sm"
style={{
width: marking.level === 1 ? "20px" : marking.level === 2 ? "18px" : "14px",
}}
/>
);
return (
<Component
key={`${marking.level}-${marking.sequence}`}
marking={marking}
onClick={() => handleOnClick(marking)}
/>
);
})}
</div>
);
};

View File

@@ -0,0 +1,29 @@
// 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-custom-text-300 hover:text-custom-primary-100 truncate transition-colors";
export const OutlineHeading1 = ({ marking, onClick }: THeadingComponentProps) => (
<button type="button" onClick={onClick} className={cn(COMMON_CLASSNAME, "text-sm pl-1")}>
{marking.text}
</button>
);
export const OutlineHeading2 = ({ marking, onClick }: THeadingComponentProps) => (
<button type="button" onClick={onClick} className={cn(COMMON_CLASSNAME, "text-xs pl-2")}>
{marking.text}
</button>
);
export const OutlineHeading3 = ({ marking, onClick }: THeadingComponentProps) => (
<button type="button" onClick={onClick} className={cn(COMMON_CLASSNAME, "text-xs pl-4")}>
{marking.text}
</button>
);

View File

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

View File

@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
// editor
import type { EditorRefApi } from "@plane/editor";
// ui
import { TextArea } from "@plane/ui";
import { cn, getPageName } from "@plane/utils";
// helpers
// hooks
import { usePageFilters } from "@/hooks/use-page-filters";
type Props = {
editorRef: EditorRefApi | null;
readOnly: boolean;
title: string | undefined;
updateTitle: (title: string) => void;
};
export const PageEditorTitle: React.FC<Props> = observer((props) => {
const { editorRef, readOnly, title, updateTitle } = props;
// states
const [isLengthVisible, setIsLengthVisible] = useState(false);
// page filters
const { fontSize } = usePageFilters();
// ui
const titleFontClassName = cn("tracking-[-2%] font-bold", {
"text-[1.6rem] leading-[1.9rem]": fontSize === "small-font",
"text-[2rem] leading-[2.375rem]": fontSize === "large-font",
});
return (
<div className="relative w-full flex-shrink-0 py-3">
{readOnly ? (
<h6
className={cn(
titleFontClassName,
{
"text-custom-text-400": !title,
},
"break-words"
)}
>
{getPageName(title)}
</h6>
) : (
<div className="relative">
<TextArea
className={cn(titleFontClassName, "block w-full border-none outline-none p-0 resize-none rounded-none")}
placeholder="Untitled"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
editorRef?.setFocusAtPosition(0);
}
}}
value={title}
onChange={(e) => updateTitle(e.target.value)}
maxLength={255}
onFocus={() => setIsLengthVisible(true)}
onBlur={() => setIsLengthVisible(false)}
autoFocus
/>
<div
className={cn(
"pointer-events-none absolute bottom-1 right-1 z-[2] font-normal rounded bg-custom-background-100 p-0.5 text-xs text-custom-text-200 opacity-0 transition-opacity",
{
"opacity-100": isLengthVisible,
}
)}
>
<span
className={cn({
"text-red-500": title && title.length > 255,
})}
>
{title?.length}
</span>
/255
</div>
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,128 @@
"use client";
import { memo } from "react";
import { ALargeSmall, Ban } from "lucide-react";
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: React.FC<Props> = memo((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">
<Popover.Button
as="button"
type="button"
className={({ open }) =>
cn("h-full", {
"outline-none": open,
})
}
>
{({ open }) => (
<span
className={cn(
"h-full px-2 text-custom-text-300 text-sm flex items-center gap-1.5 rounded hover:bg-custom-background-80",
{
"text-custom-text-100 bg-custom-background-80": open,
}
)}
>
Color
<span
className={cn(
"flex-shrink-0 size-6 grid place-items-center rounded border-[0.5px] border-custom-border-300",
{
"bg-custom-background-100": !activeBackgroundColor,
}
)}
style={{
backgroundColor: activeBackgroundColor ? activeBackgroundColor.backgroundColor : "transparent",
}}
>
<ALargeSmall
className={cn("size-3.5", {
"text-custom-text-100": !activeTextColor,
})}
style={{
color: activeTextColor ? activeTextColor.textColor : "inherit",
}}
/>
</span>
</span>
)}
</Popover.Button>
<Popover.Panel
as="div"
className="fixed z-20 mt-1 rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-rg p-2 space-y-2"
>
<div className="space-y-1.5">
<p className="text-xs text-custom-text-300 font-semibold">Text colors</p>
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.textColor,
}}
onClick={() => handleColorSelect("text-color", color.key)}
/>
))}
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => handleColorSelect("text-color", undefined)}
>
<Ban className="size-4" />
</button>
</div>
</div>
<div className="space-y-1.5">
<p className="text-xs text-custom-text-300 font-semibold">Background colors</p>
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.backgroundColor,
}}
onClick={() => handleColorSelect("background-color", color.key)}
/>
))}
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => handleColorSelect("background-color", undefined)}
>
<Ban className="size-4" />
</button>
</div>
</div>
</Popover.Panel>
</Popover>
);
});
ColorDropdown.displayName = "ColorDropdown";

View File

@@ -0,0 +1,4 @@
export * from "./color-dropdown";
export * from "./options-dropdown";
export * from "./root";
export * from "./toolbar";

View File

@@ -0,0 +1,150 @@
"use client";
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { ArrowUpToLine, Clipboard, History } from "lucide-react";
// plane imports
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TContextMenuItem } from "@plane/ui";
import { ToggleSwitch } from "@plane/ui";
import { copyTextToClipboard } from "@plane/utils";
// 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 "@/plane-web/components/pages/navigation-pane";
import type { EPageStoreType } from "@/plane-web/hooks/store";
// store
import type { TPageInstance } from "@/store/pages/base-page";
// local imports
import type { TPageActions } from "../../dropdowns";
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: React.FC<Props> = observer((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: (TContextMenuItem & { key: TPageActions })[] = useMemo(
() => [
{
key: "full-screen",
action: () => handleFullWidth(!isFullWidth),
customContent: (
<>
Full width
<ToggleSwitch value={isFullWidth} onChange={() => {}} />
</>
),
className: "flex items-center justify-between gap-2",
},
{
key: "sticky-toolbar",
action: () => handleStickyToolbar(!isStickyToolbarEnabled),
customContent: (
<>
Sticky toolbar
<ToggleSwitch value={isStickyToolbarEnabled} onChange={() => {}} />
</>
),
className: "flex items-center justify-between gap-2",
shouldRender: isContentEditable,
},
{
key: "copy-markdown",
action: () => {
if (!editorRef) return;
copyTextToClipboard(editorRef.getMarkDown()).then(() =>
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Markdown copied to clipboard.",
})
);
},
title: "Copy markdown",
icon: Clipboard,
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: History,
shouldRender: true,
},
{
key: "export",
action: () => setIsExportModalOpen(true),
title: "Export",
icon: ArrowUpToLine,
shouldRender: true,
},
],
[
editorRef,
handleFullWidth,
handleStickyToolbar,
isContentEditable,
isFullWidth,
isStickyToolbarEnabled,
router,
updateQueryParams,
]
);
return (
<>
<ExportPageModal
editorRef={editorRef}
isOpen={isExportModalOpen}
onClose={() => setIsExportModalOpen(false)}
pageTitle={name ?? ""}
/>
<PageActions
extraOptions={EXTRA_MENU_OPTIONS}
optionsOrder={[
"full-screen",
"sticky-toolbar",
"make-a-copy",
"toggle-access",
"archive-restore",
"delete",
"version-history",
"copy-markdown",
"export",
]}
page={page}
storeType={storeType}
/>
</>
);
});

View File

@@ -0,0 +1,87 @@
import { observer } from "mobx-react";
import { PanelRight } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils";
// components
import { PageToolbar } from "@/components/pages/editor/toolbar";
// hooks
import { usePageFilters } from "@/hooks/use-page-filters";
// plane web components
import { PageCollaboratorsList } from "@/plane-web/components/pages/header/collaborators-list";
// store
import type { TPageInstance } from "@/store/pages/base-page";
type Props = {
handleOpenNavigationPane: () => void;
isNavigationPaneOpen: boolean;
page: TPageInstance;
};
export const PageEditorToolbarRoot: React.FC<Props> = observer((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] transition-all ease-linear duration-300 overflow-auto", {
"max-h-0 overflow-hidden": shouldHideToolbar,
})}
>
<div
className={cn(
"hidden md:flex items-center relative min-h-[52px] page-toolbar-content px-page-x transition-all duration-200 ease-in-out",
{
"wide-layout": isFullWidth,
}
)}
>
<div className="max-w-full w-full flex items-center justify-between">
{editorRef && <PageToolbar editorRef={editorRef} />}
<div className="flex items-center gap-2">
<PageCollaboratorsList page={page} />
{!isNavigationPaneOpen && (
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80 transition-colors"
onClick={handleOpenNavigationPane}
>
<PanelRight className="size-3.5" />
</button>
)}
</div>
</div>
</div>
</div>
{shouldHideToolbar && (
<div className="absolute z-10 top-0 right-0 h-[52px] px-page-x flex items-center">
{!isNavigationPaneOpen && (
<Tooltip tooltipContent={t("page_navigation_pane.open_button")}>
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80 transition-colors"
onClick={handleOpenNavigationPane}
aria-label={t("page_navigation_pane.open_button")}
>
<PanelRight className="size-3.5" />
</button>
</Tooltip>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,164 @@
"use client";
import React, { useEffect, useState, useCallback } from "react";
import { Check, ChevronDown } from "lucide-react";
// plane imports
import type { EditorRefApi } from "@plane/editor";
import { Tooltip } from "@plane/propel/tooltip";
import { CustomMenu } from "@plane/ui";
import { cn } from "@plane/utils";
// constants
import type { ToolbarMenuItem } from "@/constants/editor";
import { TOOLBAR_ITEMS, TYPOGRAPHY_ITEMS } from "@/constants/editor";
// local imports
import { ColorDropdown } from "./color-dropdown";
type Props = {
editorRef: EditorRefApi;
};
type ToolbarButtonProps = {
item: ToolbarMenuItem;
isActive: boolean;
executeCommand: EditorRefApi["executeMenuItemCommand"];
};
const ToolbarButton: React.FC<ToolbarButtonProps> = React.memo((props) => {
const { item, isActive, executeCommand } = props;
return (
<Tooltip
tooltipContent={
<p className="flex flex-col gap-1 text-center text-xs">
<span className="font-medium">{item.name}</span>
{item.shortcut && <kbd className="text-custom-text-400">{item.shortcut.join(" + ")}</kbd>}
</p>
}
>
<button
type="button"
onClick={() =>
// TODO: update this while toolbar homogenization
// @ts-expect-error type mismatch here
executeCommand({
itemKey: item.itemKey,
...item.extraProps,
})
}
className={cn("grid size-7 place-items-center rounded text-custom-text-300 hover:bg-custom-background-80", {
"bg-custom-background-80 text-custom-text-100": isActive,
})}
>
<item.icon
className={cn("size-4", {
"text-custom-text-100": isActive,
})}
/>
</button>
</Tooltip>
);
});
ToolbarButton.displayName = "ToolbarButton";
const toolbarItems = TOOLBAR_ITEMS.document;
export const PageToolbar: React.FC<Props> = (props) => {
const { editorRef } = props;
// states
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
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);
updateActiveStates();
return () => unsubscribe();
}, [editorRef, updateActiveStates]);
const activeTypography = TYPOGRAPHY_ITEMS.find((item) =>
editorRef.isMenuItemActive({
itemKey: item.itemKey,
...item.extraProps,
})
);
return (
<div className="flex items-center divide-x divide-custom-border-200 overflow-x-scroll">
<CustomMenu
customButton={
<span className="text-custom-text-300 text-sm border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 h-7 w-24 rounded px-2 flex items-center justify-between gap-2 whitespace-nowrap text-left">
{activeTypography?.name || "Text"}
<ChevronDown className="flex-shrink-0 size-3" />
</span>
}
className="pr-2"
placement="bottom-start"
closeOnSelect
maxHeight="lg"
>
{TYPOGRAPHY_ITEMS.map((item) => (
<CustomMenu.MenuItem
key={item.renderKey}
className="flex items-center justify-between gap-2"
onClick={() =>
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 && (
<Check className="size-3 text-custom-text-300 flex-shrink-0" />
)}
</CustomMenu.MenuItem>
))}
</CustomMenu>
<div className="flex-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>
);
};

View File

@@ -0,0 +1,39 @@
"use client";
import { observer } from "mobx-react";
// plane web components
import { PageLockControl } from "@/plane-web/components/pages/header/lock-control";
import { PageMoveControl } from "@/plane-web/components/pages/header/move-control";
import { PageShareControl } from "@/plane-web/components/pages/header/share-control";
// plane web hooks
import type { EPageStoreType } from "@/plane-web/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";
type Props = {
page: TPageInstance;
storeType: EPageStoreType;
};
export const PageHeaderActions: React.FC<Props> = observer((props) => {
const { page, storeType } = props;
return (
<div className="flex items-center gap-1">
<PageArchivedBadge page={page} />
<PageOfflineBadge page={page} />
<PageLockControl page={page} />
<PageMoveControl page={page} />
<PageCopyLinkControl page={page} />
<PageFavoriteControl page={page} />
<PageShareControl page={page} storeType={storeType} />
<PageOptionsDropdown page={page} storeType={storeType} />
</div>
);
});

View File

@@ -0,0 +1,21 @@
import { observer } from "mobx-react";
// plane imports
import { ArchiveIcon } from "@plane/propel/icons";
import { renderFormattedDate } from "@plane/utils";
// store
import type { TPageInstance } from "@/store/pages/base-page";
type Props = {
page: TPageInstance;
};
export const PageArchivedBadge = observer(({ page }: Props) => {
if (!page.archived_at) return null;
return (
<div className="flex-shrink-0 h-6 flex items-center gap-1 px-2 rounded text-custom-primary-100 bg-custom-primary-100/20">
<ArchiveIcon className="flex-shrink-0 size-3.5" />
<span className="text-xs font-medium">Archived at {renderFormattedDate(page.archived_at)}</span>
</div>
);
});

View File

@@ -0,0 +1,27 @@
import { observer } from "mobx-react";
import { Link } from "lucide-react";
// 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(({ page }: Props) => {
// page operations
const { pageOperations } = usePageOperations({
page,
});
return (
<button
type="button"
onClick={pageOperations.copyLink}
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80 transition-colors"
>
<Link className="size-3.5" />
</button>
);
});

View File

@@ -0,0 +1,40 @@
import { observer } from "mobx-react";
// constants
import { PROJECT_PAGE_TRACKER_ELEMENTS } from "@plane/constants";
// ui
import { FavoriteStar } from "@plane/ui";
// helpers
import { captureClick } from "@/helpers/event-tracker.helper";
// 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(({ page }: Props) => {
// derived values
const { is_favorite, canCurrentUserFavoritePage } = page;
// page operations
const { pageOperations } = usePageOperations({
page,
});
if (!canCurrentUserFavoritePage) return null;
return (
<FavoriteStar
selected={is_favorite}
onClick={() => {
captureClick({
elementName: PROJECT_PAGE_TRACKER_ELEMENTS.FAVORITE_BUTTON,
});
pageOperations.toggleFavorite();
}}
buttonClassName="flex-shrink-0 size-6 group rounded hover:bg-custom-background-80 transition-colors"
iconClassName="size-3.5 text-custom-text-200 group-hover:text-custom-text-10"
/>
);
});

View File

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

View File

@@ -0,0 +1,30 @@
import { observer } from "mobx-react";
// plane imports
import { Tooltip } from "@plane/propel/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(({ page }: Props) => {
// use online status
const { isOnline } = useOnlineStatus();
if (!page.isContentEditable || isOnline) return null;
return (
<Tooltip
tooltipHeading="You are offline."
tooltipContent="You can continue making changes. They will be synced when you are back online."
>
<div className="flex-shrink-0 flex h-7 items-center gap-2 rounded-full bg-custom-background-80 px-3 py-0.5 text-xs font-medium text-custom-text-300">
<span className="flex-shrink-0 size-1.5 rounded-full bg-custom-text-300" />
<span>Offline</span>
</div>
</Tooltip>
);
});

View File

@@ -0,0 +1,101 @@
import { useCallback } from "react";
import { observer } from "mobx-react";
import { ListFilter } from "lucide-react";
// 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";
// plane web hooks
import type { EPageStoreType } from "@/plane-web/hooks/store";
import { usePageStore } from "@/plane-web/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: React.FC<Props> = observer((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={<ListFilter 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>
)}
</>
);
});

View File

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

View File

@@ -0,0 +1,79 @@
import { X } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { TPageFilterProps } from "@plane/types";
import { Tag } from "@plane/ui";
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 const PageAppliedFiltersList: React.FC<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 bg-custom-background-100">
{Object.entries(appliedFilters).map(([key, value]) => {
const filterKey = key as keyof TPageFilterProps;
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
return (
<Tag key={filterKey}>
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-custom-text-300">{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-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemoveFilter(filterKey, null)}
>
<X size={12} strokeWidth={2} />
</button>
)}
</div>
</Tag>
);
})}
{isEditingAllowed && (
<button type="button" onClick={handleClearAllFilters}>
<Tag>
{t("common.clear_all")}
<X size={12} strokeWidth={2} />
</Tag>
</button>
)}
</div>
);
};

View File

@@ -0,0 +1,97 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import { Earth, Info, Lock, Minus } from "lucide-react";
// plane imports
import { PROJECT_PAGE_TRACKER_ELEMENTS } from "@plane/constants";
import { Tooltip } from "@plane/propel/tooltip";
import { Avatar, FavoriteStar } from "@plane/ui";
import { renderFormattedDate, getFileURL } from "@plane/utils";
// helpers
import { captureClick } from "@/helpers/event-tracker.helper";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { usePageOperations } from "@/hooks/use-page-operations";
// plane web hooks
import type { EPageStoreType } from "@/plane-web/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>;
storeType: EPageStoreType;
};
export const BlockItemAction: FC<Props> = observer((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 tooltipHeading="Owned by" tooltipContent={ownerDetails?.display_name}>
<Avatar src={getFileURL(ownerDetails?.avatar_url ?? "")} name={ownerDetails?.display_name} />
</Tooltip>
</div>
<div className="cursor-default text-custom-text-300">
<Tooltip tooltipContent={access === 0 ? "Public" : "Private"}>
{access === 0 ? <Earth className="h-4 w-4" /> : <Lock className="h-4 w-4" />}
</Tooltip>
</div>
{/* vertical divider */}
<Minus className="h-5 w-5 text-custom-text-400 rotate-90 -mx-3" strokeWidth={1} />
{/* page info */}
<Tooltip tooltipContent={`Created on ${renderFormattedDate(created_at)}`}>
<span className="h-4 w-4 grid place-items-center cursor-default">
<Info className="h-4 w-4 text-custom-text-300" />
</span>
</Tooltip>
{/* favorite/unfavorite */}
{canCurrentUserFavoritePage && (
<FavoriteStar
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
captureClick({
elementName: PROJECT_PAGE_TRACKER_ELEMENTS.FAVORITE_BUTTON,
});
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}
/>
</>
);
});

View File

@@ -0,0 +1,57 @@
"use client";
import type { FC } from "react";
import { useRef } from "react";
import { observer } from "mobx-react";
import { PageIcon } from "@plane/propel/icons";
// plane imports
import { getPageName } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
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 "@/plane-web/hooks/store";
import { usePage } from "@/plane-web/hooks/store";
type TPageListBlock = {
pageId: string;
storeType: EPageStoreType;
};
export const PageListBlock: FC<TPageListBlock> = observer((props) => {
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" />
) : (
<PageIcon className="h-4 w-4 text-custom-text-300" />
)}
</>
}
title={getPageName(name)}
itemLink={getRedirectionLink()}
actionableItems={<BlockItemAction page={page} parentRef={parentRef} storeType={storeType} />}
isMobile={isMobile}
parentRef={parentRef}
/>
);
});

View File

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

View File

@@ -0,0 +1,102 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { Search, X } from "lucide-react";
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: React.FC<Props> = observer((props) => {
const { filters, handleFiltersUpdate, memberIds } = props;
// states
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
// store
const { isMobile } = usePlatformOS();
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-custom-background-100 p-2.5 pb-0">
<div className="flex items-center gap-1.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 py-1 text-xs">
<Search className="text-custom-text-400" size={12} strokeWidth={2} />
<input
type="text"
className="w-full bg-custom-background-90 outline-none placeholder:text-custom-text-400"
placeholder="Search"
value={filtersSearchQuery}
onChange={(e) => setFiltersSearchQuery(e.target.value)}
autoFocus={!isMobile}
/>
{filtersSearchQuery !== "" && (
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
<X className="text-custom-text-300" size={12} strokeWidth={2} />
</button>
)}
</div>
</div>
<div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 vertical-scrollbar scrollbar-sm">
<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>
);
});

View File

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

View File

@@ -0,0 +1,87 @@
"use client";
import { ArrowDownWideNarrow, ArrowUpWideNarrow, Check, ChevronDown } from "lucide-react";
// types
import { getButtonStyling } from "@plane/propel/button";
import type { TPageFiltersSortBy, TPageFiltersSortKey } from "@plane/types";
// ui
import { CustomMenu } from "@plane/ui";
// helpers
import { cn } from "@plane/utils";
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 const PageOrderByDropdown: React.FC<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={cn(getButtonStyling("neutral-primary", "sm"), "px-2 text-custom-text-300")}>
{!isDescending ? <ArrowUpWideNarrow className="size-3 " /> : <ArrowDownWideNarrow className="size-3 " />}
{orderByDetails?.label}
<ChevronDown className="h-3 w-3" strokeWidth={2} />
</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 && <Check className="h-3 w-3" />}
</CustomMenu.MenuItem>
))}
<hr className="my-2 border-custom-border-200" />
<CustomMenu.MenuItem
className="flex items-center justify-between gap-2"
onClick={() => {
if (isDescending)
onChange({
order: "asc",
});
}}
>
Ascending
{!isDescending && <Check 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 && <Check className="h-3 w-3" />}
</CustomMenu.MenuItem>
</CustomMenu>
);
};

View File

@@ -0,0 +1,33 @@
import type { FC } from "react";
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 "@/plane-web/hooks/store";
import { usePageStore } from "@/plane-web/hooks/store";
// local imports
import { PageListBlock } from "./block";
type TPagesListRoot = {
pageType: TPageNavigationTabs;
storeType: EPageStoreType;
};
export const PagesListRoot: FC<TPagesListRoot> = observer((props) => {
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>
);
});

View File

@@ -0,0 +1,85 @@
import type { FC } from "react";
import { useState, useRef, useEffect } from "react";
import { Search, X } from "lucide-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
// helpers
import { cn } from "@plane/utils";
type Props = {
searchQuery: string;
updateSearchQuery: (val: string) => void;
};
export const PageSearchInput: FC<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();
}
}
};
useEffect(() => {
if (searchQuery.trim() !== "") setIsSearchOpen(true);
}, [searchQuery]);
return (
<div className="flex">
{!isSearchOpen && (
<button
type="button"
className="flex-shrink-0 hover:bg-custom-background-80 rounded text-custom-text-400 relative flex justify-center items-center w-6 h-6 my-auto"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<Search className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
"flex items-center justify-start rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
{
"w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
placeholder="Search pages"
value={searchQuery}
onChange={(e) => updateSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
updateSearchQuery("");
setIsSearchOpen(false);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,61 @@
import type { FC } from "react";
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 const PageTabNavigation: FC<TPageTabNavigation> = (props) => {
const { workspaceSlug, projectId, pageType } = props;
const handleTabClick = (e: React.MouseEvent<HTMLAnchorElement>, tabKey: TPageNavigationTabs) => {
if (tabKey === pageType) e.preventDefault();
};
return (
<div className="relative flex items-center">
{pageTabs.map((tab) => (
<Link
key={tab.key}
href={`/${workspaceSlug}/projects/${projectId}/pages?type=${tab.key}`}
onClick={(e) => handleTabClick(e, tab.key)}
>
<span
className={cn(`block p-3 py-4 text-sm font-medium transition-all`, {
"text-custom-primary-100": tab.key === pageType,
})}
>
{tab.label}
</span>
<div
className={cn(`rounded-t border-t-2 transition-all border-transparent`, {
"border-custom-primary-100": tab.key === pageType,
})}
/>
</Link>
))}
</div>
);
};

View File

@@ -0,0 +1,85 @@
"use client";
// plane imports
import { Loader } from "@plane/ui";
import { cn } from "@plane/utils";
type Props = {
className?: string;
};
export const PageContentLoader = (props: Props) => {
const { className } = props;
return (
<div className={cn("relative size-full flex flex-col", className)}>
{/* header */}
<div className="flex-shrink-0 w-full h-12 border-b border-custom-border-100 relative flex items-center divide-x divide-custom-border-100">
<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="size-full pt-[64px] overflow-hidden relative flex">
{/* 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>
);
};

View File

@@ -0,0 +1,35 @@
"use client";
import { range } from "lodash-es";
import { Loader } from "@plane/ui";
export const PageLoader: React.FC = (props) => {
const {} = props;
return (
<div className="relative w-full h-full flex flex-col">
<div className="px-3 border-b border-custom-border-100 py-3">
<Loader className="relative flex items-center gap-2">
<Loader.Item width="200px" height="30px" />
<div className="relative flex items-center gap-2 ml-auto">
<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 p-3 py-4 border-b border-custom-border-100">
<Loader.Item width={`${250 + 10 * Math.floor(Math.random() * 10)}px`} height="22px" />
<div className="ml-auto relative 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>
);
};

View File

@@ -0,0 +1,99 @@
import type { FC } from "react";
import { useEffect, useState } from "react";
// constants
import type { EPageAccess } from "@plane/constants";
import { PROJECT_PAGE_TRACKER_EVENTS } from "@plane/constants";
import type { TPage } from "@plane/types";
// ui
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// hooks
import { captureSuccess, captureError } from "@/helpers/event-tracker.helper";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web hooks
import type { EPageStoreType } from "@/plane-web/hooks/store";
import { usePageStore } from "@/plane-web/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 const CreatePageModal: FC<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) {
captureSuccess({
eventName: PROJECT_PAGE_TRACKER_EVENTS.create,
payload: {
id: pageData.id,
},
});
handleStateClear();
if (redirectionEnabled) router.push(`/${workspaceSlug}/projects/${projectId}/pages/${pageData.id}`);
}
} catch (error: any) {
captureError({
eventName: PROJECT_PAGE_TRACKER_EVENTS.create,
error,
});
}
};
return (
<ModalCore
isOpen={isModalOpen}
handleClose={handleModalClose}
position={EModalPosition.TOP}
width={EModalWidth.XXL}
>
<PageForm
formData={pageFormData}
handleFormData={handlePageFormData}
handleModalClose={handleStateClear}
handleFormSubmit={handleFormSubmit}
/>
</ModalCore>
);
};

View File

@@ -0,0 +1,99 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react";
// ui
import { useParams } from "next/navigation";
import { PROJECT_PAGE_TRACKER_EVENTS } from "@plane/constants";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { AlertModalCore } from "@plane/ui";
// constants
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// plane web hooks
import { useAppRouter } from "@/hooks/use-app-router";
import type { EPageStoreType } from "@/plane-web/hooks/store";
import { usePageStore } from "@/plane-web/hooks/store";
// store
import type { TPageInstance } from "@/store/pages/base-page";
type TConfirmPageDeletionProps = {
isOpen: boolean;
onClose: () => void;
page: TPageInstance;
storeType: EPageStoreType;
};
export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((props) => {
const { isOpen, onClose, page, storeType } = props;
// states
const [isDeleting, setIsDeleting] = useState(false);
// store hooks
const { removePage } = usePageStore(storeType);
if (!page || !page.id) return null;
// derived values
const { id: pageId, name } = page;
const handleClose = () => {
setIsDeleting(false);
onClose();
};
const router = useAppRouter();
const { pageId: routePageId } = useParams();
const handleDelete = async () => {
setIsDeleting(true);
await removePage({ pageId })
.then(() => {
captureSuccess({
eventName: PROJECT_PAGE_TRACKER_EVENTS.delete,
payload: {
id: pageId,
},
});
handleClose();
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Page deleted successfully.",
});
if (routePageId) {
router.back();
}
})
.catch(() => {
captureError({
eventName: PROJECT_PAGE_TRACKER_EVENTS.delete,
payload: {
id: pageId,
},
});
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Page could not be deleted. Please try again.",
});
});
setIsDeleting(false);
};
return (
<AlertModalCore
handleClose={handleClose}
handleSubmit={handleDelete}
isSubmitting={isDeleting}
isOpen={isOpen}
title="Delete page"
content={
<>
Are you sure you want to delete page-{" "}
<span className="break-words font-medium text-custom-text-100 break-all">{name}</span> ? The Page will be
deleted permanently. This action cannot be undone.
</>
}
/>
);
});

View File

@@ -0,0 +1,286 @@
"use client";
import { useState } from "react";
import type { PageProps } from "@react-pdf/renderer";
import { pdf } from "@react-pdf/renderer";
import { Controller, useForm } from "react-hook-form";
// 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 const ExportPageModal: React.FC<Props> = (props) => {
const { editorRef, isOpen, onClose, pageTitle } = props;
// states
const [isExporting, setIsExporting] = useState(false);
// form info
const { control, reset, watch } = useForm<TFormValues>({
defaultValues,
});
// parse editor content
const { replaceCustomComponentsFromHTMLContent, replaceCustomComponentsFromMarkdownContent } =
useParseEditorContent();
// 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="p-5 space-y-5">
<h3 className="text-xl font-medium text-custom-text-200">Export page</h3>
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<h6 className="flex-shrink-0 text-sm text-custom-text-200">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-sm text-custom-text-200">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-sm text-custom-text-200">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="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
Cancel
</Button>
<Button variant="primary" size="sm" loading={isExporting} onClick={handleExport}>
{isExporting ? "Exporting" : "Export"}
</Button>
</div>
</div>
</ModalCore>
);
};

View File

@@ -0,0 +1,157 @@
"use client";
import type { FormEvent } from "react";
import { useState } from "react";
import type { LucideIcon } from "lucide-react";
import { Globe2, Lock } from "lucide-react";
// plane imports
import { ETabIndices, EPageAccess } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { PageIcon } from "@plane/propel/icons";
import type { TPage } from "@plane/types";
import { EmojiIconPicker, EmojiIconPickerTypes, Input } from "@plane/ui";
import { convertHexEmojiToDecimal, getTabIndex } from "@plane/utils";
// components
import { AccessField } from "@/components/common/access-field";
import { Logo } from "@/components/common/logo";
// 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;
}[] = [
{ key: EPageAccess.PUBLIC, i18n_label: "common.access.public", icon: Globe2 },
{ key: EPageAccess.PRIVATE, i18n_label: "common.access.private", icon: Lock },
];
export const PageForm: React.FC<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-xl font-medium text-custom-text-200">Create page</h3>
<div className="flex items-start gap-2 h-9 w-full">
<EmojiIconPicker
isOpen={isOpen}
handleToggle={(val: boolean) => setIsOpen(val)}
className="flex items-center justify-center flex-shrink0"
buttonClassName="flex items-center justify-center"
label={
<span className="grid h-9 w-9 place-items-center rounded-md bg-custom-background-90">
<>
{formData?.logo_props?.in_use ? (
<Logo logo={formData?.logo_props} size={18} type="lucide" />
) : (
<PageIcon className="h-4 w-4 text-custom-text-300" />
)}
</>
</span>
}
onChange={(val: any) => {
let logoValue = {};
if (val?.type === "emoji")
logoValue = {
value: convertHexEmojiToDecimal(val.value.unified),
url: val.value.imageUrl,
};
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="space-y-1 flew-grow w-full">
<Input
id="name"
type="text"
value={formData.name}
onChange={(e) => handleFormData("name", e.target.value)}
placeholder="Title"
className="w-full resize-none text-base"
tabIndex={getIndex("name")}
required
autoFocus
/>
{isTitleLengthMoreThan255Character && (
<span className="text-xs text-red-500">Max length of the name should be less than 255 characters</span>
)}
</div>
</div>
</div>
<div className="px-5 py-4 flex items-center justify-between gap-2 border-t-[0.5px] border-custom-border-200">
<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-xs font-medium">{t(i18n_access_label || "")}</h6>
</div>
<div className="flex items-center justify-end gap-2">
<Button variant="neutral-primary" size="sm" onClick={handleModalClose} tabIndex={getIndex("cancel")}>
Cancel
</Button>
<Button
variant="primary"
size="sm"
type="submit"
loading={isSubmitting}
disabled={isTitleLengthMoreThan255Character}
tabIndex={getIndex("submit")}
>
{isSubmitting ? "Creating" : "Create Page"}
</Button>
</div>
</div>
</form>
);
};

View File

@@ -0,0 +1,12 @@
// plane web imports
import { ORDERED_PAGE_NAVIGATION_TABS_LIST } from "@/plane-web/components/pages/navigation-pane";
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);

View File

@@ -0,0 +1,117 @@
import React, { useCallback } from "react";
import { observer } from "mobx-react";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowRightCircle } from "lucide-react";
import { Tab } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@plane/propel/tooltip";
// hooks
import { useQueryParams } from "@/hooks/use-query-params";
// plane web components
import type { TPageNavigationPaneTab } from "@/plane-web/components/pages/navigation-pane";
// store
import type { EPageStoreType } from "@/plane-web/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_TAB_KEYS,
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: React.FC<Props> = observer((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";
const selectedIndex = PAGE_NAVIGATION_PANE_TAB_KEYS.indexOf(activeTab);
// 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(
(index: number) => {
const updatedTab = PAGE_NAVIGATION_PANE_TAB_KEYS[index];
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-shrink-0 h-full flex flex-col bg-custom-background-100 pt-3.5 border-l border-custom-border-200 transition-all duration-300 ease-out"
style={{
width: `${paneWidth}px`,
marginRight: isNavigationPaneOpen ? "0px" : `-${paneWidth}px`,
}}
>
<div className="mb-3.5 px-3.5">
<Tooltip tooltipContent={t("page_navigation_pane.close_button")}>
<button
type="button"
className="size-3.5 grid place-items-center text-custom-text-200 hover:text-custom-text-100 transition-colors"
onClick={handleClose}
aria-label={t("page_navigation_pane.close_button")}
>
<ArrowRightCircle className="size-3.5" />
</button>
</Tooltip>
</div>
<div className="flex-1 flex flex-col overflow-hidden animate-slide-in-right">
{ActiveExtension ? (
<ActiveExtension.component page={page} extensionData={ActiveExtension.data} storeType={storeType} />
) : showNavigationTabs ? (
<Tab.Group as={React.Fragment} selectedIndex={selectedIndex} onChange={handleTabChange}>
<PageNavigationPaneTabsList />
<PageNavigationPaneTabPanelsRoot page={page} versionHistory={versionHistory} />
</Tab.Group>
) : null}
</div>
</aside>
);
});

View File

@@ -0,0 +1,120 @@
import { useMemo } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Download } from "lucide-react";
// 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";
// plane web imports
import { AdditionalPageNavigationPaneAssetItem } from "@/plane-web/components/pages/navigation-pane/tab-panels/assets";
import { PageNavigationPaneAssetsTabEmptyState } from "@/plane-web/components/pages/navigation-pane/tab-panels/empty-states/assets";
// store
import type { TPageInstance } from "@/store/pages/base-page";
type Props = {
page: TPageInstance;
};
type AssetItemProps = {
asset: TEditorAsset;
page: TPageInstance;
};
const AssetItem = observer((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="relative group/asset-item h-12 flex items-center gap-2 pr-2 rounded border border-custom-border-200 hover:bg-custom-background-80 transition-colors"
>
<div
className="flex-shrink-0 w-11 h-12 rounded-l bg-cover bg-no-repeat bg-center"
style={{
backgroundImage: `url('${assetSrc}')`,
}}
/>
<div className="flex-1 space-y-0.5 truncate">
<p className="text-sm font-medium truncate">{asset.name}</p>
<div className="flex items-end justify-between gap-2">
<p className="shrink-0 text-xs text-custom-text-200" />
<a
href={assetDownloadSrc}
target="_blank"
rel="noreferrer noopener"
className="shrink-0 py-0.5 px-1 flex items-center gap-1 rounded text-custom-text-200 hover:text-custom-text-100 opacity-0 pointer-events-none group-hover/asset-item:opacity-100 group-hover/asset-item:pointer-events-auto transition-opacity"
>
<Download className="shrink-0 size-3" />
<span className="text-xs font-medium">{t("page_navigation_pane.tabs.assets.download_button")}</span>
</a>
</div>
</div>
</a>
);
return (
<AdditionalPageNavigationPaneAssetItem
asset={asset}
assetSrc={assetSrc}
assetDownloadSrc={assetDownloadSrc}
page={page}
/>
);
});
export const PageNavigationPaneAssetsTabPanel: React.FC<Props> = observer((props) => {
const { page } = props;
// derived values
const {
editor: { assetsList },
} = page;
if (assetsList.length === 0) return <PageNavigationPaneAssetsTabEmptyState />;
return (
<div className="mt-5 space-y-4">
{assetsList?.map((asset) => (
<AssetItem key={asset.id} asset={asset} page={page} />
))}
</div>
);
});

View File

@@ -0,0 +1,68 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Avatar } from "@plane/ui";
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: React.FC<Props> = observer((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="space-y-3 mt-4">
<div>
<p className="text-xs font-medium text-custom-text-300">
{t("page_navigation_pane.tabs.info.actors_info.edited_by")}
</p>
<div className="mt-2 flex items-center justify-between gap-2 text-sm font-medium">
<Link href={`/${workspaceSlug?.toString()}/profile/${page.updated_by}`} className="flex items-center gap-1">
<Avatar
src={getFileURL(editorInformation?.avatar_url ?? "")}
name={editorInformation?.display_name}
className="flex-shrink-0"
size="sm"
/>
<span>{editorInformation?.display_name ?? t("common.deactivated_user")}</span>
</Link>
<span className="flex-shrink-0 text-custom-text-300">{calculateTimeAgoShort(page.updated_at ?? "")} ago</span>
</div>
</div>
<div>
<p className="text-xs font-medium text-custom-text-300">
{t("page_navigation_pane.tabs.info.actors_info.created_by")}
</p>
<div className="mt-2 flex items-center justify-between gap-2 text-sm font-medium">
<Link href={`/${workspaceSlug?.toString()}/profile/${page.created_by}`} className="flex items-center gap-1">
<Avatar
src={getFileURL(creatorInformation?.avatar_url ?? "")}
name={creatorInformation?.display_name}
className="flex-shrink-0"
size="sm"
/>
<span>{creatorInformation?.display_name ?? t("common.deactivated_user")}</span>
</Link>
<span className="flex-shrink-0 text-custom-text-300">{renderFormattedDate(page.created_at)}</span>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,82 @@
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: React.FC<Props> = observer((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="p-2 bg-custom-background-90 rounded">
<h6 className="text-base font-semibold">{card.info}</h6>
<p className="mt-1.5 text-sm text-custom-text-300 font-medium">{card.title}</p>
</div>
))}
</div>
);
});

View File

@@ -0,0 +1,27 @@
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: React.FC<Props> = observer((props) => {
const { page, versionHistory } = props;
return (
<div className="mt-5">
<PageNavigationPaneInfoTabDocumentInfo page={page} />
<PageNavigationPaneInfoTabActorsInfo page={page} />
<div className="flex-shrink-0 h-px bg-custom-background-80 my-3" />
<PageNavigationPaneInfoTabVersionHistory page={page} versionHistory={versionHistory} />
</div>
);
});

View File

@@ -0,0 +1,142 @@
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 { useTranslation } from "@plane/i18n";
import type { TPageVersion } from "@plane/types";
import { Avatar } from "@plane/ui";
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((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-xs font-medium">
{/* timeline icon */}
<div className="relative size-6 flex-none grid place-items-center">
<div className="size-2 rounded-full bg-custom-background-80" />
</div>
{/* end timeline icon */}
<Link
href={getVersionLink(version.id)}
className={cn("block flex-1 hover:bg-custom-background-90 rounded-md py-2 px-1", {
"bg-custom-background-80 hover:bg-custom-background-80": isVersionActive,
})}
>
<p className="text-custom-text-300">
{renderFormattedDate(version.last_saved_at)}, {renderFormattedTime(version.last_saved_at)}
</p>
<p className="mt-1 flex items-center gap-1">
<Avatar
size="sm"
src={getFileURL(versionCreator?.avatar_url ?? "")}
name={versionCreator?.display_name}
className="flex-shrink-0"
/>
<span>{versionCreator?.display_name ?? t("common.deactivated_user")}</span>
</p>
</Link>
</li>
);
});
export const PageNavigationPaneInfoTabVersionHistory: React.FC<Props> = observer((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-xs font-medium text-custom-text-200">
{t("page_navigation_pane.tabs.info.version_history.label")}
</p>
<div className="mt-3">
<ul role="list" className="relative">
{/* timeline line */}
<div className={cn("absolute left-0 top-0 h-full flex w-6 justify-center")}>
<div className="w-px bg-custom-background-80" />
</div>
{/* end timeline line */}
<li className="relative flex items-center gap-x-4 text-xs font-medium">
{/* timeline icon */}
<div className="relative size-6 flex-none rounded-full grid place-items-center bg-custom-primary-100/20">
<div className="size-2.5 rounded-full bg-custom-primary-100/40" />
</div>
{/* end timeline icon */}
<Link
href={getVersionLink()}
className={cn("flex-1 hover:bg-custom-background-90 rounded-md py-2 px-1", {
"bg-custom-background-80 hover:bg-custom-background-80": !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>
);
});

View File

@@ -0,0 +1,28 @@
// plane web imports
import { PageNavigationPaneOutlineTabEmptyState } from "@/plane-web/components/pages/navigation-pane/tab-panels/empty-states/outline";
// store
import type { TPageInstance } from "@/store/pages/base-page";
// local imports
import { PageContentBrowser } from "../../editor/summary";
type Props = {
page: TPageInstance;
};
export const PageNavigationPaneOutlineTabPanel: React.FC<Props> = (props) => {
const { page } = props;
// derived values
const {
editor: { editorRef },
} = page;
return (
<div className="size-full pt-3 space-y-1">
<PageContentBrowser
className="mt-0"
editorRef={editorRef}
emptyState={<PageNavigationPaneOutlineTabEmptyState />}
/>
</div>
);
};

View File

@@ -0,0 +1,39 @@
import React from "react";
import { Tab } from "@headlessui/react";
// components
import type { TPageRootHandlers } from "@/components/pages/editor/page-root";
// plane web imports
import { ORDERED_PAGE_NAVIGATION_TABS_LIST } from "@/plane-web/components/pages/navigation-pane";
import { PageNavigationPaneAdditionalTabPanelsRoot } from "@/plane-web/components/pages/navigation-pane/tab-panels/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";
type Props = {
page: TPageInstance;
versionHistory: Pick<TPageRootHandlers, "fetchAllVersions" | "fetchVersionDetails">;
};
export const PageNavigationPaneTabPanelsRoot: React.FC<Props> = (props) => {
const { page, versionHistory } = props;
return (
<Tab.Panels as={React.Fragment}>
{ORDERED_PAGE_NAVIGATION_TABS_LIST.map((tab) => (
<Tab.Panel
key={tab.key}
as="div"
className="size-full p-3.5 pt-0 overflow-y-auto vertical-scrollbar scrollbar-sm outline-none"
>
{tab.key === "outline" && <PageNavigationPaneOutlineTabPanel page={page} />}
{tab.key === "info" && <PageNavigationPaneInfoTabPanel page={page} versionHistory={versionHistory} />}
{tab.key === "assets" && <PageNavigationPaneAssetsTabPanel page={page} />}
<PageNavigationPaneAdditionalTabPanelsRoot activeTab={tab.key} page={page} />
</Tab.Panel>
))}
</Tab.Panels>
);
};

View File

@@ -0,0 +1,37 @@
import { Tab } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
// plane web components
import { ORDERED_PAGE_NAVIGATION_TABS_LIST } from "@/plane-web/components/pages/navigation-pane";
export const PageNavigationPaneTabsList = () => {
// translation
const { t } = useTranslation();
return (
<Tab.List className="relative flex items-center p-[2px] rounded-md bg-custom-background-80 mx-3.5">
{({ selectedIndex }) => (
<>
{ORDERED_PAGE_NAVIGATION_TABS_LIST.map((tab) => (
<Tab
key={tab.key}
type="button"
className="relative z-[1] flex-1 py-1.5 text-sm font-semibold outline-none"
>
{t(tab.i18n_label)}
</Tab>
))}
{/* active tab indicator */}
<div
className="absolute top-1/2 -translate-y-1/2 bg-custom-background-90 rounded transition-all duration-500 ease-in-out pointer-events-none"
style={{
left: `calc(${(selectedIndex / ORDERED_PAGE_NAVIGATION_TABS_LIST.length) * 100}% + 2px)`,
height: "calc(100% - 4px)",
width: `calc(${100 / ORDERED_PAGE_NAVIGATION_TABS_LIST.length}% - 4px)`,
}}
/>
</>
)}
</Tab.List>
);
};

View File

@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
import type { EPageStoreType } from "@/plane-web/hooks/store";
import type { TPageInstance } from "@/store/pages/base-page";
export interface INavigationPaneExtensionProps<T = any> {
page: TPageInstance;
extensionData?: T;
storeType: EPageStoreType;
}
export interface INavigationPaneExtensionComponent<T = any> {
(props: INavigationPaneExtensionProps<T>): ReactNode;
}
export interface INavigationPaneExtension<T = any> {
id: string;
triggerParam: string;
component: INavigationPaneExtensionComponent<T>;
data?: T;
width?: number;
}

View File

@@ -0,0 +1,6 @@
// Export generic extension system interfaces
export type {
INavigationPaneExtensionProps,
INavigationPaneExtensionComponent,
INavigationPaneExtension,
} from "./extensions";

View File

@@ -0,0 +1,191 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
// plane imports
import { useParams, useRouter } from "next/navigation";
import {
EUserPermissionsLevel,
EPageAccess,
PROJECT_PAGE_TRACKER_ELEMENTS,
PROJECT_PAGE_TRACKER_EVENTS,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TPage, TPageNavigationTabs } from "@plane/types";
import { EUserProjectRoles } from "@plane/types";
// components
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { PageLoader } from "@/components/pages/loaders/page-loader";
import { captureClick, captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
// plane web hooks
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
type Props = {
children: React.ReactNode;
pageType: TPageNavigationTabs;
storeType: EPageStoreType;
};
export const PagesListMainContent: React.FC<Props> = observer((props) => {
const { children, pageType, storeType } = props;
// plane hooks
const { t } = useTranslation();
// store hooks
const { currentProjectDetails } = useProject();
const { isAnyPageAvailable, getCurrentProjectFilteredPageIdsByTab, getCurrentProjectPageIdsByTab, filters, 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
);
const generalPageResolvedPath = useResolvedAssetPath({
basePath: "/empty-state/onboarding/pages",
});
const publicPageResolvedPath = useResolvedAssetPath({
basePath: "/empty-state/wiki/public",
});
const privatePageResolvedPath = useResolvedAssetPath({
basePath: "/empty-state/wiki/private",
});
const archivedPageResolvedPath = useResolvedAssetPath({
basePath: "/empty-state/wiki/archived",
});
const resolvedFiltersImage = useResolvedAssetPath({ basePath: "/empty-state/wiki/all-filters", extension: "svg" });
const resolvedNameFilterImage = useResolvedAssetPath({
basePath: "/empty-state/wiki/name-filter",
extension: "svg",
});
// handle page create
const handleCreatePage = async () => {
setIsCreatingPage(true);
const payload: Partial<TPage> = {
access: pageType === "private" ? EPageAccess.PRIVATE : EPageAccess.PUBLIC,
};
await createPage(payload)
.then((res) => {
captureSuccess({
eventName: PROJECT_PAGE_TRACKER_EVENTS.create,
payload: {
id: res?.id,
state: "SUCCESS",
},
});
const pageId = `/${workspaceSlug}/projects/${currentProjectDetails?.id}/pages/${res?.id}`;
router.push(pageId);
})
.catch((err) => {
captureError({
eventName: PROJECT_PAGE_TRACKER_EVENTS.create,
payload: {
state: "ERROR",
},
});
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 (
<DetailedEmptyState
title={t("project_page.empty_state.general.title")}
description={t("project_page.empty_state.general.description")}
assetPath={generalPageResolvedPath}
primaryButton={{
text: isCreatingPage ? t("creating") : t("project_page.empty_state.general.primary_button.text"),
onClick: () => {
handleCreatePage();
captureClick({ elementName: PROJECT_PAGE_TRACKER_ELEMENTS.EMPTY_STATE_CREATE_BUTTON });
},
disabled: !canPerformEmptyStateActions || isCreatingPage,
}}
/>
);
}
if (pageType === "public")
return (
<DetailedEmptyState
title={t("project_page.empty_state.public.title")}
description={t("project_page.empty_state.public.description")}
assetPath={publicPageResolvedPath}
primaryButton={{
text: isCreatingPage ? t("creating") : t("project_page.empty_state.public.primary_button.text"),
onClick: () => {
handleCreatePage();
captureClick({ elementName: PROJECT_PAGE_TRACKER_ELEMENTS.EMPTY_STATE_CREATE_BUTTON });
},
disabled: !canPerformEmptyStateActions || isCreatingPage,
}}
/>
);
if (pageType === "private")
return (
<DetailedEmptyState
title={t("project_page.empty_state.private.title")}
description={t("project_page.empty_state.private.description")}
assetPath={privatePageResolvedPath}
primaryButton={{
text: isCreatingPage ? t("creating") : t("project_page.empty_state.private.primary_button.text"),
onClick: () => {
handleCreatePage();
captureClick({ elementName: PROJECT_PAGE_TRACKER_ELEMENTS.EMPTY_STATE_CREATE_BUTTON });
},
disabled: !canPerformEmptyStateActions || isCreatingPage,
}}
/>
);
if (pageType === "archived")
return (
<DetailedEmptyState
title={t("project_page.empty_state.archived.title")}
description={t("project_page.empty_state.archived.description")}
assetPath={archivedPageResolvedPath}
/>
);
}
// if no pages match the filter criteria
if (filteredPageIds?.length === 0)
return (
<div className="h-full w-full grid place-items-center">
<div className="text-center">
<Image
src={filters.searchQuery.length > 0 ? resolvedNameFilterImage : resolvedFiltersImage}
className="h-36 sm:h-48 w-36 sm:w-48 mx-auto"
alt="No matching modules"
/>
<h5 className="text-xl font-medium mt-7 mb-1">No matching pages</h5>
<p className="text-custom-text-400 text-base">
{filters.searchQuery.length > 0
? "Remove the search criteria to see all pages"
: "Remove the filters to see all pages"}
</p>
</div>
</div>
);
return <div className="h-full w-full overflow-hidden">{children}</div>;
});

View File

@@ -0,0 +1,46 @@
import { observer } from "mobx-react";
import useSWR from "swr";
import type { TPageNavigationTabs } from "@plane/types";
// plane web hooks
import type { EPageStoreType } from "@/plane-web/hooks/store";
import { usePageStore } from "@/plane-web/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: React.FC<TPageView> = observer((props) => {
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 w-full h-full overflow-hidden flex flex-col">
{/* tab header */}
{isAnyPageAvailable && (
<PagesListHeaderRoot
pageType={pageType}
projectId={projectId}
storeType={storeType}
workspaceSlug={workspaceSlug}
/>
)}
<PagesListMainContent pageType={pageType} storeType={storeType}>
{children}
</PagesListMainContent>
</div>
);
});

View File

@@ -0,0 +1,101 @@
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 "@/plane-web/hooks/store";
export type TVersionEditorProps = {
activeVersion: string | null;
versionDetails: TPageVersion | undefined;
storeType: EPageStoreType;
};
export const PagesVersionEditor: React.FC<TVersionEditorProps> = observer((props) => {
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() ?? ""}
/>
);
});

View File

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

View File

@@ -0,0 +1,128 @@
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { EyeIcon, TriangleAlert } from "lucide-react";
// 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 "@/plane-web/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: React.FC<Props> = observer((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-grow flex flex-col overflow-hidden">
{versionDetailsError ? (
<div className="flex-grow grid place-items-center">
<div className="flex flex-col items-center gap-4 text-center">
<span className="flex-shrink-0 grid place-items-center size-11 text-custom-text-300">
<TriangleAlert className="size-10" />
</span>
<div>
<h6 className="text-lg font-semibold">Something went wrong!</h6>
<p className="text-sm text-custom-text-300">The version could not be loaded, please try again.</p>
</div>
<Button variant="link-primary" onClick={handleRetry} loading={isRetrying}>
Try again
</Button>
</div>
</div>
) : (
<>
<div className="min-h-14 py-3 px-5 border-b border-custom-border-200 flex items-center justify-between gap-2">
<div className="flex items-center gap-4">
<h6 className="text-base font-medium">
{versionDetails
? `${renderFormattedDate(versionDetails.last_saved_at)} ${renderFormattedTime(versionDetails.last_saved_at)}`
: "Loading version details"}
</h6>
<span className="flex-shrink-0 flex items-center gap-1 text-xs font-medium text-custom-primary-100 bg-custom-primary-100/20 py-1 px-1.5 rounded">
<EyeIcon className="flex-shrink-0 size-3" />
View only
</span>
</div>
{restoreEnabled && (
<Button
variant="primary"
size="sm"
className="flex-shrink-0"
onClick={handleRestoreVersion}
loading={isRestoring}
>
{isRestoring ? "Restoring" : "Restore"}
</Button>
)}
</div>
<div className="pt-8 h-full overflow-y-scroll vertical-scrollbar scrollbar-sm">
<VersionEditor activeVersion={activeVersion} storeType={storeType} versionDetails={versionDetails} />
</div>
</>
)}
</div>
);
});

View File

@@ -0,0 +1,67 @@
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 "@/plane-web/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: React.FC<Props> = observer((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(
"absolute inset-0 z-[16] h-full bg-custom-background-100 flex overflow-hidden opacity-0 pointer-events-none transition-opacity",
{
"opacity-100 pointer-events-auto": 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>
);
});