forked from github/plane
feat: New Pages with Enhanced Document Editor Packages made over Editor Core 📝 (#2784)
* fix: page transaction model * fix: page transaction model * feat: updated ui for page route * chore: initailized `document-editor` package for plane * fix: format persistence while pasting markdown in editor * feat: Inititalized Document-Editor and Editor with Ref * feat: added tooltip component and slash command for editor * feat: added `document-editor` extensions * feat: added custom search component for embedding labels * feat: added top bar menu component * feat: created document-editor exposed components * feat: integrated `document-editor` in `pages` route * chore: updated dependencies * feat: merge conflict resolution * chore: modified configuration for document editor * feat: added content browser menu for document editor summary * feat: added fixed menu and editor instances * feat: added document edittor instances and summary table * feat: implemented document-editor in PageDetail * chore: css and export fixes * fix: migration and optimisation * fix: added `on_create` hook in the core editor * feat: added conditional menu bar action in document-editor * feat: added menu actions from single page view * feat: added services for archiving, unarchiving and retriving archived pages * feat: added services for page archives * feat: implemented page archives in page list view * feat: implemented page archives in document-editor * feat: added editor marking hook * chore: seperated editor header from the main content * chore: seperated editor summary utilities from the main editor * chore: refactored necessary components from the document editor * chore: removed summary sidebar component from the main content editor * chore: removed scrollSummaryDependency from Header and Sidebar * feat: seperated page renderer as a seperate component * chore: seperated page_renderer and sidebar as component from index * feat: added locked property to IPage type * feat: added lock/unlock services in page service * chore: seperated DocumentDetails as exported interface from index * feat: seperated document editor configs as seperate interfaces * chore: seperated menu options from the editor header component * fix: fixed page_lock performing lock/unlock operation on queryset instead of single instance * fix: css positioning changes * feat: added archive/lock alert labels * feat: added boolean props in menu-actions/options * feat: added lock/unlock & archive/unarchive services * feat: added on update mutations for archived pages in page-view * feat: added archive/lock on_update mutations in single page vieq * feat: exported readonly editor for locked pages * chore: seperated kanban menu props and saved over passing redundant data * fix: readonly editor not generating markings on first render * fix: cheveron overflowing from editor-header * chore: removed unused utility actions * fix: enabled sidebar view by default * feat: removed locking on pages in archived state * feat: added indentation in heading component * fix: button classnames in vertical dropdowns * feat: added `last_archived_at` and `last_edited_at` details in editor-header * feat: changed types for archived updates and document last updates * feat: updated editor and header props * feat: updated queryset according to new page query format * feat: added parameters in page view for shared / private pages * feat: updated other-page-view to shared page view && same with private pages * feat: added page-view as shared / private * fix: replaced deleting to archiving for pages * feat: handle restoring of page from archived section from list view * feat: made previledge based option render for pages * feat: removed layout view for page list view * feat: linting changes * fix: adding mobx changes to pages * fix: removed uneccessary migrations * fix: mobx store changes * fix: adding date-fns pacakge * fix: updating yarn lock * fix: removing unneccessary method params * chore: added access specifier to the create/update page modal * fix: tab view layout changes * chore: delete endpoint for page * fix: page actions, including- archive, favorite, access control, delete * chore: remove archive page modal * fix: build errors --------- Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> Co-authored-by: sriramveeraghanta <veeraghanta.sriram@gmail.com> Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
This commit is contained in:
parent
b903126e5a
commit
de581102e3
@ -136,7 +136,9 @@ class PageViewSet(BaseViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def lock(self, request, slug, project_id, page_id):
|
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
|
# only the owner can lock the page
|
||||||
if request.user.id != page.owned_by_id:
|
if request.user.id != page.owned_by_id:
|
||||||
@ -149,7 +151,9 @@ class PageViewSet(BaseViewSet):
|
|||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
def unlock(self, request, slug, project_id, page_id):
|
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
|
# only the owner can unlock the page
|
||||||
if request.user.id != page.owned_by_id:
|
if request.user.id != page.owned_by_id:
|
||||||
@ -164,68 +168,10 @@ class PageViewSet(BaseViewSet):
|
|||||||
|
|
||||||
def list(self, request, slug, project_id):
|
def list(self, request, slug, project_id):
|
||||||
queryset = self.get_queryset().filter(archived_at__isnull=True)
|
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(
|
return Response(
|
||||||
PageSerializer(queryset, many=True).data, status=status.HTTP_200_OK
|
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
|
|
||||||
)
|
|
||||||
|
|
||||||
def archive(self, request, slug, project_id, page_id):
|
def archive(self, request, slug, project_id, page_id):
|
||||||
page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id)
|
page = Page.objects.get(pk=page_id, workspace__slug=slug, project_id=project_id)
|
||||||
|
|
||||||
@ -251,25 +197,40 @@ class PageViewSet(BaseViewSet):
|
|||||||
# if parent page is archived then the page will be un archived breaking the hierarchy
|
# if parent page is archived then the page will be un archived breaking the hierarchy
|
||||||
if page.parent_id and page.parent.archived_at:
|
if page.parent_id and page.parent.archived_at:
|
||||||
page.parent = None
|
page.parent = None
|
||||||
page.save(update_fields=['parent'])
|
page.save(update_fields=["parent"])
|
||||||
|
|
||||||
unarchive_archive_page_and_descendants(page_id, None)
|
unarchive_archive_page_and_descendants(page_id, None)
|
||||||
|
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
def archive_list(self, request, slug, project_id):
|
def archive_list(self, request, slug, project_id):
|
||||||
pages = (
|
pages = Page.objects.filter(
|
||||||
Page.objects.filter(
|
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
)
|
).filter(archived_at__isnull=False)
|
||||||
.filter(archived_at__isnull=False)
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
PageSerializer(pages, many=True).data, status=status.HTTP_200_OK
|
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):
|
class PageFavoriteViewSet(BaseViewSet):
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
@ -306,6 +267,7 @@ class PageFavoriteViewSet(BaseViewSet):
|
|||||||
page_favorite.delete()
|
page_favorite.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
class PageLogEndpoint(BaseAPIView):
|
class PageLogEndpoint(BaseAPIView):
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
ProjectEntityPermission,
|
ProjectEntityPermission,
|
||||||
|
@ -12,7 +12,7 @@ from celery import shared_task
|
|||||||
from sentry_sdk import capture_exception
|
from sentry_sdk import capture_exception
|
||||||
|
|
||||||
# Module imports
|
# 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
|
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():
|
def archive_and_close_old_issues():
|
||||||
archive_old_issues()
|
archive_old_issues()
|
||||||
close_old_issues()
|
close_old_issues()
|
||||||
delete_archived_pages()
|
|
||||||
|
|
||||||
|
|
||||||
def archive_old_issues():
|
def archive_old_issues():
|
||||||
@ -167,20 +166,3 @@ def close_old_issues():
|
|||||||
print(e)
|
print(e)
|
||||||
capture_exception(e)
|
capture_exception(e)
|
||||||
return
|
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
|
|
||||||
|
|
||||||
|
@ -133,6 +133,7 @@ class Issue(ProjectBaseModel):
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
if self._state.adding:
|
if self._state.adding:
|
||||||
# Get the maximum display_id value from the database
|
# Get the maximum display_id value from the database
|
||||||
last_id = IssueSequence.objects.filter(project=self.project).aggregate(
|
last_id = IssueSequence.objects.filter(project=self.project).aggregate(
|
||||||
|
@ -18,6 +18,7 @@ interface CustomEditorProps {
|
|||||||
value: string;
|
value: string;
|
||||||
deleteFile: DeleteImage;
|
deleteFile: DeleteImage;
|
||||||
debouncedUpdatesEnabled?: boolean;
|
debouncedUpdatesEnabled?: boolean;
|
||||||
|
onStart?: (json: any, html: string) => void;
|
||||||
onChange?: (json: any, html: string) => void;
|
onChange?: (json: any, html: string) => void;
|
||||||
extensions?: any;
|
extensions?: any;
|
||||||
editorProps?: EditorProps;
|
editorProps?: EditorProps;
|
||||||
@ -34,6 +35,7 @@ export const useEditor = ({
|
|||||||
editorProps = {},
|
editorProps = {},
|
||||||
value,
|
value,
|
||||||
extensions = [],
|
extensions = [],
|
||||||
|
onStart,
|
||||||
onChange,
|
onChange,
|
||||||
setIsSubmitting,
|
setIsSubmitting,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
@ -60,6 +62,9 @@ export const useEditor = ({
|
|||||||
],
|
],
|
||||||
content:
|
content:
|
||||||
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
|
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
|
||||||
|
onCreate: async ({ editor }) => {
|
||||||
|
onStart?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()))
|
||||||
|
},
|
||||||
onUpdate: async ({ editor }) => {
|
onUpdate: async ({ editor }) => {
|
||||||
// for instant feedback loop
|
// for instant feedback loop
|
||||||
setIsSubmitting?.("submitting");
|
setIsSubmitting?.("submitting");
|
||||||
|
1
packages/editor/document-editor/Readme.md
Normal file
1
packages/editor/document-editor/Readme.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Document Editor
|
73
packages/editor/document-editor/package.json
Normal file
73
packages/editor/document-editor/package.json
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"name": "@plane/document-editor",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Package that powers Plane's Pages Editor",
|
||||||
|
"main": "./dist/index.mjs",
|
||||||
|
"module": "./dist/index.mjs",
|
||||||
|
"types": "./dist/index.d.mts",
|
||||||
|
"files": [
|
||||||
|
"dist/**/*"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.mts",
|
||||||
|
"import": "./dist/index.mjs",
|
||||||
|
"module": "./dist/index.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"dev": "tsup --watch",
|
||||||
|
"check-types": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"next": "12.3.2",
|
||||||
|
"next-themes": "^0.2.1",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "18.2.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@headlessui/react": "^1.7.17",
|
||||||
|
"@plane/ui": "*",
|
||||||
|
"@plane/editor-core": "*",
|
||||||
|
"@popperjs/core": "^2.11.8",
|
||||||
|
"@tiptap/core": "^2.1.7",
|
||||||
|
"@tiptap/extension-code-block-lowlight": "^2.1.11",
|
||||||
|
"@tiptap/extension-horizontal-rule": "^2.1.11",
|
||||||
|
"@tiptap/extension-list-item": "^2.1.11",
|
||||||
|
"@tiptap/extension-placeholder": "^2.1.11",
|
||||||
|
"@tiptap/suggestion": "^2.1.7",
|
||||||
|
"@types/node": "18.15.3",
|
||||||
|
"@types/react": "^18.2.5",
|
||||||
|
"@types/react-dom": "18.0.11",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^1.2.1",
|
||||||
|
"eslint": "8.36.0",
|
||||||
|
"eslint-config-next": "13.2.4",
|
||||||
|
"eventsource-parser": "^0.1.0",
|
||||||
|
"highlight.js": "^11.8.0",
|
||||||
|
"lowlight": "^3.0.0",
|
||||||
|
"lucide-react": "^0.244.0",
|
||||||
|
"react-markdown": "^8.0.7",
|
||||||
|
"react-popper": "^2.3.0",
|
||||||
|
"tailwind-merge": "^1.14.0",
|
||||||
|
"tippy.js": "^6.3.7",
|
||||||
|
"tiptap-markdown": "^0.8.2",
|
||||||
|
"use-debounce": "^9.0.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"eslint": "^7.32.0",
|
||||||
|
"postcss": "^8.4.29",
|
||||||
|
"tailwind-config-custom": "*",
|
||||||
|
"tsconfig": "*",
|
||||||
|
"tsup": "^7.2.0",
|
||||||
|
"typescript": "4.9.5"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"editor",
|
||||||
|
"rich-text",
|
||||||
|
"markdown",
|
||||||
|
"nextjs",
|
||||||
|
"react"
|
||||||
|
]
|
||||||
|
}
|
9
packages/editor/document-editor/postcss.config.js
Normal file
9
packages/editor/document-editor/postcss.config.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
// If you want to use other PostCSS plugins, see the following:
|
||||||
|
// https://tailwindcss.com/docs/using-with-preprocessors
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
3
packages/editor/document-editor/src/index.ts
Normal file
3
packages/editor/document-editor/src/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { DocumentEditor, DocumentEditorWithRef } from "./ui"
|
||||||
|
export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef } from "./ui/readonly"
|
||||||
|
export { FixedMenu } from "./ui/menu/fixed-menu"
|
@ -0,0 +1,19 @@
|
|||||||
|
import { Icon } from "lucide-react"
|
||||||
|
|
||||||
|
interface IAlertLabelProps {
|
||||||
|
Icon: Icon,
|
||||||
|
backgroundColor: string,
|
||||||
|
textColor?: string,
|
||||||
|
label: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AlertLabel = ({ Icon, backgroundColor,textColor, label }: IAlertLabelProps) => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`text-xs flex items-center gap-1 ${backgroundColor} p-0.5 pl-3 pr-3 mr-1 rounded`}>
|
||||||
|
<Icon size={12} />
|
||||||
|
<span className={`normal-case ${textColor}`}>{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
import { HeadingComp, SubheadingComp } from "./heading-component";
|
||||||
|
import { IMarking } from "..";
|
||||||
|
import { Editor } from "@tiptap/react";
|
||||||
|
import { scrollSummary } from "../utils/editor-summary-utils";
|
||||||
|
|
||||||
|
interface ContentBrowserProps {
|
||||||
|
editor: Editor;
|
||||||
|
markings: IMarking[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ContentBrowser = ({
|
||||||
|
editor,
|
||||||
|
markings,
|
||||||
|
}: ContentBrowserProps) => (
|
||||||
|
<div className="mt-4 flex w-[250px] flex-col h-full">
|
||||||
|
<h2 className="ml-4 border-b border-solid border-custom-border py-5 font-medium leading-[85.714%] tracking-tight max-md:ml-2.5">
|
||||||
|
Table of Contents
|
||||||
|
</h2>
|
||||||
|
<div className="mt-3 h-0.5 w-full self-stretch border-custom-border" />
|
||||||
|
{markings.length !== 0 ? (
|
||||||
|
markings.map((marking) =>
|
||||||
|
marking.level === 1 ? (
|
||||||
|
<HeadingComp
|
||||||
|
onClick={() => scrollSummary(editor, marking)}
|
||||||
|
heading={marking.text}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SubheadingComp
|
||||||
|
onClick={() => scrollSummary(editor, marking)}
|
||||||
|
subHeading={marking.text}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p className="ml-3 mr-3 flex h-full items-center px-5 text-center text-xs text-gray-500">
|
||||||
|
{"Headings will be displayed here for Navigation"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
@ -0,0 +1,79 @@
|
|||||||
|
import { Editor } from "@tiptap/react"
|
||||||
|
import { Lock, ArchiveIcon, MenuSquare } from "lucide-react"
|
||||||
|
import { useRef, useState } from "react"
|
||||||
|
import { usePopper } from "react-popper"
|
||||||
|
import { IMarking, UploadImage } from ".."
|
||||||
|
import { FixedMenu } from "../menu"
|
||||||
|
import { DocumentDetails } from "../types/editor-types"
|
||||||
|
import { AlertLabel } from "./alert-label"
|
||||||
|
import { ContentBrowser } from "./content-browser"
|
||||||
|
import { IVerticalDropdownItemProps, VerticalDropdownMenu } from "./vertical-dropdown-menu"
|
||||||
|
|
||||||
|
interface IEditorHeader {
|
||||||
|
editor: Editor,
|
||||||
|
KanbanMenuOptions: IVerticalDropdownItemProps[],
|
||||||
|
sidePeakVisible: boolean,
|
||||||
|
setSidePeakVisible: (currentState: boolean) => void,
|
||||||
|
markings: IMarking[],
|
||||||
|
isLocked: boolean,
|
||||||
|
isArchived: boolean,
|
||||||
|
archivedAt?: Date,
|
||||||
|
readonly: boolean,
|
||||||
|
uploadFile?: UploadImage,
|
||||||
|
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
|
||||||
|
documentDetails: DocumentDetails
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditorHeader = ({ documentDetails, archivedAt, editor, sidePeakVisible, readonly, setSidePeakVisible, markings, uploadFile, setIsSubmitting, KanbanMenuOptions, isArchived, isLocked }: IEditorHeader) => {
|
||||||
|
|
||||||
|
const summaryMenuRef = useRef(null);
|
||||||
|
const summaryButtonRef = useRef(null);
|
||||||
|
const [summaryPopoverVisible, setSummaryPopoverVisible] = useState(false);
|
||||||
|
|
||||||
|
const { styles: summaryPopoverStyles, attributes: summaryPopoverAttributes } = usePopper(summaryButtonRef.current, summaryMenuRef.current, {
|
||||||
|
placement: "bottom-start"
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<div className="border-custom-border self-stretch flex flex-col border-b border-solid max-md:max-w-full">
|
||||||
|
<div
|
||||||
|
className="self-center flex ml-0 w-full items-start justify-between gap-5 max-md:max-w-full max-md:flex-wrap max-md:justify-center">
|
||||||
|
<div className={"flex flex-row items-center"}>
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => setSummaryPopoverVisible(true)}
|
||||||
|
onMouseLeave={() => setSummaryPopoverVisible(false)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
ref={summaryButtonRef}
|
||||||
|
className={"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors"}
|
||||||
|
onClick={() => {
|
||||||
|
setSidePeakVisible(!sidePeakVisible)
|
||||||
|
setSummaryPopoverVisible(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MenuSquare
|
||||||
|
size={20}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{summaryPopoverVisible &&
|
||||||
|
<div style={summaryPopoverStyles.popper} {...summaryPopoverAttributes.popper} className="z-10 h-[300px] w-[300px] ml-[40px] mt-[40px] shadow-xl rounded border-custom-border border-solid border-2 bg-custom-background-100 border-b pl-3 pr-3 pb-3 overflow-scroll">
|
||||||
|
<ContentBrowser editor={editor} markings={markings} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{isLocked && <AlertLabel Icon={Lock} backgroundColor={"bg-red-200"} label={"Locked"} />}
|
||||||
|
{(isArchived && archivedAt) && <AlertLabel Icon={ArchiveIcon} backgroundColor={"bg-blue-200"} label={`Archived at ${new Date(archivedAt).toLocaleString()}`} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(!readonly && uploadFile) && <FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} />}
|
||||||
|
<div className="self-center flex items-start gap-3 my-auto max-md:justify-center"
|
||||||
|
>
|
||||||
|
{!isArchived && <p className="text-sm text-custom-text-300">{`Last updated at ${new Date(documentDetails.last_updated_at).toLocaleString()}`}</p>}
|
||||||
|
<VerticalDropdownMenu items={KanbanMenuOptions} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
export const HeadingComp = ({
|
||||||
|
heading,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
heading: string;
|
||||||
|
onClick: (event: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => void;
|
||||||
|
}) => (
|
||||||
|
<h3
|
||||||
|
onClick={onClick}
|
||||||
|
className="ml-4 mt-3 cursor-pointer text-sm font-bold font-medium leading-[125%] tracking-tight hover:text-custom-primary max-md:ml-2.5"
|
||||||
|
>
|
||||||
|
{heading}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SubheadingComp = ({
|
||||||
|
subHeading,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
subHeading: string;
|
||||||
|
onClick: (event: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => void;
|
||||||
|
}) => (
|
||||||
|
<p
|
||||||
|
onClick={onClick}
|
||||||
|
className="ml-6 mt-2 text-xs cursor-pointer font-medium tracking-tight text-gray-400 hover:text-custom-primary"
|
||||||
|
>
|
||||||
|
{subHeading}
|
||||||
|
</p>
|
||||||
|
);
|
@ -0,0 +1,33 @@
|
|||||||
|
import { EditorContainer, EditorContentWrapper } from "@plane/editor-core"
|
||||||
|
import { Editor } from "@tiptap/react"
|
||||||
|
import { DocumentDetails } from "../types/editor-types"
|
||||||
|
|
||||||
|
interface IPageRenderer {
|
||||||
|
sidePeakVisible: boolean,
|
||||||
|
documentDetails: DocumentDetails ,
|
||||||
|
editor: Editor,
|
||||||
|
editorClassNames: string,
|
||||||
|
editorContentCustomClassNames?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageRenderer = ({ sidePeakVisible, documentDetails, editor, editorClassNames, editorContentCustomClassNames }: IPageRenderer) => {
|
||||||
|
return (
|
||||||
|
<div className={`flex h-[88vh] flex-col w-full max-md:w-full max-md:ml-0 transition-all duration-200 ease-in-out ${sidePeakVisible ? 'ml-[3%] ' : 'ml-0'}`}>
|
||||||
|
<div className="items-start mt-4 h-full flex flex-col w-fit max-md:max-w-full overflow-auto">
|
||||||
|
<div className="flex flex-col py-2 max-md:max-w-full">
|
||||||
|
<h1
|
||||||
|
className="border-none outline-none bg-transparent text-4xl font-bold leading-8 tracking-tight self-center w-[700px] max-w-full"
|
||||||
|
>{documentDetails.title}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="border-custom-border border-b border-solid self-stretch w-full h-0.5 mt-3" />
|
||||||
|
<div className="flex flex-col max-md:max-w-full">
|
||||||
|
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
||||||
|
</div>
|
||||||
|
</EditorContainer >
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,67 @@
|
|||||||
|
import React, { Fragment, useState } from "react";
|
||||||
|
import { usePopper } from "react-popper";
|
||||||
|
import { Popover, Transition } from "@headlessui/react";
|
||||||
|
import { Placement } from "@popperjs/core";
|
||||||
|
// ui
|
||||||
|
import { Button } from "@plane/ui";
|
||||||
|
// icons
|
||||||
|
import { ChevronUp, MenuIcon } from "lucide-react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
title?: string;
|
||||||
|
placement?: Placement;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SummaryPopover: React.FC<Props> = (props) => {
|
||||||
|
const { children, title = "SummaryPopover", placement } = props;
|
||||||
|
|
||||||
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||||
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||||
|
placement: placement ?? "auto",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover as="div">
|
||||||
|
{({ open }) => {
|
||||||
|
if (open) {
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Popover.Button as={React.Fragment}>
|
||||||
|
<Button
|
||||||
|
ref={setReferenceElement}
|
||||||
|
variant="neutral-primary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<MenuIcon size={20} />
|
||||||
|
</Button>
|
||||||
|
</Popover.Button>
|
||||||
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition ease-out duration-200"
|
||||||
|
enterFrom="opacity-0 translate-y-1"
|
||||||
|
enterTo="opacity-100 translate-y-0"
|
||||||
|
leave="transition ease-in duration-150"
|
||||||
|
leaveFrom="opacity-100 translate-y-0"
|
||||||
|
leaveTo="opacity-0 translate-y-1"
|
||||||
|
>
|
||||||
|
<Popover.Panel>
|
||||||
|
<div
|
||||||
|
className="z-10 bg-custom-background-100 border border-custom-border-200 shadow-custom-shadow-rg rounded overflow-hidden"
|
||||||
|
ref={setPopperElement}
|
||||||
|
style={styles.popper}
|
||||||
|
{...attributes.popper}
|
||||||
|
>
|
||||||
|
<div className="w-[18.75rem] max-h-[37.5rem] flex flex-col overflow-hidden">{children}</div>
|
||||||
|
</div>
|
||||||
|
</Popover.Panel>
|
||||||
|
</Transition>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,18 @@
|
|||||||
|
import { Editor } from "@tiptap/react"
|
||||||
|
import { IMarking } from ".."
|
||||||
|
import { ContentBrowser } from "./content-browser"
|
||||||
|
|
||||||
|
interface ISummarySideBarProps {
|
||||||
|
editor: Editor,
|
||||||
|
markings: IMarking[],
|
||||||
|
sidePeakVisible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SummarySideBar = ({ editor, markings, sidePeakVisible }: ISummarySideBarProps) => {
|
||||||
|
return (
|
||||||
|
|
||||||
|
<div className={`flex flex-col items-stretch w-[21%] max-md:w-full max-md:ml-0 border-custom-border border-r border-solid transition-all duration-200 ease-in-out transform ${sidePeakVisible ? 'translate-x-0' : '-translate-x-full'}`}>
|
||||||
|
<ContentBrowser editor={editor} markings={markings} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,50 @@
|
|||||||
|
import { Button, CustomMenu } from "@plane/ui"
|
||||||
|
import { ChevronUp, Icon, MoreVertical } from "lucide-react"
|
||||||
|
|
||||||
|
|
||||||
|
type TMenuItems = "archive_page" | "unarchive_page" | "lock_page" | "unlock_page" | "copy_markdown" | "close_page" | "copy_page_link" | "duplicate_page"
|
||||||
|
|
||||||
|
export interface IVerticalDropdownItemProps {
|
||||||
|
key: number,
|
||||||
|
type: TMenuItems,
|
||||||
|
Icon: Icon,
|
||||||
|
label: string,
|
||||||
|
action: () => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IVerticalDropdownMenuProps {
|
||||||
|
items: IVerticalDropdownItemProps[],
|
||||||
|
}
|
||||||
|
|
||||||
|
const VerticalDropdownItem = ({ Icon, label, action }: IVerticalDropdownItemProps) => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomMenu.MenuItem>
|
||||||
|
<Button variant={"neutral-primary"} onClick={action} className="flex flex-row border-none items-center m-1 max-md:pr-5 cursor-pointer">
|
||||||
|
<Icon size={16} />
|
||||||
|
<div className="text-custom-text-300 ml-2 mr-2 leading-5 tracking-tight whitespace-nowrap self-start text-md">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomMenu maxHeight={"lg"} className={"h-4"} placement={"bottom-start"} optionsClassName={"border-custom-border border-r border-solid transition-all duration-200 ease-in-out "} customButton={
|
||||||
|
<MoreVertical size={18}/>
|
||||||
|
}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<VerticalDropdownItem
|
||||||
|
key={index}
|
||||||
|
type={item.type}
|
||||||
|
Icon={item.Icon}
|
||||||
|
label={item.label}
|
||||||
|
action={item.action}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CustomMenu>
|
||||||
|
)
|
||||||
|
}
|
59
packages/editor/document-editor/src/ui/extensions/index.tsx
Normal file
59
packages/editor/document-editor/src/ui/extensions/index.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import HorizontalRule from "@tiptap/extension-horizontal-rule";
|
||||||
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||||
|
import { common, createLowlight } from 'lowlight'
|
||||||
|
import { InputRule } from "@tiptap/core";
|
||||||
|
|
||||||
|
import ts from "highlight.js/lib/languages/typescript";
|
||||||
|
|
||||||
|
import SlashCommand from "./slash-command";
|
||||||
|
import { UploadImage } from "../";
|
||||||
|
|
||||||
|
const lowlight = createLowlight(common)
|
||||||
|
lowlight.register("ts", ts);
|
||||||
|
|
||||||
|
export const DocumentEditorExtensions = (
|
||||||
|
uploadFile: UploadImage,
|
||||||
|
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||||
|
) => [
|
||||||
|
HorizontalRule.extend({
|
||||||
|
addInputRules() {
|
||||||
|
return [
|
||||||
|
new InputRule({
|
||||||
|
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
|
||||||
|
handler: ({ state, range, commands }) => {
|
||||||
|
commands.splitBlock();
|
||||||
|
|
||||||
|
const attributes = {};
|
||||||
|
const { tr } = state;
|
||||||
|
const start = range.from;
|
||||||
|
const end = range.to;
|
||||||
|
// @ts-ignore
|
||||||
|
tr.replaceWith(start - 1, end, this.type.create(attributes));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
}).configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "mb-6 border-t border-custom-border-300",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
SlashCommand(uploadFile, setIsSubmitting),
|
||||||
|
CodeBlockLowlight.configure({
|
||||||
|
lowlight,
|
||||||
|
}),
|
||||||
|
Placeholder.configure({
|
||||||
|
placeholder: ({ node }) => {
|
||||||
|
if (node.type.name === "heading") {
|
||||||
|
return `Heading ${node.attrs.level}`;
|
||||||
|
}
|
||||||
|
if (node.type.name === "image" || node.type.name === "table") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Press '/' for commands...";
|
||||||
|
},
|
||||||
|
includeChildren: true,
|
||||||
|
}),
|
||||||
|
];
|
@ -0,0 +1,343 @@
|
|||||||
|
import { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react";
|
||||||
|
import { Editor, Range, Extension } from "@tiptap/core";
|
||||||
|
import Suggestion from "@tiptap/suggestion";
|
||||||
|
import { ReactRenderer } from "@tiptap/react";
|
||||||
|
import tippy from "tippy.js";
|
||||||
|
import {
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Text,
|
||||||
|
TextQuote,
|
||||||
|
Code,
|
||||||
|
MinusSquare,
|
||||||
|
CheckSquare,
|
||||||
|
ImageIcon,
|
||||||
|
Table,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { UploadImage } from "../";
|
||||||
|
import { cn, insertTableCommand, toggleBlockquote, toggleBulletList, toggleOrderedList, toggleTaskList, insertImageCommand, toggleHeadingOne, toggleHeadingTwo, toggleHeadingThree } from "@plane/editor-core";
|
||||||
|
|
||||||
|
interface CommandItemProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommandProps {
|
||||||
|
editor: Editor;
|
||||||
|
range: Range;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Command = Extension.create({
|
||||||
|
name: "slash-command",
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
suggestion: {
|
||||||
|
char: "/",
|
||||||
|
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
|
||||||
|
props.command({ editor, range });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
Suggestion({
|
||||||
|
editor: this.editor,
|
||||||
|
allow({ editor }) {
|
||||||
|
return !editor.isActive("table");
|
||||||
|
},
|
||||||
|
...this.options.suggestion,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSuggestionItems =
|
||||||
|
(
|
||||||
|
uploadFile: UploadImage,
|
||||||
|
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||||
|
) =>
|
||||||
|
({ query }: { query: string }) =>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
title: "Text",
|
||||||
|
description: "Just start typing with plain text.",
|
||||||
|
searchTerms: ["p", "paragraph"],
|
||||||
|
icon: <Text size={18} />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Heading 1",
|
||||||
|
description: "Big section heading.",
|
||||||
|
searchTerms: ["title", "big", "large"],
|
||||||
|
icon: <Heading1 size={18} />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
toggleHeadingOne(editor, range);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Heading 2",
|
||||||
|
description: "Medium section heading.",
|
||||||
|
searchTerms: ["subtitle", "medium"],
|
||||||
|
icon: <Heading2 size={18} />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
toggleHeadingTwo(editor, range);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Heading 3",
|
||||||
|
description: "Small section heading.",
|
||||||
|
searchTerms: ["subtitle", "small"],
|
||||||
|
icon: <Heading3 size={18} />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
toggleHeadingThree(editor, range);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "To-do List",
|
||||||
|
description: "Track tasks with a to-do list.",
|
||||||
|
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
||||||
|
icon: <CheckSquare size={18} />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
toggleTaskList(editor, range)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Bullet List",
|
||||||
|
description: "Create a simple bullet list.",
|
||||||
|
searchTerms: ["unordered", "point"],
|
||||||
|
icon: <List size={18} />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
toggleBulletList(editor, range);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Divider",
|
||||||
|
description: "Visually divide blocks",
|
||||||
|
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
|
||||||
|
icon: <MinusSquare size={18} />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Table",
|
||||||
|
description: "Create a Table",
|
||||||
|
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
||||||
|
icon: <Table size={18} />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
insertTableCommand(editor, range);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Numbered List",
|
||||||
|
description: "Create a list with numbering.",
|
||||||
|
searchTerms: ["ordered"],
|
||||||
|
icon: <ListOrdered size={18} />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
toggleOrderedList(editor, range)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Quote",
|
||||||
|
description: "Capture a quote.",
|
||||||
|
searchTerms: ["blockquote"],
|
||||||
|
icon: <TextQuote size={18} />,
|
||||||
|
command: ({ editor, range }: CommandProps) =>
|
||||||
|
toggleBlockquote(editor, range)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Code",
|
||||||
|
description: "Capture a code snippet.",
|
||||||
|
searchTerms: ["codeblock"],
|
||||||
|
icon: <Code size={18} />,
|
||||||
|
command: ({ editor, range }: CommandProps) =>
|
||||||
|
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Image",
|
||||||
|
description: "Upload an image from your computer.",
|
||||||
|
searchTerms: ["photo", "picture", "media"],
|
||||||
|
icon: <ImageIcon size={18} />,
|
||||||
|
command: ({ editor, range }: CommandProps) => {
|
||||||
|
insertImageCommand(editor, uploadFile, setIsSubmitting, range);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
].filter((item) => {
|
||||||
|
if (typeof query === "string" && query.length > 0) {
|
||||||
|
const search = query.toLowerCase();
|
||||||
|
return (
|
||||||
|
item.title.toLowerCase().includes(search) ||
|
||||||
|
item.description.toLowerCase().includes(search) ||
|
||||||
|
(item.searchTerms && item.searchTerms.some((term: string) => term.includes(search)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
||||||
|
const containerHeight = container.offsetHeight;
|
||||||
|
const itemHeight = item ? item.offsetHeight : 0;
|
||||||
|
|
||||||
|
const top = item.offsetTop;
|
||||||
|
const bottom = top + itemHeight;
|
||||||
|
|
||||||
|
if (top < container.scrollTop) {
|
||||||
|
container.scrollTop -= container.scrollTop - top + 5;
|
||||||
|
} else if (bottom > containerHeight + container.scrollTop) {
|
||||||
|
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const CommandList = ({
|
||||||
|
items,
|
||||||
|
command,
|
||||||
|
}: {
|
||||||
|
items: CommandItemProps[];
|
||||||
|
command: any;
|
||||||
|
editor: any;
|
||||||
|
range: any;
|
||||||
|
}) => {
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
|
||||||
|
const selectItem = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const item = items[index];
|
||||||
|
if (item) {
|
||||||
|
command(item);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[command, items]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (navigationKeys.includes(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
setSelectedIndex((selectedIndex + 1) % items.length);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
selectItem(selectedIndex);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKeyDown);
|
||||||
|
};
|
||||||
|
}, [items, selectedIndex, setSelectedIndex, selectItem]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const container = commandListContainer?.current;
|
||||||
|
|
||||||
|
const item = container?.children[selectedIndex] as HTMLElement;
|
||||||
|
|
||||||
|
if (item && container) updateScrollView(container, item);
|
||||||
|
}, [selectedIndex]);
|
||||||
|
|
||||||
|
return items.length > 0 ? (
|
||||||
|
<div
|
||||||
|
id="slash-command"
|
||||||
|
ref={commandListContainer}
|
||||||
|
className="z-50 fixed h-auto max-h-[330px] w-72 overflow-y-auto rounded-md border border-custom-border-300 bg-custom-background-100 px-1 py-2 shadow-md transition-all"
|
||||||
|
>
|
||||||
|
{items.map((item: CommandItemProps, index: number) => (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100`,
|
||||||
|
{ "bg-custom-primary-100/5 text-custom-text-100": index === selectedIndex }
|
||||||
|
)}
|
||||||
|
key={index}
|
||||||
|
onClick={() => selectItem(index)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{item.title}</p>
|
||||||
|
<p className="text-xs text-custom-text-300">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderItems = () => {
|
||||||
|
let component: ReactRenderer | null = null;
|
||||||
|
let popup: any | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||||
|
component = new ReactRenderer(CommandList, {
|
||||||
|
props,
|
||||||
|
// @ts-ignore
|
||||||
|
editor: props.editor,
|
||||||
|
});
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
popup = tippy("body", {
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
appendTo: () => document.querySelector("#editor-container"),
|
||||||
|
content: component.element,
|
||||||
|
showOnCreate: true,
|
||||||
|
interactive: true,
|
||||||
|
trigger: "manual",
|
||||||
|
placement: "bottom-start",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||||
|
component?.updateProps(props);
|
||||||
|
|
||||||
|
popup &&
|
||||||
|
popup[0].setProps({
|
||||||
|
getReferenceClientRect: props.clientRect,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||||
|
if (props.event.key === "Escape") {
|
||||||
|
popup?.[0].hide();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
return component?.ref?.onKeyDown(props);
|
||||||
|
},
|
||||||
|
onExit: () => {
|
||||||
|
popup?.[0].destroy();
|
||||||
|
component?.destroy();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SlashCommand = (
|
||||||
|
uploadFile: UploadImage,
|
||||||
|
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||||
|
) =>
|
||||||
|
Command.configure({
|
||||||
|
suggestion: {
|
||||||
|
items: getSuggestionItems(uploadFile, setIsSubmitting),
|
||||||
|
render: renderItems,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SlashCommand;
|
@ -0,0 +1,33 @@
|
|||||||
|
import { Editor } from "@tiptap/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { IMarking } from "..";
|
||||||
|
|
||||||
|
export const useEditorMarkings = () => {
|
||||||
|
|
||||||
|
const [markings, setMarkings] = useState<IMarking[]>([])
|
||||||
|
|
||||||
|
const updateMarkings = (json: any) => {
|
||||||
|
const nodes = json.content as any[]
|
||||||
|
const tempMarkings: IMarking[] = []
|
||||||
|
let h1Sequence: number = 0
|
||||||
|
let h2Sequence: number = 0
|
||||||
|
if (nodes) {
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
if (node.type === "heading" && (node.attrs.level === 1 || node.attrs.level === 2) && node.content) {
|
||||||
|
tempMarkings.push({
|
||||||
|
type: "heading",
|
||||||
|
level: node.attrs.level,
|
||||||
|
text: node.content[0].text,
|
||||||
|
sequence: node.attrs.level === 1 ? ++h1Sequence : ++h2Sequence
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setMarkings(tempMarkings)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateMarkings,
|
||||||
|
markings,
|
||||||
|
}
|
||||||
|
}
|
151
packages/editor/document-editor/src/ui/index.tsx
Normal file
151
packages/editor/document-editor/src/ui/index.tsx
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
"use client"
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { cn, getEditorClassNames, useEditor } from '@plane/editor-core';
|
||||||
|
import { DocumentEditorExtensions } from './extensions';
|
||||||
|
import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from './types/menu-actions';
|
||||||
|
import { EditorHeader } from './components/editor-header';
|
||||||
|
import { useEditorMarkings } from './hooks/use-editor-markings';
|
||||||
|
import { SummarySideBar } from './components/summary-side-bar';
|
||||||
|
import { DocumentDetails } from './types/editor-types';
|
||||||
|
import { PageRenderer } from './components/page-renderer';
|
||||||
|
import { getMenuOptions } from './utils/menu-options';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
|
export type UploadImage = (file: File) => Promise<string>;
|
||||||
|
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
|
||||||
|
|
||||||
|
interface IDocumentEditor {
|
||||||
|
documentDetails: DocumentDetails,
|
||||||
|
value: string;
|
||||||
|
uploadFile: UploadImage;
|
||||||
|
deleteFile: DeleteImage;
|
||||||
|
customClassName?: string;
|
||||||
|
editorContentCustomClassNames?: string;
|
||||||
|
onChange: (json: any, html: string) => void;
|
||||||
|
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||||
|
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||||
|
forwardedRef?: any;
|
||||||
|
debouncedUpdatesEnabled?: boolean;
|
||||||
|
duplicationConfig?: IDuplicationConfig,
|
||||||
|
pageLockConfig?: IPageLockConfig,
|
||||||
|
pageArchiveConfig?: IPageArchiveConfig
|
||||||
|
}
|
||||||
|
interface DocumentEditorProps extends IDocumentEditor {
|
||||||
|
forwardedRef?: React.Ref<EditorHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorHandle {
|
||||||
|
clearEditor: () => void;
|
||||||
|
setEditorValue: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMarking {
|
||||||
|
type: "heading",
|
||||||
|
level: number,
|
||||||
|
text: string,
|
||||||
|
sequence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DocumentEditor = ({
|
||||||
|
documentDetails,
|
||||||
|
onChange,
|
||||||
|
debouncedUpdatesEnabled,
|
||||||
|
setIsSubmitting,
|
||||||
|
setShouldShowAlert,
|
||||||
|
editorContentCustomClassNames,
|
||||||
|
value,
|
||||||
|
uploadFile,
|
||||||
|
deleteFile,
|
||||||
|
customClassName,
|
||||||
|
forwardedRef,
|
||||||
|
duplicationConfig,
|
||||||
|
pageLockConfig,
|
||||||
|
pageArchiveConfig
|
||||||
|
}: IDocumentEditor) => {
|
||||||
|
|
||||||
|
// const [alert, setAlert] = useState<string>("")
|
||||||
|
const { markings, updateMarkings } = useEditorMarkings()
|
||||||
|
const [sidePeakVisible, setSidePeakVisible] = useState(true)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
onChange(json, html) {
|
||||||
|
updateMarkings(json)
|
||||||
|
onChange(json, html)
|
||||||
|
},
|
||||||
|
onStart(json) {
|
||||||
|
updateMarkings(json)
|
||||||
|
},
|
||||||
|
debouncedUpdatesEnabled,
|
||||||
|
setIsSubmitting,
|
||||||
|
setShouldShowAlert,
|
||||||
|
value,
|
||||||
|
uploadFile,
|
||||||
|
deleteFile,
|
||||||
|
forwardedRef,
|
||||||
|
extensions: DocumentEditorExtensions(uploadFile, setIsSubmitting),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const KanbanMenuOptions = getMenuOptions(
|
||||||
|
{
|
||||||
|
editor: editor,
|
||||||
|
router: router,
|
||||||
|
duplicationConfig: duplicationConfig,
|
||||||
|
pageLockConfig: pageLockConfig,
|
||||||
|
pageArchiveConfig: pageArchiveConfig,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const editorClassNames = getEditorClassNames({ noBorder: true, borderOnFocus: false, customClassName });
|
||||||
|
|
||||||
|
if (!editor) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="top-0 sticky z-10 bg-custom-background-100">
|
||||||
|
<EditorHeader
|
||||||
|
readonly={false}
|
||||||
|
KanbanMenuOptions={KanbanMenuOptions}
|
||||||
|
editor={editor}
|
||||||
|
sidePeakVisible={sidePeakVisible}
|
||||||
|
setSidePeakVisible={setSidePeakVisible}
|
||||||
|
markings={markings}
|
||||||
|
uploadFile={uploadFile}
|
||||||
|
setIsSubmitting={setIsSubmitting}
|
||||||
|
isLocked={!pageLockConfig ? false : pageLockConfig.is_locked}
|
||||||
|
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
|
||||||
|
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
|
||||||
|
documentDetails={documentDetails}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="self-center items-stretch w-full max-md:max-w-full h-full">
|
||||||
|
<div className={cn("gap-5 flex max-md:flex-col max-md:items-stretch max-md:gap-0 h-full", { "justify-center": !sidePeakVisible })}>
|
||||||
|
<SummarySideBar
|
||||||
|
editor={editor}
|
||||||
|
markings={markings}
|
||||||
|
sidePeakVisible={sidePeakVisible}
|
||||||
|
/>
|
||||||
|
<PageRenderer
|
||||||
|
editor={editor}
|
||||||
|
editorContentCustomClassNames={editorContentCustomClassNames}
|
||||||
|
editorClassNames={editorClassNames}
|
||||||
|
sidePeakVisible={sidePeakVisible}
|
||||||
|
documentDetails={documentDetails}
|
||||||
|
/>
|
||||||
|
{/* Page Element */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DocumentEditorWithRef = React.forwardRef<EditorHandle, IDocumentEditor>((props, ref) => (
|
||||||
|
<DocumentEditor {...props} forwardedRef={ref} />
|
||||||
|
));
|
||||||
|
|
||||||
|
DocumentEditorWithRef.displayName = "DocumentEditorWithRef";
|
||||||
|
|
||||||
|
export { DocumentEditor, DocumentEditorWithRef }
|
142
packages/editor/document-editor/src/ui/menu/fixed-menu.tsx
Normal file
142
packages/editor/document-editor/src/ui/menu/fixed-menu.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import { Editor } from "@tiptap/react";
|
||||||
|
import { BoldIcon, Heading1, Heading2, Heading3 } from "lucide-react";
|
||||||
|
|
||||||
|
import { BoldItem, BulletListItem, cn, CodeItem, ImageItem, ItalicItem, NumberedListItem, QuoteItem, StrikeThroughItem, TableItem, UnderLineItem, HeadingOneItem, HeadingTwoItem, HeadingThreeItem } from "@plane/editor-core";
|
||||||
|
import { UploadImage } from "..";
|
||||||
|
|
||||||
|
export interface BubbleMenuItem {
|
||||||
|
name: string;
|
||||||
|
isActive: () => boolean;
|
||||||
|
command: () => void;
|
||||||
|
icon: typeof BoldIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditorBubbleMenuProps = {
|
||||||
|
editor: Editor;
|
||||||
|
uploadFile: UploadImage;
|
||||||
|
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FixedMenu = (props: EditorBubbleMenuProps) => {
|
||||||
|
const basicMarkItems: BubbleMenuItem[] = [
|
||||||
|
HeadingOneItem(props.editor),
|
||||||
|
HeadingTwoItem(props.editor),
|
||||||
|
HeadingThreeItem(props.editor),
|
||||||
|
BoldItem(props.editor),
|
||||||
|
ItalicItem(props.editor),
|
||||||
|
UnderLineItem(props.editor),
|
||||||
|
StrikeThroughItem(props.editor),
|
||||||
|
];
|
||||||
|
|
||||||
|
const listItems: BubbleMenuItem[] = [
|
||||||
|
BulletListItem(props.editor),
|
||||||
|
NumberedListItem(props.editor),
|
||||||
|
];
|
||||||
|
|
||||||
|
const userActionItems: BubbleMenuItem[] = [
|
||||||
|
QuoteItem(props.editor),
|
||||||
|
CodeItem(props.editor),
|
||||||
|
];
|
||||||
|
|
||||||
|
const complexItems: BubbleMenuItem[] = [
|
||||||
|
TableItem(props.editor),
|
||||||
|
ImageItem(props.editor, props.uploadFile, props.setIsSubmitting),
|
||||||
|
];
|
||||||
|
|
||||||
|
// const handleAccessChange = (accessKey: string) => {
|
||||||
|
// props.commentAccessSpecifier?.onAccessChange(accessKey);
|
||||||
|
// };
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex w-fit rounded bg-custom-background-100"
|
||||||
|
>
|
||||||
|
<div className="flex">
|
||||||
|
{basicMarkItems.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
onClick={item.command}
|
||||||
|
className={cn(
|
||||||
|
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
||||||
|
{
|
||||||
|
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon
|
||||||
|
size={ item.icon === Heading1 || item.icon === Heading2 || item.icon === Heading3 ? 20 : 15}
|
||||||
|
className={cn({
|
||||||
|
"text-custom-text-100": item.isActive(),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
{listItems.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
onClick={item.command}
|
||||||
|
className={cn(
|
||||||
|
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
||||||
|
{
|
||||||
|
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon
|
||||||
|
className={cn("h-4 w-4", {
|
||||||
|
"text-custom-text-100": item.isActive(),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
{userActionItems.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
onClick={item.command}
|
||||||
|
className={cn(
|
||||||
|
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
||||||
|
{
|
||||||
|
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon
|
||||||
|
className={cn("h-4 w-4", {
|
||||||
|
"text-custom-text-100": item.isActive(),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
{complexItems.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
onClick={item.command}
|
||||||
|
className={cn(
|
||||||
|
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
||||||
|
{
|
||||||
|
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon
|
||||||
|
className={cn("h-4 w-4", {
|
||||||
|
"text-custom-text-100": item.isActive(),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
13
packages/editor/document-editor/src/ui/menu/icon.tsx
Normal file
13
packages/editor/document-editor/src/ui/menu/icon.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
iconName: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Icon: React.FC<Props> = ({ iconName, className = "" }) => (
|
||||||
|
<span className={`material-symbols-rounded text-sm leading-5 font-light ${className}`}>
|
||||||
|
{iconName}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
1
packages/editor/document-editor/src/ui/menu/index.tsx
Normal file
1
packages/editor/document-editor/src/ui/menu/index.tsx
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { FixedMenu } from "./fixed-menu";
|
121
packages/editor/document-editor/src/ui/readonly/index.tsx
Normal file
121
packages/editor/document-editor/src/ui/readonly/index.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { cn, getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core"
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useState, forwardRef, useEffect } from 'react'
|
||||||
|
import { EditorHeader } from "../components/editor-header";
|
||||||
|
import { PageRenderer } from "../components/page-renderer";
|
||||||
|
import { SummarySideBar } from "../components/summary-side-bar";
|
||||||
|
import { useEditorMarkings } from "../hooks/use-editor-markings";
|
||||||
|
import { DocumentDetails } from "../types/editor-types";
|
||||||
|
import { IPageArchiveConfig, IPageLockConfig, IDuplicationConfig } from "../types/menu-actions";
|
||||||
|
import { getMenuOptions } from "../utils/menu-options";
|
||||||
|
|
||||||
|
interface IDocumentReadOnlyEditor {
|
||||||
|
value: string,
|
||||||
|
noBorder: boolean,
|
||||||
|
borderOnFocus: boolean,
|
||||||
|
customClassName: string,
|
||||||
|
documentDetails: DocumentDetails,
|
||||||
|
pageLockConfig?: IPageLockConfig,
|
||||||
|
pageArchiveConfig?: IPageArchiveConfig,
|
||||||
|
pageDuplicationConfig?: IDuplicationConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor {
|
||||||
|
forwardedRef?: React.Ref<EditorHandle>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorHandle {
|
||||||
|
clearEditor: () => void;
|
||||||
|
setEditorValue: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DocumentReadOnlyEditor = ({
|
||||||
|
noBorder,
|
||||||
|
borderOnFocus,
|
||||||
|
customClassName,
|
||||||
|
value,
|
||||||
|
documentDetails,
|
||||||
|
forwardedRef,
|
||||||
|
pageDuplicationConfig,
|
||||||
|
pageLockConfig,
|
||||||
|
pageArchiveConfig,
|
||||||
|
}: DocumentReadOnlyEditorProps) => {
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const [sidePeakVisible, setSidePeakVisible] = useState(true)
|
||||||
|
const { markings, updateMarkings } = useEditorMarkings()
|
||||||
|
|
||||||
|
const editor = useReadOnlyEditor({
|
||||||
|
value,
|
||||||
|
forwardedRef,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editor) {
|
||||||
|
updateMarkings(editor.getJSON())
|
||||||
|
}
|
||||||
|
}, [editor?.getJSON()])
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorClassNames = getEditorClassNames({
|
||||||
|
noBorder,
|
||||||
|
borderOnFocus,
|
||||||
|
customClassName
|
||||||
|
})
|
||||||
|
|
||||||
|
const KanbanMenuOptions = getMenuOptions({
|
||||||
|
editor: editor,
|
||||||
|
router: router,
|
||||||
|
pageArchiveConfig: pageArchiveConfig,
|
||||||
|
pageLockConfig: pageLockConfig,
|
||||||
|
duplicationConfig: pageDuplicationConfig,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="top-0 sticky z-10 bg-custom-background-100">
|
||||||
|
<EditorHeader
|
||||||
|
isLocked={!pageLockConfig ? false : pageLockConfig.is_locked}
|
||||||
|
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
|
||||||
|
readonly={true}
|
||||||
|
editor={editor}
|
||||||
|
sidePeakVisible={sidePeakVisible}
|
||||||
|
setSidePeakVisible={setSidePeakVisible}
|
||||||
|
KanbanMenuOptions={KanbanMenuOptions}
|
||||||
|
markings={markings}
|
||||||
|
documentDetails={documentDetails}
|
||||||
|
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="self-center items-stretch w-full max-md:max-w-full overflow-y-hidden">
|
||||||
|
<div className={cn("gap-5 flex max-md:flex-col max-md:items-stretch max-md:gap-0 overflow-y-hidden", { "justify-center": !sidePeakVisible })}>
|
||||||
|
<SummarySideBar
|
||||||
|
editor={editor}
|
||||||
|
markings={markings}
|
||||||
|
sidePeakVisible={sidePeakVisible}
|
||||||
|
/>
|
||||||
|
<PageRenderer
|
||||||
|
editor={editor}
|
||||||
|
editorClassNames={editorClassNames}
|
||||||
|
sidePeakVisible={sidePeakVisible}
|
||||||
|
documentDetails={documentDetails}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const DocumentReadOnlyEditorWithRef = forwardRef<
|
||||||
|
EditorHandle,
|
||||||
|
IDocumentReadOnlyEditor
|
||||||
|
>((props, ref) => <DocumentReadOnlyEditor {...props} forwardedRef={ref} />);
|
||||||
|
|
||||||
|
DocumentReadOnlyEditorWithRef.displayName = "DocumentReadOnlyEditorWithRef";
|
||||||
|
|
||||||
|
export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef }
|
77
packages/editor/document-editor/src/ui/tooltip.tsx
Normal file
77
packages/editor/document-editor/src/ui/tooltip.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
// next-themes
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
// tooltip2
|
||||||
|
import { Tooltip2 } from "@blueprintjs/popover2";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
tooltipHeading?: string;
|
||||||
|
tooltipContent: string | React.ReactNode;
|
||||||
|
position?:
|
||||||
|
| "top"
|
||||||
|
| "right"
|
||||||
|
| "bottom"
|
||||||
|
| "left"
|
||||||
|
| "auto"
|
||||||
|
| "auto-end"
|
||||||
|
| "auto-start"
|
||||||
|
| "bottom-left"
|
||||||
|
| "bottom-right"
|
||||||
|
| "left-bottom"
|
||||||
|
| "left-top"
|
||||||
|
| "right-bottom"
|
||||||
|
| "right-top"
|
||||||
|
| "top-left"
|
||||||
|
| "top-right";
|
||||||
|
children: JSX.Element;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
openDelay?: number;
|
||||||
|
closeDelay?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Tooltip: React.FC<Props> = ({
|
||||||
|
tooltipHeading,
|
||||||
|
tooltipContent,
|
||||||
|
position = "top",
|
||||||
|
children,
|
||||||
|
disabled = false,
|
||||||
|
className = "",
|
||||||
|
openDelay = 200,
|
||||||
|
closeDelay,
|
||||||
|
}) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip2
|
||||||
|
disabled={disabled}
|
||||||
|
hoverOpenDelay={openDelay}
|
||||||
|
hoverCloseDelay={closeDelay}
|
||||||
|
content={
|
||||||
|
<div
|
||||||
|
className={`relative z-50 max-w-xs gap-1 rounded-md p-2 text-xs shadow-md ${
|
||||||
|
theme === "custom"
|
||||||
|
? "bg-custom-background-100 text-custom-text-200"
|
||||||
|
: "bg-black text-gray-400"
|
||||||
|
} break-words overflow-hidden ${className}`}
|
||||||
|
>
|
||||||
|
{tooltipHeading && (
|
||||||
|
<h5
|
||||||
|
className={`font-medium ${
|
||||||
|
theme === "custom" ? "text-custom-text-100" : "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tooltipHeading}
|
||||||
|
</h5>
|
||||||
|
)}
|
||||||
|
{tooltipContent}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
position={position}
|
||||||
|
renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) =>
|
||||||
|
React.cloneElement(children, { ref: eleReference, ...tooltipProps, ...children.props })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,8 @@
|
|||||||
|
|
||||||
|
export interface DocumentDetails {
|
||||||
|
title: string;
|
||||||
|
created_by: string;
|
||||||
|
created_on: Date;
|
||||||
|
last_updated_by: string;
|
||||||
|
last_updated_at: Date;
|
||||||
|
}
|
14
packages/editor/document-editor/src/ui/types/menu-actions.d.ts
vendored
Normal file
14
packages/editor/document-editor/src/ui/types/menu-actions.d.ts
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
|
||||||
|
export interface IDuplicationConfig {
|
||||||
|
action: () => Promise<void>
|
||||||
|
}
|
||||||
|
export interface IPageLockConfig {
|
||||||
|
is_locked: boolean,
|
||||||
|
action: () => Promise<void>
|
||||||
|
locked_by?: string,
|
||||||
|
}
|
||||||
|
export interface IPageArchiveConfig {
|
||||||
|
is_archived: boolean,
|
||||||
|
archived_at?: Date,
|
||||||
|
action: () => Promise<void>
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
import { Editor } from "@tiptap/react";
|
||||||
|
import { IMarking } from "..";
|
||||||
|
|
||||||
|
function findNthH1(editor: Editor, n: number, level: number): number {
|
||||||
|
let count = 0;
|
||||||
|
let pos = 0;
|
||||||
|
editor.state.doc.descendants((node, position) => {
|
||||||
|
if (node.type.name === 'heading' && node.attrs.level === level) {
|
||||||
|
count++;
|
||||||
|
if (count === n) {
|
||||||
|
pos = position;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToNode(editor: Editor, pos: number): void {
|
||||||
|
const headingNode = editor.state.doc.nodeAt(pos);
|
||||||
|
if (headingNode) {
|
||||||
|
const headingDOM = editor.view.nodeDOM(pos);
|
||||||
|
if (headingDOM instanceof HTMLElement) {
|
||||||
|
headingDOM.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scrollSummary(editor: Editor, marking: IMarking) {
|
||||||
|
if (editor) {
|
||||||
|
const pos = findNthH1(editor, marking.sequence, marking.level)
|
||||||
|
scrollToNode(editor, pos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
12
packages/editor/document-editor/src/ui/utils/menu-actions.ts
Normal file
12
packages/editor/document-editor/src/ui/utils/menu-actions.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Editor } from "@tiptap/core"
|
||||||
|
|
||||||
|
export const copyMarkdownToClipboard = (editor: Editor | null) => {
|
||||||
|
const markdownOutput = editor?.storage.markdown.getMarkdown();
|
||||||
|
navigator.clipboard.writeText(markdownOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CopyPageLink = () => {
|
||||||
|
if (window){
|
||||||
|
navigator.clipboard.writeText(window.location.toString())
|
||||||
|
}
|
||||||
|
}
|
75
packages/editor/document-editor/src/ui/utils/menu-options.ts
Normal file
75
packages/editor/document-editor/src/ui/utils/menu-options.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { Editor } from "@tiptap/react"
|
||||||
|
import { Archive, ArchiveIcon, ArchiveRestoreIcon, ClipboardIcon, Copy, Link, Lock, Unlock, XCircle } from "lucide-react"
|
||||||
|
import { NextRouter } from "next/router"
|
||||||
|
import { IVerticalDropdownItemProps } from "../components/vertical-dropdown-menu"
|
||||||
|
import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from "../types/menu-actions"
|
||||||
|
import { copyMarkdownToClipboard, CopyPageLink } from "./menu-actions"
|
||||||
|
|
||||||
|
export interface MenuOptionsProps{
|
||||||
|
editor: Editor,
|
||||||
|
router: NextRouter,
|
||||||
|
duplicationConfig?: IDuplicationConfig,
|
||||||
|
pageLockConfig?: IPageLockConfig ,
|
||||||
|
pageArchiveConfig?: IPageArchiveConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMenuOptions = ({ editor, router, duplicationConfig, pageLockConfig, pageArchiveConfig } : MenuOptionsProps) => {
|
||||||
|
|
||||||
|
const KanbanMenuOptions: IVerticalDropdownItemProps[] = [
|
||||||
|
{
|
||||||
|
key: 1,
|
||||||
|
type: "copy_markdown",
|
||||||
|
Icon: ClipboardIcon,
|
||||||
|
action: () => copyMarkdownToClipboard(editor),
|
||||||
|
label: "Copy Markdown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 2,
|
||||||
|
type: "close_page",
|
||||||
|
Icon: XCircle,
|
||||||
|
action: () => router.back(),
|
||||||
|
label: "Close the page"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 3,
|
||||||
|
type: "copy_page_link",
|
||||||
|
Icon: Link,
|
||||||
|
action: () => CopyPageLink(),
|
||||||
|
label: "Copy Page Link"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// If duplicateConfig is given, page duplication will be allowed
|
||||||
|
if (duplicationConfig) {
|
||||||
|
KanbanMenuOptions.push({
|
||||||
|
key: KanbanMenuOptions.length++,
|
||||||
|
type: "duplicate_page",
|
||||||
|
Icon: Copy,
|
||||||
|
action: duplicationConfig.action,
|
||||||
|
label: "Make a copy"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// If Lock Configuration is given then, lock page option will be available in the kanban menu
|
||||||
|
if (pageLockConfig) {
|
||||||
|
KanbanMenuOptions.push({
|
||||||
|
key: KanbanMenuOptions.length++,
|
||||||
|
type: pageLockConfig.is_locked ? "unlock_page" : "lock_page",
|
||||||
|
Icon: pageLockConfig.is_locked ? Unlock : Lock,
|
||||||
|
label: pageLockConfig.is_locked ? "Unlock Page" : "Lock Page",
|
||||||
|
action: pageLockConfig.action
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archiving will be visible in the menu bar config once the pageArchiveConfig is given.
|
||||||
|
if (pageArchiveConfig) {
|
||||||
|
KanbanMenuOptions.push({
|
||||||
|
key: KanbanMenuOptions.length++,
|
||||||
|
type: pageArchiveConfig.is_archived ? "unarchive_page" : "archive_page",
|
||||||
|
Icon: pageArchiveConfig.is_archived ? ArchiveRestoreIcon : Archive,
|
||||||
|
label: pageArchiveConfig.is_archived ? "Restore Page" : "Archive Page",
|
||||||
|
action: pageArchiveConfig.action,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return KanbanMenuOptions
|
||||||
|
}
|
6
packages/editor/document-editor/tailwind.config.js
Normal file
6
packages/editor/document-editor/tailwind.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
const sharedConfig = require("tailwind-config-custom/tailwind.config.js");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// prefix ui lib classes to avoid conflicting with the app
|
||||||
|
...sharedConfig,
|
||||||
|
};
|
5
packages/editor/document-editor/tsconfig.json
Normal file
5
packages/editor/document-editor/tsconfig.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "tsconfig/react-library.json",
|
||||||
|
"include": ["src/**/*", "index.d.ts"],
|
||||||
|
"exclude": ["dist", "build", "node_modules"]
|
||||||
|
}
|
11
packages/editor/document-editor/tsup.config.ts
Normal file
11
packages/editor/document-editor/tsup.config.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig, Options } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig((options: Options) => ({
|
||||||
|
entry: ["src/index.ts"],
|
||||||
|
format: ["cjs", "esm"],
|
||||||
|
dts: true,
|
||||||
|
clean: false,
|
||||||
|
external: ["react"],
|
||||||
|
injectStyle: true,
|
||||||
|
...options,
|
||||||
|
}));
|
10
turbo.json
10
turbo.json
@ -31,6 +31,7 @@
|
|||||||
"dependsOn": [
|
"dependsOn": [
|
||||||
"@plane/lite-text-editor#build",
|
"@plane/lite-text-editor#build",
|
||||||
"@plane/rich-text-editor#build",
|
"@plane/rich-text-editor#build",
|
||||||
|
"@plane/document-editor#build",
|
||||||
"@plane/ui#build"
|
"@plane/ui#build"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -40,6 +41,7 @@
|
|||||||
"dependsOn": [
|
"dependsOn": [
|
||||||
"@plane/lite-text-editor#build",
|
"@plane/lite-text-editor#build",
|
||||||
"@plane/rich-text-editor#build",
|
"@plane/rich-text-editor#build",
|
||||||
|
"@plane/document-editor#build",
|
||||||
"@plane/ui#build"
|
"@plane/ui#build"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -48,6 +50,7 @@
|
|||||||
"dependsOn": [
|
"dependsOn": [
|
||||||
"@plane/lite-text-editor#build",
|
"@plane/lite-text-editor#build",
|
||||||
"@plane/rich-text-editor#build",
|
"@plane/rich-text-editor#build",
|
||||||
|
"@plane/document-editor#build",
|
||||||
"@plane/ui#build"
|
"@plane/ui#build"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -56,6 +59,7 @@
|
|||||||
"dependsOn": [
|
"dependsOn": [
|
||||||
"@plane/lite-text-editor#build",
|
"@plane/lite-text-editor#build",
|
||||||
"@plane/rich-text-editor#build",
|
"@plane/rich-text-editor#build",
|
||||||
|
"@plane/document-editor#build",
|
||||||
"@plane/ui#build"
|
"@plane/ui#build"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -67,6 +71,12 @@
|
|||||||
"cache": true,
|
"cache": true,
|
||||||
"dependsOn": ["@plane/editor-core#build"]
|
"dependsOn": ["@plane/editor-core#build"]
|
||||||
},
|
},
|
||||||
|
"@plane/document-editor#build": {
|
||||||
|
"cache": true,
|
||||||
|
"dependsOn": [
|
||||||
|
"@plane/editor-core#build"
|
||||||
|
]
|
||||||
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": ["^build"],
|
||||||
"outputs": []
|
"outputs": []
|
||||||
|
@ -203,8 +203,6 @@ export const CommandPalette: FC = observer(() => {
|
|||||||
<CreateUpdatePageModal
|
<CreateUpdatePageModal
|
||||||
isOpen={isCreatePageModalOpen}
|
isOpen={isCreatePageModalOpen}
|
||||||
handleClose={() => toggleCreatePageModal(false)}
|
handleClose={() => toggleCreatePageModal(false)}
|
||||||
user={user}
|
|
||||||
workspaceSlug={workspaceSlug.toString()}
|
|
||||||
projectId={projectId.toString()}
|
projectId={projectId.toString()}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
@ -250,7 +250,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
|||||||
handleRemoveFromFavorites(e);
|
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>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
@ -313,7 +313,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
|||||||
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
|
||||||
</Transition.Child>
|
</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">
|
<div className="my-10 flex items-center justify-center p-4 text-center sm:p-0 md:my-20">
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={React.Fragment}
|
as={React.Fragment}
|
||||||
|
@ -1,35 +1,33 @@
|
|||||||
import React from "react";
|
import React, { FC } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { mutate } from "swr";
|
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
// services
|
|
||||||
import { PageService } from "services/page.service";
|
|
||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// components
|
// components
|
||||||
import { PageForm } from "./page-form";
|
import { PageForm } from "./page-form";
|
||||||
// types
|
// types
|
||||||
import { IUser, IPage } from "types";
|
import { IPage } from "types";
|
||||||
// fetch-keys
|
// store
|
||||||
import { ALL_PAGES_LIST, FAVORITE_PAGES_LIST, MY_PAGES_LIST, RECENT_PAGES_LIST } from "constants/fetch-keys";
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
// helpers
|
||||||
import { trackEvent } from "helpers/event-tracker.helper";
|
import { trackEvent } from "helpers/event-tracker.helper";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean;
|
|
||||||
handleClose: () => void;
|
|
||||||
data?: IPage | null;
|
data?: IPage | null;
|
||||||
user: IUser | undefined;
|
handleClose: () => void;
|
||||||
workspaceSlug: string;
|
isOpen: boolean;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// services
|
export const CreateUpdatePageModal: FC<Props> = (props) => {
|
||||||
const pageService = new PageService();
|
const { isOpen, handleClose, data, projectId } = props;
|
||||||
|
|
||||||
export const CreateUpdatePageModal: React.FC<Props> = (props) => {
|
|
||||||
const { isOpen, handleClose, data, workspaceSlug, projectId } = props;
|
|
||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { workspaceSlug } = router.query;
|
||||||
|
// store
|
||||||
|
const {
|
||||||
|
page: { createPage, updatePage },
|
||||||
|
} = useMobxStore();
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
@ -37,43 +35,22 @@ export const CreateUpdatePageModal: React.FC<Props> = (props) => {
|
|||||||
handleClose();
|
handleClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const createPage = async (payload: IPage) => {
|
const createProjectPage = async (payload: IPage) => {
|
||||||
await pageService
|
if (!workspaceSlug) return;
|
||||||
.createPage(workspaceSlug as string, projectId as string, payload)
|
|
||||||
|
createPage(workspaceSlug.toString(), projectId, payload)
|
||||||
.then((res) => {
|
.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}`);
|
router.push(`/${workspaceSlug}/projects/${projectId}/pages/${res.id}`);
|
||||||
|
onClose();
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "success",
|
type: "success",
|
||||||
title: "Success!",
|
title: "Success!",
|
||||||
message: "Page created successfully.",
|
message: "Page created successfully.",
|
||||||
});
|
});
|
||||||
trackEvent(
|
trackEvent("PAGE_CREATE", {
|
||||||
'PAGE_CREATE',
|
|
||||||
{
|
|
||||||
...res,
|
...res,
|
||||||
caase: "SUCCES"
|
case: "SUCCESS",
|
||||||
}
|
});
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
@ -81,64 +58,27 @@ export const CreateUpdatePageModal: React.FC<Props> = (props) => {
|
|||||||
title: "Error!",
|
title: "Error!",
|
||||||
message: "Page could not be created. Please try again.",
|
message: "Page could not be created. Please try again.",
|
||||||
});
|
});
|
||||||
trackEvent(
|
trackEvent("PAGE_CREATE", {
|
||||||
'PAGE_CREATE',
|
case: "FAILED",
|
||||||
{
|
});
|
||||||
case: "FAILED"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatePage = async (payload: IPage) => {
|
const updateProjectPage = async (payload: IPage) => {
|
||||||
await pageService
|
if (!data || !workspaceSlug) return;
|
||||||
.patchPage(workspaceSlug as string, projectId as string, data?.id ?? "", payload)
|
|
||||||
|
return updatePage(workspaceSlug.toString(), projectId, data.id, payload)
|
||||||
.then((res) => {
|
.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();
|
onClose();
|
||||||
|
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "success",
|
type: "success",
|
||||||
title: "Success!",
|
title: "Success!",
|
||||||
message: "Page updated successfully.",
|
message: "Page updated successfully.",
|
||||||
});
|
});
|
||||||
trackEvent(
|
trackEvent("PAGE_UPDATE", {
|
||||||
'PAGE_UPDATE',
|
|
||||||
{
|
|
||||||
...res,
|
...res,
|
||||||
case: "SUCCESS"
|
case: "SUCCESS",
|
||||||
}
|
});
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
@ -146,20 +86,17 @@ export const CreateUpdatePageModal: React.FC<Props> = (props) => {
|
|||||||
title: "Error!",
|
title: "Error!",
|
||||||
message: "Page could not be updated. Please try again.",
|
message: "Page could not be updated. Please try again.",
|
||||||
});
|
});
|
||||||
trackEvent(
|
trackEvent("PAGE_UPDATE", {
|
||||||
'PAGE_UPDATE',
|
case: "FAILED",
|
||||||
{
|
});
|
||||||
case: "FAILED"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFormSubmit = async (formData: IPage) => {
|
const handleFormSubmit = async (formData: IPage) => {
|
||||||
if (!workspaceSlug || !projectId) return;
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
if (!data) await createPage(formData);
|
if (!data) await createProjectPage(formData);
|
||||||
else await updatePage(formData);
|
else await updateProjectPage(formData);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -178,7 +115,7 @@ export const CreateUpdatePageModal: React.FC<Props> = (props) => {
|
|||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
|
|
||||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
<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
|
<Transition.Child
|
||||||
as={React.Fragment}
|
as={React.Fragment}
|
||||||
enter="ease-out duration-300"
|
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"
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
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">
|
<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
|
<PageForm handleFormSubmit={handleFormSubmit} handleClose={handleClose} data={data} />
|
||||||
handleFormSubmit={handleFormSubmit}
|
|
||||||
handleClose={handleClose}
|
|
||||||
status={data ? true : false}
|
|
||||||
data={data}
|
|
||||||
/>
|
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,13 +1,9 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
import { mutate } from "swr";
|
|
||||||
|
|
||||||
// headless ui
|
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
// services
|
// mobx store
|
||||||
import { PageService } from "services/page.service";
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// ui
|
// ui
|
||||||
@ -15,56 +11,40 @@ import { Button } from "@plane/ui";
|
|||||||
// icons
|
// icons
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
// types
|
// types
|
||||||
import type { IUser, IPage } from "types";
|
import type { IPage } from "types";
|
||||||
// fetch-keys
|
|
||||||
import { ALL_PAGES_LIST, FAVORITE_PAGES_LIST, MY_PAGES_LIST, RECENT_PAGES_LIST } from "constants/fetch-keys";
|
|
||||||
|
|
||||||
type TConfirmPageDeletionProps = {
|
type TConfirmPageDeletionProps = {
|
||||||
isOpen: boolean;
|
|
||||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
data?: IPage | null;
|
data?: IPage | null;
|
||||||
user: IUser | undefined;
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
// services
|
export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = observer((props) => {
|
||||||
const pageService = new PageService();
|
const { data, isOpen, onClose } = props;
|
||||||
|
|
||||||
export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({ isOpen, setIsOpen, data }) => {
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
|
const {
|
||||||
|
page: { deletePage },
|
||||||
|
} = useMobxStore();
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setIsOpen(false);
|
setIsDeleting(false);
|
||||||
setIsDeleteLoading(false);
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeletion = async () => {
|
const handleDelete = async () => {
|
||||||
setIsDeleteLoading(true);
|
|
||||||
if (!data || !workspaceSlug || !projectId) return;
|
if (!data || !workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
await pageService
|
setIsDeleting(true);
|
||||||
.deletePage(workspaceSlug as string, data.project, data.id)
|
|
||||||
|
await deletePage(workspaceSlug.toString(), data.project, data.id)
|
||||||
.then(() => {
|
.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();
|
handleClose();
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
type: "success",
|
type: "success",
|
||||||
@ -80,7 +60,7 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({ isOpen, s
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsDeleteLoading(false);
|
setIsDeleting(false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -122,9 +102,9 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({ isOpen, s
|
|||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<p className="text-sm text-custom-text-200">
|
<p className="text-sm text-custom-text-200">
|
||||||
Are you sure you want to delete Page-{" "}
|
Are you sure you want to delete page-{" "}
|
||||||
<span className="break-words font-medium text-custom-text-100">{data?.name}</span>? All of the
|
<span className="break-words font-medium text-custom-text-100">{data?.name}</span>? The Page
|
||||||
data related to the page will be permanently removed. This action cannot be undone.
|
will be deleted permanently. This action cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -134,8 +114,8 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({ isOpen, s
|
|||||||
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="danger" size="sm" tabIndex={1} onClick={handleDeletion} loading={isDeleteLoading}>
|
<Button variant="danger" size="sm" tabIndex={1} onClick={handleDelete} loading={isDeleting}>
|
||||||
{isDeleteLoading ? "Deleting..." : "Delete"}
|
{isDeleting ? "Deleting..." : "Delete"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
@ -145,4 +125,4 @@ export const DeletePageModal: React.FC<TConfirmPageDeletionProps> = ({ isOpen, s
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</Transition.Root>
|
</Transition.Root>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
@ -1,10 +1,6 @@
|
|||||||
export * from "./pages-list";
|
export * from "./pages-list";
|
||||||
|
export * from "./create-block";
|
||||||
export * from "./create-update-block-inline";
|
export * from "./create-update-block-inline";
|
||||||
export * from "./create-update-page-modal";
|
export * from "./create-update-page-modal";
|
||||||
export * from "./delete-page-modal";
|
export * from "./delete-page-modal";
|
||||||
export * from "./page-form";
|
export * from "./page-form";
|
||||||
export * from "./pages-view";
|
|
||||||
export * from "./single-page-block";
|
|
||||||
export * from "./single-page-detailed-item";
|
|
||||||
export * from "./single-page-list-item";
|
|
||||||
export * from "./create-block";
|
|
||||||
|
@ -1,23 +1,24 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
// ui
|
// ui
|
||||||
import { Button, Input } from "@plane/ui";
|
import { Button, Input, Tooltip } from "@plane/ui";
|
||||||
// types
|
// types
|
||||||
import { IPage } from "types";
|
import { IPage } from "types";
|
||||||
|
import { PAGE_ACCESS_SPECIFIERS } from "constants/page";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
handleFormSubmit: (values: IPage) => Promise<void>;
|
handleFormSubmit: (values: IPage) => Promise<void>;
|
||||||
handleClose: () => void;
|
handleClose: () => void;
|
||||||
status: boolean;
|
|
||||||
data?: IPage | null;
|
data?: IPage | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultValues = {
|
const defaultValues = {
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
|
access: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PageForm: React.FC<Props> = ({ handleFormSubmit, handleClose, status, data }) => {
|
export const PageForm: React.FC<Props> = ({ handleFormSubmit, handleClose, data }) => {
|
||||||
const {
|
const {
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
@ -44,8 +45,8 @@ export const PageForm: React.FC<Props> = ({ handleFormSubmit, handleClose, statu
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(handleCreateUpdatePage)}>
|
<form onSubmit={handleSubmit(handleCreateUpdatePage)}>
|
||||||
<div className="space-y-5">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-medium leading-6 text-custom-text-100">{status ? "Update" : "Create"} Page</h3>
|
<h3 className="text-lg font-medium leading-6 text-custom-text-100">{data ? "Update" : "Create"} Page</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<Controller
|
<Controller
|
||||||
@ -61,26 +62,57 @@ export const PageForm: React.FC<Props> = ({ handleFormSubmit, handleClose, statu
|
|||||||
render={({ field: { value, onChange, ref } }) => (
|
render={({ field: { value, onChange, ref } }) => (
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
|
||||||
type="text"
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
hasError={Boolean(errors.name)}
|
hasError={Boolean(errors.name)}
|
||||||
placeholder="Title"
|
placeholder="Title"
|
||||||
className="resize-none text-xl w-full"
|
className="resize-none text-lg w-full"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex justify-end gap-2">
|
<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}>
|
<Button variant="neutral-primary" size="sm" onClick={handleClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
<Button variant="primary" size="sm" type="submit" loading={isSubmitting}>
|
||||||
{status
|
{data
|
||||||
? isSubmitting
|
? isSubmitting
|
||||||
? "Updating Page..."
|
? "Updating Page..."
|
||||||
: "Update Page"
|
: "Update Page"
|
||||||
@ -89,6 +121,7 @@ export const PageForm: React.FC<Props> = ({ handleFormSubmit, handleClose, statu
|
|||||||
: "Create Page"}
|
: "Create Page"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,29 +1,26 @@
|
|||||||
import { useRouter } from "next/router";
|
import { FC } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
import useSWR from "swr";
|
|
||||||
|
|
||||||
// services
|
|
||||||
import { PageService } from "services/page.service";
|
|
||||||
// components
|
// components
|
||||||
import { PagesView } from "components/pages";
|
import { PagesListView } from "components/pages/pages-list";
|
||||||
// types
|
|
||||||
import { TPagesListProps } from "./types";
|
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { ALL_PAGES_LIST } from "constants/fetch-keys";
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
// ui
|
||||||
|
import { Loader } from "@plane/ui";
|
||||||
|
|
||||||
// services
|
export const AllPagesList: FC = observer(() => {
|
||||||
const pageService = new PageService();
|
// store
|
||||||
|
const {
|
||||||
|
page: { projectPages },
|
||||||
|
} = useMobxStore();
|
||||||
|
|
||||||
export const AllPagesList: React.FC<TPagesListProps> = ({ viewType }) => {
|
if (!projectPages)
|
||||||
const router = useRouter();
|
return (
|
||||||
const { workspaceSlug, projectId } = router.query;
|
<Loader className="space-y-4">
|
||||||
|
<Loader.Item height="40px" />
|
||||||
const { data: pages } = useSWR(
|
<Loader.Item height="40px" />
|
||||||
workspaceSlug && projectId ? ALL_PAGES_LIST(projectId as string) : null,
|
<Loader.Item height="40px" />
|
||||||
workspaceSlug && projectId
|
</Loader>
|
||||||
? () => pageService.getPagesWithParams(workspaceSlug as string, projectId as string, "all")
|
|
||||||
: null
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return <PagesView pages={pages} viewType={viewType} />;
|
return <PagesListView pages={projectPages} />;
|
||||||
};
|
});
|
||||||
|
25
web/components/pages/pages-list/archived-pages-list.tsx
Normal file
25
web/components/pages/pages-list/archived-pages-list.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
// components
|
||||||
|
import { PagesListView } from "components/pages/pages-list";
|
||||||
|
// hooks
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
// ui
|
||||||
|
import { Loader } from "@plane/ui";
|
||||||
|
|
||||||
|
export const ArchivedPagesList: FC = observer(() => {
|
||||||
|
const {
|
||||||
|
page: { archivedProjectPages },
|
||||||
|
} = useMobxStore();
|
||||||
|
|
||||||
|
if (!archivedProjectPages)
|
||||||
|
return (
|
||||||
|
<Loader className="space-y-4">
|
||||||
|
<Loader.Item height="40px" />
|
||||||
|
<Loader.Item height="40px" />
|
||||||
|
<Loader.Item height="40px" />
|
||||||
|
</Loader>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <PagesListView pages={archivedProjectPages} />;
|
||||||
|
});
|
@ -1,29 +1,25 @@
|
|||||||
import { useRouter } from "next/router";
|
import { FC } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
import useSWR from "swr";
|
|
||||||
|
|
||||||
// services
|
|
||||||
import { PageService } from "services/page.service";
|
|
||||||
// components
|
// components
|
||||||
import { PagesView } from "components/pages";
|
import { PagesListView } from "components/pages/pages-list";
|
||||||
// types
|
// hooks
|
||||||
import { TPagesListProps } from "./types";
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
// fetch-keys
|
// ui
|
||||||
import { FAVORITE_PAGES_LIST } from "constants/fetch-keys";
|
import { Loader } from "@plane/ui";
|
||||||
|
|
||||||
// services
|
export const FavoritePagesList: FC = observer(() => {
|
||||||
const pageService = new PageService();
|
const {
|
||||||
|
page: { favoriteProjectPages },
|
||||||
|
} = useMobxStore();
|
||||||
|
|
||||||
export const FavoritePagesList: React.FC<TPagesListProps> = ({ viewType }) => {
|
if (!favoriteProjectPages)
|
||||||
const router = useRouter();
|
return (
|
||||||
const { workspaceSlug, projectId } = router.query;
|
<Loader className="space-y-4">
|
||||||
|
<Loader.Item height="40px" />
|
||||||
const { data: pages } = useSWR(
|
<Loader.Item height="40px" />
|
||||||
workspaceSlug && projectId ? FAVORITE_PAGES_LIST(projectId as string) : null,
|
<Loader.Item height="40px" />
|
||||||
workspaceSlug && projectId
|
</Loader>
|
||||||
? () => pageService.getPagesWithParams(workspaceSlug as string, projectId as string, "favorite")
|
|
||||||
: null
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return <PagesView pages={pages} viewType={viewType} />;
|
return <PagesListView pages={favoriteProjectPages} />;
|
||||||
};
|
});
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
export * from "./all-pages-list";
|
export * from "./all-pages-list";
|
||||||
|
export * from "./archived-pages-list";
|
||||||
export * from "./favorite-pages-list";
|
export * from "./favorite-pages-list";
|
||||||
export * from "./my-pages-list";
|
export * from "./private-page-list";
|
||||||
export * from "./other-pages-list";
|
export * from "./shared-pages-list";
|
||||||
export * from "./recent-pages-list";
|
export * from "./recent-pages-list";
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
|
export * from "./list-view";
|
||||||
|
303
web/components/pages/pages-list/list-item.tsx
Normal file
303
web/components/pages/pages-list/list-item.tsx
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
import { FC, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
// icons
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Archive,
|
||||||
|
ArchiveRestoreIcon,
|
||||||
|
FileText,
|
||||||
|
Globe2,
|
||||||
|
LinkIcon,
|
||||||
|
Lock,
|
||||||
|
Pencil,
|
||||||
|
Star,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
// hooks
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
// helpers
|
||||||
|
import { copyUrlToClipboard } from "helpers/string.helper";
|
||||||
|
import { renderShortDate, render24HourFormatTime, renderLongDateFormat } from "helpers/date-time.helper";
|
||||||
|
// ui
|
||||||
|
import { CustomMenu, Tooltip } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { CreateUpdatePageModal, DeletePageModal } from "components/pages";
|
||||||
|
// types
|
||||||
|
import { IPage } from "types";
|
||||||
|
|
||||||
|
export interface IPagesListItem {
|
||||||
|
workspaceSlug: string;
|
||||||
|
projectId: string;
|
||||||
|
page: IPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PagesListItem: FC<IPagesListItem> = observer((props) => {
|
||||||
|
const { workspaceSlug, projectId, page } = props;
|
||||||
|
// states
|
||||||
|
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
|
||||||
|
const [deletePageModal, setDeletePageModal] = useState(false);
|
||||||
|
// store
|
||||||
|
const {
|
||||||
|
page: { archivePage, removeFromFavorites, addToFavorites, makePublic, makePrivate, restorePage },
|
||||||
|
user: { currentProjectRole },
|
||||||
|
projectMember: { projectMembers },
|
||||||
|
} = useMobxStore();
|
||||||
|
// hooks
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
const handleCopyUrl = (e: any) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/pages/${page.id}`).then(() => {
|
||||||
|
setToastAlert({
|
||||||
|
type: "success",
|
||||||
|
title: "Link Copied!",
|
||||||
|
message: "Page link copied to clipboard.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddToFavorites = (e: any) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
addToFavorites(workspaceSlug, projectId, page.id)
|
||||||
|
.then(() => {
|
||||||
|
setToastAlert({
|
||||||
|
type: "success",
|
||||||
|
title: "Success!",
|
||||||
|
message: "Successfully added the page to favorites.",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: "Couldn't add the page to favorites. Please try again.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFromFavorites = (e: any) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
removeFromFavorites(workspaceSlug, projectId, page.id)
|
||||||
|
.then(() => {
|
||||||
|
setToastAlert({
|
||||||
|
type: "success",
|
||||||
|
title: "Success!",
|
||||||
|
message: "Successfully removed the page from favorites.",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: "Couldn't remove the page from favorites. Please try again.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMakePublic = (e: any) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
makePublic(workspaceSlug, projectId, page.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMakePrivate = (e: any) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
makePrivate(workspaceSlug, projectId, page.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchivePage = (e: any) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
archivePage(workspaceSlug, projectId, page.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestorePage = (e: any) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
restorePage(workspaceSlug, projectId, page.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePage = (e: any) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
setDeletePageModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditPage = (e: any) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
setCreateUpdatePageModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const userCanEdit = currentProjectRole === 15 || currentProjectRole === 20;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CreateUpdatePageModal
|
||||||
|
isOpen={createUpdatePageModal}
|
||||||
|
handleClose={() => setCreateUpdatePageModal(false)}
|
||||||
|
data={page}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
<DeletePageModal isOpen={deletePageModal} onClose={() => setDeletePageModal(false)} data={page} />
|
||||||
|
<li>
|
||||||
|
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${page.id}`}>
|
||||||
|
<a>
|
||||||
|
<div className="relative rounded p-4 text-custom-text-200 hover:bg-custom-background-80">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex overflow-hidden items-center gap-2">
|
||||||
|
<FileText className="h-4 w-4 shrink-0" />
|
||||||
|
<p className="mr-2 truncate text-sm text-custom-text-100">{page.name}</p>
|
||||||
|
{page.label_details.length > 0 &&
|
||||||
|
page.label_details.map((label) => (
|
||||||
|
<div
|
||||||
|
key={label.id}
|
||||||
|
className="group flex items-center gap-1 rounded-2xl border border-custom-border-200 px-2 py-0.5 text-xs"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${label?.color}20`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: label?.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{label.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
{page.archived_at ? (
|
||||||
|
<Tooltip
|
||||||
|
tooltipContent={`Archived at ${render24HourFormatTime(page.archived_at)} on ${renderShortDate(
|
||||||
|
page.archived_at
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-custom-text-200">{render24HourFormatTime(page.archived_at)}</p>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Tooltip
|
||||||
|
tooltipContent={`Last updated at ${render24HourFormatTime(page.updated_at)} on ${renderShortDate(
|
||||||
|
page.updated_at
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-custom-text-200">{render24HourFormatTime(page.updated_at)}</p>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{!page.archived_at && userCanEdit && (
|
||||||
|
<Tooltip tooltipContent={`${page.is_favorite ? "Remove from favorites" : "Mark as favorite"}`}>
|
||||||
|
{page.is_favorite ? (
|
||||||
|
<button type="button" onClick={handleRemoveFromFavorites}>
|
||||||
|
<Star className="h-3.5 w-3.5 text-orange-400 fill-orange-400" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={handleAddToFavorites}>
|
||||||
|
<Star className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{!page.archived_at && userCanEdit && (
|
||||||
|
<Tooltip
|
||||||
|
tooltipContent={`${
|
||||||
|
page.access
|
||||||
|
? "This page is only visible to you"
|
||||||
|
: "This page can be viewed by anyone in the project"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{page.access ? (
|
||||||
|
<button type="button" onClick={handleMakePublic}>
|
||||||
|
<Lock className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={handleMakePrivate}>
|
||||||
|
<Globe2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip
|
||||||
|
position="top-right"
|
||||||
|
tooltipContent={`Created by ${
|
||||||
|
projectMembers?.find((projectMember) => projectMember.member.id === page.created_by)?.member
|
||||||
|
.display_name ?? ""
|
||||||
|
} on ${renderLongDateFormat(`${page.created_at}`)}`}
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-3.5 w-3.5" />
|
||||||
|
</Tooltip>
|
||||||
|
{page.archived_at ? (
|
||||||
|
<CustomMenu width="auto" placement="bottom-end" className="!-m-1" verticalEllipsis>
|
||||||
|
{userCanEdit && (
|
||||||
|
<>
|
||||||
|
<CustomMenu.MenuItem onClick={handleRestorePage}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ArchiveRestoreIcon className="h-3 w-3" />
|
||||||
|
<span>Restore page</span>
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
<CustomMenu.MenuItem onClick={handleDeletePage}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
<span>Delete page</span>
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<CustomMenu.MenuItem onClick={handleCopyUrl}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LinkIcon className="h-3 w-3" />
|
||||||
|
<span>Copy page link</span>
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
</CustomMenu>
|
||||||
|
) : (
|
||||||
|
<CustomMenu width="auto" placement="bottom-end" className="!-m-1" verticalEllipsis>
|
||||||
|
{userCanEdit && (
|
||||||
|
<>
|
||||||
|
<CustomMenu.MenuItem onClick={handleEditPage}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
<span>Edit page</span>
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
<CustomMenu.MenuItem onClick={handleArchivePage}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Archive className="h-3 w-3" />
|
||||||
|
<span>Archive page</span>
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<CustomMenu.MenuItem onClick={handleCopyUrl}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LinkIcon className="h-3 w-3" />
|
||||||
|
<span>Copy page link</span>
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
</CustomMenu>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
65
web/components/pages/pages-list/list-view.tsx
Normal file
65
web/components/pages/pages-list/list-view.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
// mobx store
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
// components
|
||||||
|
import { EmptyState } from "components/common";
|
||||||
|
import { PagesListItem } from "./list-item";
|
||||||
|
// ui
|
||||||
|
import { Loader } from "@plane/ui";
|
||||||
|
// images
|
||||||
|
import emptyPage from "public/empty-state/page.svg";
|
||||||
|
// types
|
||||||
|
import { IPage } from "types";
|
||||||
|
|
||||||
|
type IPagesListView = {
|
||||||
|
pages: IPage[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PagesListView: FC<IPagesListView> = observer(({ pages }) => {
|
||||||
|
// store
|
||||||
|
const { commandPalette: commandPaletteStore } = useMobxStore();
|
||||||
|
// router
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{pages && workspaceSlug && projectId ? (
|
||||||
|
<div className="space-y-4 h-full overflow-y-auto">
|
||||||
|
{pages.length > 0 ? (
|
||||||
|
<ul role="list" className="divide-y divide-custom-border-200">
|
||||||
|
{pages.map((page) => (
|
||||||
|
<PagesListItem
|
||||||
|
key={page.id}
|
||||||
|
workspaceSlug={workspaceSlug.toString()}
|
||||||
|
projectId={projectId.toString()}
|
||||||
|
page={page}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="Have your thoughts in place"
|
||||||
|
description="You can think of Pages as an AI-powered notepad."
|
||||||
|
image={emptyPage}
|
||||||
|
primaryButton={{
|
||||||
|
icon: <Plus className="h-4 w-4" />,
|
||||||
|
text: "New Page",
|
||||||
|
onClick: () => commandPaletteStore.toggleCreatePageModal(true),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Loader className="space-y-4">
|
||||||
|
<Loader.Item height="40px" />
|
||||||
|
<Loader.Item height="40px" />
|
||||||
|
<Loader.Item height="40px" />
|
||||||
|
</Loader>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
@ -1,29 +0,0 @@
|
|||||||
import { useRouter } from "next/router";
|
|
||||||
|
|
||||||
import useSWR from "swr";
|
|
||||||
|
|
||||||
// services
|
|
||||||
import { PageService } from "services/page.service";
|
|
||||||
// components
|
|
||||||
import { PagesView } from "components/pages";
|
|
||||||
// types
|
|
||||||
import { TPagesListProps } from "./types";
|
|
||||||
// fetch-keys
|
|
||||||
import { MY_PAGES_LIST } from "constants/fetch-keys";
|
|
||||||
|
|
||||||
// services
|
|
||||||
const pageService = new PageService();
|
|
||||||
|
|
||||||
export const MyPagesList: React.FC<TPagesListProps> = ({ viewType }) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { workspaceSlug, projectId } = router.query;
|
|
||||||
|
|
||||||
const { data: pages } = useSWR(
|
|
||||||
workspaceSlug && projectId ? MY_PAGES_LIST(projectId as string) : null,
|
|
||||||
workspaceSlug && projectId
|
|
||||||
? () => pageService.getPagesWithParams(workspaceSlug as string, projectId as string, "created_by_me")
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
return <PagesView pages={pages} viewType={viewType} />;
|
|
||||||
};
|
|
@ -1,29 +0,0 @@
|
|||||||
import { useRouter } from "next/router";
|
|
||||||
|
|
||||||
import useSWR from "swr";
|
|
||||||
|
|
||||||
// services
|
|
||||||
import { PageService } from "services/page.service";
|
|
||||||
// components
|
|
||||||
import { PagesView } from "components/pages";
|
|
||||||
// types
|
|
||||||
import { TPagesListProps } from "./types";
|
|
||||||
// fetch-keys
|
|
||||||
import { OTHER_PAGES_LIST } from "constants/fetch-keys";
|
|
||||||
|
|
||||||
// services
|
|
||||||
const pageService = new PageService();
|
|
||||||
|
|
||||||
export const OtherPagesList: React.FC<TPagesListProps> = ({ viewType }) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { workspaceSlug, projectId } = router.query;
|
|
||||||
|
|
||||||
const { data: pages } = useSWR(
|
|
||||||
workspaceSlug && projectId ? OTHER_PAGES_LIST(projectId as string) : null,
|
|
||||||
workspaceSlug && projectId
|
|
||||||
? () => pageService.getPagesWithParams(workspaceSlug as string, projectId as string, "created_by_other")
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
return <PagesView pages={pages} viewType={viewType} />;
|
|
||||||
};
|
|
25
web/components/pages/pages-list/private-page-list.tsx
Normal file
25
web/components/pages/pages-list/private-page-list.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
// components
|
||||||
|
import { PagesListView } from "components/pages/pages-list";
|
||||||
|
// hooks
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
// ui
|
||||||
|
import { Loader } from "@plane/ui";
|
||||||
|
|
||||||
|
export const PrivatePagesList: FC = observer(() => {
|
||||||
|
const {
|
||||||
|
page: { privateProjectPages },
|
||||||
|
} = useMobxStore();
|
||||||
|
|
||||||
|
if (!privateProjectPages)
|
||||||
|
return (
|
||||||
|
<Loader className="space-y-4">
|
||||||
|
<Loader.Item height="40px" />
|
||||||
|
<Loader.Item height="40px" />
|
||||||
|
<Loader.Item height="40px" />
|
||||||
|
</Loader>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <PagesListView pages={privateProjectPages} />;
|
||||||
|
});
|
@ -1,14 +1,10 @@
|
|||||||
import React from "react";
|
import React, { FC } from "react";
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
import useSWR from "swr";
|
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
// mobx store
|
// mobx store
|
||||||
import { useMobxStore } from "lib/mobx/store-provider";
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
// services
|
|
||||||
import { PageService } from "services/page.service";
|
|
||||||
// components
|
// components
|
||||||
import { PagesView } from "components/pages";
|
import { PagesListView } from "components/pages/pages-list";
|
||||||
import { EmptyState } from "components/common";
|
import { EmptyState } from "components/common";
|
||||||
// ui
|
// ui
|
||||||
import { Loader } from "@plane/ui";
|
import { Loader } from "@plane/ui";
|
||||||
@ -16,47 +12,42 @@ import { Loader } from "@plane/ui";
|
|||||||
import emptyPage from "public/empty-state/page.svg";
|
import emptyPage from "public/empty-state/page.svg";
|
||||||
// helpers
|
// helpers
|
||||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
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
|
export const RecentPagesList: FC = observer(() => {
|
||||||
const pageService = new PageService();
|
// store
|
||||||
|
const {
|
||||||
|
commandPalette: commandPaletteStore,
|
||||||
|
page: { recentProjectPages },
|
||||||
|
} = useMobxStore();
|
||||||
|
|
||||||
export const RecentPagesList: React.FC<TPagesListProps> = observer((props) => {
|
const isEmpty = recentProjectPages && Object.values(recentProjectPages).every((value) => value.length === 0);
|
||||||
const { viewType } = props;
|
|
||||||
|
|
||||||
const { commandPalette: commandPaletteStore } = useMobxStore();
|
if (!recentProjectPages) {
|
||||||
|
return (
|
||||||
const router = useRouter();
|
<Loader className="space-y-4">
|
||||||
const { workspaceSlug, projectId } = router.query;
|
<Loader.Item height="40px" />
|
||||||
|
<Loader.Item height="40px" />
|
||||||
const { data: pages } = useSWR(
|
<Loader.Item height="40px" />
|
||||||
workspaceSlug && projectId ? RECENT_PAGES_LIST(projectId as string) : null,
|
</Loader>
|
||||||
workspaceSlug && projectId ? () => pageService.getRecentPages(workspaceSlug as string, projectId as string) : null
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
const isEmpty = pages && Object.keys(pages).every((key) => pages[key].length === 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{pages ? (
|
{Object.keys(recentProjectPages).length > 0 && !isEmpty ? (
|
||||||
Object.keys(pages).length > 0 && !isEmpty ? (
|
<>
|
||||||
Object.keys(pages).map((key) => {
|
{Object.keys(recentProjectPages).map((key) => {
|
||||||
if (pages[key].length === 0) return null;
|
if (recentProjectPages[key].length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className="h-full overflow-hidden pb-9">
|
<div key={key} className="h-full overflow-hidden pb-9">
|
||||||
<h2 className="text-xl font-semibold capitalize mb-2">
|
<h2 className="text-xl font-semibold capitalize mb-2">{replaceUnderscoreIfSnakeCase(key)}</h2>
|
||||||
{replaceUnderscoreIfSnakeCase(key)}
|
<PagesListView pages={recentProjectPages[key]} />
|
||||||
</h2>
|
|
||||||
<PagesView pages={pages[key as keyof RecentPagesResponse]} viewType={viewType} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="Have your thoughts in place"
|
title="Have your thoughts in place"
|
||||||
description="You can think of Pages as an AI-powered notepad."
|
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),
|
onClick: () => commandPaletteStore.toggleCreatePageModal(true),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
</>
|
||||||
) : (
|
|
||||||
<Loader className="space-y-4">
|
|
||||||
<Loader.Item height="40px" />
|
|
||||||
<Loader.Item height="40px" />
|
|
||||||
<Loader.Item height="40px" />
|
|
||||||
</Loader>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
25
web/components/pages/pages-list/shared-pages-list.tsx
Normal file
25
web/components/pages/pages-list/shared-pages-list.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
|
// components
|
||||||
|
import { PagesListView } from "components/pages/pages-list";
|
||||||
|
// hooks
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
|
// ui
|
||||||
|
import { Loader } from "@plane/ui";
|
||||||
|
|
||||||
|
export const SharedPagesList: FC = observer(() => {
|
||||||
|
const {
|
||||||
|
page: { sharedProjectPages },
|
||||||
|
} = useMobxStore();
|
||||||
|
|
||||||
|
if (!sharedProjectPages)
|
||||||
|
return (
|
||||||
|
<Loader className="space-y-4">
|
||||||
|
<Loader.Item height="40px" />
|
||||||
|
<Loader.Item height="40px" />
|
||||||
|
<Loader.Item height="40px" />
|
||||||
|
</Loader>
|
||||||
|
);
|
||||||
|
|
||||||
|
return <PagesListView pages={sharedProjectPages} />;
|
||||||
|
});
|
@ -1,293 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
import useSWR, { mutate } from "swr";
|
|
||||||
import { Plus } from "lucide-react";
|
|
||||||
// mobx store
|
|
||||||
import { useMobxStore } from "lib/mobx/store-provider";
|
|
||||||
// services
|
|
||||||
import { PageService } from "services/page.service";
|
|
||||||
import { ProjectMemberService } from "services/project";
|
|
||||||
// hooks
|
|
||||||
import useToast from "hooks/use-toast";
|
|
||||||
// components
|
|
||||||
import { CreateUpdatePageModal, DeletePageModal, SinglePageDetailedItem, SinglePageListItem } from "components/pages";
|
|
||||||
import { EmptyState } from "components/common";
|
|
||||||
// ui
|
|
||||||
import { Loader } from "@plane/ui";
|
|
||||||
// images
|
|
||||||
import emptyPage from "public/empty-state/page.svg";
|
|
||||||
// types
|
|
||||||
import { IPage, TPageViewProps } from "types";
|
|
||||||
import {
|
|
||||||
ALL_PAGES_LIST,
|
|
||||||
FAVORITE_PAGES_LIST,
|
|
||||||
MY_PAGES_LIST,
|
|
||||||
PROJECT_MEMBERS,
|
|
||||||
RECENT_PAGES_LIST,
|
|
||||||
} from "constants/fetch-keys";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
pages: IPage[] | undefined;
|
|
||||||
viewType: TPageViewProps;
|
|
||||||
};
|
|
||||||
|
|
||||||
// services
|
|
||||||
const pageService = new PageService();
|
|
||||||
const projectMemberService = new ProjectMemberService();
|
|
||||||
|
|
||||||
export const PagesView: React.FC<Props> = observer(({ pages, viewType }) => {
|
|
||||||
// states
|
|
||||||
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
|
|
||||||
const [selectedPageToUpdate, setSelectedPageToUpdate] = useState<IPage | null>(null);
|
|
||||||
const [deletePageModal, setDeletePageModal] = useState(false);
|
|
||||||
const [selectedPageToDelete, setSelectedPageToDelete] = useState<IPage | null>(null);
|
|
||||||
|
|
||||||
const { user: userStore, commandPalette: commandPaletteStore } = useMobxStore();
|
|
||||||
const user = userStore.currentUser ?? undefined;
|
|
||||||
|
|
||||||
// router
|
|
||||||
const router = useRouter();
|
|
||||||
const { workspaceSlug, projectId } = router.query;
|
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
|
||||||
|
|
||||||
const { data: people } = useSWR(
|
|
||||||
workspaceSlug && projectId ? PROJECT_MEMBERS(projectId.toString()) : null,
|
|
||||||
workspaceSlug && projectId
|
|
||||||
? () => projectMemberService.fetchProjectMembers(workspaceSlug.toString(), projectId.toString())
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleEditPage = (page: IPage) => {
|
|
||||||
setSelectedPageToUpdate(page);
|
|
||||||
setCreateUpdatePageModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeletePage = (page: IPage) => {
|
|
||||||
setSelectedPageToDelete(page);
|
|
||||||
setDeletePageModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddToFavorites = (page: IPage) => {
|
|
||||||
if (!workspaceSlug || !projectId) return;
|
|
||||||
|
|
||||||
mutate<IPage[]>(
|
|
||||||
ALL_PAGES_LIST(projectId.toString()),
|
|
||||||
(prevData) =>
|
|
||||||
(prevData ?? []).map((p) => {
|
|
||||||
if (p.id === page.id) p.is_favorite = true;
|
|
||||||
|
|
||||||
return p;
|
|
||||||
}),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
mutate<IPage[]>(
|
|
||||||
MY_PAGES_LIST(projectId.toString()),
|
|
||||||
(prevData) =>
|
|
||||||
(prevData ?? []).map((p) => {
|
|
||||||
if (p.id === page.id) p.is_favorite = true;
|
|
||||||
|
|
||||||
return p;
|
|
||||||
}),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
mutate<IPage[]>(FAVORITE_PAGES_LIST(projectId.toString()), (prevData) => [page, ...(prevData ?? [])], false);
|
|
||||||
|
|
||||||
pageService
|
|
||||||
.addPageToFavorites(workspaceSlug.toString(), projectId.toString(), {
|
|
||||||
page: page.id,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
mutate(RECENT_PAGES_LIST(projectId.toString()));
|
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Success!",
|
|
||||||
message: "Successfully added the page to favorites.",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "Couldn't add the page to favorites. Please try again.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveFromFavorites = (page: IPage) => {
|
|
||||||
if (!workspaceSlug || !projectId) return;
|
|
||||||
|
|
||||||
mutate<IPage[]>(
|
|
||||||
ALL_PAGES_LIST(projectId.toString()),
|
|
||||||
(prevData) =>
|
|
||||||
(prevData ?? []).map((p) => {
|
|
||||||
if (p.id === page.id) p.is_favorite = false;
|
|
||||||
|
|
||||||
return p;
|
|
||||||
}),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
mutate<IPage[]>(
|
|
||||||
MY_PAGES_LIST(projectId.toString()),
|
|
||||||
(prevData) =>
|
|
||||||
(prevData ?? []).map((p) => {
|
|
||||||
if (p.id === page.id) p.is_favorite = false;
|
|
||||||
|
|
||||||
return p;
|
|
||||||
}),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
mutate<IPage[]>(
|
|
||||||
FAVORITE_PAGES_LIST(projectId.toString()),
|
|
||||||
(prevData) => (prevData ?? []).filter((p) => p.id !== page.id),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
pageService
|
|
||||||
.removePageFromFavorites(workspaceSlug.toString(), projectId.toString(), page.id)
|
|
||||||
.then(() => {
|
|
||||||
mutate(RECENT_PAGES_LIST(projectId.toString()));
|
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Success!",
|
|
||||||
message: "Successfully removed the page from favorites.",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "Couldn't remove the page from favorites. Please try again.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const partialUpdatePage = (page: IPage, formData: Partial<IPage>) => {
|
|
||||||
if (!workspaceSlug || !projectId || !user) return;
|
|
||||||
|
|
||||||
mutate<IPage[]>(
|
|
||||||
ALL_PAGES_LIST(projectId.toString()),
|
|
||||||
(prevData) => (prevData ?? []).map((p) => ({ ...p, ...(p.id === page.id ? formData : {}) })),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
mutate<IPage[]>(
|
|
||||||
MY_PAGES_LIST(projectId.toString()),
|
|
||||||
(prevData) => (prevData ?? []).map((p) => ({ ...p, ...(p.id === page.id ? formData : {}) })),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
mutate<IPage[]>(
|
|
||||||
FAVORITE_PAGES_LIST(projectId.toString()),
|
|
||||||
(prevData) => (prevData ?? []).map((p) => ({ ...p, ...(p.id === page.id ? formData : {}) })),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
pageService.patchPage(workspaceSlug.toString(), projectId.toString(), page.id, formData).then(() => {
|
|
||||||
mutate(RECENT_PAGES_LIST(projectId.toString()));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{workspaceSlug && projectId && (
|
|
||||||
<>
|
|
||||||
<CreateUpdatePageModal
|
|
||||||
isOpen={createUpdatePageModal}
|
|
||||||
handleClose={() => setCreateUpdatePageModal(false)}
|
|
||||||
data={selectedPageToUpdate}
|
|
||||||
user={user}
|
|
||||||
workspaceSlug={workspaceSlug.toString()}
|
|
||||||
projectId={projectId.toString()}
|
|
||||||
/>
|
|
||||||
<DeletePageModal
|
|
||||||
isOpen={deletePageModal}
|
|
||||||
setIsOpen={setDeletePageModal}
|
|
||||||
data={selectedPageToDelete}
|
|
||||||
user={user}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{pages ? (
|
|
||||||
<div className="space-y-4 h-full overflow-y-auto">
|
|
||||||
{pages.length > 0 ? (
|
|
||||||
viewType === "list" ? (
|
|
||||||
<ul role="list" className="divide-y divide-custom-border-200">
|
|
||||||
{pages.map((page) => (
|
|
||||||
<SinglePageListItem
|
|
||||||
key={page.id}
|
|
||||||
page={page}
|
|
||||||
people={people}
|
|
||||||
handleEditPage={() => handleEditPage(page)}
|
|
||||||
handleDeletePage={() => handleDeletePage(page)}
|
|
||||||
handleAddToFavorites={() => handleAddToFavorites(page)}
|
|
||||||
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
|
|
||||||
partialUpdatePage={partialUpdatePage}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : viewType === "detailed" ? (
|
|
||||||
<div className="divide-y divide-custom-border-200 rounded-[10px] border border-custom-border-200 bg-custom-background-100">
|
|
||||||
{pages.map((page) => (
|
|
||||||
<SinglePageDetailedItem
|
|
||||||
key={page.id}
|
|
||||||
page={page}
|
|
||||||
people={people}
|
|
||||||
handleEditPage={() => handleEditPage(page)}
|
|
||||||
handleDeletePage={() => handleDeletePage(page)}
|
|
||||||
handleAddToFavorites={() => handleAddToFavorites(page)}
|
|
||||||
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
|
|
||||||
partialUpdatePage={partialUpdatePage}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-[10px] border border-custom-border-200">
|
|
||||||
{pages.map((page) => (
|
|
||||||
<SinglePageDetailedItem
|
|
||||||
key={page.id}
|
|
||||||
page={page}
|
|
||||||
people={people}
|
|
||||||
handleEditPage={() => handleEditPage(page)}
|
|
||||||
handleDeletePage={() => handleDeletePage(page)}
|
|
||||||
handleAddToFavorites={() => handleAddToFavorites(page)}
|
|
||||||
handleRemoveFromFavorites={() => handleRemoveFromFavorites(page)}
|
|
||||||
partialUpdatePage={partialUpdatePage}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
title="Have your thoughts in place"
|
|
||||||
description="You can think of Pages as an AI-powered notepad."
|
|
||||||
image={emptyPage}
|
|
||||||
primaryButton={{
|
|
||||||
icon: <Plus className="h-4 w-4" />,
|
|
||||||
text: "New Page",
|
|
||||||
onClick: () => commandPaletteStore.toggleCreatePageModal(true),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : viewType === "list" ? (
|
|
||||||
<Loader className="space-y-4">
|
|
||||||
<Loader.Item height="40px" />
|
|
||||||
<Loader.Item height="40px" />
|
|
||||||
<Loader.Item height="40px" />
|
|
||||||
</Loader>
|
|
||||||
) : viewType === "detailed" ? (
|
|
||||||
<Loader className="space-y-4">
|
|
||||||
<Loader.Item height="150px" />
|
|
||||||
<Loader.Item height="150px" />
|
|
||||||
</Loader>
|
|
||||||
) : (
|
|
||||||
<Loader className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
<Loader.Item height="150px" />
|
|
||||||
<Loader.Item height="150px" />
|
|
||||||
<Loader.Item height="150px" />
|
|
||||||
</Loader>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
@ -1,432 +0,0 @@
|
|||||||
import React, { useEffect, useState, useRef } from "react";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { mutate } from "swr";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { Draggable } from "@hello-pangea/dnd";
|
|
||||||
// services
|
|
||||||
import { PageService } from "services/page.service";
|
|
||||||
import { IssueService } from "services/issue/issue.service";
|
|
||||||
import { AIService } from "services/ai.service";
|
|
||||||
import { FileService } from "services/file.service";
|
|
||||||
// hooks
|
|
||||||
import useToast from "hooks/use-toast";
|
|
||||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
|
||||||
// components
|
|
||||||
import { GptAssistantModal } from "components/core";
|
|
||||||
import { CreateUpdateBlockInline } from "components/pages";
|
|
||||||
import { RichTextEditor } from "@plane/rich-text-editor";
|
|
||||||
// ui
|
|
||||||
import { CustomMenu, LayersIcon, TextArea } from "@plane/ui";
|
|
||||||
// icons
|
|
||||||
import { RefreshCw, LinkIcon, Zap, Check, MoreVertical, Pencil, Sparkle, Trash2 } from "lucide-react";
|
|
||||||
// helpers
|
|
||||||
import { copyTextToClipboard } from "helpers/string.helper";
|
|
||||||
// types
|
|
||||||
import { IUser, IIssue, IPageBlock, IProject } from "types";
|
|
||||||
// fetch-keys
|
|
||||||
import { PAGE_BLOCKS_LIST } from "constants/fetch-keys";
|
|
||||||
import useEditorSuggestions from "hooks/use-editor-suggestions";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
block: IPageBlock;
|
|
||||||
projectDetails: IProject | undefined;
|
|
||||||
showBlockDetails: boolean;
|
|
||||||
index: number;
|
|
||||||
user: IUser | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const aiService = new AIService();
|
|
||||||
const pageService = new PageService();
|
|
||||||
const issueService = new IssueService();
|
|
||||||
const fileService = new FileService();
|
|
||||||
|
|
||||||
export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails, showBlockDetails, index, user }) => {
|
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
|
||||||
const [createBlockForm, setCreateBlockForm] = useState(false);
|
|
||||||
const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false);
|
|
||||||
|
|
||||||
const [gptAssistantModal, setGptAssistantModal] = useState(false);
|
|
||||||
|
|
||||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
|
||||||
const actionSectionRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const { workspaceSlug, projectId, pageId } = router.query;
|
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
|
||||||
|
|
||||||
const { handleSubmit, watch, reset, setValue } = useForm<IPageBlock>({
|
|
||||||
defaultValues: {
|
|
||||||
name: "",
|
|
||||||
description: {},
|
|
||||||
description_html: "<p></p>",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const editorSuggestion = useEditorSuggestions();
|
|
||||||
|
|
||||||
const updatePageBlock = async (formData: Partial<IPageBlock>) => {
|
|
||||||
if (!workspaceSlug || !projectId || !pageId) return;
|
|
||||||
|
|
||||||
if (!formData.name || formData.name.length === 0 || formData.name === "") return;
|
|
||||||
|
|
||||||
if (block.issue && block.sync) setIsSyncing(true);
|
|
||||||
|
|
||||||
mutate<IPageBlock[]>(
|
|
||||||
PAGE_BLOCKS_LIST(pageId as string),
|
|
||||||
(prevData) =>
|
|
||||||
prevData?.map((p) => {
|
|
||||||
if (p.id === block.id) return { ...p, ...formData };
|
|
||||||
|
|
||||||
return p;
|
|
||||||
}),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
await pageService
|
|
||||||
.patchPageBlock(workspaceSlug as string, projectId as string, pageId as string, block.id, {
|
|
||||||
name: formData.name,
|
|
||||||
description: formData.description,
|
|
||||||
description_html: formData.description_html,
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
mutate(PAGE_BLOCKS_LIST(pageId as string));
|
|
||||||
if (block.issue && block.sync)
|
|
||||||
issueService
|
|
||||||
.patchIssue(workspaceSlug as string, projectId as string, block.issue, {
|
|
||||||
name: res.name,
|
|
||||||
description: res.description,
|
|
||||||
description_html: res.description_html,
|
|
||||||
})
|
|
||||||
.finally(() => setIsSyncing(false));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const pushBlockIntoIssues = async () => {
|
|
||||||
if (!workspaceSlug || !projectId || !pageId) return;
|
|
||||||
|
|
||||||
await pageService
|
|
||||||
.convertPageBlockToIssue(workspaceSlug as string, projectId as string, pageId as string, block.id)
|
|
||||||
.then((res: IIssue) => {
|
|
||||||
mutate<IPageBlock[]>(
|
|
||||||
PAGE_BLOCKS_LIST(pageId as string),
|
|
||||||
(prevData) =>
|
|
||||||
(prevData ?? []).map((p) => {
|
|
||||||
if (p.id === block.id) return { ...p, issue: res.id, issue_detail: res };
|
|
||||||
|
|
||||||
return p;
|
|
||||||
}),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Success!",
|
|
||||||
message: "Page block converted to issue successfully.",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "Page block could not be converted to issue. Please try again.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const deletePageBlock = async () => {
|
|
||||||
if (!workspaceSlug || !projectId || !pageId) return;
|
|
||||||
|
|
||||||
mutate<IPageBlock[]>(
|
|
||||||
PAGE_BLOCKS_LIST(pageId as string),
|
|
||||||
(prevData) => (prevData ?? []).filter((p) => p.id !== block.id),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
await pageService
|
|
||||||
.deletePageBlock(workspaceSlug as string, projectId as string, pageId as string, block.id)
|
|
||||||
.catch(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "Page could not be deleted. Please try again.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAutoGenerateDescription = async () => {
|
|
||||||
if (!workspaceSlug || !projectId) return;
|
|
||||||
|
|
||||||
setIAmFeelingLucky(true);
|
|
||||||
|
|
||||||
aiService
|
|
||||||
.createGptTask(workspaceSlug as string, projectId as string, {
|
|
||||||
prompt: block.name,
|
|
||||||
task: "Generate a proper description for this issue.",
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
if (res.response === "")
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message:
|
|
||||||
"Block title isn't informative enough to generate the description. Please try with a different title.",
|
|
||||||
});
|
|
||||||
else handleAiAssistance(res.response_html);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (err.status === 429)
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "You have reached the maximum number of requests of 50 requests per month per user.",
|
|
||||||
});
|
|
||||||
else
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "Some error occurred. Please try again.",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.finally(() => setIAmFeelingLucky(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAiAssistance = async (response: string) => {
|
|
||||||
if (!workspaceSlug || !projectId) return;
|
|
||||||
|
|
||||||
setValue("description", {});
|
|
||||||
setValue("description_html", `${watch("description_html")}<p>${response}</p>`);
|
|
||||||
handleSubmit(updatePageBlock)()
|
|
||||||
.then(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Success!",
|
|
||||||
message: "Block description updated successfully.",
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "Block description could not be updated. Please try again.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBlockSync = () => {
|
|
||||||
if (!workspaceSlug || !projectId || !pageId) return;
|
|
||||||
|
|
||||||
mutate<IPageBlock[]>(
|
|
||||||
PAGE_BLOCKS_LIST(pageId as string),
|
|
||||||
(prevData) =>
|
|
||||||
(prevData ?? []).map((p) => {
|
|
||||||
if (p.id === block.id) return { ...p, sync: !block.sync };
|
|
||||||
|
|
||||||
return p;
|
|
||||||
}),
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
pageService.patchPageBlock(workspaceSlug as string, projectId as string, pageId as string, block.id, {
|
|
||||||
sync: !block.sync,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyText = () => {
|
|
||||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
|
||||||
|
|
||||||
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${block.issue}`).then(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Link Copied!",
|
|
||||||
message: "Issue link copied to clipboard.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!block) return;
|
|
||||||
|
|
||||||
reset({ ...block });
|
|
||||||
}, [reset, block]);
|
|
||||||
|
|
||||||
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
|
|
||||||
return (
|
|
||||||
<Draggable draggableId={block.id} index={index} isDragDisabled={createBlockForm}>
|
|
||||||
{(provided, snapshot) => (
|
|
||||||
<>
|
|
||||||
{createBlockForm ? (
|
|
||||||
<div ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps}>
|
|
||||||
<CreateUpdateBlockInline
|
|
||||||
handleAiAssistance={handleAiAssistance}
|
|
||||||
handleClose={() => setCreateBlockForm(false)}
|
|
||||||
data={block}
|
|
||||||
setIsSyncing={setIsSyncing}
|
|
||||||
focus="name"
|
|
||||||
user={user}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={`group relative w-full rounded bg-custom-background-80 text-custom-text-200 ${
|
|
||||||
snapshot.isDragging ? "bg-custom-background-100 p-4 shadow" : ""
|
|
||||||
}`}
|
|
||||||
ref={provided.innerRef}
|
|
||||||
{...provided.draggableProps}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute top-4 -left-0 hidden rounded p-0.5 group-hover:!flex"
|
|
||||||
{...provided.dragHandleProps}
|
|
||||||
>
|
|
||||||
<MoreVertical className="h-4" />
|
|
||||||
<MoreVertical className="-ml-5 h-4" />
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
ref={actionSectionRef}
|
|
||||||
className={`absolute top-4 right-2 hidden items-center gap-2 bg-custom-background-80 pl-4 group-hover:!flex ${
|
|
||||||
isMenuActive ? "!flex" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{block.issue && block.sync && (
|
|
||||||
<div className="flex flex-shrink-0 cursor-default items-center gap-1 rounded py-1 px-1.5 text-xs">
|
|
||||||
{isSyncing ? <RefreshCw className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
|
|
||||||
{isSyncing ? "Syncing..." : "Synced"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 ${
|
|
||||||
iAmFeelingLucky ? "cursor-wait" : ""
|
|
||||||
}`}
|
|
||||||
onClick={handleAutoGenerateDescription}
|
|
||||||
disabled={iAmFeelingLucky}
|
|
||||||
>
|
|
||||||
{iAmFeelingLucky ? (
|
|
||||||
"Generating response..."
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Sparkle className="h-4 w-4" />I{"'"}m feeling lucky
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="-mr-2 flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90"
|
|
||||||
onClick={() => setGptAssistantModal((prevData) => !prevData)}
|
|
||||||
>
|
|
||||||
<Sparkle className="h-4 w-4" />
|
|
||||||
AI
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="-mr-2 flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90"
|
|
||||||
onClick={() => setCreateBlockForm(true)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
<CustomMenu
|
|
||||||
customButton={
|
|
||||||
<div
|
|
||||||
className="flex w-full cursor-pointer items-center justify-between gap-1 rounded px-2.5 py-1 text-left text-xs duration-300 hover:bg-custom-background-90"
|
|
||||||
onClick={() => setIsMenuActive(!isMenuActive)}
|
|
||||||
>
|
|
||||||
<Zap className="h-3.5 w-3.5" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{block.issue ? (
|
|
||||||
<>
|
|
||||||
<CustomMenu.MenuItem onClick={handleBlockSync}>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<RefreshCw className="h-4 w-4" />
|
|
||||||
<span>Turn sync {block.sync ? "off" : "on"}</span>
|
|
||||||
</span>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
<CustomMenu.MenuItem onClick={handleCopyText}>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<LinkIcon className="h-4 w-4" />
|
|
||||||
Copy issue link
|
|
||||||
</span>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<CustomMenu.MenuItem onClick={pushBlockIntoIssues}>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<LayersIcon className="h-4 w-4" />
|
|
||||||
Push into issues
|
|
||||||
</span>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
)}
|
|
||||||
<CustomMenu.MenuItem onClick={deletePageBlock}>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
Delete block
|
|
||||||
</span>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
</CustomMenu>
|
|
||||||
</div>
|
|
||||||
<div className={`flex items-start gap-2 px-3 ${snapshot.isDragging ? "" : "py-4"}`}>
|
|
||||||
<div
|
|
||||||
className="w-full cursor-pointer overflow-hidden break-words px-4"
|
|
||||||
onClick={() => setCreateBlockForm(true)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
{block.issue && (
|
|
||||||
<div className="mr-1.5 flex">
|
|
||||||
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${block.issue}`}>
|
|
||||||
<a className="flex h-6 flex-shrink-0 items-center gap-1 rounded bg-custom-background-80 px-1.5 py-1 text-xs">
|
|
||||||
<LayersIcon height="16" width="16" />
|
|
||||||
{projectDetails?.identifier}-{block.issue_detail?.sequence_id}
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<TextArea
|
|
||||||
id="blockName"
|
|
||||||
name="blockName"
|
|
||||||
value={block.name}
|
|
||||||
placeholder="Title"
|
|
||||||
className="min-h-[20px] block w-full resize-none overflow-hidden border-none bg-transparent text-sm text-custom-text-100 !p-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showBlockDetails
|
|
||||||
? block.description_html.length > 7 && (
|
|
||||||
<RichTextEditor
|
|
||||||
cancelUploadImage={fileService.cancelUpload}
|
|
||||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
|
||||||
deleteFile={fileService.deleteImage}
|
|
||||||
value={block.description_html}
|
|
||||||
customClassName="text-sm min-h-[150px]"
|
|
||||||
noBorder
|
|
||||||
borderOnFocus={false}
|
|
||||||
mentionSuggestions={editorSuggestion.mentionSuggestions}
|
|
||||||
mentionHighlights={editorSuggestion.mentionHighlights}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
: block.description_stripped.length > 0 && (
|
|
||||||
<p className="mt-3 text-sm font-normal text-custom-text-200 h-5 truncate">
|
|
||||||
{block.description_stripped}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<GptAssistantModal
|
|
||||||
block={block}
|
|
||||||
isOpen={gptAssistantModal}
|
|
||||||
handleClose={() => setGptAssistantModal(false)}
|
|
||||||
inset="top-8 left-0"
|
|
||||||
content={block.description_stripped}
|
|
||||||
htmlContent={block.description_html}
|
|
||||||
onResponse={handleAiAssistance}
|
|
||||||
projectId={projectId as string}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Draggable>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,202 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
|
|
||||||
// hooks
|
|
||||||
import useUser from "hooks/use-user";
|
|
||||||
import useToast from "hooks/use-toast";
|
|
||||||
// ui
|
|
||||||
import { CustomMenu, Tooltip } from "@plane/ui";
|
|
||||||
// icons
|
|
||||||
import { AlertCircle, LinkIcon, Lock, Pencil, Star, Trash2, Unlock } from "lucide-react";
|
|
||||||
// helpers
|
|
||||||
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
|
||||||
import { render24HourFormatTime, renderShortDate, renderLongDateFormat } from "helpers/date-time.helper";
|
|
||||||
// types
|
|
||||||
import { IPage, IProjectMember } from "types";
|
|
||||||
|
|
||||||
type TSingleStatProps = {
|
|
||||||
page: IPage;
|
|
||||||
people: IProjectMember[] | undefined;
|
|
||||||
handleEditPage: () => void;
|
|
||||||
handleDeletePage: () => void;
|
|
||||||
handleAddToFavorites: () => void;
|
|
||||||
handleRemoveFromFavorites: () => void;
|
|
||||||
partialUpdatePage: (page: IPage, formData: Partial<IPage>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SinglePageDetailedItem: React.FC<TSingleStatProps> = ({
|
|
||||||
page,
|
|
||||||
people,
|
|
||||||
handleEditPage,
|
|
||||||
handleDeletePage,
|
|
||||||
handleAddToFavorites,
|
|
||||||
handleRemoveFromFavorites,
|
|
||||||
partialUpdatePage,
|
|
||||||
}) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { workspaceSlug, projectId } = router.query;
|
|
||||||
|
|
||||||
const { user } = useUser();
|
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
|
||||||
|
|
||||||
const handleCopyText = () => {
|
|
||||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
|
||||||
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/pages/${page.id}`).then(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Link Copied!",
|
|
||||||
message: "Page link copied to clipboard.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${page.id}`}>
|
|
||||||
<a className="block p-4">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center overflow-hidden gap-2">
|
|
||||||
<p className="mr-2 truncate text-sm">{page.name}</p>
|
|
||||||
{page.label_details.length > 0 &&
|
|
||||||
page.label_details.map((label) => (
|
|
||||||
<div
|
|
||||||
key={label.id}
|
|
||||||
className="group flex items-center gap-1 rounded-2xl border border-custom-border-200 px-2 py-0.5 text-xs"
|
|
||||||
style={{
|
|
||||||
backgroundColor: `${label?.color && label.color !== "" ? label.color : "#000000"}20`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
|
||||||
style={{
|
|
||||||
backgroundColor: label?.color && label.color !== "" ? label.color : "#000000",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{label.name}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Tooltip
|
|
||||||
tooltipContent={`Last updated at ${
|
|
||||||
render24HourFormatTime(page.updated_at) +
|
|
||||||
` ${new Date(page.updated_at).getHours() < 12 ? "am" : "pm"}`
|
|
||||||
} on ${renderShortDate(page.updated_at)}`}
|
|
||||||
>
|
|
||||||
<p className="text-sm text-custom-text-200">{render24HourFormatTime(page.updated_at)}</p>
|
|
||||||
</Tooltip>
|
|
||||||
{page.is_favorite ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleRemoveFromFavorites();
|
|
||||||
}}
|
|
||||||
className="z-10 grid place-items-center"
|
|
||||||
>
|
|
||||||
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleAddToFavorites();
|
|
||||||
}}
|
|
||||||
className="z-10 grid place-items-center"
|
|
||||||
>
|
|
||||||
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{page.created_by === user?.id && (
|
|
||||||
<Tooltip
|
|
||||||
tooltipContent={`${
|
|
||||||
page.access
|
|
||||||
? "This page is only visible to you."
|
|
||||||
: "This page can be viewed by anyone in the project."
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
partialUpdatePage(page, { access: page.access ? 0 : 1 });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{page.access ? (
|
|
||||||
<Lock className="h-4 w-4" color="rgb(var(--color-text-200))" />
|
|
||||||
) : (
|
|
||||||
<Unlock className="h-4 w-4" color="rgb(var(--color-text-200))" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Tooltip
|
|
||||||
position="top-right"
|
|
||||||
tooltipContent={`Created by ${
|
|
||||||
people?.find((person) => person.member.id === page.created_by)?.member.display_name ?? ""
|
|
||||||
} on ${renderLongDateFormat(`${page.created_at}`)}`}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<AlertCircle className="h-4 w-4 text-custom-text-200" />
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
<CustomMenu verticalEllipsis>
|
|
||||||
<CustomMenu.MenuItem
|
|
||||||
onClick={(e: any) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleEditPage();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="flex items-center justify-start gap-2">
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
<span>Edit Page</span>
|
|
||||||
</span>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
<CustomMenu.MenuItem
|
|
||||||
onClick={(e: any) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDeletePage();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="flex items-center justify-start gap-2">
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
<span>Delete Page</span>
|
|
||||||
</span>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
<CustomMenu.MenuItem
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleCopyText();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-start gap-2">
|
|
||||||
<LinkIcon className="h-4 w-4" />
|
|
||||||
<span>Copy Page link</span>
|
|
||||||
</div>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
</CustomMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{page.blocks.length > 0 && (
|
|
||||||
<div className="relative mt-2 space-y-2 text-sm text-custom-text-200">
|
|
||||||
{page.blocks.slice(0, 3).map((block) => (
|
|
||||||
<h4 className="truncate">{block.name}</h4>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,197 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
|
||||||
|
|
||||||
// hooks
|
|
||||||
import useUser from "hooks/use-user";
|
|
||||||
import useToast from "hooks/use-toast";
|
|
||||||
// ui
|
|
||||||
import { CustomMenu, Tooltip } from "@plane/ui";
|
|
||||||
// icons
|
|
||||||
import { AlertCircle, FileText, LinkIcon, Lock, Pencil, Star, Trash2, Unlock } from "lucide-react";
|
|
||||||
// helpers
|
|
||||||
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
|
||||||
import { renderLongDateFormat, renderShortDate, render24HourFormatTime } from "helpers/date-time.helper";
|
|
||||||
// types
|
|
||||||
import { IPage, IProjectMember } from "types";
|
|
||||||
|
|
||||||
type TSingleStatProps = {
|
|
||||||
page: IPage;
|
|
||||||
people: IProjectMember[] | undefined;
|
|
||||||
handleEditPage: () => void;
|
|
||||||
handleDeletePage: () => void;
|
|
||||||
handleAddToFavorites: () => void;
|
|
||||||
handleRemoveFromFavorites: () => void;
|
|
||||||
partialUpdatePage: (page: IPage, formData: Partial<IPage>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SinglePageListItem: React.FC<TSingleStatProps> = ({
|
|
||||||
page,
|
|
||||||
people,
|
|
||||||
handleEditPage,
|
|
||||||
handleDeletePage,
|
|
||||||
handleAddToFavorites,
|
|
||||||
handleRemoveFromFavorites,
|
|
||||||
partialUpdatePage,
|
|
||||||
}) => {
|
|
||||||
const router = useRouter();
|
|
||||||
const { workspaceSlug, projectId } = router.query;
|
|
||||||
|
|
||||||
const { user } = useUser();
|
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
|
||||||
|
|
||||||
const handleCopyText = () => {
|
|
||||||
const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
|
||||||
copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/pages/${page.id}`).then(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "success",
|
|
||||||
title: "Link Copied!",
|
|
||||||
message: "Page link copied to clipboard.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li>
|
|
||||||
<Link href={`/${workspaceSlug}/projects/${projectId}/pages/${page.id}`}>
|
|
||||||
<a>
|
|
||||||
<div className="relative rounded p-4 text-custom-text-200 hover:bg-custom-background-80">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex overflow-hidden items-center gap-2">
|
|
||||||
<FileText className="h-4 w-4 shrink-0" />
|
|
||||||
<p className="mr-2 truncate text-sm text-custom-text-100">{page.name}</p>
|
|
||||||
{page.label_details.length > 0 &&
|
|
||||||
page.label_details.map((label) => (
|
|
||||||
<div
|
|
||||||
key={label.id}
|
|
||||||
className="group flex items-center gap-1 rounded-2xl border border-custom-border-200 px-2 py-0.5 text-xs"
|
|
||||||
style={{
|
|
||||||
backgroundColor: `${label?.color}20`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
|
||||||
style={{
|
|
||||||
backgroundColor: label?.color,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{label.name}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="ml-2 flex flex-shrink-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Tooltip
|
|
||||||
tooltipContent={`Last updated at ${render24HourFormatTime(page.updated_at)} on ${renderShortDate(
|
|
||||||
page.updated_at
|
|
||||||
)}`}
|
|
||||||
>
|
|
||||||
<p className="text-sm text-custom-text-200">{render24HourFormatTime(page.updated_at)}</p>
|
|
||||||
</Tooltip>
|
|
||||||
{page.is_favorite ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleRemoveFromFavorites();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Star className="h-4 w-4 text-orange-400" fill="#f6ad55" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleAddToFavorites();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Star className="h-4 w-4 " color="rgb(var(--color-text-200))" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{page.created_by === user?.id && (
|
|
||||||
<Tooltip
|
|
||||||
tooltipContent={`${
|
|
||||||
page.access
|
|
||||||
? "This page is only visible to you."
|
|
||||||
: "This page can be viewed by anyone in the project."
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
partialUpdatePage(page, { access: page.access ? 0 : 1 });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{page.access ? (
|
|
||||||
<Lock className="h-4 w-4" color="rgb(var(--color-text-200))" />
|
|
||||||
) : (
|
|
||||||
<Unlock className="h-4 w-4" color="rgb(var(--color-text-200))" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<Tooltip
|
|
||||||
position="top-right"
|
|
||||||
tooltipContent={`Created by ${
|
|
||||||
people?.find((person) => person.member.id === page.created_by)?.member.display_name ?? ""
|
|
||||||
} on ${renderLongDateFormat(`${page.created_at}`)}`}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<AlertCircle className="h-4 w-4 text-custom-text-200" />
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<CustomMenu width="auto" verticalEllipsis>
|
|
||||||
<CustomMenu.MenuItem
|
|
||||||
onClick={(e: any) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleEditPage();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="flex items-center justify-start gap-2">
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
<span>Edit Page</span>
|
|
||||||
</span>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
<CustomMenu.MenuItem
|
|
||||||
onClick={(e: any) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDeletePage();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="flex items-center justify-start gap-2">
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
<span>Delete Page</span>
|
|
||||||
</span>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
<CustomMenu.MenuItem
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleCopyText();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-start gap-2">
|
|
||||||
<LinkIcon className="h-4 w-4" />
|
|
||||||
<span>Copy Page link</span>
|
|
||||||
</div>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
</CustomMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
};
|
|
@ -195,7 +195,7 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
|
|||||||
{project.is_favorite && (
|
{project.is_favorite && (
|
||||||
<CustomMenu.MenuItem onClick={handleRemoveFromFavorites}>
|
<CustomMenu.MenuItem onClick={handleRemoveFromFavorites}>
|
||||||
<span className="flex items-center justify-start gap-2">
|
<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>Remove from favorites</span>
|
||||||
</span>
|
</span>
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
|
@ -10,7 +10,6 @@ import { CreateUpdateProjectViewModal, DeleteProjectViewModal } from "components
|
|||||||
// ui
|
// ui
|
||||||
import { CustomMenu, PhotoFilterIcon } from "@plane/ui";
|
import { CustomMenu, PhotoFilterIcon } from "@plane/ui";
|
||||||
// helpers
|
// helpers
|
||||||
import { truncateText } from "helpers/string.helper";
|
|
||||||
import { calculateTotalFilters } from "helpers/filter.helper";
|
import { calculateTotalFilters } from "helpers/filter.helper";
|
||||||
// types
|
// types
|
||||||
import { IProjectView } from "types";
|
import { IProjectView } from "types";
|
||||||
@ -85,7 +84,7 @@ export const ProjectViewListItem: React.FC<Props> = observer((props) => {
|
|||||||
}}
|
}}
|
||||||
className="grid place-items-center"
|
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>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
@ -228,13 +228,14 @@ export const SLACK_CHANNEL_INFO = (workspaceSlug: string, projectId: string) =>
|
|||||||
// Pages
|
// Pages
|
||||||
export const RECENT_PAGES_LIST = (projectId: string) => `RECENT_PAGES_LIST_${projectId.toUpperCase()}`;
|
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 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 FAVORITE_PAGES_LIST = (projectId: string) => `FAVORITE_PAGES_LIST_${projectId.toUpperCase()}`;
|
||||||
export const MY_PAGES_LIST = (projectId: string) => `MY_PAGES_LIST_${projectId.toUpperCase()}`;
|
export const PRIVATE_PAGES_LIST = (projectId: string) => `PRIVATE_PAGES_LIST_${projectId.toUpperCase()}`;
|
||||||
export const OTHER_PAGES_LIST = (projectId: string) => `OTHER_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_DETAILS = (pageId: string) => `PAGE_DETAILS_${pageId.toUpperCase()}`;
|
||||||
export const PAGE_BLOCKS_LIST = (pageId: string) => `PAGE_BLOCK_LIST_${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 PAGE_BLOCK_DETAILS = (pageId: string) => `PAGE_BLOCK_DETAILS_${pageId.toUpperCase()}`;
|
||||||
|
export const MY_PAGES_LIST = (pageId: string) => `MY_PAGE_LIST_${pageId}`;
|
||||||
// estimates
|
// estimates
|
||||||
export const ESTIMATES_LIST = (projectId: string) => `ESTIMATES_LIST_${projectId.toUpperCase()}`;
|
export const ESTIMATES_LIST = (projectId: string) => `ESTIMATES_LIST_${projectId.toUpperCase()}`;
|
||||||
export const ESTIMATE_DETAILS = (estimateId: string) => `ESTIMATE_DETAILS_${estimateId.toUpperCase()}`;
|
export const ESTIMATE_DETAILS = (estimateId: string) => `ESTIMATE_DETAILS_${estimateId.toUpperCase()}`;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { LayoutGrid, List } from "lucide-react";
|
import { Globe2, LayoutGrid, List, Lock } from "lucide-react";
|
||||||
|
|
||||||
export const PAGE_VIEW_LAYOUTS = [
|
export const PAGE_VIEW_LAYOUTS = [
|
||||||
{
|
{
|
||||||
@ -27,11 +27,28 @@ export const PAGE_TABS_LIST: { key: string; title: string }[] = [
|
|||||||
title: "Favorites",
|
title: "Favorites",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "created-by-me",
|
key: "private",
|
||||||
title: "Created by me",
|
title: "Private",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "created-by-others",
|
key: "shared",
|
||||||
title: "Created by others",
|
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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
@ -31,6 +31,7 @@
|
|||||||
"@types/react-datepicker": "^4.8.0",
|
"@types/react-datepicker": "^4.8.0",
|
||||||
"axios": "^1.1.3",
|
"axios": "^1.1.3",
|
||||||
"cmdk": "^0.2.0",
|
"cmdk": "^0.2.0",
|
||||||
|
"date-fns": "^2.30.0",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"js-cookie": "^3.0.1",
|
"js-cookie": "^3.0.1",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
|
@ -2,76 +2,49 @@ import React, { useEffect, useRef, useState, ReactElement } from "react";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import useSWR, { mutate } from "swr";
|
import useSWR, { mutate } from "swr";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
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
|
// services
|
||||||
import { ProjectService, ProjectMemberService } from "services/project";
|
|
||||||
import { PageService } from "services/page.service";
|
import { PageService } from "services/page.service";
|
||||||
import { IssueLabelService } from "services/issue";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
|
||||||
import useUser from "hooks/use-user";
|
import useUser from "hooks/use-user";
|
||||||
// layouts
|
// layouts
|
||||||
import { AppLayout } from "layouts/app-layout";
|
import { AppLayout } from "layouts/app-layout";
|
||||||
// components
|
// 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";
|
import { PageDetailsHeader } from "components/headers/page-details";
|
||||||
// ui
|
|
||||||
import { EmptyState } from "components/common";
|
import { EmptyState } from "components/common";
|
||||||
import { CustomSearchSelect, TextArea, Loader, ToggleSwitch, Tooltip } from "@plane/ui";
|
// ui
|
||||||
// images
|
import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef } from "@plane/document-editor";
|
||||||
|
import { Loader } from "@plane/ui";
|
||||||
|
// assets
|
||||||
import emptyPage from "public/empty-state/page.svg";
|
import emptyPage from "public/empty-state/page.svg";
|
||||||
// icons
|
|
||||||
import { ArrowLeft, Lock, LinkIcon, Palette, Plus, Star, Unlock, X, ChevronDown } from "lucide-react";
|
|
||||||
// helpers
|
// helpers
|
||||||
import { render24HourFormatTime, renderShortDate } from "helpers/date-time.helper";
|
import { renderDateFormat } from "helpers/date-time.helper";
|
||||||
import { copyTextToClipboard } from "helpers/string.helper";
|
|
||||||
import { orderArrayBy } from "helpers/array.helper";
|
|
||||||
// types
|
// types
|
||||||
import { NextPageWithLayout } from "types/app";
|
import { NextPageWithLayout } from "types/app";
|
||||||
import { IIssueLabel, IPage, IPageBlock, IProjectMember } from "types";
|
import { IPage } from "types";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import {
|
import { PAGE_DETAILS } from "constants/fetch-keys";
|
||||||
PAGE_BLOCKS_LIST,
|
import { FileService } from "services/file.service";
|
||||||
PAGE_DETAILS,
|
|
||||||
PROJECT_DETAILS,
|
|
||||||
PROJECT_ISSUE_LABELS,
|
|
||||||
USER_PROJECT_VIEW,
|
|
||||||
} from "constants/fetch-keys";
|
|
||||||
|
|
||||||
// services
|
// services
|
||||||
const projectService = new ProjectService();
|
const fileService = new FileService();
|
||||||
const projectMemberService = new ProjectMemberService();
|
|
||||||
const pageService = new PageService();
|
const pageService = new PageService();
|
||||||
const issueLabelService = new IssueLabelService();
|
|
||||||
|
|
||||||
const PageDetailsPage: NextPageWithLayout = () => {
|
const PageDetailsPage: NextPageWithLayout = () => {
|
||||||
const [createBlockForm, setCreateBlockForm] = useState(false);
|
const editorRef = useRef<any>(null);
|
||||||
const [labelModal, setLabelModal] = useState(false);
|
|
||||||
const [showBlock, setShowBlock] = useState(false);
|
|
||||||
|
|
||||||
const scrollToRef = useRef<HTMLDivElement>(null);
|
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId, pageId } = router.query;
|
const { workspaceSlug, projectId, pageId } = router.query;
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
|
||||||
|
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
|
|
||||||
const { handleSubmit, reset, watch, setValue, control } = useForm<IPage>({
|
const { handleSubmit, reset, getValues, control } = useForm<IPage>({
|
||||||
defaultValues: { name: "" },
|
defaultValues: { name: "" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: projectDetails } = useSWR(
|
// =================== Fetching Page Details ======================
|
||||||
workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null,
|
|
||||||
workspaceSlug && projectId ? () => projectService.getProject(workspaceSlug as string, projectId as string) : null
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: pageDetails, error } = useSWR(
|
const { data: pageDetails, error } = useSWR(
|
||||||
workspaceSlug && projectId && pageId ? PAGE_DETAILS(pageId as string) : null,
|
workspaceSlug && projectId && pageId ? PAGE_DETAILS(pageId as string) : null,
|
||||||
workspaceSlug && projectId
|
workspaceSlug && projectId
|
||||||
@ -79,27 +52,6 @@ const PageDetailsPage: NextPageWithLayout = () => {
|
|||||||
: null
|
: 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) => {
|
const updatePage = async (formData: IPage) => {
|
||||||
if (!workspaceSlug || !projectId || !pageId) return;
|
if (!workspaceSlug || !projectId || !pageId) return;
|
||||||
|
|
||||||
@ -117,159 +69,96 @@ const PageDetailsPage: NextPageWithLayout = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const partialUpdatePage = async (formData: Partial<IPage>) => {
|
const createPage = async (payload: Partial<IPage>) => {
|
||||||
if (!workspaceSlug || !projectId || !pageId) return;
|
await pageService.createPage(workspaceSlug as string, projectId as string, payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ================ Page Menu Actions ==================
|
||||||
|
const duplicate_page = async () => {
|
||||||
|
const currentPageValues = getValues();
|
||||||
|
const formData: Partial<IPage> = {
|
||||||
|
name: "Copy of " + currentPageValues.name,
|
||||||
|
description_html: currentPageValues.description_html,
|
||||||
|
};
|
||||||
|
await createPage(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const archivePage = async () => {
|
||||||
|
try {
|
||||||
|
await pageService.archivePage(workspaceSlug as string, projectId as string, pageId as string).then(() => {
|
||||||
mutate<IPage>(
|
mutate<IPage>(
|
||||||
PAGE_DETAILS(pageId as string),
|
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 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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
mutate<IProjectMember>(
|
|
||||||
(workspaceSlug as string) && (projectId as string) ? USER_PROJECT_VIEW(projectId as string) : null,
|
|
||||||
(prevData) => {
|
(prevData) => {
|
||||||
if (!prevData) return prevData;
|
if (prevData && prevData.is_locked) {
|
||||||
|
prevData.archived_at = renderDateFormat(new Date());
|
||||||
return {
|
return prevData;
|
||||||
...prevData,
|
}
|
||||||
...payload,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
false
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
await projectService.setProjectView(workspaceSlug as string, projectId as string, payload).catch(() => {
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message: "Something went wrong. Please try again.",
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = labels?.map((label) => ({
|
const unArchivePage = async () => {
|
||||||
value: label.id,
|
try {
|
||||||
query: label.name,
|
await pageService.restorePage(workspaceSlug as string, projectId as string, pageId as string).then(() => {
|
||||||
content: (
|
mutate<IPage>(
|
||||||
<div className="flex items-center gap-2">
|
PAGE_DETAILS(pageId as string),
|
||||||
<span
|
(prevData) => {
|
||||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
if (prevData && prevData.is_locked) {
|
||||||
style={{
|
prevData.archived_at = null;
|
||||||
backgroundColor: label.color && label.color !== "" ? label.color : "#000000",
|
return prevData;
|
||||||
}}
|
}
|
||||||
/>
|
},
|
||||||
{label.name}
|
true
|
||||||
</div>
|
);
|
||||||
),
|
});
|
||||||
}));
|
} 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(() => {
|
useEffect(() => {
|
||||||
if (!pageDetails) return;
|
if (!pageDetails) return;
|
||||||
@ -279,10 +168,9 @@ const PageDetailsPage: NextPageWithLayout = () => {
|
|||||||
});
|
});
|
||||||
}, [reset, pageDetails]);
|
}, [reset, pageDetails]);
|
||||||
|
|
||||||
useEffect(() => {
|
const debouncedFormSave = useDebouncedCallback(async () => {
|
||||||
if (!memberDetails) return;
|
handleSubmit(updatePage)().finally(() => setIsSubmitting("submitted"));
|
||||||
setShowBlock(memberDetails.preferences.pages.block_display);
|
}, 1500);
|
||||||
}, [memberDetails]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -297,314 +185,80 @@ const PageDetailsPage: NextPageWithLayout = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : pageDetails ? (
|
) : pageDetails ? (
|
||||||
<div className="flex h-full flex-col justify-between space-y-4 overflow-hidden p-4">
|
<div className="flex h-full flex-col justify-between pl-5 pr-5">
|
||||||
<div className="h-full w-full overflow-y-auto">
|
<div className="h-full w-full">
|
||||||
<div className="flex items-start justify-between gap-2">
|
{pageDetails.is_locked || pageDetails.archived_at ? (
|
||||||
<div className="flex w-full flex-col gap-2">
|
<DocumentReadOnlyEditorWithRef
|
||||||
<div className="flex w-full items-center gap-2">
|
ref={editorRef}
|
||||||
<button
|
value={pageDetails.description_html}
|
||||||
type="button"
|
customClassName={"tracking-tight self-center w-full max-w-full px-0"}
|
||||||
className="flex items-center gap-2 text-sm text-custom-text-200"
|
borderOnFocus={false}
|
||||||
onClick={() => router.back()}
|
noBorder={true}
|
||||||
>
|
documentDetails={{
|
||||||
<ArrowLeft className="h-4 w-4" />
|
title: pageDetails.name,
|
||||||
</button>
|
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
|
<Controller
|
||||||
name="name"
|
name="description_html"
|
||||||
control={control}
|
control={control}
|
||||||
render={() => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<TextArea
|
<DocumentEditorWithRef
|
||||||
id="name"
|
documentDetails={{
|
||||||
name="name"
|
title: pageDetails.name,
|
||||||
value={watch("name")}
|
created_by: pageDetails.created_by,
|
||||||
placeholder="Page Title"
|
created_on: pageDetails.created_at,
|
||||||
onBlur={handleSubmit(updatePage)}
|
last_updated_at: pageDetails.updated_at,
|
||||||
onChange={(e) => setValue("name", e.target.value)}
|
last_updated_by: pageDetails.updated_by,
|
||||||
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={{
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
backgroundColor: `${label?.color && label.color !== "" ? label.color : "#000000"}20`,
|
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 }}
|
||||||
<span
|
pageArchiveConfig={
|
||||||
className="h-1.5 w-1.5 flex-shrink-0 rounded-full"
|
user && pageDetails.owned_by === user.id
|
||||||
style={{
|
? {
|
||||||
backgroundColor: label?.color && label.color !== "" ? label.color : "#000000",
|
is_archived: pageDetails.archived_at ? true : false,
|
||||||
}}
|
action: pageDetails.archived_at ? unArchivePage : archivePage,
|
||||||
/>
|
|
||||||
{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>
|
|
||||||
}
|
}
|
||||||
value={pageDetails.labels}
|
: undefined
|
||||||
footerOption={
|
}
|
||||||
<button
|
pageLockConfig={
|
||||||
type="button"
|
user && pageDetails.owned_by === user.id ? { is_locked: false, action: lockPage } : undefined
|
||||||
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>
|
|
||||||
}
|
}
|
||||||
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>
|
</div>
|
||||||
<div>
|
|
||||||
<CreateBlock user={user} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<Loader className="p-8">
|
<Loader className="p-8">
|
||||||
<Loader.Item height="200px" />
|
<Loader.Item height="200px" />
|
||||||
|
@ -2,48 +2,65 @@ import { useState, Fragment, ReactElement } from "react";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Tab } from "@headlessui/react";
|
import { Tab } from "@headlessui/react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { observer } from "mobx-react-lite";
|
||||||
// hooks
|
// hooks
|
||||||
import useLocalStorage from "hooks/use-local-storage";
|
import useLocalStorage from "hooks/use-local-storage";
|
||||||
import useUserAuth from "hooks/use-user-auth";
|
import useUserAuth from "hooks/use-user-auth";
|
||||||
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
// layouts
|
// layouts
|
||||||
import { AppLayout } from "layouts/app-layout";
|
import { AppLayout } from "layouts/app-layout";
|
||||||
// components
|
// components
|
||||||
import { RecentPagesList, CreateUpdatePageModal, TPagesListProps } from "components/pages";
|
import { RecentPagesList, CreateUpdatePageModal } from "components/pages";
|
||||||
import { PagesHeader } from "components/headers";
|
import { PagesHeader } from "components/headers";
|
||||||
// ui
|
|
||||||
import { Tooltip } from "@plane/ui";
|
|
||||||
// types
|
// types
|
||||||
import { TPageViewProps } from "types";
|
|
||||||
import { NextPageWithLayout } from "types/app";
|
import { NextPageWithLayout } from "types/app";
|
||||||
// constants
|
// 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,
|
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,
|
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,
|
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,
|
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 router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
// states
|
// states
|
||||||
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
|
const [createUpdatePageModal, setCreateUpdatePageModal] = useState(false);
|
||||||
const [viewType, setViewType] = useState<TPageViewProps>("list");
|
// store
|
||||||
|
const {
|
||||||
const { user } = useUserAuth();
|
page: { fetchPages, fetchArchivedPages },
|
||||||
|
} = useMobxStore();
|
||||||
|
// hooks
|
||||||
|
const {} = useUserAuth();
|
||||||
|
// local storage
|
||||||
const { storedValue: pageTab, setValue: setPageTab } = useLocalStorage("pageTab", "Recent");
|
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) => {
|
const currentTabValue = (tab: string | null) => {
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
@ -53,11 +70,12 @@ const ProjectPagesPage: NextPageWithLayout = () => {
|
|||||||
return 1;
|
return 1;
|
||||||
case "Favorites":
|
case "Favorites":
|
||||||
return 2;
|
return 2;
|
||||||
case "Created by me":
|
case "Private":
|
||||||
return 3;
|
return 3;
|
||||||
case "Created by others":
|
case "Shared":
|
||||||
return 4;
|
return 4;
|
||||||
|
case "Archived":
|
||||||
|
return 5;
|
||||||
default:
|
default:
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@ -69,34 +87,12 @@ const ProjectPagesPage: NextPageWithLayout = () => {
|
|||||||
<CreateUpdatePageModal
|
<CreateUpdatePageModal
|
||||||
isOpen={createUpdatePageModal}
|
isOpen={createUpdatePageModal}
|
||||||
handleClose={() => setCreateUpdatePageModal(false)}
|
handleClose={() => setCreateUpdatePageModal(false)}
|
||||||
user={user}
|
|
||||||
workspaceSlug={workspaceSlug.toString()}
|
|
||||||
projectId={projectId.toString()}
|
projectId={projectId.toString()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-5 p-8 h-full overflow-hidden flex flex-col">
|
<div className="space-y-5 p-8 h-full overflow-hidden flex flex-col">
|
||||||
<div className="flex gap-4 justify-between">
|
<div className="flex gap-4 justify-between">
|
||||||
<h3 className="text-2xl font-semibold text-custom-text-100">Pages</h3>
|
<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>
|
</div>
|
||||||
<Tab.Group
|
<Tab.Group
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
@ -110,12 +106,13 @@ const ProjectPagesPage: NextPageWithLayout = () => {
|
|||||||
case 2:
|
case 2:
|
||||||
return setPageTab("Favorites");
|
return setPageTab("Favorites");
|
||||||
case 3:
|
case 3:
|
||||||
return setPageTab("Created by me");
|
return setPageTab("Private");
|
||||||
case 4:
|
case 4:
|
||||||
return setPageTab("Created by others");
|
return setPageTab("Shared");
|
||||||
|
case 5:
|
||||||
|
return setPageTab("Archived");
|
||||||
default:
|
default:
|
||||||
return setPageTab("Recent");
|
return setPageTab("All");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -139,26 +136,29 @@ const ProjectPagesPage: NextPageWithLayout = () => {
|
|||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels as={Fragment}>
|
<Tab.Panels as={Fragment}>
|
||||||
<Tab.Panel as="div" className="h-full overflow-y-auto space-y-5">
|
<Tab.Panel as="div" className="h-full overflow-y-auto space-y-5">
|
||||||
<RecentPagesList viewType={viewType} />
|
<RecentPagesList />
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
<Tab.Panel as="div" className="h-full overflow-hidden">
|
<Tab.Panel as="div" className="h-full overflow-hidden">
|
||||||
<AllPagesList viewType={viewType} />
|
<AllPagesList />
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
<Tab.Panel as="div" className="h-full overflow-hidden">
|
<Tab.Panel as="div" className="h-full overflow-hidden">
|
||||||
<FavoritePagesList viewType={viewType} />
|
<FavoritePagesList />
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
<Tab.Panel as="div" className="h-full overflow-hidden">
|
<Tab.Panel as="div" className="h-full overflow-hidden">
|
||||||
<MyPagesList viewType={viewType} />
|
<PrivatePagesList />
|
||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
<Tab.Panel as="div" className="h-full overflow-hidden">
|
<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.Panel>
|
||||||
</Tab.Panels>
|
</Tab.Panels>
|
||||||
</Tab.Group>
|
</Tab.Group>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
ProjectPagesPage.getLayout = function getLayout(page: ReactElement) {
|
ProjectPagesPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
|
@ -33,14 +33,8 @@ export class PageService extends APIService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async addPageToFavorites(
|
async addPageToFavorites(workspaceSlug: string, projectId: string, pageId: string): Promise<any> {
|
||||||
workspaceSlug: string,
|
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-pages/`, { page: pageId })
|
||||||
projectId: string,
|
|
||||||
data: {
|
|
||||||
page: string;
|
|
||||||
}
|
|
||||||
): Promise<any> {
|
|
||||||
return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/user-favorite-pages/`, data)
|
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
throw error?.response?.data;
|
throw error?.response?.data;
|
||||||
@ -58,7 +52,7 @@ export class PageService extends APIService {
|
|||||||
async getPagesWithParams(
|
async getPagesWithParams(
|
||||||
workspaceSlug: string,
|
workspaceSlug: string,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
pageType: "all" | "favorite" | "created_by_me" | "created_by_other"
|
pageType: "all" | "favorite" | "private" | "shared"
|
||||||
): Promise<IPage[]> {
|
): Promise<IPage[]> {
|
||||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/`, {
|
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/`, {
|
||||||
params: {
|
params: {
|
||||||
@ -168,4 +162,45 @@ export class PageService extends APIService {
|
|||||||
throw error?.response?.data;
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,42 +1,51 @@
|
|||||||
import { observable, action, computed, makeObservable, runInAction } from "mobx";
|
import { observable, action, computed, makeObservable, runInAction } from "mobx";
|
||||||
// types
|
import isYesterday from "date-fns/isYesterday";
|
||||||
import { RootStore } from "./root";
|
import isToday from "date-fns/isToday";
|
||||||
import { IPage } from "types";
|
import isThisWeek from "date-fns/isThisWeek";
|
||||||
// services
|
// services
|
||||||
import { ProjectService } from "services/project";
|
import { ProjectService } from "services/project";
|
||||||
import { PageService } from "services/page.service";
|
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 {
|
export interface IPageStore {
|
||||||
loader: boolean;
|
loader: boolean;
|
||||||
error: any | null;
|
error: any | null;
|
||||||
|
|
||||||
pageId: string | null;
|
|
||||||
pages: {
|
pages: {
|
||||||
[project_id: string]: IPage[];
|
[project_id: string]: IPage[];
|
||||||
};
|
};
|
||||||
page_details: {
|
archivedPages: {
|
||||||
[page_id: string]: IPage;
|
[project_id: string]: IPage[];
|
||||||
};
|
};
|
||||||
|
|
||||||
//computed
|
//computed
|
||||||
projectPages: IPage[];
|
projectPages: IPage[] | undefined;
|
||||||
|
recentProjectPages: IRecentPages | undefined;
|
||||||
|
favoriteProjectPages: IPage[] | undefined;
|
||||||
|
privateProjectPages: IPage[] | undefined;
|
||||||
|
sharedProjectPages: IPage[] | undefined;
|
||||||
|
archivedProjectPages: IPage[] | undefined;
|
||||||
// actions
|
// actions
|
||||||
setPageId: (pageId: string) => void;
|
fetchPages: (workspaceSlug: string, projectId: string) => Promise<IPage[]>;
|
||||||
fetchPages: (workspaceSlug: string, projectSlug: string) => void;
|
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;
|
loader: boolean = false;
|
||||||
error: any | null = null;
|
error: any | null = null;
|
||||||
|
pages: { [project_id: string]: IPage[] } = {};
|
||||||
pageId: string | null = null;
|
archivedPages: { [project_id: string]: IPage[] } = {};
|
||||||
pages: {
|
|
||||||
[project_id: string]: IPage[];
|
|
||||||
} = {};
|
|
||||||
page_details: {
|
|
||||||
[page_id: string]: IPage;
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
// root store
|
// root store
|
||||||
rootStore;
|
rootStore;
|
||||||
// service
|
// service
|
||||||
@ -48,15 +57,27 @@ class PageStore implements IPageStore {
|
|||||||
// observable
|
// observable
|
||||||
loader: observable,
|
loader: observable,
|
||||||
error: observable,
|
error: observable,
|
||||||
|
|
||||||
pageId: observable.ref,
|
|
||||||
pages: observable.ref,
|
pages: observable.ref,
|
||||||
|
archivedPages: observable.ref,
|
||||||
// computed
|
// computed
|
||||||
projectPages: computed,
|
projectPages: computed,
|
||||||
|
recentProjectPages: computed,
|
||||||
|
favoriteProjectPages: computed,
|
||||||
|
privateProjectPages: computed,
|
||||||
|
sharedProjectPages: computed,
|
||||||
|
archivedProjectPages: computed,
|
||||||
// action
|
// action
|
||||||
setPageId: action,
|
|
||||||
fetchPages: 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;
|
this.rootStore = _rootStore;
|
||||||
@ -65,35 +86,252 @@ class PageStore implements IPageStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get projectPages() {
|
get projectPages() {
|
||||||
if (!this.rootStore.project.projectId) return [];
|
if (!this.rootStore.project.projectId) return;
|
||||||
return this.pages?.[this.rootStore.project.projectId] || [];
|
return this.pages?.[this.rootStore.project.projectId] || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
setPageId = (pageId: string) => {
|
get recentProjectPages() {
|
||||||
this.pageId = pageId;
|
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 {
|
try {
|
||||||
this.loader = true;
|
this.loader = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
const pagesResponse = await this.pageService.getPagesWithParams(workspaceSlug, projectSlug, "all");
|
const response = await this.pageService.getPagesWithParams(workspaceSlug, projectId, "all");
|
||||||
|
|
||||||
runInAction(() => {
|
runInAction(() => {
|
||||||
this.pages = {
|
this.pages = {
|
||||||
...this.pages,
|
...this.pages,
|
||||||
[projectSlug]: pagesResponse,
|
[projectId]: response,
|
||||||
};
|
};
|
||||||
this.loader = false;
|
this.loader = false;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
});
|
});
|
||||||
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch project pages in project store", error);
|
console.error("Failed to fetch project pages in project store", error);
|
||||||
this.loader = false;
|
this.loader = false;
|
||||||
this.error = error;
|
this.error = error;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
createPage = async (workspaceSlug: string, projectId: string, data: Partial<IPage>) => {
|
||||||
|
try {
|
||||||
|
const response = await this.pageService.createPage(workspaceSlug, projectId, data);
|
||||||
|
runInAction(() => {
|
||||||
|
this.pages = {
|
||||||
|
...this.pages,
|
||||||
|
[projectId]: [...this.pages[projectId], response],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
updatePage = async (workspaceSlug: string, projectId: string, pageId: string, data: Partial<IPage>) => {
|
||||||
|
try {
|
||||||
|
runInAction(() => {
|
||||||
|
this.pages = {
|
||||||
|
...this.pages,
|
||||||
|
[projectId]: this.pages[projectId]?.map((page) => {
|
||||||
|
if (page.id === pageId) {
|
||||||
|
return { ...page, ...data };
|
||||||
|
}
|
||||||
|
return page;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.pageService.patchPage(workspaceSlug, projectId, pageId, data);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
deletePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||||
|
try {
|
||||||
|
runInAction(() => {
|
||||||
|
this.archivedPages = {
|
||||||
|
...this.archivedPages,
|
||||||
|
[projectId]: this.archivedPages[projectId]?.filter((page) => page.id !== pageId),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.pageService.deletePage(workspaceSlug, projectId, pageId);
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
makePublic = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||||
|
try {
|
||||||
|
runInAction(() => {
|
||||||
|
this.pages = {
|
||||||
|
...this.pages,
|
||||||
|
[projectId]: this.pages[projectId]?.map((page) => ({
|
||||||
|
...page,
|
||||||
|
access: page.id === pageId ? 0 : page.access,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const response = await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 0 });
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
makePrivate = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||||
|
try {
|
||||||
|
runInAction(() => {
|
||||||
|
this.pages = {
|
||||||
|
...this.pages,
|
||||||
|
[projectId]: this.pages[projectId]?.map((page) => ({
|
||||||
|
...page,
|
||||||
|
access: page.id === pageId ? 1 : page.access,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this.pageService.patchPage(workspaceSlug, projectId, pageId, { access: 1 });
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchArchivedPages = async (workspaceSlug: string, projectId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await this.pageService.getArchivedPages(workspaceSlug, projectId);
|
||||||
|
runInAction(() => {
|
||||||
|
this.archivedPages = {
|
||||||
|
...this.archivedPages,
|
||||||
|
[projectId]: response,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
archivePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||||
|
try {
|
||||||
|
const archivedPage = this.pages[projectId]?.find((page) => page.id === pageId);
|
||||||
|
|
||||||
|
if (archivedPage) {
|
||||||
|
runInAction(() => {
|
||||||
|
this.pages = {
|
||||||
|
...this.pages,
|
||||||
|
[projectId]: [...this.pages[projectId]?.filter((page) => page.id != pageId)],
|
||||||
|
};
|
||||||
|
this.archivedPages = {
|
||||||
|
...this.archivedPages,
|
||||||
|
[projectId]: [
|
||||||
|
...this.archivedPages[projectId],
|
||||||
|
{ ...archivedPage, archived_at: renderDateFormat(new Date()) },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.pageService.archivePage(workspaceSlug, projectId, pageId);
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
restorePage = async (workspaceSlug: string, projectId: string, pageId: string) => {
|
||||||
|
try {
|
||||||
|
const restoredPage = this.archivedPages[projectId]?.find((page) => page.id === pageId);
|
||||||
|
|
||||||
|
if (restoredPage) {
|
||||||
|
runInAction(() => {
|
||||||
|
this.pages = {
|
||||||
|
...this.pages,
|
||||||
|
[projectId]: [...this.pages[projectId], { ...restoredPage, archived_at: null }],
|
||||||
|
};
|
||||||
|
this.archivedPages = {
|
||||||
|
...this.archivedPages,
|
||||||
|
[projectId]: [...this.archivedPages[projectId]?.filter((page) => page.id != pageId)],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.pageService.restorePage(workspaceSlug, projectId, pageId);
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PageStore;
|
|
||||||
|
@ -113,6 +113,8 @@ import {
|
|||||||
import { IWebhookStore, WebhookStore } from "./webhook.store";
|
import { IWebhookStore, WebhookStore } from "./webhook.store";
|
||||||
|
|
||||||
import { IMentionsStore, MentionsStore } from "store/editor";
|
import { IMentionsStore, MentionsStore } from "store/editor";
|
||||||
|
// pages
|
||||||
|
import { PageStore, IPageStore } from "store/page.store";
|
||||||
|
|
||||||
enableStaticRendering(typeof window === "undefined");
|
enableStaticRendering(typeof window === "undefined");
|
||||||
|
|
||||||
@ -186,6 +188,8 @@ export class RootStore {
|
|||||||
|
|
||||||
mentionsStore: IMentionsStore;
|
mentionsStore: IMentionsStore;
|
||||||
|
|
||||||
|
page: IPageStore;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.instance = new InstanceStore(this);
|
this.instance = new InstanceStore(this);
|
||||||
|
|
||||||
@ -254,5 +258,7 @@ export class RootStore {
|
|||||||
this.webhook = new WebhookStore(this);
|
this.webhook = new WebhookStore(this);
|
||||||
|
|
||||||
this.mentionsStore = new MentionsStore(this);
|
this.mentionsStore = new MentionsStore(this);
|
||||||
|
|
||||||
|
this.page = new PageStore(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
9
web/types/pages.d.ts
vendored
9
web/types/pages.d.ts
vendored
@ -3,6 +3,7 @@ import { IIssue, IIssueLabel, IWorkspaceLite, IProjectLite } from "types";
|
|||||||
|
|
||||||
export interface IPage {
|
export interface IPage {
|
||||||
access: number;
|
access: number;
|
||||||
|
archived_at: string | null;
|
||||||
blocks: IPageBlock[];
|
blocks: IPageBlock[];
|
||||||
color: string;
|
color: string;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
@ -12,6 +13,7 @@ export interface IPage {
|
|||||||
description_stripped: string | null;
|
description_stripped: string | null;
|
||||||
id: string;
|
id: string;
|
||||||
is_favorite: boolean;
|
is_favorite: boolean;
|
||||||
|
is_locked: boolean;
|
||||||
label_details: IIssueLabel[];
|
label_details: IIssueLabel[];
|
||||||
labels: string[];
|
labels: string[];
|
||||||
name: string;
|
name: string;
|
||||||
@ -24,6 +26,13 @@ export interface IPage {
|
|||||||
workspace_detail: IWorkspaceLite;
|
workspace_detail: IWorkspaceLite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IRecentPages {
|
||||||
|
today: IPage[];
|
||||||
|
yesterday: IPage[];
|
||||||
|
this_week: IPage[];
|
||||||
|
[key: string]: IPage[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface RecentPagesResponse {
|
export interface RecentPagesResponse {
|
||||||
[key: string]: IPage[];
|
[key: string]: IPage[];
|
||||||
}
|
}
|
||||||
|
@ -2376,7 +2376,7 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-2.1.12.tgz#7c905a577ce30ef2cb335870a23f9d24fd26f6aa"
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-2.1.12.tgz#7c905a577ce30ef2cb335870a23f9d24fd26f6aa"
|
||||||
integrity sha512-vtD8vWtNlmAZX8LYqt2yU9w3mU9rPCiHmbp4hDXJs2kBnI0Ju/qAyXFx6iJ3C3XyuMnMbJdDI9ee0spAvFz7cQ==
|
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"
|
version "2.1.12"
|
||||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.1.12.tgz#ccbca5d0d92bee373dc8e2e2ae6c27f62f66437c"
|
resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.1.12.tgz#ccbca5d0d92bee373dc8e2e2ae6c27f62f66437c"
|
||||||
integrity sha512-dtIbpI9QrWa9TzNO4v5q/zf7+d83wpy5i9PEccdJAVtRZ0yOI8JIZAWzG5ex3zAoCA0CnQFdsPSVykYSDdxtDA==
|
integrity sha512-dtIbpI9QrWa9TzNO4v5q/zf7+d83wpy5i9PEccdJAVtRZ0yOI8JIZAWzG5ex3zAoCA0CnQFdsPSVykYSDdxtDA==
|
||||||
|
Loading…
Reference in New Issue
Block a user