style: revamped page details UI (#2823)

* style: revamp page details UI

* chore: updated the info popover date format

* fix: page actions mutation

* style: made the page content responsive
This commit is contained in:
Aaryan Khandelwal 2023-11-22 12:32:49 +05:30 committed by sriram veeraghanta
parent e57b95f99e
commit d43db7fc88
15 changed files with 662 additions and 524 deletions

View File

@ -1,19 +1,21 @@
import { Icon } from "lucide-react"
import { Icon } from "lucide-react";
interface IAlertLabelProps {
Icon: Icon,
backgroundColor: string,
textColor?: string,
label: string,
Icon?: Icon;
backgroundColor: string;
textColor?: string;
label: string;
}
export const AlertLabel = ({ Icon, backgroundColor,textColor, label }: IAlertLabelProps) => {
export const AlertLabel = (props: IAlertLabelProps) => {
const { Icon, backgroundColor, textColor, label } = props;
return (
<div className={`text-xs flex items-center gap-1 ${backgroundColor} p-0.5 pl-3 pr-3 mr-1 rounded`}>
<Icon size={12} />
<span className={`normal-case ${textColor}`}>{label}</span>
<div
className={`h-7 flex items-center gap-2 font-medium py-0.5 px-3 rounded-full text-xs ${backgroundColor} ${textColor}`}
>
{Icon && <Icon className="h-3 w-3" />}
<span>{label}</span>
</div>
)
}
);
};

View File

@ -8,33 +8,33 @@ interface ContentBrowserProps {
markings: IMarking[];
}
export const ContentBrowser = ({
editor,
markings,
}: ContentBrowserProps) => (
<div className="mt-4 flex w-[250px] flex-col h-full">
<h2 className="ml-4 border-b border-solid border-custom-border py-5 font-medium leading-[85.714%] tracking-tight max-md:ml-2.5">
Table of Contents
</h2>
<div className="mt-3 h-0.5 w-full self-stretch border-custom-border" />
{markings.length !== 0 ? (
markings.map((marking) =>
marking.level === 1 ? (
<HeadingComp
onClick={() => scrollSummary(editor, marking)}
heading={marking.text}
/>
export const ContentBrowser = (props: ContentBrowserProps) => {
const { editor, markings } = props;
return (
<div className="flex flex-col h-full overflow-hidden">
<h2 className="font-medium">Table of Contents</h2>
<div className="h-full overflow-y-auto">
{markings.length !== 0 ? (
markings.map((marking) =>
marking.level === 1 ? (
<HeadingComp
onClick={() => scrollSummary(editor, marking)}
heading={marking.text}
/>
) : (
<SubheadingComp
onClick={() => scrollSummary(editor, marking)}
subHeading={marking.text}
/>
),
)
) : (
<SubheadingComp
onClick={() => scrollSummary(editor, marking)}
subHeading={marking.text}
/>
)
)
) : (
<p className="ml-3 mr-3 flex h-full items-center px-5 text-center text-xs text-gray-500">
{"Headings will be displayed here for Navigation"}
</p>
)}
</div>
);
<p className="mt-3 text-xs text-custom-text-400">
Headings will be displayed here for navigation
</p>
)}
</div>
</div>
);
};

View File

