Merge branch 'chore/file_asset_update' of github.com:makeplane/plane into develop-deploy

This commit is contained in:
pablohashescobar 2023-11-20 23:17:00 +05:30
commit ac08339cf8
99 changed files with 3965 additions and 2301 deletions

View File

@ -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)

View File

@ -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
)
)

View File

@ -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)

View File

@ -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,
)

View File

@ -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)

View File

@ -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

View File

@ -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..."))

View File

@ -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(

View File

@ -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),
)

View File

@ -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}:"

View File

@ -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");

View File

@ -0,0 +1 @@
# Document Editor

View 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"
]
}

View 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: {},
},
};

View File

@ -0,0 +1,3 @@
export { DocumentEditor, DocumentEditorWithRef } from "./ui"
export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef } from "./ui/readonly"
export { FixedMenu } from "./ui/menu/fixed-menu"

View File

@ -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>
)
}

View File

@ -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>
);

View File

@ -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>
)
}

View File

@ -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>
);

View File

@ -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>
)
}

View File

@ -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>
);
};

View File

@ -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>
)
}

View File

@ -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>
)
}

View 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,
}),
];

View File

@ -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;

View File

@ -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,
}
}

View 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 }

View 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>
);
};

View 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>
);

View File

@ -0,0 +1 @@
export { FixedMenu } from "./fixed-menu";

View 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 }

View 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 })
}
/>
);
};

View File

@ -0,0 +1,8 @@
export interface DocumentDetails {
title: string;
created_by: string;
created_on: Date;
last_updated_by: string;
last_updated_at: Date;
}

View 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>
}

View File

@ -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)
}
}

View 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())
}
}

View 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
}

View 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,
};

View File

@ -0,0 +1,5 @@
{
"extends": "tsconfig/react-library.json",
"include": ["src/**/*", "index.d.ts"],
"exclude": ["dist", "build", "node_modules"]
}

View 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,
}));

View File

@ -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": []

View File

@ -203,8 +203,6 @@ export const CommandPalette: FC = observer(() => {
<CreateUpdatePageModal
isOpen={isCreatePageModalOpen}
handleClose={() => toggleCreatePageModal(false)}
user={user}
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
/>
</>

View File

@ -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

View 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>
);
};

View File

@ -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>

View 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>
</>
);
};

View 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>
</>
);
};

View 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>
);
};

View File

@ -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>

View File

@ -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>

View File

@ -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}

View File

@ -186,7 +186,7 @@ export const CreateUpdateLabelInline = observer(
id="labelName"
name="name"
type="text"
autofocus
autoFocus
value={value}
onChange={onChange}
ref={ref}

View File

@ -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>

View File

@ -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>
);
};
});

View File

@ -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";

View File

@ -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>
);

View File

@ -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} />;
});

View 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} />;
});

View File

@ -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} />;
});

View File

@ -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";

View 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>
</>
);
});

View 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>
)}
</>
);
});

View File

@ -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} />;
};

View File

@ -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} />;
};

View 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} />;
});

View File

@ -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>
</>
)}
</>
);

View 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} />;
});

View File

@ -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>
)}
</>
);
});

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -14,7 +14,7 @@ type Props = {
const viewerTabs = [
{
route: "",
label: "Overview",
label: "Summary",
selected: "/[workspaceSlug]/profile/[userId]",
},
];

View File

@ -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>

View File

@ -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

View File

@ -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()}`;

View File

@ -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,
},
];

View File

@ -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>
);
});

View File

@ -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>
</>
);

View 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}</>;
});

View File

@ -1,3 +1,4 @@
export * from "./user-wrapper";
export * from "./workspace-wrapper";
export * from "./project-wrapper";
export * from "./admin-wrapper";

View File

@ -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",

View File

@ -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>
);

View File

@ -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>
) : (

View File

@ -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 (

View File

@ -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;

View 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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View 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;

View 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

View File

@ -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;
})
}
}

View File

@ -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;
});
}
}

View File

@ -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;
}
};

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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[];
}

View File

@ -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==