Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f50b43024f | ||
|
|
ec2af13258 | ||
|
|
8833e4e23b | ||
|
|
752a27a175 |
@@ -54,6 +54,8 @@ class PageSerializer(BaseSerializer):
|
||||
labels = validated_data.pop("labels", None)
|
||||
project_id = self.context["project_id"]
|
||||
owned_by_id = self.context["owned_by_id"]
|
||||
description = self.context["description"]
|
||||
description_binary = self.context["description_binary"]
|
||||
description_html = self.context["description_html"]
|
||||
|
||||
# Get the workspace id from the project
|
||||
@@ -62,6 +64,8 @@ class PageSerializer(BaseSerializer):
|
||||
# Create the page
|
||||
page = Page.objects.create(
|
||||
**validated_data,
|
||||
description=description,
|
||||
description_binary=description_binary,
|
||||
description_html=description_html,
|
||||
owned_by_id=owned_by_id,
|
||||
workspace_id=project.workspace_id,
|
||||
|
||||
@@ -8,6 +8,7 @@ from plane.app.views import (
|
||||
SubPagesEndpoint,
|
||||
PagesDescriptionViewSet,
|
||||
PageVersionEndpoint,
|
||||
PageDuplicateEndpoint,
|
||||
)
|
||||
|
||||
|
||||
@@ -78,4 +79,9 @@ urlpatterns = [
|
||||
PageVersionEndpoint.as_view(),
|
||||
name="page-versions",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/duplicate/",
|
||||
PageDuplicateEndpoint.as_view(),
|
||||
name="page-duplicate",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -155,6 +155,7 @@ from .page.base import (
|
||||
PageLogEndpoint,
|
||||
SubPagesEndpoint,
|
||||
PagesDescriptionViewSet,
|
||||
PageDuplicateEndpoint,
|
||||
)
|
||||
from .page.version import PageVersionEndpoint
|
||||
|
||||
|
||||
+153
-69
@@ -1,71 +1,169 @@
|
||||
# Python imports
|
||||
import requests
|
||||
# Python import
|
||||
import os
|
||||
from typing import List, Dict, Tuple
|
||||
|
||||
# Third party import
|
||||
import litellm
|
||||
import requests
|
||||
|
||||
# Third party imports
|
||||
from openai import OpenAI
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Django imports
|
||||
|
||||
# Module imports
|
||||
from ..base import BaseAPIView
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.db.models import Workspace, Project
|
||||
from plane.app.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
|
||||
# Module import
|
||||
from plane.app.permissions import ROLE, allow_permission
|
||||
from plane.app.serializers import (ProjectLiteSerializer,
|
||||
WorkspaceLiteSerializer)
|
||||
from plane.db.models import Project, Workspace
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
from ..base import BaseAPIView
|
||||
|
||||
|
||||
class LLMProvider:
|
||||
"""Base class for LLM provider configurations"""
|
||||
name: str = ""
|
||||
models: List[str] = []
|
||||
default_model: str = ""
|
||||
|
||||
@classmethod
|
||||
def get_config(cls) -> Dict[str, str | List[str]]:
|
||||
return {
|
||||
"name": cls.name,
|
||||
"models": cls.models,
|
||||
"default_model": cls.default_model,
|
||||
}
|
||||
|
||||
class OpenAIProvider(LLMProvider):
|
||||
name = "OpenAI"
|
||||
models = ["gpt-3.5-turbo", "gpt-4o-mini", "gpt-4o", "o1-mini", "o1-preview"]
|
||||
default_model = "gpt-4o-mini"
|
||||
|
||||
class AnthropicProvider(LLMProvider):
|
||||
name = "Anthropic"
|
||||
models = [
|
||||
"claude-3-5-sonnet-20240620",
|
||||
"claude-3-haiku-20240307",
|
||||
"claude-3-opus-20240229",
|
||||
"claude-3-sonnet-20240229",
|
||||
"claude-2.1",
|
||||
"claude-2",
|
||||
"claude-instant-1.2",
|
||||
"claude-instant-1"
|
||||
]
|
||||
default_model = "claude-3-sonnet-20240229"
|
||||
|
||||
class GeminiProvider(LLMProvider):
|
||||
name = "Gemini"
|
||||
models = ["gemini-pro", "gemini-1.5-pro-latest", "gemini-pro-vision"]
|
||||
default_model = "gemini-pro"
|
||||
|
||||
SUPPORTED_PROVIDERS = {
|
||||
"openai": OpenAIProvider,
|
||||
"anthropic": AnthropicProvider,
|
||||
"gemini": GeminiProvider,
|
||||
}
|
||||
|
||||
def get_llm_config() -> Tuple[str | None, str | None, str | None]:
|
||||
"""
|
||||
Helper to get LLM configuration values, returns:
|
||||
- api_key, model, provider
|
||||
"""
|
||||
api_key, provider_key, model = get_configuration_value([
|
||||
{
|
||||
"key": "LLM_API_KEY",
|
||||
"default": os.environ.get("LLM_API_KEY", None),
|
||||
},
|
||||
{
|
||||
"key": "LLM_PROVIDER",
|
||||
"default": os.environ.get("LLM_PROVIDER", "openai"),
|
||||
},
|
||||
{
|
||||
"key": "LLM_MODEL",
|
||||
"default": os.environ.get("LLM_MODEL", None),
|
||||
},
|
||||
])
|
||||
|
||||
provider = SUPPORTED_PROVIDERS.get(provider_key.lower())
|
||||
if not provider:
|
||||
log_exception(ValueError(f"Unsupported provider: {provider_key}"))
|
||||
return None, None, None
|
||||
|
||||
if not api_key:
|
||||
log_exception(ValueError(f"Missing API key for provider: {provider.name}"))
|
||||
return None, None, None
|
||||
|
||||
# If no model specified, use provider's default
|
||||
if not model:
|
||||
model = provider.default_model
|
||||
|
||||
# Validate model is supported by provider
|
||||
if model not in provider.models:
|
||||
log_exception(ValueError(
|
||||
f"Model {model} not supported by {provider.name}. "
|
||||
f"Supported models: {', '.join(provider.models)}"
|
||||
))
|
||||
return None, None, None
|
||||
|
||||
return api_key, model, provider_key
|
||||
|
||||
|
||||
def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> Tuple[str | None, str | None]:
|
||||
"""Helper to get LLM completion response"""
|
||||
final_text = task + "\n" + prompt
|
||||
try:
|
||||
# For Gemini, prepend provider name to model
|
||||
if provider.lower() == "gemini":
|
||||
model = f"gemini/{model}"
|
||||
|
||||
response = litellm.completion(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": final_text}],
|
||||
api_key=api_key,
|
||||
)
|
||||
text = response.choices[0].message.content.strip()
|
||||
return text, None
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
error_type = e.__class__.__name__
|
||||
if error_type == "AuthenticationError":
|
||||
return None, f"Invalid API key for {provider}"
|
||||
elif error_type == "RateLimitError":
|
||||
return None, f"Rate limit exceeded for {provider}"
|
||||
else:
|
||||
return None, f"Error occurred while generating response from {provider}"
|
||||
|
||||
class GPTIntegrationEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def post(self, request, slug, project_id):
|
||||
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "OPENAI_API_KEY",
|
||||
"default": os.environ.get("OPENAI_API_KEY", None),
|
||||
},
|
||||
{
|
||||
"key": "GPT_ENGINE",
|
||||
"default": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
|
||||
},
|
||||
]
|
||||
)
|
||||
api_key, model, provider = get_llm_config()
|
||||
|
||||
# Get the configuration value
|
||||
# Check the keys
|
||||
if not OPENAI_API_KEY or not GPT_ENGINE:
|
||||
if not api_key or not model or not provider:
|
||||
return Response(
|
||||
{"error": "OpenAI API key and engine is required"},
|
||||
{"error": "LLM provider API key and model are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
prompt = request.data.get("prompt", False)
|
||||
task = request.data.get("task", False)
|
||||
|
||||
if not task:
|
||||
return Response(
|
||||
{"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
final_text = task + "\n" + prompt
|
||||
|
||||
client = OpenAI(api_key=OPENAI_API_KEY)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=GPT_ENGINE, messages=[{"role": "user", "content": final_text}]
|
||||
)
|
||||
text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider)
|
||||
if not text and error:
|
||||
return Response(
|
||||
{"error": "An internal error has occurred."},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
project = Project.objects.get(pk=project_id)
|
||||
|
||||
text = response.choices[0].message.content.strip()
|
||||
text_html = text.replace("\n", "<br/>")
|
||||
return Response(
|
||||
{
|
||||
"response": text,
|
||||
"response_html": text_html,
|
||||
"response_html": text.replace("\n", "<br/>"),
|
||||
"project_detail": ProjectLiteSerializer(project).data,
|
||||
"workspace_detail": WorkspaceLiteSerializer(workspace).data,
|
||||
},
|
||||
@@ -76,47 +174,33 @@ class GPTIntegrationEndpoint(BaseAPIView):
|
||||
class WorkspaceGPTIntegrationEndpoint(BaseAPIView):
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def post(self, request, slug):
|
||||
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "OPENAI_API_KEY",
|
||||
"default": os.environ.get("OPENAI_API_KEY", None),
|
||||
},
|
||||
{
|
||||
"key": "GPT_ENGINE",
|
||||
"default": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
# Get the configuration value
|
||||
# Check the keys
|
||||
if not OPENAI_API_KEY or not GPT_ENGINE:
|
||||
api_key, model, provider = get_llm_config()
|
||||
|
||||
if not api_key or not model or not provider:
|
||||
return Response(
|
||||
{"error": "OpenAI API key and engine is required"},
|
||||
{"error": "LLM provider API key and model are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
prompt = request.data.get("prompt", False)
|
||||
task = request.data.get("task", False)
|
||||
|
||||
if not task:
|
||||
return Response(
|
||||
{"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
final_text = task + "\n" + prompt
|
||||
text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider)
|
||||
if not text and error:
|
||||
return Response(
|
||||
{"error": "An internal error has occurred."},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
client = OpenAI(api_key=OPENAI_API_KEY)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=GPT_ENGINE, messages=[{"role": "user", "content": final_text}]
|
||||
)
|
||||
|
||||
text = response.choices[0].message.content.strip()
|
||||
text_html = text.replace("\n", "<br/>")
|
||||
return Response(
|
||||
{"response": text, "response_html": text_html}, status=status.HTTP_200_OK
|
||||
{
|
||||
"response": text,
|
||||
"response_html": text.replace("\n", "<br/>"),
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -121,6 +121,8 @@ class PageViewSet(BaseViewSet):
|
||||
context={
|
||||
"project_id": project_id,
|
||||
"owned_by_id": request.user.id,
|
||||
"description": request.data.get("description", {}),
|
||||
"description_binary": request.data.get("description_binary", None),
|
||||
"description_html": request.data.get("description_html", "<p></p>"),
|
||||
},
|
||||
)
|
||||
@@ -553,3 +555,37 @@ class PagesDescriptionViewSet(BaseViewSet):
|
||||
return Response({"message": "Updated successfully"})
|
||||
else:
|
||||
return Response({"error": "No binary data provided"})
|
||||
|
||||
|
||||
class PageDuplicateEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def post(self, request, slug, project_id, page_id):
|
||||
page = Page.objects.filter(
|
||||
pk=page_id, workspace__slug=slug, projects__id=project_id
|
||||
).first()
|
||||
|
||||
# get all the project ids where page is present
|
||||
project_ids = ProjectPage.objects.filter(page_id=page_id).values_list(
|
||||
"project_id", flat=True
|
||||
)
|
||||
|
||||
page.pk = None
|
||||
page.name = f"{page.name} (Copy)"
|
||||
page.description_binary = None
|
||||
page.save()
|
||||
|
||||
for project_id in project_ids:
|
||||
ProjectPage.objects.create(
|
||||
workspace_id=page.workspace_id,
|
||||
project_id=project_id,
|
||||
page_id=page.id,
|
||||
created_by_id=page.created_by_id,
|
||||
updated_by_id=page.updated_by_id,
|
||||
)
|
||||
|
||||
page_transaction.delay(
|
||||
{"description_html": page.description_html}, None, page.id
|
||||
)
|
||||
page = Page.objects.get(pk=page.id)
|
||||
serializer = PageDetailSerializer(page)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -132,20 +132,33 @@ class Command(BaseCommand):
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "OPENAI_API_KEY",
|
||||
"value": os.environ.get("OPENAI_API_KEY"),
|
||||
"category": "OPENAI",
|
||||
"key": "LLM_API_KEY",
|
||||
"value": os.environ.get("LLM_API_KEY"),
|
||||
"category": "AI",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
{
|
||||
"key": "GPT_ENGINE",
|
||||
"key": "LLM_PROVIDER",
|
||||
"value": os.environ.get("LLM_PROVIDER", "openai"),
|
||||
"category": "AI",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "LLM_MODEL",
|
||||
"value": os.environ.get("LLM_MODEL", "gpt-4o-mini"),
|
||||
"category": "AI",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
# Deprecated, use LLM_MODEL
|
||||
{
|
||||
"key": "GPT_ENGINE",
|
||||
"value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
|
||||
"category": "SMTP",
|
||||
"is_encrypted": False,
|
||||
},
|
||||
{
|
||||
"key": "UNSPLASH_ACCESS_KEY",
|
||||
"value": os.environ.get("UNSPLASH_ACESS_KEY", ""),
|
||||
"value": os.environ.get("UNSPLASH_ACCESS_KEY", ""),
|
||||
"category": "UNSPLASH",
|
||||
"is_encrypted": True,
|
||||
},
|
||||
|
||||
@@ -37,7 +37,7 @@ uvicorn==0.29.0
|
||||
# sockets
|
||||
channels==4.1.0
|
||||
# ai
|
||||
openai==1.25.0
|
||||
litellm==1.51.0
|
||||
# slack
|
||||
slack-sdk==3.27.1
|
||||
# apm
|
||||
|
||||
@@ -131,7 +131,7 @@ export const PageRenderer = (props: IPageRenderer) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="frame-renderer flex-grow w-full -mx-5" onMouseOver={handleLinkHover}>
|
||||
<div className="frame-renderer flex-grow w-full" onMouseOver={handleLinkHover}>
|
||||
<EditorContainer
|
||||
displayConfig={displayConfig}
|
||||
editor={editor}
|
||||
|
||||
@@ -74,6 +74,7 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
"editor-container cursor-text relative",
|
||||
{
|
||||
"active-editor": editor?.isFocused && editor?.isEditable,
|
||||
"wide-layout": displayConfig.wideLayout,
|
||||
},
|
||||
displayConfig.fontSize ?? DEFAULT_DISPLAY_CONFIG.fontSize,
|
||||
displayConfig.fontStyle ?? DEFAULT_DISPLAY_CONFIG.fontStyle,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TDisplayConfig } from "@/types";
|
||||
export const DEFAULT_DISPLAY_CONFIG: TDisplayConfig = {
|
||||
fontSize: "large-font",
|
||||
fontStyle: "sans-serif",
|
||||
wideLayout: false,
|
||||
};
|
||||
|
||||
export const ACCEPTED_FILE_MIME_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"];
|
||||
|
||||
@@ -3,4 +3,6 @@ export const DocumentCollaborativeEvents = {
|
||||
unlock: { client: "unlocked", server: "unlock" },
|
||||
archive: { client: "archived", server: "archive" },
|
||||
unarchive: { client: "unarchived", server: "unarchive" },
|
||||
"make-public": { client: "made-public", server: "make-public" },
|
||||
"make-private": { client: "made-private", server: "make-private" },
|
||||
} as const;
|
||||
|
||||
@@ -12,7 +12,7 @@ export const CoreEditorProps = (props: TCoreEditorProps): EditorProps => {
|
||||
return {
|
||||
attributes: {
|
||||
class: cn(
|
||||
"prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none",
|
||||
"prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none transition-all duration-300",
|
||||
editorClassName
|
||||
),
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ export const CoreReadOnlyEditorProps = (props: TCoreEditorProps): EditorProps =>
|
||||
return {
|
||||
attributes: {
|
||||
class: cn(
|
||||
"prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none",
|
||||
"prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none transition-all duration-300",
|
||||
editorClassName
|
||||
),
|
||||
},
|
||||
|
||||
@@ -22,4 +22,5 @@ export type TEditorFontSize = "small-font" | "large-font";
|
||||
export type TDisplayConfig = {
|
||||
fontStyle?: TEditorFontStyle;
|
||||
fontSize?: TEditorFontSize;
|
||||
wideLayout?: boolean;
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
outline: none;
|
||||
outline: none !important;
|
||||
cursor: text;
|
||||
font-family: var(--font-style);
|
||||
font-size: var(--font-size-regular);
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
}
|
||||
|
||||
/* text background colors */
|
||||
[data-theme="light"],
|
||||
[data-theme="light-contrast"] {
|
||||
[data-theme*="light"] {
|
||||
--editor-colors-gray-background: #d6d6d8;
|
||||
--editor-colors-peach-background: #ffd5d7;
|
||||
--editor-colors-pink-background: #fdd4e3;
|
||||
@@ -23,8 +22,7 @@
|
||||
--editor-colors-dark-blue-background: #c9dafb;
|
||||
--editor-colors-purple-background: #e3d8fd;
|
||||
}
|
||||
[data-theme="dark"],
|
||||
[data-theme="dark-contrast"] {
|
||||
[data-theme*="dark"] {
|
||||
--editor-colors-gray-background: #404144;
|
||||
--editor-colors-peach-background: #593032;
|
||||
--editor-colors-pink-background: #562e3d;
|
||||
@@ -36,6 +34,7 @@
|
||||
}
|
||||
/* end text background colors */
|
||||
|
||||
/* font size and style */
|
||||
.editor-container {
|
||||
/* font sizes and line heights */
|
||||
&.large-font {
|
||||
@@ -83,6 +82,8 @@
|
||||
/* end font sizes and line heights */
|
||||
|
||||
/* font styles */
|
||||
--font-style: "Inter", sans-serif;
|
||||
|
||||
&.sans-serif {
|
||||
--font-style: "Inter", sans-serif;
|
||||
}
|
||||
@@ -94,3 +95,50 @@
|
||||
}
|
||||
/* end font styles */
|
||||
}
|
||||
/* end font size and style */
|
||||
|
||||
/* layout config */
|
||||
#page-content-container {
|
||||
container-name: page-content-container;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
--editor-content-width: 720px;
|
||||
--editor-margin-width: 96px;
|
||||
|
||||
&.wide-layout {
|
||||
--editor-content-width: 1152px;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
padding-left: calc((100% - var(--editor-content-width)) / 2);
|
||||
padding-right: calc((100% - var(--editor-content-width)) / 2);
|
||||
/* transition: 0.3s all linear; */
|
||||
}
|
||||
}
|
||||
|
||||
/* keep a static padding of 20px for normal layouts for container width <760px */
|
||||
@container page-content-container (max-width: 760px) {
|
||||
.editor-container:not(.wide-layout),
|
||||
.page-title-container {
|
||||
padding: 0 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* keep a static padding of 96px for wide layouts for container width >912px and <1344px */
|
||||
@container page-content-container (min-width: 912px) and (max-width: 1344px) {
|
||||
.editor-container.wide-layout,
|
||||
.page-title-container {
|
||||
padding: 0 96px;
|
||||
}
|
||||
}
|
||||
|
||||
/* keep a static padding of 20px for wide layouts for container width <912px */
|
||||
@container page-content-container (max-width: 912px) {
|
||||
.editor-container.wide-layout,
|
||||
.page-title-container {
|
||||
padding: 0 20px;
|
||||
}
|
||||
}
|
||||
/* end layout config */
|
||||
|
||||
@@ -36,19 +36,23 @@ export const ContextMenuItem: React.FC<ContextMenuItemProps> = (props) => {
|
||||
onMouseEnter={handleActiveItem}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||
<div>
|
||||
<h5>{item.title}</h5>
|
||||
{item.description && (
|
||||
<p
|
||||
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||
"text-custom-text-400": item.disabled,
|
||||
})}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{item.customContent ?? (
|
||||
<>
|
||||
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
|
||||
<div>
|
||||
<h5>{item.title}</h5>
|
||||
{item.description && (
|
||||
<p
|
||||
className={cn("text-custom-text-300 whitespace-pre-line", {
|
||||
"text-custom-text-400": item.disabled,
|
||||
})}
|
||||
>
|
||||
{item.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,7 +11,8 @@ import { usePlatformOS } from "../../hooks/use-platform-os";
|
||||
|
||||
export type TContextMenuItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
customContent?: React.ReactNode;
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.FC<any>;
|
||||
action: () => void;
|
||||
|
||||
@@ -54,7 +54,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
if (referenceElement) referenceElement.focus();
|
||||
};
|
||||
const closeDropdown = () => {
|
||||
isOpen && onMenuClose && onMenuClose();
|
||||
if (isOpen) onMenuClose?.();
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
@@ -216,7 +216,7 @@ const MenuItem: React.FC<ICustomMenuItemProps> = (props) => {
|
||||
)}
|
||||
onClick={(e) => {
|
||||
close();
|
||||
onClick && onClick(e);
|
||||
onClick?.(e);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
||||
+2
-2
@@ -41,7 +41,7 @@ const PageDetailsPage = observer(() => {
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
// derived values
|
||||
const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : "";
|
||||
const { id, name, updateDescription } = page;
|
||||
const { canCurrentUserAccessPage, id, name, updateDescription } = page;
|
||||
// entity search handler
|
||||
const fetchEntityCallback = useCallback(
|
||||
async (payload: TSearchEntityRequestPayload) =>
|
||||
@@ -129,7 +129,7 @@ const PageDetailsPage = observer(() => {
|
||||
</div>
|
||||
);
|
||||
|
||||
if (pageDetailsError)
|
||||
if (pageDetailsError || !canCurrentUserAccessPage)
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center">
|
||||
<h3 className="text-lg font-semibold text-center">Page not found</h3>
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./editor";
|
||||
export * from "./modals";
|
||||
export * from "./extra-actions";
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./move-page-modal";
|
||||
@@ -0,0 +1,10 @@
|
||||
// store types
|
||||
import { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
export type TMovePageModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
export const MovePageModal: React.FC<TMovePageModalProps> = () => null;
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
ArchiveRestoreIcon,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FileOutput,
|
||||
Globe2,
|
||||
Link,
|
||||
Lock,
|
||||
LockKeyhole,
|
||||
LockKeyholeOpen,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
// plane editor
|
||||
import { EditorRefApi } from "@plane/editor";
|
||||
// plane ui
|
||||
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem } from "@plane/ui";
|
||||
// components
|
||||
import { DeletePageModal } from "@/components/pages";
|
||||
// constants
|
||||
import { EPageAccess } from "@/constants/page";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { usePageOperations } from "@/hooks/use-page-operations";
|
||||
// plane web components
|
||||
import { MovePageModal } from "@/plane-web/components/pages";
|
||||
// store types
|
||||
import { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
export type TPageActions =
|
||||
| "full-screen"
|
||||
| "copy-markdown"
|
||||
| "toggle-lock"
|
||||
| "toggle-access"
|
||||
| "open-in-new-tab"
|
||||
| "copy-link"
|
||||
| "make-a-copy"
|
||||
| "archive-restore"
|
||||
| "delete"
|
||||
| "version-history"
|
||||
| "export"
|
||||
| "move";
|
||||
|
||||
type Props = {
|
||||
editorRef?: EditorRefApi | null;
|
||||
extraOptions?: (TContextMenuItem & { key: TPageActions })[];
|
||||
optionsOrder: TPageActions[];
|
||||
page: TPageInstance;
|
||||
parentRef?: React.RefObject<HTMLElement>;
|
||||
};
|
||||
|
||||
export const PageActions: React.FC<Props> = observer((props) => {
|
||||
const { editorRef, extraOptions, optionsOrder, page, parentRef } = props;
|
||||
// states
|
||||
const [deletePageModal, setDeletePageModal] = useState(false);
|
||||
const [movePageModal, setMovePageModal] = useState(false);
|
||||
// page operations
|
||||
const { pageOperations } = usePageOperations({
|
||||
editorRef,
|
||||
page,
|
||||
});
|
||||
// derived values
|
||||
const {
|
||||
access,
|
||||
archived_at,
|
||||
is_locked,
|
||||
canCurrentUserArchivePage,
|
||||
canCurrentUserChangeAccess,
|
||||
canCurrentUserDeletePage,
|
||||
canCurrentUserDuplicatePage,
|
||||
canCurrentUserLockPage,
|
||||
canCurrentUserMovePage,
|
||||
} = page;
|
||||
// menu items
|
||||
const MENU_ITEMS: (TContextMenuItem & { key: TPageActions })[] = useMemo(() => {
|
||||
const menuItems: (TContextMenuItem & { key: TPageActions })[] = [
|
||||
{
|
||||
key: "toggle-lock",
|
||||
action: pageOperations.toggleLock,
|
||||
title: is_locked ? "Unlock" : "Lock",
|
||||
icon: is_locked ? LockKeyholeOpen : LockKeyhole,
|
||||
shouldRender: canCurrentUserLockPage,
|
||||
},
|
||||
{
|
||||
key: "toggle-access",
|
||||
action: pageOperations.toggleAccess,
|
||||
title: access === EPageAccess.PUBLIC ? "Make private" : "Make public",
|
||||
icon: access === EPageAccess.PUBLIC ? Lock : Globe2,
|
||||
shouldRender: canCurrentUserChangeAccess && !archived_at,
|
||||
},
|
||||
{
|
||||
key: "open-in-new-tab",
|
||||
action: pageOperations.openInNewTab,
|
||||
title: "Open in new tab",
|
||||
icon: ExternalLink,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
action: pageOperations.copyLink,
|
||||
title: "Copy link",
|
||||
icon: Link,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "make-a-copy",
|
||||
action: pageOperations.duplicate,
|
||||
title: "Make a copy",
|
||||
icon: Copy,
|
||||
shouldRender: canCurrentUserDuplicatePage,
|
||||
},
|
||||
{
|
||||
key: "archive-restore",
|
||||
action: pageOperations.toggleArchive,
|
||||
title: archived_at ? "Restore" : "Archive",
|
||||
icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon,
|
||||
shouldRender: canCurrentUserArchivePage,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
action: () => setDeletePageModal(true),
|
||||
title: "Delete",
|
||||
icon: Trash2,
|
||||
shouldRender: canCurrentUserDeletePage && !!archived_at,
|
||||
},
|
||||
{
|
||||
key: "move",
|
||||
action: () => setMovePageModal(true),
|
||||
title: "Move",
|
||||
icon: FileOutput,
|
||||
shouldRender: canCurrentUserMovePage,
|
||||
},
|
||||
];
|
||||
if (extraOptions) {
|
||||
menuItems.push(...extraOptions);
|
||||
}
|
||||
return menuItems;
|
||||
}, [
|
||||
access,
|
||||
archived_at,
|
||||
extraOptions,
|
||||
is_locked,
|
||||
canCurrentUserArchivePage,
|
||||
canCurrentUserChangeAccess,
|
||||
canCurrentUserDeletePage,
|
||||
canCurrentUserDuplicatePage,
|
||||
canCurrentUserLockPage,
|
||||
canCurrentUserMovePage,
|
||||
pageOperations,
|
||||
]);
|
||||
// arrange options
|
||||
const arrangedOptions = useMemo(
|
||||
() =>
|
||||
optionsOrder
|
||||
.map((key) => MENU_ITEMS.find((item) => item.key === key))
|
||||
.filter((item) => !!item) as (TContextMenuItem & { key: TPageActions })[],
|
||||
[optionsOrder, MENU_ITEMS]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MovePageModal isOpen={movePageModal} onClose={() => setMovePageModal(false)} page={page} />
|
||||
<DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} page={page} />
|
||||
{parentRef && <ContextMenu parentRef={parentRef} items={arrangedOptions} />}
|
||||
<CustomMenu placement="bottom-end" optionsClassName="max-h-[90vh]" ellipsis closeOnSelect>
|
||||
{arrangedOptions.map((item) => {
|
||||
if (item.shouldRender === false) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.action?.();
|
||||
}}
|
||||
className={cn("flex items-center gap-2", item.className)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.customContent ?? (
|
||||
<>
|
||||
{item.icon && <item.icon className="size-3" />}
|
||||
{item.title}
|
||||
</>
|
||||
)}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -1,14 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane ui
|
||||
import { Avatar } from "@plane/ui";
|
||||
// helpers
|
||||
import { calculateTimeAgoShort, renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store";
|
||||
// store
|
||||
import { calculateTimeAgoShort } from "@/helpers/date-time.helper";
|
||||
// store types
|
||||
import { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type Props = {
|
||||
@@ -17,55 +10,10 @@ type Props = {
|
||||
|
||||
export const PageEditInformationPopover: React.FC<Props> = observer((props) => {
|
||||
const { page } = props;
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const editorInformation = page.updated_by ? getUserDetails(page.updated_by) : undefined;
|
||||
const creatorInformation = page.created_by ? getUserDetails(page.created_by) : undefined;
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0 relative group/edit-information whitespace-nowrap">
|
||||
<div className="flex-shrink-0 whitespace-nowrap">
|
||||
<span className="text-sm text-custom-text-300">Edited {calculateTimeAgoShort(page.updated_at ?? "")} ago</span>
|
||||
<div className="hidden group-hover/edit-information:block absolute z-10 top-full right-0 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-2 shadow-custom-shadow-rg space-y-2">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-custom-text-300">Edited by</p>
|
||||
<Link
|
||||
href={`/${workspaceSlug?.toString()}/profile/${page.updated_by}`}
|
||||
className="mt-2 flex items-center gap-1.5 text-sm font-medium"
|
||||
>
|
||||
<Avatar
|
||||
src={getFileURL(editorInformation?.avatar_url ?? "")}
|
||||
name={editorInformation?.display_name}
|
||||
className="flex-shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
<span>
|
||||
{editorInformation?.display_name}{" "}
|
||||
<span className="text-custom-text-300">{renderFormattedDate(page.updated_at)}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-custom-text-300">Created by</p>
|
||||
<Link
|
||||
href={`/${workspaceSlug?.toString()}/profile/${page.created_by}`}
|
||||
className="mt-2 flex items-center gap-1.5 text-sm font-medium"
|
||||
>
|
||||
<Avatar
|
||||
src={getFileURL(creatorInformation?.avatar_url ?? "")}
|
||||
name={creatorInformation?.display_name}
|
||||
className="flex-shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
<span>
|
||||
{creatorInformation?.display_name}{" "}
|
||||
<span className="text-custom-text-300">{renderFormattedDate(page.created_at)}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./actions";
|
||||
export * from "./edit-information-popover";
|
||||
export * from "./quick-actions";
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ArchiveRestoreIcon, ExternalLink, Link, Lock, Trash2, UsersRound } from "lucide-react";
|
||||
import { ArchiveIcon, ContextMenu, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { DeletePageModal } from "@/components/pages";
|
||||
// helpers
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// store
|
||||
import { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type Props = {
|
||||
page: TPageInstance;
|
||||
pageLink: string;
|
||||
parentRef: React.RefObject<HTMLElement>;
|
||||
};
|
||||
|
||||
export const PageQuickActions: React.FC<Props> = observer((props) => {
|
||||
const { page, pageLink, parentRef } = props;
|
||||
// states
|
||||
const [deletePageModal, setDeletePageModal] = useState(false);
|
||||
// store hooks
|
||||
const {
|
||||
access,
|
||||
archive,
|
||||
archived_at,
|
||||
makePublic,
|
||||
makePrivate,
|
||||
restore,
|
||||
canCurrentUserArchivePage,
|
||||
canCurrentUserChangeAccess,
|
||||
canCurrentUserDeletePage,
|
||||
} = page;
|
||||
|
||||
const handleCopyText = () =>
|
||||
copyUrlToClipboard(pageLink).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link Copied!",
|
||||
message: "Page link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
|
||||
const handleOpenInNewTab = () => window.open(`/${pageLink}`, "_blank");
|
||||
|
||||
const MENU_ITEMS: TContextMenuItem[] = [
|
||||
{
|
||||
key: "make-public-private",
|
||||
action: async () => {
|
||||
const changedPageType = access === 0 ? "private" : "public";
|
||||
|
||||
try {
|
||||
if (access === 0) await makePrivate();
|
||||
else await makePublic();
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: `The page has been marked ${changedPageType} and moved to the ${changedPageType} section.`,
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: `The page couldn't be marked ${changedPageType}. Please try again.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
title: access === 0 ? "Make private" : "Make public",
|
||||
icon: access === 0 ? Lock : UsersRound,
|
||||
shouldRender: canCurrentUserChangeAccess && !archived_at,
|
||||
},
|
||||
{
|
||||
key: "open-new-tab",
|
||||
action: handleOpenInNewTab,
|
||||
title: "Open in new tab",
|
||||
icon: ExternalLink,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "copy-link",
|
||||
action: handleCopyText,
|
||||
title: "Copy link",
|
||||
icon: Link,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "archive-restore",
|
||||
action: archived_at ? restore : archive,
|
||||
title: archived_at ? "Restore" : "Archive",
|
||||
icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon,
|
||||
shouldRender: canCurrentUserArchivePage,
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
action: () => setDeletePageModal(true),
|
||||
title: "Delete",
|
||||
icon: Trash2,
|
||||
shouldRender: canCurrentUserDeletePage && !!archived_at,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} page={page} />
|
||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||
<CustomMenu placement="bottom-end" ellipsis closeOnSelect>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (!item.shouldRender) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.action();
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && <item.icon className="h-3 w-3" />}
|
||||
{item.title}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
// plane types
|
||||
import { TSearchEntityRequestPayload, TSearchResponse, TWebhookConnectionQueryParams } from "@plane/types";
|
||||
// plane ui
|
||||
import { Row } from "@plane/ui";
|
||||
import { ERowVariant, Row } from "@plane/ui";
|
||||
// components
|
||||
import { EditorMentionsRoot } from "@/components/editor";
|
||||
import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/components/pages";
|
||||
@@ -86,8 +86,9 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
() => ({
|
||||
fontSize,
|
||||
fontStyle,
|
||||
wideLayout: isFullWidth,
|
||||
}),
|
||||
[fontSize, fontStyle]
|
||||
[fontSize, fontStyle, isFullWidth]
|
||||
);
|
||||
|
||||
const getAIMenu = useCallback(
|
||||
@@ -149,23 +150,23 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
if (pageId === undefined || !realtimeConfig) return <PageContentLoader />;
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-full w-full overflow-y-auto">
|
||||
<Row
|
||||
<>
|
||||
{/* <div
|
||||
className={cn("sticky top-0 hidden h-full flex-shrink-0 -translate-x-full py-5 duration-200 md:block", {
|
||||
"translate-x-0": sidePeekVisible,
|
||||
"w-[10rem] lg:w-[14rem]": !isFullWidth,
|
||||
"w-[5%]": isFullWidth,
|
||||
})}
|
||||
>
|
||||
{!isFullWidth && <PageContentBrowser editorRef={editorRef.current} />}
|
||||
</Row>
|
||||
<div
|
||||
className={cn("size-full pt-5 duration-200", {
|
||||
"md:w-[calc(100%-10rem)] xl:w-[calc(100%-28rem)]": !isFullWidth,
|
||||
"md:w-[90%]": isFullWidth,
|
||||
})}
|
||||
{!isFullWidth && (
|
||||
<PageContentBrowser editorRef={(isContentEditable ? editorRef : readOnlyEditorRef)?.current} />
|
||||
)}
|
||||
</div> */}
|
||||
<Row
|
||||
className="relative size-full flex flex-col gap-y-7 pt-[64px] overflow-y-auto overflow-x-hidden vertical-scrollbar scrollbar-md duration-200"
|
||||
variant={ERowVariant.HUGGING}
|
||||
>
|
||||
<div className="size-full flex flex-col gap-y-7 overflow-y-auto overflow-x-hidden">
|
||||
<div id="page-content-container" className="relative w-full flex-shrink-0 space-y-7">
|
||||
<PageEditorTitle
|
||||
editorRef={editorRef}
|
||||
title={pageTitle}
|
||||
@@ -180,7 +181,6 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
ref={editorRef}
|
||||
containerClassName="h-full p-0 pb-64"
|
||||
displayConfig={displayConfig}
|
||||
editorClassName="pl-10"
|
||||
mentionHandler={{
|
||||
searchCallback: async (query) => {
|
||||
const res = await fetchMentions(query);
|
||||
@@ -201,13 +201,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn("hidden xl:block flex-shrink-0 duration-200", {
|
||||
"w-[10rem] lg:w-[14rem]": !isFullWidth,
|
||||
"w-[5%]": isFullWidth,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,13 +16,12 @@ import useOnlineStatus from "@/hooks/use-online-status";
|
||||
import { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type Props = {
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
handleDuplicatePage: () => void;
|
||||
editorRef: EditorRefApi;
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
||||
const { editorRef, handleDuplicatePage, page } = props;
|
||||
const { editorRef, page } = props;
|
||||
// derived values
|
||||
const {
|
||||
archived_at,
|
||||
@@ -84,8 +83,8 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
|
||||
iconClassName="text-custom-text-100"
|
||||
/>
|
||||
)}
|
||||
<PageInfoPopover editorRef={editorRef?.current} />
|
||||
<PageOptionsDropdown editorRef={editorRef?.current} handleDuplicatePage={handleDuplicatePage} page={page} />
|
||||
<PageInfoPopover editorRef={editorRef} page={page} />
|
||||
<PageOptionsDropdown editorRef={editorRef} page={page} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,26 +1,44 @@
|
||||
import { useState } from "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
|
||||
import { Avatar } from "@plane/ui";
|
||||
// plane utils
|
||||
import { getFileURL, renderFormattedDate } from "@plane/utils";
|
||||
// helpers
|
||||
import { 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> = (props) => {
|
||||
const { editorRef } = props;
|
||||
const { editorRef, page } = props;
|
||||
// states
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
// refs
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// popper-js
|
||||
const { styles: infoPopoverStyles, attributes: infoPopoverAttributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: "bottom-start",
|
||||
});
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
// derived values
|
||||
const editorInformation = page.updated_by ? getUserDetails(page.updated_by) : undefined;
|
||||
const creatorInformation = page.created_by ? getUserDetails(page.created_by) : undefined;
|
||||
|
||||
const documentsInfo = editorRef?.getDocumentInfo() || { words: 0, characters: 0, paragraphs: 0 };
|
||||
|
||||
@@ -60,17 +78,57 @@ export const PageInfoPopover: React.FC<Props> = (props) => {
|
||||
</button>
|
||||
{isPopoverOpen && (
|
||||
<div
|
||||
className="z-10 w-64 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-2 shadow-custom-shadow-rg grid grid-cols-2 gap-1.5"
|
||||
className="z-10 w-64 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-2 shadow-custom-shadow-rg"
|
||||
ref={setPopperElement}
|
||||
style={infoPopoverStyles.popper}
|
||||
{...infoPopoverAttributes.popper}
|
||||
>
|
||||
{documentInfoCards.map((card) => (
|
||||
<div key={card.key} className="p-2 bg-custom-background-90 rounded">
|
||||
<h6 className="text-base font-semibold">{card.info}</h6>
|
||||
<p className="mt-1.5 text-sm text-custom-text-300">{card.title}</p>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{documentInfoCards.map((card) => (
|
||||
<div key={card.key} className="p-2 bg-custom-background-90 rounded">
|
||||
<h6 className="text-base font-semibold">{card.info}</h6>
|
||||
<p className="mt-1.5 text-sm text-custom-text-300">{card.title}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2 mt-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-custom-text-300">Edited by</p>
|
||||
<Link
|
||||
href={`/${workspaceSlug?.toString()}/profile/${page.updated_by}`}
|
||||
className="mt-2 flex items-center gap-1.5 text-sm font-medium"
|
||||
>
|
||||
<Avatar
|
||||
src={getFileURL(editorInformation?.avatar_url ?? "")}
|
||||
name={editorInformation?.display_name}
|
||||
className="flex-shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
<span>
|
||||
{editorInformation?.display_name}{" "}
|
||||
<span className="text-custom-text-300">{renderFormattedDate(page.updated_at)}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-custom-text-300">Created by</p>
|
||||
<Link
|
||||
href={`/${workspaceSlug?.toString()}/profile/${page.created_by}`}
|
||||
className="mt-2 flex items-center gap-1.5 text-sm font-medium"
|
||||
>
|
||||
<Avatar
|
||||
src={getFileURL(creatorInformation?.avatar_url ?? "")}
|
||||
name={creatorInformation?.display_name}
|
||||
className="flex-shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
<span>
|
||||
{creatorInformation?.display_name}{" "}
|
||||
<span className="text-custom-text-300">{renderFormattedDate(page.created_at)}</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,38 +9,34 @@ import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
import { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type Props = {
|
||||
editorReady: boolean;
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
handleDuplicatePage: () => void;
|
||||
editorRef: EditorRefApi;
|
||||
page: TPageInstance;
|
||||
setSidePeekVisible: (sidePeekState: boolean) => void;
|
||||
sidePeekVisible: boolean;
|
||||
};
|
||||
|
||||
export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
const { editorReady, editorRef, handleDuplicatePage, page, setSidePeekVisible, sidePeekVisible } = props;
|
||||
const { editorRef, page, setSidePeekVisible, sidePeekVisible } = props;
|
||||
// derived values
|
||||
const { isContentEditable } = page;
|
||||
// page filters
|
||||
const { isFullWidth } = usePageFilters();
|
||||
|
||||
if (!editorRef.current) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header variant={EHeaderVariant.SECONDARY}>
|
||||
<div className="flex-shrink-0 my-auto">
|
||||
<PageSummaryPopover
|
||||
editorRef={editorRef.current}
|
||||
editorRef={editorRef}
|
||||
isFullWidth={isFullWidth}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
setSidePeekVisible={setSidePeekVisible}
|
||||
/>
|
||||
</div>
|
||||
<PageExtraOptions editorRef={editorRef} handleDuplicatePage={handleDuplicatePage} page={page} />
|
||||
<PageExtraOptions editorRef={editorRef} page={page} />
|
||||
</Header>
|
||||
<Header variant={EHeaderVariant.TERNARY}>
|
||||
{editorReady && isContentEditable && editorRef.current && <PageToolbar editorRef={editorRef?.current} />}
|
||||
{isContentEditable && editorRef && <PageToolbar editorRef={editorRef} />}
|
||||
</Header>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,160 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
ArchiveRestoreIcon,
|
||||
ArrowUpToLine,
|
||||
Clipboard,
|
||||
Copy,
|
||||
History,
|
||||
Link,
|
||||
Lock,
|
||||
LockOpen,
|
||||
LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowUpToLine, Clipboard, History } from "lucide-react";
|
||||
// document editor
|
||||
import { EditorRefApi } from "@plane/editor";
|
||||
// ui
|
||||
import { ArchiveIcon, CustomMenu, type ISvgIcons, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
|
||||
import { TContextMenuItem, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { ExportPageModal } from "@/components/pages";
|
||||
import { ExportPageModal, PageActions, TPageActions } from "@/components/pages";
|
||||
// helpers
|
||||
import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
import { copyTextToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useCollaborativePageActions } from "@/hooks/use-collaborative-page-actions";
|
||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
|
||||
import { useQueryParams } from "@/hooks/use-query-params";
|
||||
// store
|
||||
import { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type Props = {
|
||||
editorRef: EditorRefApi | null;
|
||||
handleDuplicatePage: () => void;
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
const { editorRef, handleDuplicatePage, page } = props;
|
||||
const { editorRef, page } = props;
|
||||
// states
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
// router
|
||||
const router = useRouter();
|
||||
// store values
|
||||
const {
|
||||
name,
|
||||
archived_at,
|
||||
is_locked,
|
||||
id,
|
||||
canCurrentUserArchivePage,
|
||||
canCurrentUserDuplicatePage,
|
||||
canCurrentUserLockPage,
|
||||
} = page;
|
||||
// states
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
// store hooks
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const { name } = page;
|
||||
// page filters
|
||||
const { isFullWidth, handleFullWidth } = usePageFilters();
|
||||
// update query params
|
||||
const { updateQueryParams } = useQueryParams();
|
||||
// collaborative actions
|
||||
const { executeCollaborativeAction } = useCollaborativePageActions(editorRef, page);
|
||||
// parse editor content
|
||||
const { replaceCustomComponentsFromMarkdownContent } = useParseEditorContent();
|
||||
|
||||
// menu items list
|
||||
const MENU_ITEMS: {
|
||||
key: string;
|
||||
action: () => void;
|
||||
label: string;
|
||||
icon: LucideIcon | React.FC<ISvgIcons>;
|
||||
shouldRender: boolean;
|
||||
}[] = [
|
||||
{
|
||||
key: "copy-markdown",
|
||||
action: () => {
|
||||
if (!editorRef) return;
|
||||
const markdownContent = editorRef.getMarkDown();
|
||||
const parsedMarkdownContent = replaceCustomComponentsFromMarkdownContent({
|
||||
markdownContent,
|
||||
});
|
||||
copyTextToClipboard(parsedMarkdownContent).then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Markdown copied to clipboard.",
|
||||
})
|
||||
);
|
||||
const EXTRA_MENU_OPTIONS: (TContextMenuItem & { key: TPageActions })[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "full-screen",
|
||||
action: () => handleFullWidth(!isFullWidth),
|
||||
customContent: (
|
||||
<>
|
||||
Full width
|
||||
<ToggleSwitch value={isFullWidth} onChange={() => {}} />
|
||||
</>
|
||||
),
|
||||
className: "flex items-center justify-between gap-2",
|
||||
},
|
||||
label: "Copy markdown",
|
||||
icon: Clipboard,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "copy-page-link",
|
||||
action: () => {
|
||||
const pageLink = projectId
|
||||
? `${workspaceSlug?.toString()}/projects/${projectId?.toString()}/pages/${id}`
|
||||
: `${workspaceSlug?.toString()}/pages/${id}`;
|
||||
copyUrlToClipboard(pageLink).then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page link copied to clipboard.",
|
||||
})
|
||||
);
|
||||
{
|
||||
key: "copy-markdown",
|
||||
action: () => {
|
||||
if (!editorRef) return;
|
||||
copyTextToClipboard(editorRef.getMarkDown()).then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Markdown copied to clipboard.",
|
||||
})
|
||||
);
|
||||
},
|
||||
title: "Copy markdown",
|
||||
icon: Clipboard,
|
||||
shouldRender: true,
|
||||
},
|
||||
label: "Copy page link",
|
||||
icon: Link,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "make-a-copy",
|
||||
action: handleDuplicatePage,
|
||||
label: "Make a copy",
|
||||
icon: Copy,
|
||||
shouldRender: canCurrentUserDuplicatePage,
|
||||
},
|
||||
{
|
||||
key: "lock-unlock-page",
|
||||
action: is_locked
|
||||
? () => executeCollaborativeAction({ type: "sendMessageToServer", message: "unlock" })
|
||||
: () => executeCollaborativeAction({ type: "sendMessageToServer", message: "lock" }),
|
||||
label: is_locked ? "Unlock page" : "Lock page",
|
||||
icon: is_locked ? LockOpen : Lock,
|
||||
shouldRender: canCurrentUserLockPage,
|
||||
},
|
||||
{
|
||||
key: "archive-restore-page",
|
||||
action: archived_at
|
||||
? () => executeCollaborativeAction({ type: "sendMessageToServer", message: "unarchive" })
|
||||
: () => executeCollaborativeAction({ type: "sendMessageToServer", message: "archive" }),
|
||||
label: archived_at ? "Restore page" : "Archive page",
|
||||
icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon,
|
||||
shouldRender: canCurrentUserArchivePage,
|
||||
},
|
||||
{
|
||||
key: "version-history",
|
||||
action: () => {
|
||||
// add query param, version=current to the route
|
||||
const updatedRoute = updateQueryParams({
|
||||
paramsToAdd: { version: "current" },
|
||||
});
|
||||
router.push(updatedRoute);
|
||||
{
|
||||
key: "version-history",
|
||||
action: () => {
|
||||
// add query param, version=current to the route
|
||||
const updatedRoute = updateQueryParams({
|
||||
paramsToAdd: { version: "current" },
|
||||
});
|
||||
router.push(updatedRoute);
|
||||
},
|
||||
title: "Version history",
|
||||
icon: History,
|
||||
shouldRender: true,
|
||||
},
|
||||
label: "Version history",
|
||||
icon: History,
|
||||
shouldRender: true,
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
action: () => setIsExportModalOpen(true),
|
||||
label: "Export",
|
||||
icon: ArrowUpToLine,
|
||||
shouldRender: true,
|
||||
},
|
||||
];
|
||||
{
|
||||
key: "export",
|
||||
action: () => setIsExportModalOpen(true),
|
||||
title: "Export",
|
||||
icon: ArrowUpToLine,
|
||||
shouldRender: true,
|
||||
},
|
||||
],
|
||||
[editorRef, handleFullWidth, isFullWidth, router, updateQueryParams]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -164,24 +97,23 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||
onClose={() => setIsExportModalOpen(false)}
|
||||
pageTitle={name ?? ""}
|
||||
/>
|
||||
<CustomMenu maxHeight="lg" placement="bottom-start" verticalEllipsis closeOnSelect>
|
||||
<CustomMenu.MenuItem
|
||||
className="hidden md:flex w-full items-center justify-between gap-2"
|
||||
onClick={() => handleFullWidth(!isFullWidth)}
|
||||
>
|
||||
Full width
|
||||
<ToggleSwitch value={isFullWidth} onChange={() => {}} />
|
||||
</CustomMenu.MenuItem>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
if (!item.shouldRender) return null;
|
||||
return (
|
||||
<CustomMenu.MenuItem key={item.key} onClick={item.action} className="flex items-center gap-2">
|
||||
<item.icon className="h-3 w-3" />
|
||||
{item.label}
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
<PageActions
|
||||
editorRef={editorRef}
|
||||
extraOptions={EXTRA_MENU_OPTIONS}
|
||||
optionsOrder={[
|
||||
"full-screen",
|
||||
"copy-markdown",
|
||||
"copy-link",
|
||||
"toggle-lock",
|
||||
"toggle-access",
|
||||
"make-a-copy",
|
||||
"archive-restore",
|
||||
"delete",
|
||||
"version-history",
|
||||
"export",
|
||||
]}
|
||||
page={page}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,20 +13,21 @@ import { TPageInstance } from "@/store/pages/base-page";
|
||||
type Props = {
|
||||
editorReady: boolean;
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
handleDuplicatePage: () => void;
|
||||
page: TPageInstance;
|
||||
setSidePeekVisible: (sidePeekState: boolean) => void;
|
||||
sidePeekVisible: boolean;
|
||||
};
|
||||
|
||||
export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
const { editorReady, editorRef, setSidePeekVisible, sidePeekVisible, handleDuplicatePage, page } = props;
|
||||
const { editorReady, editorRef, page, setSidePeekVisible, sidePeekVisible } = props;
|
||||
// derived values
|
||||
const { isContentEditable } = page;
|
||||
// page filters
|
||||
const { isFullWidth } = usePageFilters();
|
||||
// derived values
|
||||
const resolvedEditorRef = editorRef.current;
|
||||
|
||||
if (!editorRef.current) return null;
|
||||
if (!resolvedEditorRef) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -49,13 +50,11 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
|
||||
)}
|
||||
{editorReady && isContentEditable && editorRef.current && <PageToolbar editorRef={editorRef?.current} />}
|
||||
</Header.LeftItem>
|
||||
<PageExtraOptions editorRef={editorRef} handleDuplicatePage={handleDuplicatePage} page={page} />
|
||||
<PageExtraOptions editorRef={resolvedEditorRef} page={page} />
|
||||
</Header>
|
||||
<div className="md:hidden">
|
||||
<PageEditorMobileHeaderRoot
|
||||
editorRef={editorRef}
|
||||
editorReady={editorReady}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
editorRef={resolvedEditorRef}
|
||||
page={page}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
setSidePeekVisible={setSidePeekVisible}
|
||||
|
||||
@@ -5,8 +5,6 @@ import { useSearchParams } from "next/navigation";
|
||||
import { EditorRefApi } from "@plane/editor";
|
||||
// types
|
||||
import { TDocumentPayload, TPage, TPageVersion } from "@plane/types";
|
||||
// ui
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// components
|
||||
import {
|
||||
PageEditorHeaderRoot,
|
||||
@@ -55,7 +53,7 @@ export const PageRoot = observer((props: TPageRootProps) => {
|
||||
// search params
|
||||
const searchParams = useSearchParams();
|
||||
// derived values
|
||||
const { access, description_html, name, isContentEditable } = page;
|
||||
const { isContentEditable } = page;
|
||||
// page fallback
|
||||
usePageFallback({
|
||||
editorRef,
|
||||
@@ -66,25 +64,6 @@ export const PageRoot = observer((props: TPageRootProps) => {
|
||||
// update query params
|
||||
const { updateQueryParams } = useQueryParams();
|
||||
|
||||
const handleDuplicatePage = async () => {
|
||||
const formData: Partial<TPage> = {
|
||||
name: "Copy of " + name,
|
||||
description_html: editorRef.current?.getDocument().html ?? description_html ?? "<p></p>",
|
||||
access,
|
||||
};
|
||||
|
||||
await handlers
|
||||
.create(formData)
|
||||
.then((res) => router.push(handlers.getRedirectionLink(res?.id ?? "")))
|
||||
.catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Page could not be duplicated. Please try again later.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const version = searchParams.get("version");
|
||||
useEffect(() => {
|
||||
if (!version) {
|
||||
@@ -124,7 +103,6 @@ export const PageRoot = observer((props: TPageRootProps) => {
|
||||
<PageEditorHeaderRoot
|
||||
editorReady={editorReady}
|
||||
editorRef={editorRef}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
page={page}
|
||||
setSidePeekVisible={(state) => setSidePeekVisible(state)}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
|
||||
@@ -24,15 +24,19 @@ export const PageEditorTitle: React.FC<Props> = observer((props) => {
|
||||
// states
|
||||
const [isLengthVisible, setIsLengthVisible] = useState(false);
|
||||
// page filters
|
||||
const { fontSize } = usePageFilters();
|
||||
const { fontSize, isFullWidth } = usePageFilters();
|
||||
// ui
|
||||
const titleClassName = cn("bg-transparent tracking-[-2%] font-bold", {
|
||||
"text-[1.6rem] leading-[1.9rem]": fontSize === "small-font",
|
||||
"text-[2rem] leading-[2.375rem]": fontSize === "large-font",
|
||||
});
|
||||
const titleClassName = cn(
|
||||
"bg-transparent tracking-[-2%] font-bold block max-w-[720px] mx-auto transition-all duration-300",
|
||||
{
|
||||
"text-[1.6rem] leading-[1.9rem]": fontSize === "small-font",
|
||||
"text-[2rem] leading-[2.375rem]": fontSize === "large-font",
|
||||
"max-w-[1152px]": isFullWidth,
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative w-full flex-shrink-0 md:pl-5 px-4">
|
||||
<div className="relative w-full flex-shrink-0 page-title-container">
|
||||
{readOnly ? (
|
||||
<h6
|
||||
className={cn(
|
||||
|
||||
@@ -4,14 +4,15 @@ import React, { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Earth, Info, Lock, Minus } from "lucide-react";
|
||||
// ui
|
||||
import { Avatar, FavoriteStar, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
|
||||
import { Avatar, FavoriteStar, Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { PageQuickActions } from "@/components/pages/dropdowns";
|
||||
import { PageActions } from "@/components/pages";
|
||||
// helpers
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store";
|
||||
import { usePageOperations } from "@/hooks/use-page-operations";
|
||||
import { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
type Props = {
|
||||
@@ -23,6 +24,10 @@ export const BlockItemAction: FC<Props> = observer((props) => {
|
||||
const { page, parentRef } = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
// page operations
|
||||
const { pageOperations } = usePageOperations({
|
||||
page,
|
||||
});
|
||||
// derived values
|
||||
const {
|
||||
access,
|
||||
@@ -30,33 +35,9 @@ export const BlockItemAction: FC<Props> = observer((props) => {
|
||||
is_favorite,
|
||||
owned_by,
|
||||
canCurrentUserFavoritePage,
|
||||
addToFavorites,
|
||||
removePageFromFavorites,
|
||||
getRedirectionLink,
|
||||
} = page;
|
||||
const ownerDetails = owned_by ? getUserDetails(owned_by) : undefined;
|
||||
|
||||
// handlers
|
||||
const handleFavorites = () => {
|
||||
if (is_favorite) {
|
||||
removePageFromFavorites().then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page removed from favorites.",
|
||||
})
|
||||
);
|
||||
} else {
|
||||
addToFavorites().then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page added to favorites.",
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* page details */}
|
||||
@@ -86,14 +67,26 @@ export const BlockItemAction: FC<Props> = observer((props) => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleFavorites();
|
||||
pageOperations.toggleFavorite();
|
||||
}}
|
||||
selected={is_favorite}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* quick actions dropdown */}
|
||||
<PageQuickActions parentRef={parentRef} page={page} pageLink={getRedirectionLink()} />
|
||||
<PageActions
|
||||
optionsOrder={[
|
||||
"toggle-lock",
|
||||
"toggle-access",
|
||||
"open-in-new-tab",
|
||||
"copy-link",
|
||||
"make-a-copy",
|
||||
"archive-restore",
|
||||
"delete",
|
||||
]}
|
||||
page={page}
|
||||
parentRef={parentRef}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
// plane editor
|
||||
import { EditorReadOnlyRefApi, EditorRefApi, TDocumentEventsServer } from "@plane/editor";
|
||||
import { EditorRefApi, TDocumentEventsServer } from "@plane/editor";
|
||||
import { DocumentCollaborativeEvents, TDocumentEventsClient, getServerEventName } from "@plane/editor/lib";
|
||||
// plane ui
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
@@ -17,10 +16,13 @@ type CollaborativeActionEvent =
|
||||
| { type: "sendMessageToServer"; message: TDocumentEventsServer }
|
||||
| { type: "receivedMessageFromServer"; message: TDocumentEventsClient };
|
||||
|
||||
export const useCollaborativePageActions = (
|
||||
editorRef: EditorRefApi | EditorReadOnlyRefApi | null,
|
||||
page: TPageInstance
|
||||
) => {
|
||||
type Props = {
|
||||
editorRef?: EditorRefApi | null;
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
export const useCollaborativePageActions = (props: Props) => {
|
||||
const { editorRef, page } = props;
|
||||
// currentUserAction local state to track if the current action is being processed, a
|
||||
// local action is basically the action performed by the current user to avoid double operations
|
||||
const [currentActionBeingProcessed, setCurrentActionBeingProcessed] = useState<TDocumentEventsClient | null>(null);
|
||||
@@ -43,6 +45,14 @@ export const useCollaborativePageActions = (
|
||||
execute: (shouldSync) => page.restore(shouldSync),
|
||||
errorMessage: "Page could not be restored. Please try again later.",
|
||||
},
|
||||
[DocumentCollaborativeEvents["make-public"].client]: {
|
||||
execute: (shouldSync) => page.makePublic(shouldSync),
|
||||
errorMessage: "Page could not be made public. Please try again later.",
|
||||
},
|
||||
[DocumentCollaborativeEvents["make-private"].client]: {
|
||||
execute: (shouldSync) => page.makePrivate(shouldSync),
|
||||
errorMessage: "Page could not be made private. Please try again later.",
|
||||
},
|
||||
}),
|
||||
[page]
|
||||
);
|
||||
@@ -75,7 +85,6 @@ export const useCollaborativePageActions = (
|
||||
|
||||
useEffect(() => {
|
||||
const realTimeStatelessMessageListener = editorRef?.listenToRealTimeUpdate();
|
||||
|
||||
const handleStatelessMessage = (message: { payload: TDocumentEventsClient }) => {
|
||||
if (currentActionBeingProcessed === message.payload) {
|
||||
setCurrentActionBeingProcessed(null);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
// plane editor
|
||||
import { TEditorFontSize, TEditorFontStyle } from "@plane/editor";
|
||||
// hooks
|
||||
@@ -22,39 +23,61 @@ export const usePageFilters = () => {
|
||||
DEFAULT_PERSONALIZATION_VALUES
|
||||
);
|
||||
// stored values
|
||||
const isFullWidth = !!pagesConfig?.full_width;
|
||||
const fontSize = pagesConfig?.font_size ?? DEFAULT_PERSONALIZATION_VALUES.font_size;
|
||||
const fontStyle = pagesConfig?.font_style ?? DEFAULT_PERSONALIZATION_VALUES.font_style;
|
||||
const isFullWidth = useMemo(() => !!pagesConfig?.full_width, [pagesConfig?.full_width]);
|
||||
const fontSize = useMemo(
|
||||
() => pagesConfig?.font_size ?? DEFAULT_PERSONALIZATION_VALUES.font_size,
|
||||
[pagesConfig?.font_size]
|
||||
);
|
||||
const fontStyle = useMemo(
|
||||
() => pagesConfig?.font_style ?? DEFAULT_PERSONALIZATION_VALUES.font_style,
|
||||
[pagesConfig?.font_style]
|
||||
);
|
||||
// update action
|
||||
const handleUpdateConfig = (payload: Partial<TPagesPersonalizationConfig>) =>
|
||||
setPagesConfig({
|
||||
...(pagesConfig ?? DEFAULT_PERSONALIZATION_VALUES),
|
||||
...payload,
|
||||
});
|
||||
const handleUpdateConfig = useCallback(
|
||||
(payload: Partial<TPagesPersonalizationConfig>) => {
|
||||
setPagesConfig({
|
||||
...(pagesConfig ?? DEFAULT_PERSONALIZATION_VALUES),
|
||||
...payload,
|
||||
});
|
||||
},
|
||||
[pagesConfig, setPagesConfig]
|
||||
);
|
||||
/**
|
||||
* @description action to update full_width value
|
||||
* @param {boolean} value
|
||||
*/
|
||||
const handleFullWidth = (value: boolean) =>
|
||||
handleUpdateConfig({
|
||||
full_width: value,
|
||||
});
|
||||
const handleFullWidth = useCallback(
|
||||
(value: boolean) => {
|
||||
handleUpdateConfig({
|
||||
full_width: value,
|
||||
});
|
||||
},
|
||||
[handleUpdateConfig]
|
||||
);
|
||||
/**
|
||||
* @description action to update font_size value
|
||||
* @param {TEditorFontSize} value
|
||||
*/
|
||||
const handleFontSize = (value: TEditorFontSize) =>
|
||||
handleUpdateConfig({
|
||||
font_size: value,
|
||||
});
|
||||
const handleFontSize = useCallback(
|
||||
(value: TEditorFontSize) => {
|
||||
handleUpdateConfig({
|
||||
font_size: value,
|
||||
});
|
||||
},
|
||||
[handleUpdateConfig]
|
||||
);
|
||||
/**
|
||||
* @description action to update font_size value
|
||||
* @param {TEditorFontSize} value
|
||||
*/
|
||||
const handleFontStyle = (value: TEditorFontStyle) =>
|
||||
handleUpdateConfig({
|
||||
font_style: value,
|
||||
});
|
||||
const handleFontStyle = useCallback(
|
||||
(value: TEditorFontStyle) => {
|
||||
handleUpdateConfig({
|
||||
font_style: value,
|
||||
});
|
||||
},
|
||||
[handleUpdateConfig]
|
||||
);
|
||||
|
||||
return {
|
||||
fontSize,
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useMemo } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane editor
|
||||
import { EditorRefApi } from "@plane/editor";
|
||||
// plane types
|
||||
import { EPageAccess } from "@plane/types/src/enums";
|
||||
// plane ui
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// helpers
|
||||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useCollaborativePageActions } from "@/hooks/use-collaborative-page-actions";
|
||||
// store types
|
||||
import { TPageInstance } from "@/store/pages/base-page";
|
||||
|
||||
export type TPageOperations = {
|
||||
toggleLock: () => void;
|
||||
toggleAccess: () => void;
|
||||
toggleFavorite: () => void;
|
||||
openInNewTab: () => void;
|
||||
copyLink: () => void;
|
||||
duplicate: () => void;
|
||||
toggleArchive: () => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
editorRef?: EditorRefApi | null;
|
||||
page: TPageInstance;
|
||||
};
|
||||
|
||||
export const usePageOperations = (
|
||||
props: Props
|
||||
): {
|
||||
pageOperations: TPageOperations;
|
||||
} => {
|
||||
const { page } = props;
|
||||
// params
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// derived values
|
||||
const { access, addToFavorites, archived_at, duplicate, id, is_favorite, is_locked, removePageFromFavorites } = page;
|
||||
// collaborative actions
|
||||
const { executeCollaborativeAction } = useCollaborativePageActions(props);
|
||||
// page operations
|
||||
const pageOperations: TPageOperations = useMemo(() => {
|
||||
const pageLink = projectId ? `${workspaceSlug}/projects/${projectId}/pages/${id}` : `${workspaceSlug}/pages/${id}`;
|
||||
|
||||
return {
|
||||
copyLink: () => {
|
||||
copyUrlToClipboard(pageLink).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Link Copied!",
|
||||
message: "Page link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
},
|
||||
duplicate: async () => {
|
||||
try {
|
||||
await duplicate();
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page duplicated successfully.",
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Page could not be duplicated. Please try again later.",
|
||||
});
|
||||
}
|
||||
},
|
||||
move: async () => {},
|
||||
openInNewTab: () => window.open(`/${pageLink}`, "_blank"),
|
||||
toggleAccess: async () => {
|
||||
const changedPageType = access === EPageAccess.PUBLIC ? "private" : "public";
|
||||
try {
|
||||
if (access === EPageAccess.PUBLIC)
|
||||
await executeCollaborativeAction({ type: "sendMessageToServer", message: "make-private" });
|
||||
else await executeCollaborativeAction({ type: "sendMessageToServer", message: "make-public" });
|
||||
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: `The page has been marked ${changedPageType} and moved to the ${changedPageType} section.`,
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: `The page couldn't be marked ${changedPageType}. Please try again.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
toggleArchive: async () => {
|
||||
if (archived_at) {
|
||||
try {
|
||||
await executeCollaborativeAction({ type: "sendMessageToServer", message: "unarchive" });
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page restored successfully.",
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Page could not be restored. Please try again later.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await executeCollaborativeAction({ type: "sendMessageToServer", message: "archive" });
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page archived successfully.",
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Page could not be archived. Please try again later.",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
toggleFavorite: () => {
|
||||
if (is_favorite) {
|
||||
removePageFromFavorites().then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page removed from favorites.",
|
||||
})
|
||||
);
|
||||
} else {
|
||||
addToFavorites().then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page added to favorites.",
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
toggleLock: async () => {
|
||||
if (is_locked) {
|
||||
try {
|
||||
await executeCollaborativeAction({ type: "sendMessageToServer", message: "unlock" });
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page unlocked successfully.",
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Page could not be unlocked. Please try again later.",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await executeCollaborativeAction({ type: "sendMessageToServer", message: "lock" });
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page locked successfully.",
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Page could not be locked. Please try again later.",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}, [
|
||||
access,
|
||||
addToFavorites,
|
||||
archived_at,
|
||||
duplicate,
|
||||
executeCollaborativeAction,
|
||||
id,
|
||||
is_favorite,
|
||||
is_locked,
|
||||
projectId,
|
||||
removePageFromFavorites,
|
||||
workspaceSlug,
|
||||
]);
|
||||
return {
|
||||
pageOperations,
|
||||
};
|
||||
};
|
||||
@@ -163,4 +163,12 @@ export class ProjectPageService extends APIService {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
async duplicate(workspaceSlug: string, projectId: string, pageId: string): Promise<TPage> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/duplicate/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ export type TBasePage = TPage & {
|
||||
update: (pageData: Partial<TPage>) => Promise<Partial<TPage> | undefined>;
|
||||
updateTitle: (title: string) => void;
|
||||
updateDescription: (document: TDocumentPayload) => Promise<void>;
|
||||
makePublic: () => Promise<void>;
|
||||
makePrivate: () => Promise<void>;
|
||||
makePublic: (shouldSync?: boolean) => Promise<void>;
|
||||
makePrivate: (shouldSync?: boolean) => Promise<void>;
|
||||
lock: (shouldSync?: boolean) => Promise<void>;
|
||||
unlock: (shouldSync?: boolean) => Promise<void>;
|
||||
archive: (shouldSync?: boolean) => Promise<void>;
|
||||
@@ -30,9 +30,11 @@ export type TBasePage = TPage & {
|
||||
updatePageLogo: (logo_props: TLogoProps) => Promise<void>;
|
||||
addToFavorites: () => Promise<void>;
|
||||
removePageFromFavorites: () => Promise<void>;
|
||||
duplicate: () => Promise<TPage | undefined>;
|
||||
};
|
||||
|
||||
export type TBasePagePermissions = {
|
||||
canCurrentUserAccessPage: boolean;
|
||||
canCurrentUserEditPage: boolean;
|
||||
canCurrentUserDuplicatePage: boolean;
|
||||
canCurrentUserLockPage: boolean;
|
||||
@@ -40,6 +42,7 @@ export type TBasePagePermissions = {
|
||||
canCurrentUserArchivePage: boolean;
|
||||
canCurrentUserDeletePage: boolean;
|
||||
canCurrentUserFavoritePage: boolean;
|
||||
canCurrentUserMovePage: boolean;
|
||||
isContentEditable: boolean;
|
||||
};
|
||||
|
||||
@@ -53,6 +56,7 @@ export type TBasePageServices = {
|
||||
archived_at: string;
|
||||
}>;
|
||||
restore: () => Promise<void>;
|
||||
duplicate: () => Promise<TPage>;
|
||||
};
|
||||
|
||||
export type TPageInstance = TBasePage &
|
||||
@@ -161,6 +165,7 @@ export class BasePage implements TBasePage {
|
||||
updatePageLogo: action,
|
||||
addToFavorites: action,
|
||||
removePageFromFavorites: action,
|
||||
duplicate: action,
|
||||
});
|
||||
|
||||
this.rootStore = store;
|
||||
@@ -295,38 +300,46 @@ export class BasePage implements TBasePage {
|
||||
/**
|
||||
* @description make the page public
|
||||
*/
|
||||
makePublic = async () => {
|
||||
makePublic = async (shouldSync: boolean = true) => {
|
||||
const pageAccess = this.access;
|
||||
runInAction(() => (this.access = EPageAccess.PUBLIC));
|
||||
runInAction(() => {
|
||||
this.access = EPageAccess.PUBLIC;
|
||||
});
|
||||
|
||||
try {
|
||||
await this.services.updateAccess({
|
||||
access: EPageAccess.PUBLIC,
|
||||
});
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.access = pageAccess;
|
||||
});
|
||||
throw error;
|
||||
if (shouldSync) {
|
||||
try {
|
||||
await this.services.updateAccess({
|
||||
access: EPageAccess.PUBLIC,
|
||||
});
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.access = pageAccess;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description make the page private
|
||||
*/
|
||||
makePrivate = async () => {
|
||||
makePrivate = async (shouldSync: boolean = true) => {
|
||||
const pageAccess = this.access;
|
||||
runInAction(() => (this.access = EPageAccess.PRIVATE));
|
||||
runInAction(() => {
|
||||
this.access = EPageAccess.PRIVATE;
|
||||
});
|
||||
|
||||
try {
|
||||
await this.services.updateAccess({
|
||||
access: EPageAccess.PRIVATE,
|
||||
});
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.access = pageAccess;
|
||||
});
|
||||
throw error;
|
||||
if (shouldSync) {
|
||||
try {
|
||||
await this.services.updateAccess({
|
||||
access: EPageAccess.PRIVATE,
|
||||
});
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.access = pageAccess;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -468,4 +481,9 @@ export class BasePage implements TBasePage {
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description duplicate the page
|
||||
*/
|
||||
duplicate = async () => await this.services.duplicate();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ type TLoader = "init-loader" | "mutation-loader" | undefined;
|
||||
|
||||
type TError = { title: string; description: string };
|
||||
|
||||
export const ROLE_PERMISSIONS_TO_CREATE_PAGE = [EUserPermissions.ADMIN, EUserPermissions.MEMBER];
|
||||
|
||||
export interface IProjectPageStore {
|
||||
// observables
|
||||
loader: TLoader;
|
||||
@@ -44,6 +46,7 @@ export interface IProjectPageStore {
|
||||
getPageById: (workspaceSlug: string, projectId: string, pageId: string) => Promise<TPage | undefined>;
|
||||
createPage: (pageData: Partial<TPage>) => Promise<TPage | undefined>;
|
||||
removePage: (pageId: string) => Promise<void>;
|
||||
movePage: (workspaceSlug: string, projectId: string, pageId: string, newProjectId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ProjectPageStore implements IProjectPageStore {
|
||||
@@ -78,6 +81,7 @@ export class ProjectPageStore implements IProjectPageStore {
|
||||
getPageById: action,
|
||||
createPage: action,
|
||||
removePage: action,
|
||||
movePage: action,
|
||||
});
|
||||
this.rootStore = store;
|
||||
// service
|
||||
@@ -109,7 +113,7 @@ export class ProjectPageStore implements IProjectPageStore {
|
||||
workspaceSlug?.toString() || "",
|
||||
projectId?.toString() || ""
|
||||
);
|
||||
return !!currentUserProjectRole && currentUserProjectRole >= EUserPermissions.MEMBER;
|
||||
return !!currentUserProjectRole && ROLE_PERMISSIONS_TO_CREATE_PAGE.includes(currentUserProjectRole);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,4 +298,13 @@ export class ProjectPageStore implements IProjectPageStore {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description move a page to a new project
|
||||
* @param {string} workspaceSlug
|
||||
* @param {string} projectId
|
||||
* @param {string} pageId
|
||||
* @param {string} newProjectId
|
||||
*/
|
||||
movePage = async (workspaceSlug: string, projectId: string, pageId: string, newProjectId: string) => {};
|
||||
}
|
||||
|
||||
@@ -50,9 +50,14 @@ export class ProjectPage extends BasePage implements TProjectPage {
|
||||
if (!workspaceSlug || !projectId || !page.id) throw new Error("Missing required fields.");
|
||||
await projectPageService.restore(workspaceSlug, projectId, page.id);
|
||||
},
|
||||
duplicate: async () => {
|
||||
if (!workspaceSlug || !projectId || !page.id) throw new Error("Missing required fields.");
|
||||
return await projectPageService.duplicate(workspaceSlug, projectId, page.id);
|
||||
},
|
||||
});
|
||||
makeObservable(this, {
|
||||
// computed
|
||||
canCurrentUserAccessPage: computed,
|
||||
canCurrentUserEditPage: computed,
|
||||
canCurrentUserDuplicatePage: computed,
|
||||
canCurrentUserLockPage: computed,
|
||||
@@ -60,6 +65,7 @@ export class ProjectPage extends BasePage implements TProjectPage {
|
||||
canCurrentUserArchivePage: computed,
|
||||
canCurrentUserDeletePage: computed,
|
||||
canCurrentUserFavoritePage: computed,
|
||||
canCurrentUserMovePage: computed,
|
||||
isContentEditable: computed,
|
||||
});
|
||||
}
|
||||
@@ -81,6 +87,14 @@ export class ProjectPage extends BasePage implements TProjectPage {
|
||||
return highestRole;
|
||||
});
|
||||
|
||||
/**
|
||||
* @description returns true if the current logged in user can access the page
|
||||
*/
|
||||
get canCurrentUserAccessPage() {
|
||||
const isPagePublic = this.access === EPageAccess.PUBLIC;
|
||||
return isPagePublic || this.isCurrentUserOwner;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns true if the current logged in user can edit the page
|
||||
*/
|
||||
@@ -141,6 +155,14 @@ export class ProjectPage extends BasePage implements TProjectPage {
|
||||
return !!highestRole && highestRole >= EUserPermissions.MEMBER;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns true if the current logged in user can move the page
|
||||
*/
|
||||
get canCurrentUserMovePage() {
|
||||
const highestRole = this.getHighestRoleAcrossProjects();
|
||||
return this.isCurrentUserOwner || highestRole === EUserPermissions.ADMIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description returns true if the page can be edited
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user