@ -1,79 +1,90 @@
import { Editor } from "@tiptap/react"
import { Lock, ArchiveIcon, MenuSquare } from "lucide-react"
import { useRef, useState } from "react"
import { usePopper } from "react-popper"
import { IMarking, UploadImage } from ".."
import { FixedMenu } from "../menu"
import { DocumentDetails } from "../types/editor-types"
import { AlertLabel } from "./alert-label"
import { ContentBrowser } from "./content-browser"
import { IVerticalDropdownItemProps, VerticalDropdownMenu } from "./vertical-dropdown-menu"
import { Editor } from "@tiptap/react";
import { Archive, Info, Lock } from "lucide-react";
import { IMarking, UploadImage } from "..";
import { FixedMenu } from "../menu";
import { DocumentDetails } from "../types/editor-types";
import { AlertLabel } from "./alert-label";
import {
IVerticalDropdownItemProps,
VerticalDropdownMenu,
} from "./vertical-dropdown-menu";
import { SummaryPopover } from "./summary-popover";
import { InfoPopover } from "./info-popover";
interface IEditorHeader {
editor: Editor,
KanbanMenuOptions: IVerticalDropdownItemProps[],
sidePeakVisible: boolean,
setSidePeakVisible: (currentState: boolean) => void,
markings: IMarking[],
isLocked: boolean,
isArchived: boolean,
archivedAt?: Date,
readonly: boolean,
uploadFile?: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
documentDetails: DocumentDetails
editor: Editor;
KanbanMenuOptions: IVerticalDropdownItemProps[];
sidePeekVisible: boolean;
setSidePeekVisible: (sidePeekState: boolean) => void;
markings: IMarking[];
isLocked: boolean;
isArchived: boolean;
archivedAt?: Date;
readonly: boolean;
uploadFile?: UploadImage;
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void;
documentDetails: DocumentDetails;
}
export const EditorHeader = ({ documentDetails, archivedAt, editor, sidePeakVisible, readonly, setSidePeakVisible, markings, uploadFile, setIsSubmitting, KanbanMenuOptions, isArchived, isLocked }: IEditorHeader) => {
const summaryMenuRef = useRef(null);
const summaryButtonRef = useRef(null);
const [summaryPopoverVisible, setSummaryPopoverVisible] = useState(false);
const { styles: summaryPopoverStyles, attributes: summaryPopoverAttributes } = usePopper(summaryButtonRef.current, summaryMenuRef.current, {
placement: "bottom-start"
})
export const EditorHeader = (props: IEditorHeader) => {
const {
documentDetails,
archivedAt,
editor,
sidePeekVisible,
readonly,
setSidePeekVisible,
markings,
uploadFile,
setIsSubmitting,
KanbanMenuOptions,
isArchived,
isLocked,
} = props;
return (
<div className="flex items-center border-b border-custom-border-200 py-2 px-5">
<div className="flex-shrink-0 w-56 lg:w-80">
<SummaryPopover
editor={editor}
markings={markings}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={setSidePeekVisible}
/>
</div>
<div className="border-custom-border self-stretch flex flex-col border-b border-solid max-md:max-w-full">
<div
className="self-center flex ml-0 w-full items-start justify-between gap-5 max-md:max-w-full max-md:flex-wrap max-md:justify-center">
<div className={"flex flex-row items-center"}>
<div
onMouseEnter={() => setSummaryPopoverVisible(true)}
onMouseLeave={() => setSummaryPopoverVisible(false)}
>
<button
ref={summaryButtonRef}
className={"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors"}
onClick={() => {
setSidePeakVisible(!sidePeakVisible)
setSummaryPopoverVisible(false)
}}
>
<MenuSquare
size={20}
/>
</button>
{summaryPopoverVisible &&
<div style={summaryPopoverStyles.popper} {...summaryPopoverAttributes.popper} className="z-10 h-[300px] w-[300px] ml-[40px] mt-[40px] shadow-xl rounded border-custom-border border-solid border-2 bg-custom-background-100 border-b pl-3 pr-3 pb-3 overflow-scroll">
<ContentBrowser editor={editor} markings={markings} />
</div>
}
</div>
{isLocked && <AlertLabel Icon={Lock} backgroundColor={"bg-red-200"} label={"Locked"} />}
{(isArchived && archivedAt) && <AlertLabel Icon={ArchiveIcon} backgroundColor={"bg-blue-200"} label={`Archived at ${new Date(archivedAt).toLocaleString()}`} />}
</div>
<div className="flex-shrink-0">
{!readonly && uploadFile && (
<FixedMenu
editor={editor}
uploadFile={uploadFile}
setIsSubmitting={setIsSubmitting}
/>
)}
</div>
{(!readonly && uploadFile) && <FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} />}
<div className="self-center flex items-start gap-3 my-auto max-md:justify-center"
>
{!isArchived && <p className="text-sm text-custom-text-300">{`Last updated at ${new Date(documentDetails.last_updated_at).toLocaleString()}`}</p>}
<VerticalDropdownMenu items={KanbanMenuOptions} />
</div>
<div className="flex-grow flex items-center justify-end gap-3">
{isLocked && (
<AlertLabel
Icon={Lock}
backgroundColor="bg-custom-background-80"
textColor="text-custom-text-300"
label="Locked"
/>
)}
{isArchived && archivedAt && (
<AlertLabel
Icon={Archive}
backgroundColor="bg-blue-500/20"
textColor="text-blue-500"
label={`Archived at ${new Date(archivedAt).toLocaleString()}`}
/>
)}
{!isArchived && <InfoPopover documentDetails={documentDetails} />}
<VerticalDropdownMenu items={KanbanMenuOptions} />
</div>
</div>
)
}
);
};

View File

@ -0,0 +1,9 @@
export * from "./alert-label";
export * from "./content-browser";
export * from "./editor-header";
export * from "./heading-component";
export * from "./info-popover";
export * from "./page-renderer";
export * from "./summary-popover";
export * from "./summary-side-bar";
export * from "./vertical-dropdown-menu";

View File

@ -0,0 +1,79 @@
import { useState } from "react";
import { usePopper } from "react-popper";
import { Calendar, History, Info } from "lucide-react";
// types
import { DocumentDetails } from "../types/editor-types";
type Props = {
documentDetails: DocumentDetails;
};
// function to render a Date in the format- 25 May 2023 at 2:53PM
const renderDate = (date: Date): string => {
const options: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "long",
year: "numeric",
hour: "numeric",
minute: "numeric",
hour12: true,
};
const formattedDate: string = new Intl.DateTimeFormat(
"en-US",
options,
).format(date);
return formattedDate;
};
export const InfoPopover: React.FC<Props> = (props) => {
const { documentDetails } = props;
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const [referenceElement, setReferenceElement] =
useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null,
);
const { styles: infoPopoverStyles, attributes: infoPopoverAttributes } =
usePopper(referenceElement, popperElement, {
placement: "bottom-start",
});
return (
<div
onMouseEnter={() => setIsPopoverOpen(true)}
onMouseLeave={() => setIsPopoverOpen(false)}
>
<button type="button" ref={setReferenceElement} className="block mt-1.5">
<Info className="h-3.5 w-3.5" />
</button>
{isPopoverOpen && (
<div
className="z-10 w-64 shadow-custom-shadow-rg rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3 space-y-2.5"
ref={setPopperElement}
style={infoPopoverStyles.popper}
{...infoPopoverAttributes.popper}
>
<div className="space-y-1.5">
<h6 className="text-custom-text-400 text-xs">Last updated on</h6>
<h5 className="text-sm flex items-center gap-1">
<History className="h-3 w-3" />
{renderDate(new Date(documentDetails.last_updated_at))}
</h5>
</div>
<div className="space-y-1.5">
<h6 className="text-custom-text-400 text-xs">Created on</h6>
<h5 className="text-sm flex items-center gap-1">
<Calendar className="h-3 w-3" />
{renderDate(new Date(documentDetails.created_on))}
</h5>
</div>
</div>
)}
</div>
);
};

View File

