mirror of
https://github.com/makeplane/plane.git
synced 2026-02-24 20:20:49 +01:00
chore: revamp page actions
This commit is contained in:
@@ -1,27 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { FileText } from "lucide-react";
|
||||
import { FileText, Link, LockKeyhole, LockKeyholeOpen } from "lucide-react";
|
||||
// constants
|
||||
import { IS_FAVORITE_MENU_OPEN } from "@plane/constants";
|
||||
// hooks
|
||||
import { useLocalStorage } from "@plane/hooks";
|
||||
// types
|
||||
import { TLogoProps } from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, EmojiIconPicker, EmojiIconPickerTypes, TOAST_TYPE, Tooltip, setToast, Header } from "@plane/ui";
|
||||
import {
|
||||
Breadcrumbs,
|
||||
EmojiIconPicker,
|
||||
EmojiIconPickerTypes,
|
||||
FavoriteStar,
|
||||
Header,
|
||||
TOAST_TYPE,
|
||||
Tooltip,
|
||||
setToast,
|
||||
} from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink, Logo } from "@/components/common";
|
||||
import { PageEditInformationPopover } from "@/components/pages";
|
||||
// helpers
|
||||
import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper";
|
||||
import { getPageName } from "@/helpers/page.helper";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
import { usePageOperations } from "@/hooks/use-page-operations";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web components
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages";
|
||||
// plane web hooks
|
||||
import { EPageStoreType, usePage } from "@/plane-web/hooks/store";
|
||||
// store
|
||||
import { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
export interface IPagesHeaderProps {
|
||||
showButton?: boolean;
|
||||
@@ -159,9 +173,208 @@ export const PageDetailsHeader = observer(() => {
|
||||
</div>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
<PageEditInformationPopover page={page} />
|
||||
<PageDetailsHeaderExtraActions page={page} />
|
||||
<div className="flex items-center gap-3">
|
||||
{/* <PageEditInformationPopover page={page} /> */}
|
||||
<PageActionButtons page={page} storeType={EPageStoreType.PROJECT} />
|
||||
</div>
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
|
||||
// PageActions Component
|
||||
export type TPageActions = "toggle-lock" | "copy-link";
|
||||
|
||||
// Lock states
|
||||
type LockDisplayState = "icon-only" | "locked" | "unlocked";
|
||||
|
||||
// Enhanced lock component with multiple states
|
||||
const EnhancedLockControl = ({
|
||||
isLocked,
|
||||
canToggle,
|
||||
onToggle,
|
||||
}: {
|
||||
isLocked: boolean;
|
||||
canToggle: boolean;
|
||||
onToggle: () => void;
|
||||
}) => {
|
||||
// Track visual display state
|
||||
const [displayState, setDisplayState] = useState<LockDisplayState>(isLocked ? "locked" : "icon-only");
|
||||
// Use ref to track timeout
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Clear any existing timers on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Watch only for isLocked changes and update display state accordingly
|
||||
useEffect(() => {
|
||||
// Clear any existing timers
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
if (isLocked) {
|
||||
// If locked, always show locked state
|
||||
setDisplayState("locked");
|
||||
} else {
|
||||
// If not locked and was previously showing something other than icon-only
|
||||
// First show unlocked state, then transition back
|
||||
if (displayState !== "icon-only") {
|
||||
setDisplayState("unlocked");
|
||||
timerRef.current = setTimeout(() => {
|
||||
setDisplayState("icon-only");
|
||||
timerRef.current = null;
|
||||
}, 600); // Adjusted from 800ms to 600ms for better timing
|
||||
} else {
|
||||
// Otherwise, just show icon-only
|
||||
setDisplayState("icon-only");
|
||||
}
|
||||
}
|
||||
}, [isLocked, displayState]); // Only depend on isLocked, not displayState
|
||||
|
||||
if (!canToggle) return null;
|
||||
// Handle click with state transitions
|
||||
const handleClick = () => {
|
||||
onToggle(); // This will update isLocked which will trigger the useEffect
|
||||
};
|
||||
|
||||
// Determine what to render based on current display state
|
||||
const renderLockControl = () => {
|
||||
switch (displayState) {
|
||||
case "icon-only":
|
||||
return (
|
||||
<Tooltip tooltipContent="Lock" position="bottom">
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className="w-6 h-6 flex items-center justify-center rounded-full cursor-pointer
|
||||
transition-all duration-700 text-custom-text-400
|
||||
hover:bg-custom-background-90 hover:text-custom-text-100"
|
||||
aria-label="Lock"
|
||||
>
|
||||
<LockKeyhole className="h-3.5 w-3.5 transition-all hover:scale-110" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
case "locked":
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className="h-6 flex items-center gap-1 px-2 py-0.5 rounded-md cursor-pointer
|
||||
text-blue-500 transition-all duration-500 hover:bg-blue-50/10"
|
||||
aria-label="Locked"
|
||||
>
|
||||
<LockKeyhole className="h-3.5 w-3.5 flex-shrink-0 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>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "unlocked":
|
||||
return (
|
||||
<div
|
||||
className="h-6 flex items-center gap-1 px-2 py-0.5 rounded-md cursor-pointer
|
||||
text-custom-text-800 transition-all duration-500 animate-fade-out"
|
||||
aria-label="Unlocked"
|
||||
>
|
||||
<LockKeyholeOpen className="h-3.5 w-3.5 flex-shrink-0 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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return renderLockControl();
|
||||
};
|
||||
|
||||
export const PageActionButtons = observer(({ page, storeType }: { page: TPageInstance; storeType: EPageStoreType }) => {
|
||||
// page operations
|
||||
const { pageOperations } = usePageOperations({
|
||||
page,
|
||||
});
|
||||
|
||||
// derived values
|
||||
const {
|
||||
is_locked,
|
||||
is_favorite,
|
||||
canCurrentUserLockPage,
|
||||
canCurrentUserFavoritePage,
|
||||
addToFavorites,
|
||||
removePageFromFavorites,
|
||||
} = page;
|
||||
|
||||
// local storage for favorites menu
|
||||
const { setValue: toggleFavoriteMenu, storedValue: isFavoriteMenuOpen } = useLocalStorage<boolean>(
|
||||
IS_FAVORITE_MENU_OPEN,
|
||||
false
|
||||
);
|
||||
|
||||
// favorite handler
|
||||
const handleFavorite = useCallback(() => {
|
||||
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.",
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [is_favorite, addToFavorites, removePageFromFavorites, isFavoriteMenuOpen, toggleFavoriteMenu]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<EnhancedLockControl
|
||||
isLocked={is_locked}
|
||||
canToggle={canCurrentUserLockPage}
|
||||
onToggle={pageOperations.toggleLock}
|
||||
/>
|
||||
|
||||
<Tooltip tooltipContent="Copy link" position="bottom">
|
||||
<div
|
||||
onClick={pageOperations.copyLink}
|
||||
className="w-7 h-7 flex items-center justify-center rounded-full text-custom-text-400
|
||||
hover:bg-custom-background-90 hover:text-custom-text-100 transition-all
|
||||
duration-200 cursor-pointer"
|
||||
aria-label="Copy link"
|
||||
>
|
||||
<Link className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{canCurrentUserFavoritePage && (
|
||||
<FavoriteStar
|
||||
selected={is_favorite}
|
||||
onClick={handleFavorite}
|
||||
buttonClassName="flex-shrink-0"
|
||||
iconClassName="text-custom-text-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,11 @@ export const LockedComponent = (props: { toolTipContent?: string }) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{toolTipContent ? <Tooltip tooltipContent={toolTipContent}>{lockedComponent}</Tooltip> : <>{lockedComponent}</>}
|
||||
{toolTipContent === "Lock" ? (
|
||||
<Tooltip tooltipContent={toolTipContent}>{lockedComponent}</Tooltip>
|
||||
) : (
|
||||
<>{lockedComponent}</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -70,7 +70,6 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end 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" />
|
||||
@@ -88,16 +87,6 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
||||
</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>
|
||||
);
|
||||
});
|
||||
|
||||
87
web/styles/animations.css
Normal file
87
web/styles/animations.css
Normal file
@@ -0,0 +1,87 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@import url("fonts/main.css");
|
||||
@import url("animations.css");
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
|
||||
Reference in New Issue
Block a user