[WIKI-400] feat: page navigation pane (#7206)

* init: page navigation pane

* chore: outline and info tabs

* chore: asset download endpoint

* chore: realtime document info updates

* chore: add support for code splitting

* fix: formatting

* refactor: image block id generation

* chore: implement translation

* refactor: assets list storage logic

* fix: build errors

* fix: image extension name

* refactor: add support for additional asset items

* refactor: asset extraction logic

* chore: add translations

* fix: merge conflicts resolved from preview

* chore: remove version history option from the dropdown

* chore: query params handling

* chore: remove unnecessary logic

* refactor: empty state components

* fix: empty state asset path
This commit is contained in:
Aaryan Khandelwal 2025-07-02 15:25:52 +05:30 committed by GitHub
parent cfe169c6d7
commit 0b159c4963
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
83 changed files with 2185 additions and 767 deletions

View file

@ -0,0 +1,31 @@
export type TPageNavigationPaneTab = "outline" | "info" | "assets";
export const PAGE_NAVIGATION_PANE_TABS_LIST: Record<
TPageNavigationPaneTab,
{
key: TPageNavigationPaneTab;
i18n_label: string;
}
> = {
outline: {
key: "outline",
i18n_label: "page_navigation_pane.tabs.outline.label",
},
info: {
key: "info",
i18n_label: "page_navigation_pane.tabs.info.label",
},
assets: {
key: "assets",
i18n_label: "page_navigation_pane.tabs.assets.label",
},
};
export const ORDERED_PAGE_NAVIGATION_TABS_LIST: {
key: TPageNavigationPaneTab;
i18n_label: string;
}[] = [
PAGE_NAVIGATION_PANE_TABS_LIST.outline,
PAGE_NAVIGATION_PANE_TABS_LIST.info,
PAGE_NAVIGATION_PANE_TABS_LIST.assets,
];

View file

@ -0,0 +1,11 @@
// plane imports
import { TEditorAsset } from "@plane/editor";
// store
import { TPageInstance } from "@/store/pages/base-page";
export type TAdditionalPageNavigationPaneAssetItemProps = {
asset: TEditorAsset;
page: TPageInstance;
};
export const AdditionalPageNavigationPaneAssetItem: React.FC<TAdditionalPageNavigationPaneAssetItemProps> = () => null;

View file

@ -0,0 +1,26 @@
import Image from "next/image";
// plane imports
import { useTranslation } from "@plane/i18n";
// hooks
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
export const PageNavigationPaneAssetsTabEmptyState = () => {
// asset resolved path
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/pages/navigation-pane/assets" });
// translation
const { t } = useTranslation();
return (
<div className="size-full grid place-items-center">
<div className="flex flex-col items-center gap-y-6 text-center">
<Image src={resolvedPath} width={160} height={160} alt="An image depicting the assets of a page" />
<div className="space-y-2.5">
<h4 className="text-base font-medium">{t("page_navigation_pane.tabs.assets.empty_state.title")}</h4>
<p className="text-sm text-custom-text-200 font-medium">
{t("page_navigation_pane.tabs.assets.empty_state.description")}
</p>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,26 @@
import Image from "next/image";
// plane imports
import { useTranslation } from "@plane/i18n";
// hooks
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
export const PageNavigationPaneOutlineTabEmptyState = () => {
// asset resolved path
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/pages/navigation-pane/outline" });
// translation
const { t } = useTranslation();
return (
<div className="size-full grid place-items-center">
<div className="flex flex-col items-center gap-y-6 text-center">
<Image src={resolvedPath} width={160} height={160} alt="An image depicting the outline of a page" />
<div className="space-y-2.5">
<h4 className="text-base font-medium">{t("page_navigation_pane.tabs.outline.empty_state.title")}</h4>
<p className="text-sm text-custom-text-200 font-medium">
{t("page_navigation_pane.tabs.outline.empty_state.description")}
</p>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,13 @@
// store
import type { TPageInstance } from "@/store/pages/base-page";
// local imports
import { TPageNavigationPaneTab } from "..";
export type TPageNavigationPaneAdditionalTabPanelsRootProps = {
activeTab: TPageNavigationPaneTab;
page: TPageInstance;
};
export const PageNavigationPaneAdditionalTabPanelsRoot: React.FC<
TPageNavigationPaneAdditionalTabPanelsRootProps
> = () => null;

View file

@ -10,7 +10,7 @@ import {
TRealtimeConfig,
TServerHandler,
} from "@plane/editor";
// plane imports
import { useTranslation } from "@plane/i18n";
import { TSearchEntityRequestPayload, TSearchResponse, TWebhookConnectionQueryParams } from "@plane/types";
import { ERowVariant, Row } from "@plane/ui";
import { cn, generateRandomColor, hslToHex } from "@plane/utils";
@ -46,7 +46,9 @@ type Props = {
editorForwardRef: React.RefObject<EditorRefApi>;
handleConnectionStatus: Dispatch<SetStateAction<boolean>>;
handleEditorReady: (status: boolean) => void;
handleOpenNavigationPane: () => void;
handlers: TEditorBodyHandlers;
isNavigationPaneOpen: boolean;
page: TPageInstance;
webhookConnectionParams: TWebhookConnectionQueryParams;
workspaceSlug: string;
@ -58,7 +60,9 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
editorForwardRef,
handleConnectionStatus,
handleEditorReady,
handleOpenNavigationPane,
handlers,
isNavigationPaneOpen,
page,
webhookConnectionParams,
workspaceSlug,
@ -67,9 +71,14 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
const { data: currentUser } = useUser();
const { getWorkspaceBySlug } = useWorkspace();
const { getUserDetails } = useMember();
// derived values
const { id: pageId, name: pageTitle, isContentEditable, updateTitle, editorRef } = page;
const {
id: pageId,
name: pageTitle,
isContentEditable,
updateTitle,
editor: { editorRef, updateAssetsList },
} = page;
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? "";
// issue-embed
const { issueEmbedProps } = useIssueEmbed({
@ -84,6 +93,8 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
const { document: documentEditorExtensions } = useEditorFlagging(workspaceSlug);
// page filters
const { fontSize, fontStyle, isFullWidth } = usePageFilters();
// translation
const { t } = useTranslation();
// derived values
const displayConfig: TDisplayConfig = useMemo(
() => ({
@ -167,18 +178,25 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
>
<div id="page-content-container" className="relative w-full flex-shrink-0">
{/* table of content */}
<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">
<PageContentBrowser 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 editorRef={editorRef} />
{!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 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 editorRef={editorRef} />
</div>
</div>
</div>
</div>
</div>
)}
<div className="page-header-container group/page-header">
<div className={blockWidthClassName}>
<PageEditorHeaderRoot page={page} />
@ -218,6 +236,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
aiHandler={{
menu: getAIMenu,
}}
onAssetChange={updateAssetsList}
/>
</div>
</Row>

View file

@ -1,9 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
// editor
// plane imports
import { EditorRefApi } from "@plane/editor";
// types
import { TDocumentPayload, TPage, TPageVersion, TWebhookConnectionQueryParams } from "@plane/types";
// components
import {
@ -18,8 +17,17 @@ import {
import { useAppRouter } from "@/hooks/use-app-router";
import { usePageFallback } from "@/hooks/use-page-fallback";
import { useQueryParams } from "@/hooks/use-query-params";
// plane web import
import { TPageNavigationPaneTab } from "@/plane-web/components/pages/navigation-pane";
// store
import { TPageInstance } from "@/store/pages/base-page";
// local imports
import {
PAGE_NAVIGATION_PANE_TAB_KEYS,
PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM,
PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM,
PageNavigationPaneRoot,
} from "../navigation-pane";
export type TPageRootHandlers = {
create: (payload: Partial<TPage>) => Promise<Partial<TPage> | undefined>;
@ -45,7 +53,6 @@ export const PageRoot = observer((props: TPageRootProps) => {
// states
const [editorReady, setEditorReady] = useState(false);
const [hasConnectionFailed, setHasConnectionFailed] = useState(false);
const [isVersionsOverlayOpen, setIsVersionsOverlayOpen] = useState(false);
// refs
const editorRef = useRef<EditorRefApi>(null);
// router
@ -53,7 +60,10 @@ export const PageRoot = observer((props: TPageRootProps) => {
// search params
const searchParams = useSearchParams();
// derived values
const { isContentEditable, setEditorRef } = page;
const {
isContentEditable,
editor: { setEditorRef },
} = page;
// page fallback
usePageFallback({
editorRef,
@ -67,11 +77,11 @@ export const PageRoot = observer((props: TPageRootProps) => {
const handleEditorReady = useCallback(
(status: boolean) => {
setEditorReady(status);
if (editorRef.current && !page.editorRef) {
if (editorRef.current && !page.editor.editorRef) {
setEditorRef(editorRef.current);
}
},
[page.editorRef, setEditorRef]
[page.editor.editorRef, setEditorRef]
);
useEffect(() => {
@ -80,27 +90,10 @@ export const PageRoot = observer((props: TPageRootProps) => {
}, 0);
}, [isContentEditable, setEditorRef]);
const version = searchParams.get("version");
useEffect(() => {
if (!version) {
setIsVersionsOverlayOpen(false);
return;
}
setIsVersionsOverlayOpen(true);
}, [version]);
const handleCloseVersionsOverlay = () => {
const updatedRoute = updateQueryParams({
paramsToRemove: ["version"],
});
router.push(updatedRoute);
};
const handleRestoreVersion = async (descriptionHTML: string) => {
const handleRestoreVersion = useCallback(async (descriptionHTML: string) => {
editorRef.current?.clearEditor();
editorRef.current?.setEditorValue(descriptionHTML);
};
const currentVersionDescription = editorRef.current?.getDocument().html;
}, []);
// reset editor ref on unmount
useEffect(
@ -110,32 +103,64 @@ export const PageRoot = observer((props: TPageRootProps) => {
[setEditorRef]
);
const navigationPaneQueryParam = searchParams.get(
PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM
) as TPageNavigationPaneTab | null;
const isValidNavigationPaneTab =
!!navigationPaneQueryParam && PAGE_NAVIGATION_PANE_TAB_KEYS.includes(navigationPaneQueryParam);
const handleOpenNavigationPane = useCallback(() => {
const updatedRoute = updateQueryParams({
paramsToAdd: { [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM]: "outline" },
});
router.push(updatedRoute);
}, [router, updateQueryParams]);
const handleCloseNavigationPane = useCallback(() => {
const updatedRoute = updateQueryParams({
paramsToRemove: [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM],
});
router.push(updatedRoute);
}, [router, updateQueryParams]);
return (
<>
<PageVersionsOverlay
activeVersion={version}
currentVersionDescription={currentVersionDescription ?? null}
editorComponent={PagesVersionEditor}
fetchAllVersions={handlers.fetchAllVersions}
fetchVersionDetails={handlers.fetchVersionDetails}
handleRestore={handleRestoreVersion}
isOpen={isVersionsOverlayOpen}
onClose={handleCloseVersionsOverlay}
pageId={page.id ?? ""}
restoreEnabled={isContentEditable}
/>
<PageEditorToolbarRoot page={page} />
<PageEditorBody
config={config}
editorReady={editorReady}
editorForwardRef={editorRef}
handleConnectionStatus={setHasConnectionFailed}
handleEditorReady={handleEditorReady}
handlers={handlers}
<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}
/>
<PageEditorToolbarRoot
handleOpenNavigationPane={handleOpenNavigationPane}
isNavigationPaneOpen={isValidNavigationPaneTab}
page={page}
/>
<PageEditorBody
config={config}
editorReady={editorReady}
editorForwardRef={editorRef}
handleConnectionStatus={setHasConnectionFailed}
handleEditorReady={handleEditorReady}
handleOpenNavigationPane={handleOpenNavigationPane}
handlers={handlers}
isNavigationPaneOpen={isValidNavigationPaneTab}
page={page}
webhookConnectionParams={webhookConnectionParams}
workspaceSlug={workspaceSlug}
/>
</div>
<PageNavigationPaneRoot
handleClose={handleCloseNavigationPane}
isNavigationPaneOpen={isValidNavigationPaneTab}
page={page}
webhookConnectionParams={webhookConnectionParams}
workspaceSlug={workspaceSlug}
versionHistory={{
fetchAllVersions: handlers.fetchAllVersions,
fetchVersionDetails: handlers.fetchVersionDetails,
}}
/>
</>
</div>
);
});

View file

@ -1,17 +1,20 @@
import { useState, useEffect, useCallback } from "react";
// plane editor
// plane imports
import { EditorRefApi, IMarking } from "@plane/editor";
import { cn } from "@plane/utils";
// components
import { OutlineHeading1, OutlineHeading2, OutlineHeading3 } from "./heading-components";
import { OutlineHeading1, OutlineHeading2, OutlineHeading3, THeadingComponentProps } 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 { editorRef, setSidePeekVisible, showOutline = false } = props;
const { className, editorRef, emptyState, setSidePeekVisible, showOutline = false } = props;
// states
const [headings, setHeadings] = useState<IMarking[]>([]);
@ -20,7 +23,7 @@ export const PageContentBrowser: React.FC<Props> = (props) => {
// for initial render of this component to get the editor headings
setHeadings(editorRef?.getHeadings() ?? []);
return () => {
if (unsubscribe) unsubscribe();
unsubscribe?.();
};
}, [editorRef]);
@ -33,15 +36,25 @@ export const PageContentBrowser: React.FC<Props> = (props) => {
);
const HeadingComponent: {
[key: number]: React.FC<{ marking: IMarking; onClick: () => void }>;
[key: number]: React.FC<THeadingComponentProps>;
} = {
1: OutlineHeading1,
2: OutlineHeading2,
3: OutlineHeading3,
};
if (headings.length === 0) return emptyState ?? null;
return (
<div className="h-full flex flex-col items-start gap-y-2 overflow-y-auto mt-2">
<div
className={cn(
"h-full flex flex-col items-start gap-y-1 overflow-y-auto mt-2",
{
"gap-y-2": showOutline,
},
className
)}
>
{headings.map((marking) => {
const Component = HeadingComponent[marking.level];
if (!Component) return null;

View file

@ -1,37 +1,29 @@
// plane editor
// 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 =
"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="text-sm text-left font-medium text-custom-text-300 hover:text-custom-primary-100 transition-colors"
>
<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="ml-2 text-xs text-left font-medium text-custom-text-300 hover:text-custom-primary-100 transition-colors"
>
<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="ml-4 text-xs text-left font-medium text-custom-text-300 hover:text-custom-primary-100 transition-colors"
>
<button type="button" onClick={onClick} className={cn(COMMON_CLASSNAME, "text-xs pl-4")}>
{marking.text}
</button>
);

View file

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

View file

@ -1,139 +0,0 @@
import { useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { usePopper } from "react-popper";
import { Info } from "lucide-react";
// plane imports
import { Avatar } from "@plane/ui";
import { calculateTimeAgoShort, getFileURL, getReadTimeFromWordsCount, renderFormattedDate } from "@plane/utils";
// hooks
import { useMember } from "@/hooks/store";
// store
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
page: TPageInstance;
};
export const PageInfoPopover: React.FC<Props> = observer((props) => {
const { page } = props;
// states
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
// refs
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// router
const { workspaceSlug } = useParams();
// popper-js
const { styles: infoPopoverStyles, attributes: infoPopoverAttributes } = usePopper(referenceElement, popperElement, {
placement: "bottom-start",
});
// store hooks
const { getUserDetails } = useMember();
// derived values
const editorInformation = page.updated_by ? getUserDetails(page.updated_by) : undefined;
const creatorInformation = page.created_by ? getUserDetails(page.created_by) : undefined;
const documentsInfo = page.editorRef?.getDocumentInfo() || { words: 0, characters: 0, paragraphs: 0 };
const secondsToReadableTime = () => {
const wordsCount = documentsInfo.words;
const readTimeInSeconds = Number(getReadTimeFromWordsCount(wordsCount).toFixed(0));
return readTimeInSeconds < 60 ? `${readTimeInSeconds}s` : `${Math.ceil(readTimeInSeconds / 60)}m`;
};
const documentInfoCards = [
{
key: "words-count",
title: "Words",
info: documentsInfo.words,
},
{
key: "characters-count",
title: "Characters",
info: documentsInfo.characters,
},
{
key: "paragraphs-count",
title: "Paragraphs",
info: documentsInfo.paragraphs,
},
{
key: "read-time",
title: "Read time",
info: secondsToReadableTime(),
},
];
return (
<div
className="flex-shrink-0"
onMouseEnter={() => setIsPopoverOpen(true)}
onMouseLeave={() => setIsPopoverOpen(false)}
>
<button
type="button"
ref={setReferenceElement}
className="size-6 grid place-items-center rounded text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80 transition-colors"
>
<Info className="size-3.5" />
</button>
{isPopoverOpen && (
<div
className="z-10 w-64 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-2 shadow-custom-shadow-rg"
ref={setPopperElement}
style={infoPopoverStyles.popper}
{...infoPopoverAttributes.popper}
>
<div className="grid grid-cols-2 gap-1.5">
{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">{card.title}</p>
</div>
))}
</div>
<div className="space-y-2 mt-3">
<div>
<p className="text-xs font-medium text-custom-text-300">Edited by</p>
<Link
href={`/${workspaceSlug?.toString()}/profile/${page.updated_by}`}
className="mt-2 flex items-center gap-1.5 text-sm font-medium"
>
<Avatar
src={getFileURL(editorInformation?.avatar_url ?? "")}
name={editorInformation?.display_name}
className="flex-shrink-0"
size="sm"
/>
<span>
{editorInformation?.display_name}{" "}
<span className="text-custom-text-300">{calculateTimeAgoShort(page.updated_at ?? "")} ago</span>
</span>
</Link>
</div>
<div>
<p className="text-xs font-medium text-custom-text-300">Created by</p>
<Link
href={`/${workspaceSlug?.toString()}/profile/${page.created_by}`}
className="mt-2 flex items-center gap-1.5 text-sm font-medium"
>
<Avatar
src={getFileURL(creatorInformation?.avatar_url ?? "")}
name={creatorInformation?.display_name}
className="flex-shrink-0"
size="sm"
/>
<span>
{creatorInformation?.display_name}{" "}
<span className="text-custom-text-300">{renderFormattedDate(page.created_at)}</span>
</span>
</Link>
</div>
</div>
</div>
)}
</div>
);
});

View file

@ -2,18 +2,15 @@
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
import { ArrowUpToLine, Clipboard, History } from "lucide-react";
import { ArrowUpToLine, Clipboard } from "lucide-react";
// plane imports
import { TContextMenuItem, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
// components
import { copyTextToClipboard } from "@plane/utils";
// components
import { ExportPageModal, PageActions, TPageActions } from "@/components/pages";
// helpers
// hooks
import { usePageFilters } from "@/hooks/use-page-filters";
import { useQueryParams } from "@/hooks/use-query-params";
// plane web hooks
// plane web imports
import { EPageStoreType } from "@/plane-web/hooks/store";
// store
import { TPageInstance } from "@/store/pages/base-page";
@ -27,14 +24,14 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
const { page, storeType } = props;
// states
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
// router
const router = useRouter();
// store values
const { name, isContentEditable, editorRef } = page;
const {
name,
isContentEditable,
editor: { editorRef },
} = page;
// page filters
const { isFullWidth, handleFullWidth, isStickyToolbarEnabled, handleStickyToolbar } = usePageFilters();
// update query params
const { updateQueryParams } = useQueryParams();
// menu items list
const EXTRA_MENU_OPTIONS: (TContextMenuItem & { key: TPageActions })[] = useMemo(
() => [
@ -77,19 +74,6 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
icon: Clipboard,
shouldRender: true,
},
{
key: "version-history",
action: () => {
// add query param, version=current to the route
const updatedRoute = updateQueryParams({
paramsToAdd: { version: "current" },
});
router.push(updatedRoute);
},
title: "Version history",
icon: History,
shouldRender: true,
},
{
key: "export",
action: () => setIsExportModalOpen(true),
@ -98,16 +82,7 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
shouldRender: true,
},
],
[
editorRef,
handleFullWidth,
handleStickyToolbar,
isContentEditable,
isFullWidth,
isStickyToolbarEnabled,
router,
updateQueryParams,
]
[editorRef, handleFullWidth, handleStickyToolbar, isContentEditable, isFullWidth, isStickyToolbarEnabled]
);
return (

View file

@ -1,6 +1,10 @@
import { observer } from "mobx-react";
// components
import { PanelRight } from "lucide-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { PageToolbar } from "@/components/pages";
// helpers
// hooks
@ -11,38 +15,74 @@ import { PageCollaboratorsList } from "@/plane-web/components/pages/header/colla
import { TPageInstance } from "@/store/pages/base-page";
type Props = {
handleOpenNavigationPane: () => void;
isNavigationPaneOpen: boolean;
page: TPageInstance;
};
export const PageEditorToolbarRoot: React.FC<Props> = observer((props) => {
const { page } = props;
const { handleOpenNavigationPane, isNavigationPaneOpen, page } = props;
// translation
const { t } = useTranslation();
// derived values
const { isContentEditable, editorRef } = page;
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,
}
)}
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="max-w-full w-full flex items-center justify-between">
{editorRef && <PageToolbar editorRef={editorRef} />}
<PageCollaboratorsList page={page} />
<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>
</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

@ -2,7 +2,7 @@
import { observer } from "mobx-react";
// components
import { PageInfoPopover, PageOptionsDropdown } from "@/components/pages";
import { PageOptionsDropdown } from "@/components/pages";
// plane web components
import { PageLockControl } from "@/plane-web/components/pages/header/lock-control";
import { PageMoveControl } from "@/plane-web/components/pages/header/move-control";
@ -31,7 +31,6 @@ export const PageHeaderActions: React.FC<Props> = observer((props) => {
<PageOfflineBadge page={page} />
<PageLockControl page={page} />
<PageMoveControl page={page} />
<PageInfoPopover page={page} />
<PageCopyLinkControl page={page} />
<PageFavoriteControl page={page} />
<PageShareControl page={page} storeType={storeType} />

View file

@ -0,0 +1,11 @@
// plane web imports
import { ORDERED_PAGE_NAVIGATION_TABS_LIST } from "@/plane-web/components/pages/navigation-pane";
export * from "./root";
export const PAGE_NAVIGATION_PANE_WIDTH = 294;
export const PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM = "sidebarTab";
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,88 @@
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/ui";
// hooks
import { useQueryParams } from "@/hooks/use-query-params";
// plane web components
import { TPageNavigationPaneTab } from "@/plane-web/components/pages/navigation-pane";
// store
import { TPageInstance } from "@/store/pages/base-page";
// local imports
import { TPageRootHandlers } from "../editor";
import { PageNavigationPaneTabPanelsRoot } from "./tab-panels/root";
import { PageNavigationPaneTabsList } from "./tabs-list";
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">;
};
export const PageNavigationPaneRoot: React.FC<Props> = observer((props) => {
const { handleClose, isNavigationPaneOpen, page, versionHistory } = 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);
// 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-in-out"
style={{
width: `${PAGE_NAVIGATION_PANE_WIDTH}px`,
marginRight: isNavigationPaneOpen ? "0px" : `-${PAGE_NAVIGATION_PANE_WIDTH}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>
<Tab.Group as={React.Fragment} selectedIndex={selectedIndex} onChange={handleTabChange}>
<PageNavigationPaneTabsList />
<PageNavigationPaneTabPanelsRoot page={page} versionHistory={versionHistory} />
</Tab.Group>
</aside>
);
});

View file

@ -0,0 +1,109 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Download } from "lucide-react";
// plane imports
import { CORE_EXTENSIONS, 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 { 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 getAssetSrc = (path: string) => {
if (!path || !workspaceSlug) return "";
if (path.startsWith("http")) {
return path;
} else {
return (
getEditorAssetSrc({
assetId: path,
projectId: project_ids?.[0],
workspaceSlug: workspaceSlug.toString(),
}) ?? ""
);
}
};
const getAssetDownloadSrc = (path: string) => {
if (!path || !workspaceSlug) return "";
if (path.startsWith("http")) {
return path;
} else {
return (
getEditorAssetDownloadSrc({
assetId: path,
projectId: project_ids?.[0],
workspaceSlug: workspaceSlug.toString(),
}) ?? ""
);
}
};
if ([CORE_EXTENSIONS.IMAGE, CORE_EXTENSIONS.CUSTOM_IMAGE].includes(asset.type))
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('${getAssetSrc(asset.src)}')`,
}}
/>
<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={getAssetDownloadSrc(asset.src)}
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} 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";
// store
import { 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 { created_by, updated_by } = page;
const editorInformation = updated_by ? getUserDetails(updated_by) : undefined;
const creatorInformation = created_by ? getUserDetails(created_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 { 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 { TPageRootHandlers } from "@/components/pages/editor";
// store
import { 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 { TPageVersion } from "@plane/types";
import { Avatar } from "@plane/ui";
import { cn, getFileURL, renderFormattedDate, renderFormattedTime } from "@plane/utils";
// components
import { TPageRootHandlers } from "@/components/pages/editor";
// hooks
import { useMember } from "@/hooks/store";
import { useQueryParams } from "@/hooks/use-query-params";
// store
import { 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.created_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 { TPageInstance } from "@/store/pages/base-page";
// local imports
import { PageContentBrowser } from "../../editor";
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 { TPageRootHandlers } from "@/components/pages/editor";
// 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 { 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

@ -16,13 +16,11 @@ import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed";
export type TVersionEditorProps = {
activeVersion: string | null;
currentVersionDescription: string | null;
isCurrentVersionActive: boolean;
versionDetails: TPageVersion | undefined;
};
export const PagesVersionEditor: React.FC<TVersionEditorProps> = observer((props) => {
const { activeVersion, currentVersionDescription, isCurrentVersionActive, versionDetails } = props;
const { activeVersion, versionDetails } = props;
// store hooks
const { getUserDetails } = useMember();
// params
@ -49,7 +47,7 @@ export const PagesVersionEditor: React.FC<TVersionEditorProps> = observer((props
wideLayout: true,
};
if (!isCurrentVersionActive && !versionDetails)
if (!versionDetails)
return (
<div className="size-full px-5">
<Loader className="relative space-y-4">
@ -91,7 +89,7 @@ export const PagesVersionEditor: React.FC<TVersionEditorProps> = observer((props
</div>
);
const description = isCurrentVersionActive ? currentVersionDescription : versionDetails?.description_html;
const description = versionDetails?.description_html;
if (description === undefined || description?.trim() === "") return null;
return (

View file

@ -1,6 +1,3 @@
export * from "./editor";
export * from "./main-content";
export * from "./root";
export * from "./sidebar-list-item";
export * from "./sidebar-list";
export * from "./sidebar-root";

View file

@ -1,7 +1,7 @@
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { TriangleAlert } from "lucide-react";
import { EyeIcon, TriangleAlert } from "lucide-react";
// plane types
import { TPageVersion } from "@plane/types";
// plane ui
@ -13,7 +13,6 @@ import { TVersionEditorProps } from "@/components/pages";
type Props = {
activeVersion: string | null;
currentVersionDescription: string | null;
editorComponent: React.FC<TVersionEditorProps>;
fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>;
handleClose: () => void;
@ -23,16 +22,8 @@ type Props = {
};
export const PageVersionsMainContent: React.FC<Props> = observer((props) => {
const {
activeVersion,
currentVersionDescription,
editorComponent,
fetchVersionDetails,
handleClose,
handleRestore,
pageId,
restoreEnabled,
} = props;
const { activeVersion, editorComponent, fetchVersionDetails, handleClose, handleRestore, pageId, restoreEnabled } =
props;
// states
const [isRestoring, setIsRestoring] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
@ -42,12 +33,10 @@ export const PageVersionsMainContent: React.FC<Props> = observer((props) => {
error: versionDetailsError,
mutate: mutateVersionDetails,
} = useSWR(
pageId && activeVersion && activeVersion !== "current" ? `PAGE_VERSION_${activeVersion}` : null,
pageId && activeVersion && activeVersion !== "current" ? () => fetchVersionDetails(pageId, activeVersion) : null
pageId && activeVersion ? `PAGE_VERSION_${activeVersion}` : null,
pageId && activeVersion ? () => fetchVersionDetails(pageId, activeVersion) : null
);
const isCurrentVersionActive = activeVersion === "current";
const handleRestoreVersion = async () => {
if (!restoreEnabled) return;
setIsRestoring(true);
@ -96,14 +85,18 @@ export const PageVersionsMainContent: React.FC<Props> = observer((props) => {
) : (
<>
<div className="min-h-14 py-3 px-5 border-b border-custom-border-200 flex items-center justify-between gap-2">
<h6 className="text-base font-medium">
{isCurrentVersionActive
? "Current version"
: versionDetails
<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>
{!isCurrentVersionActive && restoreEnabled && (
</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"
@ -116,12 +109,7 @@ export const PageVersionsMainContent: React.FC<Props> = observer((props) => {
)}
</div>
<div className="pt-8 h-full overflow-y-scroll vertical-scrollbar scrollbar-sm">
<VersionEditor
activeVersion={activeVersion}
currentVersionDescription={currentVersionDescription}
isCurrentVersionActive={isCurrentVersionActive}
versionDetails={versionDetails}
/>
<VersionEditor activeVersion={activeVersion} versionDetails={versionDetails} />
</div>
</>
)}

View file

@ -1,54 +1,56 @@
import { useCallback } from "react";
import { observer } from "mobx-react";
// plane types
import { useRouter, useSearchParams } from "next/navigation";
// plane imports
import { TPageVersion } from "@plane/types";
// components
import { cn } from "@plane/utils";
import { PageVersionsMainContent, PageVersionsSidebarRoot, TVersionEditorProps } from "@/components/pages";
// helpers
// components
import { PageVersionsMainContent, TVersionEditorProps } from "@/components/pages";
// hooks
import { useQueryParams } from "@/hooks/use-query-params";
// local imports
import { PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM, PAGE_NAVIGATION_PANE_WIDTH } from "../navigation-pane";
type Props = {
activeVersion: string | null;
currentVersionDescription: string | null;
editorComponent: React.FC<TVersionEditorProps>;
fetchAllVersions: (pageId: string) => Promise<TPageVersion[] | undefined>;
fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>;
handleRestore: (descriptionHTML: string) => Promise<void>;
isOpen: boolean;
onClose: () => void;
pageId: string;
restoreEnabled: boolean;
};
export const PageVersionsOverlay: React.FC<Props> = observer((props) => {
const {
activeVersion,
currentVersionDescription,
editorComponent,
fetchAllVersions,
fetchVersionDetails,
handleRestore,
isOpen,
onClose,
pageId,
restoreEnabled,
} = props;
const { editorComponent, fetchVersionDetails, handleRestore, pageId, restoreEnabled } = 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 = () => {
onClose();
};
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] size-full bg-custom-background-100 flex overflow-hidden opacity-0 pointer-events-none transition-opacity",
"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}
currentVersionDescription={currentVersionDescription}
editorComponent={editorComponent}
fetchVersionDetails={fetchVersionDetails}
handleClose={handleClose}
@ -56,13 +58,6 @@ export const PageVersionsOverlay: React.FC<Props> = observer((props) => {
pageId={pageId}
restoreEnabled={restoreEnabled}
/>
<PageVersionsSidebarRoot
activeVersion={activeVersion}
fetchAllVersions={fetchAllVersions}
handleClose={handleClose}
isOpen={isOpen}
pageId={pageId}
/>
</div>
);
});

View file

@ -1,49 +0,0 @@
import { observer } from "mobx-react";
import Link from "next/link";
// plane imports
import { useTranslation } from "@plane/i18n";
import { TPageVersion } from "@plane/types";
import { Avatar } from "@plane/ui";
import { cn, renderFormattedDate, renderFormattedTime, getFileURL } from "@plane/utils";
// helpers
// hooks
import { useMember } from "@/hooks/store";
type Props = {
href: string;
isActive: boolean;
version: TPageVersion;
};
export const PlaneVersionsSidebarListItem: React.FC<Props> = observer((props) => {
const { href, isActive, version } = props;
// store hooks
const { getUserDetails } = useMember();
// derived values
const ownerDetails = getUserDetails(version.owned_by);
// translation
const { t } = useTranslation();
return (
<Link
href={href}
className={cn("block p-2 rounded-md w-72 hover:bg-custom-background-80 transition-colors", {
"bg-custom-background-80": isActive,
})}
>
<p className="text-sm font-medium truncate">
{renderFormattedDate(version.last_saved_at)} {renderFormattedTime(version.last_saved_at)}
</p>
<p className="mt-2 flex items-center gap-1 text-xs">
<Avatar
src={getFileURL(ownerDetails?.avatar_url ?? "")}
name={ownerDetails?.display_name}
shape="square"
size="sm"
className="flex-shrink-0"
/>
<span className="text-custom-text-300">{ownerDetails?.display_name ?? t("common.deactivated_user")}</span>
</p>
</Link>
);
});

View file

@ -1,99 +0,0 @@
import { useState } from "react";
import Link from "next/link";
import useSWR from "swr";
import { TriangleAlert } from "lucide-react";
// plane types
import { TPageVersion } from "@plane/types";
// plane ui
import { Button, Loader } from "@plane/ui";
// components
import { cn } from "@plane/utils";
import { PlaneVersionsSidebarListItem } from "@/components/pages";
// helpers
// hooks
import { useQueryParams } from "@/hooks/use-query-params";
type Props = {
activeVersion: string | null;
fetchAllVersions: (pageId: string) => Promise<TPageVersion[] | undefined>;
isOpen: boolean;
pageId: string;
};
export const PageVersionsSidebarList: React.FC<Props> = (props) => {
const { activeVersion, fetchAllVersions, isOpen, pageId } = props;
// states
const [isRetrying, setIsRetrying] = useState(false);
// update query params
const { updateQueryParams } = useQueryParams();
const {
data: versionsList,
error: versionsListError,
mutate: mutateVersionsList,
} = useSWR(
pageId && isOpen ? `PAGE_VERSIONS_LIST_${pageId}` : null,
pageId && isOpen ? () => fetchAllVersions(pageId) : null
);
const handleRetry = async () => {
setIsRetrying(true);
await mutateVersionsList();
setIsRetrying(false);
};
const getVersionLink = (versionID: string) =>
updateQueryParams({
paramsToAdd: { version: versionID },
});
return (
<div className="mt-4 px-4 h-full flex flex-col space-y-2 overflow-y-scroll vertical-scrollbar scrollbar-sm">
<Link
href={getVersionLink("current")}
className={cn("block p-2 rounded-md w-72 hover:bg-custom-background-80 transition-colors", {
"bg-custom-background-80": activeVersion === "current",
})}
>
<p className="text-sm font-medium">Current version</p>
</Link>
{versionsListError ? (
<div className="h-full 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-base font-semibold">Something went wrong!</h6>
<p className="text-xs text-custom-text-300">
There was a problem while loading previous
<br />
versions, please try again.
</p>
</div>
<Button variant="link-primary" onClick={handleRetry} loading={isRetrying}>
Try again
</Button>
</div>
</div>
) : versionsList ? (
versionsList.map((version) => (
<PlaneVersionsSidebarListItem
key={version.id}
href={getVersionLink(version.id)}
isActive={activeVersion === version.id}
version={version}
/>
))
) : (
<Loader className="space-y-4">
<Loader.Item height="56px" />
<Loader.Item height="56px" />
<Loader.Item height="56px" />
<Loader.Item height="56px" />
<Loader.Item height="56px" />
</Loader>
)}
</div>
);
};

View file

@ -1,38 +0,0 @@
import { X } from "lucide-react";
// plane types
import { TPageVersion } from "@plane/types";
// components
import { PageVersionsSidebarList } from "@/components/pages";
type Props = {
activeVersion: string | null;
fetchAllVersions: (pageId: string) => Promise<TPageVersion[] | undefined>;
handleClose: () => void;
isOpen: boolean;
pageId: string;
};
export const PageVersionsSidebarRoot: React.FC<Props> = (props) => {
const { activeVersion, fetchAllVersions, handleClose, isOpen, pageId } = props;
return (
<div className="flex-shrink-0 py-4 border-l border-custom-border-200 flex flex-col">
<div className="px-6 flex items-center justify-between gap-2">
<h5 className="text-base font-semibold">Version history</h5>
<button
type="button"
onClick={handleClose}
className="flex-shrink-0 size-6 grid place-items-center text-custom-text-300 hover:text-custom-text-100 transition-colors"
>
<X className="size-4" />
</button>
</div>
<PageVersionsSidebarList
activeVersion={activeVersion}
fetchAllVersions={fetchAllVersions}
isOpen={isOpen}
pageId={pageId}
/>
</div>
);
};

View file

@ -1,3 +1,4 @@
import { useCallback } from "react";
import { useSearchParams, usePathname } from "next/navigation";
type TParamsToAdd = {
@ -9,29 +10,27 @@ export const useQueryParams = () => {
const searchParams = useSearchParams();
const pathname = usePathname();
const updateQueryParams = ({
paramsToAdd = {},
paramsToRemove = [],
}: {
paramsToAdd?: TParamsToAdd;
paramsToRemove?: string[];
}) => {
const currentParams = new URLSearchParams(searchParams.toString());
const updateQueryParams = useCallback(
({ paramsToAdd = {}, paramsToRemove = [] }: { paramsToAdd?: TParamsToAdd; paramsToRemove?: string[] }) => {
const currentParams = new URLSearchParams(searchParams.toString());
// add or update query parameters
Object.keys(paramsToAdd).forEach((key) => {
currentParams.set(key, paramsToAdd[key]);
});
// add or update query parameters
Object.keys(paramsToAdd).forEach((key) => {
currentParams.set(key, paramsToAdd[key]);
});
// remove specified query parameters
paramsToRemove.forEach((key) => {
currentParams.delete(key);
});
// remove specified query parameters
paramsToRemove.forEach((key) => {
currentParams.delete(key);
});
// construct the new route with the updated query parameters
const newRoute = `${pathname}?${currentParams.toString()}`;
return newRoute;
};
// construct the new route with the updated query parameters
const query = currentParams.toString();
const newRoute = query ? `${pathname}?${query}` : pathname;
return newRoute;
},
[pathname, searchParams]
);
return {
updateQueryParams,

View file

@ -2,18 +2,18 @@ import set from "lodash/set";
import { action, computed, makeObservable, observable, reaction, runInAction } from "mobx";
// plane imports
import { EPageAccess } from "@plane/constants";
import { EditorRefApi } from "@plane/editor";
import { TDocumentPayload, TLogoProps, TNameDescriptionLoader, TPage } from "@plane/types";
import { TChangeHandlerProps } from "@plane/ui";
import { convertHexEmojiToDecimal } from "@plane/utils";
// plane web store
import { ExtendedBasePage } from "@/plane-web/store/pages/extended-base-page";
import { RootStore } from "@/plane-web/store/root.store";
// local imports
import { PageEditorInstance } from "./page-editor-info";
export type TBasePage = TPage & {
// observables
isSubmitting: TNameDescriptionLoader;
editorRef: EditorRefApi | null;
// computed
asJSON: TPage | undefined;
isCurrentUserOwner: boolean;
@ -36,7 +36,8 @@ export type TBasePage = TPage & {
removePageFromFavorites: () => Promise<void>;
duplicate: () => Promise<TPage | undefined>;
mutateProperties: (data: Partial<TPage>, shouldUpdateName?: boolean) => void;
setEditorRef: (editorRef: EditorRefApi | null) => void;
// sub-store
editor: PageEditorInstance;
};
export type TBasePagePermissions = {
@ -73,7 +74,6 @@ export type TPageInstance = TBasePage &
export class BasePage extends ExtendedBasePage implements TBasePage {
// loaders
isSubmitting: TNameDescriptionLoader = "saved";
editorRef: EditorRefApi | null = null;
// page properties
id: string | undefined;
name: string | undefined;
@ -100,6 +100,9 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
disposers: Array<() => void> = [];
// root store
rootStore: RootStore;
// sub-store
editor: PageEditorInstance;
constructor(
private store: RootStore,
page: TPage,
@ -129,7 +132,6 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
makeObservable(this, {
// loaders
isSubmitting: observable.ref,
editorRef: observable.ref,
// page properties
id: observable.ref,
name: observable.ref,
@ -170,11 +172,12 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
removePageFromFavorites: action,
duplicate: action,
mutateProperties: action,
setEditorRef: action,
});
this.rootStore = store;
// init
this.services = services;
this.rootStore = store;
this.editor = new PageEditorInstance();
const titleDisposer = reaction(
() => this.name,
@ -524,10 +527,4 @@ export class BasePage extends ExtendedBasePage implements TBasePage {
set(this, key, value);
});
};
setEditorRef = (editorRef: EditorRefApi | null) => {
runInAction(() => {
this.editorRef = editorRef;
});
};
}

View file

@ -0,0 +1,41 @@
import { action, makeObservable, observable, runInAction } from "mobx";
// plane imports
import { EditorRefApi, TEditorAsset } from "@plane/editor";
export type TPageEditorInstance = {
// observables
assetsList: TEditorAsset[];
editorRef: EditorRefApi | null;
// actions
setEditorRef: (editorRef: EditorRefApi | null) => void;
updateAssetsList: (assets: TEditorAsset[]) => void;
};
export class PageEditorInstance implements TPageEditorInstance {
// observables
editorRef: EditorRefApi | null = null;
assetsList: TEditorAsset[] = [];
constructor() {
makeObservable(this, {
// observables
editorRef: observable.ref,
assetsList: observable,
// actions
setEditorRef: action,
updateAssetsList: action,
});
}
setEditorRef: TPageEditorInstance["setEditorRef"] = (editorRef) => {
runInAction(() => {
this.editorRef = editorRef;
});
};
updateAssetsList: TPageEditorInstance["updateAssetsList"] = (assets) => {
runInAction(() => {
this.assetsList = assets;
});
};
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -497,6 +497,7 @@
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
scroll-behavior: smooth;
}
body {