@ -3,43 +3,32 @@ import { Editor } from "@tiptap/react";
import { DocumentDetails } from "../types/editor-types";
interface IPageRenderer {
sidePeakVisible: boolean;
documentDetails: DocumentDetails;
editor: Editor;
editorClassNames: string;
editorContentCustomClassNames?: string;
}
export const PageRenderer = ({
sidePeakVisible,
documentDetails,
editor,
editorClassNames,
editorContentCustomClassNames,
}: IPageRenderer) => {
export const PageRenderer = (props: IPageRenderer) => {
const {
documentDetails,
editor,
editorClassNames,
editorContentCustomClassNames,
} = props;
return (
<div
className={`flex h-[88vh] flex-col w-full max-md:w-full max-md:ml-0 transition-all duration-200 ease-in-out ${
sidePeakVisible ? "ml-[3%] " : "ml-0"
}`}
>
<div className="items-start mt-4 h-full flex flex-col w-fit max-md:max-w-full overflow-auto">
<div className="flex flex-col py-2 max-md:max-w-full">
<h1 className="border-none outline-none bg-transparent text-4xl font-bold leading-8 tracking-tight self-center w-[700px] max-w-full">
{documentDetails.title}
</h1>
</div>
<div className="border-b border-custom-border-200 self-stretch w-full h-0.5 mt-3" />
<div className="flex flex-col h-full w-full max-md:max-w-full">
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
<div className="flex flex-col h-full w-full">
<EditorContentWrapper
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
</div>
</EditorContainer>
</div>
<div className="h-full w-full overflow-y-auto pl-7 py-5">
<h1 className="text-4xl font-bold break-all pr-5 -mt-2">
{documentDetails.title}
</h1>
<div className="flex flex-col h-full w-full pr-5">
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
<EditorContentWrapper
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
</EditorContainer>
</div>
</div>
);

View File

@ -1,67 +0,0 @@
import React, { Fragment, useState } from "react";
import { usePopper } from "react-popper";
import { Popover, Transition } from "@headlessui/react";
import { Placement } from "@popperjs/core";
// ui
import { Button } from "@plane/ui";
// icons
import { ChevronUp, MenuIcon } from "lucide-react";
type Props = {
children: React.ReactNode;
title?: string;
placement?: Placement;
};
export const SummaryPopover: React.FC<Props> = (props) => {
const { children, title = "SummaryPopover", placement } = props;
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "auto",
});
return (
<Popover as="div">
{({ open }) => {
if (open) {
}
return (
<>
<Popover.Button as={React.Fragment}>
<Button
ref={setReferenceElement}
variant="neutral-primary"
size="sm"
>
<MenuIcon size={20} />
</Button>
</Popover.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel>
<div
className="z-10 bg-custom-background-100 border border-custom-border-200 shadow-custom-shadow-rg rounded overflow-hidden"
ref={setPopperElement}
style={styles.popper}
{...attributes.popper}
>
<div className="w-[18.75rem] max-h-[37.5rem] flex flex-col overflow-hidden">{children}</div>
</div>
</Popover.Panel>
</Transition>
</>
);
}}
</Popover>
);
};

View File

@ -0,0 +1,57 @@
import { useState } from "react";
import { Editor } from "@tiptap/react";
import { usePopper } from "react-popper";
import { List } from "lucide-react";
// components
import { ContentBrowser } from "./content-browser";
// types
import { IMarking } from "..";
type Props = {
editor: Editor;
markings: IMarking[];
sidePeekVisible: boolean;
setSidePeekVisible: (sidePeekState: boolean) => void;
};
export const SummaryPopover: React.FC<Props> = (props) => {
const { editor, markings, sidePeekVisible, setSidePeekVisible } = props;
const [referenceElement, setReferenceElement] =
useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
null,
);
const { styles: summaryPopoverStyles, attributes: summaryPopoverAttributes } =
usePopper(referenceElement, popperElement, {
placement: "bottom-start",
});
return (
<div className="group/summary-popover w-min whitespace-nowrap">
<button
type="button"
ref={setReferenceElement}
className={`h-7 w-7 grid place-items-center rounded ${
sidePeekVisible
? "bg-custom-primary-100/20 text-custom-primary-100"
: "text-custom-text-300"
}`}
onClick={() => setSidePeekVisible(!sidePeekVisible)}
>
<List className="h-4 w-4" />
</button>
{!sidePeekVisible && (
<div
className="hidden group-hover/summary-popover:block z-10 h-80 w-64 shadow-custom-shadow-rg rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3"
ref={setPopperElement}
style={summaryPopoverStyles.popper}
{...summaryPopoverAttributes.popper}
>
<ContentBrowser editor={editor} markings={markings} />
</div>
)}
</div>
);
};

View File

@ -1,18 +1,25 @@
import { Editor } from "@tiptap/react"
import { IMarking } from ".."
import { ContentBrowser } from "./content-browser"
import { Editor } from "@tiptap/react";
import { IMarking } from "..";
import { ContentBrowser } from "./content-browser";
interface ISummarySideBarProps {
editor: Editor,
markings: IMarking[],
sidePeakVisible: boolean
editor: Editor;
markings: IMarking[];
sidePeekVisible: boolean;
}
export const SummarySideBar = ({ editor, markings, sidePeakVisible }: ISummarySideBarProps) => {
export const SummarySideBar = ({
editor,
markings,
sidePeekVisible,
}: ISummarySideBarProps) => {
return (
<div className={`flex flex-col items-stretch w-[21%] max-md:w-full max-md:ml-0 border-custom-border border-r border-solid transition-all duration-200 ease-in-out transform ${sidePeakVisible ? 'translate-x-0' : '-translate-x-full'}`}>
<div
className={`h-full px-5 pt-5 transition-all duration-200 transform overflow-hidden ${
sidePeekVisible ? "translate-x-0" : "-translate-x-full"
}`}
>
<ContentBrowser editor={editor} markings={markings} />
</div>
)
}
);
};

View File

