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