[WIKI-320] refactor: page header actions (#6946)

* refactor: page header actions

* chore: update toolbar component

* chore: update archived and lock badge colors

* chore: added observer to favorite control
This commit is contained in:
Aaryan Khandelwal 2025-04-17 20:52:33 +05:30 committed by GitHub
parent 8166a757a7
commit eac1115566
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 557 additions and 290 deletions

View file

@ -161,7 +161,6 @@ const PageDetailsPage = observer(() => {
config={pageRootConfig} config={pageRootConfig}
handlers={pageRootHandlers} handlers={pageRootHandlers}
page={page} page={page}
storeType={EPageStoreType.PROJECT}
webhookConnectionParams={webhookConnectionParams} webhookConnectionParams={webhookConnectionParams}
workspaceSlug={workspaceSlug?.toString() ?? ""} workspaceSlug={workspaceSlug?.toString() ?? ""}
/> />

View file

@ -8,10 +8,10 @@ import { ICustomSearchSelectOption } from "@plane/types";
import { Breadcrumbs, Header, CustomSearchSelect } from "@plane/ui"; import { Breadcrumbs, Header, CustomSearchSelect } from "@plane/ui";
// components // components
import { BreadcrumbLink, PageAccessIcon, SwitcherLabel } from "@/components/common"; import { BreadcrumbLink, PageAccessIcon, SwitcherLabel } from "@/components/common";
import { PageEditInformationPopover } from "@/components/pages"; import { PageHeaderActions } from "@/components/pages/header/actions";
// helpers // helpers
// hooks
import { getPageName } from "@/helpers/page.helper"; import { getPageName } from "@/helpers/page.helper";
// hooks
import { useProject } from "@/hooks/store"; import { useProject } from "@/hooks/store";
// plane web components // plane web components
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
@ -24,21 +24,22 @@ export interface IPagesHeaderProps {
showButton?: boolean; showButton?: boolean;
} }
const storeType = EPageStoreType.PROJECT;
export const PageDetailsHeader = observer(() => { export const PageDetailsHeader = observer(() => {
// router // router
const router = useAppRouter(); const router = useAppRouter();
const { workspaceSlug, pageId, projectId } = useParams(); const { workspaceSlug, pageId, projectId } = useParams();
// store hooks // store hooks
const { currentProjectDetails, loader } = useProject(); const { currentProjectDetails, loader } = useProject();
const { getPageById, getCurrentProjectPageIds } = usePageStore(storeType);
const page = usePage({ const page = usePage({
pageId: pageId?.toString() ?? "", pageId: pageId?.toString() ?? "",
storeType: EPageStoreType.PROJECT, storeType,
}); });
const { getPageById, getCurrentProjectPageIds } = usePageStore(EPageStoreType.PROJECT);
// derived values // derived values
const projectPageIds = getCurrentProjectPageIds(projectId?.toString()); const projectPageIds = getCurrentProjectPageIds(projectId?.toString());
if (!page) return null;
const switcherOptions = projectPageIds const switcherOptions = projectPageIds
.map((id) => { .map((id) => {
const _page = id === pageId ? page : getPageById(id); const _page = id === pageId ? page : getPageById(id);
@ -109,8 +110,8 @@ export const PageDetailsHeader = observer(() => {
</div> </div>
</Header.LeftItem> </Header.LeftItem>
<Header.RightItem> <Header.RightItem>
<PageEditInformationPopover page={page} />
<PageDetailsHeaderExtraActions page={page} /> <PageDetailsHeaderExtraActions page={page} />
<PageHeaderActions page={page} storeType={storeType} />
</Header.RightItem> </Header.RightItem>
</Header> </Header>
); );

View file

@ -1,6 +1,6 @@
"use client"; "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"; import { ChevronRight, CornerDownRight, LucideIcon, RefreshCcw, Sparkles, TriangleAlert } from "lucide-react";
// plane editor // plane editor
import { EditorRefApi } from "@plane/editor"; import { EditorRefApi } from "@plane/editor";
@ -18,7 +18,7 @@ import { AskPiMenu } from "./ask-pi-menu";
const aiService = new AIService(); const aiService = new AIService();
type Props = { type Props = {
editorRef: RefObject<EditorRefApi>; editorRef: EditorRefApi | null;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
workspaceId: string; workspaceId: string;
@ -73,7 +73,7 @@ export const EditorAIMenu: React.FC<Props> = (props) => {
}; };
// handle task click // handle task click
const handleClick = async (key: AI_EDITOR_TASKS) => { const handleClick = async (key: AI_EDITOR_TASKS) => {
const selection = editorRef.current?.getSelectedText(); const selection = editorRef?.getSelectedText();
if (!selection || activeTask === key) return; if (!selection || activeTask === key) return;
setActiveTask(key); setActiveTask(key);
if (key === AI_EDITOR_TASKS.ASK_ANYTHING) return; if (key === AI_EDITOR_TASKS.ASK_ANYTHING) return;
@ -86,7 +86,7 @@ export const EditorAIMenu: React.FC<Props> = (props) => {
}; };
// handle re-generate response // handle re-generate response
const handleRegenerate = async () => { const handleRegenerate = async () => {
const selection = editorRef.current?.getSelectedText(); const selection = editorRef?.getSelectedText();
if (!selection || !activeTask) return; if (!selection || !activeTask) return;
setIsRegenerating(true); setIsRegenerating(true);
await handleGenerateResponse({ await handleGenerateResponse({
@ -104,7 +104,7 @@ export const EditorAIMenu: React.FC<Props> = (props) => {
// handle re-generate response // handle re-generate response
const handleToneChange = async (key: string) => { const handleToneChange = async (key: string) => {
const selectedTone = TONES_LIST.find((t) => t.key === key); const selectedTone = TONES_LIST.find((t) => t.key === key);
const selection = editorRef.current?.getSelectedText(); const selection = editorRef?.getSelectedText();
if (!selectedTone || !selection || !activeTask) return; if (!selectedTone || !selection || !activeTask) return;
setResponse(undefined); setResponse(undefined);
setIsRegenerating(false); setIsRegenerating(false);
@ -123,7 +123,7 @@ export const EditorAIMenu: React.FC<Props> = (props) => {
// handle replace selected text with the response // handle replace selected text with the response
const handleInsertText = (insertOnNextLine: boolean) => { const handleInsertText = (insertOnNextLine: boolean) => {
if (!response) return; if (!response) return;
editorRef.current?.insertText(response, insertOnNextLine); editorRef?.insertText(response, insertOnNextLine);
onClose(); onClose();
}; };

View file

@ -0,0 +1,10 @@
"use client";
// store
import { TPageInstance } from "@/store/pages/base-page";
export type TPageCollaboratorsListProps = {
page: TPageInstance;
};
export const PageCollaboratorsList = ({}: TPageCollaboratorsListProps) => null;

View file

@ -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<LockDisplayState>(page.is_locked ? "locked" : "neutral");
// derived values
const { canCurrentUserLockPage, is_locked } = page;
// Ref for the transition timer
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Ref to store the previous value of isLocked for detecting transitions
const prevLockedRef = useRef(is_locked);
// page operations
const {
pageOperations: { toggleLock },
} = usePageOperations({
page,
});
// Cleanup any running timer on unmount
useEffect(
() => () => {
if (timerRef.current) clearTimeout(timerRef.current);
},
[]
);
// Update display state when isLocked changes
useEffect(() => {
// Clear any previous timer to avoid overlapping transitions
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
// Transition logic:
// If locked, ensure the display state is "locked"
// If unlocked after being locked, show "unlocked" briefly then revert to "neutral"
if (is_locked) {
setDisplayState("locked");
} else if (prevLockedRef.current === true) {
setDisplayState("unlocked");
timerRef.current = setTimeout(() => {
setDisplayState("neutral");
timerRef.current = null;
}, 600);
} else {
setDisplayState("neutral");
}
// Update the previous locked state
prevLockedRef.current = is_locked;
}, [is_locked]);
if (!canCurrentUserLockPage) return null;
// Render different UI based on the current display state
return (
<>
{displayState === "neutral" && (
<Tooltip tooltipContent="Lock" position="bottom">
<button
type="button"
onClick={toggleLock}
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"
aria-label="Lock"
>
<LockKeyhole className="size-3.5" />
</button>
</Tooltip>
)}
{displayState === "locked" && (
<button
type="button"
onClick={toggleLock}
className="h-6 flex items-center gap-1 px-2 rounded text-custom-primary-100 bg-custom-primary-100/20 hover:bg-custom-primary-100/30 transition-colors"
aria-label="Locked"
>
<LockKeyhole className="flex-shrink-0 size-3.5 animate-lock-icon" />
<span className="text-xs font-medium whitespace-nowrap overflow-hidden transition-all duration-500 ease-out animate-text-slide-in">
Locked
</span>
</button>
)}
{displayState === "unlocked" && (
<div
className="h-6 flex items-center gap-1 px-2 rounded text-custom-text-200 animate-fade-out"
aria-label="Unlocked"
>
<LockKeyholeOpen className="flex-shrink-0 size-3.5 animate-unlock-icon" />
<span className="text-xs font-medium whitespace-nowrap overflow-hidden transition-all duration-500 ease-out animate-text-slide-in animate-text-fade-out">
Unlocked
</span>
</div>
)}
</>
);
});

View file

@ -0,0 +1,10 @@
"use client";
// store
import { TPageInstance } from "@/store/pages/base-page";
export type TPageMoveControlProps = {
page: TPageInstance;
};
export const PageMoveControl = ({}: TPageMoveControlProps) => null;

View file

@ -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<Props> = observer((props) => {
const { page } = props;
return (
<div className="flex-shrink-0 whitespace-nowrap">
<span className="text-sm text-custom-text-300">Edited {calculateTimeAgoShort(page.updated_at ?? "")} ago</span>
</div>
);
});

View file

@ -1,2 +1 @@
export * from "./actions"; export * from "./actions";
export * from "./edit-information-popover";

View file

@ -43,10 +43,10 @@ export type TEditorBodyHandlers = {
type Props = { type Props = {
config: TEditorBodyConfig; config: TEditorBodyConfig;
editorRef: React.RefObject<EditorRefApi>;
editorReady: boolean; editorReady: boolean;
editorForwardRef: React.RefObject<EditorRefApi>;
handleConnectionStatus: Dispatch<SetStateAction<boolean>>; handleConnectionStatus: Dispatch<SetStateAction<boolean>>;
handleEditorReady: Dispatch<SetStateAction<boolean>>; handleEditorReady: (status: boolean) => void;
handlers: TEditorBodyHandlers; handlers: TEditorBodyHandlers;
page: TPageInstance; page: TPageInstance;
webhookConnectionParams: TWebhookConnectionQueryParams; webhookConnectionParams: TWebhookConnectionQueryParams;
@ -56,7 +56,7 @@ type Props = {
export const PageEditorBody: React.FC<Props> = observer((props) => { export const PageEditorBody: React.FC<Props> = observer((props) => {
const { const {
config, config,
editorRef, editorForwardRef,
handleConnectionStatus, handleConnectionStatus,
handleEditorReady, handleEditorReady,
handlers, handlers,
@ -70,7 +70,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
// derived values // derived values
const { id: pageId, name: pageTitle, isContentEditable, updateTitle } = page; const { id: pageId, name: pageTitle, isContentEditable, updateTitle, editorRef } = page;
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? ""; const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id ?? "";
// issue-embed // issue-embed
const { issueEmbedProps } = useIssueEmbed({ const { issueEmbedProps } = useIssueEmbed({
@ -172,10 +172,10 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
<div className="sticky top-[72px]"> <div className="sticky top-[72px]">
<div className="group/page-toc relative px-page-x"> <div className="group/page-toc relative px-page-x">
<div className="cursor-pointer max-h-[50vh] overflow-hidden"> <div className="cursor-pointer max-h-[50vh] overflow-hidden">
<PageContentBrowser editorRef={editorRef?.current} showOutline /> <PageContentBrowser editorRef={editorRef} showOutline />
</div> </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"> <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?.current} /> <PageContentBrowser editorRef={editorRef} />
</div> </div>
</div> </div>
</div> </div>
@ -196,7 +196,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
id={pageId} id={pageId}
fileHandler={config.fileHandler} fileHandler={config.fileHandler}
handleEditorReady={handleEditorReady} handleEditorReady={handleEditorReady}
ref={editorRef} ref={editorForwardRef}
containerClassName="h-full p-0 pb-64" containerClassName="h-full p-0 pb-64"
displayConfig={displayConfig} displayConfig={displayConfig}
mentionHandler={{ mentionHandler={{

View file

@ -23,7 +23,7 @@ export const PageEditorHeaderLogoPicker: React.FC<Props> = observer((props) => {
return ( return (
<div <div
className={cn(className, "max-h-0 pointer-events-none transition-all ease-linear duration-200", { className={cn(className, "max-h-0 pointer-events-none transition-all ease-linear duration-300", {
"max-h-[56px] pointer-events-auto": isLogoSelected, "max-h-[56px] pointer-events-auto": isLogoSelected,
})} })}
> >

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
// editor // editor
@ -18,8 +18,6 @@ import {
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
import { usePageFallback } from "@/hooks/use-page-fallback"; import { usePageFallback } from "@/hooks/use-page-fallback";
import { useQueryParams } from "@/hooks/use-query-params"; import { useQueryParams } from "@/hooks/use-query-params";
// plane web hooks
import { EPageStoreType } from "@/plane-web/hooks/store";
// store // store
import { TPageInstance } from "@/store/pages/base-page"; import { TPageInstance } from "@/store/pages/base-page";
@ -38,13 +36,12 @@ type TPageRootProps = {
config: TPageRootConfig; config: TPageRootConfig;
handlers: TPageRootHandlers; handlers: TPageRootHandlers;
page: TPageInstance; page: TPageInstance;
storeType: EPageStoreType;
webhookConnectionParams: TWebhookConnectionQueryParams; webhookConnectionParams: TWebhookConnectionQueryParams;
workspaceSlug: string; workspaceSlug: string;
}; };
export const PageRoot = observer((props: TPageRootProps) => { export const PageRoot = observer((props: TPageRootProps) => {
const { config, handlers, page, storeType, webhookConnectionParams, workspaceSlug } = props; const { config, handlers, page, webhookConnectionParams, workspaceSlug } = props;
// states // states
const [editorReady, setEditorReady] = useState(false); const [editorReady, setEditorReady] = useState(false);
const [hasConnectionFailed, setHasConnectionFailed] = useState(false); const [hasConnectionFailed, setHasConnectionFailed] = useState(false);
@ -56,7 +53,7 @@ export const PageRoot = observer((props: TPageRootProps) => {
// search params // search params
const searchParams = useSearchParams(); const searchParams = useSearchParams();
// derived values // derived values
const { isContentEditable } = page; const { isContentEditable, setEditorRef } = page;
// page fallback // page fallback
usePageFallback({ usePageFallback({
editorRef, editorRef,
@ -67,6 +64,16 @@ export const PageRoot = observer((props: TPageRootProps) => {
// update query params // update query params
const { updateQueryParams } = useQueryParams(); 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"); const version = searchParams.get("version");
useEffect(() => { useEffect(() => {
if (!version) { if (!version) {
@ -89,6 +96,14 @@ export const PageRoot = observer((props: TPageRootProps) => {
}; };
const currentVersionDescription = editorRef.current?.getDocument().html; const currentVersionDescription = editorRef.current?.getDocument().html;
// reset editor ref on unmount
useEffect(
() => () => {
setEditorRef(null);
},
[setEditorRef]
);
return ( return (
<> <>
<PageVersionsOverlay <PageVersionsOverlay
@ -103,13 +118,13 @@ export const PageRoot = observer((props: TPageRootProps) => {
pageId={page.id ?? ""} pageId={page.id ?? ""}
restoreEnabled={isContentEditable} restoreEnabled={isContentEditable}
/> />
<PageEditorToolbarRoot editorReady={editorReady} editorRef={editorRef} page={page} storeType={storeType} /> <PageEditorToolbarRoot page={page} />
<PageEditorBody <PageEditorBody
config={config} config={config}
editorReady={editorReady} editorReady={editorReady}
editorRef={editorRef} editorForwardRef={editorRef}
handleConnectionStatus={setHasConnectionFailed} handleConnectionStatus={setHasConnectionFailed}
handleEditorReady={setEditorReady} handleEditorReady={handleEditorReady}
handlers={handlers} handlers={handlers}
page={page} page={page}
webhookConnectionParams={webhookConnectionParams} webhookConnectionParams={webhookConnectionParams}

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
// plane editor // plane editor
import { EditorRefApi, IMarking } from "@plane/editor"; import { EditorRefApi, IMarking } from "@plane/editor";
// components // components
@ -24,10 +24,13 @@ export const PageContentBrowser: React.FC<Props> = (props) => {
}; };
}, [editorRef]); }, [editorRef]);
const handleOnClick = (marking: IMarking) => { const handleOnClick = useCallback(
editorRef?.scrollSummary(marking); (marking: IMarking) => {
if (setSidePeekVisible) setSidePeekVisible(false); editorRef?.scrollSummary(marking);
}; setSidePeekVisible?.(false);
},
[editorRef, setSidePeekVisible]
);
const HeadingComponent: { const HeadingComponent: {
[key: number]: React.FC<{ marking: IMarking; onClick: () => void }>; [key: number]: React.FC<{ marking: IMarking; onClick: () => void }>;

View file

@ -13,7 +13,7 @@ import { getPageName } from "@/helpers/page.helper";
import { usePageFilters } from "@/hooks/use-page-filters"; import { usePageFilters } from "@/hooks/use-page-filters";
type Props = { type Props = {
editorRef: React.RefObject<EditorRefApi>; editorRef: EditorRefApi | null;
readOnly: boolean; readOnly: boolean;
title: string | undefined; title: string | undefined;
updateTitle: (title: string) => void; updateTitle: (title: string) => void;
@ -53,7 +53,7 @@ export const PageEditorTitle: React.FC<Props> = observer((props) => {
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
editorRef.current?.setFocusAtPosition(0); editorRef?.setFocusAtPosition(0);
} }
}} }}
value={title} value={title}

View file

@ -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<Props> = 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<boolean>(
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 (
<div className="flex items-center gap-3">
{is_locked && <LockedComponent />}
{archived_at && (
<div className="flex-shrink-0 flex h-7 items-center gap-2 rounded-full bg-blue-500/20 px-3 py-0.5 text-xs font-medium text-blue-500">
<ArchiveIcon className="flex-shrink-0 size-3" />
<span>Archived at {renderFormattedDate(archived_at)}</span>
</div>
)}
{isContentEditable && !isOnline && (
<Tooltip
tooltipHeading="You are offline."
tooltipContent="You can continue making changes. They will be synced when you are back online."
>
<div className="flex-shrink-0 flex h-7 items-center gap-2 rounded-full bg-custom-background-80 px-3 py-0.5 text-xs font-medium text-custom-text-300">
<span className="flex-shrink-0 size-1.5 rounded-full bg-custom-text-300" />
<span>Offline</span>
</div>
</Tooltip>
)}
{canCurrentUserFavoritePage && (
<FavoriteStar
selected={is_favorite}
onClick={handleFavorite}
buttonClassName="flex-shrink-0"
iconClassName="text-custom-text-100"
/>
)}
<PageInfoPopover editorRef={editorRef} page={page} />
<PageOptionsDropdown editorRef={editorRef} page={page} storeType={storeType} />
</div>
);
});

View file

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

View file

@ -1,28 +1,25 @@
import { useState } from "react"; import { useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
import { Info } from "lucide-react"; import { Info } from "lucide-react";
// plane editor // plane imports
import { EditorRefApi } from "@plane/editor";
// plane ui
import { Avatar } from "@plane/ui"; import { Avatar } from "@plane/ui";
// plane utils
import { getFileURL, renderFormattedDate } from "@plane/utils"; import { getFileURL, renderFormattedDate } from "@plane/utils";
// helpers // helpers
import { getReadTimeFromWordsCount } from "@/helpers/date-time.helper"; import { calculateTimeAgoShort, getReadTimeFromWordsCount } from "@/helpers/date-time.helper";
// hooks // hooks
import { useMember } from "@/hooks/store"; import { useMember } from "@/hooks/store";
// store types // store types
import { TPageInstance } from "@/store/pages/base-page"; import { TPageInstance } from "@/store/pages/base-page";
type Props = { type Props = {
editorRef: EditorRefApi | null;
page: TPageInstance; page: TPageInstance;
}; };
export const PageInfoPopover: React.FC<Props> = (props) => { export const PageInfoPopover: React.FC<Props> = observer((props) => {
const { editorRef, page } = props; const { page } = props;
// states // states
const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isPopoverOpen, setIsPopoverOpen] = useState(false);
// refs // refs
@ -40,7 +37,7 @@ export const PageInfoPopover: React.FC<Props> = (props) => {
const editorInformation = page.updated_by ? getUserDetails(page.updated_by) : undefined; const editorInformation = page.updated_by ? getUserDetails(page.updated_by) : undefined;
const creatorInformation = page.created_by ? getUserDetails(page.created_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 secondsToReadableTime = () => {
const wordsCount = documentsInfo.words; const wordsCount = documentsInfo.words;
@ -72,8 +69,16 @@ export const PageInfoPopover: React.FC<Props> = (props) => {
]; ];
return ( return (
<div onMouseEnter={() => setIsPopoverOpen(true)} onMouseLeave={() => setIsPopoverOpen(false)}> <div
<button type="button" ref={setReferenceElement} className="block"> 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" /> <Info className="size-3.5" />
</button> </button>
{isPopoverOpen && ( {isPopoverOpen && (
@ -106,7 +111,7 @@ export const PageInfoPopover: React.FC<Props> = (props) => {
/> />
<span> <span>
{editorInformation?.display_name}{" "} {editorInformation?.display_name}{" "}
<span className="text-custom-text-300">{renderFormattedDate(page.updated_at)}</span> <span className="text-custom-text-300">{calculateTimeAgoShort(page.updated_at ?? "")} ago</span>
</span> </span>
</Link> </Link>
</div> </div>
@ -133,4 +138,4 @@ export const PageInfoPopover: React.FC<Props> = (props) => {
)} )}
</div> </div>
); );
}; });

View file

@ -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<Props> = observer((props) => {
const { editorRef, page, storeType } = props;
// derived values
const { isContentEditable } = page;
// page filters
const { isStickyToolbarEnabled } = usePageFilters();
return (
<>
<Header variant={EHeaderVariant.SECONDARY}>
<PageExtraOptions editorRef={editorRef} page={page} storeType={storeType} />
</Header>
<Header variant={EHeaderVariant.TERNARY}>
{isContentEditable && editorRef && <PageToolbar editorRef={editorRef} isHidden={!isStickyToolbarEnabled} />}
</Header>
</>
);
});

View file

@ -4,9 +4,7 @@ import { useMemo, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowUpToLine, Clipboard, History } from "lucide-react"; import { ArrowUpToLine, Clipboard, History } from "lucide-react";
// document editor // plane imports
import { EditorRefApi } from "@plane/editor";
// ui
import { TContextMenuItem, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui"; import { TContextMenuItem, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
// components // components
import { ExportPageModal, PageActions, TPageActions } from "@/components/pages"; 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"; import { TPageInstance } from "@/store/pages/base-page";
type Props = { type Props = {
editorRef: EditorRefApi | null;
page: TPageInstance; page: TPageInstance;
storeType: EPageStoreType; storeType: EPageStoreType;
}; };
export const PageOptionsDropdown: React.FC<Props> = observer((props) => { export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
const { editorRef, page, storeType } = props; const { page, storeType } = props;
// states // states
const [isExportModalOpen, setIsExportModalOpen] = useState(false); const [isExportModalOpen, setIsExportModalOpen] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
// store values // store values
const { name, isContentEditable } = page; const { name, isContentEditable, editorRef } = page;
// page filters // page filters
const { isFullWidth, handleFullWidth, isStickyToolbarEnabled, handleStickyToolbar } = usePageFilters(); const { isFullWidth, handleFullWidth, isStickyToolbarEnabled, handleStickyToolbar } = usePageFilters();
// update query params // update query params
@ -127,10 +124,7 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
optionsOrder={[ optionsOrder={[
"full-screen", "full-screen",
"sticky-toolbar", "sticky-toolbar",
"copy-link",
"make-a-copy", "make-a-copy",
"move",
"toggle-lock",
"toggle-access", "toggle-access",
"archive-restore", "archive-restore",
"delete", "delete",

View file

@ -1,58 +1,48 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { EditorRefApi } from "@plane/editor";
// components // components
import { PageEditorMobileHeaderRoot, PageExtraOptions, PageToolbar } from "@/components/pages"; import { PageToolbar } from "@/components/pages";
// helpers // helpers
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
// hooks // hooks
import { usePageFilters } from "@/hooks/use-page-filters"; import { usePageFilters } from "@/hooks/use-page-filters";
// plane web hooks // plane web components
import { EPageStoreType } from "@/plane-web/hooks/store"; import { PageCollaboratorsList } from "@/plane-web/components/pages/header/collaborators-list";
// store // store
import { TPageInstance } from "@/store/pages/base-page"; import { TPageInstance } from "@/store/pages/base-page";
type Props = { type Props = {
editorReady: boolean;
editorRef: React.RefObject<EditorRefApi>;
page: TPageInstance; page: TPageInstance;
storeType: EPageStoreType;
}; };
export const PageEditorToolbarRoot: React.FC<Props> = observer((props) => { export const PageEditorToolbarRoot: React.FC<Props> = observer((props) => {
const { editorReady, editorRef, page, storeType } = props; const { page } = props;
// derived values // derived values
const { isContentEditable } = page; const { isContentEditable, editorRef } = page;
// page filters // page filters
const { isFullWidth, isStickyToolbarEnabled } = usePageFilters(); const { isFullWidth, isStickyToolbarEnabled } = usePageFilters();
// derived values // derived values
const resolvedEditorRef = editorRef.current;
const shouldHideToolbar = !isStickyToolbarEnabled || !isContentEditable; const shouldHideToolbar = !isStickyToolbarEnabled || !isContentEditable;
if (!resolvedEditorRef) return null;
return ( return (
<div id="page-toolbar-container"> <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 <div
className={cn( className={cn(
"hidden md:flex items-center relative min-h-[52px] page-toolbar-content px-page-x border-b border-transparent transition-all duration-200 ease-in-out", "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, "wide-layout": isFullWidth,
"border-custom-border-200": true,
} }
)} )}
> >
<div className="max-w-full w-full flex items-center justify-between"> <div className="max-w-full w-full flex items-center justify-between">
<div> {editorRef && <PageToolbar editorRef={editorRef} />}
{editorReady && resolvedEditorRef && ( <PageCollaboratorsList page={page} />
<PageToolbar editorRef={resolvedEditorRef} isHidden={shouldHideToolbar} />
)}
</div>
<PageExtraOptions editorRef={resolvedEditorRef} page={page} storeType={storeType} />
</div> </div>
</div> </div>
<div className="md:hidden">
<PageEditorMobileHeaderRoot editorRef={resolvedEditorRef} page={page} storeType={storeType} />
</div>
</div> </div>
); );
}); });

View file

@ -15,7 +15,6 @@ import { cn } from "@/helpers/common.helper";
type Props = { type Props = {
editorRef: EditorRefApi; editorRef: EditorRefApi;
isHidden: boolean;
}; };
type ToolbarButtonProps = { type ToolbarButtonProps = {
@ -65,7 +64,7 @@ ToolbarButton.displayName = "ToolbarButton";
const toolbarItems = TOOLBAR_ITEMS.document; const toolbarItems = TOOLBAR_ITEMS.document;
export const PageToolbar: React.FC<Props> = (props) => { export const PageToolbar: React.FC<Props> = (props) => {
const { editorRef, isHidden } = props; const { editorRef } = props;
// states // states
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({}); const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
@ -98,14 +97,7 @@ export const PageToolbar: React.FC<Props> = (props) => {
); );
return ( return (
<div <div className="flex items-center divide-x divide-custom-border-200 overflow-x-scroll">
className={cn(
"flex flex-wrap items-center divide-x divide-custom-border-200 opacity-100 transition-opacity duration-200 ease-in-out",
{
"opacity-0 pointer-events-none": isHidden,
}
)}
>
<CustomMenu <CustomMenu
customButton={ customButton={
<span className="text-custom-text-300 text-sm border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 h-7 w-24 rounded px-2 flex items-center justify-between gap-2 whitespace-nowrap text-left"> <span className="text-custom-text-300 text-sm border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 h-7 w-24 rounded px-2 flex items-center justify-between gap-2 whitespace-nowrap text-left">
@ -139,20 +131,22 @@ export const PageToolbar: React.FC<Props> = (props) => {
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
))} ))}
</CustomMenu> </CustomMenu>
<ColorDropdown <div className="flex-shrink-0">
handleColorSelect={(key, color) => <ColorDropdown
editorRef.executeMenuItemCommand({ handleColorSelect={(key, color) =>
itemKey: key, editorRef.executeMenuItemCommand({
color, itemKey: key,
}) color,
} })
isColorActive={(key, color) => }
editorRef.isMenuItemActive({ isColorActive={(key, color) =>
itemKey: key, editorRef.isMenuItemActive({
color, itemKey: key,
}) color,
} })
/> }
/>
</div>
{Object.keys(toolbarItems).map((key) => ( {Object.keys(toolbarItems).map((key) => (
<div key={key} className="flex items-center gap-0.5 px-2 first:pl-0 last:pr-0"> <div key={key} className="flex items-center gap-0.5 px-2 first:pl-0 last:pr-0">
{toolbarItems[key].map((item) => ( {toolbarItems[key].map((item) => (

View file

@ -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<Props> = observer((props) => {
const { page, storeType } = props;
return (
<div className="flex items-center gap-1">
<PageArchivedBadge page={page} />
<PageOfflineBadge page={page} />
<PageLockControl page={page} />
<PageMoveControl page={page} />
<PageInfoPopover page={page} />
<PageCopyLinkControl page={page} />
<PageOptionsDropdown page={page} storeType={storeType} />
</div>
);
});

View file

@ -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 (
<div className="flex-shrink-0 h-6 flex items-center gap-1 px-2 rounded text-custom-primary-100 bg-custom-primary-100/20">
<ArchiveIcon className="flex-shrink-0 size-3.5" />
<span className="text-xs font-medium">Archived at {renderFormattedDate(page.archived_at)}</span>
</div>
);
});

View file

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

View file

@ -0,0 +1,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 (
<FavoriteStar
selected={is_favorite}
onClick={pageOperations.toggleFavorite}
buttonClassName="flex-shrink-0 size-6 group rounded hover:bg-custom-background-80 transition-colors"
iconClassName="size-3.5 text-custom-text-200 group-hover:text-custom-text-10"
/>
);
});

View file

@ -0,0 +1,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 (
<Tooltip
tooltipHeading="You are offline."
tooltipContent="You can continue making changes. They will be synced when you are back online."
>
<div className="flex-shrink-0 flex h-7 items-center gap-2 rounded-full bg-custom-background-80 px-3 py-0.5 text-xs font-medium text-custom-text-300">
<span className="flex-shrink-0 size-1.5 rounded-full bg-custom-text-300" />
<span>Offline</span>
</div>
</Tooltip>
);
});

View file

@ -47,7 +47,7 @@ export const usePageOperations = (
// collaborative actions // collaborative actions
const { executeCollaborativeAction } = useCollaborativePageActions(props); const { executeCollaborativeAction } = useCollaborativePageActions(props);
// local storage // local storage
const { setValue: toggleFavoriteMenu, storedValue: isfavoriteMenuOpen } = useLocalStorage<boolean>( const { setValue: toggleFavoriteMenu, storedValue: isFavoriteMenuOpen } = useLocalStorage<boolean>(
IS_FAVORITE_MENU_OPEN, IS_FAVORITE_MENU_OPEN,
false false
); );
@ -147,7 +147,7 @@ export const usePageOperations = (
); );
} else { } else {
addToFavorites().then(() => { addToFavorites().then(() => {
if (!isfavoriteMenuOpen) toggleFavoriteMenu(true); if (!isFavoriteMenuOpen) toggleFavoriteMenu(true);
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success!", title: "Success!",
@ -199,7 +199,9 @@ export const usePageOperations = (
getRedirectionLink, getRedirectionLink,
is_favorite, is_favorite,
is_locked, is_locked,
isFavoriteMenuOpen,
removePageFromFavorites, removePageFromFavorites,
toggleFavoriteMenu,
]); ]);
return { return {
pageOperations, pageOperations,

View file

@ -2,6 +2,7 @@ import set from "lodash/set";
import { action, computed, makeObservable, observable, reaction, runInAction } from "mobx"; import { action, computed, makeObservable, observable, reaction, runInAction } from "mobx";
// plane imports // plane imports
import { EPageAccess } from "@plane/constants"; import { EPageAccess } from "@plane/constants";
import { EditorRefApi } from "@plane/editor";
import { TDocumentPayload, TLogoProps, TNameDescriptionLoader, TPage } from "@plane/types"; import { TDocumentPayload, TLogoProps, TNameDescriptionLoader, TPage } from "@plane/types";
import { TChangeHandlerProps } from "@plane/ui"; import { TChangeHandlerProps } from "@plane/ui";
import { convertHexEmojiToDecimal } from "@plane/utils"; import { convertHexEmojiToDecimal } from "@plane/utils";
@ -11,6 +12,7 @@ import { RootStore } from "@/plane-web/store/root.store";
export type TBasePage = TPage & { export type TBasePage = TPage & {
// observables // observables
isSubmitting: TNameDescriptionLoader; isSubmitting: TNameDescriptionLoader;
editorRef: EditorRefApi | null;
// computed // computed
asJSON: TPage | undefined; asJSON: TPage | undefined;
isCurrentUserOwner: boolean; isCurrentUserOwner: boolean;
@ -32,6 +34,8 @@ export type TBasePage = TPage & {
addToFavorites: () => Promise<void>; addToFavorites: () => Promise<void>;
removePageFromFavorites: () => Promise<void>; removePageFromFavorites: () => Promise<void>;
duplicate: () => Promise<TPage | undefined>; duplicate: () => Promise<TPage | undefined>;
mutateProperties: (data: Partial<TPage>, shouldUpdateName?: boolean) => void;
setEditorRef: (editorRef: EditorRefApi | null) => void;
}; };
export type TBasePagePermissions = { export type TBasePagePermissions = {
@ -68,6 +72,7 @@ export type TPageInstance = TBasePage &
export class BasePage implements TBasePage { export class BasePage implements TBasePage {
// loaders // loaders
isSubmitting: TNameDescriptionLoader = "saved"; isSubmitting: TNameDescriptionLoader = "saved";
editorRef: EditorRefApi | null = null;
// page properties // page properties
id: string | undefined; id: string | undefined;
name: string | undefined; name: string | undefined;
@ -125,6 +130,7 @@ export class BasePage implements TBasePage {
makeObservable(this, { makeObservable(this, {
// loaders // loaders
isSubmitting: observable.ref, isSubmitting: observable.ref,
editorRef: observable.ref,
// page properties // page properties
id: observable.ref, id: observable.ref,
name: observable.ref, name: observable.ref,
@ -165,6 +171,8 @@ export class BasePage implements TBasePage {
addToFavorites: action, addToFavorites: action,
removePageFromFavorites: action, removePageFromFavorites: action,
duplicate: action, duplicate: action,
mutateProperties: action,
setEditorRef: action,
}); });
this.rootStore = store; this.rootStore = store;
@ -426,25 +434,34 @@ export class BasePage implements TBasePage {
}; };
updatePageLogo = async (value: TChangeHandlerProps) => { updatePageLogo = async (value: TChangeHandlerProps) => {
let logoValue = {}; const originalLogoProps = { ...this.logo_props };
if (value?.type === "emoji") try {
logoValue = { let logoValue = {};
value: convertHexEmojiToDecimal(value.value.unified), if (value?.type === "emoji")
url: value.value.imageUrl, 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 = { runInAction(() => {
in_use: value?.type, this.logo_props = logoProps;
[value?.type]: logoValue, });
}; await this.services.update({
logo_props: logoProps,
await this.services.update({ });
logo_props: logoProps, } catch (error) {
}); console.error("Error in updating page logo", error);
runInAction(() => { runInAction(() => {
this.logo_props = logoProps; this.logo_props = originalLogoProps as TLogoProps;
}); });
throw error;
}
}; };
/** /**
@ -498,4 +515,23 @@ export class BasePage implements TBasePage {
* @description duplicate the page * @description duplicate the page
*/ */
duplicate = async () => await this.services.duplicate(); duplicate = async () => await this.services.duplicate();
/**
* @description mutate multiple properties at once
* @param data Partial<TPage>
*/
mutateProperties = (data: Partial<TPage>, 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;
});
};
} }

View file

@ -206,7 +206,19 @@ export class ProjectPageStore implements IProjectPageStore {
const pages = await this.service.fetchAll(workspaceSlug, projectId); const pages = await this.service.fetchAll(workspaceSlug, projectId);
runInAction(() => { 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; this.loader = undefined;
}); });
@ -238,8 +250,16 @@ export class ProjectPageStore implements IProjectPageStore {
}); });
const page = await this.service.fetchById(workspaceSlug, projectId, pageId); const page = await this.service.fetchById(workspaceSlug, projectId, pageId);
const pageInstance = page?.id ? this.getPageById(page.id) : undefined;
runInAction(() => { 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; this.loader = undefined;
}); });

View file

@ -854,3 +854,91 @@ div.web-view-spinner div.bar12 {
.epr-search-container > .epr-icn-search { .epr-search-container > .epr-icn-search {
color: rgb(var(--color-text-400)) !important; 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;
}