@ -1,41 +1,52 @@
import { Button, CustomMenu } from "@plane/ui"
import { ChevronUp, Icon, MoreVertical } from "lucide-react"
import { Button, CustomMenu } from "@plane/ui";
import { ChevronUp, Icon, MoreVertical } from "lucide-react";
type TMenuItems = "archive_page" | "unarchive_page" | "lock_page" | "unlock_page" | "copy_markdown" | "close_page" | "copy_page_link" | "duplicate_page"
type TMenuItems =
| "archive_page"
| "unarchive_page"
| "lock_page"
| "unlock_page"
| "copy_markdown"
| "close_page"
| "copy_page_link"
| "duplicate_page";
export interface IVerticalDropdownItemProps {
key: number,
type: TMenuItems,
Icon: Icon,
label: string,
action: () => Promise<void> | void
key: number;
type: TMenuItems;
Icon: Icon;
label: string;
action: () => Promise<void> | void;
}
export interface IVerticalDropdownMenuProps {
items: IVerticalDropdownItemProps[],
items: IVerticalDropdownItemProps[];
}
const VerticalDropdownItem = ({ Icon, label, action }: IVerticalDropdownItemProps) => {
const VerticalDropdownItem = ({
Icon,
label,
action,
}: IVerticalDropdownItemProps) => {
return (
<CustomMenu.MenuItem>
<Button variant={"neutral-primary"} onClick={action} className="flex flex-row border-none items-center m-1 max-md:pr-5 cursor-pointer">
<Icon size={16} />
<div className="text-custom-text-300 ml-2 mr-2 leading-5 tracking-tight whitespace-nowrap self-start text-md">
{label}
</div>
</Button>
<CustomMenu.MenuItem onClick={action} className="flex items-center gap-2">
<Icon className="h-3 w-3" />
<div className="text-custom-text-300">{label}</div>
</CustomMenu.MenuItem>
)
}
);
};
export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => {
return (
<CustomMenu maxHeight={"lg"} className={"h-4"} placement={"bottom-start"} optionsClassName={"border-custom-border border-r border-solid transition-all duration-200 ease-in-out "} customButton={
<MoreVertical size={18}/>
}>
<CustomMenu
maxHeight={"lg"}
className={"h-4"}
placement={"bottom-start"}
optionsClassName={
"border-custom-border border-r border-solid transition-all duration-200 ease-in-out "
}
customButton={<MoreVertical size={14} />}
>
{items.map((item, index) => (
<VerticalDropdownItem
key={index}
@ -46,5 +57,5 @@ export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => {
/>
))}
</CustomMenu>
)
}
);
};

View File

