diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx index 2e45b9ba394..eacabf8f859 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/[pageId]/page.tsx @@ -161,7 +161,6 @@ const PageDetailsPage = observer(() => { config={pageRootConfig} handlers={pageRootHandlers} page={page} - storeType={EPageStoreType.PROJECT} webhookConnectionParams={webhookConnectionParams} workspaceSlug={workspaceSlug?.toString() ?? ""} /> diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx index 05cb3430b3a..d939c6fe5e3 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx @@ -8,10 +8,10 @@ import { ICustomSearchSelectOption } from "@plane/types"; import { Breadcrumbs, Header, CustomSearchSelect } from "@plane/ui"; // components import { BreadcrumbLink, PageAccessIcon, SwitcherLabel } from "@/components/common"; -import { PageEditInformationPopover } from "@/components/pages"; +import { PageHeaderActions } from "@/components/pages/header/actions"; // helpers -// hooks import { getPageName } from "@/helpers/page.helper"; +// hooks import { useProject } from "@/hooks/store"; // plane web components import { useAppRouter } from "@/hooks/use-app-router"; @@ -24,21 +24,22 @@ export interface IPagesHeaderProps { showButton?: boolean; } +const storeType = EPageStoreType.PROJECT; + export const PageDetailsHeader = observer(() => { // router const router = useAppRouter(); const { workspaceSlug, pageId, projectId } = useParams(); // store hooks const { currentProjectDetails, loader } = useProject(); + const { getPageById, getCurrentProjectPageIds } = usePageStore(storeType); const page = usePage({ pageId: pageId?.toString() ?? "", - storeType: EPageStoreType.PROJECT, + storeType, }); - const { getPageById, getCurrentProjectPageIds } = usePageStore(EPageStoreType.PROJECT); // derived values const projectPageIds = getCurrentProjectPageIds(projectId?.toString()); - if (!page) return null; const switcherOptions = projectPageIds .map((id) => { const _page = id === pageId ? page : getPageById(id); @@ -109,8 +110,8 @@ export const PageDetailsHeader = observer(() => { - + ); diff --git a/web/ce/components/pages/editor/ai/menu.tsx b/web/ce/components/pages/editor/ai/menu.tsx index cef586a7712..3c33d3f55d9 100644 --- a/web/ce/components/pages/editor/ai/menu.tsx +++ b/web/ce/components/pages/editor/ai/menu.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { RefObject, useEffect, useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { ChevronRight, CornerDownRight, LucideIcon, RefreshCcw, Sparkles, TriangleAlert } from "lucide-react"; // plane editor import { EditorRefApi } from "@plane/editor"; @@ -18,7 +18,7 @@ import { AskPiMenu } from "./ask-pi-menu"; const aiService = new AIService(); type Props = { - editorRef: RefObject; + editorRef: EditorRefApi | null; isOpen: boolean; onClose: () => void; workspaceId: string; @@ -73,7 +73,7 @@ export const EditorAIMenu: React.FC = (props) => { }; // handle task click const handleClick = async (key: AI_EDITOR_TASKS) => { - const selection = editorRef.current?.getSelectedText(); + const selection = editorRef?.getSelectedText(); if (!selection || activeTask === key) return; setActiveTask(key); if (key === AI_EDITOR_TASKS.ASK_ANYTHING) return; @@ -86,7 +86,7 @@ export const EditorAIMenu: React.FC = (props) => { }; // handle re-generate response const handleRegenerate = async () => { - const selection = editorRef.current?.getSelectedText(); + const selection = editorRef?.getSelectedText(); if (!selection || !activeTask) return; setIsRegenerating(true); await handleGenerateResponse({ @@ -104,7 +104,7 @@ export const EditorAIMenu: React.FC = (props) => { // handle re-generate response const handleToneChange = async (key: string) => { const selectedTone = TONES_LIST.find((t) => t.key === key); - const selection = editorRef.current?.getSelectedText(); + const selection = editorRef?.getSelectedText(); if (!selectedTone || !selection || !activeTask) return; setResponse(undefined); setIsRegenerating(false); @@ -123,7 +123,7 @@ export const EditorAIMenu: React.FC = (props) => { // handle replace selected text with the response const handleInsertText = (insertOnNextLine: boolean) => { if (!response) return; - editorRef.current?.insertText(response, insertOnNextLine); + editorRef?.insertText(response, insertOnNextLine); onClose(); }; diff --git a/web/ce/components/pages/header/collaborators-list.tsx b/web/ce/components/pages/header/collaborators-list.tsx new file mode 100644 index 00000000000..0e0a0b29476 --- /dev/null +++ b/web/ce/components/pages/header/collaborators-list.tsx @@ -0,0 +1,10 @@ +"use client"; + +// store +import { TPageInstance } from "@/store/pages/base-page"; + +export type TPageCollaboratorsListProps = { + page: TPageInstance; +}; + +export const PageCollaboratorsList = ({}: TPageCollaboratorsListProps) => null; diff --git a/web/ce/components/pages/header/lock-control.tsx b/web/ce/components/pages/header/lock-control.tsx new file mode 100644 index 00000000000..f06ba2ad5bb --- /dev/null +++ b/web/ce/components/pages/header/lock-control.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { observer } from "mobx-react"; +import { LockKeyhole, LockKeyholeOpen } from "lucide-react"; +// plane imports +import { Tooltip } from "@plane/ui"; +// hooks +import { usePageOperations } from "@/hooks/use-page-operations"; +// store +import { TPageInstance } from "@/store/pages/base-page"; + +// Define our lock display states, renaming "icon-only" to "neutral" +type LockDisplayState = "neutral" | "locked" | "unlocked"; + +type Props = { + page: TPageInstance; +}; + +export const PageLockControl = observer(({ page }: Props) => { + // Initial state: if locked, then "locked", otherwise default to "neutral" + const [displayState, setDisplayState] = useState(page.is_locked ? "locked" : "neutral"); + // derived values + const { canCurrentUserLockPage, is_locked } = page; + // Ref for the transition timer + const timerRef = useRef | null>(null); + // Ref to store the previous value of isLocked for detecting transitions + const prevLockedRef = useRef(is_locked); + // page operations + const { + pageOperations: { toggleLock }, + } = usePageOperations({ + page, + }); + + // Cleanup any running timer on unmount + useEffect( + () => () => { + if (timerRef.current) clearTimeout(timerRef.current); + }, + [] + ); + + // Update display state when isLocked changes + useEffect(() => { + // Clear any previous timer to avoid overlapping transitions + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + + // Transition logic: + // If locked, ensure the display state is "locked" + // If unlocked after being locked, show "unlocked" briefly then revert to "neutral" + if (is_locked) { + setDisplayState("locked"); + } else if (prevLockedRef.current === true) { + setDisplayState("unlocked"); + timerRef.current = setTimeout(() => { + setDisplayState("neutral"); + timerRef.current = null; + }, 600); + } else { + setDisplayState("neutral"); + } + + // Update the previous locked state + prevLockedRef.current = is_locked; + }, [is_locked]); + + if (!canCurrentUserLockPage) return null; + + // Render different UI based on the current display state + return ( + <> + {displayState === "neutral" && ( + + + + )} + + {displayState === "locked" && ( + + )} + + {displayState === "unlocked" && ( +
+ + + Unlocked + +
+ )} + + ); +}); diff --git a/web/ce/components/pages/header/move-control.tsx b/web/ce/components/pages/header/move-control.tsx new file mode 100644 index 00000000000..77397f0bc2f --- /dev/null +++ b/web/ce/components/pages/header/move-control.tsx @@ -0,0 +1,10 @@ +"use client"; + +// store +import { TPageInstance } from "@/store/pages/base-page"; + +export type TPageMoveControlProps = { + page: TPageInstance; +}; + +export const PageMoveControl = ({}: TPageMoveControlProps) => null; diff --git a/web/core/components/pages/dropdowns/edit-information-popover.tsx b/web/core/components/pages/dropdowns/edit-information-popover.tsx deleted file mode 100644 index 9157c9f91c7..00000000000 --- a/web/core/components/pages/dropdowns/edit-information-popover.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { observer } from "mobx-react"; -// helpers -import { calculateTimeAgoShort } from "@/helpers/date-time.helper"; -// store types -import { TPageInstance } from "@/store/pages/base-page"; - -type Props = { - page: TPageInstance; -}; - -export const PageEditInformationPopover: React.FC = observer((props) => { - const { page } = props; - - return ( -
- Edited {calculateTimeAgoShort(page.updated_at ?? "")} ago -
- ); -}); diff --git a/web/core/components/pages/dropdowns/index.ts b/web/core/components/pages/dropdowns/index.ts index 74ebad1d675..8ff5a89aff8 100644 --- a/web/core/components/pages/dropdowns/index.ts +++ b/web/core/components/pages/dropdowns/index.ts @@ -1,2 +1 @@ export * from "./actions"; -export * from "./edit-information-popover"; diff --git a/web/core/components/pages/editor/editor-body.tsx b/web/core/components/pages/editor/editor-body.tsx index beb33a20c87..5f194818361 100644 --- a/web/core/components/pages/editor/editor-body.tsx +++ b/web/core/components/pages/editor/editor-body.tsx @@ -43,10 +43,10 @@ export type TEditorBodyHandlers = { type Props = { config: TEditorBodyConfig; - editorRef: React.RefObject; editorReady: boolean; + editorForwardRef: React.RefObject; handleConnectionStatus: Dispatch>; - handleEditorReady: Dispatch>; + handleEditorReady: (status: boolean) => void; handlers: TEditorBodyHandlers; page: TPageInstance; webhookConnectionParams: TWebhookConnectionQueryParams; @@ -56,7 +56,7 @@ type Props = { export const PageEditorBody: React.FC = observer((props) => { const { config, - editorRef, + editorForwardRef, handleConnectionStatus, handleEditorReady, handlers, @@ -70,7 +70,7 @@ export const PageEditorBody: React.FC = observer((props) => { const { getUserDetails } = useMember(); // derived values - const { id: pageId, name: pageTitle, isContentEditable, updateTitle } = page; + const { id: pageId, name: pageTitle, isContentEditable, updateTitle, editorRef } = page; const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? ""; // issue-embed const { issueEmbedProps } = useIssueEmbed({ @@ -172,10 +172,10 @@ export const PageEditorBody: React.FC = observer((props) => {
- +
- +
@@ -196,7 +196,7 @@ export const PageEditorBody: React.FC = observer((props) => { id={pageId} fileHandler={config.fileHandler} handleEditorReady={handleEditorReady} - ref={editorRef} + ref={editorForwardRef} containerClassName="h-full p-0 pb-64" displayConfig={displayConfig} mentionHandler={{ diff --git a/web/core/components/pages/editor/header/logo-picker.tsx b/web/core/components/pages/editor/header/logo-picker.tsx index 2bb40446b45..67704667502 100644 --- a/web/core/components/pages/editor/header/logo-picker.tsx +++ b/web/core/components/pages/editor/header/logo-picker.tsx @@ -23,7 +23,7 @@ export const PageEditorHeaderLogoPicker: React.FC = observer((props) => { return (
diff --git a/web/core/components/pages/editor/page-root.tsx b/web/core/components/pages/editor/page-root.tsx index bb4bcb759b9..91c3107ad41 100644 --- a/web/core/components/pages/editor/page-root.tsx +++ b/web/core/components/pages/editor/page-root.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react"; import { useSearchParams } from "next/navigation"; // editor @@ -18,8 +18,6 @@ import { import { useAppRouter } from "@/hooks/use-app-router"; import { usePageFallback } from "@/hooks/use-page-fallback"; import { useQueryParams } from "@/hooks/use-query-params"; -// plane web hooks -import { EPageStoreType } from "@/plane-web/hooks/store"; // store import { TPageInstance } from "@/store/pages/base-page"; @@ -38,13 +36,12 @@ type TPageRootProps = { config: TPageRootConfig; handlers: TPageRootHandlers; page: TPageInstance; - storeType: EPageStoreType; webhookConnectionParams: TWebhookConnectionQueryParams; workspaceSlug: string; }; export const PageRoot = observer((props: TPageRootProps) => { - const { config, handlers, page, storeType, webhookConnectionParams, workspaceSlug } = props; + const { config, handlers, page, webhookConnectionParams, workspaceSlug } = props; // states const [editorReady, setEditorReady] = useState(false); const [hasConnectionFailed, setHasConnectionFailed] = useState(false); @@ -56,7 +53,7 @@ export const PageRoot = observer((props: TPageRootProps) => { // search params const searchParams = useSearchParams(); // derived values - const { isContentEditable } = page; + const { isContentEditable, setEditorRef } = page; // page fallback usePageFallback({ editorRef, @@ -67,6 +64,16 @@ export const PageRoot = observer((props: TPageRootProps) => { // update query params const { updateQueryParams } = useQueryParams(); + const handleEditorReady = useCallback( + (status: boolean) => { + setEditorReady(status); + if (editorRef.current && !page.editorRef) { + setEditorRef(editorRef.current); + } + }, + [page.editorRef, setEditorRef] + ); + const version = searchParams.get("version"); useEffect(() => { if (!version) { @@ -89,6 +96,14 @@ export const PageRoot = observer((props: TPageRootProps) => { }; const currentVersionDescription = editorRef.current?.getDocument().html; + // reset editor ref on unmount + useEffect( + () => () => { + setEditorRef(null); + }, + [setEditorRef] + ); + return ( <> { pageId={page.id ?? ""} restoreEnabled={isContentEditable} /> - + = (props) => { }; }, [editorRef]); - const handleOnClick = (marking: IMarking) => { - editorRef?.scrollSummary(marking); - if (setSidePeekVisible) setSidePeekVisible(false); - }; + const handleOnClick = useCallback( + (marking: IMarking) => { + editorRef?.scrollSummary(marking); + setSidePeekVisible?.(false); + }, + [editorRef, setSidePeekVisible] + ); const HeadingComponent: { [key: number]: React.FC<{ marking: IMarking; onClick: () => void }>; diff --git a/web/core/components/pages/editor/title.tsx b/web/core/components/pages/editor/title.tsx index f7f3822d3c0..5864ac5d9b7 100644 --- a/web/core/components/pages/editor/title.tsx +++ b/web/core/components/pages/editor/title.tsx @@ -13,7 +13,7 @@ import { getPageName } from "@/helpers/page.helper"; import { usePageFilters } from "@/hooks/use-page-filters"; type Props = { - editorRef: React.RefObject; + editorRef: EditorRefApi | null; readOnly: boolean; title: string | undefined; updateTitle: (title: string) => void; @@ -53,7 +53,7 @@ export const PageEditorTitle: React.FC = observer((props) => { onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); - editorRef.current?.setFocusAtPosition(0); + editorRef?.setFocusAtPosition(0); } }} value={title} diff --git a/web/core/components/pages/editor/toolbar/extra-options.tsx b/web/core/components/pages/editor/toolbar/extra-options.tsx deleted file mode 100644 index d9198b84f13..00000000000 --- a/web/core/components/pages/editor/toolbar/extra-options.tsx +++ /dev/null @@ -1,103 +0,0 @@ -"use client"; - -import { observer } from "mobx-react"; -// constants -import { IS_FAVORITE_MENU_OPEN } from "@plane/constants"; -// editor -import { EditorRefApi } from "@plane/editor"; -// plane hooks -import { useLocalStorage } from "@plane/hooks"; -// ui -import { ArchiveIcon, FavoriteStar, setToast, TOAST_TYPE, Tooltip } from "@plane/ui"; -// components -import { LockedComponent } from "@/components/icons/locked-component"; -import { PageInfoPopover, PageOptionsDropdown } from "@/components/pages"; -// helpers -import { renderFormattedDate } from "@/helpers/date-time.helper"; -// hooks -import useOnlineStatus from "@/hooks/use-online-status"; -// plane web hooks -import { EPageStoreType } from "@/plane-web/hooks/store"; -// store -import { TPageInstance } from "@/store/pages/base-page"; - -type Props = { - editorRef: EditorRefApi; - page: TPageInstance; - storeType: EPageStoreType; -}; - -export const PageExtraOptions: React.FC = observer((props) => { - const { editorRef, page, storeType } = props; - // derived values - const { - archived_at, - isContentEditable, - is_favorite, - is_locked, - canCurrentUserFavoritePage, - addToFavorites, - removePageFromFavorites, - } = page; - // use online status - const { isOnline } = useOnlineStatus(); - // local storage - const { setValue: toggleFavoriteMenu, storedValue: isFavoriteMenuOpen } = useLocalStorage( - IS_FAVORITE_MENU_OPEN, - false - ); - // favorite handler - const handleFavorite = () => { - if (is_favorite) { - removePageFromFavorites().then(() => - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Page removed from favorites.", - }) - ); - } else { - addToFavorites().then(() => { - if (!isFavoriteMenuOpen) toggleFavoriteMenu(true); - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Page added to favorites.", - }); - }); - } - }; - - return ( -
- {is_locked && } - {archived_at && ( -
- - Archived at {renderFormattedDate(archived_at)} -
- )} - {isContentEditable && !isOnline && ( - -
- - Offline -
-
- )} - {canCurrentUserFavoritePage && ( - - )} - - -
- ); -}); diff --git a/web/core/components/pages/editor/toolbar/index.ts b/web/core/components/pages/editor/toolbar/index.ts index d87f5d11946..66652b2dbd0 100644 --- a/web/core/components/pages/editor/toolbar/index.ts +++ b/web/core/components/pages/editor/toolbar/index.ts @@ -1,7 +1,5 @@ export * from "./color-dropdown"; -export * from "./extra-options"; export * from "./info-popover"; export * from "./options-dropdown"; export * from "./root"; -export * from "./mobile-root"; export * from "./toolbar"; diff --git a/web/core/components/pages/editor/toolbar/info-popover.tsx b/web/core/components/pages/editor/toolbar/info-popover.tsx index ea9b734da10..d5a28145949 100644 --- a/web/core/components/pages/editor/toolbar/info-popover.tsx +++ b/web/core/components/pages/editor/toolbar/info-popover.tsx @@ -1,28 +1,25 @@ 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 editor -import { EditorRefApi } from "@plane/editor"; -// plane ui +// plane imports import { Avatar } from "@plane/ui"; -// plane utils import { getFileURL, renderFormattedDate } from "@plane/utils"; // helpers -import { getReadTimeFromWordsCount } from "@/helpers/date-time.helper"; +import { calculateTimeAgoShort, getReadTimeFromWordsCount } from "@/helpers/date-time.helper"; // hooks import { useMember } from "@/hooks/store"; // store types import { TPageInstance } from "@/store/pages/base-page"; type Props = { - editorRef: EditorRefApi | null; page: TPageInstance; }; -export const PageInfoPopover: React.FC = (props) => { - const { editorRef, page } = props; +export const PageInfoPopover: React.FC = observer((props) => { + const { page } = props; // states const [isPopoverOpen, setIsPopoverOpen] = useState(false); // refs @@ -40,7 +37,7 @@ export const PageInfoPopover: React.FC = (props) => { const editorInformation = page.updated_by ? getUserDetails(page.updated_by) : undefined; const creatorInformation = page.created_by ? getUserDetails(page.created_by) : undefined; - const documentsInfo = editorRef?.getDocumentInfo() || { words: 0, characters: 0, paragraphs: 0 }; + const documentsInfo = page.editorRef?.getDocumentInfo() || { words: 0, characters: 0, paragraphs: 0 }; const secondsToReadableTime = () => { const wordsCount = documentsInfo.words; @@ -72,8 +69,16 @@ export const PageInfoPopover: React.FC = (props) => { ]; return ( -
setIsPopoverOpen(true)} onMouseLeave={() => setIsPopoverOpen(false)}> - {isPopoverOpen && ( @@ -106,7 +111,7 @@ export const PageInfoPopover: React.FC = (props) => { /> {editorInformation?.display_name}{" "} - {renderFormattedDate(page.updated_at)} + {calculateTimeAgoShort(page.updated_at ?? "")} ago
@@ -133,4 +138,4 @@ export const PageInfoPopover: React.FC = (props) => { )}
); -}; +}); diff --git a/web/core/components/pages/editor/toolbar/mobile-root.tsx b/web/core/components/pages/editor/toolbar/mobile-root.tsx deleted file mode 100644 index ccc04ab6ebb..00000000000 --- a/web/core/components/pages/editor/toolbar/mobile-root.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { observer } from "mobx-react"; -// plane imports -import { EditorRefApi } from "@plane/editor"; -import { Header, EHeaderVariant } from "@plane/ui"; -// components -import { PageExtraOptions, PageToolbar } from "@/components/pages"; -// hooks -import { usePageFilters } from "@/hooks/use-page-filters"; -// plane web hooks -import { EPageStoreType } from "@/plane-web/hooks/store"; -// store -import { TPageInstance } from "@/store/pages/base-page"; - -type Props = { - editorRef: EditorRefApi; - page: TPageInstance; - storeType: EPageStoreType; -}; - -export const PageEditorMobileHeaderRoot: React.FC = observer((props) => { - const { editorRef, page, storeType } = props; - // derived values - const { isContentEditable } = page; - // page filters - const { isStickyToolbarEnabled } = usePageFilters(); - - return ( - <> -
- -
-
- {isContentEditable && editorRef && } -
- - ); -}); diff --git a/web/core/components/pages/editor/toolbar/options-dropdown.tsx b/web/core/components/pages/editor/toolbar/options-dropdown.tsx index 38f0a4e239e..4dbdbf50acc 100644 --- a/web/core/components/pages/editor/toolbar/options-dropdown.tsx +++ b/web/core/components/pages/editor/toolbar/options-dropdown.tsx @@ -4,9 +4,7 @@ import { useMemo, useState } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/navigation"; import { ArrowUpToLine, Clipboard, History } from "lucide-react"; -// document editor -import { EditorRefApi } from "@plane/editor"; -// ui +// plane imports import { TContextMenuItem, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui"; // components import { ExportPageModal, PageActions, TPageActions } from "@/components/pages"; @@ -21,19 +19,18 @@ import { EPageStoreType } from "@/plane-web/hooks/store"; import { TPageInstance } from "@/store/pages/base-page"; type Props = { - editorRef: EditorRefApi | null; page: TPageInstance; storeType: EPageStoreType; }; export const PageOptionsDropdown: React.FC = observer((props) => { - const { editorRef, page, storeType } = props; + const { page, storeType } = props; // states const [isExportModalOpen, setIsExportModalOpen] = useState(false); // router const router = useRouter(); // store values - const { name, isContentEditable } = page; + const { name, isContentEditable, editorRef } = page; // page filters const { isFullWidth, handleFullWidth, isStickyToolbarEnabled, handleStickyToolbar } = usePageFilters(); // update query params @@ -127,10 +124,7 @@ export const PageOptionsDropdown: React.FC = observer((props) => { optionsOrder={[ "full-screen", "sticky-toolbar", - "copy-link", "make-a-copy", - "move", - "toggle-lock", "toggle-access", "archive-restore", "delete", diff --git a/web/core/components/pages/editor/toolbar/root.tsx b/web/core/components/pages/editor/toolbar/root.tsx index 901ce7f5ea5..8ce0bd005d9 100644 --- a/web/core/components/pages/editor/toolbar/root.tsx +++ b/web/core/components/pages/editor/toolbar/root.tsx @@ -1,58 +1,48 @@ import { observer } from "mobx-react"; -import { EditorRefApi } from "@plane/editor"; // components -import { PageEditorMobileHeaderRoot, PageExtraOptions, PageToolbar } from "@/components/pages"; +import { PageToolbar } from "@/components/pages"; // helpers import { cn } from "@/helpers/common.helper"; // hooks import { usePageFilters } from "@/hooks/use-page-filters"; -// plane web hooks -import { EPageStoreType } from "@/plane-web/hooks/store"; +// plane web components +import { PageCollaboratorsList } from "@/plane-web/components/pages/header/collaborators-list"; // store import { TPageInstance } from "@/store/pages/base-page"; type Props = { - editorReady: boolean; - editorRef: React.RefObject; page: TPageInstance; - storeType: EPageStoreType; }; export const PageEditorToolbarRoot: React.FC = observer((props) => { - const { editorReady, editorRef, page, storeType } = props; + const { page } = props; // derived values - const { isContentEditable } = page; + const { isContentEditable, editorRef } = page; // page filters const { isFullWidth, isStickyToolbarEnabled } = usePageFilters(); // derived values - const resolvedEditorRef = editorRef.current; const shouldHideToolbar = !isStickyToolbarEnabled || !isContentEditable; - if (!resolvedEditorRef) return null; - return ( -
+
-
- {editorReady && resolvedEditorRef && ( - - )} -
- + {editorRef && } +
-
- -
); }); diff --git a/web/core/components/pages/editor/toolbar/toolbar.tsx b/web/core/components/pages/editor/toolbar/toolbar.tsx index a9c374f685a..f6dd3069166 100644 --- a/web/core/components/pages/editor/toolbar/toolbar.tsx +++ b/web/core/components/pages/editor/toolbar/toolbar.tsx @@ -15,7 +15,6 @@ import { cn } from "@/helpers/common.helper"; type Props = { editorRef: EditorRefApi; - isHidden: boolean; }; type ToolbarButtonProps = { @@ -65,7 +64,7 @@ ToolbarButton.displayName = "ToolbarButton"; const toolbarItems = TOOLBAR_ITEMS.document; export const PageToolbar: React.FC = (props) => { - const { editorRef, isHidden } = props; + const { editorRef } = props; // states const [activeStates, setActiveStates] = useState>({}); @@ -98,14 +97,7 @@ export const PageToolbar: React.FC = (props) => { ); return ( -
+
@@ -139,20 +131,22 @@ export const PageToolbar: React.FC = (props) => { ))} - - editorRef.executeMenuItemCommand({ - itemKey: key, - color, - }) - } - isColorActive={(key, color) => - editorRef.isMenuItemActive({ - itemKey: key, - color, - }) - } - /> +
+ + editorRef.executeMenuItemCommand({ + itemKey: key, + color, + }) + } + isColorActive={(key, color) => + editorRef.isMenuItemActive({ + itemKey: key, + color, + }) + } + /> +
{Object.keys(toolbarItems).map((key) => (
{toolbarItems[key].map((item) => ( diff --git a/web/core/components/pages/header/actions.tsx b/web/core/components/pages/header/actions.tsx new file mode 100644 index 00000000000..c9e3843704b --- /dev/null +++ b/web/core/components/pages/header/actions.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { observer } from "mobx-react"; +// components +import { PageInfoPopover, 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"; +// plane web hooks +import { EPageStoreType } from "@/plane-web/hooks/store"; +// store +import { TPageInstance } from "@/store/pages/base-page"; +// local imports +import { PageArchivedBadge } from "./archived-badge"; +import { PageCopyLinkControl } from "./copy-link-control"; +import { PageOfflineBadge } from "./offline-badge"; + +type Props = { + page: TPageInstance; + storeType: EPageStoreType; +}; + +export const PageHeaderActions: React.FC = observer((props) => { + const { page, storeType } = props; + + return ( +
+ + + + + + + +
+ ); +}); diff --git a/web/core/components/pages/header/archived-badge.tsx b/web/core/components/pages/header/archived-badge.tsx new file mode 100644 index 00000000000..24f239c875b --- /dev/null +++ b/web/core/components/pages/header/archived-badge.tsx @@ -0,0 +1,21 @@ +import { observer } from "mobx-react"; +// plane imports +import { ArchiveIcon } from "@plane/ui"; +import { renderFormattedDate } from "@plane/utils"; +// store +import { TPageInstance } from "@/store/pages/base-page"; + +type Props = { + page: TPageInstance; +}; + +export const PageArchivedBadge = observer(({ page }: Props) => { + if (!page.archived_at) return null; + + return ( +
+ + Archived at {renderFormattedDate(page.archived_at)} +
+ ); +}); diff --git a/web/core/components/pages/header/copy-link-control.tsx b/web/core/components/pages/header/copy-link-control.tsx new file mode 100644 index 00000000000..a498e3ab93c --- /dev/null +++ b/web/core/components/pages/header/copy-link-control.tsx @@ -0,0 +1,27 @@ +import { observer } from "mobx-react"; +import { Link } from "lucide-react"; +// hooks +import { usePageOperations } from "@/hooks/use-page-operations"; +// store +import { TPageInstance } from "@/store/pages/base-page"; + +type Props = { + page: TPageInstance; +}; + +export const PageCopyLinkControl = observer(({ page }: Props) => { + // page operations + const { pageOperations } = usePageOperations({ + page, + }); + + return ( + + ); +}); diff --git a/web/core/components/pages/header/favorite-control.tsx b/web/core/components/pages/header/favorite-control.tsx new file mode 100644 index 00000000000..948ce4f522b --- /dev/null +++ b/web/core/components/pages/header/favorite-control.tsx @@ -0,0 +1,31 @@ +import { observer } from "mobx-react"; +// plane imports +import { FavoriteStar } from "@plane/ui"; +// hooks +import { usePageOperations } from "@/hooks/use-page-operations"; +// store +import { TPageInstance } from "@/store/pages/base-page"; + +type Props = { + page: TPageInstance; +}; + +export const PageFavoriteControl = observer(({ page }: Props) => { + // derived values + const { is_favorite, canCurrentUserFavoritePage } = page; + // page operations + const { pageOperations } = usePageOperations({ + page, + }); + + if (!canCurrentUserFavoritePage) return null; + + return ( + + ); +}); diff --git a/web/core/components/pages/header/offline-badge.tsx b/web/core/components/pages/header/offline-badge.tsx new file mode 100644 index 00000000000..31e68e576c2 --- /dev/null +++ b/web/core/components/pages/header/offline-badge.tsx @@ -0,0 +1,30 @@ +import { observer } from "mobx-react"; +// plane imports +import { Tooltip } from "@plane/ui"; +// hooks +import useOnlineStatus from "@/hooks/use-online-status"; +// store +import { TPageInstance } from "@/store/pages/base-page"; + +type Props = { + page: TPageInstance; +}; + +export const PageOfflineBadge = observer(({ page }: Props) => { + // use online status + const { isOnline } = useOnlineStatus(); + + if (!page.isContentEditable || isOnline) return null; + + return ( + +
+ + Offline +
+
+ ); +}); diff --git a/web/core/hooks/use-page-operations.ts b/web/core/hooks/use-page-operations.ts index c893126b8b8..ab4d2e7ab57 100644 --- a/web/core/hooks/use-page-operations.ts +++ b/web/core/hooks/use-page-operations.ts @@ -47,7 +47,7 @@ export const usePageOperations = ( // collaborative actions const { executeCollaborativeAction } = useCollaborativePageActions(props); // local storage - const { setValue: toggleFavoriteMenu, storedValue: isfavoriteMenuOpen } = useLocalStorage( + const { setValue: toggleFavoriteMenu, storedValue: isFavoriteMenuOpen } = useLocalStorage( IS_FAVORITE_MENU_OPEN, false ); @@ -147,7 +147,7 @@ export const usePageOperations = ( ); } else { addToFavorites().then(() => { - if (!isfavoriteMenuOpen) toggleFavoriteMenu(true); + if (!isFavoriteMenuOpen) toggleFavoriteMenu(true); setToast({ type: TOAST_TYPE.SUCCESS, title: "Success!", @@ -199,7 +199,9 @@ export const usePageOperations = ( getRedirectionLink, is_favorite, is_locked, + isFavoriteMenuOpen, removePageFromFavorites, + toggleFavoriteMenu, ]); return { pageOperations, diff --git a/web/core/store/pages/base-page.ts b/web/core/store/pages/base-page.ts index 6bba1c0e05a..32a087c95ff 100644 --- a/web/core/store/pages/base-page.ts +++ b/web/core/store/pages/base-page.ts @@ -2,6 +2,7 @@ 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"; @@ -11,6 +12,7 @@ import { RootStore } from "@/plane-web/store/root.store"; export type TBasePage = TPage & { // observables isSubmitting: TNameDescriptionLoader; + editorRef: EditorRefApi | null; // computed asJSON: TPage | undefined; isCurrentUserOwner: boolean; @@ -32,6 +34,8 @@ export type TBasePage = TPage & { addToFavorites: () => Promise; removePageFromFavorites: () => Promise; duplicate: () => Promise; + mutateProperties: (data: Partial, shouldUpdateName?: boolean) => void; + setEditorRef: (editorRef: EditorRefApi | null) => void; }; export type TBasePagePermissions = { @@ -68,6 +72,7 @@ export type TPageInstance = TBasePage & export class BasePage implements TBasePage { // loaders isSubmitting: TNameDescriptionLoader = "saved"; + editorRef: EditorRefApi | null = null; // page properties id: string | undefined; name: string | undefined; @@ -125,6 +130,7 @@ export class BasePage implements TBasePage { makeObservable(this, { // loaders isSubmitting: observable.ref, + editorRef: observable.ref, // page properties id: observable.ref, name: observable.ref, @@ -165,6 +171,8 @@ export class BasePage implements TBasePage { addToFavorites: action, removePageFromFavorites: action, duplicate: action, + mutateProperties: action, + setEditorRef: action, }); this.rootStore = store; @@ -426,25 +434,34 @@ export class BasePage implements TBasePage { }; updatePageLogo = async (value: TChangeHandlerProps) => { - let logoValue = {}; - if (value?.type === "emoji") - logoValue = { - value: convertHexEmojiToDecimal(value.value.unified), - url: value.value.imageUrl, + const originalLogoProps = { ...this.logo_props }; + try { + let logoValue = {}; + if (value?.type === "emoji") + logoValue = { + value: convertHexEmojiToDecimal(value.value.unified), + url: value.value.imageUrl, + }; + else if (value?.type === "icon") logoValue = value.value; + + const logoProps: TLogoProps = { + in_use: value?.type, + [value?.type]: logoValue, }; - else if (value?.type === "icon") logoValue = value.value; - - const logoProps: TLogoProps = { - in_use: value?.type, - [value?.type]: logoValue, - }; - await this.services.update({ - logo_props: logoProps, - }); - runInAction(() => { - this.logo_props = logoProps; - }); + runInAction(() => { + this.logo_props = logoProps; + }); + await this.services.update({ + logo_props: logoProps, + }); + } catch (error) { + console.error("Error in updating page logo", error); + runInAction(() => { + this.logo_props = originalLogoProps as TLogoProps; + }); + throw error; + } }; /** @@ -498,4 +515,23 @@ export class BasePage implements TBasePage { * @description duplicate the page */ duplicate = async () => await this.services.duplicate(); + + /** + * @description mutate multiple properties at once + * @param data Partial + */ + mutateProperties = (data: Partial, shouldUpdateName: boolean = true) => { + Object.keys(data).forEach((key) => { + const value = data[key as keyof TPage]; + if (key === "name" && !shouldUpdateName) return; + set(this, key, value); + }); + }; + + setEditorRef = (editorRef: EditorRefApi | null) => { + console.log("store editorRef", editorRef); + runInAction(() => { + this.editorRef = editorRef; + }); + }; } diff --git a/web/core/store/pages/project-page.store.ts b/web/core/store/pages/project-page.store.ts index e3ddacb5fff..b2f7b6a1ef3 100644 --- a/web/core/store/pages/project-page.store.ts +++ b/web/core/store/pages/project-page.store.ts @@ -206,7 +206,19 @@ export class ProjectPageStore implements IProjectPageStore { const pages = await this.service.fetchAll(workspaceSlug, projectId); runInAction(() => { - for (const page of pages) if (page?.id) set(this.data, [page.id], new ProjectPage(this.store, page)); + for (const page of pages) { + if (page?.id) { + const existingPage = this.getPageById(page.id); + if (existingPage) { + // If page already exists, update all fields except name + const { name, ...otherFields } = page; + existingPage.mutateProperties(otherFields, false); + } else { + // If new page, create a new instance with all data + set(this.data, [page.id], new ProjectPage(this.store, page)); + } + } + } this.loader = undefined; }); @@ -238,8 +250,16 @@ export class ProjectPageStore implements IProjectPageStore { }); const page = await this.service.fetchById(workspaceSlug, projectId, pageId); + const pageInstance = page?.id ? this.getPageById(page.id) : undefined; + runInAction(() => { - if (page?.id) set(this.data, [page.id], new ProjectPage(this.store, page)); + if (page?.id) { + if (pageInstance) { + pageInstance.mutateProperties(page, false); + } else { + set(this.data, [page.id], new ProjectPage(this.store, page)); + } + } this.loader = undefined; }); diff --git a/web/styles/globals.css b/web/styles/globals.css index 68e81e0984a..55c8c869ee7 100644 --- a/web/styles/globals.css +++ b/web/styles/globals.css @@ -854,3 +854,91 @@ div.web-view-spinner div.bar12 { .epr-search-container > .epr-icn-search { color: rgb(var(--color-text-400)) !important; } + +/* Lock icon animations */ +@keyframes textSlideIn { + 0% { + opacity: 0; + transform: translateX(-8px); + max-width: 0px; + } + 40% { + opacity: 0.7; + max-width: 60px; + } + 100% { + opacity: 1; + transform: translateX(0); + max-width: 60px; + } +} + +@keyframes textFadeOut { + 0% { + opacity: 1; + transform: translateX(0); + } + 100% { + opacity: 0; + transform: translateX(8px); + } +} + +@keyframes lockIconAnimation { + 0% { + transform: rotate(-5deg) scale(1); + } + 25% { + transform: rotate(0deg) scale(1.15); + } + 50% { + transform: rotate(5deg) scale(1.08); + } + 100% { + transform: rotate(0deg) scale(1); + } +} + +@keyframes unlockIconAnimation { + 0% { + transform: rotate(0deg) scale(1); + } + 40% { + transform: rotate(-8deg) scale(1.15); + } + 80% { + transform: rotate(3deg) scale(1.05); + } + 100% { + transform: rotate(0deg) scale(1); + } +} + +@keyframes fadeOut { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +.animate-text-slide-in { + animation: textSlideIn 400ms ease-out forwards; +} + +.animate-text-fade-out { + animation: textFadeOut 600ms ease-in 300ms forwards; +} + +.animate-lock-icon { + animation: lockIconAnimation 600ms ease-out forwards; +} + +.animate-unlock-icon { + animation: unlockIconAnimation 600ms ease-out forwards; +} + +.animate-fade-out { + animation: fadeOut 500ms ease-in 100ms forwards; +}