forked from github/plane
Merge branch 'chore/file_asset_update' of github.com:makeplane/plane into develop-deploy
This commit is contained in:
commit
ac08339cf8
@ -1,7 +1,7 @@
|
||||
# Third party imports
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
@ -10,7 +10,7 @@ from plane.app.serializers import FileAssetSerializer
|
||||
|
||||
|
||||
class FileAssetEndpoint(BaseAPIView):
|
||||
parser_classes = (MultiPartParser, FormParser)
|
||||
parser_classes = (MultiPartParser, FormParser, JSONParser,)
|
||||
|
||||
"""
|
||||
A viewset for viewing and editing task instances.
|
||||
@ -25,7 +25,6 @@ class FileAssetEndpoint(BaseAPIView):
|
||||
else:
|
||||
return Response({"error": "Asset key does not exist", "status": False}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
def post(self, request, slug):
|
||||
serializer = FileAssetSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
@ -34,12 +33,11 @@ class FileAssetEndpoint(BaseAPIView):
|
||||
serializer.save(workspace_id=workspace.id)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
def delete(self, request, workspace_id, asset_key):
|
||||
|
||||
def patch(self, request, workspace_id, asset_key):
|
||||
asset_key = str(workspace_id) + "/" + asset_key
|
||||
file_asset = FileAsset.objects.get(asset=asset_key)
|
||||
file_asset.is_deleted = True
|
||||
file_asset.is_deleted = request.data.get("is_deleted", file_asset.is_deleted)
|
||||
file_asset.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
@ -136,7 +136,9 @@ class PageViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
def lock(self, request, slug, project_id, page_id):
|
||||
page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id)
|
||||
page = Page.objects.filter(
|
||||
pk=page_id, workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
|
||||
# only the owner can lock the page
|
||||
if request.user.id != page.owned_by_id:
|
||||
@ -149,7 +151,9 @@ class PageViewSet(BaseViewSet):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def unlock(self, request, slug, project_id, page_id):
|
||||
page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id)
|
||||
page = Page.objects.filter(
|
||||
pk=page_id, workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
|
||||
# only the owner can unlock the page
|
||||
if request.user.id != page.owned_by_id:
|
||||
@ -164,66 +168,8 @@ class PageViewSet(BaseViewSet):
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
queryset = self.get_queryset().filter(archived_at__isnull=True)
|
||||
page_view = request.GET.get("page_view", False)
|
||||
|
||||
if not page_view:
|
||||
return Response(
|
||||
{"error": "Page View parameter is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# All Pages
|
||||
if page_view == "all":
|
||||
return Response(
|
||||
PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
# Recent pages
|
||||
if page_view == "recent":
|
||||
current_time = date.today()
|
||||
day_before = current_time - timedelta(days=1)
|
||||
todays_pages = queryset.filter(updated_at__date=date.today())
|
||||
yesterdays_pages = queryset.filter(updated_at__date=day_before)
|
||||
earlier_this_week = queryset.filter(
|
||||
updated_at__date__range=(
|
||||
(timezone.now() - timedelta(days=7)),
|
||||
(timezone.now() - timedelta(days=2)),
|
||||
)
|
||||
)
|
||||
return Response(
|
||||
{
|
||||
"today": PageSerializer(todays_pages, many=True).data,
|
||||
"yesterday": PageSerializer(yesterdays_pages, many=True).data,
|
||||
"earlier_this_week": PageSerializer(
|
||||
earlier_this_week, many=True
|
||||
).data,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
# Favorite Pages
|
||||
if page_view == "favorite":
|
||||
queryset = queryset.filter(is_favorite=True)
|
||||
return Response(
|
||||
PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
# My pages
|
||||
if page_view == "created_by_me":
|
||||
queryset = queryset.filter(owned_by=request.user)
|
||||
return Response(
|
||||
PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
# Created by other Pages
|
||||
if page_view == "created_by_other":
|
||||
queryset = queryset.filter(~Q(owned_by=request.user), access=0)
|
||||
return Response(
|
||||
PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
return Response(
|
||||
{"error": "No matching view found"}, status=status.HTTP_400_BAD_REQUEST
|
||||
PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
def archive(self, request, slug, project_id, page_id):
|
||||
@ -247,29 +193,44 @@ class PageViewSet(BaseViewSet):
|
||||
{"error": "Only the owner of the page can unarchive a page"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
# if parent page is archived then the page will be un archived breaking the hierarchy
|
||||
if page.parent_id and page.parent.archived_at:
|
||||
page.parent = None
|
||||
page.save(update_fields=['parent'])
|
||||
page.save(update_fields=["parent"])
|
||||
|
||||
unarchive_archive_page_and_descendants(page_id, None)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def archive_list(self, request, slug, project_id):
|
||||
pages = (
|
||||
Page.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.filter(archived_at__isnull=False)
|
||||
)
|
||||
pages = Page.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
).filter(archived_at__isnull=False)
|
||||
|
||||
return Response(
|
||||
PageSerializer(pages, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
page = Page.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
|
||||
|
||||
if page.archived_at is None:
|
||||
return Response(
|
||||
{"error": "The page should be archived before deleting"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# remove parent from all the children
|
||||
_ = Page.objects.filter(
|
||||
parent_id=pk, project_id=project_id, workspace__slug=slug
|
||||
).update(parent=None)
|
||||
|
||||
|
||||
page.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class PageFavoriteViewSet(BaseViewSet):
|
||||
permission_classes = [
|
||||
@ -306,6 +267,7 @@ class PageFavoriteViewSet(BaseViewSet):
|
||||
page_favorite.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class PageLogEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
ProjectEntityPermission,
|
||||
@ -397,4 +359,4 @@ class SubPagesEndpoint(BaseAPIView):
|
||||
)
|
||||
return Response(
|
||||
SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK
|
||||
)
|
||||
)
|
||||
|
@ -975,7 +975,7 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
)
|
||||
params = {
|
||||
"Bucket": settings.AWS_S3_BUCKET_NAME,
|
||||
"Bucket": settings.AWS_STORAGE_BUCKET_NAME,
|
||||
"Prefix": "static/project-cover/",
|
||||
}
|
||||
|
||||
@ -987,7 +987,7 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
|
||||
"/"
|
||||
): # This line ensures we're only getting files, not "sub-folders"
|
||||
files.append(
|
||||
f"https://{settings.AWS_S3_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}"
|
||||
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}"
|
||||
)
|
||||
|
||||
return Response(files, status=status.HTTP_200_OK)
|
||||
|
@ -81,13 +81,13 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
|
||||
)
|
||||
s3.upload_fileobj(
|
||||
zip_file,
|
||||
settings.AWS_S3_BUCKET_NAME,
|
||||
settings.AWS_STORAGE_BUCKET_NAME,
|
||||
file_name,
|
||||
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
|
||||
)
|
||||
presigned_url = s3.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={"Bucket": settings.AWS_S3_BUCKET_NAME, "Key": file_name},
|
||||
Params={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": file_name},
|
||||
ExpiresIn=expires_in,
|
||||
)
|
||||
# Create the new url with updated domain and protocol
|
||||
@ -105,14 +105,14 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
|
||||
)
|
||||
s3.upload_fileobj(
|
||||
zip_file,
|
||||
settings.AWS_S3_BUCKET_NAME,
|
||||
settings.AWS_STORAGE_BUCKET_NAME,
|
||||
file_name,
|
||||
ExtraArgs={"ACL": "public-read", "ContentType": "application/zip"},
|
||||
)
|
||||
|
||||
presigned_url = s3.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={"Bucket": settings.AWS_S3_BUCKET_NAME, "Key": file_name},
|
||||
Params={"Bucket": settings.AWS_STORAGE_BUCKET_NAME, "Key": file_name},
|
||||
ExpiresIn=expires_in,
|
||||
)
|
||||
|
||||
|
@ -42,8 +42,8 @@ def delete_old_s3_link():
|
||||
# Delete object from S3
|
||||
if file_name:
|
||||
if settings.USE_MINIO:
|
||||
s3.delete_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key=file_name)
|
||||
s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name)
|
||||
else:
|
||||
s3.delete_object(Bucket=settings.AWS_S3_BUCKET_NAME, Key=file_name)
|
||||
s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_name)
|
||||
|
||||
ExporterHistory.objects.filter(id=exporter_id).update(url=None)
|
||||
|
@ -12,7 +12,7 @@ from celery import shared_task
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Issue, Project, State, Page
|
||||
from plane.db.models import Issue, Project, State
|
||||
from plane.bgtasks.issue_activites_task import issue_activity
|
||||
|
||||
|
||||
@ -20,7 +20,6 @@ from plane.bgtasks.issue_activites_task import issue_activity
|
||||
def archive_and_close_old_issues():
|
||||
archive_old_issues()
|
||||
close_old_issues()
|
||||
delete_archived_pages()
|
||||
|
||||
|
||||
def archive_old_issues():
|
||||
@ -166,21 +165,4 @@ def close_old_issues():
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
|
||||
|
||||
def delete_archived_pages():
|
||||
try:
|
||||
pages_to_delete = Page.objects.filter(
|
||||
archived_at__isnull=False,
|
||||
archived_at__lte=(timezone.now() - timedelta(days=30)),
|
||||
)
|
||||
|
||||
pages_to_delete._raw_delete(pages_to_delete.db)
|
||||
return
|
||||
except Exception as e:
|
||||
if settings.DEBUG:
|
||||
print(e)
|
||||
capture_exception(e)
|
||||
return
|
||||
|
||||
return
|
@ -40,7 +40,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
# Create an S3 client using the session
|
||||
s3_client = session.client('s3', endpoint_url=settings.AWS_S3_ENDPOINT_URL)
|
||||
bucket_name = settings.AWS_S3_BUCKET_NAME
|
||||
bucket_name = settings.AWS_STORAGE_BUCKET_NAME
|
||||
|
||||
self.stdout.write(self.style.NOTICE("Checking bucket..."))
|
||||
|
||||
@ -50,7 +50,7 @@ class Command(BaseCommand):
|
||||
self.set_bucket_public_policy(s3_client, bucket_name)
|
||||
except ClientError as e:
|
||||
error_code = int(e.response['Error']['Code'])
|
||||
bucket_name = settings.AWS_S3_BUCKET_NAME
|
||||
bucket_name = settings.AWS_STORAGE_BUCKET_NAME
|
||||
if error_code == 404:
|
||||
# Bucket does not exist, create it
|
||||
self.stdout.write(self.style.WARNING(f"Bucket '{bucket_name}' does not exist. Creating bucket..."))
|
||||
|
@ -133,6 +133,7 @@ class Issue(ProjectBaseModel):
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
if self._state.adding:
|
||||
# Get the maximum display_id value from the database
|
||||
last_id = IssueSequence.objects.filter(project=self.project).aggregate(
|
||||
|
@ -61,7 +61,7 @@ class Command(BaseCommand):
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{license_engine_base_url}/api/instances",
|
||||
f"{license_engine_base_url}/api/instances/",
|
||||
headers=headers,
|
||||
data=json.dumps(payload),
|
||||
)
|
||||
|
@ -224,7 +224,7 @@ STORAGES["default"] = {
|
||||
}
|
||||
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
|
||||
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
|
||||
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
|
||||
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
|
||||
AWS_REGION = os.environ.get("AWS_REGION", "")
|
||||
AWS_DEFAULT_ACL = "public-read"
|
||||
AWS_QUERYSTRING_AUTH = False
|
||||
@ -234,7 +234,7 @@ AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", None) or os.environ.
|
||||
)
|
||||
if AWS_S3_ENDPOINT_URL:
|
||||
parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost"))
|
||||
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_S3_BUCKET_NAME}"
|
||||
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}"
|
||||
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
|
||||
|
||||
|
||||
|
@ -18,6 +18,7 @@ interface CustomEditorProps {
|
||||
value: string;
|
||||
deleteFile: DeleteImage;
|
||||
debouncedUpdatesEnabled?: boolean;
|
||||
onStart?: (json: any, html: string) => void;
|
||||
onChange?: (json: any, html: string) => void;
|
||||
extensions?: any;
|
||||
editorProps?: EditorProps;
|
||||
@ -34,6 +35,7 @@ export const useEditor = ({
|
||||
editorProps = {},
|
||||
value,
|
||||
extensions = [],
|
||||
onStart,
|
||||
onChange,
|
||||
setIsSubmitting,
|
||||
forwardedRef,
|
||||
@ -60,6 +62,9 @@ export const useEditor = ({
|
||||
],
|
||||
content:
|
||||
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
|
||||
onCreate: async ({ editor }) => {
|
||||
onStart?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()))
|
||||
},
|
||||
onUpdate: async ({ editor }) => {
|
||||
// for instant feedback loop
|
||||
setIsSubmitting?.("submitting");
|
||||
|
1
packages/editor/document-editor/Readme.md
Normal file
1
packages/editor/document-editor/Readme.md
Normal file
@ -0,0 +1 @@
|
||||
# Document Editor
|
73
packages/editor/document-editor/package.json
Normal file
73
packages/editor/document-editor/package.json
Normal file
@ -0,0 +1,73 @@
|
||||
{
|
||||
"name": "@plane/document-editor",
|
||||
"version": "0.0.1",
|
||||
"description": "Package that powers Plane's Pages Editor",
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.mts",
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": "12.3.2",
|
||||
"next-themes": "^0.2.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.17",
|
||||
"@plane/ui": "*",
|
||||
"@plane/editor-core": "*",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@tiptap/core": "^2.1.7",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.1.11",
|
||||
"@tiptap/extension-horizontal-rule": "^2.1.11",
|
||||
"@tiptap/extension-list-item": "^2.1.11",
|
||||
"@tiptap/extension-placeholder": "^2.1.11",
|
||||
"@tiptap/suggestion": "^2.1.7",
|
||||
"@types/node": "18.15.3",
|
||||
"@types/react": "^18.2.5",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^1.2.1",
|
||||
"eslint": "8.36.0",
|
||||
"eslint-config-next": "13.2.4",
|
||||
"eventsource-parser": "^0.1.0",
|
||||
"highlight.js": "^11.8.0",
|
||||
"lowlight": "^3.0.0",
|
||||
"lucide-react": "^0.244.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-popper": "^2.3.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.2",
|
||||
"use-debounce": "^9.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^7.32.0",
|
||||
"postcss": "^8.4.29",
|
||||
"tailwind-config-custom": "*",
|
||||
"tsconfig": "*",
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "4.9.5"
|
||||
},
|
||||
"keywords": [
|
||||
"editor",
|
||||
"rich-text",
|
||||
"markdown",
|
||||
"nextjs",
|
||||
"react"
|
||||
]
|
||||
}
|
9
packages/editor/document-editor/postcss.config.js
Normal file
9
packages/editor/document-editor/postcss.config.js
Normal file
@ -0,0 +1,9 @@
|
||||
// If you want to use other PostCSS plugins, see the following:
|
||||
// https://tailwindcss.com/docs/using-with-preprocessors
|
||||
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
3
packages/editor/document-editor/src/index.ts
Normal file
3
packages/editor/document-editor/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { DocumentEditor, DocumentEditorWithRef } from "./ui"
|
||||
export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef } from "./ui/readonly"
|
||||
export { FixedMenu } from "./ui/menu/fixed-menu"
|
@ -0,0 +1,19 @@
|
||||
import { Icon } from "lucide-react"
|
||||
|
||||
interface IAlertLabelProps {
|
||||
Icon: Icon,
|
||||
backgroundColor: string,
|
||||
textColor?: string,
|
||||
label: string,
|
||||
}
|
||||
|
||||
export const AlertLabel = ({ Icon, backgroundColor,textColor, label }: IAlertLabelProps) => {
|
||||
|
||||
return (
|
||||
<div className={`text-xs flex items-center gap-1 ${backgroundColor} p-0.5 pl-3 pr-3 mr-1 rounded`}>
|
||||
<Icon size={12} />
|
||||
<span className={`normal-case ${textColor}`}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
import { HeadingComp, SubheadingComp } from "./heading-component";
|
||||
import { IMarking } from "..";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { scrollSummary } from "../utils/editor-summary-utils";
|
||||
|
||||
interface ContentBrowserProps {
|
||||
editor: Editor;
|
||||
markings: IMarking[];
|
||||
}
|
||||
|
||||
export const ContentBrowser = ({
|
||||
editor,
|
||||
markings,
|
||||
}: ContentBrowserProps) => (
|
||||
<div className="mt-4 flex w-[250px] flex-col h-full">
|
||||
<h2 className="ml-4 border-b border-solid border-custom-border py-5 font-medium leading-[85.714%] tracking-tight max-md:ml-2.5">
|
||||
Table of Contents
|
||||
</h2>
|
||||
<div className="mt-3 h-0.5 w-full self-stretch border-custom-border" />
|
||||
{markings.length !== 0 ? (
|
||||
markings.map((marking) =>
|
||||
marking.level === 1 ? (
|
||||
<HeadingComp
|
||||
onClick={() => scrollSummary(editor, marking)}
|
||||
heading={marking.text}
|
||||
/>
|
||||
) : (
|
||||
<SubheadingComp
|
||||
onClick={() => scrollSummary(editor, marking)}
|
||||
subHeading={marking.text}
|
||||
/>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<p className="ml-3 mr-3 flex h-full items-center px-5 text-center text-xs text-gray-500">
|
||||
{"Headings will be displayed here for Navigation"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
@ -0,0 +1,79 @@
|
||||
import { Editor } from "@tiptap/react"
|
||||
import { Lock, ArchiveIcon, MenuSquare } from "lucide-react"
|
||||
import { useRef, useState } from "react"
|
||||
import { usePopper } from "react-popper"
|
||||
import { IMarking, UploadImage } from ".."
|
||||
import { FixedMenu } from "../menu"
|
||||
import { DocumentDetails } from "../types/editor-types"
|
||||
import { AlertLabel } from "./alert-label"
|
||||
import { ContentBrowser } from "./content-browser"
|
||||
import { IVerticalDropdownItemProps, VerticalDropdownMenu } from "./vertical-dropdown-menu"
|
||||
|
||||
interface IEditorHeader {
|
||||
editor: Editor,
|
||||
KanbanMenuOptions: IVerticalDropdownItemProps[],
|
||||
sidePeakVisible: boolean,
|
||||
setSidePeakVisible: (currentState: boolean) => void,
|
||||
markings: IMarking[],
|
||||
isLocked: boolean,
|
||||
isArchived: boolean,
|
||||
archivedAt?: Date,
|
||||
readonly: boolean,
|
||||
uploadFile?: UploadImage,
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
|
||||
documentDetails: DocumentDetails
|
||||
}
|
||||
|
||||
export const EditorHeader = ({ documentDetails, archivedAt, editor, sidePeakVisible, readonly, setSidePeakVisible, markings, uploadFile, setIsSubmitting, KanbanMenuOptions, isArchived, isLocked }: IEditorHeader) => {
|
||||
|
||||
const summaryMenuRef = useRef(null);
|
||||
const summaryButtonRef = useRef(null);
|
||||
const [summaryPopoverVisible, setSummaryPopoverVisible] = useState(false);
|
||||
|
||||
const { styles: summaryPopoverStyles, attributes: summaryPopoverAttributes } = usePopper(summaryButtonRef.current, summaryMenuRef.current, {
|
||||
placement: "bottom-start"
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
<div className="border-custom-border self-stretch flex flex-col border-b border-solid max-md:max-w-full">
|
||||
<div
|
||||
className="self-center flex ml-0 w-full items-start justify-between gap-5 max-md:max-w-full max-md:flex-wrap max-md:justify-center">
|
||||
<div className={"flex flex-row items-center"}>
|
||||
<div
|
||||
onMouseEnter={() => setSummaryPopoverVisible(true)}
|
||||
onMouseLeave={() => setSummaryPopoverVisible(false)}
|
||||
>
|
||||
<button
|
||||
ref={summaryButtonRef}
|
||||
className={"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors"}
|
||||
onClick={() => {
|
||||
setSidePeakVisible(!sidePeakVisible)
|
||||
setSummaryPopoverVisible(false)
|
||||
}}
|
||||
>
|
||||
<MenuSquare
|
||||
size={20}
|
||||
/>
|
||||
</button>
|
||||
{summaryPopoverVisible &&
|
||||
<div style={summaryPopoverStyles.popper} {...summaryPopoverAttributes.popper} className="z-10 h-[300px] w-[300px] ml-[40px] mt-[40px] shadow-xl rounded border-custom-border border-solid border-2 bg-custom-background-100 border-b pl-3 pr-3 pb-3 overflow-scroll">
|
||||
<ContentBrowser editor={editor} markings={markings} />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
{isLocked && <AlertLabel Icon={Lock} backgroundColor={"bg-red-200"} label={"Locked"} />}
|
||||
{(isArchived && archivedAt) && <AlertLabel Icon={ArchiveIcon} backgroundColor={"bg-blue-200"} label={`Archived at ${new Date(archivedAt).toLocaleString()}`} />}
|
||||
</div>
|
||||
|
||||
{(!readonly && uploadFile) && <FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} />}
|
||||
<div className="self-center flex items-start gap-3 my-auto max-md:justify-center"
|
||||
>
|
||||
{!isArchived && <p className="text-sm text-custom-text-300">{`Last updated at ${new Date(documentDetails.last_updated_at).toLocaleString()}`}</p>}
|
||||
<VerticalDropdownMenu items={KanbanMenuOptions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
export const HeadingComp = ({
|
||||
heading,
|
||||
onClick,
|
||||
}: {
|
||||
heading: string;
|
||||
onClick: (event: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => void;
|
||||
}) => (
|
||||
<h3
|
||||
onClick={onClick}
|
||||
className="ml-4 mt-3 cursor-pointer text-sm font-bold font-medium leading-[125%] tracking-tight hover:text-custom-primary max-md:ml-2.5"
|
||||
>
|
||||
{heading}
|
||||
</h3>
|
||||
);
|
||||
|
||||
export const SubheadingComp = ({
|
||||
subHeading,
|
||||
onClick,
|
||||
}: {
|
||||
subHeading: string;
|
||||
onClick: (event: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => void;
|
||||
}) => (
|
||||
<p
|
||||
onClick={onClick}
|
||||
className="ml-6 mt-2 text-xs cursor-pointer font-medium tracking-tight text-gray-400 hover:text-custom-primary"
|
||||
>
|
||||
{subHeading}
|
||||
</p>
|
||||
);
|
@ -0,0 +1,33 @@
|
||||
import { EditorContainer, EditorContentWrapper } from "@plane/editor-core"
|
||||
import { Editor } from "@tiptap/react"
|
||||
import { DocumentDetails } from "../types/editor-types"
|
||||
|
||||
interface IPageRenderer {
|
||||
sidePeakVisible: boolean,
|
||||
documentDetails: DocumentDetails ,
|
||||
editor: Editor,
|
||||
editorClassNames: string,
|
||||
editorContentCustomClassNames?: string
|
||||
}
|
||||
|
||||
export const PageRenderer = ({ sidePeakVisible, documentDetails, editor, editorClassNames, editorContentCustomClassNames }: IPageRenderer) => {
|
||||
return (
|
||||
<div className={`flex h-[88vh] flex-col w-full max-md:w-full max-md:ml-0 transition-all duration-200 ease-in-out ${sidePeakVisible ? 'ml-[3%] ' : 'ml-0'}`}>
|
||||
<div className="items-start mt-4 h-full flex flex-col w-fit max-md:max-w-full overflow-auto">
|
||||
<div className="flex flex-col py-2 max-md:max-w-full">
|
||||
<h1
|
||||
className="border-none outline-none bg-transparent text-4xl font-bold leading-8 tracking-tight self-center w-[700px] max-w-full"
|
||||
>{documentDetails.title}</h1>
|
||||
</div>
|
||||
<div className="border-custom-border border-b border-solid self-stretch w-full h-0.5 mt-3" />
|
||||
<div className="flex flex-col max-md:max-w-full">
|
||||
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
||||
<div className="flex flex-col">
|
||||
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
||||
</div>
|
||||
</EditorContainer >
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,67 @@
|
||||
import React, { Fragment, useState } from "react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import { Placement } from "@popperjs/core";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
// icons
|
||||
import { ChevronUp, MenuIcon } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
placement?: Placement;
|
||||
};
|
||||
|
||||
export const SummaryPopover: React.FC<Props> = (props) => {
|
||||
const { children, title = "SummaryPopover", placement } = props;
|
||||
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: placement ?? "auto",
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover as="div">
|
||||
{({ open }) => {
|
||||
if (open) {
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Popover.Button as={React.Fragment}>
|
||||
<Button
|
||||
ref={setReferenceElement}
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
>
|
||||
<MenuIcon size={20} />
|
||||
</Button>
|
||||
</Popover.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel>
|
||||
<div
|
||||
className="z-10 bg-custom-background-100 border border-custom-border-200 shadow-custom-shadow-rg rounded overflow-hidden"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="w-[18.75rem] max-h-[37.5rem] flex flex-col overflow-hidden">{children}</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Popover>
|
||||
);
|
||||
};
|
@ -0,0 +1,18 @@
|
||||
import { Editor } from "@tiptap/react"
|
||||
import { IMarking } from ".."
|
||||
import { ContentBrowser } from "./content-browser"
|
||||
|
||||
interface ISummarySideBarProps {
|
||||
editor: Editor,
|
||||
markings: IMarking[],
|
||||
sidePeakVisible: boolean
|
||||
}
|
||||
|
||||
export const SummarySideBar = ({ editor, markings, sidePeakVisible }: ISummarySideBarProps) => {
|
||||
return (
|
||||
|
||||
<div className={`flex flex-col items-stretch w-[21%] max-md:w-full max-md:ml-0 border-custom-border border-r border-solid transition-all duration-200 ease-in-out transform ${sidePeakVisible ? 'translate-x-0' : '-translate-x-full'}`}>
|
||||
<ContentBrowser editor={editor} markings={markings} />
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
import { Button, CustomMenu } from "@plane/ui"
|
||||
import { ChevronUp, Icon, MoreVertical } from "lucide-react"
|
||||
|
||||
|
||||
type TMenuItems = "archive_page" | "unarchive_page" | "lock_page" | "unlock_page" | "copy_markdown" | "close_page" | "copy_page_link" | "duplicate_page"
|
||||
|
||||
export interface IVerticalDropdownItemProps {
|
||||
key: number,
|
||||
type: TMenuItems,
|
||||
Icon: Icon,
|
||||
label: string,
|
||||
action: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export interface IVerticalDropdownMenuProps {
|
||||
items: IVerticalDropdownItemProps[],
|
||||
}
|
||||
|
||||
const VerticalDropdownItem = ({ Icon, label, action }: IVerticalDropdownItemProps) => {
|
||||
|
||||
return (
|
||||
<CustomMenu.MenuItem>
|
||||
<Button variant={"neutral-primary"} onClick={action} className="flex flex-row border-none items-center m-1 max-md:pr-5 cursor-pointer">
|
||||
<Icon size={16} />
|
||||
<div className="text-custom-text-300 ml-2 mr-2 leading-5 tracking-tight whitespace-nowrap self-start text-md">
|
||||
{label}
|
||||
</div>
|
||||
</Button>
|
||||
</CustomMenu.MenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => {
|
||||
|
||||
return (
|
||||
<CustomMenu maxHeight={"lg"} className={"h-4"} placement={"bottom-start"} optionsClassName={"border-custom-border border-r border-solid transition-all duration-200 ease-in-out "} customButton={
|
||||
<MoreVertical size={18}/>
|
||||
}>
|
||||
{items.map((item, index) => (
|
||||
<VerticalDropdownItem
|
||||
key={index}
|
||||
type={item.type}
|
||||
Icon={item.Icon}
|
||||
label={item.label}
|
||||
action={item.action}
|
||||
/>
|
||||
))}
|
||||
</CustomMenu>
|
||||
)
|
||||
}
|
59
packages/editor/document-editor/src/ui/extensions/index.tsx
Normal file
59
packages/editor/document-editor/src/ui/extensions/index.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import HorizontalRule from "@tiptap/extension-horizontal-rule";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||
import { common, createLowlight } from 'lowlight'
|
||||
import { InputRule } from "@tiptap/core";
|
||||
|
||||
import ts from "highlight.js/lib/languages/typescript";
|
||||
|
||||
import SlashCommand from "./slash-command";
|
||||
import { UploadImage } from "../";
|
||||
|
||||
const lowlight = createLowlight(common)
|
||||
lowlight.register("ts", ts);
|
||||
|
||||
export const DocumentEditorExtensions = (
|
||||
uploadFile: UploadImage,
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||
) => [
|
||||
HorizontalRule.extend({
|
||||
addInputRules() {
|
||||
return [
|
||||
new InputRule({
|
||||
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
|
||||
handler: ({ state, range, commands }) => {
|
||||
commands.splitBlock();
|
||||
|
||||
const attributes = {};
|
||||
const { tr } = state;
|
||||
const start = range.from;
|
||||
const end = range.to;
|
||||
// @ts-ignore
|
||||
tr.replaceWith(start - 1, end, this.type.create(attributes));
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
}).configure({
|
||||
HTMLAttributes: {
|
||||
class: "mb-6 border-t border-custom-border-300",
|
||||
},
|
||||
}),
|
||||
SlashCommand(uploadFile, setIsSubmitting),
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === "heading") {
|
||||
return `Heading ${node.attrs.level}`;
|
||||
}
|
||||
if (node.type.name === "image" || node.type.name === "table") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return "Press '/' for commands...";
|
||||
},
|
||||
includeChildren: true,
|
||||
}),
|
||||
];
|
@ -0,0 +1,343 @@
|
||||
import { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react";
|
||||
import { Editor, Range, Extension } from "@tiptap/core";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import tippy from "tippy.js";
|
||||
import {
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
List,
|
||||
ListOrdered,
|
||||
Text,
|
||||
TextQuote,
|
||||
Code,
|
||||
MinusSquare,
|
||||
CheckSquare,
|
||||
ImageIcon,
|
||||
Table,
|
||||
} from "lucide-react";
|
||||
import { UploadImage } from "../";
|
||||
import { cn, insertTableCommand, toggleBlockquote, toggleBulletList, toggleOrderedList, toggleTaskList, insertImageCommand, toggleHeadingOne, toggleHeadingTwo, toggleHeadingThree } from "@plane/editor-core";
|
||||
|
||||
interface CommandItemProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
interface CommandProps {
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
const Command = Extension.create({
|
||||
name: "slash-command",
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
char: "/",
|
||||
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
|
||||
props.command({ editor, range });
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
allow({ editor }) {
|
||||
return !editor.isActive("table");
|
||||
},
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const getSuggestionItems =
|
||||
(
|
||||
uploadFile: UploadImage,
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||
) =>
|
||||
({ query }: { query: string }) =>
|
||||
[
|
||||
{
|
||||
title: "Text",
|
||||
description: "Just start typing with plain text.",
|
||||
searchTerms: ["p", "paragraph"],
|
||||
icon: <Text size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 1",
|
||||
description: "Big section heading.",
|
||||
searchTerms: ["title", "big", "large"],
|
||||
icon: <Heading1 size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingOne(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 2",
|
||||
description: "Medium section heading.",
|
||||
searchTerms: ["subtitle", "medium"],
|
||||
icon: <Heading2 size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingTwo(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 3",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading3 size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingThree(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "To-do List",
|
||||
description: "Track tasks with a to-do list.",
|
||||
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
||||
icon: <CheckSquare size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleTaskList(editor, range)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Bullet List",
|
||||
description: "Create a simple bullet list.",
|
||||
searchTerms: ["unordered", "point"],
|
||||
icon: <List size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleBulletList(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Divider",
|
||||
description: "Visually divide blocks",
|
||||
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
|
||||
icon: <MinusSquare size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Table",
|
||||
description: "Create a Table",
|
||||
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
||||
icon: <Table size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
insertTableCommand(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Numbered List",
|
||||
description: "Create a list with numbering.",
|
||||
searchTerms: ["ordered"],
|
||||
icon: <ListOrdered size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleOrderedList(editor, range)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Quote",
|
||||
description: "Capture a quote.",
|
||||
searchTerms: ["blockquote"],
|
||||
icon: <TextQuote size={18} />,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
toggleBlockquote(editor, range)
|
||||
},
|
||||
{
|
||||
title: "Code",
|
||||
description: "Capture a code snippet.",
|
||||
searchTerms: ["codeblock"],
|
||||
icon: <Code size={18} />,
|
||||
command: ({ editor, range }: CommandProps) =>
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||
},
|
||||
{
|
||||
title: "Image",
|
||||
description: "Upload an image from your computer.",
|
||||
searchTerms: ["photo", "picture", "media"],
|
||||
icon: <ImageIcon size={18} />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
insertImageCommand(editor, uploadFile, setIsSubmitting, range);
|
||||
},
|
||||
},
|
||||
].filter((item) => {
|
||||
if (typeof query === "string" && query.length > 0) {
|
||||
const search = query.toLowerCase();
|
||||
return (
|
||||
item.title.toLowerCase().includes(search) ||
|
||||
item.description.toLowerCase().includes(search) ||
|
||||
(item.searchTerms && item.searchTerms.some((term: string) => term.includes(search)))
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
||||
const containerHeight = container.offsetHeight;
|
||||
const itemHeight = item ? item.offsetHeight : 0;
|
||||
|
||||
const top = item.offsetTop;
|
||||
const bottom = top + itemHeight;
|
||||
|
||||
if (top < container.scrollTop) {
|
||||
container.scrollTop -= container.scrollTop - top + 5;
|
||||
} else if (bottom > containerHeight + container.scrollTop) {
|
||||
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
|
||||
}
|
||||
};
|
||||
|
||||
const CommandList = ({
|
||||
items,
|
||||
command,
|
||||
}: {
|
||||
items: CommandItemProps[];
|
||||
command: any;
|
||||
editor: any;
|
||||
range: any;
|
||||
}) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index];
|
||||
if (item) {
|
||||
command(item);
|
||||
}
|
||||
},
|
||||
[command, items]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (navigationKeys.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
if (e.key === "ArrowUp") {
|
||||
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
setSelectedIndex((selectedIndex + 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
selectItem(selectedIndex);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [items, selectedIndex, setSelectedIndex, selectItem]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [items]);
|
||||
|
||||
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = commandListContainer?.current;
|
||||
|
||||
const item = container?.children[selectedIndex] as HTMLElement;
|
||||
|
||||
if (item && container) updateScrollView(container, item);
|
||||
}, [selectedIndex]);
|
||||
|
||||
return items.length > 0 ? (
|
||||
<div
|
||||
id="slash-command"
|
||||
ref={commandListContainer}
|
||||
className="z-50 fixed h-auto max-h-[330px] w-72 overflow-y-auto rounded-md border border-custom-border-300 bg-custom-background-100 px-1 py-2 shadow-md transition-all"
|
||||
>
|
||||
{items.map((item: CommandItemProps, index: number) => (
|
||||
<button
|
||||
className={cn(
|
||||
`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100`,
|
||||
{ "bg-custom-primary-100/5 text-custom-text-100": index === selectedIndex }
|
||||
)}
|
||||
key={index}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-xs text-custom-text-300">{item.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const renderItems = () => {
|
||||
let component: ReactRenderer | null = null;
|
||||
let popup: any | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component = new ReactRenderer(CommandList, {
|
||||
props,
|
||||
// @ts-ignore
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.querySelector("#editor-container"),
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component?.updateProps(props);
|
||||
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return component?.ref?.onKeyDown(props);
|
||||
},
|
||||
onExit: () => {
|
||||
popup?.[0].destroy();
|
||||
component?.destroy();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const SlashCommand = (
|
||||
uploadFile: UploadImage,
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||
) =>
|
||||
Command.configure({
|
||||
suggestion: {
|
||||
items: getSuggestionItems(uploadFile, setIsSubmitting),
|
||||
render: renderItems,
|
||||
},
|
||||
});
|
||||
|
||||
export default SlashCommand;
|
@ -0,0 +1,33 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { useState } from "react";
|
||||
import { IMarking } from "..";
|
||||
|
||||
export const useEditorMarkings = () => {
|
||||
|
||||
const [markings, setMarkings] = useState<IMarking[]>([])
|
||||
|
||||
const updateMarkings = (json: any) => {
|
||||
const nodes = json.content as any[]
|
||||
const tempMarkings: IMarking[] = []
|
||||
let h1Sequence: number = 0
|
||||
let h2Sequence: number = 0
|
||||
if (nodes) {
|
||||
nodes.forEach((node) => {
|
||||
if (node.type === "heading" && (node.attrs.level === 1 || node.attrs.level === 2) && node.content) {
|
||||
tempMarkings.push({
|
||||
type: "heading",
|
||||
level: node.attrs.level,
|
||||
text: node.content[0].text,
|
||||
sequence: node.attrs.level === 1 ? ++h1Sequence : ++h2Sequence
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
setMarkings(tempMarkings)
|
||||
}
|
||||
|
||||
return {
|
||||
updateMarkings,
|
||||
markings,
|
||||
}
|
||||
}
|
151
packages/editor/document-editor/src/ui/index.tsx
Normal file
151
packages/editor/document-editor/src/ui/index.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
"use client"
|
||||
import React, { useState } from 'react';
|
||||
import { cn, getEditorClassNames, useEditor } from '@plane/editor-core';
|
||||
import { DocumentEditorExtensions } from './extensions';
|
||||
import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from './types/menu-actions';
|
||||
import { EditorHeader } from './components/editor-header';
|
||||
import { useEditorMarkings } from './hooks/use-editor-markings';
|
||||
import { SummarySideBar } from './components/summary-side-bar';
|
||||
import { DocumentDetails } from './types/editor-types';
|
||||
import { PageRenderer } from './components/page-renderer';
|
||||
import { getMenuOptions } from './utils/menu-options';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
export type UploadImage = (file: File) => Promise<string>;
|
||||
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
|
||||
|
||||
interface IDocumentEditor {
|
||||
documentDetails: DocumentDetails,
|
||||
value: string;
|
||||
uploadFile: UploadImage;
|
||||
deleteFile: DeleteImage;
|
||||
customClassName?: string;
|
||||
editorContentCustomClassNames?: string;
|
||||
onChange: (json: any, html: string) => void;
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||
forwardedRef?: any;
|
||||
debouncedUpdatesEnabled?: boolean;
|
||||
duplicationConfig?: IDuplicationConfig,
|
||||
pageLockConfig?: IPageLockConfig,
|
||||
pageArchiveConfig?: IPageArchiveConfig
|
||||
}
|
||||
interface DocumentEditorProps extends IDocumentEditor {
|
||||
forwardedRef?: React.Ref<EditorHandle>;
|
||||
}
|
||||
|
||||
interface EditorHandle {
|
||||
clearEditor: () => void;
|
||||
setEditorValue: (content: string) => void;
|
||||
}
|
||||
|
||||
export interface IMarking {
|
||||
type: "heading",
|
||||
level: number,
|
||||
text: string,
|
||||
sequence: number
|
||||
}
|
||||
|
||||
const DocumentEditor = ({
|
||||
documentDetails,
|
||||
onChange,
|
||||
debouncedUpdatesEnabled,
|
||||
setIsSubmitting,
|
||||
setShouldShowAlert,
|
||||
editorContentCustomClassNames,
|
||||
value,
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
customClassName,
|
||||
forwardedRef,
|
||||
duplicationConfig,
|
||||
pageLockConfig,
|
||||
pageArchiveConfig
|
||||
}: IDocumentEditor) => {
|
||||
|
||||
// const [alert, setAlert] = useState<string>("")
|
||||
const { markings, updateMarkings } = useEditorMarkings()
|
||||
const [sidePeakVisible, setSidePeakVisible] = useState(true)
|
||||
const router = useRouter()
|
||||
|
||||
const editor = useEditor({
|
||||
onChange(json, html) {
|
||||
updateMarkings(json)
|
||||
onChange(json, html)
|
||||
},
|
||||
onStart(json) {
|
||||
updateMarkings(json)
|
||||
},
|
||||
debouncedUpdatesEnabled,
|
||||
setIsSubmitting,
|
||||
setShouldShowAlert,
|
||||
value,
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
forwardedRef,
|
||||
extensions: DocumentEditorExtensions(uploadFile, setIsSubmitting),
|
||||
});
|
||||
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
|
||||
const KanbanMenuOptions = getMenuOptions(
|
||||
{
|
||||
editor: editor,
|
||||
router: router,
|
||||
duplicationConfig: duplicationConfig,
|
||||
pageLockConfig: pageLockConfig,
|
||||
pageArchiveConfig: pageArchiveConfig,
|
||||
}
|
||||
)
|
||||
const editorClassNames = getEditorClassNames({ noBorder: true, borderOnFocus: false, customClassName });
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="top-0 sticky z-10 bg-custom-background-100">
|
||||
<EditorHeader
|
||||
readonly={false}
|
||||
KanbanMenuOptions={KanbanMenuOptions}
|
||||
editor={editor}
|
||||
sidePeakVisible={sidePeakVisible}
|
||||
setSidePeakVisible={setSidePeakVisible}
|
||||
markings={markings}
|
||||
uploadFile={uploadFile}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
isLocked={!pageLockConfig ? false : pageLockConfig.is_locked}
|
||||
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
|
||||
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
|
||||
documentDetails={documentDetails}
|
||||
/>
|
||||
</div>
|
||||
<div className="self-center items-stretch w-full max-md:max-w-full h-full">
|
||||
<div className={cn("gap-5 flex max-md:flex-col max-md:items-stretch max-md:gap-0 h-full", { "justify-center": !sidePeakVisible })}>
|
||||
<SummarySideBar
|
||||
editor={editor}
|
||||
markings={markings}
|
||||
sidePeakVisible={sidePeakVisible}
|
||||
/>
|
||||
<PageRenderer
|
||||
editor={editor}
|
||||
editorContentCustomClassNames={editorContentCustomClassNames}
|
||||
editorClassNames={editorClassNames}
|
||||
sidePeakVisible={sidePeakVisible}
|
||||
documentDetails={documentDetails}
|
||||
/>
|
||||
{/* Page Element */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const DocumentEditorWithRef = React.forwardRef<EditorHandle, IDocumentEditor>((props, ref) => (
|
||||
<DocumentEditor {...props} forwardedRef={ref} />
|
||||
));
|
||||
|
||||
DocumentEditorWithRef.displayName = "DocumentEditorWithRef";
|
||||
|
||||
export { DocumentEditor, DocumentEditorWithRef }
|
142
packages/editor/document-editor/src/ui/menu/fixed-menu.tsx
Normal file
142
packages/editor/document-editor/src/ui/menu/fixed-menu.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { BoldIcon, Heading1, Heading2, Heading3 } from "lucide-react";
|
||||
|
||||
import { BoldItem, BulletListItem, cn, CodeItem, ImageItem, ItalicItem, NumberedListItem, QuoteItem, StrikeThroughItem, TableItem, UnderLineItem, HeadingOneItem, HeadingTwoItem, HeadingThreeItem } from "@plane/editor-core";
|
||||
import { UploadImage } from "..";
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
name: string;
|
||||
isActive: () => boolean;
|
||||
command: () => void;
|
||||
icon: typeof BoldIcon;
|
||||
}
|
||||
|
||||
type EditorBubbleMenuProps = {
|
||||
editor: Editor;
|
||||
uploadFile: UploadImage;
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
}
|
||||
|
||||
export const FixedMenu = (props: EditorBubbleMenuProps) => {
|
||||
const basicMarkItems: BubbleMenuItem[] = [
|
||||
HeadingOneItem(props.editor),
|
||||
HeadingTwoItem(props.editor),
|
||||
HeadingThreeItem(props.editor),
|
||||
BoldItem(props.editor),
|
||||
ItalicItem(props.editor),
|
||||
UnderLineItem(props.editor),
|
||||
StrikeThroughItem(props.editor),
|
||||
];
|
||||
|
||||
const listItems: BubbleMenuItem[] = [
|
||||
BulletListItem(props.editor),
|
||||
NumberedListItem(props.editor),
|
||||
];
|
||||
|
||||
const userActionItems: BubbleMenuItem[] = [
|
||||
QuoteItem(props.editor),
|
||||
CodeItem(props.editor),
|
||||
];
|
||||
|
||||
const complexItems: BubbleMenuItem[] = [
|
||||
TableItem(props.editor),
|
||||
ImageItem(props.editor, props.uploadFile, props.setIsSubmitting),
|
||||
];
|
||||
|
||||
// const handleAccessChange = (accessKey: string) => {
|
||||
// props.commentAccessSpecifier?.onAccessChange(accessKey);
|
||||
// };
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-fit rounded bg-custom-background-100"
|
||||
>
|
||||
<div className="flex">
|
||||
{basicMarkItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={item.command}
|
||||
className={cn(
|
||||
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
||||
{
|
||||
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
size={ item.icon === Heading1 || item.icon === Heading2 || item.icon === Heading3 ? 20 : 15}
|
||||
className={cn({
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex">
|
||||
{listItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={item.command}
|
||||
className={cn(
|
||||
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
||||
{
|
||||
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-4 w-4", {
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex">
|
||||
{userActionItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={item.command}
|
||||
className={cn(
|
||||
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
||||
{
|
||||
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-4 w-4", {
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex">
|
||||
{complexItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={item.command}
|
||||
className={cn(
|
||||
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
||||
{
|
||||
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-4 w-4", {
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
13
packages/editor/document-editor/src/ui/menu/icon.tsx
Normal file
13
packages/editor/document-editor/src/ui/menu/icon.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
iconName: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const Icon: React.FC<Props> = ({ iconName, className = "" }) => (
|
||||
<span className={`material-symbols-rounded text-sm leading-5 font-light ${className}`}>
|
||||
{iconName}
|
||||
</span>
|
||||
);
|
||||
|
1
packages/editor/document-editor/src/ui/menu/index.tsx
Normal file
1
packages/editor/document-editor/src/ui/menu/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export { FixedMenu } from "./fixed-menu";
|
121
packages/editor/document-editor/src/ui/readonly/index.tsx
Normal file
121
packages/editor/document-editor/src/ui/readonly/index.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import { cn, getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core"
|
||||
import { useRouter } from "next/router";
|
||||
import { useState, forwardRef, useEffect } from 'react'
|
||||
import { EditorHeader } from "../components/editor-header";
|
||||
import { PageRenderer } from "../components/page-renderer";
|
||||
import { SummarySideBar } from "../components/summary-side-bar";
|
||||
import { useEditorMarkings } from "../hooks/use-editor-markings";
|
||||
import { DocumentDetails } from "../types/editor-types";
|
||||
import { IPageArchiveConfig, IPageLockConfig, IDuplicationConfig } from "../types/menu-actions";
|
||||
import { getMenuOptions } from "../utils/menu-options";
|
||||
|
||||
interface IDocumentReadOnlyEditor {
|
||||
value: string,
|
||||
noBorder: boolean,
|
||||
borderOnFocus: boolean,
|
||||
customClassName: string,
|
||||
documentDetails: DocumentDetails,
|
||||
pageLockConfig?: IPageLockConfig,
|
||||
pageArchiveConfig?: IPageArchiveConfig,
|
||||
pageDuplicationConfig?: IDuplicationConfig,
|
||||
}
|
||||
|
||||
interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor {
|
||||
forwardedRef?: React.Ref<EditorHandle>
|
||||
}
|
||||
|
||||
interface EditorHandle {
|
||||
clearEditor: () => void;
|
||||
setEditorValue: (content: string) => void;
|
||||
}
|
||||
|
||||
const DocumentReadOnlyEditor = ({
|
||||
noBorder,
|
||||
borderOnFocus,
|
||||
customClassName,
|
||||
value,
|
||||
documentDetails,
|
||||
forwardedRef,
|
||||
pageDuplicationConfig,
|
||||
pageLockConfig,
|
||||
pageArchiveConfig,
|
||||
}: DocumentReadOnlyEditorProps) => {
|
||||
|
||||
const router = useRouter()
|
||||
const [sidePeakVisible, setSidePeakVisible] = useState(true)
|
||||
const { markings, updateMarkings } = useEditorMarkings()
|
||||
|
||||
const editor = useReadOnlyEditor({
|
||||
value,
|
||||
forwardedRef,
|
||||
})
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
updateMarkings(editor.getJSON())
|
||||
}
|
||||
}, [editor?.getJSON()])
|
||||
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
|
||||
const editorClassNames = getEditorClassNames({
|
||||
noBorder,
|
||||
borderOnFocus,
|
||||
customClassName
|
||||
})
|
||||
|
||||
const KanbanMenuOptions = getMenuOptions({
|
||||
editor: editor,
|
||||
router: router,
|
||||
pageArchiveConfig: pageArchiveConfig,
|
||||
pageLockConfig: pageLockConfig,
|
||||
duplicationConfig: pageDuplicationConfig,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="top-0 sticky z-10 bg-custom-background-100">
|
||||
<EditorHeader
|
||||
isLocked={!pageLockConfig ? false : pageLockConfig.is_locked}
|
||||
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
|
||||
readonly={true}
|
||||
editor={editor}
|
||||
sidePeakVisible={sidePeakVisible}
|
||||
setSidePeakVisible={setSidePeakVisible}
|
||||
KanbanMenuOptions={KanbanMenuOptions}
|
||||
markings={markings}
|
||||
documentDetails={documentDetails}
|
||||
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
|
||||
/>
|
||||
</div>
|
||||
<div className="self-center items-stretch w-full max-md:max-w-full overflow-y-hidden">
|
||||
<div className={cn("gap-5 flex max-md:flex-col max-md:items-stretch max-md:gap-0 overflow-y-hidden", { "justify-center": !sidePeakVisible })}>
|
||||
<SummarySideBar
|
||||
editor={editor}
|
||||
markings={markings}
|
||||
sidePeakVisible={sidePeakVisible}
|
||||
/>
|
||||
<PageRenderer
|
||||
editor={editor}
|
||||
editorClassNames={editorClassNames}
|
||||
sidePeakVisible={sidePeakVisible}
|
||||
documentDetails={documentDetails}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const DocumentReadOnlyEditorWithRef = forwardRef<
|
||||
EditorHandle,
|
||||
IDocumentReadOnlyEditor
|
||||
>((props, ref) => <DocumentReadOnlyEditor {...props} forwardedRef={ref} />);
|
||||
|
||||
DocumentReadOnlyEditorWithRef.displayName = "DocumentReadOnlyEditorWithRef";
|
||||
|
||||
export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef }
|
77
packages/editor/document-editor/src/ui/tooltip.tsx
Normal file
77
packages/editor/document-editor/src/ui/tooltip.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import * as React from 'react';
|
||||
|
||||
// next-themes
|
||||
import { useTheme } from "next-themes";
|
||||
// tooltip2
|
||||
import { Tooltip2 } from "@blueprintjs/popover2";
|
||||
|
||||
type Props = {
|
||||
tooltipHeading?: string;
|
||||
tooltipContent: string | React.ReactNode;
|
||||
position?:
|
||||
| "top"
|
||||
| "right"
|
||||
| "bottom"
|
||||
| "left"
|
||||
| "auto"
|
||||
| "auto-end"
|
||||
| "auto-start"
|
||||
| "bottom-left"
|
||||
| "bottom-right"
|
||||
| "left-bottom"
|
||||
| "left-top"
|
||||
| "right-bottom"
|
||||
| "right-top"
|
||||
| "top-left"
|
||||
| "top-right";
|
||||
children: JSX.Element;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
openDelay?: number;
|
||||
closeDelay?: number;
|
||||
};
|
||||
|
||||
export const Tooltip: React.FC<Props> = ({
|
||||
tooltipHeading,
|
||||
tooltipContent,
|
||||
position = "top",
|
||||
children,
|
||||
disabled = false,
|
||||
className = "",
|
||||
openDelay = 200,
|
||||
closeDelay,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<Tooltip2
|
||||
disabled={disabled}
|
||||
hoverOpenDelay={openDelay}
|
||||
hoverCloseDelay={closeDelay}
|
||||
content={
|
||||
<div
|
||||
className={`relative z-50 max-w-xs gap-1 rounded-md p-2 text-xs shadow-md ${
|
||||
theme === "custom"
|
||||
? "bg-custom-background-100 text-custom-text-200"
|
||||
: "bg-black text-gray-400"
|
||||
} break-words overflow-hidden ${className}`}
|
||||
>
|
||||
{tooltipHeading && (
|
||||
<h5
|
||||
className={`font-medium ${
|
||||
theme === "custom" ? "text-custom-text-100" : "text-white"
|
||||
}`}
|
||||
>
|
||||
{tooltipHeading}
|
||||
</h5>
|
||||
)}
|
||||
{tooltipContent}
|
||||
</div>
|
||||
}
|
||||
position={position}
|
||||
renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) =>
|
||||
React.cloneElement(children, { ref: eleReference, ...tooltipProps, ...children.props })
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,8 @@
|
||||
|
||||
export interface DocumentDetails {
|
||||
title: string;
|
||||
created_by: string;
|
||||
created_on: Date;
|
||||
last_updated_by: string;
|
||||
last_updated_at: Date;
|
||||
}
|
14
packages/editor/document-editor/src/ui/types/menu-actions.d.ts
vendored
Normal file
14
packages/editor/document-editor/src/ui/types/menu-actions.d.ts
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
|
||||
export interface IDuplicationConfig {
|
||||
action: () => Promise<void>
|
||||
}
|
||||
export interface IPageLockConfig {
|
||||
is_locked: boolean,
|
||||
action: () => Promise<void>
|
||||
locked_by?: string,
|
||||
}
|
||||
export interface IPageArchiveConfig {
|
||||
is_archived: boolean,
|
||||
archived_at?: Date,
|
||||
action: () => Promise<void>
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { IMarking } from "..";
|
||||
|
||||
function findNthH1(editor: Editor, n: number, level: number): number {
|
||||
let count = 0;
|
||||
let pos = 0;
|
||||
editor.state.doc.descendants((node, position) => {
|
||||
if (node.type.name === 'heading' && node.attrs.level === level) {
|
||||
count++;
|
||||
if (count === n) {
|
||||
pos = position;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
return pos;
|
||||
}
|
||||
|
||||
function scrollToNode(editor: Editor, pos: number): void {
|
||||
const headingNode = editor.state.doc.nodeAt(pos);
|
||||
if (headingNode) {
|
||||
const headingDOM = editor.view.nodeDOM(pos);
|
||||
if (headingDOM instanceof HTMLElement) {
|
||||
headingDOM.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function scrollSummary(editor: Editor, marking: IMarking) {
|
||||
if (editor) {
|
||||
const pos = findNthH1(editor, marking.sequence, marking.level)
|
||||
scrollToNode(editor, pos)
|
||||
}
|
||||
}
|
||||
|
12
packages/editor/document-editor/src/ui/utils/menu-actions.ts
Normal file
12
packages/editor/document-editor/src/ui/utils/menu-actions.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Editor } from "@tiptap/core"
|
||||
|
||||
export const copyMarkdownToClipboard = (editor: Editor | null) => {
|
||||
const markdownOutput = editor?.storage.markdown.getMarkdown();
|
||||
navigator.clipboard.writeText(markdownOutput)
|
||||
}
|
||||
|
||||
export const CopyPageLink = () => {
|
||||
if (window){
|
||||
navigator.clipboard.writeText(window.location.toString())
|
||||
}
|
||||
}
|
75
packages/editor/document-editor/src/ui/utils/menu-options.ts
Normal file
75
packages/editor/document-editor/src/ui/utils/menu-options.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { Editor } from "@tiptap/react"
|
||||
import { Archive, ArchiveIcon, ArchiveRestoreIcon, ClipboardIcon, Copy, Link, Lock, Unlock, XCircle } from "lucide-react"
|
||||
import { NextRouter } from "next/router"
|
||||
import { IVerticalDropdownItemProps } from "../components/vertical-dropdown-menu"
|
||||
import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from "../types/menu-actions"
|
||||
import { copyMarkdownToClipboard, CopyPageLink } from "./menu-actions"
|
||||
|
||||
export interface MenuOptionsProps{
|
||||
editor: Editor,
|
||||
router: NextRouter,
|
||||
duplicationConfig?: IDuplicationConfig,
|
||||
pageLockConfig?: IPageLockConfig ,
|
||||
pageArchiveConfig?: IPageArchiveConfig,
|
||||
}
|
||||
|
||||
export const getMenuOptions = ({ editor, router, duplicationConfig, pageLockConfig, pageArchiveConfig } : MenuOptionsProps) => {
|
||||
|
||||
const KanbanMenuOptions: IVerticalDropdownItemProps[] = [
|
||||
{
|
||||
key: 1,
|
||||
type: "copy_markdown",
|
||||
Icon: ClipboardIcon,
|
||||
action: () => copyMarkdownToClipboard(editor),
|
||||
label: "Copy Markdown"
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
type: "close_page",
|
||||
Icon: XCircle,
|
||||
action: () => router.back(),
|
||||
label: "Close the page"
|
||||
},
|
||||
{
|
||||
key: 3,
|
||||
type: "copy_page_link",
|
||||
Icon: Link,
|
||||
action: () => CopyPageLink(),
|
||||
label: "Copy Page Link"
|
||||
},
|
||||
]
|
||||
|
||||
// If duplicateConfig is given, page duplication will be allowed
|
||||
if (duplicationConfig) {
|
||||
KanbanMenuOptions.push({
|
||||
key: KanbanMenuOptions.length++,
|
||||
type: "duplicate_page",
|
||||
Icon: Copy,
|
||||
action: duplicationConfig.action,
|
||||
label: "Make a copy"
|
||||
})
|
||||
}
|
||||
// If Lock Configuration is given then, lock page option will be available in the kanban menu
|
||||
if (pageLockConfig) {
|
||||
KanbanMenuOptions.push({
|
||||
key: KanbanMenuOptions.length++,
|
||||
type: pageLockConfig.is_locked ? "unlock_page" : "lock_page",
|
||||
Icon: pageLockConfig.is_locked ? Unlock : Lock,
|
||||
label: pageLockConfig.is_locked ? "Unlock Page" : "Lock Page",
|
||||
action: pageLockConfig.action
|
||||
})
|
||||
}
|
||||
|
||||
// Archiving will be visible in the menu bar config once the pageArchiveConfig is given.
|
||||
if (pageArchiveConfig) {
|
||||
KanbanMenuOptions.push({
|
||||
key: KanbanMenuOptions.length++,
|
||||
type: pageArchiveConfig.is_archived ? "unarchive_page" : "archive_page",
|
||||
Icon: pageArchiveConfig.is_archived ? ArchiveRestoreIcon : Archive,
|
||||
label: pageArchiveConfig.is_archived ? "Restore Page" : "Archive Page",
|
||||
action: pageArchiveConfig.action,
|
||||
})
|
||||
}
|
||||
|
||||
return KanbanMenuOptions
|
||||
}
|
6
packages/editor/document-editor/tailwind.config.js
Normal file
6
packages/editor/document-editor/tailwind.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
const sharedConfig = require("tailwind-config-custom/tailwind.config.js");
|
||||
|
||||
module.exports = {
|
||||
// prefix ui lib classes to avoid conflicting with the app
|
||||
...sharedConfig,
|
||||
};
|
5
packages/editor/document-editor/tsconfig.json
Normal file
5
packages/editor/document-editor/tsconfig.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "tsconfig/react-library.json",
|
||||
"include": ["src/**/*", "index.d.ts"],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
}
|
11
packages/editor/document-editor/tsup.config.ts
Normal file
11
packages/editor/document-editor/tsup.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { defineConfig, Options } from "tsup";
|
||||
|
||||
export default defineConfig((options: Options) => ({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: false,
|
||||
external: ["react"],
|
||||
injectStyle: true,
|
||||
...options,
|
||||
}));
|
10
turbo.json
10
turbo.json
@ -31,6 +31,7 @@
|
||||
"dependsOn": [
|
||||
"@plane/lite-text-editor#build",
|
||||
"@plane/rich-text-editor#build",
|
||||
"@plane/document-editor#build",
|
||||
"@plane/ui#build"
|
||||
]
|
||||
},
|
||||
@ -40,6 +41,7 @@
|
||||
"dependsOn": [
|
||||
"@plane/lite-text-editor#build",
|
||||
"@plane/rich-text-editor#build",
|
||||
"@plane/document-editor#build",
|
||||
"@plane/ui#build"
|
||||
]
|
||||
},
|
||||
@ -48,6 +50,7 @@
|
||||
"dependsOn": [
|
||||
"@plane/lite-text-editor#build",
|
||||
"@plane/rich-text-editor#build",
|
||||
"@plane/document-editor#build",
|
||||
"@plane/ui#build"
|
||||
]
|
||||
},
|
||||
@ -56,6 +59,7 @@
|
||||
"dependsOn": [
|
||||
"@plane/lite-text-editor#build",
|
||||
"@plane/rich-text-editor#build",
|
||||
"@plane/document-editor#build",
|
||||
"@plane/ui#build"
|
||||
]
|
||||
},
|
||||
@ -67,6 +71,12 @@
|
||||
"cache": true,
|
||||
"dependsOn": ["@plane/editor-core#build"]
|
||||
},
|
||||
"@plane/document-editor#build": {
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"@plane/editor-core#build"
|
||||
]
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
|
@ -203,8 +203,6 @@ export const CommandPalette: FC = observer(() => {
|
||||
<CreateUpdatePageModal
|
||||
isOpen={isCreatePageModalOpen}
|
||||
handleClose={() => toggleCreatePageModal(false)}
|
||||
user={user}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
/>
|
||||
</>
|
||||
|
@ -250,7 +250,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
handleRemoveFromFavorites(e);
|
||||
}}
|
||||
>
|
||||
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
|
||||
<Star className="h-4 w-4 text-orange-400 fill-orange-400" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
|
204
web/components/instance/email-form.tsx
Normal file
204
web/components/instance/email-form.tsx
Normal file
@ -0,0 +1,204 @@
|
||||
import { FC } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// ui
|
||||
import { Button, Input, ToggleSwitch } from "@plane/ui";
|
||||
// types
|
||||
import { IFormattedInstanceConfiguration } from "types/instance";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
|
||||
export interface IInstanceEmailForm {
|
||||
config: IFormattedInstanceConfiguration;
|
||||
}
|
||||
|
||||
export interface EmailFormValues {
|
||||
EMAIL_HOST: string;
|
||||
EMAIL_PORT: string;
|
||||
EMAIL_HOST_USER: string;
|
||||
EMAIL_HOST_PASSWORD: string;
|
||||
EMAIL_USE_TLS: string;
|
||||
EMAIL_USE_SSL: string;
|
||||
}
|
||||
|
||||
export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
|
||||
const { config } = props;
|
||||
// store
|
||||
const { instance: instanceStore } = useMobxStore();
|
||||
// toast
|
||||
const { setToastAlert } = useToast();
|
||||
// form data
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<EmailFormValues>({
|
||||
defaultValues: {
|
||||
EMAIL_HOST: config["EMAIL_HOST"],
|
||||
EMAIL_PORT: config["EMAIL_PORT"],
|
||||
EMAIL_HOST_USER: config["EMAIL_HOST_USER"],
|
||||
EMAIL_HOST_PASSWORD: config["EMAIL_HOST_PASSWORD"],
|
||||
EMAIL_USE_TLS: config["EMAIL_USE_TLS"],
|
||||
EMAIL_USE_SSL: config["EMAIL_USE_SSL"],
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: EmailFormValues) => {
|
||||
const payload: Partial<EmailFormValues> = { ...formData };
|
||||
|
||||
await instanceStore
|
||||
.updateInstanceConfigurations(payload)
|
||||
.then(() =>
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: "Email Settings updated successfully",
|
||||
})
|
||||
)
|
||||
.catch((err) => console.error(err));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 m-8 w-4/5">
|
||||
<div className="pb-2 mb-2 border-b border-custom-border-100">
|
||||
<div className="text-custom-text-100 font-medium text-lg">Email</div>
|
||||
<div className="text-custom-text-300 font-normal text-sm">Email related settings.</div>
|
||||
</div>
|
||||
<div className="grid grid-col grid-cols-1 lg:grid-cols-2 items-center justify-between gap-x-16 gap-y-8 w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm">Host</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="EMAIL_HOST"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="EMAIL_HOST"
|
||||
name="EMAIL_HOST"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.EMAIL_HOST)}
|
||||
placeholder="Email Host"
|
||||
className="rounded-md font-medium w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm">Port</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="EMAIL_PORT"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="EMAIL_PORT"
|
||||
name="EMAIL_PORT"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.EMAIL_PORT)}
|
||||
placeholder="Email Port"
|
||||
className="rounded-md font-medium w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-col grid-cols-1 lg:grid-cols-2 items-center justify-between gap-x-16 gap-y-8 w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm">Username</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="EMAIL_HOST_USER"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="EMAIL_HOST_USER"
|
||||
name="EMAIL_HOST_USER"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.EMAIL_HOST_USER)}
|
||||
placeholder="Username"
|
||||
className="rounded-md font-medium w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm">Password</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="EMAIL_HOST_PASSWORD"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="EMAIL_HOST_PASSWORD"
|
||||
name="EMAIL_HOST_PASSWORD"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.EMAIL_HOST_PASSWORD)}
|
||||
placeholder="Password"
|
||||
className="rounded-md font-medium w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 pt-4">
|
||||
<div>
|
||||
<div className="text-custom-text-100 font-medium text-sm">Enable TLS</div>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="EMAIL_USE_TLS"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(value))}
|
||||
onChange={() => {
|
||||
Boolean(parseInt(value)) === true ? onChange("0") : onChange("1");
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 pt-4">
|
||||
<div>
|
||||
<div className="text-custom-text-100 font-medium text-sm">Enable SSL</div>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="EMAIL_USE_SSL"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(value))}
|
||||
onChange={() => {
|
||||
Boolean(parseInt(value)) === true ? onChange("0") : onChange("1");
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center py-1">
|
||||
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -52,6 +52,12 @@ export const InstanceGeneralForm: FC<IInstanceGeneralForm> = (props) => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 m-8">
|
||||
<div className="pb-2 mb-2 border-b border-custom-border-100">
|
||||
<div className="text-custom-text-100 font-medium text-lg">General</div>
|
||||
<div className="text-custom-text-300 font-normal text-sm">
|
||||
The usual things like your mail, name of instance and other stuff.
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-col grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 items-center justify-between gap-8 w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm">Name of instance</h4>
|
||||
|
132
web/components/instance/github-config-form.tsx
Normal file
132
web/components/instance/github-config-form.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import { FC } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// types
|
||||
import { IFormattedInstanceConfiguration } from "types/instance";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// icons
|
||||
import { Copy } from "lucide-react";
|
||||
|
||||
export interface IInstanceGithubConfigForm {
|
||||
config: IFormattedInstanceConfiguration;
|
||||
}
|
||||
|
||||
export interface GithubConfigFormValues {
|
||||
GITHUB_CLIENT_ID: string;
|
||||
GITHUB_CLIENT_SECRET: string;
|
||||
}
|
||||
|
||||
export const InstanceGithubConfigForm: FC<IInstanceGithubConfigForm> = (props) => {
|
||||
const { config } = props;
|
||||
// store
|
||||
const { instance: instanceStore } = useMobxStore();
|
||||
// toast
|
||||
const { setToastAlert } = useToast();
|
||||
// form data
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<GithubConfigFormValues>({
|
||||
defaultValues: {
|
||||
GITHUB_CLIENT_ID: config["GITHUB_CLIENT_ID"],
|
||||
GITHUB_CLIENT_SECRET: config["GITHUB_CLIENT_SECRET"],
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: GithubConfigFormValues) => {
|
||||
const payload: Partial<GithubConfigFormValues> = { ...formData };
|
||||
|
||||
await instanceStore
|
||||
.updateInstanceConfigurations(payload)
|
||||
.then(() =>
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: "Github Configuration Settings updated successfully",
|
||||
})
|
||||
)
|
||||
.catch((err) => console.error(err));
|
||||
};
|
||||
|
||||
const originURL = typeof window !== "undefined" ? window.location.origin : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-col grid-cols-1 lg:grid-cols-2 items-center justify-between gap-x-16 gap-y-8 w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm">Client ID</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="GITHUB_CLIENT_ID"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="GITHUB_CLIENT_ID"
|
||||
name="GITHUB_CLIENT_ID"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.GITHUB_CLIENT_ID)}
|
||||
placeholder="Github Client ID"
|
||||
className="rounded-md font-medium w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm">Client Secret</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="GITHUB_CLIENT_SECRET"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="GITHUB_CLIENT_SECRET"
|
||||
name="GITHUB_CLIENT_SECRET"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.GITHUB_CLIENT_SECRET)}
|
||||
placeholder="Github Client Secret"
|
||||
className="rounded-md font-medium w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-col grid-cols-1 lg:grid-cols-2 items-center justify-between gap-x-16 gap-y-8 w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm">Origin URL</h4>
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
className="py-2 flex justify-between items-center"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(originURL);
|
||||
setToastAlert({
|
||||
message: "The Origin URL has been successfully copied to your clipboard",
|
||||
type: "success",
|
||||
title: "Copied to clipboard",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<p className="font-medium text-sm">{originURL}</p>
|
||||
<Copy size={18} color="#B9B9B9" />
|
||||
</Button>
|
||||
<p className="text-xs text-custom-text-400/60">*paste this URL in your Github console.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center p-2">
|
||||
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
132
web/components/instance/google-config-form.tsx
Normal file
132
web/components/instance/google-config-form.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import { FC } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// types
|
||||
import { IFormattedInstanceConfiguration } from "types/instance";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// icons
|
||||
import { Copy } from "lucide-react";
|
||||
|
||||
export interface IInstanceGoogleConfigForm {
|
||||
config: IFormattedInstanceConfiguration;
|
||||
}
|
||||
|
||||
export interface GoogleConfigFormValues {
|
||||
GOOGLE_CLIENT_ID: string;
|
||||
GOOGLE_CLIENT_SECRET: string;
|
||||
}
|
||||
|
||||
export const InstanceGoogleConfigForm: FC<IInstanceGoogleConfigForm> = (props) => {
|
||||
const { config } = props;
|
||||
// store
|
||||
const { instance: instanceStore } = useMobxStore();
|
||||
// toast
|
||||
const { setToastAlert } = useToast();
|
||||
// form data
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<GoogleConfigFormValues>({
|
||||
defaultValues: {
|
||||
GOOGLE_CLIENT_ID: config["GOOGLE_CLIENT_ID"],
|
||||
GOOGLE_CLIENT_SECRET: config["GOOGLE_CLIENT_SECRET"],
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: GoogleConfigFormValues) => {
|
||||
const payload: Partial<GoogleConfigFormValues> = { ...formData };
|
||||
|
||||
await instanceStore
|
||||
.updateInstanceConfigurations(payload)
|
||||
.then(() =>
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: "Google Configuration Settings updated successfully",
|
||||
})
|
||||
)
|
||||
.catch((err) => console.error(err));
|
||||
};
|
||||
|
||||
const originURL = typeof window !== "undefined" ? window.location.origin : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-col grid-cols-1 lg:grid-cols-2 items-center justify-between gap-x-16 gap-y-8 w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm">Client ID</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="GOOGLE_CLIENT_ID"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="GOOGLE_CLIENT_ID"
|
||||
name="GOOGLE_CLIENT_ID"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.GOOGLE_CLIENT_ID)}
|
||||
placeholder="Google Client ID"
|
||||
className="rounded-md font-medium w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm">Client Secret</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="GOOGLE_CLIENT_SECRET"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="GOOGLE_CLIENT_SECRET"
|
||||
name="GOOGLE_CLIENT_SECRET"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.GOOGLE_CLIENT_SECRET)}
|
||||
placeholder="Google Client Secret"
|
||||
className="rounded-md font-medium w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-col grid-cols-1 lg:grid-cols-2 items-center justify-between gap-x-16 gap-y-8 w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm">Origin URL</h4>
|
||||
<Button
|
||||
variant="neutral-primary"
|
||||
className="py-2 flex justify-between items-center"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(originURL);
|
||||
setToastAlert({
|
||||
message: "The Origin URL has been successfully copied to your clipboard",
|
||||
type: "success",
|
||||
title: "Copied to clipboard",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<p className="font-medium text-sm">{originURL}</p>
|
||||
<Copy size={18} color="#B9B9B9" />
|
||||
</Button>
|
||||
<p className="text-xs text-custom-text-400/60">*paste this URL in your Google developer console.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center p-2">
|
||||
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
137
web/components/instance/openai-form.tsx
Normal file
137
web/components/instance/openai-form.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { FC } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
// types
|
||||
import { IFormattedInstanceConfiguration } from "types/instance";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
|
||||
export interface IInstanceOpenAIForm {
|
||||
config: IFormattedInstanceConfiguration;
|
||||
}
|
||||
|
||||
export interface OpenAIFormValues {
|
||||
OPENAI_API_BASE: string;
|
||||
OPENAI_API_KEY: string;
|
||||
GPT_ENGINE: string;
|
||||
}
|
||||
|
||||
export const InstanceOpenAIForm: FC<IInstanceOpenAIForm> = (props) => {
|
||||
const { config } = props;
|
||||
// store
|
||||
const { instance: instanceStore } = useMobxStore();
|
||||
// toast
|
||||
const { setToastAlert } = useToast();
|
||||
// form data
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<OpenAIFormValues>({
|
||||
defaultValues: {
|
||||
OPENAI_API_BASE: config["OPENAI_API_BASE"],
|
||||
OPENAI_API_KEY: config["OPENAI_API_KEY"],
|
||||
GPT_ENGINE: config["GPT_ENGINE"],
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (formData: OpenAIFormValues) => {
|
||||
const payload: Partial<OpenAIFormValues> = { ...formData };
|
||||
|
||||
await instanceStore
|
||||
.updateInstanceConfigurations(payload)
|
||||
.then(() =>
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: "Open AI Settings updated successfully",
|
||||
})
|
||||
)
|
||||
.catch((err) => console.error(err));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 m-8 w-4/5">
|
||||
<div className="pb-2 mb-2 border-b border-custom-border-100">
|
||||
<div className="text-custom-text-100 font-medium text-lg">OpenAI</div>
|
||||
<div className="text-custom-text-300 font-normal text-sm">
|
||||
AI is everywhere make use it as much as you can! <a href="#" className="text-custom-primary-100">Learn more.</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-col grid-cols-1 lg:grid-cols-2 items-center justify-between gap-x-16 gap-y-8 w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm">OpenAI API Base</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="OPENAI_API_BASE"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="OPENAI_API_BASE"
|
||||
name="OPENAI_API_BASE"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.OPENAI_API_BASE)}
|
||||
placeholder="OpenAI API Base"
|
||||
className="rounded-md font-medium w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm">OpenAI API Key</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="OPENAI_API_KEY"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="OPENAI_API_KEY"
|
||||
name="OPENAI_API_KEY"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.OPENAI_API_KEY)}
|
||||
placeholder="OpenAI API Key"
|
||||
className="rounded-md font-medium w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-col grid-cols-1 lg:grid-cols-2 items-center justify-between gap-x-16 gap-y-8 w-full">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm">GPT Engine</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="GPT_ENGINE"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="GPT_ENGINE"
|
||||
name="GPT_ENGINE"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.GPT_ENGINE)}
|
||||
placeholder="GPT Engine"
|
||||
className="rounded-md font-medium w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center py-1">
|
||||
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -3,7 +3,7 @@ import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import Link from "next/link";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { LogOut, Settings, Shield, UserCircle2 } from "lucide-react";
|
||||
import { Cog, LogIn, LogOut, Settings, UserCircle2 } from "lucide-react";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// hooks
|
||||
@ -11,7 +11,7 @@ import useToast from "hooks/use-toast";
|
||||
// services
|
||||
import { AuthService } from "services/auth.service";
|
||||
// ui
|
||||
import { Avatar } from "@plane/ui";
|
||||
import { Avatar, Tooltip } from "@plane/ui";
|
||||
|
||||
// Static Data
|
||||
const profileLinks = (workspaceSlug: string, userId: string) => [
|
||||
@ -70,19 +70,30 @@ export const InstanceSidebarDropdown = observer(() => {
|
||||
sidebarCollapsed ? "justify-center" : ""
|
||||
}`}
|
||||
>
|
||||
<div className={`flex-shrink-0 `}>
|
||||
<Shield className="h-6 w-6 text-custom-text-100" />
|
||||
<div className={`flex-shrink-0 flex items-center justify-center h-6 w-6 bg-custom-sidebar-background-80 rounded`}>
|
||||
<Cog className="h-5 w-5 text-custom-text-200" />
|
||||
</div>
|
||||
|
||||
{!sidebarCollapsed && (
|
||||
<h4 className="text-custom-text-100 font-medium text-base truncate">Instance Admin Settings</h4>
|
||||
<h4 className="text-custom-text-200 font-medium text-base truncate">Instance Admin</h4>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!sidebarCollapsed && (
|
||||
<Menu as="div" className="relative flex-shrink-0">
|
||||
<Menu.Button className="grid place-items-center outline-none">
|
||||
<Menu.Button className="flex gap-4 place-items-center outline-none">
|
||||
{!sidebarCollapsed && (
|
||||
<Tooltip position="bottom-left" tooltipContent="Go back to your workspace">
|
||||
<div className="flex-shrink-0">
|
||||
<Link href={`/${redirectWorkspaceSlug}`}>
|
||||
<a>
|
||||
<LogIn className="h-5 w-5 text-custom-text-200 rotate-180" />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Avatar
|
||||
name={currentUser?.display_name}
|
||||
src={currentUser?.avatar}
|
||||
@ -132,7 +143,7 @@ export const InstanceSidebarDropdown = observer(() => {
|
||||
|
||||
<div className="p-2 pb-0">
|
||||
<Menu.Item as="button" type="button" className="w-full">
|
||||
<Link href={redirectWorkspaceSlug}>
|
||||
<Link href={`/${redirectWorkspaceSlug}`}>
|
||||
<a className="flex w-full items-center justify-center rounded px-2 py-1 text-sm font-medium text-custom-primary-100 hover:text-custom-primary-200 bg-custom-primary-10 hover:bg-custom-primary-20">
|
||||
Normal Mode
|
||||
</a>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { BarChart2, Briefcase, CheckCircle, LayoutGrid } from "lucide-react";
|
||||
// icons
|
||||
import { BrainCog, Cog, Lock, Mail } from "lucide-react";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// ui
|
||||
@ -8,24 +9,28 @@ import { Tooltip } from "@plane/ui";
|
||||
|
||||
const INSTANCE_ADMIN_LINKS = [
|
||||
{
|
||||
Icon: LayoutGrid,
|
||||
Icon: Cog,
|
||||
name: "General",
|
||||
description: "General settings here",
|
||||
href: `/admin`,
|
||||
},
|
||||
{
|
||||
Icon: BarChart2,
|
||||
name: "OAuth",
|
||||
href: `/admin/oauth`,
|
||||
},
|
||||
{
|
||||
Icon: Briefcase,
|
||||
Icon: Mail,
|
||||
name: "Email",
|
||||
description: "Email related settings will go here",
|
||||
href: `/admin/email`,
|
||||
},
|
||||
{
|
||||
Icon: CheckCircle,
|
||||
name: "AI",
|
||||
href: `/admin/ai`,
|
||||
Icon: Lock,
|
||||
name: "Authorization",
|
||||
description: "Autorization",
|
||||
href: `/admin/authorization`,
|
||||
},
|
||||
{
|
||||
Icon: BrainCog,
|
||||
name: "OpenAI",
|
||||
description: "OpenAI configurations",
|
||||
href: `/admin/openai`,
|
||||
},
|
||||
];
|
||||
|
||||
@ -37,7 +42,7 @@ export const InstanceAdminSidebarMenu = () => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto w-full cursor-pointer space-y-2 p-4">
|
||||
<div className="h-full overflow-y-auto w-full cursor-pointer space-y-3 p-4">
|
||||
{INSTANCE_ADMIN_LINKS.map((item, index) => {
|
||||
const isActive = item.name === "Settings" ? router.asPath.includes(item.href) : router.asPath === item.href;
|
||||
|
||||
@ -46,14 +51,29 @@ export const InstanceAdminSidebarMenu = () => {
|
||||
<a className="block w-full">
|
||||
<Tooltip tooltipContent={item.name} position="right" className="ml-2" disabled={!sidebarCollapsed}>
|
||||
<div
|
||||
className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
|
||||
className={`group flex w-full items-center gap-3 rounded-md px-3 py-2 outline-none ${
|
||||
isActive
|
||||
? "bg-custom-primary-100/10 text-custom-primary-100"
|
||||
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
|
||||
} ${sidebarCollapsed ? "justify-center" : ""}`}
|
||||
>
|
||||
{<item.Icon className="h-4 w-4" />}
|
||||
{!sidebarCollapsed && item.name}
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex flex-col leading-snug">
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
isActive ? "text-custom-primary-100" : "text-custom-sidebar-text-200"
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs ${isActive ? "text-custom-primary-100" : "text-custom-sidebar-text-300"}`}
|
||||
>
|
||||
{item.description}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</a>
|
||||
|
@ -313,7 +313,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
|
@ -186,7 +186,7 @@ export const CreateUpdateLabelInline = observer(
|
||||
id="labelName"
|
||||
name="name"
|
||||
type="text"
|
||||
autofocus
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
|
@ -1,35 +1,33 @@
|
||||
import React from "react";
|
||||
import React, { FC } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { mutate } from "swr";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import { PageService } from "services/page.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { PageForm } from "./page-form";
|
||||
// types
|
||||
import { IUser, IPage } from "types";
|
||||
// fetch-keys
|
||||
import { ALL_PAGES_LIST, FAVORITE_PAGES_LIST, MY_PAGES_LIST, RECENT_PAGES_LIST } from "constants/fetch-keys";
|
||||
import { IPage } from "types";
|
||||
// store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// helpers
|
||||
import { trackEvent } from "helpers/event-tracker.helper";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
handleClose: () => void;
|
||||
data?: IPage | null;
|
||||
user: IUser | undefined;
|
||||
workspaceSlug: string;
|
||||
handleClose: () => void;
|
||||
isOpen: boolean;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
// services
|
||||
const pageService = new PageService();
|
||||
|
||||
export const CreateUpdatePageModal: React.FC<Props> = (props) => {
|
||||
const { isOpen, handleClose, data, workspaceSlug, projectId } = props;
|
||||
export const CreateUpdatePageModal: FC<Props> = (props) => {
|
||||
const { isOpen, handleClose, data, projectId } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
// store
|
||||
const {
|
||||
page: { createPage, updatePage },
|
||||
} = useMobxStore();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
@ -37,43 +35,22 @@ export const CreateUpdatePageModal: React.FC<Props> = (props) => {
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const createPage = async (payload: IPage) => {
|
||||
await pageService
|
||||
.createPage(workspaceSlug as string, projectId as string, payload)
|
||||
const createProjectPage = async (payload: IPage) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
createPage(workspaceSlug.toString(), projectId, payload)
|
||||
.then((res) => {
|
||||
mutate(RECENT_PAGES_LIST(projectId as string));
|
||||
mutate<IPage[]>(
|
||||
MY_PAGES_LIST(projectId as string),
|
||||
(prevData) => {
|
||||
if (!prevData) return undefined;
|
||||
|
||||
return [res, ...(prevData as IPage[])];
|
||||
},
|
||||
false
|
||||
);
|
||||
mutate<IPage[]>(
|
||||
ALL_PAGES_LIST(projectId as string),
|
||||
(prevData) => {
|
||||
if (!prevData) return undefined;
|
||||
|
||||
return [res, ...(prevData as IPage[])];
|
||||
},
|
||||
false
|
||||
);
|
||||
onClose();
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/pages/${res.id}`);
|
||||
onClose();
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Page created successfully.",
|
||||
});
|
||||
trackEvent(
|
||||
'PAGE_CREATE',
|
||||
{
|
||||
...res,
|
||||
caase: "SUCCES"
|
||||
}
|
||||
)
|
||||
trackEvent("PAGE_CREATE", {
|
||||
...res,
|
||||
case: "SUCCESS",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
@ -81,64 +58,27 @@ export const CreateUpdatePageModal: React.FC<Props> = (props) => {
|
||||
title: "Error!",
|
||||
message: "Page could not be created. Please try again.",
|
||||
});
|
||||
trackEvent(
|
||||
'PAGE_CREATE',
|
||||
{
|
||||
case: "FAILED"
|
||||
}
|
||||
)
|
||||
trackEvent("PAGE_CREATE", {
|
||||
case: "FAILED",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const updatePage = async (payload: IPage) => {
|
||||
await pageService
|
||||
.patchPage(workspaceSlug as string, projectId as string, data?.id ?? "", payload)
|
||||
const updateProjectPage = async (payload: IPage) => {
|
||||
if (!data || !workspaceSlug) return;
|
||||
|
||||
return updatePage(workspaceSlug.toString(), projectId, data.id, payload)
|
||||
.then((res) => {
|
||||
mutate(RECENT_PAGES_LIST(projectId as string));
|
||||
mutate<IPage[]>(
|
||||
FAVORITE_PAGES_LIST(projectId as string),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (p.id === res.id) return { ...p, ...res };
|
||||
|
||||
return p;
|
||||
}),
|
||||
false
|
||||
);
|
||||
mutate<IPage[]>(
|
||||
MY_PAGES_LIST(projectId as string),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (p.id === res.id) return { ...p, ...res };
|
||||
|
||||
return p;
|
||||
}),
|
||||
false
|
||||
);
|
||||
mutate<IPage[]>(
|
||||
ALL_PAGES_LIST(projectId as string),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (p.id === res.id) return { ...p, ...res };
|
||||
|
||||
return p;
|
||||
}),
|
||||
false
|
||||
);
|
||||
onClose();
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Page updated successfully.",
|
||||
});
|
||||
trackEvent(
|
||||
'PAGE_UPDATE',
|
||||
{
|
||||
...res,
|
||||
case: "SUCCESS"
|
||||
}
|
||||
)
|
||||
trackEvent("PAGE_UPDATE", {
|
||||
...res,
|
||||
case: "SUCCESS",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
@ -146,20 +86,17 @@ export const CreateUpdatePageModal: React.FC<Props> = (props) => {
|
||||
title: "Error!",
|
||||
message: "Page could not be updated. Please try again.",
|
||||
});
|
||||
trackEvent(
|
||||
'PAGE_UPDATE',
|
||||
{
|
||||
case: "FAILED"
|
||||
}
|
||||
)
|
||||
trackEvent("PAGE_UPDATE", {
|
||||
case: "FAILED",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (formData: IPage) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
if (!data) await createPage(formData);
|
||||
else await updatePage(formData);
|
||||
if (!data) await createProjectPage(formData);
|
||||
else await updateProjectPage(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
@ -178,7 +115,7 @@ export const CreateUpdatePageModal: React.FC<Props> = (props) => {
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<div className="flex justify-center text-center p-4 sm:p-0 my-10 md:my-20">
|
||||
<Transition.Child
|
||||
as={React.Fragment}
|
||||
enter="ease-out duration-300"
|
||||
@ -188,13 +125,8 @@ export const CreateUpdatePageModal: React.FC<Props> = (props) => {
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 px-5 py-8 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||
<PageForm
|
||||
handleFormSubmit={handleFormSubmit}
|
||||
handleClose={handleClose}
|
||||
status={data ? true : false}
|
||||
data={data}
|
||||
/>
|
||||
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 p-5 text-left shadow-custom-shadow-md transition-all px-4 sm:w-full sm:max-w-2xl">
|
||||
<PageForm handleFormSubmit={handleFormSubmit} handleClose={handleClose} data={data} />
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
|
@ -1,13 +1,9 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { mutate } from "swr";
|
||||
|
||||
// headless ui
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// services
|
||||
import { PageService } from "services/page.service";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
@ -15,56 +11,40 @@ import { Button } from "@plane/ui";
|
||||
// icons
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
// types
|
||||
import type { IUser, IPage } from "types";
|
||||
// fetch-keys
|
||||
import { ALL_PAGES_LIST, FAVORITE_PAGES_LIST, MY_PAGES_LIST, RECENT_PAGES_LIST } from "constants/fetch-keys";
|
||||
import type { IPage } from "types";
|
||||
|
||||
type TConfirmPageDeletionProps = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
data?: IPage | null;
|
||||
user: IUser | undefined;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
// services
|
||||
const pageService = new PageService();
|
||||
export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((props) => {
|
||||
const { data, isOpen, onClose } = props;
|
||||
|
||||
export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({ isOpen, setIsOpen, data }) => {
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const {
|
||||
page: { deletePage },
|
||||
} = useMobxStore();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
setIsDeleteLoading(false);
|
||||
setIsDeleting(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDeletion = async () => {
|
||||
setIsDeleteLoading(true);
|
||||
const handleDelete = async () => {
|
||||
if (!data || !workspaceSlug || !projectId) return;
|
||||
|
||||
await pageService
|
||||
.deletePage(workspaceSlug as string, data.project, data.id)
|
||||
setIsDeleting(true);
|
||||
|
||||
await deletePage(workspaceSlug.toString(), data.project, data.id)
|
||||
.then(() => {
|
||||
mutate(RECENT_PAGES_LIST(projectId as string));
|
||||
mutate<IPage[]>(
|
||||
MY_PAGES_LIST(projectId as string),
|
||||
(prevData) => (prevData ?? []).filter((page) => page.id !== data?.id),
|
||||
false
|
||||
);
|
||||
mutate<IPage[]>(
|
||||
ALL_PAGES_LIST(projectId as string),
|
||||
(prevData) => (prevData ?? []).filter((page) => page.id !== data?.id),
|
||||
false
|
||||
);
|
||||
mutate<IPage[]>(
|
||||
FAVORITE_PAGES_LIST(projectId as string),
|
||||
(prevData) => (prevData ?? []).filter((page) => page.id !== data?.id),
|
||||
false
|
||||
);
|
||||
handleClose();
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
@ -80,7 +60,7 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({ isOpen, s
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setIsDeleteLoading(false);
|
||||
setIsDeleting(false);
|
||||
});
|
||||
};
|
||||
|
||||
@ -122,9 +102,9 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({ isOpen, s
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Are you sure you want to delete Page-{" "}
|
||||
<span className="break-words font-medium text-custom-text-100">{data?.name}</span>? All of the
|
||||
data related to the page will be permanently removed. This action cannot be undone.
|
||||
Are you sure you want to delete page-{" "}
|
||||
<span className="break-words font-medium text-custom-text-100">{data?.name}</span>? The Page
|
||||
will be deleted permanently. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -134,8 +114,8 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({ isOpen, s
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" tabIndex={1} onClick={handleDeletion} loading={isDeleteLoading}>
|
||||
{isDeleteLoading ? "Deleting..." : "Delete"}
|
||||
<Button variant="danger" size="sm" tabIndex={1} onClick={handleDelete} loading={isDeleting}>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
@ -145,4 +125,4 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({ isOpen, s
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -1,10 +1,6 @@
|
||||
export * from "./pages-list";
|
||||
export * from "./create-block";
|
||||
export * from "./create-update-block-inline";
|
||||
export * from "./create-update-page-modal";
|
||||
export * from "./delete-page-modal";
|
||||
export * from "./page-form";
|
||||
export * from "./pages-view";
|
||||
export * from "./single-page-block";
|
||||
export * from "./single-page-detailed-item";
|
||||
export * from "./single-page-list-item";
|
||||
export * from "./create-block";
|
||||
|
@ -1,23 +1,24 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
// ui
|
||||
import { Button, Input } from "@plane/ui";
|
||||
import { Button, Input, Tooltip } from "@plane/ui";
|
||||
// types
|
||||
import { IPage } from "types";
|
||||
import { PAGE_ACCESS_SPECIFIERS } from "constants/page";
|
||||
|
||||
type Props = {
|
||||
handleFormSubmit: (values: IPage) => Promise<void>;
|
||||
handleClose: () => void;
|
||||
status: boolean;
|
||||
data?: IPage | null;
|
||||
};
|
||||
|
||||
const defaultValues = {
|
||||
name: "",
|
||||
description: "",
|
||||
access: 0,
|
||||
};
|
||||
|
||||
export const PageForm: React.FC<Props> = ({ handleFormSubmit, handleClose, status, data }) => {
|
||||
export const PageForm: React.FC<Props> = ({ handleFormSubmit, handleClose, data }) => {
|
||||
const {
|
||||
formState: { errors, isSubmitting },
|
||||
handleSubmit,
|
||||
@ -44,8 +45,8 @@ export const PageForm: React.FC<Props> = ({ handleFormSubmit, handleClose, statu
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleCreateUpdatePage)}>
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-lg font-medium leading-6 text-custom-text-100">{status ? "Update" : "Create"} Page</h3>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium leading-6 text-custom-text-100">{data ? "Update" : "Create"} Page</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Controller
|
||||
@ -61,33 +62,65 @@ export const PageForm: React.FC<Props> = ({ handleFormSubmit, handleClose, statu
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.name)}
|
||||
placeholder="Title"
|
||||
className="resize-none text-xl w-full"
|
||||
className="resize-none text-lg w-full"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{status
|
||||
? isSubmitting
|
||||
? "Updating Page..."
|
||||
: "Update Page"
|
||||
: isSubmitting
|
||||
? "Creating Page..."
|
||||
: "Create Page"}
|
||||
</Button>
|
||||
<div className="mt-5 flex items-center justify-between gap-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="access"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-shrink-0 flex items-stretch gap-0.5 border-[0.5px] border-custom-border-200 rounded p-1">
|
||||
{PAGE_ACCESS_SPECIFIERS.map((access) => (
|
||||
<Tooltip key={access.key} tooltipContent={access.label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(access.key)}
|
||||
className={`aspect-square grid place-items-center p-1 rounded-sm hover:bg-custom-background-90 ${
|
||||
value === access.key ? "bg-custom-background-90" : ""
|
||||
}`}
|
||||
>
|
||||
<access.icon
|
||||
className={`w-3.5 h-3.5 ${
|
||||
value === access.key ? "text-custom-text-100" : "text-custom-text-400"
|
||||
}`}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
<h6 className="text-xs font-medium">
|
||||
{PAGE_ACCESS_SPECIFIERS.find((access) => access.key === value)?.label}
|
||||
</h6>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||
{data
|
||||
? isSubmitting
|
||||
? "Updating Page..."
|
||||
: "Update Page"
|
||||
: isSubmitting
|
||||
? "Creating Page..."
|
||||
: "Create Page"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
@ -1,29 +1,26 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import { PageService } from "services/page.service";
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { PagesView } from "components/pages";
|
||||
// types
|
||||
import { TPagesListProps } from "./types";
|
||||
import { PagesListView } from "components/pages/pages-list";
|
||||
// fetch-keys
|
||||
import { ALL_PAGES_LIST } from "constants/fetch-keys";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
// services
|
||||
const pageService = new PageService();
|
||||
export const AllPagesList: FC = observer(() => {
|
||||
// store
|
||||
const {
|
||||
page: { projectPages },
|
||||
} = useMobxStore();
|
||||
|
||||
export const AllPagesList: React.FC<TPagesListProps> = ({ viewType }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
if (!projectPages)
|
||||
return (
|
||||
<Loader className="space-y-4">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
);
|
||||
|
||||
const { data: pages } = useSWR(
|
||||
workspaceSlug && projectId ? ALL_PAGES_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => pageService.getPagesWithParams(workspaceSlug as string, projectId as string, "all")
|
||||
: null
|
||||
);
|
||||
|
||||
return <PagesView pages={pages} viewType={viewType} />;
|
||||
};
|
||||
return <PagesListView pages={projectPages} />;
|
||||
});
|
||||
|
25
web/components/pages/pages-list/archived-pages-list.tsx
Normal file
25
web/components/pages/pages-list/archived-pages-list.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { PagesListView } from "components/pages/pages-list";
|
||||
// hooks
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const ArchivedPagesList: FC = observer(() => {
|
||||
const {
|
||||
page: { archivedProjectPages },
|
||||
} = useMobxStore();
|
||||
|
||||
if (!archivedProjectPages)
|
||||
return (
|
||||
<Loader className="space-y-4">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
);
|
||||
|
||||
return <PagesListView pages={archivedProjectPages} />;
|
||||
});
|
@ -1,29 +1,25 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import { PageService } from "services/page.service";
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { PagesView } from "components/pages";
|
||||
// types
|
||||
import { TPagesListProps } from "./types";
|
||||
// fetch-keys
|
||||
import { FAVORITE_PAGES_LIST } from "constants/fetch-keys";
|
||||
import { PagesListView } from "components/pages/pages-list";
|
||||
// hooks
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
// services
|
||||
const pageService = new PageService();
|
||||
export const FavoritePagesList: FC = observer(() => {
|
||||
const {
|
||||
page: { favoriteProjectPages },
|
||||
} = useMobxStore();
|
||||
|
||||
export const FavoritePagesList: React.FC<TPagesListProps> = ({ viewType }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
if (!favoriteProjectPages)
|
||||
return (
|
||||
<Loader className="space-y-4">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
);
|
||||
|
||||
const { data: pages } = useSWR(
|
||||
workspaceSlug && projectId ? FAVORITE_PAGES_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => pageService.getPagesWithParams(workspaceSlug as string, projectId as string, "favorite")
|
||||
: null
|
||||
);
|
||||
|
||||
return <PagesView pages={pages} viewType={viewType} />;
|
||||
};
|
||||
return <PagesListView pages={favoriteProjectPages} />;
|
||||
});
|
||||
|
@ -1,6 +1,8 @@
|
||||
export * from "./all-pages-list";
|
||||
export * from "./archived-pages-list";
|
||||
export * from "./favorite-pages-list";
|
||||
export * from "./my-pages-list";
|
||||
export * from "./other-pages-list";
|
||||
export * from "./private-page-list";
|
||||
export * from "./shared-pages-list";
|
||||
export * from "./recent-pages-list";
|
||||
export * from "./types";
|
||||
export * from "./list-view";
|
||||
|
303
web/components/pages/pages-list/list-item.tsx
Normal file
303
web/components/pages/pages-list/list-item.tsx
Normal file
@ -0,0 +1,303 @@
|
||||
import { FC, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// icons
|
||||
import {
|
||||
AlertCircle,
|
||||
Archive,
|
||||
ArchiveRestoreIcon,
|
||||
FileText,
|
||||
Globe2,
|
||||
LinkIcon,
|
||||
Lock,
|
||||
Pencil,
|
||||
Star,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// helpers
|
||||
import { copyUrlToClipboard } from "helpers/string.helper";
|
||||
import { renderShortDate, render24HourFormatTime, renderLongDateFormat } from "helpers/date-time.helper";
|
||||
// ui
|
||||
import { CustomMenu, Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { CreateUpdatePageModal, DeletePageModal } from "components/pages";
|
||||
// types
|
||||
import { IPage } from "types";
|
||||
|
||||
export interface IPagesListItem {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
page: IPage;
|
||||
}
|
||||
|
||||
export const PagesListItem: FC<IPagesListItem> = observer((props) => {
|
||||
const { workspaceSlug, projectId, page } = props;
|
||||
// states
|
||||
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
|
||||
const [deletePageModal, setDeletePageModal] = useState(false);
|
||||
// store
|
||||
const {
|
||||
page: { archivePage, removeFromFavorites, addToFavorites, makePublic, makePrivate, restorePage },
|
||||
user: { currentProjectRole },
|
||||
projectMember: { projectMembers },
|
||||
} = useMobxStore();
|
||||
// hooks
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const handleCopyUrl = (e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/pages/${page.id}`).then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Link Copied!",
|
||||
message: "Page link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddToFavorites = (e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
addToFavorites(workspaceSlug, projectId, page.id)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Successfully added the page to favorites.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the page to favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = (e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
removeFromFavorites(workspaceSlug, projectId, page.id)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Successfully removed the page from favorites.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't remove the page from favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleMakePublic = (e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
makePublic(workspaceSlug, projectId, page.id);
|
||||
};
|
||||
|
||||
const handleMakePrivate = (e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
makePrivate(workspaceSlug, projectId, page.id);
|
||||
};
|
||||
|
||||
const handleArchivePage = (e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
archivePage(workspaceSlug, projectId, page.id);
|
||||
};
|
||||
|
||||
const handleRestorePage = (e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
restorePage(workspaceSlug, projectId, page.id);
|
||||
};
|
||||
|
||||
const handleDeletePage = (e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setDeletePageModal(true);
|
||||
};
|
||||
|
||||
const handleEditPage = (e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setCreateUpdatePageModal(true);
|
||||
};
|
||||
|
||||
const userCanEdit = currentProjectRole === 15 || currentProjectRole === 20;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdatePageModal
|
||||
isOpen={createUpdatePageModal}
|
||||
handleClose={() => setCreateUpdatePageModal(false)}
|
||||
data={page}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} data={page} />
|
||||
<li>
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${page.id}`}>
|
||||
<a>
|
||||
<div className="relative rounded p-4 text-custom-text-200 hover:bg-custom-background-80">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex overflow-hidden items-center gap-2">
|
||||
<FileText className="h-4 w-4 shrink-0" />
|
||||
<p className="mr-2 truncate text-sm text-custom-text-100">{page.name}</p>
|
||||
{page.label_details.length > 0 &&
|
||||
page.label_details.map((label) => (
|
||||
<div
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border border-custom-border-200 px-2 py-0.5 text-xs"
|
||||
style={{
|
||||
backgroundColor: `${label?.color}20`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color,
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{page.archived_at ? (
|
||||
<Tooltip
|
||||
tooltipContent={`Archived at ${render24HourFormatTime(page.archived_at)} on ${renderShortDate(
|
||||
page.archived_at
|
||||
)}`}
|
||||
>
|
||||
<p className="text-sm text-custom-text-200">{render24HourFormatTime(page.archived_at)}</p>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip
|
||||
tooltipContent={`Last updated at ${render24HourFormatTime(page.updated_at)} on ${renderShortDate(
|
||||
page.updated_at
|
||||
)}`}
|
||||
>
|
||||
<p className="text-sm text-custom-text-200">{render24HourFormatTime(page.updated_at)}</p>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!page.archived_at && userCanEdit && (
|
||||
<Tooltip tooltipContent={`${page.is_favorite ? "Remove from favorites" : "Mark as favorite"}`}>
|
||||
{page.is_favorite ? (
|
||||
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||
<Star className="h-3.5 w-3.5 text-orange-400 fill-orange-400" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={handleAddToFavorites}>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
{!page.archived_at && userCanEdit && (
|
||||
<Tooltip
|
||||
tooltipContent={`${
|
||||
page.access
|
||||
? "This page is only visible to you"
|
||||
: "This page can be viewed by anyone in the project"
|
||||
}`}
|
||||
>
|
||||
{page.access ? (
|
||||
<button type="button" onClick={handleMakePublic}>
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={handleMakePrivate}>
|
||||
<Globe2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip
|
||||
position="top-right"
|
||||
tooltipContent={`Created by ${
|
||||
projectMembers?.find((projectMember) => projectMember.member.id === page.created_by)?.member
|
||||
.display_name ?? ""
|
||||
} on ${renderLongDateFormat(`${page.created_at}`)}`}
|
||||
>
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
</Tooltip>
|
||||
{page.archived_at ? (
|
||||
<CustomMenu width="auto" placement="bottom-end" className="!-m-1" verticalEllipsis>
|
||||
{userCanEdit && (
|
||||
<>
|
||||
<CustomMenu.MenuItem onClick={handleRestorePage}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ArchiveRestoreIcon className="h-3 w-3" />
|
||||
<span>Restore page</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleDeletePage}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
<span>Delete page</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={handleCopyUrl}>
|
||||
<div className="flex items-center gap-2">
|
||||
<LinkIcon className="h-3 w-3" />
|
||||
<span>Copy page link</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
) : (
|
||||
<CustomMenu width="auto" placement="bottom-end" className="!-m-1" verticalEllipsis>
|
||||
{userCanEdit && (
|
||||
<>
|
||||
<CustomMenu.MenuItem onClick={handleEditPage}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="h-3 w-3" />
|
||||
<span>Edit page</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleArchivePage}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Archive className="h-3 w-3" />
|
||||
<span>Archive page</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={handleCopyUrl}>
|
||||
<div className="flex items-center gap-2">
|
||||
<LinkIcon className="h-3 w-3" />
|
||||
<span>Copy page link</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
</>
|
||||
);
|
||||
});
|
65
web/components/pages/pages-list/list-view.tsx
Normal file
65
web/components/pages/pages-list/list-view.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { FC } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Plus } from "lucide-react";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// components
|
||||
import { EmptyState } from "components/common";
|
||||
import { PagesListItem } from "./list-item";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// images
|
||||
import emptyPage from "public/empty-state/page.svg";
|
||||
// types
|
||||
import { IPage } from "types";
|
||||
|
||||
type IPagesListView = {
|
||||
pages: IPage[];
|
||||
};
|
||||
|
||||
export const PagesListView: FC<IPagesListView> = observer(({ pages }) => {
|
||||
// store
|
||||
const { commandPalette: commandPaletteStore } = useMobxStore();
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
return (
|
||||
<>
|
||||
{pages && workspaceSlug && projectId ? (
|
||||
<div className="space-y-4 h-full overflow-y-auto">
|
||||
{pages.length > 0 ? (
|
||||
<ul role="list" className="divide-y divide-custom-border-200">
|
||||
{pages.map((page) => (
|
||||
<PagesListItem
|
||||
key={page.id}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
page={page}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Have your thoughts in place"
|
||||
description="You can think of Pages as an AI-powered notepad."
|
||||
image={emptyPage}
|
||||
primaryButton={{
|
||||
icon: <Plus className="h-4 w-4" />,
|
||||
text: "New Page",
|
||||
onClick: () => commandPaletteStore.toggleCreatePageModal(true),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Loader className="space-y-4">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
@ -1,29 +0,0 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import { PageService } from "services/page.service";
|
||||
// components
|
||||
import { PagesView } from "components/pages";
|
||||
// types
|
||||
import { TPagesListProps } from "./types";
|
||||
// fetch-keys
|
||||
import { MY_PAGES_LIST } from "constants/fetch-keys";
|
||||
|
||||
// services
|
||||
const pageService = new PageService();
|
||||
|
||||
export const MyPagesList: React.FC<TPagesListProps> = ({ viewType }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: pages } = useSWR(
|
||||
workspaceSlug && projectId ? MY_PAGES_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => pageService.getPagesWithParams(workspaceSlug as string, projectId as string, "created_by_me")
|
||||
: null
|
||||
);
|
||||
|
||||
return <PagesView pages={pages} viewType={viewType} />;
|
||||
};
|
@ -1,29 +0,0 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import useSWR from "swr";
|
||||
|
||||
// services
|
||||
import { PageService } from "services/page.service";
|
||||
// components
|
||||
import { PagesView } from "components/pages";
|
||||
// types
|
||||
import { TPagesListProps } from "./types";
|
||||
// fetch-keys
|
||||
import { OTHER_PAGES_LIST } from "constants/fetch-keys";
|
||||
|
||||
// services
|
||||
const pageService = new PageService();
|
||||
|
||||
export const OtherPagesList: React.FC<TPagesListProps> = ({ viewType }) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: pages } = useSWR(
|
||||
workspaceSlug && projectId ? OTHER_PAGES_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => pageService.getPagesWithParams(workspaceSlug as string, projectId as string, "created_by_other")
|
||||
: null
|
||||
);
|
||||
|
||||
return <PagesView pages={pages} viewType={viewType} />;
|
||||
};
|
25
web/components/pages/pages-list/private-page-list.tsx
Normal file
25
web/components/pages/pages-list/private-page-list.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { PagesListView } from "components/pages/pages-list";
|
||||
// hooks
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const PrivatePagesList: FC = observer(() => {
|
||||
const {
|
||||
page: { privateProjectPages },
|
||||
} = useMobxStore();
|
||||
|
||||
if (!privateProjectPages)
|
||||
return (
|
||||
<Loader className="space-y-4">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
);
|
||||
|
||||
return <PagesListView pages={privateProjectPages} />;
|
||||
});
|
@ -1,14 +1,10 @@
|
||||
import React from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { FC } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
import { Plus } from "lucide-react";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// services
|
||||
import { PageService } from "services/page.service";
|
||||
// components
|
||||
import { PagesView } from "components/pages";
|
||||
import { PagesListView } from "components/pages/pages-list";
|
||||
import { EmptyState } from "components/common";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
@ -16,47 +12,42 @@ import { Loader } from "@plane/ui";
|
||||
import emptyPage from "public/empty-state/page.svg";
|
||||
// helpers
|
||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { TPagesListProps } from "./types";
|
||||
import { RecentPagesResponse } from "types";
|
||||
// fetch-keys
|
||||
import { RECENT_PAGES_LIST } from "constants/fetch-keys";
|
||||
|
||||
// services
|
||||
const pageService = new PageService();
|
||||
export const RecentPagesList: FC = observer(() => {
|
||||
// store
|
||||
const {
|
||||
commandPalette: commandPaletteStore,
|
||||
page: { recentProjectPages },
|
||||
} = useMobxStore();
|
||||
|
||||
export const RecentPagesList: React.FC<TPagesListProps> = observer((props) => {
|
||||
const { viewType } = props;
|
||||
const isEmpty = recentProjectPages && Object.values(recentProjectPages).every((value) => value.length === 0);
|
||||
|
||||
const { commandPalette: commandPaletteStore } = useMobxStore();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { data: pages } = useSWR(
|
||||
workspaceSlug && projectId ? RECENT_PAGES_LIST(projectId as string) : null,
|
||||
workspaceSlug && projectId ? () => pageService.getRecentPages(workspaceSlug as string, projectId as string) : null
|
||||
);
|
||||
|
||||
const isEmpty = pages && Object.keys(pages).every((key) => pages[key].length === 0);
|
||||
if (!recentProjectPages) {
|
||||
return (
|
||||
<Loader className="space-y-4">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{pages ? (
|
||||
Object.keys(pages).length > 0 && !isEmpty ? (
|
||||
Object.keys(pages).map((key) => {
|
||||
if (pages[key].length === 0) return null;
|
||||
|
||||
{Object.keys(recentProjectPages).length > 0 && !isEmpty ? (
|
||||
<>
|
||||
{Object.keys(recentProjectPages).map((key) => {
|
||||
if (recentProjectPages[key].length === 0) return null;
|
||||
return (
|
||||
<div key={key} className="h-full overflow-hidden pb-9">
|
||||
<h2 className="text-xl font-semibold capitalize mb-2">
|
||||
{replaceUnderscoreIfSnakeCase(key)}
|
||||
</h2>
|
||||
<PagesView pages={pages[key as keyof RecentPagesResponse]} viewType={viewType} />
|
||||
<h2 className="text-xl font-semibold capitalize mb-2">{replaceUnderscoreIfSnakeCase(key)}</h2>
|
||||
<PagesListView pages={recentProjectPages[key]} />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EmptyState
|
||||
title="Have your thoughts in place"
|
||||
description="You can think of Pages as an AI-powered notepad."
|
||||
@ -67,13 +58,7 @@ export const RecentPagesList: React.FC<TPagesListProps> = observer((props) => {
|
||||
onClick: () => commandPaletteStore.toggleCreatePageModal(true),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<Loader className="space-y-4">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
25
web/components/pages/pages-list/shared-pages-list.tsx
Normal file
25
web/components/pages/pages-list/shared-pages-list.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { PagesListView } from "components/pages/pages-list";
|
||||
// hooks
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
export const SharedPagesList: FC = observer(() => {
|
||||
const {
|
||||
page: { sharedProjectPages },
|
||||
} = useMobxStore();
|
||||
|
||||
if (!sharedProjectPages)
|
||||
return (
|
||||
<Loader className="space-y-4">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
);
|
||||
|
||||
return <PagesListView pages={sharedProjectPages} />;
|
||||
});
|
@ -1,293 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { Plus } from "lucide-react";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// services
|
||||
import { PageService } from "services/page.service";
|
||||
import { ProjectMemberService } from "services/project";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { CreateUpdatePageModal, DeletePageModal, SinglePageDetailedItem, SinglePageListItem } from "components/pages";
|
||||
import { EmptyState } from "components/common";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// images
|
||||
import emptyPage from "public/empty-state/page.svg";
|
||||
// types
|
||||
import { IPage, TPageViewProps } from "types";
|
||||
import {
|
||||
ALL_PAGES_LIST,
|
||||
FAVORITE_PAGES_LIST,
|
||||
MY_PAGES_LIST,
|
||||
PROJECT_MEMBERS,
|
||||
RECENT_PAGES_LIST,
|
||||
} from "constants/fetch-keys";
|
||||
|
||||
type Props = {
|
||||
pages: IPage[] | undefined;
|
||||
viewType: TPageViewProps;
|
||||
};
|
||||
|
||||
// services
|
||||
const pageService = new PageService();
|
||||
const projectMemberService = new ProjectMemberService();
|
||||
|
||||
export const PagesView: React.FC<Props> = observer(({ pages, viewType }) => {
|
||||
// states
|
||||
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
|
||||
const [selectedPageToUpdate, setSelectedPageToUpdate] = useState<IPage | null>(null);
|
||||
const [deletePageModal, setDeletePageModal] = useState(false);
|
||||
const [selectedPageToDelete, setSelectedPageToDelete] = useState<IPage | null>(null);
|
||||
|
||||
const { user: userStore, commandPalette: commandPaletteStore } = useMobxStore();
|
||||
const user = userStore.currentUser ?? undefined;
|
||||
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { data: people } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId.toString()) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectMemberService.fetchProjectMembers(workspaceSlug.toString(), projectId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
const handleEditPage = (page: IPage) => {
|
||||
setSelectedPageToUpdate(page);
|
||||
setCreateUpdatePageModal(true);
|
||||
};
|
||||
|
||||
const handleDeletePage = (page: IPage) => {
|
||||
setSelectedPageToDelete(page);
|
||||
setDeletePageModal(true);
|
||||
};
|
||||
|
||||
const handleAddToFavorites = (page: IPage) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
mutate<IPage[]>(
|
||||
ALL_PAGES_LIST(projectId.toString()),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (p.id === page.id) p.is_favorite = true;
|
||||
|
||||
return p;
|
||||
}),
|
||||
false
|
||||
);
|
||||
mutate<IPage[]>(
|
||||
MY_PAGES_LIST(projectId.toString()),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (p.id === page.id) p.is_favorite = true;
|
||||
|
||||
return p;
|
||||
}),
|
||||
false
|
||||
);
|
||||
mutate<IPage[]>(FAVORITE_PAGES_LIST(projectId.toString()), (prevData) => [page, ...(prevData ?? [])], false);
|
||||
|
||||
pageService
|
||||
.addPageToFavorites(workspaceSlug.toString(), projectId.toString(), {
|
||||
page: page.id,
|
||||
})
|
||||
.then(() => {
|
||||
mutate(RECENT_PAGES_LIST(projectId.toString()));
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Successfully added the page to favorites.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't add the page to favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = (page: IPage) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
mutate<IPage[]>(
|
||||
ALL_PAGES_LIST(projectId.toString()),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (p.id === page.id) p.is_favorite = false;
|
||||
|
||||
return p;
|
||||
}),
|
||||
false
|
||||
);
|
||||
mutate<IPage[]>(
|
||||
MY_PAGES_LIST(projectId.toString()),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (p.id === page.id) p.is_favorite = false;
|
||||
|
||||
return p;
|
||||
}),
|
||||
false
|
||||
);
|
||||
mutate<IPage[]>(
|
||||
FAVORITE_PAGES_LIST(projectId.toString()),
|
||||
(prevData) => (prevData ?? []).filter((p) => p.id !== page.id),
|
||||
false
|
||||
);
|
||||
|
||||
pageService
|
||||
.removePageFromFavorites(workspaceSlug.toString(), projectId.toString(), page.id)
|
||||
.then(() => {
|
||||
mutate(RECENT_PAGES_LIST(projectId.toString()));
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Successfully removed the page from favorites.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Couldn't remove the page from favorites. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const partialUpdatePage = (page: IPage, formData: Partial<IPage>) => {
|
||||
if (!workspaceSlug || !projectId || !user) return;
|
||||
|
||||
mutate<IPage[]>(
|
||||
ALL_PAGES_LIST(projectId.toString()),
|
||||
(prevData) => (prevData ?? []).map((p) => ({ ...p, ...(p.id === page.id ? formData : {}) })),
|
||||
false
|
||||
);
|
||||
mutate<IPage[]>(
|
||||
MY_PAGES_LIST(projectId.toString()),
|
||||
(prevData) => (prevData ?? []).map((p) => ({ ...p, ...(p.id === page.id ? formData : {}) })),
|
||||
false
|
||||
);
|
||||
mutate<IPage[]>(
|
||||
FAVORITE_PAGES_LIST(projectId.toString()),
|
||||
(prevData) => (prevData ?? []).map((p) => ({ ...p, ...(p.id === page.id ? formData : {}) })),
|
||||
false
|
||||
);
|
||||
|
||||
pageService.patchPage(workspaceSlug.toString(), projectId.toString(), page.id, formData).then(() => {
|
||||
mutate(RECENT_PAGES_LIST(projectId.toString()));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{workspaceSlug && projectId && (
|
||||
<>
|
||||
<CreateUpdatePageModal
|
||||
isOpen={createUpdatePageModal}
|
||||
handleClose={() => setCreateUpdatePageModal(false)}
|
||||
data={selectedPageToUpdate}
|
||||
user={user}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
/>
|
||||
<DeletePageModal
|
||||
isOpen={deletePageModal}
|
||||
setIsOpen={setDeletePageModal}
|
||||
data={selectedPageToDelete}
|
||||
user={user}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{pages ? (
|
||||
<div className="space-y-4 h-full overflow-y-auto">
|
||||
{pages.length > 0 ? (
|
||||
viewType === "list" ? (
|
||||
<ul role="list" className="divide-y divide-custom-border-200">
|
||||
{pages.map((page) => (
|
||||
<SinglePageListItem
|
||||
key={page.id}
|
||||
page={page}
|
||||
people={people}
|
||||
handleEditPage={() => handleEditPage(page)}
|
||||
handleDeletePage={() => handleDeletePage(page)}
|
||||
handleAddToFavorites={() => handleAddToFavorites(page)}
|
||||
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
|
||||
partialUpdatePage={partialUpdatePage}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : viewType === "detailed" ? (
|
||||
<div className="divide-y divide-custom-border-200 rounded-[10px] border border-custom-border-200 bg-custom-background-100">
|
||||
{pages.map((page) => (
|
||||
<SinglePageDetailedItem
|
||||
key={page.id}
|
||||
page={page}
|
||||
people={people}
|
||||
handleEditPage={() => handleEditPage(page)}
|
||||
handleDeletePage={() => handleDeletePage(page)}
|
||||
handleAddToFavorites={() => handleAddToFavorites(page)}
|
||||
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
|
||||
partialUpdatePage={partialUpdatePage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-[10px] border border-custom-border-200">
|
||||
{pages.map((page) => (
|
||||
<SinglePageDetailedItem
|
||||
key={page.id}
|
||||
page={page}
|
||||
people={people}
|
||||
handleEditPage={() => handleEditPage(page)}
|
||||
handleDeletePage={() => handleDeletePage(page)}
|
||||
handleAddToFavorites={() => handleAddToFavorites(page)}
|
||||
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
|
||||
partialUpdatePage={partialUpdatePage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<EmptyState
|
||||
title="Have your thoughts in place"
|
||||
description="You can think of Pages as an AI-powered notepad."
|
||||
image={emptyPage}
|
||||
primaryButton={{
|
||||
icon: <Plus className="h-4 w-4" />,
|
||||
text: "New Page",
|
||||
onClick: () => commandPaletteStore.toggleCreatePageModal(true),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : viewType === "list" ? (
|
||||
<Loader className="space-y-4">
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
<Loader.Item height="40px" />
|
||||
</Loader>
|
||||
) : viewType === "detailed" ? (
|
||||
<Loader className="space-y-4">
|
||||
<Loader.Item height="150px" />
|
||||
<Loader.Item height="150px" />
|
||||
</Loader>
|
||||
) : (
|
||||
<Loader className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Loader.Item height="150px" />
|
||||
<Loader.Item height="150px" />
|
||||
<Loader.Item height="150px" />
|
||||
</Loader>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
@ -1,432 +0,0 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
import { mutate } from "swr";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Draggable } from "@hello-pangea/dnd";
|
||||
// services
|
||||
import { PageService } from "services/page.service";
|
||||
import { IssueService } from "services/issue/issue.service";
|
||||
import { AIService } from "services/ai.service";
|
||||
import { FileService } from "services/file.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
// components
|
||||
import { GptAssistantModal } from "components/core";
|
||||
import { CreateUpdateBlockInline } from "components/pages";
|
||||
import { RichTextEditor } from "@plane/rich-text-editor";
|
||||
// ui
|
||||
import { CustomMenu, LayersIcon, TextArea } from "@plane/ui";
|
||||
// icons
|
||||
import { RefreshCw, LinkIcon, Zap, Check, MoreVertical, Pencil, Sparkle, Trash2 } from "lucide-react";
|
||||
// helpers
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import { IUser, IIssue, IPageBlock, IProject } from "types";
|
||||
// fetch-keys
|
||||
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
|
||||
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
||||
|
||||
type Props = {
|
||||
block: IPageBlock;
|
||||
projectDetails: IProject | undefined;
|
||||
showBlockDetails: boolean;
|
||||
index: number;
|
||||
user: IUser | undefined;
|
||||
};
|
||||
|
||||
const aiService = new AIService();
|
||||
const pageService = new PageService();
|
||||
const issueService = new IssueService();
|
||||
const fileService = new FileService();
|
||||
|
||||
export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails, showBlockDetails, index, user }) => {
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [createBlockForm, setCreateBlockForm] = useState(false);
|
||||
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
|
||||
|
||||
const [gptAssistantModal, setGptAssistantModal] = useState(false);
|
||||
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
const actionSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, pageId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { handleSubmit, watch, reset, setValue } = useForm<IPageBlock>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: {},
|
||||
description_html: "<p></p>",
|
||||
},
|
||||
});
|
||||
|
||||
const editorSuggestion = useEditorSuggestions();
|
||||
|
||||
const updatePageBlock = async (formData: Partial<IPageBlock>) => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
|
||||
if (!formData.name || formData.name.length === 0 || formData.name === "") return;
|
||||
|
||||
if (block.issue && block.sync) setIsSyncing(true);
|
||||
|
||||
mutate<IPageBlock[]>(
|
||||
PAGE_BLOCKS_LIST(pageId as string),
|
||||
(prevData) =>
|
||||
prevData?.map((p) => {
|
||||
if (p.id === block.id) return { ...p, ...formData };
|
||||
|
||||
return p;
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
await pageService
|
||||
.patchPageBlock(workspaceSlug as string, projectId as string, pageId as string, block.id, {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
description_html: formData.description_html,
|
||||
})
|
||||
.then((res) => {
|
||||
mutate(PAGE_BLOCKS_LIST(pageId as string));
|
||||
if (block.issue && block.sync)
|
||||
issueService
|
||||
.patchIssue(workspaceSlug as string, projectId as string, block.issue, {
|
||||
name: res.name,
|
||||
description: res.description,
|
||||
description_html: res.description_html,
|
||||
})
|
||||
.finally(() => setIsSyncing(false));
|
||||
});
|
||||
};
|
||||
|
||||
const pushBlockIntoIssues = async () => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
|
||||
await pageService
|
||||
.convertPageBlockToIssue(workspaceSlug as string, projectId as string, pageId as string, block.id)
|
||||
.then((res: IIssue) => {
|
||||
mutate<IPageBlock[]>(
|
||||
PAGE_BLOCKS_LIST(pageId as string),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (p.id === block.id) return { ...p, issue: res.id, issue_detail: res };
|
||||
|
||||
return p;
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Page block converted to issue successfully.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Page block could not be converted to issue. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const deletePageBlock = async () => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
|
||||
mutate<IPageBlock[]>(
|
||||
PAGE_BLOCKS_LIST(pageId as string),
|
||||
(prevData) => (prevData ?? []).filter((p) => p.id !== block.id),
|
||||
false
|
||||
);
|
||||
|
||||
await pageService
|
||||
.deletePageBlock(workspaceSlug as string, projectId as string, pageId as string, block.id)
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Page could not be deleted. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleAutoGenerateDescription = async () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
setIAmFeelingLucky(true);
|
||||
|
||||
aiService
|
||||
.createGptTask(workspaceSlug as string, projectId as string, {
|
||||
prompt: block.name,
|
||||
task: "Generate a proper description for this issue.",
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.response === "")
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message:
|
||||
"Block title isn't informative enough to generate the description. Please try with a different title.",
|
||||
});
|
||||
else handleAiAssistance(res.response_html);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.status === 429)
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "You have reached the maximum number of requests of 50 requests per month per user.",
|
||||
});
|
||||
else
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Some error occurred. Please try again.",
|
||||
});
|
||||
})
|
||||
.finally(() => setIAmFeelingLucky(false));
|
||||
};
|
||||
|
||||
const handleAiAssistance = async (response: string) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
setValue("description", {});
|
||||
setValue("description_html", `${watch("description_html")}<p>${response}</p>`);
|
||||
handleSubmit(updatePageBlock)()
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success!",
|
||||
message: "Block description updated successfully.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Block description could not be updated. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleBlockSync = () => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
|
||||
mutate<IPageBlock[]>(
|
||||
PAGE_BLOCKS_LIST(pageId as string),
|
||||
(prevData) =>
|
||||
(prevData ?? []).map((p) => {
|
||||
if (p.id === block.id) return { ...p, sync: !block.sync };
|
||||
|
||||
return p;
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
pageService.patchPageBlock(workspaceSlug as string, projectId as string, pageId as string, block.id, {
|
||||
sync: !block.sync,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyText = () => {
|
||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
|
||||
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${block.issue}`).then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Link Copied!",
|
||||
message: "Issue link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!block) return;
|
||||
|
||||
reset({ ...block });
|
||||
}, [reset, block]);
|
||||
|
||||
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
|
||||
return (
|
||||
<Draggable draggableId={block.id} index={index} isDragDisabled={createBlockForm}>
|
||||
{(provided, snapshot) => (
|
||||
<>
|
||||
{createBlockForm ? (
|
||||
<div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}>
|
||||
<CreateUpdateBlockInline
|
||||
handleAiAssistance={handleAiAssistance}
|
||||
handleClose={() => setCreateBlockForm(false)}
|
||||
data={block}
|
||||
setIsSyncing={setIsSyncing}
|
||||
focus="name"
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`group relative w-full rounded bg-custom-background-80 text-custom-text-200 ${
|
||||
snapshot.isDragging ? "bg-custom-background-100 p-4 shadow" : ""
|
||||
}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-4 -left-0 hidden rounded p-0.5 group-hover:!flex"
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<MoreVertical className="h-4" />
|
||||
<MoreVertical className="-ml-5 h-4" />
|
||||
</button>
|
||||
<div
|
||||
ref={actionSectionRef}
|
||||
className={`absolute top-4 right-2 hidden items-center gap-2 bg-custom-background-80 pl-4 group-hover:!flex ${
|
||||
isMenuActive ? "!flex" : ""
|
||||
}`}
|
||||
>
|
||||
{block.issue && block.sync && (
|
||||
<div className="flex flex-shrink-0 cursor-default items-center gap-1 rounded py-1 px-1.5 text-xs">
|
||||
{isSyncing ? <RefreshCw className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
||||
{isSyncing ? "Syncing..." : "Synced"}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 ${
|
||||
iAmFeelingLucky ? "cursor-wait" : ""
|
||||
}`}
|
||||
onClick={handleAutoGenerateDescription}
|
||||
disabled={iAmFeelingLucky}
|
||||
>
|
||||
{iAmFeelingLucky ? (
|
||||
"Generating response..."
|
||||
) : (
|
||||
<>
|
||||
<Sparkle className="h-4 w-4" />I{"'"}m feeling lucky
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="-mr-2 flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90"
|
||||
onClick={() => setGptAssistantModal((prevData) => !prevData)}
|
||||
>
|
||||
<Sparkle className="h-4 w-4" />
|
||||
AI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="-mr-2 flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90"
|
||||
onClick={() => setCreateBlockForm(true)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center justify-between gap-1 rounded px-2.5 py-1 text-left text-xs duration-300 hover:bg-custom-background-90"
|
||||
onClick={() => setIsMenuActive(!isMenuActive)}
|
||||
>
|
||||
<Zap className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{block.issue ? (
|
||||
<>
|
||||
<CustomMenu.MenuItem onClick={handleBlockSync}>
|
||||
<span className="flex items-center gap-1">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<span>Turn sync {block.sync ? "off" : "on"}</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem onClick={handleCopyText}>
|
||||
<span className="flex items-center gap-1">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
Copy issue link
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<CustomMenu.MenuItem onClick={pushBlockIntoIssues}>
|
||||
<span className="flex items-center gap-1">
|
||||
<LayersIcon className="h-4 w-4" />
|
||||
Push into issues
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={deletePageBlock}>
|
||||
<span className="flex items-center gap-1">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete block
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
<div className={`flex items-start gap-2 px-3 ${snapshot.isDragging ? "" : "py-4"}`}>
|
||||
<div
|
||||
className="w-full cursor-pointer overflow-hidden break-words px-4"
|
||||
onClick={() => setCreateBlockForm(true)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{block.issue && (
|
||||
<div className="mr-1.5 flex">
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${block.issue}`}>
|
||||
<a className="flex h-6 flex-shrink-0 items-center gap-1 rounded bg-custom-background-80 px-1.5 py-1 text-xs">
|
||||
<LayersIcon height="16" width="16" />
|
||||
{projectDetails?.identifier}-{block.issue_detail?.sequence_id}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<TextArea
|
||||
id="blockName"
|
||||
name="blockName"
|
||||
value={block.name}
|
||||
placeholder="Title"
|
||||
className="min-h-[20px] block w-full resize-none overflow-hidden border-none bg-transparent text-sm text-custom-text-100 !p-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showBlockDetails
|
||||
? block.description_html.length > 7 && (
|
||||
<RichTextEditor
|
||||
cancelUploadImage={fileService.cancelUpload}
|
||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||
deleteFile={fileService.deleteImage}
|
||||
value={block.description_html}
|
||||
customClassName="text-sm min-h-[150px]"
|
||||
noBorder
|
||||
borderOnFocus={false}
|
||||
mentionSuggestions={editorSuggestion.mentionSuggestions}
|
||||
mentionHighlights={editorSuggestion.mentionHighlights}
|
||||
/>
|
||||
)
|
||||
: block.description_stripped.length > 0 && (
|
||||
<p className="mt-3 text-sm font-normal text-custom-text-200 h-5 truncate">
|
||||
{block.description_stripped}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<GptAssistantModal
|
||||
block={block}
|
||||
isOpen={gptAssistantModal}
|
||||
handleClose={() => setGptAssistantModal(false)}
|
||||
inset="top-8 left-0"
|
||||
content={block.description_stripped}
|
||||
htmlContent={block.description_html}
|
||||
onResponse={handleAiAssistance}
|
||||
projectId={projectId as string}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
};
|
@ -1,202 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { CustomMenu, Tooltip } from "@plane/ui";
|
||||
// icons
|
||||
import { AlertCircle, LinkIcon, Lock, Pencil, Star, Trash2, Unlock } from "lucide-react";
|
||||
// helpers
|
||||
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
||||
import { render24HourFormatTime, renderShortDate, renderLongDateFormat } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { IPage, IProjectMember } from "types";
|
||||
|
||||
type TSingleStatProps = {
|
||||
page: IPage;
|
||||
people: IProjectMember[] | undefined;
|
||||
handleEditPage: () => void;
|
||||
handleDeletePage: () => void;
|
||||
handleAddToFavorites: () => void;
|
||||
handleRemoveFromFavorites: () => void;
|
||||
partialUpdatePage: (page: IPage, formData: Partial<IPage>) => void;
|
||||
};
|
||||
|
||||
export const SinglePageDetailedItem: React.FC<TSingleStatProps> = ({
|
||||
page,
|
||||
people,
|
||||
handleEditPage,
|
||||
handleDeletePage,
|
||||
handleAddToFavorites,
|
||||
handleRemoveFromFavorites,
|
||||
partialUpdatePage,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const handleCopyText = () => {
|
||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/pages/${page.id}`).then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Link Copied!",
|
||||
message: "Page link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${page.id}`}>
|
||||
<a className="block p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center overflow-hidden gap-2">
|
||||
<p className="mr-2 truncate text-sm">{page.name}</p>
|
||||
{page.label_details.length > 0 &&
|
||||
page.label_details.map((label) => (
|
||||
<div
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border border-custom-border-200 px-2 py-0.5 text-xs"
|
||||
style={{
|
||||
backgroundColor: `${label?.color && label.color !== "" ? label.color : "#000000"}20`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color && label.color !== "" ? label.color : "#000000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip
|
||||
tooltipContent={`Last updated at ${
|
||||
render24HourFormatTime(page.updated_at) +
|
||||
` ${new Date(page.updated_at).getHours() < 12 ? "am" : "pm"}`
|
||||
} on ${renderShortDate(page.updated_at)}`}
|
||||
>
|
||||
<p className="text-sm text-custom-text-200">{render24HourFormatTime(page.updated_at)}</p>
|
||||
</Tooltip>
|
||||
{page.is_favorite ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleRemoveFromFavorites();
|
||||
}}
|
||||
className="z-10 grid place-items-center"
|
||||
>
|
||||
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleAddToFavorites();
|
||||
}}
|
||||
className="z-10 grid place-items-center"
|
||||
>
|
||||
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
|
||||
</button>
|
||||
)}
|
||||
{page.created_by === user?.id && (
|
||||
<Tooltip
|
||||
tooltipContent={`${
|
||||
page.access
|
||||
? "This page is only visible to you."
|
||||
: "This page can be viewed by anyone in the project."
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
partialUpdatePage(page, { access: page.access ? 0 : 1 });
|
||||
}}
|
||||
>
|
||||
{page.access ? (
|
||||
<Lock className="h-4 w-4" color="rgb(var(--color-text-200))" />
|
||||
) : (
|
||||
<Unlock className="h-4 w-4" color="rgb(var(--color-text-200))" />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip
|
||||
position="top-right"
|
||||
tooltipContent={`Created by ${
|
||||
people?.find((person) => person.member.id === page.created_by)?.member.display_name ?? ""
|
||||
} on ${renderLongDateFormat(`${page.created_at}`)}`}
|
||||
>
|
||||
<span>
|
||||
<AlertCircle className="h-4 w-4 text-custom-text-200" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
<CustomMenu verticalEllipsis>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleEditPage();
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
<span>Edit Page</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDeletePage();
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span>Delete Page</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCopyText();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
<span>Copy Page link</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
{page.blocks.length > 0 && (
|
||||
<div className="relative mt-2 space-y-2 text-sm text-custom-text-200">
|
||||
{page.blocks.slice(0, 3).map((block) => (
|
||||
<h4 className="truncate">{block.name}</h4>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,197 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// hooks
|
||||
import useUser from "hooks/use-user";
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { CustomMenu, Tooltip } from "@plane/ui";
|
||||
// icons
|
||||
import { AlertCircle, FileText, LinkIcon, Lock, Pencil, Star, Trash2, Unlock } from "lucide-react";
|
||||
// helpers
|
||||
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
||||
import { renderLongDateFormat, renderShortDate, render24HourFormatTime } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { IPage, IProjectMember } from "types";
|
||||
|
||||
type TSingleStatProps = {
|
||||
page: IPage;
|
||||
people: IProjectMember[] | undefined;
|
||||
handleEditPage: () => void;
|
||||
handleDeletePage: () => void;
|
||||
handleAddToFavorites: () => void;
|
||||
handleRemoveFromFavorites: () => void;
|
||||
partialUpdatePage: (page: IPage, formData: Partial<IPage>) => void;
|
||||
};
|
||||
|
||||
export const SinglePageListItem: React.FC<TSingleStatProps> = ({
|
||||
page,
|
||||
people,
|
||||
handleEditPage,
|
||||
handleDeletePage,
|
||||
handleAddToFavorites,
|
||||
handleRemoveFromFavorites,
|
||||
partialUpdatePage,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const handleCopyText = () => {
|
||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/pages/${page.id}`).then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Link Copied!",
|
||||
message: "Page link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<li>
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${page.id}`}>
|
||||
<a>
|
||||
<div className="relative rounded p-4 text-custom-text-200 hover:bg-custom-background-80">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex overflow-hidden items-center gap-2">
|
||||
<FileText className="h-4 w-4 shrink-0" />
|
||||
<p className="mr-2 truncate text-sm text-custom-text-100">{page.name}</p>
|
||||
{page.label_details.length > 0 &&
|
||||
page.label_details.map((label) => (
|
||||
<div
|
||||
key={label.id}
|
||||
className="group flex items-center gap-1 rounded-2xl border border-custom-border-200 px-2 py-0.5 text-xs"
|
||||
style={{
|
||||
backgroundColor: `${label?.color}20`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color,
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-2 flex flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip
|
||||
tooltipContent={`Last updated at ${render24HourFormatTime(page.updated_at)} on ${renderShortDate(
|
||||
page.updated_at
|
||||
)}`}
|
||||
>
|
||||
<p className="text-sm text-custom-text-200">{render24HourFormatTime(page.updated_at)}</p>
|
||||
</Tooltip>
|
||||
{page.is_favorite ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleRemoveFromFavorites();
|
||||
}}
|
||||
>
|
||||
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleAddToFavorites();
|
||||
}}
|
||||
>
|
||||
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
|
||||
</button>
|
||||
)}
|
||||
{page.created_by === user?.id && (
|
||||
<Tooltip
|
||||
tooltipContent={`${
|
||||
page.access
|
||||
? "This page is only visible to you."
|
||||
: "This page can be viewed by anyone in the project."
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
partialUpdatePage(page, { access: page.access ? 0 : 1 });
|
||||
}}
|
||||
>
|
||||
{page.access ? (
|
||||
<Lock className="h-4 w-4" color="rgb(var(--color-text-200))" />
|
||||
) : (
|
||||
<Unlock className="h-4 w-4" color="rgb(var(--color-text-200))" />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip
|
||||
position="top-right"
|
||||
tooltipContent={`Created by ${
|
||||
people?.find((person) => person.member.id === page.created_by)?.member.display_name ?? ""
|
||||
} on ${renderLongDateFormat(`${page.created_at}`)}`}
|
||||
>
|
||||
<span>
|
||||
<AlertCircle className="h-4 w-4 text-custom-text-200" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
|
||||
<CustomMenu width="auto" verticalEllipsis>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleEditPage();
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
<span>Edit Page</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={(e: any) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDeletePage();
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span>Delete Page</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCopyText();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
<span>Copy Page link</span>
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
};
|
@ -14,7 +14,7 @@ type Props = {
|
||||
const viewerTabs = [
|
||||
{
|
||||
route: "",
|
||||
label: "Overview",
|
||||
label: "Summary",
|
||||
selected: "/[workspaceSlug]/profile/[userId]",
|
||||
},
|
||||
];
|
||||
|
@ -195,7 +195,7 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
|
||||
{project.is_favorite && (
|
||||
<CustomMenu.MenuItem onClick={handleRemoveFromFavorites}>
|
||||
<span className="flex items-center justify-start gap-2">
|
||||
<Star className="h-3.5 w-3.5 stroke-[1.5] text-orange-400" fill="#f6ad55" />
|
||||
<Star className="h-3.5 w-3.5 stroke-[1.5] text-orange-400 fill-orange-400" />
|
||||
<span>Remove from favorites</span>
|
||||
</span>
|
||||
</CustomMenu.MenuItem>
|
||||
|
@ -10,7 +10,6 @@ import { CreateUpdateProjectViewModal, DeleteProjectViewModal } from "components
|
||||
// ui
|
||||
import { CustomMenu, PhotoFilterIcon } from "@plane/ui";
|
||||
// helpers
|
||||
import { truncateText } from "helpers/string.helper";
|
||||
import { calculateTotalFilters } from "helpers/filter.helper";
|
||||
// types
|
||||
import { IProjectView } from "types";
|
||||
@ -85,7 +84,7 @@ export const ProjectViewListItem: React.FC<Props> = observer((props) => {
|
||||
}}
|
||||
className="grid place-items-center"
|
||||
>
|
||||
<StarIcon className="text-orange-400" fill="#f6ad55" size={14} strokeWidth={2} />
|
||||
<StarIcon className="h-3.5 w-3.5 text-orange-400 fill-orange-400" strokeWidth={2} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
|
@ -228,13 +228,14 @@ export const SLACK_CHANNEL_INFO = (workspaceSlug: string, projectId: string) =>
|
||||
// Pages
|
||||
export const RECENT_PAGES_LIST = (projectId: string) => `RECENT_PAGES_LIST_${projectId.toUpperCase()}`;
|
||||
export const ALL_PAGES_LIST = (projectId: string) => `ALL_PAGES_LIST_${projectId.toUpperCase()}`;
|
||||
export const ARCHIVED_PAGES_LIST = (projectId: string) => `ARCHIVED_PAGES_LIST_${projectId.toUpperCase}`;
|
||||
export const FAVORITE_PAGES_LIST = (projectId: string) => `FAVORITE_PAGES_LIST_${projectId.toUpperCase()}`;
|
||||
export const MY_PAGES_LIST = (projectId: string) => `MY_PAGES_LIST_${projectId.toUpperCase()}`;
|
||||
export const OTHER_PAGES_LIST = (projectId: string) => `OTHER_PAGES_LIST_${projectId.toUpperCase()}`;
|
||||
export const PRIVATE_PAGES_LIST = (projectId: string) => `PRIVATE_PAGES_LIST_${projectId.toUpperCase()}`;
|
||||
export const SHARED_PAGES_LIST = (projectId: string) => `SHARED_PAGES_LIST_${projectId.toUpperCase()}`;
|
||||
export const PAGE_DETAILS = (pageId: string) => `PAGE_DETAILS_${pageId.toUpperCase()}`;
|
||||
export const PAGE_BLOCKS_LIST = (pageId: string) => `PAGE_BLOCK_LIST_${pageId.toUpperCase()}`;
|
||||
export const PAGE_BLOCK_DETAILS = (pageId: string) => `PAGE_BLOCK_DETAILS_${pageId.toUpperCase()}`;
|
||||
|
||||
export const MY_PAGES_LIST = (pageId: string) => `MY_PAGE_LIST_${pageId}`;
|
||||
// estimates
|
||||
export const ESTIMATES_LIST = (projectId: string) => `ESTIMATES_LIST_${projectId.toUpperCase()}`;
|
||||
export const ESTIMATE_DETAILS = (estimateId: string) => `ESTIMATE_DETAILS_${estimateId.toUpperCase()}`;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { LayoutGrid, List } from "lucide-react";
|
||||
import { Globe2, LayoutGrid, List, Lock } from "lucide-react";
|
||||
|
||||
export const PAGE_VIEW_LAYOUTS = [
|
||||
{
|
||||
@ -27,11 +27,28 @@ export const PAGE_TABS_LIST: { key: string; title: string }[] = [
|
||||
title: "Favorites",
|
||||
},
|
||||
{
|
||||
key: "created-by-me",
|
||||
title: "Created by me",
|
||||
key: "private",
|
||||
title: "Private",
|
||||
},
|
||||
{
|
||||
key: "created-by-others",
|
||||
title: "Created by others",
|
||||
key: "shared",
|
||||
title: "Shared",
|
||||
},
|
||||
{
|
||||
key: "archived-pages",
|
||||
title: "Archived",
|
||||
},
|
||||
];
|
||||
|
||||
export const PAGE_ACCESS_SPECIFIERS: { key: number; label: string; icon: any }[] = [
|
||||
{
|
||||
key: 0,
|
||||
label: "Public",
|
||||
icon: Globe2,
|
||||
},
|
||||
{
|
||||
key: 1,
|
||||
label: "Private",
|
||||
icon: Lock,
|
||||
},
|
||||
];
|
||||
|
@ -1,26 +1,17 @@
|
||||
import { FC } from "react";
|
||||
// next
|
||||
import Link from "next/link";
|
||||
// mobx
|
||||
import { observer } from "mobx-react-lite";
|
||||
// ui
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// icons
|
||||
import { ArrowLeftToLine, Settings } from "lucide-react";
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
export const InstanceAdminHeader: FC = observer(() => {
|
||||
const {
|
||||
workspace: { workspaceSlug },
|
||||
user: { currentUserSettings },
|
||||
} = useMobxStore();
|
||||
export interface IInstanceAdminHeader {
|
||||
title: string;
|
||||
}
|
||||
|
||||
const redirectWorkspaceSlug =
|
||||
workspaceSlug ||
|
||||
currentUserSettings?.workspace?.last_workspace_slug ||
|
||||
currentUserSettings?.workspace?.fallback_workspace_slug ||
|
||||
"";
|
||||
export const InstanceAdminHeader: FC<IInstanceAdminHeader> = observer((props) => {
|
||||
const { title } = props;
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full flex-shrink-0 flex-row z-10 h-[3.75rem] items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
|
||||
@ -30,18 +21,16 @@ export const InstanceAdminHeader: FC = observer(() => {
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
icon={<Settings className="h-4 w-4 text-custom-text-300" />}
|
||||
label="General"
|
||||
label="Settings"
|
||||
link="/admin"
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
label={title}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<Link href={redirectWorkspaceSlug}>
|
||||
<a>
|
||||
<ArrowLeftToLine className="h-4 w-4 text-custom-text-300" />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
@ -1,31 +1,33 @@
|
||||
import { FC, ReactNode } from "react";
|
||||
// layouts
|
||||
import { UserAuthWrapper } from "layouts/auth-layout";
|
||||
import { AdminAuthWrapper, UserAuthWrapper } from "layouts/auth-layout";
|
||||
// components
|
||||
import { InstanceAdminSidebar } from "./sidebar";
|
||||
import { InstanceAdminHeader } from "./header";
|
||||
|
||||
export interface IInstanceAdminLayout {
|
||||
children: ReactNode;
|
||||
header: ReactNode;
|
||||
}
|
||||
|
||||
export const InstanceAdminLayout: FC<IInstanceAdminLayout> = (props) => {
|
||||
const { children } = props;
|
||||
const { children, header } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<UserAuthWrapper>
|
||||
<div className="relative flex h-screen w-full overflow-hidden">
|
||||
<InstanceAdminSidebar />
|
||||
<main className="relative flex flex-col h-full w-full overflow-hidden bg-custom-background-100">
|
||||
<InstanceAdminHeader />
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<div className="relative h-full w-full overflow-x-hidden overflow-y-scroll">
|
||||
<>{children}</>
|
||||
<AdminAuthWrapper>
|
||||
<div className="relative flex h-screen w-full overflow-hidden">
|
||||
<InstanceAdminSidebar />
|
||||
<main className="relative flex flex-col h-full w-full overflow-hidden bg-custom-background-100">
|
||||
{header}
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<div className="relative h-full w-full overflow-x-hidden overflow-y-scroll">
|
||||
<>{children}</>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</AdminAuthWrapper>
|
||||
</UserAuthWrapper>
|
||||
</>
|
||||
);
|
||||
|
68
web/layouts/auth-layout/admin-wrapper.tsx
Normal file
68
web/layouts/auth-layout/admin-wrapper.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { FC, ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// icons
|
||||
import { LayoutGrid } from "lucide-react";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
// hooks
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// images
|
||||
import AccessDeniedImg from "public/auth/access-denied.svg";
|
||||
|
||||
export interface IAdminAuthWrapper {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AdminAuthWrapper: FC<IAdminAuthWrapper> = observer(({ children }) => {
|
||||
// store
|
||||
const {
|
||||
user: { isUserInstanceAdmin },
|
||||
workspace: { workspaceSlug },
|
||||
user: { currentUserSettings },
|
||||
} = useMobxStore();
|
||||
|
||||
// redirect url
|
||||
const redirectWorkspaceSlug =
|
||||
workspaceSlug ||
|
||||
currentUserSettings?.workspace?.last_workspace_slug ||
|
||||
currentUserSettings?.workspace?.fallback_workspace_slug ||
|
||||
"";
|
||||
|
||||
// if user does not have admin access to the instance
|
||||
if (isUserInstanceAdmin !== undefined && isUserInstanceAdmin === false) {
|
||||
return (
|
||||
<div className={`h-screen w-full flex items-center justify-center overflow-hidden`}>
|
||||
<div className="w-3/5 h-2/3 bg-custom-background-90">
|
||||
<div className="grid h-full place-items-center p-4">
|
||||
<div className="space-y-8 text-center">
|
||||
<div className="space-y-2">
|
||||
<Image src={AccessDeniedImg} height="220" width="550" alt="AccessDeniedImg" />
|
||||
<h3 className="text-3xl font-semibold">Access denied!</h3>
|
||||
<div className="mx-auto text-base text-custom-text-100">
|
||||
<p>Sorry, but you do not have permission to view this page.</p>
|
||||
<p>
|
||||
If you think there{"’"}s a mistake contact <span className="font-semibold">support.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Link href={`/${redirectWorkspaceSlug}`}>
|
||||
<a>
|
||||
<Button variant="primary" size="sm">
|
||||
<LayoutGrid width={16} height={16} />
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
});
|
@ -1,3 +1,4 @@
|
||||
export * from "./user-wrapper";
|
||||
export * from "./workspace-wrapper";
|
||||
export * from "./project-wrapper";
|
||||
export * from "./admin-wrapper";
|
||||
|
@ -31,6 +31,7 @@
|
||||
"@types/react-datepicker": "^4.8.0",
|
||||
"axios": "^1.1.3",
|
||||
"cmdk": "^0.2.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"dotenv": "^16.0.3",
|
||||
"js-cookie": "^3.0.1",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
|
@ -56,7 +56,7 @@ const ProfileOverviewPage: NextPageWithLayout = () => {
|
||||
|
||||
ProfileOverviewPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<AppLayout header={<UserProfileHeader title="Overview" />}>
|
||||
<AppLayout header={<UserProfileHeader title="Summary" />}>
|
||||
<ProfileAuthWrapper>{page}</ProfileAuthWrapper>
|
||||
</AppLayout>
|
||||
);
|
||||
|
@ -2,76 +2,49 @@ import React, { useEffect, useRef, useState, ReactElement } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import { TwitterPicker } from "react-color";
|
||||
import { DragDropContext, DropResult } from "@hello-pangea/dnd";
|
||||
// services
|
||||
import { ProjectService, ProjectMemberService } from "services/project";
|
||||
import { PageService } from "services/page.service";
|
||||
import { IssueLabelService } from "services/issue";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
import useUser from "hooks/use-user";
|
||||
// layouts
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
// components
|
||||
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
|
||||
import { CreateUpdateBlockInline, SinglePageBlock } from "components/pages";
|
||||
import { CreateLabelModal } from "components/labels";
|
||||
import { CreateBlock } from "components/pages/create-block";
|
||||
import { PageDetailsHeader } from "components/headers/page-details";
|
||||
// ui
|
||||
import { EmptyState } from "components/common";
|
||||
import { CustomSearchSelect, TextArea, Loader, ToggleSwitch, Tooltip } from "@plane/ui";
|
||||
// images
|
||||
// ui
|
||||
import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef } from "@plane/document-editor";
|
||||
import { Loader } from "@plane/ui";
|
||||
// assets
|
||||
import emptyPage from "public/empty-state/page.svg";
|
||||
// icons
|
||||
import { ArrowLeft, Lock, LinkIcon, Palette, Plus, Star, Unlock, X, ChevronDown } from "lucide-react";
|
||||
// helpers
|
||||
import { render24HourFormatTime, renderShortDate } from "helpers/date-time.helper";
|
||||
import { copyTextToClipboard } from "helpers/string.helper";
|
||||
import { orderArrayBy } from "helpers/array.helper";
|
||||
import { renderDateFormat } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { NextPageWithLayout } from "types/app";
|
||||
import { IIssueLabel, IPage, IPageBlock, IProjectMember } from "types";
|
||||
import { IPage } from "types";
|
||||
// fetch-keys
|
||||
import {
|
||||
PAGE_BLOCKS_LIST,
|
||||
PAGE_DETAILS,
|
||||
PROJECT_DETAILS,
|
||||
PROJECT_ISSUE_LABELS,
|
||||
USER_PROJECT_VIEW,
|
||||
} from "constants/fetch-keys";
|
||||
import { PAGE_DETAILS } from "constants/fetch-keys";
|
||||
import { FileService } from "services/file.service";
|
||||
|
||||
// services
|
||||
const projectService = new ProjectService();
|
||||
const projectMemberService = new ProjectMemberService();
|
||||
const fileService = new FileService();
|
||||
const pageService = new PageService();
|
||||
const issueLabelService = new IssueLabelService();
|
||||
|
||||
const PageDetailsPage: NextPageWithLayout = () => {
|
||||
const [createBlockForm, setCreateBlockForm] = useState(false);
|
||||
const [labelModal, setLabelModal] = useState(false);
|
||||
const [showBlock, setShowBlock] = useState(false);
|
||||
const editorRef = useRef<any>(null);
|
||||
|
||||
const scrollToRef = useRef<HTMLDivElement>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, pageId } = router.query;
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
const { user } = useUser();
|
||||
|
||||
const { handleSubmit, reset, watch, setValue, control } = useForm<IPage>({
|
||||
const { handleSubmit, reset, getValues, control } = useForm<IPage>({
|
||||
defaultValues: { name: "" },
|
||||
});
|
||||
|
||||
const { data: projectDetails } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
|
||||
workspaceSlug && projectId ? () => projectService.getProject(workspaceSlug as string, projectId as string) : null
|
||||
);
|
||||
|
||||
// =================== Fetching Page Details ======================
|
||||
const { data: pageDetails, error } = useSWR(
|
||||
workspaceSlug && projectId && pageId ? PAGE_DETAILS(pageId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
@ -79,27 +52,6 @@ const PageDetailsPage: NextPageWithLayout = () => {
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: pageBlocks } = useSWR(
|
||||
workspaceSlug && projectId && pageId ? PAGE_BLOCKS_LIST(pageId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => pageService.listPageBlocks(workspaceSlug as string, projectId as string, pageId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: labels } = useSWR<IIssueLabel[]>(
|
||||
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => issueLabelService.getProjectIssueLabels(workspaceSlug as string, projectId as string)
|
||||
: null
|
||||
);
|
||||
|
||||
const { data: memberDetails } = useSWR(
|
||||
workspaceSlug && projectId ? USER_PROJECT_VIEW(projectId.toString()) : null,
|
||||
workspaceSlug && projectId
|
||||
? () => projectMemberService.projectMemberMe(workspaceSlug.toString(), projectId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
const updatePage = async (formData: IPage) => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
|
||||
@ -117,159 +69,96 @@ const PageDetailsPage: NextPageWithLayout = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const partialUpdatePage = async (formData: Partial<IPage>) => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
|
||||
mutate<IPage>(
|
||||
PAGE_DETAILS(pageId as string),
|
||||
(prevData) => ({
|
||||
...(prevData as IPage),
|
||||
...formData,
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
await pageService.patchPage(workspaceSlug as string, projectId as string, pageId as string, formData).then(() => {
|
||||
mutate(PAGE_DETAILS(pageId as string));
|
||||
});
|
||||
const createPage = async (payload: Partial<IPage>) => {
|
||||
await pageService.createPage(workspaceSlug as string, projectId as string, payload);
|
||||
};
|
||||
|
||||
const handleAddToFavorites = () => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
|
||||
mutate<IPage>(
|
||||
PAGE_DETAILS(pageId as string),
|
||||
(prevData) => ({
|
||||
...(prevData as IPage),
|
||||
is_favorite: true,
|
||||
}),
|
||||
false
|
||||
).then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success",
|
||||
message: "Added to favorites",
|
||||
});
|
||||
});
|
||||
|
||||
pageService.addPageToFavorites(workspaceSlug as string, projectId as string, {
|
||||
page: pageId as string,
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveFromFavorites = () => {
|
||||
if (!workspaceSlug || !projectId || !pageId) return;
|
||||
|
||||
mutate<IPage>(
|
||||
PAGE_DETAILS(pageId as string),
|
||||
(prevData) => ({
|
||||
...(prevData as IPage),
|
||||
is_favorite: false,
|
||||
}),
|
||||
false
|
||||
).then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Success",
|
||||
message: "Removed from favorites",
|
||||
});
|
||||
});
|
||||
|
||||
pageService.removePageFromFavorites(workspaceSlug as string, projectId as string, pageId as string);
|
||||
};
|
||||
|
||||
const handleOnDragEnd = (result: DropResult) => {
|
||||
if (!result.destination || !workspaceSlug || !projectId || !pageId || !pageBlocks) return;
|
||||
|
||||
const { source, destination } = result;
|
||||
|
||||
let newSortOrder = pageBlocks.find((p) => p.id === result.draggableId)?.sort_order ?? 65535;
|
||||
|
||||
if (destination.index === 0) newSortOrder = pageBlocks[0].sort_order - 10000;
|
||||
else if (destination.index === pageBlocks.length - 1)
|
||||
newSortOrder = pageBlocks[pageBlocks.length - 1].sort_order + 10000;
|
||||
else {
|
||||
if (destination.index > source.index)
|
||||
newSortOrder = (pageBlocks[destination.index].sort_order + pageBlocks[destination.index + 1].sort_order) / 2;
|
||||
else if (destination.index < source.index)
|
||||
newSortOrder = (pageBlocks[destination.index - 1].sort_order + pageBlocks[destination.index].sort_order) / 2;
|
||||
}
|
||||
|
||||
const newBlocksList = pageBlocks.map((p) => ({
|
||||
...p,
|
||||
sort_order: p.id === result.draggableId ? newSortOrder : p.sort_order,
|
||||
}));
|
||||
mutate<IPageBlock[]>(
|
||||
PAGE_BLOCKS_LIST(pageId as string),
|
||||
orderArrayBy(newBlocksList, "sort_order", "ascending"),
|
||||
false
|
||||
);
|
||||
|
||||
pageService.patchPageBlock(workspaceSlug as string, projectId as string, pageId as string, result.draggableId, {
|
||||
sort_order: newSortOrder,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyText = () => {
|
||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
|
||||
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/pages/${pageId}`).then(() => {
|
||||
setToastAlert({
|
||||
type: "success",
|
||||
title: "Link Copied!",
|
||||
message: "Page link copied to clipboard.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleShowBlockToggle = async () => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
||||
const payload: Partial<IProjectMember> = {
|
||||
preferences: {
|
||||
pages: {
|
||||
block_display: !showBlock,
|
||||
},
|
||||
},
|
||||
// ================ Page Menu Actions ==================
|
||||
const duplicate_page = async () => {
|
||||
const currentPageValues = getValues();
|
||||
const formData: Partial<IPage> = {
|
||||
name: "Copy of " + currentPageValues.name,
|
||||
description_html: currentPageValues.description_html,
|
||||
};
|
||||
|
||||
mutate<IProjectMember>(
|
||||
(workspaceSlug as string) && (projectId as string) ? USER_PROJECT_VIEW(projectId as string) : null,
|
||||
(prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
...payload,
|
||||
};
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
await projectService.setProjectView(workspaceSlug as string, projectId as string, payload).catch(() => {
|
||||
setToastAlert({
|
||||
type: "error",
|
||||
title: "Error!",
|
||||
message: "Something went wrong. Please try again.",
|
||||
});
|
||||
});
|
||||
await createPage(formData);
|
||||
};
|
||||
|
||||
const options = labels?.map((label) => ({
|
||||
value: label.id,
|
||||
query: label.name,
|
||||
content: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color && label.color !== "" ? label.color : "#000000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
const archivePage = async () => {
|
||||
try {
|
||||
await pageService.archivePage(workspaceSlug as string, projectId as string, pageId as string).then(() => {
|
||||
mutate<IPage>(
|
||||
PAGE_DETAILS(pageId as string),
|
||||
(prevData) => {
|
||||
if (prevData && prevData.is_locked) {
|
||||
prevData.archived_at = renderDateFormat(new Date());
|
||||
return prevData;
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
};
|
||||
|
||||
const unArchivePage = async () => {
|
||||
try {
|
||||
await pageService.restorePage(workspaceSlug as string, projectId as string, pageId as string).then(() => {
|
||||
mutate<IPage>(
|
||||
PAGE_DETAILS(pageId as string),
|
||||
(prevData) => {
|
||||
if (prevData && prevData.is_locked) {
|
||||
prevData.archived_at = null;
|
||||
return prevData;
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
};
|
||||
|
||||
// ========================= Page Lock ==========================
|
||||
const lockPage = async () => {
|
||||
try {
|
||||
await pageService.lockPage(workspaceSlug as string, projectId as string, pageId as string).then(() => {
|
||||
mutate<IPage>(
|
||||
PAGE_DETAILS(pageId as string),
|
||||
(prevData) => {
|
||||
if (prevData && prevData.is_locked) {
|
||||
prevData.is_locked = true;
|
||||
}
|
||||
return prevData;
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
};
|
||||
|
||||
const unlockPage = async () => {
|
||||
try {
|
||||
await pageService.unlockPage(workspaceSlug as string, projectId as string, pageId as string).then(() => {
|
||||
mutate<IPage>(
|
||||
PAGE_DETAILS(pageId as string),
|
||||
(prevData) => {
|
||||
if (prevData && prevData.is_locked) {
|
||||
prevData.is_locked = false;
|
||||
return prevData;
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!pageDetails) return;
|
||||
@ -279,10 +168,9 @@ const PageDetailsPage: NextPageWithLayout = () => {
|
||||
});
|
||||
}, [reset, pageDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!memberDetails) return;
|
||||
setShowBlock(memberDetails.preferences.pages.block_display);
|
||||
}, [memberDetails]);
|
||||
const debouncedFormSave = useDebouncedCallback(async () => {
|
||||
handleSubmit(updatePage)().finally(() => setIsSubmitting("submitted"));
|
||||
}, 1500);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -297,312 +185,78 @@ const PageDetailsPage: NextPageWithLayout = () => {
|
||||
}}
|
||||
/>
|
||||
) : pageDetails ? (
|
||||
<div className="flex h-full flex-col justify-between space-y-4 overflow-hidden p-4">
|
||||
<div className="h-full w-full overflow-y-auto">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-sm text-custom-text-200"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={() => (
|
||||
<TextArea
|
||||
id="name"
|
||||
name="name"
|
||||
value={watch("name")}
|
||||
placeholder="Page Title"
|
||||
onBlur={handleSubmit(updatePage)}
|
||||
onChange={(e) => setValue("name", e.target.value)}
|
||||
required
|
||||
className="min-h-10 block w-full resize-none overflow-hidden rounded border-none bg-transparent !px-3 !py-2 text-xl font-semibold outline-none ring-0"
|
||||
role="textbox"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-wrap gap-1">
|
||||
{pageDetails.labels.length > 0 && (
|
||||
<>
|
||||
{pageDetails.labels.map((labelId) => {
|
||||
const label = labels?.find((label) => label.id === labelId);
|
||||
|
||||
if (!label) return;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={label.id}
|
||||
className="group flex cursor-pointer items-center gap-1 rounded-2xl border border-custom-border-200 px-2 py-0.5 text-xs hover:border-red-500 hover:bg-red-50"
|
||||
onClick={() => {
|
||||
const updatedLabels = pageDetails.labels.filter((l) => l !== labelId);
|
||||
partialUpdatePage({ labels: updatedLabels });
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: `${label?.color && label.color !== "" ? label.color : "#000000"}20`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color && label.color !== "" ? label.color : "#000000",
|
||||
}}
|
||||
/>
|
||||
{label.name}
|
||||
<X className="h-2.5 w-2.5 group-hover:text-red-500" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
<CustomSearchSelect
|
||||
customButton={
|
||||
<div className="flex items-center gap-1 rounded-sm bg-custom-background-80 p-1.5 text-xs">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{pageDetails.labels.length <= 0 && <span>Add Label</span>}
|
||||
</div>
|
||||
<div className="flex h-full flex-col justify-between pl-5 pr-5">
|
||||
<div className="h-full w-full">
|
||||
{pageDetails.is_locked || pageDetails.archived_at ? (
|
||||
<DocumentReadOnlyEditorWithRef
|
||||
ref={editorRef}
|
||||
value={pageDetails.description_html}
|
||||
customClassName={"tracking-tight self-center w-full max-w-full px-0"}
|
||||
borderOnFocus={false}
|
||||
noBorder={true}
|
||||
documentDetails={{
|
||||
title: pageDetails.name,
|
||||
created_by: pageDetails.created_by,
|
||||
created_on: pageDetails.created_at,
|
||||
last_updated_at: pageDetails.updated_at,
|
||||
last_updated_by: pageDetails.updated_by,
|
||||
}}
|
||||
pageLockConfig={
|
||||
!pageDetails.archived_at && user && pageDetails.owned_by === user.id
|
||||
? { action: unlockPage, is_locked: pageDetails.is_locked }
|
||||
: undefined
|
||||
}
|
||||
pageArchiveConfig={
|
||||
user && pageDetails.owned_by === user.id
|
||||
? {
|
||||
action: pageDetails.archived_at ? unArchivePage : archivePage,
|
||||
is_archived: pageDetails.archived_at ? true : false,
|
||||
archived_at: pageDetails.archived_at ? new Date(pageDetails.archived_at) : undefined,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Controller
|
||||
name="description_html"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<DocumentEditorWithRef
|
||||
documentDetails={{
|
||||
title: pageDetails.name,
|
||||
created_by: pageDetails.created_by,
|
||||
created_on: pageDetails.created_at,
|
||||
last_updated_at: pageDetails.updated_at,
|
||||
last_updated_by: pageDetails.updated_by,
|
||||
}}
|
||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||
deleteFile={fileService.deleteImage}
|
||||
ref={editorRef}
|
||||
debouncedUpdatesEnabled={false}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
value={!value || value === "" ? "<p></p>" : value}
|
||||
customClassName="tracking-tight self-center w-full max-w-full px-0"
|
||||
onChange={(_description_json: Object, description_html: string) => {
|
||||
onChange(description_html);
|
||||
setIsSubmitting("submitting");
|
||||
debouncedFormSave();
|
||||
}}
|
||||
duplicationConfig={{ action: duplicate_page }}
|
||||
pageArchiveConfig={
|
||||
user && pageDetails.owned_by === user.id
|
||||
? {
|
||||
is_archived: pageDetails.archived_at ? true : false,
|
||||
action: pageDetails.archived_at ? unArchivePage : archivePage,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
value={pageDetails.labels}
|
||||
footerOption={
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full select-none items-center rounded py-2 px-1 hover:bg-custom-background-80"
|
||||
onClick={() => {
|
||||
setLabelModal(true);
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center justify-start gap-1 text-custom-text-200">
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
<span>Create New Label</span>
|
||||
</span>
|
||||
</button>
|
||||
pageLockConfig={
|
||||
user && pageDetails.owned_by === user.id ? { is_locked: false, action: lockPage } : undefined
|
||||
}
|
||||
onChange={(val: string[]) => partialUpdatePage({ labels: val })}
|
||||
options={options}
|
||||
multiple
|
||||
noChevron
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center gap-6 text-custom-text-200">
|
||||
<Tooltip
|
||||
tooltipContent={`Last updated at ${render24HourFormatTime(
|
||||
pageDetails.updated_at
|
||||
)} on ${renderShortDate(pageDetails.updated_at)}`}
|
||||
>
|
||||
<p className="text-sm">{render24HourFormatTime(pageDetails.updated_at)}</p>
|
||||
</Tooltip>
|
||||
<Popover className="relative">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
className={`group flex items-center gap-2 rounded-md border border-custom-sidebar-border-200 bg-transparent px-2 py-1 text-xs hover:bg-custom-sidebar-background-90 hover:text-custom-sidebar-text-100 focus:outline-none duration-300 ${
|
||||
open
|
||||
? "bg-custom-sidebar-background-90 text-custom-sidebar-text-100"
|
||||
: "text-custom-sidebar-text-200"
|
||||
}`}
|
||||
>
|
||||
Display
|
||||
<ChevronDown className="h-3 w-3" aria-hidden="true" />
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute right-0 z-30 mt-1 w-screen max-w-xs transform rounded-lg border border-custom-border-200 bg-custom-background-90 p-3 shadow-lg">
|
||||
<div className="relative divide-y-2 divide-custom-border-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-custom-text-200">Show full block content</span>
|
||||
<ToggleSwitch
|
||||
value={showBlock}
|
||||
onChange={(value) => {
|
||||
setShowBlock(value);
|
||||
handleShowBlockToggle();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
<button className="flex items-center gap-2" onClick={handleCopyText}>
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex-shrink-0">
|
||||
<Popover className="relative grid place-items-center">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<Popover.Button
|
||||
type="button"
|
||||
className={`group inline-flex items-center outline-none ${
|
||||
open ? "text-custom-text-100" : "text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
{watch("color") && watch("color") !== "" ? (
|
||||
<span
|
||||
className="h-4 w-4 rounded"
|
||||
style={{
|
||||
backgroundColor: watch("color") ?? "black",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Palette height={16} width={16} />
|
||||
)}
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={React.Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<Popover.Panel className="absolute top-full right-0 z-20 mt-1 max-w-xs px-2 sm:px-0">
|
||||
<TwitterPicker
|
||||
color={pageDetails.color}
|
||||
styles={{
|
||||
default: {
|
||||
card: {
|
||||
backgroundColor: `rgba(var(--color-background-80))`,
|
||||
},
|
||||
triangle: {
|
||||
position: "absolute",
|
||||
borderColor:
|
||||
"transparent transparent rgba(var(--color-background-80)) transparent",
|
||||
},
|
||||
input: {
|
||||
border: "none",
|
||||
height: "1.85rem",
|
||||
fontSize: "0.875rem",
|
||||
paddingLeft: "0.25rem",
|
||||
color: `rgba(var(--color-text-200))`,
|
||||
boxShadow: "none",
|
||||
backgroundColor: `rgba(var(--color-background-90))`,
|
||||
borderLeft: `1px solid rgba(var(--color-background-80))`,
|
||||
},
|
||||
hash: {
|
||||
color: `rgba(var(--color-text-200))`,
|
||||
boxShadow: "none",
|
||||
backgroundColor: `rgba(var(--color-background-90))`,
|
||||
},
|
||||
},
|
||||
}}
|
||||
onChange={(val) => partialUpdatePage({ color: val.hex })}
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
{pageDetails.created_by === user?.id && (
|
||||
<Tooltip
|
||||
tooltipContent={`${
|
||||
pageDetails.access
|
||||
? "This page is only visible to you."
|
||||
: "This page can be viewed by anyone in the project."
|
||||
}`}
|
||||
>
|
||||
{pageDetails.access ? (
|
||||
<button onClick={() => partialUpdatePage({ access: 0 })} className="z-10">
|
||||
<Lock className="h-4 w-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={() => partialUpdatePage({ access: 1 })} type="button" className="z-10">
|
||||
<Unlock className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
{pageDetails.is_favorite ? (
|
||||
<button onClick={handleRemoveFromFavorites} className="z-10">
|
||||
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleAddToFavorites} type="button" className="z-10">
|
||||
<Star className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 h-full w-full">
|
||||
{pageBlocks ? (
|
||||
<>
|
||||
<DragDropContext onDragEnd={handleOnDragEnd}>
|
||||
{pageBlocks.length !== 0 && (
|
||||
<StrictModeDroppable droppableId="blocks-list">
|
||||
{(provided) => (
|
||||
<div
|
||||
className="flex w-full flex-col gap-2"
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
<>
|
||||
{pageBlocks.map((block, index) => (
|
||||
<SinglePageBlock
|
||||
key={block.id}
|
||||
block={block}
|
||||
projectDetails={projectDetails}
|
||||
showBlockDetails={showBlock}
|
||||
index={index}
|
||||
user={user}
|
||||
/>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</StrictModeDroppable>
|
||||
)}
|
||||
</DragDropContext>
|
||||
{createBlockForm && (
|
||||
<div className="mt-4" ref={scrollToRef}>
|
||||
<CreateUpdateBlockInline handleClose={() => setCreateBlockForm(false)} focus="name" user={user} />
|
||||
</div>
|
||||
)}
|
||||
{labelModal && typeof projectId === "string" && (
|
||||
<CreateLabelModal
|
||||
isOpen={labelModal}
|
||||
handleClose={() => setLabelModal(false)}
|
||||
projectId={projectId}
|
||||
onSuccess={(response) => {
|
||||
partialUpdatePage({
|
||||
labels: [...(pageDetails.labels ?? []), response.id],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Loader>
|
||||
<Loader.Item height="150px" />
|
||||
<Loader.Item height="150px" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<CreateBlock user={user} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
@ -2,48 +2,65 @@ import { useState, Fragment, ReactElement } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import useSWR from "swr";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import useLocalStorage from "hooks/use-local-storage";
|
||||
import useUserAuth from "hooks/use-user-auth";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// layouts
|
||||
import { AppLayout } from "layouts/app-layout";
|
||||
// components
|
||||
import { RecentPagesList, CreateUpdatePageModal, TPagesListProps } from "components/pages";
|
||||
import { RecentPagesList, CreateUpdatePageModal } from "components/pages";
|
||||
import { PagesHeader } from "components/headers";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// types
|
||||
import { TPageViewProps } from "types";
|
||||
import { NextPageWithLayout } from "types/app";
|
||||
// constants
|
||||
import { PAGE_TABS_LIST, PAGE_VIEW_LAYOUTS } from "constants/page";
|
||||
import { PAGE_TABS_LIST } from "constants/page";
|
||||
|
||||
const AllPagesList = dynamic<TPagesListProps>(() => import("components/pages").then((a) => a.AllPagesList), {
|
||||
const AllPagesList = dynamic<any>(() => import("components/pages").then((a) => a.AllPagesList), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const FavoritePagesList = dynamic<TPagesListProps>(() => import("components/pages").then((a) => a.FavoritePagesList), {
|
||||
const FavoritePagesList = dynamic<any>(() => import("components/pages").then((a) => a.FavoritePagesList), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const MyPagesList = dynamic<TPagesListProps>(() => import("components/pages").then((a) => a.MyPagesList), {
|
||||
const PrivatePagesList = dynamic<any>(() => import("components/pages").then((a) => a.PrivatePagesList), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const OtherPagesList = dynamic<TPagesListProps>(() => import("components/pages").then((a) => a.OtherPagesList), {
|
||||
const ArchivedPagesList = dynamic<any>(() => import("components/pages").then((a) => a.ArchivedPagesList), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const ProjectPagesPage: NextPageWithLayout = () => {
|
||||
const SharedPagesList = dynamic<any>(() => import("components/pages").then((a) => a.SharedPagesList), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const ProjectPagesPage: NextPageWithLayout = observer(() => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// states
|
||||
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
|
||||
const [viewType, setViewType] = useState<TPageViewProps>("list");
|
||||
|
||||
const { user } = useUserAuth();
|
||||
|
||||
// store
|
||||
const {
|
||||
page: { fetchPages, fetchArchivedPages },
|
||||
} = useMobxStore();
|
||||
// hooks
|
||||
const {} = useUserAuth();
|
||||
// local storage
|
||||
const { storedValue: pageTab, setValue: setPageTab } = useLocalStorage("pageTab", "Recent");
|
||||
// fetching pages from API
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? `ALL_PAGES_LIST_${projectId}` : null,
|
||||
workspaceSlug && projectId ? () => fetchPages(workspaceSlug.toString(), projectId.toString()) : null
|
||||
);
|
||||
// fetching archived pages from API
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? `ALL_ARCHIVED_PAGES_LIST_${projectId}` : null,
|
||||
workspaceSlug && projectId ? () => fetchArchivedPages(workspaceSlug.toString(), projectId.toString()) : null
|
||||
);
|
||||
|
||||
const currentTabValue = (tab: string | null) => {
|
||||
switch (tab) {
|
||||
@ -53,11 +70,12 @@ const ProjectPagesPage: NextPageWithLayout = () => {
|
||||
return 1;
|
||||
case "Favorites":
|
||||
return 2;
|
||||
case "Created by me":
|
||||
case "Private":
|
||||
return 3;
|
||||
case "Created by others":
|
||||
case "Shared":
|
||||
return 4;
|
||||
|
||||
case "Archived":
|
||||
return 5;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
@ -69,34 +87,12 @@ const ProjectPagesPage: NextPageWithLayout = () => {
|
||||
<CreateUpdatePageModal
|
||||
isOpen={createUpdatePageModal}
|
||||
handleClose={() => setCreateUpdatePageModal(false)}
|
||||
user={user}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-5 p-8 h-full overflow-hidden flex flex-col">
|
||||
<div className="flex gap-4 justify-between">
|
||||
<h3 className="text-2xl font-semibold text-custom-text-100">Pages</h3>
|
||||
<div className="flex items-center gap-1 p-1 rounded bg-custom-background-80">
|
||||
{PAGE_VIEW_LAYOUTS.map((layout) => (
|
||||
<Tooltip key={layout.key} tooltipContent={layout.title}>
|
||||
<button
|
||||
type="button"
|
||||
className={`w-7 h-[22px] rounded grid place-items-center transition-all hover:bg-custom-background-100 overflow-hidden group ${
|
||||
viewType == layout.key ? "bg-custom-background-100 shadow-custom-shadow-2xs" : ""
|
||||
}`}
|
||||
onClick={() => setViewType(layout.key as TPageViewProps)}
|
||||
>
|
||||
<layout.icon
|
||||
strokeWidth={2}
|
||||
className={`h-3.5 w-3.5 ${
|
||||
viewType == layout.key ? "text-custom-text-100" : "text-custom-text-200"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Tab.Group
|
||||
as={Fragment}
|
||||
@ -110,12 +106,13 @@ const ProjectPagesPage: NextPageWithLayout = () => {
|
||||
case 2:
|
||||
return setPageTab("Favorites");
|
||||
case 3:
|
||||
return setPageTab("Created by me");
|
||||
return setPageTab("Private");
|
||||
case 4:
|
||||
return setPageTab("Created by others");
|
||||
|
||||
return setPageTab("Shared");
|
||||
case 5:
|
||||
return setPageTab("Archived");
|
||||
default:
|
||||
return setPageTab("Recent");
|
||||
return setPageTab("All");
|
||||
}
|
||||
}}
|
||||
>
|
||||
@ -139,26 +136,29 @@ const ProjectPagesPage: NextPageWithLayout = () => {
|
||||
</Tab.List>
|
||||
<Tab.Panels as={Fragment}>
|
||||
<Tab.Panel as="div" className="h-full overflow-y-auto space-y-5">
|
||||
<RecentPagesList viewType={viewType} />
|
||||
<RecentPagesList />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel as="div" className="h-full overflow-hidden">
|
||||
<AllPagesList viewType={viewType} />
|
||||
<AllPagesList />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel as="div" className="h-full overflow-hidden">
|
||||
<FavoritePagesList viewType={viewType} />
|
||||
<FavoritePagesList />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel as="div" className="h-full overflow-hidden">
|
||||
<MyPagesList viewType={viewType} />
|
||||
<PrivatePagesList />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel as="div" className="h-full overflow-hidden">
|
||||
<OtherPagesList viewType={viewType} />
|
||||
<SharedPagesList />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel as="div" className="h-full overflow-hidden">
|
||||
<ArchivedPagesList />
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
ProjectPagesPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
|
@ -1,16 +0,0 @@
|
||||
import { ReactElement } from "react";
|
||||
// layouts
|
||||
import { InstanceAdminLayout } from "layouts/admin-layout";
|
||||
// types
|
||||
import { NextPageWithLayout } from "types/app";
|
||||
|
||||
const InstanceAdminAIPage: NextPageWithLayout = () => {
|
||||
console.log("admin page");
|
||||
return <div>Admin AI Page</div>;
|
||||
};
|
||||
|
||||
InstanceAdminAIPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <InstanceAdminLayout>{page}</InstanceAdminLayout>;
|
||||
};
|
||||
|
||||
export default InstanceAdminAIPage;
|
166
web/pages/admin/authorization.tsx
Normal file
166
web/pages/admin/authorization.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
import { ReactElement, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// layouts
|
||||
import { InstanceAdminHeader, InstanceAdminLayout } from "layouts/admin-layout";
|
||||
// types
|
||||
import { NextPageWithLayout } from "types/app";
|
||||
// store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// icons
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
// ui
|
||||
import { Loader, ToggleSwitch } from "@plane/ui";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
// components
|
||||
import { InstanceGoogleConfigForm } from "components/instance/google-config-form";
|
||||
import { InstanceGithubConfigForm } from "components/instance/github-config-form";
|
||||
|
||||
const InstanceAdminAuthorizationPage: NextPageWithLayout = observer(() => {
|
||||
// store
|
||||
const {
|
||||
instance: { fetchInstanceConfigurations, formattedConfig, updateInstanceConfigurations },
|
||||
} = useMobxStore();
|
||||
|
||||
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
|
||||
|
||||
// toast
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
// state
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
|
||||
const enableSignup = formattedConfig?.ENABLE_SIGNUP ?? "0";
|
||||
|
||||
const updateConfig = async (value: string) => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
const payload = {
|
||||
ENABLE_SIGNUP: value,
|
||||
};
|
||||
|
||||
await updateInstanceConfigurations(payload)
|
||||
.then(() => {
|
||||
setToastAlert({
|
||||
title: "Success",
|
||||
type: "success",
|
||||
message: "Authorization Settings updated successfully",
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
setToastAlert({
|
||||
title: "Error",
|
||||
type: "error",
|
||||
message: "Failed to update Authorization Settings",
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{formattedConfig ? (
|
||||
<div className="flex flex-col gap-8 m-8 w-4/5">
|
||||
<div className="pb-2 mb-2 border-b border-custom-border-100">
|
||||
<div className="text-custom-text-100 font-medium text-lg">Authorization</div>
|
||||
<div className="text-custom-text-300 font-normal text-sm">
|
||||
Make your teams life easy by letting them sign-up with their Google and GitHub accounts, and below are the
|
||||
settings.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-8 pb-4 border-b border-custom-border-100">
|
||||
<div>
|
||||
<div className="text-custom-text-100 font-medium text-sm">Enable sign-up</div>
|
||||
<div className="text-custom-text-300 font-normal text-xs">
|
||||
Keep the doors open so people can join your workspaces.
|
||||
</div>
|
||||
</div>
|
||||
<div className={isSubmitting ? "opacity-70" : ""}>
|
||||
<ToggleSwitch
|
||||
value={Boolean(parseInt(enableSignup))}
|
||||
onChange={() => {
|
||||
Boolean(parseInt(enableSignup)) === true ? updateConfig("0") : updateConfig("1");
|
||||
}}
|
||||
size="sm"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-6 py-2">
|
||||
<Disclosure as="div">
|
||||
{({ open }) => (
|
||||
<div className="w-full">
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className="flex items-center justify-between w-full py-2 border-b border-custom-border-100"
|
||||
>
|
||||
<span className="text-lg font-medium tracking-tight">Google</span>
|
||||
{open ? <ChevronDown className="h-5 w-5" /> : <ChevronRight className="h-5 w-5" />}
|
||||
</Disclosure.Button>
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform opacity-0"
|
||||
enterTo="transform opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform opacity-100"
|
||||
leaveTo="transform opacity-0"
|
||||
>
|
||||
<Disclosure.Panel className="flex flex-col gap-8 px-2 py-8">
|
||||
<InstanceGoogleConfigForm config={formattedConfig} />
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Disclosure>
|
||||
<Disclosure as="div">
|
||||
{({ open }) => (
|
||||
<div className="w-full">
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className="flex items-center justify-between w-full py-2 border-b border-custom-border-100"
|
||||
>
|
||||
<span className="text-lg font-medium tracking-tight">Github</span>
|
||||
{open ? <ChevronDown className="h-5 w-5" /> : <ChevronRight className="h-5 w-5" />}
|
||||
</Disclosure.Button>
|
||||
<Transition
|
||||
show={open}
|
||||
enter="transition duration-100 ease-out"
|
||||
enterFrom="transform opacity-0"
|
||||
enterTo="transform opacity-100"
|
||||
leave="transition duration-75 ease-out"
|
||||
leaveFrom="transform opacity-100"
|
||||
leaveTo="transform opacity-0"
|
||||
>
|
||||
<Disclosure.Panel className="flex flex-col gap-8 px-2 py-8">
|
||||
<InstanceGithubConfigForm config={formattedConfig} />
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Disclosure>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Loader className="space-y-4 m-8">
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" width="25%" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
InstanceAdminAuthorizationPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <InstanceAdminLayout header={<InstanceAdminHeader title="Authorization" />}>{page}</InstanceAdminLayout>;
|
||||
};
|
||||
|
||||
export default InstanceAdminAuthorizationPage;
|
@ -1,16 +1,44 @@
|
||||
import { ReactElement } from "react";
|
||||
import useSWR from "swr";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// layouts
|
||||
import { InstanceAdminLayout } from "layouts/admin-layout";
|
||||
import { InstanceAdminHeader, InstanceAdminLayout } from "layouts/admin-layout";
|
||||
// types
|
||||
import { NextPageWithLayout } from "types/app";
|
||||
// store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { InstanceEmailForm } from "components/instance/email-form";
|
||||
|
||||
const InstanceAdminEmailPage: NextPageWithLayout = () => {
|
||||
console.log("admin page");
|
||||
return <div>Admin Email Page</div>;
|
||||
};
|
||||
const InstanceAdminEmailPage: NextPageWithLayout = observer(() => {
|
||||
// store
|
||||
const {
|
||||
instance: { fetchInstanceConfigurations, formattedConfig },
|
||||
} = useMobxStore();
|
||||
|
||||
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
|
||||
|
||||
return (
|
||||
<div>
|
||||
{formattedConfig ? (
|
||||
<InstanceEmailForm config={formattedConfig} />
|
||||
) : (
|
||||
<Loader className="space-y-4 m-8">
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" width="25%" />
|
||||
<Loader.Item height="50px" width="25%" />
|
||||
<Loader.Item height="50px" width="25%" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
});
|
||||
|
||||
InstanceAdminEmailPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <InstanceAdminLayout>{page}</InstanceAdminLayout>;
|
||||
return <InstanceAdminLayout header={<InstanceAdminHeader title="Email" />}>{page}</InstanceAdminLayout>;
|
||||
};
|
||||
|
||||
export default InstanceAdminEmailPage;
|
||||
|
@ -2,11 +2,13 @@ import { ReactElement } from "react";
|
||||
import useSWR from "swr";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// layouts
|
||||
import { InstanceAdminLayout } from "layouts/admin-layout";
|
||||
import { InstanceAdminHeader, InstanceAdminLayout } from "layouts/admin-layout";
|
||||
// types
|
||||
import { NextPageWithLayout } from "types/app";
|
||||
// store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { InstanceGeneralForm } from "components/instance";
|
||||
|
||||
@ -18,11 +20,23 @@ const InstanceAdminPage: NextPageWithLayout = observer(() => {
|
||||
|
||||
useSWR("INSTANCE_INFO", () => fetchInstanceInfo());
|
||||
|
||||
return <div>{instance && <InstanceGeneralForm instance={instance} />}</div>;
|
||||
return (
|
||||
<div>
|
||||
{instance ? (
|
||||
<InstanceGeneralForm instance={instance} />
|
||||
) : (
|
||||
<Loader className="space-y-4 m-8">
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" width="50%" />
|
||||
<Loader.Item height="50px" width="25%" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
InstanceAdminPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <InstanceAdminLayout>{page}</InstanceAdminLayout>;
|
||||
return <InstanceAdminLayout header={<InstanceAdminHeader title="General" />}>{page}</InstanceAdminLayout>;
|
||||
};
|
||||
|
||||
export default InstanceAdminPage;
|
||||
|
@ -1,16 +0,0 @@
|
||||
import { ReactElement } from "react";
|
||||
// layouts
|
||||
import { InstanceAdminLayout } from "layouts/admin-layout";
|
||||
// types
|
||||
import { NextPageWithLayout } from "types/app";
|
||||
|
||||
const InstanceAdminOAuthPage: NextPageWithLayout = () => {
|
||||
console.log("admin page");
|
||||
return <div>Admin oauth Page</div>;
|
||||
};
|
||||
|
||||
InstanceAdminOAuthPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <InstanceAdminLayout>{page}</InstanceAdminLayout>;
|
||||
};
|
||||
|
||||
export default InstanceAdminOAuthPage;
|
42
web/pages/admin/openai.tsx
Normal file
42
web/pages/admin/openai.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { ReactElement } from "react";
|
||||
import useSWR from "swr";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// layouts
|
||||
import { InstanceAdminHeader, InstanceAdminLayout } from "layouts/admin-layout";
|
||||
// types
|
||||
import { NextPageWithLayout } from "types/app";
|
||||
// store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { InstanceOpenAIForm } from "components/instance/openai-form";
|
||||
|
||||
const InstanceAdminOpenAIPage: NextPageWithLayout = observer(() => {
|
||||
// store
|
||||
const {
|
||||
instance: { fetchInstanceConfigurations, formattedConfig },
|
||||
} = useMobxStore();
|
||||
|
||||
useSWR("INSTANCE_CONFIGURATIONS", () => fetchInstanceConfigurations());
|
||||
|
||||
return (
|
||||
<div>
|
||||
{formattedConfig ? (
|
||||
<InstanceOpenAIForm config={formattedConfig} />
|
||||
) : (
|
||||
<Loader className="space-y-4 m-8">
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" width="50%" />
|
||||
<Loader.Item height="50px" width="25%" />
|
||||
</Loader>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
InstanceAdminOpenAIPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return <InstanceAdminLayout header={<InstanceAdminHeader title="OpenAI" />}>{page}</InstanceAdminLayout>;
|
||||
};
|
||||
|
||||
export default InstanceAdminOpenAIPage;
|
49
web/public/auth/access-denied.svg
Normal file
49
web/public/auth/access-denied.svg
Normal file
@ -0,0 +1,49 @@
|
||||
<svg width="632" height="269" viewBox="0 0 632 269" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_2279_42539)">
|
||||
<path d="M447.648 9.2942L108.777 58.3359L132.006 219.429L470.877 170.387L447.648 9.2942Z" fill="#F5F5F5"/>
|
||||
<path d="M439.95 24.4973L120.459 70.7344L139.839 205.14L459.33 158.902L439.95 24.4973Z" fill="white"/>
|
||||
<path d="M446.161 -0.00267782L107.291 49.0391L109.371 63.4616L448.241 14.4198L446.161 -0.00267782Z" fill="#E5E5E5"/>
|
||||
<path d="M119.212 58.6596C120.701 58.6596 121.908 57.4504 121.908 55.9587C121.908 54.467 120.701 53.2578 119.212 53.2578C117.723 53.2578 116.516 54.467 116.516 55.9587C116.516 57.4504 117.723 58.6596 119.212 58.6596Z" fill="white"/>
|
||||
<path d="M129.34 57.2065C130.829 57.2065 132.036 55.9973 132.036 54.5056C132.036 53.0139 130.829 51.8047 129.34 51.8047C127.852 51.8047 126.645 53.0139 126.645 54.5056C126.645 55.9973 127.852 57.2065 129.34 57.2065Z" fill="white"/>
|
||||
<path d="M139.467 55.7377C140.956 55.7377 142.163 54.5285 142.163 53.0368C142.163 51.5452 140.956 50.3359 139.467 50.3359C137.978 50.3359 136.771 51.5452 136.771 53.0368C136.771 54.5285 137.978 55.7377 139.467 55.7377Z" fill="white"/>
|
||||
<path d="M525.492 78.6016H183.104V241.367H525.492V78.6016Z" fill="#F1F1F1"/>
|
||||
<path d="M515.702 92.5547H192.895V228.355H515.702V92.5547Z" fill="white"/>
|
||||
<path d="M525.345 69.1953H182.957V83.7675H525.345V69.1953Z" fill="#E5E5E5"/>
|
||||
<path d="M193.766 80.4643C195.255 80.4643 196.462 79.2551 196.462 77.7634C196.462 76.2717 195.255 75.0625 193.766 75.0625C192.277 75.0625 191.07 76.2717 191.07 77.7634C191.07 79.2551 192.277 80.4643 193.766 80.4643Z" fill="white"/>
|
||||
<path d="M203.999 80.4643C205.488 80.4643 206.695 79.2551 206.695 77.7634C206.695 76.2717 205.488 75.0625 203.999 75.0625C202.51 75.0625 201.303 76.2717 201.303 77.7634C201.303 79.2551 202.51 80.4643 203.999 80.4643Z" fill="white"/>
|
||||
<path d="M214.233 80.4643C215.722 80.4643 216.929 79.2551 216.929 77.7634C216.929 76.2717 215.722 75.0625 214.233 75.0625C212.744 75.0625 211.537 76.2717 211.537 77.7634C211.537 79.2551 212.744 80.4643 214.233 80.4643Z" fill="white"/>
|
||||
<path d="M424.368 113.203H378.904V116.626H424.368V113.203Z" fill="#F1F1F1"/>
|
||||
<path d="M449.11 123.258H378.904V126.681H449.11V123.258Z" fill="#F1F1F1"/>
|
||||
<path d="M436.851 132.82H379.635V136.244H436.851V132.82Z" fill="#F1F1F1"/>
|
||||
<path d="M412.306 143.328H378.904V146.751H412.306V143.328Z" fill="#F1F1F1"/>
|
||||
<path d="M429.316 153.586H378.904V157.009H429.316V153.586Z" fill="#F1F1F1"/>
|
||||
<path d="M363.751 112.133H358.184V117.71H363.751V112.133Z" fill="#F1F1F1"/>
|
||||
<path d="M363.751 122.172H358.184V127.749H363.751V122.172Z" fill="#F1F1F1"/>
|
||||
<path d="M363.751 131.75H358.184V137.327H363.751V131.75Z" fill="#F1F1F1"/>
|
||||
<path d="M363.751 142.258H358.184V147.835H363.751V142.258Z" fill="#F1F1F1"/>
|
||||
<path d="M363.751 152.508H358.184V158.085H363.751V152.508Z" fill="#F1F1F1"/>
|
||||
<path d="M424.368 163.906H378.904V167.33H424.368V163.906Z" fill="#F1F1F1"/>
|
||||
<path d="M449.11 173.945H378.904V177.369H449.11V173.945Z" fill="#F1F1F1"/>
|
||||
<path d="M436.851 183.523H379.635V186.947H436.851V183.523Z" fill="#F1F1F1"/>
|
||||
<path d="M412.306 194.031H378.904V197.455H412.306V194.031Z" fill="#F1F1F1"/>
|
||||
<path d="M429.316 204.289H378.904V207.712H429.316V204.289Z" fill="#F1F1F1"/>
|
||||
<path d="M363.751 162.836H358.184V168.413H363.751V162.836Z" fill="#F1F1F1"/>
|
||||
<path d="M363.751 172.875H358.184V178.452H363.751V172.875Z" fill="#F1F1F1"/>
|
||||
<path d="M363.751 182.453H358.184V188.03H363.751V182.453Z" fill="#F1F1F1"/>
|
||||
<path d="M363.751 192.945H358.184V198.522H363.751V192.945Z" fill="#F1F1F1"/>
|
||||
<path d="M363.751 203.219H358.184V208.796H363.751V203.219Z" fill="#F1F1F1"/>
|
||||
<path d="M280.545 190.758H235.082V194.181H280.545V190.758Z" fill="#F1F1F1"/>
|
||||
<path d="M286.216 199.664H229V203.087H286.216V199.664Z" fill="#F1F1F1"/>
|
||||
<path d="M287.364 147.583C287.362 152.728 286.028 157.785 283.493 162.259C280.957 166.734 277.307 170.473 272.898 173.112C268.489 175.75 263.472 177.199 258.337 177.315C253.203 177.431 248.126 176.211 243.603 173.774L243.597 173.768C238.977 171.274 235.099 167.598 232.359 163.114C229.618 158.63 228.113 153.498 227.997 148.242C227.881 142.986 229.158 137.792 231.698 133.191C234.238 128.59 237.95 124.746 242.456 122.051C246.962 119.355 252.1 117.904 257.348 117.846C262.596 117.787 267.765 119.124 272.33 121.719C276.894 124.314 280.69 128.075 283.331 132.618C285.972 137.162 287.363 142.325 287.364 147.583Z" fill="#F1F1F1"/>
|
||||
<path d="M257.829 146.138C262.286 146.138 265.899 142.518 265.899 138.053C265.899 133.588 262.286 129.969 257.829 129.969C253.373 129.969 249.76 133.588 249.76 138.053C249.76 142.518 253.373 146.138 257.829 146.138Z" fill="white"/>
|
||||
<path d="M271.607 162.829C262.585 166.001 252.748 165.963 243.752 162.721L243.746 162.717L249.758 150.828H265.897L271.607 162.829Z" fill="white"/>
|
||||
<path d="M315.893 221.387C301.828 221.387 290.385 209.209 290.385 194.24C290.385 179.272 301.828 167.094 315.893 167.094C329.959 167.094 341.402 179.272 341.402 194.24C341.402 209.209 329.959 221.387 315.893 221.387ZM315.893 173.953C305.603 173.953 297.232 183.054 297.232 194.24C297.232 205.427 305.603 214.528 315.893 214.528C326.184 214.528 334.555 205.427 334.555 194.24C334.555 183.054 326.184 173.953 315.893 173.953Z" fill="#3F76FF"/>
|
||||
<rect x="275" y="194" width="80" height="74" rx="6" fill="#C5D6FF"/>
|
||||
<path d="M325.307 221.118C325.308 219.442 324.864 217.795 324.018 216.348C323.173 214.901 321.958 213.706 320.499 212.886C319.039 212.065 317.388 211.65 315.715 211.681C314.041 211.713 312.407 212.191 310.979 213.066C309.552 213.941 308.383 215.181 307.593 216.66C306.803 218.138 306.421 219.8 306.486 221.475C306.55 223.151 307.059 224.778 307.961 226.191C308.862 227.604 310.123 228.75 311.614 229.512V244.268H320.172V229.512C321.717 228.723 323.014 227.522 323.921 226.04C324.827 224.559 325.307 222.856 325.307 221.118Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2279_42539">
|
||||
<rect width="632" height="269" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 5.8 KiB |
@ -2,7 +2,7 @@ import { APIService } from "services/api.service";
|
||||
// helpers
|
||||
import { API_BASE_URL } from "helpers/common.helper";
|
||||
// types
|
||||
import type { IInstance } from "types/instance";
|
||||
import type { IFormattedInstanceConfiguration, IInstance, IInstanceConfiguration } from "types/instance";
|
||||
|
||||
export class InstanceService extends APIService {
|
||||
constructor() {
|
||||
@ -34,4 +34,14 @@ export class InstanceService extends APIService {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
async updateInstanceConfigurations(
|
||||
data: Partial<IFormattedInstanceConfiguration>
|
||||
): Promise<IInstanceConfiguration[]> {
|
||||
return this.patch("/api/licenses/instances/configurations/", data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -33,14 +33,8 @@ export class PageService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
async addPageToFavorites(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
data: {
|
||||
page: string;
|
||||
}
|
||||
): Promise<any> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-pages/`, data)
|
||||
async addPageToFavorites(workspaceSlug: string, projectId: string, pageId: string): Promise<any> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-pages/`, { page: pageId })
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
@ -58,7 +52,7 @@ export class PageService extends APIService {
|
||||
async getPagesWithParams(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageType: "all" | "favorite" | "created_by_me" | "created_by_other"
|
||||
pageType: "all" | "favorite" | "private" | "shared"
|
||||
): Promise<IPage[]> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/`, {
|
||||
params: {
|
||||
@ -168,4 +162,45 @@ export class PageService extends APIService {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
// =============== Archiving & Unarchiving Pages =================
|
||||
async archivePage(workspaceSlug: string, projectId: string, pageId: string): Promise<void> {
|
||||
this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/archive/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async restorePage(workspaceSlug: string, projectId: string, pageId: string): Promise<void> {
|
||||
this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/unarchive/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async getArchivedPages(workspaceSlug: string, projectId: string): Promise<IPage[]> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/archived-pages/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
// ==================== Pages Locking Services ==========================
|
||||
async lockPage(workspaceSlug: string, projectId: string, pageId: string): Promise<any> {
|
||||
this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/lock/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async unlockPage(workspaceSlug: string, projectId: string, pageId: string): Promise<any> {
|
||||
this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/unlock/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ import { observable, action, computed, makeObservable, runInAction } from "mobx"
|
||||
// store
|
||||
import { RootStore } from "../root";
|
||||
// types
|
||||
import { IInstance } from "types/instance";
|
||||
import { IInstance, IInstanceConfiguration, IFormattedInstanceConfiguration } from "types/instance";
|
||||
// services
|
||||
import { InstanceService } from "services/instance.service";
|
||||
|
||||
@ -11,19 +11,21 @@ export interface IInstanceStore {
|
||||
error: any | null;
|
||||
// issues
|
||||
instance: IInstance | null;
|
||||
configurations: any | null;
|
||||
configurations: IInstanceConfiguration[] | null;
|
||||
// computed
|
||||
formattedConfig: IFormattedInstanceConfiguration | null;
|
||||
// action
|
||||
fetchInstanceInfo: () => Promise<IInstance>;
|
||||
updateInstanceInfo: (data: Partial<IInstance>) => Promise<IInstance>;
|
||||
fetchInstanceConfigurations: () => Promise<any>;
|
||||
updateInstanceConfigurations: (data: Partial<IFormattedInstanceConfiguration>) => Promise<IInstanceConfiguration[]>;
|
||||
}
|
||||
|
||||
export class InstanceStore implements IInstanceStore {
|
||||
loader: boolean = false;
|
||||
error: any | null = null;
|
||||
instance: IInstance | null = null;
|
||||
configurations: any | null = null;
|
||||
configurations: IInstanceConfiguration[] | null = null;
|
||||
// service
|
||||
instanceService;
|
||||
rootStore;
|
||||
@ -36,17 +38,31 @@ export class InstanceStore implements IInstanceStore {
|
||||
instance: observable.ref,
|
||||
configurations: observable.ref,
|
||||
// computed
|
||||
// getIssueType: computed,
|
||||
formattedConfig: computed,
|
||||
// actions
|
||||
fetchInstanceInfo: action,
|
||||
updateInstanceInfo: action,
|
||||
fetchInstanceConfigurations: action,
|
||||
updateInstanceConfigurations: action,
|
||||
});
|
||||
|
||||
this.rootStore = _rootStore;
|
||||
this.instanceService = new InstanceService();
|
||||
}
|
||||
|
||||
/**
|
||||
* computed value for instance configurations data for forms.
|
||||
* @returns configurations in the form of {key, value} pair.
|
||||
*/
|
||||
get formattedConfig() {
|
||||
if (!this.configurations) return null;
|
||||
|
||||
return this.configurations?.reduce((formData: IFormattedInstanceConfiguration, config) => {
|
||||
formData[config.key] = config.value;
|
||||
return formData;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch instace info from API
|
||||
*/
|
||||
@ -58,7 +74,7 @@ export class InstanceStore implements IInstanceStore {
|
||||
});
|
||||
return instance;
|
||||
} catch (error) {
|
||||
console.log("Error while fetching the instance");
|
||||
console.log("Error while fetching the instance info");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@ -104,7 +120,37 @@ export class InstanceStore implements IInstanceStore {
|
||||
});
|
||||
return configurations;
|
||||
} catch (error) {
|
||||
console.log("Error while fetching the instance");
|
||||
console.log("Error while fetching the instance configurations");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* update instance configurations
|
||||
* @param data
|
||||
*/
|
||||
updateInstanceConfigurations = async (data: Partial<IFormattedInstanceConfiguration>) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.loader = true;
|
||||
this.error = null;
|
||||
});
|
||||
|
||||
const response = await this.instanceService.updateInstanceConfigurations(data);
|
||||
|
||||
runInAction(() => {
|
||||
this.loader = false;
|
||||
this.error = null;
|
||||
this.configurations = this.configurations ? [...this.configurations, ...response] : response;
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
runInAction(() => {
|
||||
this.loader = false;
|
||||
this.error = error;
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
@ -1,42 +1,51 @@
|
||||
import { observable, action, computed, makeObservable, runInAction } from "mobx";
|
||||
// types
|
||||
import { RootStore } from "./root";
|
||||
import { IPage } from "types";
|
||||
import isYesterday from "date-fns/isYesterday";
|
||||
import isToday from "date-fns/isToday";
|
||||
import isThisWeek from "date-fns/isThisWeek";
|
||||
// services
|
||||
import { ProjectService } from "services/project";
|
||||
import { PageService } from "services/page.service";
|
||||
// helpers
|
||||
import { renderDateFormat } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { RootStore } from "./root";
|
||||
import { IPage, IRecentPages } from "types";
|
||||
|
||||
export interface IPageStore {
|
||||
loader: boolean;
|
||||
error: any | null;
|
||||
|
||||
pageId: string | null;
|
||||
pages: {
|
||||
[project_id: string]: IPage[];
|
||||
};
|
||||
page_details: {
|
||||
[page_id: string]: IPage;
|
||||
archivedPages: {
|
||||
[project_id: string]: IPage[];
|
||||
};
|
||||
|
||||
//computed
|
||||
projectPages: IPage[];
|
||||
projectPages: IPage[] | undefined;
|
||||
recentProjectPages: IRecentPages | undefined;
|
||||
favoriteProjectPages: IPage[] | undefined;
|
||||
privateProjectPages: IPage[] | undefined;
|
||||
sharedProjectPages: IPage[] | undefined;
|
||||
archivedProjectPages: IPage[] | undefined;
|
||||
// actions
|
||||
setPageId: (pageId: string) => void;
|
||||
fetchPages: (workspaceSlug: string, projectSlug: string) => void;
|
||||
fetchPages: (workspaceSlug: string, projectId: string) => Promise<IPage[]>;
|
||||
createPage: (workspaceSlug: string, projectId: string, data: Partial<IPage>) => Promise<IPage>;
|
||||
updatePage: (workspaceSlug: string, projectId: string, pageId: string, data: Partial<IPage>) => Promise<IPage>;
|
||||
deletePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
||||
addToFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
||||
removeFromFavorites: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
||||
makePublic: (workspaceSlug: string, projectId: string, pageId: string) => Promise<IPage>;
|
||||
makePrivate: (workspaceSlug: string, projectId: string, pageId: string) => Promise<IPage>;
|
||||
fetchArchivedPages: (workspaceSlug: string, projectId: string) => Promise<IPage[]>;
|
||||
archivePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
||||
restorePage: (workspaceSlug: string, projectId: string, pageId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
class PageStore implements IPageStore {
|
||||
export class PageStore implements IPageStore {
|
||||
loader: boolean = false;
|
||||
error: any | null = null;
|
||||
|
||||
pageId: string | null = null;
|
||||
pages: {
|
||||
[project_id: string]: IPage[];
|
||||
} = {};
|
||||
page_details: {
|
||||
[page_id: string]: IPage;
|
||||
} = {};
|
||||
|
||||
pages: { [project_id: string]: IPage[] } = {};
|
||||
archivedPages: { [project_id: string]: IPage[] } = {};
|
||||
// root store
|
||||
rootStore;
|
||||
// service
|
||||
@ -48,15 +57,27 @@ class PageStore implements IPageStore {
|
||||
// observable
|
||||
loader: observable,
|
||||
error: observable,
|
||||
|
||||
pageId: observable.ref,
|
||||
pages: observable.ref,
|
||||
|
||||
archivedPages: observable.ref,
|
||||
// computed
|
||||
projectPages: computed,
|
||||
recentProjectPages: computed,
|
||||
favoriteProjectPages: computed,
|
||||
privateProjectPages: computed,
|
||||
sharedProjectPages: computed,
|
||||
archivedProjectPages: computed,
|
||||
// action
|
||||
setPageId: action,
|
||||
fetchPages: action,
|
||||
createPage: action,
|
||||
updatePage: action,
|
||||
deletePage: action,
|
||||
addToFavorites: action,
|
||||
removeFromFavorites: action,
|
||||
makePublic: action,
|
||||
makePrivate: action,
|
||||
archivePage: action,
|
||||
restorePage: action,
|
||||
fetchArchivedPages: action,
|
||||
});
|
||||
|
||||
this.rootStore = _rootStore;
|
||||
@ -65,35 +86,252 @@ class PageStore implements IPageStore {
|
||||
}
|
||||
|
||||
get projectPages() {
|
||||
if (!this.rootStore.project.projectId) return [];
|
||||
if (!this.rootStore.project.projectId) return;
|
||||
return this.pages?.[this.rootStore.project.projectId] || [];
|
||||
}
|
||||
|
||||
setPageId = (pageId: string) => {
|
||||
this.pageId = pageId;
|
||||
get recentProjectPages() {
|
||||
if (!this.rootStore.project.projectId) return;
|
||||
const data: IRecentPages = { today: [], yesterday: [], this_week: [] };
|
||||
data["today"] = this.pages[this.rootStore.project.projectId]?.filter((p) => isToday(new Date(p.created_at))) || [];
|
||||
data["yesterday"] =
|
||||
this.pages[this.rootStore.project.projectId]?.filter((p) => isYesterday(new Date(p.created_at))) || [];
|
||||
data["this_week"] =
|
||||
this.pages[this.rootStore.project.projectId]?.filter((p) => isThisWeek(new Date(p.created_at))) || [];
|
||||
return data;
|
||||
}
|
||||
|
||||
get favoriteProjectPages() {
|
||||
if (!this.rootStore.project.projectId) return;
|
||||
return this.pages[this.rootStore.project.projectId]?.filter((p) => p.is_favorite);
|
||||
}
|
||||
|
||||
get privateProjectPages() {
|
||||
if (!this.rootStore.project.projectId) return;
|
||||
return this.pages[this.rootStore.project.projectId]?.filter((p) => p.access === 1);
|
||||
}
|
||||
|
||||
get sharedProjectPages() {
|
||||
if (!this.rootStore.project.projectId) return;
|
||||
return this.pages[this.rootStore.project.projectId]?.filter((p) => p.access === 0);
|
||||
}
|
||||
|
||||
get archivedProjectPages() {
|
||||
if (!this.rootStore.project.projectId) return;
|
||||
return this.archivedPages[this.rootStore.project.projectId];
|
||||
}
|
||||
|
||||
addToFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.pages = {
|
||||
...this.pages,
|
||||
[projectId]: this.pages[projectId].map((page) => {
|
||||
if (page.id === pageId) {
|
||||
return { ...page, is_favorite: true };
|
||||
}
|
||||
return page;
|
||||
}),
|
||||
};
|
||||
});
|
||||
await this.pageService.addPageToFavorites(workspaceSlug, projectId, pageId);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
fetchPages = async (workspaceSlug: string, projectSlug: string) => {
|
||||
removeFromFavorites = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.pages = {
|
||||
...this.pages,
|
||||
[projectId]: this.pages[projectId].map((page) => {
|
||||
if (page.id === pageId) {
|
||||
return { ...page, is_favorite: false };
|
||||
}
|
||||
return page;
|
||||
}),
|
||||
};
|
||||
});
|
||||
await this.pageService.removePageFromFavorites(workspaceSlug, projectId, pageId);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
fetchPages = async (workspaceSlug: string, projectId: string) => {
|
||||
try {
|
||||
this.loader = true;
|
||||
this.error = null;
|
||||
|
||||
const pagesResponse = await this.pageService.getPagesWithParams(workspaceSlug, projectSlug, "all");
|
||||
const response = await this.pageService.getPagesWithParams(workspaceSlug, projectId, "all");
|
||||
|
||||
runInAction(() => {
|
||||
this.pages = {
|
||||
...this.pages,
|
||||
[projectSlug]: pagesResponse,
|
||||
[projectId]: response,
|
||||
};
|
||||
this.loader = false;
|
||||
this.error = null;
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch project pages in project store", error);
|
||||
this.loader = false;
|
||||
this.error = error;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
createPage = async (workspaceSlug: string, projectId: string, data: Partial<IPage>) => {
|
||||
try {
|
||||
const response = await this.pageService.createPage(workspaceSlug, projectId, data);
|
||||
runInAction(() => {
|
||||
this.pages = {
|
||||
...this.pages,
|
||||
[projectId]: [...this.pages[projectId], response],
|
||||
};
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
updatePage = async (workspaceSlug: string, projectId: string, pageId: string, data: Partial<IPage>) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.pages = {
|
||||
...this.pages,
|
||||
[projectId]: this.pages[projectId]?.map((page) => {
|
||||
if (page.id === pageId) {
|
||||
return { ...page, ...data };
|
||||
}
|
||||
return page;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const response = await this.pageService.patchPage(workspaceSlug, projectId, pageId, data);
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
deletePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.archivedPages = {
|
||||
...this.archivedPages,
|
||||
[projectId]: this.archivedPages[projectId]?.filter((page) => page.id !== pageId),
|
||||
};
|
||||
});
|
||||
|
||||
const response = await this.pageService.deletePage(workspaceSlug, projectId, pageId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
makePublic = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.pages = {
|
||||
...this.pages,
|
||||
[projectId]: this.pages[projectId]?.map((page) => ({
|
||||
...page,
|
||||
access: page.id === pageId ? 0 : page.access,
|
||||
})),
|
||||
};
|
||||
});
|
||||
const response = await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 0 });
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
makePrivate = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.pages = {
|
||||
...this.pages,
|
||||
[projectId]: this.pages[projectId]?.map((page) => ({
|
||||
...page,
|
||||
access: page.id === pageId ? 1 : page.access,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const response = await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 1 });
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
fetchArchivedPages = async (workspaceSlug: string, projectId: string) => {
|
||||
try {
|
||||
const response = await this.pageService.getArchivedPages(workspaceSlug, projectId);
|
||||
runInAction(() => {
|
||||
this.archivedPages = {
|
||||
...this.archivedPages,
|
||||
[projectId]: response,
|
||||
};
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
archivePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||
try {
|
||||
const archivedPage = this.pages[projectId]?.find((page) => page.id === pageId);
|
||||
|
||||
if (archivedPage) {
|
||||
runInAction(() => {
|
||||
this.pages = {
|
||||
...this.pages,
|
||||
[projectId]: [...this.pages[projectId]?.filter((page) => page.id != pageId)],
|
||||
};
|
||||
this.archivedPages = {
|
||||
...this.archivedPages,
|
||||
[projectId]: [
|
||||
...this.archivedPages[projectId],
|
||||
{ ...archivedPage, archived_at: renderDateFormat(new Date()) },
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
await this.pageService.archivePage(workspaceSlug, projectId, pageId);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
restorePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||
try {
|
||||
const restoredPage = this.archivedPages[projectId]?.find((page) => page.id === pageId);
|
||||
|
||||
if (restoredPage) {
|
||||
runInAction(() => {
|
||||
this.pages = {
|
||||
...this.pages,
|
||||
[projectId]: [...this.pages[projectId], { ...restoredPage, archived_at: null }],
|
||||
};
|
||||
this.archivedPages = {
|
||||
...this.archivedPages,
|
||||
[projectId]: [...this.archivedPages[projectId]?.filter((page) => page.id != pageId)],
|
||||
};
|
||||
});
|
||||
}
|
||||
await this.pageService.restorePage(workspaceSlug, projectId, pageId);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default PageStore;
|
||||
|
@ -113,6 +113,8 @@ import {
|
||||
import { IWebhookStore, WebhookStore } from "./webhook.store";
|
||||
|
||||
import { IMentionsStore, MentionsStore } from "store/editor";
|
||||
// pages
|
||||
import { PageStore, IPageStore } from "store/page.store";
|
||||
|
||||
enableStaticRendering(typeof window === "undefined");
|
||||
|
||||
@ -186,6 +188,8 @@ export class RootStore {
|
||||
|
||||
mentionsStore: IMentionsStore;
|
||||
|
||||
page: IPageStore;
|
||||
|
||||
constructor() {
|
||||
this.instance = new InstanceStore(this);
|
||||
|
||||
@ -254,5 +258,7 @@ export class RootStore {
|
||||
this.webhook = new WebhookStore(this);
|
||||
|
||||
this.mentionsStore = new MentionsStore(this);
|
||||
|
||||
this.page = new PageStore(this);
|
||||
}
|
||||
}
|
||||
|
14
web/types/instance.d.ts
vendored
14
web/types/instance.d.ts
vendored
@ -20,3 +20,17 @@ export interface IInstance {
|
||||
updated_by: string | null;
|
||||
primary_owner: string;
|
||||
}
|
||||
|
||||
export interface IInstanceConfiguration {
|
||||
id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
key: string;
|
||||
value: string;
|
||||
created_by: string | null;
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
export interface IFormattedInstanceConfiguration{
|
||||
[key: string]: string;
|
||||
}
|
||||
|
9
web/types/pages.d.ts
vendored
9
web/types/pages.d.ts
vendored
@ -3,6 +3,7 @@ import { IIssue, IIssueLabel, IWorkspaceLite, IProjectLite } from "types";
|
||||
|
||||
export interface IPage {
|
||||
access: number;
|
||||
archived_at: string | null;
|
||||
blocks: IPageBlock[];
|
||||
color: string;
|
||||
created_at: Date;
|
||||
@ -12,6 +13,7 @@ export interface IPage {
|
||||
description_stripped: string | null;
|
||||
id: string;
|
||||
is_favorite: boolean;
|
||||
is_locked: boolean;
|
||||
label_details: IIssueLabel[];
|
||||
labels: string[];
|
||||
name: string;
|
||||
@ -24,6 +26,13 @@ export interface IPage {
|
||||
workspace_detail: IWorkspaceLite;
|
||||
}
|
||||
|
||||
export interface IRecentPages {
|
||||
today: IPage[];
|
||||
yesterday: IPage[];
|
||||
this_week: IPage[];
|
||||
[key: string]: IPage[];
|
||||
}
|
||||
|
||||
export interface RecentPagesResponse {
|
||||
[key: string]: IPage[];
|
||||
}
|
||||
|
@ -2376,7 +2376,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-2.1.12.tgz#7c905a577ce30ef2cb335870a23f9d24fd26f6aa"
|
||||
integrity sha512-vtD8vWtNlmAZX8LYqt2yU9w3mU9rPCiHmbp4hDXJs2kBnI0Ju/qAyXFx6iJ3C3XyuMnMbJdDI9ee0spAvFz7cQ==
|
||||
|
||||
"@tiptap/extension-code-block-lowlight@^2.1.12":
|
||||
"@tiptap/extension-code-block-lowlight@^2.1.11", "@tiptap/extension-code-block-lowlight@^2.1.12":
|
||||
version "2.1.12"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.1.12.tgz#ccbca5d0d92bee373dc8e2e2ae6c27f62f66437c"
|
||||
integrity sha512-dtIbpI9QrWa9TzNO4v5q/zf7+d83wpy5i9PEccdJAVtRZ0yOI8JIZAWzG5ex3zAoCA0CnQFdsPSVykYSDdxtDA==
|
||||
|
Loading…
Reference in New Issue
Block a user