@ -1,34 +1,40 @@
"use client"
import React, { useState } from 'react';
import { cn, getEditorClassNames, useEditor } from '@plane/editor-core';
import { DocumentEditorExtensions } from './extensions';
import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from './types/menu-actions';
import { EditorHeader } from './components/editor-header';
import { useEditorMarkings } from './hooks/use-editor-markings';
import { SummarySideBar } from './components/summary-side-bar';
import { DocumentDetails } from './types/editor-types';
import { PageRenderer } from './components/page-renderer';
import { getMenuOptions } from './utils/menu-options';
import { useRouter } from 'next/router';
"use client";
import React, { useState } from "react";
import { cn, getEditorClassNames, useEditor } from "@plane/editor-core";
import { DocumentEditorExtensions } from "./extensions";
import {
IDuplicationConfig,
IPageArchiveConfig,
IPageLockConfig,
} from "./types/menu-actions";
import { EditorHeader } from "./components/editor-header";
import { useEditorMarkings } from "./hooks/use-editor-markings";
import { SummarySideBar } from "./components/summary-side-bar";
import { DocumentDetails } from "./types/editor-types";
import { PageRenderer } from "./components/page-renderer";
import { getMenuOptions } from "./utils/menu-options";
import { useRouter } from "next/router";
export type UploadImage = (file: File) => Promise<string>;
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
interface IDocumentEditor {
documentDetails: DocumentDetails,
documentDetails: DocumentDetails;
value: string;
uploadFile: UploadImage;
deleteFile: DeleteImage;
customClassName?: string;
editorContentCustomClassNames?: string;
onChange: (json: any, html: string) => void;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void;
setShouldShowAlert?: (showAlert: boolean) => void;
forwardedRef?: any;
debouncedUpdatesEnabled?: boolean;
duplicationConfig?: IDuplicationConfig,
pageLockConfig?: IPageLockConfig,
pageArchiveConfig?: IPageArchiveConfig
duplicationConfig?: IDuplicationConfig;
pageLockConfig?: IPageLockConfig;
pageArchiveConfig?: IPageArchiveConfig;
}
interface DocumentEditorProps extends IDocumentEditor {
forwardedRef?: React.Ref<EditorHandle>;
@ -40,10 +46,10 @@ interface EditorHandle {
}
export interface IMarking {
type: "heading",
level: number,
text: string,
sequence: number
type: "heading";
level: number;
text: string;
sequence: number;
}
const DocumentEditor = ({
@ -60,21 +66,20 @@ const DocumentEditor = ({
forwardedRef,
duplicationConfig,
pageLockConfig,
pageArchiveConfig
pageArchiveConfig,
}: IDocumentEditor) => {
// const [alert, setAlert] = useState<string>("")
const { markings, updateMarkings } = useEditorMarkings()
const [sidePeakVisible, setSidePeakVisible] = useState(true)
const router = useRouter()
const { markings, updateMarkings } = useEditorMarkings();
const [sidePeekVisible, setSidePeekVisible] = useState(true);
const router = useRouter();
const editor = useEditor({
onChange(json, html) {
updateMarkings(json)
onChange(json, html)
updateMarkings(json);
onChange(json, html);
},
onStart(json) {
updateMarkings(json)
updateMarkings(json);
},
debouncedUpdatesEnabled,
setIsSubmitting,
@ -87,65 +92,66 @@ const DocumentEditor = ({
});
if (!editor) {
return null
return null;
}
const KanbanMenuOptions = getMenuOptions(
{
editor: editor,
router: router,
duplicationConfig: duplicationConfig,
pageLockConfig: pageLockConfig,
pageArchiveConfig: pageArchiveConfig,
}
)
const editorClassNames = getEditorClassNames({ noBorder: true, borderOnFocus: false, customClassName });
const KanbanMenuOptions = getMenuOptions({
editor: editor,
router: router,
duplicationConfig: duplicationConfig,
pageLockConfig: pageLockConfig,
pageArchiveConfig: pageArchiveConfig,
});
const editorClassNames = getEditorClassNames({
noBorder: true,
borderOnFocus: false,
customClassName,
});
if (!editor) return null;
return (
<div className="flex flex-col">
<div className="top-0 sticky z-10 bg-custom-background-100">
<EditorHeader
readonly={false}
KanbanMenuOptions={KanbanMenuOptions}
editor={editor}
sidePeakVisible={sidePeakVisible}
setSidePeakVisible={setSidePeakVisible}
markings={markings}
uploadFile={uploadFile}
setIsSubmitting={setIsSubmitting}
isLocked={!pageLockConfig ? false : pageLockConfig.is_locked}
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
documentDetails={documentDetails}
/>
</div>
<div className="self-center items-stretch w-full max-md:max-w-full h-full">
<div className={cn("gap-5 flex max-md:flex-col max-md:items-stretch max-md:gap-0 h-full", { "justify-center": !sidePeakVisible })}>
<div className="h-full w-full flex flex-col overflow-hidden">
<EditorHeader
readonly={false}
KanbanMenuOptions={KanbanMenuOptions}
editor={editor}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={(val) => setSidePeekVisible(val)}
markings={markings}
uploadFile={uploadFile}
setIsSubmitting={setIsSubmitting}
isLocked={!pageLockConfig ? false : pageLockConfig.is_locked}
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
documentDetails={documentDetails}
/>
<div className="h-full w-full flex overflow-hidden">
<div className="flex-shrink-0 h-full w-56 lg:w-80">
<SummarySideBar
editor={editor}
markings={markings}
sidePeakVisible={sidePeakVisible}
sidePeekVisible={sidePeekVisible}
/>
</div>
<div className="h-full w-full">
<PageRenderer
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
editorClassNames={editorClassNames}
sidePeakVisible={sidePeakVisible}
documentDetails={documentDetails}
/>
{/* Page Element */}
</div>
<div className="hidden lg:block flex-shrink-0 w-56 lg:w-80" />
</div>
</div>
);
}
};
const DocumentEditorWithRef = React.forwardRef<EditorHandle, IDocumentEditor>((props, ref) => (
<DocumentEditor {...props} forwardedRef={ref} />
));
const DocumentEditorWithRef = React.forwardRef<EditorHandle, IDocumentEditor>(
(props, ref) => <DocumentEditor {...props} forwardedRef={ref} />,
);
DocumentEditorWithRef.displayName = "DocumentEditorWithRef";
export { DocumentEditor, DocumentEditorWithRef }
export { DocumentEditor, DocumentEditorWithRef };

View File

@ -1,7 +1,22 @@
import { Editor } from "@tiptap/react";
import { BoldIcon, Heading1, Heading2, Heading3 } from "lucide-react";
import { BoldIcon } from "lucide-react";
import { BoldItem, BulletListItem, cn, CodeItem, ImageItem, ItalicItem, NumberedListItem, QuoteItem, StrikeThroughItem, TableItem, UnderLineItem, HeadingOneItem, HeadingTwoItem, HeadingThreeItem } from "@plane/editor-core";
import {
BoldItem,
BulletListItem,
cn,
CodeItem,
ImageItem,
ItalicItem,
NumberedListItem,
QuoteItem,
StrikeThroughItem,
TableItem,
UnderLineItem,
HeadingOneItem,
HeadingTwoItem,
HeadingThreeItem,
} from "@plane/editor-core";
import { UploadImage } from "..";
export interface BubbleMenuItem {
@ -14,77 +29,69 @@ export interface BubbleMenuItem {
type EditorBubbleMenuProps = {
editor: Editor;
uploadFile: UploadImage;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
}
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void;
};
export const FixedMenu = (props: EditorBubbleMenuProps) => {
const { editor, uploadFile, setIsSubmitting } = props;
const basicMarkItems: BubbleMenuItem[] = [
HeadingOneItem(props.editor),
HeadingTwoItem(props.editor),
HeadingThreeItem(props.editor),
BoldItem(props.editor),
ItalicItem(props.editor),
UnderLineItem(props.editor),
StrikeThroughItem(props.editor),
HeadingOneItem(editor),
HeadingTwoItem(editor),
HeadingThreeItem(editor),
BoldItem(editor),
ItalicItem(editor),
UnderLineItem(editor),
StrikeThroughItem(editor),
];
const listItems: BubbleMenuItem[] = [
BulletListItem(props.editor),
NumberedListItem(props.editor),
BulletListItem(editor),
NumberedListItem(editor),
];
const userActionItems: BubbleMenuItem[] = [
QuoteItem(props.editor),
CodeItem(props.editor),
QuoteItem(editor),
CodeItem(editor),
];
const complexItems: BubbleMenuItem[] = [
TableItem(props.editor),
ImageItem(props.editor, props.uploadFile, props.setIsSubmitting),
TableItem(editor),
ImageItem(editor, uploadFile, setIsSubmitting),
];
// const handleAccessChange = (accessKey: string) => {
// props.commentAccessSpecifier?.onAccessChange(accessKey);
// };
return (
<div
className="flex w-fit rounded bg-custom-background-100"
>
<div className="flex">
{basicMarkItems.map((item, index) => (
<div className="flex items-center divide-x divide-custom-border-200">
<div className="flex items-center gap-0.5 pr-2">
{basicMarkItems.map((item) => (
<button
key={index}
key={item.name}
type="button"
onClick={item.command}
className={cn(
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
"h-7 w-7 grid place-items-center text-custom-text-300 hover:bg-custom-background-80 rounded",
{
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
}
"text-custom-text-100 bg-custom-background-80": item.isActive(),
},
)}
>
<item.icon
size={ item.icon === Heading1 || item.icon === Heading2 || item.icon === Heading3 ? 20 : 15}
className={cn({
"text-custom-text-100": item.isActive(),
})}
/>
<item.icon className="h-4 w-4" />
</button>
))}
</div>
<div className="flex">
{listItems.map((item, index) => (
<div className="flex items-center gap-0.5 px-2">
{listItems.map((item) => (
<button
key={index}
key={item.name}
type="button"
onClick={item.command}
className={cn(
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
"h-7 w-7 grid place-items-center text-custom-text-300 hover:bg-custom-background-80 rounded",
{
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
}
"text-custom-text-100 bg-custom-background-80": item.isActive(),
},
)}
>
<item.icon
@ -95,17 +102,17 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
</button>
))}
</div>
<div className="flex">
{userActionItems.map((item, index) => (
<div className="flex items-center gap-0.5 px-2">
{userActionItems.map((item) => (
<button
key={index}
key={item.name}
type="button"
onClick={item.command}
className={cn(
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
"h-7 w-7 grid place-items-center text-custom-text-300 hover:bg-custom-background-80 rounded",
{
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
}
"text-custom-text-100 bg-custom-background-80": item.isActive(),
},
)}
>
<item.icon
@ -116,17 +123,17 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
</button>
))}
</div>
<div className="flex">
{complexItems.map((item, index) => (
<div className="flex items-center gap-0.5 pl-2">
{complexItems.map((item) => (
<button
key={index}
key={item.name}
type="button"
onClick={item.command}
className={cn(
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
"h-7 w-7 grid place-items-center text-custom-text-300 hover:bg-custom-background-80 rounded",
{
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
}
"text-custom-text-100 bg-custom-background-80": item.isActive(),
},
)}
>
<item.icon

View File

@ -1,27 +1,31 @@
import { cn, getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core"
import { cn, getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core";
import { useRouter } from "next/router";
import { useState, forwardRef, useEffect } from 'react'
import { useState, forwardRef, useEffect } from "react";
import { EditorHeader } from "../components/editor-header";
import { PageRenderer } from "../components/page-renderer";
import { SummarySideBar } from "../components/summary-side-bar";
import { useEditorMarkings } from "../hooks/use-editor-markings";
import { DocumentDetails } from "../types/editor-types";
import { IPageArchiveConfig, IPageLockConfig, IDuplicationConfig } from "../types/menu-actions";
import {
IPageArchiveConfig,
IPageLockConfig,
IDuplicationConfig,
} from "../types/menu-actions";
import { getMenuOptions } from "../utils/menu-options";
interface IDocumentReadOnlyEditor {
value: string,
noBorder: boolean,
borderOnFocus: boolean,
customClassName: string,
documentDetails: DocumentDetails,
pageLockConfig?: IPageLockConfig,
pageArchiveConfig?: IPageArchiveConfig,
pageDuplicationConfig?: IDuplicationConfig,
value: string;
noBorder: boolean;
borderOnFocus: boolean;
customClassName: string;
documentDetails: DocumentDetails;
pageLockConfig?: IPageLockConfig;
pageArchiveConfig?: IPageArchiveConfig;
pageDuplicationConfig?: IDuplicationConfig;
}
interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor {
forwardedRef?: React.Ref<EditorHandle>
forwardedRef?: React.Ref<EditorHandle>;
}
interface EditorHandle {
@ -40,32 +44,30 @@ const DocumentReadOnlyEditor = ({
pageLockConfig,
pageArchiveConfig,
}: DocumentReadOnlyEditorProps) => {
const router = useRouter()
const [sidePeakVisible, setSidePeakVisible] = useState(true)
const { markings, updateMarkings } = useEditorMarkings()
const router = useRouter();
const [sidePeekVisible, setSidePeekVisible] = useState(true);
const { markings, updateMarkings } = useEditorMarkings();
const editor = useReadOnlyEditor({
value,
forwardedRef,
})
});
useEffect(() => {
if (editor) {
updateMarkings(editor.getJSON())
updateMarkings(editor.getJSON());
}
}, [editor?.getJSON()])
}, [editor?.getJSON()]);
if (!editor) {
return null
return null;
}
const editorClassNames = getEditorClassNames({
noBorder,
borderOnFocus,
customClassName
})
customClassName,
});
const KanbanMenuOptions = getMenuOptions({
editor: editor,
@ -73,43 +75,42 @@ const DocumentReadOnlyEditor = ({
pageArchiveConfig: pageArchiveConfig,
pageLockConfig: pageLockConfig,
duplicationConfig: pageDuplicationConfig,
})
});
return (
<div className="flex flex-col">
<div className="top-0 sticky z-10 bg-custom-background-100">
<EditorHeader
isLocked={!pageLockConfig ? false : pageLockConfig.is_locked}
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
readonly={true}
editor={editor}
sidePeakVisible={sidePeakVisible}
setSidePeakVisible={setSidePeakVisible}
KanbanMenuOptions={KanbanMenuOptions}
markings={markings}
documentDetails={documentDetails}
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
/>
</div>
<div className="self-center items-stretch w-full max-md:max-w-full overflow-y-hidden">
<div className={cn("gap-5 flex max-md:flex-col max-md:items-stretch max-md:gap-0 overflow-y-hidden", { "justify-center": !sidePeakVisible })}>
<div className="h-full w-full flex flex-col overflow-hidden">
<EditorHeader
isLocked={!pageLockConfig ? false : pageLockConfig.is_locked}
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
readonly={true}
editor={editor}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={setSidePeekVisible}
KanbanMenuOptions={KanbanMenuOptions}
markings={markings}
documentDetails={documentDetails}
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
/>
<div className="h-full w-full flex overflow-hidden">
<div className="flex-shrink-0 h-full w-56 lg:w-80">
<SummarySideBar
editor={editor}
markings={markings}
sidePeakVisible={sidePeakVisible}
sidePeekVisible={sidePeekVisible}
/>
</div>
<div className="h-full w-full">
<PageRenderer
editor={editor}
editorClassNames={editorClassNames}
sidePeakVisible={sidePeakVisible}
documentDetails={documentDetails}
/>
</div>
<div className="hidden lg:block flex-shrink-0 w-56 lg:w-80" />
</div>
</div>
)
}
);
};
const DocumentReadOnlyEditorWithRef = forwardRef<
EditorHandle,
@ -118,4 +119,4 @@ const DocumentReadOnlyEditorWithRef = forwardRef<
DocumentReadOnlyEditorWithRef.displayName = "DocumentReadOnlyEditorWithRef";
export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef }
export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef };

View File

@ -1,75 +1,94 @@
import { Editor } from "@tiptap/react"
import { Archive, ArchiveIcon, ArchiveRestoreIcon, ClipboardIcon, Copy, Link, Lock, Unlock, XCircle } from "lucide-react"
import { NextRouter } from "next/router"
import { IVerticalDropdownItemProps } from "../components/vertical-dropdown-menu"
import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from "../types/menu-actions"
import { copyMarkdownToClipboard, CopyPageLink } from "./menu-actions"
import { Editor } from "@tiptap/react";
import {
Archive,
ArchiveIcon,
ArchiveRestoreIcon,
ClipboardIcon,
Copy,
Link,
Lock,
Unlock,
XCircle,
} from "lucide-react";
import { NextRouter } from "next/router";
import { IVerticalDropdownItemProps } from "../components/vertical-dropdown-menu";
import {
IDuplicationConfig,
IPageArchiveConfig,
IPageLockConfig,
} from "../types/menu-actions";
import { copyMarkdownToClipboard, CopyPageLink } from "./menu-actions";
export interface MenuOptionsProps{
editor: Editor,
router: NextRouter,
duplicationConfig?: IDuplicationConfig,
pageLockConfig?: IPageLockConfig ,
pageArchiveConfig?: IPageArchiveConfig,
export interface MenuOptionsProps {
editor: Editor;
router: NextRouter;
duplicationConfig?: IDuplicationConfig;
pageLockConfig?: IPageLockConfig;
pageArchiveConfig?: IPageArchiveConfig;
}
export const getMenuOptions = ({ editor, router, duplicationConfig, pageLockConfig, pageArchiveConfig } : MenuOptionsProps) => {
export const getMenuOptions = ({
editor,
router,
duplicationConfig,
pageLockConfig,
pageArchiveConfig,
}: MenuOptionsProps) => {
const KanbanMenuOptions: IVerticalDropdownItemProps[] = [
{
key: 1,
key: 1,
type: "copy_markdown",
Icon: ClipboardIcon,
action: () => copyMarkdownToClipboard(editor),
label: "Copy Markdown"
label: "Copy markdown",
},
// {
// key: 2,
// type: "close_page",
// Icon: XCircle,
// action: () => router.back(),
// label: "Close page",
// },
{
key: 2,
type: "close_page",
Icon: XCircle,
action: () => router.back(),
label: "Close the page"
},
{
key: 3,
key: 3,
type: "copy_page_link",
Icon: Link,
action: () => CopyPageLink(),
label: "Copy Page Link"
label: "Copy page link",
},
]
];
// If duplicateConfig is given, page duplication will be allowed
if (duplicationConfig) {
KanbanMenuOptions.push({
key: KanbanMenuOptions.length++,
key: KanbanMenuOptions.length++,
type: "duplicate_page",
Icon: Copy,
action: duplicationConfig.action,
label: "Make a copy"
})
label: "Make a copy",
});
}
// If Lock Configuration is given then, lock page option will be available in the kanban menu
if (pageLockConfig) {
KanbanMenuOptions.push({
key: KanbanMenuOptions.length++,
key: KanbanMenuOptions.length++,
type: pageLockConfig.is_locked ? "unlock_page" : "lock_page",
Icon: pageLockConfig.is_locked ? Unlock : Lock,
label: pageLockConfig.is_locked ? "Unlock Page" : "Lock Page",
action: pageLockConfig.action
})
label: pageLockConfig.is_locked ? "Unlock page" : "Lock page",
action: pageLockConfig.action,
});
}
// Archiving will be visible in the menu bar config once the pageArchiveConfig is given.
if (pageArchiveConfig) {
KanbanMenuOptions.push({
key: KanbanMenuOptions.length++,
key: KanbanMenuOptions.length++,
type: pageArchiveConfig.is_archived ? "unarchive_page" : "archive_page",
Icon: pageArchiveConfig.is_archived ? ArchiveRestoreIcon : Archive,
label: pageArchiveConfig.is_archived ? "Restore Page" : "Archive Page",
label: pageArchiveConfig.is_archived ? "Restore page" : "Archive page",
action: pageArchiveConfig.action,
})
});
}
return KanbanMenuOptions
}
return KanbanMenuOptions;
};

View File

@ -1,12 +1,13 @@
import React, { useEffect, useRef, useState, ReactElement } from "react";
import { useRouter } from "next/router";
import useSWR, { mutate } from "swr";
import useSWR from "swr";
import { Controller, useForm } from "react-hook-form";
// services
import { PageService } from "services/page.service";
import { useDebouncedCallback } from "use-debounce";
import { FileService } from "services/file.service";
// hooks
import useUser from "hooks/use-user";
import { useDebouncedCallback } from "use-debounce";
// layouts
import { AppLayout } from "layouts/app-layout";
// components
@ -24,7 +25,6 @@ import { NextPageWithLayout } from "types/app";
import { IPage } from "types";
// fetch-keys
import { PAGE_DETAILS } from "constants/fetch-keys";
import { FileService } from "services/file.service";
// services
const fileService = new FileService();
@ -45,10 +45,14 @@ const PageDetailsPage: NextPageWithLayout = () => {
});
// =================== Fetching Page Details ======================
const { data: pageDetails, error } = useSWR(
workspaceSlug && projectId && pageId ? PAGE_DETAILS(pageId as string) : null,
workspaceSlug && projectId
? () => pageService.getPageDetails(workspaceSlug as string, projectId as string, pageId as string)
const {
data: pageDetails,
mutate: mutatePageDetails,
error,
} = useSWR(
workspaceSlug && projectId && pageId ? PAGE_DETAILS(pageId.toString()) : null,
workspaceSlug && projectId && pageId
? () => pageService.getPageDetails(workspaceSlug.toString(), projectId.toString(), pageId.toString())
: null
);
@ -57,20 +61,23 @@ const PageDetailsPage: NextPageWithLayout = () => {
if (!formData.name || formData.name.length === 0 || formData.name === "") return;
await pageService.patchPage(workspaceSlug as string, projectId as string, pageId as string, formData).then(() => {
mutate<IPage>(
PAGE_DETAILS(pageId as string),
(prevData) => ({
...prevData,
...formData,
}),
false
);
});
await pageService
.patchPage(workspaceSlug.toString(), projectId.toString(), pageId.toString(), formData)
.then(() => {
mutatePageDetails(
(prevData) => ({
...prevData,
...formData,
}),
false
);
});
};
const createPage = async (payload: Partial<IPage>) => {
await pageService.createPage(workspaceSlug as string, projectId as string, payload);
if (!workspaceSlug || !projectId) return;
await pageService.createPage(workspaceSlug.toString(), projectId.toString(), payload);
};
// ================ Page Menu Actions ==================
@ -84,79 +91,79 @@ const PageDetailsPage: NextPageWithLayout = () => {
};
const archivePage = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
try {
await pageService.archivePage(workspaceSlug as string, projectId as string, pageId as string).then(() => {
mutate<IPage>(
PAGE_DETAILS(pageId as string),
(prevData) => {
if (prevData && prevData.is_locked) {
prevData.archived_at = renderDateFormat(new Date());
return prevData;
}
},
true
);
});
mutatePageDetails((prevData) => {
if (!prevData) return;
return {
...prevData,
archived_at: renderDateFormat(new Date()),
};
}, true);
await pageService.archivePage(workspaceSlug.toString(), projectId.toString(), pageId.toString());
} catch (e) {
console.log(e);
mutatePageDetails();
}
};
const unArchivePage = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
try {
await pageService.restorePage(workspaceSlug as string, projectId as string, pageId as string).then(() => {
mutate<IPage>(
PAGE_DETAILS(pageId as string),
(prevData) => {
if (prevData && prevData.is_locked) {
prevData.archived_at = null;
return prevData;
}
},
true
);
});
mutatePageDetails((prevData) => {
if (!prevData) return;
return {
...prevData,
archived_at: null,
};
}, false);
await pageService.restorePage(workspaceSlug.toString(), projectId.toString(), pageId.toString());
} catch (e) {
console.log(e);
mutatePageDetails();
}
};
// ========================= Page Lock ==========================
const lockPage = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
try {
await pageService.lockPage(workspaceSlug as string, projectId as string, pageId as string).then(() => {
mutate<IPage>(
PAGE_DETAILS(pageId as string),
(prevData) => {
if (prevData && prevData.is_locked) {
prevData.is_locked = true;
}
return prevData;
},
true
);
});
mutatePageDetails((prevData) => {
if (!prevData) return;
return {
...prevData,
is_locked: true,
};
}, false);
await pageService.lockPage(workspaceSlug.toString(), projectId.toString(), pageId.toString());
} catch (e) {
console.log(e);
mutatePageDetails();
}
};
const unlockPage = async () => {
if (!workspaceSlug || !projectId || !pageId) return;
try {
await pageService.unlockPage(workspaceSlug as string, projectId as string, pageId as string).then(() => {
mutate<IPage>(
PAGE_DETAILS(pageId as string),
(prevData) => {
if (prevData && prevData.is_locked) {
prevData.is_locked = false;
return prevData;
}
},
true
);
});
mutatePageDetails((prevData) => {
if (!prevData) return;
return {
...prevData,
is_locked: false,
};
}, false);
await pageService.unlockPage(workspaceSlug.toString(), projectId.toString(), pageId.toString());
} catch (e) {
console.log(e);
mutatePageDetails();
}
};
@ -185,8 +192,8 @@ const PageDetailsPage: NextPageWithLayout = () => {
}}
/>
) : pageDetails ? (
<div className="flex h-full flex-col justify-between pl-5 pr-5">
<div className="h-full w-full">
<div className="flex h-full flex-col justify-between">
<div className="h-full w-full overflow-hidden">
{pageDetails.is_locked || pageDetails.archived_at ? (
<DocumentReadOnlyEditorWithRef
ref={editorRef}